Files
hermes-agent/tests/tools/test_interrupt.py
ethernet 3c08d16ba7 fix(pm): close runtime publication and updater audit gaps
Dependency publication now recovers interrupted config/facts changes before
activation and leases live generations during collection. Receipts retain
update correlation and failed steps across nested command boundaries.
Doctor and desktop surfaces report those failures through shared owners.

Move checkout updates out of the desktop facade. Stage a detached Windows
relaunch waiter before shutdown, with bounded handshake and process-birth
checks. Keep packaged lifecycle tests isolated from the installed app.

Native verification exposed two production races: cron maintenance imported
the interactive CLI and rewrote TERMINAL_CWD, and install-ID reads collided
with first publication. Use the existing owners and locks. Plugin checks
now run at startup and each due-gated housekeeping tick, not after 60 ticks.

Share updater-test mutation boundaries and remove collection-root fixtures.
Separate cold MCP startup from command latency and give the real HTTP drip
test enough time to reach body handling.

Root npm check passed, including packaging. The fixed-tree Windows Python
run reported 44557 passed, one failed, and 1404 skipped, plus one retry-only
HTTP test. Those final failures now pass in a 35-test bounded batch. A real
isolated gateway wrote startup and periodic plugin-check receipts.

Full final-tree CI, bundled Sandbox deployment, and actual App Installer
relaunch remain unverified. docs/pm-audit-status.md records these limits.
2026-09-06 11:45:41 -04:00

418 lines
15 KiB
Python

