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:
+109
-85
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user