Console SSE: _strip_event_headlines can emit a bare null payload, and stream_one sends no terminal event on failure
Summary
Two related robustness gaps on the Console SSE path:
BaseChannel._strip_event_headlinescan serialize to the bare stringnull, producing an invalid SSE event whose payload is not an object.- When a turn fails mid-stream,
ConsoleChannel.stream_onelogs the exception and returns without emitting any terminal event, so the client is never told the turn is over.
Together these turn a single recoverable backend error into a client-side freeze that only a page reload clears. A companion report covers the frontend crash that (1) can trigger.
Environment
- QwenPaw 2.2.1, Windows desktop build (
/api/versionreturns2.2.1) - Console channel, streaming chat via
POST /api/console/chat - Both issues verified against current
main, not just the 2.2.1 tag
Issue 1: a bare null SSE payload
File: src/qwenpaw/app/channels/base.py, function _strip_event_headlines.
The helper walks a dumped copy of the event and re-serializes it:
def walk(node: Any) -> Any:
if isinstance(node, str):
return strip_headline(node)
if isinstance(node, dict):
for key, value in list(node.items()):
node[key] = walk(value)
return node
if isinstance(node, list):
return [walk(value) for value in node]
return node
payload = walk(payload)
return json.dumps(payload, ensure_ascii=False, default=str)There is no branch for None, so it falls through to return node and payload can remain None. json.dumps(None) is the string 'null'. Back in stream_one this is wrapped as:
data = self._serialize_event_for_sse(event, headline_stream_states)
yield f"data: {data}\n\n"which puts data: null on the wire. A client doing JSON.parse on that frame gets null rather than an exception, and any consumer that assumes an object will fail on the very next property access.
Note the type annotation on the parameter is fallback: str, while the implementation treats payload as a dict. That mismatch is probably how the None case went unnoticed.
Two possible directions, either seems fine:
- give
walk/ the tail an explicitNonefallback tofallback, and correct the annotation - make
_serialize_event_for_ssevalidate its result and substitute a valid JSON object (e.g. an error payload) whenever the serialization is not an object
The second one is worth doing regardless, since it closes the whole class of "serializer produced a non-object" rather than just this instance.
Issue 2: no terminal event when a turn fails
File: src/qwenpaw/app/channels/console/channel.py, function stream_one.
The error path currently looks like this on main:
except Exception as e:
self._clear_session_turn_usage(session_id)
logger.exception("console process/reply failed")
err_msg = str(e).strip() or "An error occurred while processing."
self._print_error(err_msg)
finally:
...The exception is logged and printed to the terminal, but nothing is yielded. The response never reaches completed or failed, so a client that keys its UI state off those events stays in a generating state indefinitely — the message bubble keeps spinning and the composer stays blocked.
The router does have a fallback for generator-level errors (src/qwenpaw/app/routers/console.py yields {"error": ...} when iteration raises), but that does not help here because stream_one swallows the exception and returns normally. The stream therefore ends cleanly from the router's point of view, with no terminal event ever sent.
Suggestion: emit an explicit terminal event on this path, e.g. a failed message/response event carrying the error text, so clients can settle the turn and recover without a reload.
How I hit this
A turn failed server-side with a provider error. The backend logged console process/reply failed and wrote a normalized error dump, which is expected behavior. The client never received a terminal event, so the chat stayed stuck until the page was reloaded.
Verification notes
- Both code paths above were read from current
mainand from the v2.2.1 tag; neither has changed. - I have not captured the raw
data: nullframe itself, so I cannot say which event type first produced aNonepayload. TheNonehandling gap inwalkis read directly from the source, and thejson.dumps(None)behavior is straightforward. Flagging that distinction so the finding is not read as stronger than it is. - Related but unexplained, and probably a separate issue: the same session log contained
Message not found for content: null52 times, all withmsg_idnull. I have not established any link to the above.
Source: agentscope-ai/QwenPaw