fix(tui): stop a stream outlived by /new from leaking into the next conversation #13

Merged
enderofwings merged 6 commits from fix/tui-history-leak into main 2026-08-26 19:04:59 +00:00
2 changed files with 193 additions and 93 deletions
Showing only changes of commit 9ca37057eb - Show all commits
+60 -36
View File
@@ -6,6 +6,7 @@ stdin/stdout are a TTY. Classic one-shots (``nexus chat send``, ``nexus monitor`
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import threading import threading
import uuid import uuid
@@ -15,7 +16,6 @@ import httpx
from synapse.nexus_config import settings from synapse.nexus_config import settings
from . import nexus_api
from .monitor import collect_snapshot from .monitor import collect_snapshot
# Between SSE chunks a silent backend must not pin the UI forever. Connect stays # Between SSE chunks a silent backend must not pin the UI forever. Connect stays
@@ -199,7 +199,9 @@ class NexusTUI:
self._model: str | None = None self._model: str | None = None
self._busy = False self._busy = False
self._stop_stream = threading.Event() self._stop_stream = threading.Event()
self._http_client: httpx.Client | None = None self._stream_cancel: (
tuple[asyncio.AbstractEventLoop, asyncio.Task] | None
) = None
self._status_lock = threading.Lock() self._status_lock = threading.Lock()
self._status_pending = False self._status_pending = False
@@ -269,25 +271,26 @@ class NexusTUI:
"""Write a stream error to the persistent transcript.""" """Write a stream error to the persistent transcript."""
self.query_one("#log", RichLog).write(message) self.query_one("#log", RichLog).write(message)
def action_quit(self) -> None: def _cancel_stream(self) -> None:
"""Cancel the task that owns the socket read.
Closing a synchronous httpx client from the UI thread does not
reliably unblock its worker-thread read on macOS. Async task
cancellation is delivered to the pending read itself.
"""
self._stop_stream.set() self._stop_stream.set()
client = self._http_client cancel = self._stream_cancel
if client is not None: if cancel is not None:
try: loop, task = cancel
client.close() loop.call_soon_threadsafe(task.cancel)
except Exception:
pass def action_quit(self) -> None:
self._cancel_stream()
self.exit() self.exit()
def action_interrupt(self) -> None: def action_interrupt(self) -> None:
if self._busy: if self._busy:
self._stop_stream.set() self._cancel_stream()
client = self._http_client
if client is not None:
try:
client.close()
except Exception:
pass
self.query_one("#log", RichLog).write( self.query_one("#log", RichLog).write(
"[yellow]▸ interrupt requested[/]" "[yellow]▸ interrupt requested[/]"
) )
@@ -364,18 +367,24 @@ class NexusTUI:
body["model"] = self._model body["model"] = self._model
self.history.append({"role": "user", "content": message}) self.history.append({"role": "user", "content": message})
def worker(): async def stream_worker():
reply_parts: list[str] = [] reply_parts: list[str] = []
client = httpx.Client( task = asyncio.current_task()
base_url=self.api_url, timeout=_STREAM_TIMEOUT loop = asyncio.get_running_loop()
) if task is None: # pragma: no cover - asyncio guarantees it
self._http_client = client raise RuntimeError("stream worker has no task")
self._stream_cancel = (loop, task)
try: try:
with client.stream( if self._stop_stream.is_set():
raise asyncio.CancelledError
async with httpx.AsyncClient(
base_url=self.api_url, timeout=_STREAM_TIMEOUT
) as client:
async with client.stream(
"POST", "/chat/stream", json=body "POST", "/chat/stream", json=body
) as resp: ) as resp:
if resp.status_code >= 400: if resp.status_code >= 400:
detail = resp.read().decode( detail = (await resp.aread()).decode(
"utf-8", errors="replace" "utf-8", errors="replace"
)[:300] )[:300]
self._call_ui( self._call_ui(
@@ -384,11 +393,23 @@ class NexusTUI:
f"{_escape(detail)}", f"{_escape(detail)}",
) )
return return
for kind, payload in nexus_api.iter_chunks( event = "message"
resp.iter_lines() async for line in resp.aiter_lines():
):
if self._stop_stream.is_set(): if self._stop_stream.is_set():
break raise asyncio.CancelledError
if line == "":
event = "message"
continue
if line.startswith("event:"):
event = line[6:].strip()
continue
if not line.startswith("data:"):
continue
payload = line[5:].strip()
kind = event
if kind in ("message", ""):
kind = "chunk"
payload = json.loads(payload)
if kind == "chunk": if kind == "chunk":
reply_parts.append(payload) reply_parts.append(payload)
preview = "".join(reply_parts) preview = "".join(reply_parts)
@@ -409,8 +430,9 @@ class NexusTUI:
self._call_ui( self._call_ui(
log.write, log.write,
"[yellow]▸ denied action tool " "[yellow]▸ denied action tool "
f"{_escape(shown)} — interactive approval " f"{_escape(shown)} — interactive "
"is not yet available in the TUI[/]", "approval is not yet available in "
"the TUI[/]",
) )
except Exception as exc: except Exception as exc:
self._call_ui( self._call_ui(
@@ -428,10 +450,13 @@ class NexusTUI:
detail = payload detail = payload
self._call_ui( self._call_ui(
self._show_error, self._show_error,
f"[red]error:[/] {_escape(str(detail))}", f"[red]error:[/] "
f"{_escape(str(detail))}",
) )
elif kind == "done": elif kind == "done":
break break
except asyncio.CancelledError:
pass
except httpx.ConnectError: except httpx.ConnectError:
self._call_ui( self._call_ui(
self._show_error, self._show_error,
@@ -445,15 +470,14 @@ class NexusTUI:
f"{_escape(str(exc))}", f"{_escape(str(exc))}",
) )
finally: finally:
self._http_client = None if self._stream_cancel == (loop, task):
try: self._stream_cancel = None
client.close()
except Exception:
pass
text = "".join(reply_parts).strip() text = "".join(reply_parts).strip()
self._call_ui(self._finish_stream, text) self._call_ui(self._finish_stream, text)
threading.Thread(target=worker, daemon=True).start() threading.Thread(
target=lambda: asyncio.run(stream_worker()), daemon=True
).start()
def _finish_stream(self, text: str) -> None: def _finish_stream(self, text: str) -> None:
log = self.query_one("#log", RichLog) log = self.query_one("#log", RichLog)
+84 -8
View File
@@ -154,15 +154,15 @@ def test_inflight_tool_denial_uses_original_conversation_id(monkeypatch):
class _StreamResponse: class _StreamResponse:
status_code = 200 status_code = 200
def __enter__(self): async def __aenter__(self):
return self return self
def __exit__(self, *args): async def __aexit__(self, *args):
return None return None
def iter_lines(self): async def aiter_lines(self):
stream_started.set() stream_started.set()
release_stream.wait(timeout=2) await asyncio.to_thread(release_stream.wait, 2)
yield "event: tool_request" yield "event: tool_request"
yield 'data: {"token":"secret","actions":[{"name":"run_snippet"}]}' yield 'data: {"token":"secret","actions":[{"name":"run_snippet"}]}'
yield "" yield ""
@@ -173,17 +173,20 @@ def test_inflight_tool_denial_uses_original_conversation_id(monkeypatch):
def __init__(self, **kwargs): def __init__(self, **kwargs):
pass pass
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return None
def stream(self, *args, **kwargs): def stream(self, *args, **kwargs):
return _StreamResponse() return _StreamResponse()
def close(self):
return None
def _capture_denial(*, conversation_id, **kwargs): def _capture_denial(*, conversation_id, **kwargs):
denied_for.append(conversation_id) denied_for.append(conversation_id)
return ["run_snippet"] return ["run_snippet"]
monkeypatch.setattr(tui_app.httpx, "Client", _StreamClient) monkeypatch.setattr(tui_app.httpx, "AsyncClient", _StreamClient)
monkeypatch.setattr(tui_app, "_deny_tool_request", _capture_denial) monkeypatch.setattr(tui_app, "_deny_tool_request", _capture_denial)
app = tui_app.NexusTUI.build_app(api_url="http://127.0.0.1:9") app = tui_app.NexusTUI.build_app(api_url="http://127.0.0.1:9")
@@ -205,5 +208,78 @@ def test_inflight_tool_denial_uses_original_conversation_id(monkeypatch):
asyncio.run(_run()) asyncio.run(_run())
def test_interrupt_cancels_silent_stream_and_accepts_next_message(monkeypatch):
pytest.importorskip("textual")
import nexusos_cli.tui_app as tui_app
first_stream_started = threading.Event()
class _StreamResponse:
status_code = 200
def __init__(self, call_number):
self.call_number = call_number
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return None
async def aiter_lines(self):
if self.call_number == 1:
first_stream_started.set()
await asyncio.Event().wait()
yield "data: \"READY\""
yield ""
yield "event: done"
yield "data: {}"
class _StreamClient:
calls = 0
def __init__(self, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return None
def stream(self, *args, **kwargs):
type(self).calls += 1
return _StreamResponse(type(self).calls)
monkeypatch.setattr(tui_app.httpx, "AsyncClient", _StreamClient)
app = tui_app.NexusTUI.build_app(api_url="http://127.0.0.1:9")
async def _wait_until_idle():
for _ in range(100):
if not app._busy:
return
await asyncio.sleep(0.01)
pytest.fail("stream did not become idle within one second")
async def _run():
async with app.run_test():
app._start_chat("first")
assert await asyncio.to_thread(first_stream_started.wait, 2)
app.action_interrupt()
await _wait_until_idle()
log = app.query_one("#log")
assert not any("ReadTimeout" in line.text for line in log.lines)
app._start_chat("second")
await _wait_until_idle()
assert app.history[-1] == {
"role": "assistant",
"content": "READY",
}
asyncio.run(_run())
def test_escape_round_trip_helper(): def test_escape_round_trip_helper():
assert "[" in _escape("x[y]") or "\\[" in _escape("x[y]") assert "[" in _escape("x[y]") or "\\[" in _escape("x[y]")