Files
hermes-agent/tests/tools/test_process_registry_completion_races.py
Mark S. 88c6085846 fix(process): a kill's result stands on every backend, and a failed scope stop never counts as stopped (#133648)
* fix(process): a kill's result stands on every backend, and a failed scope stop never counts as stopped

Follow-up to #133434, which fixed these races for local processes only.

Non-local backends (spawn_via_env) settle through _env_poller_loop, which
still wrote the session's final state without the session lock:

- It set exit_code before _finish_exited, so a kill that landed first ended
  as killed with the sandbox's exit code. The exit now goes through
  _finish_exited alone, and a committed kill keeps its code.
- Its backend-lost path set exited/-1/lost directly, replacing a recorded
  kill. It now goes through _finish_exited(reason="lost"), so the kill stands
  and the completion is still published by its owner.
- A log delta (or a log-rotation reset) fetched before a kill was applied
  after it, so the session's output no longer matched the kill's snapshot.
  Both are now dropped once the session has exited, decided under the lock.

A failed systemd scope stop could still count as a successful stop:

- A recovered PID that died between get() and _signal_kill ignored the stop
  result and returned a plain already_exited. It now reports
  scope_stop_failed.
- kill_all counted already_exited as stopped even when the scope stop
  failed, and only looked at _running, so a session that exited after being
  picked kept a live scope that no later sweep retried. Every kill path now
  stops the scope through _stop_scope, which marks a failed stop pending;
  kill_all does not count a result with scope_stop_failed and also retries
  finished sessions whose stop is pending.

_log_delta_command moves unchanged into tools/process_registry_env_log.py
(ProcessRegistry._log_delta_command stays as an alias) to keep
process_registry.py under its size cap.

Refs #132358. Both gaps were found by @andrexibiza in the review of #133434.

* fix(process): pruning keeps a session whose scope stop is pending

kill_all retries a failed scope stop through the finished session, but
_prune_if_needed still evicted that session by TTL (measured from process
start, so a long-running process was prunable the moment it finished) or
by capacity, leaving a possibly live scope with no retry target. Sessions
with _scope_stop_pending are now exempt from both until the stop succeeds;
capacity pruning drops the oldest prunable session instead.

Found by the independent review of #133648.
2026-10-06 00:00:57 -04:00

443 lines
18 KiB
Python

