fix: quadratic chrome-event dedup and clean-quit races in the TUI - #43
Conversation
|
I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".
Original prompt from a
|
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (3)
Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review. 📜 Recent review details⏰ Context from checks skipped due to timeout. (5)
🔇 Additional comments (3)
📝 SummarySummary by CodeRabbit
WalkthroughThe TUI now treats selected client EOF errors as clean shutdowns and restores terminal state during cleanup. The redraw path now builds and merges chrome events only when an attached channel is uninitialized. ChangesTUI clean client exit
Redraw chrome events
Priority: ⬇️ Low Change: Bug fix Merge Risk: ⚪ Minimal · up to The changes improve clean TUI shutdown handling and avoid unnecessary chrome snapshot work. No actionable merge-blocking risk remains in the supplied evidence; merge after normal checks. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to The changes improve clean-exit handling without demonstrating broader access or privileges. One shutdown edge case remains: waiting for transport readers after a broken pipe can delay terminal restoration indefinitely if inherited output pipes remain open. Retained concerns
Security review detailsSecurity Blast Radius
Security Findings and Attack Paths
Trust Boundaries and Controls
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches✨ Simplify code
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
On the retained low-severity concern (unbounded The exposure predates this change and is not widened materially by it. Reaching the stall also requires a descendant process that inherits the embed's RPC stdout/stderr. All embed-spawned work uses A bounded join isn't a small change: |
|
E2E verification on a real terminal (konsole + tmux on DISPLAY :0,
Clean-quit race (client request fix): fired a continuous Regression sanity: file open/insert/save, |
Emitter::redraw merged snapshot_events with pending chrome events by linear-scanning the *merged* list per event, so a batch of N msg_show events cost O(N^2) Object::eq comparisons — `:!seq 1 50000` took 25s with a UI attached (30ms headless). Dedup now checks only the immutable snapshot, matching what steady-state channels already see verbatim, and the merged list is built only when a channel actually needs it. A `-c qall`-style child can also be gone before the startup try_resize request lands; route that error through finish_run so it resolves the child's exit status like the in-loop request paths (the in-loop clean quit race itself is covered by finish_run/successful_exit).
88c582f to
45b1ba1
Compare
A synchronous nvim_input request blocks in Client::next_message until the embed replies, and a :!<CR> input only replies after its shell command finishes. The run loop's signals.pending() check is unreachable while that wait is parked, so SIGTERM during a ':!seq 1 200000' flood wedged the TUI: the main thread sat in futex_wait on the incoming channel while the reader thread kept draining 200k echo events and the embed blocked on a full stdout pipe. ShutdownSignals now registers a second, non-consumed 'requested' flag per restore signal and Client polls it via watch_shutdown() inside a 50ms recv_timeout, surfacing ClientError::Interrupted. The run loop maps Interrupted back to the existing pending() -> restore -> resume_default path at both sites (recv_redraw_timeout and forward_terminal_events). Verified: SIGTERM mid ':!seq 1 200000' now exits in ~0.1s instead of hanging for the full command duration.
A child that exits with a status chose its own exit code (`:cq 3`), but the TUI's finish_run wrapped every Eof in the transport diagnostic and run_interactive discarded it, so the process always exited 1. finish_run now relays a code-bearing child's captured stderr verbatim (the message already ends the session) and run_interactive maps ClientError::Eof/NonZeroExit carrying an exit code onto the process ExitCode via process_code, matching upstream's mch_exit(code). Verified: ':cq 3' through the full TUI exits with status 3.
readfile marks 'readonly' on first load when no write permission bit is set on the file (fileio.c:462-539), but oxvim never set it: the BufferFlags::READONLY flag had no writer at all, and the E45 gates in :w/:wq/:x/:update/:wqall checked only that flag - so even ':set readonly' failed to protect a file. Two fixes: - Load paths set the buffer-local 'readonly' option: open_startup_files for argv files, load_buffer_for_switch for :e/:b and friends when the staged buffer is fresh (origin.is_created()), each via a mode&0222==0 metadata probe - matching upstream's first-load-only semantics. - The E45 gates now honor 'readonly' OR the flag. The FileIO seam has no access(W_OK), so ACL-/mount-level unwritability is not covered; upstream's read-message '[RO]' suffix is also not wired. Verified E2E: 444-perm file + modified buffer + ':x' reports E45 and stays; ':w!' overrides.
Summary
Adversarial E2E waves against the Linux TUI (raw PTY byte streams, paste+resize+key races, 200k-line command output, malformed RPC, signals under load, UI attach/detach churn, state bombs) surfaced four real defects, each fixed in its own commit. The clean-quit race fixes for the in-loop request path landed separately in #42 (
finish_run/successful_exit), so this PR is the delta on top.Emitter::redrawquadratic dedup. First-redraw channels getchrome.snapshot_events()merged with this pass's pending events. The merge appended every deduped event into the list it was scanning — ~N²/2Object::eqcomparisons for a batch of Nmsg_showevents::!seq 1 50000took ~25s with a UI attached vs 30ms headless. Dedup is now against the immutable snapshot only, and the merged list is built only when a channel is actually uninitialized.:!seq 1 50000now replies in ~0.16s.Startup
try_resizequit race. The one request path #42'sfinish_rundidn't cover: a-c qall-style child can exit afterattachbut before the registered resize request lands, failing the TUI on transport instead of exiting cleanly. The startuptry_resizeerror now goes throughfinish_runexactly like the in-loop paths.SIGTERM wedge during synchronous RPC waits.
Client::requestblocks innext_messageonincoming.recv()until the reply arrives, and a:!cmd<CR>input only replies after its shell command finishes — sosignals.pending()at the loop top is unreachable while the wait is parked. SIGTERM during a 200k-line:!seqleft the TUI alive permanently (main thread futex-parked; reader thread busy draining echo events).ShutdownSignalsnow registers a second, non-consumedrequestedflag per restore signal;Client::watch_shutdownpolls it inside a 50msrecv_timeout, surfacingClientError::Interrupted, which the run loop maps back to the existingpending()→ restore →resume_defaultpath. SIGTERM mid-flood now exits in ~0.1s.:cq Nexit code discarded. A child that exits with a status chose its own code, butfinish_runwrapped everyEofin the transport diagnostic andrun_interactivediscarded it — the process always exited 1.finish_runnow relays a code-bearing child's captured stderr verbatim, andrun_interactivereturnsResult<ExitCode, AppError>mappingEof/NonZeroExitcodes onto the process status (mch_exit(code)).:cq 3through the full TUI exits 3.'readonly' never set → E45 unreachable.
readfilemarks'readonly'on first load when no write bit is set (fileio.c:462-539), butBufferFlags::READONLYhad no writer and the E45 gates checked only that flag — even:set readonlycouldn't protect a file. Load paths (open_startup_filesfor argv files,load_buffer_for_switchfor:e/:bon a fresh buffer,origin.is_created()) now set the buffer-local option via amode & 0222 == 0metadata probe, and the gates honor option OR flag. The FileIO seam has noaccess(W_OK)so ACL/mount-level unwritability is uncovered; the[RO]read-message suffix is also not wired — flagged for follow-up.Testing
cargo nextest run --workspace: 3502/3502 pass.:cq/:cq Nexit codes, readonly:x/:w!, job floods outliving quit, autocmd/redraw/register/wildmenu/redir bombs, resize-during-flood, rapid launch-quit cycles); wave-2 61/61 and wave-1 44/44 suites still green.:!seq 1 50000~0.05s to request reply, resize-storm +:wq5/5 clean exits, SIGTERM mid-flood restores terminal and exits,:xon a 444 file reports E45 and:w!overrides.cargo test -p ox-tui -p ox-editor -p oxvimall green; no new clippy warnings on touched files.Link to Devin session: https://app.devin.ai/sessions/5f07717a060d44ddafade088e73fe4fc
Open in Devin Desktop: https://app.devin.ai/desktop/session/5f07717a060d44ddafade088e73fe4fc?variant=devin
Requested by: @metaphorics