Skip to content

Event Stream and Response Timer - #9

Merged
tranphucbol merged 2 commits into
mainfrom
feature/event-stream-and-response-timer
Sep 6, 2026
Merged

tranphucbol merged 2 commits into
mainfrom
feature/event-stream-and-response-timer

Conversation

@tranphucbol

@tranphucbol tranphucbol commented Sep 6, 2026 •

Copy link
Copy Markdown
Owner

1. Purpose & Motivation

Description

preman could not show a text/event-stream. invokeHttp buffered 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.ts previously said, in a comment, that core had no
cancellation and that Cancel only stopped the reporting. That was defensible while every response
was finite and --timeout-request was the floor. A stream ends when the server says so, so
without 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

Send clicked → sendNode() stamps the click → engine → runHttpRequest → invokeHttp
                                                                           │
       [OLD] buffer every chunk → resolve → response-head (durationMs) → response-body
             one timeoutMs covers the whole exchange
                                                                           │
       [NEW] did the caller pass a stream sink?
                 │
                 ├── no (CLI) ────────► buffered path above, --timeout-request unchanged
                 │
                 └── yes (desktop) ──► head arrives
                                          │
                                          ├── compressed, redirect, or not an
                                          │   event-stream → buffered path above
                                          │
                                          └── text/event-stream
                                                    │
                                                    ├── clearTimeout(exchange budget)
                                                    ├── response-head (streaming, headersMs)
                                                    ├── SseParser per chunk → frames
                                                    │      → 32ms batcher → response-frames ×N
                                                    ├── stream-end (durationMs, total, cutShort?)
                                                    └── response-body (raw bytes, as before)

2. Key Changes

Description

Cancellation reaches the socket. An AbortSignal now runs through HttpInvokeOptions, the
gRPC InvokeOptions, RunOptions, ScriptOptions and RunSelectionArgs. The engine host owns
one AbortController per run, so Cancel destroys the request, cancels the gRPC call, stops the
group loop with bailReason: "cancelled", and reaches pm.sendRequest too.

Streaming is opt-in at the transport. invokeHttp takes an optional stream sink. Passing
one gives up timeoutMs after the head arrives and reports the head early. Passing nothing keeps
the old buffered exchange exactly as it was, which is what the CLI does — preman run still
buffers and --timeout-request is still a hard ceiling in CI.

Three new events, one unchanged body. response-head gained a required streaming flag and
carries headersMs instead of durationMs while a stream is open. response-frames repeats.
stream-end closes with the duration the head could not carry and the true frame total. The raw
bytes still become one response-body in the engine's body store, so scripts, Save-to-file and
the 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.ts coalesces frames on a 32ms tick. It may change when a
frame arrives, never which, in what order, or after a stream-end.

The window holds a bounded ring. StreamState keeps 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 main before it. send resolved from res.on("end") without checking
res.complete, so a response cut short mid-body came back as a clean success. Against
openrouter.ai, preman run --timeout-request 3000 printed ✓ 200 OK 3007ms and exited 0 with
half a body. A pm.test in CI would have asserted against it. The same destroyed response could
also emit neither end nor error, leaving the promise unsettled and the process hanging. Both
are fixed by checking res.complete and adding a close backstop.

The response header runs a clock. The click time is stamped in sendNode and adopted by
request-start, so the timer covers the save, the IPC hop and pre-request scripts — the wait the
reader actually experiences. It settles on the transport durationMs, which is the number the
Timeline, the console, --report and the CLI all print. The Timeline shows wall-clock waited
beside 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 streaming flag, not by sniffing the content
type. 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, plain
HTTP 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.complete and by the evidence below.