"""A process's completion is published once, by its owner, and only with its final output.
Child exit, result finalization, notice publication and output consumption are separate steps.
Each test forces one interleaving where another caller treated an earlier step as the last one.
"""
import shlex
import sys
import threading
import time
from types import SimpleNamespace
from typing import Any, cast
import pytest
import tools.process_registry as module
from tools.process_registry import ProcessRegistry, ProcessSession
def _session(sid: str, **fields) -> ProcessSession:
return ProcessSession(id=sid, command="cmd", task_id="task", started_at=time.time(), **fields)
def test_duplicate_finisher_leaves_completion_to_the_owner(monkeypatch):
"""A second finisher must not release waiters while the first is still publishing the notice."""
registry = ProcessRegistry()
session = _session("proc_dup_finish", notify_on_complete=True)
registry._running[session.id] = session
entered, release = threading.Event(), threading.Event()
calls = []
def checkpoint(*_args, **_kwargs):
calls.append(None)
if len(calls) == 1: # the owner, between the registry move and the notice
entered.set()
release.wait(10)
monkeypatch.setattr(registry, "_write_checkpoint", checkpoint)
owner = threading.Thread(target=registry._move_to_finished, args=(session,), daemon=True)
owner.start()
try:
assert entered.wait(5)
assert registry._move_to_finished(session) is False
assert not session._completion_event.is_set(), "duplicate released waiters before the notice"
assert registry.completion_queue.empty()
finally:
release.set()
owner.join(5)
assert session._completion_event.is_set()
assert registry.completion_queue.qsize() == 1
def test_owner_that_fails_mid_publish_still_releases_waiters(monkeypatch):
registry = ProcessRegistry()
session = _session("proc_owner_fails", notify_on_complete=True)
registry._running[session.id] = session
def checkpoint(*_args, **_kwargs):
raise RuntimeError("checkpoint write failed")
monkeypatch.setattr(registry, "_write_checkpoint", checkpoint)
with pytest.raises(RuntimeError):
registry._move_to_finished(session)
assert session._completion_event.is_set()
def test_reader_exit_cannot_overwrite_a_committed_kill(monkeypatch):
"""The reader records its exit under the session lock, so it sees a kill committed under it."""
registry = ProcessRegistry()
session = _session("proc_kill_vs_reader")
registry._running[session.id] = session
monkeypatch.setattr(registry, "_write_checkpoint", lambda *_a, **_k: None)
reader = threading.Thread(target=registry._finish_exited, args=(session, 0), daemon=True)
with session._lock: # kill_process commits its result under this lock
reader.start()
time.sleep(0.2)
recorded_early = session.exited
session.exited = True
session.exit_code = -15
session.completion_reason = "killed"
session.termination_source = "process.kill"
reader.join(5)
assert not recorded_early, "reader recorded its exit without the session lock"
assert (session.exit_code, session.completion_reason, session.termination_source) == (
-15, "killed", "process.kill")
def _finalizing_session(registry: ProcessRegistry) -> ProcessSession:
"""Direct child exited and a reconcile flipped ``exited``; the reader is still on its final drain."""
session = _session("proc_finalizing", notify_on_complete=True)
session.process = cast(Any, SimpleNamespace(poll=lambda: 0, stdout=None, stderr=None, stdin=None))
session._reader_selectable = True
session._reader_thread = cast(Any, SimpleNamespace(is_alive=lambda: True))
session._reader_finish_requested.set()
session.mark_exited(0)
session.append_output("partial")
registry._running[session.id] = session
return session
def test_status_reads_do_not_consume_a_completion_still_being_finalized():
registry = ProcessRegistry()
session = _finalizing_session(registry)
assert registry.poll(session.id)["status"] == "exited"
registry.read_log(session.id)
assert session.id not in registry._poll_observed
assert session.id not in registry._completion_consumed
class _ReleaseHookLock:
"""Session lock that runs a hook once, right after its first release."""
def __init__(self, hook):
self._lock, self._hook = threading.Lock(), hook
def acquire(self, *args, **kwargs):
return self._lock.acquire(*args, **kwargs)
__enter__ = acquire
def release(self):
self._lock.release()
hook, self._hook = self._hook, None
if hook:
hook()
def __exit__(self, *_exc):
self.release()
@pytest.mark.parametrize("read", [
lambda registry, sid: registry.poll(sid),
lambda registry, sid: registry.read_log(sid),
lambda registry, sid: registry.kill_process(sid),
], ids=["poll", "read_log", "kill_exited"])
def test_a_snapshot_taken_before_the_reader_finishes_does_not_consume_it(read, monkeypatch):
"""The reader finishes between the snapshot and the consume decision: the decision must
describe the snapshot returned, so the completion still delivers the whole output."""
registry = ProcessRegistry()
monkeypatch.setattr(registry, "_write_checkpoint", lambda *_a, **_k: None)
session = _finalizing_session(registry)
def reader_finishes():
session.append_output("FINAL")
registry._finish_exited(session, 0)
session._lock = cast(Any, _ReleaseHookLock(reader_finishes))
read(registry, session.id)
assert session._completion_event.is_set()
assert session.id not in registry._poll_observed
assert not registry.is_completion_consumed(session.id)
assert registry.completion_queue.get_nowait()["output"].endswith("FINAL")
@pytest.mark.platforms("posix")
def test_wait_returns_the_final_tail_when_the_reader_drains_slowly(tmp_path, monkeypatch):
"""``wait`` must not take (and consume) the output before the reader has drained the exited
child's tail, however long the reader takes."""
monkeypatch.setattr(module, "_SYSTEMD_SCOPE_AVAILABLE", False, raising=False)
registry = ProcessRegistry()
stalled, release = threading.Event(), threading.Event()
def slow_sink(_session, _chunk): # a live-output consumer that falls behind
if not stalled.is_set():
stalled.set()
release.wait(10)
registry.on_output = slow_sink
burst = ("import sys; sys.stdout.write('x' * 99 + '\\n'); "
"sys.stdout.write(('y' * 99 + '\\n') * 100 + 'FINAL\\n')")
session = registry.spawn_local(f"{shlex.quote(sys.executable)} -c {shlex.quote(burst)}", cwd=str(tmp_path))
session.notify_on_complete = True
try:
assert stalled.wait(10)
assert session.process.wait(timeout=10) == 0
# Longer than any bounded wait inside the reconcile.
threading.Timer(2.5, release.set).start()
result = registry.wait(session.id, timeout=15)
assert result["status"] == "exited"
assert result["output"].rstrip().endswith("FINAL"), result["output"][-80:]
finally:
release.set()
registry.kill_all(source="test")
def test_kill_with_a_failed_scope_stop_keeps_the_session_running(monkeypatch):
"""Without a stopped scope, nothing proves the detached descendants are gone."""
registry = ProcessRegistry()
session = _session("proc_scope_stuck", systemd_unit="hermes-worker-proc_scope_stuck.scope")
registry._running[session.id] = session
monkeypatch.setattr(registry, "_signal_kill", lambda *_args: None)
monkeypatch.setattr(registry, "_post_kill_survivors", lambda _session: [])
monkeypatch.setattr(registry, "_write_checkpoint", lambda *_a, **_k: None)
monkeypatch.setattr(module, "_stop_systemd_unit", lambda _unit: False)
result = registry.kill_process(session.id)
assert result["status"] == "error"
assert result["process_running"] is True
assert "hermes-worker-proc_scope_stuck.scope" in result["error"]
assert session.id in registry._running
assert not session.exited
def test_kill_of_an_exited_session_reports_a_failed_scope_stop(monkeypatch):
registry = ProcessRegistry()
session = _session("proc_exited_scope_stuck", systemd_unit="hermes-worker-proc_exited_scope_stuck.scope")
session.mark_exited(0)
registry._finished[session.id] = session
monkeypatch.setattr(module, "_stop_systemd_unit", lambda _unit: False)
result = registry.kill_process(session.id)
assert result["status"] == "already_exited"
assert result["scope_stop_failed"] == "hermes-worker-proc_exited_scope_stuck.scope"
def test_a_reader_exit_during_a_failed_scope_stop_is_not_reported_as_killed(monkeypatch):
"""The root exiting proves nothing about the scope's detached descendants."""
registry = ProcessRegistry()
session = _session("proc_scope_race", systemd_unit="hermes-worker-proc_scope_race.scope")
registry._running[session.id] = session
monkeypatch.setattr(registry, "_signal_kill", lambda *_args: None)
monkeypatch.setattr(registry, "_write_checkpoint", lambda *_a, **_k: None)
def stop_while_the_reader_finishes(_unit):
registry._finish_exited(session, 0)
return False
monkeypatch.setattr(module, "_stop_systemd_unit", stop_while_the_reader_finishes)
result = registry.kill_process(session.id)
assert result["status"] == "error"
assert result["scope_stop_failed"] == "hermes-worker-proc_scope_race.scope"
assert result["process_running"] is False
assert (session.completion_reason, session.exit_code) == ("exited", 0)
assert session.id in registry._finished # keeps its unit, so a later kill retries the stop
class _SandboxEnv:
"""Non-local backend double: answers the poller's log-delta, liveness and exit-code reads,
running ``hooks[step]`` first so a test can land a kill at an exact point."""
def __init__(self, *, delta="", alive=False, hooks=None):
self.delta, self.alive, self.hooks = delta, alive, hooks or {}
def execute(self, command, **_kwargs):
if command.startswith("kill -0"):
step, out = "check", "0\n" if self.alive else "1\n"
elif command.startswith("kill "):
return {"output": ""}
elif command.startswith("cat "):
step, out = "exit", "0\n"
else:
step, out = "delta", f"{len(self.delta.encode())} 0\n{self.delta}"
hook = self.hooks.pop(step, None)
if hook:
hook()
return {"output": out}
def _sandbox_session(registry, monkeypatch, env) -> ProcessSession:
session = _session("proc_sandbox", pid=4242, pid_scope="sandbox", env_ref=env, notify_on_complete=True)
registry._running[session.id] = session
monkeypatch.setattr(module.time, "sleep", lambda _s: None)
monkeypatch.setattr(registry, "_write_checkpoint", lambda *_a, **_k: None)
monkeypatch.setattr(registry, "_post_kill_survivors", lambda _session: [])
return session
def _poll_sandbox(registry, session, env):
registry._env_poller_loop(session, env, "/tmp/bg.log", "/tmp/bg.pid", "/tmp/bg.exit")
def test_a_sandbox_exit_read_after_a_kill_keeps_the_kill(monkeypatch):
registry = ProcessRegistry()
killed = []
env = _SandboxEnv(hooks={"exit": lambda: killed.append(registry.kill_process("proc_sandbox"))})
session = _sandbox_session(registry, monkeypatch, env)
_poll_sandbox(registry, session, env)
assert killed[0]["status"] == "killed"
assert (session.exit_code, session.completion_reason, session.termination_source) == (
-15, "killed", "process.kill")
def test_a_sandbox_lost_after_a_kill_keeps_the_kill(monkeypatch):
registry = ProcessRegistry()
def kill_then_lose_the_backend():
registry.kill_process("proc_sandbox")
raise RuntimeError("sandbox reaped")
env = _SandboxEnv(alive=True, hooks={"check": kill_then_lose_the_backend})
session = _sandbox_session(registry, monkeypatch, env)
_poll_sandbox(registry, session, env)
assert (session.exit_code, session.completion_reason, session.termination_source) == (
-15, "killed", "process.kill")
def test_a_lost_sandbox_without_a_kill_is_reported_lost(monkeypatch):
registry = ProcessRegistry()
def lose_the_backend():
raise RuntimeError("sandbox reaped")
env = _SandboxEnv(hooks={"check": lose_the_backend})
session = _sandbox_session(registry, monkeypatch, env)
_poll_sandbox(registry, session, env)
assert (session.exit_code, session.completion_reason, session.termination_source) == (
-1, "lost", "backend_lost")
assert session.id in registry._finished
assert session._completion_event.is_set()
def test_sandbox_output_fetched_before_a_kill_is_not_added_after_it(monkeypatch):
"""The kill published its snapshot; a delta the poller already had in hand must not grow
the session's output past what the caller and the completion were given."""
registry = ProcessRegistry()
killed = []
env = _SandboxEnv(delta="FINAL", alive=True,
hooks={"delta": lambda: killed.append(registry.kill_process("proc_sandbox"))})
session = _sandbox_session(registry, monkeypatch, env)
session.append_output("partial")
_poll_sandbox(registry, session, env)
assert killed[0]["output"] == "partial"
assert session.output_buffer == "partial"
def test_a_log_rotation_seen_after_a_kill_does_not_clear_its_output(monkeypatch):
registry = ProcessRegistry()
env = _SandboxEnv(delta="partial", alive=True)
session = _sandbox_session(registry, monkeypatch, env)
execute = env.execute
def rotated_on_the_second_read(command, **kwargs):
if command.startswith("O=7;"): # second log read: the log shrank, so the offset restarts
registry.kill_process("proc_sandbox")
return {"output": "3 0\nnew"}
return execute(command, **kwargs)
monkeypatch.setattr(env, "execute", rotated_on_the_second_read)
_poll_sandbox(registry, session, env)
assert session.output_buffer == "partial"
def _scoped_session(registry, monkeypatch, sid, stops, **fields) -> ProcessSession:
"""A session with its own systemd scope; ``stops`` lists each stop's outcome in turn."""
session = _session(sid, systemd_unit=f"hermes-worker-{sid}.scope", **fields)
registry._running[session.id] = session
monkeypatch.setattr(registry, "_write_checkpoint", lambda *_a, **_k: None)
monkeypatch.setattr(module, "_stop_systemd_unit", lambda _unit: stops.pop(0))
return session
def test_a_recovered_pid_that_dies_mid_kill_reports_the_failed_scope_stop(monkeypatch):
"""``get`` sees the recovered PID alive, ``_signal_kill`` sees it gone: the scope stop it
then runs can still fail, and that must not be reported as a clean exit."""
registry = ProcessRegistry()
session = _scoped_session(registry, monkeypatch, "proc_recovered", [False],
pid=4242, detached=True, pid_scope="host")
fates = iter(["running", "gone"])
monkeypatch.setattr(registry, "_detached_host_fate", lambda *_args: next(fates))
result = registry.kill_process(session.id)
assert result["status"] == "already_exited"
assert result["scope_stop_failed"] == session.systemd_unit
assert session.id in registry._finished # keeps its unit for a retry
def test_bulk_cleanup_does_not_count_a_failed_scope_stop_and_retries_it(monkeypatch):
"""The root exits after ``kill_all`` picked the session, and the scope stop fails."""
registry = ProcessRegistry()
stops = [False, False, True]
session = _scoped_session(registry, monkeypatch, "proc_bulk_scope", stops)
kill_process = registry.kill_process
def root_exits_first(sid, **kwargs):
if not session.exited:
registry._finish_exited(session, 0)
return kill_process(sid, **kwargs)
monkeypatch.setattr(registry, "kill_process", root_exits_first)
assert registry.kill_all() == 0
assert session.id in registry._finished
assert registry.kill_all() == 0, "the scope still did not stop"
assert stops == [True], "the finished session's scope stop was not retried"
assert registry.kill_all() == 1
assert registry.kill_all() == 0
assert stops == [], "a stopped scope was retried"
def _failed_scope_stop(registry, monkeypatch, stops) -> ProcessSession:
"""A long-running session whose root just exited and whose scope stop then failed."""
session = _scoped_session(registry, monkeypatch, "proc_pending_scope", stops)
session.started_at -= module.FINISHED_TTL_SECONDS + 1
registry._finish_exited(session, 0)
assert "scope_stop_failed" in registry.kill_process(session.id)
return session
def test_ttl_pruning_keeps_a_session_whose_scope_stop_is_pending(monkeypatch):
registry = ProcessRegistry()
stops = [False, True]
session = _failed_scope_stop(registry, monkeypatch, stops)
with registry._lock:
registry._prune_if_needed()
assert session.id in registry._finished
assert registry.kill_all() == 1 and stops == []
with registry._lock:
registry._prune_if_needed()
assert session.id not in registry._finished, "a stopped scope is pruned as usual"
def test_capacity_pruning_keeps_a_session_whose_scope_stop_is_pending(monkeypatch):
registry = ProcessRegistry()
session = _failed_scope_stop(registry, monkeypatch, [False])
for i in range(module.MAX_PROCESSES):
registry._finished[f"proc_newer_{i}"] = _session(f"proc_newer_{i}", exited=True)
with registry._lock:
registry._prune_if_needed()
assert session.id in registry._finished
assert "proc_newer_0" not in registry._finished # the oldest prunable one goes instead