From 6262f1c052679a47aebec07cd69712f98ca968cc Mon Sep 17 00:00:00 2001 From: Coden Date: Sun, 6 Sep 2026 23:33:01 +0900 Subject: [PATCH 1/3] =?UTF-8?q?test(receipt):=20pid=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EC=9D=84=20=EC=A1=B4=EC=9E=AC=EA=B0=80=20=EC=95=84=EB=8B=88?= =?UTF-8?q?=EB=9D=BC=20=EB=82=B4=EC=9A=A9=EC=9C=BC=EB=A1=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8B=A4=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 에서 test_sigterm_cleanup_reaps_tracked_setsid_descendant 가 "ValueError: invalid literal for int() with base 10: ''" 로 실패했다. 같은 커밋 재실행에서는 통과했고, gen26 을 포함한 상위집합 커밋에서도 통과했다. 제품 결함이 아니라 테스트 자체의 경쟁 조건이다. 원인. 자식은 open(path,'w') 로 pid 를 쓰는데, 그 호출은 **쓰기 전에 파일을 만든다.** 부모는 pid_path.is_file() 이 참이 되기를 기다린 뒤 곧바로 읽으므로, 자식이 열기만 하고 아직 쓰지 않은 창에 걸리면 빈 문자열을 int() 에 넘긴다. 부하가 크면 그 창이 넓어진다. _read_pid_when_written() 을 넣고 pid 를 읽는 네 곳을 전부 그것으로 바꿨다. 그중 둘은 아예 기다리지 않고 선행 동작이 끝났으리라 가정하고 있었다. 찢어진 읽기도 함께 막았다. 내용만 보면 "12345" 를 쓰는 중의 "12" 도 정수로 파싱되어 엉뚱한 pid 를 돌려준다. 쓰는 쪽이 개행을 종결자로 붙이고 읽는 쪽이 그것을 요구하게 해서, "쓰기가 끝났다" 를 추측이 아니라 관측으로 만들었다. 직접 확인했다. 빈 파일에 old 코드는 ValueError 를 내고 새 헬퍼는 0.31초 기다렸다가 4242 를 돌려준다. 찢어진 "12" 는 12 가 아니라 완성된 12345 를 돌려준다. 끝내 종결되지 않으면 0.41초 뒤 명시적으로 실패한다. 검증: contract.test_g008_runner 75건 OK, 문제의 테스트 단독 3회 연속 통과, receipt package_check 통과, ci_test_gate fast 76건 OK, 실제 3.11 compileall exit 0. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DE3AupXcyv64SEuQeTckSh --- .../tests/contract/test_g008_runner.py | 53 ++++++++++++++----- 1 file changed, 39 insertions(+), 14 deletions(-) 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..f39d6f25 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,31 @@ 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 `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 content that parses 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. + if last.endswith("\n"): + return int(last.strip()) + 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 +972,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 +1053,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 +1088,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 +1384,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 +1398,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 +1427,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 +1466,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 +1479,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) From 3dea5e69b297f736eb331beeca45ed48cfbccd2f Mon Sep 17 00:00:00 2001 From: Coden Date: Sun, 6 Sep 2026 23:38:50 +0900 Subject: [PATCH 2/3] =?UTF-8?q?chore(pins):=20Receipt=20=EC=BB=B4=ED=8C=A8?= =?UTF-8?q?=EB=8B=88=EC=96=B8=20=EC=9D=B8=EB=B2=A4=ED=86=A0=EB=A6=AC?= =?UTF-8?q?=EC=97=90=EC=84=9C=20g008=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20diges?= =?UTF-8?q?t=20=EB=A5=BC=20=EA=B0=B1=EC=8B=A0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앞 커밋이 packages/context-guard-receipt/tests/contract/test_g008_runner.py 를 고쳤고, 그 파일은 RECEIPT_COMPANION_INVENTORY 에 sha256 으로 핀돼 있다. 갱신하지 않으면 tests.test_context_guard_receipt_suite 가 "receipt companion inventory path/type/mode/hash drifted; updating the pin requires human review" 로 실패한다. 이 인벤토리는 scripts/refresh_protected_pins.py 가 다루지 않는다. 그 스크립트의 INVENTORY_PINNED 는 verify_protected_surfaces.py 와 test_boundary.py 두 경로뿐이고, 나머지 136개 항목은 의도적으로 수동 검토 대상이다. 실행해 보면 "all pins match" 를 출력하므로, 이 인벤토리를 건드렸을 때 그 스크립트만 믿으면 안 된다. receipt_companion_surface_inventory() 로 재생성해 대조한 결과 138개 중 정확히 한 항목만, sha256 필드만 달랐다. 나머지 필드는 동일하다. 그 한 값만 바꿨다. 검증: tests.test_contextguard_stage2_feasibility 9건 OK, tests.test_context_guard_receipt_suite exit 0(수정 전 exit 1), refresh_protected_pins --check 통과, ci_test_gate fast 76건 OK, 실제 3.11 compileall exit 0, prepublish_check --skip-tests OK. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DE3AupXcyv64SEuQeTckSh --- tests/test_contextguard_stage2_feasibility.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_contextguard_stage2_feasibility.py b/tests/test_contextguard_stage2_feasibility.py index 6ee05ad9..0e8fff6d 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': '533e38e82804eb5ea1562e1ade293cedd55a0a489a81b182dbd5dc016b41a9a8'}, {'file_type': 'regular', 'mode': '0644', 'path': 'packages/context-guard-receipt/tests/contract/test_g009_cli.py', From 677141fcdf813b9014ed74133162d9a1dd7f259f Mon Sep 17 00:00:00 2001 From: Coden Date: Sun, 6 Sep 2026 23:46:40 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test(receipt):=20GLM=20=ED=9B=84=EC=86=8D?= =?UTF-8?q?=20non-blocker=20=E2=80=94=20docstring=20=EC=9D=84=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=20=EC=BD=94=EB=93=9C=EC=97=90=20=EB=A7=9E=EC=B6=94?= =?UTF-8?q?=EA=B3=A0=20=EB=B9=88=20=EB=A0=88=EC=BD=94=EB=93=9C=EB=A5=BC=20?= =?UTF-8?q?=EB=A7=89=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docstring 이 "waits on Path.exists()" 라고 했는데 기존 코드는 is_file() 이었다. 둘 다 "경로가 생겼는지"만 보는 것이 요점이므로 그렇게 적는다. - 내용이 개행 하나뿐일 때 int('') 가 터지는 이론적 경로를 막았다. 작성자가 단일 write 로 "pid\\n" 을 쓰므로 도달하지 않지만, 헬퍼가 스스로 방어하는 편이 낫다. - 파일이 바뀌었으므로 RECEIPT_COMPANION_INVENTORY 의 digest 를 다시 맞췄다. 검증: contract.test_g008_runner 75건 OK, 헬퍼 케이스 3종(빈 파일, 찢어진 쓰기, 선행 개행) 통과, stage2 feasibility 9건 OK, receipt suite exit 0, fast 76건 OK. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DE3AupXcyv64SEuQeTckSh --- .../tests/contract/test_g008_runner.py | 14 ++++++++------ tests/test_contextguard_stage2_feasibility.py | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) 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 f39d6f25..0f312700 100644 --- a/packages/context-guard-receipt/tests/contract/test_g008_runner.py +++ b/packages/context-guard-receipt/tests/contract/test_g008_runner.py @@ -128,10 +128,11 @@ 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 `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 content that parses instead. + 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 = "" @@ -143,8 +144,9 @@ def _read_pid_when_written(path: Path, timeout: float = 5.0) -> int: # 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. - if last.endswith("\n"): - return int(last.strip()) + 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})") diff --git a/tests/test_contextguard_stage2_feasibility.py b/tests/test_contextguard_stage2_feasibility.py index 0e8fff6d..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': '533e38e82804eb5ea1562e1ade293cedd55a0a489a81b182dbd5dc016b41a9a8'}, + 'sha256': 'bf9a2a15254ac6a230fb86767a586cc0c3657be6e57a7924b5abe50ae69ad8a9'}, {'file_type': 'regular', 'mode': '0644', 'path': 'packages/context-guard-receipt/tests/contract/test_g009_cli.py',