From 357f1baf7cdb3833d1f50b34f0ed02cc6b36d42e Mon Sep 17 00:00:00 2001 From: aunttwister Date: Tue, 8 Sep 2026 09:40:47 +0000 Subject: [PATCH 1/3] fix(cli): kill orphaned scans when the parent process dies PyInstaller onefile binaries run as an outer bootloader + inner Python app. When a caller stops a scan by killing the direct child (e.g. subprocess.run(timeout=...) sends SIGKILL, or a pipeline/CI sends SIGKILL), only the bootloader dies: the inner app is reparented to PID 1 and keeps scanning forever, leaking resources until OOM. Strix already installs SIGINT/SIGTERM/SIGHUP handlers, but none of those fire because SIGKILL cannot be caught or forwarded by the bootloader. On Linux, PR_SET_PDEATHSIG asks the kernel to deliver SIGTERM the moment the parent dies, so the app's existing SIGTERM cleanup path runs instead of orphaning the scan. No-op on non-Linux platforms. --- strix/interface/main.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/strix/interface/main.py b/strix/interface/main.py index c9bd55961..7c1a3c783 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -6,6 +6,7 @@ Strix Agent Interface import argparse import asyncio import contextlib +import signal import sys from pathlib import Path @@ -418,7 +419,38 @@ def _bootstrap_scan(args: argparse.Namespace) -> None: telemetry_start(args) +def _enable_parent_death_signal() -> None: + """On Linux, 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 + stops a scan by killing the direct child -- e.g. ``subprocess.run(..., timeout=)`` + sends SIGKILL, or a container/pipeline sends SIGKILL -- the bootloader dies + but the inner app cannot receive or forward that SIGKILL: it is reparented + 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. + """ + if sys.platform != "linux": + return + try: + import ctypes # noqa: PLC0415 (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: + logger.warning( + "prctl(PR_SET_PDEATHSIG, SIGTERM) failed: %s", + ctypes.get_errno(), + ) + except Exception: + logger.debug("PR_SET_PDEATHSIG unavailable", exc_info=True) + + def main() -> None: + _enable_parent_death_signal() configure_dependency_logging() if sys.platform == "win32": From 80ffcbf82c77478e8a6de149827f26bc183aa1aa Mon Sep 17 00:00:00 2001 From: aunttwister Date: Tue, 8 Sep 2026 10:27:44 +0000 Subject: [PATCH 2/3] 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. --- strix/interface/main.py | 50 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/strix/interface/main.py b/strix/interface/main.py index 7c1a3c783..8ac1c81aa 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -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() From 3d8d93e3c946b27c3e0d4e61a01f2eb930f82d30 Mon Sep 17 00:00:00 2001 From: aunttwister Date: Tue, 8 Sep 2026 10:27:48 +0000 Subject: [PATCH 3/3] test(cli): cover parent-death signal registration and SIGTERM fallback Unit tests for _enable_parent_death_signal (source-install/no-op gating, prctl registration, reparenting race, prctl failure) and the early cleanup-aware SIGTERM fallback handler (cleanup with/without state). --- tests/test_parent_death_signal.py | 115 ++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 tests/test_parent_death_signal.py diff --git a/tests/test_parent_death_signal.py b/tests/test_parent_death_signal.py new file mode 100644 index 000000000..634547242 --- /dev/null +++ b/tests/test_parent_death_signal.py @@ -0,0 +1,115 @@ +"""Tests for the Linux parent-death signal helper in interface/main.py.""" + +from __future__ import annotations + +import os +import signal +from unittest import mock + +from strix.interface.main import ( + _enable_parent_death_signal, + _install_cleanup_sigterm_handler, +) + + +def _ctypes_fixture() -> tuple[mock.Mock, mock.Mock, mock._patch]: + """Return (ctypes_mock, libc_mock, sysmods_patch) for intercepting ctypes. + + ``_enable_parent_death_signal`` binds ``ctypes`` as a function-local name via + ``import ctypes``, so patching the module attribute does not intercept it. + Replacing the module in ``sys.modules`` does. + """ + + ctypes_mock = mock.Mock() + libc_mock = ctypes_mock.CDLL.return_value + return ctypes_mock, libc_mock, mock.patch.dict("sys.modules", {"ctypes": ctypes_mock}) + + +@mock.patch("strix.interface.main.is_binary_install", return_value=False) +def test_noop_for_source_install(_mock_binary: mock.Mock) -> None: + ctypes_mock, _, sysmods = _ctypes_fixture() + with mock.patch("strix.interface.main.sys.platform", "linux"), sysmods: + _enable_parent_death_signal() + ctypes_mock.CDLL.assert_not_called() + + +@mock.patch("strix.interface.main.is_binary_install", return_value=True) +def test_noop_for_non_linux(_mock_binary: mock.Mock) -> None: + ctypes_mock, _, sysmods = _ctypes_fixture() + with mock.patch("strix.interface.main.sys.platform", "darwin"), sysmods: + _enable_parent_death_signal() + ctypes_mock.CDLL.assert_not_called() + + +@mock.patch("strix.interface.main.is_binary_install", return_value=True) +def test_registers_pdeathsig_when_frozen(_mock_binary: mock.Mock) -> None: + _, libc_mock, sysmods = _ctypes_fixture() + libc_mock.prctl.return_value = 0 + with ( + mock.patch("strix.interface.main.sys.platform", "linux"), + mock.patch("strix.interface.main.os.getppid", return_value=1234), + mock.patch("strix.interface.main.os.kill") as mock_kill, + sysmods, + ): + _enable_parent_death_signal() + libc_mock.prctl.assert_called_once_with(1, signal.SIGTERM) + mock_kill.assert_not_called() + + +@mock.patch("strix.interface.main.is_binary_install", return_value=True) +def test_self_terminates_when_parent_died_before_registration( + _mock_binary: mock.Mock, +) -> None: + _, libc_mock, sysmods = _ctypes_fixture() + libc_mock.prctl.return_value = 0 + with ( + mock.patch("strix.interface.main.sys.platform", "linux"), + mock.patch("strix.interface.main.os.getppid", side_effect=[1234, 1]), + mock.patch("strix.interface.main.os.kill") as mock_kill, + sysmods, + ): + _enable_parent_death_signal() + mock_kill.assert_called_once_with(os.getpid(), signal.SIGTERM) + + +@mock.patch("strix.interface.main.is_binary_install", return_value=True) +def test_warns_and_returns_when_prctl_fails(_mock_binary: mock.Mock) -> None: + _, libc_mock, sysmods = _ctypes_fixture() + libc_mock.prctl.return_value = -1 + with ( + mock.patch("strix.interface.main.sys.platform", "linux"), + mock.patch("strix.interface.main.os.getppid", return_value=1234), + mock.patch("strix.interface.main.os.kill") as mock_kill, + sysmods, + ): + _enable_parent_death_signal() + libc_mock.prctl.assert_called_once_with(1, signal.SIGTERM) + mock_kill.assert_not_called() + + +def test_cleanup_sigterm_handler_cleans_state_and_exits() -> None: + with mock.patch("strix.interface.main.signal.signal") as mock_signal: + _install_cleanup_sigterm_handler() + handler = mock_signal.call_args.args[1] + + state = mock.Mock() + with ( + mock.patch("strix.report.state.get_global_report_state", return_value=state), + mock.patch("sys.exit") as mock_exit, + ): + handler(signal.SIGTERM, None) + state.cleanup.assert_called_once_with(status="interrupted") + mock_exit.assert_called_once_with(1) + + +def test_cleanup_sigterm_handler_exits_without_state() -> None: + with mock.patch("strix.interface.main.signal.signal") as mock_signal: + _install_cleanup_sigterm_handler() + handler = mock_signal.call_args.args[1] + + with ( + mock.patch("strix.report.state.get_global_report_state", return_value=None), + mock.patch("sys.exit") as mock_exit, + ): + handler(signal.SIGTERM, None) + mock_exit.assert_called_once_with(1)