Repository navigation
Event Stream and Response Timer - #9
Merged
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
1. Purpose & Motivation
Description
preman could not show a
text/event-stream.invokeHttpbuffered every chunk and resolved once,so a completion stream showed nothing for thirty seconds and then appeared as one concatenated
document that no server ever sent. The response header had the same gap in the other direction:
it showed a duration only after the exchange finished, so a slow request reported nothing at all
while the reader waited.
This PR adds both. The app reads a stream event by event as it arrives, and the response header
runs a clock from the moment Send is clicked.
Streaming needed cancellation first.
host.tspreviously said, in a comment, that core had nocancellation and that Cancel only stopped the reporting. That was defensible while every response
was finite and
--timeout-requestwas the floor. A stream ends when the server says so, sowithout a real abort each Cancel would have leaked one open socket. Decision 051 reverses that
call and Decision 052 records the streaming design built on top of it.
Context & Flow
2. Key Changes
Description
Cancellation reaches the socket. An
AbortSignalnow runs throughHttpInvokeOptions, thegRPC
InvokeOptions,RunOptions,ScriptOptionsandRunSelectionArgs. The engine host ownsone
AbortControllerper run, so Cancel destroys the request, cancels the gRPC call, stops thegroup loop with
bailReason: "cancelled", and reachespm.sendRequesttoo.Streaming is opt-in at the transport.
invokeHttptakes an optionalstreamsink. Passingone gives up
timeoutMsafter the head arrives and reports the head early. Passing nothing keepsthe old buffered exchange exactly as it was, which is what the CLI does —
preman runstillbuffers and
--timeout-requestis still a hard ceiling in CI.Three new events, one unchanged body.
response-headgained a requiredstreamingflag andcarries
headersMsinstead ofdurationMswhile a stream is open.response-framesrepeats.stream-endcloses with the duration the head could not carry and the true frame total. The rawbytes still become one
response-bodyin the engine's body store, so scripts, Save-to-file andthe CLI see the whole stream unchanged.
Batching between the engine and the window. A model streaming tokens dispatches faster than
any window can paint, so
engine/frames.tscoalesces frames on a 32ms tick. It may change when aframe arrives, never which, in what order, or after a
stream-end.The window holds a bounded ring.
StreamStatekeeps at most 1000 frames and the true total,so an hour-long stream does not cost an hour of heap. Decision 013 already said the renderer never
holds a response body; this keeps that true for a response that has no end.
A truncated response is no longer reported as a whole one. Found while gathering evidence for
this PR, and present on
mainbefore it.sendresolved fromres.on("end")without checkingres.complete, so a response cut short mid-body came back as a clean success. Againstopenrouter.ai,preman run --timeout-request 3000printed✓ 200 OK 3007msand exited0withhalf a body. A
pm.testin CI would have asserted against it. The same destroyed response couldalso emit neither
endnorerror, leaving the promise unsettled and the process hanging. Bothare fixed by checking
res.completeand adding aclosebackstop.The response header runs a clock. The click time is stamped in
sendNodeand adopted byrequest-start, so the timer covers the save, the IPC hop and pre-request scripts — the wait thereader actually experiences. It settles on the transport
durationMs, which is the number theTimeline, the console,
--reportand the CLI all print. The Timeline shows wall-clockwaitedbeside it, and the gap between the two is the overhead.
Technical Decisions
The frame list is the Body tab rather than a sixth tab, because a stream has no document to
compete with. It stays selected after the stream closes: the body event does arrive at the end,
but flipping the reader to a concatenated document the moment the last frame lands would take away
the view they had been reading.
The store learns a response is streaming from core's
streamingflag, not by sniffing the contenttype. Core refuses to stream a gzipped or redirected event-stream, so a renderer that sniffed
would open a frame list that never fills.
A cancelled stream and a cancelled request end differently, one function apart in
invoke.ts.Cancelling before a response is a transport failure. Cancelling a stream resolves with the real
status, the partial body and a warning, because the request succeeded and the reader chose to stop.
Rejected: an event per frame (the renderer loses that race), keeping every frame in the renderer
(Decision 013), and parsing SSE in the renderer.
The truncation fix has no local regression test, and the test added for it says so in its own
comment. Six local server shapes were tried — idle, mid-write, RST, a short
content-length, plainHTTP and HTTPS — and every one already behaved correctly on
main, so none of them guards the fix.The defect needs a socket destroyed while a remote peer is mid-write, which ends the readable
stream rather than erroring it. The test that was kept locks in the contract and passed before the
fix too; the case that did not is covered by
res.completeand by the evidence below.Critical Modules/Files
packages/core/src/http/invoke.ts— the highest-risk file. It gained an abort path, a streamingpath, the
res.completecheck, and aninterruptclosure that exists because a destroyed requestreports through the request's error listener, not the response's. Without it, Cancel would discard
every frame it had just shown. The four ways an exchange can now end —
endcomplete,endincomplete,
error,close— all route through onestophelper, and the promise ignoreswhichever arrives second.
packages/core/src/http/sse.ts— a hand-rolled incremental parser. It has to survive a codepointsplit across chunks, a CR whose LF has not arrived, and a BOM split in two.
packages/desktop/src/engine/frames.ts— reorders nothing but timing, andhost.tsmust flushit before any other event or a
stream-endovertakes the frames it closes.packages/desktop/src/renderer/stores/runs.ts— thestream-endreducer rewriteshead.timingsafter the fact, which is the one place a head is not final.
Recommended Review Order
docs/decisions/051-cancel-reaches-the-socket.mdand052-a-stream-is-a-response-in-parts.mdpackages/core/src/http/sse.ts, thenpackages/core/src/http/invoke.tspackages/core/src/api/events.tsandpackages/core/src/runner.tspackages/desktop/src/engine/frames.tsandhost.tspackages/desktop/src/renderer/stores/runs.ts,model/stream.ts,panes/StreamViewer.tsxtest/sse.test.ts,test/stream.test.ts,test/cancel.test.ts,test/renderer/stream.test.ts3. Verification & Testing
Provide reproducible inputs or steps for each test case, followed by the result and supporting evidence.
In the desktop app, send a request to a
text/event-streamendpoint and confirm rows appear one at a time while the request is still in flight, newest first, each with an inbound arrow and anHH:MM:SS.mmmtimestampCaptured from the built app against a local SSE endpoint emitting 40 OpenAI-shaped chunks then
[DONE]. Each row leads with the inbound arrow in accent ink, then the collapsed data summary, then the millisecond timestamp, then the disclosure caret.Expand a frame row and confirm it opens a read-only editor with line numbers and JSON highlighting, and that typing into it changes nothing
The same run with one row expanded. The frame opens into a bordered read-only CodeEditor with line numbers, fold arrows and JSON highlighting, sized to the frame's own line count.
Confirm the response header runs a timer from the moment Send is clicked, and that it settles on the transport duration once the response completes
Press Cancel mid-stream and confirm the socket closes, the status and every frame already received are kept, and the run ends
Run against the live
openrouter.ai/api/v1/chat/completionsendpoint withdeepseek/deepseek-v4-flashandstream: true. Sent, allowed to stream for 4 seconds, then cancelled. The response keeps its200and all 40 frames received up to that point, the strip readsConnection closed, and the run ends rather than sitting on an open socket.Kill the server mid-stream and confirm the warning above the list reads
the server closed the connectionRun the same streaming request through
preman runand confirm it buffers, prints the raw stream after close, and is still bounded by--timeout-requestThe same request through the CLI. Nothing is printed for 12 seconds, then the outcome line and the whole raw stream arrive together — the outcome appearing above the body is the buffered contract. 1616 lines, 243,638 bytes.
Confirm a non-streaming response is unchanged: the Body tab still shows the document viewer with its byte count and duration
Confirm the Timeline tab reports
time to headersandwaitedalongsidedurationfor a streamed responseConfirm a response cut short by the per-request deadline is reported as a failure rather than as a partial body
Run against the live
openrouter.aistreaming endpoint, which takes about 12 seconds tocomplete, with a deadline well below that.
mainand this branch before the fix behavedidentically; the third row is this branch after it.
Called as a library,
invokeHttpwithtimeoutMs: 3000returnedok: true, statusCode: 200with 12,339 truncated bytes and an empty
warningsarray before the fix, and returnsok: false, statusCode: 0after it. WithtimeoutMs: 2000the same call could instead hangindefinitely; that no longer happens.
4. Impact and Scope
All three packages.
@preman/coregained the signal and the streaming path,premangainedone bail line,
@preman/desktopgained the batcher, the store fields and the frame list.Behavioural changes.
--timeout-requestnow means two things depending on the caller: a hardceiling for the CLI, a ceiling on the head alone for the app.
response-headis no longer the lastword on a response. Cancel now closes the socket where it previously left it open, which is a
change in what the button does rather than in what it says.
afterResponsescripts do not rununtil a stream closes.
One change affects requests that have nothing to do with streaming. A response cut short now
fails instead of succeeding with a partial body. A suite that was quietly passing on half a
response — because a request was slower than
--timeout-requestand nobody noticed — will startfailing with exit
2. That is the point of the change, but it is the one thing here that can turna green pipeline red without the pipeline having changed.
Compatibility.
RunEventgained two variants and one required field onresponse-head, so anyconsumer switching over the union must handle them. This is an internal contract between core and
the desktop engine; nothing published changes shape. No config, schema or external integration is
affected. A buffered response takes the same path it always did.
Performance. The batcher bounds renderer work at roughly 31 store writes per second regardless
of stream rate, and the 1000-frame ring bounds heap. The response header ticks at 100ms in its own
component, so a tick reconciles one span rather than the pane and the body viewer under it.