fix(cli): hard-exit the Windows update hand-off child once work is durable
The re-exec'd venv child spawned by _reexec_dependency_sync_off_windows_shim completes every update step — the receipt records success / "completed at command boundary" — but then hangs in interpreter shutdown on a leftover non-daemon thread, freezing the PowerShell window for minutes after "Update complete!". On the hand-off path only (HERMES_UPDATE_REEXEC=1), after the receipt is finalized, the update lock released, and stdio restored, flush and os._exit(code) instead of unwinding — the same treatment #79040's cron workaround applies. SystemExit codes (including early refusals) propagate to the hard exit; real exceptions keep the normal raise path so tracebacks still print. Non-hand-off invocations are untouched: the marker env is set solely when the shim spawns the child. Fixes #93581
This commit is contained in:
@@ -10378,6 +10378,10 @@ def cmd_update(args):
|
||||
_finalize_update_output(_update_io_state)
|
||||
sys.exit(UPDATE_EXIT_CONCURRENT)
|
||||
|
||||
# Exit code for the Windows hand-off child's hard exit (see finally).
|
||||
# None = not a SystemExit-shaped outcome; real exceptions keep the
|
||||
# normal raise path so their traceback still prints.
|
||||
_update_handoff_exit_code: int | None = None
|
||||
try:
|
||||
_self()._cmd_update_impl(args, gateway_mode=gateway_mode)
|
||||
except SystemExit as _update_exit:
|
||||
@@ -10394,6 +10398,9 @@ def cmd_update(args):
|
||||
finalize_pending_update_receipt(_code, f"sys.exit({_code})")
|
||||
except Exception:
|
||||
pass
|
||||
_update_handoff_exit_code = (
|
||||
_update_exit.code if isinstance(_update_exit.code, int) else 0
|
||||
)
|
||||
raise
|
||||
except BaseException as _update_exc:
|
||||
try:
|
||||
@@ -10412,9 +10419,29 @@ def cmd_update(args):
|
||||
finalize_pending_update_receipt(0, "completed at command boundary")
|
||||
except Exception:
|
||||
pass
|
||||
_update_handoff_exit_code = 0
|
||||
finally:
|
||||
_update_lock.release()
|
||||
_finalize_update_output(_update_io_state)
|
||||
# Windows hand-off child (#93581): the re-exec'd venv child cannot
|
||||
# rely on graceful interpreter shutdown — a leftover non-daemon
|
||||
# thread from the update tail keeps the console busy long after
|
||||
# the receipt is durable (success, exit 0, "completed at command
|
||||
# boundary"), freezing the PowerShell window for minutes. By this
|
||||
# point every durable step is done (receipt finalized above, lock
|
||||
# released, stdio restored), so on the hand-off path only, flush
|
||||
# and exit hard instead of waiting for the interpreter to unwind
|
||||
# — the same treatment #79040's cron workaround applies. No-op on
|
||||
# every non-hand-off invocation: the marker env is set solely by
|
||||
# _reexec_dependency_sync_off_windows_shim when it spawns the child.
|
||||
if _update_handoff_exit_code is not None and os.environ.get(_UPDATE_REEXEC_ENV) == "1":
|
||||
logger.debug(
|
||||
"Update hand-off child %s exiting via os._exit(%s)",
|
||||
os.getpid(), _update_handoff_exit_code,
|
||||
)
|
||||
sys.stdout.flush()
|
||||
sys.stderr.flush()
|
||||
os._exit(_update_handoff_exit_code)
|
||||
|
||||
|
||||
def _coalesce_session_name_args(argv: list) -> list:
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Windows hand-off child must hard-exit once the update is durably done (#93581).
|
||||
|
||||
The re-exec'd venv child (spawned by
|
||||
``_reexec_dependency_sync_off_windows_shim`` with ``HERMES_UPDATE_REEXEC=1``)
|
||||
completes all update work — the receipt records ``success`` / ``completed at
|
||||
command boundary`` — but then hangs in interpreter shutdown on a leftover
|
||||
non-daemon thread, freezing the PowerShell window for minutes. The fix: on
|
||||
the hand-off path only, after the receipt is finalized, the lock released,
|
||||
and stdio restored, flush and ``os._exit(code)`` instead of unwinding.
|
||||
|
||||
These tests pin: the hard exit fires (with the right code) only when the
|
||||
re-exec marker env is set, it happens after lock release + stdio restore,
|
||||
early ``SystemExit`` codes propagate to it, and real exceptions keep the
|
||||
normal raise path (traceback intact, no hard exit).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.main as main_mod
|
||||
from hermes_cli.main import cmd_update
|
||||
|
||||
|
||||
class _FakeLock:
|
||||
def __init__(self, events):
|
||||
self._events = events
|
||||
|
||||
def acquire(self):
|
||||
self._events.append("acquire")
|
||||
return True
|
||||
|
||||
def release(self):
|
||||
self._events.append("release")
|
||||
|
||||
|
||||
# Events from the most recent _run_cmd_update call, also filled in when
|
||||
# cmd_update propagates an exception (the return value is unreachable then).
|
||||
_LAST = {}
|
||||
|
||||
|
||||
def _run_cmd_update(monkeypatch, impl, *, reexec: bool):
|
||||
"""Run cmd_update with everything external mocked; return the events."""
|
||||
events = {"order": [], "exit_codes": [], "receipts": []}
|
||||
|
||||
def fake_impl(args, gateway_mode=False):
|
||||
events["order"].append("impl")
|
||||
impl(args, gateway_mode=gateway_mode)
|
||||
|
||||
def fake_finalize_io(state):
|
||||
events["order"].append("restore-stdio")
|
||||
|
||||
def fake_receipt(code, reason):
|
||||
events["receipts"].append((code, reason))
|
||||
|
||||
def fake_exit(code):
|
||||
events["order"].append("hard-exit")
|
||||
events["exit_codes"].append(code)
|
||||
|
||||
monkeypatch.setattr("hermes_cli.config.is_managed", lambda: False)
|
||||
monkeypatch.setattr("hermes_cli.config.detect_install_method", lambda root: "git")
|
||||
monkeypatch.setattr("hermes_cli.update_lock.UpdateLock", lambda: _FakeLock(events["order"]))
|
||||
monkeypatch.setattr(main_mod, "_cmd_update_impl", fake_impl)
|
||||
monkeypatch.setattr(main_mod, "_install_hangup_protection", lambda gateway_mode=False: None)
|
||||
monkeypatch.setattr(main_mod, "_finalize_update_output", fake_finalize_io)
|
||||
monkeypatch.setattr("hermes_cli.update_receipt.finalize_pending_update_receipt", fake_receipt)
|
||||
monkeypatch.setattr("os._exit", fake_exit)
|
||||
if reexec:
|
||||
monkeypatch.setenv("HERMES_UPDATE_REEXEC", "1")
|
||||
else:
|
||||
monkeypatch.delenv("HERMES_UPDATE_REEXEC", raising=False)
|
||||
|
||||
args = SimpleNamespace(plan=False, check=False, gateway=False, branch=None)
|
||||
try:
|
||||
cmd_update(args)
|
||||
finally:
|
||||
_LAST.clear()
|
||||
_LAST.update(events)
|
||||
return events
|
||||
|
||||
|
||||
def _noop_impl(args, gateway_mode=False):
|
||||
return None
|
||||
|
||||
|
||||
def test_handoff_child_hard_exits_zero_after_success(monkeypatch):
|
||||
events = _run_cmd_update(monkeypatch, _noop_impl, reexec=True)
|
||||
assert events["exit_codes"] == [0]
|
||||
assert events["receipts"] == [(0, "completed at command boundary")]
|
||||
# The hard exit is the last thing, after lock release and stdio restore.
|
||||
assert events["order"] == ["acquire", "impl", "release", "restore-stdio", "hard-exit"]
|
||||
|
||||
|
||||
def test_non_handoff_run_never_hard_exits(monkeypatch):
|
||||
events = _run_cmd_update(monkeypatch, _noop_impl, reexec=False)
|
||||
assert events["exit_codes"] == []
|
||||
assert "hard-exit" not in events["order"]
|
||||
assert events["receipts"] == [(0, "completed at command boundary")]
|
||||
|
||||
|
||||
def test_handoff_child_propagates_early_systemexit_code(monkeypatch):
|
||||
def early_refusal(args, gateway_mode=False):
|
||||
raise SystemExit(3)
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
_run_cmd_update(monkeypatch, early_refusal, reexec=True)
|
||||
assert excinfo.value.code == 3
|
||||
# The finally-block hard exit ran (before the re-raise propagated)
|
||||
# and carried the early exit's code, not a blanket 0.
|
||||
assert _LAST["exit_codes"] == [3]
|
||||
|
||||
|
||||
def test_handoff_child_systemexit_none_means_zero(monkeypatch):
|
||||
def bare_exit(args, gateway_mode=False):
|
||||
raise SystemExit(None)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
_run_cmd_update(monkeypatch, bare_exit, reexec=True)
|
||||
assert _LAST["exit_codes"] == [0]
|
||||
|
||||
|
||||
def test_unhandled_exception_keeps_raise_path_no_hard_exit(monkeypatch):
|
||||
def boom(args, gateway_mode=False):
|
||||
raise RuntimeError("update tail exploded")
|
||||
|
||||
# With os._exit patched to record, the re-raised RuntimeError reaches
|
||||
# pytest with the finally block already run — and no hard exit fires.
|
||||
with pytest.raises(RuntimeError, match="update tail exploded"):
|
||||
_run_cmd_update(monkeypatch, boom, reexec=True)
|
||||
assert "hard-exit" not in _LAST["order"]
|
||||
assert _LAST["receipts"] == [(1, "RuntimeError: update tail exploded")]
|
||||
Reference in New Issue
Block a user