Back to Wails

Streams — internals

docs/src/content/docs/guides/advanced/streams-internals.mdx

2.15.020.0 KB
Original Source

Reference for anyone — human or agent — changing the stream transport. The user-facing API is in Streams; this page is about the machinery underneath and the reasoning behind it, because several decisions look arbitrary until you know what they are avoiding.

Files

filerole
v3/pkg/application/stream.gopublic API, StreamConn, streamSink, the manager and its registry
v3/pkg/application/stream_session.goone page load in one window: the outbound queue, frame kinds, connection table
v3/pkg/application/stream_transport.gothe two HTTP endpoints, binary framing, chunk reassembly, runtime prelude
v3/pkg/application/stream_server.go-tags server only: real WebSocket sink
v3/pkg/application/stream_prelude_{server,desktop}.gopicks the client transport at bundle-serve time
v3/internal/runtime/desktop/@wailsio/runtime/src/stream.tsthe WebSocket-shaped client
v3/tests/stream-performance/load harness (-upload, -reloads, scenario sweeps)

The shape

Go→JS and JS→Go use different mechanisms, and that asymmetry is the whole design.

Go                                  webview
──                                  ───────
Send() ─► per-window queue ─────────► GET  /wails/stream/poll   (held open)
                                      └─ one held request per window,
                                         carrying frames for every connection

Receive() ◄─ per-conn inbox ◄──────── POST /wails/stream/send   (one or more frames)

Go→JS is a held poll. The request parks until there is something to deliver. There is no polling interval and nothing adaptive, deliberately: the server holds until a frame exists, so delivery latency is already ~0, and any client-side interval could only add to it. Frames that arrive 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. Measured: 1.0 frames per response at 100/s, still 1.0 at 5,000/s, 3.4 at 20,000/s, and p99 latency falls as the rate climbs.

JS→Go uses ordinary POSTs. Sends are serialised per connection with a promise chain, because concurrent fetch calls do not preserve order and Go relies on send order being the order it observes. Frames accumulated behind an in-flight request are batched into the next POST. Go appends the accepted frame or batch prefix to the connection's inbox before responding, so the client cannot advance past bytes Go has not queued.

One poll in flight per window, multiplexing every connection. This is what makes ordering correct by construction — one queue, one drainer, no second delivery path that could overtake the first. It also sidesteps the HTTP/1.1 six-connections-per-host limit on Windows, where these are real Chromium network requests against http://wails.localhost.

Why these specific decisions

Each of these is a scar from the event-transport work. Removing one re-opens a measured bug.

Nothing in the Go→JS path touches the main thread. Send appends under a mutex and returns. Events used to run their eval inline when emitted from the main thread while an earlier goroutine emit was still queued — 4.4% of events inverted, on all three platforms. A single queue with a single drainer cannot do that.

Nothing touches evaluateJavaScript, at any size. Splicing payload into eval source retains host memory above a platform-specific knee: 11.6 GB on macOS and 6.2 GB on WebKitGTK at 100 × 1 MB/sec. Streams never go near it, which is why the constant-byte-rate sweep is flat at every frame size.

Control data travels in headers, never the body or query string. WebKitGTK 6.0 can deliver POST bodies as query params for custom URI schemes (transport_http.go carries a fallback for exactly that), and WebView2 caps body delivery around 2 MB.

The poll response is binary, not JSON. Frames are []byte; base64 inside a JSON envelope would cost 33% on every frame plus a parse on the UI thread.

magic "WS1\0" | flags u8 | count u32 | count × ( connID u32 | kind u8 | len u32 | payload )

kind is data / open / close / error. There is no sequence number and no ack: a WebSocket does not replay, and a connection that drops loses what was in flight. Emulating that is simpler and more honest than a cursor the bounded buffer could not always satisfy.

Holding a request is safe because every webview request already gets its own goroutine. dispatchWorkers in assetserver_webview.go is pinned at 0 with a comment naming exactly this case; turning that pool on would need a request-lifetime bound first.

Buffer constants

