Files
hermes-webui/tests/test_terminal_zombie_reaper.py
nesquena-hermes 3a8a51e507
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.304 — Release JT (stage-p2a — un-held terminal reaper + opt-in Docker GPU) (#3757)
* fix(terminal): reap reparented terminal descendants by process group (#3725, #2577)

Embedded-terminal descendants reparented to the WebUI process could linger as
zombies. The reaper now calls os.waitpid(-terminal_pgid, WNOHANG) scoped to the
terminal's own process group (terminals spawn with start_new_session=True, so
proc.pid == pgid) rather than process-wide waitpid(-1), which would otherwise
reap unrelated WebUI subprocess children and silently coerce their exit codes to
0. Bounded by a 64-iteration limit and lock-guarded. Runs on reader cleanup and
terminal close.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* docs(docker): add opt-in GPU runtime image path (#3721, #3243)

The default image stays CPU-only. A new INSTALL_GPU_LIBS=1 build arg installs
VA-API user-space libraries for users passing through host GPU devices, and
docker_init.bash preserves Docker --group-add supplemental groups (e.g. render/
video for /dev/dri) when dropping privileges to the runtime user. Default
(INSTALL_GPU_LIBS=0) is a no-op. Docs + regression test included.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* docs(changelog): stamp v0.51.304 — Release JT (stage-p2a #3725 #3721)

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-06 18:04:16 -07:00

125 lines
3.6 KiB
Python

import os
import sys
import time
import pytest
pytestmark = pytest.mark.skipif(
os.name == "nt"
or not sys.platform.startswith("linux")
or not getattr(__import__("api.terminal", fromlist=["_TERMINAL_SUPPORTED"]), "_TERMINAL_SUPPORTED", False),
reason="Linux-only terminal zombie reaper coverage",
)
import api.terminal as terminal
def _wait_until_waitable(pid: int, timeout: float = 2.0) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
result = os.waitid(os.P_PID, pid, os.WEXITED | os.WNOHANG | os.WNOWAIT)
if result is not None and result.si_pid == pid:
return
time.sleep(0.01)
raise AssertionError(f"child {pid} did not exit before timeout")
def test_reap_terminal_descendants_reaps_exited_child():
ready_r, ready_w = os.pipe()
pid = os.fork()
if pid == 0:
os.close(ready_r)
os.setpgid(0, 0)
os.write(ready_w, b"1")
os.close(ready_w)
os._exit(0)
reaped = False
try:
os.close(ready_w)
assert os.read(ready_r, 1) == b"1"
os.close(ready_r)
_wait_until_waitable(pid)
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline:
terminal._reap_terminal_descendants(pid)
try:
os.waitid(os.P_PID, pid, os.WEXITED | os.WNOHANG | os.WNOWAIT)
except ChildProcessError:
reaped = True
break
time.sleep(0.01)
assert reaped, "terminal descendant reaper did not reap the exited child"
finally:
if not reaped:
try:
os.waitpid(pid, 0)
except ChildProcessError:
pass
def test_close_terminal_reaps_descendants_after_shell_wait(monkeypatch):
class FakeProc:
pid = 987654
def __init__(self):
self.wait_calls = []
self.returncode = None
def poll(self):
return self.returncode
def wait(self, timeout=None):
self.wait_calls.append(timeout)
self.returncode = -1
return self.returncode
proc = FakeProc()
term = terminal.TerminalSession(
session_id="term-descendant-reap",
workspace="/tmp",
proc=proc,
master_fd=12345,
)
terminal._TERMINALS["term-descendant-reap"] = term
kills = []
reaped = []
monkeypatch.setattr(terminal.os, "killpg", lambda pid, sig: kills.append((pid, sig)))
monkeypatch.setattr(terminal.os, "close", lambda fd: None)
monkeypatch.setattr(terminal, "_reap_terminal_descendants", lambda pgid: reaped.append(pgid) or 0)
assert terminal.close_terminal("term-descendant-reap") is True
assert kills == [(proc.pid, terminal.signal.SIGHUP)]
assert proc.wait_calls == [1.5]
assert reaped == [proc.pid]
def test_reap_terminal_descendants_ignores_expected_waitpid_errors(monkeypatch):
calls = []
def fake_waitpid(pid, flags):
calls.append((pid, flags))
raise ChildProcessError()
monkeypatch.setattr(terminal.os, "waitpid", fake_waitpid)
assert terminal._reap_terminal_descendants(123) == 0
assert calls == [(-123, os.WNOHANG)]
def test_reap_terminal_descendants_is_bounded(monkeypatch):
calls = []
def fake_waitpid(pid, flags):
calls.append((pid, flags))
return (len(calls), 0)
monkeypatch.setattr(terminal.os, "waitpid", fake_waitpid)
assert terminal._reap_terminal_descendants(123, limit=3) == 3
assert calls == [(-123, os.WNOHANG), (-123, os.WNOHANG), (-123, os.WNOHANG)]