From 162d66b005634dafe8877bc0b8cf5cc15d646bd8 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:46:08 +0800 Subject: [PATCH] fix(daemon): block SIGTERM before any thread exists, or the shutdown save never runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `serve_sigterm` blocks SIGTERM and waits for it on a dedicated thread, so that `store_scrollback_now` can write every pane's screen one last time on the way out — "the periodic writer covers the deaths nobody gets to prepare for; this covers the ones we do". It ran too late to do that. `pthread_sigmask` blocks a signal on the *calling* thread only, and a signal sent to a process is delivered to any one thread that has not blocked it. The call sat after the control listener was already serving, so several threads had SIGTERM unblocked and the kernel could hand it to one of them, where the default disposition ends the process on the spot: `sigwait` never returns, no screen is written, and nothing is logged. Measured against a running daemon, sending SIGTERM directly: before the marker printed after the last periodic snapshot was absent from the store, and "daemon shutting down on SIGTERM" never appeared after the marker is in the store and the line is logged So a machine shutting down, a logout, a supervisor stopping the daemon or a plain `kill` cost every pane up to SNAPSHOT_INTERVAL — 30 seconds — of screen that the code was written to keep. The fix is where the call sits, not what it does: immediately after the registry exists and before the control listener, which is the first thing in `run_daemon` to start a thread. A thread inherits the mask of the one that created it, so blocking there makes the whole process deaf to SIGTERM except on the waiter. Blocking and waiting stay in the same call, so there is never a window where the signal is blocked with nobody to answer it. `tty7 server stop` was never affected: it asks over the protocol (`ClientMsg::Shutdown`) and only falls back to signals, so it took the other `store_scrollback_now` path. 2949 tests pass. --- crates/tty7-core/src/daemon/server.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index d14de0cb..f671874f 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -329,6 +329,22 @@ pub fn run_daemon() -> anyhow::Result<()> { let registry = Arc::new(Registry::new()); + // Before anything else spawns a thread, and that is the whole point of it + // being here rather than further down with the other startup work. + // `pthread_sigmask` blocks a signal on the *calling thread* only, and a + // signal sent to the process goes to any one thread that has not blocked + // it — where SIGTERM's default disposition ends the process on the spot, + // `sigwait` never returns, and the shutdown that was supposed to write + // every pane's screen one last time does not happen. A thread started + // after this inherits the mask, so blocking here and nowhere else is what + // makes the whole process deaf to it except on the waiter. + // + // Nothing in this function has started a thread yet: `handoff::requested` + // and `singleton::claim` do not, and the control listener below is the + // first that does. Keep it that way, or move this up again. + #[cfg(unix)] + serve_sigterm(registry.clone()); + #[cfg(any(unix, windows))] { let mut services = control_services(); @@ -554,9 +570,6 @@ fn run_with(registry: Arc) -> anyhow::Result<()> { crate::daemon::pidfile::write_current(); - #[cfg(unix)] - serve_sigterm(registry.clone()); - if let Some(store) = crate::core::machine::observed_store() { registry.seed_ids_past(&store.machine()); let probe = registry.clone();