python: kill the sidecar when its parent dies

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.
This commit is contained in:
Adrià Arrufat
2026-07-24 16:26:03 +02:00
parent 73a7a44139
commit ed966e15ec
2 changed files with 55 additions and 0 deletions

View File

@@ -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)

View File

@@ -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')