fix(zmodem): 接收路径写背压;sftp 删除容忍 ENOENT;sysinfo 停止关流
- zmodem 接收:fs WriteStream 缓冲无上限,慢盘 + 快对端可把内存涨到接近 文件大小。write() 返回 false 时暂停喂入 sentry 的 ssh 流、drain 恢复; touchActivity 只在写入被接受或 drain 恢复时计数,stall 看门狗不再把 '堆在缓冲里'当进展;finalize/detach 解除悬挂暂停 - sftp 删除路径 ENOENT 一律视为已删成功(文件已被他人删除/重复删除/ 传输重跑后目标已消失),不再汇成假 delete failed;真错误仍报错 (tests/sftp-timeout.mjs 新增 5 断言含防过度吞错用例) - sysinfo:exec 回调里 state.stopped 分支先 close 到手的 stream
This commit is contained in:
1 parent
81ac112dac
commit
e23010f950
4 files changed
+247
-12
No files matched your search
+78
-2
@@ -51,8 +51,9 @@ sftp.setLocalPathPolicy({
|
||||
/**
|
||||
* A fake ssh2 SFTPWrapper whose per-call behavior is scripted.
|
||||
* `behaviour(op, args)` returns `'silent'` (never calls back — the dead-channel
|
||||
* case), `'error'` (calls back with an error), or `'ok'` (calls back with
|
||||
* `result`). Every call is recorded so the test can assert on what was opened.
|
||||
* case), `'enoent'` (calls back with the server's "no such file"), `'error'`
|
||||
* (calls back with a generic error), or `'ok'` (calls back with `result`). Every
|
||||
* call is recorded so the test can assert on what was opened.
|
||||
*/
|
||||
const defaultStat = () => ({
|
||||
isDirectory: () => false,
|
||||
@@ -63,6 +64,9 @@ const defaultStat = () => ({
|
||||
gid: 1000
|
||||
})
|
||||
|
||||
/** SSH_FX_NO_SUCH_FILE as ssh2 surfaces it: a numeric status on an Error. */
|
||||
const noSuchFile = () => Object.assign(new Error('No such file'), { code: 2 })
|
||||
|
||||
let calls
|
||||
let behaviour
|
||||
let openedChannels
|
||||
@@ -101,6 +105,7 @@ const makeWrapper = () => {
|
||||
calls.push({ op, args })
|
||||
const verdict = behaviour(op, args)
|
||||
if (verdict === 'silent') return
|
||||
if (verdict === 'enoent') return cb(noSuchFile())
|
||||
if (verdict === 'error') return cb(new Error(`${op} failed`))
|
||||
cb(null, voidResult ? undefined : result)
|
||||
}
|
||||
@@ -433,6 +438,77 @@ console.log('default budgets are sane relative to each other')
|
||||
ok(elapsed < 3000, `the check itself was quick (${elapsed}ms)`)
|
||||
}
|
||||
|
||||
// ---- 7. a delete is idempotent: "already gone" is success ------------------
|
||||
console.log('deleting an entry that is already gone succeeds instead of reporting a failure')
|
||||
{
|
||||
// The file may have been removed by someone else between the listing and the
|
||||
// click, or the same delete may be run again after an earlier pass already
|
||||
// removed it. "No such file" means the end state the user asked for already
|
||||
// holds, so it must not surface as "delete failed".
|
||||
reset((op) => (op === 'lstat' || op === 'unlink' ? 'enoent' : 'ok'))
|
||||
sftp.closeSftp('del-gone')
|
||||
const missing = await expectReject(sftp.deleteRemote('del-gone', ['/remote/gone.txt']), 'deleteRemote')
|
||||
ok(!missing.rejected, `a missing file is not a delete failure (${missing.message})`)
|
||||
|
||||
// The tolerance is for that one status only: a real unlink failure still has
|
||||
// to reach the user.
|
||||
reset((op) => (op === 'unlink' ? 'error' : 'ok'))
|
||||
sftp.closeSftp('del-denied')
|
||||
const denied = await expectReject(sftp.deleteRemote('del-denied', ['/remote/x.txt']), 'deleteRemote')
|
||||
ok(denied.rejected, 'a genuine unlink failure is still reported')
|
||||
|
||||
// The other shape of the same status: no numeric code, only the library's
|
||||
// English message.
|
||||
reset(() => 'ok')
|
||||
sftp.registerSftpClientProvider(() => ({
|
||||
sftp: (cb) => {
|
||||
const w = makeWrapper()
|
||||
w.unlink = (p, cb) => {
|
||||
calls.push({ op: 'unlink', args: [p] })
|
||||
cb(new Error('No such file'))
|
||||
}
|
||||
openedChannels.push(w)
|
||||
cb(null, w)
|
||||
}
|
||||
}))
|
||||
sftp.closeSftp('del-gone-msg')
|
||||
const byMessage = await expectReject(sftp.deleteRemote('del-gone-msg', ['/remote/gone.txt']), 'deleteRemote')
|
||||
ok(!byMessage.rejected, `the message-only form is tolerated too (${byMessage.message})`)
|
||||
|
||||
// The recursive shapes: the directory itself is gone, and a tree whose entries
|
||||
// were already removed reports the same status from unlink and rmdir.
|
||||
reset((op, args) => (op === 'readdir' && args[0] === '/remote/dir' ? 'enoent' : 'ok'))
|
||||
sftp.registerSftpClientProvider(() => ({
|
||||
sftp: (cb) => {
|
||||
const w = makeWrapper()
|
||||
// Only the top-level path is a directory, or the children would recurse.
|
||||
w.lstat = (p, cb) => {
|
||||
calls.push({ op: 'lstat', args: [p] })
|
||||
cb(null, { ...defaultStat(), isDirectory: () => p === '/remote/dir' })
|
||||
}
|
||||
openedChannels.push(w)
|
||||
cb(null, w)
|
||||
}
|
||||
}))
|
||||
sftp.closeSftp('del-dir-gone')
|
||||
const dirGone = await expectReject(sftp.deleteRemote('del-dir-gone', ['/remote/dir']), 'deleteRemote')
|
||||
ok(!dirGone.rejected, `a directory that no longer exists is not a failure (${dirGone.message})`)
|
||||
|
||||
reset((op) => (op === 'unlink' || op === 'rmdir' ? 'enoent' : 'ok'))
|
||||
sftp.closeSftp('del-dir-partial')
|
||||
const dirPartial = await expectReject(sftp.deleteRemote('del-dir-partial', ['/remote/dir']), 'deleteRemote')
|
||||
ok(!dirPartial.rejected, `a tree whose entries were already removed is not a failure (${dirPartial.message})`)
|
||||
|
||||
// Restore the standard provider for any later section.
|
||||
sftp.registerSftpClientProvider(() => ({
|
||||
sftp: (cb) => {
|
||||
const w = makeWrapper()
|
||||
openedChannels.push(w)
|
||||
cb(null, w)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// ---- helpers ----------------------------------------------------------------
|
||||
function waitFor(pred, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
|
||||
Reference in new issue
Block a user