From 43ad0129e8a9b1be0b1c3bf45043b5c1f7a98724 Mon Sep 17 00:00:00 2001 From: kipavy Date: Fri, 28 Aug 2026 16:29:29 +0000 Subject: [PATCH] feat(serial): close/reopen the port and gate auto-reconnect (#192) A serial console holds /dev/ttyUSB0 for as long as its tab is open, and a drop starts the reconnect backoff, which reclaims the port every few seconds. Both fight the flashing tool on a board like an ESP32: the user has to close the whole tab to flash, and even then the loop can take the port back before esptool gets it. The serial status bar now carries two buttons: - A plug toggle that releases the port while keeping the tab, its scrollback and its config, and reopens it on the next click. The close marks the session disconnected before releasing, so the resulting serial-closed event is not read as a drop worth chasing. - An auto-reconnect toggle, stored on the connection so it survives a restart and follows the device. Off means a drop leaves the port free and the reopen button armed. Quick-connect sessions have no connection to persist it on and keep it for the session's lifetime instead. Reopening also exposed a defect in the backend: the previous read thread outlives its port on a cloned descriptor, so two threads would split the device's output and the older one's close would tear down the session that replaced it. Sessions now carry a generation a thread checks before continuing or reporting a close. --- src-tauri/src/commands/connections.rs | 12 +- src-tauri/src/serial/connect.rs | 66 ++++++-- src-tauri/src/serial/connect_tests.rs | 31 ++++ src-tauri/src/serial/mod.rs | 3 + src-tauri/src/storage/config.rs | 3 + src/components/connections/ConnectionForm.tsx | 70 +++------ .../connections/SerialConnectionForm.tsx | 19 ++- src/components/connections/formShared.tsx | 22 +++ src/components/hosts/HostsPage.tsx | 1 + src/components/terminal/TerminalStatusBar.tsx | 67 ++++++--- src/i18n/locales/en/connections.json | 2 + src/i18n/locales/en/terminal.json | 4 + src/i18n/locales/fr/connections.json | 2 + src/i18n/locales/fr/terminal.json | 4 + src/i18n/locales/ru/connections.json | 2 + src/i18n/locales/ru/terminal.json | 4 + src/i18n/locales/zh/connections.json | 2 + src/i18n/locales/zh/terminal.json | 4 + src/stores/connectionStore.ts | 5 +- src/stores/reconnectBackoff.test.ts | 14 +- src/stores/reconnectBackoff.ts | 7 +- src/stores/reconnectBackoffCore.ts | 9 ++ src/stores/serialAutoReconnect.test.ts | 34 +++++ src/stores/serialAutoReconnect.ts | 16 ++ src/stores/sessionStore.serialPort.test.ts | 142 ++++++++++++++++++ src/stores/sessionStore.ts | 53 ++++++- ...vaultObjectStores.characterisation.test.ts | 2 +- src/types/index.ts | 5 + 28 files changed, 514 insertions(+), 91 deletions(-) create mode 100644 src-tauri/src/serial/connect_tests.rs create mode 100644 src/stores/serialAutoReconnect.test.ts create mode 100644 src/stores/serialAutoReconnect.ts create mode 100644 src/stores/sessionStore.serialPort.test.ts diff --git a/src-tauri/src/commands/connections.rs b/src-tauri/src/commands/connections.rs index 947ee21fc..f5cee4ac3 100644 --- a/src-tauri/src/commands/connections.rs +++ b/src-tauri/src/commands/connections.rs @@ -57,6 +57,7 @@ fn merge_form_into_connection(existing: &Connection, data: ConnectionFormData) - serial_parity: data.serial_parity, serial_stop_bits: data.serial_stop_bits, serial_flow_control: data.serial_flow_control, + serial_auto_reconnect: data.serial_auto_reconnect, ftp_secure: data.ftp_secure, notes: data.notes, created_at: existing.created_at.clone(), @@ -117,7 +118,8 @@ connection_clocks! { pre_snippet_id, post_snippet_id, ask_vars_each_time, terminal_encoding, distro, icon, ping_disabled, shell_integration, keepalive_preset, persist_session, connection_type, serial_port, serial_baud, - serial_data_bits, serial_parity, serial_stop_bits, serial_flow_control, ftp_secure, + serial_data_bits, serial_parity, serial_stop_bits, serial_flow_control, + serial_auto_reconnect, ftp_secure, notes, ], by_id: [jump_hosts, env_vars], @@ -186,6 +188,7 @@ fn build_connection( serial_parity: data.serial_parity, serial_stop_bits: data.serial_stop_bits, serial_flow_control: data.serial_flow_control, + serial_auto_reconnect: data.serial_auto_reconnect, ftp_secure: data.ftp_secure, notes: data.notes, clocks, @@ -329,6 +332,7 @@ mod tests { serial_parity: Some("none".into()), serial_stop_bits: Some(1), serial_flow_control: Some("none".into()), + serial_auto_reconnect: Some(true), ftp_secure: false, notes: Some("orig note".into()), updated_at: "2026-01-01T00:00:00Z".into(), @@ -385,6 +389,7 @@ mod tests { serial_parity: Some("even".into()), serial_stop_bits: Some(2), serial_flow_control: Some("rtscts".into()), + serial_auto_reconnect: Some(false), ftp_secure: true, notes: Some("new note".into()), } @@ -566,6 +571,7 @@ mod tests { "post_snippet_id", "pre_command", "pre_snippet_id", + "serial_auto_reconnect", "serial_baud", "serial_data_bits", "serial_flow_control", @@ -580,7 +586,7 @@ mod tests { ]; expected.sort(); assert_eq!(keys, expected); - assert_eq!(keys.len(), 35); + assert_eq!(keys.len(), 36); } /// Phase 1 reconciliation: the clocks seeded for a brand-new connection @@ -600,6 +606,6 @@ mod tests { let bumpable: HashSet = new.clocks.into_keys().collect(); assert_eq!(seeded, bumpable); - assert_eq!(seeded.len(), 35); + assert_eq!(seeded.len(), 36); } } diff --git a/src-tauri/src/serial/connect.rs b/src-tauri/src/serial/connect.rs index c7f987efa..060f51dc7 100644 --- a/src-tauri/src/serial/connect.rs +++ b/src-tauri/src/serial/connect.rs @@ -1,21 +1,64 @@ use serialport; use std::collections::HashMap; use std::io::{Read, Write}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::thread; use std::time::Duration; use tauri::{AppHandle, Emitter}; +pub(super) struct SerialSession { + generation: u64, + port: Box, +} + +pub(super) type SessionMap = HashMap; + pub struct SerialSessionManager { - sessions: Arc>>>, + pub(super) sessions: Arc>, + next_generation: AtomicU64, } impl SerialSessionManager { pub fn new() -> Self { Self { sessions: Arc::new(Mutex::new(HashMap::new())), + next_generation: AtomicU64::new(1), } } + + /// Registers a freshly opened port under `session_id`, replacing whatever + /// was there, and hands back the generation identifying this open. + pub(super) fn insert(&self, session_id: &str, port: Box) -> u64 { + let generation = self.next_generation.fetch_add(1, Ordering::Relaxed); + self.sessions + .lock() + .unwrap() + .insert(session_id.to_string(), SerialSession { generation, port }); + generation + } + + pub(super) fn remove(&self, session_id: &str) { + self.sessions.lock().unwrap().remove(session_id); + } +} + +/// Whether `generation` still owns `session_id`. +/// +/// A read thread keeps its own cloned descriptor, so closing and reopening the +/// port leaves the previous thread running. Without this check both threads read +/// the same device and split its output between them, and the older one's +/// `serial-closed` would tear down the session that replaced it. +pub(super) fn generation_is_current( + sessions: &Mutex, + session_id: &str, + generation: u64, +) -> bool { + sessions + .lock() + .unwrap() + .get(session_id) + .is_some_and(|s| s.generation == generation) } #[derive(serde::Serialize, Clone)] @@ -117,11 +160,7 @@ pub fn serial_connect( .map_err(|e| e.to_string())?; let read_port = serial.try_clone().map_err(|e| e.to_string())?; - - { - let mut sessions = state.sessions.lock().unwrap(); - sessions.insert(session_id.clone(), serial); - } + let generation = state.insert(&session_id, serial); // Spawn read loop thread let app_clone = app.clone(); @@ -138,8 +177,9 @@ pub fn serial_connect( let _ = app_clone.emit(&format!("serial-output-{}", sid), data); } Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => { - // Check if session was removed (disconnected) - if !sessions_arc.lock().unwrap().contains_key(&sid) { + // Bail once this open is no longer the session's: it was + // disconnected, or reopened by a newer generation. + if !generation_is_current(&sessions_arc, &sid, generation) { break; } continue; @@ -147,7 +187,9 @@ pub fn serial_connect( Err(_) => break, } } - let _ = app_clone.emit(&format!("serial-closed-{}", sid), ()); + if generation_is_current(&sessions_arc, &sid, generation) { + let _ = app_clone.emit(&format!("serial-closed-{}", sid), ()); + } }); let _ = app.emit( @@ -165,10 +207,10 @@ pub fn serial_write( data: Vec, ) -> Result<(), String> { let mut sessions = state.sessions.lock().unwrap(); - let port = sessions + let session = sessions .get_mut(&session_id) .ok_or("Serial session not found")?; - port.write_all(&data).map_err(|e| e.to_string()) + session.port.write_all(&data).map_err(|e| e.to_string()) } #[tauri::command] @@ -176,6 +218,6 @@ pub fn serial_disconnect( state: tauri::State<'_, SerialSessionManager>, session_id: String, ) -> Result<(), String> { - state.sessions.lock().unwrap().remove(&session_id); + state.remove(&session_id); Ok(()) } diff --git a/src-tauri/src/serial/connect_tests.rs b/src-tauri/src/serial/connect_tests.rs new file mode 100644 index 000000000..9e08b67e7 --- /dev/null +++ b/src-tauri/src/serial/connect_tests.rs @@ -0,0 +1,31 @@ +use super::connect::{generation_is_current, SerialSessionManager}; + +fn open_port() -> Box { + let (port, _peer) = serialport::TTYPort::pair().expect("pty pair"); + Box::new(port) +} + +// Reopening a port leaves the previous read thread alive on its cloned fd. It +// has to notice a newer generation owns the id and stop, or two threads split +// the device's output between them (#192 made this reachable from a button). +#[test] +fn reopening_a_session_supersedes_the_previous_generation() { + let manager = SerialSessionManager::new(); + + let first = manager.insert("s1", open_port()); + assert!(generation_is_current(&manager.sessions, "s1", first)); + + let second = manager.insert("s1", open_port()); + assert!(!generation_is_current(&manager.sessions, "s1", first)); + assert!(generation_is_current(&manager.sessions, "s1", second)); +} + +#[test] +fn a_removed_session_is_no_longer_current() { + let manager = SerialSessionManager::new(); + let generation = manager.insert("s1", open_port()); + + manager.remove("s1"); + + assert!(!generation_is_current(&manager.sessions, "s1", generation)); +} diff --git a/src-tauri/src/serial/mod.rs b/src-tauri/src/serial/mod.rs index be6843511..044cfe090 100644 --- a/src-tauri/src/serial/mod.rs +++ b/src-tauri/src/serial/mod.rs @@ -1 +1,4 @@ pub mod connect; + +#[cfg(test)] +mod connect_tests; diff --git a/src-tauri/src/storage/config.rs b/src-tauri/src/storage/config.rs index 110de8b2c..76d41b87f 100644 --- a/src-tauri/src/storage/config.rs +++ b/src-tauri/src/storage/config.rs @@ -175,6 +175,7 @@ pub struct Connection { pub serial_stop_bits: Option, #[serde(default)] pub serial_flow_control: Option, + pub serial_auto_reconnect: Option, /// FTP only: use explicit FTPS (AUTH TLS) instead of plain FTP. #[serde(default)] pub ftp_secure: bool, @@ -257,6 +258,7 @@ pub struct ConnectionFormData { pub serial_stop_bits: Option, #[serde(default)] pub serial_flow_control: Option, + pub serial_auto_reconnect: Option, #[serde(default)] pub ftp_secure: bool, #[serde(default)] @@ -1009,6 +1011,7 @@ mod tests { serial_parity: Some("none".into()), serial_stop_bits: Some(1), serial_flow_control: Some("none".into()), + serial_auto_reconnect: Some(true), ftp_secure: false, notes: Some("maintenance window: Sat".into()), updated_at: "2026-01-02T00:00:00Z".into(), diff --git a/src/components/connections/ConnectionForm.tsx b/src/components/connections/ConnectionForm.tsx index 13524d970..72d46e644 100644 --- a/src/components/connections/ConnectionForm.tsx +++ b/src/components/connections/ConnectionForm.tsx @@ -45,6 +45,7 @@ import { import { SecretInput, TagsAndFolderFields } from "@/components/shared/vaultObjectForm"; import { AdvancedDisclosure, + SettingRow, HostCommandFields, hostCommandFieldsSet, useHostCommandFields, @@ -514,59 +515,40 @@ const ConnectionForm = forwardRef(function Connecti -
- - {t("connections.form.agentForwarding")} - - { markDirty(); setAgentForwarding(v); }} - /> - -
-
+ { markDirty(); setAgentForwarding(v); }} /> + + - - {t("connections.form.legacyAlgorithms")} - - { markDirty(); setLegacyAlgorithms(v); }} - /> - -
-
- - {t("connections.form.shellIntegration")} + { markDirty(); setLegacyAlgorithms(v); }} /> + + { markDirty(); setShellIntegration(v as "" | "on" | "off"); }} /> -
-
- - {t("connections.form.keepalive")} + + { markDirty(); setKeepalivePreset(v as KeepalivePreset | ""); }} /> -
-
- - {t("connections.form.persistentSession")} + + { markDirty(); setPersistSession(v as "" | "on" | "off"); }} /> -
+ )} @@ -612,18 +594,14 @@ const ConnectionForm = forwardRef(function Connecti {isFtp && ( -
- - {t("connections.form.ftpsToggle")} - { markDirty(); setFtpSecure(v); }} /> -
+ + { markDirty(); setFtpSecure(v); }} /> + )} {isFtp && ( -
- - {t("connections.form.anonymousLogin")} - { markDirty(); setUsername(v ? "anonymous" : ""); }} /> -
+ + { markDirty(); setUsername(v ? "anonymous" : ""); }} /> + )} {!isFtp && ( diff --git a/src/components/connections/SerialConnectionForm.tsx b/src/components/connections/SerialConnectionForm.tsx index 573de4df4..bc8b22752 100644 --- a/src/components/connections/SerialConnectionForm.tsx +++ b/src/components/connections/SerialConnectionForm.tsx @@ -20,8 +20,10 @@ import { Pills } from "@/components/shared/Pills"; import { FormSelect } from "@/components/shared/FormSelect"; import { PortInput } from "@/components/shared/PortInput"; import { TagsAndFolderFields } from "@/components/shared/vaultObjectForm"; +import { Toggle } from "@/components/shared/Toggle"; import { AdvancedDisclosure, + SettingRow, HostCommandFields, hostCommandFieldsSet, useHostCommandFields, @@ -48,6 +50,7 @@ const SerialConnectionForm = forwardRef(initial?.serial_stop_bits ?? 1); const [flowControl, setFlowControl] = useState(initial?.serial_flow_control ?? "none"); + const [autoReconnect, setAutoReconnect] = useState(initial?.serial_auto_reconnect ?? true); const hostCommands = useHostCommandFields(initial); const [showAdvanced, setShowAdvanced] = useState( !!( @@ -59,7 +62,8 @@ const SerialConnectionForm = forwardRef(initial?.tags ?? []); @@ -89,6 +93,7 @@ const SerialConnectionForm = forwardRef schedule(), [name, serialPort, baud, customBaud, useCustomBaud, dataBits, parity, stopBits, flowControl, hostCommands.preCommand, hostCommands.postCommand, hostCommands.preSnippetId, hostCommands.postSnippetId, hostCommands.askVarsEachTime, hostCommands.terminalEncoding, tags, folderId, vaultId]); + useEffect(() => schedule(), [name, serialPort, baud, customBaud, useCustomBaud, dataBits, parity, stopBits, flowControl, autoReconnect, hostCommands.preCommand, hostCommands.postCommand, hostCommands.preSnippetId, hostCommands.postSnippetId, hostCommands.askVarsEachTime, hostCommands.terminalEncoding, tags, folderId, vaultId]); useImperativeHandle(ref, () => ({ flush, isDirty: () => userEditedRef.current }), [flush]); @@ -234,7 +239,7 @@ const SerialConnectionForm = forwardRef setShowAdvanced((v) => !v)} - hasValues={!!(hostCommandFieldsSet(hostCommands) || dataBits !== 8 || parity !== "none" || stopBits !== 1 || flowControl !== "none")} + hasValues={!!(hostCommandFieldsSet(hostCommands) || dataBits !== 8 || parity !== "none" || stopBits !== 1 || flowControl !== "none" || !autoReconnect)} >
@@ -288,6 +293,14 @@ const SerialConnectionForm = forwardRef
+ + { markDirty(); setAutoReconnect(v); }} /> + + diff --git a/src/components/connections/formShared.tsx b/src/components/connections/formShared.tsx index 6bbbd4430..9764889dd 100644 --- a/src/components/connections/formShared.tsx +++ b/src/components/connections/formShared.tsx @@ -90,6 +90,28 @@ export interface HostCommandFieldsState { setTerminalEncoding: (v: string) => void; } +/** One labelled row in a form's advanced block: icon, label, and a control + * pushed to the right edge. */ +export function SettingRow({ + icon, + label, + title, + children, +}: { + icon: string; + label: string; + title?: string; + children: ReactNode; +}) { + return ( +
+ + {label} + {children} +
+ ); +} + /** The pre/post command state both forms keep and submit. */ export function useHostCommandFields(initial?: Connection): HostCommandFieldsState { const [preCommand, setPreCommand] = useState(initial?.pre_command ?? ""); diff --git a/src/components/hosts/HostsPage.tsx b/src/components/hosts/HostsPage.tsx index 5b4673d4d..ec9de2346 100644 --- a/src/components/hosts/HostsPage.tsx +++ b/src/components/hosts/HostsPage.tsx @@ -438,6 +438,7 @@ export default function HostsPage() { serial_parity: conn.serial_parity, serial_stop_bits: conn.serial_stop_bits, serial_flow_control: conn.serial_flow_control, + serial_auto_reconnect: conn.serial_auto_reconnect, pre_command: conn.pre_command, post_command: conn.post_command, pre_snippet_id: conn.pre_snippet_id, diff --git a/src/components/terminal/TerminalStatusBar.tsx b/src/components/terminal/TerminalStatusBar.tsx index 2277c60a0..b7c8da5bc 100644 --- a/src/components/terminal/TerminalStatusBar.tsx +++ b/src/components/terminal/TerminalStatusBar.tsx @@ -9,6 +9,7 @@ import { useHostPingStore } from "@/stores/hostPingStore"; import { usePluginStore, findRightPanelSectionWithFlag } from "@/stores/pluginStore"; import { useUIStore } from "@/stores/uiStore"; import { useSessionStore } from "@/stores/sessionStore"; +import { serialAutoReconnectEnabled } from "@/stores/serialAutoReconnect"; import { useAllConnections } from "@/hooks/useAllConnections"; import { useStatusBarContributions } from "@/hooks/useStatusBarContributions"; import { getPfState } from "@/services/portForwardingTunnels"; @@ -120,6 +121,31 @@ const SPARKLINE_MAX = 20; const statusBarItemClass = "h-full rounded-none transition-colors hover:bg-(--t-bg-card-hover)"; const statusBarIdentityGroupClass = "flex items-center h-full"; +function StatusBarIconButton({ + icon, + title, + color, + dimmed, + onClick, +}: { + icon: string; + title: string; + color: string; + dimmed?: boolean; + onClick: () => void; +}) { + return ( + + ); +} + export function TerminalStatusBar({ sessionId, sessionType, connectionId, connectionName, serialConfig, sessionStatus, dimensions }: Props) { const { t } = useTranslation(); const connections = useAllConnections(); @@ -138,6 +164,10 @@ export function TerminalStatusBar({ sessionId, sessionType, connectionId, connec const monitoringActive = metricsSectionId !== null; const reconnect = useSessionStore((s) => s.reconnect); const disconnect = useSessionStore((s) => s.disconnect); + const closeSerialPort = useSessionStore((s) => s.closeSerialPort); + const setSerialAutoReconnect = useSessionStore((s) => s.setSerialAutoReconnect); + const session = useSessionStore((s) => s.sessions.find((x) => x.id === sessionId)); + const serialAutoReconnect = session ? serialAutoReconnectEnabled(session, connection) : true; const [tunnels, setTunnels] = useState([]); const [pulse, setPulse] = useState(false); @@ -648,14 +678,12 @@ export function TerminalStatusBar({ sessionId, sessionType, connectionId, connec {isDisconnectedOrError && ( - + color="var(--t-status-error)" + onClick={() => void reconnect(sessionId)} + /> )} )} @@ -740,16 +768,21 @@ export function TerminalStatusBar({ sessionId, sessionType, connectionId, connec > {copied ? t("terminal.statusBar.copiedBang") : (serialConfig ? `${serialConfig.port} · ${serialConfig.baud} baud` : t("terminal.statusBar.serialFallback"))} - {isDisconnectedOrError && ( - - )} + + void (isDisconnectedOrError ? reconnect(sessionId) : closeSerialPort(sessionId)) + } + /> + void setSerialAutoReconnect(sessionId, !serialAutoReconnect)} + /> )} diff --git a/src/i18n/locales/en/connections.json b/src/i18n/locales/en/connections.json index 6e8ae330e..0017c2e5d 100644 --- a/src/i18n/locales/en/connections.json +++ b/src/i18n/locales/en/connections.json @@ -56,6 +56,8 @@ "legacyAlgorithms": "Legacy Algorithms", "legacyAlgorithmsTooltip": "Allow weak legacy algorithms (diffie-hellman-group1-sha1, 3des-cbc, hmac-sha1, …) for old devices such as legacy Cisco IOS. Strong algorithms are still preferred. Only enable for hosts that require it.", "agentForwarding": "Agent Forwarding", + "serialAutoReconnect": "Auto-reconnect", + "serialAutoReconnectTooltip": "Reopen the port automatically after a drop. Turn this off for boards you reflash: the retry loop would otherwise take the port back while the flashing tool needs it.", "shellIntegration": "Shell Integration", "keepalive": "Keepalive", "inheritKeepalive": "Inherit ({{label}})", diff --git a/src/i18n/locales/en/terminal.json b/src/i18n/locales/en/terminal.json index e51d76b1d..dd9369c8b 100644 --- a/src/i18n/locales/en/terminal.json +++ b/src/i18n/locales/en/terminal.json @@ -291,6 +291,10 @@ "copyLabel": "Copy {{text}}", "openPortsPanel": "Open ports panel", "reconnectTitle": "Reconnect", + "serialClose": "Close the serial port (keeps the tab)", + "serialReopen": "Reopen the serial port", + "serialAutoReconnectOn": "Auto-reconnect on — click to stop reclaiming the port", + "serialAutoReconnectOff": "Auto-reconnect off — the port stays free after a drop", "systemMetricsTitle": "System metrics", "activeTunnels_one": "{{count}} active tunnel", "activeTunnels_other": "{{count}} active tunnels", diff --git a/src/i18n/locales/fr/connections.json b/src/i18n/locales/fr/connections.json index c7a362c57..07471f79e 100644 --- a/src/i18n/locales/fr/connections.json +++ b/src/i18n/locales/fr/connections.json @@ -56,6 +56,8 @@ "legacyAlgorithms": "Algorithmes hérités", "legacyAlgorithmsTooltip": "Autoriser les algorithmes hérités faibles (diffie-hellman-group1-sha1, 3des-cbc, hmac-sha1, …) pour les anciens appareils tels que les Cisco IOS obsolètes. Les algorithmes robustes restent privilégiés. N'activez cette option que pour les hôtes qui l'exigent.", "agentForwarding": "Redirection de l'agent", + "serialAutoReconnect": "Reconnexion automatique", + "serialAutoReconnectTooltip": "Rouvrir le port automatiquement après une coupure. À désactiver pour les cartes que vous reflashez : sinon la boucle de reconnexion reprend le port pendant que l'outil de flash en a besoin.", "shellIntegration": "Intégration au shell", "keepalive": "Keepalive", "inheritKeepalive": "Hérite ({{label}})", diff --git a/src/i18n/locales/fr/terminal.json b/src/i18n/locales/fr/terminal.json index 448b7a119..ac83327a9 100644 --- a/src/i18n/locales/fr/terminal.json +++ b/src/i18n/locales/fr/terminal.json @@ -291,6 +291,10 @@ "copyLabel": "Copier {{text}}", "openPortsPanel": "Ouvrir le panneau des ports", "reconnectTitle": "Reconnecter", + "serialClose": "Fermer le port série (l'onglet reste ouvert)", + "serialReopen": "Rouvrir le port série", + "serialAutoReconnectOn": "Reconnexion auto activée — cliquer pour ne plus reprendre le port", + "serialAutoReconnectOff": "Reconnexion auto désactivée — le port reste libre après une coupure", "systemMetricsTitle": "Métriques système", "activeTunnels_one": "{{count}} tunnel actif", "activeTunnels_other": "{{count}} tunnels actifs", diff --git a/src/i18n/locales/ru/connections.json b/src/i18n/locales/ru/connections.json index be3005a2b..d9ce09629 100644 --- a/src/i18n/locales/ru/connections.json +++ b/src/i18n/locales/ru/connections.json @@ -56,6 +56,8 @@ "legacyAlgorithms": "Устаревшие алгоритмы", "legacyAlgorithmsTooltip": "Разрешить слабые устаревшие алгоритмы (diffie-hellman-group1-sha1, 3des-cbc, hmac-sha1, …) для старых устройств, таких как старые версии Cisco IOS. Надёжные алгоритмы по-прежнему предпочтительны. Включайте только для хостов, которым это необходимо.", "agentForwarding": "Проброс агента", + "serialAutoReconnect": "Автопереподключение", + "serialAutoReconnectTooltip": "Автоматически открывать порт заново после обрыва. Отключите для плат, которые вы перепрошиваете: иначе цикл переподключения займёт порт, пока он нужен программатору.", "shellIntegration": "Интеграция с оболочкой", "keepalive": "Keepalive", "inheritKeepalive": "Наследовать ({{label}})", diff --git a/src/i18n/locales/ru/terminal.json b/src/i18n/locales/ru/terminal.json index c7ea1adc2..5f05ffa11 100644 --- a/src/i18n/locales/ru/terminal.json +++ b/src/i18n/locales/ru/terminal.json @@ -301,6 +301,10 @@ "copyLabel": "Копировать {{text}}", "openPortsPanel": "Открыть панель портов", "reconnectTitle": "Переподключиться", + "serialClose": "Закрыть последовательный порт (вкладка останется)", + "serialReopen": "Снова открыть последовательный порт", + "serialAutoReconnectOn": "Автопереподключение включено — нажмите, чтобы не занимать порт", + "serialAutoReconnectOff": "Автопереподключение выключено — после обрыва порт остаётся свободным", "systemMetricsTitle": "Системные метрики", "activeTunnels_one": "{{count}} активный туннель", "activeTunnels_few": "{{count}} активных туннеля", diff --git a/src/i18n/locales/zh/connections.json b/src/i18n/locales/zh/connections.json index 5f0567579..aa9544cca 100644 --- a/src/i18n/locales/zh/connections.json +++ b/src/i18n/locales/zh/connections.json @@ -56,6 +56,8 @@ "legacyAlgorithms": "传统算法", "legacyAlgorithmsTooltip": "允许使用弱传统算法(diffie-hellman-group1-sha1、3des-cbc、hmac-sha1 等)以兼容旧设备(如旧版 Cisco IOS)。仍然优先使用强算法。仅对需要的主机启用。", "agentForwarding": "代理转发", + "serialAutoReconnect": "自动重连", + "serialAutoReconnectTooltip": "断开后自动重新打开串口。为需要重新烧录的开发板关闭此项:否则重连循环会在烧录工具占用串口时把它抢回来。", "shellIntegration": "Shell 集成", "keepalive": "保持连接", "inheritKeepalive": "继承({{label}})", diff --git a/src/i18n/locales/zh/terminal.json b/src/i18n/locales/zh/terminal.json index 68d07a54e..fc4ad9da9 100644 --- a/src/i18n/locales/zh/terminal.json +++ b/src/i18n/locales/zh/terminal.json @@ -291,6 +291,10 @@ "copyLabel": "复制 {{text}}", "openPortsPanel": "打开端口面板", "reconnectTitle": "重连", + "serialClose": "关闭串口(保留标签页)", + "serialReopen": "重新打开串口", + "serialAutoReconnectOn": "自动重连已开启 — 点击后不再占用串口", + "serialAutoReconnectOff": "自动重连已关闭 — 断开后串口保持空闲", "systemMetricsTitle": "系统指标", "activeTunnels_one": "{{count}} 个活跃隧道", "activeTunnels_other": "{{count}} 个活跃隧道", diff --git a/src/stores/connectionStore.ts b/src/stores/connectionStore.ts index 12bf31a1c..cf6f4b173 100644 --- a/src/stores/connectionStore.ts +++ b/src/stores/connectionStore.ts @@ -33,7 +33,7 @@ export function connectionToFormData(c: Connection): ConnectionFormData { keepalive_preset: c.keepalive_preset, connection_type: c.connection_type, serial_port: c.serial_port, serial_baud: c.serial_baud, serial_data_bits: c.serial_data_bits, serial_parity: c.serial_parity, serial_stop_bits: c.serial_stop_bits, - serial_flow_control: c.serial_flow_control, ftp_secure: c.ftp_secure, + serial_flow_control: c.serial_flow_control, serial_auto_reconnect: c.serial_auto_reconnect, ftp_secure: c.ftp_secure, notes: c.notes, }; } @@ -113,6 +113,7 @@ export const useConnectionStore = create((set, get) => ({ serial_parity: data.serial_parity, serial_stop_bits: data.serial_stop_bits, serial_flow_control: data.serial_flow_control, + serial_auto_reconnect: data.serial_auto_reconnect, ftp_secure: data.ftp_secure, notes: data.notes, created_at: now, @@ -188,6 +189,7 @@ export const useConnectionStore = create((set, get) => ({ serial_parity: data.serial_parity ?? prev.serial_parity, serial_stop_bits: data.serial_stop_bits ?? prev.serial_stop_bits, serial_flow_control: data.serial_flow_control ?? prev.serial_flow_control, + serial_auto_reconnect: data.serial_auto_reconnect ?? prev.serial_auto_reconnect, ftp_secure: data.ftp_secure ?? prev.ftp_secure, notes: data.notes, ping_disabled: data.ping_disabled, @@ -259,6 +261,7 @@ export const useConnectionStore = create((set, get) => ({ serial_parity: data.serial_parity ?? prev.serial_parity, serial_stop_bits: data.serial_stop_bits ?? prev.serial_stop_bits, serial_flow_control: data.serial_flow_control ?? prev.serial_flow_control, + serial_auto_reconnect: data.serial_auto_reconnect ?? prev.serial_auto_reconnect, ftp_secure: data.ftp_secure ?? prev.ftp_secure, notes: data.notes, ping_disabled: data.ping_disabled, diff --git a/src/stores/reconnectBackoff.test.ts b/src/stores/reconnectBackoff.test.ts index 2a337eb71..5eabbb5ab 100644 --- a/src/stores/reconnectBackoff.test.ts +++ b/src/stores/reconnectBackoff.test.ts @@ -160,9 +160,10 @@ globalThis.setTimeout = realSetTimeout; // --- handleSessionClosed: start reconnect only on an unexpected close --- (() => { const calls: string[] = []; - const deps = (status: SessionStatus, persist = false) => ({ + const deps = (status: SessionStatus, persist = false, autoReconnect = true) => ({ status: () => status, persist: () => persist, + autoReconnect: () => autoReconnect, markDisconnected: () => calls.push("disconnect"), reconnectWithBackoff: () => calls.push("backoff"), endSession: () => calls.push("end"), @@ -188,6 +189,17 @@ globalThis.setTimeout = realSetTimeout; handleSessionClosed("local", "s1", deps("connected")); assertEqual(calls, ["disconnect"], "local close marks disconnected without reconnecting"); + // Auto-reconnect turned off for this serial device (#192): a drop must leave + // the port free — the loop would otherwise reclaim /dev/ttyUSB0 every 10s and + // fight the flashing tool the user just started. + calls.length = 0; + handleSessionClosed("serial", "s1", deps("connected", false, false)); + assertEqual(calls, ["disconnect"], "serial close with auto-reconnect off marks disconnected without reconnecting"); + + calls.length = 0; + handleSessionClosed("ssh", "s1", deps("connected", false, false)); + assertEqual(calls, ["disconnect"], "auto-reconnect off suppresses the ssh loop too"); + // The remote shell exited on purpose (`exit`): the channel carried an // exit-status, so this is not a drop and reconnecting would resurrect a // session the user just closed (#180). diff --git a/src/stores/reconnectBackoff.ts b/src/stores/reconnectBackoff.ts index 00485cb2f..b150123f2 100644 --- a/src/stores/reconnectBackoff.ts +++ b/src/stores/reconnectBackoff.ts @@ -1,4 +1,5 @@ -import { useSessionStore } from "./sessionStore"; +import { connectionForSession, useSessionStore } from "./sessionStore"; +import { serialAutoReconnectEnabled } from "./serialAutoReconnect"; import { type BackoffStore, handleSessionClosed, runBackoff } from "./reconnectBackoffCore"; const liveStore: BackoffStore = { @@ -32,6 +33,10 @@ export function sessionClosed(sessionType: string, sessionId: string, remoteExit { status: (id) => useSessionStore.getState().sessions.find((s) => s.id === id)?.status, persist: (id) => !!useSessionStore.getState().sessions.find((s) => s.id === id)?.persist, + autoReconnect: (id) => { + const sess = useSessionStore.getState().sessions.find((s) => s.id === id); + return !sess || serialAutoReconnectEnabled(sess, connectionForSession(sess)); + }, markDisconnected: (id) => useSessionStore.getState().markDisconnected(id), reconnectWithBackoff, endSession: (id) => { diff --git a/src/stores/reconnectBackoffCore.ts b/src/stores/reconnectBackoffCore.ts index 48ce17130..ad7320100 100644 --- a/src/stores/reconnectBackoffCore.ts +++ b/src/stores/reconnectBackoffCore.ts @@ -104,6 +104,10 @@ export async function runBackoff(sessionId: string, store: BackoffStore): Promis * Persistent sessions are excluded: their wrapper also exits on a tmux/screen * detach, so there the attach probe (SESSION_ENDED) stays the judge. * + * Auto-reconnect turned off (serial devices that must release the port, #192): + * the drop just marks the session disconnected, leaving the port free and the + * reopen button armed. + * * local: just mark disconnected (no reconnect). */ export function handleSessionClosed( sessionType: string, @@ -111,6 +115,7 @@ export function handleSessionClosed( deps: { status: (id: string) => SessionStatus; persist: (id: string) => boolean; + autoReconnect: (id: string) => boolean; markDisconnected: (id: string) => void; reconnectWithBackoff: (id: string) => void; endSession: (id: string) => void; @@ -122,6 +127,10 @@ export function handleSessionClosed( return; } if (deps.status(sessionId) !== "connected") return; + if (!deps.autoReconnect(sessionId)) { + deps.markDisconnected(sessionId); + return; + } if (remoteExit && !deps.persist(sessionId)) { deps.endSession(sessionId); return; diff --git a/src/stores/serialAutoReconnect.test.ts b/src/stores/serialAutoReconnect.test.ts new file mode 100644 index 000000000..0c1e01f41 --- /dev/null +++ b/src/stores/serialAutoReconnect.test.ts @@ -0,0 +1,34 @@ +import { describe, test, expect } from "vitest"; +import type { Connection, TerminalSession } from "@/types"; +import { serialAutoReconnectEnabled } from "./serialAutoReconnect"; + +const session = (over: Partial = {}) => + ({ id: "s1", type: "serial", title: "esp32", status: "connected", ...over }) as unknown as TerminalSession; + +const connection = (over: Partial = {}) => + ({ id: "c1", name: "esp32", connection_type: "serial", ...over }) as unknown as Connection; + +describe("serialAutoReconnectEnabled", () => { + test("defaults to on when nothing has been set", () => { + expect(serialAutoReconnectEnabled(session(), connection())).toBe(true); + }); + + test("the connection's stored preference wins", () => { + expect(serialAutoReconnectEnabled(session(), connection({ serial_auto_reconnect: false }))).toBe(false); + expect(serialAutoReconnectEnabled(session(), connection({ serial_auto_reconnect: true }))).toBe(true); + }); + + test("an ephemeral serial session falls back to its own flag", () => { + expect(serialAutoReconnectEnabled(session({ autoReconnect: false }), undefined)).toBe(false); + expect(serialAutoReconnectEnabled(session(), undefined)).toBe(true); + }); + + test("ssh sessions are never gated", () => { + expect( + serialAutoReconnectEnabled( + session({ type: "ssh", autoReconnect: false }), + connection({ serial_auto_reconnect: false }), + ), + ).toBe(true); + }); +}); diff --git a/src/stores/serialAutoReconnect.ts b/src/stores/serialAutoReconnect.ts new file mode 100644 index 000000000..8ffd3fa46 --- /dev/null +++ b/src/stores/serialAutoReconnect.ts @@ -0,0 +1,16 @@ +import type { Connection, TerminalSession } from "@/types"; + +/** Whether a dropped session should be chased by the reconnect backoff. + * + * Only serial can be turned off (#192): the loop reclaims the port every few + * seconds, which locks out the flashing tool the user just started. The + * preference lives on the connection so it survives a restart and follows the + * device; an ephemeral serial session has nowhere to persist it and falls back + * to a flag on the session itself. */ +export function serialAutoReconnectEnabled( + session: TerminalSession, + connection: Connection | undefined, +): boolean { + if (session.type !== "serial") return true; + return connection?.serial_auto_reconnect ?? session.autoReconnect ?? true; +} diff --git a/src/stores/sessionStore.serialPort.test.ts b/src/stores/sessionStore.serialPort.test.ts new file mode 100644 index 000000000..8603653aa --- /dev/null +++ b/src/stores/sessionStore.serialPort.test.ts @@ -0,0 +1,142 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; +import type { Connection, TerminalSession } from "@/types"; + +const connection = { + id: "c1", + name: "esp32", + connection_type: "serial", + serial_port: "/dev/ttyUSB0", + serial_baud: 115200, +} as unknown as Connection; + +const h = vi.hoisted(() => ({ + statusWhenReleased: [] as (string | undefined)[], + serialDisconnect: vi.fn(async () => {}), + serialConnect: vi.fn(async () => {}), + cancelBackoff: vi.fn(), + updateConnection: vi.fn(async () => {}), +})); + +vi.mock("@/services/serial", () => ({ + serialConnect: h.serialConnect, + serialDisconnect: h.serialDisconnect, + serialListPorts: vi.fn(async () => []), +})); +vi.mock("@/services/ssh", () => ({ + sshConnect: vi.fn(async () => {}), + sshDisconnect: vi.fn(async () => true), + sshDisconnectForReconnect: vi.fn(async () => {}), + sshDetectDistro: vi.fn(async () => null), + sshSendInput: vi.fn(async () => {}), +})); +vi.mock("./reconnectBackoffCore", async (importOriginal) => ({ + ...(await importOriginal()), + cancelBackoff: h.cancelBackoff, +})); +vi.mock("@/services/credentials", () => ({ + resolveConnectionCredentials: vi.fn(async () => ({ username: "root" })), + resolveJumpHosts: vi.fn(async () => []), +})); +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: { + getState: () => ({ + connections: [connection], + teamConnections: {}, + setLastUsed: vi.fn(async () => {}), + updateConnection: h.updateConnection, + }), + }, + connectionToFormData: (c: Connection) => ({ name: c.name, connection_type: c.connection_type }), +})); +vi.mock("./layoutStore", () => ({ + useLayoutStore: { getState: () => ({ setSplitTabActive: vi.fn(), removeSession: vi.fn() }) }, +})); +vi.mock("@/services/hostCommandRun", () => ({ runHostCommand: vi.fn(async () => {}) })); +vi.mock("@/services/auditReporter", () => ({ reportAuditClientEvent: vi.fn() })); +vi.mock("@/services/auditContextResolver", () => ({ auditContextForVaultId: vi.fn(() => ({})) })); + +import { useSessionStore } from "./sessionStore"; + +const session = (over: Partial = {}) => + ({ + id: "s1", + type: "serial", + connectionId: "c1", + title: "esp32", + status: "connected", + serialConfig: { sessionId: "s1", port: "/dev/ttyUSB0", baud: 115200 }, + ...over, + }) as unknown as TerminalSession; + +function seed(over: Partial = {}) { + useSessionStore.setState({ sessions: [session(over)], activeSessionId: "s1" }); +} + +const current = () => useSessionStore.getState().sessions.find((s) => s.id === "s1"); + +describe("closeSerialPort", () => { + beforeEach(() => { + vi.clearAllMocks(); + h.statusWhenReleased.length = 0; + h.serialDisconnect.mockImplementation(async () => { + h.statusWhenReleased.push(current()?.status); + }); + }); + + test("releases the port but keeps the tab and its config", async () => { + seed(); + await useSessionStore.getState().closeSerialPort("s1"); + + expect(h.serialDisconnect).toHaveBeenCalledWith("s1"); + expect(current()?.status).toBe("disconnected"); + expect(current()?.serialConfig).toBeDefined(); + }); + + // The backend emits serial-closed the moment the port drops; if the session + // still read 'connected' then, handleSessionClosed would start the backoff + // loop and immediately reclaim the port the user asked to free. + test("marks the session disconnected before the port is released", async () => { + seed(); + await useSessionStore.getState().closeSerialPort("s1"); + + expect(h.statusWhenReleased).toEqual(["disconnected"]); + }); + + test("cancels any reconnect loop already in flight", async () => { + seed({ status: "connecting" }); + await useSessionStore.getState().closeSerialPort("s1"); + + expect(h.cancelBackoff).toHaveBeenCalledWith("s1"); + }); + + test("ignores a session that is not serial", async () => { + seed({ type: "ssh" }); + await useSessionStore.getState().closeSerialPort("s1"); + + expect(h.serialDisconnect).not.toHaveBeenCalled(); + expect(current()?.status).toBe("connected"); + }); +}); + +describe("setSerialAutoReconnect", () => { + beforeEach(() => vi.clearAllMocks()); + + test("persists a saved connection's preference on the connection", async () => { + seed(); + await useSessionStore.getState().setSerialAutoReconnect("s1", false); + + expect(h.updateConnection).toHaveBeenCalledWith( + "c1", + expect.objectContaining({ name: "esp32", serial_auto_reconnect: false }), + ); + }); + + // Quick-connect serial sessions carry a sentinel id that no connection store + // resolves, so there is nothing to persist the preference on. + test("stores an ephemeral session's preference on the session", async () => { + seed({ connectionId: "serial-ephemeral" }); + await useSessionStore.getState().setSerialAutoReconnect("s1", false); + + expect(current()?.autoReconnect).toBe(false); + }); +}); diff --git a/src/stores/sessionStore.ts b/src/stores/sessionStore.ts index 7a034420d..23ed66cba 100644 --- a/src/stores/sessionStore.ts +++ b/src/stores/sessionStore.ts @@ -60,6 +60,10 @@ interface SessionStore { connectSerialEphemeral: (initialPort?: string) => Promise; connectSerialEphemeralFinalize: (sessionId: string, params: SerialConnectParams) => Promise; resetSerialEphemeral: (sessionId: string) => void; + /** Release the serial port while keeping the tab, its buffer and its + * config, so the user can hand the device to another tool and reopen (#192). */ + closeSerialPort: (sessionId: string) => Promise; + setSerialAutoReconnect: (sessionId: string, enabled: boolean) => Promise; disconnect: (sessionId: string) => Promise; setActive: (sessionId: string) => void; markDisconnected: (sessionId: string) => void; @@ -100,6 +104,11 @@ function findConnection(connectionId: string): Connection | undefined { ); } +/** The saved, team or ephemeral connection a session was opened from. */ +export function connectionForSession(session: TerminalSession): Connection | undefined { + return session.connectionId ? findConnection(session.connectionId) : undefined; +} + function reportConnectionAudit(connection: Connection, action: ClientAuditAction): void { reportAuditClientEvent(auditContextForVaultId(connection.vault_id), action, { target_type: "connection", @@ -387,6 +396,14 @@ function markSessionConnecting(set: SessionSetter, sessionId: string) { })); } +function markSessionDisconnected(set: SessionSetter, sessionId: string) { + set((s) => ({ + sessions: s.sessions.map((sess) => + sess.id === sessionId ? { ...sess, status: "disconnected" as const } : sess, + ), + })); +} + // Auth/username supplied through the overlay, carried across the two-step prompt // flow (username first, then auth) for a single session. Cleared on success. const connectOverrides = new Map(); @@ -753,6 +770,35 @@ export const useSessionStore = create((set, get) => ({ })); }, + closeSerialPort: async (sessionId) => { + const session = get().sessions.find((s) => s.id === sessionId); + if (!session || session.type !== "serial") return; + // Ordering matters: the backend emits serial-closed as soon as the port + // drops, and handleSessionClosed only starts the backoff loop for a session + // that still reads 'connected'. + markSessionDisconnected(set, sessionId); + cancelBackoff(sessionId); + await serialDisconnect(sessionId).catch(() => {}); + }, + + setSerialAutoReconnect: async (sessionId, enabled) => { + const session = get().sessions.find((s) => s.id === sessionId); + if (!session) return; + const connection = connectionForSession(session); + if (connection) { + await useConnectionStore.getState().updateConnection(connection.id, { + ...connectionToFormData(connection), + serial_auto_reconnect: enabled, + }); + return; + } + set((s) => ({ + sessions: s.sessions.map((sess) => + sess.id === sessionId ? { ...sess, autoReconnect: enabled } : sess, + ), + })); + }, + disconnect: async (sessionId) => { cancelBackoff(sessionId); const session = get().sessions.find((s) => s.id === sessionId); @@ -831,12 +877,7 @@ export const useSessionStore = create((set, get) => ({ setActive: (sessionId) => set({ activeSessionId: sessionId }), - markDisconnected: (sessionId) => - set((s) => ({ - sessions: s.sessions.map((sess) => - sess.id === sessionId ? { ...sess, status: "disconnected" as const } : sess, - ), - })), + markDisconnected: (sessionId) => markSessionDisconnected(set, sessionId), // Steady "connecting" the auto-reconnect loop holds across attempts, so the // overlay shows the normal connection steps (TCP step spinning) instead of a diff --git a/src/stores/vaultObjectStores.characterisation.test.ts b/src/stores/vaultObjectStores.characterisation.test.ts index 65f7529d3..fbf45790a 100644 --- a/src/stores/vaultObjectStores.characterisation.test.ts +++ b/src/stores/vaultObjectStores.characterisation.test.ts @@ -114,7 +114,7 @@ const adapters: Adapter[] = [ store: useConnectionStore as unknown as Store, localKey: "connections", teamKey: "teamConnections", persistKind: "connection", auditKind: "connection", pinField: "pinned", - pinPayloadKeys: ["name", "host", "port", "username", "auth_type", "tags", "identity_id", "key_id", "folder_id", "vault_id", "jump_hosts", "env_vars", "agent_forwarding", "legacy_algorithms", "pre_command", "post_command", "pre_snippet_id", "post_snippet_id", "ask_vars_each_time", "terminal_encoding", "distro", "icon", "pinned", "ping_disabled", "shell_integration", "keepalive_preset", "connection_type", "serial_port", "serial_baud", "serial_data_bits", "serial_parity", "serial_stop_bits", "serial_flow_control", "ftp_secure", "notes"], + pinPayloadKeys: ["name", "host", "port", "username", "auth_type", "tags", "identity_id", "key_id", "folder_id", "vault_id", "jump_hosts", "env_vars", "agent_forwarding", "legacy_algorithms", "pre_command", "post_command", "pre_snippet_id", "post_snippet_id", "ask_vars_each_time", "terminal_encoding", "distro", "icon", "pinned", "ping_disabled", "shell_integration", "keepalive_preset", "connection_type", "serial_port", "serial_baud", "serial_data_bits", "serial_parity", "serial_stop_bits", "serial_flow_control", "serial_auto_reconnect", "ftp_secure", "notes"], api: h.connections, seed: (o) => ({ id: "x1", name: "Web", host: "h", port: 22, username: "u", auth_type: "key", tags: [], vault_id: "personal", ...stamps, ...o }), form: (o) => ({ name: "Web", host: "h", port: 22, username: "u", auth_type: "key", tags: [], vault_id: "personal", ...o }), diff --git a/src/types/index.ts b/src/types/index.ts index e82f76a40..fbf442a0c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -138,6 +138,7 @@ export interface Connection { serial_parity?: string; serial_stop_bits?: number; serial_flow_control?: string; + serial_auto_reconnect?: boolean; updated_at: string; deleted_at?: string; clocks: Record; @@ -180,6 +181,7 @@ export interface ConnectionFormData { serial_parity?: string; serial_stop_bits?: number; serial_flow_control?: string; + serial_auto_reconnect?: boolean; } export interface KnownHost { @@ -224,6 +226,9 @@ export interface TerminalSession { encoding?: string; localShell?: string; serialConfig?: SerialConnectParams; + /** Serial only, ephemeral sessions: the auto-reconnect preference has no + * connection to live on, so it is held here for the session's lifetime. */ + autoReconnect?: boolean; /** Serial only: port typed at quick-connect time, prefilled into the config overlay. */ initialSerialPort?: string; containerExec?: