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:
Bill committed 2026-10-09 11:30:51 +08:00
1 parent 81ac112dac
commit e23010f950
4 files changed
+247 -12

No files matched your search

+78 -2
View File
@@ -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) => {