feat(v3): streams — WebSocket semantics without a listening socket - #5942
Conversation
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.
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
WalkthroughAdded 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. ChangesGoStream feature
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
Possibly related PRs
Suggested labels: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
leaanthony
left a comment
There was a problem hiding this comment.
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.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (11)
v3/Taskfile.yaml (1)
64-66: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueUse
toSlashwhen constructingPKG.Go Task v3.44.1 leaves
TASKFILE_DIRwith 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 valueGuard the echo timers against a second
onopen.
echo.onopenstarts 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 valueReport CSV flush errors.
writeScenarioCSVdefersw.Flush()andf.Close()but checks neither. A short write or a full disk produces a truncated CSV and the function still returnsnil. Checkw.Error()and theCloseresult 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 valueAdd JSON tags to
SendP50andSendP99.Every other field in
scenarioResultcarries a lowercase JSON tag. These two fields serialise asSendP50andSendP99insummary.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 winReport unmatched names in
-only.
selectedScenariosdrops any name that matches no scenario. A typo such as-only rate-500returns an empty slice. The harness then logs "running 0 scenario(s)", writes an emptysummary.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 | 🔵 TrivialDocument the interaction between
Server.WriteTimeoutand the held-poll fallback.The desktop poll endpoints stay reachable in server mode through the
"/"asset mount, as noted inv3/pkg/application/stream_server.go. A held poll parks for up tostreamHoldTimeout(20 seconds). The defaultwriteTimeouthere is 30 seconds, so the default is safe, but an operator who setsopts.WriteTimeoutbelow 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 winReplace 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.gorecords 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.muuntil the expected frames are present, with a generous deadline.TestStreamPendingClosesReserveConnectionCapacityat 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 valueReconsider 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 winUse
vi.waitForinstead 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 thefetchmock within exactly two microtask ticks. That coupling breaks if the promise chain insend/postFramegains anotherawait.♻️ 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 valueRemove the duplicated conversion branches in
toBytes.
toBytesrepeats every branch oftoBytesSyncexcept theBlobcase. Delegate the non-Blobbranches totoBytesSyncso 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 valueSimplify 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:
remainingis 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
📒 Files selected for processing (75)
AGENTS.mddocs/src/content/docs/guides/advanced/streams-internals.mdxdocs/src/content/docs/guides/streams-from-websockets.mdxdocs/src/content/docs/guides/streams.mdxv3/Taskfile.yamlv3/UNRELEASED_CHANGELOG.mdv3/examples/streams/README.mdv3/examples/streams/assets/index.htmlv3/examples/streams/main.gov3/internal/assetserver/bundledassets/runtime.debug.jsv3/internal/assetserver/bundledassets/runtime.jsv3/internal/debounce/debounce.gov3/internal/debounce/debounce_test.gov3/internal/runtime/Taskfile.yamlv3/internal/runtime/desktop/@wailsio/runtime/src/index.tsv3/internal/runtime/desktop/@wailsio/runtime/src/nanoid.tsv3/internal/runtime/desktop/@wailsio/runtime/src/ssr_import.test.jsv3/internal/runtime/desktop/@wailsio/runtime/src/stream.test.jsv3/internal/runtime/desktop/@wailsio/runtime/src/stream.tsv3/pkg/application/application.gov3/pkg/application/application_server.gov3/pkg/application/autostart.gov3/pkg/application/autostart_android.gov3/pkg/application/autostart_darwin_smappservice.gov3/pkg/application/autostart_ios.gov3/pkg/application/autostart_linux_test.gov3/pkg/application/autostart_server.gov3/pkg/application/autostart_test.gov3/pkg/application/autostart_windows_test.gov3/pkg/application/browser_window.gov3/pkg/application/context_menu_manager.gov3/pkg/application/dialogs_android.gov3/pkg/application/dialogs_ios.gov3/pkg/application/dialogs_windows_test.gov3/pkg/application/events_common_ios.gov3/pkg/application/events_common_windows.gov3/pkg/application/init_desktop.gov3/pkg/application/ios_runtime_ios.gov3/pkg/application/ios_runtime_stub.gov3/pkg/application/keys_ios.gov3/pkg/application/keys_linux.gov3/pkg/application/keys_test.gov3/pkg/application/logger_dev.gov3/pkg/application/logger_dev_windows.gov3/pkg/application/mcp_protocol_enabled.gov3/pkg/application/menu_ios.gov3/pkg/application/menuitem_ios.gov3/pkg/application/screenmanager_internal_test.gov3/pkg/application/signal_handler_ios.gov3/pkg/application/signal_handler_types_desktop.gov3/pkg/application/signal_handler_types_ios.gov3/pkg/application/single_instance_ios.gov3/pkg/application/stream.gov3/pkg/application/stream_prelude_desktop.gov3/pkg/application/stream_prelude_server.gov3/pkg/application/stream_server.gov3/pkg/application/stream_server_test.gov3/pkg/application/stream_session.gov3/pkg/application/stream_test.gov3/pkg/application/stream_transport.gov3/pkg/application/systemtray_ios.gov3/pkg/application/transport_event_ipc_test.gov3/pkg/application/transport_http.gov3/pkg/application/transport_http_test.gov3/pkg/application/webview_window.gov3/pkg/application/webview_window_windows.gov3/pkg/application/webview_window_windows_nonclient.gov3/tests/stream-performance/assets/index.htmlv3/tests/stream-performance/main.gov3/tests/stream-performance/report.gov3/tests/stream-performance/sampler_darwin.gov3/tests/stream-performance/sampler_linux.gov3/tests/stream-performance/sampler_other.gov3/tests/stream-performance/sampler_windows.gov3/tests/stream-performance/scenarios.go
There was a problem hiding this comment.
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 winDocument the storage-unavailable generation path.
These lines describe
sessionStoragegeneration 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
📒 Files selected for processing (12)
docs/src/content/docs/guides/advanced/streams-internals.mdxdocs/src/content/docs/guides/streams.mdxv3/internal/assetserver/bundledassets/runtime.debug.jsv3/internal/assetserver/bundledassets/runtime.jsv3/internal/runtime/desktop/@wailsio/runtime/src/stream.test.jsv3/internal/runtime/desktop/@wailsio/runtime/src/stream.tsv3/pkg/application/stream.gov3/pkg/application/stream_server.gov3/pkg/application/stream_server_test.gov3/pkg/application/stream_session.gov3/pkg/application/stream_test.gov3/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
There was a problem hiding this comment.
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
📒 Files selected for processing (14)
docs/src/content/docs/guides/server-build.mdxdocs/src/content/docs/guides/streams-from-websockets.mdxdocs/src/content/docs/guides/streams.mdxv3/UNRELEASED_CHANGELOG.mdv3/examples/server/README.mdv3/examples/streams/README.mdv3/examples/streams/assets/index.htmlv3/internal/runtime/desktop/@wailsio/runtime/src/stream.test.jsv3/pkg/application/application_options.gov3/pkg/application/stream_server.gov3/pkg/application/stream_server_test.gov3/pkg/application/websocket_server.gov3/pkg/application/websocket_server_test.gov3/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
|
Reviewed One real bug, proven below. Everything else is sound. Verification
Global lifecycle slots leak on a close/teardown race
That handoff is not atomic, and // stream_session.go, close()
for _, c := range conns {
c.shutdown() // not gated by closeOnce
}Interleaving:
No close frame was ever queued, so ReproductionRacing 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)
}
}ImpactThe trigger is a page reload landing while a handler runs its Suggested directionRather than making the handoff atomic, consider removing it: give the queued close its own reservation ( What works well
Smaller notesThe check → reserve → recheck → rollback pattern now appears twice (
|
|
Pushed 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)
This also collapsed The property the original design was protecting is kept: a queued close still cannot be starved by refused opens, because Tests.
Server mode is unaffected: it reserves and releases its own slot via 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 |
|
Reviewed Validated with:
All pass. I found no new correctness issue in this correction. |
…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>
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.
Stream(name)returns synchronously withreadyState === CONNECTING, so connections canbe created at module scope — the shape generated bindings would use.
JSONStreamplusSendJSON/ReceiveJSONgive 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 isalready a listener to upgrade on.
HandleStreamandStreamConnare 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
evaluateJavaScriptat any size (that retained11.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
Nothing existing changes behaviour. Events,
Emit, and the runtime call path areuntouched.
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, andscenario sweeps). Across the three platforms, ~41 million frames with 0 drops and 0
reorders in every Go→JS scenario.
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 devwith a Vite dev server in front of theasset server, and the included example end to end.
Test Configuration
tests,
wails3 dev, example— both directions
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:
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
stopURLSchemeTaskand its equivalents through to acontext.CancelFuncis theoutstanding item.
[]byteby decision.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.
InitialHTMLwindows cannot use streams: they load withorigin === "null"and cannotreach the asset server.
Checklist:
website/src/pages/changelog.mdxwith details of this PRDocumentation 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.mdgains a pointer to the internals page and a section onthe two frontend runtime build outputs, which are easy to confuse.
Summary by CodeRabbit
New Features
Documentation