mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
The 30 s timeout killed only the shell, so anything it had started (`sleep 120 & wait`) kept running. On the WebSocket route a cancelled await (gateway stopping) fell past both exception handlers, so even the shell kept running. The command now starts in its own session/process group (start_new_session; Windows ignores it). Timeout and cancellation share one cleanup, _stop_exec_tree: agent.deadline.kill_process_tree kills the descendants (taskkill /T on Windows), a POSIX killpg on the group the shell led catches members that outlived it, and the shell is reaped with a 5 s bound. The cleanup is shielded so a second cancel cannot cut it short, and CancelledError is re-raised once it finishes. Refs NousResearch#134889
54 lines
2.0 KiB
Python
54 lines
2.0 KiB
Python
"""A ``type: exec`` quick command that times out or is cancelled leaves nothing running.
|
|
|
|
Killing only the shell left ``sleep 120 & wait``'s sleep alive, and a cancelled await (gateway
|
|
shutdown on the WebSocket path) skipped cleanup entirely.
|
|
"""
|
|
import asyncio
|
|
from pathlib import Path
|
|
|
|
import psutil
|
|
import pytest
|
|
|
|
from gateway import run_inbound_hooks
|
|
from gateway.run_inbound_hooks import GatewayInboundHooksMixin
|
|
from tests.waiting import async_wait_until
|
|
|
|
_TREE = 'echo $$ > shell.pid; sleep 120 & echo $! > child.pid; wait'
|
|
|
|
|
|
def _alive(pid):
|
|
try:
|
|
return psutil.Process(pid).status() != psutil.STATUS_ZOMBIE
|
|
except psutil.NoSuchProcess:
|
|
return False
|
|
|
|
|
|
def _pid(path: Path):
|
|
text = path.read_text().strip() if path.exists() else ''
|
|
return int(text) if text else None
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
@pytest.mark.asyncio
|
|
async def test_timed_out_exec_command_kills_its_background_child(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(run_inbound_hooks, '_EXEC_QUICK_COMMAND_TIMEOUT', 2)
|
|
reply = await GatewayInboundHooksMixin()._hm_run_exec_quick_command('probe', _TREE, cwd=str(tmp_path))
|
|
assert reply == run_inbound_hooks.t('gateway.quick_command.timed_out')
|
|
child = _pid(tmp_path / 'child.pid')
|
|
assert child is not None
|
|
await async_wait_until(lambda: not _alive(child), timeout=10, what='the backgrounded sleep to stop')
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_exec_command_kills_shell_and_child(tmp_path):
|
|
task = asyncio.create_task(
|
|
GatewayInboundHooksMixin()._hm_run_exec_quick_command('probe', _TREE, cwd=str(tmp_path)))
|
|
child = await async_wait_until(lambda: _pid(tmp_path / 'child.pid'), timeout=10, what='the child pid')
|
|
shell = _pid(tmp_path / 'shell.pid')
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
await async_wait_until(lambda: not _alive(shell) and not _alive(child), timeout=10,
|
|
what='the shell and its child to stop')
|