All in stream.go. They are compile-time constants, not options — there is no Options.Streams and no per-stream setting. Changing them means editing the file.

constantvaluewhat it bounds
streamOutQueueBytes8 MBbytes buffered per window awaiting collection
streamOutQueueDepth256frames buffered per window
streamOutQueueBytesGlobal / streamOutQueueDepthGlobal256 MB / 8,192outbound data buffered across the application
streamInQueueBytesGlobal / streamInQueueDepthGlobal256 MB / 8,192inbound data awaiting Receive across the application
streamMaxConnections256live connections plus queued closes in one session
streamMaxConnectionsGlobal4,096live connections across the application
streamOutCloseDepthGlobal4,096undelivered close notifications across the application
streamMaxSessionsPerWindow16sessions one window may hold before a newer generation must supersede an older one
streamMaxSessions1,024sessions across the application
streamOutControlDepth / streamOutControlDepthGlobal256 / 4,096queued non-close control frames, per session and across the application
streamMaxChunkSets / streamMaxChunkTotal256 / 4,096incomplete uploads per session, and parts in one upload
streamMaxChunkBytesGlobal / streamMaxChunkPartsGlobal128 MB / 4,096chunk payload and part metadata across the application
streamMaxChunkIDLen64 bytesone client-supplied chunk-set identifier
streamMaxResponseBytes1 MBone poll response
streamHoldTimeout20 show long an empty poll parks
streamSessionTTL60 sno poll for this long and no live connections ⇒ session is dead
streamSessionGrace10 minno poll for this long with live connections ⇒ session is dead
streamSessionSweep20 show often the janitor looks for dead sessions
streamMaxFrameBytes64 MBone frame in either direction
streamMaxNameLen256 bytesone registered or requested stream name
streamInQueueDepth / streamInQueueBytes256 / 8 MBframes received and not yet taken by Receive

How to pick them

streamOutQueueDepth is deliberately not eventQueueCapacity (64). That constant was measured for a queue drained one eval at a time, where depth bought nothing but tail latency. A poll drains in batches, so depth here has to cover one round trip of production — at 5,000 frames/s and a 5 ms round trip, ~25 frames. 256 leaves room for a burst without stalling the producer.

streamOutQueueBytes is the bound that actually matters, because 256 frames of 1 MB is 256 MB. It is the backstop on host memory when a frontend stops collecting.

Two rules interact here, and the second is easy to break by accident:

  • The depth and byte caps bound accumulation.
  • An empty queue always accepts one frame, however large. Enforcing the byte cap unconditionally made a frame larger than the cap impossible to send at all — the wait condition could never become true, so 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.

streamMaxResponseBytes exists because of Windows. The WebView2 response writer accumulates the entire body in memory and only hands it over in Finish, so an unbounded response is an unbounded allocation there. Raising it does not buy Windows throughput — measured, the Windows bottleneck is per-byte, not per-response: responses/s varies 4× across the frame sweep while MB/s stays flat at ~90.

The inbound bound is what makes the frontend wait. On the desktop, deliver reports fullness, the endpoint responds 429, and the client retries the same frame or unaccepted batch suffix with bounded backoff. This avoids occupying a webview request slot while the handler catches up. In server mode the socket read pump waits and lets TCP apply backpressure. Without the bound, a handler slow to call Receive could grow host memory without limit.

Control frames bypass the data caps, but have independent lifecycle bounds. Losing a data frame under backpressure is a slow-down; losing an open ack leaves the frontend in CONNECTING forever, and losing a close leaves it believing a dead connection is live. Non-close controls therefore have their own bounded queue, separate from the one closes draw on, so a burst of refused opens cannot consume the capacity an accepted connection needs to report that it ended. Each session also reserves one close slot per accepted connection. When that capacity is occupied, a new open receives retryable backpressure before it is registered.

Per-session bounds also have application-wide counterparts. Without them, every admitted session or connection could retain its full local allowance at once. Outbound and inbound data therefore share separate 256 MiB / 8,192-frame budgets across desktop and server transports. Live connections have a 4,096-entry budget, and undelivered close notifications have a second one of the same size. Reaching a shared allowance applies the same blocking Send or non-blocking TrySend behaviour as reaching a local allowance, and every drain, receive, close, failed write, and shutdown path returns its reservation.

