The group-leadership guard nulls a non-leader pgid so the shared group is never killpg'd, but that also skipped the helpers-outlive-leader sweep (#76759): a SIGHUP-ignoring helper of a shared-group child kept the PTY slave open past close(). Snapshot the child's descendants with psutil BEFORE the parent is signalled (they reparent and become undiscoverable once it exits), then send SIGHUP with the helper grace and SIGKILL the survivors individually — mirroring browser_tool_lifecycle's non-leader branch and the group sweep's leader-dead cadence.
445 lines
15 KiB
Python
445 lines
15 KiB
Python
"""Unit tests for hermes_cli.pty_bridge — PTY spawning + byte forwarding.
|
|
|
|
These tests drive the bridge with minimal POSIX processes (echo, env, sleep,
|
|
printf) to verify it behaves like a PTY you can read/write/resize/close.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import errno
|
|
import os
|
|
import select
|
|
import shutil
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
import pytest
|
|
|
|
pytest.importorskip("ptyprocess", reason="ptyprocess not installed")
|
|
|
|
from hermes_cli.pty_bridge import PtyBridge
|
|
|
|
skip_on_windows = pytest.mark.skipif(
|
|
sys.platform.startswith("win"), reason="PTY bridge is POSIX-only"
|
|
)
|
|
|
|
def _read_until(bridge: PtyBridge, needle: bytes, timeout: float = 5.0) -> bytes:
|
|
"""Accumulate PTY output until we see `needle` or time out."""
|
|
deadline = time.monotonic() + timeout
|
|
buf = bytearray()
|
|
while time.monotonic() < deadline:
|
|
chunk = bridge.read(timeout=0.2)
|
|
if chunk is None:
|
|
break
|
|
buf.extend(chunk)
|
|
if needle in buf:
|
|
return bytes(buf)
|
|
return bytes(buf)
|
|
|
|
@skip_on_windows
|
|
class TestPtyBridgeSpawn:
|
|
|
|
def test_spawn_raises_on_missing_argv0(self, tmp_path):
|
|
with pytest.raises((FileNotFoundError, OSError)):
|
|
PtyBridge.spawn([str(tmp_path / "definitely-not-a-real-binary")])
|
|
|
|
def test_spawn_marks_child_as_dashboard_hosted(self):
|
|
# Ink reads this to skip its focus-in erase+repaint, which under
|
|
# xterm.js was a visible reload on every OS app-switch (#94337).
|
|
from hermes_cli.pty_bridge import PTY_HOST_DASHBOARD, PTY_HOST_ENV
|
|
|
|
bridge = PtyBridge.spawn([shutil.which("sh") or "sh", "-c", f'printf "%s" "${PTY_HOST_ENV}"'])
|
|
try:
|
|
output = _read_until(bridge, PTY_HOST_DASHBOARD.encode())
|
|
assert PTY_HOST_DASHBOARD.encode() in output
|
|
finally:
|
|
bridge.close()
|
|
|
|
@skip_on_windows
|
|
class TestPtyBridgeIO:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_sends_to_child_stdin(self):
|
|
# `cat` with no args echoes stdin back to stdout. We write a line,
|
|
# read it back, then signal EOF to let cat exit cleanly.
|
|
bridge = PtyBridge.spawn([shutil.which("cat") or "cat"])
|
|
try:
|
|
assert await bridge.write(b"hello-pty\n") is True
|
|
output = _read_until(bridge, b"hello-pty")
|
|
assert b"hello-pty" in output
|
|
finally:
|
|
bridge.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_yields_while_input_is_backpressured(self, monkeypatch):
|
|
bridge = PtyBridge.__new__(PtyBridge)
|
|
bridge._fd = 123
|
|
bridge._closed = False
|
|
wait_started = asyncio.Event()
|
|
release_write = asyncio.Event()
|
|
write_calls = 0
|
|
|
|
def fake_write(fd, data):
|
|
nonlocal write_calls
|
|
assert fd == 123
|
|
write_calls += 1
|
|
if write_calls == 1:
|
|
raise BlockingIOError(errno.EAGAIN, "buffer full")
|
|
return len(data)
|
|
|
|
async def fake_wait_writable(timeout):
|
|
assert timeout > 0
|
|
wait_started.set()
|
|
await release_write.wait()
|
|
return True
|
|
|
|
monkeypatch.setattr(os, "write", fake_write)
|
|
monkeypatch.setattr(bridge, "_wait_writable", fake_wait_writable)
|
|
|
|
write_task = asyncio.create_task(bridge.write(b"queued input"))
|
|
await wait_started.wait()
|
|
|
|
# The stalled PTY write yielded control instead of pinning asyncio.
|
|
heartbeat_ran = False
|
|
|
|
async def heartbeat():
|
|
nonlocal heartbeat_ran
|
|
heartbeat_ran = True
|
|
|
|
await heartbeat()
|
|
assert heartbeat_ran is True
|
|
|
|
release_write.set()
|
|
assert await write_task is True
|
|
assert write_calls == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_reports_sustained_backpressure(self, monkeypatch):
|
|
bridge = PtyBridge.__new__(PtyBridge)
|
|
bridge._fd = 123
|
|
bridge._closed = False
|
|
|
|
def fake_write(_fd, _data):
|
|
raise BlockingIOError(errno.EAGAIN, "buffer full")
|
|
|
|
async def never_writable(_timeout):
|
|
return False
|
|
|
|
monkeypatch.setattr(os, "write", fake_write)
|
|
monkeypatch.setattr(bridge, "_wait_writable", never_writable)
|
|
|
|
assert await bridge.write(b"queued input") is False
|
|
|
|
def test_read_treats_nonblocking_read_race_as_idle(self, monkeypatch):
|
|
bridge = PtyBridge.__new__(PtyBridge)
|
|
bridge._fd = 123
|
|
bridge._closed = False
|
|
|
|
monkeypatch.setattr(select, "select", lambda *_args: ([123], [], []))
|
|
|
|
def would_block(_fd, _size):
|
|
raise BlockingIOError(errno.EAGAIN, "try again")
|
|
|
|
monkeypatch.setattr(os, "read", would_block)
|
|
assert bridge.read(timeout=0.01) == b""
|
|
|
|
def test_read_returns_none_after_child_exits(self):
|
|
bridge = PtyBridge.spawn(["/bin/sh", "-c", "printf done"])
|
|
try:
|
|
_read_until(bridge, b"done")
|
|
# Give the child a beat to exit cleanly, then drain until EOF.
|
|
deadline = time.monotonic() + 3.0
|
|
while bridge.is_alive() and time.monotonic() < deadline:
|
|
bridge.read(timeout=0.1)
|
|
# Next reads after exit should return None (EOF), not raise.
|
|
got_none = False
|
|
for _ in range(10):
|
|
if bridge.read(timeout=0.1) is None:
|
|
got_none = True
|
|
break
|
|
assert got_none, "PtyBridge.read did not return None after child EOF"
|
|
finally:
|
|
bridge.close()
|
|
|
|
@skip_on_windows
|
|
class TestPtyBridgeResize:
|
|
def test_resize_updates_child_winsize(self):
|
|
# Query the TTY ioctl directly instead of using tput, which requires
|
|
# TERM and fails in GitHub Actions' non-interactive environment.
|
|
winsize_script = (
|
|
"import fcntl, struct, termios, time; "
|
|
"time.sleep(0.1); "
|
|
"rows, cols, *_ = struct.unpack('HHHH', "
|
|
"fcntl.ioctl(0, termios.TIOCGWINSZ, b'\\0' * 8)); "
|
|
"print(cols); print(rows)"
|
|
)
|
|
bridge = PtyBridge.spawn(
|
|
[sys.executable, "-c", winsize_script],
|
|
cols=80,
|
|
rows=24,
|
|
)
|
|
try:
|
|
bridge.resize(cols=123, rows=45)
|
|
output = _read_until(bridge, b"45", timeout=5.0)
|
|
# tput prints just the numbers, one per line
|
|
assert b"123" in output
|
|
assert b"45" in output
|
|
finally:
|
|
bridge.close()
|
|
|
|
@skip_on_windows
|
|
class TestClampDimension:
|
|
def test_clamps_above_max(self):
|
|
from hermes_cli.pty_bridge import _MAX_COLS, _MAX_ROWS, _clamp_dimension
|
|
|
|
assert _clamp_dimension(131072, _MAX_COLS) == _MAX_COLS
|
|
assert _clamp_dimension(131072, _MAX_ROWS) == _MAX_ROWS
|
|
|
|
def test_non_numeric_falls_back_to_min(self):
|
|
from hermes_cli.pty_bridge import _MAX_COLS, _clamp_dimension
|
|
|
|
assert _clamp_dimension(None, _MAX_COLS) == 1 # type: ignore[arg-type]
|
|
assert _clamp_dimension(float("nan"), _MAX_COLS) == 1 # type: ignore[arg-type]
|
|
assert _clamp_dimension(float("inf"), _MAX_COLS) == 1 # type: ignore[arg-type]
|
|
|
|
def test_clamped_values_pack_as_unsigned_short(self):
|
|
# The whole point: clamped output must never raise struct.error.
|
|
import struct as _struct
|
|
|
|
from hermes_cli.pty_bridge import _MAX_COLS, _MAX_ROWS, _clamp_dimension
|
|
|
|
cols = _clamp_dimension(131072, _MAX_COLS)
|
|
rows = _clamp_dimension(1, _MAX_ROWS)
|
|
# Should not raise.
|
|
_struct.pack("HHHH", rows, cols, 0, 0)
|
|
|
|
@skip_on_windows
|
|
class TestPtyBridgeClose:
|
|
def test_close_is_idempotent(self):
|
|
bridge = PtyBridge.spawn(["/bin/sh", "-c", "sleep 30"])
|
|
bridge.close()
|
|
bridge.close() # must not raise
|
|
assert not bridge.is_alive()
|
|
|
|
def test_close_terminates_long_running_child(self):
|
|
bridge = PtyBridge.spawn(["/bin/sh", "-c", "sleep 30"])
|
|
pid = bridge.pid
|
|
bridge.close()
|
|
# Give the kernel a moment to reap
|
|
deadline = time.monotonic() + 3.0
|
|
reaped = False
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
os.kill(pid, 0)
|
|
time.sleep(0.05)
|
|
except ProcessLookupError:
|
|
reaped = True
|
|
break
|
|
assert reaped, f"pid {pid} still running after close()"
|
|
|
|
def test_close_signals_child_process_group(self, monkeypatch):
|
|
sent: list[tuple[int, signal.Signals]] = []
|
|
|
|
class _FakeProc:
|
|
pid = 12345
|
|
fd = -1
|
|
|
|
def __init__(self):
|
|
self.alive = True
|
|
|
|
def isalive(self):
|
|
return self.alive
|
|
|
|
def kill(self, sig):
|
|
raise AssertionError(f"single-process kill used: {sig}")
|
|
|
|
def close(self, force=False):
|
|
self.closed = force
|
|
|
|
fake = _FakeProc()
|
|
|
|
def fake_killpg(pgid, sig):
|
|
if not fake.alive:
|
|
raise ProcessLookupError # the whole group exited with its leader
|
|
sent.append((pgid, sig))
|
|
fake.alive = False
|
|
|
|
monkeypatch.setattr(os, "killpg", fake_killpg)
|
|
|
|
bridge = PtyBridge.__new__(PtyBridge)
|
|
bridge._proc = fake
|
|
bridge._fd = -1
|
|
# recorded at spawn; the child leads its own group (pgid == pid)
|
|
bridge._pgid = 12345
|
|
bridge._closed = False
|
|
|
|
bridge.close()
|
|
|
|
assert sent == [(12345, signal.SIGHUP)]
|
|
assert bridge._closed is True
|
|
|
|
def test_close_never_killpgs_a_shared_group(self, monkeypatch):
|
|
"""A child that does not lead its own group shares OURS — killpg would
|
|
signal the TUI's own process tree. The fallback is per-signal proc.kill."""
|
|
sent: list[tuple[int, signal.Signals]] = []
|
|
direct: list[signal.Signals] = []
|
|
|
|
class _FakeProc:
|
|
pid = 12345
|
|
fd = -1
|
|
|
|
def __init__(self):
|
|
self.alive = True
|
|
|
|
def isalive(self):
|
|
return self.alive
|
|
|
|
def kill(self, sig):
|
|
direct.append(sig)
|
|
self.alive = False
|
|
|
|
def close(self, force=False):
|
|
self.closed = force
|
|
|
|
fake = _FakeProc()
|
|
monkeypatch.setattr(os, "killpg", lambda pgid, sig: sent.append((pgid, sig)))
|
|
|
|
bridge = PtyBridge.__new__(PtyBridge)
|
|
bridge._proc = fake
|
|
bridge._fd = -1
|
|
# recorded at spawn: the child was found in OUR group (pgid != pid)
|
|
bridge._pgid = 67890
|
|
bridge._closed = False
|
|
|
|
bridge.close()
|
|
|
|
assert sent == [] # the guard nulls the non-leader pgid: no group signal
|
|
assert direct == [signal.SIGHUP]
|
|
assert bridge._closed is True
|
|
|
|
def test_close_ends_helpers_that_outlive_a_dead_leader(self):
|
|
# #76759: the helper ignores SIGHUP and keeps the PTY slave open, so the leader's death
|
|
# never produces EOF. close() must still end it.
|
|
bridge = PtyBridge.spawn(["/bin/sh", "-c", "trap '' HUP; sleep 60 & echo helper=$!; exec sleep 60"])
|
|
out = _read_until(bridge, b"\n", timeout=5.0)
|
|
helper = int(out.split(b"helper=")[1].split()[0])
|
|
os.kill(bridge.pid, signal.SIGKILL)
|
|
deadline = time.monotonic() + 3.0
|
|
while bridge.is_alive() and time.monotonic() < deadline:
|
|
time.sleep(0.02)
|
|
|
|
bridge.close()
|
|
|
|
deadline = time.monotonic() + 3.0
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
os.kill(helper, 0)
|
|
except ProcessLookupError:
|
|
return
|
|
time.sleep(0.05)
|
|
os.kill(helper, signal.SIGKILL)
|
|
pytest.fail(f"helper pid {helper} survived close() after its leader died")
|
|
|
|
@pytest.mark.live_system_guard_bypass
|
|
def test_close_sweeps_helpers_of_a_non_leader_child(self):
|
|
# The group-leadership guard (above) signals a shared-group child one PID at a
|
|
# time, so the group sweep cannot run: no killpg is ever safe. A helper that
|
|
# ignores SIGHUP must still be snapshotted before its parent dies and killed
|
|
# individually, or it keeps the PTY slave open forever (#76759).
|
|
helper = None
|
|
|
|
class _PopenBackedProc:
|
|
"""Duck-typed ptyprocess handle around a plain Popen child that stays in
|
|
OUR process group (no start_new_session), exercising the non-leader branch."""
|
|
|
|
def __init__(self, popen):
|
|
self._popen = popen
|
|
self.pid = popen.pid
|
|
self.fd = -1
|
|
|
|
def isalive(self):
|
|
return self._popen.poll() is None
|
|
|
|
def kill(self, sig):
|
|
self._popen.send_signal(sig)
|
|
|
|
def close(self, force=False):
|
|
pass
|
|
|
|
# The helper subshell ignores SIGHUP itself (trap is inherited by its sleep
|
|
# loop), so only the sweep's SIGKILL can end it. Both it and the leader loop
|
|
# forever; the leader dies on the first SIGHUP close() sends it.
|
|
script = (
|
|
'/bin/sh -c \'trap "" HUP; while :; do sleep 0.05; done\' & '
|
|
"echo helper=$!; while :; do sleep 0.05; done"
|
|
)
|
|
popen = subprocess.Popen(["/bin/sh", "-c", script], stdout=subprocess.PIPE, text=True)
|
|
out = popen.stdout
|
|
assert out is not None
|
|
try:
|
|
line = ""
|
|
while "helper=" not in line:
|
|
line = out.readline()
|
|
if not line:
|
|
pytest.fail("leader exited before printing its helper pid")
|
|
helper = int(line.split("helper=")[1].strip())
|
|
|
|
bridge = PtyBridge.__new__(PtyBridge)
|
|
bridge._proc = _PopenBackedProc(popen)
|
|
bridge._fd = -1
|
|
# recorded at spawn: the child was found in OUR group (pgid != pid)
|
|
bridge._pgid = os.getpgid(popen.pid)
|
|
bridge._closed = False
|
|
|
|
bridge.close()
|
|
finally:
|
|
out.close()
|
|
if popen.poll() is None:
|
|
popen.kill()
|
|
popen.wait()
|
|
|
|
deadline = time.monotonic() + 3.0
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
os.kill(helper, 0)
|
|
except ProcessLookupError:
|
|
return
|
|
time.sleep(0.05)
|
|
os.kill(helper, signal.SIGKILL)
|
|
pytest.fail(f"helper pid {helper} survived close() of its non-leader parent")
|
|
|
|
def test_close_lets_a_helper_finish_its_sighup_shutdown(self, tmp_path):
|
|
# The TUI gateway saves its sessions on SIGHUP within a 1 s grace. Once the group got
|
|
# SIGHUP, close() must wait for it, not re-signal or SIGKILL it early.
|
|
marker = tmp_path / "saved"
|
|
# The trap ignores further SIGHUPs (the kernel may re-send one when the session leader
|
|
# exits), so only an early SIGKILL from close() can stop the save.
|
|
script = tmp_path / "helper.sh"
|
|
script.write_text(
|
|
f"trap 'trap \"\" HUP; sleep 0.6; echo ok > {marker}; exit 0' HUP\n"
|
|
"echo armed\n"
|
|
"while :; do sleep 0.05; done\n"
|
|
)
|
|
bridge = PtyBridge.spawn(["/bin/sh", "-c", f"/bin/sh {script} & exec sleep 60"])
|
|
_read_until(bridge, b"armed", timeout=10.0) # the helper's trap is installed
|
|
|
|
bridge.close()
|
|
|
|
assert marker.read_text().strip() == "ok"
|
|
|
|
@skip_on_windows
|
|
class TestPtyBridgeEnv:
|
|
def test_cwd_is_respected(self, tmp_path):
|
|
bridge = PtyBridge.spawn(
|
|
["/bin/sh", "-c", "pwd"],
|
|
cwd=str(tmp_path),
|
|
)
|
|
try:
|
|
output = _read_until(bridge, str(tmp_path).encode())
|
|
assert str(tmp_path).encode() in output
|
|
finally:
|
|
bridge.close()
|