Skip to content

feat(v3): streams — WebSocket semantics without a listening socket - #5942

Merged
leaanthony merged 64 commits into
masterfrom
feature/gostream
Aug 12, 2026
Merged

leaanthony merged 64 commits into
masterfrom
feature/gostream

Conversation

@leaanthony

@leaanthony leaanthony commented Aug 10, 2026 •

Copy link
Copy Markdown
Member

Description

Adds streams: a named, ordered, bidirectional byte channel between Go and the frontend
with the WebSocket programming model, and no listening socket.

A WebSocket cannot be spoken over a custom URL scheme, so the only way to get one inside a
webview today is to bind a real TCP port. For a desktop app that means an open local port
reachable by any other process on the machine, needing an origin check and a token to be
safe, and visible to every firewall and endpoint-security product the user runs. Apps do
this anyway, because there was no other way to push a continuous feed to the frontend.
This removes the need.

app.HandleStream("telemetry", func(c *application.StreamConn) {
    defer c.Close()
    for {
        frame, err := c.Receive()   // blocks; err on reload, close, or shutdown
        if err != nil {
            return
        }
        c.Send(reply(frame))        // blocks like a socket write
    }
})
const s = Stream("telemetry");      // synchronous, like new WebSocket(url)
s.onmessage = (ev) => decode(ev.data);
s.send(bytes);

Stream(name) returns synchronously with readyState === CONNECTING, so connections can
be created at module scope — the shape generated bindings would use. JSONStream plus
SendJSON/ReceiveJSON give the same thing in objects rather than bytes.

How it works

Go→JS is a held poll over the asset server: the request parks until a frame exists, so
delivery latency is ~0 with no polling interval and nothing adaptive. Frames arriving while
a response is in flight ride the next one, which makes the round trip itself the batching
window — it widens under load without anything measuring it. JS→Go is an ordinary POST per
frame. One poll in flight per window multiplexes every connection on that page.

The handler goroutine's lifetime is the connection's lifetime, exactly like a
gorilla/coder WebSocket handler, so reload, shutdown and cleanup fall out of that single
choice rather than each needing a policy.

Server builds (-tags server) swap the transport for a real WebSocket, since there is
already a listener to upgrade on. HandleStream and StreamConn are shared verbatim;
only the sink differs.

Design decisions carry over the lessons from the recent event-transport work, and each is
commented where it lives: nothing in the Go→JS path touches the main thread (that
inverted 4.4% of events), nothing reaches evaluateJavaScript at any size (that retained
11.6 GB on macOS at 100 × 1 MB/s), control data travels in headers rather than bodies, and
every buffer is bounded and dropped with its window.

Type of change

  • New feature (non-breaking change which adds functionality)
  • This change requires a documentation update (included)

Nothing existing changes behaviour. Events, Emit, and the runtime call path are
untouched.

How Has This Been Tested?

Unit tests (go test ./pkg/application/ -run TestStream -race) cover the protocol,
ordering under eight concurrent senders, backpressure and both caps, poll supersede,
session close, chunk reassembly, and the connection-refusal path. Server mode has its own
suite under -tags server.

A load harness is included at v3/tests/stream-performance (-upload, -reloads, and
scenario sweeps). Across the three platforms, ~41 million frames with 0 drops and 0
reorders in every Go→JS scenario
.

Go→JS peak JS→Go peak
macOS / WebKit-Cocoa 2,141 MB/s 1,507 MB/s
Linux / WebKitGTK 226 MB/s 727 MB/s
Windows / WebView2 100 MB/s 99 MB/s

Small frames go far faster downward than upward — 634,000 frames/s vs ~6,200/s on macOS —
because a poll response coalesces up to 256 frames while every JS→Go frame is its own
request. Latency does not degrade with load: 20,000 frames/s measured a lower p99 than
100 frames/s.

Also verified: reload closes the connection promptly (6 reloads → 7 connects, 6
disconnects, never more than one live), wails3 dev with a Vite dev server in front of the
asset server, and the included example end to end.

  • Windows
  • macOS
  • Linux

Test Configuration

  • macOS 26.4.1 arm64, WebKit-Cocoa, Go 1.26.2 — full sweeps, reload check, server-mode
    tests, wails3 dev, example
  • Ubuntu (kernel 7.0.0-29) x86_64, WebKitGTK 2.52.3 under Xvfb with software GL, Go 1.26.2
    — both directions
  • Windows 11 26200 x86_64, WebView2 151.0.4129.72, MinGW-w64 15.2.0, Go 1.26.2 — both
    directions, run in the interactive console session

The three machines are not comparable hardware, so the shape of each platform's curve is
the meaningful comparison rather than one platform's MB/s against another's.

Known gaps

Documented in the internals guide rather than hidden:

  • The platform layer does not report a cancelled request, so a poll belonging to a page
    that navigated away stays parked until the hold expires. The connection still closes
    promptly, because a new session for a window supersedes the old one. Plumbing
    stopURLSchemeTask and its equivalents through to a context.CancelFunc is the
    outstanding item.
  • Buffer sizes are compile-time constants, not options.
  • No typed streams; frames are []byte by decision.
  • Linux and Windows both plateau on the Go→JS path (~213 and ~90 MB/s) where macOS keeps
    scaling. Each has a candidate mechanism in its response writer — a pipe on Linux,
    whole-body buffering plus a UI-thread hop on Windows — but both are hypotheses, not
    findings.
  • InitialHTML windows cannot use streams: they load with origin === "null" and cannot
    reach the asset server.

Checklist:

  • (v2 only) I have updated website/src/pages/changelog.mdx with details of this PR
  • My code follows the general coding style of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

Documentation added: a user guide, an internals page written for whoever changes this code
next, a mechanical WebSocket-to-Streams migration guide, and a minimal example under
v3/examples/streams. AGENTS.md gains a pointer to the internals page and a section on
the two frontend runtime build outputs, which are easy to confuse.

Summary by CodeRabbit

  • New Features

    • Added bidirectional Go–JavaScript Streams for desktop and server applications.
    • Added binary and JSON stream APIs with lifecycle, buffering, backpressure, and error handling.
    • Added WebSocket-compatible runtime support and server-side WebSocket transport.
    • Added configurable WebSocket origin allowlisting for server applications.
    • Added a Streams example and cross-platform performance testing tools.
  • Documentation

    • Added guides for Streams, transport internals, WebSocket migration, and runtime installation and build workflows.

A WebSocket cannot be spoken over a custom URL scheme, so the only way to
get one inside a webview today is to bind a real TCP port - an open local
port reachable by any other process on the machine, needing an origin check
and a token to be safe. GoStream gives the same programming model over the
asset server that already exists and is already origin-bound.

app.HandleStream(name, handler) registers a handler that runs once per
connection on its own goroutine. StreamConn.Send blocks like a socket write,
Receive blocks like a socket read, and both fail once the peer is gone. The
handler goroutine's lifetime is the connection's lifetime, so reload,
shutdown and cleanup follow from it rather than each needing a policy.

Transport is GET /wails/stream/poll, held until there is something to send,
and POST /wails/stream/send. Control data travels in headers because
WebKitGTK 6.0 can deliver POST bodies as query params for custom URI schemes
and WebView2 caps body delivery around 2 MB. The poll response is binary
rather than JSON: frames are []byte, and base64 in a JSON envelope would
cost 33% on every one.

Deliberately separate from the event system - it shares no code with
Emit/events and does not change them. It does carry over that work's
lessons:

  - Nothing in the Go->JS path touches the main thread. A main-thread emit
    running its eval inline while an earlier goroutine emit was still queued
    inverted 4.4% of events on all three platforms; one queue with one
    drainer cannot do that.
  - Nothing touches evaluateJavaScript at any size, so the 8-16 KB retention
    knee (11.6 GB on macOS, 6.2 GB on WebKitGTK at 100 x 1 MB/sec) is not
    reachable from here.
  - Every buffer is bounded and dropped when its window dies, following
    eventPayloadStore.

Holding a request is safe because every webview request already gets its own
goroutine; the dispatchWorkers pool is pinned at 0 for exactly this reason.

Known gap: the platform layer does not report a cancelled request, so a page
that navigates away leaves its poll parked. A new poll supersedes the old one
immediately, and a session with no poll is reaped, so this is bounded rather
than leaked. Plumbing stopURLSchemeTask through to a context cancel would
make the close instant.
Stream(name) returns synchronously with readyState === CONNECTING, the way
new WebSocket(url) does, so a connection can be created at module scope and
generated bindings can export them as constants:

    export const Telemetry = Stream("telemetry");

The object implements the useful subset of the WebSocket interface -
readyState and its constants, onopen/onmessage/onclose/onerror,
addEventListener, send, close(code, reason), binaryType, bufferedAmount - so
the same application code will work against a real WebSocket in server
builds. One deliberate divergence: binaryType defaults to "arraybuffer"
rather than "blob", because frames are always binary and a Blob would force
an extra async hop to read every message. Properties that cannot be
supported honestly (protocol, extensions) return the empty string rather
than being faked.

Every connection in a page shares one held poll. There is no polling
interval and nothing adaptive, on purpose: the server holds the request
until a frame exists, so latency is already ~0 and a client-side interval
could only add to it. Frames arriving while a response is in flight
accumulate and ride the next one, which makes the round trip itself the
batching window - it widens as load rises without anything measuring it.
The only delay is an error backoff, 250 ms doubling to 5 s.

Sends are serialised per connection with a promise chain, since concurrent
fetch POSTs do not preserve order, and frames over 512 KB are split the same
way the runtime already splits oversized calls.
The ordering test is the regression guard that matters: eight goroutines
send concurrently against a counter handed out under the queue lock, and the
drained order must match the accepted order exactly. That is the failure
that cost the event path 4.4% of its events when a main-thread emit could
run ahead of an earlier queued one.

Also covered: frame encode round trip including a payload past the response
cap, TrySend reporting a full buffer where Send waits for room, the byte cap
binding before the depth cap, a single oversized frame still being delivered
rather than wedging the queue, a newer poll superseding a parked one,
session close unblocking both a parked Receive and a blocked Send, dropWindow
touching only its own window, open being refused with no handler registered,
the open ack preceding anything the handler sends, and chunk reassembly
including out-of-order arrival and an inconsistent total.
Modelled on tests/event-performance, and asking the same questions of the
poll transport that harness asked of the eval path: throughput, loss,
ordering, host and content-process memory, and how long a Send blocks.

Scenarios cover a rate sweep at a small frame, a size sweep, a constant
4 MB/s sweep varying only frame size (the design that located the eval knee
between 8 and 16 KB), and unthrottled runs that exercise the blocking
backpressure path. Go MemStats is sampled alongside host footprint so any
host growth can be attributed to our own allocation rather than to the
transport - the comparison that settled the equivalent question for events.

The page counts frames per poll response without the runtime exposing any
counters: every frame from one response is dispatched synchronously in a
single run, so a microtask queued on the first message fires exactly once
per response. It also runs the JS->Go direction continuously underneath the
Go->JS load, including a 1 MB frame every two seconds that must go through
the chunked send path.
Server mode already has a listener, so there is nothing to emulate there.
/wails/stream/ws upgrades and wsStreamSink writes frames straight to the
socket. HandleStream and StreamConn are shared verbatim with the desktop
build - only the sink differs, which is the payoff for mirroring the
WebSocket model rather than inventing an API.

There is no outbound buffer on that path and the blocking flag is ignored
deliberately: websocket.Conn.Write blocks until the frame is written, so the
socket's own send buffer already provides the backpressure that the desktop
build's bounded queue exists to imitate.

The client chooses via window._wails.streamFactory, installed by custom.js in
server builds. custom.js is injected asynchronously, so a stream created at
module scope can connect before the factory lands and take the poll
transport instead; both work in server mode, since the asset server is
mounted at "/" and the poll endpoints are reachable there too. Delivering the
mode synchronously with the runtime bundle would close that gap.

Also in this change:

- Close the previous session when a window presents a new session id. A
  reload has no socket to close and the platform layer reports no
  cancellation, so without this the old session survived to the TTL sweep -
  60-80s during which the app holds two live connections to the same stream
  and a handler owning a per-connection resource has two of them. Measured
  with -reloads 6: 7 connects, 6 disconnects, 1 live at a time, the old
  connection closing before its replacement appears.

- Pass the connection id to enqueue explicitly instead of patching the frame
  after the fact. The refusal path had no StreamConn to carry the id and
  retagged the last queued frame under a second lock acquisition; a poll
  draining in between would either ship the refusal with id 0, leaving the
  frontend in CONNECTING with no error, or retag an unrelated connection's
  frame.

- Surface a framing mismatch instead of swallowing it. The throw was inside
  the poll loop's own try, so a stale cached runtime against a newer Go side
  became a silent backoff loop forever; it now closes every connection with
  code 1002.
… unblock oversized frames

Two fixes and the throughput measurements behind them.

Transport selection was racy for the case it matters most for. custom.js is
injected with loadOptionalScript - a HEAD request followed by a <script> tag -
so it lands long after the runtime's dependents have evaluated. A stream
created at module scope, which is exactly what generated bindings will emit:

    export const Telemetry = Stream("telemetry");

connected before the WebSocket factory existed and silently took the poll
transport in server builds. The factory now ships as a prelude prepended to
the runtime bundle, which is synchronous by construction: ES module
dependencies evaluate before their importers, so it is installed before any
generated module can call Stream. custom.js no longer carries a copy.

A frame larger than the per-window byte cap could not be sent at all. enqueue
required outBytes+len(data) <= streamOutQueueBytes, which for a frame bigger
than the cap can never come true however long it waits: Send blocked forever
and TrySend reported full permanently. Frame size is not always the caller's
choice - a struct with a []byte field marshals to whatever it marshals to -
so an empty queue now accepts one frame of any size, the same principle as a
poll response always carrying at least one frame. The cap governs how much
may accumulate, not how large any single frame may be.

