Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions docs/research/windows-replay-timeout-audit.md
Original file line number Diff line number Diff line change
@@ -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.
180 changes: 180 additions & 0 deletions tests/test_research_windows_process_tree.py
Original file line number Diff line number Diff line change
@@ -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()