mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
45d95a2c1f
Restore 'start a dev server, use it in a later call' as an explicit opt-in
after #816 made bash reap its whole process group on return. The surface
mirrors the dominant coding-agent convention: bash(run_in_background=true)
returns a bash_N handle immediately; bash_output(id, filter?) returns only
output produced since the previous read plus status and exit code;
kill_shell(id) terminates the shell's whole process group.
- Per-session BackgroundShellRegistry: capped rolling line buffer with
drop-oldest gap accounting, exit-order record pruning, owner scoping for
task_agents (shells reaped when the agent finishes), liveness-guarded
group kills (a stale pgid is never signalled), budgeted teardown joins.
- Exit notices ride a shared external-event rail (sanitize, soft cap,
channel 'any', idle wake) now common to watch fires; a new 'quiet'
NudgeQueue channel lets a user cancel defer pending notices without
letting them re-wake the stopped workstream, and failed wake delivery
re-queues external notices seq- and predicate-intact without re-arming
the wake gate.
- The bash_output filter runs in a killable subprocess: sre holds the GIL
for an entire search, so no in-process timeout can bound a hostile
pattern. Scrubbed child env, pinned UTF-8 pipes, honest timeout-vs-
helper-failure error taxonomy, per-line match window with explicit
clipping notes; a failed filter never consumes the delta.
- run_in_background rides the bash intent-judge projection; bash_output is
exempt from the repeat warning but still recorded so interleaved polls
keep breaking other tools' streaks; all bash boolean args share one
lenient coercion dialect.
- Shells survive generation cancel and die with the workstream: every
teardown path funnels through ChatSession.close(); CLI exit and the
server lifespan now close every loaded session, signal-first and
Ctrl-C-safe, so nothing detached outlives a graceful shutdown.
(cherry picked from commit ab7d56e0ba)
166 lines
6.1 KiB
Python
166 lines
6.1 KiB
Python
"""Regression tests for the bash tool hanging on a backgrounded child.
|
|
|
|
A bash command that backgrounds a long-lived process (``server &``,
|
|
``python -m http.server &``, any daemon) used to wedge the whole workstream
|
|
forever: the child inherits the tool's stdout/stderr pipe, so the foreground
|
|
read never hit EOF, and the timeout watchdog bailed the moment the tracked
|
|
``bash`` exited. ``_exec_bash`` now waits on the tracked process (not pipe
|
|
EOF) bounded by ``tool_timeout`` and kills the whole session group on exit, so
|
|
the call always returns and never leaks the background child.
|
|
"""
|
|
|
|
import threading
|
|
import time
|
|
|
|
from tests._proc_helpers import kill_pid as _kill_pid
|
|
from tests._proc_helpers import pid_alive as _pid_alive
|
|
from tests._session_helpers import NullUI, make_session
|
|
from turnstone.core.trajectory import EffectStatus
|
|
|
|
|
|
def _run_in_thread(fn, timeout):
|
|
"""Run ``fn`` in a daemon thread; return ``(finished, result)``."""
|
|
box = {}
|
|
|
|
def _target():
|
|
box["result"] = fn()
|
|
|
|
t = threading.Thread(target=_target, daemon=True)
|
|
t.start()
|
|
t.join(timeout)
|
|
return (not t.is_alive()), box.get("result")
|
|
|
|
|
|
def test_backgrounded_child_does_not_hang_and_is_reaped(tmp_path):
|
|
"""Foreground exits immediately but leaves ``sleep 60 &`` holding the pipe.
|
|
|
|
Old behaviour: infinite hang (EOF never arrives, watchdog bails once the
|
|
tracked bash exits). New behaviour: returns promptly and the background
|
|
child is reaped by the session-group kill.
|
|
"""
|
|
pidfile = str(tmp_path / "bg.pid")
|
|
# A generous tool_timeout proves the return comes from foreground-exit, not
|
|
# from the deadline firing.
|
|
session = make_session(tool_timeout=30)
|
|
command = f"sleep 60 & echo $! > {pidfile}; echo done"
|
|
bg_pid = None
|
|
try:
|
|
finished, result = _run_in_thread(
|
|
lambda: session._exec_bash({"call_id": "c1", "command": command}),
|
|
timeout=15,
|
|
)
|
|
assert finished, "_exec_bash hung on a backgrounded child"
|
|
assert result is not None
|
|
call_id, output = result
|
|
assert call_id == "c1"
|
|
assert "done" in output
|
|
|
|
# The backgrounded process must have been reaped by the group kill.
|
|
with open(pidfile) as f:
|
|
bg_pid = int(f.read().strip())
|
|
deadline = time.monotonic() + 5
|
|
while _pid_alive(bg_pid) and time.monotonic() < deadline:
|
|
time.sleep(0.05)
|
|
assert not _pid_alive(bg_pid), f"backgrounded child {bg_pid} leaked"
|
|
finally:
|
|
if bg_pid is not None:
|
|
_kill_pid(bg_pid)
|
|
|
|
|
|
def test_timeout_still_fires_with_backgrounded_child():
|
|
"""A silent foreground command plus a backgrounded child still hits the
|
|
deadline: the watchdog kills the whole group and the result reads UNKNOWN
|
|
(the ``unknown, never none`` timeout discipline)."""
|
|
session = make_session(tool_timeout=1)
|
|
command = "sleep 60 & sleep 60"
|
|
|
|
finished, result = _run_in_thread(
|
|
lambda: session._exec_bash({"call_id": "c1", "command": command}),
|
|
timeout=10,
|
|
)
|
|
assert finished, "_exec_bash did not return at its deadline"
|
|
assert result is not None
|
|
call_id, output = result
|
|
assert call_id == "c1"
|
|
assert "timed out" in output.lower()
|
|
assert "UNKNOWN" in output
|
|
assert session._tool_status.get("c1") is EffectStatus.UNKNOWN
|
|
|
|
|
|
def test_undecodable_output_is_preserved_not_swallowed():
|
|
"""Undecodable bytes on stdout must not silently vanish.
|
|
|
|
The drain's broad ``except (ValueError, OSError)`` would otherwise catch the
|
|
``UnicodeDecodeError`` (a ``ValueError``) and kill the thread before any line
|
|
was yielded — dropping ALL output and reporting a clean success. ``Popen``
|
|
now decodes with ``errors="replace"`` so output always survives.
|
|
"""
|
|
session = make_session(tool_timeout=30)
|
|
# Valid lines bracketing a raw invalid-UTF-8 byte sequence.
|
|
command = r"printf 'before\n'; printf '\xff\xfe'; printf 'after\n'"
|
|
finished, result = _run_in_thread(
|
|
lambda: session._exec_bash({"call_id": "c1", "command": command}),
|
|
timeout=15,
|
|
)
|
|
assert finished
|
|
assert result is not None
|
|
_call_id, output = result
|
|
assert output != "(no output)"
|
|
assert "before" in output
|
|
assert "after" in output
|
|
|
|
|
|
def test_stdout_streams_to_ui_from_drain_thread():
|
|
"""stdout chunks are now emitted from the drain thread; they must still reach
|
|
``on_tool_output_chunk``."""
|
|
chunks: list[str] = []
|
|
|
|
class RecordingUI(NullUI):
|
|
def on_tool_output_chunk(self, call_id, chunk):
|
|
chunks.append(chunk)
|
|
|
|
session = make_session(tool_timeout=30, ui=RecordingUI())
|
|
finished, result = _run_in_thread(
|
|
lambda: session._exec_bash({"call_id": "c1", "command": "echo streamed-line"}),
|
|
timeout=15,
|
|
)
|
|
assert finished
|
|
assert any("streamed-line" in c for c in chunks)
|
|
|
|
|
|
def test_cancel_midbash_reports_unknown():
|
|
"""An external ``cancel()`` during a running bash unblocks the process-bounded
|
|
wait and reports UNKNOWN (unknown-never-none), not a clean result."""
|
|
session = make_session(tool_timeout=30)
|
|
|
|
def _cancel_soon():
|
|
time.sleep(0.5)
|
|
session.cancel()
|
|
|
|
threading.Thread(target=_cancel_soon, daemon=True).start()
|
|
finished, result = _run_in_thread(
|
|
lambda: session._exec_bash({"call_id": "c1", "command": "sleep 30"}),
|
|
timeout=15,
|
|
)
|
|
assert finished, "cancel did not unblock _exec_bash"
|
|
assert result is not None
|
|
_call_id, output = result
|
|
assert "cancelled" in output.lower()
|
|
assert session._tool_status.get("c1") is EffectStatus.UNKNOWN
|
|
|
|
|
|
def test_popen_failure_reports_cleanly(monkeypatch):
|
|
"""If ``Popen`` itself raises, the ``finally`` must not mask the real error
|
|
with ``UnboundLocalError`` — ``proc`` is pre-bound to ``None``."""
|
|
from turnstone.core import session as session_mod
|
|
|
|
session = make_session(tool_timeout=30)
|
|
|
|
def _boom(*args, **kwargs):
|
|
raise OSError("cannot fork")
|
|
|
|
monkeypatch.setattr(session_mod.subprocess, "Popen", _boom)
|
|
call_id, output = session._exec_bash({"call_id": "c1", "command": "echo hi"})
|
|
assert call_id == "c1"
|
|
assert "cannot fork" in output
|