#7814·QwenPaw

Console SSE: _strip_event_headlines can emit a bare null payload, and stream_one sends no terminal event on failure

Author: wjt0321Created Sep 16, 2026Updated Sep 17, 2026

Summary

Two related robustness gaps on the Console SSE path:

  1. BaseChannel._strip_event_headlines can serialize to the bare string null, producing an invalid SSE event whose payload is not an object.
  2. When a turn fails mid-stream, ConsoleChannel.stream_one logs 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/version returns 2.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:

python
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:

python
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 explicit None fallback to fallback, and correct the annotation
  • make _serialize_event_for_sse validate 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:

python
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 main and from the v2.2.1 tag; neither has changed.
  • I have not captured the raw data: null frame itself, so I cannot say which event type first produced a None payload. The None handling gap in walk is read directly from the source, and the json.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: null 52 times, all with msg_id null. I have not established any link to the above.