v0.4.7: a stream that can end without telling either end
What's Changed
- #45 — 0.4.7: the web frontend's event stream survives its own connection dropping by @matAtWork
- A patch release. One defect with two faces, three follow-ons found while proving it, and a new doc for anyone writing their own UI. No plugin-API changes; one new frontend-web route and one new
WebServerDepsfield.
Full Changelog: v0.4.6...v0.4.7
0.4.7 — a stream that can end without telling either end
Two symptoms, reported separately, with one cause. The skills compiler stopped to ask for install
confirmation and the question never appeared, so the turn parked until it was cancelled. And separately,
some turns "never completed" — but had completed, and showed as complete the moment the page was refreshed.
The cause
ctx.prompt() in frontend-web was fire-and-forget over the per-session SSE stream, with no replay, no reach
check and no timeout. sendToSession returns silently when a session has no attached — or no live —
connection, so a prompt raised while no browser was on that conversation's stream went nowhere and the turn
blocked forever. Reproduced with no viewer attached: the turn parks, and POST /prompt answers 200 for a
question nobody could have seen.
Read the other way round, the same gap is the stuck turn. A stream replays the running turn and says
nothing about one that began and ended while it was gone, so the loading dots stayed up until committed
history was re-read by a refresh.
Underneath both: nothing was written to a quiet stream between turns, so neither end could tell quiet from
dead. The server kept a zombie connection in its viewer set and went on reporting successful writes into
it — which is how a prompt was lost — while the client's reader.read() stayed pending with no error, so
the reconnect loop it already had never ran at all. A long tool call is minutes of silence, and that is the
window a socket dies in.
Not a regression. The initial hypothesis was a deadlock in 0.4.5's machineBusy/quiescent-edge work, and
that was ruled out by test rather than by reading: a nested invokeTool → ctx.prompt is delivered and
answered over the real createWebServer while the pump holds the machine. Every wait in
context-switch.ts is bounded, and quiesced() is called by nothing but tests. git log -L on the
prompt-parking code stops well before 0.4.5. What made the skills compiler the place it showed is that its
install confirmation fires at the end of the longest, quietest tool call in the system.
The fix
A prompt is state, not an event. It stays true until answered, so it is kept and re-sent to every stream
that connects while it is outstanding — covering the absent viewer, the reloading tab and the zombie alike.
Both SSE endpoints heartbeat, and the client bounds how long it will sit in silence. This is the
enabling half: the beat is what lets the server reap a dead socket and the client notice one. Reconnect was
never the missing piece — detection was, and that is protocol-independent. It is why the WebSocket spec has
ping/pong frames, and browsers do not expose those to JS either.
A reconnect is announced rather than assumed continuous. The transport yields a synthetic
stream-resumed, and the UI re-reads committed history for any turn it still shows as running.
Becoming visible revives a stream that has gone quiet — visibilitychange and pageshow both, the
latter for the back/forward cache, which Safari leans on and where the page returns with its scripts
un-rerun and its streams gone. Deliberately not disconnect-on-hide: a hidden tab usually keeps its
connections, so forcing the gap would make the recovery re-read certain rather than rare.
A viewer going away is no longer treated as an answer. The old "no viewers left" release resolved the
prompt with '', which the prompt implementation turns into the field's default — an answer nobody gave,
to a question nobody saw. Harmless-looking on a confirm, where it declines, and destructive on
plugin store-key, whose default is '' and where a blank value removes the key. Abort and shutdown
cancel instead, which a tool already reports as an error.
The in-process build had the same prompt hole, with no socket in it. browser.js injected a prompt in
one pass over whatever streams were draining, and a session the user is not looking at has none — the same
bug in a build with no network, which is the clearest evidence that this half of the problem was never about
transport.
Found while proving it
-
The
/toolsboot grace expired on a clock, not an event. The endpoint holds a name that has not
registered yet, because the server starts listening insidesetup()— but the wait ended 30s after server
construction regardless of what the registry was doing. So a name that would never register (the UI
askingprofile_actionwhether a profiles backend exists) parked for the whole window before 404ing, long
after loading had finished. Nothing was slow at boot; the wait was for a deadline. It now ends when the
tool registry goes quiet, re-armed so a slow boot keeps its grace, with the 30s ceiling retained. -
A control built from a tool call now tracks the tool registry, in both directions. A plugin's
setup()may itself callloadPlugin()(google-drive, per-user bootstrap plugins), so a capability can
register arbitrarily late and beat any deadline; and a plugin can be unloaded from this UI's own panel, so
one can leave while the page is up. The old one-way latch got both wrong — a panel that never appeared
however long you waited, or sharing controls still offering operations that now 404. A 404 from/tools
means "not registered when you asked", never "absent". -
GET /ui-configserves the values the server and its UI must agree on —heartbeatMstoday — so that
agreement is data rather than a comment in each half stating what it assumes about the other. Deliberately
narrow, and deliberately not a feature-flag channel: whether a capability exists is answered by the tool
registry, which changes while the page is up. -
Smaller: the UI no longer serialises its whole bootstrap behind an optional-capability probe; a stale
"plugin not loaded" banner clears when its plugin arrives; and an abandoned read race no longer leaves a
65-second deadline pending after the stream it guarded has gone.
docs/SSE-CLIENTS.md
New, and the reason it exists is that anything embedding matbot with its own UI has to reimplement all of
the above from the outside. It is about the streams rather than the endpoints: what they guarantee, what
they do not, and the four mistakes that are invisible in testing and permanent in production — an
unanswerable prompt, a fabricated default, awaiting a prompt dialog inside the event loop (a deadlock, since
prompt-resolved arrives on the stream you stopped reading), and a turn that finished while nobody was
listening.
It is written against the transition rather than the browser event, because a soft-tabbed shell toggling
panels with display: none gets no lifecycle event at all when a conversation is hidden. The rules attach
to whatever that UI's own foreground/background signal is — a tab click, a route change — with
visibilitychange/pageshow as two sources among several. It also covers what a background transition
must not do, the ~6-socket-per-host budget, and two cases whose answers differ from the bundled UI's: a
soft-tabbed shell, and the serverless in-process build, where turn durability is the other way round — the
provider request is made from the page, so losing the network interrupts the turn itself and there is no
completed work to re-read.
A final section is for an embedder who owns the server half too, where five of these rules stop being ones
to obey and become ones to provide, since a client cannot work around their absence.
Upgrading
Nothing to do, and nothing to migrate. No plugin-API surface changed, no stored data is affected.
Two additions to frontend-web's own surface: GET /ui-config, and WebServerDeps.heartbeatMs (default 20s;
safe to change, since the client reads the interval rather than assuming it). Lower it behind an
intermediary that idles connections out aggressively.
If you have written a UI against the web frontend's HTTP+SSE API, read docs/SSE-CLIENTS.md — the server
half of these fixes is in this release, but the client half is yours.
Known gap
There is still no stream cursor: the server emits no id: lines and does not honour Last-Event-ID, so a
reconnect recovers by re-reading committed history rather than resuming. You get the finished answer, not
the tokens replaying. That is the fix if a re-render ever proves too expensive — most likely for a
soft-tabbed UI that opens and closes streams per conversation, where recovery is the common path rather than
the exception.