Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 b1aa6ec266 fix(terminal): keep flow-pause leases on skipped epoch closes
Worker close:await previously cleared pause ownership in finally even when
the worker returned skipped for an epoch mismatch, dropping the live
replacement's lease. Only clear on non-skipped closes; epoch-scoped
fire-and-forget close similarly defers to onSessionClosed.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 07:27:38 +00:00
Cursor Agentand陈大猫 2de2cc071c merge(main): resolve sshBridge start wrapper conflict
Keep pending-boot passphrase AbortSignal wiring while adopting main's
macOS local-network probe result attachment on SSH start.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 07:17:15 +00:00
Cursor Agentand陈大猫 51d8c253f9 fix(terminal): update disconnect assertion and cancel pending serial opens
Align the disconnect source regression with invalidateBootEpochForClose, and
abort in-flight serialPort.open attempts via pending boot AbortSignal so
Disconnect→Reconnect cannot leave a stale open racing the replacement.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 07:09:55 +00:00
Cursor Agentand陈大猫 c466228ff4 fix(terminal): tag Telnet auto-login and Mosh ready with bootEpoch
Emit bootEpoch on telnet auto-login and mosh:ready events so overlapping
Disconnect→Reconnect boots ignore cross-delivered callbacks meant for an
older shared sessionId.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 07:02:26 +00:00
Cursor Agentand陈大猫 c6ea8c8605 fix(terminal): close owned boot epoch and scope passphrase cancels
Capture the pre-bump epoch for disconnect/teardown closeSession so live
transports are not skipped by epoch mismatch, cancel only passphrase prompts
at or below that epoch, and revalidate boot currency after async passphrase
lookups before queuing a modal.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 06:54:45 +00:00
Cursor Agentand陈大猫 cee6ae9477 fix(terminal): cancel SSH passphrase prompts on disconnect
Register a pending-boot AbortSignal for SSH starts, abort it (and session-
scoped passphrase requests) from closeSession even before a registry slot
exists, and filter/drop late passphrase modals by boot epoch / disconnect.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 06:48:41 +00:00
Cursor Agentand陈大猫 50896db011 fix(terminal): await plugin cancel cleanup and clear boot epochs on teardown
Track plugin start/cancel promises so Disconnect→Reconnect waits until the
external session finishes unregistering, and stop unmount teardown from
republishing boot epochs into the process-wide map.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 06:36:19 +00:00
Cursor Agentand陈大猫 645d933b63 fix(terminal): bind reused SSH shellPid only to the claimed session
Late PID discovery after Copy Tab reuse must not write onto a newer
bootEpoch that already replaced the shared sessionId registry slot.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 06:24:53 +00:00
Cursor Agentand陈大猫 ba5d608447 fix(terminal): keep woken runtime when disconnect cancels hibernate wake
Disconnect invalidates in-flight hibernated reconnects with keep mode so a
successful wake that already cleared hibernatedRef is not disposed into a
dead pane that can never reconnect. Unmount still disposes orphan runtimes.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 06:16:54 +00:00
Cursor Agentand陈大猫 e67306c9f7 fix(terminal): settle superseded boots and stop displaced log streams
Reject Telnet/SSH claim failures through Promise reject/failReuse instead of
uncaught EventEmitter/.then throws, and stop a displaced session's log stream
so a logging-disabled reconnect cannot append into the old file.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 06:08:59 +00:00
Cursor Agentand陈大猫 66a433345e fix(terminal): clean rejected Mosh starts and keep replacement listeners
Kill superseded Mosh/ET PTYs and auth temp files when claimSessionSlot
rejects, and never clear shared preload listeners on epoch-scoped closes.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 05:50:12 +00:00
Cursor Agentand陈大猫 9900634935 fix(terminal): protect preload listeners and ET artifacts on displace
Defer epoch-scoped closeSession listener teardown until main confirms
ownership, and remove ET external-auth temp artifacts when a newer boot
displaces the previous registry owner.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 05:37:04 +00:00
Cursor Agentand陈大猫 876c436521 fix(terminal): claim bootEpoch for all reconnectable backends
Pass bootEpoch through Telnet/Mosh/ET/serial starts, claim registry slots
the same way as SSH/local, and dispose displaced owners when a newer
boot replaces the shared sessionId.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 05:29:12 +00:00
Cursor Agentand陈大猫 6a76d7aead fix(terminal): own shared sessionIds by boot epoch
Claim SSH/local registry slots with bootEpoch so a stale start cannot
overwrite a replacement, and make closeSession no-op when the payload
epoch does not match the live session owner.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 05:21:38 +00:00
Cursor Agentand陈大猫 57c60dc997 fix(terminal): keep replacement SSH session alive across stale starts
Skip closeSession for superseded starts while a newer boot still owns
the shared sessionId, and avoid clearing MFA wait state before the
attempt-freshness guard on SSH success.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 05:12:51 +00:00
Cursor Agentand陈大猫 53bd9be177 fix(terminal): correlate MFA and jump host-keys with boot epoch
Forward bootEpoch through jump-host verification and keyboard-interactive
prompts, and reject superseded MFA when the retained tab has already
started a replacement reconnect.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 05:06:45 +00:00
Cursor Agentand陈大猫 d120a6810a fix(terminal): guard stale SSH progress and host-key prompts
Ignore chain-progress callbacks from superseded boot attempts, and
thread bootEpoch through SSH host-key verification so a late prompt
from an aborted start cannot overwrite a replacement reconnect.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 04:43:41 +00:00
Cursor Agentand陈大猫 068dc0c73d fix(terminal): ignore stale SSH catch UI and password fallback
Check boot attempt currency before resetting reconnect UI state on
SSH start failure, and before launching key→password fallback after
disconnect so a replacement reconnect is not interrupted.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 04:36:51 +00:00
Cursor Agentand陈大猫 3e32240acc fix(terminal): cancel wake and MFA when status-bar disconnects
Invalidate hibernation reconnect-wake tokens on disconnect so a late wake
cannot re-arm boot. Reject/cancel keyboard-interactive prompts for
disconnected panes and clear any already-queued MFA for that session.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 04:30:32 +00:00
Cursor Agentand陈大猫 dc6cdba05f fix(terminal): reject late host-key prompts after disconnect
Disconnect can land before pendingHostKeyRequestId is set. Auto-deny
onHostKeyVerification when boot is inactive or status is disconnected
so approval UI cannot reopen on an aborted pane.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 04:18:53 +00:00
Cursor Agentand陈大猫 db244c4af3 fix(terminal): do not let stale attach abort a newer reconnect
tryAttach failure for an expired boot epoch must close the orphan session
only. Calling abortSessionStartAfterUnmount() would force disconnected on
a reconnect that already re-armed boot.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 04:15:59 +00:00
Cursor Agentand陈大猫 1afc8641cc fix(terminal): use boot epoch so reconnect cannot revive aborted starts
Disconnect alone flipping isBootActiveRef is not enough: an immediate
reconnect re-arms boot, and a late startSSH from the aborted attempt can
still attach. Capture a boot epoch per attempt and ignore stale attaches/UI.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 04:12:24 +00:00
Cursor Agentand陈大猫 221f8e63ee fix(terminal): hide disconnect/reconnect on compact popup terminals
Owned command popups use compactToolbar with a one-shot startup command.
Exposing reconnect there can re-run that command; keep the controls on
normal tabs/workspace panes only.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 04:00:03 +00:00
Cursor Agentand陈大猫 d0dded0bff fix(terminal): ignore stale start UI after status-bar disconnect
When disconnect aborts an in-flight connect, starter catch paths must not
reopen auth or repopulate errors. Gate SSH/telnet/mosh/ET/local/serial
failure UI on isTerminalBootActive and harden the boot effect awaits.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 03:57:29 +00:00
Cursor Agentand陈大猫 4e6834741c fix(terminal): abort in-flight boot on status-bar disconnect
Disconnect keeps the pane mounted, so cancel's unmount cleanup never runs.
Flip isBootActiveRef (and isCancelling) so a late startSSH/startMosh attach
cannot resurrect the session after the user disconnects mid-connect.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 03:51:59 +00:00
Cursor Agentand陈大猫 2deae6274e feat(terminal): add disconnect/reconnect controls on session status bar
Workspace panes could not reach tab-level reconnect. Expose disconnect
(without closing the pane) and reconnect beside the host info actions so
split sessions stay operable. Closes #2769.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 03:47:01 +00:00
37 changed files with 1861 additions and 130 deletions

No files matched your search

