Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/engine/effect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub trait Effect: EffectHooks {
pub enum RunOutcome {
Complete,
Interrupted,
Terminated,
TerminalResized,
}

Expand Down Expand Up @@ -70,6 +71,8 @@ pub fn run_effect(
fn requested_stop(ctx: &mut EngineCtx, stop_on_resize: bool) -> Option<RunOutcome> {
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 {
Expand Down
19 changes: 19 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() {
Expand Down Expand Up @@ -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;
Expand Down
14 changes: 9 additions & 5 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down Expand Up @@ -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) => {
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions tools/tests/cli_corpus.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
113 changes: 113 additions & 0 deletions tools/tests/sigterm_behavior.py
Original file line number Diff line number Diff line change
@@ -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())
Loading