Skip to content

fix(client): restore terminal on SIGHUP and SIGTERM - #2041

Merged
ogulcancelik merged 9 commits into
herdrdev:masterfrom
MattJColes:fix/client-restore-terminal-on-sighup-v2
Jul 30, 2026
Merged

fix(client): restore terminal on SIGHUP and SIGTERM#2041
ogulcancelik merged 9 commits into
herdrdev:masterfrom
MattJColes:fix/client-restore-terminal-on-sighup-v2

Conversation

@MattJColes

@MattJColes MattJColes commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

What

The client only enabled ctrlc without its termination feature, so it caught SIGINT alone. On a direct SIGHUP or SIGTERM the process died without unwinding, so neither TerminalGuard::Drop nor the panic hook ran and clear_host_mouse_reporting() never fired. Mouse tracking stayed on and leaked SGR reports as garbage on every pointer move.

This is a direct-signal path, e.g. a terminal emulator SIGHUPing its foreground child on window close. It is not the herdr --remote ssh-death path. Under --remote the local herdr client is a subprocess talking to the bridge socket; when ssh dies the client sees EOF on the socket, the loop returns an error, and drop(terminal_guard) restores the terminal. No signal is delivered, so that path was already clean.

Fix

Enable ctrlc's termination feature so the existing handler also catches SIGTERM and SIGHUP. It sets should_quit, the loop exits, and TerminalGuard::Drop restores the terminal the same way it does for Ctrl+C. Handler install failure is logged with warn! rather than swallowed. No behaviour change on SIGINT.

Tests

tests/client_mode.rs drives a client under a pty and checks for the mouse teardown bytes emitted after the trigger:

Test master this PR
client_restores_terminal_on_server_eof (kill server → EOF) pass pass
client_restores_terminal_on_sighup (SIGHUP to client) fail (zero teardown bytes) pass

The EOF test passing on master confirms the --remote/ssh path already restores. The SIGHUP test is the regression this PR fixes.

Note

The #1713 SGR leak looks like an ordering issue rather than signals: restore_terminal_state clears the mouse modes before ratatui::restore() leaves the alt screen, and terminals like Ghostty restore saved private-mode state on LeaveAlternateScreen. Not addressed here.

refs #1713

The client only installed a Ctrl+C handler and depended on ctrlc without
its "termination" feature, so it caught SIGINT alone. On SIGHUP or SIGTERM
the process died without unwinding, so neither TerminalGuard::Drop nor the
panic hook ran and clear_host_mouse_reporting() never fired. Mouse tracking
stayed enabled on the host terminal, leaking SGR mouse reports as garbage on
every pointer move.

Enable ctrlc's "termination" feature so the existing handler also catches
SIGTERM and SIGHUP. It sets should_quit, the client loop exits, and
TerminalGuard::Drop restores the terminal as it does for Ctrl+C.

refs herdrdev#1713
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The ctrlc dependency now enables termination signals. Client handler installation failures are logged, and PTY integration tests verify terminal mouse-reporting teardown after server EOF and client SIGHUP.

Changes

Signal handling and terminal restoration

Layer / File(s) Summary
Termination signal cleanup
Cargo.toml, src/client/mod.rs
Enables ctrlc termination signals, checks handler installation, logs failures, and documents terminal restoration behavior.
PTY output capture
tests/client_mode.rs
Adds shared PTY capture, mouse-teardown marker detection, thin-client attachment, and exit-draining helpers.
Terminal restoration scenarios
tests/client_mode.rs
Tests terminal mouse-reporting teardown after server EOF and direct client SIGHUP termination.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Signal as Termination signal
  participant Client as Thin client
  participant Guard as TerminalGuard
  participant PTY as Client PTY

  Signal->>Client: Trigger quit path
  Client->>Guard: Drop terminal guard
  Guard->>PTY: Emit mouse-reporting teardown
  PTY-->>Client: Captured teardown markers
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: restoring terminal handling for SIGHUP and SIGTERM.
Description check ✅ Passed The description matches the implemented signal-handling fix and the related PTY tests.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@kangal-bot kangal-bot added coderabbit-review greptile-review Trigger Greptile review for contributor-approved pull requests labels Jul 29, 2026
@greptile-apps

greptile-apps Bot commented Jul 29, 2026

Copy link
Copy Markdown

Greptile Summary

Enables graceful client exit on SIGHUP/SIGTERM so TerminalGuard restores the terminal and clears mouse reporting.

  • Cargo.toml: enable ctrlc termination feature (SIGTERM/SIGHUP in addition to SIGINT).
  • src/client/mod.rs: log warn! if ctrlc::set_handler fails instead of ignoring the error.
  • tests/client_mode.rs: PTY helpers and tests that assert mouse-teardown bytes after server EOF and after SIGHUP to the client.

Confidence Score: 5/5

This PR appears safe to merge; no blocking failures remain from prior or current review findings.

No blocking failure remains. The change wires termination signals into the existing should_quit path so TerminalGuard can restore the terminal, and tests cover the SIGHUP and server-EOF restore cases.

Important Files Changed

Filename Overview
Cargo.toml Enables ctrlc termination feature so SIGTERM/SIGHUP share the existing quit handler.
src/client/mod.rs Installs the expanded signal handler and warns on install failure without changing SIGINT behavior.
tests/client_mode.rs Adds PTY-based regression coverage for mouse teardown on server EOF and client SIGHUP.

Sequence Diagram

sequenceDiagram
  participant Term as Terminal emulator
  participant Client as herdr client
  participant Guard as TerminalGuard
  Term->>Client: SIGHUP / SIGTERM
  Client->>Client: ctrlc handler sets should_quit
  Client->>Client: client loop exits
  Client->>Guard: drop(TerminalGuard)
  Guard->>Term: clear_host_mouse_reporting + restore
Loading

Reviews (9): Last reviewed commit: "Merge branch 'master' into fix/client-re..." | Re-trigger Greptile

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/client/mod.rs (1)

1228-1233: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Propagate ctrlc::set_handler failures. Ignoring this Result leaves SIGTERM/SIGHUP handling disabled if registration fails, so the quit path may never run while TerminalGuard is active. Return the error instead of discarding it.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 272d15de-ba72-4589-bb66-c39330c8d9e7

📥 Commits

Reviewing files that changed from the base of the PR and between 3563f5c and 8824cce.

📒 Files selected for processing (2)
  • Cargo.toml
  • src/client/mod.rs

Surface a failed ctrlc::set_handler instead of discarding the Result. On
failure the terminal restore falls back to TerminalGuard::Drop and the panic
hook, and the warning makes the degraded state visible rather than silent.

refs herdrdev#1713
@MattJColes

Copy link
Copy Markdown
Contributor Author

Good catch, fixed in 66772d1. I log the failure with warn! rather than returning it. ctrlc::set_handler fails mainly on double-registration (one handler per process), and aborting client startup for that would be worse than the degraded state, TerminalGuard::Drop and the panic hook still cover normal exit and panics. The warning makes the degraded case visible instead of silent.

@ogulcancelik

Copy link
Copy Markdown
Collaborator

thanks for the pr. i think this addresses a different path: when ssh dies, the local thin client gets eof through the bridge socket and exits normally; it does not receive sigterm or sighup. your reproduction signals the client directly, so it does not reproduce an --remote ssh failure.

i also couldn’t reproduce an sgr leak by killing the ssh bridge. do you have a reproduction where the bridge dies but the thin client fails to restore the terminal?

@MattJColes

Copy link
Copy Markdown
Contributor Author

Hey, i think we were just talking past each other, youre right. went and wrote two integration tests in tests/client_mode.rs that drive a client under a pty and check for the mouse teardown bytes, and they match what you said.