+57
View File
@@ -22,6 +22,7 @@ import {
rememberKeyPassphrase,
shouldUpdateReferenceKeyPassphrase,
} from './application/defaultKeyPassphrases';
import { isTerminalBootEpochCurrent } from './domain/terminalBootEpoch';
import { initializeFonts } from './application/state/fontStore';
import { initializeUIFonts } from './application/state/uiFontStore';
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
@@ -827,12 +828,28 @@ function App({ settings }: { settings: SettingsState }) {
const unsubscribe = bridge.onPassphraseRequest(async (request) => {
console.log('[App] Passphrase request received:', request);
const isRequestCurrent = () => (
typeof request.sessionId !== "string"
|| isTerminalBootEpochCurrent(request.sessionId, request.bootEpoch)
);
// Disconnect/reconnect may leave a late passphrase prompt for a
// superseded boot; reject epoch-tagged requests that are no longer current.
if (!isRequestCurrent()) {
void bridge.respondPassphrase?.(request.requestId, "", true);
return;
}
// If the bridge already tried a passphrase and it was wrong, skip auto-respond
if (!request.passphraseInvalid) {
// Check if a reference key exists for this path — use its passphrase
const currentKeys = keysRef.current;
const refKey = currentKeys.find((k: SSHKey) => k.source === 'reference' && k.filePath === request.keyPath);
if (refKey?.passphrase && refKey.savePassphrase !== false && !isEncryptedCredentialPlaceholder(refKey.passphrase)) {
if (!isRequestCurrent()) {
void bridge.respondPassphrase?.(request.requestId, "", true);
return;
}
console.log('[App] Auto-responding with reference key passphrase for:', request.keyPath);
void bridge.respondPassphrase?.(request.requestId, refKey.passphrase, false);
return;
@@ -840,6 +857,10 @@ function App({ settings }: { settings: SettingsState }) {
// Fallback: try old storage for passphrase
const saved = await loadDefaultKeyPassphrase(request.keyPath);
if (!isRequestCurrent()) {
void bridge.respondPassphrase?.(request.requestId, "", true);
return;
}
if (saved) {
console.log('[App] Auto-responding with saved passphrase for:', request.keyPath);
// Migrate to reference key if one exists
@@ -857,18 +878,29 @@ function App({ settings }: { settings: SettingsState }) {
} catch (err) {
console.warn('[App] Failed to migrate passphrase to reference key:', err);
}
if (!isRequestCurrent()) {
void bridge.respondPassphrase?.(request.requestId, "", true);
return;
}
}
void bridge.respondPassphrase?.(request.requestId, saved, false);
return;
}
}
if (!isRequestCurrent()) {
void bridge.respondPassphrase?.(request.requestId, "", true);
return;
}
// No saved passphrase or it was invalid, show modal
setPassphraseQueue(prev => [...prev, {
requestId: request.requestId,
keyPath: request.keyPath,
keyName: request.keyName,
hostname: request.hostname,
sessionId: request.sessionId,
bootEpoch: request.bootEpoch,
}]);
});
@@ -877,6 +909,31 @@ function App({ settings }: { settings: SettingsState }) {
};
}, [updateKeys]);
// Drop queued passphrase prompts when a terminal disconnects mid-boot.
useEffect(() => {
const onDisconnected = (event: Event) => {
const detail = (event as CustomEvent<{ sessionId?: string }>).detail;
const disconnectedSessionId = detail?.sessionId;
if (!disconnectedSessionId) return;
const bridge = netcattyBridge.get();
setPassphraseQueue((prev) => {
const keep: typeof prev = [];
for (const item of prev) {
if (item.sessionId === disconnectedSessionId) {
void bridge?.respondPassphrase?.(item.requestId, "", true);
continue;
}
keep.push(item);
}
return keep;
});
};
window.addEventListener("netcatty:terminal-session-disconnected", onDisconnected);
return () => {
window.removeEventListener("netcatty:terminal-session-disconnected", onDisconnected);
};
}, []);
// Handle passphrase submit
const handlePassphraseSubmit = useCallback(async (requestId: string, passphrase: string, remember: boolean) => { return handlePassphraseSubmitImpl(() => ({ keysRef, netcattyBridge, passphrase, passphraseQueue, remember, rememberKeyPassphrase, requestId, setPassphraseQueue, updateKeys }), requestId, passphrase, remember); }, [passphraseQueue, updateKeys]);
@@ -5,6 +5,10 @@ import {
removeKeyboardInteractiveRequest,
shouldQueueKeyboardInteractiveRequest,
} from "./useAppStartupEffects.ts";
import {
clearTerminalBootEpoch,
setTerminalBootEpoch,
} from "../../domain/terminalBootEpoch.ts";
const sessions = [{ id: "terminal-1" }, { id: "terminal-2" }];
@@ -19,6 +23,42 @@ test("terminal-scoped keyboard-interactive requests are limited to owned session
);
});
test("disconnected terminal sessions do not queue keyboard-interactive prompts", () => {
assert.equal(
shouldQueueKeyboardInteractiveRequest(
{ scope: "terminal", sessionId: "terminal-1" },
[{ id: "terminal-1", status: "disconnected" }],
),
false,
);
assert.equal(
shouldQueueKeyboardInteractiveRequest(
{ scope: "terminal", sessionId: "terminal-1" },
[{ id: "terminal-1", status: "connecting" }],
),
true,
);
});
test("superseded terminal boot epochs do not queue keyboard-interactive prompts", () => {
setTerminalBootEpoch("terminal-1", 3);
assert.equal(
shouldQueueKeyboardInteractiveRequest(
{ scope: "terminal", sessionId: "terminal-1", bootEpoch: 1 },
[{ id: "terminal-1", status: "connecting" }],
),
false,
);
assert.equal(
shouldQueueKeyboardInteractiveRequest(
{ scope: "terminal", sessionId: "terminal-1", bootEpoch: 3 },
[{ id: "terminal-1", status: "connecting" }],
),
true,
);
clearTerminalBootEpoch("terminal-1");
});
test("external keyboard-interactive requests are not filtered by terminal session ids", () => {
assert.equal(
shouldQueueKeyboardInteractiveRequest({ scope: "external", sessionId: "sftp-conn-1" }, sessions),
+31 -3
View File
@@ -11,6 +11,7 @@ import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from '../sta
import { hasNewSourceFingerprint } from '../state/sftp/transferProgressMetadata';
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from '../../infrastructure/config/storageKeys';
import type { TransferTask } from '../../domain/models';
import { isTerminalBootEpochCurrent } from '../../domain/terminalBootEpoch';
import {
canApplyDedicatedResumeProgress,
createDedicatedResumeChildUpdateBatcher,
@@ -24,8 +25,10 @@ type KeyboardInteractiveRequestLike = {
scope?: KeyboardInteractiveScope;
sessionId?: string;
hostId?: string;
requestId?: string;
bootEpoch?: number;
};
type SessionIdLike = { id: string; hostId?: string; hostname?: string };
type SessionIdLike = { id: string; hostId?: string; hostname?: string; status?: string };
type KeyboardInteractiveQueueItem = { requestId: string };
export function shouldQueueKeyboardInteractiveRequest(
@@ -34,7 +37,14 @@ export function shouldQueueKeyboardInteractiveRequest(
): boolean {
if (request.scope !== "terminal") return true;
if (!request.sessionId) return false;
return sessions.some((session) => session.id === request.sessionId);
const session = sessions.find((entry) => entry.id === request.sessionId);
if (!session) return false;
// Status-bar disconnect keeps the tab; do not queue MFA for aborted panes.
if (session.status === "disconnected") return false;
// After disconnect → reconnect the tab is connecting again; reject MFA from
// a superseded SSH start that still shares this sessionId.
if (!isTerminalBootEpochCurrent(request.sessionId, request.bootEpoch)) return false;
return true;
}
export function removeKeyboardInteractiveRequest<T extends KeyboardInteractiveQueueItem>(
@@ -519,7 +529,12 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
if (!bridge?.onKeyboardInteractive) return;
const unsubscribe = bridge.onKeyboardInteractive((request) => {
if (!shouldQueueKeyboardInteractiveRequest(request, sessionsRef.current)) return;
if (!shouldQueueKeyboardInteractiveRequest(request, sessionsRef.current)) {
if (request.scope === "terminal" && request.requestId) {
void bridge.respondKeyboardInteractive?.(request.requestId, [], true);
}
return;
}
console.log('[App] Keyboard-interactive request received:', request);
// Add to queue instead of replacing - supports multiple concurrent sessions
setKeyboardInteractiveQueue(prev => [...prev, {
@@ -537,10 +552,23 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
const unsubscribeCancelled = bridge.onKeyboardInteractiveCancelled?.((event) => {
setKeyboardInteractiveQueue(prev => removeKeyboardInteractiveRequest(prev, event.requestId));
});
const onTerminalDisconnected = (event: Event) => {
const sessionId = (event as CustomEvent<{ sessionId?: string }>).detail?.sessionId;
if (!sessionId) return;
setKeyboardInteractiveQueue((prev) => {
const doomed = prev.filter((request) => request.sessionId === sessionId);
for (const request of doomed) {
void bridge.respondKeyboardInteractive?.(request.requestId, [], true);
}
return prev.filter((request) => request.sessionId !== sessionId);
});
};
window.addEventListener("netcatty:terminal-session-disconnected", onTerminalDisconnected);
return () => {
unsubscribe?.();
unsubscribeCancelled?.();
window.removeEventListener("netcatty:terminal-session-disconnected", onTerminalDisconnected);
};
}, [enabled, setKeyboardInteractiveQueue]);
+4
View File
@@ -82,6 +82,10 @@ export const enTerminalMessages: Messages = {
'terminal.statusbar.copyHostname.tooltip': 'Copy host address ({hostname})',
'terminal.statusbar.copyHostname.toast': 'Copied host address: {hostname}',
'terminal.statusbar.copyHostname.error': 'Failed to copy host address to clipboard',
'terminal.statusbar.disconnect.label': 'Disconnect',
'terminal.statusbar.disconnect.tooltip': 'Disconnect this session without closing the tab',
'terminal.statusbar.reconnect.label': 'Reconnect',
'terminal.statusbar.reconnect.tooltip': 'Reconnect this session',
'terminal.serverStats.cpu': 'CPU Usage',
'terminal.serverStats.cpuCores': 'CPU Core Usage',
'terminal.serverStats.memory': 'Memory Usage',
+4
View File
@@ -103,6 +103,10 @@ export const ruTerminalMessages: Messages = {
'terminal.statusbar.copyHostname.tooltip': 'Копировать адрес хоста ({hostname})',
'terminal.statusbar.copyHostname.toast': 'Адрес хоста скопирован: {hostname}',
'terminal.statusbar.copyHostname.error': 'Не удалось скопировать адрес хоста в буфер обмена',
'terminal.statusbar.disconnect.label': 'Отключить',
'terminal.statusbar.disconnect.tooltip': 'Отключить сессию, не закрывая вкладку',
'terminal.statusbar.reconnect.label': 'Переподключиться',
'terminal.statusbar.reconnect.tooltip': 'Переподключить эту сессию',
'terminal.serverStats.cpu': 'Использование CPU',
'terminal.serverStats.cpuCores': 'Использование ядер CPU',
'terminal.serverStats.memory': 'Использование памяти',
+4
View File
@@ -427,6 +427,10 @@ export const zhCNVaultMessages: Messages = {
'terminal.statusbar.copyHostname.tooltip': '复制主机地址({hostname})',
'terminal.statusbar.copyHostname.toast': '已复制主机地址:{hostname}',
'terminal.statusbar.copyHostname.error': '复制主机地址失败',
'terminal.statusbar.disconnect.label': '断开连接',
'terminal.statusbar.disconnect.tooltip': '断开当前会话,但保留标签页',
'terminal.statusbar.reconnect.label': '重新连接',
'terminal.statusbar.reconnect.tooltip': '重新连接当前会话',
'terminal.serverStats.cpu': 'CPU 使用率',
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
'terminal.serverStats.memory': '内存使用',
+4
View File
@@ -427,6 +427,10 @@ export const zhTWVaultMessages: Messages = {
'terminal.statusbar.copyHostname.tooltip': '複製主機地址({hostname})',
'terminal.statusbar.copyHostname.toast': '已複製主機地址:{hostname}',
'terminal.statusbar.copyHostname.error': '複製主機地址失敗',
'terminal.statusbar.disconnect.label': '中斷連線',
'terminal.statusbar.disconnect.tooltip': '中斷目前工作階段,但保留標籤頁',
'terminal.statusbar.reconnect.label': '重新連線',
'terminal.statusbar.reconnect.tooltip': '重新連線目前工作階段',
'terminal.serverStats.cpu': 'CPU 使用率',
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
'terminal.serverStats.memory': '記憶體使用',
+5 -5
View File
@@ -276,9 +276,9 @@ export const useTerminalBackend = () => {
netcattyBridge.get()?.notifyTerminalSessionDisplayReady?.(sessionId);
}, []);
const closeSession = useCallback(async (sessionId: string) => {
const closeSession = useCallback(async (sessionId: string, options?: { bootEpoch?: number }) => {
const bridge = netcattyBridge.get();
await bridge?.closeSession?.(sessionId);
await bridge?.closeSession?.(sessionId, options);
}, []);
const rebindSessionOutput = useCallback(async (sessionId: string, authorization: string) => {
@@ -351,17 +351,17 @@ export const useTerminalBackend = () => {
return bridge.onSessionExit(sessionId, cb);
}, []);
const onTelnetAutoLoginComplete = useCallback((sessionId: string, cb: (evt: { sessionId: string }) => void) => {
const onTelnetAutoLoginComplete = useCallback((sessionId: string, cb: (evt: { sessionId: string; bootEpoch?: number }) => void) => {
const bridge = netcattyBridge.get();
return bridge?.onTelnetAutoLoginComplete?.(sessionId, cb);
}, []);
const onTelnetAutoLoginCancelled = useCallback((sessionId: string, cb: (evt: { sessionId: string }) => void) => {
const onTelnetAutoLoginCancelled = useCallback((sessionId: string, cb: (evt: { sessionId: string; bootEpoch?: number }) => void) => {
const bridge = netcattyBridge.get();
return bridge?.onTelnetAutoLoginCancelled?.(sessionId, cb);
}, []);
const onMoshSessionReady = useCallback((sessionId: string, cb: (evt: { sessionId: string }) => void) => {
const onMoshSessionReady = useCallback((sessionId: string, cb: (evt: { sessionId: string; bootEpoch?: number }) => void) => {
const bridge = netcattyBridge.get();
return bridge?.onMoshSessionReady?.(sessionId, cb);
}, []);
+2
View File
@@ -21,6 +21,8 @@ export interface PassphraseRequest {
keyPath: string;
keyName: string;
hostname?: string;
sessionId?: string;
bootEpoch?: number;
}
/** Opt-in only: do not default to remembering key passphrases (#2024). */
+134 -8
View File
@@ -3,7 +3,7 @@ import { FitAddon } from "@xterm/addon-fit";
import { SerializeAddon } from "@xterm/addon-serialize";
import { SearchAddon } from "@xterm/addon-search";
import "@xterm/xterm/css/xterm.css";
import { Activity, Cpu, Clock3, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine, Sparkles, SquareArrowOutUpRight } from "lucide-react";
import { Activity, Cpu, Clock3, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug } from "lucide-react";
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { detectLocalOs } from "../lib/localShell";
@@ -48,6 +48,7 @@ import { supportsZmodemTerminalDragDrop } from "../lib/zmodemDragDrop";
import { resolveHostAuth, resolveHostAutofillPassword } from "../domain/sshAuth";
import { resolveEffectiveTerminalProtocol } from "../domain/terminalProtocol";
import { isPluginHostProtocol } from "../domain/pluginConnection";
import { clearTerminalBootEpoch, setTerminalBootEpoch } from "../domain/terminalBootEpoch";
import {
appendTerminalPromptSecurityTail,
isConfirmedTerminalShellPrompt,
@@ -458,7 +459,48 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const wakePromiseRef = useRef<Promise<boolean> | null>(null);
const sessionRef = useRef<string | null>(null);
const sessionCleanupPromiseRef = useRef<Promise<void> | null>(null);
/** Epoch owned by an in-flight disconnect/teardown close (before bump). */
const pendingCloseBootEpochRef = useRef<number | undefined>(undefined);
const trackSessionCleanup = (promise: Promise<unknown>) => {
const settled = Promise.resolve(promise).then(
() => undefined,
() => undefined,
);
const previous = sessionCleanupPromiseRef.current;
const next = previous
? Promise.all([previous, settled]).then(() => undefined)
: settled;
sessionCleanupPromiseRef.current = next;
void next.finally(() => {
if (sessionCleanupPromiseRef.current === next) {
sessionCleanupPromiseRef.current = null;
}
});
};
const isBootActiveRef = useRef(false);
const bootEpochRef = useRef(0);
const publishBootEpoch = () => {
setTerminalBootEpoch(sessionId, bootEpochRef.current);
};
const bumpBootEpoch = () => {
bootEpochRef.current += 1;
publishBootEpoch();
};
/** Invalidate the current boot and remember its epoch for the matching closeSession. */
const invalidateBootEpochForClose = () => {
const closingEpoch = bootEpochRef.current;
bumpBootEpoch();
pendingCloseBootEpochRef.current = closingEpoch;
return closingEpoch;
};
const resolveCloseBootEpoch = () => {
if (pendingCloseBootEpochRef.current !== undefined) {
const epoch = pendingCloseBootEpochRef.current;
pendingCloseBootEpochRef.current = undefined;
return epoch;
}
return bootEpochRef.current;
};
const hasConnectedRef = useRef(false);
const hasRunStartupCommandRef = useRef(false);
const restoreCwdIntentRef = useRef<{ cwd: string; command: string } | null>(null);
@@ -477,6 +519,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
/** Connected wake for multi-tab snippet fan-out (reattaches session listeners). */
const wakeHibernatedRuntimeForConnectedRef = useRef<(() => Promise<boolean>) | null>(null);
const reconnectWakeInFlightRef = useRef(false);
// When a hibernated reconnect wake is invalidated: 'dispose' for unmount/
// session teardown (orphan runtime must go), 'keep' for Disconnect so the
// pane retains an xterm instance and can reconnect without being remounted.
const reconnectWakeInvalidateModeRef = useRef<"dispose" | "keep">("dispose");
const reconnectWakeTokenRef = useRef<symbol | null>(null);
const manualReconnectRequestRef = useRef<() => void>(() => {});
const terminalDataCapturedRef = useRef(false);
@@ -498,6 +544,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, [onSessionExit]);
useEffect(() => () => {
reconnectWakeInvalidateModeRef.current = "dispose";
reconnectWakeTokenRef.current = null;
}, [sessionId]);
@@ -1547,6 +1594,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (!closingSessionId) {
disposeSessionListeners();
// Still notify main so in-flight SSH passphrase prompts for this UI
// sessionId are aborted even before a backend session was attached.
try {
await terminalBackend.closeSession(sessionId, { bootEpoch: resolveCloseBootEpoch() });
} catch (err) {
logger.warn("Failed to cancel pending session boot on disconnect", err);
}
const postDisposeCleanup = sessionCleanupPromiseRef.current;
if (postDisposeCleanup) {
await postDisposeCleanup;
}
return;
}
@@ -1648,7 +1706,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
clearTerminalSessionFlowAck(closingSessionId);
}
try {
await terminalBackend.closeSession(closingSessionId);
await terminalBackend.closeSession(closingSessionId, { bootEpoch: resolveCloseBootEpoch() });
} catch (err) {
logger.warn("Failed to close SSH session", err);
}
@@ -2072,9 +2130,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useEffect(() => () => {
clearKittyKeyboardBroadcastSession(sessionId);
clearTerminalBootEpoch(sessionId);
}, [sessionId]);
const teardown = () => {
// Capture the live epoch before invalidating so closeSession still matches
// the registered backend session / pending passphrase boot.
invalidateBootEpochForClose();
clearTerminalBootEpoch(sessionId);
isBootActiveRef.current = false;
retryTokenRef.current = null;
reconnectPreparationTokenRef.current = null;
@@ -2205,6 +2268,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isPaneVisibleRef: isVisibleRef,
isVisibleRef: isRendererActiveRef,
isBootActiveRef,
bootEpochRef,
pendingOutputScrollRef,
sessionRef,
hasConnectedRef,
@@ -2212,6 +2276,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
restoreCwdIntentRef,
disposeDataRef,
disposeExitRef,
trackSessionCleanup,
disposeTelnetEchoModeRef,
fitAddonRef,
serializeAddonRef,
@@ -2334,7 +2399,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
setMoshShellReady(false);
disposeMoshReadyRef.current = terminalBackend.onMoshSessionReady(sessionId, () => {
disposeMoshReadyRef.current = terminalBackend.onMoshSessionReady(sessionId, (evt) => {
const currentEpoch = bootEpochRef.current;
if (
Number.isFinite(currentEpoch)
&& Number.isFinite(evt?.bootEpoch)
&& evt.bootEpoch !== currentEpoch
) {
return;
}
setMoshShellReady(true);
}) ?? null;
}, [effectiveTerminalProtocol, sessionId, terminalBackend]);
@@ -3101,6 +3174,50 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onCloseSession?.(sessionId);
};
/** Tear down the live transport but keep the tab/pane for a later reconnect. */
const handleDisconnect = () => {
if (attachExistingSession) return;
if (statusRef.current === "disconnected") return;
if (pendingHostKeyRequestId) {
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
}
clearAutoReconnect({ stopLoop: true });
retryTokenRef.current = null;
reconnectPreparationTokenRef.current = null;
restoreCwdIntentRef.current = null;
// A hibernated reconnect may still be waking the runtime; invalidate that
// continuation so it cannot re-arm boot after this disconnect. Keep any
// runtime already created — wakeHibernatedRuntime clears hibernatedRef
// before returning, and disposing here would leave no term and no
// hibernation marker, permanently blocking later Reconnect.
reconnectWakeInvalidateModeRef.current = "keep";
reconnectWakeTokenRef.current = null;
reconnectWakeInFlightRef.current = false;
// Cancel closes the tab (effect cleanup flips boot-active). Disconnect keeps
// the pane mounted, so mark boot inactive here or a late startSSH/startMosh
// attach can still bring the session back after the user asked to stop.
// Bump the epoch so a later reconnect cannot revive this aborted attempt,
// but closeSession must still target the pre-bump epoch.
invalidateBootEpochForClose();
isBootActiveRef.current = false;
setIsCancelling(true);
auth.setNeedsAuth(false);
auth.setAuthRetryMessage(null);
setNeedsHostKeyVerification(false);
setPendingHostKeyInfo(null);
setPendingHostKeyRequestId(null);
setError(null);
setProgressLogs((prev) => [...prev, "Disconnected by user."]);
void cleanupSession();
updateStatus("disconnected");
setChainProgress(null);
setIsDisconnectedDialogDismissed(false);
window.dispatchEvent(new CustomEvent("netcatty:terminal-session-disconnected", {
detail: { sessionId },
}));
setTimeout(() => setIsCancelling(false), 600);
};
const handleDismissDisconnectedDialog = () => {
setIsDisconnectedDialogDismissed(true);
queueMicrotask(() => termRef.current?.focus());
@@ -3161,11 +3278,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
reconnectWakeInFlightRef.current = true;
const wakeToken = Symbol();
reconnectWakeInvalidateModeRef.current = "dispose";
reconnectWakeTokenRef.current = wakeToken;
updateStatus("connecting");
void wakeForReconnect().then((woke) => {
if (reconnectWakeTokenRef.current !== wakeToken) {
disposeRuntimeOnly();
reconnectWakeInFlightRef.current = false;
if (reconnectWakeInvalidateModeRef.current === "dispose") {
disposeRuntimeOnly();
}
return;
}
reconnectWakeTokenRef.current = null;
@@ -3177,7 +3298,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
updateStatus("disconnected");
}).catch(() => {
if (reconnectWakeTokenRef.current !== wakeToken) {
disposeRuntimeOnly();
reconnectWakeInFlightRef.current = false;
if (reconnectWakeInvalidateModeRef.current === "dispose") {
disposeRuntimeOnly();
}
return;
}
reconnectWakeTokenRef.current = null;
@@ -3235,6 +3359,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// ghost backend session with no owning UI.
const retryStillActive = () => retryTokenStillCurrent() && termRef.current === term;
bootEpochRef.current += 1;
publishBootEpoch();
isBootActiveRef.current = true;
auth.resetForRetry();
terminalDataCapturedRef.current = false;
@@ -3873,11 +3999,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onWake: wakeFromHibernateRuntime,
});
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
return (
<>
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
<ScriptExecutionOverlay
run={activeScriptRun}
onPause={() => { void pauseScriptRun(activeScriptRun.runId); }}
@@ -3886,7 +4012,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onDismiss={dismissScriptOverlay}
compactTopChrome={terminalSettings?.showHostInfoBar === false}
/>
) : null, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction: Boolean(showSelectionAIAction && onAddSelectionToAI), isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect: handlePasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />
) : null, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showConnectionControls: !attachExistingSession && !compactToolbar, showLogs, showSelectionAIAction: Boolean(showSelectionAIAction && onAddSelectionToAI), isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect: handlePasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />
<ScriptSaveRecordingDialog
open={saveRecordingOpen}
code={recordedCode}
+97 -1
View File
@@ -11,9 +11,12 @@ import {
resolveTerminalRightInset,
resolveTerminalTopOffsets,
shouldBlockTerminalReconnectForTarget,
shouldEnableStatusBarDisconnect,
shouldEnableStatusBarReconnect,
shouldReconnectTerminalOnEnterKey,
shouldShowSelectionAIOverlay,
shouldShowLineTimestampToolbarToggle,
shouldShowStatusBarConnectionControls,
} from "./TerminalView.tsx";
test("line timestamp toggle creates a persistent host update", () => {
@@ -305,6 +308,8 @@ test("hidden host information keeps terminal actions rendered", () => {
const copyAction = source.indexOf('aria-label={t("terminal.statusbar.copyHostname.label")}');
const timestampAction = source.indexOf("shouldShowLineTimestampToolbarToggle", copyAction);
const systemAction = source.indexOf('aria-label={t("terminal.layer.system")}', timestampAction);
const disconnectAction = source.indexOf('aria-label={t("terminal.statusbar.disconnect.label")}', systemAction);
const reconnectAction = source.indexOf('aria-label={t("terminal.statusbar.reconnect.label")}', disconnectAction);
const actionsStart = source.indexOf('className="flex items-center gap-0.5 flex-shrink-0"');
const controls = source.indexOf("{renderControls({ showClose: inWorkspace })}");
const compactDragHandle = source.indexOf('data-terminal-detach-drag-handle="true"');
@@ -314,6 +319,8 @@ test("hidden host information keeps terminal actions rendered", () => {
assert.notEqual(copyAction, -1);
assert.notEqual(timestampAction, -1);
assert.notEqual(systemAction, -1);
assert.notEqual(disconnectAction, -1);
assert.notEqual(reconnectAction, -1);
assert.notEqual(actionsStart, -1);
assert.notEqual(controls, -1);
assert.notEqual(compactDragHandle, -1);
@@ -324,11 +331,100 @@ test("hidden host information keeps terminal actions rendered", () => {
assert.ok(hostInfoEnd < copyAction);
assert.ok(copyAction < timestampAction);
assert.ok(timestampAction < systemAction);
assert.ok(systemAction < actionsStart);
assert.ok(systemAction < disconnectAction);
assert.ok(disconnectAction < reconnectAction);
assert.ok(reconnectAction < actionsStart);
assert.ok(actionsStart < controls);
assert.ok(compactDragHandle < hostInfoStart);
});
test("status bar disconnect stays enabled while connected or connecting", () => {
assert.equal(shouldEnableStatusBarDisconnect("connected"), true);
assert.equal(shouldEnableStatusBarDisconnect("connecting"), true);
assert.equal(shouldEnableStatusBarDisconnect("disconnected"), false);
assert.equal(shouldEnableStatusBarDisconnect(undefined), false);
});
test("status bar reconnect matches tab-menu reconnect gating", () => {
assert.equal(shouldEnableStatusBarReconnect("connected"), true);
assert.equal(shouldEnableStatusBarReconnect("disconnected"), true);
assert.equal(shouldEnableStatusBarReconnect("connecting"), false);
assert.equal(shouldEnableStatusBarReconnect(undefined), false);
});
test("status bar connection controls require an owned session surface", () => {
assert.equal(
shouldShowStatusBarConnectionControls({
showConnectionControls: true,
hasDisconnectHandler: true,
hasReconnectHandler: true,
}),
true,
);
assert.equal(
shouldShowStatusBarConnectionControls({
showConnectionControls: false,
hasDisconnectHandler: true,
hasReconnectHandler: true,
}),
false,
);
assert.equal(
shouldShowStatusBarConnectionControls({
showConnectionControls: true,
hasDisconnectHandler: false,
hasReconnectHandler: false,
}),
false,
);
});
test("manual disconnect keeps the session pane for reconnect", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const disconnectStart = source.indexOf("const handleDisconnect = () => {");
const disconnectEnd = source.indexOf("const handleDismissDisconnectedDialog", disconnectStart);
assert.notEqual(disconnectStart, -1);
assert.notEqual(disconnectEnd, -1);
const body = source.slice(disconnectStart, disconnectEnd);
assert.match(body, /clearAutoReconnect\(\{ stopLoop: true \}\)/);
assert.match(body, /reconnectWakeTokenRef\.current = null/);
assert.match(body, /reconnectWakeInFlightRef\.current = false/);
assert.match(body, /netcatty:terminal-session-disconnected/);
assert.match(body, /invalidateBootEpochForClose\(\)/);
assert.match(body, /isBootActiveRef\.current = false/);
assert.match(body, /setIsCancelling\(true\)/);
assert.match(body, /updateStatus\("disconnected"\)/);
assert.match(body, /void cleanupSession\(\)/);
assert.match(source, /trackSessionCleanup/);
assert.doesNotMatch(body, /onCloseSession/);
assert.match(source, /handleDisconnect: \(attachExistingSession \|\| compactToolbar\) \? undefined : handleDisconnect/);
assert.match(source, /showConnectionControls: !attachExistingSession && !compactToolbar/);
assert.match(source, /setTerminalBootEpoch/);
const startersSource = readFileSync(
new URL("./runtime/createTerminalSessionStarters.ts", import.meta.url),
"utf8",
);
assert.match(startersSource, /createBootAttemptGuard\(ctx\)/);
assert.match(startersSource, /setTerminalBootEpoch\(ctx\.sessionId, bootEpoch\)/);
const effectsSource = readFileSync(new URL("./useTerminalEffects.ts", import.meta.url), "utf8");
assert.match(
effectsSource,
/!isBootActiveRef\.current[\s\S]*statusRef\.current === "disconnected"[\s\S]*bootEpochMismatch/,
);
assert.match(
effectsSource,
/respondHostKeyVerification\?\.\(request\.requestId, false\)/,
);
assert.match(effectsSource, /request\.bootEpoch/);
assert.match(
startersSource,
/if \(!isCurrentAttempt\(\)\) return;/,
);
assert.match(startersSource, /bootEpoch,/);
});
test("hidden host information reveals actions without permanently covering terminal content", () => {
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
+82 -1
View File
@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import { ChevronsLeft, GripVertical, Network, X as XIcon } from 'lucide-react';
import { isSessionReconnectDisabled } from '../top-tabs/SessionTabContextMenuContent';
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
import { classifyDistroId } from '../../domain/host';
@@ -47,6 +48,32 @@ export function shouldShowLineTimestampToolbarToggle(
return lineTimestampsAvailable !== false && Boolean(onUpdateHost);
}
/** Keep the tab/pane; only tear down the live transport. */
export function shouldEnableStatusBarDisconnect(
status: 'connecting' | 'connected' | 'disconnected' | undefined,
): boolean {
return status === 'connected' || status === 'connecting';
}
export function shouldEnableStatusBarReconnect(
status: 'connecting' | 'connected' | 'disconnected' | undefined,
): boolean {
if (!status) return false;
return !isSessionReconnectDisabled(status);
}
export function shouldShowStatusBarConnectionControls({
showConnectionControls,
hasDisconnectHandler,
hasReconnectHandler,
}: {
showConnectionControls?: boolean;
hasDisconnectHandler?: boolean;
hasReconnectHandler?: boolean;
}): boolean {
return Boolean(showConnectionControls && (hasDisconnectHandler || hasReconnectHandler));
}
export function shouldEnableYmodemAction({
isSerialConnection,
status,
@@ -287,7 +314,7 @@ function terminalViewCtxEqual(
}
function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
const { Activity, Button, Clock3, Copy, Maximize2, Radio, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTerminalProtocol, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scriptExecutionOverlay, searchMatchCount, searchFocusToken, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
const { Activity, Button, Clock3, Copy, Maximize2, Radio, RefreshCcw, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, Unplug, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTerminalProtocol, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scriptExecutionOverlay, searchMatchCount, searchFocusToken, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showConnectionControls, showLogs, showSelectionAIAction, isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
// Context menu only needs a snapshot at open; avoid selection state lifting into Terminal.
const [contextMenuHasSelection, setContextMenuHasSelection] = useState(false);
const isNetworkDevice = host.deviceType === 'network'
@@ -646,6 +673,60 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
<TooltipContent side="bottom">{t("terminal.layer.system")}</TooltipContent>
</Tooltip>
)}
{shouldShowStatusBarConnectionControls({
showConnectionControls,
hasDisconnectHandler: Boolean(handleDisconnect),
hasReconnectHandler: Boolean(handleRetry),
}) && (
<>
{handleDisconnect && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
"ml-0.5 p-0.5 rounded transition-colors flex-shrink-0",
"hover:bg-[color:var(--terminal-toolbar-btn-hover)]",
shouldEnableStatusBarDisconnect(status)
? "opacity-60 hover:opacity-100"
: "opacity-30 cursor-not-allowed",
)}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleDisconnect}
disabled={!shouldEnableStatusBarDisconnect(status)}
aria-label={t("terminal.statusbar.disconnect.label")}
>
<Unplug size={10} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{t("terminal.statusbar.disconnect.tooltip")}</TooltipContent>
</Tooltip>
)}
{handleRetry && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
"ml-0.5 p-0.5 rounded transition-colors flex-shrink-0",
"hover:bg-[color:var(--terminal-toolbar-btn-hover)]",
shouldEnableStatusBarReconnect(status)
? "opacity-60 hover:opacity-100"
: "opacity-30 cursor-not-allowed",
)}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleRetry}
disabled={!shouldEnableStatusBarReconnect(status)}
aria-label={t("terminal.statusbar.reconnect.label")}
>
<RefreshCcw size={10} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{t("terminal.statusbar.reconnect.tooltip")}</TooltipContent>
</Tooltip>
)}
</>
)}
</div>
{showHostInfoBar && !compactToolbar && (
<TerminalServerStats
@@ -129,6 +129,7 @@ test("reconnect wakes a hibernated terminal before requiring a terminal instance
const wakePromiseRefIndex = source.indexOf("const wakePromiseRef = useRef<Promise<boolean> | null>(null)");
const wakeGuardRefIndex = source.indexOf("const reconnectWakeInFlightRef = useRef(false)");
const wakeTokenRefIndex = source.indexOf("const reconnectWakeTokenRef = useRef<symbol | null>(null)");
const wakeInvalidateModeIndex = source.indexOf('const reconnectWakeInvalidateModeRef = useRef<"dispose" | "keep">("dispose")');
const wakeTokenCleanupIndex = source.indexOf("reconnectWakeTokenRef.current = null", wakeTokenRefIndex);
const reconnectIndex = source.indexOf("const startReconnect = ");
const hibernatedBranchIndex = source.indexOf('!termRef.current && hibernatedRef.current', reconnectIndex);
@@ -140,12 +141,15 @@ test("reconnect wakes a hibernated terminal before requiring a terminal instance
const wakeJoinIndex = source.indexOf("return wakePromiseRef.current ?? false", source.indexOf("const wakeFromHibernateRuntime"));
const wakeTokenIndex = source.indexOf("const wakeToken = Symbol()", hibernatedBranchIndex);
const staleWakeGuardIndex = source.indexOf("reconnectWakeTokenRef.current !== wakeToken", wakeInvocationIndex);
const staleWakeDisposeIndex = source.indexOf("disposeRuntimeOnly();", staleWakeGuardIndex);
const staleWakeDisposeGuardIndex = source.indexOf('reconnectWakeInvalidateModeRef.current === "dispose"', staleWakeGuardIndex);
const staleWakeDisposeIndex = source.indexOf("disposeRuntimeOnly();", staleWakeDisposeGuardIndex);
const disconnectKeepModeIndex = source.indexOf('reconnectWakeInvalidateModeRef.current = "keep"', source.indexOf("const handleDisconnect"));
const missingTermReturnIndex = source.indexOf("if (!termRef.current) return;", reconnectIndex);
assert.notEqual(wakePromiseRefIndex, -1);
assert.notEqual(wakeGuardRefIndex, -1);
assert.notEqual(wakeTokenRefIndex, -1);
assert.notEqual(wakeInvalidateModeIndex, -1);
assert.notEqual(wakeTokenCleanupIndex, -1);
assert.ok(wakeTokenCleanupIndex < reconnectIndex);
assert.notEqual(reconnectIndex, -1);
@@ -158,7 +162,9 @@ test("reconnect wakes a hibernated terminal before requiring a terminal instance
assert.notEqual(wakeJoinIndex, -1);
assert.notEqual(wakeTokenIndex, -1);
assert.notEqual(staleWakeGuardIndex, -1);
assert.notEqual(staleWakeDisposeGuardIndex, -1);
assert.notEqual(staleWakeDisposeIndex, -1);
assert.notEqual(disconnectKeepModeIndex, -1);
assert.notEqual(missingTermReturnIndex, -1);
assert.ok(
hibernatedBranchIndex < missingTermReturnIndex && wakeCallIndex < missingTermReturnIndex,
@@ -173,9 +179,13 @@ test("reconnect wakes a hibernated terminal before requiring a terminal instance
"closing a terminal must be able to invalidate a pending hibernated reconnect",
);
assert.ok(
staleWakeGuardIndex < staleWakeDisposeIndex,
staleWakeGuardIndex < staleWakeDisposeGuardIndex && staleWakeDisposeGuardIndex < staleWakeDisposeIndex,
"an invalidated hibernated wake must dispose any runtime created after unmount cleanup",
);
assert.ok(
disconnectKeepModeIndex !== -1,
"disconnect must keep a woken runtime so later reconnect still has a terminal instance",
);
});
test("dismissing the disconnected dialog returns focus to the terminal for enter reconnect", () => {
@@ -569,6 +569,465 @@ test("startSSH rechecks the live automation policy before password fallback", as
assert.equal(commitCount, 0);
});
test("startSSH auth failure after disconnect does not reopen credential UI", async () => {
let resolveStart: ((error: Error) => void) | null = null;
let releaseStartEntered: (() => void) | null = null;
const started = new Promise<void>((resolve) => { releaseStartEntered = resolve; });
const needsAuthCalls: boolean[] = [];
const statuses: string[] = [];
const errors: Array<string | null> = [];
const isBootActiveRef = { current: true };
const bootEpochRef = { current: 1 };
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async () => {
releaseStartEntered?.();
return await new Promise<string>((_resolve, reject) => {
resolveStart = reject;
});
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = createStarterContext({
host: {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
password: "secret",
authMethod: "password",
},
isBootActiveRef,
bootEpochRef,
setNeedsAuth: (value: boolean) => { needsAuthCalls.push(value); },
setStatus: (value: string) => { statuses.push(value); },
setError: (value: string | null) => { errors.push(value); },
updateStatus: (value: string) => { statuses.push(value); },
terminalBackend,
});
const startPromise = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
await started;
isBootActiveRef.current = false;
bootEpochRef.current += 1;
resolveStart?.(new Error("Authentication failed"));
await startPromise;
assert.deepEqual(needsAuthCalls, []);
assert.deepEqual(statuses, []);
assert.deepEqual(errors, []);
});
test("startSSH stale attempt cannot attach after disconnect then reconnect", async () => {
let resolveStart: ((id: string) => void) | null = null;
let releaseStartEntered: (() => void) | null = null;
const started = new Promise<void>((resolve) => { releaseStartEntered = resolve; });
const attached: string[] = [];
const closed: string[] = [];
const isBootActiveRef = { current: true };
const bootEpochRef = { current: 1 };
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async () => {
releaseStartEntered?.();
return await new Promise<string>((resolve) => {
resolveStart = resolve;
});
},
closeSession: (id: string, opts?: { bootEpoch?: number }) => {
if (opts?.bootEpoch !== undefined && opts.bootEpoch !== bootEpochRef.current) return;
closed.push(id);
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = createStarterContext({
host: {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
password: "secret",
authMethod: "password",
},
isBootActiveRef,
bootEpochRef,
onSessionAttached: (id: string) => { attached.push(id); },
terminalBackend,
});
const startPromise = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
await started;
// Disconnect then immediately reconnect: boot becomes active again on a new epoch.
bootEpochRef.current += 1;
isBootActiveRef.current = false;
bootEpochRef.current += 1;
isBootActiveRef.current = true;
resolveStart?.("stale-ssh-session");
await startPromise;
assert.deepEqual(attached, []);
// Shared sessionId must not be closed while the replacement reconnect owns it.
assert.deepEqual(closed, []);
});
test("stale startSSH closes orphan only when boot is fully inactive", async () => {
let resolveStart: ((id: string) => void) | null = null;
let releaseStartEntered: (() => void) | null = null;
const started = new Promise<void>((resolve) => { releaseStartEntered = resolve; });
const closed: string[] = [];
const isBootActiveRef = { current: true };
const bootEpochRef = { current: 1 };
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async () => {
releaseStartEntered?.();
return await new Promise<string>((resolve) => {
resolveStart = resolve;
});
},
// No newer registry owner exists; closing by the attempt's bootEpoch succeeds.
closeSession: (id: string) => { closed.push(id); },
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = createStarterContext({
host: {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
password: "secret",
authMethod: "password",
},
isBootActiveRef,
bootEpochRef,
terminalBackend,
});
const startPromise = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
await started;
bootEpochRef.current += 1;
isBootActiveRef.current = false;
resolveStart?.("orphan-ssh-session");
await startPromise;
assert.deepEqual(closed, ["orphan-ssh-session"]);
});
test("stale startSSH success does not clear replacement MFA wait state", async () => {
let resolveStart: ((id: string) => void) | null = null;
let releaseStartEntered: (() => void) | null = null;
const started = new Promise<void>((resolve) => { releaseStartEntered = resolve; });
const awaitingUserInput: boolean[] = [];
const closed: string[] = [];
const isBootActiveRef = { current: true };
const bootEpochRef = { current: 1 };
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async () => {
releaseStartEntered?.();
return await new Promise<string>((resolve) => {
resolveStart = resolve;
});
},
closeSession: (id: string, opts?: { bootEpoch?: number }) => {
if (opts?.bootEpoch !== undefined && opts.bootEpoch !== bootEpochRef.current) return;
closed.push(id);
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = createStarterContext({
host: {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
password: "secret",
authMethod: "password",
},
isBootActiveRef,
bootEpochRef,
setIsConnectionAwaitingUserInput: (value: boolean) => { awaitingUserInput.push(value); },
terminalBackend,
});
const startPromise = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
await started;
awaitingUserInput.length = 0;
bootEpochRef.current += 1;
isBootActiveRef.current = false;
bootEpochRef.current += 1;
isBootActiveRef.current = true;
resolveStart?.("stale-ssh-session");
await startPromise;
assert.deepEqual(awaitingUserInput, []);
assert.deepEqual(closed, []);
});
test("stale startSSH attach failure does not disconnect a newer reconnect", async () => {
let resolveStart: ((id: string) => void) | null = null;
let releaseStartEntered: (() => void) | null = null;
const started = new Promise<void>((resolve) => { releaseStartEntered = resolve; });
const statuses: string[] = [];
const isBootActiveRef = { current: true };
const bootEpochRef = { current: 1 };
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async () => {
releaseStartEntered?.();
return await new Promise<string>((resolve) => {
resolveStart = resolve;
});
},
closeSession: noop,
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = createStarterContext({
host: {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
password: "secret",
authMethod: "password",
},
isBootActiveRef,
bootEpochRef,
updateStatus: (value: string) => { statuses.push(value); },
setStatus: (value: string) => { statuses.push(value); },
terminalBackend,
});
const startPromise = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
await started;
bootEpochRef.current += 1;
isBootActiveRef.current = false;
bootEpochRef.current += 1;
isBootActiveRef.current = true;
statuses.length = 0;
resolveStart?.("stale-ssh-session");
await startPromise;
assert.deepEqual(statuses, []);
});
test("stale startSSH failure does not reset replacement reconnect UI state", async () => {
let rejectStart: ((error: Error) => void) | null = null;
let releaseStartEntered: (() => void) | null = null;
const started = new Promise<void>((resolve) => { releaseStartEntered = resolve; });
const awaitingUserInput: boolean[] = [];
const pastTcpDial: boolean[] = [];
const chainProgress: Array<unknown> = [];
let unsubscribed = false;
const isBootActiveRef = { current: true };
const bootEpochRef = { current: 1 };
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async () => {
releaseStartEntered?.();
return await new Promise<string>((_resolve, reject) => {
rejectStart = reject;
});
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => () => { unsubscribed = true; },
writeToSession: noop,
resizeSession: noop,
};
const ctx = createStarterContext({
host: {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
password: "secret",
authMethod: "password",
},
isBootActiveRef,
bootEpochRef,
setIsConnectionAwaitingUserInput: (value: boolean) => { awaitingUserInput.push(value); },
setIsConnectionPastTcpDial: (value: boolean) => { pastTcpDial.push(value); },
setChainProgress: (value: unknown) => { chainProgress.push(value); },
terminalBackend,
});
const startPromise = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
await started;
// Replacement reconnect is waiting for keyboard-interactive input.
awaitingUserInput.length = 0;
pastTcpDial.length = 0;
chainProgress.length = 0;
bootEpochRef.current += 1;
isBootActiveRef.current = false;
bootEpochRef.current += 1;
isBootActiveRef.current = true;
rejectStart?.(new Error("Authentication failed"));
await startPromise;
assert.equal(unsubscribed, true);
assert.deepEqual(awaitingUserInput, []);
assert.deepEqual(pastTcpDial, []);
assert.deepEqual(chainProgress, []);
});
test("stale startSSH key auth failure does not launch password fallback", async () => {
let rejectStart: ((error: Error) => void) | null = null;
let releaseStartEntered: (() => void) | null = null;
const started = new Promise<void>((resolve) => { releaseStartEntered = resolve; });
const startCalls: Array<Record<string, unknown>> = [];
const progressLogs: string[] = [];
const isBootActiveRef = { current: true };
const bootEpochRef = { current: 1 };
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async (options: Record<string, unknown>) => {
startCalls.push(options);
releaseStartEntered?.();
return await new Promise<string>((_resolve, reject) => {
rejectStart = reject;
});
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = createStarterContext({
host: {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
authMethod: "key",
identityFileId: "key-1",
password: "login-secret",
},
keys: [{
id: "key-1",
name: "Key",
privateKey: "plain-private-key",
publicKey: "",
source: "embedded",
}],
isBootActiveRef,
bootEpochRef,
setProgressLogs: (updater: string[] | ((prev: string[]) => string[])) => {
const next = typeof updater === "function" ? updater(progressLogs) : updater;
progressLogs.splice(0, progressLogs.length, ...next);
},
terminalBackend,
});
const startPromise = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
await started;
bootEpochRef.current += 1;
isBootActiveRef.current = false;
rejectStart?.(new Error("Authentication failed"));
await startPromise;
assert.equal(startCalls.length, 1);
assert.equal(startCalls[0].password, "login-secret");
assert.equal(startCalls[0].bootEpoch, 1);
assert.equal(
progressLogs.includes("Key auth failed. Trying password..."),
false,
);
});
test("stale startSSH chain progress does not overwrite replacement reconnect UI", async () => {
let chainProgressListener:
| ((sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void)
| null = null;
let releaseStartEntered: (() => void) | null = null;
const started = new Promise<void>((resolve) => { releaseStartEntered = resolve; });
const awaitingUserInput: boolean[] = [];
const chainProgress: unknown[] = [];
const isBootActiveRef = { current: true };
const bootEpochRef = { current: 1 };
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async () => {
releaseStartEntered?.();
return await new Promise<string>(() => {});
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: (
listener: (sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void,
) => {
chainProgressListener = listener;
return noop;
},
writeToSession: noop,
resizeSession: noop,
};
const ctx = createStarterContext({
host: {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
password: "secret",
authMethod: "password",
hostChain: { hostIds: ["jump-1"] },
},
resolvedChainHosts: [{
id: "jump-1",
label: "Jump",
hostname: "jump.example.test",
username: "jump",
password: "jump-secret",
}],
isBootActiveRef,
bootEpochRef,
setIsConnectionAwaitingUserInput: (value: boolean) => { awaitingUserInput.push(value); },
setChainProgress: (value: unknown) => { chainProgress.push(value); },
terminalBackend,
});
void createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
await started;
awaitingUserInput.length = 0;
chainProgress.length = 0;
bootEpochRef.current += 1;
isBootActiveRef.current = false;
bootEpochRef.current += 1;
isBootActiveRef.current = true;
chainProgressListener?.(
"session-1",
1,
2,
"jump.example.test",
"auth-attempt",
"waiting for user input...",
);
assert.deepEqual(awaitingUserInput, []);
assert.deepEqual(chainProgress, []);
});
test("startSSH keeps interactive source auth retries off unrelated pooled transports", async () => {
const captured: Record<string, unknown>[] = [];
const terminalBackend = {
@@ -3,6 +3,7 @@ import type { ProviderValidationIssue } from "@netcatty/plugin-contract";
import { logger } from "../../../lib/logger";
import type { Host, SSHKey } from "../../../types";
import type { TerminalSessionExitEvent } from "../../../application/state/resolveTerminalSessionExitIntent";
import { setTerminalBootEpoch } from "../../../domain/terminalBootEpoch";
import type { TerminalSessionStartersContext } from "./createTerminalSessionStarters.types";
export type {
PendingAuth,
@@ -16,8 +17,8 @@ import {
attachSessionToTerminal,
buildTermEnv,
closeOrphanBackendSession,
createBootAttemptGuard,
getFlowController,
isTerminalBootActive,
notePendingOutputScrollIfEnabled,
resetTerminalLineTimestampState,
tryAttachSessionToTerminal,
@@ -124,6 +125,16 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
ctx.setChainProgress(null);
};
const createAttemptGuards = () => {
const bootEpoch = ctx.bootEpochRef?.current ?? 0;
const isCurrentAttempt = createBootAttemptGuard(ctx);
return {
bootEpoch,
isCurrentAttempt,
ignoreStaleAttemptUi: () => !isCurrentAttempt(),
};
};
const consumeRestoreCwdIntent = (term: XTerm, id: string): void => {
const intent = ctx.restoreCwdIntentRef?.current;
if (!intent) return;
@@ -186,6 +197,10 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
const startSSH = async (term: XTerm) => {
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
// Correlate host-key prompts with this boot so a superseded start cannot
// reopen approval UI after disconnect → reconnect.
setTerminalBootEpoch(ctx.sessionId, bootEpoch);
if (!ctx.terminalBackend.backendAvailable()) {
ctx.setError("Native SSH bridge unavailable. Launch via Electron app.");
writeTerminalLine(
@@ -439,6 +454,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
const unsub = ctx.terminalBackend.onChainProgress((sid, hop, total, label, status, error) => {
// P1: Only process events for this session
if (sid !== ctx.sessionId) return;
// Disconnect/reconnect can leave two SSH starts sharing this UI
// sessionId; ignore progress from the superseded boot attempt.
if (!isCurrentAttempt()) return;
// P3: Only show chain progress UI for multi-hop connections
if (total > 1) {
@@ -599,6 +617,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
sshTcpConnectTimeoutMs: connectionTimeouts.tcpConnectTimeoutSeconds * 1000,
sshAuthReadyTimeoutMs: connectionTimeouts.authReadyTimeoutSeconds * 1000,
verifyHostKeys: globalTerminalSettings.verifyHostKeys,
bootEpoch,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
sshDebugLogEnabled: ctx.sshDebugLogEnabled,
identityFilePaths: attempt.useIdentityFiles ? targetIdentityFilePaths : undefined,
@@ -686,6 +705,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
id = await startAttempt({ key, password: hasPassword ? effectivePassword : undefined, useIdentityFiles: true });
} catch (err) {
if (isAuthError(err) && hasPassword) {
// Disconnect/reconnect may have invalidated this boot; do not
// launch a password fallback that cannot be cleaned up yet.
if (!isCurrentAttempt()) throw err;
ctx.setProgressLogs((prev) => [
...prev,
"Key auth failed. Trying password...",
@@ -700,16 +722,27 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
if (unsubscribeChainProgress) unsubscribeChainProgress();
// A superseded start may still resolve after reconnect; do not clear the
// replacement's MFA wait / connection-timeout state, and do not close the
// shared sessionId while the newer boot is active.
if (!isCurrentAttempt()) {
closeOrphanBackendSession(ctx, id, { bootEpoch });
return;
}
ctx.setIsConnectionAwaitingUserInput?.(false);
if (!tryAttachSessionToTerminal(ctx, term, id, {
isCurrentAttempt,
bootEpoch,
onConnected: () => ctx.setChainProgress(null),
onExitMessage: (evt) =>
`\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
sudoAutofillCandidates: resolveSudoAutofillCandidates(),
})) {
abortSessionStartAfterUnmount();
// Only the current attempt may clear UI; a stale attach must not
// disconnect a newer reconnect that already re-armed boot.
if (isCurrentAttempt()) abortSessionStartAfterUnmount();
return;
}
@@ -730,6 +763,14 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}, 600);
}
} catch (err) {
// Always drop this attempt's progress listener; only the current
// boot may reset shared reconnect/UI state that a replacement owns.
if (unsubscribeChainProgress) unsubscribeChainProgress();
if (ignoreStaleAttemptUi()) return;
ctx.setChainProgress(null);
ctx.setIsConnectionAwaitingUserInput?.(false);
ctx.setIsConnectionPastTcpDial?.(false);
const message = err instanceof Error ? err.message : String(err);
const authError = isAuthError(err);
@@ -763,15 +804,11 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
writeTerminalLine(ctx, term, `\r\n[Failed to start SSH: ${message}]`);
ctx.updateStatus("disconnected");
}
ctx.setChainProgress(null);
ctx.setIsConnectionAwaitingUserInput?.(false);
ctx.setIsConnectionPastTcpDial?.(false);
if (unsubscribeChainProgress) unsubscribeChainProgress();
}
};
const startTelnet = async (term: XTerm) => {
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
if (!ctx.terminalBackend.telnetAvailable()) {
ctx.setError("Telnet bridge unavailable. Please run the desktop build.");
writeTerminalLine(ctx, term, "\r\n[Telnet bridge unavailable. Please run the desktop build.]");
@@ -879,7 +916,14 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
if (waitsForAutoLogin) {
disposeAutoLoginComplete = ctx.terminalBackend.onTelnetAutoLoginComplete?.(
ctx.sessionId,
() => {
(evt) => {
if (
Number.isFinite(bootEpoch)
&& Number.isFinite(evt?.bootEpoch)
&& evt.bootEpoch !== bootEpoch
) {
return;
}
disposeAutoLoginListener();
cancelPendingStartupCommand = scheduleStartupCommand(ctx, term, telnetSessionId, () => {
cancelPendingStartupCommand = undefined;
@@ -889,7 +933,16 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
);
disposeAutoLoginCancelled = ctx.terminalBackend.onTelnetAutoLoginCancelled?.(
ctx.sessionId,
cleanupTelnetStartupWait,
(evt) => {
if (
Number.isFinite(bootEpoch)
&& Number.isFinite(evt?.bootEpoch)
&& evt.bootEpoch !== bootEpoch
) {
return;
}
cleanupTelnetStartupWait();
},
);
}
attachTelnetEchoMode(ctx.sessionId);
@@ -905,6 +958,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
charset: ctx.host.charset,
env: telnetEnv,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
bootEpoch,
});
telnetSessionId = id;
if (id !== ctx.sessionId) {
@@ -912,12 +966,14 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
if (!tryAttachSessionToTerminal(ctx, term, id, {
isCurrentAttempt,
bootEpoch,
onExitMessage: (evt) =>
`\r\n[Telnet session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
onExit: cleanupTelnetSession,
})) {
cleanupTelnetSession();
abortSessionStartAfterUnmount();
if (isCurrentAttempt()) abortSessionStartAfterUnmount();
return;
}
@@ -938,6 +994,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
return;
}
cleanupTelnetSession();
if (ignoreStaleAttemptUi()) return;
ctx.setError(message);
writeTerminalLine(ctx, term, `\r\n[Failed to start Telnet: ${message}]`);
ctx.updateStatus("disconnected");
@@ -945,6 +1002,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
const startMosh = async (term: XTerm) => {
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
if (!ctx.terminalBackend.moshAvailable()) {
ctx.setError("Mosh bridge unavailable. Please run the desktop build.");
writeTerminalLine(ctx, term, "\r\n[Mosh bridge unavailable. Please run the desktop build.]");
@@ -1083,7 +1141,14 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
cancelPendingStartupCommand = undefined;
});
};
const onMoshReady = () => {
const onMoshReady = (evt?: { sessionId: string; bootEpoch?: number }) => {
if (
Number.isFinite(bootEpoch)
&& Number.isFinite(evt?.bootEpoch)
&& evt.bootEpoch !== bootEpoch
) {
return;
}
moshReadyFired = true;
if (sessionAttached) {
runMoshStartup();
@@ -1129,10 +1194,13 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
charset: ctx.host.charset,
env: moshEnv,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
bootEpoch,
});
attachedSessionId = id;
if (!tryAttachSessionToTerminal(ctx, term, id, {
isCurrentAttempt,
bootEpoch,
onExitMessage: (evt) =>
`\r\n[Mosh session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
// Real backend exit only — do not chain onto disposeExitRef, because
@@ -1143,7 +1211,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
sudoAutofillCandidates: resolveSudoAutofillCandidates(),
})) {
cleanupMoshStartupWait();
abortSessionStartAfterUnmount();
if (isCurrentAttempt()) abortSessionStartAfterUnmount();
return;
}
sessionAttached = true;
@@ -1162,6 +1230,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
disposeMoshReady = undefined;
cancelPendingStartupCommand?.();
cancelPendingStartupCommand = undefined;
if (ignoreStaleAttemptUi()) return;
const message = err instanceof Error ? err.message : String(err);
ctx.setError(message);
writeTerminalLine(ctx, term, `\r\n[Failed to start Mosh: ${message}]`);
@@ -1170,6 +1239,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
const startEt = async (term: XTerm) => {
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
if (!ctx.terminalBackend.etAvailable()) {
ctx.setError("EternalTerminal bridge unavailable. Please run the desktop build.");
writeTerminalLine(ctx, term, "\r\n[EternalTerminal bridge unavailable. Please run the desktop build.]");
@@ -1439,15 +1509,20 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
charset: ctx.host.charset,
env: etEnv,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
bootEpoch,
});
if (!tryAttachSessionToTerminal(ctx, term, id, {
isCurrentAttempt,
bootEpoch,
onExitMessage: (evt) =>
`\r\n[EternalTerminal session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
sudoAutofillCandidates: resolveSudoAutofillCandidates(),
})) {
abortSessionStartAfterUnmount();
// Only the current attempt may clear UI; a stale attach must not
// disconnect a newer reconnect that already re-armed boot.
if (isCurrentAttempt()) abortSessionStartAfterUnmount();
return;
}
@@ -1463,6 +1538,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}, 600);
}
} catch (err) {
if (ignoreStaleAttemptUi()) return;
const message = err instanceof Error ? err.message : String(err);
ctx.setError(message);
writeTerminalLine(ctx, term, `\r\n[Failed to start EternalTerminal: ${message}]`);
@@ -1471,6 +1547,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
const startPluginConnection = async (term: XTerm) => {
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
if (!ctx.terminalBackend.pluginConnectionAvailable()) {
ctx.setError("Plugin connection bridge unavailable. Please run the desktop build with Plugin Development enabled.");
writeTerminalLine(ctx, term, "\r\n[Plugin connection bridge unavailable.]");
@@ -1508,9 +1585,12 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
startController.abort(new DOMException("Plugin connection request was cancelled", "AbortError"));
if (ctx.terminalBackend.cancelPluginExtensionRequest) {
try {
void Promise.resolve(ctx.terminalBackend.cancelPluginExtensionRequest(requestId)).catch((err) => {
const cancelPromise = Promise.resolve(
ctx.terminalBackend.cancelPluginExtensionRequest(requestId),
).catch((err) => {
logger.warn("Failed to cancel pending plugin connection request", err);
});
ctx.trackSessionCleanup?.(cancelPromise);
} catch (err) {
logger.warn("Failed to cancel pending plugin connection request", err);
}
@@ -1521,7 +1601,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
bootMonitorTimer = setTimeout(() => {
bootMonitorTimer = null;
if (pendingCancelReleased) return;
if (!isTerminalBootActive(ctx)) {
if (!isCurrentAttempt()) {
cancelPendingStart();
return;
}
@@ -1538,7 +1618,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
ctx.disposeExitRef.current = cancelPendingStart;
scheduleBootMonitor();
try {
const opened = await ctx.terminalBackend.startPluginConnection({
const startPromise = ctx.terminalBackend.startPluginConnection({
requestId,
sessionId: ctx.sessionId,
protocol: ctx.host.protocol,
@@ -1557,9 +1637,17 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
: {}),
signal: startController.signal,
});
// Disconnect may cancel before this settles; reconnect must wait until
// finishExternalSession cleared the shared sessionId registration.
ctx.trackSessionCleanup?.(startPromise);
const opened = await startPromise;
releasePendingStartCancellation();
if (startController.signal.aborted || !isTerminalBootActive(ctx)) {
closeOrphanBackendSession(ctx, opened.sessionId);
if (!isCurrentAttempt()) {
closeOrphanBackendSession(ctx, opened.sessionId, { bootEpoch });
return;
}
if (startController.signal.aborted) {
closeOrphanBackendSession(ctx, opened.sessionId, { bootEpoch });
abortSessionStartAfterUnmount();
return;
}
@@ -1577,13 +1665,17 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
scheduleStartupCommand(ctx, term, id);
};
if (!tryAttachSessionToTerminal(ctx, term, id, {
isCurrentAttempt,
bootEpoch,
onExitMessage: formatPluginConnectionExitMessage,
requireExplicitConnectionReady: true,
onConnected: (meta) => {
if (meta?.pluginConnectionReady === true) schedulePluginStartup();
},
})) {
abortSessionStartAfterUnmount();
// Only the current attempt may clear UI; a stale attach must not
// disconnect a newer reconnect that already re-armed boot.
if (isCurrentAttempt()) abortSessionStartAfterUnmount();
return;
}
if (opened.status === "connected") {
@@ -1592,10 +1684,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
} catch (error) {
releasePendingStartCancellation();
if (!isTerminalBootActive(ctx)) {
abortSessionStartAfterUnmount();
return;
}
if (ignoreStaleAttemptUi()) return;
const message = error instanceof Error ? error.message : String(error);
ctx.setError(message);
writeTerminalLine(ctx, term, "\r\n[Failed to start plugin connection. See connection details.]");
@@ -1604,6 +1693,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
const startLocal = async (term: XTerm) => {
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
if (!ctx.terminalBackend.localAvailable()) {
ctx.setError("Local shell bridge unavailable. Please run the desktop build.");
writeTerminalLine(
@@ -1636,10 +1726,11 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
TERM: ctx.terminalSettings?.terminalEmulationType ?? "xterm-256color",
},
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
bootEpoch,
});
if (!isTerminalBootActive(ctx)) {
closeOrphanBackendSession(ctx, id);
if (!isCurrentAttempt()) {
closeOrphanBackendSession(ctx, id, { bootEpoch });
return;
}
@@ -1714,6 +1805,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
consumeRestoreCwdIntent(term, id);
scheduleStartupCommand(ctx, term, id);
} catch (err) {
if (ignoreStaleAttemptUi()) return;
const message = err instanceof Error ? err.message : String(err);
ctx.setError(message);
writeTerminalLine(ctx, term, `\r\n[Failed to start local shell: ${message}]`);
@@ -1723,6 +1815,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
// Start Serial session
const startSerial = async (term: XTerm) => {
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
if (!ctx.serialConfig) {
ctx.setError("No serial configuration provided");
writeTerminalLine(ctx, term, "\r\n[Error: No serial configuration provided]");
@@ -1746,15 +1839,20 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
flowControl: ctx.serialConfig.flowControl,
charset: ctx.host.charset,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
bootEpoch,
});
if (!tryAttachSessionToTerminal(ctx, term, id, {
isCurrentAttempt,
bootEpoch,
onExitMessage: (evt) =>
`\r\n[serial port closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
// Convert lone LF to CRLF to prevent "staircase effect" in serial terminals
convertLfToCrlf: true,
})) {
abortSessionStartAfterUnmount();
// Only the current attempt may clear UI; a stale attach must not
// disconnect a newer reconnect that already re-armed boot.
if (isCurrentAttempt()) abortSessionStartAfterUnmount();
return;
}
@@ -1763,6 +1861,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
ctx.setProgressValue(100);
writeTerminalLine(ctx, term, `[Connected to ${ctx.serialConfig.path} at ${ctx.serialConfig.baudRate} baud]`);
} catch (err) {
if (ignoreStaleAttemptUi()) return;
const message = err instanceof Error ? err.message : String(err);
ctx.setError(message);
writeTerminalLine(ctx, term, `\r\n[Failed to connect to serial port: ${message}]`);
@@ -73,15 +73,15 @@ export type TerminalBackendApi = {
) => () => void;
onTelnetAutoLoginComplete?: (
sessionId: string,
cb: (evt: { sessionId: string }) => void,
cb: (evt: { sessionId: string; bootEpoch?: number }) => void,
) => (() => void) | undefined;
onTelnetAutoLoginCancelled?: (
sessionId: string,
cb: (evt: { sessionId: string }) => void,
cb: (evt: { sessionId: string; bootEpoch?: number }) => void,
) => (() => void) | undefined;
onMoshSessionReady?: (
sessionId: string,
cb: (evt: { sessionId: string }) => void,
cb: (evt: { sessionId: string; bootEpoch?: number }) => void,
) => (() => void) | undefined;
onTelnetEchoMode?: (
sessionId: string,
@@ -103,7 +103,7 @@ export type TerminalBackendApi = {
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean; sensitive?: boolean; lineDelayMs?: number; logRewrite?: ProgrammaticCommandLogRewrite }) => void;
interruptSession?: (sessionId: string, trace?: NetcattyTerminalInterruptTrace) => void;
resizeSession: (sessionId: string, cols: number, rows: number) => void;
closeSession: (sessionId: string) => void | Promise<void>;
closeSession: (sessionId: string, options?: { bootEpoch?: number }) => void | Promise<void>;
/** Pause/resume the source stream for output back-pressure (optional). */
setSessionFlowPaused?: (sessionId: string, paused: boolean) => void;
/** Acknowledge rendered terminal output bytes for main-process IPC back-pressure. */
@@ -189,6 +189,11 @@ export type TerminalSessionStartersContext = {
isVisibleRef?: RefObject<boolean>;
/** False after unmount/teardown so in-flight session starts skip attach. */
isBootActiveRef?: RefObject<boolean>;
/**
* Monotonic boot epoch. Disconnect / a newer reconnect bumps this so an
* older in-flight start cannot become current again when boot is re-armed.
*/
bootEpochRef?: RefObject<number>;
pendingOutputScrollRef?: RefObject<boolean>;
sessionRef: RefObject<string | null>;
@@ -196,6 +201,11 @@ export type TerminalSessionStartersContext = {
hasRunStartupCommandRef: RefObject<boolean>;
disposeDataRef: RefObject<(() => void) | null>;
disposeExitRef: RefObject<(() => void) | null>;
/**
* Track an async cleanup (e.g. cancelled plugin start → finishExternalSession)
* so Disconnect/Reconnect can await it before starting a replacement boot.
*/
trackSessionCleanup?: (promise: Promise<unknown>) => void;
disposeTelnetEchoModeRef?: RefObject<(() => void) | null>;
fitAddonRef: RefObject<FitAddon | null>;
serializeAddonRef: RefObject<SerializeAddon | null>;
@@ -743,12 +743,21 @@ const writeSessionDataImmediate = (
export const isTerminalBootActive = (ctx: TerminalSessionStartersContext): boolean =>
!ctx.isBootActiveRef || ctx.isBootActiveRef.current;
/** Capture the current boot epoch so a later reconnect cannot revive this attempt. */
export const createBootAttemptGuard = (
ctx: TerminalSessionStartersContext,
): (() => boolean) => {
const epoch = ctx.bootEpochRef?.current ?? 0;
return () => isTerminalBootActive(ctx) && (ctx.bootEpochRef?.current ?? 0) === epoch;
};
export const closeOrphanBackendSession = (
ctx: TerminalSessionStartersContext,
sessionBackendId: string,
opts?: { bootEpoch?: number },
) => {
try {
const closeResult = ctx.terminalBackend.closeSession(sessionBackendId);
const closeResult = ctx.terminalBackend.closeSession(sessionBackendId, opts);
void Promise.resolve(closeResult).catch((err) => {
logger.warn("Failed to close orphan session after terminal unmount", err);
});
@@ -769,10 +778,14 @@ export const tryAttachSessionToTerminal = (
convertLfToCrlf?: boolean;
sudoAutofillPassword?: string;
sudoAutofillCandidates?: SudoPasswordAutofillCandidate[];
isCurrentAttempt?: () => boolean;
bootEpoch?: number;
},
): boolean => {
if (!isTerminalBootActive(ctx)) {
closeOrphanBackendSession(ctx, id);
if (!isTerminalBootActive(ctx) || opts?.isCurrentAttempt?.() === false) {
// Pass the attempt bootEpoch so closeSession no-ops when a newer boot
// already owns this shared sessionId in the main-process registry.
closeOrphanBackendSession(ctx, id, { bootEpoch: opts?.bootEpoch });
return false;
}
attachSessionToTerminal(ctx, term, id, opts);
+31 -9
View File
@@ -22,6 +22,7 @@ import {
resolveHibernatePreferWasmSerialize,
resolveTerminalHibernateEnabledForProtocol,
} from '../../domain/terminalHibernate';
import { setTerminalBootEpoch } from '../../domain/terminalBootEpoch';
import { applyUserCursorBlinkPreference } from './runtime/cursorPreference';
import { resolveCursorLineHighlightBackground } from '../../domain/cursorLineHighlight';
import { getFlowControllerForTerm } from './runtime/terminalSessionAttachment';
@@ -164,7 +165,7 @@ export function resolveSelectionOverlayPosition(term: any, container: HTMLElemen
}
export function useTerminalEffects(ctx: TerminalEffectsContext) {
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef, onPluginRuntimeCwdChange, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRules, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, onSnippetShortkeyRef, snippetsRef, splitResizeActive, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState } = ctx;
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef, onPluginRuntimeCwdChange, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRules, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, onSnippetShortkeyRef, snippetsRef, splitResizeActive, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState } = ctx;
const effectiveTerminalProtocol = resolveEffectiveTerminalProtocol(host);
const hibernateHiddenTabs = resolveTerminalHibernateEnabledForProtocol(
terminalSettings,
@@ -364,6 +365,22 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
useEffect(() => {
const dispose = terminalBackend.onHostKeyVerification?.((request) => {
if (request.sessionId !== sessionId) return;
// Disconnect keeps the pane mounted; reject late host-key prompts instead
// of reopening approval UI on a disconnected / aborted boot. After a
// reconnect the shared flags are active again, so also require the
// request's boot epoch (when present) to match the current boot.
const requestBootEpoch = request.bootEpoch;
const bootEpochMismatch = Number.isFinite(requestBootEpoch)
&& bootEpochRef
&& requestBootEpoch !== bootEpochRef.current;
if (
!isBootActiveRef.current
|| statusRef.current === "disconnected"
|| bootEpochMismatch
) {
void terminalBackend.respondHostKeyVerification?.(request.requestId, false);
return;
}
setPendingHostKeyRequestId(request.requestId);
setPendingHostKeyInfo(toHostKeyInfo(request));
@@ -387,6 +404,10 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
let disposed = false;
let initialFitTimer: ReturnType<typeof setTimeout> | undefined;
const closeGeneration = ++terminalBootCloseGenerationRef.current;
if (bootEpochRef) {
bootEpochRef.current += 1;
setTerminalBootEpoch(sessionId, bootEpochRef.current);
}
isBootActiveRef.current = true;
terminalDataCapturedRef.current = false;
connectionLogBufferRef.current.reset();
@@ -688,36 +709,37 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
return;
}
const bootStillActive = () => !disposed && isBootActiveRef.current;
if (effectiveTerminalProtocol.startsWith("plugin:")) {
setBackendConnectingStatus();
setProgressLogs(["Initializing plugin connection..."]);
await sessionStarters.startPluginConnection(term);
if (disposed) return;
if (!bootStillActive()) return;
} else if (effectiveTerminalProtocol === "serial") {
setBackendConnectingStatus();
setProgressLogs(["Initializing serial connection..."]);
await sessionStarters.startSerial(term);
if (disposed) return;
if (!bootStillActive()) return;
} else if (effectiveTerminalProtocol === "local") {
setBackendConnectingStatus();
setProgressLogs(["Initializing local shell..."]);
await sessionStarters.startLocal(term);
if (disposed) return;
if (!bootStillActive()) return;
} else if (effectiveTerminalProtocol === "telnet") {
setBackendConnectingStatus();
setProgressLogs(["Initializing Telnet connection..."]);
await sessionStarters.startTelnet(term);
if (disposed) return;
if (!bootStillActive()) return;
} else if (effectiveTerminalProtocol === "mosh") {
setBackendConnectingStatus();
setProgressLogs(["Initializing Mosh connection..."]);
await sessionStarters.startMosh(term);
if (disposed) return;
if (!bootStillActive()) return;
} else if (effectiveTerminalProtocol === "et") {
setBackendConnectingStatus();
setProgressLogs(["Initializing EternalTerminal connection..."]);
await sessionStarters.startEt(term);
if (disposed) return;
if (!bootStillActive()) return;
} else {
const resolvedAuth = resolveHostAuth({ host, keys, identities });
const hasPassword = !!resolvedAuth.password;
@@ -738,10 +760,10 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
setBackendConnectingStatus();
setProgressLogs(["Initializing secure channel..."]);
await sessionStarters.startSSH(term);
if (disposed) return;
if (!bootStillActive()) return;
}
} catch (err) {
if (disposed) return;
if (disposed || !isBootActiveRef.current) return;
logger.error("Failed to initialize terminal", err);
setError(err instanceof Error ? err.message : String(err));
updateStatus("disconnected");
+18
View File
@@ -0,0 +1,18 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
clearTerminalBootEpoch,
isTerminalBootEpochCurrent,
setTerminalBootEpoch,
} from "./terminalBootEpoch";
test("mismatched boot epochs are treated as stale", () => {
setTerminalBootEpoch("session-1", 3);
assert.equal(isTerminalBootEpochCurrent("session-1", 1), false);
assert.equal(isTerminalBootEpochCurrent("session-1", 3), true);
assert.equal(isTerminalBootEpochCurrent("session-1", undefined), true);
clearTerminalBootEpoch("session-1");
assert.equal(isTerminalBootEpochCurrent("session-1", 3), false);
assert.equal(isTerminalBootEpochCurrent("session-1", undefined), true);
});
+31
View File
@@ -0,0 +1,31 @@
/**
* Tracks the active terminal boot epoch per UI session id so async auth
* prompts (keyboard-interactive / MFA) can reject superseded reconnect
* attempts that still share the same sessionId.
*/
const terminalBootEpochs = new Map<string, number>();
export function setTerminalBootEpoch(sessionId: string, epoch: number): void {
if (!sessionId) return;
terminalBootEpochs.set(sessionId, epoch);
}
export function clearTerminalBootEpoch(sessionId: string): void {
if (!sessionId) return;
terminalBootEpochs.delete(sessionId);
}
export function getTerminalBootEpoch(sessionId: string): number | undefined {
return terminalBootEpochs.get(sessionId);
}
/** Epoch-tagged prompts need a live map entry; missing entry means the session tore down. Legacy prompts without bootEpoch stay eligible. */
export function isTerminalBootEpochCurrent(
sessionId: string,
requestEpoch: number | undefined,
): boolean {
if (!Number.isFinite(requestEpoch)) return true;
const current = terminalBootEpochs.get(sessionId);
if (current === undefined) return false;
return current === requestEpoch;
}
+2
View File
@@ -208,6 +208,7 @@ const createHostVerifier = ({
port = 22,
knownHosts = [],
verifyHostKeys = true,
bootEpoch,
}) => (rawKey, callback) => {
if (verifyHostKeys === false) {
callback(true);
@@ -238,6 +239,7 @@ const createHostVerifier = ({
publicKey: keyInfo.publicKey,
knownHostId: decision.knownHost?.id,
knownFingerprint: decision.expectedFingerprint,
...(Number.isFinite(bootEpoch) ? { bootEpoch: Number(bootEpoch) } : {}),
}).then((response) => {
callback(Boolean(response?.accept));
}).catch(() => {
+27
View File
@@ -356,6 +356,33 @@ test("createHostVerifier prompts for unknown host keys and waits for user respon
assert.equal(await acceptedPromise, true);
});
test("createHostVerifier forwards bootEpoch on host-key prompts", async () => {
const rawKey = Buffer.from("new server key");
const sent = [];
const sender = {
id: 1,
isDestroyed: () => false,
send: (channel, payload) => sent.push({ channel, payload }),
};
const verifier = createHostVerifier({
sender,
sessionId: "session-1",
hostname: "switch.local",
port: 22,
knownHosts: [],
bootEpoch: 7,
});
const acceptedPromise = new Promise((resolve) => verifier(rawKey, resolve));
assert.equal(sent[0].payload.bootEpoch, 7);
handleResponse({ sender: { id: sender.id } }, {
requestId: sent[0].payload.requestId,
accept: false,
});
assert.equal(await acceptedPromise, false);
});
test("createHostVerifier includes existing known host details when a key changes", async () => {
// A well-formed wire blob so `describeHostKey` can recover keyType =
// "ssh-ed25519"; that triggers the strict (host, port, type) mismatch
+24
View File
@@ -73,6 +73,25 @@ function cancelPassphraseRequest(requestId, reason = "cancelled") {
return cancelled;
}
function cancelPassphraseRequestsForSession(sessionId, reason = "session-closed", bootEpoch) {
if (!sessionId) return 0;
const requestedEpoch = Number.isFinite(bootEpoch) ? Number(bootEpoch) : undefined;
let cancelled = 0;
for (const [requestId, pending] of [...passphraseRequests.entries()]) {
if (pending.sessionId !== sessionId) continue;
// A stale close for an older boot must not cancel a newer reconnect's prompt.
if (
requestedEpoch !== undefined
&& Number.isFinite(pending.bootEpoch)
&& pending.bootEpoch > requestedEpoch
) {
continue;
}
if (cancelPassphraseRequest(requestId, reason)) cancelled += 1;
}
return cancelled;
}
function requestPassphrase(sender, keyPath, keyName, hostname, passphraseInvalid, options = {}) {
return new Promise((resolve) => {
if (!sender || sender.isDestroyed()) {
@@ -111,6 +130,8 @@ function requestPassphrase(sender, keyPath, keyName, hostname, passphraseInvalid
webContentsId: sender.id,
keyPath,
keyName,
sessionId: typeof options.sessionId === "string" ? options.sessionId : undefined,
bootEpoch: Number.isFinite(options.bootEpoch) ? Number(options.bootEpoch) : undefined,
createdAt: Date.now(),
timeoutId,
signal,
@@ -130,6 +151,8 @@ function requestPassphrase(sender, keyPath, keyName, hostname, passphraseInvalid
keyName,
hostname,
passphraseInvalid: !!passphraseInvalid,
...(typeof options.sessionId === "string" ? { sessionId: options.sessionId } : {}),
...(Number.isFinite(options.bootEpoch) ? { bootEpoch: Number(options.bootEpoch) } : {}),
});
} catch (err) {
console.error('[Passphrase] Failed to send passphrase request:', err);
@@ -189,6 +212,7 @@ module.exports = {
generateRequestId,
requestPassphrase,
cancelPassphraseRequest,
cancelPassphraseRequestsForSession,
handleResponse,
registerHandler,
getRequests,
+203
View File
@@ -0,0 +1,203 @@
/**
* Correlate overlapping terminal starts that share one UI sessionId.
* A higher bootEpoch owns the registry slot; mismatched closes are no-ops.
*/
const pendingBootAborts = new Map();
function normalizeBootEpoch(bootEpoch) {
if (!Number.isFinite(bootEpoch)) return undefined;
return Number(bootEpoch);
}
function attachBootEpoch(session, bootEpoch) {
const normalized = normalizeBootEpoch(bootEpoch);
if (normalized === undefined || !session || typeof session !== "object") return session;
session.bootEpoch = normalized;
return session;
}
/**
* Register an AbortController for an in-flight start that has not yet claimed
* the sessions registry (e.g. SSH passphrase prompts before shell open).
* A newer register for the same sessionId aborts the older controller.
*/
function registerPendingBootAbort(sessionId, bootEpoch) {
if (!sessionId) return new AbortController();
const existing = pendingBootAborts.get(sessionId);
if (existing) {
try { existing.controller.abort(); } catch { /* ignore */ }
}
const controller = new AbortController();
pendingBootAborts.set(sessionId, {
controller,
bootEpoch: normalizeBootEpoch(bootEpoch),
});
return controller;
}
/**
* Abort a pending boot. When bootEpoch is provided, only abort if the pending
* entry is not newer than that epoch (so a stale close cannot kill a reconnect).
*/
function abortPendingBoot(sessionId, bootEpoch) {
if (!sessionId) return false;
const pending = pendingBootAborts.get(sessionId);
if (!pending) return false;
const requested = normalizeBootEpoch(bootEpoch);
if (
requested !== undefined
&& pending.bootEpoch !== undefined
&& pending.bootEpoch > requested
) {
return false;
}
try { pending.controller.abort(); } catch { /* ignore */ }
if (pendingBootAborts.get(sessionId) === pending) {
pendingBootAborts.delete(sessionId);
}
return true;
}
function clearPendingBootAbort(sessionId, controller) {
if (!sessionId || !controller) return;
const pending = pendingBootAborts.get(sessionId);
if (pending?.controller === controller) {
pendingBootAborts.delete(sessionId);
}
}
/**
* Best-effort teardown for a session object that lost its registry slot to a
* newer bootEpoch. Must not look the session up by ID (the map already points
* at the replacement).
*
* @param {object} session
* @param {string} [sessionId] Registry key the displaced session previously owned.
*/
function disposeDisplacedSessionResources(session, sessionId) {
if (!session || session._displacedDisposed) return;
session._displacedDisposed = true;
try { session.zmodemSentry?.cancel?.(); } catch { /* ignore */ }
try { session.discardPendingData?.(); } catch { /* ignore */ }
try { session.releaseTelnetGeneration?.(); } catch { /* ignore */ }
// Exit handlers for Mosh/ET/serial bail out once the registry no longer
// points at them, so they never stop their own log stream. Stop it here
// before a replacement (possibly without logging) keeps appendData-ing
// into the displaced file. Claim runs before the replacement starts its
// own stream, so a token-less stop still targets this boot's entry.
if (sessionId) {
try {
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
const logToken = session.logStreamToken ?? session._logStreamToken;
void sessionLogStreamManager.stopStream(sessionId, logToken);
} catch { /* ignore */ }
}
try {
if (session.stream) {
try { session.stream.close(); } catch { /* ignore */ }
if (session.connRef) {
try {
const { releaseConnectionRef } = require("./sshConnectionPool.cjs");
releaseConnectionRef(session);
} catch {
try { session.conn?.end?.(); } catch { /* ignore */ }
}
} else {
try { session.conn?.end?.(); } catch { /* ignore */ }
for (const hop of session.chainConnections || []) {
try { hop.end?.(); } catch { /* ignore */ }
}
}
} else if (session.proc) {
try { session.proc.kill(); } catch { /* ignore */ }
try { session.moshStatsConn?.end?.(); } catch { /* ignore */ }
try { session.etStatsConn?.end?.(); } catch { /* ignore */ }
// ET stores private HOME/key/askpass paths on the session; normal close
// and owning-exit handlers clean them, but displaced owners skip those
// paths once the registry slot is overwritten.
if (Array.isArray(session.externalAuthArtifacts) && !session.externalAuthArtifactsCleaned) {
session.externalAuthArtifactsCleaned = true;
const fs = require("node:fs");
for (const artifactPath of session.externalAuthArtifacts) {
try {
fs.rmSync(artifactPath, { recursive: true, force: true });
} catch {
// ignore cleanup failures
}
}
}
} else if (session.socket) {
try { session.socket.destroy(); } catch { /* ignore */ }
} else if (session.serialPort) {
try { session.serialPort.close(); } catch { /* ignore */ }
} else if (session.chainConnections) {
for (const hop of session.chainConnections) {
try { hop.end?.(); } catch { /* ignore */ }
}
}
} catch {
// Best effort only.
}
}
/**
* @returns {{ ok: true, displaced?: object } | { ok: false, reason: "superseded" }}
*/
function claimSessionSlot(sessions, sessionId, session, bootEpoch) {
if (!sessions || typeof sessions.get !== "function" || typeof sessions.set !== "function") {
return { ok: true };
}
const normalized = normalizeBootEpoch(bootEpoch);
const existing = sessions.get(sessionId);
if (
existing
&& existing !== session
&& Number.isFinite(existing.bootEpoch)
&& normalized !== undefined
&& normalized < existing.bootEpoch
) {
return { ok: false, reason: "superseded" };
}
if (normalized !== undefined) {
session.bootEpoch = normalized;
}
let displaced;
// When a newer boot replaces an older registry entry, mark and return the
// old object so the caller can tear down its transport without touching the
// replacement slot.
if (
existing
&& existing !== session
&& Number.isFinite(existing.bootEpoch)
&& normalized !== undefined
&& normalized > existing.bootEpoch
) {
existing.supersededByBootEpoch = normalized;
existing.closed = true;
displaced = existing;
}
sessions.set(sessionId, session);
if (displaced) {
disposeDisplacedSessionResources(displaced, sessionId);
}
return displaced ? { ok: true, displaced } : { ok: true };
}
function sessionMatchesBootEpoch(session, bootEpoch) {
const normalized = normalizeBootEpoch(bootEpoch);
if (normalized === undefined) return true;
if (!session || !Number.isFinite(session.bootEpoch)) return true;
return session.bootEpoch === normalized;
}
module.exports = {
abortPendingBoot,
attachBootEpoch,
claimSessionSlot,
clearPendingBootAbort,
disposeDisplacedSessionResources,
normalizeBootEpoch,
registerPendingBootAbort,
sessionMatchesBootEpoch,
};
@@ -0,0 +1,83 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
claimSessionSlot,
sessionMatchesBootEpoch,
} = require("./sessionBootEpoch.cjs");
test("claimSessionSlot rejects a superseded lower boot epoch", () => {
const sessions = new Map();
const newer = { bootEpoch: 3 };
sessions.set("s1", newer);
const stale = {};
const result = claimSessionSlot(sessions, "s1", stale, 1);
assert.equal(result.ok, false);
assert.equal(sessions.get("s1"), newer);
assert.equal(stale.bootEpoch, undefined);
});
test("pending boot abort cancels older generations but not newer ones", () => {
const {
abortPendingBoot,
clearPendingBootAbort,
registerPendingBootAbort,
} = require("./sessionBootEpoch.cjs");
const older = registerPendingBootAbort("s-pending", 2);
const newer = registerPendingBootAbort("s-pending", 4);
assert.equal(older.signal.aborted, true);
assert.equal(newer.signal.aborted, false);
assert.equal(abortPendingBoot("s-pending", 3), false);
assert.equal(newer.signal.aborted, false);
assert.equal(abortPendingBoot("s-pending", 4), true);
assert.equal(newer.signal.aborted, true);
clearPendingBootAbort("s-pending", newer);
});
test("claimSessionSlot replaces an older boot epoch and marks it superseded", () => {
const sessions = new Map();
const older = {
bootEpoch: 1,
proc: { killed: false, kill() { this.killed = true; } },
externalAuthArtifacts: ["/tmp/et-auth-artifact"],
externalAuthArtifactsCleaned: false,
logStreamToken: Symbol("displaced-log"),
};
const removed = [];
const stoppedLogs = [];
const fs = require("node:fs");
const originalRmSync = fs.rmSync;
fs.rmSync = (target) => { removed.push(target); };
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
const originalStopStream = sessionLogStreamManager.stopStream;
sessionLogStreamManager.stopStream = (id, token) => {
stoppedLogs.push({ id, token });
};
try {
sessions.set("s1", older);
const newer = {};
const result = claimSessionSlot(sessions, "s1", newer, 4);
assert.equal(result.ok, true);
assert.equal(sessions.get("s1"), newer);
assert.equal(newer.bootEpoch, 4);
assert.equal(older.closed, true);
assert.equal(older.supersededByBootEpoch, 4);
assert.equal(older.proc.killed, true);
assert.equal(older._displacedDisposed, true);
assert.equal(older.externalAuthArtifactsCleaned, true);
assert.deepEqual(removed, ["/tmp/et-auth-artifact"]);
assert.equal(result.displaced, older);
assert.equal(stoppedLogs.length, 1);
assert.equal(stoppedLogs[0].id, "s1");
assert.equal(stoppedLogs[0].token, older.logStreamToken);
} finally {
fs.rmSync = originalRmSync;
sessionLogStreamManager.stopStream = originalStopStream;
}
});
test("sessionMatchesBootEpoch ignores closes for a different generation", () => {
assert.equal(sessionMatchesBootEpoch({ bootEpoch: 3 }, 1), false);
assert.equal(sessionMatchesBootEpoch({ bootEpoch: 3 }, 3), true);
assert.equal(sessionMatchesBootEpoch({ bootEpoch: 3 }, undefined), true);
});
+28 -4
View File
@@ -183,6 +183,8 @@ async function preparePrivateKeyForAuth({
hostname,
initialPassphrase,
passphraseSignal,
sessionId,
bootEpoch,
logPrefix = "[SSHAuth]",
onPassphrasePromptShown,
onPassphrasePromptResolved,
@@ -220,7 +222,11 @@ async function preparePrivateKeyForAuth({
promptKeyName,
hostname,
passphraseInvalid,
{ signal: passphraseSignal }
{
signal: passphraseSignal,
sessionId,
bootEpoch,
}
);
onPassphrasePromptResolved?.();
if (result?.cancelled) {
@@ -247,6 +253,8 @@ async function loadIdentityFileForAuth({
hostname,
initialPassphrase,
passphraseSignal,
sessionId,
bootEpoch,
logPrefix = "[SSHAuth]",
onPassphrasePromptShown,
onPassphrasePromptResolved,
@@ -283,7 +291,11 @@ async function loadIdentityFileForAuth({
keyName,
hostname,
passphraseInvalid,
{ signal: passphraseSignal }
{
signal: passphraseSignal,
sessionId,
bootEpoch,
}
);
onPassphrasePromptResolved?.();
if (result?.cancelled) {
@@ -310,6 +322,8 @@ async function loadFirstIdentityFileForAuth({
hostname,
initialPassphrase,
passphraseSignal,
sessionId,
bootEpoch,
logPrefix = "[SSHAuth]",
onLoaded,
onError,
@@ -328,6 +342,8 @@ async function loadFirstIdentityFileForAuth({
hostname,
initialPassphrase,
passphraseSignal,
sessionId,
bootEpoch,
logPrefix,
onPassphrasePromptShown,
onPassphrasePromptResolved,
@@ -1821,6 +1837,7 @@ function createKeyboardInteractiveHandler(options) {
password,
logPrefix = "[SSH]",
scope = "external",
bootEpoch,
shouldSkipAutoFill,
onAutoFill,
onPromptShown,
@@ -1948,6 +1965,7 @@ function createKeyboardInteractiveHandler(options) {
savedPassword: savedPasswordForModal,
allowSavePassword,
scope,
...(Number.isFinite(bootEpoch) ? { bootEpoch: Number(bootEpoch) } : {}),
});
};
}
@@ -1977,7 +1995,7 @@ function applyAuthToConnOpts(connOpts, authConfig) {
* @param {string} [hostname] - Optional hostname for context
* @returns {Promise<{ keys: Array<{ privateKey: string, keyPath: string, keyName: string, passphrase: string }>, cancelled: boolean }>}
*/
async function requestPassphrasesForEncryptedKeys(sender, hostname) {
async function requestPassphrasesForEncryptedKeys(sender, hostname, options = {}) {
const allKeys = await findAllDefaultPrivateKeys({ includeEncrypted: true });
const encryptedKeys = allKeys.filter(k => k.isEncrypted);
@@ -1995,7 +2013,13 @@ async function requestPassphrasesForEncryptedKeys(sender, hostname) {
sender,
keyInfo.keyPath,
keyInfo.keyName,
hostname
hostname,
false,
{
signal: options.signal,
sessionId: options.sessionId,
bootEpoch: options.bootEpoch,
},
);
// Handle different response types
@@ -367,6 +367,26 @@ test("createKeyboardInteractiveHandler includes the request scope in modal paylo
drainPendingRequests(sent);
});
test("createKeyboardInteractiveHandler forwards bootEpoch on modal payloads", () => {
const { sender, sent } = createSender();
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "vps-1.example.com",
password: "hunter2",
scope: "terminal",
bootEpoch: 11,
});
handler("Two-factor", "", "", [passwordPrompt, verificationCodePrompt], () => {});
assert.equal(sent.length, 1);
assert.equal(sent[0].payload.bootEpoch, 11);
drainPendingRequests(sent);
});
test("createKeyboardInteractiveHandler does not auto-fill when no saved password is configured", () => {
const { sender, sent } = createSender();
const autoFillEvents = [];
+23 -2
View File
@@ -599,6 +599,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
port: jump.port || 22,
knownHosts: options.knownHosts,
verifyHostKeys: jump.verifyHostKeys ?? options.verifyHostKeys,
bootEpoch: options.bootEpoch,
});
attachSshDebugLogger(connOpts, sshDiagnosticLogger);
logSshAlgorithms("Jump host", connOpts.algorithms, {
@@ -624,6 +625,8 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
hostname: hopLabel,
initialPassphrase: jump.passphrase,
passphraseSignal: options._passphraseSignal,
sessionId: options.sessionId,
bootEpoch: options.bootEpoch,
logPrefix: `[Chain] Hop ${i + 1}:`,
onPassphrasePromptShown: () => sendProgress(
i + 1, totalHops + 1, hopLabel, "auth-attempt", "waiting for user input...",
@@ -651,6 +654,8 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
hostname: hopLabel,
initialPassphrase: jump.passphrase,
passphraseSignal: options._passphraseSignal,
sessionId: options.sessionId,
bootEpoch: options.bootEpoch,
logPrefix: `[Chain] Hop ${i + 1}:`,
onPassphrasePromptShown: () => sendProgress(
i + 1, totalHops + 1, hopLabel, "auth-attempt", "waiting for user input...",
@@ -694,7 +699,11 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
keyLabel,
hopLabel,
false,
{ signal: options._passphraseSignal }
{
signal: options._passphraseSignal,
sessionId: options.sessionId,
bootEpoch: options.bootEpoch,
}
);
sendProgress(i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'user responded');
if (result?.passphrase) {
@@ -846,6 +855,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
password: jump.password,
logPrefix: `[Chain] Hop ${i + 1}/${totalHops}`,
scope: keyboardInteractiveScope,
bootEpoch: options.bootEpoch,
getAuthBanner: () => authBanner,
shouldSkipAutoFill: () => shouldSkipKiPasswordAutoFill(hopAuthPhase),
onAutoFill: () => sendProgress(
@@ -1207,7 +1217,12 @@ async function startSSHSessionWithRetries(event, options, pendingDialState) {
// Request passphrases from user
const passphraseResult = await requestPassphrasesForEncryptedKeys(
event.sender,
options.hostname
options.hostname,
{
signal: options._passphraseSignal,
sessionId: options.sessionId,
bootEpoch: options.bootEpoch,
},
);
// If user cancelled, don't retry even if some keys were unlocked
@@ -1304,6 +1319,9 @@ async function startSSHSessionWrapper(event, options) {
let sourceReuseState = options.sourceSessionId && options.reuseTransport !== false
? { attempted: false, session: null }
: null;
const sessionId = options.sessionId || require("node:crypto").randomUUID();
const { registerPendingBootAbort, clearPendingBootAbort } = require("./sessionBootEpoch.cjs");
const passphraseAbortController = registerPendingBootAbort(sessionId, options.bootEpoch);
if (options.sourceSessionId && options.reuseTransport !== false) {
const sourceAtRequest = findReusableSession(sessions, options.sourceSessionId);
if (sourceAtRequest?.connRef) {
@@ -1325,6 +1343,8 @@ async function startSSHSessionWrapper(event, options) {
const probeResult = await ensureMacLocalNetworkAccess(options);
return await startSSHSessionWithRetries(event, {
...attachMacLocalNetworkProbeResult(options, probeResult),
sessionId,
_passphraseSignal: passphraseAbortController.signal,
...(sourceReuseState ? { _sourceReuseState: sourceReuseState } : {}),
}, pendingDialState);
} catch (err) {
@@ -1333,6 +1353,7 @@ async function startSSHSessionWrapper(event, options) {
}
throw err;
} finally {
clearPendingBootAbort(sessionId, passphraseAbortController);
if (sourcePinHolder) releaseConnectionRef(sourcePinHolder);
}
}
+64 -27
View File
@@ -365,7 +365,15 @@ printf '%s\n' '${scanCompleteMarker}'`;
cols: options.cols || 80,
rows: options.rows || 24,
};
sessions.set(sessionId, session);
const { claimSessionSlot } = require("../sessionBootEpoch.cjs");
const claim = claimSessionSlot(sessions, sessionId, session, options.bootEpoch);
if (!claim.ok) {
const supersededError = new Error("Connection superseded by a newer reconnect");
supersededError.code = "NETCATTY_BOOT_SUPERSEDED";
try { stream?.close?.(); } catch { /* ignore */ }
try { if (!isReused) conn?.end?.(); } catch { /* ignore */ }
throw supersededError;
}
openTerminalOutputSession?.(sessionId, event.sender);
// Attach the shared connection descriptor to this session. The caller owns
@@ -819,17 +827,24 @@ printf '%s\n' '${scanCompleteMarker}'`;
// the lease count (transferConnectionRef). setupShellSession still
// records connRef; transfer rebinds _sshTransportLeaseId so a later
// releaseConnectionRef(session) returns the right lease.
setupShellSession({
conn,
stream,
options: { ...options, _connRef: connRef },
sessionId,
event,
log,
detachX11Forwarding: null,
chainConnections: [],
isReused: true,
});
try {
setupShellSession({
conn,
stream,
options: { ...options, _connRef: connRef },
sessionId,
event,
log,
detachX11Forwarding: null,
chainConnections: [],
isReused: true,
});
} catch (setupErr) {
// openBoundedSshShellCallback delivers this from a Promise .then
// without catching callback throws — reject via failReuse.
failReuse(setupErr);
return;
}
const copiedSession = sessions.get(sessionId);
if (copiedSession) {
if (typeof transferConnectionRef === "function") {
@@ -852,8 +867,10 @@ printf '%s\n' '${scanCompleteMarker}'`;
)
: Promise.resolve(null);
void newShellPidPromise.then((newShellPid) => {
// Bind PID only to the session this reuse opened. A higher
// bootEpoch reconnect may already own sessionId in the map.
const liveSession = sessions.get(sessionId);
if (liveSession && newShellPid) {
if (liveSession && liveSession === copiedSession && newShellPid) {
liveSession.shellPid = newShellPid;
}
settled = true;
@@ -1134,6 +1151,7 @@ printf '%s\n' '${scanCompleteMarker}'`;
port: options.port || 22,
knownHosts: options.knownHosts,
verifyHostKeys: options.verifyHostKeys,
bootEpoch: options.bootEpoch,
});
// Authentication for final target
@@ -1176,6 +1194,9 @@ printf '%s\n' '${scanCompleteMarker}'`;
identityFilePaths: options.identityFilePaths,
hostname: options.hostname,
initialPassphrase: options.passphrase,
passphraseSignal: options._passphraseSignal,
sessionId,
bootEpoch: options.bootEpoch,
logPrefix: "[SSH]",
onPassphrasePromptShown: () => sendProgress(
totalHops, totalHops, options.hostname, "auth-attempt", "waiting for user input...",
@@ -1199,6 +1220,9 @@ printf '%s\n' '${scanCompleteMarker}'`;
keyName: options.keyId || options.username,
hostname: options.hostname,
initialPassphrase: effectivePassphrase,
passphraseSignal: options._passphraseSignal,
sessionId,
bootEpoch: options.bootEpoch,
logPrefix: "[SSH]",
onPassphrasePromptShown: () => sendProgress(
totalHops, totalHops, options.hostname, "auth-attempt", "waiting for user input...",
@@ -1948,20 +1972,32 @@ printf '%s\n' '${scanCompleteMarker}'`;
// Create the shared reference-counted descriptor for this
// connection now that the owning channel is open, then wire the
// shell up through the shared helper.
const ownerSession = setupShellSession({
conn,
stream,
options: {
...options,
_actualAgentForwarding: Boolean(connectOpts.agentForward),
},
sessionId,
event,
log,
detachX11Forwarding,
chainConnections,
isReused: false,
});
let ownerSession;
try {
ownerSession = setupShellSession({
conn,
stream,
options: {
...options,
_actualAgentForwarding: Boolean(connectOpts.agentForward),
},
sessionId,
event,
log,
detachX11Forwarding,
chainConnections,
isReused: false,
});
} catch (setupErr) {
// Callback runs from openBoundedSshShellCallback's Promise
// .then without a catch — reject the owning start Promise.
if (detachX11Forwarding) {
try { detachX11Forwarding(); } catch { /* ignore */ }
}
settled = true;
reject(setupErr);
return;
}
establishedOwnerSession = ownerSession;
connRef = createConnectionRef(ownerSession, conn, chainConnections);
if (pendingDialCoordination) {
@@ -2194,6 +2230,7 @@ printf '%s\n' '${scanCompleteMarker}'`;
password: options.password,
logPrefix,
scope: "terminal",
bootEpoch: options.bootEpoch,
getAuthBanner: () => authBanner,
shouldSkipAutoFill: () => shouldSkipKiPasswordAutoFill(authPhase),
onAutoFill: () => sendProgress(
+91 -12
View File
@@ -961,7 +961,14 @@ function startLocalSession(event, payload) {
lastIdlePromptAt: 0,
_promptTrackTail: "",
};
sessions.set(sessionId, session);
const { claimSessionSlot } = require("./sessionBootEpoch.cjs");
const claim = claimSessionSlot(sessions, sessionId, session, payload?.bootEpoch);
if (!claim.ok) {
try { proc.kill(); } catch { /* ignore */ }
const supersededError = new Error("Local session superseded by a newer reconnect");
supersededError.code = "NETCATTY_BOOT_SUPERSEDED";
throw supersededError;
}
openTerminalOutputSession(sessionId, event.sender);
ptyProcessTree.registerPid(sessionId, proc.pid);
@@ -980,6 +987,7 @@ function startLocalSession(event, payload) {
timestampsEnabled: Boolean(payload.sessionLog.timestampsEnabled),
startTime: Date.now(),
});
session.logStreamToken = logStreamToken;
}
const {
@@ -1201,6 +1209,24 @@ async function startSerialSession(event, options) {
// tearing down a freshly started stream after a "Restart" reconnect on
// the same sessionId (issue #916).
let logStreamToken = null;
const {
registerPendingBootAbort,
clearPendingBootAbort,
} = require("./sessionBootEpoch.cjs");
const pendingBootAbort = registerPendingBootAbort(sessionId, options?.bootEpoch);
let settled = false;
const settleReject = (err) => {
if (settled) return;
settled = true;
clearPendingBootAbort(sessionId, pendingBootAbort);
reject(err);
};
const settleResolve = (value) => {
if (settled) return;
settled = true;
clearPendingBootAbort(sessionId, pendingBootAbort);
resolve(value);
};
try {
const serialPort = new SerialPort({
path: portPath,
@@ -1214,10 +1240,30 @@ async function startSerialSession(event, options) {
autoOpen: false,
});
const abortPendingOpen = () => {
try { serialPort.close(); } catch { /* ignore */ }
const supersededError = new Error("Connection superseded by a newer reconnect");
supersededError.code = "NETCATTY_BOOT_SUPERSEDED";
settleReject(supersededError);
};
if (pendingBootAbort.signal.aborted) {
abortPendingOpen();
return;
}
pendingBootAbort.signal.addEventListener("abort", abortPendingOpen, { once: true });
serialPort.open((err) => {
if (settled) {
try { serialPort.close(); } catch { /* ignore */ }
return;
}
if (pendingBootAbort.signal.aborted) {
abortPendingOpen();
return;
}
if (err) {
console.error(`[Serial] Failed to open port ${portPath}:`, err.message);
reject(new Error(`Failed to open serial port: ${err.message}`));
settleReject(new Error(`Failed to open serial port: ${err.message}`));
return;
}
@@ -1238,7 +1284,15 @@ async function startSerialSession(event, options) {
decoderRef: serialDecoderRef,
webContentsId: event.sender.id,
};
sessions.set(sessionId, session);
{
const { claimSessionSlot } = require("./sessionBootEpoch.cjs");
const claim = claimSessionSlot(sessions, sessionId, session, options?.bootEpoch);
if (!claim.ok) {
try { serialPort.close(); } catch { /* ignore */ }
settleReject(new Error("Connection superseded by a newer reconnect"));
return;
}
}
openTerminalOutputSession(sessionId, event.sender);
// Start real-time session log stream if configured
@@ -1251,6 +1305,7 @@ async function startSerialSession(event, options) {
timestampsEnabled: Boolean(options.sessionLog.timestampsEnabled),
startTime: Date.now(),
});
session.logStreamToken = logStreamToken;
}
const serialZmodemSentry = createZmodemSentry({
@@ -1320,11 +1375,11 @@ async function startSerialSession(event, options) {
finalizeSerialExit({ exitCode: 0, reason: "closed" });
});
resolve({ sessionId });
settleResolve({ sessionId });
});
} catch (err) {
console.error("[Serial] Failed to start serial session:", err.message);
reject(err);
settleReject(err);
}
});
}
@@ -1913,8 +1968,23 @@ function clearSessionPtyBuffer(event, payload) {
*/
function closeSession(event, payload) {
const session = sessions.get(payload.sessionId);
const {
abortPendingBoot,
sessionMatchesBootEpoch,
} = require("./sessionBootEpoch.cjs");
const passphraseHandler = require("./passphraseHandler.cjs");
// Abort in-flight SSH passphrase prompts even before a registry slot exists.
abortPendingBoot(payload.sessionId, payload?.bootEpoch);
passphraseHandler.cancelPassphraseRequestsForSession?.(
payload.sessionId,
"session-closed",
payload?.bootEpoch,
);
if (session && !sessionMatchesBootEpoch(session, payload?.bootEpoch)) {
return { skipped: true, reason: "boot-epoch-mismatch" };
}
releaseAttachedSessionState(payload.sessionId);
if (!session) return;
if (!session) return { closed: false, reason: "missing" };
terminalFlowPauseArbiter.clearSession(payload.sessionId);
session.closed = true;
fanoutSessionLifecycleEvent(
@@ -1986,6 +2056,7 @@ function closeSession(event, payload) {
}
ptyProcessTree.unregisterPid(payload.sessionId);
sessions.delete(payload.sessionId);
return { closed: true };
}
/**
@@ -2184,13 +2255,16 @@ function registerHandlers(ipcMain, options = {}) {
"netcatty:telnet:getEchoMode",
].forEach((channel) => registerWorkerHandle(ipcMain, terminalWorkerManager, channel));
ipcMain.handle("netcatty:close:await", async (event, payload) => {
try {
return await terminalWorkerManager.request("netcatty:close:await", payload, {
webContentsId: event?.sender?.id,
});
} finally {
const result = await terminalWorkerManager.request("netcatty:close:await", payload, {
webContentsId: event?.sender?.id,
});
// A skipped epoch-mismatch close must not drop the replacement's
// flow-pause lease; only clear after the owned epoch was closed (or the
// session was already gone / returned without skipped).
if (!result?.skipped) {
terminalFlowPauseArbiter.clearSession(payload?.sessionId);
}
return result;
});
ipcMain.on("netcatty:write", (event, payload) => {
// Session log streams started in the main process (manual/script logs)
@@ -2228,7 +2302,12 @@ function registerHandlers(ipcMain, options = {}) {
terminalWorkerManager.send("netcatty:close", payload, {
webContentsId: event?.sender?.id,
});
terminalFlowPauseArbiter.clearSession(payload?.sessionId);
// Epoch-scoped closes may be no-ops in the worker; clearing here would
// orphan the replacement's pause lease. Unscoped closes still clear
// eagerly; owned closes also clear via onSessionClosed.
if (!Number.isFinite(payload?.bootEpoch)) {
terminalFlowPauseArbiter.clearSession(payload?.sessionId);
}
});
return;
}
@@ -213,13 +213,18 @@ test("failed worker lease acquisition compensates the backend pause", async () =
test("worker close and natural exit clear main-process pause ownership", async () => {
const { bridge } = loadTerminalBridgeWithMocks();
const handles = new Map();
const sends = new Map();
const ipcMain = {
handle(channel, handler) { handles.set(channel, handler); },
on() {},
on(channel, handler) { sends.set(channel, handler); },
};
let notifySessionClosed = null;
const terminalWorkerManager = {
request: async () => ({ success: true }),
request: async (_channel, payload) => (
Number.isFinite(payload?.bootEpoch)
? { skipped: true }
: { success: true }
),
send() {},
onSessionClosed(listener) {
notifySessionClosed = listener;
@@ -227,10 +232,11 @@ test("worker close and natural exit clear main-process pause ownership", async (
},
};
bridge.registerHandlers(ipcMain, { terminalWorkerManager });
const sender = { id: 10, once() {}, send() {} };
const sender = { id: 10, isDestroyed() {}, send() {} };
const acquire = handles.get("netcatty:terminal:acquireFlowPauseLease");
const release = handles.get("netcatty:terminal:releaseFlowPauseLease");
const closeAwait = handles.get("netcatty:close:await");
const closeFireAndForget = sends.get("netcatty:close");
const closingLease = await acquire({ sender }, { sessionId: "session-close" });
await closeAwait({ sender }, { sessionId: "session-close" });
@@ -239,6 +245,34 @@ test("worker close and natural exit clear main-process pause ownership", async (
{ sessionId: "session-close", leaseId: closingLease.leaseId },
)).success, false);
// Epoch-mismatch / skipped close must leave the replacement lease intact.
const skippedLease = await acquire({ sender }, { sessionId: "session-skipped" });
const skippedClose = await closeAwait(
{ sender },
{ sessionId: "session-skipped", bootEpoch: 1 },
);
assert.equal(skippedClose?.skipped, true);
assert.equal((await release(
{ sender },
{ sessionId: "session-skipped", leaseId: skippedLease.leaseId },
)).success, true);
// Fire-and-forget epoch-scoped close must also preserve leases (worker may
// no-op); unscoped close still clears eagerly.
const epochFireLease = await acquire({ sender }, { sessionId: "session-epoch-fire" });
closeFireAndForget({ sender }, { sessionId: "session-epoch-fire", bootEpoch: 2 });
assert.equal((await release(
{ sender },
{ sessionId: "session-epoch-fire", leaseId: epochFireLease.leaseId },
)).success, true);
const unscopedFireLease = await acquire({ sender }, { sessionId: "session-unscoped-fire" });
closeFireAndForget({ sender }, { sessionId: "session-unscoped-fire" });
assert.equal((await release(
{ sender },
{ sessionId: "session-unscoped-fire", leaseId: unscopedFireLease.leaseId },
)).success, false);
const exitLease = await acquire({ sender }, { sessionId: "session-exit" });
notifySessionClosed({ sessionId: "session-exit", reason: "exited" });
assert.equal((await release(
+14 -3
View File
@@ -1283,12 +1283,22 @@ main();
lastIdlePromptAt: 0,
_promptTrackTail: "",
};
sessions.set(sessionId, session);
{
const { claimSessionSlot } = require("../sessionBootEpoch.cjs");
const claim = claimSessionSlot(sessions, sessionId, session, options.bootEpoch);
if (!claim.ok) {
try { proc.kill(); } catch { /* ignore */ }
cleanupSessionExternalAuthArtifacts(session);
const supersededError = new Error("Connection superseded by a newer reconnect");
supersededError.code = "NETCATTY_BOOT_SUPERSEDED";
throw supersededError;
}
}
openTerminalOutputSession?.(sessionId, event.sender);
// Start real-time session log stream if configured
if (options.sessionLog?.enabled && options.sessionLog?.directory) {
sessionLogStreamManager.startStream(sessionId, {
const logStreamToken = sessionLogStreamManager.startStream(sessionId, {
hostLabel: options.label || options.hostname,
hostname: options.hostname,
directory: options.sessionLog.directory,
@@ -1296,6 +1306,7 @@ main();
timestampsEnabled: Boolean(options.sessionLog.timestampsEnabled),
startTime: Date.now(),
});
session.logStreamToken = logStreamToken;
}
const {
@@ -1367,7 +1378,7 @@ main();
etExitFinalized = true;
try { session.etStatsConn?.end(); } catch { /* ignore */ }
cleanupSessionExternalAuthArtifacts(session);
sessionLogStreamManager.stopStream(sessionId);
sessionLogStreamManager.stopStream(sessionId, session.logStreamToken);
closeTerminalOutputSession?.(sessionId);
sessions.delete(sessionId);
if (session.closed) return;
@@ -545,7 +545,17 @@ function createMoshSessionApi(ctx) {
moshHandshakeResult: null,
moshAuthTempFiles: moshAuth.tempFiles,
};
sessions.set(sessionId, session);
{
const { claimSessionSlot } = require("../sessionBootEpoch.cjs");
const claim = claimSessionSlot(sessions, sessionId, session, options.bootEpoch);
if (!claim.ok) {
try { sshPty.kill(); } catch { /* ignore */ }
cleanupMoshAuthTempFiles(moshAuth.tempFiles);
const supersededError = new Error("Connection superseded by a newer reconnect");
supersededError.code = "NETCATTY_BOOT_SUPERSEDED";
throw supersededError;
}
}
openTerminalOutputSession?.(sessionId, event.sender);
let logStreamToken = null;
@@ -774,7 +784,10 @@ function createMoshSessionApi(ctx) {
// ephemeral handshake PTY — issue #2199).
try {
const readyContents = electronModule.webContents.fromId(session.webContentsId);
readyContents?.send("netcatty:mosh:ready", { sessionId });
readyContents?.send("netcatty:mosh:ready", {
sessionId,
bootEpoch: session.bootEpoch,
});
} catch {
// Best-effort; startup-command deferral falls back if the event is missed.
}
@@ -114,11 +114,19 @@ function createTelnetSessionApi(ctx) {
},
onComplete() {
const contents = electronModule.webContents.fromId(event.sender.id);
contents?.send("netcatty:telnet:auto-login-complete", { sessionId });
const liveSession = sessions.get(sessionId);
contents?.send("netcatty:telnet:auto-login-complete", {
sessionId,
bootEpoch: liveSession?.bootEpoch ?? options.bootEpoch,
});
},
onUserInput() {
const contents = electronModule.webContents.fromId(event.sender.id);
contents?.send("netcatty:telnet:auto-login-cancelled", { sessionId });
const liveSession = sessions.get(sessionId);
contents?.send("netcatty:telnet:auto-login-cancelled", {
sessionId,
bootEpoch: liveSession?.bootEpoch ?? options.bootEpoch,
});
},
});
@@ -226,7 +234,17 @@ function createTelnetSessionApi(ctx) {
};
activeSession = session;
session.flushPendingData = flushTelnetPaced;
sessions.set(sessionId, session);
const { claimSessionSlot } = require("../sessionBootEpoch.cjs");
const claim = claimSessionSlot(sessions, sessionId, session, options.bootEpoch);
if (!claim.ok) {
try { socket?.destroy?.(); } catch { /* ignore */ }
const supersededError = new Error("Connection superseded by a newer reconnect");
supersededError.code = "NETCATTY_BOOT_SUPERSEDED";
// EventEmitter 'connect' callbacks are not covered by the
// enclosing Promise executor try/catch — reject explicitly.
reject(supersededError);
return;
}
openTerminalOutputSession?.(sessionId, event.sender);
// Start real-time session log stream if configured
@@ -239,6 +257,7 @@ function createTelnetSessionApi(ctx) {
timestampsEnabled: Boolean(options.sessionLog.timestampsEnabled),
startTime: Date.now(),
});
session.logStreamToken = logStreamToken;
}
resolve({ sessionId });
+17 -7
View File
@@ -513,15 +513,25 @@ function createPreloadApi(ctx) {
if (!sessionId || !Number.isFinite(bytes) || bytes <= 0) return;
ipcRenderer.send("netcatty:flow:ack", { sessionId, bytes });
},
closeSession: async (sessionId) => {
markTerminalDataSessionClosed(sessionId);
// closeSession sets session.closed before kill, so some protocol-specific
// exit events can be skipped. Release every session-scoped listener here.
clearSessionScopedTerminalListeners(sessionId);
closeSession: async (sessionId, options) => {
const payload = {
sessionId,
...(Number.isFinite(options?.bootEpoch) ? { bootEpoch: Number(options.bootEpoch) } : {}),
};
const epochScoped = Number.isFinite(options?.bootEpoch);
// Unscoped closes keep the historical sync listener teardown so reconnect
// paths that fire-and-forget closeSession still drop stale handlers.
// Epoch-scoped closes are only used to dispose a superseded backend owner;
// they must never clear preload listeners for the shared sessionId, because
// a replacement boot may already have registered readiness/auto-login hooks.
if (!epochScoped) {
markTerminalDataSessionClosed(sessionId);
clearSessionScopedTerminalListeners(sessionId);
}
try {
await ipcRenderer.invoke("netcatty:close:await", { sessionId });
await ipcRenderer.invoke("netcatty:close:await", payload);
} catch {
ipcRenderer.send("netcatty:close", { sessionId });
ipcRenderer.send("netcatty:close", payload);
}
},
rebindTerminalSessionOutput: (sessionId, authorization) =>
Vendored
+2
View File
@@ -149,6 +149,8 @@ declare global {
sessionLog?: { enabled: boolean; directory: string; format: string; timestampsEnabled?: boolean };
// SSH connection diagnostics. Does not capture terminal output.
sshDebugLogEnabled?: boolean;
// Boot generation for correlating host-key prompts with a terminal start.
bootEpoch?: number;
// Local SSH key file paths (from SSH config IdentityFile)
identityFilePaths?: string[];
useSshAgent?: boolean;
+19 -5
View File
@@ -125,7 +125,17 @@ declare global {
env?: Record<string, string>;
sessionLog?: { enabled: boolean; directory: string; format: string; timestampsEnabled?: boolean };
}): Promise<string>;
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; shellArgs?: string[]; cwd?: string; env?: Record<string, string>; sessionLog?: { enabled: boolean; directory: string; format: string; timestampsEnabled?: boolean } }): Promise<string>;
startLocalSession?(options: {
sessionId?: string;
cols?: number;
rows?: number;
shell?: string;
shellArgs?: string[];
cwd?: string;
env?: Record<string, string>;
sessionLog?: { enabled: boolean; directory: string; format: string; timestampsEnabled?: boolean };
bootEpoch?: number;
}): Promise<string>;
startSerialSession?(options: {
sessionId?: string;
path: string;
@@ -347,7 +357,7 @@ declare global {
respondTerminalOutputDrain?(requestId: string): void;
notifyTerminalSessionDisplayReady?(sessionId: string): void;
ackSessionFlow(sessionId: string, bytes: number): void;
closeSession(sessionId: string): void | Promise<void>;
closeSession(sessionId: string, options?: { bootEpoch?: number }): void | Promise<void>;
/** Move a live session's output port to this renderer (same PTY). */
rebindTerminalSessionOutput?(sessionId: string, authorization: string): Promise<{
success: boolean;
@@ -489,16 +499,16 @@ declare global {
): () => void;
onTelnetAutoLoginComplete?(
sessionId: string,
cb: (evt: { sessionId: string }) => void
cb: (evt: { sessionId: string; bootEpoch?: number }) => void
): () => void;
onTelnetAutoLoginCancelled?(
sessionId: string,
cb: (evt: { sessionId: string }) => void
cb: (evt: { sessionId: string; bootEpoch?: number }) => void
): () => void;
/** Fires after Mosh swaps from the SSH handshake PTY to mosh-client. */
onMoshSessionReady?(
sessionId: string,
cb: (evt: { sessionId: string }) => void
cb: (evt: { sessionId: string; bootEpoch?: number }) => void
): () => void;
onTelnetEchoMode?(
sessionId: string,
@@ -530,6 +540,7 @@ declare global {
/** When false, UI must not offer saving the response as the host password. */
allowSavePassword?: boolean;
scope?: "terminal" | "external";
bootEpoch?: number;
}) => void
): () => void;
onKeyboardInteractiveCancelled?(
@@ -557,6 +568,7 @@ declare global {
publicKey?: string;
knownHostId?: string;
knownFingerprint?: string;
bootEpoch?: number;
}) => void
): () => void;
respondHostKeyVerification?(
@@ -573,6 +585,8 @@ declare global {
keyName: string;
hostname?: string;
passphraseInvalid?: boolean;
sessionId?: string;
bootEpoch?: number;
}) => void
): () => void;
respondPassphrase?(