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) <noreply@anthropic.com>
(cherry picked from commit 94a9657e93)
This commit is contained in:
Sinan Gümüşkabak authored and safishamsi committed 2026-09-25 15:44:21 +01:00
1 parent 45ceb2273a
commit f3483171b9
2 files changed
+58 -4

No files matched your search

+21 -2
View File
@@ -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 <that .exe path>` 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
+37 -2
View File
@@ -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"}}