Skip to content

fix(process-daemon): stop cleanly on SIGTERM and SIGINT - #60691

Open
jlongster wants to merge 3 commits into
devfrom
daemon-stop-signals
Open

jlongster wants to merge 3 commits into
devfrom
daemon-stop-signals

Conversation

@jlongster

@jlongster jlongster commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Issue for this PR

Closes #

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

The PTY daemon had no handler for SIGTERM or SIGINT, so either signal killed it on the spot: its terminals were left to die on SIGHUP and its runtime directory, socket and registration stayed behind until a later sweep. Once the daemon also runs non-PTY commands (next PRs), those children don't get SIGHUP at all and would keep running.

  • On Unix, run first blocks SIGTERM and SIGINT, before bind starts the sweeper thread or publishes service.json, so every later thread inherits the mask. It then resets both to their default action (macOS never delivers an ignored signal to sigwait, and a launcher may leave SIGINT ignored).
  • A dedicated thread waits with libc::sigwait (retrying on EINTR). The first signal calls Control::request_stop(), so the daemon goes through its normal stop: kill every terminal, join workers, remove the runtime files. A second signal more than 1 s later means the stop is stuck and calls _exit(128 + signal); one within 1 s (Ctrl-C plus a wrapper's SIGTERM) is part of the same stop.
  • If the signal thread can't start, the runtime files are removed before returning the error.
  • Uses libc directly rather than nix's SigSet::wait, which treats sigwait's positive error return as success and reads an uninitialized signal.
  • Children inherit the signal mask across exec. portable-pty resets it for PTY children, so terminals are unaffected; the new test checks that a terminal's process still dies from each signal. Anything else the daemon spawns must reset the mask itself (the pipe-mode PR does). Windows is unchanged.

How did you verify your code works?

cargo fmt --check, cargo clippy --all-targets --all-features -- -D warnings and the macOS test suite in packages/process-daemon; clippy for x86_64-pc-windows-msvc and x86_64-unknown-linux-gnu. New test stop_signals_stop_terminals_and_clean_up (against the real binary, for both signals): a terminal's child still receives the signal, and after the daemon gets the signal it exits successfully, removes its runtime directory and socket, and its terminal process is gone.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

@opencodejr opencodejr 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.

The approach is sound: block SIGTERM and SIGINT, wait for them with sigwait on a dedicated thread, and route the first signal through Control::request_stop(). request_stop locks and notifies the wake, so calling it from another thread is safe. std and portable-pty clear the signal mask before exec, so children behave as before. I couldn't build or run this here because the sandbox has no Rust or Zig toolchain, so it's reviewed from the code only.

Public API: unchanged. This only affects how the daemon handles signals internally.

One ordering problem, see the inline comment: the signals are blocked after Runtime::bind, which has already started the sweeper thread. That leaves a window where the daemon can still die the old way.

// Created after bind so bind I/O does not consume the acquire window.
let control = Arc::new(Control::new(wake, Ownership::new(Instant::now())));
#[cfg(unix)]
stop_on_signals(&control)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

platform::Runtime::bind already starts a thread on Unix: the stale-runtime sweeper (thread::spawn(move || super::sweep::stale_runtimes(&directory)) in daemon/unix.rs). That thread keeps the original signal mask, so the doc comment's "blocked on this thread before any worker exists" isn't true. While the sweeper runs (longer when there are many stale runtimes), the kernel can deliver SIGTERM or SIGINT to it. The default action then kills the process and leaves the runtime directory, socket and service.json behind. The registration is already written by then, so other processes can find the daemon and signal it during that window.

Fix: block the signals at the very top of run, before Wake::new()/bind, and only start the sigwait thread once Control exists. A signal that arrives during bind stays pending and is picked up by the waiter.

@opencodejr opencodejr 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.

The earlier ordering point is fixed: SIGTERM and SIGINT are now blocked at the very top of run, before bind starts the sweeper thread or writes service.json, so every later thread inherits the block. The rest of the new code also reads correctly: the libc::sigwait loop with its EINTR retry, the reset to the default action after blocking, the 1 s grace before a second signal calls _exit, and removing the runtime files when the signal thread can't start. I found nothing new that needs changing.

This is from reading the code only. This environment has no Rust or Zig toolchain, so I couldn't build the daemon or run its tests.

Public API: unchanged.

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant