Files
turnstone/tests/test_background_shells.py
Patrick Buckley f311555026 fix(bash): report exit code for killed shells; single import style in registry tests
Copilot: bash_output's schema promises the exit code once the shell has
exited, but the formatting attached it only to 'completed' — a killed
shell has one too (the negated signal number). Attach it to any exited
state.

Code-quality: the registry test file mixed a top-level from-import with
function-local 'import ... as bg_mod' for monkeypatching module
attributes; one from-style module alias at the top now serves all of
them.

(cherry picked from commit dc647f4d63)
2026-07-11 19:05:48 -07:00

671 lines
25 KiB
Python

"""Unit tests for the per-session background-shell registry (#817).
The registry backs the ``bash(run_in_background=true)`` / ``bash_output`` /
``kill_shell`` tool surface: it spawns detached shells (``bash_N`` handles),
buffers their merged output in a capped rolling buffer, serves delta reads
(only lines since the last read), and reaps whole session groups on kill /
owner reap / close — the #816 rule (the tracked command defines the lifetime,
nothing escapes its process group) extended to explicit backgrounding.
Pure registry tests — no ChatSession. Session wiring is covered in
``test_bash_background_tool.py``.
"""
import re
import threading
import time
import pytest
from tests._proc_helpers import kill_pid as _kill_pid
from tests._proc_helpers import pid_alive as _pid_alive
from tests._proc_helpers import poll_until as _wait_until
# Module alias (from-style, matching the symbol imports below) for tests
# that monkeypatch module attributes (os.killpg, subprocess.Popen, ...).
from turnstone.core import background_shells as bg_mod
from turnstone.core.background_shells import (
BackgroundShellRegistry,
FilterExecError,
FilterTimeoutError,
TooManyShellsError,
UnknownShellError,
)
def _wait_status(shell, status, timeout=10.0):
return _wait_until(lambda: shell.status == status, timeout=timeout)
@pytest.fixture
def registry():
reg = BackgroundShellRegistry()
yield reg
reg.close()
# ---------------------------------------------------------------------------
# Handles + spawning
# ---------------------------------------------------------------------------
def test_spawn_returns_incrementing_bash_handles(registry):
s1 = registry.spawn("sleep 30")
s2 = registry.spawn("sleep 30")
assert s1.shell_id == "bash_1"
assert s2.shell_id == "bash_2"
def test_spawned_shell_is_running_with_live_pid(registry):
shell = registry.spawn("sleep 30")
assert shell.status == "running"
assert _pid_alive(shell.pid)
def test_spawn_records_command(registry):
shell = registry.spawn("sleep 30")
assert shell.command == "sleep 30"
def test_spawn_after_close_is_refused():
reg = BackgroundShellRegistry()
reg.close()
with pytest.raises(RuntimeError):
reg.spawn("echo hi")
def test_max_live_shells_cap():
reg = BackgroundShellRegistry(max_shells=2)
try:
reg.spawn("sleep 30")
s2 = reg.spawn("sleep 30")
with pytest.raises(TooManyShellsError):
reg.spawn("sleep 30")
# Cap counts LIVE shells: killing one frees a slot.
reg.kill(s2.shell_id)
s3 = reg.spawn("sleep 30")
assert s3.status == "running"
finally:
reg.close()
def test_completed_shells_do_not_count_toward_cap():
reg = BackgroundShellRegistry(max_shells=1)
try:
s1 = reg.spawn("true")
assert _wait_status(s1, "completed")
s2 = reg.spawn("sleep 30")
assert s2.status == "running"
finally:
reg.close()
# ---------------------------------------------------------------------------
# Exit tracking
# ---------------------------------------------------------------------------
def test_natural_exit_sets_completed_and_exit_code(registry):
shell = registry.spawn("exit 7")
assert _wait_status(shell, "completed")
assert shell.exit_code == 7
def test_output_is_complete_once_completed(registry):
"""Status flips to completed only after the drains finish: a read at
completed must see everything the command wrote."""
shell = registry.spawn("echo alpha; echo beta")
assert _wait_status(shell, "completed")
read = registry.read(shell.shell_id)
assert [ln.strip() for ln in read.lines] == ["alpha", "beta"]
def test_leader_exit_reaps_backgrounded_grandchild(registry, tmp_path):
"""#816 consistency: the tracked command defines the lifetime. When the
leader exits, the whole session group is killed — a child the command
backgrounded does not outlive it."""
pidfile = tmp_path / "bg.pid"
shell = registry.spawn(f"sleep 60 & echo $! > {pidfile}; echo done")
bg_pid = None
try:
assert _wait_status(shell, "completed")
bg_pid = int(pidfile.read_text().strip())
assert _wait_until(lambda: not _pid_alive(bg_pid)), (
f"grandchild {bg_pid} leaked past leader exit"
)
read = registry.read(shell.shell_id)
assert "done" in "".join(read.lines)
finally:
if bg_pid is not None:
_kill_pid(bg_pid)
def test_stderr_lines_are_tagged_inline(registry):
shell = registry.spawn("echo out; echo err >&2")
assert _wait_status(shell, "completed")
lines = [ln.strip() for ln in registry.read(shell.shell_id).lines]
assert "out" in lines
assert "[stderr] err" in lines
# ---------------------------------------------------------------------------
# Delta reads
# ---------------------------------------------------------------------------
def test_read_returns_only_new_lines_since_last_read(registry):
"""The load-bearing convention: consecutive reads never overlap and never
drop a line — collecting across polls yields each line exactly once."""
shell = registry.spawn("echo one; echo two; sleep 0.4; echo three; sleep 30")
collected: list[str] = []
def _collect():
collected.extend(ln.strip() for ln in registry.read(shell.shell_id).lines)
return "three" in collected
assert _wait_until(_collect)
assert collected == ["one", "two", "three"]
registry.kill(shell.shell_id)
def test_read_after_exit_then_again_reports_no_new_output(registry):
shell = registry.spawn("echo hi")
assert _wait_status(shell, "completed")
first = registry.read(shell.shell_id)
assert [ln.strip() for ln in first.lines] == ["hi"]
second = registry.read(shell.shell_id)
assert second.lines == []
assert second.status == "completed"
assert second.exit_code == 0
def test_read_reports_status_and_exit_code(registry):
shell = registry.spawn("sleep 30")
read = registry.read(shell.shell_id)
assert read.shell_id == shell.shell_id
assert read.status == "running"
assert read.exit_code is None
registry.kill(shell.shell_id)
def test_read_unknown_id_raises_with_live_ids(registry):
registry.spawn("sleep 30")
with pytest.raises(UnknownShellError) as excinfo:
registry.read("bash_99")
assert "bash_99" in str(excinfo.value)
assert "bash_1" in str(excinfo.value)
def test_read_unknown_id_when_registry_empty(registry):
with pytest.raises(UnknownShellError):
registry.read("bash_1")
# ---------------------------------------------------------------------------
# Filter
# ---------------------------------------------------------------------------
def test_filter_selects_matching_lines_only(registry):
shell = registry.spawn("echo match-a; echo skip-b; echo match-c")
assert _wait_status(shell, "completed")
read = registry.read(shell.shell_id, filter_pattern="^match")
assert [ln.strip() for ln in read.lines] == ["match-a", "match-c"]
def test_filter_is_display_only_and_consumes_the_delta(registry):
"""Filtered-out lines are consumed, not deferred — the cursor advances
past the whole delta (Claude Code ``BashOutput`` semantics)."""
shell = registry.spawn("echo match-a; echo skip-b")
assert _wait_status(shell, "completed")
first = registry.read(shell.shell_id, filter_pattern="^match")
assert [ln.strip() for ln in first.lines] == ["match-a"]
assert first.new_line_count == 2 # both lines were new, one shown
second = registry.read(shell.shell_id)
assert second.lines == []
assert second.new_line_count == 0
def test_filter_uses_search_not_match(registry):
shell = registry.spawn("echo prefix-needle-suffix")
assert _wait_status(shell, "completed")
read = registry.read(shell.shell_id, filter_pattern="needle")
assert len(read.lines) == 1
def test_invalid_filter_regex_raises(registry):
shell = registry.spawn("echo hi")
assert _wait_status(shell, "completed")
with pytest.raises(re.error):
registry.read(shell.shell_id, filter_pattern="[unclosed")
# ---------------------------------------------------------------------------
# Buffer cap
# ---------------------------------------------------------------------------
def test_buffer_cap_drops_oldest_and_reports_gap():
reg = BackgroundShellRegistry(max_buffer_chars=200)
try:
shell = reg.spawn('for i in $(seq 1 50); do echo "line-$i-padded-to-length"; done')
assert _wait_status(shell, "completed")
read = reg.read(shell.shell_id)
assert read.dropped_lines > 0
# Newest output survives; the tail is intact.
assert read.lines, "cap must retain the newest lines, not drop everything"
assert read.lines[-1].strip() == "line-50-padded-to-length"
finally:
reg.close()
def test_unread_lines_excludes_buffer_evicted():
"""The exit notice's line count must not promise evicted output."""
reg = BackgroundShellRegistry(max_buffer_chars=200)
try:
shell = reg.spawn('for i in $(seq 1 50); do echo "line-$i-padded-to-length"; done')
assert _wait_status(shell, "completed")
with shell.lock:
retained = len(shell._buffer)
assert shell.unread_lines == retained
finally:
reg.close()
def test_buffer_gap_is_relative_to_cursor():
"""Lines dropped BEFORE being read are a reported gap; lines already
read and then dropped are not."""
reg = BackgroundShellRegistry(max_buffer_chars=10_000)
try:
shell = reg.spawn("echo early; sleep 30")
# Each poll consumes whatever has arrived; stop once something did.
assert _wait_until(lambda: bool(reg.read(shell.shell_id).lines))
# Everything emitted so far is read; nothing has been dropped.
read = reg.read(shell.shell_id)
assert read.dropped_lines == 0
reg.kill(shell.shell_id)
finally:
reg.close()
# ---------------------------------------------------------------------------
# Kill / reap / close
# ---------------------------------------------------------------------------
def test_kill_marks_killed_and_reaps_group(registry, tmp_path):
pidfile = tmp_path / "bg.pid"
shell = registry.spawn(f"sleep 60 & echo $! > {pidfile}; sleep 60")
assert _wait_until(pidfile.exists)
bg_pid = int(pidfile.read_text().strip())
try:
killed = registry.kill(shell.shell_id)
assert killed.status == "killed"
assert _wait_until(lambda: not _pid_alive(shell.pid))
assert _wait_until(lambda: not _pid_alive(bg_pid)), "grandchild survived kill"
finally:
_kill_pid(bg_pid)
def test_kill_unknown_id_raises(registry):
with pytest.raises(UnknownShellError):
registry.kill("bash_7")
def test_killed_shell_output_remains_readable(registry, tmp_path):
"""Output that arrived before the kill survives it: the record keeps its
buffer, and ``kill`` returns only after the drains have flushed."""
sentinel = tmp_path / "started"
shell = registry.spawn(f"echo before-kill; touch {sentinel}; sleep 60")
assert _wait_until(sentinel.exists)
registry.kill(shell.shell_id)
read = registry.read(shell.shell_id)
assert read.status == "killed"
assert "before-kill" in "".join(read.lines)
def test_signal_all_kills_live_shells_without_closing(registry):
"""signal_all is the instant half of teardown: every live group dies,
but the registry stays open (records intact, spawns still allowed) —
close() remains the complete teardown."""
s1 = registry.spawn("sleep 60")
s2 = registry.spawn("sleep 60")
registry.signal_all()
assert _wait_until(lambda: not _pid_alive(s1.pid))
assert _wait_until(lambda: not _pid_alive(s2.pid))
assert registry.has(s1.shell_id), "signal_all must not drop records"
s3 = registry.spawn("true")
assert _wait_status(s3, "completed"), "registry must remain usable after signal_all"
def test_close_kills_everything_and_is_idempotent():
reg = BackgroundShellRegistry()
s1 = reg.spawn("sleep 60")
s2 = reg.spawn("sleep 60")
reg.close()
assert not _pid_alive(s1.pid)
assert not _pid_alive(s2.pid)
reg.close() # second close is a no-op
def test_reap_owner_kills_only_that_owners_shells(registry):
mine = registry.spawn("sleep 60", owner="agent-1")
other = registry.spawn("sleep 60", owner="agent-2")
main = registry.spawn("sleep 60")
registry.reap(owner="agent-1")
assert _wait_until(lambda: not _pid_alive(mine.pid))
assert _pid_alive(other.pid)
assert _pid_alive(main.pid)
# ---------------------------------------------------------------------------
# Owner scoping
# ---------------------------------------------------------------------------
def test_owner_scoped_lookup_isolates_shells(registry):
agent_shell = registry.spawn("sleep 30", owner="agent-1")
main_shell = registry.spawn("sleep 30")
# Main scope cannot see the agent's shell...
with pytest.raises(UnknownShellError):
registry.read(agent_shell.shell_id)
# ...and the agent scope cannot see the main shell.
with pytest.raises(UnknownShellError):
registry.read(main_shell.shell_id, owner="agent-1")
# Each side reads its own.
assert registry.read(agent_shell.shell_id, owner="agent-1").status == "running"
assert registry.read(main_shell.shell_id).status == "running"
def test_shells_snapshot_is_owner_scoped(registry):
registry.spawn("sleep 30", owner="agent-1")
registry.spawn("sleep 30")
assert [s.owner for s in registry.shells(owner="agent-1")] == ["agent-1"]
assert [s.owner for s in registry.shells()] == [None]
def test_handles_are_unique_across_owners(registry):
a = registry.spawn("sleep 30", owner="agent-1")
b = registry.spawn("sleep 30")
assert a.shell_id != b.shell_id
# ---------------------------------------------------------------------------
# Exit callback (the notice hook)
# ---------------------------------------------------------------------------
def test_on_exit_fires_once_on_natural_exit():
fired = threading.Event()
seen = []
def _on_exit(shell):
seen.append(shell)
fired.set()
reg = BackgroundShellRegistry(on_exit=_on_exit)
try:
shell = reg.spawn("echo done")
assert fired.wait(10)
assert len(seen) == 1
assert seen[0].shell_id == shell.shell_id
assert seen[0].exit_code == 0
finally:
reg.close()
def test_on_exit_not_fired_for_kill():
seen = []
reg = BackgroundShellRegistry(on_exit=seen.append)
try:
shell = reg.spawn("sleep 60")
reg.kill(shell.shell_id)
assert _wait_until(lambda: not _pid_alive(shell.pid))
time.sleep(0.2) # give a buggy late callback a chance to land
assert seen == []
finally:
reg.close()
def test_on_exit_not_fired_for_close():
seen = []
reg = BackgroundShellRegistry(on_exit=seen.append)
shell = reg.spawn("sleep 60")
reg.close()
assert not _pid_alive(shell.pid)
time.sleep(0.2)
assert seen == []
# ---------------------------------------------------------------------------
# Review-hardening regressions (#817 code review)
# ---------------------------------------------------------------------------
def test_kill_on_completed_shell_does_not_signal_group(registry, monkeypatch):
"""A completed shell's pgid is a stale snapshot the OS may have recycled
to an unrelated process group — kill() must not signal it (the waiter's
own group kill already ran at exit, when the pgid was fresh)."""
shell = registry.spawn("true")
assert _wait_status(shell, "completed")
calls = []
monkeypatch.setattr(bg_mod.os, "killpg", lambda *a: calls.append(a))
killed = registry.kill(shell.shell_id)
assert calls == [], "killpg must not fire for an already-exited shell"
assert killed.status == "completed", "a natural exit must not be relabelled 'killed'"
def test_close_is_time_bounded_with_pipe_holding_escapee(registry, tmp_path):
"""An escaped-group grandchild that holds the output pipes wedges the
drain threads. close() must still return within its total budget —
it can run under the server's async close route, where an unbounded
join would freeze the whole node's event loop."""
pidfile = tmp_path / "holder.pid"
# ``setsid`` puts the sleep in a NEW session (outside our kill group)
# while it still inherits our stdout/stderr pipes — the accepted
# leaked-daemon case from the module docstring.
shell = registry.spawn(f"setsid sleep 60 & echo $! > {pidfile}; echo started")
assert _wait_until(pidfile.exists)
holder_pid = int(pidfile.read_text().strip())
try:
start = time.monotonic()
registry.close()
elapsed = time.monotonic() - start
assert elapsed < 8, f"close() took {elapsed:.1f}s — teardown must be budget-bounded"
finally:
_kill_pid(holder_pid)
# The holder is dead, so the wedged drains EOF promptly; wait for
# them here so the conftest leak guard sees a clean teardown.
assert _wait_until(lambda: not any(t.is_alive() for t in shell._threads))
def test_exited_records_are_pruned_at_cap():
reg = BackgroundShellRegistry(max_exited_records=2)
try:
shells = [reg.spawn(f"echo job-{i}") for i in range(3)]
for s in shells:
assert _wait_status(s, "completed")
# Eviction happens on each exit; poll until the oldest is gone
# (waiter threads race, prune runs per-exit).
assert _wait_until(lambda: not reg.has(shells[0].shell_id))
assert reg.has(shells[1].shell_id)
assert reg.has(shells[2].shell_id)
with pytest.raises(UnknownShellError):
reg.read(shells[0].shell_id)
finally:
reg.close()
def test_catastrophic_filter_times_out_without_consuming(registry):
"""A backtracking-bomb filter must error within the bound and consume
NOTHING — the retry without a filter still gets the output. The match
runs in a killable child process: sre holds the GIL, so an in-process
bomb would freeze the whole interpreter, watchdogs included."""
# One ~3000-char line of a's ending in 'b' — the classic (a+)+$ bomb
# subject — followed by a sentinel line.
shell = registry.spawn("printf 'a%.0s' $(seq 1 3000); echo b; echo tail-line")
assert _wait_status(shell, "completed")
start = time.monotonic()
with pytest.raises(FilterTimeoutError):
registry.read(shell.shell_id, filter_pattern=r"(a+)+$")
assert time.monotonic() - start < 10, "filter timeout must be bounded"
# Nothing was consumed: an unfiltered read sees the whole delta.
read = registry.read(shell.shell_id)
assert any("tail-line" in ln for ln in read.lines)
def test_overlong_filter_pattern_is_rejected(registry):
shell = registry.spawn("echo hi")
assert _wait_status(shell, "completed")
with pytest.raises(re.error):
registry.read(shell.shell_id, filter_pattern="x" * 600)
def test_cap_error_is_owner_scope_honest():
"""The cap is registry-wide, but the advice must only name shells the
caller can actually kill — kill_shell is owner-scoped."""
reg = BackgroundShellRegistry(max_shells=1)
try:
reg.spawn("sleep 30") # main scope fills the cap
with pytest.raises(TooManyShellsError) as excinfo:
reg.spawn("sleep 30", owner="agent-1")
msg = str(excinfo.value)
assert "bash_1" not in msg, "must not advise killing another scope's shell"
assert "other agents" in msg
# The same-scope variant names the killable shell.
with pytest.raises(TooManyShellsError) as excinfo2:
reg.spawn("sleep 30")
assert "bash_1" in str(excinfo2.value)
assert "kill_shell" in str(excinfo2.value)
finally:
reg.close()
def test_prune_evicts_by_exit_order_not_spawn_order():
"""A long-lived first-spawned server must never be evicted by its OWN
exit's prune once enough later jobs have finished — eviction follows
exit order, so the just-exited shell is always the newest record."""
reg = BackgroundShellRegistry(max_exited_records=2)
try:
server = reg.spawn("sleep 30") # bash_1, exits LAST
jobs = [reg.spawn(f"echo job-{i}") for i in range(3)]
for job in jobs:
assert _wait_status(job, "completed")
reg.kill(server.shell_id)
assert reg.has(server.shell_id), "the just-exited shell must survive its own exit's prune"
# The earliest-EXITED job is the eviction victim, not bash_1.
assert _wait_until(lambda: len(reg.shells()) <= 3)
assert reg.read(server.shell_id).status == "killed"
finally:
reg.close()
def test_thread_start_failure_leaves_no_orphan_record(registry, monkeypatch, tmp_path):
"""If Thread.start raises (thread exhaustion), the record must be
unregistered and the fresh group reaped — an orphan with never-started
Thread objects would make every later close()/reap() join raise and
abort session teardown."""
pidfile = tmp_path / "leader.pid"
real_thread = bg_mod.threading.Thread
class FailingWaiterThread(real_thread):
def start(self):
if "bg-shell-wait" in (self.name or ""):
raise RuntimeError("can't start new thread")
super().start()
monkeypatch.setattr(bg_mod.threading, "Thread", FailingWaiterThread)
with pytest.raises(RuntimeError):
registry.spawn(f"echo $$ > {pidfile}; sleep 60")
assert registry.shells() == [], "failed spawn must not strand a record"
if pidfile.exists():
leader_pid = int(pidfile.read_text().strip())
assert _wait_until(lambda: not _pid_alive(leader_pid)), "fresh group leaked"
monkeypatch.undo()
registry.close() # must not raise on the (empty) registry
def test_filter_helper_failure_reports_exec_error_not_timeout(registry, monkeypatch):
"""A crashed helper must not tell the model its (fine) pattern was too
slow — and must not consume the delta."""
shell = registry.spawn("echo hello")
assert _wait_status(shell, "completed")
monkeypatch.setattr(bg_mod.sys, "executable", "/bin/false")
with pytest.raises(FilterExecError) as excinfo:
registry.read(shell.shell_id, filter_pattern="hello")
assert "not a problem with your pattern" in str(excinfo.value)
monkeypatch.undo()
read = registry.read(shell.shell_id)
assert [ln.strip() for ln in read.lines] == ["hello"]
def test_filter_matches_only_within_line_cap_and_reports_clipping(registry):
"""Lines are truncated parent-side before shipping to the helper: a
match beyond the per-line cap is not found (a filter targets log
lines), and a huge retained line cannot burn the time budget on I/O.
The clipping is NEVER silent — the read reports how many lines were
only partially visible to the pattern."""
shell = registry.spawn("printf 'x%.0s' $(seq 1 5000); echo needle-suffix")
assert _wait_status(shell, "completed")
read = registry.read(shell.shell_id, filter_pattern="needle")
assert read.lines == []
assert read.new_line_count == 1
assert read.clipped_lines == 1
def test_concurrent_reads_never_double_deliver(registry):
"""Two simultaneous reads of one shell must SPLIT the delta between
them, never both return it — the whole pass (snapshot → commit)
serializes per shell. Without that, a parallel tool batch reading the
same handle gets every line twice."""
shell = registry.spawn("seq 1 200")
assert _wait_status(shell, "completed")
results: list[list[str]] = [[], []]
barrier = threading.Barrier(2)
def _reader(slot: int) -> None:
barrier.wait()
results[slot] = [ln.strip() for ln in registry.read(shell.shell_id).lines]
threads = [threading.Thread(target=_reader, args=(i,)) for i in range(2)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
combined = results[0] + results[1]
assert len(combined) == 200, f"expected each line exactly once, got {len(combined)}"
assert sorted(combined, key=int) == [str(i) for i in range(1, 201)]
def test_filter_helper_spawn_failure_is_exec_error(registry, monkeypatch):
"""A helper that fails to LAUNCH (fork pressure) must land in the same
honest FilterExecError as a crashed helper — not escape as a raw
OSError blaming nothing — and must not consume the delta."""
shell = registry.spawn("echo hello")
assert _wait_status(shell, "completed")
def _boom(*args, **kwargs):
raise BlockingIOError("Resource temporarily unavailable")
monkeypatch.setattr(bg_mod.subprocess, "Popen", _boom)
with pytest.raises(FilterExecError):
registry.read(shell.shell_id, filter_pattern="hello")
monkeypatch.undo()
read = registry.read(shell.shell_id)
assert [ln.strip() for ln in read.lines] == ["hello"]
def test_on_exit_exception_does_not_wedge_the_shell():
def _boom(shell):
raise RuntimeError("callback bug")
reg = BackgroundShellRegistry(on_exit=_boom)
try:
shell = reg.spawn("echo hi")
# The waiter thread must survive the callback raising: status still
# lands and output is still readable.
assert _wait_status(shell, "completed")
assert [ln.strip() for ln in reg.read(shell.shell_id).lines] == ["hi"]
finally:
reg.close()