mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 23:49:14 +00:00
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:
1 parent
45ceb2273a
commit
f3483171b9
2 files changed
+58
-4
No files matched your search
+21
-2
@@ -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
|
||||
|
||||
@@ -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"}}
|
||||
Reference in new issue
Block a user