fix: a panic on one thread no longer takes the whole app down

Every shared lock was std::sync::Mutex with lock().unwrap() (~150 sites),
so a panic in any session's reader or callback poisoned the lock and the
next UI frame panicked too, dropping every open session. Locks are now
parking_lot::Mutex (no poisoning; already in the dependency tree), and a
panic hook reports worker panics as an error toast instead of the session
silently stopping (F3, F4).
This commit is contained in:
Andy Dixon
2026-10-12 09:07:32 +01:00
parent afec95bb36
commit f09300fd95
28 changed files with 252 additions and 221 deletions
+1
View File
@@ -12,6 +12,7 @@ path = "src/main.rs"
nexus-core = { path = "../core" }
eframe = { version = "0.36", default-features = false, features = ["default_fonts", "glow", "wayland", "x11", "accesskit", "persistence"] }
egui = "0.36"
parking_lot = "0.12"
egui-phosphor = "0.14"
rfd = "0.17"
image = { version = "0.25", default-features = false, features = ["png"] }
+12 -4
View File
@@ -11,9 +11,10 @@ use egui::{Key, KeyboardShortcut, Modifiers, Ui};
use nexus_core::models::{Profile, Protocol, SessionInfo, SessionState};
use nexus_core::service::Service;
use nexus_core::sessions::{Event, HostKeyDecision};
use parking_lot::Mutex;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, mpsc};
use std::sync::{Arc, mpsc};
pub struct Options {
pub screenshot: Option<PathBuf>,
@@ -37,7 +38,14 @@ pub struct Options {
static POSTED: Mutex<Vec<String>> = Mutex::new(Vec::new());
pub fn post_error(e: String) {
POSTED.lock().unwrap().push(e);
POSTED.lock().push(e);
}
/// For the panic hook: the panicking thread may already hold the lock.
pub fn post_error_nonblocking(e: String) {
if let Some(mut p) = POSTED.try_lock() {
p.push(e);
}
}
/// A credential or host-key question from a connecting session.
@@ -141,7 +149,7 @@ impl App {
let ctx = cc.egui_ctx.clone();
let tx = Mutex::new(tx);
svc.sessions.set_sink(Arc::new(move |e| {
let _ = tx.lock().unwrap().send(e);
let _ = tx.lock().send(e);
ctx.request_repaint();
}));
let s = Shared::new(svc.clone(), cc.egui_ctx.clone());
@@ -405,7 +413,7 @@ impl App {
// --- events ---------------------------------------------------------------------------
fn drain_events(&mut self) {
for e in POSTED.lock().unwrap().drain(..) {
for e in POSTED.lock().drain(..) {
self.s.error(e);
}
while let Ok(e) = self.rx.try_recv() {
+3 -3
View File
@@ -370,13 +370,13 @@ pub fn status_bar(app: &mut App, ui: &mut Ui) {
});
}
static LATENCY: std::sync::Mutex<Option<(String, Result<i64, String>)>> = std::sync::Mutex::new(None);
static LATENCY: parking_lot::Mutex<Option<(String, Result<i64, String>)>> = parking_lot::Mutex::new(None);
/// Pings the active SSH session every 10 s for the status-bar latency.
fn latency_tick(app: &mut App) {
let id = app.s.active.clone();
let ssh = id.as_deref().and_then(|a| app.s.pane(a)).is_some_and(|p| p.info.protocol == Protocol::Ssh);
if let Some(r) = LATENCY.lock().unwrap().take() {
if let Some(r) = LATENCY.lock().take() {
app.latency = Some(r);
}
let Some(id) = id.filter(|_| ssh) else { return };
@@ -395,7 +395,7 @@ fn latency_tick(app: &mut App) {
let (svc, ctx) = (app.s.svc.clone(), app.s.ctx.clone());
std::thread::spawn(move || {
let r = svc.sessions.ping(&id).map_err(|e| e.to_string());
*LATENCY.lock().unwrap() = Some((id, r));
*LATENCY.lock() = Some((id, r));
ctx.request_repaint();
});
}
+5 -4
View File
@@ -7,7 +7,8 @@ use crate::widgets;
use egui::{Align, Key, Layout, RichText};
use egui_phosphor::regular as icon;
use nexus_core::models::{ForwardDirection, PortForward, new_id};
use std::sync::{Arc, Mutex};
use parking_lot::Mutex;
use std::sync::Arc;
struct Form {
remote: bool,
@@ -49,7 +50,7 @@ impl Forwards {
}
pub fn ui(&mut self, ctx: &egui::Context, s: &mut Shared) {
for started in std::mem::take(&mut *self.done.lock().unwrap()) {
for started in std::mem::take(&mut *self.done.lock()) {
if started {
s.success("Forward started");
self.form.bind_port.clear();
@@ -131,7 +132,7 @@ impl Forwards {
if let Some(id) = stop {
let (svc, sid, done) = (s.svc.clone(), sid.clone(), self.done.clone());
s.spawn(move || svc.sessions.stop_forward(&sid, &id).map_err(|e| format!("Failed to stop: {e}").into()), move |_| done.lock().unwrap().push(false));
s.spawn(move || svc.sessions.stop_forward(&sid, &id).map_err(|e| format!("Failed to stop: {e}").into()), move |_| done.lock().push(false));
}
if start {
let f = &self.form;
@@ -153,7 +154,7 @@ impl Forwards {
let (svc, done) = (s.svc.clone(), self.done.clone());
s.spawn(
move || svc.sessions.start_forward(&sid, rule).map_err(|e| format!("Failed to start forward: {e}").into()),
move |_| done.lock().unwrap().push(true),
move |_| done.lock().push(true),
);
}
_ => s.error("Bind port and destination port are required"),
+5 -4
View File
@@ -8,7 +8,8 @@ use chrono::{DateTime, Utc};
use egui::{Color32, RichText, Sense, Ui, vec2};
use egui_phosphor::regular as icon;
use nexus_core::models::GitHubRepo;
use std::sync::{Arc, Mutex};
use parking_lot::Mutex;
use std::sync::Arc;
enum Msg {
Repos(Vec<GitHubRepo>),
@@ -175,7 +176,7 @@ impl GithubView {
Ok(r) => Msg::Repos(r),
Err(e) => Msg::RefreshFailed(e.to_string()),
};
inbox.lock().unwrap().push(msg);
inbox.lock().push(msg);
ctx.request_repaint();
});
}
@@ -193,13 +194,13 @@ impl GithubView {
Ok(()) => Msg::Downloaded(full, path.display().to_string()),
Err(e) => Msg::DownloadFailed(e.to_string()),
};
inbox.lock().unwrap().push(msg);
inbox.lock().push(msg);
ctx.request_repaint();
});
}
pub fn ui(&mut self, ui: &mut Ui, s: &mut Shared) {
let msgs = std::mem::take(&mut *self.inbox.lock().unwrap());
let msgs = std::mem::take(&mut *self.inbox.lock());
for m in msgs {
match m {
Msg::Repos(r) => {
+10
View File
@@ -33,6 +33,16 @@ use nexus_core::service::Service;
use std::sync::Arc;
fn main() -> eframe::Result {
// A panic on a worker thread (a session's reader, a transfer) ends only that thread; say so in
// the UI rather than letting the session just stop.
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
default_hook(info);
let what =
info.payload().downcast_ref::<&str>().copied().or_else(|| info.payload().downcast_ref::<String>().map(String::as_str)).unwrap_or("unknown error");
let thread = std::thread::current().name().unwrap_or("worker").to_string();
app::post_error_nonblocking(format!("Internal error in {thread} thread: {what}. Other sessions are unaffected; reconnect the affected one."));
}));
let mut opts = app::Options {
screenshot: None,
theme: None,
+4 -3
View File
@@ -11,9 +11,10 @@ use nexus_core::clients::serial;
use nexus_core::keyfiles;
use nexus_core::models::*;
use nexus_core::validation::{ValidationError, validate_profile};
use parking_lot::Mutex;
use std::collections::{BTreeMap, HashMap};
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::sync::Arc;
type KeyChecks = HashMap<String, Result<&'static str, &'static str>>;
@@ -184,7 +185,7 @@ impl ProfileEditor {
/// Drawn inside the right dock while open.
pub fn ui(&mut self, ui: &mut Ui, s: &mut Shared) {
let t = Tokens::get(ui.ctx());
if let Some(r) = self.test.lock().unwrap().take() {
if let Some(r) = self.test.lock().take() {
self.testing = false;
match r {
Ok(ms) => s.success(format!("Reachable — {ms} ms")),
@@ -336,7 +337,7 @@ impl ProfileEditor {
let (slot, svc, ctx) = (self.test.clone(), s.svc.clone(), s.ctx.clone());
std::thread::spawn(move || {
let r = svc.test_connection(&host, port).map_err(|e| e.to_string());
*slot.lock().unwrap() = Some(r);
*slot.lock() = Some(r);
ctx.request_repaint();
});
}
+4 -3
View File
@@ -9,7 +9,8 @@ use egui::{RichText, Ui, vec2};
use egui_phosphor::regular as icon;
use nexus_core::models::{AppPreferences, KnownHost, Protocol};
use nexus_core::updater::UpdateInfo;
use std::sync::{Arc, Mutex};
use parking_lot::Mutex;
use std::sync::Arc;
/// A result from a worker thread, polled each frame (`Some` while the work is in flight).
pub struct Job<T>(Arc<Mutex<Option<T>>>);
@@ -20,14 +21,14 @@ impl<T: Send + 'static> Job<T> {
let (out, ctx) = (slot.clone(), ctx.clone());
std::thread::spawn(move || {
let v = f();
*out.lock().unwrap() = Some(v);
*out.lock() = Some(v);
ctx.request_repaint();
});
Job(slot)
}
pub fn take(&self) -> Option<T> {
self.0.lock().unwrap().take()
self.0.lock().take()
}
}
+5 -4
View File
@@ -13,9 +13,10 @@ use nexus_core::clients::sftp::{self as rsftp, FileEntry, Progress};
use nexus_core::keyfiles::safe_sftp_upload_name;
use nexus_core::models::Protocol;
use nexus_core::service::Service;
use parking_lot::Mutex;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::sync::Arc;
/// Worker results, applied on the UI thread.
enum Done {
@@ -47,7 +48,7 @@ struct Inbox(Arc<Mutex<Vec<Done>>>, egui::Context);
impl Inbox {
fn push(&self, d: Done) {
self.0.lock().unwrap().push(d);
self.0.lock().push(d);
self.1.request_repaint();
}
}
@@ -250,7 +251,7 @@ impl Sftp {
let tr = self.transfers.remove(i);
if self.open_for.as_deref() != Some(session) {
let verb = if tr.upload { "↑ Uploaded" } else { "↓ Downloaded" };
self.inbox.lock().unwrap().push(Done::Toast(format!("{verb} {}", base_name(&tr.path))));
self.inbox.lock().push(Done::Toast(format!("{verb} {}", base_name(&tr.path))));
}
}
}
@@ -269,7 +270,7 @@ impl Sftp {
}
fn drain(&mut self, s: &mut Shared) {
let items = std::mem::take(&mut *self.inbox.lock().unwrap());
let items = std::mem::take(&mut *self.inbox.lock());
for d in items {
match d {
Done::Listed { session, path, files } => {
+5 -4
View File
@@ -12,7 +12,8 @@ use alacritty_terminal::term::test::TermSize;
use alacritty_terminal::term::{Config, Term, TermMode};
use alacritty_terminal::vte::ansi::{self, Color, CursorShape, NamedColor, Processor, StdSyncHandler};
use egui::{Color32, Event, FontId, Key, Modifiers, PointerButton, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2};
use std::sync::{Arc, Mutex};
use parking_lot::Mutex;
use std::sync::Arc;
#[derive(Clone, Default)]
struct Listener(Arc<Mutex<Vec<TermEvent>>>);
@@ -20,7 +21,7 @@ struct Listener(Arc<Mutex<Vec<TermEvent>>>);
impl EventListener for Listener {
fn send_event(&self, e: TermEvent) {
if matches!(e, TermEvent::PtyWrite(_) | TermEvent::Title(_) | TermEvent::Bell | TermEvent::ClipboardStore(..)) {
self.0.lock().unwrap().push(e);
self.0.lock().push(e);
}
}
}
@@ -294,7 +295,7 @@ impl TermView {
self.mouse(ui, &resp, origin, cw, ch, &mut out);
// Terminal-originated events.
for e in std::mem::take(&mut *self.events.0.lock().unwrap()) {
for e in std::mem::take(&mut *self.events.0.lock()) {
match e {
TermEvent::PtyWrite(s) => out.reply.extend_from_slice(s.as_bytes()),
TermEvent::Title(t) => out.title = Some(t),
@@ -918,7 +919,7 @@ mod tests {
fn replies_to_device_attributes() {
let mut t = TermView::new(10, 14.0, CursorStyle::Block);
t.feed(b"\x1b[6n");
let ev = t.events.0.lock().unwrap();
let ev = t.events.0.lock();
assert!(ev.iter().any(|e| matches!(e, TermEvent::PtyWrite(s) if s == "\x1b[1;1R")));
}
+1 -1
View File
@@ -218,7 +218,7 @@ impl VncView {
fn upload(&mut self, ui: &Ui) {
let Some(c) = &self.client else { return };
let fb = c.framebuffer();
let mut fb = fb.lock().unwrap_or_else(|e| e.into_inner());
let mut fb = fb.lock();
if !fb.dirty || fb.width == 0 || fb.height == 0 {
return;
}