Skip to content

Commit c2bb17b

Browse files
authored
Add support for sending ReadableStream and WritableStream over RPC, with automatic flow control. (#132)
* Refactor: RpcPayload.stubs -> hooks Instead of storing an array of RpcStub, we now store an array of the underlying StubHooks. This will make it easier to add support for new types like streams, which aren't RpcStubs, but they will wrap / be wrapped in StubHooks. * Add support for sending WritableStream over RPC. (Flow control is left for a future commit.) Written using Claude+Opencode: https://share.opencode.cloudflare.dev/share/gJU0pT8p (I cleaned some stuff up manually.) * Add support for sending a ReadableStream over RPC. As described in protocol.md, the basic idea here is that whenever we want to send a ReadableStream, we first send a message to the other side creating a "pipe". We pump our ReadableStream to the pipe's WritableSteam end, and we deliver the pipe's ReadableStream end to the remote peer. This way, we can begin pushing bytes immediately upon sending a ReadableStream, without waiting for the remote end to call back asking for the bytes (which would be an unnecessary round trip). Written using Claude+Opencode: https://share.opencode.cloudflare.dev/share/ctbbBnOu * Implement backpressure for streams. If more than 256kb of writes are in flight, we pause writes until past writes complete so that the number drops back below 256kb. Future commits will expand the window size. Written using Claude+Opencode: https://share.opencode.cloudflare.dev/share/D1bqkx2K This was not the best Claude session. I could probably have done it faster by hand. * Optimization: Elide "pull" and "resolve" for streaming calls. We add a new "stream" message to the protocol which skips these. See protocol.md for explanation. (Since this is only used for streams, which were just introduced in this PR, this is not a breaking change.) Written using Claude+Opencode: https://share.opencode.cloudflare.dev/share/OfY838e7 * Add changeset for streaming. * Adaptively resize streaming windows. With this change, we'll automatically update a stream's window size based on the observed bandwidth-delay product, in order to fully saturate the stream with minimal additional buffer bloat. The algorithm works by observing when each stream chunk is sent and acknowledged (via return from the RPC), allowing us to calculate: 1. Minimum round trip time. 2. Running average bandwidth over the last RTT. From that we calculate bandwidth-delay product and adjust the window to match. We actually set the window a bit bigger than the calculated BDP during startup (2x) and steady-state (1.25x) so that we can observe if the actual bandwidth is greater than expected, and thus update the window accordingly. I worked with Claude+Opencode to design the algorithm and implement, although I significantly refactored almost everything it wrote as the code was pretty meh: https://share.opencode.cloudflare.dev/share/rGV0SKLW * Document streaming support in readme. * Fix streaming error propagation. This bug was caught and fixed by AI (prompted by @dmmulroy): #132 (comment) This commit applies exactly the suggestion from the comment. * Fix occasional hang in stream tests. This was a pain to track down, when the code goes into a busy loop, vitest gets very confused. It fails to display console.log()s that happened before the hang and doesn't even correctly report which test is running -- often claiming it's still working on some previous test. Ugh! Anyway, it turns out that the stream implementations on some platforms require macro tasks to make progress, so pumping only microtasks doesn't get there. Weirdly, it seems to be non-deterministic. I was seeing workerd hang maybe 1/4 of the time, and webkit also hang sometimes but less often. It's possible the other platforms were also affected but even more rarely and I just never saw it happen. * Fix ReadableStreams in received payloads not being disposed. Fix and test provided by @dmmulroy here: #132 (comment) * Don't cancel ReadableStreams that are locked. I noticed that the `state.stream.cancel()` call in `ReadableStreamStubHook.dispose()` was often failing, throwing an exception because the stream was already locked. But we were ignoring the exceptions. This actually fixes the problem in two ways: 1. When we pipe from a ReadableStream (which locks it), we also take a reference on its StubHook, which we dispose then the pipe completes. This makes sense and solves the case seen in the tests. 2. I also just made it skip the cancel() call if the stream is locked. Throwing the exception and ignoring it is just a waste of cycles.
1 parent e3fa093 commit c2bb17b

13 files changed

Lines changed: 2004 additions & 51 deletions

File tree

.changeset/brave-donkeys-dress.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"capnweb": minor
3+
---
4+
5+
Added support for sending ReadableStream and WritableStream over RPC, with automatic flow control.

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,12 +201,12 @@ The following types can be passed over RPC (in arguments or return values), and
201201
* `Date`
202202
* `Uint8Array`
203203
* `Error` and its well-known subclasses
204+
* `ReadableStream` and `WritableStream`, with automatic flow control.
204205

205206
The following types are not supported as of this writing, but may be added in the future:
206207
* `Map` and `Set`
207208
* `ArrayBuffer` and typed arrays other than `Uint8Array`
208209
* `RegExp`
209-
* `ReadableStream` and `WritableStream`, with automatic flow control.
210210
* `Headers`, `Request`, and `Response`
211211

212212
The following are intentionally NOT supported:
@@ -305,6 +305,10 @@ The trick here is record-replay: On the calling side, Cap'n Web will invoke your
305305

306306
Since all of the not-yet-determined values seen by the callback are represented as `RpcPromise`s, the callback's behavior is deterministic. Any actual computation (arithmetic, branching, etc.) can't possibly use these promises as (meaningful) inputs, so would logically produce the same results for every invocation of the callback. Any such computation will actually end up being performed on the sending side, just once, with the results being imbued into the recording.
307307

308+
### Streaming with flow control
309+
310+
You may pass a `ReadableStream` or `WritableStream` over RPC. When doing so, the RPC system automatically creates an equivalent stream at the other end and pumps bytes (or arbitrarily-typed chunks) across. This is done in such a way as to ensure the available bandwidth is fully utilized while minimizing buffer bloat, by observing the bandwidth-delay product and applying backpressure when too much is written. Multiple streams can be sent across the same connection -- they will be multiplexed appropriately, similar to HTTP/2 stream multiplexing.
311+
308312
### Cloudflare Workers RPC interoperability
309313

310314
Cap'n Web works on any JavaScript platform. But, on Cloudflare Workers specifically, it's designed to play nicely with the [the built-in RPC system](https://blog.cloudflare.com/javascript-native-rpc/). The two have basically the same semantics, the only difference being that Workers RPC is a built-in API provided by the Workers Runtime, whereas Cap'n Web is implemented in pure JavaScript.

__tests__/flow-control.test.ts

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
// Copyright (c) 2026 Cloudflare, Inc.
2+
// Licensed under the MIT license found in the LICENSE.txt file or at:
3+
// https://opensource.org/license/mit
4+
5+
import { expect, it, describe } from "vitest"
6+
import { FlowController, type SendToken } from "../src/streams.js"
7+
8+
// To emulate random-ish chunk sizes while keeping the test reproducible, we cycle through this
9+
// list of sizes.
10+
const CHUNK_SIZES = [32 * 1024, 4 * 1024, 16000, 12345, 16, 9999, 4321, 8];
11+
12+
// Helper: simulates a stream with a fake clock and set RTT and bandwidth. Sends chunks, advances
13+
// time, and acks in order.
14+
class StreamSimulator {
15+
// Default RTT of 100ms.
16+
rtt = 100;
17+
18+
// Default bandwidth of 10kB/ms = 10MB/s = 1MB/RTT. This is larger than the initial window size
19+
// of 256k, so the window should grow if staurated. Individual test cases may set something
20+
// different, or modify the properties during the test.
21+
bandwidth = 10 * 1024;
22+
23+
// Convenience getter to calculate BDP.
24+
get bdp() { return this.rtt * this.bandwidth; }
25+
26+
// Current simulated time.
27+
t = 0;
28+
29+
// Are we currently blocked, according to the flow controller's return values from onSend/onAck?
30+
blocked = false;
31+
32+
// The outgoing link is sending bytes (for a previous send()) until this time. Subsequent sends
33+
// cannot start until after this time. This is how we simulate bandwidth constraints.
34+
linkOccupiedUntil = 0;
35+
36+
// The flow controller itself.
37+
fc = new FlowController(() => this.t);
38+
39+
// In-flight writes, in send order. Each has its token and scheduled ack time.
40+
inFlight: { token: SendToken, ackTime: number }[] = [];
41+
42+
// Send a chunk at the current time.
43+
send(size: number) {
44+
// The new message begins sending now, unless some other message is still sending, in which
45+
// case it sends after that message. It occupies the link until all the bytes are sent, based
46+
// on the bandwidth.
47+
this.linkOccupiedUntil = Math.max(this.linkOccupiedUntil, this.t) + size / this.bandwidth;
48+
49+
let { token, shouldBlock } = this.fc.onSend(size);
50+
51+
// ackTime = time when the chunk finishes writing out to the pipe, plus 1 rtt
52+
this.inFlight.push({ token, ackTime: this.linkOccupiedUntil + this.rtt });
53+
this.blocked = shouldBlock;
54+
}
55+
56+
// Fill the window by sending chunks of the given size. Returns the number of chunks sent
57+
// (the last one caused blocking).
58+
fillWindow(chunkSize: number): number {
59+
let count = 0;
60+
while (!this.blocked) {
61+
count++;
62+
this.send(chunkSize);
63+
}
64+
return count;
65+
}
66+
67+
// Advance to the next message's ack time, and deliver the ack to the flow controller.
68+
waitForNextAck() {
69+
let entry = this.inFlight.shift();
70+
if (entry) {
71+
this.t = entry.ackTime;
72+
if (this.fc.onAck(entry.token)) {
73+
this.blocked = false;
74+
}
75+
}
76+
}
77+
78+
// Simulate the application writing to the stream as fast as it can for the given duration.
79+
saturateFor(duration: number): void {
80+
let endTime = this.t + duration;
81+
let i = 0;
82+
83+
// Send chunks until blocked, then wait for acks until unblocked. Repeat until the end time
84+
// is reached.
85+
while (this.t < endTime) {
86+
if (this.blocked) {
87+
this.waitForNextAck();
88+
} else {
89+
this.send(CHUNK_SIZES[i++ % CHUNK_SIZES.length]);
90+
}
91+
}
92+
93+
// Drain remaining acks.
94+
while (this.inFlight.length > 0) {
95+
this.waitForNextAck();
96+
}
97+
}
98+
}
99+
100+
describe("FlowController", () => {
101+
it("blocks when window is full", () => {
102+
let sim = new StreamSimulator();
103+
let initialWindow = sim.fc.window; // 256KB
104+
105+
// Send chunks until blocked.
106+
let count = sim.fillWindow(64 * 1024);
107+
expect(count).toBe(4); // 4 * 64KB = 256KB = window
108+
expect(sim.fc.bytesInFlight).toBe(initialWindow);
109+
});
110+
111+
it("unblocks after ack frees space", () => {
112+
let sim = new StreamSimulator();
113+
114+
// Send 4 chunks at staggered times so acks arrive at different times.
115+
sim.t = 0;
116+
sim.send(64 * 1024);
117+
sim.t = 1;
118+
sim.send(64 * 1024);
119+
sim.t = 2;
120+
sim.send(64 * 1024);
121+
sim.t = 3;
122+
expect(sim.blocked).toBe(false);
123+
sim.send(64 * 1024);
124+
expect(sim.blocked).toBe(true);
125+
126+
// Ack only the first one.
127+
sim.waitForNextAck();
128+
expect(sim.fc.bytesInFlight).toBe(192 * 1024); // 3 chunks still in flight
129+
});
130+
131+
it("window grows during startup", () => {
132+
let sim = new StreamSimulator();
133+
134+
let initialWindow = sim.fc.window; // 256KB
135+
// Simulate for a few RTTs — window should grow toward the BDP.
136+
sim.saturateFor(sim.rtt * 5);
137+
138+
// During startup, window should quickly grow past the 1MB BDP.
139+
expect(sim.fc.window).toBeGreaterThan(initialWindow);
140+
expect(sim.fc.window).toBeGreaterThan(sim.bdp);
141+
});
142+
143+
it("exits startup after window growth plateaus", () => {
144+
let sim = new StreamSimulator();
145+
146+
expect(sim.fc.inStartupPhase).toBe(true);
147+
148+
// Simulate for long enough that startup exits.
149+
sim.saturateFor(sim.rtt * 50);
150+
151+
expect(sim.fc.inStartupPhase).toBe(false);
152+
});
153+
154+
it("steady-state window converges near BDP", () => {
155+
let sim = new StreamSimulator();
156+
157+
// Run through startup.
158+
sim.saturateFor(sim.rtt * 50);
159+
expect(sim.fc.inStartupPhase).toBe(false);
160+
161+
// Window should have settled around 25% above BDP.
162+
expect(sim.fc.window).toBeGreaterThan(sim.bdp * 1.2);
163+
expect(sim.fc.window).toBeLessThan(sim.bdp * 1.3);
164+
165+
// Keep running.
166+
sim.saturateFor(sim.rtt * 20);
167+
168+
// Window should be pretty stable.
169+
expect(sim.fc.window).toBeGreaterThan(sim.bdp * 1.2);
170+
expect(sim.fc.window).toBeLessThan(sim.bdp * 1.3);
171+
});
172+
173+
it("window does not shrink when app-limited", () => {
174+
let sim = new StreamSimulator();
175+
176+
// Run through startup to establish a window.
177+
sim.saturateFor(sim.rtt * 50);
178+
let windowAfterStartup = sim.fc.window;
179+
180+
// Now repeatedly send small chunks that don't fill the window, waiting for each one.
181+
for (let i = 0; i < 50; i++) {
182+
sim.send(1024);
183+
sim.waitForNextAck();
184+
}
185+
186+
// Window should not have shrunk.
187+
expect(sim.fc.window).toStrictEqual(windowAfterStartup);
188+
});
189+
190+
it("window shrinks when pipe bandwidth decreases", () => {
191+
let sim = new StreamSimulator();
192+
193+
// Run through startup at full speed — window should grow well above INITIAL_WINDOW.
194+
sim.saturateFor(sim.rtt * 50);
195+
expect(sim.fc.inStartupPhase).toBe(false);
196+
expect(sim.fc.window).toBeGreaterThan(sim.bdp);
197+
198+
// Simulate pipe bandwidth drop to 1/4 of original. The sender still tries to fill the
199+
// window, but acks arrive more slowly (bottleneck delivers chunks at 1/4 the rate).
200+
sim.bandwidth /= 4;
201+
sim.saturateFor(sim.rtt * 200);
202+
203+
// The window should have decayed, and is now closer to the new, lower bdp.
204+
expect(sim.fc.window).toBeLessThan(sim.bdp * 2);
205+
});
206+
207+
it("onError restores bytesInFlight without changing window", () => {
208+
let sim = new StreamSimulator();
209+
210+
let { token } = sim.fc.onSend(64 * 1024);
211+
expect(sim.fc.bytesInFlight).toBe(64 * 1024);
212+
213+
let windowBefore = sim.fc.window;
214+
sim.fc.onError(token);
215+
expect(sim.fc.bytesInFlight).toBe(0);
216+
expect(sim.fc.window).toBe(windowBefore);
217+
});
218+
219+
it("growth collar limits window increase to growthFactor per RTT", () => {
220+
let sim = new StreamSimulator();
221+
let chunkSize = 64 * 1024;
222+
223+
// Do a first round to get past the first-ack bootstrap.
224+
sim.fillWindow(chunkSize);
225+
sim.waitForNextAck();
226+
227+
// Record the initial window.
228+
let windowBefore = sim.fc.window;
229+
230+
// Do another round.
231+
sim.fillWindow(chunkSize);
232+
// Ack all of them.
233+
while (sim.inFlight.length > 0) {
234+
sim.waitForNextAck();
235+
}
236+
237+
// In startup, window can at most double per RTT (STARTUP_GROWTH_FACTOR = 2).
238+
expect(sim.fc.window).toBeLessThanOrEqual(windowBefore * 2 + 1);
239+
});
240+
241+
it("minimum window is enforced", () => {
242+
let sim = new StreamSimulator();
243+
244+
// Set RTT and bandwidth excessively low, to create a low BDP.
245+
sim.rtt = 1;
246+
sim.bandwidth = 1;
247+
248+
// We're going to have to run for quite a while since bandwidth is so low.
249+
sim.saturateFor(10000000);
250+
251+
// Window should have been clamped at MIN_WINDOW.
252+
expect(sim.fc.window).toStrictEqual(64 * 1024);
253+
});
254+
});

0 commit comments

Comments
 (0)