Add deferred WebSocket upgrade materialization (deferUpgradeMaterialization + materializeUpgrade) - #7
Conversation
|
Note on checks: the Test suite (full matrix) passed on this branch via workflow_dispatch (run 32038698280), and Semgrep is green. The pkg.pr.new check failure is a service-side issue, not this PR: the publish step 404s with |
48166d0 to
79f771e
Compare
commit: |
|
Force-pushed a reviewed revision (
Also: the earlier pkg.pr.new registration failure has resolved itself — the check now passes, so my previous comment's ask (reinstall the app) is moot. |
79f771e to
8f9077e
Compare
|
Round 2 (
Full suite 1258/1258 + Bun 16/16; dispatched CI Test run green. The PR description gained a "Notes for the iterate/iterate integration" section with three design-doc deltas the consuming author needs (forward the whole pair object; detect upgrades by |
|
Description updated with a "What existing users need to know" section (TL;DR: nothing changes unless you opt in — no wire change, additive API, semver minor; opt-in users get a four-rule checklist for the pair) and a "What's newly possible" section with a complete relay + Durable Object example showing the previously-impossible split: capnweb session terminating in one worker, the eyeball's WebSocket served from another isolate via |
8f9077e to
ef4af85
Compare
|
Round 3 ( The reasoning, empirically grounded: a workerd Response must receive its socket at construction (expandos/getters are invisible to the upgrade machinery), and materialization is a one-way door (the pump consumes the tunnel streams) — so the receiver must choose pair-vs-socket at deserialization time, and no fully automatic scheme can exist. We also probed the last conceivable flag-free alternative — extending the distinguished-fetch chain into the session-holding stateless instance via a lent Net effect: the relay/edge session is created with no options at all; Suites: Node 34/34, workerd 19/19, full run 1257/1258 (the one failure is the long-documented WebKit backpressure flake), Bun 16/16. |
On Cloudflare Workers, a tunneled upgrade Response's socket now arrives
by default as an opaque { readable, writable, init } byte-stream pair
(DeferredWebSocketUpgrade) instead of an eagerly materialized
WebSocketPair end: at a session endpoint with internal RPC hops beyond
it, eager materialization was only ever correct when that endpoint
itself served the upgrade, and materialization is a one-way door (once
the pump attaches, the tunnel streams are consumed), so the receiver
must build the forwardable form up front. The new materializeUpgrade()
export rebuilds the real upgrade Response from the pair at the hop that
actually serves the 101; pair.init carries the provider's upgrade
headers (e.g. a negotiated Sec-WebSocket-Protocol) so they survive to
that response. Other runtimes keep delivering a usable TunneledWebSocket
by default (no internal hops to cross), and the new
deferUpgradeMaterialization session option overrides the default in
either direction -- false on Workers restores eager materialization for
an endpoint that serves the socket itself.
This exists to thread a tunneled socket through boundaries that can
serialize byte streams but not sockets -- specifically native
Cloudflare Workers RPC between isolates, whose serializer refuses a
live WebSocket (DataCloneError). Without it, an upgrade Response
received from a capnweb session materializes one hop too early and
dies on the next internal Workers-RPC hop. This composes with the
capnweb-in-a-Worker, native-RPC-to-the-DO topology upstream recommends
(capnweb issue cloudflare#36).
The pair is byte-oriented because workerd RPC proxies streams as byte
pipes only (a value-chunk stream fails with "This ReadableStream did
not return bytes"), so the tunnel's text/binary/close frames travel in
a length-prefixed framing spoken only by the deferring session and
materializeUpgrade(). Since the two speakers are independently deployed
workers, the header layout is frozen and evolution is append-only (an
unknown frame type tears the tunnel down loudly). The capnweb wire
format is unchanged; deferral is purely a receive-side choice, threaded
to the deserializer through the Importer interface the same way
upstream threads RpcLimits (as an optional method, so only the RPC
session implements it).
Ownership follows the ordinary semantics of streams received over RPC:
the pair's inner ends stay payload-owned for the tunnel's whole life
(first use locks them but takes no ownership), and the outer streams
use highWaterMark 0 so nothing pulls -- or locks -- before a real
read; an untouched deferred Response releases the tunnel on payload
disposal, like an unclaimed TunneledWebSocket. materializeUpgrade()
consumes the pair, locking both streams synchronously so a second call
fails fast. The decoder tolerates arbitrary re-chunking, assembles
split frames in linear time, caps the frame length on both the encode
and decode side, and fails closed -- aborting the sender's socket --
on malformed frames and on truncation at end-of-stream. Flow control
is inherited end to end: stream acks fire as the pair is read, so an
unconsumed pair keeps the provider throttled to the flow-control
window (asserted by test), and a materialized endpoint behaves like a
non-deferred one.
Reviewed with two staged multi-agent adversarial passes (eleven lenses
total; every bug claim independently reproduced, several fixes
empirically probed down to a raw-socket RFC 6455 handshake against the
materialized 101) plus upstream-maintainer design evidence. The review
removed a parallel writable mode on TunneledWebSocket in favor of the
existing WritableStreamStubHook machinery, collapsed four stream
wrappers into two, made the deferred branch a one-line call in
serialize.ts, and reverted map.ts to byte-identical with upstream.
Tested on Node (framing pinned byte-for-byte, split/coalesced/large/
empty frames, header carriage, mixed upgrade and plain traffic on one
deferring session, window-bounded backpressure of an unread pair,
double-materialize fail-fast, malformed and oversized and truncated
frames failing closed, abort/cancel teardown, ignored-params release,
RPC-session death yielding error + close 1006, and the full session
battery over a deferred-then-materialized socket) and on workerd
(in-isolate defer/materialize with a real WebSocketPair; the pair
crossing a native service-binding hop in a call param AND in a return
payload -- the relay-to-DO shape -- with init and close propagation
asserted through both boundaries; and a REAL HTTP upgrade served from
a fetch handler via materializeUpgrade, subprotocol echoed on the
actual 101).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
c3d5b23 to
51a87a1
Compare
ef4af85 to
ca3da33
Compare
What this is
Base:
main(the #6 rebase has landed — this is now a normal single-commit PR, rebased onto upstream2de5871, which includes cloudflare#241's new StubHook arg-disposal contract).Implements the Level 2 ("fix it in the fork") recommendation from
project-worker/docs/live-capnweb-ws-handler.md(iterate/iterate R3b): stop materializing a tunneled WebSocket at the capnweb session endpoint, and instead let infrastructure code carry the tunnel onward in stream form, materializing a real socket exactly once — at the hop that actually returns the 101 to the eyeball. This composes with the topology Kenton describes as the long-term plan in capnweb#36: capnweb terminates in a stateless Worker, and the DO sees only regular Workers RPC.What existing users need to know
One deliberate behavior change on Workers; everything else just works. In detail:
webSocketas the transportable pair by default, instead of an eagerly materializedWebSocketPairend. Rationale: at a session endpoint, eager materialization was only ever correct when that endpoint itself served the upgrade — in every other topology it produced theDataCloneErroron the next internal hop — and materialization is a one-way door (the pump consumes the tunnel streams), so the receiver must build the forwardable form up front. The universally-safe shape is now the default; an endpoint that serves the socket itself either callsmaterializeUpgrade(response.webSocket)on the next line or setsdeferUpgradeMaterialization: falseon the session.response.webSocketas a native socket at the session endpoint. The deferred feature itself has never been published; the only published surface is the base tunnel in@iterate-com/capnweb@0.10.0, and we know of no consumer of its workerd auto-materialize branch (iterate's tunnel consumers — captun, the e2e harnesses — run on Node, where nothing changes). Ships as a documented minor with a migration note in the changeset.response.webSocketis still a usableTunneledWebSocketby default; the session option opts a non-Workers receiver into the pair form (that's how a Node relay — or our own tests — get it).deferUpgradeMaterializationsession option (now an override in either direction rather than an opt-in), thematerializeUpgrade()export, and theDeferredWebSocketUpgradetype.materializeUpgrade(). Both must run this version or later (they can be different deployments — the format is frozen with append-only evolution, and a mismatch tears the tunnel down loudly rather than desyncing). Peers on the far side of the capnweb wire are unaffected either way.{readable, writable}dropsinitand with it the negotiated subprotocol; detect upgrades viaresponse.webSocket != null— the deferred Response's own status is 200, and the Response itself must not cross a native boundary (itswebSocketproperty is a JS expando that silently vanishes); and if you receive the pair as a call result, keep the delivering Response referenced (undisposed) while the tunnel is in use — a pair received in call params can't be kept past the call.What's newly possible (with example code)
What already worked (the base WebSocket-over-RPC feature, unchanged): a
fetchtarget on the far side of a capnweb session can return an upgradeResponse, and the receiving endpoint gets a working socket —The wall this PR removes: that socket was welded to the isolate holding the capnweb session. Handing it to any other worker or Durable Object over native Workers RPC — or returning the Response through an internal RPC hop toward a fetch handler elsewhere — died with
DataCloneError: Could not serialize object of type "WebSocket". So you could not split "where the capnweb session terminates" from "where the eyeball's WebSocket is served", which is exactly the split the recommended architecture wants (capnweb in a stateless Worker, DOs speaking only native RPC — capnweb#36).Now — the session endpoint defers, the raw pair crosses the native hop like any serializable value, and the far end mints the real socket:
Both halves of that flow are what the workerd tests exercise verbatim: the pair crossing a service-binding hop in a call param and in a return payload, and a real HTTP upgrade completing through a fetch handler with the subprotocol echoed on the actual 101. End to end, the eyeball's frames travel: eyeball socket ⇄
WebSocketPairpump ⇄ framed byte streams over native RPC ⇄ capnweb tunnel ⇄ provider's socket — with capnweb's flow-control window bounding what any middle hop buffers, and every failure mode (death of either socket, the session, or the transit streams) collapsing the whole chain closed.The same option also unblocks the apps/os mesh case (the quarantined
live-capability-websockete2e): the capability-host isolate defers,invokeCapabilityreturns the pair over its internal hop, and the dynamic worker callsmaterializeUpgrade(pair)in its own fetch handler.API
Responsedeserializes withResponse.webSocketas an opaqueDeferredWebSocketUpgrade—{ readable, writable, init }— on workerd by default, and as a usable socket elsewhere.RpcSessionOptions.deferUpgradeMaterialization?: booleanoverrides the default in either direction (falseon Workers restores eager materialization for an endpoint that serves the socket itself;trueopts a Node relay into the pair form).initcarries the provider's upgrade headers (e.g. a negotiatedSec-WebSocket-Protocol), so infrastructure that forwards the whole pair object preserves them with zero extra plumbing. The deferred Response itself has status 200 (constructed Responses can't be 1xx) — detect upgrades viaresponse.webSocket != null, never by status. Receive-side only; the wire format is unchanged.materializeUpgrade(pair, init?): Response— rebuilds the real upgradeResponse(on workerd:WebSocketPair+ pump + status 101 + the carried headers; an explicitinitreplacespair.initwholesale). The pair is consumed: both streams lock synchronously, so a second call on the same pair throws instead of corrupting the first.The option threads to the deserializer through the
Importerinterface — the same route upstream added forRpcLimits— as an optional method, so only the real RPC session implements it andmap.tsstays byte-identical with upstream.The load-bearing design finding: the pair is byte-oriented
The design doc assumed the deferred pair could be the tunnel's raw value streams ("streams serialize across Workers RPC" was listed as known). Empirically false for value streams — probed against a real service-binding hop: reads fail with
TypeError: This ReadableStream did not return bytes, string writes are refused;Uint8Arraychunks flow fine in both directions. So the pair carries the tunnel's text/binary/close frames in a length-prefixed framing (1 byte type, 4 bytes big-endian length, payload). Because the deferring worker and the materializing worker deploy independently, the framing is a frozen cross-deployment format: header layout fixed, evolution append-only (unknown frame types tear the tunnel down loudly). The layout is pinned byte-for-byte in the tests.Robustness properties (each one tested)
highWaterMark: 0, so nothing pulls — or locks — before a real read). An untouched deferred Response in params releases the tunnel on payload disposal. A pair received in params cannot be kept past the call (same as upstream's plain-stream semantics — documented); the intended flow receives it as a result and keeps the delivering Response undisposed while the tunnel is in use.pair.init(Connection,Sec-WebSocket-Accept, …) are recomputed/dropped by workerd/kj on a genuine upgrade — verified during review with a raw-socket RFC 6455 probe against a poisonedinit, which produced a fully sanitized 101 with a correctly computedSec-WebSocket-Accept— so only non-reserved headers like the subprotocol reach the wire. Documented onmaterializeUpgrade.Two-round multi-agent adversarial review
At Jonas's request this PR went through two staged multi-agent reviews (eleven lenses total), with every bug claim independently re-reproduced by adversarial verifiers running live experiments, and Kenton's actual review comments on our PR #1 plus his capnweb#36 architecture comments as the design yardstick.
Round 1 (six lenses → 28 findings): dropped
TunneledWebSocket's parallel writable mode in favor of upstream'sWritableStreamStubHook; collapsed four stream wrappers into two; made the deferred branch a one-line call; revertedmap.tsto upstream-identical. Fixed reproduced defects: eager HWM-1 pull locking the tunnel readable at deserialization; double-materializeUpgradesilently killing the first socket's writes; truncation-at-EOS masquerading as a clean close; quadratic decoder buffering with no length cap. The round's own re-chunking tests then caught a deadlock in its own verified simplification (apull()that enqueues nothing is never re-invoked) — exactly the "hung tunnel" failure its test-gap lens predicted.Round 2 (five lenses, requirements-first → 18 findings, zero surviving bug claims): a requirement-by-requirement audit against the design doc, which drove the real fetch-lane-exit test (an actual HTTP upgrade served from a fetch handler, subprotocol echoed on the literal 101 — independently confirmed with a raw-wire RFC 6455 handshake probe);
initasserted across the native hop; the mixed-traffic and window-bounded-backpressure pins; the symmetric encode-side frame cap; and a clarity pass that corrected every remaining stale or imprecise sentence in the shipped prose (ownership model, wholesale-init-replacement, the framing banner, test rationales).Round 3 (architecture challenge): pressure-tested against the "why not just proxy natively?" alternative. Empirical outcome: workerd's distinguished-fetch chain (which does proxy WebSockets natively) cannot be extended into a stateless session-holding instance —
ctx.exportsloopback stubs refuse to serialize (DataCloneError: ... "LoopbackServiceStub"), so the only flag-free design was to make deferral the default on Workers, which this PR now does. The relay-side configuration disappeared entirely, andmaterializeUpgraderemains confined to platform code at the serving exits.Verification
npm run build,npm run test:types— clean. Full suite: 1258/1258 across node, workerd, Chromium, Firefox, WebKit, plus Bun 16/16.websocket-tunnel.test.ts): frame format pinned byte-for-byte; split/coalesced/large/empty frames; header carriage; mixed traffic; unread-pair backpressure; double-materialize fail-fast; malformed/oversized/truncated frames failing closed; abort + lazy-cancel teardown; ignored-params release; session-death → 1006; full session battery over a deferred-then-materialized socket.initand close propagation asserted through both boundaries; and a real HTTP upgrade served from a fetch handler viamaterializeUpgrade.Notes for the iterate/iterate integration
The design doc (
project-worker/docs/live-capnweb-ws-handler.md) has been amended in the iterate worktree to match the shipped contract: a "Status: shipped in the fork" section records the byte-framing correction, the{ readable, writable, init }pair shape and its four usage rules, the resolved lifetime story (keep the delivering Response referenced; no dup/claim needed), and two operational notes (a live materialized tunnel keeps its DO resident — the pump is in-memory listeners, not the hibernatable API; per-tunnel reclamation before session end = observing pass-throughs around the pair's streams, or retention tied to the Invoker/Pager connection). All four of the doc's open spikes are marked resolved with their fork-test evidence; the 25-consecutive-parallel-run criterion for the re-enabled e2e remains on the apps/os side.What this unblocks
The clean-room relay requests deferred delivery, returns the pair over the Invoker Workers-RPC leg, and
ItxDurableObject.#fetchCapabilityreplaces its501 ("needs a frame bridge")withmaterializeUpgrade(pair)→ 101 to the eyeball. Same shape flips the quarantinedlive-capability-websockete2e in apps/os.🤖 Generated with Claude Code