Skip to content

fix(tui): restore terminal cursor on exit (Linux/tmux)#240

Merged
inureyes merged 2 commits into
mainfrom
fix/issue-235-restore-cursor-on-exit
May 26, 2026
Merged

fix(tui): restore terminal cursor on exit (Linux/tmux)#240
inureyes merged 2 commits into
mainfrom
fix/issue-235-restore-cursor-on-exit

Conversation

@inureyes

Copy link
Copy Markdown
Member

Summary

On Linux terminals (VTE family, tmux, kitty) cursor visibility is tracked independently of the alternate-screen mode. Leaving the alternate screen does not automatically restore a hidden cursor — so after pressing q, Ctrl-C, or a panic the shell cursor remained invisible, requiring the user to run reset.

Root cause

Two independent failure modes:

  1. TerminalManager::Drop emitted LeaveAlternateScreen but not cursor::Show, so the normal q-exit path left the cursor hidden on non-Apple terminals.
  2. The SIGINT/SIGTERM handlers called std::process::exit(0) directly, which bypasses all Drop impls — so even adding cursor::Show to Drop alone would not fix Ctrl-C. (See issue fix(tui): restore terminal cursor on exit (Linux/tmux leave cursor hidden) #235 for full analysis.)

Changes

  • src/view/terminal_manager.rs: Added TERMINAL_NEEDS_RESTORE (AtomicBool) static flag and a public restore_terminal() function. The flag is set in initialize() after the alternate screen is entered and cleared atomically in restore_terminal(). The function is idempotent — safe to call concurrently from Drop, signal handlers, and a panic hook. Added PANIC_HOOK_INSTALLED (Once) to install a panic hook exactly once; the hook calls restore_terminal() before delegating to the previous hook so panic backtraces render on a usable terminal. TerminalManager::Drop now delegates to restore_terminal() (including explicit cursor::Show). Added 3 unit tests for the swap semantics and idempotency.
  • src/main.rs: Added view::terminal_manager::restore_terminal() calls at the start of both the SIGINT and SIGTERM handlers, before std::process::exit(0). Renamed setup_panic_handler to setup_panic_handlers and removed the #[cfg(target_os = "macos")] gate on the function definition; the macOS-specific native-metrics shutdown remains gated inside the closure. The call site in main() is now unconditional.
  • src/view/ui_loop.rs: Updated stale comment that claimed LeaveAlternateScreen resets cursor visibility — which was precisely the bug.

Verification

Automated checks run:

Command Result
cargo check --features cli --lib passed
cargo check --features cli --lib --tests passed
cargo clippy --features cli --lib --tests -- -D warnings passed
cargo test --features cli --bin all-smi -- view::terminal_manager::tests 3/3 passed
cargo fmt --check passed

Acceptance criteria requiring manual verification by the reviewer:

  • Press q on Linux (TTY + VTE terminal) — cursor must be visible in the shell afterward.
  • Press q inside tmux — cursor must be visible.
  • Press Ctrl-C — cursor must be visible and terminal must be out of raw mode / alt-screen.
  • Induce a panic in the view loop — terminal must be restored without needing reset.

The macOS / iTerm2 regression check (idempotent cursor::Show is safe when cursor already visible) does not require separate verification — the escape sequence is a no-op when the cursor is already shown.

Closes #235

On Linux terminals (VTE family, tmux, kitty) cursor visibility is tracked
independently of the alternate-screen mode. Leaving the alternate screen
does not automatically restore a hidden cursor — so after pressing q,
Ctrl-C, or a panic the shell cursor remained invisible, requiring the user
to run `reset`.

Root cause: TerminalManager::Drop emitted LeaveAlternateScreen but not
cursor::Show; and the SIGINT/SIGTERM handlers called std::process::exit(0)
directly, bypassing Drop entirely.

Fix:

- Add TERMINAL_NEEDS_RESTORE (AtomicBool) and restore_terminal() in
  terminal_manager.rs. The function is idempotent: a single atomic swap
  ensures that only the first caller does the actual cleanup, making it
  safe to call concurrently from Drop, signal handlers, and a panic hook.
- Set the flag in TerminalManager::initialize() after the alternate screen
  is entered; clear it in restore_terminal() after cleanup.
- Replace the inline execute! in TerminalManager::Drop with a call to
  restore_terminal() so cursor::Show is included on the normal q-exit path.
- Install a panic hook (via Once) inside TerminalManager::new() that calls
  restore_terminal() before delegating to the previous hook, so panic
  backtraces render on a usable terminal.
- Call view::terminal_manager::restore_terminal() in both the SIGINT and
  SIGTERM handlers in main.rs before std::process::exit(0). The function
  is a no-op for subcommands that never initialise the TUI.
- Rename setup_panic_handler to setup_panic_handlers and remove the macOS-
  only cfg gate; the macOS-specific shutdown call stays gated inside the
  closure. The call site in main() becomes unconditional.
- Update the stale comment in ui_loop.rs that claimed LeaveAlternateScreen
  resets cursor visibility (which was the bug).

Tests added in terminal_manager.rs verify: no-op when flag is unset,
flag is cleared after restore_terminal(), idempotent repeated calls.
@inureyes inureyes added status:review Under review type:bug Something isn't working priority:medium Medium priority issue labels May 26, 2026
@inureyes

Copy link
Copy Markdown
Member Author

Implementation Review Summary

Intent

Restore terminal cursor (and full TUI state) on every exit path — normal q, Ctrl-C / SIGTERM, and panic — so Linux/tmux/VTE users no longer need reset after all-smi exits.

Findings

LOW

  • src/view/terminal_manager.rs:132-134 — The inline comment "Use std::io::stdout() directly — no locks, no shared state" is technically inaccurate. std::io::stdout() does acquire an internal Mutex inside execute!; the intent appears to be "no application-managed locks held across the boundary," but the wording could mislead a future maintainer who needs to reason about deadlock potential in a panic-hook context. Suggest rewording to: "Use std::io::stdout() directly — no application-owned locks are held across this call — so this function is safe to call from a panic hook context, from a tokio task, and from Drop."

NIT

  • src/view/terminal_manager.rs:75,128,171,etc.Ordering::SeqCst on TERMINAL_NEEDS_RESTORE is conservative but stronger than necessary. Since the flag does not synchronise any data (only gates a self-contained block of terminal IO), Ordering::Acquire/Ordering::Release (or even Relaxed) would be sufficient. SeqCst is defensible as the simpler default; not worth changing unless you care about the (negligible) extra fence cost.
  • src/view/terminal_manager.rs:163-198 — The three unit tests share the global TERMINAL_NEEDS_RESTORE flag and can race under cargo's default parallel test execution within a binary. Each test does set the flag to its needed starting state, so the asserts cannot fail — but a parallel race could cause restore_terminal_is_noop_when_flag_unset to spuriously emit escape codes (the test does not assert against IO). Consider --test-threads=1 for this module or a Mutex<()> test lock. Optional.
  • Commit body is hard-wrapped at ~72 cols, which conflicts with the project's documented commit conventions (paragraphs as one long line, per commit-conventions skill). Body content is otherwise excellent — every paragraph clearly explains WHY. Cosmetic only; will not block.

Verification

  • All stated requirements implemented (cursor restore on q, signals, panic)
  • No placeholder / mock code
  • Integrated into project code flow (signal handlers in main.rs, Drop impl, panic hook installed inside TerminalManager::new())
  • Project conventions followed (license header, import grouping, no unwrap() on hot paths, let _ = documented)
  • Existing modules reused (crossterm, tokio::signal, std::panic::take_hook/set_hook)
  • No unintended structural changes
  • Tests pass (cargo test --features cli --bin all-smi -- view::terminal_manager::tests → 3/3 ok)
  • cargo check --features cli --lib — clean
  • cargo check --features cli --lib --tests — clean
  • cargo clippy --features cli --lib --tests -- -D warnings — clean
  • cargo fmt --check — clean

Cross-cutting checks confirmed

  • Panic hook chain ordering: setup_panic_handlers (macOS-native shutdown, no-op on Linux) installs first in main(); install_panic_hook (terminal restore) installs second inside TerminalManager::new(). Execution order on panic: terminal restore → macOS native shutdown → default backtrace hook. Terminal is usable before backtrace prints. Correct.
  • Signal-handler ordering: restore_terminal() is called before shutdown_native_metrics_manager / shutdown_hlsmi_manager and before std::process::exit(0) in both SIGINT and SIGTERM handlers. Matches issue's prescribed order.
  • Flag set-after-IO ordering: TERMINAL_NEEDS_RESTORE.store(true) runs only after both enable_raw_mode() AND execute!(EnterAlternateScreen, ...) succeed. A failed init leaves the flag false, so restore_terminal() is a clean no-op — no spurious escape codes for non-TUI subcommands or for partially-initialised terminals. Confirmed.
  • Idempotency under concurrency: AtomicBool::swap(false, ...) is the correct primitive — exactly one caller wins, all others early-return. No TOCTOU window since the flag-clear and cleanup IO are sequenced atomically per caller (the swap is the gate).
  • Feature gating: view::terminal_manager is reachable only via src/main.rs (declared as mod view;), and the all-smi bin requires the cli feature. No extra #[cfg(feature = "cli")] gates needed inside the module.
  • No regression on non-TUI subcommands: snapshot / doctor / config / record / api paths never call TerminalManager::new(), so restore_terminal() in signal handlers becomes a single atomic load + early return. record skips the new signal handlers entirely via the existing if !is_record guard.

Verdict

APPROVE — implementation is correct, well-commented, well-tested, and matches the issue's acceptance criteria. The three LOW/NIT items are non-blocking polish suggestions.

Reword the comment in restore_terminal() that incorrectly claimed "no locks, no shared state": stdout() does hold an internal stdlib mutex briefly inside execute!, so the accurate claim is that no *application-owned* locks are held across the call — which is what matters for panic-hook and Drop safety.

Add a module-level TEST_MUTEX (std::sync::Mutex) to the tests block so all three tests that mutate the process-global TERMINAL_NEEDS_RESTORE flag run sequentially even when cargo parallelises unit tests within a binary. No new dev-dependency needed.
@inureyes

Copy link
Copy Markdown
Member Author

PR Finalization Complete

Summary

Comment fix — The misleading "no locks, no shared state" comment at restore_terminal() lines 132-134 has been reworded. The accurate claim is that no application-owned locks are held across the execute! call; stdout() does acquire the stdlib mutex internally and releases it before returning, which is safe from every call site (panic hook, tokio task, Drop).

Test serialization — Added a module-level static TEST_MUTEX: Mutex<()> inside the tests block. Each of the three tests acquires a _guard before touching TERMINAL_NEEDS_RESTORE, ensuring they run sequentially even when cargo parallelises unit tests within a binary. No new dev-dependency introduced.

Documentation — No CHANGELOG.md at repo root; skipped. No user-facing docs describe TUI exit behavior explicitly (the fix is invisible when working correctly); no doc updates needed.

Lint/Formatcargo fmt --check clean. cargo clippy --features cli --lib --tests -- -D warnings passes with zero warnings. All 3 targeted tests pass.

Verification

cargo fmt --check        → clean
cargo clippy ...         → Finished, 0 warnings
cargo test ... view::terminal_manager::tests
  restore_terminal_clears_flag          ... ok
  restore_terminal_is_idempotent        ... ok
  restore_terminal_is_noop_when_flag_unset ... ok
  3 passed; 0 failed

Commit: 65a3013 — ready for merge.

@inureyes inureyes added status:done Completed and removed status:review Under review labels May 26, 2026
@inureyes inureyes merged commit 0edf86b into main May 26, 2026
4 checks passed
@inureyes inureyes deleted the fix/issue-235-restore-cursor-on-exit branch May 26, 2026 04:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority:medium Medium priority issue status:done Completed type:bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(tui): restore terminal cursor on exit (Linux/tmux leave cursor hidden)

1 participant