mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-01 08:05:23 +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",
|
"reconnect": "Reconnect",
|
||||||
"reconnected": "Reconnected successfully",
|
"reconnected": "Reconnected successfully",
|
||||||
"reconnecting": "Reconnecting... ({{attempt}}/{{max}})",
|
"reconnecting": "Reconnecting... ({{attempt}}/{{max}})",
|
||||||
|
"autoReconnecting": "Connection lost, reconnecting...",
|
||||||
"searchCaseSensitive": "Match Case",
|
"searchCaseSensitive": "Match Case",
|
||||||
"searchClose": "Close (Escape)",
|
"searchClose": "Close (Escape)",
|
||||||
"searchNext": "Next Match (Enter)",
|
"searchNext": "Next Match (Enter)",
|
||||||
@@ -352,6 +353,9 @@
|
|||||||
},
|
},
|
||||||
"sudoPasswordAutoFill": {
|
"sudoPasswordAutoFill": {
|
||||||
"label": "Fill sudo prompts"
|
"label": "Fill sudo prompts"
|
||||||
|
},
|
||||||
|
"autoReconnect": {
|
||||||
|
"label": "Reconnect automatically"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
@@ -479,6 +483,8 @@
|
|||||||
"none": "None",
|
"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.",
|
"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",
|
"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",
|
"rightClickSelectsWord": "Right Click Selects Word",
|
||||||
"rightClickSelectsWordLabel": "Right-click Selects Word",
|
"rightClickSelectsWordLabel": "Right-click Selects Word",
|
||||||
"rightClickSelectsWordShortDesc": "Select the word under cursor on right-click",
|
"rightClickSelectsWordShortDesc": "Select the word under cursor on right-click",
|
||||||
|
|||||||
@@ -702,6 +702,13 @@
|
|||||||
"labelKey": "settings.host.sudoPasswordAutoFill.label",
|
"labelKey": "settings.host.sudoPasswordAutoFill.label",
|
||||||
"hidden": true,
|
"hidden": true,
|
||||||
"default": false
|
"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)}
|
onChange={(v) => setField("autoMosh", v)}
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
label={t("hosts.autoReconnectLabel")}
|
||||||
|
defaultKey="autoReconnect"
|
||||||
|
description={t("hosts.autoReconnectDesc")}
|
||||||
|
>
|
||||||
|
<FakeSwitch
|
||||||
|
checked={form.autoReconnect}
|
||||||
|
onChange={(v) => setField("autoReconnect", v)}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label={t("hosts.passwordPromptAutoFillLabel")}
|
label={t("hosts.passwordPromptAutoFillLabel")}
|
||||||
defaultKey="passwordPromptAutoFill"
|
defaultKey="passwordPromptAutoFill"
|
||||||
|
|||||||
@@ -282,6 +282,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
const terminalInputDisposableRef = useRef<{ dispose(): void } | null>(null);
|
const terminalInputDisposableRef = useRef<{ dispose(): void } | null>(null);
|
||||||
const localEchoRef = useRef<TerminalLocalEcho | null>(null);
|
const localEchoRef = useRef<TerminalLocalEcho | null>(null);
|
||||||
const sessionOriginRef = useRef<"local" | "remote">("local");
|
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 customKeybindingsRef = useRef<CustomKeybinding[]>([]);
|
||||||
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
|
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||||
const wasDisconnectedBySSH = useRef(false);
|
const wasDisconnectedBySSH = useRef(false);
|
||||||
@@ -1514,6 +1516,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
|
|
||||||
if (reconnectAttempts.current >= maxReconnectAttempts) {
|
if (reconnectAttempts.current >= maxReconnectAttempts) {
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
|
keepScrollbackRef.current = false;
|
||||||
shouldNotReconnectRef.current = true;
|
shouldNotReconnectRef.current = true;
|
||||||
setShowDisconnectedOverlay(true);
|
setShowDisconnectedOverlay(true);
|
||||||
addLog({
|
addLog({
|
||||||
@@ -1526,7 +1529,11 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
|
|
||||||
isReconnectingRef.current = true;
|
isReconnectingRef.current = true;
|
||||||
|
|
||||||
if (terminal && !isAttachingSessionRef.current) {
|
if (
|
||||||
|
terminal &&
|
||||||
|
!isAttachingSessionRef.current &&
|
||||||
|
!keepScrollbackRef.current
|
||||||
|
) {
|
||||||
terminal.clear();
|
terminal.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1564,7 +1571,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (terminal && hostConfig) {
|
if (terminal && hostConfig) {
|
||||||
if (!isAttachingSessionRef.current) {
|
if (!isAttachingSessionRef.current && !keepScrollbackRef.current) {
|
||||||
terminal.clear();
|
terminal.clear();
|
||||||
}
|
}
|
||||||
const cols = terminal.cols;
|
const cols = terminal.cols;
|
||||||
@@ -1929,6 +1936,13 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
errorMessage.toLowerCase().includes("timeout") ||
|
errorMessage.toLowerCase().includes("timeout") ||
|
||||||
errorMessage.toLowerCase().includes("network")
|
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);
|
updateConnectionError(errorMessage);
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
if (terminal) {
|
if (terminal) {
|
||||||
@@ -1948,6 +1962,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
errorMessage.toLowerCase().includes("key"))) ||
|
errorMessage.toLowerCase().includes("key"))) ||
|
||||||
errorMessage.toLowerCase().includes("incorrect password")
|
errorMessage.toLowerCase().includes("incorrect password")
|
||||||
) {
|
) {
|
||||||
|
keepScrollbackRef.current = false;
|
||||||
updateConnectionError(errorMessage);
|
updateConnectionError(errorMessage);
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
shouldNotReconnectRef.current = true;
|
shouldNotReconnectRef.current = true;
|
||||||
@@ -1960,6 +1975,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
updateConnectionError(errorMessage);
|
updateConnectionError(errorMessage);
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
} else if (msg.type === "connected") {
|
} else if (msg.type === "connected") {
|
||||||
|
if (keepScrollbackRef.current) {
|
||||||
|
keepScrollbackRef.current = false;
|
||||||
|
reconnectAttempts.current = 0;
|
||||||
|
}
|
||||||
wasConnectedRef.current = true;
|
wasConnectedRef.current = true;
|
||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
@@ -2061,7 +2080,23 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
if (onClose) onClose();
|
if (onClose) onClose();
|
||||||
} else if (wasConnectedRef.current) {
|
} else if (wasConnectedRef.current) {
|
||||||
wasConnectedRef.current = false;
|
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) {
|
} else if (!connectionErrorRef.current) {
|
||||||
updateConnectionError(
|
updateConnectionError(
|
||||||
msg.message || t("terminal.connectionRejected"),
|
msg.message || t("terminal.connectionRejected"),
|
||||||
@@ -2386,6 +2421,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
if (
|
if (
|
||||||
!wasConnectedRef.current &&
|
!wasConnectedRef.current &&
|
||||||
!isAttachingSessionRef.current &&
|
!isAttachingSessionRef.current &&
|
||||||
|
!keepScrollbackRef.current &&
|
||||||
event.wasClean &&
|
event.wasClean &&
|
||||||
(event.code === 1005 || event.code === 1000)
|
(event.code === 1005 || event.code === 1000)
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export interface TerminalBehavior {
|
|||||||
localEcho: HostLocalEcho;
|
localEcho: HostLocalEcho;
|
||||||
passwordPromptAutoFill: boolean;
|
passwordPromptAutoFill: boolean;
|
||||||
sudoPasswordAutoFill: boolean;
|
sudoPasswordAutoFill: boolean;
|
||||||
|
autoReconnect: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Everything the host editor's Terminal tab edits. */
|
/** Everything the host editor's Terminal tab edits. */
|
||||||
@@ -121,6 +122,7 @@ export const BEHAVIOR_KEYS = [
|
|||||||
"localEcho",
|
"localEcho",
|
||||||
"passwordPromptAutoFill",
|
"passwordPromptAutoFill",
|
||||||
"sudoPasswordAutoFill",
|
"sudoPasswordAutoFill",
|
||||||
|
"autoReconnect",
|
||||||
] as const satisfies readonly (keyof TerminalBehavior)[];
|
] as const satisfies readonly (keyof TerminalBehavior)[];
|
||||||
|
|
||||||
export const INHERIT_APPEARANCE_KEY = "inheritAppearance";
|
export const INHERIT_APPEARANCE_KEY = "inheritAppearance";
|
||||||
@@ -169,6 +171,7 @@ export const DEFAULT_BEHAVIOR: TerminalBehavior = {
|
|||||||
localEcho: "auto",
|
localEcho: "auto",
|
||||||
passwordPromptAutoFill: true,
|
passwordPromptAutoFill: true,
|
||||||
sudoPasswordAutoFill: false,
|
sudoPasswordAutoFill: false,
|
||||||
|
autoReconnect: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_HOST_TERMINAL_SETTINGS: HostTerminalSettings = {
|
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