mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
dc647f4d63
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.
671 lines
25 KiB
Python
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()
|