Skip to content

Commit 07e27ff

Browse files
mizu-junclaudehappy-otter
authored
fix: address audit round 3 findings (P1, B1, P2, R1) (#26)
* fix: address audit round 3 findings P1, B1, P2, R1 P1 (HIGH): the PTY reader cloned the whole parser grid into latest_grid on every output burst. Apply only the changed rows onto a persistent snapshot instead (Grid::apply_dirty_row), syncing cursor and hyperlinks since GridDiff carries neither. A criterion bench (nexterm-vt/benches/grid_snapshot) shows a single-row update drop from ~19.1us to ~0.53us (~36x) for the common partial -update case. B1 (MEDIUM): std::sync::Mutex sites used .expect("...poisoned"), so one panic while holding a lock cascaded into a panic on every later access. Add lock_recover() which recovers the inner value and logs, and route the plugin manager, web session/OAuth/TOTP-setup locks through it. P2 (MEDIUM): Screen::take_dirty_rows now sizes the result Vec once from the dirty-row count instead of reallocating as rows are pushed. R1 (MEDIUM): glyph atlas LRU capacity math used unchecked u32 multiplication; a large user-configured gpu.atlas_size (>= 65536) could overflow. Use saturating u64 math with a non-zero fallback. Tests: Grid::apply_dirty_row parity/bounds tests, lock_recover poison-recovery test, glyph atlas overflow/zero-dimension tests. cargo test / clippy -D warnings / fmt --check all green. The attach regression test (make_full_refresh_reflects_pty_output_emitted_before_attach) is cfg(not(windows)) and runs in Linux/macOS CI. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering> * style: fix import ordering in grid_snapshot bench The editor hook's rustfmt reordered the criterion import differently from the workspace `cargo fmt --all`, which the CI `fmt --check` leg enforces. Normalize with the workspace formatter. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Happy <yesreply@happy.engineering>
1 parent a5a0713 commit 07e27ff

13 files changed

Lines changed: 346 additions & 60 deletions

File tree

nexterm-client-gpu/src/glyph_atlas.rs

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,11 @@ impl GlyphAtlas {
157157
});
158158

159159
// LRU capacity upper bound: size*size divided by the smallest glyph area (8×8).
160-
let lru_cap = NonZeroUsize::new(((size * size) / 64).max(256) as usize)
161-
.expect("lru capacity is non-zero");
160+
// Audit round 3 (R1): use u64 math so a large configured atlas size cannot
161+
// overflow the u32 multiplication (debug panic / release wrap).
162+
let atlas_sq = (size as u64).saturating_mul(size as u64);
163+
let lru_cap = NonZeroUsize::new((atlas_sq / 64).max(256).min(usize::MAX as u64) as usize)
164+
.unwrap_or(NonZeroUsize::MIN);
162165

163166
Self {
164167
texture,
@@ -203,9 +206,12 @@ impl GlyphAtlas {
203206
/// This is a pure function that can be called without a GPU device, making
204207
/// it straightforward to unit-test independently.
205208
fn lru_cap_from_cell(atlas_size: u32, cell_w: u32, cell_h: u32) -> NonZeroUsize {
206-
let cell_area = (cell_w as u64 * cell_h as u64).max(1) as u32;
207-
NonZeroUsize::new(((atlas_size * atlas_size) / cell_area).max(256) as usize)
208-
.expect("lru capacity is non-zero")
209+
// Audit round 3 (R1): guard against u32 overflow in atlas_size * atlas_size
210+
// and cell_w * cell_h (atlas_size is user-configurable via gpu.atlas_size).
211+
let atlas_sq = (atlas_size as u64).saturating_mul(atlas_size as u64);
212+
let cell_area = (cell_w as u64).saturating_mul(cell_h as u64).max(1);
213+
let cap = (atlas_sq / cell_area).max(256).min(usize::MAX as u64) as usize;
214+
NonZeroUsize::new(cap).unwrap_or(NonZeroUsize::MIN)
209215
}
210216

211217
/// Update LRU capacity based on the actual font cell dimensions.
@@ -417,4 +423,23 @@ mod tests {
417423
let cap2 = GlyphAtlas::lru_cap_from_cell(2048, 16, 32).get();
418424
assert_eq!(cap2, cap1 * 4);
419425
}
426+
427+
#[test]
428+
fn lru_cap_survives_overflowing_atlas_size() {
429+
// Audit round 3 (R1): atlas_size is user-configurable; a value whose
430+
// square overflows u32 (>= 65536) must not panic or wrap. It should
431+
// simply yield a large, valid capacity via saturating u64 math.
432+
let cap = GlyphAtlas::lru_cap_from_cell(100_000, 8, 8);
433+
assert!(cap.get() >= 256);
434+
// 100_000^2 / 64 must be computed in u64, not a wrapped u32.
435+
let expected = ((100_000u64 * 100_000) / 64) as usize;
436+
assert_eq!(cap.get(), expected);
437+
}
438+
439+
#[test]
440+
fn lru_cap_handles_zero_cell_dimensions() {
441+
// Degenerate cell dimensions must not divide by zero.
442+
let cap = GlyphAtlas::lru_cap_from_cell(1024, 0, 0);
443+
assert!(cap.get() >= 256);
444+
}
420445
}

nexterm-proto/src/grid.rs

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,24 @@ impl Grid {
8282
}
8383
}
8484

85+
/// Applies a differential [`DirtyRow`] onto this grid in place.
86+
///
87+
/// This is the incremental counterpart to cloning the whole grid: the PTY
88+
/// reader keeps a `latest_grid` snapshot fresh for late-attaching clients by
89+
/// applying only the rows that changed, instead of copying every cell on
90+
/// each output burst (audit round 3, finding P1). Out-of-bounds rows are
91+
/// ignored; if the row widths differ, only the overlapping prefix is copied.
92+
pub fn apply_dirty_row(&mut self, dirty: &DirtyRow) {
93+
if let Some(row) = self.rows.get_mut(dirty.row as usize) {
94+
if row.len() == dirty.cells.len() {
95+
row.clone_from(&dirty.cells);
96+
} else {
97+
let n = row.len().min(dirty.cells.len());
98+
row[..n].clone_from_slice(&dirty.cells[..n]);
99+
}
100+
}
101+
}
102+
85103
/// Copies the contents of row `src` into row `dst` (out-of-bounds indices are
86104
/// ignored instead of panicking).
87105
pub fn copy_row(&mut self, dst: u16, src: u16) {
@@ -187,6 +205,115 @@ mod tests {
187205
assert_eq!(row, decoded);
188206
}
189207

208+
#[test]
209+
fn apply_dirty_row_updates_only_target_row() {
210+
let mut grid = Grid::new(4, 3);
211+
let cells = vec![
212+
Cell {
213+
ch: 'w',
214+
..Cell::default()
215+
},
216+
Cell {
217+
ch: 'x',
218+
..Cell::default()
219+
},
220+
Cell {
221+
ch: 'y',
222+
..Cell::default()
223+
},
224+
Cell {
225+
ch: 'z',
226+
..Cell::default()
227+
},
228+
];
229+
grid.apply_dirty_row(&DirtyRow { row: 1, cells });
230+
// Row 1 reflects the new content.
231+
assert_eq!(grid.get(0, 1).unwrap().ch, 'w');
232+
assert_eq!(grid.get(3, 1).unwrap().ch, 'z');
233+
// Other rows stay blank.
234+
assert_eq!(grid.get(0, 0).unwrap().ch, ' ');
235+
assert_eq!(grid.get(0, 2).unwrap().ch, ' ');
236+
}
237+
238+
#[test]
239+
fn apply_dirty_row_matches_incremental_and_full_snapshot() {
240+
// Applying every dirty row of a screen incrementally must yield the same
241+
// grid content as a one-shot full copy would (audit round 3, P1 parity).
242+
let mut incremental = Grid::new(3, 2);
243+
let mut full = Grid::new(3, 2);
244+
let rows = [
245+
DirtyRow {
246+
row: 0,
247+
cells: vec![
248+
Cell {
249+
ch: 'a',
250+
..Cell::default()
251+
};
252+
3
253+
],
254+
},
255+
DirtyRow {
256+
row: 1,
257+
cells: vec![
258+
Cell {
259+
ch: 'b',
260+
..Cell::default()
261+
};
262+
3
263+
],
264+
},
265+
];
266+
for d in &rows {
267+
incremental.apply_dirty_row(d);
268+
}
269+
for d in &rows {
270+
full.rows[d.row as usize] = d.cells.clone();
271+
}
272+
assert_eq!(incremental.rows, full.rows);
273+
}
274+
275+
#[test]
276+
fn apply_dirty_row_ignores_out_of_bounds_row() {
277+
let mut grid = Grid::new(2, 2);
278+
// Row index beyond height must be a no-op, not a panic.
279+
grid.apply_dirty_row(&DirtyRow {
280+
row: 99,
281+
cells: vec![
282+
Cell {
283+
ch: 'X',
284+
..Cell::default()
285+
};
286+
2
287+
],
288+
});
289+
assert_eq!(grid.get(0, 0).unwrap().ch, ' ');
290+
}
291+
292+
#[test]
293+
fn apply_dirty_row_copies_overlapping_prefix_on_width_mismatch() {
294+
let mut grid = Grid::new(2, 1);
295+
// A wider dirty row: only the first 2 cells fit; no panic.
296+
grid.apply_dirty_row(&DirtyRow {
297+
row: 0,
298+
cells: vec![
299+
Cell {
300+
ch: 'p',
301+
..Cell::default()
302+
},
303+
Cell {
304+
ch: 'q',
305+
..Cell::default()
306+
},
307+
Cell {
308+
ch: 'r',
309+
..Cell::default()
310+
},
311+
],
312+
});
313+
assert_eq!(grid.get(0, 0).unwrap().ch, 'p');
314+
assert_eq!(grid.get(1, 0).unwrap().ch, 'q');
315+
}
316+
190317
#[test]
191318
fn hyperlink_span_field_check() {
192319
let span = HyperlinkSpan {

nexterm-server/src/ipc/plugin_dispatch.rs

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,7 @@ pub(super) async fn handle_list_plugins(
1414
) {
1515
// Drop the lock before any await (MutexGuard is not Send).
1616
let paths = {
17-
let lock = manager
18-
.plugin_manager
19-
.lock()
20-
.expect("plugin_manager poisoned");
17+
let lock = crate::lock_recover(&manager.plugin_manager, "plugin_manager");
2118
lock.as_ref()
2219
.map(|m| {
2320
m.plugin_paths()
@@ -37,10 +34,7 @@ pub(super) async fn handle_load_plugin(
3734
path: &str,
3835
) {
3936
let result = {
40-
let mut lock = manager
41-
.plugin_manager
42-
.lock()
43-
.expect("plugin_manager poisoned");
37+
let mut lock = crate::lock_recover(&manager.plugin_manager, "plugin_manager");
4438
match lock.as_mut() {
4539
Some(m) => m.load(std::path::Path::new(path)),
4640
None => Err(anyhow::anyhow!("plugin manager is not initialized")),
@@ -72,10 +66,7 @@ pub(super) async fn handle_unload_plugin(
7266
path: &str,
7367
) {
7468
let result = {
75-
let mut lock = manager
76-
.plugin_manager
77-
.lock()
78-
.expect("plugin_manager poisoned");
69+
let mut lock = crate::lock_recover(&manager.plugin_manager, "plugin_manager");
7970
match lock.as_mut() {
8071
Some(m) => m.unload(std::path::Path::new(path)),
8172
None => Err(anyhow::anyhow!("plugin manager is not initialized")),
@@ -114,10 +105,7 @@ pub(super) async fn handle_reload_plugin(
114105
path: &str,
115106
) {
116107
let result = {
117-
let mut lock = manager
118-
.plugin_manager
119-
.lock()
120-
.expect("plugin_manager poisoned");
108+
let mut lock = crate::lock_recover(&manager.plugin_manager, "plugin_manager");
121109
match lock.as_mut() {
122110
Some(m) => m.reload(std::path::Path::new(path)),
123111
None => Err(anyhow::anyhow!("plugin manager is not initialized")),

nexterm-server/src/lib.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,23 @@ pub use runtime_config::{
2929
use session::SessionManager;
3030
use snapshot::ServerSnapshot;
3131

32+
/// Lock a [`std::sync::Mutex`], recovering the inner value instead of panicking
33+
/// when the lock was poisoned by a panic in another holder.
34+
///
35+
/// A poisoned lock means a previous holder panicked mid-update, so the guarded
36+
/// data may be inconsistent. For a long-running terminal server the pragmatic
37+
/// choice is to log and keep serving rather than let one panic cascade into a
38+
/// process-wide crash on every subsequent lock (audit round 3, finding B1).
39+
pub(crate) fn lock_recover<'a, T>(
40+
mutex: &'a std::sync::Mutex<T>,
41+
context: &str,
42+
) -> std::sync::MutexGuard<'a, T> {
43+
mutex.lock().unwrap_or_else(|poisoned| {
44+
warn!("{context}: mutex poisoned; recovering inner state");
45+
poisoned.into_inner()
46+
})
47+
}
48+
3249
/// Run the main logic of `nexterm-server`, loading the config file from disk.
3350
///
3451
/// Use this entry point when running as a standalone `nexterm-server` binary.
@@ -370,3 +387,28 @@ async fn shutdown_signal() {
370387
.expect("failed to install Ctrl+C handler");
371388
info!("received Ctrl+C");
372389
}
390+
391+
#[cfg(test)]
392+
mod lock_recover_tests {
393+
use super::lock_recover;
394+
use std::sync::{Arc, Mutex};
395+
396+
#[test]
397+
fn recovers_a_poisoned_mutex_without_panicking() {
398+
let mutex = Arc::new(Mutex::new(41));
399+
// Poison the mutex: panic while holding the guard on another thread.
400+
let poison = Arc::clone(&mutex);
401+
let _ = std::thread::spawn(move || {
402+
let _guard = poison.lock().expect("first lock cannot be poisoned yet");
403+
panic!("intentional panic to poison the mutex");
404+
})
405+
.join();
406+
407+
assert!(mutex.lock().is_err(), "mutex should now be poisoned");
408+
409+
// The recovering helper still yields a usable guard.
410+
let mut guard = lock_recover(&mutex, "test store");
411+
*guard += 1;
412+
assert_eq!(*guard, 42);
413+
}
414+
}

nexterm-server/src/pane.rs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -866,15 +866,32 @@ impl Pane {
866866
);
867867
logged_first_diff = true;
868868
}
869-
// v1.9.3 fix: refresh the full-grid snapshot so a
869+
let (cursor_col, cursor_row) = parser.screen().cursor();
870+
871+
// v1.9.3 fix: keep the full-grid snapshot fresh so a
870872
// client attaching after this burst still sees the
871873
// current screen via `make_full_refresh`. Without
872874
// this the parser state is trapped in the reader
873875
// thread and late-attachers get an empty grid.
876+
//
877+
// Audit round 3 (P1): apply only the changed rows
878+
// instead of cloning the entire parser grid on every
879+
// burst. Under heavy output (`yes`, `cat largefile`)
880+
// the full clone copied every cell each time; here we
881+
// touch only the dirty rows we already computed. The
882+
// reader's parser is never resized, so `latest_grid`
883+
// and the dirty rows always share dimensions. Cursor
884+
// and hyperlinks are synced too because `GridDiff`
885+
// carries neither, so a late attach relies on this
886+
// snapshot for both.
874887
if let Ok(mut g) = latest_grid_clone.lock() {
875-
*g = parser.screen().full_refresh_grid();
888+
for d in &dirty {
889+
g.apply_dirty_row(d);
890+
}
891+
g.cursor_col = cursor_col;
892+
g.cursor_row = cursor_row;
893+
g.hyperlinks.clone_from(&parser.screen().grid().hyperlinks);
876894
}
877-
let (cursor_col, cursor_row) = parser.screen().cursor();
878895

879896
// F3 / ADR-0008: move lines that scrolled off during
880897
// this burst into the pane-side scrollback mirror,

nexterm-server/src/session.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -623,7 +623,7 @@ impl SessionManager {
623623

624624
/// Set the plugin manager (called at server startup).
625625
pub fn set_plugin_manager(&self, mgr: nexterm_plugin::PluginManager) {
626-
let mut lock = self.plugin_manager.lock().expect("plugin_manager poisoned");
626+
let mut lock = crate::lock_recover(&self.plugin_manager, "plugin_manager");
627627
*lock = Some(mgr);
628628
}
629629

nexterm-server/src/web/auth.rs

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ impl AuthManager {
5454
.collect();
5555

5656
let expiry = Instant::now() + self.ttl;
57-
let mut sessions = self.sessions.lock().expect("session store mutex poisoned");
57+
let mut sessions = crate::lock_recover(&self.sessions, "session store");
5858

5959
// Purge expired sessions up front.
6060
sessions.retain(|_, v| Instant::now() < v.expiry);
@@ -84,7 +84,7 @@ impl AuthManager {
8484

8585
/// Check whether a session token is still valid.
8686
pub fn is_valid(&self, token: &str) -> bool {
87-
let sessions = self.sessions.lock().expect("session store mutex poisoned");
87+
let sessions = crate::lock_recover(&self.sessions, "session store");
8888
sessions
8989
.get(token)
9090
.map(|entry| Instant::now() < entry.expiry)
@@ -93,7 +93,7 @@ impl AuthManager {
9393

9494
/// Return session metadata (for the access log).
9595
pub fn session_info(&self, token: &str) -> Option<(String, String)> {
96-
let sessions = self.sessions.lock().expect("session store mutex poisoned");
96+
let sessions = crate::lock_recover(&self.sessions, "session store");
9797
sessions.get(token).and_then(|entry| {
9898
if Instant::now() < entry.expiry {
9999
Some((entry.auth_method.clone(), entry.user_id.clone()))
@@ -105,16 +105,13 @@ impl AuthManager {
105105

106106
/// Explicitly remove a session (used for logout).
107107
pub fn revoke_session(&self, token: &str) {
108-
self.sessions
109-
.lock()
110-
.expect("session store mutex poisoned")
111-
.remove(token);
108+
crate::lock_recover(&self.sessions, "session store").remove(token);
112109
}
113110

114111
/// Return the number of active sessions (excluding expired ones).
115112
#[allow(dead_code)]
116113
pub fn active_count(&self) -> usize {
117-
let sessions = self.sessions.lock().expect("session store mutex poisoned");
114+
let sessions = crate::lock_recover(&self.sessions, "session store");
118115
sessions
119116
.values()
120117
.filter(|v| Instant::now() < v.expiry)

0 commit comments

Comments
 (0)