hermes-agent/tests/tools/test_process_registry.py
Teknium 39975613b1
test: prune wave 2 + speed fixes — 28,106 → 19,757 test functions, suite wall 315s → 294s
Second, deeper pass over tools/gateway/hermes_cli plus first pass over
the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker,
dashboard, conformance, monitoring, secret_sources, hermes_state,
providers). Same rubric as wave 1 (AGENTS.md test policy); security,
alternation/caching invariants, issue-number regressions, and E2E kept.

Real test-quality fixes found and rooted out along the way:
- tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls
  (DEFAULT_CONFIG smart-approval leaked in) — pinned approval
  mode=manual via autouse fixture: 17.4s → 0.4s.
- test_model_switch_custom_providers.py / test_user_providers_model_switch.py
  silently probed live provider catalogs (~2s/test) — stubbed
  cached_provider_model_ids/provider_model_ids/fetch_api_models.
- test_telegram_noise_filter.py: 15-platform copy-paste matrix over
  shared gateway.run logic → 3 representative platforms (55s → 3.9s).
- test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on
  MagicMock agents — interrupt.side_effect now clears _running_agents
  (22s → 1.0s).
- test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x
  (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps
  patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait
  5s → 0.5s.
- test_telegram_init_deadline.py: loop-block margin restored to 1.0s
  with rationale comment — the watchdog-dump assertion needs the loop
  blocked well past deadline+grace under parallel load (flaked once in
  the 40-worker verification run at a 0.2s margin).

Verification: full hermetic suite via scripts/run_tests.sh —
2,438 files, 21,718 tests passed, 0 failed, 293.9s wall.
Suite totals vs original baseline: 46,820 → 19,757 test functions
(−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
2026-07-29 13:39:40 -07:00

1371 lines
54 KiB
Python

"""Tests for tools/process_registry.py — ProcessRegistry query methods, pruning, checkpoint."""
import json
import os
import signal
import subprocess
import sys
import threading
import time
import pytest
from unittest.mock import MagicMock, patch
from tools.environments.local import _HERMES_PROVIDER_ENV_FORCE_PREFIX
from tools.process_registry import (
ProcessRegistry,
ProcessSession,
FINISHED_TTL_SECONDS,
MAX_PROCESSES,
MAX_ACTIVE_PROCESS_AGE,
)
@pytest.fixture()
def registry():
"""Create a fresh ProcessRegistry."""
return ProcessRegistry()
def _make_session(
sid="proc_test123",
command="echo hello",
task_id="t1",
exited=False,
exit_code=None,
output="",
started_at=None,
) -> ProcessSession:
"""Helper to create a ProcessSession for testing."""
s = ProcessSession(
id=sid,
command=command,
task_id=task_id,
started_at=started_at or time.time(),
exited=exited,
exit_code=exit_code,
output_buffer=output,
)
return s
def _spawn_python_sleep(seconds: float) -> subprocess.Popen:
"""Spawn a portable short-lived Python sleep process."""
return subprocess.Popen(
[sys.executable, "-c", f"import time; time.sleep({seconds})"],
)
def _wait_until(predicate, timeout: float = 5.0, interval: float = 0.05) -> bool:
"""Poll a predicate until it returns truthy or the timeout elapses."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(interval)
return False
def test_write_stdin_uses_str_for_windows_pty(monkeypatch, registry):
"""pywinpty expects str input; bytes raises a PyString conversion error."""
written = []
class _FakePty:
def write(self, value):
written.append(value)
session = _make_session(sid="pty-win")
session._pty = _FakePty()
registry._running[session.id] = session
monkeypatch.setattr("tools.process_registry._IS_WINDOWS", True)
result = registry.write_stdin(session.id, "hello\n")
assert result == {"status": "ok", "bytes_written": 6}
assert written == ["hello\n"]
assert isinstance(written[0], str)
# =========================================================================
# Get / Poll
# =========================================================================
class TestGetAndPoll:
def test_poll_running(self, registry):
s = _make_session(output="some output here")
registry._running[s.id] = s
result = registry.poll(s.id)
assert result["status"] == "running"
assert "some output" in result["output_preview"]
assert result["command"] == "echo hello"
def test_poll_exited(self, registry):
s = _make_session(exited=True, exit_code=0, output="done")
registry._finished[s.id] = s
result = registry.poll(s.id)
assert result["status"] == "exited"
assert result["exit_code"] == 0
def test_request_close_terminal_invokes_sink_without_killing(registry):
"""With a sink wired, close routes (session, process_id) to the UI and leaves
the process running — close is a view drop, not a kill."""
s = _make_session(sid="proc_close_live")
registry._running[s.id] = s
calls = []
registry.on_close = lambda session, pid: calls.append((session, pid))
result = registry.request_close_terminal(s.id)
assert result["status"] == "ok"
assert result["closed"] == "proc_close_live"
assert calls == [(s, "proc_close_live")]
# Still tracked as running — closing the tab must not reap the process.
assert s.id in registry._running
def test_reader_loop_streams_incremental_chunks_from_read1(registry, monkeypatch):
"""Local reader must emit live chunks, not one EOF burst.
Regression for desktop agent terminals: ``stdout.read(4096)`` can buffer
until process exit for small periodic output. ``buffer.read1(4096)`` should
surface each chunk as it arrives.
"""
class _FakeBuffer:
def __init__(self, chunks):
self._chunks = list(chunks)
def read1(self, _n):
if self._chunks:
return self._chunks.pop(0)
return b""
class _FakeStdout:
def __init__(self, chunks):
self.buffer = _FakeBuffer(chunks)
class _FakeProcess:
def __init__(self, chunks):
self.stdout = _FakeStdout(chunks)
self.returncode = 0
def wait(self, timeout=None):
return 0
session = _make_session(sid="proc_reader_live")
session.process = _FakeProcess([b"tick 1\n", b"tick 2\n", b"tick 3\n", b""])
emitted = []
moved = []
monkeypatch.setattr(registry, "_check_watch_patterns", lambda _s, _c: None)
monkeypatch.setattr(registry, "_emit_output", lambda _s, chunk: emitted.append(chunk))
monkeypatch.setattr(registry, "_move_to_finished", lambda _s: moved.append(_s.id))
registry._reader_loop(session)
assert emitted == ["tick 1\n", "tick 2\n", "tick 3\n"]
assert session.output_buffer == "tick 1\ntick 2\ntick 3\n"
assert session.exited is True
assert session.exit_code == 0
assert moved == ["proc_reader_live"]
# =========================================================================
# Orphaned-pipe reconciliation (issue #17327)
# =========================================================================
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only: uses setsid/fcntl")
class TestOrphanedPipeReconciliation:
"""Regression tests for issue #17327.
`hermes update` in Feishu spawned a background subprocess that restarted
the gateway; the direct child exited quickly but a descendant daemon
held the stdout pipe open. `_reader_loop.finally` never ran, so
`session.exited` stayed False and the agent polled 74 times over 7
minutes, all returning `status: running`.
The fix is `_reconcile_local_exit()`: poll() and wait() now check the
direct `Popen.poll()` before trusting `session.exited`.
"""
def test_reconcile_flips_exited_when_direct_child_done(self, registry):
"""Direct child exited but reader thread is blocked on orphaned pipe."""
# Simulate the orphaned-pipe scenario: direct child exited, but a
# descendant holds stdout open so the reader never sees EOF.
# Approach: spawn `sh -c 'sleep 10 &'` with setsid — sh forks the
# sleep into a new session group, exits immediately, but sleep
# inherits the stdout pipe and keeps it open.
proc = subprocess.Popen(
["sh", "-c", "exec 1>&2; ( sleep 30 ) & disown; exit 0"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
preexec_fn=os.setsid,
)
s = _make_session(sid="proc_orphan_test")
s.process = proc
s.pid = proc.pid
registry._running[s.id] = s
# Wait for the direct child to exit. We don't start a reader thread,
# so session.exited stays False (mimicking the stuck-reader state).
assert _wait_until(lambda: proc.poll() is not None, timeout=5.0), (
"Direct child should exit quickly (sh exits, sleep descendant "
"holds the pipe open)"
)
# Before the fix: poll would return "running" forever.
# After the fix: poll reconciles against proc.poll() and flips.
assert s.exited is False # Precondition: reader hasn't updated it.
result = registry.poll(s.id)
assert result["status"] == "exited", (
f"Expected reconciled 'exited' status; got {result!r}. "
"This is issue #17327 — reader is blocked on orphaned pipe."
)
assert result["exit_code"] == 0
assert s.exited is True
assert s.id in registry._finished
assert s.id not in registry._running
# Clean up the orphaned descendant.
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
pass
def test_wait_returns_when_reader_blocked(self, registry):
"""wait() must also reconcile — not just poll()."""
proc = subprocess.Popen(
["sh", "-c", "( sleep 30 ) & disown; exit 0"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
preexec_fn=os.setsid,
)
s = _make_session(sid="proc_wait_orphan")
s.process = proc
s.pid = proc.pid
registry._running[s.id] = s
assert _wait_until(lambda: proc.poll() is not None, timeout=5.0)
start = time.monotonic()
result = registry.wait(s.id, timeout=10)
elapsed = time.monotonic() - start
assert result["status"] == "exited", result
assert elapsed < 5.0, (
f"wait() should return ~immediately via reconcile; took {elapsed:.1f}s"
)
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
pass
def test_wait_wakes_when_session_moves_to_finished(self, registry):
"""wait() should not sleep for the old 1s polling tick after exit."""
s = _make_session(sid="proc_wait_event", output="done")
registry._running[s.id] = s
def finish_later():
time.sleep(0.05)
s.exited = True
s.exit_code = 0
with patch.object(registry, "_write_checkpoint"):
registry._move_to_finished(s)
t = threading.Thread(target=finish_later)
t.start()
start = time.monotonic()
try:
result = registry.wait(s.id, timeout=5)
finally:
t.join(timeout=1)
elapsed = time.monotonic() - start
assert result["status"] == "exited", result
assert result["exit_code"] == 0
assert elapsed < 0.9 # must stay under the old 1s poll tick being regression-tested, f"wait() should wake on completion; took {elapsed:.3f}s"
# =========================================================================
# Read log
# =========================================================================
class TestReadLog:
def test_read_full_log(self, registry):
lines = "\n".join([f"line {i}" for i in range(50)])
s = _make_session(output=lines)
registry._running[s.id] = s
result = registry.read_log(s.id)
assert result["total_lines"] == 50
def test_read_with_offset(self, registry):
lines = "\n".join([f"line {i}" for i in range(100)])
s = _make_session(output=lines)
registry._running[s.id] = s
result = registry.read_log(s.id, offset=10, limit=5)
assert "5 lines" in result["showing"]
# =========================================================================
# Stdin helpers
# =========================================================================
class TestStdinHelpers:
def test_close_stdin_pipe_mode(self, registry):
proc = MagicMock()
proc.stdin = MagicMock()
s = _make_session()
s.process = proc
registry._running[s.id] = s
result = registry.close_stdin(s.id)
proc.stdin.close.assert_called_once()
assert result["status"] == "ok"
def test_close_stdin_allows_eof_driven_process_to_finish(self, registry, tmp_path):
"""PTY mode: writing data + sending EOF lets an EOF-driven child finish.
Background non-PTY mode used to expose subprocess stdin via a pipe,
but PR #214b95392 detached non-PTY stdin to DEVNULL to fix keyboard
lockout (#17959). For interactive stdin → PTY mode is now the only
supported path.
"""
session = registry.spawn_local(
'python3 -c "import sys; print(sys.stdin.read().strip())"',
cwd=str(tmp_path),
use_pty=True,
)
try:
# Wait for the PTY child to be up rather than sleeping blindly.
assert _wait_until(
lambda: registry.poll(session.id)["status"] == "running",
timeout=5.0,
interval=0.02,
), "PTY session never reached running"
assert registry.submit_stdin(session.id, "hello")["status"] == "ok"
assert registry.close_stdin(session.id)["status"] == "ok"
deadline = time.time() + 5
while time.time() < deadline:
poll = registry.poll(session.id)
if poll["status"] == "exited":
assert poll["exit_code"] == 0
assert "hello" in poll["output_preview"]
return
time.sleep(0.02)
pytest.fail("process did not exit after stdin was closed")
finally:
registry.kill_process(session.id)
# =========================================================================
# List sessions
# =========================================================================
class TestListSessions:
def test_filter_by_task_id(self, registry):
s1 = _make_session(sid="proc_1", task_id="t1")
s2 = _make_session(sid="proc_2", task_id="t2")
registry._running[s1.id] = s1
registry._running[s2.id] = s2
result = registry.list_sessions(task_id="t1")
assert len(result) == 1
assert result[0]["session_id"] == "proc_1"
def test_session_key_surfaces_cross_task_processes(self, registry):
"""A bg process under the same gateway session but a DIFFERENT task is
surfaced when session_key is passed, and flagged session_scoped (#29177).
"""
# Current turn's task = "t_now"; forgotten preview server = "t_old"
# but both share gateway session_key "gw1".
own = _make_session(sid="proc_own", task_id="t_now")
own.session_key = "gw1"
forgotten = _make_session(sid="proc_forgotten", task_id="t_old")
forgotten.session_key = "gw1"
other = _make_session(sid="proc_other", task_id="t_x")
other.session_key = "gw_other"
registry._running[own.id] = own
registry._running[forgotten.id] = forgotten
registry._running[other.id] = other
# Task-only (legacy) view sees just the current task's process.
legacy = registry.list_sessions(task_id="t_now")
assert {r["session_id"] for r in legacy} == {"proc_own"}
# With session_key, the forgotten process under the same gateway
# session is surfaced and flagged; the unrelated session is not.
result = registry.list_sessions(task_id="t_now", session_key="gw1")
by_id = {r["session_id"]: r for r in result}
assert set(by_id) == {"proc_own", "proc_forgotten"}
assert by_id["proc_forgotten"].get("session_scoped") is True
assert "session_scoped" not in by_id["proc_own"]
# =========================================================================
# Active process queries
# =========================================================================
class TestActiveQueries:
def test_has_active_processes(self, registry):
s = _make_session(task_id="t1")
registry._running[s.id] = s
assert registry.has_active_processes("t1") is True
assert registry.has_active_processes("t2") is False
def test_has_active_for_session_with_max_age_stale(self, registry):
"""Stale process (older than max_active_age) is ignored."""
s = _make_session(started_at=time.time() - 90000) # 25 hours ago
s.session_key = "gw_session_1"
registry._running[s.id] = s
assert registry.has_active_for_session("gw_session_1", max_active_age=86400) is False
# =========================================================================
# Pruning
# =========================================================================
class TestPruning:
def test_prune_expired_finished(self, registry):
old_session = _make_session(
sid="proc_old",
exited=True,
started_at=time.time() - FINISHED_TTL_SECONDS - 100,
)
registry._finished[old_session.id] = old_session
registry._prune_if_needed()
assert "proc_old" not in registry._finished
def test_prune_over_max_removes_oldest(self, registry):
# Fill up to MAX_PROCESSES
for i in range(MAX_PROCESSES):
s = _make_session(
sid=f"proc_{i}",
exited=True,
started_at=time.time() - i, # older as i increases
)
registry._finished[s.id] = s
# Add one more running to trigger prune
s = _make_session(sid="proc_new")
registry._running[s.id] = s
registry._prune_if_needed()
total = len(registry._running) + len(registry._finished)
assert total <= MAX_PROCESSES
# =========================================================================
# Spawn env sanitization
# =========================================================================
class TestSpawnEnvSanitization:
def test_spawn_local_strips_blocked_vars_from_background_env(self, registry):
captured = {}
def fake_popen(cmd, **kwargs):
captured["env"] = kwargs["env"]
proc = MagicMock()
proc.pid = 4321
proc.stdout = iter([])
proc.stdin = MagicMock()
proc.poll.return_value = None
return proc
fake_thread = MagicMock()
with patch.dict(os.environ, {
"PATH": "/usr/bin:/bin",
"HOME": "/home/user",
"USER": "tester",
"TELEGRAM_BOT_TOKEN": "bot-secret",
"FIRECRAWL_API_KEY": "fc-secret",
}, clear=True), \
patch("tools.process_registry._find_shell", return_value="/bin/bash"), \
patch("subprocess.Popen", side_effect=fake_popen), \
patch("threading.Thread", return_value=fake_thread), \
patch.object(registry, "_write_checkpoint"):
registry.spawn_local(
"echo hello",
cwd="/tmp",
env_vars={
"MY_CUSTOM_VAR": "keep-me",
"TELEGRAM_BOT_TOKEN": "drop-me",
f"{_HERMES_PROVIDER_ENV_FORCE_PREFIX}TELEGRAM_BOT_TOKEN": "forced-bot-token",
},
)
env = captured["env"]
assert env["MY_CUSTOM_VAR"] == "keep-me"
assert env["TELEGRAM_BOT_TOKEN"] == "forced-bot-token"
assert "FIRECRAWL_API_KEY" not in env
assert f"{_HERMES_PROVIDER_ENV_FORCE_PREFIX}TELEGRAM_BOT_TOKEN" not in env
assert env["PYTHONUNBUFFERED"] == "1"
def test_spawn_via_env_checks_returncode_when_wrapper_fails(self, registry):
class FakeEnv:
def __init__(self):
self.commands = []
def execute(self, command, **kwargs):
self.commands.append((command, kwargs))
return {"output": "syntax error", "returncode": 2}
env = FakeEnv()
fake_thread = MagicMock()
with patch("tools.process_registry.threading.Thread", return_value=fake_thread), \
patch.object(registry, "_write_checkpoint"):
session = registry.spawn_via_env(env, "echo hello")
assert session.exited is True
assert session.exit_code == 2
assert session.pid is None
assert session.output_buffer == "syntax error"
fake_thread.start.assert_not_called()
# A failed launch must not be exposed as a running/tracked session.
assert session.id not in registry._running
def test_env_poller_quotes_temp_paths_with_spaces(self, registry):
session = _make_session(sid="proc_space")
session.exited = False
class FakeEnv:
def __init__(self):
self.commands = []
self._responses = iter([
{"output": "hello\n"},
{"output": "1\n"},
{"output": "0\n"},
])
def execute(self, command, **kwargs):
self.commands.append((command, kwargs))
return next(self._responses)
env = FakeEnv()
with patch("tools.process_registry.time.sleep", return_value=None), \
patch.object(registry, "_move_to_finished"):
registry._env_poller_loop(
session,
env,
"/path with spaces/hermes_bg.log",
"/path with spaces/hermes_bg.pid",
"/path with spaces/hermes_bg.exit",
)
assert env.commands[0][0] == "cat '/path with spaces/hermes_bg.log' 2>/dev/null"
assert env.commands[1][0] == "kill -0 \"$(cat '/path with spaces/hermes_bg.pid' 2>/dev/null)\" 2>/dev/null; echo $?"
assert env.commands[2][0] == "cat '/path with spaces/hermes_bg.exit' 2>/dev/null"
# =========================================================================
# Popen leak prevention
# =========================================================================
class TestPopenLeakOnSetupFailure:
"""Regression for issue #2749: subprocess orphaned when post-Popen setup raises."""
def test_popen_killed_when_thread_creation_fails(self, registry):
"""If Thread() raises after Popen, proc must be killed — not orphaned."""
killed = []
proc = MagicMock()
proc.pid = 9999
proc.stdout = iter([])
proc.stdin = MagicMock()
proc.poll.return_value = None
def fake_kill():
killed.append(True)
proc.kill = fake_kill
proc.wait = MagicMock()
def boom(*args, **kwargs):
raise RuntimeError("Thread creation failed")
# proc.pid is a MagicMock-backed fake; os.getpgid(fake_pid) would query
# the real OS for an arbitrary PID. On a busy host that PID may exist,
# in which case spawn_local's primary cleanup path
# (os.killpg(os.getpgid(pid), SIGKILL)) succeeds against an UNRELATED
# real process group and proc.kill() is never reached — flaky failure,
# and a real risk of SIGKILLing an innocent process group. Force the
# ProcessLookupError fallback so the test deterministically exercises
# proc.kill() and never issues a real killpg.
with patch("tools.process_registry._find_shell", return_value="/bin/bash"), \
patch("subprocess.Popen", return_value=proc), \
patch("threading.Thread", side_effect=boom), \
patch("os.getpgid", side_effect=ProcessLookupError), \
patch.object(registry, "_write_checkpoint"):
with pytest.raises(RuntimeError, match="Thread creation failed"):
registry.spawn_local("echo hello", cwd="/tmp")
assert killed, "proc.kill() must be called when post-Popen setup raises"
# =========================================================================
# Spawn rewrite regression (issue #68915)
# =========================================================================
class TestSpawnRewriteCompoundBackground:
"""Verify that spawn_local rewrites `A && B &` patterns to avoid subshell deadlocks.
Issue #68915: when bash parses ``A && B &`` it forks a subshell ``(A && B) &``.
If B is a long-running server, the subshell never exits and holds the stdout
pipe open, causing a permanent deadlock. The rewriter wraps the tail to
``A && { B & }`` so no subshell fork occurs.
"""
def test_compound_and_background_gets_rewritten(self, registry):
"""A && B & must be rewritten to A && { B & } before Popen."""
captured_cmd = []
def fake_popen(args, **kwargs):
captured_cmd.append(args)
proc = MagicMock()
proc.pid = 1111
proc.stdout = MagicMock()
return proc
fake_thread = MagicMock()
fake_thread.daemon = False
with patch("tools.process_registry._find_shell", return_value="/bin/bash"), \
patch("subprocess.Popen", side_effect=fake_popen), \
patch("threading.Thread", return_value=fake_thread), \
patch.object(registry, "_write_checkpoint"):
registry.spawn_local("cd /app && node server.js &>/tmp/srv.log &", cwd="/tmp")
assert len(captured_cmd) == 1
shell_cmd = captured_cmd[0]
# The command passed to Popen should be the REWRITTEN version
assert "&& { node server.js &>/tmp/srv.log & }" in shell_cmd[2]
def test_simple_background_preserved(self, registry):
"""Simple cmd & (no &&) must NOT be rewritten — no subshell bug."""
captured_cmd = []
def fake_popen(args, **kwargs):
captured_cmd.append(args)
proc = MagicMock()
proc.pid = 2222
proc.stdout = MagicMock()
return proc
fake_thread = MagicMock()
fake_thread.daemon = False
with patch("tools.process_registry._find_shell", return_value="/bin/bash"), \
patch("subprocess.Popen", side_effect=fake_popen), \
patch("threading.Thread", return_value=fake_thread), \
patch.object(registry, "_write_checkpoint"):
registry.spawn_local("sleep 5 &", cwd="/tmp")
assert len(captured_cmd) == 1
shell_cmd = captured_cmd[0][2]
# Simple background must remain as-is
assert "sleep 5 &" in shell_cmd
def test_pty_path_uses_rewritten_command(self, registry):
"""PTY spawn path must also use the rewritten command (issue #68915)."""
mock_pty_proc = MagicMock()
mock_pty_proc.pid = 5555
mock_pty_module = MagicMock()
mock_pty_module.PtyProcess.spawn = MagicMock(return_value=mock_pty_proc)
fake_thread = MagicMock()
fake_thread.daemon = False
with patch("tools.process_registry._find_shell", return_value="/bin/bash"), \
patch("tools.process_registry._IS_WINDOWS", False), \
patch.dict("sys.modules", {"ptyprocess": mock_pty_module}), \
patch("threading.Thread", return_value=fake_thread), \
patch.object(registry, "_write_checkpoint"):
session = registry.spawn_local(
"cd /app && node server.js &",
cwd="/tmp",
use_pty=True,
)
assert mock_pty_module.PtyProcess.spawn.called, \
"PTY spawn should have been attempted"
pty_args = mock_pty_module.PtyProcess.spawn.call_args[0][0]
assert "&& { node server.js & }" in pty_args[2], \
f"PTY path should use rewritten command, got: {pty_args[2]}"
assert session.command == "cd /app && node server.js &"
# =========================================================================
# Checkpoint
# =========================================================================
class TestCheckpoint:
def test_recover_dead_pid(self, registry, tmp_path):
checkpoint = tmp_path / "procs.json"
checkpoint.write_text(json.dumps([{
"session_id": "proc_dead",
"command": "sleep 999",
"pid": 999999999, # almost certainly not running
"task_id": "t1",
}]))
with patch("tools.process_registry.CHECKPOINT_PATH", checkpoint):
recovered = registry.recover_from_checkpoint()
assert recovered == 0
def test_recovery_skips_explicit_sandbox_backed_entries(self, registry, tmp_path):
checkpoint = tmp_path / "procs.json"
original = [{
"session_id": "proc_remote",
"command": "sleep 999",
"pid": os.getpid(),
"task_id": "t1",
"pid_scope": "sandbox",
}]
checkpoint.write_text(json.dumps(original))
with patch("tools.process_registry.CHECKPOINT_PATH", checkpoint):
recovered = registry.recover_from_checkpoint()
assert recovered == 0
assert registry.get("proc_remote") is None
data = json.loads(checkpoint.read_text())
assert data == []
# =========================================================================
# Kill process
# =========================================================================
class TestKillProcess:
def test_kill_already_exited(self, registry):
s = _make_session(exited=True, exit_code=0)
registry._finished[s.id] = s
result = registry.kill_process(s.id)
assert result["status"] == "already_exited"
def test_kill_detached_session_uses_host_pid(self, registry):
s = _make_session(sid="proc_detached", command="sleep 999")
s.pid = 424242
s.detached = True
registry._running[s.id] = s
terminate_calls = []
class FakeProcess:
def __init__(self, pid):
self.pid = pid
def children(self, recursive=False):
return []
def terminate(self):
terminate_calls.append(("terminate", self.pid))
import psutil as _psutil
try:
# Post-#21561: liveness probe routes through
# ``ProcessRegistry._is_host_pid_alive`` (→
# ``gateway.status._pid_exists``), and the actual kill on POSIX
# routes through ``psutil.Process(pid).terminate()``. Neither
# touches ``os.kill`` directly. Mock both seams. Disable the
# SIGKILL-escalation step (grace=0) so it doesn't call
# ``psutil.wait_procs`` on the FakeProcess.
with patch("gateway.status._pid_exists", return_value=True), \
patch.object(ProcessRegistry, "_daemon_term_grace_seconds",
staticmethod(lambda: 0.0)), \
patch.object(_psutil, "Process", side_effect=lambda pid: FakeProcess(pid)):
result = registry.kill_process(s.id)
assert result["status"] == "killed"
assert ("terminate", 424242) in terminate_calls
finally:
registry._running.pop(s.id, None)
# =========================================================================
# Tool handler
# =========================================================================
class TestProcessToolHandler:
def test_unknown_action(self):
from tools.process_registry import _handle_process
result = json.loads(_handle_process({"action": "unknown_action"}))
assert "error" in result
# =========================================================================
# format_process_notification + drain_notifications (shared helpers)
# =========================================================================
from tools.process_registry import format_process_notification
def test_drain_notifications_completion_callback_exception_fails_closed(registry):
event = {
"type": "completion",
"session_id": "proc_callback_error",
"session_key": "session-a",
"command": "safe-test-command",
"exit_code": 0,
"output": "done",
}
registry.completion_queue.put(event)
def broken(_event):
raise RuntimeError("ownership check exploded")
results = registry.drain_notifications(
session_key="session-a",
owns_event=broken,
)
assert results == []
assert registry.completion_queue.get_nowait() == event
assert registry.completion_queue.empty()
def test_drain_notifications_filters_async_delegation_by_session_key():
"""Async-delegation events should only be consumed by the matching session's drain.
Regression test for issue #58684: background delegation results delivered
to the wrong session when the user switches sessions while a subagent runs.
"""
from tools.process_registry import process_registry
# Clear the queue first
while not process_registry.completion_queue.empty():
process_registry.completion_queue.get_nowait()
try:
# Put events for different sessions
process_registry.completion_queue.put({
"type": "async_delegation",
"delegation_id": "deleg_session_a",
"session_key": "telegram:dm:111:user_a",
"goal": "task A",
"status": "completed",
"summary": "done A",
"api_calls": 1,
"duration_seconds": 0.5,
})
process_registry.completion_queue.put({
"type": "async_delegation",
"delegation_id": "deleg_session_b",
"session_key": "telegram:dm:222:user_b",
"goal": "task B",
"status": "completed",
"summary": "done B",
"api_calls": 1,
"duration_seconds": 0.3,
})
# Drain for session A — should only get deleg_session_a
results_a = process_registry.drain_notifications(session_key="telegram:dm:111:user_a")
assert len(results_a) == 1, (
f"Expected 1 event for session A, got {len(results_a)}"
)
assert results_a[0][0]["delegation_id"] == "deleg_session_a"
assert "done A" in results_a[0][1]
# Session B's event should have been re-queued — drain for session B
results_b = process_registry.drain_notifications(session_key="telegram:dm:222:user_b")
assert len(results_b) == 1, (
f"Expected 1 event for session B, got {len(results_b)}"
)
assert results_b[0][0]["delegation_id"] == "deleg_session_b"
assert "done B" in results_b[0][1]
# No more events should remain
assert process_registry.completion_queue.empty()
finally:
while not process_registry.completion_queue.empty():
process_registry.completion_queue.get_nowait()
def test_drain_notifications_owns_event_callback_beats_key_equality():
"""The positive-proof ownership callback consumes ONLY approved events —
including across a compression rotation where bare key equality would
wrongly re-queue the session's own pre-compression dispatch (#55578)."""
from tools.process_registry import process_registry
while not process_registry.completion_queue.empty():
process_registry.completion_queue.get_nowait()
try:
# Pre-compression dispatch: event carries the OLD key.
process_registry.completion_queue.put({
"type": "async_delegation",
"delegation_id": "deleg_precompress",
"session_key": "old_parent_key",
"goal": "task", "status": "completed", "summary": "mine",
"api_calls": 1, "duration_seconds": 0.1,
})
# Foreign event that plain key equality would also reject.
process_registry.completion_queue.put({
"type": "async_delegation",
"delegation_id": "deleg_foreign",
"session_key": "someone_else",
"goal": "task", "status": "completed", "summary": "not mine",
"api_calls": 1, "duration_seconds": 0.1,
})
# Chain-aware ownership: this session's lineage includes old_parent_key.
lineage = {"old_parent_key", "new_child_key"}
results = process_registry.drain_notifications(
session_key="new_child_key",
owns_event=lambda e: e.get("session_key") in lineage,
)
assert [r[0]["delegation_id"] for r in results] == ["deleg_precompress"]
# The foreign event was re-queued, not consumed.
leftover = process_registry.completion_queue.get_nowait()
assert leftover["delegation_id"] == "deleg_foreign"
finally:
while not process_registry.completion_queue.empty():
process_registry.completion_queue.get_nowait()
# ---------------------------------------------------------------------------
# _terminate_host_pid — cross-platform process-tree termination
# ---------------------------------------------------------------------------
class TestTerminateHostPidWindows:
"""Windows branch uses ``taskkill /T /F`` — the documented MS tree-kill
primitive. We can't use psutil's ``children(recursive=True)`` /
``.terminate()`` path on Windows because (1) Windows doesn't maintain
a Unix-style process tree so the walk is unreliable, and (2)
``Process.terminate()`` on Windows is ``TerminateProcess()`` for the
target handle only, not the tree.
"""
def test_windows_invokes_taskkill_with_tree_and_force_flags(self, monkeypatch):
"""The Windows branch must shell out to ``taskkill /PID N /T /F``."""
from tools import process_registry as pr
captured = {}
def fake_run(args, **kwargs):
captured["args"] = args
captured["kwargs"] = kwargs
return MagicMock(returncode=0, stderr="", stdout="")
monkeypatch.setattr(pr, "_IS_WINDOWS", True)
monkeypatch.setattr(pr.subprocess, "run", fake_run)
pr.ProcessRegistry._terminate_host_pid(12345)
assert captured["args"][0] == "taskkill"
assert "/PID" in captured["args"]
assert "12345" in captured["args"]
assert "/T" in captured["args"], "Tree flag required to reach descendants"
assert "/F" in captured["args"], "Force flag required for headless Chromium"
class TestTerminateHostPidPosix:
"""POSIX branch walks the tree via psutil and SIGTERMs children first."""
def test_posix_walks_tree_and_terminates_children_then_parent(self, monkeypatch):
from tools import process_registry as pr
import psutil
terminate_order = []
class _FakeChild:
def __init__(self, pid):
self.pid = pid
def terminate(self):
terminate_order.append(self.pid)
class _FakeParent:
def __init__(self, pid):
self.pid = pid
def children(self, recursive=False):
assert recursive is True
return [_FakeChild(101), _FakeChild(102), _FakeChild(103)]
def terminate(self):
terminate_order.append(self.pid)
monkeypatch.setattr(pr, "_IS_WINDOWS", False)
monkeypatch.setattr(psutil, "Process", _FakeParent)
# This test covers only the SIGTERM tree-walk ordering; disable the
# SIGKILL-escalation step (which would call psutil.wait_procs on the
# fakes) by setting the grace to 0.
monkeypatch.setattr(pr.ProcessRegistry, "_daemon_term_grace_seconds",
staticmethod(lambda: 0.0))
pr.ProcessRegistry._terminate_host_pid(12345)
assert terminate_order == [101, 102, 103, 12345], (
"Children must be terminated before the parent"
)
def test_posix_oserror_falls_back_to_os_kill(self, monkeypatch):
from tools import process_registry as pr
import psutil
def boom(pid):
raise PermissionError("can't read /proc")
kill_calls = []
def fake_kill(pid, sig):
kill_calls.append((pid, sig))
monkeypatch.setattr(pr, "_IS_WINDOWS", False)
monkeypatch.setattr(psutil, "Process", boom)
monkeypatch.setattr(pr.os, "kill", fake_kill)
pr.ProcessRegistry._terminate_host_pid(12345)
assert kill_calls == [(12345, signal.SIGTERM)]
# =========================================================================
# PID-reuse guard — a recycled PID/PGID must never be signalled.
#
# Regression: once a background-session process exits and is reaped, the kernel
# can recycle its PID onto an unrelated process (observed in the wild landing on
# a desktop browser's session leader, whose whole tree we then SIGTERMed —
# Firefox dying at irregular intervals). Identity is re-validated via the
# kernel start time captured at spawn before any signal is sent.
# =========================================================================
class TestPidReuseGuard:
def test_terminate_refuses_when_start_time_mismatches(self, registry):
"""A live PID whose start time changed (recycled) is NOT killed."""
proc = _spawn_python_sleep(30)
try:
real_start = ProcessRegistry._safe_host_start_time(proc.pid)
assert real_start is not None, "no /proc start time on this platform?"
# Simulate recycling: the recorded baseline no longer matches.
registry._terminate_host_pid(proc.pid, expected_start=real_start + 1)
# The process must still be alive — the guard refused to signal it.
assert not _wait_until(lambda: proc.poll() is not None, timeout=0.3)
assert proc.poll() is None
finally:
proc.kill()
proc.wait()
def test_refresh_detached_marks_recycled_pid_exited(self, registry):
"""A detached session whose PID got recycled is moved to finished."""
wrong_start = (ProcessRegistry._safe_host_start_time(os.getpid()) or 0) + 999
s = _make_session(sid="proc_detached")
s.pid = os.getpid() # alive, but...
s.pid_scope = "host"
s.detached = True
s.host_start_time = wrong_start # ...identity no longer matches
registry._running[s.id] = s
refreshed = registry._refresh_detached_session(s)
assert refreshed.exited is True
assert s.id in registry._finished
@pytest.mark.skipif(sys.platform == "win32",
reason="POSIX SIGTERM→SIGKILL escalation; Windows uses taskkill /F")
class TestSigkillEscalation:
"""Bounded SIGTERM→SIGKILL escalation in _terminate_host_pid.
A daemon that ignores/stalls on SIGTERM must be force-killed after the
configured grace window so it can't leak indefinitely — while well-behaved
processes still exit cleanly on SIGTERM and the recycled-PID guard is never
bypassed.
"""
# A process that traps SIGTERM (ignores it): only SIGKILL stops it.
# It prints "ready" AFTER installing the handler so the parent never
# signals it during the startup window (before SIG_IGN is in place).
_TRAP = (
"import signal, sys, time;"
"signal.signal(signal.SIGTERM, signal.SIG_IGN);"
"sys.stdout.write('ready\\n'); sys.stdout.flush();"
"[time.sleep(0.2) for _ in iter(int, 1)]"
)
def _spawn_trap(self):
proc = subprocess.Popen(
[sys.executable, "-c", self._TRAP],
stdout=subprocess.PIPE, text=True,
)
# Wait until the handler is installed before returning.
line = proc.stdout.readline()
assert line.strip() == "ready", "trap process failed to start"
return proc
def test_sigterm_ignoring_daemon_is_sigkilled(self, monkeypatch):
monkeypatch.setattr(ProcessRegistry, "_daemon_term_grace_seconds",
staticmethod(lambda: 0.3))
proc = self._spawn_trap()
try:
ProcessRegistry._terminate_host_pid(proc.pid)
assert _wait_until(lambda: proc.poll() is not None, timeout=4.0), \
"SIGTERM-ignoring daemon should be SIGKILLed after grace"
finally:
if proc.poll() is None:
proc.kill()
proc.wait()
def test_escalation_does_not_bypass_recycled_pid_guard(self, monkeypatch):
"""A start-time mismatch must still spare the PID — no SIGTERM, no SIGKILL."""
monkeypatch.setattr(ProcessRegistry, "_daemon_term_grace_seconds",
staticmethod(lambda: 0.3))
proc = self._spawn_trap()
try:
real_start = ProcessRegistry._safe_host_start_time(proc.pid)
ProcessRegistry._terminate_host_pid(
proc.pid, expected_start=(real_start or 0) + 1)
assert not _wait_until(lambda: proc.poll() is not None, timeout=0.3)
assert proc.poll() is None
finally:
proc.kill()
proc.wait()
def test_grace_reader_floors_at_zero(self, monkeypatch):
"""A negative configured grace is clamped to 0 (no escalation)."""
import hermes_cli.config as cfg_mod
monkeypatch.setattr(cfg_mod, "read_raw_config",
lambda: {"terminal": {"daemon_term_grace_seconds": -5}})
assert ProcessRegistry._daemon_term_grace_seconds() == 0.0
@pytest.mark.live_system_guard_bypass
def test_entire_tree_is_sigkilled_not_just_parent(self, monkeypatch):
"""A SIGTERM-ignoring parent + children are ALL force-killed.
Regression: an earlier implementation trusted psutil.wait_procs's
gone/alive partition, which mis-partitioned across a parent/child tree
and left survivors un-killed (flaky — sometimes the parent lived,
sometimes a child). The escalation now re-probes every target directly.
"""
import psutil
# 2.0s grace (not 1.0): with three interpreters mid-startup on a
# loaded runner, a 1s SIGTERM->partition window races child spawn and
# is how a child PID escaped the live-system guard in CI.
monkeypatch.setattr(ProcessRegistry, "_daemon_term_grace_seconds",
staticmethod(lambda: 2.0))
# Parent spawns 2 children; all trap SIGTERM. Parent prints child pids
# after the handler is installed.
parent_src = (
"import signal, subprocess, sys, time;"
"child='import signal,time\\nsignal.signal(signal.SIGTERM, signal.SIG_IGN)\\n"
"[time.sleep(0.2) for _ in iter(int,1)]';"
"kids=[subprocess.Popen([sys.executable,'-c',child]) for _ in range(2)];"
"signal.signal(signal.SIGTERM, signal.SIG_IGN);"
"sys.stdout.write(' '.join(str(k.pid) for k in kids)+'\\n'); sys.stdout.flush();"
"[time.sleep(0.2) for _ in iter(int,1)]"
)
parent = subprocess.Popen([sys.executable, "-c", parent_src],
stdout=subprocess.PIPE, text=True)
# Bound the readline: if the parent wedges before printing, fail THIS
# test with a clear message instead of letting the per-file timeout
# SIGKILL the whole pytest process (opaque rc=124 in CI).
import select as _select
ready, _, _ = _select.select([parent.stdout], [], [], 20.0)
assert ready, "parent process failed to print child pids within 20s"
child_pids = [int(x) for x in parent.stdout.readline().split()]
all_pids = [parent.pid] + child_pids
try:
ProcessRegistry._terminate_host_pid(parent.pid)
def _pid_dead(p: int) -> bool:
# A pid is "dead" for our purposes if it no longer exists OR
# exists only as an unreaped zombie (already terminated, just
# not reaped by its reparented parent yet). psutil can also
# raise mid-probe if the pid vanishes between the existence
# check and the status read — treat any such race as dead.
try:
if not psutil.pid_exists(p):
return True
return not ProcessRegistry._proc_alive(psutil.Process(p))
except Exception:
return True
def _all_dead():
return all(_pid_dead(p) for p in all_pids)
# _terminate_host_pid SIGKILLs synchronously before returning, so
# the kill signals are already delivered here. The only remaining
# wait is the kernel tearing down 3 processes and the reparented
# children transitioning to zombie — which can lag on a loaded CI
# runner. Give a generous budget (matches the wait() test's 10s)
# so this asserts the escalation BEHAVIOR, not the runner's
# scheduling latency. The assertion itself never weakens: every
# tree member must end up dead/zombie.
assert _wait_until(_all_dead, timeout=15.0, interval=0.02), (
"entire SIGTERM-ignoring tree (parent + children) must be SIGKILLed"
)
finally:
for p in all_pids:
try:
os.kill(p, signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
pass
parent.wait()
class TestHandleProcessRedaction:
"""`_handle_process` redacts background-process output before it reaches the
model / session.db / CLI display — issue #43025.
Mirrors the foreground `terminal` redaction so the two surfaces can't
diverge. Env-dump commands (`printenv`/`env`) get the ENV-assignment pass
so opaque tokens are masked; other commands stay on the code_file path.
"""
def _setup(self, monkeypatch, command, output):
import agent.redact as _r
monkeypatch.setattr(_r, "_REDACT_ENABLED", True)
from tools import process_registry as pr
reg = ProcessRegistry()
sess = _make_session(sid="proc_redact1", command=command)
sess.output_buffer = output
sess.exited = True
sess.exit_code = 0
reg._running.clear()
reg._finished[sess.id] = sess
reg._running[sess.id] = sess
monkeypatch.setattr(pr, "process_registry", reg)
return pr, sess
def test_log_redacts_env_dump_opaque_token(self, monkeypatch):
pr, sess = self._setup(
monkeypatch, "printenv",
"MY_SERVICE_TOKEN=abc123randomopaquetokenvalue999\nHOME=/home/u",
)
out = json.loads(pr._handle_process({"action": "log", "session_id": sess.id}))
assert "abc123randomopaquetokenvalue999" not in out["output"]
assert "HOME=/home/u" in out["output"]
def test_poll_redacts_prefix_key(self, monkeypatch):
pr, sess = self._setup(
monkeypatch, "python app.py",
"leaked OPENAI_API_KEY sk-proj-abc123def456ghi789jkl012 here",
)
out = json.loads(pr._handle_process({"action": "poll", "session_id": sess.id}))
assert "abc123def456" not in out["output_preview"]
def test_disabled_passes_through(self, monkeypatch):
import agent.redact as _r
monkeypatch.setattr(_r, "_REDACT_ENABLED", False)
from tools import process_registry as pr
reg = ProcessRegistry()
sess = _make_session(sid="proc_redact2", command="printenv")
sess.output_buffer = "CUSTOM_TOKEN=zzzopaque1234567890abcdef"
sess.exited = True
sess.exit_code = 0
reg._running[sess.id] = sess
monkeypatch.setattr(pr, "process_registry", reg)
out = json.loads(pr._handle_process({"action": "log", "session_id": sess.id}))
assert "zzzopaque1234567890abcdef" in out["output"]
# =========================================================================
# Reader loop: orphaned grandchild holding the stdout pipe (issue #68915)
# =========================================================================
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only: select() on pipes")
class TestReaderLoopOrphanedPipe:
"""Regression tests for issue #68915.
When an agent command backgrounds a long-lived process (``node server.js
&``), the grandchild inherits the write end of the reader's stdout pipe.
The direct bash child exits, but the pipe never EOFs — the old blocking
``read1()`` parked the reader thread forever, ``session.exited`` never
flipped on its own, and ``notify_on_complete`` never fired. The reader
must instead terminate shortly after the direct child exits, even while
a descendant still holds the pipe open.
"""
def test_reader_exits_when_orphan_holds_pipe(self, registry):
"""Reader loop must return promptly after the direct child exits,
even though a backgrounded descendant keeps the pipe open."""
proc = subprocess.Popen(
["sh", "-c", "echo started; sleep 30 & exit 0"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
preexec_fn=os.setsid,
)
s = _make_session(sid="proc_orphan_reader")
s.process = proc
s.pid = proc.pid
registry._running[s.id] = s
done = threading.Event()
def _run():
registry._reader_loop(s)
done.set()
t = threading.Thread(target=_run, daemon=True)
t.start()
try:
# The direct child exits immediately; the reader must notice and
# return well before the 30s descendant releases the pipe.
assert done.wait(timeout=10.0), (
"_reader_loop is still blocked on the orphan-held pipe "
"(issue #68915) — session.exited would never flip and "
"notify_on_complete would never fire"
)
assert s.exited is True
assert s.exit_code == 0
assert s.completion_reason == "exited"
assert "started" in s.output_buffer
assert s.id in registry._finished
finally:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
pass
def test_reader_exit_fires_notify_on_complete(self, registry):
"""The autonomous completion notification must not depend on a
poll()/wait() call when an orphan holds the pipe."""
proc = subprocess.Popen(
["sh", "-c", "sleep 30 & echo bg-started"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
preexec_fn=os.setsid,
)
s = _make_session(sid="proc_orphan_notify")
s.process = proc
s.pid = proc.pid
s.notify_on_complete = True
registry._running[s.id] = s
done = threading.Event()
def _run():
registry._reader_loop(s)
done.set()
t = threading.Thread(target=_run, daemon=True)
t.start()
try:
assert done.wait(timeout=10.0), (
"_reader_loop blocked — completion notification lost (#68915)"
)
# Exactly one completion event must have been queued.
item = registry.completion_queue.get_nowait()
assert item["type"] == "completion"
assert item["session_id"] == s.id
assert item["exit_code"] == 0
finally:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
pass