From 9f4366f598911f1fa38ff1a81d8a1c99082466c5 Mon Sep 17 00:00:00 2001 From: Alexandre Teixeira Date: Thu, 29 Oct 2026 17:35:52 +0000 Subject: [PATCH] perf(tests): bound Notes server descendant cleanup --- scripts/verify_notes_browser_runtime.py | 42 ++++++++++++++++++++---- tests/test_notes_native_cleanup.py | 43 +++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 tests/test_notes_native_cleanup.py diff --git a/scripts/verify_notes_browser_runtime.py b/scripts/verify_notes_browser_runtime.py index 5fedbaf33..679e3f111 100644 --- a/scripts/verify_notes_browser_runtime.py +++ b/scripts/verify_notes_browser_runtime.py @@ -11,6 +11,38 @@ import time from urllib.request import urlopen ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +def _stop_owned_server(server, identity): + """Close only identity-bound descendants, including MCP's new sessions. + + Capture before signalling the parent: an orphaned stderr holder can no + longer be attributed by ancestry. Never search by executable name or kill + the caller's process group. + """ + from src import process_lifecycle as lifecycle + + descendants = (lifecycle.bind_descendants([identity.pid]) + if identity.verdict() == lifecycle.OWNED else []) + if os.name == "nt": + if identity.verdict() == lifecycle.OWNED: + lifecycle.taskkill_tree(identity.pid) + else: + outcome = lifecycle.terminate_identities( + [*(entry.identity for entry in reversed(descendants)), identity], + steps=lifecycle.term_kill_steps(10), + ) + if outcome.survivors or outcome.unverified: + raise RuntimeError(f"Disposable Notes cleanup could not verify exit: {outcome}") + try: + logs, _ = server.communicate(timeout=2) + except subprocess.TimeoutExpired as error: + # Do not turn an unidentified pipe holder into an unbounded wait. + server.stdout.close() + raise RuntimeError("Disposable Notes output pipe remained open after owned cleanup") from error + return logs def main(): @@ -36,7 +68,10 @@ def main(): server = subprocess.Popen( [sys.executable, "-m", "uvicorn", "app:app", "--host", "127.0.0.1", "--port", str(port)], cwd=target, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + start_new_session=(os.name != "nt"), ) + from src.process_lifecycle import ProcessIdentity + server_identity = ProcessIdentity.capture(server.pid) result = None try: origin = f"http://127.0.0.1:{port}" @@ -62,12 +97,7 @@ def main(): print(result.stderr, file=sys.stderr) return result.returncode finally: - server.terminate() - try: - logs, _ = server.communicate(timeout=10) - except subprocess.TimeoutExpired: - server.kill() - logs, _ = server.communicate() + logs = _stop_owned_server(server, server_identity) if result is None or result.returncode: print(logs[-8000:], file=sys.stderr) diff --git a/tests/test_notes_native_cleanup.py b/tests/test_notes_native_cleanup.py new file mode 100644 index 000000000..ad815d0fc --- /dev/null +++ b/tests/test_notes_native_cleanup.py @@ -0,0 +1,43 @@ +"""A detached Notes descendant must not hold output open or kill a bystander.""" +import os +import runpy +import subprocess +import sys +from pathlib import Path + +import pytest +from src import process_lifecycle as lifecycle + +ROOT = Path(__file__).resolve().parents[1] + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX detached-session regression; Windows uses taskkill tree") +def test_notes_cleanup_reaps_detached_pipe_holder_and_preserves_bystander(): + stop = runpy.run_path(str(ROOT / "scripts/verify_notes_browser_runtime.py"))["_stop_owned_server"] + child = "import time; time.sleep(60)" + program = ("import subprocess,sys,time; " + f"p=subprocess.Popen([sys.executable,'-c',{child!r}],start_new_session=True); " + "print(p.pid,flush=True); time.sleep(60)") + with subprocess.Popen([sys.executable, "-c", child]) as bystander: + server = subprocess.Popen([sys.executable, "-c", program], stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, start_new_session=True) + root_identity = lifecycle.ProcessIdentity.capture(server.pid) + detached = None + try: + child_pid = int(server.stdout.readline()) + detached = lifecycle.ProcessIdentity.capture(child_pid) + assert os.getpgid(child_pid) == child_pid + stop(server, root_identity) + assert server.poll() is not None + assert detached.exited() + assert bystander.poll() is None, "cleanup touched an unrelated process" + finally: + # Test-owned fallback cleanup also runs when the negative control + # intentionally substitutes the old parent-only teardown. + targets = [identity for identity in (detached, root_identity) if identity] + lifecycle.terminate_identities(targets, steps=lifecycle.term_kill_steps(1)) + server.wait(timeout=3) + if server.stdout: + server.stdout.close() + bystander.terminate() + bystander.wait(timeout=3)