The harness gains an unthrottled frame-size sweep and a JS->Go upload matrix
over frame size and connection count, driven from Go over a control stream -
which also exercises four simultaneous streams on one window.

Measured, macOS, 0 drops and 0 reorders throughout:

  Go->JS   634,036 frames/s at 256 B (155 MB/s), rising to 2,141 MB/s at 4 MB
  JS->Go   6,200 frames/s at 64 KB (192 MB/s), peaking at 1,507 MB/s at 4 MB

Both directions top out near 1.3-2.1 GB/s on large frames; the asymmetry is in
frame rate, since a poll response carries up to 256 frames while every JS->Go
frame is its own POST.

Two harness bugs were fixed before those numbers could be trusted: setTimeout(0)
is clamped to ~4ms once nested, which was capping small-frame uploads at the
timer rather than the transport, and the replacement MessageChannel ticker had
a single resolver slot, so concurrent uploaders hung and multi-connection rows
read as "concurrency is slower".
Functional only, per instruction - server mode needs to work, not to be fast.
Covers a five-round-trip echo through the same StreamConn the desktop build
uses, a 12 MB frame (past the desktop per-window buffer, which a socket has no
equivalent of), a 404 for an unregistered stream name, and socket close making
Receive return ErrStreamClosed so the handler unwinds the way a desktop reload
makes it.

Also fixes an App.error format-directive mismatch that only the server build
compiled.
Two pages. guides/streams.mdx is the user-facing API: quick start, the Go and
JavaScript surfaces, lifecycle, backpressure, server mode, measured throughput
per platform, and when to reach for bindings or events instead.

guides/advanced/streams-internals.mdx is written for whoever changes this code
next. It covers the held-poll design and why there is no polling interval, the
binary framing, the buffer constants and how to pick them, session and
connection lifecycle, transport selection, and an explicit list of what is not
finished.

The internals page exists because several decisions look arbitrary until you
know which measured bug they prevent - no main-thread hop in the send path, no
evaluateJavaScript at any size, control data in headers rather than bodies, one
poll in flight per window. Each of those is a scar from the event transport
work, and removing one re-opens a measured bug.

AGENTS.md gains a Subsystem References section pointing at it.
The API guide says what streams are and the internals page says how they work;
neither told anyone how to convert existing WebSocket code. This does, in a form
that can be followed literally.

It leads with the three differences that break silently rather than loudly. The
worst is that ev.data is always an ArrayBuffer, so JSON.parse(ev.data) keeps
compiling and running while parsing "[object ArrayBuffer]". Sending is
compatible - send() encodes a string as UTF-8 - so half the code keeps working,
which is exactly what makes the receive path easy to miss. A drop-in TextStream
shim is included for codebases that would rather not touch every handler.

Also covers the case where the frontend talks to a broker rather than to the
app - nats.ws and friends. A stream is not a drop-in there, because it connects
the frontend to Go rather than to a third party. The migration is to move the
broker client into Go and expose what the frontend needs over a stream, which
keeps broker credentials out of the frontend entirely. The sketch uses TrySend
in the subscription callback, since blocking there would stall delivery for
every subscription on that connection.

Ends with a checklist, the symbols worth grepping for, a behaviour-difference
table, and four post-migration sanity checks.
The usual reason a Wails app has a WebSocket is not that it wanted one - it is
that there was no way to push a continuous feed to the frontend, so the app
stands up its own http.Server on a local port and connects back to it. That case
now leads the guide, because the migration deletes more than the socket.

Port discovery goes: a bound GetServerPort, a fixed port with a fallback, an
injected global - all of it, since a stream is addressed by name and the name is
a compile-time constant on both sides. CORS configuration goes, because the page
and the stream now share an origin. Any token invented to stop other local
processes connecting goes, because there is no port left to connect to.

Non-WebSocket endpoints that accumulated on that server do not need a second
server either - AssetOptions.Middleware takes the existing mux, and the frontend
calls it as a same-origin relative URL.

The broker case (nats.ws and similar) is kept but demoted, since it is the rarer
shape in a Wails app and is an architecture change rather than a swap.
The smallest thing that shows both directions: Go sends the time once a second
and echoes whatever the frontend sends. One handler, one page, no framework.

Verified end to end on macOS - the Go side logs the connection and the frame it
receives from the browser, so the round trip is demonstrated rather than
asserted.

The README calls out the two things people trip over: ev.data is always an
ArrayBuffer so it must be decoded, while sending a string works unchanged
because send() encodes as UTF-8. That asymmetry is what makes the receive path
easy to miss.
Frames stay []byte on the wire - no protocol change, and a Go handler cannot
tell the difference - but almost nobody wants to think in bytes. StreamConn
gains SendJSON and ReceiveJSON; the runtime gains JSONStream, which is the same
socket with encoding done at the boundary, so ev.data arrives as a parsed object
and send() stringifies.

This also removes the sharpest edge in the migration path. ev.data being an
ArrayBuffer means JSON.parse(ev.data) keeps running while parsing "[object
ArrayBuffer]"; with JSONStream a JSON WebSocket migration is now swap the
constructor and delete the parse/stringify calls.

Making that composable required a fix in the client: on* handlers were invoked
directly and then the event was also dispatched, so a wrapper would have seen
both the raw and the decoded event. They are now accessor-backed listeners, the
way the DOM defines them.

Verified end to end on macOS with a temporary probe in the example: JS sends an
object, Go logs it via ReceiveJSON, replies with SendJSON, and JS decodes it and
sends a confirmation Go logs in turn - so both legs including the client-side
decode are demonstrated rather than assumed. Probe reverted; the example stays
on raw bytes as the reference.
…tend resolves the runtime

