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"}}