diff --git a/packages/context-guard-receipt/tests/contract/test_g008_runner.py b/packages/context-guard-receipt/tests/contract/test_g008_runner.py index dc9d4784..0f312700 100644 --- a/packages/context-guard-receipt/tests/contract/test_g008_runner.py +++ b/packages/context-guard-receipt/tests/contract/test_g008_runner.py @@ -124,6 +124,33 @@ def _process_is_alive(pid: int) -> bool: return True +def _read_pid_when_written(path: Path, timeout: float = 5.0) -> int: + """Wait for a child's pid file to hold a pid, not merely to exist. + + `open(path, "w")` creates the file before anything is written to it, so a + reader that waits on the path existing — `Path.is_file()` or + `Path.exists()` — can win the race and read an empty string. That is a + flake, not a product defect: it surfaced on CI as `ValueError: invalid + literal for int() with base 10: ''` while the same commit passed on a + re-run. Wait for a finished record instead. + """ + deadline = time.monotonic() + timeout + last = "" + while time.monotonic() < deadline: + try: + last = path.read_text(encoding="ascii") + except (FileNotFoundError, UnicodeDecodeError): + last = "" + # The writers end the pid with a newline, so a trailing newline is what + # makes "the write finished" observable. Without it a torn read of + # "12345" could return 12 and look entirely valid. + body = last.strip() + if last.endswith("\n") and body: + return int(body) + time.sleep(0.01) + raise AssertionError(f"pid file never became readable: {path} (last read {last!r})") + + def _wait_for_process_exit(pid: int, timeout: float = 3.0) -> bool: deadline = time.monotonic() + timeout while time.monotonic() < deadline: @@ -947,14 +974,13 @@ def interrupt_after_child_starts() -> None: with self.assertRaises(KeyboardInterrupt): harness.run( "import os,time; " - "open('child.pid','w').write(str(os.getpid())); " + "open('child.pid','w').write(str(os.getpid()) + chr(10)); " "time.sleep(30)" ) finally: interrupter.join(timeout=5.0) - self.assertTrue(pid_path.is_file()) - child_pid = int(pid_path.read_text(encoding="ascii")) + child_pid = _read_pid_when_written(pid_path) try: os.kill(child_pid, 0) except ProcessLookupError: @@ -1029,7 +1055,7 @@ def test_sigterm_runs_cleanup_before_the_wrapper_exits(self) -> None: child_code = ( "import os,signal,sys,time; " "signal.signal(signal.SIGTERM,signal.SIG_IGN); " - "open(sys.argv[1],'w').write(str(os.getpid())); " + "open(sys.argv[1],'w').write(str(os.getpid()) + chr(10)); " "time.sleep(30)" ) wrapper_code = ( @@ -1064,8 +1090,7 @@ def test_sigterm_runs_cleanup_before_the_wrapper_exits(self) -> None: if wrapper.poll() is not None: break time.sleep(0.01) - self.assertTrue(pid_path.is_file()) - child_pid = int(pid_path.read_text(encoding="ascii")) + child_pid = _read_pid_when_written(pid_path) wrapper.send_signal(signal.SIGTERM) time.sleep(0.05) if wrapper.poll() is None: @@ -1361,7 +1386,7 @@ def test_setsid_descendant_that_closes_stdio_blocks_normal_publication(self) -> " except OSError: pass\n" " signal.signal(signal.SIGTERM,signal.SIG_IGN)\n" " with open(sys.argv[1],'w') as stream:\n" - " stream.write(str(os.getpid())); stream.flush(); os.fsync(stream.fileno())\n" + " stream.write(str(os.getpid()) + chr(10)); stream.flush(); os.fsync(stream.fileno())\n" " time.sleep(30)\n" " os._exit(0)\n" "deadline=time.monotonic()+3\n" @@ -1375,15 +1400,16 @@ def test_setsid_descendant_that_closes_stdio_blocks_normal_publication(self) -> arguments=(str(pid_path),), limits=module.RunnerLimits(timeout_seconds=0.4), ) - self.assertTrue(pid_path.is_file()) - detached_pid = int(pid_path.read_text(encoding="ascii")) + detached_pid = _read_pid_when_written(pid_path) self.assertEqual(result.error_code, module.RunnerErrorCode.TIMEOUT) self.assertIsNone(result.receipt) self.assertEqual(harness.factory_calls, 0) self.assertTrue(_wait_for_process_exit(detached_pid)) finally: if detached_pid is None and pid_path.is_file(): - detached_pid = int(pid_path.read_text(encoding="ascii")) + # Best effort during cleanup: the child may have died mid-write. + raw = pid_path.read_text(encoding="ascii").strip() + detached_pid = int(raw) if raw.isdigit() else None if detached_pid is not None: _kill_test_process_group(detached_pid) @@ -1403,7 +1429,7 @@ def test_sigterm_cleanup_reaps_tracked_setsid_descendant(self) -> None: " except OSError: pass\n" " signal.signal(signal.SIGTERM,signal.SIG_IGN)\n" " with open(sys.argv[1],'w') as stream:\n" - " stream.write(str(os.getpid())); stream.flush(); os.fsync(stream.fileno())\n" + " stream.write(str(os.getpid()) + chr(10)); stream.flush(); os.fsync(stream.fileno())\n" " time.sleep(30)\n" " os._exit(0)\n" "time.sleep(30)\n" @@ -1442,8 +1468,7 @@ def test_sigterm_cleanup_reaps_tracked_setsid_descendant(self) -> None: if wrapper.poll() is not None: break time.sleep(0.01) - self.assertTrue(pid_path.is_file()) - detached_pid = int(pid_path.read_text(encoding="ascii")) + detached_pid = _read_pid_when_written(pid_path) time.sleep(0.2) wrapper.send_signal(signal.SIGTERM) stdout, stderr = wrapper.communicate(timeout=5.0) @@ -1456,7 +1481,9 @@ def test_sigterm_cleanup_reaps_tracked_setsid_descendant(self) -> None: wrapper.kill() wrapper.communicate(timeout=5.0) if detached_pid is None and pid_path.is_file(): - detached_pid = int(pid_path.read_text(encoding="ascii")) + # Best effort during cleanup: the child may have died mid-write. + raw = pid_path.read_text(encoding="ascii").strip() + detached_pid = int(raw) if raw.isdigit() else None if detached_pid is not None: _kill_test_process_group(detached_pid) diff --git a/tests/test_contextguard_stage2_feasibility.py b/tests/test_contextguard_stage2_feasibility.py index 6ee05ad9..c69c005e 100644 --- a/tests/test_contextguard_stage2_feasibility.py +++ b/tests/test_contextguard_stage2_feasibility.py @@ -898,7 +898,7 @@ def is_weightclass_scaffolding_path(path_text: str) -> bool: {'file_type': 'regular', 'mode': '0644', 'path': 'packages/context-guard-receipt/tests/contract/test_g008_runner.py', - 'sha256': '39f7874f98df99245d9d4ee345f5cc83c05e094a64096521d61fa73ddf359744'}, + 'sha256': 'bf9a2a15254ac6a230fb86767a586cc0c3657be6e57a7924b5abe50ae69ad8a9'}, {'file_type': 'regular', 'mode': '0644', 'path': 'packages/context-guard-receipt/tests/contract/test_g009_cli.py',