Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src-tauri/src/commands/connections.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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()),
}
Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand All @@ -600,6 +606,6 @@ mod tests {
let bumpable: HashSet<String> = new.clocks.into_keys().collect();

assert_eq!(seeded, bumpable);
assert_eq!(seeded.len(), 35);
assert_eq!(seeded.len(), 36);
}
}
66 changes: 54 additions & 12 deletions src-tauri/src/serial/connect.rs
Original file line number Diff line number Diff line change
@@ -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<dyn serialport::SerialPort>,
}

pub(super) type SessionMap = HashMap<String, SerialSession>;

pub struct SerialSessionManager {
sessions: Arc<Mutex<HashMap<String, Box<dyn serialport::SerialPort>>>>,
pub(super) sessions: Arc<Mutex<SessionMap>>,
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<dyn serialport::SerialPort>) -> 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<SessionMap>,
session_id: &str,
generation: u64,
) -> bool {
sessions
.lock()
.unwrap()
.get(session_id)
.is_some_and(|s| s.generation == generation)
}

#[derive(serde::Serialize, Clone)]
Expand Down Expand Up @@ -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();
Expand All @@ -138,16 +177,19 @@ 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;
}
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(
Expand All @@ -165,17 +207,17 @@ pub fn serial_write(
data: Vec<u8>,
) -> 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]
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(())
}
31 changes: 31 additions & 0 deletions src-tauri/src/serial/connect_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
use super::connect::{generation_is_current, SerialSessionManager};

fn open_port() -> Box<dyn serialport::SerialPort> {
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));
}
3 changes: 3 additions & 0 deletions src-tauri/src/serial/mod.rs
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
pub mod connect;

#[cfg(test)]
mod connect_tests;
3 changes: 3 additions & 0 deletions src-tauri/src/storage/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ pub struct Connection {
pub serial_stop_bits: Option<u8>,
#[serde(default)]
pub serial_flow_control: Option<String>,
pub serial_auto_reconnect: Option<bool>,
/// FTP only: use explicit FTPS (AUTH TLS) instead of plain FTP.
#[serde(default)]
pub ftp_secure: bool,
Expand Down Expand Up @@ -257,6 +258,7 @@ pub struct ConnectionFormData {
pub serial_stop_bits: Option<u8>,
#[serde(default)]
pub serial_flow_control: Option<String>,
pub serial_auto_reconnect: Option<bool>,
#[serde(default)]
pub ftp_secure: bool,
#[serde(default)]
Expand Down Expand Up @@ -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(),
Expand Down
70 changes: 24 additions & 46 deletions src/components/connections/ConnectionForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
import { SecretInput, TagsAndFolderFields } from "@/components/shared/vaultObjectForm";
import {
AdvancedDisclosure,
SettingRow,
HostCommandFields,
hostCommandFieldsSet,
useHostCommandFields,
Expand Down Expand Up @@ -514,59 +515,40 @@ const ConnectionForm = forwardRef<ConnectionFormHandle, Props>(function Connecti
<Icon icon="lucide:chevron-right" width={12} className="ml-auto" />
</button>
<HostCommandFields connectionId={initial?.id} fields={hostCommands} markDirty={markDirty} />
<div className="flex items-center gap-1.5 text-xs text-(--t-text-dim) w-full py-1">
<Icon icon="lucide:key-round" width={13} />
<span>{t("connections.form.agentForwarding")}</span>
<span className="ml-auto">
<Toggle
checked={agentForwarding}
onChange={(v) => { markDirty(); setAgentForwarding(v); }}
/>
</span>
</div>
<div
className="flex items-center gap-1.5 text-xs text-(--t-text-dim) w-full py-1"
<SettingRow icon="lucide:key-round" label={t("connections.form.agentForwarding")}>
<Toggle checked={agentForwarding} onChange={(v) => { markDirty(); setAgentForwarding(v); }} />
</SettingRow>
<SettingRow
icon="lucide:shield-alert"
label={t("connections.form.legacyAlgorithms")}
title={t("connections.form.legacyAlgorithmsTooltip")}
>
<Icon icon="lucide:shield-alert" width={13} />
<span>{t("connections.form.legacyAlgorithms")}</span>
<span className="ml-auto">
<Toggle
checked={legacyAlgorithms}
onChange={(v) => { markDirty(); setLegacyAlgorithms(v); }}
/>
</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-(--t-text-dim) w-full py-1">
<Icon icon="lucide:terminal" width={13} />
<span>{t("connections.form.shellIntegration")}</span>
<Toggle checked={legacyAlgorithms} onChange={(v) => { markDirty(); setLegacyAlgorithms(v); }} />
</SettingRow>
<SettingRow icon="lucide:terminal" label={t("connections.form.shellIntegration")}>
<FormSelect
className="ml-auto w-36"
className="w-36"
value={shellIntegration}
options={shellIntegrationOptions}
onChange={(v) => { markDirty(); setShellIntegration(v as "" | "on" | "off"); }}
/>
</div>
<div className="flex items-center gap-1.5 text-xs text-(--t-text-dim) w-full py-1">
<Icon icon="lucide:heart-pulse" width={13} />
<span>{t("connections.form.keepalive")}</span>
</SettingRow>
<SettingRow icon="lucide:heart-pulse" label={t("connections.form.keepalive")}>
<FormSelect
className="ml-auto w-36"
className="w-36"
value={keepalivePreset}
options={keepaliveOptions}
onChange={(v) => { markDirty(); setKeepalivePreset(v as KeepalivePreset | ""); }}
/>
</div>
<div className="flex items-center gap-1.5 text-xs text-(--t-text-dim) w-full py-1">
<Icon icon="lucide:layers" width={13} />
<span>{t("connections.form.persistentSession")}</span>
</SettingRow>
<SettingRow icon="lucide:layers" label={t("connections.form.persistentSession")}>
<FormSelect
className="ml-auto w-36"
className="w-36"
value={persistSession}
options={persistOptions}
onChange={(v) => { markDirty(); setPersistSession(v as "" | "on" | "off"); }}
/>
</div>
</SettingRow>

</AdvancedDisclosure>
</>)}
Expand Down Expand Up @@ -612,18 +594,14 @@ const ConnectionForm = forwardRef<ConnectionFormHandle, Props>(function Connecti
</div>

{isFtp && (
<div className="flex items-center gap-1.5 text-xs text-(--t-text-dim) w-full py-1">
<Icon icon="lucide:shield" width={13} />
<span>{t("connections.form.ftpsToggle")}</span>
<span className="ml-auto"><Toggle checked={ftpSecure} onChange={(v) => { markDirty(); setFtpSecure(v); }} /></span>
</div>
<SettingRow icon="lucide:shield" label={t("connections.form.ftpsToggle")}>
<Toggle checked={ftpSecure} onChange={(v) => { markDirty(); setFtpSecure(v); }} />
</SettingRow>
)}
{isFtp && (
<div className="flex items-center gap-1.5 text-xs text-(--t-text-dim) w-full py-1">
<Icon icon="lucide:user-x" width={13} />
<span>{t("connections.form.anonymousLogin")}</span>
<span className="ml-auto"><Toggle checked={username === "anonymous"} onChange={(v) => { markDirty(); setUsername(v ? "anonymous" : ""); }} /></span>
</div>
<SettingRow icon="lucide:user-x" label={t("connections.form.anonymousLogin")}>
<Toggle checked={username === "anonymous"} onChange={(v) => { markDirty(); setUsername(v ? "anonymous" : ""); }} />
</SettingRow>
)}

{!isFtp && (
Expand Down
Loading