"""Tests for the interrupt system.
Run with: python -m pytest tests/test_interrupt.py -v
"""
import queue
import threading
import time
import pytest
# ---------------------------------------------------------------------------
# Unit tests: shared interrupt module
# ---------------------------------------------------------------------------
class TestInterruptModule:
"""Tests for tools/interrupt.py"""
def test_set_and_check(self):
from tools.interrupt import set_interrupt, is_interrupted
set_interrupt(False)
assert not is_interrupted()
set_interrupt(True)
assert is_interrupted()
set_interrupt(False)
assert not is_interrupted()
def test_is_thread_interrupted_checks_target_tid_not_caller(self):
from tools.interrupt import (
set_interrupt, is_interrupted, is_thread_interrupted, _interrupted_threads, _lock,
)
with _lock:
_interrupted_threads.clear()
other_tid = threading.get_ident() + 1
set_interrupt(True, thread_id=other_tid)
assert not is_interrupted()
assert is_thread_interrupted(other_tid)
assert is_thread_interrupted(None) is False
set_interrupt(False, thread_id=other_tid)
assert not is_thread_interrupted(other_tid)
def test_clear_current_thread_interrupt_leaves_other_threads(self):
"""clear_current_thread_interrupt only touches the calling thread."""
from tools.interrupt import (
set_interrupt, is_interrupted, clear_current_thread_interrupt,
_interrupted_threads, _lock,
)
with _lock:
_interrupted_threads.clear()
other_tid = threading.get_ident() + 1 # an ident that isn't us
set_interrupt(True, thread_id=other_tid)
set_interrupt(True) # current thread
assert is_interrupted()
clear_current_thread_interrupt()
assert not is_interrupted() # ours cleared
with _lock:
assert other_tid in _interrupted_threads # other thread untouched
_interrupted_threads.discard(other_tid)
def test_run_if_not_interrupted_skips_callback_when_already_interrupted(self):
from tools.interrupt import run_if_not_interrupted, set_interrupt
callbacks = []
set_interrupt(True)
try:
assert run_if_not_interrupted(lambda: callbacks.append("claimed")) is False
finally:
set_interrupt(False)
assert callbacks == []
@pytest.mark.parametrize("callback_should_fail", [False, True])
def test_run_if_not_interrupted_orders_callback_before_concurrent_interrupt(
self, callback_should_fail, monkeypatch
):
import tools.interrupt as interrupt
class CallbackFailure(Exception):
pass
original_lock = interrupt._lock
attempting_interrupt_lock = threading.Event()
interrupt_published = threading.Event()
publisher_lock_contention = []
callback_observations = []
setters = []
setter_tids = []
class ObservedLock:
def __enter__(self):
if (
threading.current_thread() in setters
and not attempting_interrupt_lock.is_set()
):
acquired = original_lock.acquire(blocking=False)
publisher_lock_contention.append(not acquired)
attempting_interrupt_lock.set()
if acquired:
return self
original_lock.acquire()
return self
def __exit__(self, exc_type, exc_value, traceback):
original_lock.release()
interrupt.set_interrupt(False)
with original_lock:
baseline = (
set(interrupt._interrupted_threads),
dict(interrupt._interrupt_reasons),
)
monkeypatch.setattr(interrupt, "_lock", ObservedLock())
def publish_interrupt():
setter_tids.append(threading.get_ident())
try:
interrupt.set_interrupt(True)
interrupt_published.set()
finally:
interrupt.set_interrupt(False)
def callback():
setter = threading.Thread(target=publish_interrupt)
setters.append(setter)
setter.start()
assert attempting_interrupt_lock.wait(5)
assert publisher_lock_contention == [True]
callback_observations.append(interrupt_published.is_set())
if callback_should_fail:
raise CallbackFailure
try:
if callback_should_fail:
with pytest.raises(CallbackFailure):
interrupt.run_if_not_interrupted(callback)
else:
assert interrupt.run_if_not_interrupted(callback) is True
assert interrupt_published.wait(5)
finally:
for setter in setters:
if setter.ident is not None:
setter.join(timeout=5)
interrupt.set_interrupt(False)
assert setters
assert all(not setter.is_alive() for setter in setters)
assert setter_tids
assert callback_observations == [False]
assert interrupt_published.is_set()
with original_lock:
final_state = (
set(interrupt._interrupted_threads),
dict(interrupt._interrupt_reasons),
)
assert final_state == baseline
assert all(setter_tid not in final_state[0] for setter_tid in setter_tids)
assert all(setter_tid not in final_state[1] for setter_tid in setter_tids)
# ---------------------------------------------------------------------------
# Unit tests: pre-tool interrupt check
# ---------------------------------------------------------------------------
class TestPreToolCheck:
"""Verify that _execute_tool_calls skips all tools when interrupted."""
def test_all_tools_skipped_when_interrupted(self):
"""Mock an interrupted agent and verify no tools execute."""
from unittest.mock import MagicMock
# Build a fake assistant_message with 3 tool calls
tc1 = MagicMock()
tc1.id = "tc_1"
tc1.function.name = "terminal"
tc1.function.arguments = '{"command": "rm -rf /"}'
tc2 = MagicMock()
tc2.id = "tc_2"
tc2.function.name = "terminal"
tc2.function.arguments = '{"command": "echo hello"}'
tc3 = MagicMock()
tc3.id = "tc_3"
tc3.function.name = "web_search"
tc3.function.arguments = '{"query": "test"}'
assistant_msg = MagicMock()
assistant_msg.tool_calls = [tc1, tc2, tc3]
messages = []
# Create a minimal mock agent with _interrupt_requested = True
agent = MagicMock()
agent._interrupt_requested = True
agent.log_prefix = ""
agent._persist_session = MagicMock()
# PR #72425: execute_tool_calls_* read _incremental_persistence_failed
# via getattr at loop top. A bare MagicMock auto-creates a truthy value
# for any attribute access, which would short-circuit the interrupt
# skip path before any cancelled-tool messages are appended.
agent._incremental_persistence_failed = False
# Import and call the method
import types
from run_agent import AIAgent
# Bind the real methods to our mock so dispatch works correctly
agent._execute_tool_calls_sequential = types.MethodType(AIAgent._execute_tool_calls_sequential, agent)
agent._execute_tool_calls_concurrent = types.MethodType(AIAgent._execute_tool_calls_concurrent, agent)
AIAgent._execute_tool_calls(agent, assistant_msg, messages, "default")
# All 3 should be skipped
assert len(messages) == 3
for msg in messages:
assert msg["role"] == "tool"
assert "cancelled" in msg["content"].lower() or "interrupted" in msg["content"].lower()
# No actual tool handlers should have been called
# (handle_function_call should NOT have been invoked)
# ---------------------------------------------------------------------------
# Unit tests: message combining
# ---------------------------------------------------------------------------
class TestMessageCombining:
"""Verify multiple interrupt messages are joined."""
def test_cli_interrupt_queue_drain(self):
"""Simulate draining multiple messages from the interrupt queue."""
q = queue.Queue()
q.put("Stop!")
q.put("Don't delete anything")
q.put("Show me what you were going to delete instead")
parts = []
while not q.empty():
try:
msg = q.get_nowait()
if msg:
parts.append(msg)
except queue.Empty:
break
combined = "\n".join(parts)
assert "Stop!" in combined
assert "Don't delete anything" in combined
assert "Show me what you were going to delete instead" in combined
assert combined.count("\n") == 2
def test_gateway_pending_messages_append(self):
"""Simulate gateway _pending_messages append logic."""
pending = {}
key = "agent:main:telegram:dm"
# First message
if key in pending:
pending[key] += "\n" + "Stop!"
else:
pending[key] = "Stop!"
# Second message
if key in pending:
pending[key] += "\n" + "Do something else instead"
else:
pending[key] = "Do something else instead"
assert pending[key] == "Stop!\nDo something else instead"
# ---------------------------------------------------------------------------
# Integration tests (require local terminal)
# ---------------------------------------------------------------------------
class TestSIGKILLEscalation:
"""Test that SIGTERM-resistant processes get SIGKILL'd."""
@pytest.mark.skipif(
not __import__("shutil").which("bash"),
reason="Requires bash"
)
@pytest.mark.platforms("posix")
def test_sigterm_trap_killed_within_2s(self, tmp_path):
"""A process that traps SIGTERM should be SIGKILL'd after 1s grace."""
from tools.interrupt import set_interrupt
from tools.environments.local import LocalEnvironment
set_interrupt(False)
env = LocalEnvironment(cwd=str(tmp_path), timeout=30)
# Start execution in a thread, interrupt after 0.5s
result_holder = {"value": None}
def _run():
result_holder["value"] = env.execute(
"trap '' TERM; sleep 60",
timeout=30,
)
t = threading.Thread(target=_run)
t.start()
time.sleep(0.5)
set_interrupt(True, thread_id=t.ident)
t.join(timeout=5)
set_interrupt(False, thread_id=t.ident)
assert result_holder["value"] is not None
assert result_holder["value"]["returncode"] == 130
assert "interrupted" in result_holder["value"]["output"].lower()
# ---------------------------------------------------------------------------
# Regression: _run_tool cleanup on BaseException (issue #35309)
# ---------------------------------------------------------------------------
class TestRunToolCleanupOnBaseException:
"""Verify that _run_tool cleans up _interrupted_threads even when
_invoke_tool raises a BaseException (e.g. CancelledError).
Regression test for #35309: without the finally block, a BaseException
bypasses ``except Exception``, leaking the worker tid into
_interrupted_threads. ThreadPoolExecutor recycles tids, so the next
tool scheduled on the same thread is instantly "interrupted".
"""
def test_cleanup_on_base_exception(self):
from unittest.mock import MagicMock, patch
import types
from tools.interrupt import set_interrupt, is_interrupted, _interrupted_threads, _lock
# Clear global state
with _lock:
_interrupted_threads.clear()
# Build a minimal mock agent with the attributes _run_tool needs
agent = MagicMock()
agent._interrupt_requested = False
agent._tool_worker_threads = set()
agent._tool_worker_threads_lock = threading.Lock()
# _set_interrupt delegates to the real module
def _mock_set_interrupt(active, tid=None):
set_interrupt(active, tid)
agent._set_interrupt = _mock_set_interrupt
# _invoke_tool raises BaseException (simulating CancelledError)
agent._invoke_tool = MagicMock(side_effect=BaseException("simulated CancelledError"))
# Bind the real concurrent method so we get _run_tool
from run_agent import AIAgent
agent._execute_tool_calls_concurrent = types.MethodType(
AIAgent._execute_tool_calls_concurrent, agent
)
# Build a single tool call
tc = MagicMock()
tc.id = "tc_base_exc"
tc.function.name = "dummy_tool"
tc.function.arguments = "{}"
assistant_msg = MagicMock()
assistant_msg.tool_calls = [tc]
# _execute_tool_calls_concurrent will submit _run_tool to a
# ThreadPoolExecutor. The BaseException propagates out of the
# worker, but the finally block should still clean up.
try:
agent._execute_tool_calls_concurrent(assistant_msg, [], "default")
except Exception:
pass # ThreadPoolExecutor may re-raise
# After the worker finishes (even with BaseException), the worker
# tid should have been removed from _interrupted_threads and
# _tool_worker_threads.
assert len(agent._tool_worker_threads) == 0, (
f"_tool_worker_threads not cleaned up: {agent._tool_worker_threads}"
)
# Verify no stale tid is left in the global interrupt set. The
# worker thread is recycled by ThreadPoolExecutor, so a leaked tid
# would poison the next task on that thread. We cleared the set at
# the start and never set any interrupt ourselves, so a leak from
# _run_tool is the only way an entry could land here.
with _lock:
leaked = set(_interrupted_threads)
assert leaked == set(), f"leaked tids in _interrupted_threads: {leaked}"
# ---------------------------------------------------------------------------
# Manual smoke test checklist (not automated)
# ---------------------------------------------------------------------------
SMOKE_TESTS = """
Manual Smoke Test Checklist:
1. CLI: Run `hermes`, ask it to `sleep 30` in terminal, type "stop" + Enter.
Expected: command dies within 2s, agent responds to "stop".
2. CLI: Ask it to extract content from 5 URLs, type interrupt mid-way.
Expected: remaining URLs are skipped, partial results returned.
3. Gateway (Telegram): Send a long task, then send "Stop".
Expected: agent stops and responds acknowledging the stop.
4. Gateway (Telegram): Send "Stop" then "Do X instead" rapidly.
Expected: both messages appear as the next prompt (joined by newline).
5. CLI: Start a task that generates 3+ tool calls in one batch.
Type interrupt during the first tool call.
Expected: only 1 tool executes, remaining are skipped.
"""