fix(cli): close parent-death signaling gaps found in review

- Gate PR_SET_PDEATHSIG on frozen PyInstaller binaries (is_binary_install)
  so pip/source installs are not killed when their launching shell exits.
- Capture the parent PID before prctl and verify afterward: if the
  bootloader died before registration the process is reparented and
  PDEATHSIG would watch the wrong parent, so deliver SIGTERM ourselves.
- Install a cleanup-aware SIGTERM fallback before enabling the parent-death
  signal; run_cli replaces it later, and interactive/TUI scans now enter
  report-state cleanup instead of dying by the default action.
This commit is contained in:
aunttwister 2026-09-08 10:27:44 +00:00
parent 357f1baf7c
commit 80ffcbf82c

View file

@ -6,6 +6,7 @@ Strix Agent Interface
import argparse
import asyncio
import contextlib
import os
import signal
import sys
from pathlib import Path
@ -419,8 +420,31 @@ def _bootstrap_scan(args: argparse.Namespace) -> None:
telemetry_start(args)
def _install_cleanup_sigterm_handler() -> None:
"""Install a cleanup-aware SIGTERM fallback before the scan engine starts.
``run_cli`` installs its own SIGINT/SIGTERM/SIGHUP handler once a report
state exists, but the interactive Go TUI installs none Python-side. Without
an early handler, a SIGTERM that arrives mid-setup or during an interactive
scan terminates the process by the default action, skipping report-state
cleanup. This fallback runs ``cleanup(status="interrupted")`` when a report
state exists, then exits; scan paths replace it with their own handler as
soon as it is safe to do so.
"""
def _on_sigterm(_signum: int, _frame: object) -> None:
from strix.report.state import get_global_report_state
state = get_global_report_state()
if state is not None:
state.cleanup(status="interrupted")
sys.exit(1)
signal.signal(signal.SIGTERM, _on_sigterm)
def _enable_parent_death_signal() -> None:
"""On Linux, ask the kernel to deliver SIGTERM when our parent dies.
"""On Linux frozen binaries, ask the kernel to deliver SIGTERM when our parent dies.
Strix ships as a PyInstaller onefile binary: the process users see is an
outer bootloader that spawns the real Python app as a child. When a caller
@ -430,26 +454,40 @@ def _enable_parent_death_signal() -> None:
to PID 1 and keeps scanning as an orphaned background process.
PR_SET_PDEATHSIG makes the kernel deliver SIGTERM to this process the moment
the bootloader dies, so the app's existing SIGTERM handler (cleanup + exit)
runs instead of leaking an orphaned scan. No-op outside Linux.
the bootloader dies, so the app's SIGTERM handler (cleanup + exit) runs
instead of leaking an orphaned scan. Only meaningful when running as the
inner process of a PyInstaller onefile binary; pip/source installs keep the
user's shell as their parent and must not be killed when that shell exits.
No-op outside Linux or when not frozen.
"""
if sys.platform != "linux":
return
if not is_binary_install():
return
try:
import ctypes # noqa: PLC0415 (stdlib; late import keeps startup lean)
import ctypes # stdlib; late import keeps startup lean
libc = ctypes.CDLL(None, use_errno=True)
PR_SET_PDEATHSIG = 1
if libc.prctl(PR_SET_PDEATHSIG, signal.SIGTERM) != 0:
pr_set_pdeathsig = 1
parent_pid = os.getppid()
if libc.prctl(pr_set_pdeathsig, signal.SIGTERM) != 0:
logger.warning(
"prctl(PR_SET_PDEATHSIG, SIGTERM) failed: %s",
ctypes.get_errno(),
)
return
# The bootloader can die between spawning this process and the prctl
# above; the process is then reparented before the kernel snapshots the
# parent, and PDEATHSIG would watch the wrong parent. Deliver SIGTERM
# ourselves when that happened so the scan still cleans up and exits.
if os.getppid() != parent_pid:
os.kill(os.getpid(), signal.SIGTERM)
except Exception:
logger.debug("PR_SET_PDEATHSIG unavailable", exc_info=True)
def main() -> None:
_install_cleanup_sigterm_handler()
_enable_parent_death_signal()
configure_dependency_logging()