Compare commits

...
Author SHA1 Message Date
bincxz ad9c3b71a5 test: clean up drag-drop suppression expiry watchdog 2026-09-18 11:29:50 +08:00
netcatty-bot 1b83232784 fix(#3423): automated AI fix 2026-09-17 02:35:48 +00:00
2 changed files with 124 additions and 4 deletions

No files matched your search

+36 -4
View File
@@ -130,6 +130,17 @@ function createZmodemSentry(opts) {
let transferAbortController = null;
const pendingEchoes = [];
let pendingTerminalSuppression = null;
let pendingTerminalSuppressionAt = 0;
function setPendingTerminalSuppression(buf) {
pendingTerminalSuppression = buf;
pendingTerminalSuppressionAt = Date.now();
}
function clearPendingTerminalSuppression() {
pendingTerminalSuppression = null;
pendingTerminalSuppressionAt = 0;
}
let cancelInterruptTimer = null;
let ignoreDetectionUntil = 0;
// After aborting, suppress incoming data briefly so residual ZMODEM
@@ -141,9 +152,21 @@ function createZmodemSentry(opts) {
const COOLDOWN_MS = 2000;
const ECHO_TTL_MS = 1500;
const ECHO_MAX_BYTES = 256;
/**
* Upper bound on how long a drag-drop echo suppression may stay armed.
* The suppression hides the terminal echo of the upload command, whose echo
* always precedes the remote rz starting. If the echo never matches (e.g. it
* was coalesced with a prompt, or rz is missing and the SFTP fallback kicks
* in), a stale suppression would keep stripping matched prefixes from later
* terminal output — including the echo of the user's next command (#3423).
*/
const SUPPRESSION_TTL_MS = 5000;
const dragDropStartTimeoutMs = Number.isFinite(opts.dragDropStartTimeoutMs)
? Math.max(0, opts.dragDropStartTimeoutMs)
: 15000;
const pendingTerminalSuppressionTtlMs = Number.isFinite(
opts.pendingTerminalSuppressionTtlMs,
) ? opts.pendingTerminalSuppressionTtlMs : SUPPRESSION_TTL_MS;
function prunePendingEchoes(now = Date.now()) {
while (pendingEchoes.length && pendingEchoes[0].expiresAt <= now) {
@@ -185,6 +208,11 @@ function createZmodemSentry(opts) {
function stripPendingTerminalSuppression(data) {
if (!pendingTerminalSuppression?.length) return data;
if (Date.now() - pendingTerminalSuppressionAt > pendingTerminalSuppressionTtlMs) {
clearPendingTerminalSuppression();
return data;
}
let buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
const fullMatchAt = buf.indexOf(pendingTerminalSuppression);
if (fullMatchAt !== -1) {
@@ -303,6 +331,10 @@ function createZmodemSentry(opts) {
cleanupDragDropTempFiles(dragDropUpload);
dragDropUpload = null;
}
// A pending upload that never reaches ZMODEM detection (rz missing → SFTP
// fallback, cancel, start timeout) must not leave its command-echo
// suppression armed, or it will eat the user's next typed command (#3423).
clearPendingTerminalSuppression();
}
function takeDragDropUpload() {
@@ -459,11 +491,11 @@ function createZmodemSentry(opts) {
active = true;
const zsession = detection.confirm();
currentZSession = zsession;
pendingTerminalSuppression = zsession.type === "receive"
setPendingTerminalSuppression(zsession.type === "receive"
? Buffer.from(Zmodem.Header.build("ZRQINIT").to_hex())
: zsession._last_ZRINIT?.to_hex
? Buffer.from(zsession._last_ZRINIT.to_hex())
: null;
: null);
const transferType = zsession.type === "send" ? "upload" : "download";
@@ -706,12 +738,12 @@ function createZmodemSentry(opts) {
const pendingEchoCount = pendingEchoes.length;
try {
rememberOutgoingEcho(cmdBuf);
pendingTerminalSuppression = Buffer.from(uploadCommand.replace(/\r$/, ""));
setPendingTerminalSuppression(Buffer.from(uploadCommand.replace(/\r$/, "")));
writeToRemote(cmdBuf);
scheduleDragDropStartTimeout();
} catch (err) {
pendingEchoes.length = pendingEchoCount;
pendingTerminalSuppression = null;
clearPendingTerminalSuppression();
clearDragDropUpload();
throw err;
}
+88
View File
@@ -236,6 +236,94 @@ test("queued drag-drop upload cleans temp files when command write fails", () =>
fs.rmSync(tempDir, { recursive: true, force: true });
});
// Issue #3423: after a drag-drop upload falls back to SFTP (rz missing, no
// ZMODEM detection), a stale command-echo suppression must not strip matched
// prefixes from the user's next typed command echo — the reporter's
// "su - username -s /bin/bash" rendered as "u - username -s /bin/bas".
test("stale drag-drop echo suppression is dropped when the pending upload is cancelled", () => {
const seen = [];
const sentry = createZmodemSentry({
sessionId: "session-1",
onData: (buf) => seen.push(Buffer.from(buf).toString("utf8")),
writeToRemote: () => true,
getWebContents: () => null,
});
const uploadCommand =
"sh -lc 'if command -v rz >/dev/null 2>&1; then exec rz -y; else printf marker; fi'\r";
sentry.queueDragDropUpload({
filePaths: ["/tmp/a.txt"],
remoteNames: ["a.txt"],
uploadCommand,
});
// The shell echo of the long upload command is split across two transport
// chunks with the prompt glued in front, so neither the full match nor the
// chunk-prefix partial match can consume the suppression — it leaks.
sentry.consume(Buffer.from("root@host:~# sh -lc 'if command -v rz >/dev/null"));
sentry.consume(Buffer.from(" 2>&1; then exec rz -y; else printf marker; fi'\r\n"));
seen.length = 0;
// rz missing → renderer watches the marker, falls back to SFTP, cancels.
sentry.cancel({ interrupt: false });
// User types the next command after the upload finishes.
sentry.consume(Buffer.from("su - username -s /bin/bas"));
sentry.consume(Buffer.from("h"));
assert.deepEqual(seen, ["su - username -s /bin/bas", "h"]);
});
test("drag-drop echo suppression expires instead of eating later user echo", async (t) => {
const seen = [];
const sentry = createZmodemSentry({
sessionId: "session-1",
onData: (buf) => seen.push(Buffer.from(buf).toString("utf8")),
writeToRemote: () => true,
getWebContents: () => null,
pendingTerminalSuppressionTtlMs: 0,
});
t.after(() => sentry.cancel({ interrupt: false }));
sentry.queueDragDropUpload({
filePaths: ["/tmp/a.txt"],
remoteNames: ["a.txt"],
uploadCommand: "sh -lc 'if command -v rz >/dev/null 2>&1; then exec rz -y; fi'\r",
});
// Split echo is never fully matched, so the suppression stays armed.
sentry.consume(Buffer.from("root@host:~# sh -lc 'if command -v rz >/dev/null"));
sentry.consume(Buffer.from(" 2>&1; then exec rz -y; fi'"));
await new Promise((resolve) => setTimeout(resolve, 5));
seen.length = 0;
sentry.consume(Buffer.from("su - username -s /bin/bash"));
assert.deepEqual(seen, ["su - username -s /bin/bash"]);
});
test("drag-drop echo suppression still hides the upload command echo", () => {
const seen = [];
const sentry = createZmodemSentry({
sessionId: "session-1",
onData: (buf) => seen.push(Buffer.from(buf).toString("utf8")),
writeToRemote: () => true,
getWebContents: () => null,
});
sentry.queueDragDropUpload({
filePaths: ["/tmp/a.txt"],
remoteNames: ["a.txt"],
uploadCommand: "rz -y\r",
});
// Echo flushed as its own chunk right after the write — must be suppressed.
sentry.consume(Buffer.from("rz -y"));
assert.deepEqual(seen, []);
// Later user input is untouched.
sentry.consume(Buffer.from("ls -l"));
assert.deepEqual(seen, ["ls -l"]);
sentry.cancel({ interrupt: false });
});
test("handleUpload completes when the remote confirms after progress reaches 100 percent", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
const filePath = path.join(tempDir, "upload.txt");