feat: optional auto reconnect for dropped ssh sessions (#1321)

This commit is contained in:
LukeGus committed 2026-09-30 14:02:42 -05:00
1 parent 41d0c0ab85
commit 911fc3bc69
6 files changed
+129 -3

No files matched your search

+6
View File
@@ -44,6 +44,7 @@
"reconnect": "Reconnect",
"reconnected": "Reconnected successfully",
"reconnecting": "Reconnecting... ({{attempt}}/{{max}})",
"autoReconnecting": "Connection lost, reconnecting...",
"searchCaseSensitive": "Match Case",
"searchClose": "Close (Escape)",
"searchNext": "Next Match (Enter)",
@@ -352,6 +353,9 @@
},
"sudoPasswordAutoFill": {
"label": "Fill sudo prompts"
},
"autoReconnect": {
"label": "Reconnect automatically"
}
},
"user": {
@@ -479,6 +483,8 @@
"none": "None",
"passwordPromptAutoFillDesc": "Offer to fill in the saved password when the terminal asks for one. Turn off to never auto-fill any password on this host.",
"passwordPromptAutoFillLabel": "Offer Password Auto-fill",
"autoReconnectLabel": "Auto Reconnect",
"autoReconnectDesc": "Reconnect on its own when the SSH connection drops, like after a network blip or sleep. Keeps the scrollback.",
"rightClickSelectsWord": "Right Click Selects Word",
"rightClickSelectsWordLabel": "Right-click Selects Word",
"rightClickSelectsWordShortDesc": "Select the word under cursor on right-click",
+7
View File
@@ -702,6 +702,13 @@
"labelKey": "settings.host.sudoPasswordAutoFill.label",
"hidden": true,
"default": false
},
{
"key": "autoReconnect",
"type": "boolean",
"labelKey": "settings.host.autoReconnect.label",
"hidden": true,
"default": false
}
]
},
@@ -720,6 +720,16 @@ export function HostTerminalSettings({
onChange={(v) => setField("autoMosh", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.autoReconnectLabel")}
defaultKey="autoReconnect"
description={t("hosts.autoReconnectDesc")}
>
<FakeSwitch
checked={form.autoReconnect}
onChange={(v) => setField("autoReconnect", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.passwordPromptAutoFillLabel")}
defaultKey="passwordPromptAutoFill"
@@ -282,6 +282,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const terminalInputDisposableRef = useRef<{ dispose(): void } | null>(null);
const localEchoRef = useRef<TerminalLocalEcho | null>(null);
const sessionOriginRef = useRef<"local" | "remote">("local");
// Set while an auto reconnect runs, so the old output stays on screen.
const keepScrollbackRef = useRef(false);
const customKeybindingsRef = useRef<CustomKeybinding[]>([]);
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
const wasDisconnectedBySSH = useRef(false);
@@ -1514,6 +1516,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
if (reconnectAttempts.current >= maxReconnectAttempts) {
setIsConnecting(false);
keepScrollbackRef.current = false;
shouldNotReconnectRef.current = true;
setShowDisconnectedOverlay(true);
addLog({
@@ -1526,7 +1529,11 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
isReconnectingRef.current = true;
if (terminal && !isAttachingSessionRef.current) {
if (
terminal &&
!isAttachingSessionRef.current &&
!keepScrollbackRef.current
) {
terminal.clear();
}
@@ -1564,7 +1571,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
if (terminal && hostConfig) {
if (!isAttachingSessionRef.current) {
if (!isAttachingSessionRef.current && !keepScrollbackRef.current) {
terminal.clear();
}
const cols = terminal.cols;
@@ -1929,6 +1936,13 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
errorMessage.toLowerCase().includes("timeout") ||
errorMessage.toLowerCase().includes("network")
) {
if (keepScrollbackRef.current && !isUnmountingRef.current) {
// The host is still unreachable; try again after the backoff.
isConnectingRef.current = false;
wasDisconnectedBySSH.current = false;
attemptReconnection();
return;
}
updateConnectionError(errorMessage);
setIsConnected(false);
if (terminal) {
@@ -1948,6 +1962,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
errorMessage.toLowerCase().includes("key"))) ||
errorMessage.toLowerCase().includes("incorrect password")
) {
keepScrollbackRef.current = false;
updateConnectionError(errorMessage);
setIsConnecting(false);
shouldNotReconnectRef.current = true;
@@ -1960,6 +1975,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
updateConnectionError(errorMessage);
setIsConnecting(false);
} else if (msg.type === "connected") {
if (keepScrollbackRef.current) {
keepScrollbackRef.current = false;
reconnectAttempts.current = 0;
}
wasConnectedRef.current = true;
setIsConnected(true);
setIsConnecting(false);
@@ -2061,7 +2080,23 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
if (onClose) onClose();
} else if (wasConnectedRef.current) {
wasConnectedRef.current = false;
setShowDisconnectedOverlay(true);
if (
termSettingsRef.current.autoReconnect &&
!isUnmountingRef.current
) {
wasDisconnectedBySSH.current = false;
shouldNotReconnectRef.current = false;
reconnectAttempts.current = 0;
keepScrollbackRef.current = true;
terminal?.write(
`
${t("terminal.autoReconnecting")}
`,
);
attemptReconnection();
} else {
setShowDisconnectedOverlay(true);
}
} else if (!connectionErrorRef.current) {
updateConnectionError(
msg.message || t("terminal.connectionRejected"),
@@ -2386,6 +2421,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
if (
!wasConnectedRef.current &&
!isAttachingSessionRef.current &&
!keepScrollbackRef.current &&
event.wasClean &&
(event.code === 1005 || event.code === 1000)
) {
@@ -81,6 +81,7 @@ export interface TerminalBehavior {
localEcho: HostLocalEcho;
passwordPromptAutoFill: boolean;
sudoPasswordAutoFill: boolean;
autoReconnect: boolean;
}
/** Everything the host editor's Terminal tab edits. */
@@ -121,6 +122,7 @@ export const BEHAVIOR_KEYS = [
"localEcho",
"passwordPromptAutoFill",
"sudoPasswordAutoFill",
"autoReconnect",
] as const satisfies readonly (keyof TerminalBehavior)[];
export const INHERIT_APPEARANCE_KEY = "inheritAppearance";
@@ -169,6 +171,7 @@ export const DEFAULT_BEHAVIOR: TerminalBehavior = {
localEcho: "auto",
passwordPromptAutoFill: true,
sudoPasswordAutoFill: false,
autoReconnect: false,
};
export const DEFAULT_HOST_TERMINAL_SETTINGS: HostTerminalSettings = {
@@ -0,0 +1,64 @@
// @vitest-environment node
import fs from "node:fs";
import vm from "node:vm";
import { describe, expect, it, vi } from "vitest";
const source = fs.readFileSync(
new URL("../../../src/frontend/terminal/Terminal.tsx", import.meta.url),
"utf8",
);
const attempt = source.slice(
source.indexOf(" function attemptReconnection()"),
source.indexOf(" // A persisted session that timed out"),
);
function run(keepScrollback: boolean, attempts = 0) {
const timers: Array<() => void> = [];
const context = {
terminal: { cols: 100, rows: 30, clear: vi.fn() },
hostConfig: { id: 1 },
isUnmountingRef: { current: false },
shouldNotReconnectRef: { current: false },
isReconnectingRef: { current: false },
isConnectingRef: { current: false },
isAttachingSessionRef: { current: false },
wasDisconnectedBySSH: { current: false },
reconnectTimeoutRef: { current: null as unknown },
reconnectAttempts: { current: attempts },
keepScrollbackRef: { current: keepScrollback },
maxReconnectAttempts: 8,
setIsConnecting: vi.fn(),
setShowDisconnectedOverlay: vi.fn(),
addLog: vi.fn(),
t: (key: string) => key,
connectToHost: vi.fn(),
setTimeout: (fn: () => void) => {
timers.push(fn);
return 1;
},
Math,
};
vm.runInNewContext(`${attempt}\nattemptReconnection();`, context);
timers.forEach((fn) => fn());
return context;
}
describe("terminal auto reconnect", () => {
it("keeps the scrollback while reconnecting after a drop", () => {
const context = run(true);
expect(context.terminal.clear).not.toHaveBeenCalled();
expect(context.connectToHost).toHaveBeenCalledWith(100, 30);
});
it("still clears the screen on an ordinary reconnect", () => {
const context = run(false);
expect(context.terminal.clear).toHaveBeenCalled();
});
it("gives up after the last attempt and stops keeping scrollback", () => {
const context = run(true, 8);
expect(context.connectToHost).not.toHaveBeenCalled();
expect(context.setShowDisconnectedOverlay).toHaveBeenCalledWith(true);
expect(context.keepScrollbackRef.current).toBe(false);
});
});