Those two budgets are deliberately separate rather than one allowance a connection hands to its own close frame. Each reservation is released by exactly one owner: a connection's slot by shutdown, which runs once, and a close frame's slot by whatever disposes of the frame — a drain, or its session being torn down. Ownership that migrates between two parties has to be transferred atomically, and an earlier revision that let a close inherit the connection's slot leaked one permanently whenever teardown ran between a close being attempted and that attempt failing.

Go frames transfer ownership; JavaScript frames are snapshotted. Go Send retains the caller's slice until the transport writes it, so callers must not mutate or reuse that storage after a successful call. JavaScript send() copies mutable binary inputs before it returns, matching native WebSocket ownership semantics. The asymmetric rule avoids a second full-frame copy inside Go while keeping the browser-facing API unsurprising.

JavaScript sending follows the WebSocket buffering contract. send() cannot block, so an application can queue data faster than the desktop request channel accepts it, just as it can outrun a native WebSocket. bufferedAmount includes every byte retained by that socket and is the caller's backpressure signal; the host-side queues remain independently bounded by the limits above. A terminal failure, peer close, or local close() releases the retained payloads. Local close also cancels an open or data request waiting on 429 before it posts the reserved close control, so admission or receiver backpressure cannot leave the socket stuck in CLOSING.

Chunk reassembly has a shared host-memory allowance. Each session may assemble a single frame up to 64 MiB, but that allowance must not be multiplied by every admitted session. Incomplete and retryable chunk sets therefore share a 128 MiB admitted-payload budget. Completing a set briefly retains both its parts and its contiguous assembled frame, so doubling that logical allowance still stays within the 256 MiB effective memory ceiling. Retained parts also share a 4,096-entry metadata allowance, so tiny or empty chunks cannot grow maps and slice bookkeeping without approaching the byte limit. A request that would cross either allowance receives retryable backpressure; delivery, rejection, expiry, or session shutdown returns both bytes and part entries to the shared budgets.

Polling retries only recoverable failures. Network errors, request-timeout responses (408), early-data responses (425), backpressure (429), and server errors (5xx) use exponential backoff from 250 ms up to 5 seconds. Other 4xx responses are protocol or ownership failures and close the page's Streams immediately; 410 is the clean terminal signal for a retired session. Closing the last connection aborts an in-flight poll or backoff timer, while a connection opened during that wind-down starts one replacement poll loop.

streamSessionTTL must stay comfortably above streamHoldTimeout, or a session would be reaped while its own poll is legitimately parked.

If you are tuning for a workload of many small messages, the depth cap binds first; for large payloads, the byte cap does. Neither needs changing for a typical app — on macOS the defaults sustain 634,000 frames/s and 2.1 GB/s.

Connection and session lifecycle

