From ed966e15ecb0ea520cbc9060a41ce19dcededf4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Fri, 24 Jul 2026 16:26:03 +0200 Subject: [PATCH] python: kill the sidecar when its parent dies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR_SET_PDEATHSIG on the spawned lightpanda mcp process (Linux) so a SIGKILLed or crashed interpreter cannot leak a browser process — a 2h44m orphan surfaced during the v6 benchmark campaign. Regression test kills a parent and asserts the sidecar exits. --- bindings/python/lightpanda/client.py | 14 +++++++++ bindings/python/tests/test_browser.py | 41 +++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/bindings/python/lightpanda/client.py b/bindings/python/lightpanda/client.py index 8d24f7130..83831e33e 100644 --- a/bindings/python/lightpanda/client.py +++ b/bindings/python/lightpanda/client.py @@ -29,6 +29,19 @@ _SPAWN_ATTEMPTS = 3 _READY_TIMEOUT = 15.0 +def _die_with_parent(): + """Linux: ask the kernel to SIGTERM the sidecar when the parent dies, + so a SIGKILLed interpreter can't leak a browser process.""" + try: + import ctypes + import signal as _signal + + PR_SET_PDEATHSIG = 1 + ctypes.CDLL(None, use_errno=True).prctl(PR_SET_PDEATHSIG, _signal.SIGTERM) + except Exception: + pass + + def find_binary(explicit: str | os.PathLike | None = None) -> Path: """Locate the lightpanda binary: explicit arg, $LIGHTPANDA_BIN, the bundled package copy, then PATH.""" @@ -91,6 +104,7 @@ class Client: stdout=subprocess.DEVNULL, stderr=stderr, env=child_env, + preexec_fn=_die_with_parent if sys.platform == "linux" else None, ) try: self._wait_ready(proc, port) diff --git a/bindings/python/tests/test_browser.py b/bindings/python/tests/test_browser.py index 9dcd74b3b..87d2938de 100644 --- a/bindings/python/tests/test_browser.py +++ b/bindings/python/tests/test_browser.py @@ -1,3 +1,10 @@ +import os +import signal +import subprocess +import sys +import time +from pathlib import Path + import pytest from lightpanda import ToolError, run_script @@ -64,6 +71,40 @@ def test_extra_args_passthrough(binary, fixture_url, tmp_path): assert cache_dir.exists() +@pytest.mark.skipif(sys.platform != "linux", reason="PDEATHSIG is Linux-only") +def test_sidecar_dies_with_killed_parent(binary, tmp_path): + child_src = tmp_path / "spawn_and_hang.py" + child_src.write_text( + "import os, sys, time\n" + "from lightpanda import Browser\n" + "b = Browser(binary=sys.argv[1])\n" + "print(b._client._proc.pid, flush=True)\n" + "time.sleep(60)\n" + ) + env = dict(os.environ, PYTHONPATH=str(Path(__file__).parent.parent)) + child = subprocess.Popen( + [sys.executable, str(child_src), binary], + stdout=subprocess.PIPE, text=True, env=env, + ) + sidecar_pid = int(child.stdout.readline()) + assert _alive(sidecar_pid) + + os.kill(child.pid, signal.SIGKILL) + child.wait() + deadline = time.monotonic() + 5 + while time.monotonic() < deadline and _alive(sidecar_pid): + time.sleep(0.1) + assert not _alive(sidecar_pid), "sidecar survived its parent's SIGKILL" + + +def _alive(pid): + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + + def test_run_script(binary, fixture_url, tmp_path): script = tmp_path / "visit.js" script.write_text('const page = new Page();\nawait page.goto("$LP_TEST_URL");\n')