From 5208ab2780550273b1d8933ff584ce7581fb242d Mon Sep 17 00:00:00 2001 From: ywz65 <154373401+ywz65@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:04:02 -0700 Subject: [PATCH] test: observe native Windows replay descendant cleanup --- docs/research/windows-replay-timeout-audit.md | 87 +++++++++ tests/test_research_windows_process_tree.py | 180 ++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 docs/research/windows-replay-timeout-audit.md create mode 100644 tests/test_research_windows_process_tree.py diff --git a/docs/research/windows-replay-timeout-audit.md b/docs/research/windows-replay-timeout-audit.md new file mode 100644 index 0000000000..fd23553644 --- /dev/null +++ b/docs/research/windows-replay-timeout-audit.md @@ -0,0 +1,87 @@ +# Native Windows replay timeout observation + +This answers the Windows observer handoff in [discussion #1472](https://github.com/CrocSwap/integer-mult-bounds/discussions/1472). +The new `tests/test_research_windows_process_tree.py` checks the production +launcher's native `taskkill /F /T` branch against a ready parent, child and +grandchild. All three process objects stop on timeout. Replacing tree cleanup +with a successful-looking parent-only kill is rejected because the retained +child and grandchild handles still report live processes. + +This contributes missing native-platform coverage. It changes no production +launcher, mathematical source, selected result or supplier claim. + +## Scope, sources and overlap + +Task `portable-replay`, priority revision 4, selection nonce +`bee056178d0d10813b1e128c510250d8`, explicit verifier-engineering track override. +Scope status **changed**: this investigates missing native timeout coverage +rather than a compressed-payload comparison. Native child cleanup is a bounded +verification obligation; a leader-only check cannot establish it. + +Base: canonical main `0fdd7d82de03e43c9e5a1000dfbcd380d1b687d2`. + +| Input at base | Git blob | SHA256 | +|---|---|---| +| `scripts/research_package.py` | `6733ec2f5700e6602d60dc95938c1ea1854980f0` | `b077068519d85bc04a7192d8bcd2fdbc1ce487ea2b7264ed7f437c439b0a16de` | +| `tests/test_research_process_tree.py` | `7073039625ccd0c6582ad066f475c70d05358980` | `5caef4430724a6895923bd0c0e3a58b381af9e7df9c2df943402a0f569f50638` | + +Paul Romeo's merged #1484 supplies the Linux test and explicitly leaves a +Windows observer open. Open #2377, head +`5d444eee7dedd997c6094776fbb32ed19831e241`, handles already-exited leaders on +POSIX; it explicitly does not solve that Windows boundary. This test targets +ordinary descendants while the timed-out leader still exists. It is neither +a duplicate POSIX fix nor a Windows containment implementation. + +The entry overlap snapshot covered 647 open PRs and 1,177 discussion records, +with complete reported pagination and no fetch warnings. Its PR snapshot was +2026-10-15T10:46:05Z and discussion snapshot 10:48:58Z. Arbitrary code diffs and +all closed PRs are outside the helper's coverage. The claim is in the existing +canonical discussion; no new reservation issue was created. + +## Verification + +On native Windows 11 build 26300, CPython 3.12.7 normal and `-O`, and CPython 3.14.5 +normal, all three tests pass in each mode, with no skips. Each complete suite +took about five seconds. The three cases are successful joined completion, +real timeout tree termination, and rejection of parent-only termination. + +```sh +python3.12 -B tests/test_research_windows_process_tree.py -v +python3.12 -O -B tests/test_research_windows_process_tree.py -v +python3.14 -B tests/test_research_windows_process_tree.py -v +``` + +On Windows use the corresponding installed interpreter path. Non-Windows +hosts intentionally skip this module; that skip is not native validation. + +Readiness is established before the launcher's timeout clock starts. The +observer holds native process handles acquired while all three processes are +live, rather than reopening numeric PIDs after cleanup. It checks `taskkill`'s +return code and elapsed cleanup time as well as the process states, so the +fixture's 60-second fallback self-exit cannot be mistaken for successful cleanup. +Teardown uses only the fixture's retained handles and release marker. + +The observer follows Microsoft's [process-handle wait contract](https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-waitforsingleobject) +with `SYNCHRONIZE` access. Native execution needs permission to stop the test's +own processes; a sandbox-denied `taskkill` is an environment failure, not a +passing production cleanup result. + +A separate AI agent reviewed the observer, readiness, mutation control and +teardown with no blocking finding. This is not human review. Hosted Windows CI, +detached processes, already-exited leader containment, and the selected +mathematical cold replay were not checked by this contribution. + +## Open next questions + +Native Windows already-exited-leader cleanup remains open in #2377. Reproduce +its ready-child/early-exit cases and design lifetime ownership that survives +leader exit; a late `taskkill` alone does not supply that contract. This test +does not resolve that separate boundary or guarantee hostile-process containment. + +## Credit + +Douglas Colkitt and retained contributors authored the launcher. Paul Romeo +authored the Linux fixture and Windows handoff; the tree shape and readiness +strategy are credited to that test. PR1298 and the earlier Windows replay audit +are retained predecessors. Prepared for ywz65 with substantial OpenAI Codex +assistance in implementation, verification and writing. Apache-2.0. diff --git a/tests/test_research_windows_process_tree.py b/tests/test_research_windows_process_tree.py new file mode 100644 index 0000000000..578927f1a7 --- /dev/null +++ b/tests/test_research_windows_process_tree.py @@ -0,0 +1,180 @@ +"""Native Windows timeout-tree controls for registered research replay. + +ywz65, with substantial OpenAI Codex assistance. Apache-2.0. +Complements Paul Romeo's Linux controls in test_research_process_tree.py. +Only ordinary descendants of a live leader are in scope; this is not containment. +""" +import ctypes +from ctypes import wintypes +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +import research_package as package +sys.path.pop(0) + +TREE = """\ +import os +from pathlib import Path +import subprocess +import sys +import time + +root, depth = Path(sys.argv[1]), int(sys.argv[2]) +(root / f"{depth}.pid").write_text(str(os.getpid())) +child = (subprocess.Popen([sys.executable, __file__, str(root), str(depth - 1)]) + if depth else None) +if depth == 0: + (root / "ready").touch() +deadline = time.monotonic() + 60 +while not (root / "release").exists() and time.monotonic() < deadline: + time.sleep(0.01) +if child is not None: + child.wait(timeout=5) +""" + + +def wait_for(predicate, timeout=5): + deadline = time.monotonic() + timeout + while not predicate(): + if time.monotonic() >= deadline: + return False + time.sleep(0.01) + return True + + +class ProcessHandle: + """Retain one fixture process object, so later PID reuse cannot fool a check.""" + + def __init__(self, pid): + self.api = ctypes.WinDLL("kernel32", use_last_error=True) + self.api.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + self.api.OpenProcess.restype = wintypes.HANDLE + self.api.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD] + self.api.WaitForSingleObject.restype = wintypes.DWORD + self.api.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT] + self.api.TerminateProcess.restype = wintypes.BOOL + self.api.CloseHandle.argtypes = [wintypes.HANDLE] + self.api.CloseHandle.restype = wintypes.BOOL + self.pid = pid + # SYNCHRONIZE for observation; PROCESS_TERMINATE only for fixture teardown. + self.handle = self.api.OpenProcess(0x00100000 | 0x0001, False, pid) + if not self.handle: + raise ctypes.WinError(ctypes.get_last_error()) + + def alive(self): + result = self.api.WaitForSingleObject(self.handle, 0) + if result == 0: # WAIT_OBJECT_0: the process terminated. + return False + if result == 258: # WAIT_TIMEOUT: the process is still running. + return True + raise ctypes.WinError(ctypes.get_last_error()) + + def stop(self): + if self.alive() and not self.api.TerminateProcess(self.handle, 1): + if self.alive(): + raise ctypes.WinError(ctypes.get_last_error()) + if self.api.WaitForSingleObject(self.handle, 5000) != 0: + raise RuntimeError(f"fixture process {self.pid} did not stop") + + def close(self): + if self.handle: + if not self.api.CloseHandle(self.handle): + raise ctypes.WinError(ctypes.get_last_error()) + self.handle = None + + +@unittest.skipUnless(sys.platform == "win32", "requires native Windows process handles") +class WindowsProcessTreeTests(unittest.TestCase): + def exercise_tree(self, complete=False, leader_only=False): + process = None + cleanup_at = None + handles = [] + real_popen, real_run = subprocess.Popen, subprocess.run + with tempfile.TemporaryDirectory(prefix="replay tree ") as tmp: + root = Path(tmp) + script = root / "tree.py" + script.write_text(TREE, encoding="utf-8") + + def started(argv, **kwargs): + nonlocal process + candidate = real_popen(argv, **kwargs) + if str(script) not in argv: + return candidate # taskkill is not part of the fixture tree. + process = candidate + self.assertTrue(wait_for((root / "ready").exists, 30), "tree never became ready") + pids = [int((root / f"{depth}.pid").read_text()) for depth in range(3)] + self.assertEqual(pids[2], process.pid) + self.assertEqual(len(set(pids)), 3) + for pid in pids: + handles.append(ProcessHandle(pid)) + self.assertTrue(all(handle.alive() for handle in handles)) + if complete: + (root / "release").touch() + return candidate + + def run(argv, **kwargs): + nonlocal cleanup_at + is_cleanup = argv[:4] == ["taskkill", "/F", "/T", "/PID"] + if is_cleanup: + cleanup_at = time.monotonic() + if leader_only and is_cleanup: + self.assertEqual(argv[4], str(process.pid)) + process.kill() + return subprocess.CompletedProcess(argv, 0, b"", b"") + result = real_run(argv, **kwargs) + if is_cleanup: + # Do not mistake the fixture's eventual self-exit for a kill. + self.assertEqual(result.returncode, 0, result.stderr) + return result + + try: + # All three handles are retained before the launcher's timeout begins. + with patch.object(package.subprocess, "Popen", side_effect=started), \ + patch.object(package.subprocess, "run", side_effect=run): + argv = [sys.executable, str(script), str(root), "2"] + if complete: + package.run_command(argv, root, 5) + else: + with self.assertRaisesRegex(ValueError, "verification timed out"): + package.run_command(argv, root, 0.05) + if not complete: + self.assertIsNotNone(cleanup_at, "timeout did not invoke tree cleanup") + self.assertLess(time.monotonic() - cleanup_at, 10, + "cleanup waited for the fixture's 60-second self-exit") + wait_for(lambda: not any(handle.alive() for handle in handles), 1) + self.assertFalse([h.pid for h in handles if h.alive()], "live descendants after replay") + finally: + (root / "release").touch() + try: + # Only retained handles belonging to this fixture are terminated. + for handle in handles: + handle.stop() + if process is not None: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + finally: + for handle in handles: + handle.close() + + def test_successful_tree_completion(self): + self.exercise_tree(complete=True) + + def test_timeout_terminates_descendants(self): + self.exercise_tree() + + def test_parent_only_cleanup_is_detected(self): + with self.assertRaisesRegex(AssertionError, "live descendants after replay"): + self.exercise_tree(leader_only=True) + + +if __name__ == "__main__": + unittest.main()