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?: