fix(tui): cancel silent streams promptly

Move chat streaming onto a cancellable async task so Ctrl+C interrupts a pending socket read on macOS instead of waiting for the 120-second read timeout. Add a headless silent-stream regression that verifies prompt recovery and a successful next message.
This commit is contained in:
Athena Kaminsky
2026-08-26 08:17:31 -05:00
committed by Athena
parent da3509eb04
commit 9ca37057eb
2 changed files with 193 additions and 93 deletions
+109 -85
View File
@@ -6,6 +6,7 @@ stdin/stdout are a TTY. Classic one-shots (``nexus chat send``, ``nexus monitor`
"""
from __future__ import annotations
import asyncio
import json
import threading
import uuid
@@ -15,7 +16,6 @@ import httpx
from synapse.nexus_config import settings
from . import nexus_api
from .monitor import collect_snapshot
# 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._busy = False
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_pending = False
@@ -269,25 +271,26 @@ class NexusTUI:
"""Write a stream error to the persistent transcript."""
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()
client = self._http_client
if client is not None:
try:
client.close()
except Exception:
pass
cancel = self._stream_cancel
if cancel is not None:
loop, task = cancel
loop.call_soon_threadsafe(task.cancel)
def action_quit(self) -> None:
self._cancel_stream()
self.exit()
def action_interrupt(self) -> None:
if self._busy:
self._stop_stream.set()
client = self._http_client
if client is not None:
try:
client.close()
except Exception:
pass
self._cancel_stream()
self.query_one("#log", RichLog).write(
"[yellow]▸ interrupt requested[/]"
)
@@ -364,74 +367,96 @@ class NexusTUI:
body["model"] = self._model
self.history.append({"role": "user", "content": message})
def worker():
async def stream_worker():
reply_parts: list[str] = []
client = httpx.Client(
base_url=self.api_url, timeout=_STREAM_TIMEOUT
)
self._http_client = client
task = asyncio.current_task()
loop = asyncio.get_running_loop()
if task is None: # pragma: no cover - asyncio guarantees it
raise RuntimeError("stream worker has no task")
self._stream_cancel = (loop, task)
try:
with client.stream(
"POST", "/chat/stream", json=body
) as resp:
if resp.status_code >= 400:
detail = resp.read().decode(
"utf-8", errors="replace"
)[:300]
self._call_ui(
self._show_error,
f"[red]error HTTP {resp.status_code}[/] "
f"{_escape(detail)}",
)
return
for kind, payload in nexus_api.iter_chunks(
resp.iter_lines()
):
if self._stop_stream.is_set():
break
if kind == "chunk":
reply_parts.append(payload)
preview = "".join(reply_parts)
if len(preview) > 4000:
preview = "" + preview[-4000:]
self._call_ui(
live.update,
format_assistant_line(preview),
)
elif kind == "tool_request":
try:
names = _deny_tool_request(
api_url=self.api_url,
conversation_id=conversation_id,
payload=payload,
)
shown = ", ".join(names)
self._call_ui(
log.write,
"[yellow]▸ denied action tool "
f"{_escape(shown)} — interactive approval "
"is not yet available in the TUI[/]",
)
except Exception as exc:
self._call_ui(
self._show_error,
"[red]tool denial failed:[/] "
f"{_escape(str(exc))}",
)
return
elif kind == "error":
try:
detail = json.loads(payload).get(
"detail", payload
)
except Exception:
detail = payload
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
) as resp:
if resp.status_code >= 400:
detail = (await resp.aread()).decode(
"utf-8", errors="replace"
)[:300]
self._call_ui(
self._show_error,
f"[red]error:[/] {_escape(str(detail))}",
f"[red]error HTTP {resp.status_code}[/] "
f"{_escape(detail)}",
)
elif kind == "done":
break
return
event = "message"
async for line in resp.aiter_lines():
if self._stop_stream.is_set():
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":
reply_parts.append(payload)
preview = "".join(reply_parts)
if len(preview) > 4000:
preview = "" + preview[-4000:]
self._call_ui(
live.update,
format_assistant_line(preview),
)
elif kind == "tool_request":
try:
names = _deny_tool_request(
api_url=self.api_url,
conversation_id=conversation_id,
payload=payload,
)
shown = ", ".join(names)
self._call_ui(
log.write,
"[yellow]▸ denied action tool "
f"{_escape(shown)} — interactive "
"approval is not yet available in "
"the TUI[/]",
)
except Exception as exc:
self._call_ui(
self._show_error,
"[red]tool denial failed:[/] "
f"{_escape(str(exc))}",
)
return
elif kind == "error":
try:
detail = json.loads(payload).get(
"detail", payload
)
except Exception:
detail = payload
self._call_ui(
self._show_error,
f"[red]error:[/] "
f"{_escape(str(detail))}",
)
elif kind == "done":
break
except asyncio.CancelledError:
pass
except httpx.ConnectError:
self._call_ui(
self._show_error,
@@ -445,15 +470,14 @@ class NexusTUI:
f"{_escape(str(exc))}",
)
finally:
self._http_client = None
try:
client.close()
except Exception:
pass
if self._stream_cancel == (loop, task):
self._stream_cancel = None
text = "".join(reply_parts).strip()
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:
log = self.query_one("#log", RichLog)