mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-23 12:24:46 -06:00
3f432b8a42
* fix: watch dispatch error handler missing stream_end and state cleanup The watch dispatch run() closure was missing GenerationCancelled handling, stream_end emission, on_state_change calls, and the worker_thread identity guard that the send_message path has. This left the web UI in a stale state when watch-dispatched sends failed. * fix: address review feedback - on_stream_end, put_nowait, ws._lock, tests * fix: ruff lint (unused pytest import) * fix: send_message() use on_stream_end() instead of raw _enqueue
218 lines
6.7 KiB
Python
218 lines
6.7 KiB
Python
"""Tests for _make_watch_dispatch error/cancel handling and concurrency guards."""
|
|
|
|
import queue
|
|
import threading
|
|
import time
|
|
|
|
from turnstone.core.session import GenerationCancelled
|
|
from turnstone.core.workstream import Workstream
|
|
from turnstone.server import _make_watch_dispatch
|
|
|
|
|
|
class _StubSession:
|
|
"""Minimal ChatSession stand-in with controllable send() behaviour."""
|
|
|
|
def __init__(self, *, side_effect=None):
|
|
self._watch_pending: queue.Queue = queue.Queue(maxsize=20)
|
|
self._side_effect = side_effect
|
|
|
|
def send(self, msg: str) -> None:
|
|
if self._side_effect is not None:
|
|
raise self._side_effect
|
|
|
|
|
|
class _RecordingUI:
|
|
"""Track calls made by the dispatch error handlers."""
|
|
|
|
def __init__(self):
|
|
self.errors: list[str] = []
|
|
self.state_changes: list[str] = []
|
|
self.stream_end_calls: int = 0
|
|
|
|
# -- SessionUI protocol stubs used by the dispatch code --
|
|
|
|
def on_error(self, message: str) -> None:
|
|
self.errors.append(message)
|
|
|
|
def on_state_change(self, state: str) -> None:
|
|
self.state_changes.append(state)
|
|
|
|
def on_stream_end(self) -> None:
|
|
self.stream_end_calls += 1
|
|
|
|
|
|
# ── helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _wait_for_worker(ws: Workstream, timeout: float = 2.0) -> None:
|
|
"""Block until the worker thread started by dispatch() finishes."""
|
|
t = ws.worker_thread
|
|
if t is not None:
|
|
t.join(timeout)
|
|
assert not t.is_alive(), "worker thread did not finish in time"
|
|
|
|
|
|
# ── GenerationCancelled path ────────────────────────────────────────────────
|
|
|
|
|
|
def test_cancelled_emits_stream_end_and_idle():
|
|
session = _StubSession(side_effect=GenerationCancelled())
|
|
ws = Workstream()
|
|
ui = _RecordingUI()
|
|
|
|
dispatch = _make_watch_dispatch(ws, session, ui)
|
|
dispatch("hello")
|
|
_wait_for_worker(ws)
|
|
|
|
assert ui.stream_end_calls == 1
|
|
assert ui.state_changes == ["idle"]
|
|
assert ui.errors == []
|
|
|
|
|
|
# ── Generic exception path ──────────────────────────────────────────────────
|
|
|
|
|
|
def test_exception_emits_stream_end_and_error():
|
|
session = _StubSession(side_effect=RuntimeError("boom"))
|
|
ws = Workstream()
|
|
ui = _RecordingUI()
|
|
|
|
dispatch = _make_watch_dispatch(ws, session, ui)
|
|
dispatch("hello")
|
|
_wait_for_worker(ws)
|
|
|
|
assert ui.stream_end_calls == 1
|
|
assert ui.state_changes == ["error"]
|
|
assert len(ui.errors) == 1
|
|
assert "boom" in ui.errors[0]
|
|
|
|
|
|
# ── Worker-thread identity guard ────────────────────────────────────────────
|
|
|
|
|
|
def test_abandoned_thread_emits_no_events():
|
|
"""After force-cancel sets worker_thread=None, the old thread must not
|
|
emit stream_end or state changes."""
|
|
barrier = threading.Event()
|
|
|
|
class _BlockingSession(_StubSession):
|
|
def send(self, msg: str) -> None:
|
|
barrier.wait(timeout=5)
|
|
raise RuntimeError("late error")
|
|
|
|
session = _BlockingSession()
|
|
ws = Workstream()
|
|
ui = _RecordingUI()
|
|
|
|
dispatch = _make_watch_dispatch(ws, session, ui)
|
|
dispatch("hello")
|
|
|
|
# Simulate force-cancel: clear the worker_thread reference.
|
|
ws.worker_thread = None
|
|
barrier.set()
|
|
|
|
# Wait for the thread to actually complete (it's still running).
|
|
time.sleep(0.3)
|
|
|
|
assert ui.stream_end_calls == 0
|
|
assert ui.state_changes == []
|
|
assert ui.errors == []
|
|
|
|
|
|
# ── Path A: busy workstream enqueue ─────────────────────────────────────────
|
|
|
|
|
|
def test_busy_workstream_enqueues_message():
|
|
"""When the workstream already has a live worker, dispatch enqueues."""
|
|
session = _StubSession()
|
|
ws = Workstream()
|
|
ui = _RecordingUI()
|
|
|
|
# Simulate a live worker thread.
|
|
blocker = threading.Event()
|
|
ws.worker_thread = threading.Thread(target=blocker.wait, args=(5,), daemon=True)
|
|
ws.worker_thread.start()
|
|
|
|
try:
|
|
dispatch = _make_watch_dispatch(ws, session, ui)
|
|
dispatch("queued msg")
|
|
|
|
item = session._watch_pending.get_nowait()
|
|
assert item == {"message": "queued msg"}
|
|
finally:
|
|
blocker.set()
|
|
ws.worker_thread.join(2)
|
|
|
|
|
|
def test_busy_workstream_drops_on_full_queue():
|
|
"""When the pending queue is full, dispatch drops the message."""
|
|
session = _StubSession()
|
|
# Fill the queue to capacity.
|
|
for i in range(20):
|
|
session._watch_pending.put_nowait({"message": f"msg{i}"})
|
|
|
|
ws = Workstream()
|
|
ui = _RecordingUI()
|
|
|
|
blocker = threading.Event()
|
|
ws.worker_thread = threading.Thread(target=blocker.wait, args=(5,), daemon=True)
|
|
ws.worker_thread.start()
|
|
|
|
try:
|
|
dispatch = _make_watch_dispatch(ws, session, ui)
|
|
# Should not block or raise — just log a warning and drop.
|
|
dispatch("overflow msg")
|
|
|
|
assert session._watch_pending.full()
|
|
finally:
|
|
blocker.set()
|
|
ws.worker_thread.join(2)
|
|
|
|
|
|
# ── Lock guard ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_dispatch_holds_lock_during_thread_start():
|
|
"""Dispatch acquires ws._lock before checking/starting the worker."""
|
|
session = _StubSession()
|
|
ws = Workstream()
|
|
ui = _RecordingUI()
|
|
|
|
acquire_count = 0
|
|
inner = ws._lock
|
|
|
|
class _CountingLock:
|
|
def __enter__(self):
|
|
nonlocal acquire_count
|
|
acquire_count += 1
|
|
return inner.__enter__()
|
|
|
|
def __exit__(self, *args):
|
|
return inner.__exit__(*args)
|
|
|
|
ws._lock = _CountingLock() # type: ignore[assignment]
|
|
|
|
dispatch = _make_watch_dispatch(ws, session, ui)
|
|
dispatch("hello")
|
|
_wait_for_worker(ws)
|
|
|
|
assert acquire_count >= 1
|
|
|
|
|
|
# ── Happy path ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_successful_send_no_error_events():
|
|
"""Normal send() completion should not trigger error/cancel events."""
|
|
session = _StubSession() # send() does nothing (success)
|
|
ws = Workstream()
|
|
ui = _RecordingUI()
|
|
|
|
dispatch = _make_watch_dispatch(ws, session, ui)
|
|
dispatch("hello")
|
|
_wait_for_worker(ws)
|
|
|
|
assert ui.stream_end_calls == 0
|
|
assert ui.state_changes == []
|
|
assert ui.errors == []
|