Skip to content

Commit 6fc62fe

Browse files
committed
fix(core): avoid blocking event loop during TUI PTY resize
When switching from inline mode to full-screen TUI (or during window resize), the PTY resize operation reparsed ALL raw terminal output through a new vt100 parser synchronously on the event loop. For tasks with large output, this caused a noticeable hang. Add `resize_async()` which moves the expensive reparse to a background thread using a snapshot-and-replay pattern: 1. Quick snapshot of raw output (brief read lock) 2. Expensive reparse on background thread (no locks held) 3. Quick swap with replay of any new output (brief write lock) A generation counter prevents stale resizes from overwriting newer ones. Also combine two separate O(n) scrollback processing calls in inline mode into a single pass.
1 parent f1873b2 commit 6fc62fe

3 files changed

Lines changed: 430 additions & 98 deletions

File tree

packages/nx/src/native/tui/app.rs

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1867,10 +1867,8 @@ impl App {
18671867
continue;
18681868
}
18691869

1870-
// With shared dimensions, we only need to call resize once per PTY instance
1871-
// The shared Arc<RwLock<(u16, u16)>> ensures all references see the update
1872-
let mut pty_clone = pty.as_ref().clone();
1873-
pty_clone.resize(pty_height, pty_width)?;
1870+
// Async resize avoids blocking the event loop for large terminal outputs
1871+
pty.resize_async(pty_height, pty_width);
18741872