A session is one page load in one window, keyed by a client-generated id (like the runtime's clientId). Sessions are created lazily by whichever request arrives first. When a platform cannot identify the requesting window (windowID == 0), session ids are still globally bounded but their generations are deliberately not compared: they may belong to independent browser clients with unrelated generation counters. Those sessions expire through close or TTL rather than superseding one another.

Three mechanisms close things, in order of how quickly they notice:

  1. A newer session poll for a window supersedes older generations. A reload gives the page a new session id and increments a generation stored in that window's sessionStorage. The same value is mirrored in window.name, which survives reloads when storage is disabled, and is anchored to performance.timeOrigin (or Date.now() on older engines) so clearing both stores does not restart at one. Every request carries the session id and generation. A poll retires only lower page generations, so server scheduling cannot make a delayed request from the previous page look newer than its replacement. If policy blocks both storage and window.name, ordering falls back to the page clock and therefore depends on later pages receiving a later time origin. The manager retains a per-window retired-generation watermark so an already in-flight request cannot recreate the old page without retaining every historical session id. The previous session's connections close immediately.
  2. Window destroy drops every session for that window, like eventPayloadStore.dropWindow.
  3. TTL reaping catches anything else — a crashed renderer, a machine asleep.

Only the first two mechanisms retire the page generation. TTL cleanup removes the idle session without advancing the retired-generation watermark: a page stops polling whenever its last connection closes, but that same still-loaded page must be able to open another stream later. A genuinely superseded generation remains blocked because the newer page's poll advances the watermark before the old session is removed.

Apple WebViews report cancelled requests. On macOS and iOS, WebKit's stopURLSchemeTask callback cancels the matching request context, so a poll belonging to a page that has navigated away unblocks immediately. The registry is keyed by the retained native task identity and removes entries when request processing closes them. Linux and Windows still expose no equivalent early-abort callback in the current bridge; there a parked request remains until the hold expires. Rule 1 means the connection closes promptly regardless. Cancellation otherwise surfaces as EPIPE on Linux and only at Finish on Windows.

Transport selection

Stream(name) consults window._wails.streamFactory. Server builds install one that returns a real WebSocket; webview builds leave it unset and get the poll client.

The factory must be installed before any module body runs, because generated bindings will create streams at module scope. custom.js cannot do it — loadOptionalScript does a HEAD request and then appends a <script> tag, so it lands far too late. Instead the factory is prepended to the runtime bundle as it is served (stream_prelude_server.go), which is synchronous by construction: ES module dependencies evaluate before their importers.

If you add a third transport, put it in the prelude too. Do not be tempted back to custom.js.

What is not finished

status
Request cancellation from the platform layerApple done; Linux/Windows pending — see above
Buffer constants as optionsnot done; compile-time only
Typed streamsnot done, deliberately — frames are []byte by decision
Pipelining (a second poll in flight)not done; would need ordered reassembly in JS
Per-connection fairnessnot done — connections in a window share one queue, so a flooding connection slows its neighbours
JS→Go frame coalescingdone — frames accumulated behind an in-flight request are sent in bounded batches; lightly loaded connections still send one frame per POST
Windows throughput~100 MB/s, bounded by WebResourceRequested marshalling. Shared buffers (PostSharedBufferToScript) are the candidate fix; bindings exist under internal/webview2/pkg/webview2/ but are not wired into pkg/edge
wails3 dev / Viteworks — verified with a generated vanilla-js project: the Vite dev server proxies at /, and /wails/stream/* is matched by the asset server middleware before the proxy, so streams are unaffected
Multi-windowuntested under load, though sessions are window-scoped by construction

The frontend package in dev mode

A generated project imports @wailsio/runtime from npm, not from the bundled /wails/runtime.js that the asset server serves. Under wails3 dev, Vite resolves it from node_modules, so an app built against a published runtime will not see client-side additions made on a branch.

While streams are unreleased, point a test app at this checkout's package:

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

That rebuilds dist/ first, so it always installs current sources. Undo it with npm install @wailsio/runtime@latest in the same directory.

Note there are two client build outputs and it is easy to rebuild one and not the other: task v3:runtime:build:package produces the npm package's dist/ (what an app's frontend imports), while task v3:runtime:build:assets produces bundledassets/runtime.js (what the webview loads from the asset server). A change to stream.ts needs both.

Testing

bash
go test ./pkg/application/ -run TestStream -race        # protocol, ordering, backpressure
go test -tags server ./pkg/application/ -run TestServerMode
pnpm --dir v3/internal/runtime/desktop/@wailsio/runtime test

The ordering test is the one 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. If it ever fails, the single-drainer invariant has been broken.

Load harness:

bash
go run ./tests/stream-performance -duration 20s              # full sweep
go run ./tests/stream-performance -upload -duration 10s      # JS→Go matrix
go run ./tests/stream-performance -reloads 6                 # connection lifecycle

On Windows it must run in the interactive console session — a plain SSH invocation dies in session 0 with zero-length output — and the binary must be staged somewhere both the SSH account and the console account can read, since C:\Users\<user> is ACL'd to its owner.