mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1aa6ec266 | ||
|
|
2de2cc071c | ||
|
|
51d8c253f9 | ||
|
|
c466228ff4 | ||
|
|
c6ea8c8605 | ||
|
|
cee6ae9477 | ||
|
|
50896db011 | ||
|
|
645d933b63 | ||
|
|
ba5d608447 | ||
|
|
e67306c9f7 | ||
|
|
66a433345e | ||
|
|
9900634935 | ||
|
|
876c436521 | ||
|
|
6a76d7aead | ||
|
|
57c60dc997 | ||
|
|
53bd9be177 | ||
|
|
d120a6810a | ||
|
|
068dc0c73d | ||
|
|
3e32240acc | ||
|
|
dc6cdba05f | ||
|
|
db244c4af3 | ||
|
|
1afc8641cc | ||
|
|
221f8e63ee | ||
|
|
d0dded0bff | ||
|
|
4e6834741c | ||
|
|
2deae6274e |
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Использование памяти',
|
||||
|
||||
@@ -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': '内存使用',
|
||||
|
||||
@@ -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': '記憶體使用',
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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
@@ -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?(
|
||||
|
||||
Reference in new issue
Block a user