fix(dash): restore terminal on SIGTERM/SIGINT - #326
Conversation
r0x0r
left a comment
There was a problem hiding this comment.
Thanks for tackling the broken-terminal-on-signal case — the bounded, best-effort restore_terminal() helper, the 128 + signo exit codes, and the signal_task.abort() on a clean return are all correct and nicely tested.
One substantive interaction worth deciding explicitly: the signal task ends in std::process::exit(code), which skips stack unwinding and therefore the kill_on_drop teardown of any in-flight focused install/serve child. On a clean quit, event_loop returning unwinds the runtime and kills those children; on the signal path they are now left running (orphaned). This isn't a regression versus today's default disposition (which also skips kill_on_drop), but this PR is the natural place to decide the intended behavior: should a SIGTERM'd rocm dash leave a mid-write focused install running, or terminate it with the child? The comment at focused_close_key_blocked shows the truncation hazard is already on your radar, so a one-line statement of intent in the code or PR body would close the question either way.
Minor related note (not blocking): once tokio's signal handlers are installed they stay registered process-wide even after signal_task.abort(), so during the brief clean-exit teardown window a SIGTERM is caught-and-ignored rather than terminating. The process is exiting anyway, so this is fine to leave — just worth being aware of.
michaelroy-amd
left a comment
There was a problem hiding this comment.
PR #326 review — request changes
Head reviewed: 4048d69d8fcfd1b70f2a1a157cccdfbc28e5afdc
Standards
Blocking — Windows build is red and Ctrl-Break is not handled
crates/rocm-dash-tui/src/app/mod.rs:1688-1699
The PR claims Windows Ctrl-C and Ctrl-Break support, but tokio::signal::ctrl_c() subscribes only to CTRL_C_EVENT; Tokio exposes tokio::signal::windows::ctrl_break() separately. Consequently EXIT_CODE_SIGTERM is unused on Windows, and the live windows-build-and-test check fails under -D warnings at line 1699. Handle both Windows event streams (and their registration errors) rather than suppressing the warning. The current let _ = tokio::signal::ctrl_c().await also treats registration failure as a received Ctrl-C and exits 130 immediately.
Blocking — required user-observable scenario is absent
tests/e2e-cucumber/features/dash.feature has no scenario covering this change. AGENTS.md §3 requires a Gherkin scenario for user-observable CLI behavior; the two unit tests only check constants and that a no-TTY helper call does not panic. The existing PTY harness already owns portable_pty::Child, whose process_id() API makes signal injection feasible. Add Linux PTY scenarios that deliver SIGTERM/SIGINT, assert 143/130, and verify the final output leaves the alternate screen and shows the cursor.
Spec
Blocking — a startup SIGTERM still reproduces the broken-terminal bug
crates/rocm-dash-tui/src/app/mod.rs:1610-1628
The code enters raw/alternate-screen mode before spawning an async task whose first poll registers the signal listeners. A signal received after EnterAlternateScreen but before that task is polled still takes the default disposition and skips restoration. In a PTY smoke test that sent SIGTERM as soon as \x1b[?1049h was observed, all 10/10 runs exited from the signal (-15) without LeaveAlternateScreen; delayed SIGTERM/SIGINT correctly exited 143/130 and emitted restoration sequences. Register the signal listeners synchronously before mutating terminal state, then move the initialized listeners into the watcher task. Propagate setup failure before entering raw mode; this also avoids permanently swallowing a successfully registered Unix signal if the second registration fails.
Should fix — implementation does not reuse the shared teardown helper
crates/rocm-dash-tui/src/app/mod.rs:1636-1646
The PR body says restore_terminal is reused by both signal and normal teardown paths, but normal return still duplicates the old teardown sequence. Either call the helper on the normal path or correct the PR description. One shared path avoids the two sequences drifting.
Verification
cargo test -p rocm-dash-tui: 686 passed; 4 ignored.cargo clippy -p rocm-dash-tui --all-targets -- -D warnings: passed on Linux.cargo build -p rocm: passed on Linux.- PTY delayed-signal smoke: SIGTERM → 143 and SIGINT → 130; both emitted
LeaveAlternateScreenandShow. - PTY startup-race smoke: 10/10 immediate SIGTERM runs exited
-15withoutLeaveAlternateScreen. - Live CI:
windows-build-and-testfailed becauseEXIT_CODE_SIGTERMis unused; all other required completed checks passed at the reviewed head.
Decision: request changes.
… (EAI-7194) Address review on #326: - Register the SIGTERM/SIGINT listeners BEFORE entering raw/alternate-screen mode. A signal delivered during startup is now latched by the already-installed handlers instead of taking the default disposition and killing the process mid-setup, which left the terminal in the broken raw/alt-screen state. Registration failure is propagated before any terminal state is mutated. - Windows: subscribe to BOTH Ctrl-C and Ctrl-Break (tokio exposes them as separate streams; ctrl_c() alone misses Ctrl-Break). Ctrl-Break maps to the SIGTERM code and Ctrl-C to the SIGINT code, so EXIT_CODE_SIGTERM is now used on Windows and the -D warnings build is green. Registration failure is propagated rather than treated as a received Ctrl-C. - Reuse the shared restore_terminal() helper on the normal-return path so the signal and clean-exit teardowns cannot drift. - Add Linux PTY regression scenarios (dash.feature 11-13): deliver SIGTERM/SIGINT to the live dashboard and a startup-window SIGTERM at alt-screen entry, assert the 143/130 exit codes, and verify the terminal left the alternate screen and showed the cursor. Harness gains signal delivery (via kill(1) on the child pid), exit-code capture, and alt-screen/cursor restoration assertions. Signed-off-by: Roman Sirokov <roman.sirokov@amd.com>
|
Thanks for the thorough review — addressed all four points in Blocking — Windows Ctrl-Break / red Blocking (Spec) — startup SIGTERM race. The listeners are now installed before Blocking — required user-observable scenario. Added three Linux PTY scenarios in Should fix — shared teardown. The normal-return path now calls the same Local: |
|
@michaelroy-amd all four points are addressed in
|
rominf
left a comment
There was a problem hiding this comment.
The fix itself is right and the revision history shows it — registering before raw mode, propagating setup failure before any terminal state is mutated, the shared restore_terminal helper, 128 + signo codes, and signal_task.abort() on a clean return. @michaelroy-amd's startup-race point is properly addressed, and the Ctrl-Break split is a genuine improvement over ctrl_c() alone.
I want to push on one thing @r0x0r raised and marked non-blocking. The observation was exactly right — tokio never unregisters the libc handler, so it survives signal_task.abort() — but the conclusion ("the process is exiting anyway, so this is fine to leave") holds only for rocm dash. On bare rocm it doesn't: dash::run_launcher is a synchronous loop that calls run_focused / run_chat / run, each of which builds a runtime, runs a session, drops the runtime, and returns to the loop. The process keeps living, in the launcher's own raw-mode/alt-screen event::read() loop, with a handler installed that catches SIGTERM and does nothing.
I built a probe mirroring that structure rather than reasoning about it. Control (no registration) exits 143 on the first SIGTERM. The PR-shaped version survives three SIGTERMs and a SIGINT and exits 0. Details in the first comment.
That makes bare rocm — the default invocation — unkillable by the default signals after the user's first flow, while sitting in exactly the raw-mode state this PR set out to protect. So I don't think it's leaveable, though the fix is small.
Two smaller things: scenario 13 doesn't reach the window it names (the polling granularity is ~1000× the race it guards), and the new restore_terminal unit test drives real global console state in the cargo test Windows lane. Also one stale doc link.
On @r0x0r's kill_on_drop question — worth answering explicitly in the code, and I'd note that whichever way it goes, process::exit in a peer task also skips run_async's embedded-daemon socket cleanup, so the two have the same shape.
| // failure is propagated here, before any terminal state is mutated, so we | ||
| // never enter raw mode without a working restore path (and never silently | ||
| // swallow a signal because a second registration failed). | ||
| let termination = TerminationSignals::register()?; |
There was a problem hiding this comment.
This registration is process-global and permanent, but it's scoped as if it were per-session — and on the bare-rocm path the process outlives the session.
dash::run_launcher (apps/rocm/src/dash.rs:359-377) is a synchronous loop:
loop {
let serving = launcher_serving_instances(&paths);
match rocm_dash_tui::ui::launcher::run_launcher(&theme, serving)? {
None => return Ok(()),
Some(choice) => match launcher_route(choice) {
LauncherRoute::Focused(focus) => run_focused(focus)?,
LauncherRoute::Chat => run_chat(chat_mock, ChatInferenceParams::default())?,
LauncherRoute::Dashboard => run(None, false, chat_mock)?,
},
}
}Each of those three builds a runtime, block_ons run_async → app::run, and drops the runtime on return. Tokio documents that the libc handler it installs is never removed (tokio/src/signal/unix.rs: "the libc signal handler is never unregistered"), and that handler doesn't terminate — it sets a flag and writes a byte to tokio's global pipe. After the runtime is gone there's no reactor draining that pipe and no listener, so the signal is caught and discarded.
Control returns to ui::launcher::run_launcher, which enters raw mode and the alt-screen itself (ui/launcher.rs:285-289) and blocks in event::read(). So the process sits in raw mode, permanently deaf to SIGTERM and SIGINT.
I built a probe with the same shape rather than trusting the reading:
########## CONTROL: no registration ##########
exit=143
########## PR SHAPE: register, drop runtime, keep looping ##########
session returned; runtime dropped
PID 47073 now in the synchronous outer loop
BUG: survived 5s of signals
exit=0
Three kill -TERM plus a kill -INT, all absorbed. The only difference between the two runs is whether a signal() stream was ever constructed.
Windows has the same shape for a different reason: SetConsoleCtrlHandler is installed once behind a OnceLock and the handler returns 1 ("handled") while the static watch::Sender lives, so Ctrl-C stops terminating the launcher too.
The practical effect: rocm → Set up → back at the menu → kill <pid> does nothing, and Ctrl-C doesn't either. That's the default invocation, and the terminal is in raw mode the whole time — a worse version of the bug this PR fixes.
This isn't a reason to revert; reverting leaves the handler installed too, since the current code also calls ctrl_c()/signal() inside the session. The registration needs to move up to where its lifetime matches the process: install the listeners once in apps/rocm's entry path with something alive to service them for the whole launcher loop, or keep a process-global watcher that the launcher also honours. A fallback in the synchronous launcher that restores the terminal and exits would work too, and might be simpler.
Worth a scenario as well — the existing PTY harness could drive rocm (not rocm dash), pick a flow, return to the menu, and then deliver SIGTERM. Scenarios 11/12 only cover the single-session path, which is why this is invisible today.
| Then the dashboard exits from the signal with code 130 | ||
| And the terminal is restored to the normal screen | ||
|
|
||
| @id:dash-startup-sigterm-restores-terminal @requires-os:linux |
There was a problem hiding this comment.
Scenario 13 can't observe the window it describes, so it doesn't guard the ordering fix.
The race is between enable_raw_mode() and the point the listeners become effective — microseconds. The scenario reaches it via:
wait_for_alternate_screen, which polls atPOLL_INTERVAL = 20ms(tui_driver.rs:51) until the parser has already consumed\x1b[?1049h;deliver_signal_and_wait, whichfork/execskill(1)— another process spawn.
By the time the signal lands, the event loop has been running for many milliseconds. So scenario 13 is behaviorally identical to scenario 11, and moving TerminationSignals::register() back below enable_raw_mode() would leave all three green. AGENTS.md §3 wants the regression test to fail before the fix.
Notably @michaelroy-amd's own smoke test did catch it (10/10 immediate SIGTERMs exited -15 without LeaveAlternateScreen) — but that harness armed on the raw byte stream, not on a 20ms poll of the parsed screen.
Two options that would work: have the product emit a startup marker before enable_raw_mode and arm a pre-forked helper on it, or cover the ordering with a unit test asserting registration precedes raw-mode entry. Dropping scenario 13 and saying in the PR text that the ordering is covered by construction is also honest — better than a scenario that reads as a guard and isn't one.
(Scenarios 11 and 12 are genuine: on main a signalled child surfaces as code=1 through portable_pty, not 143.)
| } | ||
|
|
||
| #[test] | ||
| fn restore_terminal_is_infallible_without_a_tty() { |
There was a problem hiding this comment.
This test mutates process-global terminal state, which is safe under nextest and not under cargo test.
restore_terminal() calls the real disable_raw_mode() and unconditionally writes \x1b[?1049l, the mouse-disable sequence, and \x1b[?25h to the process's shared stdout. On Windows disable_raw_mode is a SetConsoleMode on the shared console input handle; on Unix it's a tcsetattr guarded by crossterm's TERMINAL_MODE_PRIOR_RAW_MODE static.
The Linux lane runs cargo nextest run (ci.yml:328), which is process-per-test and isolates this completely. The required Windows lane runs cargo test --workspace --all-targets (ci.yml:357) — threads in one process — so this races every other test in the binary. It's the kind of thing that shows up as an unreproducible Windows-only flake much later.
It also writes escape sequences into the developer's shell when run locally under a real terminal.
Taking an impl Write for the escape-sequence half would let the test drive an in-memory sink and assert the emitted bytes — which is a stronger assertion than "didn't panic" anyway. The disable_raw_mode() call is the part that has to stay global, and it's also the part the test isn't really checking.
| let signal_task = tokio::spawn(async move { | ||
| let code = termination.recv().await; | ||
| restore_terminal(); | ||
| std::process::exit(code); |
There was a problem hiding this comment.
Two things follow from exiting inside the spawned task rather than unwinding through it, both minor on their own but they share a fix with @r0x0r's kill_on_drop question.
Socket leak. run_async (apps/rocm/src/dash.rs:662-672) aborts the embedded daemon and remove_files the socket only after app::run(args).await returns. process::exit never unwinds, so every SIGTERM/SIGINT leaves the socket path on disk. It self-heals — rocm-dash-daemon/src/server.rs unlinks a stale path on next bind — so the impact is small, but a previously-clean shutdown becomes a leaking one.
Concurrent-write race. The runtime is multi-threaded (dash.rs:222 new_multi_thread), so this task runs on a different worker than event_loop. It emits LeaveAlternateScreen + Show while the event loop may be mid-terminal.draw() on the same stdout. An interleaving of restore → draw → exit paints a partial dashboard frame into the user's normal-screen scrollback. Narrow, but it's the same class of corruption this PR fixes, and unlike the clean-exit path there's no ordering guarantee here.
Both go away with the same restructuring: make the signal a select! arm around event_loop so the loop unwinds first and the single existing teardown runs, then return the exit code up through run/run_async and process::exit at the top level. That also puts kill_on_drop back in play for @r0x0r's question — whichever answer you want, it becomes expressible rather than foreclosed.
| /// observed exit code for the scenario's `Then` steps. Only harness faults | ||
| /// (no pid, a failed `kill`, a reader panic, or a timeout) are surfaced as | ||
| /// `Err`; asserting the exit *value* and terminal restoration is left to | ||
| /// [`expect_signal_exit_code`](Self::expect_signal_exit_code) and |
There was a problem hiding this comment.
Stale intra-doc link: expect_signal_exit_code doesn't exist on TuiSession. The accessor is observed_exit_code (:609) and the assertion lives in the step at dash_steps.rs. (expect_terminal_restored on the next line is fine — that one's real, at :617.)
rustdoc isn't a CI gate here, so nothing catches it.
fd15c13 to
2496202
Compare
|
Thanks @rominf, @michaelroy-amd, @r0x0r — rebased onto Blocking — bare
|
2496202 to
d3ed2cd
Compare
… (EAI-7194) A signal delivered to `rocm dash` (or bare `rocm`) tore the process down while it still held the terminal in raw mode on the alternate screen, leaving the shell needing `reset`. `app::run` now installs a termination-signal watcher before entering raw mode that restores the terminal and exits `128 + signo` (SIGINT -> 130, SIGTERM -> 143) on Unix (Ctrl-C/Ctrl-Break on Windows). Bare `rocm` is a persistent hub: it outlives each session's Tokio runtime and returns to a synchronous, raw-mode launcher menu between flows. Tokio never unregisters the libc handler it installs, so once a session's runtime is dropped nothing drains the signal pipe and a per-session watcher goes deaf — leaving the menu holding the terminal in raw mode while SIGTERM/SIGINT are caught-and-discarded (an unkillable, worse form of the bug). `run_launcher` now keeps ONE runtime alive for the whole hub and spawns a single process-lifetime watcher on it, so every window — menu and session alike — stays killable. The teardown is factored into `restore_terminal`, split so the escape-sequence half (`write_restore_sequences`) takes an `impl Write` and a unit test asserts the emitted bytes against an in-memory sink instead of mutating the process's shared terminal state (which raced other tests in the single-process `cargo test` lane). `spawn_termination_watcher` documents the deliberate `process::exit` semantics: on a signal we stop promptly rather than racing an orderly teardown, so an in-flight focused-install child is left to the OS (matching the default disposition) and the embedded daemon's socket self-heals on the next bind. Tests: a Linux PTY scenario drives a full launcher round-trip (open the dashboard, quit back to the menu, then SIGTERM) and asserts 143 plus terminal restoration — red without the hub watcher, green with it. The dashboard SIGTERM/SIGINT scenarios (11/12) are unchanged. The ineffective startup-race scenario is dropped: a 20 ms-polled PTY cannot observe the microsecond register-before-raw-mode window, which is instead covered by construction (`spawn_termination_watcher` runs before `enable_raw_mode`). Signed-off-by: Roman Sirokov <roman.sirokov@amd.com>
d3ed2cd to
f6fe0ec
Compare
Problem
A signal delivered to
rocm dash—kill <pid>(SIGTERM), a supervisor stop,or Ctrl-C (SIGINT) — took the default disposition and killed the process while
it still held the terminal in raw mode on the alternate screen. The best-effort
teardown after the event loop never ran, leaving the shell in a broken display
state (no cursor, raw mode on, stuck in the alt-screen) that needs
reset.Bare
rocmwas worse. It is a persistent hub: it outlives each session's Tokioruntime and returns to a synchronous, raw-mode launcher menu between flows.
Tokio never unregisters the libc handler it installs, so once a session's
runtime is dropped nothing drains the signal pipe — the menu sat in raw mode,
catching and discarding SIGTERM/SIGINT (unkillable) after the user's first flow.
Fix
app::runinstalls a termination-signal watcher (spawn_termination_watcher)before entering raw/alternate-screen mode. On SIGTERM/SIGINT (Unix) or
Ctrl-C/Ctrl-Break (Windows) it restores the terminal and exits
128 + signo(SIGINT → 130, SIGTERM → 143). Registration is propagated via
?before anyterminal state is mutated. The task is aborted on the clean-return path.
dash::run_launcherkeeps onemulti_thread(1)runtime alive for thewhole hub and spawns a single process-lifetime watcher on it, so every window
— the launcher menu and each session — has a live reactor to restore the
terminal and exit, across the session runtimes built and dropped in the loop.
restore_terminal; the escape-sequence half iswrite_restore_sequences<W: Write>so it is unit-testable against anin-memory sink without mutating the shared terminal.
process::exiton a signal is deliberate (seespawn_termination_watcherdocs): we stop promptly rather than race an orderly teardown, so an in-flight
focused-install child is left to the OS (matching the default disposition) and
the embedded daemon socket self-heals on the next bind.
Signal handling uses
tokio::signal(already available via tokio'sfullfeature) — no new dependency.
Tests
write_restore_sequences_leaves_alt_screen_and_shows_cursor— asserts theemitted restore bytes against an in-memory sink.
signal_exit_codes_follow_shell_convention— the128 + signomapping.dash.feature: a steady-state SIGTERM (→143) andSIGINT (→130) to a running dashboard, and a launcher round-trip (open the
dashboard, quit back to the menu, then SIGTERM → 143 + terminal restored) that
exercises the across-session hub path. Verified red without the hub watcher,
green with it.
The startup register-before-raw-mode ordering is covered by construction
(
spawn_termination_watcherruns beforeenable_raw_mode); a 20 ms-polled PTYscenario cannot observe that microsecond window, so none is claimed for it.