From e696cdae910e6ea14ce907a66586fe855bce0ae3 Mon Sep 17 00:00:00 2001 From: adityagarud Date: Mon, 10 Aug 2026 20:04:30 +0000 Subject: [PATCH] Restore the cursor on SIGTERM The default SIGTERM action exits immediately, bypassing terminal teardown and leaving an interactive cursor hidden. Install a handler only when stdout is a tty, carry the signal through the normal run-loop exit, and return the conventional 143 status after restoring the cursor. Redirected output keeps the default signal behavior and receives no teardown bytes. Add a pty-driven regression covering both cases. --- src/engine/effect.rs | 3 + src/lib.rs | 19 ++++++ src/main.rs | 14 ++-- tools/tests/cli_corpus.sh | 2 + tools/tests/sigterm_behavior.py | 113 ++++++++++++++++++++++++++++++++ 5 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 tools/tests/sigterm_behavior.py diff --git a/src/engine/effect.rs b/src/engine/effect.rs index 234069f..8e4b66a 100644 --- a/src/engine/effect.rs +++ b/src/engine/effect.rs @@ -17,6 +17,7 @@ pub trait Effect: EffectHooks { pub enum RunOutcome { Complete, Interrupted, + Terminated, TerminalResized, } @@ -70,6 +71,8 @@ pub fn run_effect( fn requested_stop(ctx: &mut EngineCtx, stop_on_resize: bool) -> Option { if crate::interrupted() { Some(RunOutcome::Interrupted) + } else if crate::terminated() { + Some(RunOutcome::Terminated) } else if stop_on_resize && ctx.terminal.resize_settled() { Some(RunOutcome::TerminalResized) } else { diff --git a/src/lib.rs b/src/lib.rs index 54be714..b4f9464 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub mod utils; use std::sync::atomic::{AtomicBool, Ordering}; static INTERRUPTED: AtomicBool = AtomicBool::new(false); +static TERMINATED: AtomicBool = AtomicBool::new(false); static TERMINAL_RESIZED: AtomicBool = AtomicBool::new(false); /// SIGINT is recorded and checked from the run loop so teardown (cursor @@ -26,6 +27,23 @@ pub fn interrupted() -> bool { INTERRUPTED.load(Ordering::SeqCst) } +/// SIGTERM takes the normal output teardown path when stdout is a tty, so a +/// process supervisor cannot leave the cursor hidden. +pub fn install_sigterm_handler() { + // SAFETY: signal(2) with a signal-safe handler that only stores a flag. + unsafe { + libc_signal(SIGTERM, handle_sigterm as *const () as usize); + } +} + +extern "C" fn handle_sigterm(_: i32) { + TERMINATED.store(true, Ordering::SeqCst); +} + +pub fn terminated() -> bool { + TERMINATED.load(Ordering::SeqCst) +} + /// Record terminal resizes so the CLI can rebuild effects whose canvas and /// character positions were derived from the previous dimensions. pub fn install_sigwinch_handler() { @@ -53,6 +71,7 @@ pub fn restore_sigpipe() { } const SIGINT: i32 = 2; +const SIGTERM: i32 = 15; const SIGPIPE: i32 = 13; /// 28 on Linux and on the BSDs, macOS included. const SIGWINCH: i32 = 28; diff --git a/src/main.rs b/src/main.rs index 3a4153e..99e5e99 100644 --- a/src/main.rs +++ b/src/main.rs @@ -125,12 +125,14 @@ fn main() -> ExitCode { // SIGWINCH is delivered to every process in the terminal's foreground group, // whatever its stdout points at. Reacting to it when the animation is being // redirected would leave a truncated first run followed by a complete second - // one in the file, so the resize path is tty-only. - let resize_aware = !cli.parity_dump && std::io::stdout().is_terminal(); + // one in the file. SIGTERM teardown is tty-only for the same reason: a pipe + // keeps the default signal semantics and receives no extra teardown bytes. + let tty_output = !cli.parity_dump && std::io::stdout().is_terminal(); if !cli.parity_dump { ttfx::install_sigint_handler(); } - if resize_aware { + if tty_output { + ttfx::install_sigterm_handler(); ttfx::install_sigwinch_handler(); } @@ -162,7 +164,7 @@ fn main() -> ExitCode { ttfx::engine::effect::dump_effect(effect.as_mut(), &mut ctx, cli.max_frames) .map(|_| ttfx::engine::effect::RunOutcome::Complete) } else { - ttfx::engine::effect::run_effect(effect.as_mut(), &mut ctx, resize_aware) + ttfx::engine::effect::run_effect(effect.as_mut(), &mut ctx, tty_output) }; match outcome { Ok(ttfx::engine::effect::RunOutcome::TerminalResized) => { @@ -184,7 +186,9 @@ fn main() -> ExitCode { match result { Ok(()) => { - if ttfx::interrupted() { + if ttfx::terminated() { + ExitCode::from(143) + } else if ttfx::interrupted() { ExitCode::from(1) } else { ExitCode::SUCCESS diff --git a/tools/tests/cli_corpus.sh b/tools/tests/cli_corpus.sh index c8a6ff6..2034204 100755 --- a/tools/tests/cli_corpus.sh +++ b/tools/tests/cli_corpus.sh @@ -47,3 +47,5 @@ check success-multi-stops 0 bash -c "printf 'hi' | $RUST --parity-dump --seed 1 echo "cli corpus: $pass passed, $fail failed" if [ $fail -gt 0 ]; then printf 'FAILED: %s\n' "${failed[@]}"; exit 1; fi + +python3 tools/tests/sigterm_behavior.py diff --git a/tools/tests/sigterm_behavior.py b/tools/tests/sigterm_behavior.py new file mode 100644 index 0000000..6cf0a43 --- /dev/null +++ b/tools/tests/sigterm_behavior.py @@ -0,0 +1,113 @@ +"""SIGTERM cleanup behavior, driven on a real pty. + +Interactive output must restore the cursor before exiting. Redirected output +keeps the default SIGTERM behavior and must not gain teardown bytes. +""" + +from __future__ import annotations + +import fcntl +import os +import pty +import select +import signal +import struct +import sys +import termios +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +BIN = sys.argv[1] if len(sys.argv) > 1 else str(ROOT / "target/release/ttfx") +HIDE, SHOW = b"\x1b[?25l", b"\x1b[?25h" +ARGS = ["--frame-rate", "30", "colorshift", "--cycles", "100"] + + +def spawn(stdout_pipe: bool): + stdin_read, stdin_write = os.pipe() + stdout_read, stdout_write = os.pipe() if stdout_pipe else (None, None) + pid, tty = pty.fork() + if pid == 0: + os.close(stdin_write) + os.dup2(stdin_read, 0) + os.close(stdin_read) + if stdout_pipe: + os.close(stdout_read) + os.dup2(stdout_write, 1) + os.close(stdout_write) + os.execv(BIN, [BIN] + ARGS) + os._exit(127) + os.close(stdin_read) + if stdout_pipe: + os.close(stdout_write) + fcntl.ioctl(tty, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0)) + os.write(stdin_write, b"hello\n") + os.close(stdin_write) + return pid, tty, stdout_read + + +def run(stdout_pipe: bool): + pid, tty, stdout_read = spawn(stdout_pipe) + source = stdout_read if stdout_pipe else tty + captured = bytearray() + deadline = time.monotonic() + 2 + while HIDE not in captured and time.monotonic() < deadline: + ready, _, _ = select.select([source], [], [], 0.02) + if ready: + captured.extend(os.read(source, 65536)) + + os.kill(pid, signal.SIGTERM) + status = None + deadline = time.monotonic() + 2 + while time.monotonic() < deadline: + ready, _, _ = select.select([source], [], [], 0.02) + if ready: + try: + chunk = os.read(source, 65536) + except OSError: + chunk = b"" + captured.extend(chunk) + done, candidate = os.waitpid(pid, os.WNOHANG) + if done: + status = candidate + break + if status is None: + os.kill(pid, signal.SIGKILL) + _, status = os.waitpid(pid, 0) + # The child may exit just before its final tty bytes become readable. + deadline = time.monotonic() + 0.1 + while time.monotonic() < deadline: + ready, _, _ = select.select([source], [], [], 0.01) + if not ready: + break + try: + chunk = os.read(source, 65536) + except OSError: + break + if not chunk: + break + captured.extend(chunk) + os.close(source) + if stdout_pipe: + os.close(tty) + return os.waitstatus_to_exitcode(status), bytes(captured) + + +def main() -> int: + tty_status, tty_output = run(False) + pipe_status, pipe_output = run(True) + checks = [ + ("tty exits 143", tty_status == 143), + ("tty restores cursor", tty_output.count(HIDE) == 1 and tty_output.count(SHOW) == 1), + ("pipe keeps default SIGTERM", pipe_status == -signal.SIGTERM), + ("pipe gets no teardown", SHOW not in pipe_output), + ] + for label, passed in checks: + print(f" {'ok ' if passed else 'FAIL'} {label}") + failures = sum(not passed for _, passed in checks) + print(f"\nSIGTERM behavior: {'all checks passed' if not failures else f'{failures} failed'}") + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main())