18751873
// If dimensions changed, mark for sort
18761874
if current_rows != pty_height {
@@ -2049,10 +2047,9 @@ impl App {
20492047
allow_interactive && in_progress && pty.can_be_interactive();
20502048
terminal_pane_data.pty = Some(pty.clone());
20512049

2052-
// Resize PTY to match terminal pane dimensions
2050+
// Resize PTY to match terminal pane dimensions (async to avoid blocking render)
20532051
let (pty_height, pty_width) = TerminalPane::calculate_pty_dimensions(pane_area);
2054-
let mut pty_clone = pty.as_ref().clone();
2055-
pty_clone.resize(pty_height, pty_width).ok();
2052+
pty.resize_async(pty_height, pty_width);
20562053
} else {
20572054
terminal_pane_data.pty = None;
20582055
terminal_pane_data.can_be_interactive = false;
@@ -2283,15 +2280,14 @@ impl App {
22832280
if let Some(pty_instance) = state.get_pty_instance(&selection_id) {
22842281
self.terminal_pane_data[pane_idx].pty = Some(pty_instance.clone());
22852282

2286-
// Immediately resize PTY to match the current terminal pane dimensions
2283+
// Async resize PTY to match the current terminal pane dimensions
22872284
if let Some(pane_area) = self
22882285
.layout_areas
22892286
.as_ref()
22902287
.and_then(|la| la.terminal_panes.get(pane_idx))
22912288
{
22922289
let (pty_height, pty_width) = TerminalPane::calculate_pty_dimensions(*pane_area);
2293-
let mut pty_clone = pty_instance.as_ref().clone();
2294-
pty_clone.resize(pty_height, pty_width).ok();
2290+
pty_instance.resize_async(pty_height, pty_width);
22952291
}
22962292
} else {
22972293
self.terminal_pane_data[pane_idx].pty = None;

packages/nx/src/native/tui/inline_app.rs

Lines changed: 14 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,6 @@ pub struct InlineApp {
7474
// === Scrollback Rendering ===
7575
/// Track scrollback line count per task for incremental rendering
7676
task_scrollback_lines: HashMap<String, usize>,
77-
/// Track last rendered scrollback lines per task for buffered rendering
78-
task_last_rendered_scrollback: HashMap<String, usize>,
7977
/// Counter for buffering scrollback renders (render every 20th iteration)
8078
scrollback_render_counter: u32,
8179
/// Total lines inserted above TUI (for cleanup on exit)
@@ -126,7 +124,7 @@ impl InlineApp {
126124
countdown_popup: CountdownPopup::new(),
127125
is_interactive: false,
128126
task_scrollback_lines: HashMap::new(),
129-
task_last_rendered_scrollback: HashMap::new(),
127+
130128
scrollback_render_counter: 0,
131129
total_inserted_lines: 0,
132130
status_message: None,
@@ -156,7 +154,7 @@ impl InlineApp {
156154
// Reset all scrollback tracking when mode switching
157155
// PTYs will be resized in init(), which changes scrollback calculations
158156
task_scrollback_lines: HashMap::new(),
159-
task_last_rendered_scrollback: HashMap::new(),
157+
160158
// Start at 19 so the first render (increment to 20) will trigger scrollback rendering
161159
// This ensures existing PTY content from full-screen mode is immediately displayed
162160
scrollback_render_counter: 19,
@@ -239,11 +237,10 @@ impl InlineApp {
239237
rows
240238
);
241239

242-
// Clone the PTY instance, resize it, and replace the Arc
243-
let mut pty_clone = pty_arc.as_ref().clone();
244-
if pty_clone.resize(rows, cols).is_ok() {
245-
state.register_pty_instance(task_id.to_string(), Arc::new(pty_clone));
246-
}
240+
// Async resize moves the expensive reparse off the event loop.
241+
// Scrollback rendering is skipped until the resize completes
242+
// (detected via is_resize_pending).
243+
pty_arc.resize_async(rows, cols);
247244
}
248245

249246
Some(())
@@ -615,8 +612,6 @@ impl TuiApp for InlineApp {
615612
fn on_pty_registered(&mut self, task_id: &str) {
616613
// Initialize scrollback tracking for inline mode
617614
self.task_scrollback_lines.insert(task_id.to_string(), 0);
618-
self.task_last_rendered_scrollback
619-
.insert(task_id.to_string(), 0);
620615
}
621616

622617
/// Override to resize interactive PTYs to inline dimensions
@@ -758,19 +753,11 @@ impl InlineApp {
758753
let pty = pty.clone();
759754
drop(state);
760755

761-
// Get last rendered scrollback line count for this task
762-
let last_rendered_lines = self
763-
.task_last_rendered_scrollback
764-
.get(current_task)
765-
.copied()
766-
.unwrap_or(0);
767-
768-
// Get buffered scrollback content since last render
769-
let buffered_scrollback_lines =
770-
pty.get_buffered_scrollback_content_for_inline(last_rendered_lines);
771-
772-
// Update tracking for next buffered render
773-
let current_scrollback_lines = pty.get_scrollback_line_count();
756+
// Get buffered scrollback content produced by the background thread.
757+
// The background thread tracks its own cursor into the scrollback
758+
// region and appends new lines to pending_lines. We just drain them.
759+
let (buffered_scrollback_lines, current_scrollback_lines) =
760+
pty.get_buffered_scrollback_content_for_inline();
774761

775762
self.task_scrollback_lines
776763
.insert(current_task.clone(), current_scrollback_lines);
@@ -809,17 +796,10 @@ impl InlineApp {
809796
// Track total lines inserted for cleanup on exit
810797
self.total_inserted_lines += height as u32;
811798

812-
// Update last rendered count to reflect what we actually rendered
813-
// This is incremental - we only advance by the batch size
814-
let new_last_rendered = last_rendered_lines + lines_to_render;
815-
self.task_last_rendered_scrollback
816-
.insert(current_task.clone(), new_last_rendered);
817-
818799
tracing::trace!(
819-
"render_scrollback_above_tui: Updated last_rendered from {} to {} (remaining: {})",
820-
last_rendered_lines,
821-
new_last_rendered,
822-
current_scrollback_lines - new_last_rendered
800+
"render_scrollback_above_tui: Rendered {} lines (total scrollback: {})",
801+
lines_to_render,
802+
current_scrollback_lines
823803
);
824804
} else {
825805
tracing::error!(
@@ -1375,7 +1355,6 @@ mod tests {
13751355

13761356
// Verify scrollback tracking initialized
13771357
assert_eq!(app.task_scrollback_lines.get("app1"), Some(&0));
1378-
assert_eq!(app.task_last_rendered_scrollback.get("app1"), Some(&0));
13791358

13801359
// Verify PTY registered in state
13811360
let state_ref = app.get_state();

0 commit comments

Comments
 (0)