fix(tui): stop a stream outlived by /new from leaking into the next conversation #13
+60
-36
@@ -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
@@ -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]")
|
||||||
|
|||||||
Reference in New Issue
Block a user