Files
hermes-agent/tests/hermes_cli/test_update_completion_process.py
ethernet a60bc9cc64 fix(update): completion tail skips the fleet restart it does not owe
Every update route now finishes through update_completion._complete_selected,
which restarted the whole fleet unconditionally -- including the "Already up to
date" route that main sent through the pending-restart catch-up. Net effect:
each cron tick and each profile's `hermes update` drained and re-killed the one
multiplexed gateway.

Port the catch-up path's two live guards into the completion tail:
- host_restart_already_completed(checkout sha): a sibling profile attaches to
  the restart this host already stamped (#95294); the restart phase now stamps
  it via mark_host_restart_completed.
- every planned runtime AND every live fleet row current at the checkout sha
  (#117051, d6b0d37ece). Both are required: the live matrix lists gateways
  only, so a planned serve still on pre-update code keeps the restart.

The already-current route also arms the host obligation with the checkout sha;
an SHA-less arm replaced the standing record and wiped the restarted proof.
2026-09-21 19:03:35 -04:00

624 lines
28 KiB
Python

"""A checkout transition must not finish in the old interpreter's module graph."""
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import venv
import pytest
from hermes_cli import update_completion
@pytest.fixture
def transition(tmp_path):
root = tmp_path / "checkout"
root.mkdir()
home = tmp_path / "home"
home.mkdir()
package = root / "hermes_cli"
package.mkdir()
(package / "__init__.py").write_text("")
pm_package = root / "pm"
pm_package.mkdir()
(pm_package / "__init__.py").write_text("OLD_API = True\n")
def git(*args):
return subprocess.run(["git", *args], cwd=root, text=True, capture_output=True, check=True).stdout.strip()
git("init", "-b", "main")
git("config", "user.name", "Completion test")
git("config", "user.email", "completion@example.invalid")
git("add", ".")
git("-c", "commit.gpgsign=false", "commit", "-m", "old incompatible runtime")
old = git("rev-parse", "HEAD")
# Deliberately incompatible: a cached OLD_API-only PM cannot prepare this tree.
(root / "pm/__init__.py").write_text(
"from hermes_cli.probe import event\n"
"def sync_venv(*, explicit, project_root):\n"
" assert explicit\n"
" event('prepare')\n"
)
(root / "pm/receipt.py").write_text(
"from contextlib import nullcontext\n"
"worker_context = lambda update_id: nullcontext()\n"
"last_for_update = lambda update_id: {'update_id': update_id, 'outcome': 'success'}\n"
"def accept_worker_receipt(data, update_id):\n"
" assert data['update_id'] == update_id\n"
)
(package / "probe.py").write_text(
"import json, os, pathlib, sys\n"
"def event(name, **values):\n"
" with pathlib.Path('events.jsonl').open('a') as f:\n"
" f.write(json.dumps(dict(name=name, pid=os.getpid(), python=sys.executable, **values)) + '\\n')\n"
)
selected = tmp_path / "selected-python"
venv.EnvBuilder(with_pip=False).create(selected)
selected_python = selected / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
(pm_package / "environments.py").write_text(
"import os, sys\n"
"from pathlib import Path\n"
"selected_venv = lambda root: Path(sys.executable).parent.parent\n"
f"project_python = lambda root: Path({str(selected_python)!r})\n"
"activation_environment = lambda root: {**os.environ, 'PYTHONPATH': str(root)}\n"
"def activate_dependencies(root):\n"
" from hermes_cli.probe import event\n"
" event('activate')\n"
)
(root / "hermes_constants.py").write_text("")
(package / "venv_sync.py").write_text(
"from hermes_cli.probe import event\n"
"publish_launchers = lambda root: event('launchers')\n"
)
(package / "source_build.py").write_text(
"from hermes_cli.probe import event\n"
"def build_update_products(root, *, desktop): event('build', desktop=desktop)\n"
)
# The shared completion tail is part of the NEW tree the child runs from.
shutil.copy2(Path(update_completion.__file__).with_name("source_completion.py"),
package / "source_completion.py")
(package / "main.py").write_text("")
(package / "update_cmd_config.py").write_text("_LAST_SIBLING_SNAPSHOTS = {}\n")
(package / "update_inventory.py").write_text(
"from types import SimpleNamespace\nRuntimeRecord = UpdatePlan = SimpleNamespace\n"
)
(package / "update_cmd_maint.py").write_text(
"from hermes_cli.probe import event\n"
"def _run_post_update_maintenance(**kwargs):\n"
" from hermes_cli.update_cmd_config import _LAST_SIBLING_SNAPSHOTS\n"
" event('maintenance', snapshots=_LAST_SIBLING_SNAPSHOTS, **kwargs)\n"
" return True\n"
)
(package / "update_cmd.py").write_text(
"from hermes_cli.probe import event\n"
"_invalidate_update_cache = lambda: event('cache')\n"
"_sweep_bytecode_after_update = lambda branch: event('bytecode')\n"
"_write_fleet_restart_pending_marker = lambda **kw: event('pending')\n"
"_write_gateway_update_exit_code = lambda ok: event('exit_marker', ok=ok)\n"
"_fleet_restart_skip_reason = lambda plan: None\n"
"def _restart_gateway_fleet_after_update(plan, gateway_mode):\n"
" event('restart', profiles=[r.profile for r in plan.runtimes])\n"
" return object()\n"
"def _resume_windows_gateways_and_merge_outcome(out, token, gateway_mode):\n"
" token['resume_needed'] = False\n"
" event('resume')\n"
"def _resume_windows_gateways_after_update(token):\n"
" if token and token.get('resume_needed'):\n"
" token['resume_needed'] = False\n"
" event('emergency_resume')\n"
"def _verify_fleet_after_update(out, **kw):\n"
" from hermes_cli.update_receipt import finalize_pending_update_receipt\n"
" event('verify')\n"
" finalize_pending_update_receipt(0, 'verified')\n"
)
(package / "update_receipt.py").write_text(
"import contextvars, json, os, pathlib\n"
"_current = contextvars.ContextVar('receipt', default=None)\n"
"class UpdateReceipt: pass\n"
"def finalize_pending_update_receipt(code, reason):\n"
" r = _current.get()\n"
" if r is None: return\n"
" r.data.update(exit_code=code, outcome='success' if code == 0 else 'failed', finished_at='now')\n"
" path = pathlib.Path(os.environ['HERMES_HOME']) / 'logs/update_receipts'\n"
" path.mkdir(parents=True, exist_ok=True)\n"
" path = path / ('update_test_' + r.correlation_id + '.json')\n"
" path.write_text(json.dumps(r.data, ensure_ascii=False), encoding=r.data.get('encoding', 'utf-8'))\n"
" _current.set(None)\n"
" return path\n"
)
git("add", ".")
git("-c", "commit.gpgsign=false", "commit", "-m", "new incompatible runtime")
new = git("rev-parse", "HEAD")
request = {
"schema": 1, "source": str(root), "home": str(home), "branch": "main",
"desktop": True, "assume_yes": True, "gateway_mode": True,
"pre_update_version": "old", "snapshot_id": "active-before",
"sibling_snapshots": {"work": "work-before"},
"plan": {"runtimes": [{"kind": "gateway", "profile": "work"}]},
"receipt": {"update_id": "b" * 32, "outcome": "running", "steps": []},
"windows_resume": {"resume_needed": True, "profiles": {"work": [123]}},
}
return root, git, old, new, request
@pytest.mark.parametrize("bom_boundary", [None, "request", "receipt"])
def test_old_process_new_git_tree_completes_in_fresh_python(transition, tmp_path, bom_boundary):
from hermes_cli import update_completion
root, git, old, new, request = transition
request["pre_update_version"] = "日本 café"
if bom_boundary == "receipt":
request["receipt"]["encoding"] = "utf-8-sig"
# Copy executable code, not its text shape: the process exercises the real transport.
shutil.copy2(update_completion.__file__, root / "hermes_cli/update_completion.py")
git("add", ".")
git("-c", "commit.gpgsign=false", "commit", "-m", "completion entrypoint")
new = git("rev-parse", "HEAD")
git("checkout", old)
driver = (
"import importlib.util, json, os, subprocess, sys\n"
"spec = importlib.util.spec_from_file_location('transport', sys.argv[1])\n"
"transport = importlib.util.module_from_spec(spec); spec.loader.exec_module(transport)\n"
f"if {bom_boundary == 'request'!r}:\n"
" transport._write_json = lambda path, data: path.write_text(json.dumps(data, ensure_ascii=False), encoding='utf-8-sig')\n"
"import pm\nassert pm.OLD_API\n"
"subprocess.run(['git', 'checkout', sys.argv[2]], check=True)\n"
"result = transport.run_completion(json.loads(sys.argv[3]))\n"
"assert pm.OLD_API, 'transport mutated old module graph'\n"
"print('RESULT=' + json.dumps(result))\n"
)
result = subprocess.run(
[sys.executable, "-c", driver, update_completion.__file__, new, json.dumps(request)],
cwd=root, env={**os.environ, "PYTHONPATH": str(root), "HERMES_HOME": request["home"]},
capture_output=True, text=True, timeout=30,
)
assert result.returncode == 0, result.stdout + result.stderr
response = json.loads(result.stdout.split("RESULT=")[1])
assert response["exit_code"] == 0
assert response["receipt"]["update_id"] == request["receipt"]["update_id"]
assert response["windows_resume"]["resume_needed"] is False
events = [json.loads(line) for line in (root / "events.jsonl").read_text().splitlines()]
by_name = {event["name"]: event for event in events}
assert by_name["activate"]["pid"] == by_name["build"]["pid"]
assert by_name["prepare"]["pid"] != by_name["build"]["pid"]
assert Path(by_name["build"]["python"]).is_relative_to(root.parent / "selected-python")
assert by_name["build"]["pid"] == by_name["maintenance"]["pid"] == by_name["restart"]["pid"]
assert by_name["maintenance"]["snapshots"] == {"work": "work-before"}
assert by_name["maintenance"]["pre_update_snapshot_id"] == "active-before"
assert by_name["maintenance"]["pre_update_version"] == "日本 café"
assert by_name["restart"]["profiles"] == ["work"]
assert [e["name"] for e in events].index("exit_marker") < [e["name"] for e in events].index("restart")
@pytest.mark.parametrize("code", [0, 23])
def test_missing_child_result_fails_boundary_receipt_and_releases_lock(transition, monkeypatch, code):
from types import SimpleNamespace
from hermes_cli import main, update_cmd, update_receipt, update_lock
root, git, old, new, request = transition
(root / "hermes_cli/update_completion.py").write_text(f"import os\nos._exit({code})\n")
monkeypatch.setenv("HERMES_HOME", request["home"])
monkeypatch.setattr(main, "_update_preflight_handled", lambda args: False)
monkeypatch.setattr(main, "_install_hangup_protection", lambda **kw: None)
monkeypatch.setattr(main, "_finalize_update_output", lambda state: None)
def complete(args, gateway_mode):
update_receipt.begin_update_receipt()
request["receipt"] = update_receipt._current.get().data
update_cmd._complete_source_update(request)
monkeypatch.setattr(update_cmd, "_cmd_update_impl", complete)
with pytest.raises(SystemExit) as error:
main.cmd_update(SimpleNamespace(gateway=True))
assert error.value.code == (code or 1)
receipt = update_receipt.read_latest_receipt()
assert receipt["outcome"] == "failed"
assert receipt["exit_code"] == (code or 1)
assert receipt["update_id"] == request["receipt"]["update_id"]
assert request["windows_resume"]["resume_needed"] is True
assert (Path(request["home"]) / ".update_exit_code").read_text().strip() == "1"
lock = update_lock.UpdateLock()
assert lock.acquire()
lock.release()
@pytest.mark.parametrize("cleanup_failure", [None, "kill", "wait"])
def test_interrupt_after_child_success_demotes_gateway_marker_at_boundary(transition, monkeypatch, cleanup_failure):
import io
from types import SimpleNamespace
from hermes_cli import main, update_cmd, update_lock, update_receipt
root, git, old, new, request = transition
marker = Path(request["home"]) / ".update_exit_code"
(root / "hermes_cli/update_completion.py").write_text(
"import os, pathlib, time\n"
"(pathlib.Path(os.environ['HERMES_HOME']) / '.update_exit_code').write_text('0\\n')\n"
"print('SUCCESS_PUBLISHED', flush=True)\n"
"time.sleep(30)\n"
)
monkeypatch.setenv("HERMES_HOME", request["home"])
monkeypatch.setattr(main, "_update_preflight_handled", lambda args: False)
monkeypatch.setattr(main, "_install_hangup_protection", lambda **kw: None)
monkeypatch.setattr(main, "_finalize_update_output", lambda state: None)
interrupted = False
cleanup_error = (OSError("retained-handle kill failed") if cleanup_failure == "kill"
else subprocess.TimeoutExpired("completion", 5))
waits = []
children = []
popen = subprocess.Popen
def capture_child(*args, **kwargs):
proc = popen(*args, **kwargs)
if not children:
children.append(proc)
wait = proc.wait
kill = proc.kill
def cleanup_kill():
kill()
if cleanup_failure == "kill":
raise cleanup_error
def cleanup_wait(timeout=None):
waits.append(timeout)
# Inject faults after real cleanup so a regression cannot leak the child.
result = wait(timeout=5)
if cleanup_failure == "wait":
raise cleanup_error
return result
monkeypatch.setattr(proc, "kill", cleanup_kill)
monkeypatch.setattr(proc, "wait", cleanup_wait)
return proc
class Interrupt(io.StringIO):
def write(self, value):
nonlocal interrupted
result = super().write(value)
if not interrupted and "SUCCESS_PUBLISHED" in self.getvalue():
assert marker.read_text().strip() == "0"
interrupted = True
raise KeyboardInterrupt()
return result
def complete(args, gateway_mode):
update_receipt.begin_update_receipt()
request["receipt"] = update_receipt._current.get().data
monkeypatch.setattr(subprocess, "Popen", capture_child)
update_cmd._complete_source_update(request)
monkeypatch.setattr(sys, "stdout", Interrupt())
monkeypatch.setattr(update_cmd, "_cmd_update_impl", complete)
with pytest.raises(KeyboardInterrupt) as error:
main.cmd_update(SimpleNamespace(gateway=True))
assert interrupted, "child never published success before cancellation"
assert children[0].poll() is not None
assert children[0].stdout.closed
assert waits and all(timeout is not None and timeout > 0 for timeout in waits)
if cleanup_failure:
assert error.value.__cause__ is cleanup_error
receipt = update_receipt.read_latest_receipt()
assert receipt["update_id"] == request["receipt"]["update_id"]
assert receipt["outcome"] == "failed"
assert receipt["exit_code"] == 1
assert receipt["stop_reason"].startswith("KeyboardInterrupt:")
assert marker.read_text().strip() == "1"
lock = update_lock.UpdateLock()
assert lock.acquire()
lock.release()
@pytest.mark.platforms("posix")
def test_killed_selected_python_returns_signal_exit_status(transition):
from hermes_cli import update_completion
root, git, old, new, request = transition
shutil.copy2(update_completion.__file__, root / "hermes_cli/update_completion.py")
(root / "hermes_cli/source_build.py").write_text(
"import os, signal\n"
"def build_update_products(*a, **kw): os.kill(os.getpid(), signal.SIGKILL)\n"
)
result = update_completion.run_completion(request)
assert result["exit_code"] == 137
assert result["pm_receipt"]["update_id"] == request["receipt"]["update_id"]
def test_failed_build_preserves_exit_status_without_maintenance(transition):
from hermes_cli import update_completion
root, git, old, new, request = transition
shutil.copy2(update_completion.__file__, root / "hermes_cli/update_completion.py")
(root / "hermes_cli/source_build.py").write_text(
"import subprocess\n"
"def build_update_products(*a, **kw): raise subprocess.CalledProcessError(23, ['builder'])\n"
)
result = update_completion.run_completion(request)
assert result["exit_code"] == 23
assert result["receipt"]["outcome"] == "failed"
events = [json.loads(line)["name"] for line in (root / "events.jsonl").read_text().splitlines()]
assert "maintenance" not in events
assert "restart" not in events
assert "emergency_resume" in events
def test_prepare_failure_preserves_correlated_pm_receipt(transition, monkeypatch):
from types import SimpleNamespace
from hermes_cli import main, update_cmd, update_completion, update_receipt
root, git, old, new, request = transition
shutil.copy2(update_completion.__file__, root / "hermes_cli/update_completion.py")
(root / "pm/__init__.py").write_text(
"def sync_venv(**kw): raise RuntimeError('dependency refused')\n"
)
with (root / "pm/receipt.py").open("a") as stream:
stream.write("last_for_update = lambda update_id: {'update_id': update_id, 'outcome': 'refused', 'refusal': {'reason': 'dependency refused'}}\n")
monkeypatch.setenv("HERMES_HOME", request["home"])
monkeypatch.setattr(main, "_update_preflight_handled", lambda args: False)
monkeypatch.setattr(main, "_install_hangup_protection", lambda **kw: None)
monkeypatch.setattr(main, "_finalize_update_output", lambda state: None)
def complete(args, gateway_mode):
update_receipt.begin_update_receipt()
request["receipt"] = update_receipt._current.get().data
update_cmd._complete_source_update(request)
monkeypatch.setattr(update_cmd, "_cmd_update_impl", complete)
with pytest.raises(SystemExit) as error:
main.cmd_update(SimpleNamespace(gateway=True))
assert error.value.code == 1
receipt = update_receipt.read_latest_receipt()
assert receipt["update_id"] == request["receipt"]["update_id"]
assert receipt["pm_sync_outcome"] == "refused"
assert receipt["pm_refusal"] == {"reason": "dependency refused"}
def test_bootstrap_does_not_initialize_old_site_packages(transition, tmp_path, monkeypatch):
from hermes_cli import update_completion
root, git, old, new, request = transition
shutil.copy2(update_completion.__file__, root / "hermes_cli/update_completion.py")
obsolete = tmp_path / "obsolete-python"
venv.EnvBuilder(with_pip=False).create(obsolete)
site = obsolete / ("Lib/site-packages" if os.name == "nt" else
f"lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages")
trap = tmp_path / "old-site-loaded"
(site / "application.pth").write_text(f"import pathlib; pathlib.Path({str(trap)!r}).touch()\n")
monkeypatch.setattr(sys, "executable", str(obsolete / ("Scripts/python.exe" if os.name == "nt" else "bin/python")))
result = update_completion.run_completion(request)
assert result["exit_code"] == 0
assert not trap.exists(), "preparation initialized the old application's .pth graph"
@pytest.mark.parametrize("stage", ["prepare", "selected"])
def test_progress_is_forwarded_before_held_stage_is_released(transition, monkeypatch, stage):
import io
import threading
from hermes_cli import update_completion
root, git, old, new, request = transition
shutil.copy2(update_completion.__file__, root / "hermes_cli/update_completion.py")
release = root / "release"
released = root / "released"
held_body = (
" print('STAGE_READY')\n" # Deliberately no flush: -I ignores PYTHONUNBUFFERED.
" deadline = time.monotonic() + 20\n"
" while not Path('release').exists():\n"
" if time.monotonic() >= deadline: raise TimeoutError('stage was not released')\n"
" time.sleep(0.02)\n"
" Path('released').touch()\n"
)
module, definition = (
("pm/__init__.py", "def sync_venv(**kw):\n") if stage == "prepare" else
("hermes_cli/source_build.py", "def build_update_products(*a, **kw):\n")
)
(root / module).write_text("import time\nfrom pathlib import Path\n" + definition + held_body)
observed = threading.Event()
class Output(io.StringIO):
def write(self, value):
result = super().write(value)
if "STAGE_READY" in self.getvalue():
observed.set()
return result
output = Output()
monkeypatch.setattr(sys, "stdout", output)
responses, errors = [], []
def run():
try:
responses.append(update_completion.run_completion(request))
except BaseException as exc:
errors.append(exc)
worker = threading.Thread(target=run, daemon=True)
worker.start()
try:
assert observed.wait(10), f"{stage} progress remained buffered: {output.getvalue()}"
assert worker.is_alive(), "completion finished instead of waiting for release"
assert not released.exists(), "stage passed its gate before progress was observed"
finally:
release.touch()
worker.join(timeout=30)
assert not worker.is_alive(), "completion did not stop after release"
assert not errors
assert released.exists()
assert responses[0]["exit_code"] == 0
@pytest.mark.platforms("posix")
def test_interactive_configuration_keeps_terminal_input(transition):
import pty
import select
import signal
import time
from hermes_cli import update_completion
root, git, old, new, request = transition
shutil.copy2(update_completion.__file__, root / "hermes_cli/update_completion.py")
(root / "hermes_cli/update_cmd_maint.py").write_text(
"import sys\nfrom hermes_cli.probe import event\n"
"def _run_post_update_maintenance(**kw):\n"
" assert sys.stdin.isatty() and sys.stdout.isatty()\n"
" event('answer', value=input('CONFIG? '))\n"
" return True\n"
)
master, slave = pty.openpty()
driver = "import json,runpy,sys; m=runpy.run_path(sys.argv[1]); raise SystemExit(m['run_completion'](json.loads(sys.argv[2]))['exit_code'])"
proc = subprocess.Popen([sys.executable, "-c", driver, update_completion.__file__, json.dumps(request)],
cwd=root, stdin=slave, stdout=slave, stderr=slave)
os.close(slave)
output = b""
try:
deadline = time.monotonic() + 20
while b"CONFIG?" not in output:
assert time.monotonic() < deadline, output.decode(errors="replace")
if select.select([master], [], [], 0.1)[0]:
output += os.read(master, 8192)
os.write(master, b"yes\n")
assert proc.wait(timeout=20) == 0
events = [json.loads(line) for line in (root / "events.jsonl").read_text().splitlines()]
assert next(e for e in events if e["name"] == "answer")["value"] == "yes"
finally:
if proc.poll() is None:
proc.send_signal(signal.SIGINT)
proc.wait(timeout=5)
os.close(master)
@pytest.mark.platforms("posix")
@pytest.mark.live_system_guard_bypass
def test_interrupt_reaps_completion_descendants_before_return(transition, monkeypatch):
import io
import psutil
import time
from hermes_cli import update_completion
root, git, old, new, request = transition
(root / "hermes_cli/update_completion.py").write_text(
"import subprocess, sys, time\n"
"child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(600)'])\n"
"print('READY ' + str(child.pid), flush=True)\n"
"time.sleep(600)\n"
)
pids = []
class Interrupt(io.StringIO):
def write(self, value):
if 'READY ' in value:
pids.append(int(value.split('READY ')[1].strip()))
raise KeyboardInterrupt()
return super().write(value)
monkeypatch.setattr(sys, "stdout", Interrupt())
try:
with pytest.raises(KeyboardInterrupt):
update_completion.run_completion(request)
assert pids
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
if not psutil.pid_exists(pids[0]) or psutil.Process(pids[0]).status() == psutil.STATUS_ZOMBIE:
break
time.sleep(0.02)
else:
pytest.fail("completion descendant survived parent cancellation")
finally:
for pid in pids:
if psutil.pid_exists(pid):
psutil.Process(pid).kill()
@pytest.mark.platforms("windows")
@pytest.mark.parametrize("failure", ["launch", "timeout", "nonzero"])
def test_taskkill_failure_still_reaps_child_and_preserves_interrupt(tmp_path, monkeypatch, failure):
"""Only native Windows exercises taskkill dispatch and retained-handle kill."""
import io
from hermes_cli import update_completion
package = tmp_path / "hermes_cli"
package.mkdir()
(package / "update_completion.py").write_text(
"import time\nprint('READY', flush=True)\ntime.sleep(60)\n"
)
request = {"source": str(tmp_path), "home": str(tmp_path), "receipt": {"update_id": "test"}}
interrupted = KeyboardInterrupt("cancel completion")
child = None
waits = []
popen, run = subprocess.Popen, subprocess.run
def capture_child(*args, **kwargs):
nonlocal child
proc = popen(*args, **kwargs)
# The nonzero fault uses a real command, but it is not the completion child.
if child is None:
child = proc
wait = proc.wait
def bounded_wait(timeout=None):
waits.append(timeout)
return wait(timeout=timeout)
monkeypatch.setattr(proc, "wait", bounded_wait)
return proc
def failed_taskkill(command, **kwargs):
assert child is not None
assert command == ["taskkill", "/T", "/F", "/PID", str(child.pid)]
if failure == "launch":
raise FileNotFoundError("taskkill unavailable")
if failure == "timeout":
raise subprocess.TimeoutExpired(command, kwargs["timeout"])
# Keep subprocess.run's real nonzero/check behavior, without killing a tree.
return run([sys.executable, "-c", "raise SystemExit(9)"], **kwargs)
class Interrupt(io.StringIO):
def write(self, value):
result = super().write(value)
if "READY" in self.getvalue():
raise interrupted
return result
monkeypatch.setattr(subprocess, "Popen", capture_child)
monkeypatch.setattr(subprocess, "run", failed_taskkill)
monkeypatch.setattr(sys, "stdout", Interrupt())
try:
with pytest.raises(KeyboardInterrupt) as error:
update_completion.run_completion(request)
assert error.value is interrupted
expected = {"launch": FileNotFoundError, "timeout": subprocess.TimeoutExpired,
"nonzero": subprocess.CalledProcessError}[failure]
assert isinstance(error.value.__cause__, expected)
assert child is not None
assert child.poll() is not None, "retained completion child survived failed taskkill"
assert child.stdout.closed
assert waits and all(timeout is not None and timeout > 0 for timeout in waits)
finally:
# Only this disposable child is ours; no service or process scan is involved.
if child is not None:
child.kill()
child.wait(timeout=5)
@pytest.mark.parametrize("encoding", ["utf-8", "utf-8-sig"])
@pytest.mark.parametrize("correlated", [False, True])
def test_only_correlated_terminal_receipt_can_acknowledge_success(transition, encoding, correlated):
from hermes_cli.update_completion import run_completion
root, git, old, new, request = transition
receipt = {"update_id": request["receipt"]["update_id"] if correlated else "wrong",
"outcome": "success", "finished_at": "now", "detail": "日本 café"}
(root / "hermes_cli/update_completion.py").write_text(
"import json, pathlib, sys\n"
"request = json.loads(pathlib.Path(sys.argv[1]).read_text())\n"
"pathlib.Path(sys.argv[2]).write_text(json.dumps(dict(\n"
" schema=1, update_id=request['receipt']['update_id'], exit_code=0,\n"
f" windows_resume={{}}, receipt={receipt!r}), ensure_ascii=False), encoding={encoding!r})\n",
encoding="utf-8",
)
response = run_completion(request)
assert response["exit_code"] == (0 if correlated else 1)
assert response["receipt"] == (receipt if correlated else None)