Critical Modules/Files

  • packages/core/src/http/invoke.ts — the highest-risk file. It gained an abort path, a streaming
    path, the res.complete check, and an interrupt closure that exists because a destroyed request
    reports 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 — end complete, end
    incomplete, error, close — all route through one stop helper, and the promise ignores
    whichever arrives second.
  • packages/core/src/http/sse.ts — a hand-rolled incremental parser. It has to survive a codepoint
    split 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, and host.ts must flush
    it before any other event or a stream-end overtakes the frames it closes.
  • packages/desktop/src/renderer/stores/runs.ts — the stream-end reducer rewrites head.timings
    after the fact, which is the one place a head is not final.

Recommended Review Order

  1. docs/decisions/051-cancel-reaches-the-socket.md and 052-a-stream-is-a-response-in-parts.md
  2. packages/core/src/http/sse.ts, then packages/core/src/http/invoke.ts
  3. packages/core/src/api/events.ts and packages/core/src/runner.ts
  4. packages/desktop/src/engine/frames.ts and host.ts
  5. packages/desktop/src/renderer/stores/runs.ts, model/stream.ts, panes/StreamViewer.tsx
  6. test/sse.test.ts, test/stream.test.ts, test/cancel.test.ts, test/renderer/stream.test.ts

3. 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-stream endpoint and confirm rows appear one at a time while the request is still in flight, newest first, each with an inbound arrow and an HH:MM:SS.mmm timestamp

    Captured 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.

    rows.png

  • 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.

    expanded.png

  • 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/completions endpoint with deepseek/deepseek-v4-flash and stream: true. Sent, allowed to stream for 4 seconds, then cancelled. The response keeps its 200 and all 40 frames received up to that point, the strip reads Connection closed, and the run ends rather than sitting on an open socket.

    cancel-desktop.png

  • Kill the server mid-stream and confirm the warning above the list reads the server closed the connection

  • Run the same streaming request through preman run and confirm it buffers, prints the raw stream after close, and is still bounded by --timeout-request

    The 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.

    warn: no environment selected; only --var values are available
    AI/Deepseek Stream  →  POST /api/v1/chat/completions
    target https://openrouter.ai [tls] (request url)
    
    ✓ 200 OK 12131ms
    : OPENROUTER PROCESSING
    
    : OPENROUTER PROCESSING
    ...
    data: {"id":"gen-1788692583-hFY4poInXRaqMKzgpaK1","object":"chat.completion.chunk", …
    
    data: {"id":"gen-1788692583-hFY4poInXRaqMKzgpaK1","object":"chat.completion.chunk", …
    
    data: [DONE]
    
  • 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 headers and waited alongside duration for a streamed response

  • Confirm 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.ai streaming endpoint, which takes about 12 seconds to
    complete, with a deadline well below that. main and this branch before the fix behaved
    identically; the third row is this branch after it.

    # before, on main and on this branch
    $ preman run "Deepseek Stream" --timeout-request 3000
    ✓ 200 OK 3007ms
    <11,165 bytes of a 243,638-byte response, no [DONE], no warning>
    $ echo $?
    0
    
    # after
    $ preman run "Deepseek Stream" --timeout-request 3000
    ✗ no response 3002ms
      timed out after 3000ms
    $ echo $?
    2
    

    Called as a library, invokeHttp with timeoutMs: 3000 returned ok: true, statusCode: 200
    with 12,339 truncated bytes and an empty warnings array before the fix, and returns
    ok: false, statusCode: 0 after it. With timeoutMs: 2000 the same call could instead hang
    indefinitely; that no longer happens.

4. Impact and Scope

All three packages. @preman/core gained the signal and the streaming path, preman gained
one bail line, @preman/desktop gained the batcher, the store fields and the frame list.

Behavioural changes. --timeout-request now means two things depending on the caller: a hard
ceiling for the CLI, a ceiling on the head alone for the app. response-head is no longer the last
word 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. afterResponse scripts do not run
until 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-request and nobody noticed — will start
failing with exit 2. That is the point of the change, but it is the one thing here that can turn
a green pipeline red without the pipeline having changed.

Compatibility. RunEvent gained two variants and one required field on response-head, so any
consumer 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.

@tranphucbol
tranphucbol merged commit 0584449 into main Sep 6, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant