Compare commits

..
Author SHA1 Message Date
Cursor Agentand陈大猫 ae7b316623 fix(ssh): resolve ET ssh wrapper without a host OpenSSH binary
Unit tests stub execFileSync and only found ssh via /usr/bin/ssh, so the
PATH wrapper was skipped on images without that path. Prefer an absolute
findExecutable result, then command -v, then the well-known locations.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-13 07:49:20 +00:00
51 changed files with 141 additions and 2936 deletions

No files matched your search

+2 -1
View File
@@ -1202,7 +1202,8 @@ jobs:
persist-credentials: false
- name: Freeze trusted helper
run: *freeze_ai_helper
run: cp scripts/ai-automation.cjs "$RUNNER_TEMP/ai-automation.cjs"
chmod 0444 "$RUNNER_TEMP/ai-automation.cjs"
- name: Install Claude Code CLI
run: |
-109
View File
@@ -466,115 +466,6 @@ test('writeSessionsForStorage reduces several small sessions to fit a sub-512 KB
}
});
test('serializeSessionsForStorage truncates slightly-over-cap tool results to strictly smaller sizes', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
// Just over the 8 KiB cap: the truncation marker must not make the degraded
// copy larger than the original, or the ladder could evict a session whose
// unmodified JSON would have fit the budget.
for (const oversize of [8193, 8266, 20000]) {
const session = makeSession('near-cap', 1, [{
id: 'tool-message',
role: 'tool',
content: '',
timestamp: 1,
toolResults: [{ toolCallId: 'call-1', content: 'z'.repeat(oversize) }],
}]);
const fullJson = JSON.stringify([session]);
const result = serializeSessionsForStorage([session], fullJson.length - 10);
const content = result.sessions[0].messages[0].toolResults?.[0].content ?? '';
assert.ok(content.length <= 8 * 1024, `cap respected for ${oversize}`);
assert.ok(
JSON.stringify(result.sessions).length < fullJson.length,
`degraded copy smaller than original for ${oversize}`,
);
assert.ok(content.includes('truncated'));
}
});
test('serializeSessionsForStorage sheds oversized tool output before dropping visible sessions', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const newest = makeSession('newest', 2, [{
id: 'new-message',
role: 'assistant',
content: 'still-open chat',
timestamp: 2,
}]);
const toolBulk = makeSession('tool-bulk', 1, [{
id: 'tool-message',
role: 'tool',
content: '',
timestamp: 1,
toolResults: [{ toolCallId: 'call-1', content: 'z'.repeat(400 * 1024) }],
}]);
const result = serializeSessionsForStorage([toolBulk, newest], 100 * 1024);
// The bloated tool result must not evict the other visible chat.
assert.deepEqual(result.sessions.map(session => session.id), ['newest', 'tool-bulk']);
assert.ok(result.json.length <= 100 * 1024);
const persistedToolContent = result.sessions[1].messages[0].toolResults?.[0].content;
assert.ok(persistedToolContent);
assert.ok(persistedToolContent.length <= 9 * 1024);
assert.ok(persistedToolContent.includes('truncated'));
assert.ok(persistedToolContent.startsWith('zzz'));
assert.equal(result.sessions[0].messages[0].content, 'still-open chat');
});
test('writeSessionsForStorage keeps every chat by shedding tool output under quota pressure', async () => {
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
let stored: string | undefined;
const previousLocalStorage = globalThis.localStorage;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: () => null,
setItem: (_key: string, value: string) => {
if (value.length > 300 * 1024) {
throw new DOMException('quota exceeded', 'QuotaExceededError');
}
stored = value;
},
removeItem: () => {},
},
});
try {
const sessions = Array.from({ length: 10 }, (_, index) => makeSession(
`session-${index}`,
10 - index,
[{
id: `message-${index}`,
role: 'tool' as const,
content: '',
timestamp: index,
toolResults: [{ toolCallId: `call-${index}`, content: 'z'.repeat(40 * 1024) }],
}],
));
assert.equal(writeSessionsForStorage(sessions), true);
assert.ok(stored);
assert.ok(stored.length <= 300 * 1024);
const persisted = JSON.parse(stored) as AISession[];
assert.deepEqual(
persisted.map(session => session.id),
sessions.map(session => session.id),
);
// Degradation sheds only as many messages as the budget demands, so at
// least one chat keeps its full tool output while the persisted set stays
// complete. Assert completion first; truncation second.
const shedContents = persisted.map(
session => session.messages[0].toolResults?.[0].content ?? '',
);
assert.ok(shedContents.some(content => content.includes('truncated')));
assert.ok(shedContents.some(content => content.startsWith('zzz') && !content.includes('truncated')));
} finally {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: previousLocalStorage,
});
}
});
test('writeSessionsForStorage makes a final attempt with only the newest session', async () => {
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
const writes: string[] = [];
+29 -79
View File
@@ -195,40 +195,12 @@ const RETRY_SESSIONS_JSON_BYTES = [
128 * 1024,
] as const;
/**
* Maximum persisted length of a single tool result payload. Terminal tool
* output can reach hundreds of KB per command, and AI-driven terminal work
* accumulates many of them. When storage pressure forces degradation, shedding
* this replay bulk keeps every visible conversation persisted instead of
* evicting whole chats that happen to carry it.
*/
const MAX_PERSISTED_TOOL_RESULT_CHARS = 8 * 1024;
function truncatePersistedToolResultContent(content: string): string {
if (content.length <= MAX_PERSISTED_TOOL_RESULT_CHARS) return content;
// The marker consumes budget and must stay single-line: JSON-escaped
// newlines would cost 2 characters each, letting a slightly-over-cap
// result serialize at the same (or larger) size than the original — the
// ladder assumes the degraded form is the smallest. Shrink the retained
// head+tail until the whole persisted copy fits the cap.
let half = Math.floor(MAX_PERSISTED_TOOL_RESULT_CHARS / 2);
let omitted = content.length - 2 * half;
let marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
while (2 * half + marker.length > MAX_PERSISTED_TOOL_RESULT_CHARS) {
half -= Math.ceil((2 * half + marker.length - MAX_PERSISTED_TOOL_RESULT_CHARS) / 2);
omitted = content.length - 2 * half;
marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
}
return `${content.slice(0, half)}${marker}${content.slice(content.length - half)}`;
}
/**
* Remove `reasoningEncryptedContent` ciphertext from a message's persisted
* continuation and shed bulk tool output. The ciphertext exists so stateless
* Responses turns can replay prior reasoning items; tool output dominates
* persisted size in AI-driven terminal chats. When storage pressure forces
* it, shedding this bulk keeps the visible conversation intact at the cost of
* reasoning replay and full tool output for affected messages.
* continuation. The ciphertext exists so stateless Responses turns can replay
* prior reasoning items; it is also by far the largest per-message payload.
* When storage pressure forces it, dropping the ciphertext keeps the visible
* conversation intact at the cost of reasoning replay for affected messages.
*/
function stripReasoningEncryptedContent(
options: ProviderContinuationOptions,
@@ -246,7 +218,7 @@ function stripReasoningEncryptedContent(
return Object.keys(stripped).length ? stripped : undefined;
}
function stripReasoningCiphertextFromMessage(message: AISession['messages'][number]) {
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
const continuation = message.providerContinuation;
if (!continuation?.reasoningParts) return message;
let changed = false;
@@ -267,22 +239,6 @@ function stripReasoningCiphertextFromMessage(message: AISession['messages'][numb
};
}
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
const strippedContinuationMessage = stripReasoningCiphertextFromMessage(message);
const toolResults = strippedContinuationMessage.toolResults;
if (!toolResults?.length) return strippedContinuationMessage;
let toolResultsChanged = false;
const nextToolResults = toolResults.map((result) => {
const content = truncatePersistedToolResultContent(result.content);
if (content === result.content) return result;
toolResultsChanged = true;
return { ...result, content };
});
return toolResultsChanged
? { ...strippedContinuationMessage, toolResults: nextToolResults }
: strippedContinuationMessage;
}
function stripEncryptedReasoningFromSession(session: AISession): AISession {
let changed = false;
const messages = session.messages.map(message => {
@@ -303,7 +259,7 @@ function stripCompactedEncryptedReasoningFromSession(session: AISession): AISess
let changed = false;
const messages = session.messages.map((message, index) => {
if (index >= compactedMessageCount) return message;
const stripped = stripReasoningCiphertextFromMessage(message);
const stripped = stripEncryptedReasoningFromMessage(message);
if (stripped !== message) changed = true;
return stripped;
});
@@ -354,11 +310,6 @@ export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
* Serialize sessions for localStorage under a byte budget, escalating pruning
* as needed. Returns the JSON to persist plus the (possibly) further-pruned
* sessions that JSON represents.
*
* Degradation ladder, cheapest loss first: strip replay ciphertext, shed bulk
* tool output (kept at a bounded head+tail per result), and only then drop
* oldest whole sessions. Evicting visible chats to preserve tool bulk is what
* emptied history for users under storage pressure.
*/
export function serializeSessionsForStorage(
sessions: AISession[],
@@ -370,13 +321,13 @@ export function serializeSessionsForStorage(
// before deciding whether the newest session deserves full protection;
// otherwise dead metadata can evict other visible conversations.
const session = stripCompactedEncryptedReasoningFromSession(rawSession);
const degradableMessages = session.messages.flatMap((message, index) => {
const degradedMessage = stripEncryptedReasoningFromMessage(message);
if (degradedMessage === message) return [];
const ciphertextMessages = session.messages.flatMap((message, index) => {
const strippedMessage = stripEncryptedReasoningFromMessage(message);
if (strippedMessage === message) return [];
return [{
index,
degradedMessage,
jsonLengthDelta: JSON.stringify(degradedMessage).length - JSON.stringify(message).length,
strippedMessage,
jsonLengthDelta: JSON.stringify(strippedMessage).length - JSON.stringify(message).length,
}];
});
const strippedSession = stripEncryptedReasoningFromSession(session);
@@ -388,7 +339,7 @@ export function serializeSessionsForStorage(
strippedJson: strippedSession === session
? json
: JSON.stringify(strippedSession),
degradableMessages,
ciphertextMessages,
};
});
@@ -413,12 +364,11 @@ export function serializeSessionsForStorage(
minimalLength -= removed.strippedJson.length + 1;
}
// Then keep full continuation data for the retained sessions and shed
// replay-only bulk (ciphertext, oversized tool output) from the oldest
// retained sessions only as needed, never touching the protected newest
// session. Within one session, shed it one message at a time from oldest to
// newest so a long active chat can retain its most recent replayable tool
// exchange when that still fits.
// Then keep full continuation data for the retained sessions and remove
// replay-only ciphertext from the oldest retained sessions only as needed,
// never touching the protected newest session. Within one session, remove
// it one message at a time from oldest to newest so a long active chat can
// retain its most recent replayable tool exchange when that still fits.
let jsonLength = 2 + serialized.reduce((total, entry) => total + entry.json.length, 0)
+ Math.max(0, serialized.length - 1);
const firstStrippableIndex = protectNewestContinuation ? 1 : 0;
@@ -428,27 +378,27 @@ export function serializeSessionsForStorage(
index -= 1
) {
const current = serialized[index];
let degradedMessageCount = 0;
while (jsonLength > budgetBytes && degradedMessageCount < current.degradableMessages.length) {
jsonLength += current.degradableMessages[degradedMessageCount].jsonLengthDelta;
degradedMessageCount += 1;
let strippedMessageCount = 0;
while (jsonLength > budgetBytes && strippedMessageCount < current.ciphertextMessages.length) {
jsonLength += current.ciphertextMessages[strippedMessageCount].jsonLengthDelta;
strippedMessageCount += 1;
}
if (degradedMessageCount > 0) {
const degradedMessagesByIndex = new Map(
current.degradableMessages
.slice(0, degradedMessageCount)
.map(entry => [entry.index, entry.degradedMessage] as const),
if (strippedMessageCount > 0) {
const strippedMessagesByIndex = new Map(
current.ciphertextMessages
.slice(0, strippedMessageCount)
.map(entry => [entry.index, entry.strippedMessage] as const),
);
const nextSession = {
...current.session,
messages: current.session.messages.map((message, messageIndex) => (
degradedMessagesByIndex.get(messageIndex) ?? message
strippedMessagesByIndex.get(messageIndex) ?? message
)),
};
const nextJson = JSON.stringify(nextSession);
const projectedJsonLength = current.json.length
+ current.degradableMessages
.slice(0, degradedMessageCount)
+ current.ciphertextMessages
.slice(0, strippedMessageCount)
.reduce((total, entry) => total + entry.jsonLengthDelta, 0);
jsonLength += nextJson.length - projectedJsonLength;
serialized[index] = {
@@ -8,20 +8,10 @@ import {
shouldRevealTerminalPopupOnExit,
} from "./resolveTerminalSessionExitIntent.ts";
test("backend exited events close the session tab only when auto-close is enabled", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }, true),
{ kind: "closeSession" },
);
});
test("omitted auto-close setting keeps the session tab on a clean exit", () => {
// The fallback for callers that have not loaded the terminal setting yet:
// keep the tab so a jump host / bastion reaping a session (a clean exit)
// never silently deletes the user's scrollback.
test("normal backend exited events close the session tab", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }),
{ kind: "markDisconnected" },
{ kind: "closeSession" },
);
});
@@ -67,19 +57,11 @@ test("backend closed events keep the tab and mark it disconnected", () => {
);
});
test("administrative exit-signal disconnects keep the tab", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "signaled", exitSignal: "SIGHUP" }, true),
{ kind: "markDisconnected" },
);
});
test("terminal popup only auto-closes after clean command exit", () => {
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 0 }), true);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 1 }), false);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "error", error: "connection reset" }), false);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "closed", exitCode: 0 }), false);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "signaled", exitSignal: "SIGHUP" }), false);
});
test("disabled auto-close keeps command and attached terminal popups open", () => {
@@ -151,17 +133,6 @@ test("exit messages show the reported code for a real remote exit", () => {
);
});
test("an administrative exit-signal renders as a remote signal, not a clean code", () => {
assert.equal(
formatSessionClosedExitMessage({ reason: "signaled", exitSignal: "SIGHUP" }),
"\r\n[session closed by remote signal]",
);
assert.equal(
formatSessionClosedExitMessage({ reason: "closed", exitSignal: "SIGTERM" }),
"\r\n[session closed by remote signal]",
);
});
test("a channel closed without exit-status or exit-signal must not render as code 0", () => {
assert.equal(
formatSessionClosedExitMessage({
@@ -179,25 +150,3 @@ test("exit messages without a reported code stay plain instead of showing a sent
);
assert.equal(formatSessionClosedExitMessage({}), "\r\n[session closed]");
});
test("infrastructure teardowns must not render a numeric code as a clean exit (#3631)", () => {
// A terminal-worker exit broadcast carries the worker's own exit status
// (graceful drain = 0). It belongs to the infrastructure failure, not to
// any remote shell, so it must never read as "session closed (code 0)".
assert.equal(
formatSessionClosedExitMessage({
reason: "error",
exitCode: 0,
error: "Terminal worker exited with code 0",
}),
"\r\n[session closed unexpectedly]",
);
assert.equal(
formatSessionClosedExitMessage({ reason: "error", exitCode: 1 }),
"\r\n[session closed unexpectedly]",
);
assert.equal(
formatSessionClosedExitMessage({ reason: "error" }),
"\r\n[session closed unexpectedly]",
);
});
@@ -4,10 +4,7 @@ export type TerminalSessionExitEvent = {
exitCode?: number;
signal?: number;
error?: string;
reason?: "exited" | "error" | "timeout" | "closed" | "signaled";
// ssh2 exit-signal name ("SIGHUP", "SIGTERM", ...). Set when the server
// terminated the session process. Distinct from a transport "closed".
exitSignal?: string;
reason?: "exited" | "error" | "timeout" | "closed";
// True when the remote closed the channel without sending exit-status or
// exit-signal (#3591): no numeric exit code exists, and the renderer shows
// a distinct message instead of a misleading "code 0".
@@ -19,13 +16,6 @@ export function formatSessionClosedExitMessage(evt: TerminalSessionExitEvent): s
if (evt?.remoteClosedWithoutExitStatus) {
return "\r\n[session closed by remote (no exit status)]";
}
if (evt?.reason === "signaled" || (typeof evt?.exitSignal === "string" && evt.exitSignal.trim().length > 0)) {
return "\r\n[session closed by remote signal]";
}
// Infrastructure exit codes do not describe the remote shell's status.
if (evt?.reason === "error") {
return "\r\n[session closed unexpectedly]";
}
return `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
}
@@ -44,11 +34,7 @@ function isConfirmedCleanExit(evt: TerminalSessionExitEvent): boolean {
export function resolveTerminalSessionExitIntent(
evt: TerminalSessionExitEvent,
// Keep-tab is the safe default: a caller that has not loaded the terminal
// setting yet must not silently delete a session tab. Remote graceful
// channel closes (e.g. a jump host reaping an idle session) are reported
// as a clean exit, so an unavailable setting must not re-enable closing.
autoCloseOnExit = false,
autoCloseOnExit = true,
): TerminalSessionExitIntent {
if (autoCloseOnExit && isConfirmedCleanExit(evt)) {
return { kind: "closeSession" };
@@ -9,14 +9,9 @@ import {
resolveReadableForegroundForHsl,
resolveThemeAccentForeground,
migrateIncomingTerminalFontId,
migrateMaterializedAutoCloseOnExitDefault,
} from "./settingsStateDefaults.ts";
import { TERMINAL_FONT_AUTO } from "../../infrastructure/config/fonts.ts";
import {
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_SETTINGS,
} from "../../infrastructure/config/storageKeys.ts";
import { STORAGE_KEY_TERM_FONT_FAMILY } from "../../infrastructure/config/storageKeys.ts";
import type { UiThemeTokens } from "../../infrastructure/config/uiThemes.ts";
function installMemoryLocalStorage(): Map<string, string> {
@@ -145,72 +140,3 @@ test("app surface theme vars isolate non-terminal pages from active terminal chr
"--ring": "270 70% 45%",
});
});
test("migrateMaterializedAutoCloseOnExitDefault resets the materialized default-on once", () => {
const store = installMemoryLocalStorage();
try {
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 3000, autoCloseOnExit: true }),
);
migrateMaterializedAutoCloseOnExitDefault();
const migrated = JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!);
assert.equal(migrated.autoCloseOnExit, false);
// Other saved settings pass through untouched.
assert.equal(migrated.scrollback, 3000);
assert.equal(store.get(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED), "true");
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
test("migrateMaterializedAutoCloseOnExitDefault runs once and respects later explicit edits", () => {
const store = installMemoryLocalStorage();
try {
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ autoCloseOnExit: true }),
);
migrateMaterializedAutoCloseOnExitDefault();
assert.equal(
JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).autoCloseOnExit,
false,
);
// The user re-enables after upgrading: the migration must not undo it again.
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ autoCloseOnExit: true }),
);
migrateMaterializedAutoCloseOnExitDefault();
assert.equal(
JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).autoCloseOnExit,
true,
);
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
test("migrateMaterializedAutoCloseOnExitDefault leaves fresh installs and explicit-off alone", () => {
const store = installMemoryLocalStorage();
try {
// Fresh install: no saved settings at all.
migrateMaterializedAutoCloseOnExitDefault();
assert.equal(store.has(STORAGE_KEY_TERM_SETTINGS), false);
assert.equal(store.get(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED), "true");
// Explicit-off (or any settings blob without the key) is never rewritten.
delete (globalThis as { localStorage?: unknown }).localStorage;
const store2 = installMemoryLocalStorage();
store2.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 500 }),
);
migrateMaterializedAutoCloseOnExitDefault();
assert.deepEqual(JSON.parse(store2.get(STORAGE_KEY_TERM_SETTINGS)!), {
scrollback: 500,
});
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
+1 -29
View File
@@ -1,9 +1,5 @@
import type { HotkeyScheme, SessionLogFormat, TerminalSettings } from '../../domain/models';
import {
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_SETTINGS,
} from '../../infrastructure/config/storageKeys';
import { STORAGE_KEY_TERM_FONT_FAMILY } from '../../infrastructure/config/storageKeys';
import {
isDeprecatedPrimaryFontId,
TERMINAL_FONT_AUTO,
@@ -67,30 +63,6 @@ export function migrateIncomingTerminalFontId(raw: string | null | undefined): s
}
return raw;
}
/**
* One-time migration for the autoCloseOnExit default flip (tabs now survive
* session exits by default; the old default-on silently deleted tabs whenever
* a bastion or idle-harvesting server reported a clean exit). Builds before
* the flip shipped autoCloseOnExit: true as the default and materialized it
* into STORAGE_KEY_TERM_SETTINGS on any settings write, so most upgraders
* carry a value they never chose. Leaving it in place would make the new
* default unreachable for them. Reset that materialized `true` exactly once
* and persist the marker; afterwards every stored value is treated as the
* user's own choice and is respected. An inbound cloud or backup payload that
* still carries the pre-migration materialized true (no
* autoCloseOnExitDefaultMigrated flag) is rewritten to false so sync cannot
* resurrect auto-close. The cost: a user who deliberately enabled auto-close
* before this release must re-enable it once.
*/
export function migrateMaterializedAutoCloseOnExitDefault(): void {
if (localStorageAdapter.readBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED)) return;
const stored = localStorageAdapter.read<Partial<TerminalSettings>>(STORAGE_KEY_TERM_SETTINGS);
if (stored?.autoCloseOnExit === true) {
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, { ...stored, autoCloseOnExit: false });
}
localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, true);
}
// Auto-detect default hotkey scheme based on platform
export const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
@@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { UploadController, uploadEntriesDirect } from "../../../lib/uploadService";
import { UploadController } from "../../../lib/uploadService";
import {
cancelExternalUploadRuntime,
getExternalUploadController,
@@ -37,64 +37,3 @@ test("external upload controls survive their originating panel unmount", async (
assert.equal(getExternalUploadController("folder-1"), undefined);
assert.equal(getExternalUploadController("child-1"), undefined);
});
for (const selectedFile of ["a.bin", "c.bin"]) {
test(`cancelling independent ${selectedFile} preserves its active and queued upload siblings`, async (t) => {
resetExternalUploadRuntimeForTests();
t.after(resetExternalUploadRuntimeForTests);
const controller = new UploadController();
const cancelled: string[] = [];
const stagedCancelled: string[] = [];
const ids = new Map<string, string>();
const pending = new Map<string, (cancelled?: boolean) => void>();
const started: string[] = [];
const upload = uploadEntriesDirect(
["a.bin", "b.bin", "c.bin"].map((name) => ({
file: null, relativePath: name, isDirectory: false, localPath: `/local/${name}`, size: 8,
})),
{
targetPath: "/remote", sftpId: "fixture", isLocal: false,
fileTransferConcurrency: 2, joinPath: (base, name) => `${base}/${name}`,
callbacks: {
onTaskCreated: (task) => {
ids.set(task.fileName, task.id);
registerExternalUploadController(task.id, controller, "file");
},
},
bridge: {
mkdirSftp: async () => {},
cancelStagedUploadFile: async (id) => { stagedCancelled.push(id); },
cancelTransfer: async (id) => { cancelled.push(id); pending.get(id)?.(true); },
startStreamTransfer: ({ transferId, sourcePath }) => new Promise((resolve) => {
started.push(sourcePath);
pending.set(transferId, (cancelled = false) => resolve({ transferId, cancelled }));
}),
},
},
controller,
);
t.after(async () => {
await controller.cancel();
for (const finish of pending.values()) finish(true);
await upload;
});
await new Promise(setImmediate);
assert.deepEqual(started, ["/local/a.bin", "/local/b.bin"]);
const selectedId = ids.get(selectedFile)!;
await cancelExternalUploadRuntime(selectedId);
assert.deepEqual(cancelled, [selectedId]);
assert.deepEqual(stagedCancelled, [selectedId]);
assert.equal(controller.isCancelled(), false);
await new Promise(setImmediate);
assert.deepEqual(started, selectedFile === "a.bin"
? ["/local/a.bin", "/local/b.bin", "/local/c.bin"]
: ["/local/a.bin", "/local/b.bin"]);
for (const [id, finish] of pending) if (id !== selectedId) finish();
const results = (await upload).sort((a, b) => a.fileName.localeCompare(b.fileName));
assert.deepEqual(results, ["a.bin", "b.bin", "c.bin"].map((fileName) => (
fileName === selectedFile
? { fileName, success: false, cancelled: true }
: { fileName, success: true }
)));
});
}
@@ -5,13 +5,11 @@ import type { UploadController } from "../../../lib/uploadService";
// terminal only removes the view; the global transfer runtime can still cancel
// the upload, and the upload's own finally block releases these entries.
const controllersByTaskId = new Map<string, UploadController>();
const scopesByTaskId = new Map<string, "file" | "batch">();
const taskIdsByController = new Map<UploadController, Set<string>>();
export function registerExternalUploadController(
taskId: string,
controller: UploadController,
scope: "file" | "batch" = "batch",
): void {
if (!taskId) return;
const previous = controllersByTaskId.get(taskId);
@@ -21,7 +19,6 @@ export function registerExternalUploadController(
if (previousIds?.size === 0) taskIdsByController.delete(previous);
}
controllersByTaskId.set(taskId, controller);
scopesByTaskId.set(taskId, scope);
const taskIds = taskIdsByController.get(controller) ?? new Set<string>();
taskIds.add(taskId);
taskIdsByController.set(controller, taskIds);
@@ -33,7 +30,6 @@ export function unregisterExternalUploadController(controller: UploadController)
for (const taskId of taskIds) {
if (controllersByTaskId.get(taskId) === controller) {
controllersByTaskId.delete(taskId);
scopesByTaskId.delete(taskId);
}
}
taskIdsByController.delete(controller);
@@ -44,10 +40,6 @@ export function getExternalUploadController(taskId: string): UploadController |
}
export async function cancelExternalUploadRuntime(taskId?: string): Promise<boolean> {
if (taskId && scopesByTaskId.get(taskId) === "file") {
await controllersByTaskId.get(taskId)?.cancelFile(taskId);
return true;
}
const controllers = taskId
? [controllersByTaskId.get(taskId)].filter((value): value is UploadController => !!value)
: [...taskIdsByController.keys()];
@@ -58,6 +50,5 @@ export async function cancelExternalUploadRuntime(taskId?: string): Promise<bool
export function resetExternalUploadRuntimeForTests(): void {
controllersByTaskId.clear();
scopesByTaskId.clear();
taskIdsByController.clear();
}
@@ -201,8 +201,8 @@ export const useSftpExternalOperations = (
return { sftpId, release: () => {} };
}, [ensureRemoteSftpId, getActivePane, getPaneByConnectionId, getPaneByTabId, getSideByTabId, sftpSessionsRef]);
const registerUploadController = useCallback((taskId: string, controller: UploadController, scope: "file" | "batch" = "batch") => {
registerExternalUploadController(taskId, controller, scope);
const registerUploadController = useCallback((taskId: string, controller: UploadController) => {
registerExternalUploadController(taskId, controller);
}, []);
const unregisterUploadController = useCallback((controller: UploadController) => {
@@ -222,7 +222,7 @@ export const useSftpExternalOperations = (
callbacks.onScanningProgress?.(taskId, progress);
},
onTaskCreated: (task) => {
registerUploadController(task.id, controller, !task.isDirectory && !task.parentTaskId ? "file" : "batch");
registerUploadController(task.id, controller);
if (task.parentTaskId) {
registerUploadController(task.parentTaskId, controller);
}
@@ -58,52 +58,6 @@ test("normal shell exits do not auto reconnect", () => {
);
});
test("administrative idle, timeout, and exit-signal disconnects do not auto reconnect", () => {
for (const evt of [
{ reason: "timeout" as const },
{ reason: "timeout" as const, exitCode: 0 },
{ reason: "timeout" as const, error: "Connection timeout to example.com" },
{ reason: "signaled" as const, exitSignal: "SIGHUP" },
{ reason: "signaled" as const, exitSignal: "SIGTERM" },
{ reason: "signaled" as const },
// A path that still says "closed" but plumbed the exit-signal is the same kick.
{ reason: "closed" as const, exitSignal: "SIGUSR2" },
{ reason: "closed" as const, exitSignal: " SIGHUP " },
]) {
assert.equal(
shouldAutoReconnectAfterExit({
evt,
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: true },
hasEverConnected: true,
}),
false,
);
}
});
test("transport drops still auto reconnect", () => {
for (const evt of [
{ reason: "closed" as const },
{ reason: "error" as const, error: "connection reset" },
{ reason: "closed" as const, remoteClosedWithoutExitStatus: true },
{ reason: "closed" as const, exitSignal: "" },
{ reason: "closed" as const, exitSignal: " " },
{ reason: "error" as const, error: "read ECONNRESET", exitSignal: "" },
{ reason: "error" as const, error: "read ECONNRESET", exitSignal: "SIGHUP" },
]) {
assert.equal(
shouldAutoReconnectAfterExit({
evt,
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: true },
hasEverConnected: true,
}),
true,
);
}
});
test("auto reconnect ignores protocols that already have different lifecycle semantics", () => {
const variants = [
{ protocol: "local" as const, hostname: "localhost" },
+1 -10
View File
@@ -40,16 +40,7 @@ export function shouldAutoReconnectAfterExit({
if (!hasEverConnected) return false;
if (!isTerminalAutoReconnectEnabled(terminalSettings)) return false;
if (!isAutoReconnectableSshHost(host)) return false;
// "exited" is a shell exit. "timeout" is an administrative idle/TMOUT logout
// (or a connect timeout). "signaled" — or a plumbed exit-signal on an
// otherwise "closed" end — is a server kick (idle policy, SIGHUP, SIGTERM).
// Reconnecting would fight that policy. Transport "error" and bare "closed"
// still retry.
if (evt.reason === "error") return true;
if (evt.reason === "exited" || evt.reason === "timeout" || evt.reason === "signaled") {
return false;
}
return typeof evt.exitSignal !== "string" || evt.exitSignal.trim().length === 0;
return evt.reason !== "exited";
}
export function shouldContinueAutoReconnectAfterFailure({
@@ -1,8 +1,5 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { createSessionIdleManager } from '../../electron/bridges/mcpServerBridge/sessionIdleManager.cjs';
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS,
bumpExternalMcpEnableGenerationForTests,
@@ -21,7 +18,6 @@ import {
resetExternalMcpStartupSyncOnceForTests,
shouldStartExternalMcpOnStartup,
shouldWaitForExternalMcpStartupReady,
syncExternalMcpConfig,
syncExternalMcpStartupState,
syncExternalMcpStartupStateOnce,
waitForExternalMcpStartupReady,
@@ -67,44 +63,10 @@ describe('useExternalMcpToggleState helpers', () => {
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(null), 10);
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(0), 1);
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(99999), 24 * 60);
assert.equal(normalizeSessionIdleTimeoutMinutes(null), 8 * 60);
assert.equal(normalizeSessionIdleTimeoutMinutes(null), 30);
assert.equal(normalizeSessionIdleTimeoutMinutes(0), 1);
});
for (const savedMinutes of [null, 30]) {
it(`syncs the session idle timeout without replacing saved value ${savedMinutes}`, async () => {
const restore = installMemoryLocalStorage();
const manager = createSessionIdleManager();
try {
assert.equal(manager.getTimeoutMinutes(), 8 * 60, 'main-process default');
if (savedMinutes !== null) {
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, savedMinutes);
}
const expected = savedMinutes ?? manager.getTimeoutMinutes();
const received: number[] = [];
const bridge = {
externalMcpSetConfig: (config: { sessionIdleTimeoutMinutes: number }) => {
received.push(config.sessionIdleTimeoutMinutes);
manager.setTimeoutMinutes(config.sessionIdleTimeoutMinutes);
},
};
await syncExternalMcpStartupState(bridge);
assert.equal(manager.getTimeoutMinutes(), expected, 'startup keeps the intended timeout');
syncExternalMcpConfig(bridge);
assert.deepEqual(received, [expected, expected]);
assert.equal(manager.getTimeoutMinutes(), expected, 'config sync keeps the intended timeout');
assert.equal(
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES),
savedMinutes,
'sync does not migrate or overwrite the stored preference',
);
} finally {
manager.clearAll();
restore();
}
});
}
it('only starts on launch for persistent+enabled', () => {
assert.equal(shouldStartExternalMcpOnStartup({ enabled: true, mode: 'persistent' }), true);
assert.equal(shouldStartExternalMcpOnStartup({ enabled: true, mode: 'temporary' }), false);
@@ -18,7 +18,7 @@ export type ExternalMcpMode = 'temporary' | 'persistent';
const DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 10;
const MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 1;
const MAX_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 24 * 60;
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 480;
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 30;
/** Keep top-bar / settings switch state aligned with runtime auto-disable. */
export const EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS = 3000;
-4
View File
@@ -151,7 +151,6 @@ import {
isValidUiFontId,
isValidUiThemeId,
migrateIncomingTerminalFontId,
migrateMaterializedAutoCloseOnExitDefault,
readStoredString,
serializeTerminalSettings,
type HostClickBehavior,
@@ -356,9 +355,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
passwordVerifier: null,
});
const [terminalSettings, setTerminalSettingsState] = useState<TerminalSettings>(() => {
// One-time reset of the pre-flip materialized autoCloseOnExit: true before
// it is ingested; afterwards stored values are the user's own and win.
migrateMaterializedAutoCloseOnExitDefault();
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
return normalizeTerminalSettings(stored);
});
-44
View File
@@ -1667,50 +1667,6 @@ test("terminal auto-close preference survives sync round-trip", async () => {
assert.equal(restored.autoCloseOnExit, false);
});
test("inbound sync does not resurrect the materialized auto-close default after the marker", async () => {
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, "true");
try {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ autoCloseOnExit: false, scrollback: 3000 }),
);
await applySyncPayload({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
settings: { terminalSettings: { autoCloseOnExit: true } },
}, { importVaultData: () => {} });
const resurrected = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
assert.equal(resurrected.autoCloseOnExit, false);
assert.equal(resurrected.scrollback, 3000);
// An explicit opt-in made after the migration round-trips with a marker.
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ autoCloseOnExit: true, scrollback: 3000 }),
);
const optedIn = buildSyncPayload(vault());
assert.equal(optedIn.settings?.autoCloseOnExitDefaultMigrated, true);
assert.equal(optedIn.settings?.terminalSettings?.autoCloseOnExit, true);
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ autoCloseOnExit: false, scrollback: 3000 }),
);
await applySyncPayload(optedIn, { importVaultData: () => {} });
const restoredChoice = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
assert.equal(restoredChoice.autoCloseOnExit, true);
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED), "true");
} finally {
localStorage.removeItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED);
}
});
test("terminal disconnected notice preference survives sync round-trip", async () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
-22
View File
@@ -25,7 +25,6 @@ import {
SYNC_PAYLOAD_ENTITY_KEYS,
SYNC_STORAGE_KEYS,
hasSyncPayloadEntityData,
reconcileInboundAutoCloseOnExit,
retainLocalHostLastConnectedAt,
sanitizeHostsForSync,
type SyncPayload,
@@ -63,7 +62,6 @@ import {
} from './state/customAccentSync';
import { isValidHslToken } from './state/settingsStateDefaults';
import {
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
STORAGE_KEY_THEME,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_UI_THEME_DARK,
@@ -507,9 +505,6 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (Object.keys(subset).length > 0) settings.terminalSettings = subset;
} catch { /* ignore corrupt data */ }
}
if (localStorageAdapter.readBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED)) {
settings.autoCloseOnExitDefaultMigrated = true;
}
// Custom terminal themes
const customThemesRaw = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_THEMES);
@@ -800,23 +795,6 @@ async function applySyncableSettings(
? 'disabled'
: 'paste';
}
if ('autoCloseOnExit' in settings.terminalSettings) {
merged.autoCloseOnExit = reconcileInboundAutoCloseOnExit(
settings.terminalSettings.autoCloseOnExit,
settings.autoCloseOnExitDefaultMigrated,
);
}
// A legacy true is the old default. A migrated peer's explicit choice must
// stick, so record the marker before the next local migration pass.
if (
settings.autoCloseOnExitDefaultMigrated === true
|| (
settings.terminalSettings.autoCloseOnExit === true
&& settings.autoCloseOnExitDefaultMigrated !== true
)
) {
localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, true);
}
localStorageAdapter.writeString(STORAGE_KEY_TERM_SETTINGS, JSON.stringify(merged));
}
+1 -3
View File
@@ -669,9 +669,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const handleSessionExit = useCallback((sessionId: string, evt: TerminalSessionExitEvent) => {
const intent = resolveTerminalSessionExitIntent(
evt,
// No terminal settings loaded yet -> do not close the tab; auto-close is
// opt-in via Settings -> Terminal -> Behavior (DEFAULT_TERMINAL_SETTINGS).
terminalSettings?.autoCloseOnExit ?? false,
terminalSettings?.autoCloseOnExit ?? true,
);
if (intent.kind === "closeSession") {
handleCloseSession(sessionId);
+1 -1
View File
@@ -162,7 +162,7 @@ test('attach popup close preparation has a bounded timeout', () => {
test('terminal exit auto-close setting reaches tabs, workspaces, and popups', () => {
assert.match(
terminalLayerSource,
/resolveTerminalSessionExitIntent\(\s+evt,[\s\S]*?terminalSettings\?\.autoCloseOnExit \?\? false,\s+\)/,
/resolveTerminalSessionExitIntent\(\s+evt,\s+terminalSettings\?\.autoCloseOnExit \?\? true,\s+\)/,
);
assert.match(
terminalSource,
@@ -1,86 +0,0 @@
import assert from "node:assert/strict";
import test, { type TestContext } from "node:test";
import React from "react";
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings";
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from "../../test-support/renderReactDom";
async function setup(t: TestContext, label: string) {
const env = installDomEnvironment();
const previous = new Map<string, PropertyDescriptor | undefined>();
const globals = {
DocumentFragment: env.window.DocumentFragment,
MutationObserver: env.window.MutationObserver,
Element: env.window.Element,
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
cancelAnimationFrame: clearTimeout,
};
for (const [key, value] of Object.entries(globals)) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
const { default: SettingsShortcutsTab } = await import("./SettingsShortcutsTab");
const { I18nProvider } = await import("../../../application/i18n/I18nProvider");
const { Tabs } = await import("../../ui/tabs");
const renderer = await createDomRenderer(env.document);
t.after(async () => {
await renderer.unmount();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
env.cleanup();
});
const updates: string[][] = [];
await renderer.render(<I18nProvider locale="en"><Tabs value="shortcuts"><SettingsShortcutsTab
hotkeyScheme="pc" setHotkeyScheme={() => {}}
shellOnlyTabNumberShortcuts={false} setShellOnlyTabNumberShortcuts={() => {}}
showTabNumberBadges={true} setShowTabNumberBadges={() => {}}
disableTerminalFontZoom={false} setDisableTerminalFontZoom={() => {}}
keyBindings={DEFAULT_KEY_BINDINGS} resetAllKeyBindings={() => {}}
updateKeyBinding={(...args) => { updates.push(args); }}
/></Tabs></I18nProvider>);
const row = [...env.document.querySelectorAll("span")].find((span) => span.textContent === label)?.parentElement;
const button = row?.querySelector("button");
assert.ok(button);
await dispatchDomEvent(button, new env.window.MouseEvent("click", { bubbles: true }));
const key = (type: "keydown" | "keyup", init: KeyboardEventInit) => dispatchDomEvent(
env.window, new env.window.KeyboardEvent(type, { bubbles: true, cancelable: true, ...init }),
);
return { updates, key, button };
}
test("the tab modifier editor saves left Alt on release without requiring a digit", async (t) => {
const { updates, key } = await setup(t, "Switch to Tab [1...9]");
await key("keydown", { key: "Alt", code: "AltLeft", altKey: true });
assert.deepEqual(updates, []);
await key("keyup", { key: "Alt", code: "AltLeft" });
assert.deepEqual(updates, [["switch-tab-1-9", "pc", "Alt + [1...9]"]]);
});
test("the modifier editor waits for the full modifier chord to be released", async (t) => {
const { updates, key } = await setup(t, "Switch to Tab [1...9]");
await key("keydown", { key: "Control", ctrlKey: true });
await key("keydown", { key: "Alt", ctrlKey: true, altKey: true });
await key("keyup", { key: "Alt", ctrlKey: true });
assert.deepEqual(updates, []);
await key("keyup", { key: "Control" });
assert.deepEqual(updates, [["switch-tab-1-9", "pc", "Ctrl + Alt + [1...9]"]]);
});
test("ordinary shortcuts still require a non-modifier key", async (t) => {
const { updates, key } = await setup(t, "Next Tab");
await key("keydown", { key: "Alt", altKey: true });
await key("keyup", { key: "Alt" });
assert.deepEqual(updates, []);
await key("keydown", { key: "ArrowRight", altKey: true });
assert.deepEqual(updates, [["next-tab", "pc", "Alt + \u2192"]]);
});
test("Escape cancels an unfinished modifier recording", async (t) => {
const { updates, key, button } = await setup(t, "Switch to Tab [1...9]");
await key("keydown", { key: "Alt", altKey: true });
await key("keydown", { key: "Escape", altKey: true });
await key("keyup", { key: "Alt" });
assert.deepEqual(updates, []);
assert.equal(button.textContent, "Ctrl");
});
@@ -64,7 +64,6 @@ export default function SettingsShortcutsTab(props: {
if (!recordingBindingId || !recordingScheme) return;
const specialSuffix = getSpecialSuffix(recordingBindingId);
let pendingModifierBinding: string | null = null;
const handleKeyDown = (e: KeyboardEvent) => {
// Skip while app lock overlay is up so password keys never bind (Codex P2).
@@ -78,6 +77,8 @@ export default function SettingsShortcutsTab(props: {
}
if (specialSuffix) {
if (["Meta", "Control", "Alt", "Shift"].includes(e.key)) return;
const parts: string[] = [];
if (recordingScheme === "mac") {
if (e.metaKey) parts.push("⌘");
@@ -94,11 +95,6 @@ export default function SettingsShortcutsTab(props: {
const modifierString = parts.length > 0 ? `${parts.join(" + ")} + ` : "";
const fullKeyString = modifierString + specialSuffix;
if (["Meta", "Control", "Alt", "Shift"].includes(e.key)) {
pendingModifierBinding = fullKeyString;
return;
}
updateKeyBinding?.(recordingBindingId, recordingScheme, fullKeyString);
cancelRecording();
return;
@@ -110,17 +106,6 @@ export default function SettingsShortcutsTab(props: {
cancelRecording();
};
const handleKeyUp = (e: KeyboardEvent) => {
if (!specialSuffix || !pendingModifierBinding || isAppLockOverlayActive()) return;
if (!["Meta", "Control", "Alt", "Shift"].includes(e.key)) return;
e.preventDefault();
e.stopPropagation();
// Wait for release so a modifier-only editor can capture Ctrl+Alt, etc.
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
updateKeyBinding?.(recordingBindingId, recordingScheme, pendingModifierBinding);
cancelRecording();
};
const handleClick = () => {
cancelRecording();
};
@@ -130,11 +115,9 @@ export default function SettingsShortcutsTab(props: {
}, 100);
window.addEventListener("keydown", handleKeyDown, true);
window.addEventListener("keyup", handleKeyUp, true);
return () => {
clearTimeout(timer);
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("keyup", handleKeyUp, true);
window.removeEventListener("click", handleClick, true);
};
}, [recordingBindingId, recordingScheme, updateKeyBinding, cancelRecording, getSpecialSuffix]);
@@ -1,258 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
attachWindowResizeFocusRestore,
WINDOW_RESIZE_RESTORE_FOCUS_DELAY_MS,
type MinimalDocument,
} from "./restoreWindowResizeFocus";
type FakeWindow = {
listeners: Map<string, Array<() => void>>;
addEventListener: (type: string, listener: () => void) => void;
removeEventListener: (type: string, listener: () => void) => void;
emit: (type: string) => void;
};
const createWindowStub = (): FakeWindow => {
const listeners = new Map<string, Array<() => void>>();
return {
listeners,
addEventListener(type, listener) {
const bucket = listeners.get(type) ?? [];
bucket.push(listener);
listeners.set(type, bucket);
},
removeEventListener(type, listener) {
const bucket = listeners.get(type) ?? [];
const index = bucket.indexOf(listener);
if (index >= 0) bucket.splice(index, 1);
},
emit(type) {
for (const listener of [...(listeners.get(type) ?? [])]) listener();
},
};
};
type SchedulerState = {
schedule: (callback: () => void, delay: number) => number;
cancel: (id: number) => void;
runNext: () => void;
pendingCount: () => number;
};
const createScheduler = (): SchedulerState => {
type Entry = { id: number; callback: () => void; cancelled: boolean };
const queue: Entry[] = [];
let nextId = 1;
return {
schedule(callback, delay) {
assert.equal(delay, WINDOW_RESIZE_RESTORE_FOCUS_DELAY_MS);
queue.push({ id: nextId, callback, cancelled: false });
return nextId++;
},
cancel(id) {
for (const entry of queue) {
if (entry.id === id) entry.cancelled = true;
}
},
runNext() {
while (queue.length) {
const entry = queue.shift()!;
if (!entry.cancelled) {
entry.callback();
return;
}
}
},
pendingCount: () => queue.filter((entry) => !entry.cancelled).length,
};
};
test("resize restores terminal focus once the resize settles", () => {
const win = createWindowStub();
const scheduler = createScheduler();
let refocusCalls = 0;
const detach = attachWindowResizeFocusRestore({
win,
doc: { querySelector: () => null, hasFocus: () => true } as MinimalDocument,
refocus: () => { refocusCalls += 1; },
scheduleTimeout: scheduler.schedule,
cancelTimeout: scheduler.cancel,
});
win.emit("resize");
assert.equal(scheduler.pendingCount(), 1, "restore must be debounced");
assert.equal(refocusCalls, 0);
scheduler.runNext();
assert.equal(refocusCalls, 1);
detach();
});
test("a continuous frame drag only triggers one restore", () => {
const win = createWindowStub();
const scheduler = createScheduler();
let refocusCalls = 0;
const detach = attachWindowResizeFocusRestore({
win,
doc: { querySelector: () => null, hasFocus: () => true } as MinimalDocument,
refocus: () => { refocusCalls += 1; },
scheduleTimeout: scheduler.schedule,
cancelTimeout: scheduler.cancel,
});
win.emit("resize");
win.emit("resize");
win.emit("resize");
scheduler.runNext();
assert.equal(refocusCalls, 1, "debounced resizes must collapse into one restore");
detach();
});
test("resize does not steal focus when the app window is not focused", () => {
const win = createWindowStub();
const scheduler = createScheduler();
let refocusCalls = 0;
const detach = attachWindowResizeFocusRestore({
win,
doc: { querySelector: () => null, hasFocus: () => false } as MinimalDocument,
refocus: () => { refocusCalls += 1; },
scheduleTimeout: scheduler.schedule,
cancelTimeout: scheduler.cancel,
});
win.emit("resize");
scheduler.runNext();
assert.equal(refocusCalls, 0);
detach();
});
test("resize restore is skipped while a modal dialog is open", () => {
const win = createWindowStub();
const scheduler = createScheduler();
let refocusCalls = 0;
const detach = attachWindowResizeFocusRestore({
win,
doc: {
querySelector: (selector: string) => (
selector === '[role="dialog"][data-state="open"]' ? {} : null
),
hasFocus: () => true,
} as MinimalDocument,
refocus: () => { refocusCalls += 1; },
scheduleTimeout: scheduler.schedule,
cancelTimeout: scheduler.cancel,
});
win.emit("resize");
scheduler.runNext();
assert.equal(refocusCalls, 0);
detach();
});
test("resize does not steal focus from an active editable field", () => {
const win = createWindowStub();
const scheduler = createScheduler();
let refocusCalls = 0;
const detach = attachWindowResizeFocusRestore({
win,
doc: {
querySelector: () => null,
hasFocus: () => true,
activeElement: { tagName: "INPUT", matches: () => false },
} as unknown as MinimalDocument,
refocus: () => { refocusCalls += 1; },
scheduleTimeout: scheduler.schedule,
cancelTimeout: scheduler.cancel,
});
win.emit("resize");
scheduler.runNext();
assert.equal(refocusCalls, 0, "restore must not yank focus out of an editable field");
detach();
});
test("resize does not steal focus from a contenteditable field", () => {
const win = createWindowStub();
const scheduler = createScheduler();
let refocusCalls = 0;
const detach = attachWindowResizeFocusRestore({
win,
doc: {
querySelector: () => null,
hasFocus: () => true,
activeElement: { tagName: "DIV", isContentEditable: true, matches: () => false },
} as unknown as MinimalDocument,
refocus: () => { refocusCalls += 1; },
scheduleTimeout: scheduler.schedule,
cancelTimeout: scheduler.cancel,
});
win.emit("resize");
scheduler.runNext();
assert.equal(refocusCalls, 0);
detach();
});
test("resize still restores while the xterm helper textarea is focused", () => {
const win = createWindowStub();
const scheduler = createScheduler();
let refocusCalls = 0;
const detach = attachWindowResizeFocusRestore({
win,
doc: {
querySelector: () => null,
hasFocus: () => true,
activeElement: {
tagName: "TEXTAREA",
matches: (selector: string) => selector === "textarea.xterm-helper-textarea",
},
} as unknown as MinimalDocument,
refocus: () => { refocusCalls += 1; },
scheduleTimeout: scheduler.schedule,
cancelTimeout: scheduler.cancel,
});
win.emit("resize");
scheduler.runNext();
assert.equal(refocusCalls, 1, "terminal-owned focus must be restored after resize");
detach();
});
test("detach removes the listener and cancels a pending restore", () => {
const win = createWindowStub();
const scheduler = createScheduler();
let refocusCalls = 0;
const detach = attachWindowResizeFocusRestore({
win,
doc: { querySelector: () => null, hasFocus: () => true } as MinimalDocument,
refocus: () => { refocusCalls += 1; },
scheduleTimeout: scheduler.schedule,
cancelTimeout: scheduler.cancel,
});
win.emit("resize");
detach();
assert.equal((win.listeners.get("resize") ?? []).length, 0, "listener must be removed");
assert.equal(scheduler.pendingCount(), 0, "pending restore must be cancelled");
scheduler.runNext();
assert.equal(refocusCalls, 0);
});
test("a win without listener support degrades to a no-op detach", () => {
const detach = attachWindowResizeFocusRestore({
win: null,
refocus: () => {},
scheduleTimeout: () => 1,
cancelTimeout: () => {},
});
assert.doesNotThrow(() => detach());
});
@@ -1,104 +0,0 @@
import { hasOpenAppDialog } from "./focusTerminalSession";
/** Debounce so a continuous frame drag (or maximize transition) triggers one restore. */
export const WINDOW_RESIZE_RESTORE_FOCUS_DELAY_MS = 120;
export type MinimalWindow = {
addEventListener?: (type: string, listener: () => void) => unknown;
removeEventListener?: (type: string, listener: () => void) => unknown;
};
export type MinimalActiveElement = {
tagName?: string | null;
isContentEditable?: boolean;
matches?: (selector: string) => boolean;
};
export type MinimalDocument = Parameters<typeof hasOpenAppDialog>[0] & {
hasFocus?: () => boolean;
activeElement?: MinimalActiveElement | null;
};
/**
* True when keyboard focus sits in an editable app field (search box, chat
* input, SFTP filter, inline rename, ...) that is not an xterm helper
* textarea. Resize-driven restores must not yank focus away from such fields.
* Focus parked on `body`/`html` (or an xterm helper textarea) does not count
* as editing another field.
*/
export const hasActiveEditableElement = (doc: MinimalDocument | null): boolean => {
const element = doc?.activeElement;
if (!element) return false;
if (typeof element.matches === "function" && element.matches("textarea.xterm-helper-textarea")) {
return false;
}
const tagName = typeof element.tagName === "string" ? element.tagName.toLowerCase() : "";
return (
tagName === "input"
|| tagName === "textarea"
|| tagName === "select"
|| Boolean(element.isContentEditable)
);
};
type AttachWindowResizeFocusRestoreOptions = {
win?: MinimalWindow | null;
doc?: MinimalDocument | null;
refocus?: () => void;
delayMs?: number;
scheduleTimeout?: (callback: () => void, delay: number) => unknown;
cancelTimeout?: (id: unknown) => void;
};
/**
* #3647: dragging the OS window frame (or maximizing) leaves the active xterm
* helper textarea unfocused, so keystrokes do nothing until the user clicks
* back into the terminal. Attach a window `resize` listener that re-runs the
* tab-change refocus path after the resize settles.
*
* Restore is skipped when the app window does not hold OS input focus, when a
* modal dialog is open, or when an editable element other than the xterm
* helper textarea already holds focus, so the restore never steals typing
* from elsewhere.
*/
export const attachWindowResizeFocusRestore = (
{
win = typeof window !== "undefined" ? window : null,
doc = typeof document !== "undefined" ? document : null,
refocus,
delayMs = WINDOW_RESIZE_RESTORE_FOCUS_DELAY_MS,
scheduleTimeout = (callback: () => void, delay: number) => setTimeout(callback, delay),
cancelTimeout = (id: unknown) => clearTimeout(id as ReturnType<typeof setTimeout>),
}: AttachWindowResizeFocusRestoreOptions = {},
): (() => void) => {
if (!win?.addEventListener || !win.removeEventListener || typeof refocus !== "function") {
return () => {};
}
let timerId: unknown = null;
const restoreFocus = () => {
timerId = null;
if (doc?.hasFocus?.() === false) return;
if (hasOpenAppDialog(doc)) return;
// The window holds OS focus, but the user may still be typing in a
// non-terminal field (terminal search, AI chat, SFTP filter, rename).
// Never steal focus from an active editable element.
if (hasActiveEditableElement(doc)) return;
refocus();
};
const handleResize = () => {
if (timerId !== null) {
cancelTimeout(timerId);
}
timerId = scheduleTimeout(restoreFocus, delayMs);
};
win.addEventListener("resize", handleResize);
return () => {
win.removeEventListener("resize", handleResize);
if (timerId !== null) {
cancelTimeout(timerId);
timerId = null;
}
};
};
@@ -33,7 +33,7 @@ const createFakeTerm = (cols = 80) => {
onRender: (handler: (element: FakeElement) => void) => { dispose: () => void };
onDispose: (handler: Handler) => { dispose: () => void };
}> = [];
const markers: Array<{ line: number; disposed: boolean; dispose: () => void }> = [];
const markers: Array<{ line: number; disposed: boolean }> = [];
const term = {
cols,
@@ -477,84 +477,6 @@ test('CursorLineHighlighter restores after the terminal resets its decorations',
highlighter.dispose();
});
test('CursorLineHighlighter self-heals when xterm disposes its decorations without a write', () => {
const term = createFakeTerm(80);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
assert.equal(term.decorations.at(-1)?.disposed, false);
// xterm-side disposal (decoration service reset / term.reset): no cursor
// move and no write follow, only the next render frame.
term.resetDecorations();
assert.equal(term.decorations[0]?.disposed, true);
term.render();
assert.equal(term.decorations.at(-1)?.disposed, false);
assert.equal(term.decorations.at(-1)?.options.width, 80);
highlighter.dispose();
});
test('CursorLineHighlighter recreates after a buffer clear wipes markers and decorations', () => {
const term = createFakeTerm(80);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
assert.equal(term.markers.at(-1)?.disposed, false);
// term.clear() disposes every marker; decorations follow their markers.
for (const marker of term.markers) marker.dispose();
term.resetDecorations();
assert.equal(term.decorations.at(-1)?.disposed, true);
term.render();
assert.equal(term.decorations.at(-1)?.disposed, false);
assert.equal(term.markers.at(-1)?.line, 0);
highlighter.dispose();
});
test('CursorLineHighlighter does not duplicate decorations on redundant renders', () => {
const term = createFakeTerm(80);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
const aliveBefore = term.decorations.filter((decoration) => !decoration.disposed).length;
term.render();
term.render();
assert.equal(
term.decorations.filter((decoration) => !decoration.disposed).length,
aliveBefore,
);
highlighter.dispose();
});
test('CursorLineHighlighter does not churn markers when a fully colored row has no decorations', () => {
const cols = 8;
const term = createFakeTerm(cols);
// Every cell has an explicit ANSI background and the row is full, so both
// highlight ranges and the blank tail are legitimately empty.
term.setColoredBackgrounds(Array.from({ length: cols }, (_, column) => column));
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
assert.equal(term.decorations.length, 0);
assert.equal(term.markers.length, 1);
const marker = term.markers[0];
assert.ok(marker);
assert.equal(marker.disposed, false);
assert.equal(marker.line, 0);
const aliveBefore = term.markers.filter((candidate) => !candidate.disposed).length;
term.render();
term.render();
term.render();
assert.equal(term.markers.length, 1);
assert.equal(term.markers.filter((candidate) => !candidate.disposed).length, aliveBefore);
assert.equal(marker.disposed, false);
assert.equal(marker.line, 0);
assert.equal(term.decorations.length, 0);
highlighter.dispose();
});
test('CursorLineHighlighter clears in the alternate buffer and restores in normal buffer', () => {
const term = createFakeTerm(80);
const highlighter = new CursorLineHighlighter(term as never);
@@ -37,8 +37,6 @@ export class CursorLineHighlighter implements IDisposable {
private activeColor: string | null = null;
private activeRanges: HighlightRange[] = [];
private activeTailRanges: HighlightRange[] = [];
/** False once any tracked decoration has been disposed out from under us. */
private decorationsAlive = false;
private readonly disposables: IDisposable[] = [];
private pendingRefresh = false;
private disposed = false;
@@ -48,16 +46,10 @@ export class CursorLineHighlighter implements IDisposable {
this.term.onCursorMove(() => this.markPendingRefresh()),
this.term.onWriteParsed(() => this.markPendingRefresh()),
this.term.onRender(() => {
const pending = this.pendingRefresh;
this.pendingRefresh = false;
if (this.disposed || !this.enabled) return;
// Writes and cursor moves defer to render, but xterm can also dispose
// or clear our decorations out from under us (term.clear/reset,
// decoration service resets, marker reindexing) without any further
// cursor move or write. Verify the tracked set on every render so the
// highlight can self-heal at the cursor line instead of staying
// anchored to stale bookkeeping (wrong/missing line).
if (pending || !this.isTrackingIntact()) this.refresh();
if (this.pendingRefresh) {
this.pendingRefresh = false;
this.refresh();
}
}),
this.term.onSelectionChange(() => this.refresh({ force: true })),
this.term.onResize(() => this.refresh({ force: true })),
@@ -119,7 +111,6 @@ export class CursorLineHighlighter implements IDisposable {
color === this.activeColor &&
rangesEqual(ranges, this.activeRanges) &&
rangesEqual(tailRanges, this.activeTailRanges) &&
this.decorationsAlive &&
this.marker &&
!this.marker.isDisposed &&
this.marker.line === absoluteLine
@@ -169,18 +160,13 @@ export class CursorLineHighlighter implements IDisposable {
this.marker = marker;
this.decorations = decorations;
// An empty set is valid when the whole row already has ANSI backgrounds or
// inverse cells. Tracking stays intact so later frames do not churn markers.
this.decorationsAlive = true;
this.decorationDisposeListeners = decorations.map((decoration) =>
decoration.onDispose(() => {
if (this.decorations.includes(decoration)) {
this.decorationsAlive = false;
this.activeLine = null;
this.activeCols = null;
this.activeColor = null;
this.activeRanges = [];
this.activeTailRanges = [];
}
}),
);
@@ -258,7 +244,6 @@ export class CursorLineHighlighter implements IDisposable {
this.marker = null;
this.decorations = [];
this.decorationDisposeListeners = [];
this.decorationsAlive = false;
this.activeLine = null;
this.activeCols = null;
this.activeColor = null;
@@ -266,19 +251,6 @@ export class CursorLineHighlighter implements IDisposable {
this.activeTailRanges = [];
}
/**
* Cheap render-time check that the tracked marker and decorations are still
* owned by xterm. xterm disposes highlight decorations on buffer clears,
* resets, and marker reindexing without emitting cursor-move/write events,
* so a stale tracking state must be recreated before the next frame paints.
*/
private isTrackingIntact(): boolean {
if (this.marker === null || this.marker.isDisposed) {
return this.marker === null && this.decorations.length === 0;
}
return this.decorationsAlive;
}
private clearOwned(
marker: IMarker | null,
decorations: IDecoration[],
@@ -23,7 +23,6 @@ import {
type SidePanelTabRemap,
} from '../../domain/workspaceSidePanelTabRemap';
import { AI_PANEL_FORCE_HIDE_SHELL } from '../ai/aiPanelDiagnostics';
import { attachWindowResizeFocusRestore } from '../terminal/restoreWindowResizeFocus';
import { toast } from '../ui/toast';
import { getTerminalSidePanelShellWidth } from './TerminalLayerSidePanelSection';
import type { RendererCwdSource } from '../terminal/sftpCwd';
@@ -748,23 +747,6 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
refocusActiveTerminalSession();
}, [activeTabId, isTerminalLayerVisible, refocusActiveTerminalSession]);
// Restore keyboard focus after the OS window is resized or maximized.
// #3647: dragging the frame (or maximizing) leaves the active xterm helper
// textarea unfocused on Windows, so keystrokes do nothing until a click.
// Reuse the tab-change refocus path; the helper only fires once the resize
// settles and is gated on the app window actually holding input focus.
useEffect(() => {
if (!isTerminalLayerVisible) return undefined;
return attachWindowResizeFocusRestore({
win: window,
doc: document,
refocus: () => {
if (isComposeBarOpen) return;
refocusActiveTerminalSession?.();
},
});
}, [document, isComposeBarOpen, isTerminalLayerVisible, refocusActiveTerminalSession, window]);
// When focusedSessionId changes or terminal layer becomes visible,
// focus the corresponding terminal to restore :focus-within CSS state
useEffect(() => {
+2 -9
View File
@@ -536,10 +536,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
middleClickPaste: true,
wordSeparators: DEFAULT_TERMINAL_WORD_SEPARATORS,
linkModifier: 'none',
// Off by default: remote graceful channel closes can be reported as a clean
// exit (code 0), which would auto-close tabs on network drops. Users who
// want close-on-exit can enable it in Settings -> Terminal -> Behavior.
autoCloseOnExit: false,
autoCloseOnExit: true,
// Issue #3087: keep terminal history visible after an established session disconnects.
disconnectedNoticeMode: 'terminal',
keywordHighlightEnabled: true,
@@ -555,11 +552,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
verifyHostKeys: true,
keepaliveInterval: 30,
keepaliveCountMax: 10,
// On by default: unexpected transport drops (VPN / Wi-Fi blips) schedule an
// in-place reconnect every 5s with the same session id. Clean shell exits
// still close the tab, and administrative idle/timeout exits stay
// disconnected. Users on always-stable links can turn it off.
sshAutoReconnectEnabled: true,
sshAutoReconnectEnabled: false,
x11Display: '', // Empty = use DISPLAY/default local X server
moshClientPath: '', // Legacy mosh-client override; normal UI uses bundled mosh-client
showHostInfoBar: true, // Preserve the existing host information bar by default
-27
View File
@@ -313,12 +313,6 @@ export interface SyncPayload {
terminalFontFamily?: string;
terminalFontSize?: number;
terminalSettings?: Record<string, unknown>;
/**
* Present once the sender has run the autoCloseOnExit default migration.
* Legacy payloads omit it; a materialized `true` in those payloads is the
* old default, not an explicit opt-in.
*/
autoCloseOnExitDefaultMigrated?: boolean;
terminalSidePanelAutoOpen?: boolean;
terminalSidePanelAutoOpenTab?: import('./terminalSidePanelAutoOpen').TerminalSidePanelAutoOpenTab;
localShellSidePanelAutoOpen?: boolean;
@@ -396,27 +390,6 @@ export interface SyncPayload {
convergentSync?: ConvergentSyncEnvelopeV2;
}
/**
* Pre-migration snapshots stored autoCloseOnExit: true as the default and do
* not carry `autoCloseOnExitDefaultMigrated`. Rewrite that legacy true to
* false. A payload that records the migration keeps an explicit true.
*/
export function reconcileInboundAutoCloseOnExit(
autoCloseOnExit: unknown,
payloadRecordsMigratedChoice: unknown,
): unknown {
if (autoCloseOnExit === true && payloadRecordsMigratedChoice !== true) return false;
return autoCloseOnExit;
}
/** True when this side both migrated and explicitly enabled auto-close. */
export function payloadOptsIntoAutoClose(
settings: SyncPayload['settings'] | undefined,
): boolean {
return settings?.autoCloseOnExitDefaultMigrated === true
&& settings.terminalSettings?.autoCloseOnExit === true;
}
/**
* Strip device-local connection telemetry from hosts before any sync
* comparison or upload (#2629). `lastConnectedAt` updates on every successful
-41
View File
@@ -631,44 +631,3 @@ test("group notes sync edits and clearing without dropping connection settings",
const cleared = payload({ customGroups: ["Project"], groupConfigs: [{ path: "Project", username: "admin" }] });
assert.equal(mergeSyncPayloads(merged, merged, cleared).payload.groupConfigs?.[0].notes, undefined);
});
test("mergeSyncPayloads does not glue a local migration marker onto a legacy auto-close true", () => {
const merged = mergeSyncPayloads(
null,
payload({
settings: {
autoCloseOnExitDefaultMigrated: true,
terminalSettings: { autoCloseOnExit: false, scrollback: 3000 },
},
}),
payload({
settings: {
terminalSettings: { autoCloseOnExit: true, scrollback: 3000 },
},
}),
).payload.settings;
assert.equal(merged?.terminalSettings?.autoCloseOnExit, false);
assert.equal(merged?.terminalSettings?.scrollback, 3000);
});
test("mergeSyncPayloads keeps an explicit auto-close opt-in from a migrated peer", () => {
const merged = mergeSyncPayloads(
null,
payload({
settings: {
autoCloseOnExitDefaultMigrated: true,
terminalSettings: { autoCloseOnExit: false },
},
}),
payload({
settings: {
autoCloseOnExitDefaultMigrated: true,
terminalSettings: { autoCloseOnExit: true },
},
}),
).payload.settings;
assert.equal(merged?.autoCloseOnExitDefaultMigrated, true);
assert.equal(merged?.terminalSettings?.autoCloseOnExit, true);
});
-19
View File
@@ -21,7 +21,6 @@
import { carryForwardSyncDeletions, getDeletedEntityIds } from './syncReliability';
import {
payloadOptsIntoAutoClose,
sanitizeHostsForSync,
type CloudSyncPayloadEntityKey,
type SyncPayload,
@@ -404,24 +403,6 @@ function mergeSettings(
}
}
// A three-way merge can keep this device's migration flag while adopting a
// legacy remote autoCloseOnExit: true. That true is the old default unless
// one of the sides explicitly opted in after migrating.
const terminalSettings = merged.terminalSettings;
if (
terminalSettings
&& typeof terminalSettings === 'object'
&& !Array.isArray(terminalSettings)
&& (terminalSettings as Record<string, unknown>).autoCloseOnExit === true
&& !payloadOptsIntoAutoClose(local)
&& !payloadOptsIntoAutoClose(remote)
) {
merged.terminalSettings = {
...(terminalSettings as Record<string, unknown>),
autoCloseOnExit: false,
};
}
return Object.keys(merged).length > 0 ? (merged as SettingsObj) : undefined;
}
+4 -4
View File
@@ -185,16 +185,16 @@ test("normalizeTerminalSettings preserves disabled font ligatures", () => {
assert.equal(normalizeTerminalSettings({ fontLigatures: false }).fontLigatures, false);
});
test("normalizeTerminalSettings disables terminal auto-close by default", () => {
assert.equal(normalizeTerminalSettings().autoCloseOnExit, false);
test("normalizeTerminalSettings enables terminal auto-close by default", () => {
assert.equal(normalizeTerminalSettings().autoCloseOnExit, true);
});
test("normalizeTerminalSettings preserves disabled terminal auto-close", () => {
assert.equal(normalizeTerminalSettings({ autoCloseOnExit: false }).autoCloseOnExit, false);
});
test("normalizeTerminalSettings enables SSH auto reconnect by default", () => {
assert.equal(normalizeTerminalSettings().sshAutoReconnectEnabled, true);
test("normalizeTerminalSettings disables SSH auto reconnect by default", () => {
assert.equal(normalizeTerminalSettings().sshAutoReconnectEnabled, false);
});
test("normalizeTerminalSettings preserves explicit SSH auto reconnect settings", () => {
+2 -16
View File
@@ -58,25 +58,11 @@ function subscribeToPtyData(ptyStream, onData) {
throw new Error("PTY stream does not support data subscriptions");
}
// Only the end of the output can hold the idle prompt, and this check runs for
// every PTY chunk while the retained output may be hundreds of thousands of
// characters long. Normalising the whole text (ANSI and carriage returns) on
// each chunk stalls the worker during a flood, so look at a tail window that is
// comfortably larger than the prompt including the escape codes it may carry.
const PROMPT_SUFFIX_MIN_WINDOW_CHARS = 2048;
const PROMPT_SUFFIX_WINDOW_PER_PROMPT_CHAR = 8;
function hasExpectedPromptSuffix(text, expectedPrompt) {
if (!expectedPrompt) return false;
const normalizedText = stripAnsi(String(text || "")).replace(/\r/g, "");
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
if (!normalizedPrompt) return false;
const fullText = String(text || "");
const windowChars = Math.max(
PROMPT_SUFFIX_MIN_WINDOW_CHARS,
String(expectedPrompt).length * PROMPT_SUFFIX_WINDOW_PER_PROMPT_CHAR,
);
const tail = fullText.length > windowChars ? fullText.slice(-windowChars) : fullText;
return stripAnsi(tail).replace(/\r/g, "").endsWith(normalizedPrompt);
return !!normalizedPrompt && normalizedText.endsWith(normalizedPrompt);
}
function escapePosixSingleQuoted(text) {
@@ -1,52 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { hasExpectedPromptSuffix } = require("./ptyExecHelpers.cjs");
const { stripAnsi } = require("./shellUtils.cjs");
// The behaviour before the tail window was introduced: normalise the whole
// text, then compare its end.
function referenceHasExpectedPromptSuffix(text, expectedPrompt) {
if (!expectedPrompt) return false;
const normalizedText = stripAnsi(String(text || "")).replace(/\r/g, "");
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
return !!normalizedPrompt && normalizedText.endsWith(normalizedPrompt);
}
const COLORED_PROMPT = "\x1b[01;32mroot@3160T\x1b[00m:\x1b[01;34m~\x1b[00m# ";
const CASES = [
["plain prompt at the end", "output\r\nroot@3160T:~# ", "root@3160T:~# "],
["prompt not at the end", "root@3160T:~# ls\r\nfile\r\n", "root@3160T:~# "],
["colored prompt in the text, plain expected", `out\r\n${COLORED_PROMPT}`, "root@3160T:~# "],
["colored prompt on both sides", `out\r\n${COLORED_PROMPT}`, COLORED_PROMPT],
["carriage returns inside the prompt", "out\r\nroot@3160T:~\r# ", "root@3160T:~# "],
["empty text", "", "root@3160T:~# "],
["empty expected prompt", "root@3160T:~# ", ""],
["null inputs", null, null],
["text shorter than the prompt", "~# ", "root@3160T:~# "],
];
for (const [name, text, prompt] of CASES) {
test(`prompt suffix matches the full-text result: ${name}`, () => {
assert.equal(hasExpectedPromptSuffix(text, prompt), referenceHasExpectedPromptSuffix(text, prompt));
});
}
test("prompt suffix is found after a very large flood of CRLF output", () => {
const flood = "y\r\n".repeat(700_000);
assert.equal(hasExpectedPromptSuffix(`${flood}root@3160T:~# `, "root@3160T:~# "), true);
assert.equal(hasExpectedPromptSuffix(`${flood}root@3160T:~# x`, "root@3160T:~# "), false);
assert.equal(hasExpectedPromptSuffix(`${flood}${COLORED_PROMPT}`, "root@3160T:~# "), true);
});
test("prompt suffix check does not scan the whole buffer on every call", () => {
// 2M characters of CRLF output used to cost seconds per call because every
// call stripped ANSI and carriage returns over the entire text.
const flood = "y\r\n".repeat(700_000);
const started = process.hrtime.bigint();
for (let i = 0; i < 50; i += 1) hasExpectedPromptSuffix(flood, "root@3160T:~# ");
const elapsedMs = Number(process.hrtime.bigint() - started) / 1e6;
assert.ok(elapsedMs < 250, `50 checks took ${elapsedMs.toFixed(0)} ms`);
});
@@ -1,10 +1,6 @@
"use strict";
// 8h. AI/agent-opened sessions are long-lived, stateful surfaces (prod DB
// operations, deployments); a 30-minute silent close destroyed more in-flight
// work than it saved. 8h covers a workday without sitting on the 24h clamp
// maximum. Users can still set a shorter window in Settings -> AI.
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 480;
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 30;
const MIN_SESSION_IDLE_TIMEOUT_MINUTES = 1;
const MAX_SESSION_IDLE_TIMEOUT_MINUTES = 24 * 60;
@@ -1,193 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const fs = require("node:fs");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { Server } = require("ssh2");
const bridge = require("./sshBridge.cjs");
const tempDirBridge = require("./tempDirBridge.cjs");
const listen = (server, ...args) => new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(...args, resolve);
});
const close = (server) => new Promise((resolve) => server.close(resolve));
test("normal SSH startup preserves fallback after an unavailable system agent", {
skip: process.platform === "win32", // This fixture exercises Unix agent sockets.
timeout: 20_000,
}, async (t) => {
const fixture = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("a")}-`);
const originalHomedir = os.homedir;
const originalAgentSocket = process.env.SSH_AUTH_SOCK;
os.homedir = () => fixture; // Never discover the developer's default keys.
t.after(() => {
os.homedir = originalHomedir;
if (originalAgentSocket === undefined) delete process.env.SSH_AUTH_SOCK;
else process.env.SSH_AUTH_SOCK = originalAgentSocket;
fs.rmSync(fixture, { recursive: true, force: true });
});
const hostKey = crypto.generateKeyPairSync("rsa", {
modulusLength: 2048,
privateKeyEncoding: { type: "pkcs1", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
}).privateKey;
let sequence = 0;
async function run({ stale = true, useSshAgent, password = "fixture-password", stall = false, jump = false } = {}) {
const senderId = 3640 + sequence++;
const socketPath = path.join(fixture, "s");
fs.rmSync(socketPath, { force: true });
const agentClients = new Set();
let agentChecks = 0;
const agent = net.createServer((socket) => {
agentClients.add(socket);
agentChecks += 1;
socket.on("error", () => {});
socket.on("close", () => agentClients.delete(socket));
// SSH_AGENT_IDENTITIES_ANSWER with zero keys.
socket.once("data", () => socket.end(Buffer.from([0, 0, 0, 5, 12, 0, 0, 0, 0])));
});
await listen(agent, socketPath);
if (stale) {
// Preserve a real socket entry with no listener, without a timing race.
fs.renameSync(socketPath, socketPath + ".stale");
await close(agent);
fs.renameSync(socketPath + ".stale", socketPath);
assert.ok(fs.statSync(socketPath).isSocket());
}
process.env.SSH_AUTH_SOCK = socketPath;
const sshClients = new Set();
const forwardingSockets = new Set();
const methods = [];
let shells = 0;
const ssh = new Server({ hostKeys: [hostKey] }, (client) => {
sshClients.add(client);
client.on("error", () => {});
client.on("close", () => sshClients.delete(client));
client.on("tcpip", (accept, reject, info) => {
if (info.destIP !== "127.0.0.1" || info.destPort !== ssh.address().port) return reject();
const socket = net.connect(info.destPort, info.destIP, () => {
const stream = accept();
stream.on("error", () => socket.destroy());
stream.pipe(socket).pipe(stream);
});
forwardingSockets.add(socket);
socket.on("close", () => forwardingSockets.delete(socket));
socket.on("error", () => reject());
});
client.on("authentication", (ctx) => {
methods.push(ctx.method);
if (ctx.method === "password" && stall) return;
if (ctx.method === "password" && ctx.password === "fixture-password") ctx.accept();
else ctx.reject(["publickey", "password"]);
});
client.on("ready", () => client.on("session", (accept) => {
const session = accept();
session.on("pty", (acceptPty) => acceptPty());
session.on("shell", (acceptShell) => {
shells += 1;
acceptShell().write("fixture ready\r\n");
});
}));
});
await listen(ssh, 0, "127.0.0.1");
const handlers = new Map();
const sent = [];
bridge.init({ sessions: new Map(), electronModule: {} });
bridge.registerHandlers({ handle: (name, fn) => handlers.set(name, fn), on() {} });
const event = { sender: { id: senderId, isDestroyed: () => false,
send: (channel, payload) => sent.push({ channel, payload }) } };
let guard;
try {
const start = handlers.get("netcatty:start")(event, {
sessionId: `agent-fallback-${senderId}`, hostname: "127.0.0.1", port: ssh.address().port,
username: "fixture", password, authMethod: "auto", useSshAgent,
verifyHostKeys: false, reuseTransport: false, skipShellPidDiscovery: true,
sshTcpConnectTimeoutMs: 1000, sshAuthReadyTimeoutMs: 1000,
...(jump ? { jumpHosts: [{
hostname: "127.0.0.1", port: ssh.address().port, username: "fixture",
password, authMethod: "auto", useSshAgent,
sshTcpConnectTimeoutMs: 1000, sshAuthReadyTimeoutMs: 1000,
}] } : {}),
});
let error;
try {
await Promise.race([start, new Promise((_, reject) => {
guard = setTimeout(() => reject(new Error("Fixture exceeded authentication deadline")), 4000);
})]);
} catch (err) { error = err; }
return { error, methods, shells, sent: sent.slice(), agentChecks };
} finally {
clearTimeout(guard);
for (const client of sshClients) client.end();
for (const socket of forwardingSockets) socket.destroy();
for (const client of agentClients) client.destroy();
if (agent.listening) await close(agent);
await close(ssh);
}
}
for (const [name, options] of [
["stale agent", {}],
["disabled agent", { useSshAgent: false }],
["healthy empty agent", { stale: false }],
]) {
await t.test(`${name} still opens a real shell using the saved password`, async () => {
const result = await run(options);
assert.equal(result.error, undefined);
assert.deepEqual(result.methods, ["none", "password"]);
assert.equal(result.shells, 1);
assert.equal(result.agentChecks, options.stale === false ? 1 : 0);
assert.equal(result.sent.some(({ channel }) => channel === "netcatty:auth:failed"), false);
assert.equal(result.sent.some(({ channel }) => channel === "netcatty:exit"), false);
});
}
for (const [name, password, methods] of [
["agent without other credentials", undefined, ["none"]],
["incorrect password", "wrong-password", ["none", "password"]],
]) {
await t.test(`${name} still reports a real authentication failure`, async () => {
// null deliberately omits a password rather than taking run's default.
const result = await run({ password: password ?? null });
assert.equal(result.error?.level, "client-authentication");
assert.match(result.error.message, /All configured authentication methods failed/);
assert.deepEqual(result.methods, methods);
assert.equal(result.shells, 0);
assert.ok(result.sent.some(({ channel }) => channel === "netcatty:auth:failed"));
});
}
await t.test("stalled password fallback retains its authentication deadline", async () => {
const result = await run({ stall: true });
assert.match(result.error?.message || "", /Connection timeout to 127\.0\.0\.1/);
assert.deepEqual(result.methods, ["none", "password"]);
assert.equal(result.shells, 0);
assert.ok(result.sent.some(({ channel, payload }) => (
channel === "netcatty:exit" && payload.reason === "timeout"
)));
});
await t.test("a stale jump-host agent still reaches a shell through saved passwords", async () => {
const result = await run({ jump: true });
assert.equal(result.error, undefined);
assert.deepEqual(result.methods, ["none", "password", "none", "password"]);
assert.equal(result.shells, 1);
assert.equal(result.sent.some(({ channel }) => channel === "netcatty:auth:failed"), false);
});
await t.test("a fatal jump-host auth error after its agent error still rejects", async () => {
const result = await run({ jump: true, password: "wrong-password" });
assert.equal(result.error?.level, "client-authentication");
assert.equal(result.error?.isJumpHostAuthError, true);
assert.deepEqual(result.methods, ["none", "password"]);
assert.equal(result.shells, 0);
});
await t.test("a stalled jump-host password retains its authentication deadline", async () => {
const result = await run({ jump: true, stall: true });
assert.match(result.error?.message || "", /Connection timeout to 127\.0\.0\.1/);
assert.deepEqual(result.methods, ["none", "password"]);
assert.equal(result.shells, 0);
});
});
+1 -6
View File
@@ -813,12 +813,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
}
resolve();
});
conn.on('error', (err) => {
// ssh2 continues to the next auth method after agent errors.
if (err.level === 'agent') {
console.log(`[Chain] Hop ${i + 1} non-fatal agent auth error (will try next method):`, err.message);
return;
}
conn.once('error', (err) => {
if (settled) return;
settled = true;
clearAuthReadyTimer();
@@ -2415,90 +2415,7 @@ test("an exit-signal with no code does not fabricate an exit code 0", async (t)
const exits = getExitPayloads(sender);
assert.equal(exits.length, 1);
assert.equal(exits[0].reason, "signaled");
assert.equal(exits[0].exitSignal, "SIGUSR2");
assert.equal(exits[0].reason, "closed");
assert.equal(exits[0].exitCode, undefined);
assert.equal(exits[0].remoteClosedWithoutExitStatus, undefined, "the remote did report a signal");
});
test("a connection close after an exit-signal stays an administrative disconnect", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const sender = makeSender();
const options = {
hostname: "10.0.0.50",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
};
await start({ sender }, { ...options, sessionId: "signal-then-conn-close" });
const session = sessions.get("signal-then-conn-close");
// ssh2 can emit conn "close" before the channel's paced finalize. The
// signal recorded on "exit" must still win over a generic transport close.
session.stream.emit("exit", null, "SIGHUP");
session.conn.emit("close");
await waitForExitDeliveries();
const exits = getExitPayloads(sender);
assert.equal(exits.length, 1);
assert.equal(exits[0].reason, "signaled");
assert.equal(exits[0].exitSignal, "SIGHUP");
assert.equal(exits[0].exitCode, undefined);
});
test("a connection close without an exit-signal stays a transport close", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const sender = makeSender();
const options = {
hostname: "10.0.0.50",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
};
await start({ sender }, { ...options, sessionId: "transport-conn-close" });
sessions.get("transport-conn-close").conn.emit("close");
await waitForExitDeliveries();
const exits = getExitPayloads(sender);
assert.equal(exits.length, 1);
assert.equal(exits[0].reason, "closed");
assert.equal(exits[0].exitCode, undefined);
assert.equal(exits[0].remoteClosedWithoutExitStatus, true);
assert.equal(exits[0].exitSignal, undefined);
});
for (const exitCode of [0, 7]) {
test(`connection close preserves reported shell status ${exitCode} before channel finalization`, async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const sender = makeSender();
const sessionId = `status-then-conn-close-${exitCode}`;
await start({ sender }, {
hostname: "10.0.0.50", username: "alice", port: 22,
authMethod: "password", password: "secret",
useSshAgent: false, verifyHostKeys: false, sessionId,
});
const session = sessions.get(sessionId);
session.stream.emit("exit", exitCode);
session.conn.emit("close");
await waitForExitDeliveries();
const exits = getExitPayloads(sender);
assert.equal(exits.length, 1);
assert.equal(exits[0].reason, "exited");
assert.equal(exits[0].exitCode, exitCode);
assert.equal(exits[0].remoteClosedWithoutExitStatus, undefined);
});
}
@@ -1,57 +0,0 @@
/**
* Classify how a remote SSH shell channel ended.
*
* An exit-signal (ssh2: code null, signal set) is an administrative
* termination — idle kick, SIGHUP, SIGTERM — not a VPN/transport flap.
* Those stay "signaled" so auto-reconnect can leave them alone.
* A channel that closes with neither exit-status nor exit-signal stays
* "closed" and is still eligible to reconnect.
*/
function normalizeRemoteExitSignal(signal) {
return typeof signal === "string" && signal.length > 0 ? signal : null;
}
function classifyRemoteShellClose({ idleTimedOut, exitSignal, shellExited }) {
if (idleTimedOut) return "timeout";
const signal = normalizeRemoteExitSignal(exitSignal);
if (signal) return "signaled";
if (shellExited) return "exited";
return "closed";
}
function buildRemoteShellExitPayload({
sessionId,
generation,
exitCode,
exitSignal,
shellExited,
idleTimedOut,
}) {
const signal = normalizeRemoteExitSignal(exitSignal);
const reason = classifyRemoteShellClose({
idleTimedOut: idleTimedOut === true,
exitSignal: signal,
shellExited: shellExited === true,
});
const payload = {
sessionId,
reason,
_terminalSessionGeneration: generation,
};
if (typeof exitCode === "number") {
payload.exitCode = exitCode;
} else if (!signal) {
payload.remoteClosedWithoutExitStatus = true;
}
if (signal) {
payload.exitSignal = signal;
}
return payload;
}
module.exports = {
normalizeRemoteExitSignal,
classifyRemoteShellClose,
buildRemoteShellExitPayload,
};
@@ -1,73 +0,0 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
buildRemoteShellExitPayload,
classifyRemoteShellClose,
normalizeRemoteExitSignal,
} = require("./remoteShellExit.cjs");
test("normalizeRemoteExitSignal keeps a reported signal and drops empty values", () => {
assert.equal(normalizeRemoteExitSignal("SIGHUP"), "SIGHUP");
assert.equal(normalizeRemoteExitSignal("SIGUSR2"), "SIGUSR2");
assert.equal(normalizeRemoteExitSignal(""), null);
assert.equal(normalizeRemoteExitSignal(null), null);
assert.equal(normalizeRemoteExitSignal(undefined), null);
});
test("an exit-signal is an administrative signaled close, not a transport close", () => {
for (const exitSignal of ["SIGHUP", "SIGTERM", "SIGUSR2"]) {
assert.equal(
classifyRemoteShellClose({ idleTimedOut: false, exitSignal, shellExited: false }),
"signaled",
);
}
});
test("a channel close without exit-status or exit-signal stays closed", () => {
assert.equal(
classifyRemoteShellClose({ idleTimedOut: false, exitSignal: null, shellExited: false }),
"closed",
);
});
test("a numeric shell exit stays exited, and a TMOUT banner stays timeout", () => {
assert.equal(
classifyRemoteShellClose({ idleTimedOut: false, exitSignal: null, shellExited: true }),
"exited",
);
assert.equal(
classifyRemoteShellClose({ idleTimedOut: true, exitSignal: null, shellExited: true }),
"timeout",
);
});
test("an exit-signal payload reports the signal and does not invent an exit code", () => {
const payload = buildRemoteShellExitPayload({
sessionId: "signal-exit",
generation: 3,
exitSignal: "SIGUSR2",
shellExited: false,
idleTimedOut: false,
});
assert.equal(payload.reason, "signaled");
assert.equal(payload.exitSignal, "SIGUSR2");
assert.equal(payload.exitCode, undefined);
assert.equal(payload.remoteClosedWithoutExitStatus, undefined);
assert.equal(payload._terminalSessionGeneration, 3);
});
test("a bare channel-close payload stays a reconnectable transport close", () => {
const payload = buildRemoteShellExitPayload({
sessionId: "transport-close",
exitSignal: null,
shellExited: false,
idleTimedOut: false,
});
assert.equal(payload.reason, "closed");
assert.equal(payload.exitSignal, undefined);
assert.equal(payload.exitCode, undefined);
assert.equal(payload.remoteClosedWithoutExitStatus, true);
});
+1 -1
View File
@@ -1734,7 +1734,7 @@ function createSessionOpsApi(ctx) {
disks, // Array of all mounted disks
netRxSpeed, // Total network receive speed (bytes/sec)
netTxSpeed, // Total network transmit speed (bytes/sec)
latencyMs, // SSH transport ping on the existing session (ms); never a second TCP dial (#3630)
latencyMs, // TCP connection establishment latency to the SSH endpoint (ms)
netInterfaces, // Per-interface network stats
hostname,
osName,
@@ -746,46 +746,6 @@ test("getServerStats measures latency by pinging the stats connection", async ()
assert.deepEqual(pingedConns, [session.moshStatsConn]);
});
test("getServerStats does not open an unauthenticated SSH handshake (#3630)", async (t) => {
const net = require("node:net");
const { createSshPingLatencyProbe } = require("./sshPingLatency.cjs");
let accepted = 0;
const server = net.createServer((socket) => {
accepted += 1;
socket.destroy();
});
t.after(() => new Promise((resolve) => server.close(() => resolve())));
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = server.address().port;
const conn = Object.assign(fakeConn(LINUX_STATS), {
config: { keepaliveInterval: 30000 },
});
const sessions = new Map([["sid", {
type: "ssh",
conn,
hostname: "127.0.0.1",
port,
_reuseEndpoint: { hostname: "127.0.0.1", port },
}]]);
let pingTarget = null;
const measureSshPingLatency = createSshPingLatencyProbe();
const api = makeSessionOps(sessions, {
measureSshPingLatency: (target, timeoutMs) => {
pingTarget = target;
return measureSshPingLatency(target, timeoutMs);
},
});
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
await new Promise((resolve) => setTimeout(resolve, 50));
assert.equal(result.success, true);
assert.equal(pingTarget, conn);
assert.equal(result.stats.latencyMs, null);
assert.equal(accepted, 0);
});
test("getServerStats pings the companion connection for jump-host sessions", async () => {
const sessions = new Map([["sid", {
type: "mosh",
+26 -68
View File
@@ -27,10 +27,6 @@ const {
annotateMacLocalNetworkErrorMessage,
resolveFirstTcpEndpoint,
} = require("../macLocalNetworkAccess.cjs");
const {
buildRemoteShellExitPayload,
normalizeRemoteExitSignal,
} = require("./remoteShellExit.cjs");
const SSH_TCP_CONNECT_TIMEOUT_MS = 20000;
const SSH_AUTH_READY_TIMEOUT_MS = 120000;
@@ -548,15 +544,6 @@ function createStartSessionApi(ctx) {
),
});
},
// Upload progress tracks bytes the transport actually accepted instead
// of bytes queued (issue #3663): ssh2 keeps writableLength unchanged
// until one queued write fully completes but shrinks _chunk as the
// channel window opens, so summing both approximates the outstanding
// backlog while it drains. Without this, an rz progress bar freezes
// mid-file for as long as the channel window takes to flush.
getTransportPendingBytes() {
return (Number(stream.writableLength) || 0) + (stream._chunk?.length || 0);
},
interruptRemote() {
try { stream.signal?.("INT"); } catch { /* ignore */ }
},
@@ -678,8 +665,7 @@ function createStartSessionApi(ctx) {
// "close" fires whenever the channel closes (could be network drop).
// Only treat it as user-initiated exit if "exit" fired with a numeric
// code and no signal. Signal terminations (e.g. server kill, idle
// timeout) have code=null and signal set — those are not user exits
// and are reported as "signaled", not the reconnectable "closed".
// timeout) have code=null and signal set — those are not user exits.
// A third case exists: a remote that closes the channel without sending
// exit-status or exit-signal never fires "exit" at all (#3591). The
// initial values below stay null in that case and must not be reported
@@ -690,17 +676,8 @@ function createStartSessionApi(ctx) {
stream.on("exit", (code, signal) => {
log("shell exit", { sessionId, hostname: options.hostname, code, signal, reused: !!isReused });
streamExitCode = typeof code === "number" ? code : null;
streamExitSignal = normalizeRemoteExitSignal(signal);
streamExitSignal = typeof signal === "string" && signal ? signal : null;
streamExited = typeof code === "number" && !streamExitSignal;
// conn "close" can win the race against this channel's paced
// finalize. Stamp the signal so that path can still tell an
// administrative exit-signal from a transport drop.
if (streamExitSignal && sessions.get(sessionId) === session) {
session._remoteExitSignal = streamExitSignal;
}
if (streamExited && sessions.get(sessionId) === session) {
session._remoteExitCode = streamExitCode;
}
});
let closeFinalized = false;
@@ -709,7 +686,6 @@ function createStartSessionApi(ctx) {
sessionId,
hostname: options.hostname,
streamExitCode,
streamExitSignal,
streamExited,
reused: !!isReused,
transportError: sessions.get(sessionId)?._transportError,
@@ -741,20 +717,27 @@ function createStartSessionApi(ctx) {
_terminalSessionGeneration: liveSession?._terminalSessionGeneration,
});
} else {
// TMOUT auto-logout is a clean exit (numeric code, no signal) and
// is reported as "timeout" via the shell banner (#1062 / #977).
// An exit-signal is a separate administrative kick ("signaled").
// A channel close with neither exit-status nor exit-signal stays
// "closed" and must not be reported as code 0 (#3591).
// A shell TMOUT auto-logout is a clean exit (numeric code, no
// signal) — identical to a user-typed `exit` by code/signal —
// so detect it via the banner the shell prints just before
// exiting and report it as a timeout. That keeps the tab open
// for reconnect instead of auto-closing it (#1062 / #977).
const idleTimedOut = streamExited && looksLikeIdleAutoLogout(liveSession?._promptTrackTail);
const exitPayload = buildRemoteShellExitPayload({
const reason = idleTimedOut ? "timeout" : (streamExited ? "exited" : "closed");
const exitPayload = {
sessionId,
generation: liveSession?._terminalSessionGeneration,
exitCode: streamExitCode,
exitSignal: streamExitSignal,
shellExited: streamExited,
idleTimedOut,
});
reason,
_terminalSessionGeneration: liveSession?._terminalSessionGeneration,
};
// Report only exit facts the remote actually sent (#3591). When
// the peer closed the channel without exit-status/exit-signal,
// there is no code to show — flag it instead so the renderer can
// distinguish an abnormal teardown from a user-typed `exit 0`.
if (typeof streamExitCode === "number") {
exitPayload.exitCode = streamExitCode;
} else if (streamExitSignal === null) {
exitPayload.remoteClosedWithoutExitStatus = true;
}
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, exitPayload);
}
liveSession?.zmodemSentry?.cancel();
@@ -2481,16 +2464,6 @@ function createStartSessionApi(ctx) {
});
conn.on("error", (err) => {
// ssh2 reports agent failures before trying the next auth method.
// Keep the connection and its authentication deadline alive.
if (err.level === "agent") {
log("non-fatal agent auth error (will try next method)", {
sessionId,
hostname: options.hostname,
error: err.message,
});
return;
}
clearAuthReadyTimer();
// After the promise is settled, we can't reject again. But if the
// session was already established (resolved), we still need to notify
@@ -2665,28 +2638,13 @@ function createStartSessionApi(ctx) {
reason: "error",
_terminalSessionGeneration: session?._terminalSessionGeneration,
});
} else if (normalizeRemoteExitSignal(session?._remoteExitSignal)) {
// The shell already reported an exit-signal. This connection
// close is the server finishing that administrative kick, not
// a transport flap — keep it out of the reconnect loop.
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, buildRemoteShellExitPayload({
sessionId,
generation: session?._terminalSessionGeneration,
exitSignal: session._remoteExitSignal,
shellExited: false,
idleTimedOut: false,
}));
} else {
// Preserve a shell status already received before transport
// close; otherwise do not invent a successful exit code.
const shellExited = typeof session?._remoteExitCode === "number";
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, buildRemoteShellExitPayload({
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, {
sessionId,
generation: session?._terminalSessionGeneration,
exitCode: session?._remoteExitCode,
shellExited,
idleTimedOut: shellExited && looksLikeIdleAutoLogout(session?._promptTrackTail),
}));
exitCode: 0,
reason: "closed",
_terminalSessionGeneration: session?._terminalSessionGeneration,
});
}
// Use this connection's captured token so a late close from an
// old transport can't stop a newer same-sessionId stream (#916).
-3
View File
@@ -76,9 +76,6 @@ const DEFAULT_WINDOW_WIDTH = 1400;
const DEFAULT_WINDOW_HEIGHT = 900;
// Minimum window size: enough to render the expanded sidebar + a usable
// host list + the 420px host details / new-host aside panel without overflow.
// Small displays shrink this fit to their work area at window-creation time
// (see resolveWindowMinimumSize in windowManager/mainWindow.cjs, #3647);
// the minimum tracks the display the window currently occupies.
const MIN_WINDOW_WIDTH = 1100;
const MIN_WINDOW_HEIGHT = 640;
+4 -136
View File
@@ -6,104 +6,6 @@ const { attachDisplayRecovery } = require("./displayRecovery.cjs");
const TERMINAL_KEYBOARD_FOCUS = Symbol("netcattyTerminalKeyboardFocus");
// #3647: the 1100×640 layout minimum is larger than the usable workspace on
// small laptops (e.g. 1366×768 at 125% Windows scaling leaves only ~1093×574
// logical px), which made those windows impossible to fit — let alone shrink.
// Keep the layout minimum on capable screens, but shrink it to the work area
// of the display the window targets so the window can always fit with a small
// margin.
const MIN_SIZE_FIT_FACTOR = 0.9;
const MIN_WINDOW_FLOOR_WIDTH = 320;
const MIN_WINDOW_FLOOR_HEIGHT = 240;
function pickTargetDisplay(screenModule, bounds) {
if (!screenModule || typeof screenModule.getDisplayMatching !== "function") {
return screenModule?.getPrimaryDisplay?.() || null;
}
try {
return screenModule.getDisplayMatching(bounds) || screenModule.getPrimaryDisplay?.() || null;
} catch {
try {
return screenModule.getPrimaryDisplay?.() || null;
} catch {
return null;
}
}
}
function resolveWindowMinimumSize(screenModule, minWidth, minHeight, bounds) {
let effectiveWidth = minWidth;
let effectiveHeight = minHeight;
try {
// Size for the display the window actually lives on (saved position or
// current placement), falling back to the primary display.
const display = bounds
? pickTargetDisplay(screenModule, bounds)
: screenModule?.getPrimaryDisplay?.();
const workArea = display?.workArea;
if (Number.isFinite(workArea?.width) && workArea.width > 0) {
effectiveWidth = Math.min(effectiveWidth, Math.floor(workArea.width * MIN_SIZE_FIT_FACTOR));
}
if (Number.isFinite(workArea?.height) && workArea.height > 0) {
effectiveHeight = Math.min(effectiveHeight, Math.floor(workArea.height * MIN_SIZE_FIT_FACTOR));
}
} catch {
// screen unavailable (e.g. before app ready): keep the static minimums
}
return {
width: Math.max(MIN_WINDOW_FLOOR_WIDTH, effectiveWidth),
height: Math.max(MIN_WINDOW_FLOOR_HEIGHT, effectiveHeight),
};
}
// Keep the BrowserWindow minimum in sync with the display it currently lives
// on: docking, unplugging a screen, or display-scaling changes can move the
// window onto a display with a different work area.
function attachDisplayMinimumSync({ win, screen: screenModule, minWidth, minHeight }) {
if (!win || !screenModule || typeof screenModule.on !== "function") return () => {};
const applyMinimumForCurrentDisplay = () => {
try {
if (win.isDestroyed?.()) return;
const next = resolveWindowMinimumSize(screenModule, minWidth, minHeight, win.getBounds());
const [currentWidth, currentHeight] = win.getMinimumSize?.() || [];
if (next.width === currentWidth && next.height === currentHeight) return;
win.setMinimumSize(next.width, next.height);
} catch {
// Best-effort only.
}
};
const onDisplayMetricsChanged = () => applyMinimumForCurrentDisplay();
const onDisplayRemoved = () => applyMinimumForCurrentDisplay();
const onWindowMoved = () => applyMinimumForCurrentDisplay();
try {
screenModule.on("display-metrics-changed", onDisplayMetricsChanged);
screenModule.on("display-removed", onDisplayRemoved);
win.on("move", onWindowMoved);
} catch {
try {
win.removeListener?.("move", onWindowMoved);
} catch {
// ignore
}
return () => {};
}
return function detachDisplayMinimumSync() {
try {
screenModule.removeListener?.("display-metrics-changed", onDisplayMetricsChanged);
screenModule.removeListener?.("display-removed", onDisplayRemoved);
} catch {
// ignore
}
try {
win.removeListener?.("move", onWindowMoved);
} catch {
// ignore
}
};
}
function setTerminalKeyboardFocusForWindow(win, focused) {
if (!win || win.isDestroyed?.() || !win.webContents) return false;
const isFocused = focused === true;
@@ -211,24 +113,11 @@ function createMainWindowApi(ctx) {
height: DEFAULT_WINDOW_HEIGHT,
};
// Size the minimum to the display the restored window will live on
// (a saved position may target a smaller secondary display), falling
// back to the primary display when there is no usable saved position.
const savedTargetBounds = savedState && Number.isFinite(savedState.x) && Number.isFinite(savedState.y)
? { x: savedState.x, y: savedState.y, width: savedState.width, height: savedState.height }
: null;
const windowMinimumSize = resolveWindowMinimumSize(
screen,
MIN_WINDOW_WIDTH,
MIN_WINDOW_HEIGHT,
savedTargetBounds,
);
if (savedState) {
// Use saved dimensions, but clamp to the minimum so a previously
// shrunk window from an older build cannot start below the minimum.
windowBounds.width = Math.max(savedState.width, windowMinimumSize.width);
windowBounds.height = Math.max(savedState.height, windowMinimumSize.height);
windowBounds.width = Math.max(savedState.width, MIN_WINDOW_WIDTH);
windowBounds.height = Math.max(savedState.height, MIN_WINDOW_HEIGHT);
// Only use saved position if the screen is available at that location
if (typeof savedState.x === "number" && typeof savedState.y === "number") {
@@ -260,8 +149,8 @@ function createMainWindowApi(ctx) {
const win = new BrowserWindow({
...windowBounds,
minWidth: windowMinimumSize.width,
minHeight: windowMinimumSize.height,
minWidth: MIN_WINDOW_WIDTH,
minHeight: MIN_WINDOW_HEIGHT,
backgroundColor,
icon: appIcon,
show: false,
@@ -301,20 +190,6 @@ function createMainWindowApi(ctx) {
detachDisplayRecovery = null;
}
// #3647: docking / display-scaling changes can move the window onto a
// display with a different work area; keep the minimum in sync so the
// window can always fit its current display.
const detachDisplayMinimumSync = attachDisplayMinimumSync({
win,
screen,
// Pass the configured (layout) minimum as the baseline, not the
// display-fitted value resolved above: otherwise a reduced minimum
// would become a permanent ceiling and could never grow back to
// MIN_WINDOW_WIDTH × MIN_WINDOW_HEIGHT on a larger display.
minWidth: MIN_WINDOW_WIDTH,
minHeight: MIN_WINDOW_HEIGHT,
});
// Clear reference when the main window is destroyed
win.on('closed', () => {
try {
@@ -322,11 +197,6 @@ function createMainWindowApi(ctx) {
} catch {
// ignore
}
try {
detachDisplayMinimumSync?.();
} catch {
// ignore
}
try {
if (win?.webContents?.id) {
unhealthyWebContentsIds.delete(win.webContents.id);
@@ -725,8 +595,6 @@ function createMainWindowApi(ctx) {
}
module.exports = {
attachDisplayMinimumSync,
createMainWindowApi,
resolveWindowMinimumSize,
setTerminalKeyboardFocusForWindow,
};
@@ -1,194 +0,0 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { resolveWindowMinimumSize, attachDisplayMinimumSync } = require("./mainWindow.cjs");
test("uses the static layout minimums when they fit the display", () => {
const size = resolveWindowMinimumSize(
{ getPrimaryDisplay: () => ({ workArea: { width: 1920, height: 1080 } }) },
1100,
640,
);
assert.deepEqual(size, { width: 1100, height: 640 });
});
test("sizes the minimum for the display containing the target bounds", () => {
const primary = { workArea: { width: 1920, height: 1080 } };
const secondary = { workArea: { width: 1093, height: 585 } };
const size = resolveWindowMinimumSize(
{
getPrimaryDisplay: () => primary,
getDisplayMatching: (bounds) => (bounds.x > 1920 ? secondary : primary),
},
1100,
640,
{ x: 2000, y: 0, width: 1000, height: 700 },
);
assert.deepEqual(size, { width: Math.floor(1093 * 0.9), height: Math.floor(585 * 0.9) });
});
test("falls back to the primary display when the target cannot be matched", () => {
const size = resolveWindowMinimumSize(
{
getPrimaryDisplay: () => ({ workArea: { width: 1093, height: 585 } }),
getDisplayMatching: () => null,
},
1100,
640,
{ x: 0, y: 0, width: 1000, height: 700 },
);
assert.deepEqual(size, { width: Math.floor(1093 * 0.9), height: Math.floor(585 * 0.9) });
});
test("refreshes the window minimum when display metrics change", () => {
const { EventEmitter } = require("node:events");
const screenModule = new EventEmitter();
screenModule.getPrimaryDisplay = () => ({ workArea: { width: 1920, height: 1080 } });
const win = new EventEmitter();
win.isDestroyed = () => false;
win.getBounds = () => ({ x: 0, y: 0, width: 1100, height: 640 });
let minimum = { width: 1100, height: 640 };
win.getMinimumSize = () => [minimum.width, minimum.height];
win.setMinimumSize = (width, height) => { minimum = { width, height }; };
const detach = attachDisplayMinimumSync({ win, screen: screenModule, minWidth: 1100, minHeight: 640 });
try {
// Docking change shrinks the primary work area under the window.
screenModule.getPrimaryDisplay = () => ({ workArea: { width: 1093, height: 585 } });
screenModule.emit("display-metrics-changed");
assert.deepEqual(minimum, { width: Math.floor(1093 * 0.9), height: Math.floor(585 * 0.9) });
// No-op emission keeps the same minimum.
screenModule.emit("display-metrics-changed");
assert.deepEqual(minimum, { width: Math.floor(1093 * 0.9), height: Math.floor(585 * 0.9) });
} finally {
detach();
}
});
test("ignores display events after detach", () => {
const { EventEmitter } = require("node:events");
const screenModule = new EventEmitter();
const win = new EventEmitter();
win.isDestroyed = () => false;
win.getBounds = () => ({ x: 0, y: 0, width: 1100, height: 640 });
win.getMinimumSize = () => [1100, 640];
let called = 0;
win.setMinimumSize = () => { called += 1; };
const detach = attachDisplayMinimumSync({ win, screen: screenModule, minWidth: 1100, minHeight: 640 });
detach();
screenModule.emit("display-metrics-changed");
screenModule.emit("display-removed");
assert.equal(called, 0);
});
test("shrinks the minimum to fit a small primary work area (#3647)", () => {
// 1366×768 at 125% Windows scaling leaves ~1093×585 logical px of work area.
const size = resolveWindowMinimumSize(
{ getPrimaryDisplay: () => ({ workArea: { width: 1093, height: 585 } }) },
1100,
640,
);
assert.equal(size.width, Math.floor(1093 * 0.9));
assert.equal(size.height, Math.floor(585 * 0.9));
assert.ok(size.width < 1100 && size.height < 640);
});
test("never shrinks below a small usable floor", () => {
const size = resolveWindowMinimumSize(
{ getPrimaryDisplay: () => ({ workArea: { width: 300, height: 200 } }) },
1100,
640,
);
assert.deepEqual(size, { width: 320, height: 240 });
});
test("keeps the static minimums when screen is unavailable", () => {
assert.deepEqual(resolveWindowMinimumSize(null, 1100, 640), { width: 1100, height: 640 });
assert.deepEqual(resolveWindowMinimumSize({}, 1100, 640), { width: 1100, height: 640 });
assert.deepEqual(
resolveWindowMinimumSize(
{ getPrimaryDisplay: () => { throw new Error("screen not ready"); } },
1100,
640,
),
{ width: 1100, height: 640 },
);
});
test("createWindow clamps saved bounds and BrowserWindow minima to the fitted size", async () => {
const { EventEmitter } = require("node:events");
const { createMainWindowApi } = require("./mainWindow.cjs");
let constructedOptions = null;
class Window extends EventEmitter {
constructor(options) {
super();
constructedOptions = options;
this.destroyed = false;
this.webContents = Object.assign(new EventEmitter(), {
id: 71,
isDestroyed: () => this.destroyed,
setWindowOpenHandler() {},
openDevTools() {},
send() {},
});
}
close() {}
isDestroyed() { return this.destroyed; }
isMaximized() { return false; }
isFullScreen() { return false; }
getBounds() { return { width: 1000, height: 700 }; }
setBackgroundColor() {}
loadURL() { return Promise.resolve(); }
}
const api = createMainWindowApi({
mainWindow: null, electronApp: null, currentTheme: "light", isQuitting: false,
pendingWindowStateWrite: null, queuedWindowState: null, windowStateCloseRequested: false,
DEFAULT_WINDOW_WIDTH: 1400, DEFAULT_WINDOW_HEIGHT: 900, MIN_WINDOW_WIDTH: 1100, MIN_WINDOW_HEIGHT: 640,
V8_CACHE_OPTIONS: "code", THEME_COLORS: { light: {} },
unhealthyWebContentsIds: new Set(), rendererReadySeenByWebContentsId: new Set(),
__dirname, URL, require, console, setTimeout, clearTimeout,
getGlobalShortcutBridge: () => ({ handleWindowClose: () => false }),
getMainWindowCount: () => 1,
debugLog() {}, resolveFrontendBackgroundColor() { return null; },
loadWindowState() { return { width: 980, height: 560, x: 0, y: 0 }; },
getDevRendererBaseUrl: (url) => url, getWindowBoundsState() { return null; },
queueWindowStateSave() {}, saveWindowStateSync() {}, setupDeferredShow() {},
createExternalOnlyWindowOpenHandler() { return {}; }, createAppWindowOpenHandler() { return {}; },
attachOAuthLoadingOverlay() {}, registerWindowHandlers() {}, requestWindowCommandClose() {},
shouldCloseWindowFromInput() { return false; },
registerMainWindow() {}, unregisterMainWindow() {}, registerAppContentWindow() {}, unregisterAppContentWindow() {},
closeSettingsWindow() {}, hideSettingsWindow() {}, applyWindowOpacityToWindow() {},
});
await api.createWindow(
{
BrowserWindow: Window,
nativeTheme: {},
app: {},
screen: {
getPrimaryDisplay: () => ({ workArea: { width: 1093, height: 585 } }),
getAllDisplays: () => [],
},
shell: {},
ipcMain: {},
},
{
preload: "/tmp/preload.cjs",
devServerUrl: "http://localhost:5173",
isDev: true,
appIcon: null,
isMac: true,
electronDir: __dirname,
},
);
assert.ok(constructedOptions);
assert.equal(constructedOptions.minWidth, Math.floor(1093 * 0.9));
assert.equal(constructedOptions.minHeight, Math.floor(585 * 0.9));
assert.equal(constructedOptions.width, Math.max(980, Math.floor(1093 * 0.9)));
assert.equal(constructedOptions.height, Math.max(560, Math.floor(585 * 0.9)));
});
+30 -272
View File
@@ -107,11 +107,6 @@ function buildModeRestores(dir, names, removeIndices, modes) {
* Optional. When writeToRemote returns false, the upload loop awaits this
* before sending more file data (typically waitForWritableDrain(stream)).
* Callers should forward `opts.signal` so cancel can abort a blocked drain.
* @param {() => number} [opts.getTransportPendingBytes]
* Optional. Return the bytes the transport has queued but not delivered yet
* (e.g. the SSH channel backlog). Enables delivered-byte upload progress:
* while parked on backpressure the loop reports queued-minus-outstanding
* bytes so the progress indicator tracks actual delivery (issue #3663).
* @param {() => import('electron').WebContents | null} opts.getWebContents
* Returns the Electron WebContents for sending progress IPC events.
* @param {string} [opts.label]
@@ -136,11 +131,6 @@ function createZmodemSentry(opts) {
const pendingEchoes = [];
let pendingTerminalSuppression = null;
let pendingTerminalSuppressionAt = 0;
let pendingHexHeaderFragment = null;
let pendingHexHeaderFragmentTimer = null;
// 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;
function setPendingTerminalSuppression(buf) {
pendingTerminalSuppression = buf;
@@ -162,16 +152,6 @@ function createZmodemSentry(opts) {
const COOLDOWN_MS = 2000;
const ECHO_TTL_MS = 1500;
const ECHO_MAX_BYTES = 256;
// "**" + ZDLE + 'B': the start of every ZMODEM hex header.
const ZMODEM_HEX_HEADER_PREFIX = Buffer.from([0x2a, 0x2a, 0x18, 0x42]);
// 4-byte pattern + 14 hex digits + CR is the most a still-incomplete
// header can look like before the final LF (plus optional XON) arrives.
const ZMODEM_HEX_HEADER_MAX_PARTIAL = 19;
// How long a withheld partial-header fragment may sit unresolved before
// it is flushed through to the terminal anyway.
const HEX_HEADER_FRAGMENT_TTL_MS = Number.isFinite(opts.hexHeaderFragmentTtlMs)
? Math.max(1, opts.hexHeaderFragmentTtlMs)
: 1000;
/**
* 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
@@ -260,14 +240,6 @@ function createZmodemSentry(opts) {
return buf;
}
function isHexDigitByte(byte) {
return (
(byte >= 0x30 && byte <= 0x39)
|| (byte >= 0x41 && byte <= 0x46)
|| (byte >= 0x61 && byte <= 0x66)
);
}
function stripVisibleZmodemHeaders(data) {
let buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
let searchFrom = 0;
@@ -281,7 +253,12 @@ function createZmodemSentry(opts) {
let isHexHeader = true;
for (let i = 0; i < 14; i += 1) {
if (!isHexDigitByte(buf[prefixAt + 4 + i])) {
const byte = buf[prefixAt + 4 + i];
const isHexDigit =
(byte >= 0x30 && byte <= 0x39) ||
(byte >= 0x41 && byte <= 0x46) ||
(byte >= 0x61 && byte <= 0x66);
if (!isHexDigit) {
isHexHeader = false;
break;
}
@@ -297,10 +274,6 @@ function createZmodemSentry(opts) {
headerLength = 20;
if (buf[prefixAt + 20] === 0x11) {
headerLength = 21;
} else if (prefixAt + 20 === buf.length) {
// The optional XON that follows the CR/LF may arrive in the next
// chunk; strip at most one leading XON byte from it (#3651).
suppressNextTerminalXon = true;
}
}
@@ -314,119 +287,6 @@ function createZmodemSentry(opts) {
return buf;
}
function flushPendingHexHeaderFragment() {
pendingHexHeaderFragmentTimer = null;
const buf = pendingHexHeaderFragment;
pendingHexHeaderFragment = null;
if (buf?.length) onData(buf);
}
/**
* A ZMODEM hex header (the ZRQINIT/ZRINIT preamble of `sz`/`rz`) can be
* split across transport chunks. zmodem.js hands each original chunk to
* to_terminal verbatim and only complete headers can be stripped, so a
* chunk that ends in the middle of the preamble painted it as terminal
* garble (#3651). Detect the trailing bytes that may still form one and
* hold them back; the next chunk either completes the header (stripped
* above) or proves the buffer invalid. A TTL guard flushes held bytes so
* deferrals can never lose legitimate terminal output.
*
* Returns the index from which the buffer should be withheld, or -1.
*/
function findTrailingHexHeaderFragment(buf) {
if (!buf.length) return -1;
// Longest still-incomplete header tail: the 4-byte pattern, 14 hex
// digits, and a CR — i.e. everything up to the final LF (+ optional XON).
const windowStart = Math.max(0, buf.length - ZMODEM_HEX_HEADER_MAX_PARTIAL);
for (let i = windowStart; i <= buf.length - 4; i += 1) {
let matched = true;
for (let j = 0; j < 4; j += 1) {
if (buf[i + j] !== ZMODEM_HEX_HEADER_PREFIX[j]) {
matched = false;
break;
}
}
if (!matched) continue;
let digits = 0;
while (i + 4 + digits < buf.length && isHexDigitByte(buf[i + 4 + digits])) {
digits += 1;
}
// Chunk ends inside the hex digits — the header may still complete.
if (i + 4 + digits === buf.length) return i;
// Digit run ended before the 14 required digits, or overshoots them:
// not a hex header.
if (digits !== 14) continue;
// A CR (but not yet the trailing LF) may still complete the header.
if (buf.length - i <= 19) return i;
break;
}
// The "**" + ZDLE + 'B' pattern itself may be split: withhold any
// trailing bytes that are a prefix of it until the next chunk resolves.
for (let k = Math.min(3, buf.length); k >= 1; k -= 1) {
let matches = true;
for (let j = 0; j < k; j += 1) {
if (buf[buf.length - k + j] !== ZMODEM_HEX_HEADER_PREFIX[j]) {
matches = false;
break;
}
}
if (matches) return buf.length - k;
}
return -1;
}
/**
* Join any previously withheld partial-header bytes onto the new chunk so
* the suppression/stripping stages can see a complete header again. This
* must run BEFORE the stripping stages, or a header split across two
* chunks would be judged complete too late and reach the terminal.
*/
function assembleTerminalChunk(octets) {
const buf = Buffer.isBuffer(octets) ? octets : Buffer.from(octets);
if (!pendingHexHeaderFragment?.length) {
return buf;
}
if (pendingHexHeaderFragmentTimer) {
clearTimeout(pendingHexHeaderFragmentTimer);
pendingHexHeaderFragmentTimer = null;
}
const merged = Buffer.concat([pendingHexHeaderFragment, buf]);
pendingHexHeaderFragment = null;
return merged;
}
/**
* Withhold a trailing partial hex header (if any) and arm a timer that
* flushes it when no more bytes arrive to resolve it.
*/
function deferIncompleteHexHeader(data) {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
const fragmentAt = findTrailingHexHeaderFragment(buf);
if (fragmentAt === -1) return buf;
if (fragmentAt < buf.length) {
pendingHexHeaderFragment = Buffer.from(buf.subarray(fragmentAt));
if (!pendingHexHeaderFragmentTimer) {
pendingHexHeaderFragmentTimer = setTimeout(
flushPendingHexHeaderFragment,
HEX_HEADER_FRAGMENT_TTL_MS,
);
}
}
return fragmentAt ? buf.subarray(0, fragmentAt) : Buffer.alloc(0);
}
function looksLikeResidualZmodemData(data) {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
if (!buf.length) return true;
@@ -593,19 +453,8 @@ function createZmodemSentry(opts) {
const sentry = new Zmodem.Sentry({
to_terminal(octets) {
// Normal data – pass raw bytes to the caller for charset-aware decoding.
// Bytes that may be the start of a ZMODEM hex header split across
// chunks are withheld first (and later stripped or flushed) so the
// preamble of sz/rz never reaches the terminal as garble (#3651).
let sanitizedOctets = assembleTerminalChunk(octets);
if (suppressNextTerminalXon) {
suppressNextTerminalXon = false;
if (sanitizedOctets[0] === 0x11) {
sanitizedOctets = sanitizedOctets.subarray(1);
}
}
sanitizedOctets = stripPendingTerminalSuppression(sanitizedOctets);
let sanitizedOctets = stripPendingTerminalSuppression(Buffer.from(octets));
sanitizedOctets = stripVisibleZmodemHeaders(sanitizedOctets);
sanitizedOctets = deferIncompleteHexHeader(sanitizedOctets);
if (!sanitizedOctets.length) return;
onData(sanitizedOctets);
},
@@ -917,8 +766,6 @@ const UPLOAD_DRAIN_TIMEOUT_MS = 60000;
const UPLOAD_CHUNK_SIZE = 64 * 1024;
/** Default interval between non-final upload progress IPC events. */
const DEFAULT_UPLOAD_PROGRESS_THROTTLE_MS = 100;
/** Interval between delivered-byte progress reports while parked on drain. */
const UPLOAD_DRAIN_PROGRESS_INTERVAL_MS = 100;
function resolveTimeoutMs(value, fallback) {
return Number.isFinite(value) && value >= 0 ? value : fallback;
@@ -1134,76 +981,6 @@ function createZmodemUploadDrainWaiter(opts) {
};
}
/**
* Park the upload loop on transport backpressure while keeping progress
* updates alive (issue #3663).
*
* Once a large write backlog fills the transport, the upload loop otherwise
* sits silent on the "drain" event while the transport keeps delivering the
* queued backlog at full link speed: on SSH the entire channel window drains
* between two upload-loop steps, so the progress indicator freezes mid-file
* and jumps when the loop resumes. While the drain wait stays pending, poll
* it and let the caller emit progress events showing bytes the transport has
* actually accepted instead.
*
* Falls back to a plain drain wait when the transport cannot report its
* outstanding byte count. Rejections (timeout, cancellation) propagate.
*
* @param {{
* waitForDrain?: () => Promise<void>,
* getTransportPendingBytes?: () => unknown,
* progressThrottleMs?: number,
* }} opts
* @param {() => void} emitProgress Called at most once per throttle interval
* while the drain wait stays pending.
* @returns {Promise<void>}
*/
async function awaitUploadDrainWithProgress(opts, emitProgress) {
if (!opts.waitForDrain) return;
if (typeof opts.getTransportPendingBytes !== "function") {
await opts.waitForDrain();
return;
}
let settled = false;
const drain = opts.waitForDrain().finally(() => {
settled = true;
});
drain.catch(() => {}); // observed at the final await; never surface early
// A drain wait without pending backpressure resolves after one event-loop
// turn; yield once so backpressure-free uploads do not gain polling delay.
await new Promise((resolve) => setImmediate(resolve));
if (settled) {
await drain;
return;
}
const intervalMs = Math.max(
UPLOAD_DRAIN_PROGRESS_INTERVAL_MS,
resolveProgressThrottleMs(opts.progressThrottleMs),
);
// Parked: report accepted bytes right away, then once per interval while
// the transport drains its backlog. Progress emissions must never break
// (or slow) the transfer itself.
try { emitProgress(); } catch { /* ignore */ }
// Race each poll delay against drain. A wait that settles mid-interval
// must resume the upload immediately; sleeping out the rest of the
// interval throttles every backpressure cycle by up to ~100ms.
while (!settled) {
let timer;
await Promise.race([
drain.then(() => {}, () => {}),
new Promise((resolve) => {
timer = setTimeout(resolve, intervalMs);
}),
]);
clearTimeout(timer);
if (settled) break;
try { emitProgress(); } catch { /* ignore */ }
}
await drain;
}
/**
* Resolve when `promise` settles, or reject with NETCATTY_ZMODEM_CANCELLED
* if `signal` aborts first. Used so cancel unblocks upload drain waits even
@@ -1406,42 +1183,19 @@ async function handleUpload(zsession, opts) {
const { originalIndex, filePath, stat, name } = offers[i];
opts.resetUploadBackpressure?.();
safeSend(getWebContents(), "netcatty:zmodem:progress", {
sessionId,
filename: name,
transferred: 0,
total: stat.size,
fileIndex: i,
fileCount: offers.length,
transferType: "upload",
});
let bytesRemaining = 0;
for (let j = i; j < offers.length; j++) bytesRemaining += offers[j].stat.size;
// Progress should track bytes the transport accepted, not just bytes
// queued (#3663): on SSH the channel can buffer its whole window before
// write() returns false, so queued-only reporting freezes the progress
// bar while the backlog drains at link speed. When the transport can
// report outstanding bytes, show `sent - outstanding` and never move
// the bar backwards.
const getOutstandingBytes = typeof opts.getTransportPendingBytes === "function"
? opts.getTransportPendingBytes
: null;
let sent = 0;
let displayedTransferred = 0;
const emitFileProgress = () => {
const outstanding = getOutstandingBytes ? Number(getOutstandingBytes()) : NaN;
const delivered = Number.isFinite(outstanding)
? sent - Math.max(outstanding, 0)
: sent;
displayedTransferred = Math.min(
Math.max(delivered, displayedTransferred),
stat.size,
);
safeSend(getWebContents(), "netcatty:zmodem:progress", {
sessionId,
filename: name,
transferred: displayedTransferred,
total: stat.size,
fileIndex: i,
fileCount: offers.length,
transferType: "upload",
});
};
emitFileProgress();
// The offer handshake is the only upload step without a built-in
// deadline: if rz dies before answering ZFILE (crash, YMODEM fallback),
// send_offer() would park this loop forever and block all future
@@ -1469,6 +1223,7 @@ async function handleUpload(zsession, opts) {
// Read and send in chunks
const fd = fs.openSync(filePath, "r");
const buf = Buffer.alloc(UPLOAD_CHUNK_SIZE);
let sent = 0;
// Progress IPC is throttled by default for every transport. Pass 0 only
// when a caller explicitly needs one event per chunk.
const progressThrottleMs = resolveProgressThrottleMs(opts.progressThrottleMs);
@@ -1488,16 +1243,21 @@ async function handleUpload(zsession, opts) {
const now = Date.now();
if (progressThrottleMs === 0 || now - lastProgressEmitAt >= progressThrottleMs) {
emitFileProgress();
safeSend(getWebContents(), "netcatty:zmodem:progress", {
sessionId,
filename: name,
transferred: sent,
total: stat.size,
fileIndex: i,
fileCount: offers.length,
transferType: "upload",
});
lastProgressEmitAt = now;
}
// Wait for transport to drain if its buffer is full (emitting
// delivered-byte progress while parked, #3663), then yield so
// inbound ZMODEM control frames can be processed.
if (opts.waitForDrain) {
await awaitUploadDrainWithProgress(opts, emitFileProgress);
}
// Wait for transport to drain if its buffer is full, then yield
// so inbound ZMODEM control frames can be processed.
if (opts.waitForDrain) await opts.waitForDrain();
await yieldToIO();
}
// All data written to Node.js buffer — but TCP may still be
@@ -1782,8 +1542,6 @@ module.exports = {
handleDownload,
waitForWritableDrain,
createZmodemUploadDrainWaiter,
awaitUploadDrainWithProgress,
UPLOAD_CHUNK_SIZE,
UPLOAD_DRAIN_TIMEOUT_MS,
UPLOAD_DRAIN_PROGRESS_INTERVAL_MS,
};
-406
View File
@@ -11,7 +11,6 @@ const {
handleDownload,
waitForWritableDrain,
createZmodemUploadDrainWaiter,
awaitUploadDrainWithProgress,
UPLOAD_CHUNK_SIZE,
UPLOAD_DRAIN_TIMEOUT_MS,
} = require("./zmodemHelper.cjs");
@@ -423,282 +422,6 @@ test("handleUpload does not read the next chunk until transport backpressure cle
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("handleUpload reports transport-accepted bytes instead of the queued backlog", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-delivered-"));
const filePath = path.join(tempDir, "upload.bin");
const size = UPLOAD_CHUNK_SIZE * 2;
fs.writeFileSync(filePath, Buffer.alloc(size, 0x5a));
const progress = [];
const zsession = {
async send_offer() {
return {
send() {},
async end() {},
};
},
async close() {},
};
await handleUpload(zsession, {
sessionId: "session-1",
getWebContents: () => ({
isDestroyed: () => false,
send(channel, data) {
if (channel === "netcatty:zmodem:progress") progress.push(data);
},
}),
takeDragDropUpload: () => ({
filePaths: [filePath],
remoteNames: ["upload.bin"],
}),
progressThrottleMs: 0,
// 4096 bytes of every queued amount are still outstanding in the transport.
getTransportPendingBytes: () => 4096,
});
assert.deepEqual(
progress.map((event) => [event.transferred, Boolean(event.finalizing)]),
[
[0, false],
[UPLOAD_CHUNK_SIZE - 4096, false],
[size - 4096, false],
[size, true],
],
);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("handleUpload emits delivered-byte progress while parked on transport drain", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-park-"));
const filePath = path.join(tempDir, "upload.bin");
const size = UPLOAD_CHUNK_SIZE * 2 + 1;
fs.writeFileSync(filePath, Buffer.alloc(size, 0x5a));
const progress = [];
// Bytes queued into the transport (never consumed; delivery is simulated
// below on the pending-byte readout).
let outstanding = 0;
let drainStartedAt = null;
let releaseFirstDrain;
const firstDrain = new Promise((resolve) => {
releaseFirstDrain = resolve;
});
let drainCalls = 0;
const zsession = {
async send_offer() {
return {
send(chunk) {
outstanding += chunk.byteLength;
},
async end() {},
};
},
async close() {},
};
const upload = handleUpload(zsession, {
sessionId: "session-1",
getWebContents: () => ({
isDestroyed: () => false,
send(channel, data) {
if (channel === "netcatty:zmodem:progress") progress.push(data);
},
}),
takeDragDropUpload: () => ({
filePaths: [filePath],
remoteNames: ["upload.bin"],
}),
progressThrottleMs: 0,
getTransportPendingBytes: () => {
if (drainStartedAt === null) return outstanding;
// The backlog drains at 512 B/ms once the parked wait begins.
const delivered = Math.min(outstanding, (Date.now() - drainStartedAt) * 512);
return outstanding - delivered;
},
async waitForDrain() {
drainCalls += 1;
if (drainCalls === 1) {
drainStartedAt = Date.now();
await firstDrain;
}
},
});
while (drainCalls === 0) {
await new Promise((resolve) => setImmediate(resolve));
}
const progressedBefore = progress.length;
await new Promise((resolve) => setTimeout(resolve, 250));
const parkedEvents = progress.slice(progressedBefore);
releaseFirstDrain();
await upload;
// While parked, delivered-byte progress must keep flowing (previously the
// indicator stayed frozen until the whole backlog drained).
assert.ok(
parkedEvents.some((event) => event.transferred > 0 && !event.finalizing),
`expected delivered-byte emissions while parked, got ${JSON.stringify(parkedEvents.map((e) => [e.transferred, Boolean(e.finalizing)]))}`,
);
const values = progress.map((event) => event.transferred);
for (let k = 1; k < values.length; k++) {
assert.ok(
values[k] >= values[k - 1],
`progress must never move backwards: ${values.join(",")}`,
);
assert.ok(values[k] <= size, `progress must not exceed the file size: ${values.join(",")}`);
}
assert.equal(progress.at(-1).finalizing, true);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("awaitUploadDrainWithProgress falls back to a plain drain wait without pending-byte accounting", async () => {
let drainCalls = 0;
await awaitUploadDrainWithProgress(
{
waitForDrain: () => {
drainCalls += 1;
return Promise.resolve();
},
},
() => {
throw new Error("no progress emissions without pending-byte accounting");
},
);
assert.equal(drainCalls, 1);
});
test("awaitUploadDrainWithProgress adds no polling delay when drain resolves immediately", async () => {
const startedAt = Date.now();
await awaitUploadDrainWithProgress(
{
waitForDrain: () => Promise.resolve(),
getTransportPendingBytes: () => 0,
},
() => {
throw new Error("no emissions without parked backpressure");
},
);
assert.ok(Date.now() - startedAt < 50);
});
test("awaitUploadDrainWithProgress returns promptly when drain settles during the poll interval", async () => {
let releaseDrain;
const drainGate = new Promise((resolve) => {
releaseDrain = resolve;
});
let parked = false;
const done = awaitUploadDrainWithProgress(
{
waitForDrain: () => drainGate,
getTransportPendingBytes: () => 2048,
},
() => {
parked = true;
},
);
const waitStarted = Date.now();
while (!parked) {
if (Date.now() - waitStarted > 1000) {
throw new Error("upload drain wait never parked");
}
await new Promise((resolve) => setImmediate(resolve));
}
// The immediate park emission has run and the poll timer (~100ms) is armed.
// Settle on the next turn, still inside that interval.
await new Promise((resolve) => setImmediate(resolve));
const releasedAt = Date.now();
releaseDrain();
await done;
const elapsed = Date.now() - releasedAt;
assert.ok(
elapsed < 50,
`drain settlement during a poll interval resumed in ${elapsed}ms`,
);
});
test("awaitUploadDrainWithProgress propagates a rejection that arrives during the poll interval", async () => {
let rejectDrain;
const drainGate = new Promise((_, reject) => {
rejectDrain = reject;
});
const err = new Error("Transport drain timeout");
err.code = "NETCATTY_ZMODEM_TIMEOUT";
let parked = false;
const done = awaitUploadDrainWithProgress(
{
waitForDrain: () => drainGate,
getTransportPendingBytes: () => 2048,
},
() => {
parked = true;
},
);
const waitStarted = Date.now();
while (!parked) {
if (Date.now() - waitStarted > 1000) {
throw new Error("upload drain wait never parked");
}
await new Promise((resolve) => setImmediate(resolve));
}
await new Promise((resolve) => setImmediate(resolve));
const releasedAt = Date.now();
rejectDrain(err);
await assert.rejects(done, (caught) => caught === err);
const elapsed = Date.now() - releasedAt;
assert.ok(
elapsed < 50,
`drain rejection during a poll interval resumed in ${elapsed}ms`,
);
});
test("awaitUploadDrainWithProgress emits progress while parked until drain settles", async () => {
let releaseDrain;
const drainGate = new Promise((resolve) => {
releaseDrain = resolve;
});
let displayed = 0;
const emitted = [];
const deliverTimer = setInterval(() => {
displayed += 512;
}, 40);
try {
const parked = awaitUploadDrainWithProgress(
{
waitForDrain: () => drainGate,
getTransportPendingBytes: () => 1024,
},
() => emitted.push(displayed),
);
await new Promise((resolve) => setTimeout(resolve, 280));
assert.ok(emitted.length >= 2, `expected poll emissions, got ${emitted.length}`);
assert.ok(emitted.every((v) => v === 0 || v % 512 === 0));
releaseDrain();
await parked;
} finally {
clearInterval(deliverTimer);
}
});
test("awaitUploadDrainWithProgress propagates drain-wait rejections", async () => {
const err = new Error("Transport drain timeout");
err.code = "NETCATTY_ZMODEM_TIMEOUT";
await assert.rejects(
awaitUploadDrainWithProgress(
{
waitForDrain: () => Promise.reject(err),
getTransportPendingBytes: () => 0,
},
() => {},
),
(caught) => caught === err,
);
});
test("handleUpload throttles progress IPC by default for every transport", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-progress-"));
const filePath = path.join(tempDir, "large-upload.bin");
@@ -1766,132 +1489,3 @@ test("createZmodemUploadDrainWaiter falls back to a single yield without transpo
await waitForDrain();
assert.equal(needsDrain, false);
});
// ---------------------------------------------------------------------------
// Split hex-header preamble (#3651): the ZRQINIT/ZRINIT hex header that opens
// every sz/rz session can be fragmented across transport chunks; it must
// never reach the terminal as garble, and withheld bytes must never be lost.
// ---------------------------------------------------------------------------
const ZmodemLib = require("zmodem.js");
const DOWNLOAD_PREAMBLE_TEXT = "sz: Sending test.bin\r\nrz\r";
function buildHexHeaderBytes(name, args) {
return Buffer.from(ZmodemLib.Header.build(name, ...(args || [])).to_hex());
}
function createProbeSentry(extra) {
const terminal = [];
const sentry = createZmodemSentry({
sessionId: "probe",
onData: (buf) => terminal.push(Buffer.from(buf)),
writeToRemote: () => true,
getWebContents: () => null,
selectDownloadDirectory: async () => ({ canceled: true }),
selectUploadFiles: async () => null,
hexHeaderFragmentTtlMs: 30,
...extra,
});
return { sentry, terminal };
}
function joinedText(terminal) {
return Buffer.concat(terminal).toString("latin1");
}
function assertNoZmodemHeaderBytes(terminal, context) {
const joined = Buffer.concat(terminal);
assert.equal(
joined.indexOf(Buffer.from([0x2a, 0x2a, 0x18, 0x42])),
-1,
`${context}: ZRQINIT/ZRINIT preamble leaked to the terminal`,
);
assert.equal(
joined.includes(Buffer.from([0x18])),
false,
`${context}: ZDLE/CAN byte leaked to the terminal`,
);
}
test("fragmented sz ZRQINIT preamble never reaches the terminal", () => {
const { sentry, terminal } = createProbeSentry();
const stream = Buffer.concat([
Buffer.from(DOWNLOAD_PREAMBLE_TEXT),
buildHexHeaderBytes("ZRQINIT"),
Buffer.from("$ "),
]);
for (const byte of stream) sentry.consume(Buffer.from([byte]));
assert.equal(
joinedText(terminal),
`${DOWNLOAD_PREAMBLE_TEXT}$ `,
"only the human-readable sz preamble text should be shown",
);
assertNoZmodemHeaderBytes(terminal, "byte-at-a-time");
});
test("split sz preamble leaks nothing at any chunk boundary", () => {
const header = buildHexHeaderBytes("ZRQINIT");
// Header.build().to_hex() already includes the trailing XON byte.
const stream = Buffer.concat([
Buffer.from(DOWNLOAD_PREAMBLE_TEXT),
header,
Buffer.from("$ "),
]);
for (let chunkSize = 1; chunkSize <= 24; chunkSize += 1) {
const { sentry, terminal } = createProbeSentry();
for (let i = 0; i < stream.length; i += chunkSize) {
sentry.consume(stream.subarray(i, i + chunkSize));
}
assert.equal(joinedText(terminal), `${DOWNLOAD_PREAMBLE_TEXT}$ `);
assertNoZmodemHeaderBytes(terminal, `chunkSize=${chunkSize}`);
}
});
test("fragmented rz ZRINIT preamble never reaches the terminal", () => {
const header = buildHexHeaderBytes("ZRINIT", [["CANFDX", "CANOVIO", "CANFC32"]]);
const stream = Buffer.concat([Buffer.from("root@host:~$ "), header]);
for (let chunkSize = 1; chunkSize <= 24; chunkSize += 1) {
const { sentry, terminal } = createProbeSentry();
for (let i = 0; i < stream.length; i += chunkSize) {
sentry.consume(stream.subarray(i, i + chunkSize));
}
assert.equal(
joinedText(terminal),
"root@host:~$ ",
`split ZRINIT preamble leaked for chunkSize=${chunkSize}`,
);
assertNoZmodemHeaderBytes(terminal, `rz chunkSize=${chunkSize}`);
}
});
test("bytes withheld as a header fragment are flushed after the TTL", async () => {
const { sentry, terminal } = createProbeSentry();
const stream = Buffer.from("hello *");
for (const byte of stream) sentry.consume(Buffer.from([byte]));
// The trailing "*" may be the start of a hex header, so it is withheld.
assert.equal(joinedText(terminal), "hello ");
await new Promise((resolve) => setTimeout(resolve, 80));
assert.equal(joinedText(terminal), "hello *", "TTL flush must not lose terminal output");
});
test("output that disproves a header fragment is emitted without delay", () => {
const stream = Buffer.from("text **\x18Bxy more");
for (let chunkSize = 1; chunkSize <= 6; chunkSize += 1) {
const { sentry, terminal } = createProbeSentry();
for (let i = 0; i < stream.length; i += chunkSize) {
sentry.consume(stream.subarray(i, i + chunkSize));
}
assert.equal(
joinedText(terminal),
"text **\x18Bxy more",
"a non-hex byte after the pattern stops the deferral",
);
}
});
-2
View File
@@ -24,8 +24,6 @@ export const STORAGE_KEY_TERM_THEME_LIGHT = 'netcatty_term_theme_light_v1';
export const STORAGE_KEY_TERM_FONT_FAMILY = 'netcatty_term_font_family_v1';
export const STORAGE_KEY_TERM_FONT_SIZE = 'netcatty_term_font_size_v1';
export const STORAGE_KEY_TERM_SETTINGS = 'netcatty_term_settings_v1';
/** One-time marker: pre-#3619 builds materialized autoCloseOnExit: true into saved settings. */
export const STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED = 'netcatty_term_auto_close_exit_migrated_v1';
export const STORAGE_KEY_HOTKEY_SCHEME = 'netcatty_hotkey_scheme_v1';
export const STORAGE_KEY_CUSTOM_KEY_BINDINGS = 'netcatty_custom_key_bindings_v1';
export const STORAGE_KEY_HOTKEY_RECORDING = 'netcatty_hotkey_recording_v1';
+12 -16
View File
@@ -2,7 +2,6 @@ import type { UploadBridge } from "./uploadService.types";
export class UploadController {
private cancelled = false;
private cancelledFileIds = new Set<string>();
private activeFileTransferIds = new Set<string>();
private activeCompressionIds = new Set<string>();
private currentTransferId = "";
@@ -48,7 +47,16 @@ export class UploadController {
// Cancel all active file uploads
const activeIds = Array.from(this.activeFileTransferIds);
for (const transferId of activeIds) {
await this.cancelFile(transferId);
try {
if (this.bridge?.cancelStagedUploadFile) {
await this.bridge.cancelStagedUploadFile(transferId);
}
if (this.bridge?.cancelTransfer) {
await this.bridge.cancelTransfer(transferId);
}
} catch {
// Ignore cancel errors
}
}
// Also cancel current one if not in the set
@@ -63,22 +71,11 @@ export class UploadController {
}
}
/** Cancel one standalone file without stopping the shared upload queue. */
async cancelFile(transferId: string): Promise<void> {
this.cancelledFileIds.add(transferId);
try {
await this.bridge?.cancelStagedUploadFile?.(transferId);
await this.bridge?.cancelTransfer?.(transferId);
} catch {
// The transfer runtime reconciles backend cancellation failures.
}
}
/**
* Check if upload was cancelled
*/
isCancelled(fileId?: string): boolean {
return this.cancelled || (!!fileId && this.cancelledFileIds.has(fileId));
isCancelled(): boolean {
return this.cancelled;
}
/**
@@ -101,7 +98,6 @@ export class UploadController {
*/
reset(): void {
this.cancelled = false;
this.cancelledFileIds.clear();
this.cancelListeners.clear();
this.activeFileTransferIds.clear();
this.activeCompressionIds.clear();
+2 -8
View File
@@ -769,7 +769,6 @@ async function uploadEntries(
standaloneTransferId: string,
fileTotalBytes: number,
): Promise<{ cancelled?: boolean; error?: string }> => {
if (controller?.isCancelled(standaloneTransferId)) return { cancelled: true };
let localFilePath = getDropEntryLocalPath(entry);
let ownedTempPath: string | undefined;
@@ -785,13 +784,13 @@ async function uploadEntries(
ownedTempPath = await bridge.stageUploadFile(entry.file, standaloneTransferId);
} catch (error) {
controller?.removeActiveTransfer(standaloneTransferId);
if (controller?.isCancelled(standaloneTransferId) || /cancel/i.test(error instanceof Error ? error.message : String(error))) {
if (controller?.isCancelled() || /cancel/i.test(error instanceof Error ? error.message : String(error))) {
return { cancelled: true };
}
throw error;
}
localFilePath = ownedTempPath;
if (controller?.isCancelled(standaloneTransferId)) {
if (controller?.isCancelled()) {
await bridge.deleteTempFile(ownedTempPath).catch(() => {});
return { cancelled: true };
}
@@ -957,11 +956,6 @@ async function uploadEntries(
);
if (uploadResult.cancelled) {
if (!bundleTaskId && !controller?.isCancelled()) {
settleFileTask(standaloneTransferId, lifecycleManaged, (taskId) => callbacks?.onTaskCancelled?.(taskId));
results.push({ fileName: entry.relativePath, success: false, cancelled: true });
continue;
}
wasCancelled = true;
settleFileTask(bundledChildTaskId, lifecycleManaged, (taskId) => callbacks?.onTaskCancelled?.(taskId));
settleTask(bundleTaskId ?? "", (taskId) => callbacks?.onTaskCancelled?.(taskId));
+2 -2
View File
@@ -318,7 +318,7 @@ declare global {
}>;
netRxSpeed: number; // Total network receive speed (bytes/sec)
netTxSpeed: number; // Total network transmit speed (bytes/sec)
latencyMs: number | null; // SSH transport ping on the existing session, not a second TCP dial
latencyMs: number | null; // TCP connection establishment latency to the SSH endpoint
netInterfaces: Array<{ // Per-interface network stats
name: string; // Interface name (e.g., eth0, ens33)
rxBytes: number; // Total received bytes
@@ -531,7 +531,7 @@ declare global {
): () => void;
onSessionExit(
sessionId: string,
cb: (evt: { exitCode?: number; signal?: number; exitSignal?: string; error?: string; reason?: "exited" | "error" | "timeout" | "closed" | "signaled"; remoteClosedWithoutExitStatus?: boolean }) => void
cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void
): () => void;
onTelnetAutoLoginComplete?(
sessionId: string,