Verified with a generated vanilla-js project against the branch: the Vite dev
server proxies at "/", and /wails/stream/* is matched by the asset server
middleware before the proxy, so streams are unaffected. The handler logged the
connection and the frame the browser sent through it.

Setting that test up surfaced something worth writing down. A generated project
imports @wailsio/runtime from npm, not from the bundled /wails/runtime.js the
asset server serves, so under wails3 dev an app built against the published
runtime will not see client-side additions made on a branch - Stream and
JSONStream included. Testing an app against this branch means installing the
package from the working tree.

That package's dist/ is built by npx tsc in its own directory, which is a
separate step from the esbuild run that produces bundledassets/runtime.js for
the webview. Both need rebuilding after a change to stream.ts, and it is easy to
do one and not the other.
…heckout

An app imports @wailsio/runtime from npm, not from the /wails/runtime.js the
asset server serves, so under `wails3 dev` Vite resolves it from node_modules
and the app never sees runtime changes made in a checkout. Testing a branch
against a real app therefore needs the package installed from the working tree,
which was a fiddly manual step.

    task v3:install-runtime -- ./path/to/your-app/frontend

Rebuilds dist/ first so it always installs current sources, and refuses a
directory with no package.json rather than letting npm create one.

Also adds runtime:build:package, which produces that dist/. It is a different
output from build:assets: build:package is what an app's frontend imports,
build:assets is what the webview loads. A change to the client needs both, and
rebuilding one and not the other is an easy mistake to make.

Verified against a freshly generated vanilla-js project: JSONStream is absent
from the installed package before the task runs and present after, and both
guard clauses were exercised.
…ild outputs in AGENTS.md

The example predated SendJSON/ReceiveJSON and JSONStream, so it demonstrated
byte handling for traffic that is objects in almost every real app. It now shows
the ergonomic path, which is also less code - no TextDecoder, no manual
stringify. The raw byte API is kept in the README under "If you want bytes",
along with the ArrayBuffer caveat that applies there.

AGENTS.md gains a section on the two frontend runtime build outputs, because
rebuilding one and not the other is an easy mistake with confusing symptoms:
build:assets produces the bundles the webview loads and CI verifies, while
build:package produces the dist/ an app's frontend imports from node_modules. It
also documents install-runtime for pointing a real app at a checkout.

Verified by running the updated example on macOS: the frontend sends an object,
Go logs it via ReceiveJSON and replies with SendJSON. Probe reverted afterwards.
@coderabbitai

coderabbitai Bot commented Aug 10, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ac403e34-72f2-4674-97d5-316e5318242e

📥 Commits

Reviewing files that changed from the base of the PR and between b52cca3 and d02c8ae.

📒 Files selected for processing (1)
  • v3/UNRELEASED_CHANGELOG.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • v3/UNRELEASED_CHANGELOG.md

Walkthrough

Added bidirectional Go and JavaScript Streams with desktop polling and server WebSocket transports. Added runtime APIs, bounded buffering, retries, lifecycle handling, documentation, examples, tests, and a performance harness.

Changes

GoStream feature

Layer / File(s) Summary
Stream application transport
v3/pkg/application/stream*.go, v3/pkg/application/application*.go, v3/pkg/application/*test.go
Added stream contracts, sessions, HTTP polling, WebSocket serving, framing, chunk reassembly, backpressure, global budgets, origin controls, lifecycle cleanup, and tests.
JavaScript runtime implementation
v3/internal/runtime/desktop/@wailsio/runtime/src/*, v3/internal/assetserver/bundledassets/runtime.js
Added Stream, JSONStream, and WailsSocket with batching, chunking, retries, polling, validation, secure identifiers, and lifecycle events.
Documentation and example
docs/src/content/docs/guides/*, v3/examples/streams/*, v3/UNRELEASED_CHANGELOG.md, AGENTS.md
Documented the APIs, internals, WebSocket migration, build workflow, and Streams example.
Performance harness
v3/tests/stream-performance/*
Added load scenarios, browser metrics, throughput reporting, and platform-specific WebContent memory sampling.
Build and lifecycle support
v3/Taskfile.yaml, v3/internal/runtime/Taskfile.yaml, v3/internal/debounce/*, v3/pkg/application/transport_http*
Added local runtime package tasks, deterministic debounce tests, and synchronized transport cleanup.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant JavaScriptRuntime
  participant StreamTransport
  participant StreamSession
  participant GoHandler
  JavaScriptRuntime->>StreamTransport: Open, poll, and POST stream frames
  StreamTransport->>StreamSession: Validate session and enqueue frames
  StreamSession->>GoHandler: Deliver inbound data and lifecycle events
  GoHandler->>StreamSession: Send or close
  StreamSession->>StreamTransport: Encode queued frames
  StreamTransport->>JavaScriptRuntime: Return binary stream frames
Loading

Possibly related PRs

  • wailsapp/wails#4702: The stream transport builds on its application transport and runtime asset architecture.
  • wailsapp/wails#4903: The server transport adds WebSocket-backed Streams through the existing server application paths.
  • wailsapp/wails#5369: The stream transport extends related HTTP chunk assembly and cleanup behavior.

Suggested labels: Enhancement, go, size:XXL

Poem

I’m a rabbit hopping through each stream,
Carrying bytes in a tidy dream.
Go sends, JavaScript replies,
Polls and sockets cross the skies.
Bounded queues keep carrots neat,
Tests make every path complete.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.12% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: adding WebSocket-like streams without requiring a listening socket.
Description check ✅ Passed The description covers the feature, motivation, testing, platforms, known gaps, documentation, and checklist; issue and wails doctor details are not provided.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/gostream

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added Documentation Improvements or additions to documentation v3 Windows MacOS Linux runtime labels Aug 10, 2026
Comment thread v3/internal/runtime/desktop/@wailsio/runtime/src/stream.ts Fixed

@leaanthony leaanthony left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream review findings from a local implementation trace and targeted test pass. These comments evaluate the documented Wails Streams contracts; they do not assume that the desktop held-poll/POST transport is a WebSocket implementation. The one coder/websocket-specific comment is limited to -tags server.

Comment thread v3/pkg/application/stream_server.go Outdated
Comment thread v3/pkg/application/stream_session.go Outdated
Comment thread v3/pkg/application/stream.go Outdated
Comment thread v3/pkg/application/stream_session.go Outdated
Comment thread v3/pkg/application/stream_server.go Outdated
Comment thread v3/internal/runtime/desktop/@wailsio/runtime/src/stream.ts
Comment thread v3/pkg/application/stream_transport.go Outdated
Comment thread v3/internal/runtime/desktop/@wailsio/runtime/src/stream.ts Outdated
Comment thread v3/internal/runtime/desktop/@wailsio/runtime/src/stream.ts Outdated
Comment thread v3/pkg/application/stream_transport.go Outdated
Eleven issues from the PR review, all real. The three that mattered most were
places where the implementation did not do what the documentation claimed.

Bounded the inbound queue. deliver appended without limit, so a handler slow to
call Receive let the frontend grow host memory without bound - the send endpoint
responds as soon as it has queued, so the client is free to post again
immediately. It now blocks while the inbox is full, which leaves the client's
fetch unresolved on the desktop and stalls the socket read pump in server mode.
The feature claimed bounded memory; now it has it in both directions.

Control frames bypass the caps. A full data queue made the non-blocking open ack
fail silently, leaving the frontend in CONNECTING forever with the handler
running. Losing a data frame is a slow-down; losing a control frame is a
protocol failure.

A handler that simply returns now notifies the frontend. The wrapper deferred
shutdown(), which cancels the connection but queues no close frame, so the
browser socket stayed open and onclose never fired - despite the API documenting
that returning from the handler closes the connection.

Also:

- Server mode raised its read limit to streamMaxSendBytes. coder/websocket
  defaults to 32 KiB, so any frontend frame past that closed the connection,
  well under the documented 64 MB.
- Server mode gained a bounded write queue with a single pump, so TrySend is
  genuinely non-blocking there rather than blocking on Conn.Write. The migration
  guide's fan-out and broker-callback examples pick TrySend precisely to avoid
  stalling a producer, and that promise did not hold under -tags server.
- Both sinks copy the payload while accepting it. Acceptance is reported before
  the frame is encoded, so a reused or pooled buffer could alter a frame already
  acknowledged. The client snapshots at the send() boundary for the same reason:
  conversion was deferred into the promise chain and read the caller's buffer
  later.
- Rejected chunk sets return an error instead of the same not-done signal used
  for an incomplete frame, which had produced a 204 - the client's send chain
  completed successfully while the frame was silently dropped. The
  inconsistent-total path also leaked its bytes, permanently consuming capacity.
- HEAD no longer reaches the poll. It ran the same drain and then had its body
  suppressed, consuming frames that were never delivered.
- JSONStream decodes through a hook on the socket rather than by wrapping
  onmessage, so addEventListener("message") sees the parsed object too, and it
  returns a JSONSocket interface whose send accepts a value - TypeScript
  consumers could not compile the documented usage before.
- nanoid draws from crypto.getRandomValues instead of Math.random, with a
  fallback for engines without Web Crypto. This covers the runtime client id and
  binding call ids as well as stream sessions; a stream session in server mode
  has no window-id header to bind against, so a predictable id would be
  guessable by anything that can reach the port.

Regression tests added for each. Verified with the example: both the onmessage
and addEventListener paths receive decoded objects.
Blocking deliver bounded memory but held the POST open while it waited, and on
this transport the held request is the scarce resource: enough stalled sends
starve the window's single poll. Measured as a 25-32% upload throughput loss at
large frames.

deliver now returns ErrStreamFull, the endpoint answers 429, and the client
retries the same frame - order preserved by the send chain, memory still
bounded, request slot released. Server mode keeps a waiting read pump, since a
socket has no retry channel and holds no slot.

Throughput against the pre-review baseline: 64KB x1 192 -> 218 MB/s, 1KB x1
3,626 -> 5,527 frames/s.

NOT a complete fix: 512KB x4 and x8 still fail with the session closed mid-run,
so request-slot starvation was not the whole cause. Recorded rather than
claimed.
A data or close frame naming a session that no longer exists used to create a
fresh one, because the send endpoint passed create=true unconditionally. Go
would then hold a live session with no connections while the page believed its
streams were open, and every later send succeeded into the void. Sends for an
unknown session now get 410 so the client surfaces a close.

Correct on its own merits, but it does NOT fix the multi-connection upload
failure: 512KB x4 and x8 still end with the session closed mid-run. That is the
second hypothesis for it disproved, after request-slot starvation.
…th zero delay

Two regressions from the review round, both mine, both measured with the
streams example driving four connections of 512 KB frames.

The client copied every buffer at the send() boundary. That is spec-faithful -
a real WebSocket copies - but at several hundred MB/s the allocation churn
dominates the transport, and it landed in the same commit as the review fixes,
which matches where the cgo call rate was first seen to climb. send() now takes
a view and the ownership rule is documented instead: do not mutate a buffer you
have handed to send() until it has gone out.

The 429 retry started at zero delay, so a connection whose receiver was behind
busy-looped on fetch - each iteration a scheme-handler round trip, and on the
host a cgo call. It now starts at 1 ms and ramps to 50.

Profiled with examples/streams as the base, sampling runtime.NumCgoCall
alongside frames received:

  live=4 for the whole run, no connection closed in 100 s
  ~3,100 frames/s at 512 KB = ~1,570 MB/s
  ~34,500 cgo/s against ~3,135 frames/s - a fixed ~11 calls per frame

That fixed ratio is the point: a spin shows up as cgo rate decoupled from frame
rate, and it is not. The 512KB x4 load that previously ended with the session
closed now runs indefinitely.
The API shape is what has to match a WebSocket, not its copy semantics. Both
sinks were doing a full memcpy per frame on accept, which at these rates is the
single largest cost in the transport and buys nothing a caller cannot arrange
itself - almost every caller hands over freshly marshalled bytes.

Send now queues the caller's slice, with the ownership rule documented: do not
mutate or reuse a slice after passing it. A test pins the aliasing so a copy is
not reintroduced for tidiness.

Measured on macOS with the streams example and the load harness, no drops and no
reorders:

  Go->JS  256 B  806,834 frames/s    197 MB/s
          4 KB   406,804 frames/s  1,589 MB/s
          64 KB   31,489 frames/s  1,968 MB/s
          1 MB     2,076 frames/s  2,076 MB/s
          4 MB       779 frames/s  3,117 MB/s
  JS->Go  512 KB x4  5,586 frames/s  2,793 MB/s

Both directions now beat the pre-review baseline: Go->JS 4 MB 2,141 -> 3,117
MB/s, JS->Go 1,303 -> 2,793 MB/s. cgo calls hold at ~11 per frame, unchanged -
the rate rises only because more frames move, which is what distinguishes real
throughput from a spin. Four concurrent 512 KB connections stay live with zero
closures, the load that previously ended with the session closed.
@leaanthony
leaanthony marked this pull request as ready for review August 11, 2026 23:38
Copilot AI lite review requested due to automatic review settings August 11, 2026 23:38
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 11, 2026 •

Copy link
Copy Markdown

Deploying wails with  Cloudflare Pages  Cloudflare Pages

Latest commit: 37f6e96
Status:🚫  Build failed.

View logs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces Streams for Wails v3: a named, ordered, bidirectional Go↔frontend byte channel with WebSocket-like semantics but no listening TCP socket (desktop transport via held-poll + POST; -tags server uses a real WebSocket transport). It also adds documentation, examples, and a performance/load harness to validate correctness and throughput.

Changes:

  • Add the Streams Go API (HandleStream, StreamConn, JSON helpers), session/connection lifecycle management, and desktop + server transports.
  • Extend runtime/tasking/docs/examples to support and explain Streams (including server-mode transport selection via a runtime prelude).
  • Add tests and tooling: Go unit tests around transport lifecycle and server-mode streams, runtime unit tests for the JS client/protocol behavior, and a cross-platform performance harness.

Reviewed changes

Copilot reviewed 45 out of 75 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
v3/UNRELEASED_CHANGELOG.md Adds an unreleased entry describing Streams
v3/tests/stream-performance/scenarios.go Defines load/perf scenarios and upload variants
v3/tests/stream-performance/sampler_windows.go Windows WebView2 process enumeration + memory sampling
v3/tests/stream-performance/sampler_other.go Fallback sampler stub for unsupported platforms
v3/tests/stream-performance/sampler_linux.go Linux WebKit web-process enumeration + memory sampling
v3/tests/stream-performance/sampler_darwin.go macOS WebContent enumeration + phys_footprint sampling
v3/tests/stream-performance/report.go Scenario result aggregation and CSV/JSON reporting
v3/tests/stream-performance/main.go Stream performance harness app and load generator
v3/tests/stream-performance/assets/index.html Frontend harness logic using Stream() and upload drivers
v3/Taskfile.yaml Adds v3:install-runtime helper task for installing runtime from working tree
v3/pkg/application/webview_window.go Drops stream sessions on window destroy to unblock handlers promptly
v3/pkg/application/webview_window_windows.go Import order/formatting adjustment
v3/pkg/application/webview_window_windows_nonclient.go Import order/formatting adjustment
v3/pkg/application/transport_http.go Makes cleanup goroutine lifecycle idempotent + Stop waits for completion
v3/pkg/application/transport_http_test.go Adds concurrency/lifecycle test coverage for HTTP transport cleanup
v3/pkg/application/transport_event_ipc_test.go Formatting adjustment
v3/pkg/application/systemtray_ios.go Formatting adjustment
v3/pkg/application/stream.go New Streams public API + manager + connection semantics
v3/pkg/application/stream_transport.go New desktop HTTP transport endpoints, framing, chunk reassembly, runtime prelude bundling
v3/pkg/application/stream_session.go New per-page session: queues, multiplexing, ordering, connection table
v3/pkg/application/stream_server.go New server-mode WebSocket sink + endpoint handler (-tags server)
v3/pkg/application/stream_server_test.go Server-mode WebSocket transport tests
v3/pkg/application/stream_prelude_server.go Server-mode runtime prelude to select WebSocket transport early
v3/pkg/application/stream_prelude_desktop.go Desktop build prelude stub (no factory)
v3/pkg/application/single_instance_ios.go Formatting adjustment
v3/pkg/application/signal_handler_types_ios.go Formatting adjustment
v3/pkg/application/signal_handler_types_desktop.go Formatting adjustment
v3/pkg/application/signal_handler_ios.go Formatting adjustment
v3/pkg/application/screenmanager_internal_test.go Formatting adjustment
v3/pkg/application/menuitem_ios.go Formatting adjustment
v3/pkg/application/menu_ios.go Formatting adjustment
v3/pkg/application/mcp_protocol_enabled.go Formatting adjustment
v3/pkg/application/logger_dev.go Import order/formatting adjustment
v3/pkg/application/logger_dev_windows.go Import order/formatting adjustment
v3/pkg/application/keys_test.go Formatting adjustment
v3/pkg/application/keys_linux.go Whitespace cleanup
v3/pkg/application/keys_ios.go Formatting adjustment
v3/pkg/application/ios_runtime_stub.go Formatting adjustment
v3/pkg/application/ios_runtime_ios.go Comment/formatting adjustments
v3/pkg/application/init_desktop.go Formatting adjustment
v3/pkg/application/events_common_windows.go Formatting adjustment
v3/pkg/application/events_common_ios.go Formatting adjustment
v3/pkg/application/dialogs_windows_test.go Formatting adjustment
v3/pkg/application/dialogs_ios.go Formatting adjustment
v3/pkg/application/dialogs_android.go Formatting adjustment
v3/pkg/application/context_menu_manager.go Formatting adjustment
v3/pkg/application/browser_window.go Formatting adjustment
v3/pkg/application/autostart.go Formatting adjustment
v3/pkg/application/autostart_windows_test.go Formatting adjustment
v3/pkg/application/autostart_test.go Formatting adjustment
v3/pkg/application/autostart_server.go Formatting adjustment
v3/pkg/application/autostart_linux_test.go Formatting adjustment
v3/pkg/application/autostart_ios.go Formatting adjustment
v3/pkg/application/autostart_darwin_smappservice.go Formatting adjustment
v3/pkg/application/autostart_android.go Formatting adjustment
v3/pkg/application/application.go Wires Streams into app lifecycle + routes stream endpoints + serves runtime with prelude
v3/pkg/application/application_server.go Adds server-mode stream WebSocket endpoint route
v3/internal/runtime/Taskfile.yaml Adds build:package runtime task (dist/)
v3/internal/runtime/desktop/@wailsio/runtime/src/stream.test.js Adds extensive runtime protocol and JSONStream tests
v3/internal/runtime/desktop/@wailsio/runtime/src/ssr_import.test.js Adjusts timeout for SSR import test
v3/internal/runtime/desktop/@wailsio/runtime/src/nanoid.ts Switches nanoid to Web Crypto when available
v3/internal/runtime/desktop/@wailsio/runtime/src/index.ts Exports Streams API from runtime package
v3/internal/debounce/debounce.go Refactors timer callback into fire helper
v3/internal/debounce/debounce_test.go Makes generation/zero-duration tests deterministic (no fixed sleeps)
v3/examples/streams/README.md New Streams example README
v3/examples/streams/main.go New Streams example app using HandleStream + JSON helpers
v3/examples/streams/assets/index.html New Streams example frontend using JSONStream()
docs/src/content/docs/guides/streams.mdx New Streams user guide
docs/src/content/docs/guides/streams-from-websockets.mdx New mechanical migration guide from WebSockets
docs/src/content/docs/guides/advanced/streams-internals.mdx New internals guide documenting design decisions and invariants
AGENTS.md Adds agent guidance for Streams internals + runtime build outputs
Files not reviewed (1)
  • v3/internal/assetserver/bundledassets/runtime.js: Generated file

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread v3/pkg/application/stream_transport.go
Comment thread v3/UNRELEASED_CHANGELOG.md Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (11)
v3/Taskfile.yaml (1)

64-66: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use toSlash when constructing PKG.

Go Task v3.44.1 leaves TASKFILE_DIR with Windows separators. The repository does not declare a minimum Go Task version. Use {{.TASKFILE_DIR | toSlash}} before shell parsing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v3/Taskfile.yaml` around lines 64 - 66, Update the PKG assignment in the npm
installation task to construct TASKFILE_DIR with the toSlash template function
before shell parsing, preserving the existing runtime package path and npm
install behavior.
v3/tests/stream-performance/assets/index.html (1)

133-136: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Guard the echo timers against a second onopen.

echo.onopen starts two intervals. If the stream reopens during a run, the handler runs again and registers duplicate timers. The echo send rate then doubles and perturbs the measurement. Store the timer ids and clear them, or start the timers only once.

♻️ Proposed guard
+let echoTimers = null;
 echo.onopen = () => {
-  setInterval(() => sendEcho(64), 100);        // steady small traffic
-  setInterval(() => sendEcho(1024 * 1024), 2000); // exercises chunked send
+  if (echoTimers) return;
+  echoTimers = [
+    setInterval(() => sendEcho(64), 100),        // steady small traffic
+    setInterval(() => sendEcho(1024 * 1024), 2000), // exercises chunked send
+  ];
 };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v3/tests/stream-performance/assets/index.html` around lines 133 - 136, Update
the echo.onopen handler to prevent duplicate interval registration when the
stream reopens. Store the timer IDs and clear existing intervals before creating
new ones, or otherwise guard initialization so the two sendEcho timers run only
once.
v3/tests/stream-performance/report.go (2)

108-137: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Report CSV flush errors.

writeScenarioCSV defers w.Flush() and f.Close() but checks neither. A short write or a full disk produces a truncated CSV and the function still returns nil. Check w.Error() and the Close result before returning.

♻️ Proposed change
-func writeScenarioCSV(dir string, r *scenarioResult) error {
+func writeScenarioCSV(dir string, r *scenarioResult) (err error) {
 	f, err := os.Create(filepath.Join(dir, r.Name+".csv"))
 	if err != nil {
 		return err
 	}
-	defer f.Close()
+	defer func() {
+		if cerr := f.Close(); err == nil {
+			err = cerr
+		}
+	}()
 
 	w := csv.NewWriter(f)
-	defer w.Flush()
@@
 	}
-	return nil
+	w.Flush()
+	return w.Error()
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v3/tests/stream-performance/report.go` around lines 108 - 137, Update
writeScenarioCSV to propagate deferred CSV flush and file-close failures instead
of always returning nil. Check w.Error() after flushing and return any resulting
error, then handle and return the result of f.Close(), while preserving existing
write-error handling.

39-42: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add JSON tags to SendP50 and SendP99.

Every other field in scenarioResult carries a lowercase JSON tag. These two fields serialise as SendP50 and SendP99 in summary.json, which breaks the naming convention for any consumer of that file.

♻️ Proposed change
 	Sent    int64 `json:"sent"`
 	Echoes  int64 `json:"echoes"`
-	SendP50 float64
-	SendP99 float64
+	SendP50 float64 `json:"sendP50"`
+	SendP99 float64 `json:"sendP99"`
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v3/tests/stream-performance/report.go` around lines 39 - 42, Update the
scenarioResult fields SendP50 and SendP99 to include lowercase JSON tags
matching the naming convention used by Sent and Echoes, so summary.json
serializes them with consistent names.
v3/tests/stream-performance/scenarios.go (1)

73-88: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Report unmatched names in -only.

selectedScenarios drops any name that matches no scenario. A typo such as -only rate-500 returns an empty slice. The harness then logs "running 0 scenario(s)", writes an empty summary.json, and exits successfully, so the mistake is not obvious. Return the unmatched names, or log them, so the operator sees the typo.

♻️ Proposed change
 func selectedScenarios(only string) []Scenario {
 	if strings.TrimSpace(only) == "" {
 		return allScenarios
 	}
 	want := map[string]bool{}
 	for _, n := range strings.Split(only, ",") {
-		want[strings.TrimSpace(n)] = true
+		if n = strings.TrimSpace(n); n != "" {
+			want[n] = true
+		}
 	}
 	var out []Scenario
 	for _, sc := range allScenarios {
 		if want[sc.Name] {
 			out = append(out, sc)
+			delete(want, sc.Name)
 		}
 	}
+	for n := range want {
+		log.Printf("unknown scenario name %q ignored", n)
+	}
 	return out
 }

Add "log" to the import block.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v3/tests/stream-performance/scenarios.go` around lines 73 - 88, Update
selectedScenarios to detect names from the only list that do not match any
allScenarios entry, and log each unmatched name using the log package. Preserve
the existing selection behavior for valid names while ensuring typos are visible
to the operator.
v3/pkg/application/application_server.go (1)

223-226: 🩺 Stability & Availability | 🔵 Trivial

Document the interaction between Server.WriteTimeout and the held-poll fallback.

The desktop poll endpoints stay reachable in server mode through the "/" asset mount, as noted in v3/pkg/application/stream_server.go. A held poll parks for up to streamHoldTimeout (20 seconds). The default writeTimeout here is 30 seconds, so the default is safe, but an operator who sets opts.WriteTimeout below 20 seconds will see held polls terminated by the server rather than by the stream layer.

The WebSocket route itself is unaffected, because the upgrade hijacks the connection and clears the deadlines.

Add a note to the server options documentation, or clamp the effective hold to a value below the configured write timeout.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v3/pkg/application/application_server.go` around lines 223 - 226, Document in
the server options documentation that Server.WriteTimeout must exceed the
held-poll streamHoldTimeout (20 seconds) to avoid server-terminating fallback
polls, noting that the WebSocket route is unaffected; alternatively, clamp the
effective held-poll duration below the configured write timeout while preserving
the existing WebSocket behavior.
v3/pkg/application/stream_test.go (1)

799-818: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace the fixed sleeps before these assertions with a deadline poll.

Both tests sleep 50 ms and then assert on the queue contents. On a loaded CI runner the handler goroutine may not have run yet, so the drain returns fewer frames than expected and the test fails.

This repository already moved away from this pattern. v3/internal/debounce/debounce_test.go records that a fixed 10 ms wait "was the same bet that made the stale-callback test flaky on CI" and now waits on a channel instead.

Poll the queue under s.mu until the expected frames are present, with a generous deadline. TestStreamPendingClosesReserveConnectionCapacity at lines 1072-1084 already uses exactly that shape.

♻️ Proposed change for `TestStreamOpenAckPrecedesHandlerOutput`
 	s.open(9, "greet")
 	<-started
 
-	// Give the handler's Send time to land.
-	time.Sleep(50 * time.Millisecond)
-
-	s.mu.Lock()
-	frames, _ := s.drainLocked(streamMaxResponseBytes)
-	s.mu.Unlock()
-
-	if len(frames) < 2 {
-		t.Fatalf("got %d frames, want the ack plus the handler's frame", len(frames))
-	}
+	// Wait for the handler's Send to land rather than for a clock.
+	var frames []outFrame
+	deadline := time.Now().Add(2 * time.Second)
+	for {
+		s.mu.Lock()
+		queued := len(s.out)
+		s.mu.Unlock()
+		if queued >= 2 {
+			break
+		}
+		if time.Now().After(deadline) {
+			t.Fatalf("got %d frames, want the ack plus the handler's frame", queued)
+		}
+		time.Sleep(time.Millisecond)
+	}
+	s.mu.Lock()
+	frames, _ = s.drainLocked(streamMaxResponseBytes)
+	s.mu.Unlock()

Also applies to: 937-954

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v3/pkg/application/stream_test.go` around lines 799 - 818, Replace the fixed
50 ms sleeps in both stream tests, including
TestStreamOpenAckPrecedesHandlerOutput and the test around the second
occurrence, with deadline-based polling of the queue under s.mu. Continue
polling until the expected frames are available, then perform the existing
assertions; follow the polling shape used by
TestStreamPendingClosesReserveConnectionCapacity and retain a generous timeout.
v3/internal/assetserver/bundledassets/runtime.js (1)

1-1: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Reconsider the unbounded 429 retry loop in the single-frame send path.

The single-frame send retries the same POST while the server answers 429, with backoff capped at 50 ms and no attempt limit. If a Go handler never calls Receive, the client polls the send endpoint twenty times a second for as long as the page lives.

This is deliberate backpressure and it does terminate when the connection goes away, because the server then answers 410 and the chain rejects. Consider raising the backoff cap so a permanently stalled handler costs less, and surface a diagnostic after a sustained stall.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v3/internal/assetserver/bundledassets/runtime.js` at line 1, Update the
single-frame retry loop in ft to use a substantially larger backoff ceiling for
repeated 429 responses, reducing request frequency when the server remains
stalled. Add a diagnostic after the retry has persisted for a sustained
duration, while preserving the existing backpressure behavior and termination on
non-429 responses or connection closure.
v3/internal/runtime/desktop/@wailsio/runtime/src/stream.test.js (1)

92-97: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use vi.waitFor instead of counting microtask ticks.

The neighbouring generation tests wait with vi.waitFor (lines 62, 122, 130, 151, 166). This test relies on the open POST reaching the fetch mock within exactly two microtask ticks. That coupling breaks if the promise chain in send/postFrame gains another await.

♻️ Proposed refactor
         const socket = new WailsSocket("test");
-        await Promise.resolve();
-        await Promise.resolve();
-
-        expect(generations).toHaveLength(1);
+        await vi.waitFor(() => expect(generations).toHaveLength(1), { interval: 1, timeout: 100 });
+
         expect(Number.isSafeInteger(generations[0])).toBe(true);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v3/internal/runtime/desktop/`@wailsio/runtime/src/stream.test.js around lines
92 - 97, In the generation test around the `generations` assertions, replace the
two fixed `Promise.resolve()` microtask waits with `vi.waitFor` that waits until
`generations` contains one entry. Preserve the existing assertions validating
the generation count, safe integer, and positive value.
v3/internal/runtime/desktop/@wailsio/runtime/src/stream.ts (2)

570-581: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the duplicated conversion branches in toBytes.

toBytes repeats every branch of toBytesSync except the Blob case. Delegate the non-Blob branches to toBytesSync so the two conversions cannot diverge.

♻️ Proposed refactor
 async function toBytes(data: string | ArrayBufferLike | ArrayBufferView | Blob): Promise<Uint8Array> {
-    if (typeof data === "string") {
-        return textEncoder.encode(data);
-    }
     if (typeof Blob !== "undefined" && data instanceof Blob) {
         return new Uint8Array(await data.arrayBuffer());
     }
-    if (ArrayBuffer.isView(data)) {
-        return new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice();
-    }
-    return new Uint8Array(data as ArrayBufferLike).slice();
+    return toBytesSync(data)!;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v3/internal/runtime/desktop/`@wailsio/runtime/src/stream.ts around lines 570
- 581, Refactor toBytes so it handles only the Blob case asynchronously, then
delegates all non-Blob inputs to toBytesSync. Preserve the existing string,
ArrayBuffer, and ArrayBufferView conversion behavior by reusing toBytesSync and
retain the Blob arrayBuffer conversion unchanged.

704-711: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Simplify the batch acknowledgement validation.

The current form negates the valid range and then re-admits zero from inside the failure branch. State the accepted range directly instead.

♻️ Proposed refactor
         const accepted = Number(resp.headers.get(HDR_BATCH) ?? 0);
         const remaining = frames.length - sent;
-        if (!Number.isInteger(accepted) || accepted < 0 || accepted >= remaining) {
-            // Zero is valid (no progress), but an out-of-range acknowledgement
-            // is a protocol error. Keep zero on the retry path below.
-            if (accepted !== 0) throw new Error("invalid stream batch acknowledgement");
-        }
+        // Zero is valid and means no progress; the loop retries after a backoff.
+        // Anything outside [0, remaining) is a protocol error.
+        if (!Number.isInteger(accepted) || accepted < 0 || accepted >= remaining) {
+            throw new Error("invalid stream batch acknowledgement");
+        }
         sent += accepted;

Note: remaining is always at least 1 here, so zero stays inside the valid range and no longer needs the special case.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v3/internal/runtime/desktop/`@wailsio/runtime/src/stream.ts around lines 704
- 711, Update the batch acknowledgement validation in the stream send loop to
directly accept integer values from 0 through remaining - 1, using an explicit
invalid-range condition that rejects non-integers and out-of-range values.
Remove the nested zero special case while preserving the existing protocol error
for invalid acknowledgements and the sent += accepted behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/src/content/docs/guides/streams-from-websockets.mdx`:
- Around line 214-237: Update TextStream so the message handler normalizes the
event delivered through EventTarget, ensuring listeners registered with
addEventListener("message", ...) receive decoded string data rather than the raw
ArrayBuffer. Preserve string events unchanged and retain compatible onmessage
behavior, while avoiding duplicate delivery of the original raw event.

In `@v3/examples/streams/assets/index.html`:
- Around line 26-28: Update the log function to insert the text argument as a
text node rather than interpolating it into HTML via insertAdjacentHTML, while
preserving insertion at the beginning of the log element.

In `@v3/examples/streams/README.md`:
- Around line 56-58: Correct the statement in the README that claims
JSON.parse(ev.data) will not throw; explain that coercing the ArrayBuffer
produces "[object ArrayBuffer]", which is invalid JSON and causes a SyntaxError,
then retain the instruction to decode ev.data before parsing.

In `@v3/internal/runtime/desktop/`@wailsio/runtime/src/stream.test.js:
- Around line 43-46: Add vi.restoreAllMocks() to the existing afterEach cleanup
alongside vi.unstubAllGlobals(), ensuring Storage.prototype.getItem and setItem
spies created by the stream tests are restored between tests while preserving
the existing window._wails cleanup.

In `@v3/pkg/application/stream_server.go`:
- Around line 195-208: Replace the unconditional InsecureSkipVerify setting in
the stream WebSocket accept flow with configurable OriginPatterns that default
to same-origin validation, while allowing any-origin access only through
explicit configuration. Apply the identical origin policy to the events
broadcaster’s WebSocket accept options, preserving existing stream and event
behavior otherwise.

In `@v3/tests/stream-performance/main.go`:
- Around line 154-170: Replace the non-atomic maxLive read/compare/store
sequence in the HandleStream callback with a compare-and-swap loop that retries
until the observed maximum is at least the current live count, preserving the
existing live-count update and logging behavior.

In `@v3/UNRELEASED_CHANGELOG.md`:
- Around line 20-27: Add the applicable PR reference link to the GoStream
changelog entry, matching the existing [PR](...) format used by the other
entries in UNRELEASED_CHANGELOG.md while preserving the entry’s current content.

---

Nitpick comments:
In `@v3/internal/assetserver/bundledassets/runtime.js`:
- Line 1: Update the single-frame retry loop in ft to use a substantially larger
backoff ceiling for repeated 429 responses, reducing request frequency when the
server remains stalled. Add a diagnostic after the retry has persisted for a
sustained duration, while preserving the existing backpressure behavior and
termination on non-429 responses or connection closure.

In `@v3/internal/runtime/desktop/`@wailsio/runtime/src/stream.test.js:
- Around line 92-97: In the generation test around the `generations` assertions,
replace the two fixed `Promise.resolve()` microtask waits with `vi.waitFor` that
waits until `generations` contains one entry. Preserve the existing assertions
validating the generation count, safe integer, and positive value.

In `@v3/internal/runtime/desktop/`@wailsio/runtime/src/stream.ts:
- Around line 570-581: Refactor toBytes so it handles only the Blob case
asynchronously, then delegates all non-Blob inputs to toBytesSync. Preserve the
existing string, ArrayBuffer, and ArrayBufferView conversion behavior by reusing
toBytesSync and retain the Blob arrayBuffer conversion unchanged.
- Around line 704-711: Update the batch acknowledgement validation in the stream
send loop to directly accept integer values from 0 through remaining - 1, using
an explicit invalid-range condition that rejects non-integers and out-of-range
values. Remove the nested zero special case while preserving the existing
protocol error for invalid acknowledgements and the sent += accepted behavior.

In `@v3/pkg/application/application_server.go`:
- Around line 223-226: Document in the server options documentation that
Server.WriteTimeout must exceed the held-poll streamHoldTimeout (20 seconds) to
avoid server-terminating fallback polls, noting that the WebSocket route is
unaffected; alternatively, clamp the effective held-poll duration below the
configured write timeout while preserving the existing WebSocket behavior.

In `@v3/pkg/application/stream_test.go`:
- Around line 799-818: Replace the fixed 50 ms sleeps in both stream tests,
including TestStreamOpenAckPrecedesHandlerOutput and the test around the second
occurrence, with deadline-based polling of the queue under s.mu. Continue
polling until the expected frames are available, then perform the existing
assertions; follow the polling shape used by
TestStreamPendingClosesReserveConnectionCapacity and retain a generous timeout.

In `@v3/Taskfile.yaml`:
- Around line 64-66: Update the PKG assignment in the npm installation task to
construct TASKFILE_DIR with the toSlash template function before shell parsing,
preserving the existing runtime package path and npm install behavior.

In `@v3/tests/stream-performance/assets/index.html`:
- Around line 133-136: Update the echo.onopen handler to prevent duplicate
interval registration when the stream reopens. Store the timer IDs and clear
existing intervals before creating new ones, or otherwise guard initialization
so the two sendEcho timers run only once.

In `@v3/tests/stream-performance/report.go`:
- Around line 108-137: Update writeScenarioCSV to propagate deferred CSV flush
and file-close failures instead of always returning nil. Check w.Error() after
flushing and return any resulting error, then handle and return the result of
f.Close(), while preserving existing write-error handling.
- Around line 39-42: Update the scenarioResult fields SendP50 and SendP99 to
include lowercase JSON tags matching the naming convention used by Sent and
Echoes, so summary.json serializes them with consistent names.

In `@v3/tests/stream-performance/scenarios.go`:
- Around line 73-88: Update selectedScenarios to detect names from the only list
that do not match any allScenarios entry, and log each unmatched name using the
log package. Preserve the existing selection behavior for valid names while
ensuring typos are visible to the operator.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 549088f3-8b98-469f-9e20-bb84923815b0

📥 Commits

Reviewing files that changed from the base of the PR and between 637b6ee and 0b43f20.

📒 Files selected for processing (75)
  • AGENTS.md
  • docs/src/content/docs/guides/advanced/streams-internals.mdx
  • docs/src/content/docs/guides/streams-from-websockets.mdx
  • docs/src/content/docs/guides/streams.mdx
  • v3/Taskfile.yaml
  • v3/UNRELEASED_CHANGELOG.md
  • v3/examples/streams/README.md
  • v3/examples/streams/assets/index.html
  • v3/examples/streams/main.go
  • v3/internal/assetserver/bundledassets/runtime.debug.js
  • v3/internal/assetserver/bundledassets/runtime.js
  • v3/internal/debounce/debounce.go
  • v3/internal/debounce/debounce_test.go
  • v3/internal/runtime/Taskfile.yaml
  • v3/internal/runtime/desktop/@wailsio/runtime/src/index.ts
  • v3/internal/runtime/desktop/@wailsio/runtime/src/nanoid.ts
  • v3/internal/runtime/desktop/@wailsio/runtime/src/ssr_import.test.js
  • v3/internal/runtime/desktop/@wailsio/runtime/src/stream.test.js
  • v3/internal/runtime/desktop/@wailsio/runtime/src/stream.ts
  • v3/pkg/application/application.go
  • v3/pkg/application/application_server.go
  • v3/pkg/application/autostart.go
  • v3/pkg/application/autostart_android.go
  • v3/pkg/application/autostart_darwin_smappservice.go
  • v3/pkg/application/autostart_ios.go
  • v3/pkg/application/autostart_linux_test.go
  • v3/pkg/application/autostart_server.go
  • v3/pkg/application/autostart_test.go
  • v3/pkg/application/autostart_windows_test.go
  • v3/pkg/application/browser_window.go
  • v3/pkg/application/context_menu_manager.go
  • v3/pkg/application/dialogs_android.go
  • v3/pkg/application/dialogs_ios.go
  • v3/pkg/application/dialogs_windows_test.go
  • v3/pkg/application/events_common_ios.go
  • v3/pkg/application/events_common_windows.go
  • v3/pkg/application/init_desktop.go
  • v3/pkg/application/ios_runtime_ios.go
  • v3/pkg/application/ios_runtime_stub.go
  • v3/pkg/application/keys_ios.go
  • v3/pkg/application/keys_linux.go
  • v3/pkg/application/keys_test.go
  • v3/pkg/application/logger_dev.go
  • v3/pkg/application/logger_dev_windows.go
  • v3/pkg/application/mcp_protocol_enabled.go
  • v3/pkg/application/menu_ios.go
  • v3/pkg/application/menuitem_ios.go
  • v3/pkg/application/screenmanager_internal_test.go
  • v3/pkg/application/signal_handler_ios.go
  • v3/pkg/application/signal_handler_types_desktop.go
  • v3/pkg/application/signal_handler_types_ios.go
  • v3/pkg/application/single_instance_ios.go
  • v3/pkg/application/stream.go
  • v3/pkg/application/stream_prelude_desktop.go
  • v3/pkg/application/stream_prelude_server.go
  • v3/pkg/application/stream_server.go
  • v3/pkg/application/stream_server_test.go
  • v3/pkg/application/stream_session.go
  • v3/pkg/application/stream_test.go
  • v3/pkg/application/stream_transport.go
  • v3/pkg/application/systemtray_ios.go
  • v3/pkg/application/transport_event_ipc_test.go
  • v3/pkg/application/transport_http.go
  • v3/pkg/application/transport_http_test.go
  • v3/pkg/application/webview_window.go
  • v3/pkg/application/webview_window_windows.go
  • v3/pkg/application/webview_window_windows_nonclient.go
  • v3/tests/stream-performance/assets/index.html
  • v3/tests/stream-performance/main.go
  • v3/tests/stream-performance/report.go
  • v3/tests/stream-performance/sampler_darwin.go
  • v3/tests/stream-performance/sampler_linux.go
  • v3/tests/stream-performance/sampler_other.go
  • v3/tests/stream-performance/sampler_windows.go
  • v3/tests/stream-performance/scenarios.go

Comment thread docs/src/content/docs/guides/streams-from-websockets.mdx
Comment thread v3/examples/streams/assets/index.html
Comment thread v3/examples/streams/README.md
Comment thread v3/internal/runtime/desktop/@wailsio/runtime/src/stream.test.js
Comment thread v3/pkg/application/stream_server.go Outdated
Comment thread v3/tests/stream-performance/main.go
Comment thread v3/UNRELEASED_CHANGELOG.md Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/src/content/docs/guides/advanced/streams-internals.mdx (1)

209-215: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document the storage-unavailable generation path.

These lines describe sessionStorage generation as universal. The runtime also uses a wall-clock generation path when storage is unavailable. Document that fallback and its ordering limitation. Otherwise this lifecycle description omits a supported runtime path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/src/content/docs/guides/advanced/streams-internals.mdx` around lines 209
- 215, Update the session-generation lifecycle description to document the
wall-clock generation fallback used when sessionStorage is unavailable. State
that this fallback preserves generation ordering only within its supported
runtime constraints and has the associated ordering limitation, while retaining
the existing sessionStorage behavior for environments where storage is
available.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@v3/pkg/application/stream_transport.go`:
- Around line 341-345: Update the stream request handler around frame-kind
dispatch to validate supported kinds before session lookup and readStreamBody.
Resolve and validate connID before accepting frameData chunks, and reject any
body or chunks for frameOpen/frameClose since control frames do not consume
body; ensure invalid requests return an error without storing data or consuming
global chunk capacity.
- Line 61: Update streamMaxChunkBytesGlobal and the streamChunkStore.add
reassembly path so temporary assembled buffers are included in the global memory
budget before allocation; ensure four concurrent maximum-frame uploads cannot
retain roughly twice the configured limit. Add a concurrent maximum-frame
reassembly test that verifies the effective budget, using the existing
streamChunkStore symbols and preserving successful reassembly.

In `@v3/pkg/application/stream.go`:
- Around line 568-571: Make outbound lifecycle accounting symmetric for
frameClose: update releaseOutbound so it does not decrement lifecycles for
frameClose, and remove the now-unreachable frameClose branch from
reserveOutbound while preserving its early return. Ensure enqueue failure
cleanup cannot release a slot that frameClose never reserved.

---

Outside diff comments:
In `@docs/src/content/docs/guides/advanced/streams-internals.mdx`:
- Around line 209-215: Update the session-generation lifecycle description to
document the wall-clock generation fallback used when sessionStorage is
unavailable. State that this fallback preserves generation ordering only within
its supported runtime constraints and has the associated ordering limitation,
while retaining the existing sessionStorage behavior for environments where
storage is available.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0153fe6d-c995-4670-8e25-3d12c25019b9

📥 Commits

Reviewing files that changed from the base of the PR and between 633c700 and b7f51ef.

📒 Files selected for processing (12)
  • docs/src/content/docs/guides/advanced/streams-internals.mdx
  • docs/src/content/docs/guides/streams.mdx
  • v3/internal/assetserver/bundledassets/runtime.debug.js
  • v3/internal/assetserver/bundledassets/runtime.js
  • v3/internal/runtime/desktop/@wailsio/runtime/src/stream.test.js
  • v3/internal/runtime/desktop/@wailsio/runtime/src/stream.ts
  • v3/pkg/application/stream.go
  • v3/pkg/application/stream_server.go
  • v3/pkg/application/stream_server_test.go
  • v3/pkg/application/stream_session.go
  • v3/pkg/application/stream_test.go
  • v3/pkg/application/stream_transport.go
🚧 Files skipped from review as they are similar to previous changes (5)
  • docs/src/content/docs/guides/streams.mdx
  • v3/pkg/application/stream_session.go
  • v3/internal/runtime/desktop/@wailsio/runtime/src/stream.test.js
  • v3/internal/runtime/desktop/@wailsio/runtime/src/stream.ts
  • v3/pkg/application/stream_test.go

Comment thread v3/pkg/application/stream_transport.go Outdated
Comment thread v3/pkg/application/stream_transport.go
Comment thread v3/pkg/application/stream.go Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@v3/examples/server/README.md`:
- Around line 76-78: Update the WebSocketOriginPatterns example to include the
https:// scheme in the app.example.com pattern, preserving only HTTPS support
unless both schemes are intentionally required.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 88dbd040-0ad8-4cbe-a9c3-2138aa1adda6

📥 Commits

Reviewing files that changed from the base of the PR and between b7f51ef and b52cca3.

📒 Files selected for processing (14)
  • docs/src/content/docs/guides/server-build.mdx
  • docs/src/content/docs/guides/streams-from-websockets.mdx
  • docs/src/content/docs/guides/streams.mdx
  • v3/UNRELEASED_CHANGELOG.md
  • v3/examples/server/README.md
  • v3/examples/streams/README.md
  • v3/examples/streams/assets/index.html
  • v3/internal/runtime/desktop/@wailsio/runtime/src/stream.test.js
  • v3/pkg/application/application_options.go
  • v3/pkg/application/stream_server.go
  • v3/pkg/application/stream_server_test.go
  • v3/pkg/application/websocket_server.go
  • v3/pkg/application/websocket_server_test.go
  • v3/tests/stream-performance/main.go
🚧 Files skipped from review as they are similar to previous changes (8)
  • v3/UNRELEASED_CHANGELOG.md
  • v3/examples/streams/README.md
  • v3/examples/streams/assets/index.html
  • docs/src/content/docs/guides/streams-from-websockets.mdx
  • docs/src/content/docs/guides/streams.mdx
  • v3/internal/runtime/desktop/@wailsio/runtime/src/stream.test.js
  • v3/pkg/application/stream_server.go
  • v3/tests/stream-performance/main.go

Comment thread v3/examples/server/README.md Outdated
@leaanthony

Copy link
Copy Markdown
Member Author

Reviewed 60adebf3f..8820b3ee9 — the four stream commits plus the master merges.

One real bug, proven below. Everything else is sound.

Verification

check result
go test -race -count=1 ./pkg/application/ ok, 2.359s
tsc --noEmit exit 0
vitest run 6 files, 1207 tests passed
task runtime:build:assets bundles byte-reproducible

Global lifecycle slots leak on a close/teardown race

reserveOpen() takes a slot from m.lifecycles (cap streamMaxConnectionsGlobal = 4096). Ownership then transfers to the queued close frame: shutdown() deliberately skips the release when closeQueued is set, on the basis that releaseOutbound(frameClose) will do it when the frame drains.

That handoff is not atomic, and streamSession.close calls c.shutdown() directly — outside the closeOnce gate that otherwise serialises Close against closedByPeer:

// stream_session.go, close()
for _, c := range conns {
    c.shutdown()          // not gated by closeOnce
}

Interleaving:

  1. A — conn.Close() wins closeOnce, sets closeQueued = true, enters enqueue, blocks on s.mu
  2. B — session.close() holds s.mu, sets closed = true, releases queued frames, unlocks
  3. B — c.shutdown() wins shutdownOnce, evaluates c.lifecycle && !c.closeQueued.Load() → closeQueued is still true → skips releaseLifecycle()
  4. A — acquires s.mu inside enqueue, sees s.closed, returns ErrStreamClosed
  5. A — closeQueued.Store(false), but shutdown has already made its decision
  6. A — c.shutdown() → shutdownOnce already consumed → no-op

No close frame was ever queued, so releaseOutbound(frameClose) never runs. The slot is gone for the life of the process.

Reproduction

Racing Close() against session.close() and asserting the global allowance returns to zero:

func TestProbeLifecycleLeak(t *testing.T) {
	const iterations = 3000
	leaks := 0
	for i := 0; i < iterations; i++ {
		mgr := newStreamManager(nil)
		s := newStreamSession("sess", 1, mgr)
		mgr.sessions["sess"] = s

		if !mgr.reserveOpen() {
			t.Fatal("reserveOpen failed")
		}
		ctx, cancel := context.WithCancel(context.Background())
		c := &StreamConn{id: 1, name: "t", sink: s, ctx: ctx, cancel: cancel, manager: mgr, lifecycle: true}
		c.inCond = sync.NewCond(&c.inMu)
		s.mu.Lock()
		s.conns[1] = c
		s.out = append(s.out, outFrame{connID: 1, kind: frameOpen})
		s.outControls++
		s.mu.Unlock()

		var wg sync.WaitGroup
		wg.Add(2)
		go func() { defer wg.Done(); _ = c.Close() }()
		go func() { defer wg.Done(); s.close() }()
		wg.Wait()

		if mgr.lifecycles.Load() != 0 {
			leaks++
		}
	}
	if leaks > 0 {
		t.Fatalf("lifecycle slot leaked in %d/%d iterations", leaks, iterations)
	}
}
--- FAIL: TestProbeLifecycleLeak (0.01s)
    lifecycle slot leaked in 3/3000 iterations

Impact

The trigger is a page reload landing while a handler runs its defer c.Close() — the ordinary path, and frequent under wails3 dev. Leaks are permanent and cumulative. At 4096 the process refuses every new stream connection app-wide, with no recovery short of restart. Low rate, no self-correction, hard stop at the end.

Suggested direction

Rather than making the handoff atomic, consider removing it: give the queued close its own reservation (streamOutControlDepthGlobal already exists for controls) and let shutdown() unconditionally own the lifecycle release. That deletes closeQueued and the ordering constraint with it, instead of adding a CAS that the next refactor has to preserve.


What works well

  • The global accounting is the right call. Per-session bounds genuinely did multiply by the session allowance into a multi-gigabyte ceiling; capping in the manager is the only place that can hold.
  • decodeStreamBatch is now overflow-safe — len(body)-off < 4 rather than off+4 > len(body).
  • Input validation closes real gaps: the explicit frame-kind allowlist, streamMaxNameLen, and rejecting a body on control frames before session lookup.
  • ErrStreamTooLarge on Send/TrySend makes the 64 MB limit symmetric instead of only enforced on ingress.
  • Receive and deliverWithBackpressure no longer hold inMu across manager calls, which removes a lock-ordering hazard between the connection and the manager budget.
  • Client-side AbortController plumbing correctly tears down in-flight sends and the poll when the last connection goes.

Smaller notes

The check → reserve → recheck → rollback pattern now appears twice (enqueue and deliverWithBackpressure). Both are correct, but the invariant they maintain — local and global capacity are guarded by independent locks, so the local check must be redone after the global reservation — is currently only reconstructible by reading the rollback calls. Worth stating once above reserveOutbound, since a future edit that drops the recheck would look harmless and would double-count silently.

if n < 0 in decodeStreamBatch is only reachable on 32-bit builds, where int(uint32) can go negative. Harmless, but on every platform CI exercises it reads as dead code — a one-line comment would stop someone deleting it.

@leaanthony

Copy link
Copy Markdown
Member Author

Pushed 0a40242cb fixing the lifecycle slot leak reported above.

Approach: remove the handoff rather than make it atomic. Closes now reserve from their own counter instead of inheriting the connection's lifecycle slot:

case frameClose:
    reserved = reserveCounter(&m.outCloses, 1, streamOutCloseDepthGlobal)

shutdown() releases the lifecycle slot unconditionally — it runs exactly once and the slot belongs to the connection for exactly the connection's lifetime. closeQueued is deleted, and with it the window where a concurrent shutdown and a failing enqueue disagreed about who releases.

This also collapsed rollbackOutbound into releaseOutbound. The frameClose special case was its only reason to exist; every kind now releases exactly what it reserved, so the rollback and drain paths are the same call. Net fewer lines of logic despite the added counter.

The property the original design was protecting is kept: a queued close still cannot be starved by refused opens, because streamOutCloseDepthGlobal is separate from streamOutControlDepthGlobal and sized to the connection allowance. Each connection queues at most one close, and closes carry no payload.

Tests. TestStreamCloseRollbackPreservesLifecycleReservation asserted the transfer semantics that are now gone, so it is replaced by TestStreamCloseAccountingIsIndependentOfLifecycle, plus TestStreamLifecycleSlotSurvivesCloseTeardownRace which races Close() against session.close() and asserts both counters return to zero.

check result
the probe that failed 4/3000 on 0d3a4c3c9 passes
go test -race ./pkg/application/ ok, 2.572s
same under -tags server ok, 3.778s
go vet clean
regression test at -race -count=5 (15,000 iterations) ok

Server mode is unaffected: it reserves and releases its own slot via defer in stream_server.go and never sets lifecycle, so shutdown skips that block there.

Two things worth a second opinion. The regression test is timing-dependent — it caught this reliably here, but the deterministic coverage is the accounting test beside it. And this changes a global resource contract, so streamOutCloseDepthGlobal at 4096 is worth a sanity check against your intended sizing.

@leaanthony

Copy link
Copy Markdown
Member Author

Reviewed 0a40242cb. The separate lifecycle and queued-close counters are the right fix: connection teardown now releases its lifecycle slot exactly once via shutdownOnce, while every close frame releases only the capacity it reserved, whether it is drained, discarded, or fails to queue. This removes the non-atomic ownership handoff that caused the teardown race.

Validated with:

  • the new close/teardown regression tests, 20 repetitions under -race
  • the full Stream tests under -race
  • the server-tagged Stream/WebSocket tests under -race

All pass. I found no new correctness issue in this correction.

@leaanthony
leaanthony merged commit 08274e0 into master Aug 12, 2026
51 checks passed
@leaanthony
leaanthony deleted the feature/gostream branch August 12, 2026 04:25
timlinde pushed a commit to Topborn/wails that referenced this pull request Aug 22, 2026
…ailsapp#5942)

* feat(v3): add GoStream Go core and desktop transport

A WebSocket cannot be spoken over a custom URL scheme, so the only way to
get one inside a webview today is to bind a real TCP port - an open local
port reachable by any other process on the machine, needing an origin check
and a token to be safe. GoStream gives the same programming model over the
asset server that already exists and is already origin-bound.

app.HandleStream(name, handler) registers a handler that runs once per
connection on its own goroutine. StreamConn.Send blocks like a socket write,
Receive blocks like a socket read, and both fail once the peer is gone. The
handler goroutine's lifetime is the connection's lifetime, so reload,
shutdown and cleanup follow from it rather than each needing a policy.

Transport is GET /wails/stream/poll, held until there is something to send,
and POST /wails/stream/send. Control data travels in headers because
WebKitGTK 6.0 can deliver POST bodies as query params for custom URI schemes
and WebView2 caps body delivery around 2 MB. The poll response is binary
rather than JSON: frames are []byte, and base64 in a JSON envelope would
cost 33% on every one.

Deliberately separate from the event system - it shares no code with
Emit/events and does not change them. It does carry over that work's
lessons:

  - Nothing in the Go->JS path touches the main thread. A main-thread emit
    running its eval inline while an earlier goroutine emit was still queued
    inverted 4.4% of events on all three platforms; one queue with one
    drainer cannot do that.
  - Nothing touches evaluateJavaScript at any size, so the 8-16 KB retention
    knee (11.6 GB on macOS, 6.2 GB on WebKitGTK at 100 x 1 MB/sec) is not
    reachable from here.
  - Every buffer is bounded and dropped when its window dies, following
    eventPayloadStore.

Holding a request is safe because every webview request already gets its own
goroutine; the dispatchWorkers pool is pinned at 0 for exactly this reason.

Known gap: the platform layer does not report a cancelled request, so a page
that navigates away leaves its poll parked. A new poll supersedes the old one
immediately, and a session with no poll is reaped, so this is bounded rather
than leaked. Plumbing stopURLSchemeTask through to a context cancel would
make the close instant.

* feat(v3): add the WebSocket-shaped stream client to the runtime

Stream(name) returns synchronously with readyState === CONNECTING, the way
new WebSocket(url) does, so a connection can be created at module scope and
generated bindings can export them as constants:

    export const Telemetry = Stream("telemetry");

The object implements the useful subset of the WebSocket interface -
readyState and its constants, onopen/onmessage/onclose/onerror,
addEventListener, send, close(code, reason), binaryType, bufferedAmount - so
the same application code will work against a real WebSocket in server
builds. One deliberate divergence: binaryType defaults to "arraybuffer"
rather than "blob", because frames are always binary and a Blob would force
an extra async hop to read every message. Properties that cannot be
supported honestly (protocol, extensions) return the empty string rather
than being faked.

Every connection in a page shares one held poll. There is no polling
interval and nothing adaptive, on purpose: the server holds the request
until a frame exists, so latency is already ~0 and a client-side interval
could only add to it. Frames arriving while a response is in flight
accumulate and ride the next one, which makes the round trip itself the
batching window - it widens as load rises without anything measuring it.
The only delay is an error backoff, 250 ms doubling to 5 s.

Sends are serialised per connection with a promise chain, since concurrent
fetch POSTs do not preserve order, and frames over 512 KB are split the same
way the runtime already splits oversized calls.

* test(v3): cover the stream protocol, ordering and backpressure

The ordering test is the regression guard that matters: eight goroutines
send concurrently against a counter handed out under the queue lock, and the
drained order must match the accepted order exactly. That is the failure
that cost the event path 4.4% of its events when a main-thread emit could
run ahead of an earlier queued one.

Also covered: frame encode round trip including a payload past the response
cap, TrySend reporting a full buffer where Send waits for room, the byte cap
binding before the depth cap, a single oversized frame still being delivered
rather than wedging the queue, a newer poll superseding a parked one,
session close unblocking both a parked Receive and a blocked Send, dropWindow
touching only its own window, open being refused with no handler registered,
the open ack preceding anything the handler sends, and chunk reassembly
including out-of-order arrival and an inconsistent total.

* test(v3): add the GoStream load harness

Modelled on tests/event-performance, and asking the same questions of the
poll transport that harness asked of the eval path: throughput, loss,
ordering, host and content-process memory, and how long a Send blocks.

Scenarios cover a rate sweep at a small frame, a size sweep, a constant
4 MB/s sweep varying only frame size (the design that located the eval knee
between 8 and 16 KB), and unthrottled runs that exercise the blocking
backpressure path. Go MemStats is sampled alongside host footprint so any
host growth can be attributed to our own allocation rather than to the
transport - the comparison that settled the equivalent question for events.

The page counts frames per poll response without the runtime exposing any
counters: every frame from one response is dispatched synchronously in a
single run, so a microtask queued on the first message fires exactly once
per response. It also runs the JS->Go direction continuously underneath the
Go->JS load, including a 1 MB frame every two seconds that must go through
the chunked send path.

* feat(v3): serve streams over a real WebSocket in server mode

Server mode already has a listener, so there is nothing to emulate there.
/wails/stream/ws upgrades and wsStreamSink writes frames straight to the
socket. HandleStream and StreamConn are shared verbatim with the desktop
build - only the sink differs, which is the payoff for mirroring the
WebSocket model rather than inventing an API.

There is no outbound buffer on that path and the blocking flag is ignored
deliberately: websocket.Conn.Write blocks until the frame is written, so the
socket's own send buffer already provides the backpressure that the desktop
build's bounded queue exists to imitate.

The client chooses via window._wails.streamFactory, installed by custom.js in
server builds. custom.js is injected asynchronously, so a stream created at
module scope can connect before the factory lands and take the poll
transport instead; both work in server mode, since the asset server is
mounted at "/" and the poll endpoints are reachable there too. Delivering the
mode synchronously with the runtime bundle would close that gap.

Also in this change:

- Close the previous session when a window presents a new session id. A
  reload has no socket to close and the platform layer reports no
  cancellation, so without this the old session survived to the TTL sweep -
  60-80s during which the app holds two live connections to the same stream
  and a handler owning a per-connection resource has two of them. Measured
  with -reloads 6: 7 connects, 6 disconnects, 1 live at a time, the old
  connection closing before its replacement appears.

- Pass the connection id to enqueue explicitly instead of patching the frame
  after the fact. The refusal path had no StreamConn to carry the id and
  retagged the last queued frame under a second lock acquisition; a poll
  draining in between would either ship the refusal with id 0, leaving the
  frontend in CONNECTING with no error, or retag an unrelated connection's
  frame.

- Surface a framing mismatch instead of swallowing it. The throw was inside
  the poll loop's own try, so a stale cached runtime against a newer Go side
  became a silent backoff loop forever; it now closes every connection with
  code 1002.

* docs(v3): note server-mode WebSocket support in the GoStream changelog entry

* fix(v3): choose the stream transport before any module body runs, and unblock oversized frames

Two fixes and the throughput measurements behind them.

Transport selection was racy for the case it matters most for. custom.js is
injected with loadOptionalScript - a HEAD request followed by a <script> tag -
so it lands long after the runtime's dependents have evaluated. A stream
created at module scope, which is exactly what generated bindings will emit:

    export const Telemetry = Stream("telemetry");

connected before the WebSocket factory existed and silently took the poll
transport in server builds. The factory now ships as a prelude prepended to
the runtime bundle, which is synchronous by construction: ES module
dependencies evaluate before their importers, so it is installed before any
generated module can call Stream. custom.js no longer carries a copy.

A frame larger than the per-window byte cap could not be sent at all. enqueue
required outBytes+len(data) <= streamOutQueueBytes, which for a frame bigger
than the cap can never come true however long it waits: Send blocked forever
and TrySend reported full permanently. Frame size is not always the caller's
choice - a struct with a []byte field marshals to whatever it marshals to -
so an empty queue now accepts one frame of any size, the same principle as a
poll response always carrying at least one frame. The cap governs how much
may accumulate, not how large any single frame may be.

The harness gains an unthrottled frame-size sweep and a JS->Go upload matrix
over frame size and connection count, driven from Go over a control stream -
which also exercises four simultaneous streams on one window.

Measured, macOS, 0 drops and 0 reorders throughout:

  Go->JS   634,036 frames/s at 256 B (155 MB/s), rising to 2,141 MB/s at 4 MB
  JS->Go   6,200 frames/s at 64 KB (192 MB/s), peaking at 1,507 MB/s at 4 MB

Both directions top out near 1.3-2.1 GB/s on large frames; the asymmetry is in
frame rate, since a poll response carries up to 256 frames while every JS->Go
frame is its own POST.

Two harness bugs were fixed before those numbers could be trusted: setTimeout(0)
is clamped to ~4ms once nested, which was capping small-frame uploads at the
timer rather than the transport, and the replacement MessageChannel ticker had
a single resolver slot, so concurrent uploaders hung and multi-connection rows
read as "concurrency is slower".

* test(v3): verify server-mode streams over a real WebSocket

Functional only, per instruction - server mode needs to work, not to be fast.
Covers a five-round-trip echo through the same StreamConn the desktop build
uses, a 12 MB frame (past the desktop per-window buffer, which a socket has no
equivalent of), a 404 for an unregistered stream name, and socket close making
Receive return ErrStreamClosed so the handler unwinds the way a desktop reload
makes it.

Also fixes an App.error format-directive mismatch that only the server build
compiled.

* docs(v3): document streams for users and for agents

Two pages. guides/streams.mdx is the user-facing API: quick start, the Go and
JavaScript surfaces, lifecycle, backpressure, server mode, measured throughput
per platform, and when to reach for bindings or events instead.

guides/advanced/streams-internals.mdx is written for whoever changes this code
next. It covers the held-poll design and why there is no polling interval, the
binary framing, the buffer constants and how to pick them, session and
connection lifecycle, transport selection, and an explicit list of what is not
finished.

The internals page exists because several decisions look arbitrary until you
know which measured bug they prevent - no main-thread hop in the send path, no
evaluateJavaScript at any size, control data in headers rather than bodies, one
poll in flight per window. Each of those is a scar from the event transport
work, and removing one re-opens a measured bug.

AGENTS.md gains a Subsystem References section pointing at it.

* docs(v3): add a mechanical WebSocket-to-Streams migration guide

The API guide says what streams are and the internals page says how they work;
neither told anyone how to convert existing WebSocket code. This does, in a form
that can be followed literally.

It leads with the three differences that break silently rather than loudly. The
worst is that ev.data is always an ArrayBuffer, so JSON.parse(ev.data) keeps
compiling and running while parsing "[object ArrayBuffer]". Sending is
compatible - send() encodes a string as UTF-8 - so half the code keeps working,
which is exactly what makes the receive path easy to miss. A drop-in TextStream
shim is included for codebases that would rather not touch every handler.

Also covers the case where the frontend talks to a broker rather than to the
app - nats.ws and friends. A stream is not a drop-in there, because it connects
the frontend to Go rather than to a third party. The migration is to move the
broker client into Go and expose what the frontend needs over a stream, which
keeps broker credentials out of the frontend entirely. The sketch uses TrySend
in the subscription callback, since blocking there would stall delivery for
every subscription on that connection.

Ends with a checklist, the symbols worth grepping for, a behaviour-difference
table, and four post-migration sanity checks.

* docs(v3): lead the migration guide with the local-server workaround

The usual reason a Wails app has a WebSocket is not that it wanted one - it is
that there was no way to push a continuous feed to the frontend, so the app
stands up its own http.Server on a local port and connects back to it. That case
now leads the guide, because the migration deletes more than the socket.

Port discovery goes: a bound GetServerPort, a fixed port with a fallback, an
injected global - all of it, since a stream is addressed by name and the name is
a compile-time constant on both sides. CORS configuration goes, because the page
and the stream now share an origin. Any token invented to stop other local
processes connecting goes, because there is no port left to connect to.

Non-WebSocket endpoints that accumulated on that server do not need a second
server either - AssetOptions.Middleware takes the existing mux, and the frontend
calls it as a same-origin relative URL.

The broker case (nats.ws and similar) is kept but demoted, since it is the rarer
shape in a Wails app and is an architecture change rather than a swap.

* docs(v3): add a minimal streams example

The smallest thing that shows both directions: Go sends the time once a second
and echoes whatever the frontend sends. One handler, one page, no framework.

Verified end to end on macOS - the Go side logs the connection and the frame it
receives from the browser, so the round trip is demonstrated rather than
asserted.

The README calls out the two things people trip over: ev.data is always an
ArrayBuffer so it must be decoded, while sending a string works unchanged
because send() encodes as UTF-8. That asymmetry is what makes the receive path
easy to miss.

* feat(v3): add JSON convenience for streams on both sides

Frames stay []byte on the wire - no protocol change, and a Go handler cannot
tell the difference - but almost nobody wants to think in bytes. StreamConn
gains SendJSON and ReceiveJSON; the runtime gains JSONStream, which is the same
socket with encoding done at the boundary, so ev.data arrives as a parsed object
and send() stringifies.

This also removes the sharpest edge in the migration path. ev.data being an
ArrayBuffer means JSON.parse(ev.data) keeps running while parsing "[object
ArrayBuffer]"; with JSONStream a JSON WebSocket migration is now swap the
constructor and delete the parse/stringify calls.

Making that composable required a fix in the client: on* handlers were invoked
directly and then the event was also dispatched, so a wrapper would have seen
both the raw and the decoded event. They are now accessor-backed listeners, the
way the DOM defines them.

Verified end to end on macOS with a temporary probe in the example: JS sends an
object, Go logs it via ReceiveJSON, replies with SendJSON, and JS decodes it and
sends a confirmation Go logs in turn - so both legs including the client-side
decode are demonstrated rather than assumed. Probe reverted; the example stays
on raw bytes as the reference.

* docs(v3): record that streams work under wails3 dev, and how the frontend resolves the runtime

Verified with a generated vanilla-js project against the branch: the Vite dev
server proxies at "/", and /wails/stream/* is matched by the asset server
middleware before the proxy, so streams are unaffected. The handler logged the
connection and the frame the browser sent through it.

Setting that test up surfaced something worth writing down. A generated project
imports @wailsio/runtime from npm, not from the bundled /wails/runtime.js the
asset server serves, so under wails3 dev an app built against the published
runtime will not see client-side additions made on a branch - Stream and
JSONStream included. Testing an app against this branch means installing the
package from the working tree.

That package's dist/ is built by npx tsc in its own directory, which is a
separate step from the esbuild run that produces bundledassets/runtime.js for
the webview. Both need rebuilding after a change to stream.ts, and it is easy to
do one and not the other.

* build(v3): add install-runtime task for testing an app against this checkout

An app imports @wailsio/runtime from npm, not from the /wails/runtime.js the
asset server serves, so under `wails3 dev` Vite resolves it from node_modules
and the app never sees runtime changes made in a checkout. Testing a branch
against a real app therefore needs the package installed from the working tree,
which was a fiddly manual step.

    task v3:install-runtime -- ./path/to/your-app/frontend

Rebuilds dist/ first so it always installs current sources, and refuses a
directory with no package.json rather than letting npm create one.

Also adds runtime:build:package, which produces that dist/. It is a different
output from build:assets: build:package is what an app's frontend imports,
build:assets is what the webview loads. A change to the client needs both, and
rebuilding one and not the other is an easy mistake to make.

Verified against a freshly generated vanilla-js project: JSONStream is absent
from the installed package before the task runs and present after, and both
guard clauses were exercised.

* docs(v3): move the example to the JSON API, and record the runtime build outputs in AGENTS.md

The example predated SendJSON/ReceiveJSON and JSONStream, so it demonstrated
byte handling for traffic that is objects in almost every real app. It now shows
the ergonomic path, which is also less code - no TextDecoder, no manual
stringify. The raw byte API is kept in the README under "If you want bytes",
along with the ArrayBuffer caveat that applies there.

AGENTS.md gains a section on the two frontend runtime build outputs, because
rebuilding one and not the other is an easy mistake with confusing symptoms:
build:assets produces the bundles the webview loads and CI verifies, while
build:package produces the dist/ an app's frontend imports from node_modules. It
also documents install-runtime for pointing a real app at a checkout.

Verified by running the updated example on macOS: the frontend sends an object,
Go logs it via ReceiveJSON and replies with SendJSON. Probe reverted afterwards.

* fix(v3): address review findings on streams

Eleven issues from the PR review, all real. The three that mattered most were
places where the implementation did not do what the documentation claimed.

Bounded the inbound queue. deliver appended without limit, so a handler slow to
call Receive let the frontend grow host memory without bound - the send endpoint
responds as soon as it has queued, so the client is free to post again
immediately. It now blocks while the inbox is full, which leaves the client's
fetch unresolved on the desktop and stalls the socket read pump in server mode.
The feature claimed bounded memory; now it has it in both directions.

Control frames bypass the caps. A full data queue made the non-blocking open ack
fail silently, leaving the frontend in CONNECTING forever with the handler
running. Losing a data frame is a slow-down; losing a control frame is a
protocol failure.

A handler that simply returns now notifies the frontend. The wrapper deferred
shutdown(), which cancels the connection but queues no close frame, so the
browser socket stayed open and onclose never fired - despite the API documenting
that returning from the handler closes the connection.

Also:

- Server mode raised its read limit to streamMaxSendBytes. coder/websocket
  defaults to 32 KiB, so any frontend frame past that closed the connection,
  well under the documented 64 MB.
- Server mode gained a bounded write queue with a single pump, so TrySend is
  genuinely non-blocking there rather than blocking on Conn.Write. The migration
  guide's fan-out and broker-callback examples pick TrySend precisely to avoid
  stalling a producer, and that promise did not hold under -tags server.
- Both sinks copy the payload while accepting it. Acceptance is reported before
  the frame is encoded, so a reused or pooled buffer could alter a frame already
  acknowledged. The client snapshots at the send() boundary for the same reason:
  conversion was deferred into the promise chain and read the caller's buffer
  later.
- Rejected chunk sets return an error instead of the same not-done signal used
  for an incomplete frame, which had produced a 204 - the client's send chain
  completed successfully while the frame was silently dropped. The
  inconsistent-total path also leaked its bytes, permanently consuming capacity.
- HEAD no longer reaches the poll. It ran the same drain and then had its body
  suppressed, consuming frames that were never delivered.
- JSONStream decodes through a hook on the socket rather than by wrapping
  onmessage, so addEventListener("message") sees the parsed object too, and it
  returns a JSONSocket interface whose send accepts a value - TypeScript
  consumers could not compile the documented usage before.
- nanoid draws from crypto.getRandomValues instead of Math.random, with a
  fallback for engines without Web Crypto. This covers the runtime client id and
  binding call ids as well as stream sessions; a stream session in server mode
  has no window-id header to bind against, so a predictable id would be
  guessable by anything that can reach the port.

Regression tests added for each. Verified with the example: both the onmessage
and addEventListener paths receive decoded objects.

* fix(v3): signal inbound backpressure instead of holding the request

Blocking deliver bounded memory but held the POST open while it waited, and on
this transport the held request is the scarce resource: enough stalled sends
starve the window's single poll. Measured as a 25-32% upload throughput loss at
large frames.

deliver now returns ErrStreamFull, the endpoint answers 429, and the client
retries the same frame - order preserved by the send chain, memory still
bounded, request slot released. Server mode keeps a waiting read pump, since a
socket has no retry channel and holds no slot.

Throughput against the pre-review baseline: 64KB x1 192 -> 218 MB/s, 1KB x1
3,626 -> 5,527 frames/s.

NOT a complete fix: 512KB x4 and x8 still fail with the session closed mid-run,
so request-slot starvation was not the whole cause. Recorded rather than
claimed.

* fix(v3): only an open frame may create a stream session

A data or close frame naming a session that no longer exists used to create a
fresh one, because the send endpoint passed create=true unconditionally. Go
would then hold a live session with no connections while the page believed its
streams were open, and every later send succeeded into the void. Sends for an
unknown session now get 410 so the client surfaces a close.

Correct on its own merits, but it does NOT fix the multi-connection upload
failure: 512KB x4 and x8 still end with the session closed mid-run. That is the
second hypothesis for it disproved, after request-slot starvation.

* fix(v3): stop copying every outbound frame, and never retry a send with zero delay

Two regressions from the review round, both mine, both measured with the
streams example driving four connections of 512 KB frames.

The client copied every buffer at the send() boundary. That is spec-faithful -
a real WebSocket copies - but at several hundred MB/s the allocation churn
dominates the transport, and it landed in the same commit as the review fixes,
which matches where the cgo call rate was first seen to climb. send() now takes
a view and the ownership rule is documented instead: do not mutate a buffer you
have handed to send() until it has gone out.

The 429 retry started at zero delay, so a connection whose receiver was behind
busy-looped on fetch - each iteration a scheme-handler round trip, and on the
host a cgo call. It now starts at 1 ms and ramps to 50.

Profiled with examples/streams as the base, sampling runtime.NumCgoCall
alongside frames received:

  live=4 for the whole run, no connection closed in 100 s
  ~3,100 frames/s at 512 KB = ~1,570 MB/s
  ~34,500 cgo/s against ~3,135 frames/s - a fixed ~11 calls per frame

That fixed ratio is the point: a spin shows up as cgo rate decoupled from frame
rate, and it is not. The 512KB x4 load that previously ended with the session
closed now runs indefinitely.

* perf(v3): stop copying frames in the Go sinks too

The API shape is what has to match a WebSocket, not its copy semantics. Both
sinks were doing a full memcpy per frame on accept, which at these rates is the
single largest cost in the transport and buys nothing a caller cannot arrange
itself - almost every caller hands over freshly marshalled bytes.

Send now queues the caller's slice, with the ownership rule documented: do not
mutate or reuse a slice after passing it. A test pins the aliasing so a copy is
not reintroduced for tidiness.

Measured on macOS with the streams example and the load harness, no drops and no
reorders:

  Go->JS  256 B  806,834 frames/s    197 MB/s
          4 KB   406,804 frames/s  1,589 MB/s
          64 KB   31,489 frames/s  1,968 MB/s
          1 MB     2,076 frames/s  2,076 MB/s
          4 MB       779 frames/s  3,117 MB/s
  JS->Go  512 KB x4  5,586 frames/s  2,793 MB/s

Both directions now beat the pre-review baseline: Go->JS 4 MB 2,141 -> 3,117
MB/s, JS->Go 1,303 -> 2,793 MB/s. cgo calls hold at ~11 per frame, unchanged -
the rate rises only because more frames move, which is what distinguishes real
throughput from a spin. Four concurrent 512 KB connections stay live with zero
closures, the load that previously ended with the session closed.

* perf(v3): read upload bodies in one sized allocation

io.ReadAll starts at 512 bytes and doubles, so a 512 KB frame cost about eleven
allocations and a megabyte of copying, once per frame on the hottest path in the
transport. The webview supplies Content-Length, so the buffer is now sized
exactly and filled with io.ReadFull. The growing path is kept for platforms that
omit the header, and the length is checked against streamMaxSendBytes before
allocating so a bogus header cannot ask for an arbitrary buffer.

A/B with the streams example, four connections of 512 KB:

  before  5,586 frames/s   61,478 cgo/s   11.0 cgo/frame   load 5.11
  after   5,720 frames/s   62,900 cgo/s   11.0 cgo/frame   load 8.21

+2.4% while the machine was more heavily loaded than during the baseline, so the
throughput gain is a floor rather than a ceiling. The allocation reduction is
structural rather than measured: eleven grows per frame become one.

cgo per frame is unchanged at 11.0, as expected - this removes allocations, not
round trips. Cutting that ratio needs fewer requests, i.e. batching several
frames into one POST, which is the next and much larger lever.

* perf(v3): pool poll response buffers and encode by append

Every poll response allocated a fresh buffer of up to streamMaxResponseBytes,
and under load there are thousands of responses a second, which made this the
largest single source of garbage in the transport. encodeStreamFrames now
appends into a caller-supplied buffer and serveStreamPoll takes one from a pool,
returning it after Write - safe because the platform response writer copies the
bytes before Write returns. Buffers grown past the response cap by a single
oversized frame are dropped rather than pooled, so one large frame cannot pin
megabytes for the life of the process.

Encoding switched from bytes.Buffer with a scratch array to
binary.BigEndian.AppendUint32, which removes the intermediate copies per field.

A/B on the load harness, Go->JS, unthrottled:

  4 KB    406,804 -> 432,715 frames/s   1,589 -> 1,690 MB/s   +6.4%
  64 KB    31,489 ->  34,385 frames/s   1,968 -> 2,149 MB/s   +9.2%
  1 MB      2,076 ->   2,124 frames/s   2,076 -> 2,124 MB/s   +2.3%

Measured at a higher load average than the baseline (6.16 vs 5.11), so these are
floors. No drops and no reorders in any run.

* perf(v3): batch queued frontend sends into one request

Every JS->Go frame was its own POST, and a POST is a scheme-handler round trip -
about eleven cgo calls on macOS once the task start, the URL, method, header and
body getters, and the response, data and finish calls are counted. That fixed
cost per frame was the dominant remaining expense in the transport and no amount
of allocation work could touch it.

send() now queues instead of posting. Whatever accumulates while a request is in
flight goes out together in the next one, so the per-request cost is divided
across the batch. This is the same self-clocking the poll already uses in the
other direction: under light load a batch is a single frame and behaves exactly
as before, and batching only appears once the sender outruns the transport,
which is precisely when it is wanted. No timer, nothing to tune.

Body layout is count u32 then count x ( len u32 | payload ), with the count in
an x-wails-stream-batch header. A frame past the chunk threshold is still sent
alone, since it has to be split anyway. On backpressure Go reports how many
frames of the batch it accepted and the client resends only the remainder, so
ordering holds and nothing is delivered twice. Go decodes the batch with the
payloads aliasing the request body rather than copying.

A/B with the streams example, four connections of 512 KB:

  before  5,720 frames/s   62,900 cgo/s   11.0 cgo/frame
  after   5,820 frames/s   16,300 cgo/s    2.8 cgo/frame

A 74% reduction in cgo calls, with throughput up 1.7% at a comparable load
average. Four connections stayed live with zero closures, and Go and JS suites
pass.

* Revert "perf(v3): batch queued frontend sends into one request"

This reverts commit c434e70.

* Revert "perf(v3): pool poll response buffers and encode by append"

This reverts commit 349538a.

* Revert "perf(v3): read upload bodies in one sized allocation"

This reverts commit a8f91f4.

* Reapply "perf(v3): read upload bodies in one sized allocation"

This reverts commit 3c40e61.

* Reapply "perf(v3): pool poll response buffers and encode by append"

This reverts commit 0b01ea8.

* Reapply "perf(v3): batch queued frontend sends into one request"

This reverts commit 4ddb946.

* fix(v3): one stream session per page, and only a poll may supersede

The runtime can be instantiated more than once in a page - the platform injects
a copy and an app may import it as well - and sessionID lived in module scope,
so each instance minted its own. Since a new session id for a window supersedes
the previous one, the instances then tore down each other's connections in turn,
killing live streams mid-run. The id now lives on window._wails, so there is one
per page however many module instances exist. Verified by instrumenting the
supersede path: it fired repeatedly before, and not once after.

Supersede is also now restricted to the poll. A page bootstrapping always polls,
so the poll is the honest signal that a new page has replaced the old one; a
send carrying an unfamiliar session id has no business tearing down a live
session and every connection on it.

Both were found by instrumenting the close path rather than inferring from
symptoms, after three wrong guesses.

* fix(v3): do not reap a session that still has connections, and wake blocked producers

Two bugs found by instrumenting the emitter rather than inferring from symptoms.

The TTL reaper killed live sessions under load. lastSeen advances when a poll
arrives, and under saturation the page can spend longer than the TTL dispatching
one large response before it issues the next poll - so a perfectly healthy
session looked idle. Measured: the connection died mid-scenario after 230,077
frames, and every later scenario then had nothing to send on. Idle time is only
a trustworthy death signal once nothing is connected; a session with live
connections now gets a ten-minute grace instead, with window destroy and the
next page's poll superseding it as the real signals, and the grace as a backstop
for a renderer that died without either.

That change then exposed what the reaper had been masking. shutdown() claimed to
wake "anyone blocked in Send on a full buffer" but only called wake(), which
nudges the poll and not the producers, so a Send parked on a full queue stayed
parked forever after its connection closed. The sink interface gains
wakeProducers, implemented by both sinks, and shutdown calls it.

Neither is an optimisation, but both had to be fixed before any measurement
could be believed: the first made the load harness lose its connection partway
through every run, which is what made throughput numbers after the first
scenario meaningless.

* test(v3): stop the load harness hanging on a parked producer

The emitter used a blocking Send and the stop function joined its goroutine
unconditionally. Send parks until the frontend drains and does not observe the
scenario context, so once the transport stopped reaping live sessions - which
was the correct fix - an unthrottled producer could park indefinitely and the
run would never finish.

Send stays blocking, because it is markedly faster than spinning on TrySend
against the same consumer, and the join is now bounded at two seconds. The frame
count is atomic, so reading it after abandoning the goroutine is safe.

Recorded with the fix: throughput at 4 KB measured 432k, 47k, 46k and 26k
frames/s across runs of the same build in the same hour. The harness is not yet
reproducible enough to A/B against, and that must be resolved before any further
optimisation is measured.

* fix(v3): address Codex review - supersede race, shared client registry, JSONStream in server mode, bufferedAmount

Four of the five findings. All four follow from earlier changes in this branch,
and none alters the public API.

Supersede now runs on every poll, not only when the poll creates the session.
The client starts its open POST and its first poll concurrently, so the POST can
create the session first; supersede was then skipped and the previous page's
session and handlers survived the whole live-connection grace. The sweep skips
the session being polled for, since it now runs when that session already
exists.

The client's cross-instance state is page-global, not just the session id. Only
the session was shared, so two runtime instances - the injected copy and an
imported one - still allocated connection id 1 apiece, ran competing polls
against one session, and discarded frames addressed to ids only the other
instance knew. The id allocator, connection registry and polling flag now live
beside the session on window._wails.

JSONStream decodes on the native WebSocket too. Server builds return a real
socket, whose dispatch never consults _decode, so both listener styles received
raw ArrayBuffers and the transport-independent contract did not hold. Both
addEventListener and onmessage are now wrapped on that instance.

bufferedAmount counts a frame from the moment send() returns. It was incremented
inside the async chain, so it read zero while frames waited behind an in-flight
batch, which would tell an application using it for backpressure to keep
sending. Everything but a Blob is now sized synchronously, which also removes a
promise per send.

The fifth finding - the server-mode writer sharing the connection context, so a
frame accepted by a preceding Send can fail before it reaches the peer - is
real and reproduced locally with the documented `defer c.Close(); c.Send(final)`
shape. Three attempts at a fix (independent writer context, separating the
socket-close flag from the queue-closed flag, forcing CloseNow only on drain
timeout) each failed to make the frame arrive, so it is left unfixed rather than
shipped half-solved. Recorded on the PR.

* test(v3): stop debounce tests betting on wall-clock scheduling

Run Go Tests v3 (macos-latest) failed on this PR with:

    debounce_test.go:135: fn2 should have been called once, got 0

TestGenerationCounter_StaleCallbacksDiscarded armed a 2ms timer and then
asserted the callback had run after a fixed time.Sleep(30ms). That is a bet
that a short timer gets scheduled within 30ms, which a loaded CI runner does
not honour. It now waits on a channel the callback closes, with a five second
ceiling, and checks the stale callback only once the live one has demonstrably
run - by which point a stale fn1, armed earlier with the same delay, would also
have run. Same guarantee, no clock.

TestZeroDuration had the same shape with a thinner margin: a zero-delay timer
and a 10ms sleep. Given the same treatment before it becomes the next
intermittent failure.

The 300ms waitFor assertions elsewhere in the file are left alone: six times the
debounce window is enough headroom, and rewriting them is not this change.

Nothing in the debounce package itself is altered, only the tests' timing
assumptions. Verified with 200 runs of the failing test, then 50 runs of the
package under -race with four cores spinning, all green.

Note the old test also passed locally under that contention - this machine is
faster than the runner, so the fix is reasoned from the failure message rather
than from a local reproduction.

* fix(v3): count Blob bytes in bufferedAmount as soon as send returns

Blob.size is available synchronously even though the conversion is not, so a
large Blob send no longer reports zero while it is queued. Code using
bufferedAmount for backpressure could otherwise send past the limit it thought
it was respecting.

The other finding from the same re-review - a late poll from a superseded page
retiring its own replacement - is real and is NOT fixed here. Ordering sessions
by a creation sequence, so a poll only retires sessions older than itself,
deadlocked TestStreamNewSessionSupersedesPreviousInSameWindow and was reverted
rather than pushed. Recorded on the PR.

* fix(v3): harden stream lifecycle and delivery

* fix(v3): preserve stream and transport lifecycle

* fix(v3): rebuild production runtime asset

* fix(v3): snapshot Stream send buffers

* fix(v3): preserve Stream generation without storage

* fix(v3): decode JSON Stream frames once

* test(v3): tolerate loaded runtime test workers

* perf(v3): reuse Stream text encoder

* docs(v3): correct Stream harness rationale

* test(v3): synchronize stale debounce callbacks

* fix(v3): close Stream review coverage gaps

* fix(v3): block Stream ingress without polling

* fix(v3): bound Stream chunk reassembly state

* fix(v3): bound Stream session admission

* chore(v3): remove unrelated formatting changes

* fix(v3): harden Stream lifecycle resource bounds

* fix(v3): address Stream review findings

* fix(v3): address remaining stream review findings

* fix(v3): make stream frame allocation explicit

* fix(v3): stop stream close racing teardown for the lifecycle slot

* docs(v3): align stream close accounting docs with the split budgets

* docs(v3): complete the stream limits reference

---------

Co-authored-by: taliesin-ai <bot@taliesin.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Documentation Improvements or additions to documentation Linux MacOS runtime v3 Windows

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants