mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: optional auto reconnect for dropped ssh sessions (#1321)
This commit is contained in:
1 parent
41d0c0ab85
commit
911fc3bc69
6 files changed
+129
-3
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
`
|
||||
[2m${t("terminal.autoReconnecting")}[0m
|
||||
`,
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user