From f3483171b9f40bf75168873a0a2ff7b98c8e8042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sinan=20G=C3=BCm=C3=BC=C5=9Fkabak?= <256841712+sinangumuskabak-sys@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:47:47 +0300 Subject: [PATCH] fix(cli): wait for the hash-seed re-exec on Windows instead of os.execvpe (#3799) On Windows os.exec* does not replace the process: CPython spawns a new one and the caller exits at once, so update/extract/cluster-only/label returned before graph.json was written, and the exiting parent intermittently died with an access violation (exit 139). Run the child with subprocess, wait for it and propagate its exit status there; POSIX keeps the real os.execvpe. Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit 94a9657e93bb846208bc76fd5b396c912e4891c9) --- graphify/__main__.py | 23 ++++++++++++++++++++-- tests/test_pin_hash_seed.py | 39 +++++++++++++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/graphify/__main__.py b/graphify/__main__.py index a2c5416de..730d7a874 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -528,8 +528,7 @@ def _pin_hash_seed_if_needed() -> None: # launcher on Windows is a native .exe with no .py content, so # `python.exe ` fails outright with "can't open # file" -- every command this function touches (#3779). - os.execvpe( - sys.executable, + _reexec( [sys.executable, "-m", "graphify", *sys.argv[1:]], {**os.environ, "PYTHONHASHSEED": "0"}, ) @@ -537,6 +536,26 @@ def _pin_hash_seed_if_needed() -> None: pass +def _reexec(argv: list[str], env: dict[str, str], windows: bool | None = None) -> None: + """Replace the current command with `argv` run under `env`. + + On POSIX this is a real os.execvpe. On Windows os.exec* does not replace + the process: CPython spawns a new one and the calling process exits at + once, so the caller sees the command "finish" while the work continues + detached, and the exiting parent intermittently dies with an access + violation (exit 139 / 0xC0000005) instead (#3799). There the child is + run with subprocess and waited for, and its exit status is propagated. + """ + if windows is None: + windows = os.name == "nt" + if windows: + import subprocess + sys.stdout.flush() + sys.stderr.flush() + raise SystemExit(subprocess.run(argv, env=env).returncode) + os.execvpe(argv[0], argv, env) + + def main() -> None: """Console entry point. Wraps the CLI so that when a downstream consumer closes stdout early, graphify treats it as success instead of crashing with an diff --git a/tests/test_pin_hash_seed.py b/tests/test_pin_hash_seed.py index 2aad777b5..cd3ec4b27 100644 --- a/tests/test_pin_hash_seed.py +++ b/tests/test_pin_hash_seed.py @@ -32,9 +32,9 @@ import sys _PROBE = """ import json, os, sys calls = [] -os.execvpe = lambda *a: calls.append(a) sys.argv = {argv!r} import graphify.__main__ as mainmod +mainmod._reexec = lambda argv, env: calls.append((argv[0], argv, env)) mainmod._pin_hash_seed_if_needed() print(json.dumps({{"called": bool(calls), "argv": calls[0][1] if calls else None, "env_hashseed": calls[0][2].get("PYTHONHASHSEED") if calls else None}})) @@ -106,9 +106,9 @@ def test_degrades_instead_of_raising_when_reexec_fails(): import os, sys def _raise(*a): raise OSError("exec not permitted") -os.execvpe = _raise sys.argv = ["graphify", "update", "."] import graphify.__main__ as mainmod +mainmod._reexec = _raise mainmod._pin_hash_seed_if_needed() # must not raise print("survived") """ @@ -137,3 +137,38 @@ def test_update_still_runs_end_to_end_with_hashseed_unset(tmp_path): assert result.returncode == 0, result.stderr assert (tmp_path / "graphify-out" / "graph.json").exists() + + +def test_reexec_uses_execvpe_on_posix(monkeypatch): + import graphify.__main__ as mainmod + calls = [] + monkeypatch.setattr(mainmod.os, "execvpe", lambda *a: calls.append(a)) + mainmod._reexec(["py", "-m", "graphify", "update", "."], {"PYTHONHASHSEED": "0"}, windows=False) + assert calls == [("py", ["py", "-m", "graphify", "update", "."], {"PYTHONHASHSEED": "0"})] + + +def test_reexec_waits_and_propagates_exit_code_on_windows(monkeypatch): + """#3799: on Windows os.exec* spawns a detached process and the parent + exits at once (sometimes with an access violation), so the caller saw + the command finish before graph.json was written. The Windows branch + must run the child synchronously and exit with the child's status.""" + import subprocess + import graphify.__main__ as mainmod + seen = {} + + class _Done: + returncode = 3 + + def _run(argv, env=None, **kw): + seen["argv"], seen["env"] = argv, env + return _Done() + + monkeypatch.setattr(subprocess, "run", _run) + monkeypatch.setattr(mainmod.os, "execvpe", lambda *a: (_ for _ in ()).throw(AssertionError("execvpe used on Windows"))) + try: + mainmod._reexec(["py", "-m", "graphify", "extract", "."], {"PYTHONHASHSEED": "0"}, windows=True) + except SystemExit as e: + assert e.code == 3 + else: + raise AssertionError("must exit with the child's return code") + assert seen == {"argv": ["py", "-m", "graphify", "extract", "."], "env": {"PYTHONHASHSEED": "0"}}