the ssh-death path never hits a signal. run_remote spawns a local herdr client subprocess talking to the bridge socket, so when ssh dies the client just gets eof on the socket, the loop bails with an error and drop(terminal_guard) does the restore. the test that kills the server to force that eof passes on master untouched, so no leak there and my repro in the pr doesnt reproduce the --remote case.

where it does apply is a direct SIGHUP/SIGTERM to the client, like when a terminal emulator sighups its foreground child as the window closes. that test fails on master with zero teardown bytes and passes once termination is on. so it still fixes something, just not the path #1713 is describing.

one other thing while i was in there. i think the #1713 leak might be ordering, not signals. in restore_terminal_state we clear the mouse modes before ratatui::restore() leaves the alt screen, and ghostty puts back saved private mode state on LeaveAlternateScreen, so mouse tracking comes back on the normal screen.

@kangal-bot kangal-bot added the ai-review Trigger automated AI reviews for pull requests admitted by the PR gate label Jul 30, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
tests/client_mode.rs (1)

711-749: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider extracting the shared restore-test body.

client_restores_terminal_on_server_eof and client_restores_terminal_on_sighup duplicate the full setup/watermark/drain/assert/cleanup sequence, differing only in the trigger action. Extracting a shared helper parameterized by the trigger would remove ~35 lines of duplication and keep the two tests focused on the interesting behavior.

♻️ Proposed refactor
+fn run_restore_test(trigger: impl FnOnce(&mut SpawnedHerdr, &mut SpawnedHerdr)) {
+    let _lock = test_lock();
+    let base = unique_test_dir();
+    let config_home = base.join("config");
+    let runtime_dir = base.join("runtime");
+    let api_socket = runtime_dir.join("herdr.sock");
+    let client_socket = runtime_dir.join("herdr-client.sock");
+
+    let (mut spawned_server, mut thin_client, pty_output) =
+        attach_thin_client(&config_home, &runtime_dir, &api_socket, &client_socket);
+
+    let since = output_len(&pty_output);
+    trigger(&mut spawned_server, &mut thin_client);
+
+    let output = drain_until_client_exits(&mut thin_client, &pty_output, since);
+    assert!(
+        output_has_mouse_teardown(&output),
+        "client must emit mouse teardown after trigger; output after trigger: {output:?}"
+    );
+
+    drop(spawned_server);
+    cleanup_spawned_herdr(thin_client, base);
+}
+
 #[test]
 fn client_restores_terminal_on_server_eof() {
-    let _lock = test_lock();
-    let base = unique_test_dir();
-    ...
-    drop(spawned_server);
-    cleanup_spawned_herdr(thin_client, base);
+    run_restore_test(|server, _client| {
+        if let Some(pid) = server.child.process_id() {
+            unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL); }
+        }
+        server.close_master();
+    });
 }
 
 #[test]
 fn client_restores_terminal_on_sighup() {
-    let _lock = test_lock();
-    ...
-    drop(spawned_server);
-    cleanup_spawned_herdr(thin_client, base);
+    run_restore_test(|_server, client| {
+        let pid = client.child.process_id().expect("thin client pid") as libc::pid_t;
+        unsafe { libc::kill(pid, libc::SIGHUP); }
+    });
 }

Also applies to: 751-787


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c925a8ce-9b12-4d90-aeab-006ddb38c3e6

📥 Commits

Reviewing files that changed from the base of the PR and between 66772d1 and b74b652.

📒 Files selected for processing (1)
  • tests/client_mode.rs

@MattJColes

MattJColes commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

done in be1d2ce - shared setup pulled into assert_client_restores_terminal, tests just pass their trigger closure now

@ogulcancelik
ogulcancelik merged commit b411274 into herdrdev:master Jul 30, 2026
9 checks passed
@ogulcancelik

Copy link
Copy Markdown
Collaborator

ty!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review Trigger automated AI reviews for pull requests admitted by the PR gate coderabbit-review greptile-review Trigger Greptile review for contributor-approved pull requests waiting-answer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants