Compare commits

...
Author SHA1 Message Date
陈奇 5347a52adc fix(terminal): bound printable ZMODEM prefix echo delay 2026-10-26 09:06:53 +00:00
2 changed files with 120 additions and 6 deletions

No files matched your search

+33 -6
View File
@@ -138,6 +138,7 @@ function createZmodemSentry(opts) {
let pendingTerminalSuppressionAt = 0;
let pendingHexHeaderFragment = null;
let pendingHexHeaderFragmentTimer = null;
let emittedHexHeaderPrefixBytes = 0;
// Set when a stripped hex header's trailing XON (0x11) may arrive in the
// next terminal-output chunk; at most one byte is then dropped.
let suppressNextTerminalXon = false;
@@ -172,6 +173,11 @@ function createZmodemSentry(opts) {
const HEX_HEADER_FRAGMENT_TTL_MS = Number.isFinite(opts.hexHeaderFragmentTtlMs)
? Math.max(1, opts.hexHeaderFragmentTtlMs)
: 1000;
// A bare "*" or "**" is also ordinary shell echo (#3959). Give this
// ambiguous printable prefix at most one frame to complete, rather than
// holding every typed/pasted star for the full protocol timeout. Once
// ZDLE arrives, retain the longer timeout for fragmented protocol bytes.
const PRINTABLE_HEX_PREFIX_TTL_MS = Math.min(16, HEX_HEADER_FRAGMENT_TTL_MS);
/**
* 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
@@ -308,6 +314,10 @@ function createZmodemSentry(opts) {
buf.subarray(0, prefixAt),
buf.subarray(prefixAt + headerLength),
]);
// Printable prefix bytes may already have been shown after the short
// echo timeout. If they belonged to this header, do not skip bytes
// from the ordinary output that follows the stripped header.
emittedHexHeaderPrefixBytes = Math.min(emittedHexHeaderPrefixBytes, prefixAt);
searchFrom = prefixAt;
}
@@ -317,8 +327,17 @@ function createZmodemSentry(opts) {
function flushPendingHexHeaderFragment() {
pendingHexHeaderFragmentTimer = null;
const buf = pendingHexHeaderFragment;
pendingHexHeaderFragment = null;
if (buf?.length) onData(buf);
if (!buf?.length) return;
const output = buf.subarray(emittedHexHeaderPrefixBytes);
if (buf.every((byte) => byte === 0x2a)) {
// Keep at most two printable bytes as parsing context: a later ZDLE
// must still be recognized and stripped, without replaying the stars.
emittedHexHeaderPrefixBytes = buf.length;
} else {
pendingHexHeaderFragment = null;
emittedHexHeaderPrefixBytes = 0;
}
if (output.length) onData(output);
}
/**
@@ -412,19 +431,27 @@ function createZmodemSentry(opts) {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
const fragmentAt = findTrailingHexHeaderFragment(buf);
if (fragmentAt === -1) return buf;
const alreadyEmitted = emittedHexHeaderPrefixBytes;
emittedHexHeaderPrefixBytes = fragmentAt === -1
? 0
: Math.max(0, alreadyEmitted - fragmentAt);
if (fragmentAt === -1) return buf.subarray(alreadyEmitted);
if (fragmentAt < buf.length) {
pendingHexHeaderFragment = Buffer.from(buf.subarray(fragmentAt));
if (!pendingHexHeaderFragmentTimer) {
if (!pendingHexHeaderFragmentTimer
&& pendingHexHeaderFragment.length > emittedHexHeaderPrefixBytes) {
const ttl = pendingHexHeaderFragment.every((byte) => byte === 0x2a)
? PRINTABLE_HEX_PREFIX_TTL_MS
: HEX_HEADER_FRAGMENT_TTL_MS;
pendingHexHeaderFragmentTimer = setTimeout(
flushPendingHexHeaderFragment,
HEX_HEADER_FRAGMENT_TTL_MS,
ttl,
);
}
}
return fragmentAt ? buf.subarray(0, fragmentAt) : Buffer.alloc(0);
return buf.subarray(Math.min(alreadyEmitted, fragmentAt), fragmentAt);
}
function looksLikeResidualZmodemData(data) {
+87
View File
@@ -1814,6 +1814,93 @@ function assertNoZmodemHeaderBytes(terminal, context) {
);
}
test("ordinary star echoes wait at most one frame for a possible ZMODEM prefix", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
for (const text of ["*", "**", "ls *", "**/*.txt*"]) {
const { sentry, terminal } = createProbeSentry({ hexHeaderFragmentTtlMs: undefined });
sentry.consume(Buffer.from(text));
t.mock.timers.tick(16);
assert.equal(joinedText(terminal), text, `echo delayed: ${text}`);
t.mock.timers.tick(1000);
assert.equal(joinedText(terminal), text, "the fragment must not be emitted twice");
}
});
test("a following parenthesis and repeated stars preserve echo order", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const { sentry, terminal } = createProbeSentry({ hexHeaderFragmentTtlMs: undefined });
for (const text of ["*", "(", "*", "*", "*", "!"]) {
sentry.consume(Buffer.from(text));
t.mock.timers.tick(16);
}
assert.equal(joinedText(terminal), "*(***!");
t.mock.timers.tick(1000);
assert.equal(joinedText(terminal), "*(***!");
});
test("expired printable prefixes preserve ordinary bytes across chunk boundaries", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const input = Buffer.from("a***(b)**/*.txt *\x18X **\x18Bxy more *\uFF0A");
for (const gap of [0, 16, 200]) {
for (let chunkSize = 1; chunkSize <= input.length; chunkSize += 1) {
const { sentry, terminal } = createProbeSentry({ hexHeaderFragmentTtlMs: undefined });
for (let i = 0; i < input.length; i += chunkSize) {
sentry.consume(input.subarray(i, i + chunkSize));
t.mock.timers.tick(gap);
}
t.mock.timers.tick(1000);
assert.deepEqual(Buffer.concat(terminal), input, `chunkSize=${chunkSize}, gap=${gap}`);
}
}
});
test("a prefix containing ZDLE keeps the full fragmented-header timeout", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const { sentry, terminal } = createProbeSentry({ hexHeaderFragmentTtlMs: undefined });
const header = buildHexHeaderBytes("ZRQINIT");
sentry.consume(header.subarray(0, 3));
t.mock.timers.tick(200);
assert.equal(joinedText(terminal), "");
sentry.consume(header.subarray(3));
t.mock.timers.tick(1000);
assert.equal(joinedText(terminal), "");
assertNoZmodemHeaderBytes(terminal, "delayed ZDLE prefix");
});
test("a slow split header after printable-prefix expiry still detects and hides control bytes", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
for (const prefixLength of [1, 2]) {
let detected = false;
const { sentry, terminal } = createProbeSentry({
hexHeaderFragmentTtlMs: undefined,
selectDownloadDirectory: async () => { detected = true; return { canceled: true }; },
});
const header = buildHexHeaderBytes("ZRQINIT");
sentry.consume(header.subarray(0, prefixLength));
t.mock.timers.tick(200);
assert.equal(joinedText(terminal), "*".repeat(prefixLength));
sentry.consume(header.subarray(prefixLength, 3));
t.mock.timers.tick(200);
sentry.consume(header.subarray(3));
await Promise.resolve();
assert.equal(detected, true, "the printable echo timeout must not reset protocol detection");
assert.equal(joinedText(terminal), "*".repeat(prefixLength));
assertNoZmodemHeaderBytes(terminal, "slow split after printable prefix");
}
});
test("stripping a slow header does not skip ordinary output after it", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const { sentry, terminal } = createProbeSentry({ hexHeaderFragmentTtlMs: undefined });
const header = buildHexHeaderBytes("ZRQINIT");
sentry.consume(header.subarray(0, 2));
t.mock.timers.tick(200);
sentry.consume(Buffer.concat([header.subarray(2), Buffer.from("* next prompt")]));
t.mock.timers.tick(1000);
assert.equal(joinedText(terminal), "*** next prompt");
assertNoZmodemHeaderBytes(terminal, "ordinary output after slow header");
});
test("fragmented sz ZRQINIT preamble never reaches the terminal", () => {
const { sentry, terminal } = createProbeSentry();
const stream = Buffer.concat([