Skip to content

Commit f06f7b1

Browse files
authored
fix(web): pull a streamed result behind a demand gate (#3124)
* fix(web): pull a streamed result behind a demand gate The stream was built with no `pull` and no queuing strategy, and every codec node is enqueued as soon as it is parsed, so the producer ran as fast as it could resolve whether or not anyone read. One slow consumer buffered the entire result in server memory — unbounded, and invisible to application code. The gate sits in the iterator wrapper the runtime already installs, which its own comment calls "the only seam where a dropped consumer can stop the producer": the same seam lets a SLOW consumer slow it. A read drives `pull`, `pull` releases one source pull. Teardown releases a parked pull too, or an aborted stream would hang on it. Measured with an async generator and no reads for 200 event-loop turns: the producer advanced by 1, where it previously tracked the turn count (and reached ~17,000 items, +19 MiB, in the wall-clock shape the issue reports). Reading 20 chunks advances it by 20, and cancelling stops it within the pull in flight. The #3112 marker for this gap comes off, per the convention the other two followed when they closed. * fix(web): release a parked pull when the stream ends, and say what the gate covers Review of the first shape found a regression I introduced. `onDone` and `onError` set `closed` directly rather than through `teardown()`, so a pull parked on the demand gate was stranded: `desiredSize` is 0 after close and null after error, both failing the `> 0` check, so the gate never reopened and the source's `finally` never ran — leaking generator, cursor and file-handle cleanup once per failed request. Every path that ends the stream now runs `finishSource()`, which closes the source and releases the parked pull. Also from review: - The gate rests on the default high-water mark of 1, and `releaseDemand` holds a single resolver, safe only because the pump is sequential. Both were load-bearing and unstated; both are now in the comment. - `!streamController` in `wantsMore` was unreachable — `start()` assigns the controller before the iterator is ever opened. - The spec header still called #3118 open, and the changeset claimed the producer "advanced by one step" where the test asserts it stays near the queue size. - The liveness assertion was `> 1` under a comment claiming it tracked the reads; a gate that resumed twice in twenty reads would have passed. Scope is now stated rather than implied: only the result itself is gated. A nested `{ items: rows() }` is pumped by the codec directly and still runs ahead — measured at 200 items over 200 idle turns against 1 for the top-level shape. * test(web): make the teardown guard actually discriminate The first version of this test passed with finishSource() removed from both codec-end paths — it never reached the defect. Two reasons, and the second is the one worth writing down: - the source was NESTED (`{ rows: gen(), … }`), and a nested iterable never enters the wrapper, so nothing parked; - a top-level source needs no sibling branch to carry the failure. The deferred failure rides INSIDE a yielded chunk: a pending promise in the first value resolving to an object whose getter throws. The pump enqueues the chunk, asks for the second, parks because the consumer stopped reading, and only then does the promise settle and onError fire against a parked pull. `produced` is asserted alongside the cleanup so the nested-shape mistake cannot quietly recur: if the source never parks, the test says so instead of passing for the wrong reason. Verified both ways — with finishSource() in the codec-end paths the cleanup runs, without it the assertion fails. * fix(web): check finished before the gate, and make a test that parks Third review round, both findings real. The gate was checked before `finished`, so teardown landing while a pull was in flight stranded the codec's pump: the release it fires finds nothing parked, the in-flight pull then resolves, the pump asks for the next item, `wantsMore()` is false — 0 after close, null after error — and it parks on a resolver nobody will ever call. `push()` never returns. Impossible before this PR, where `finished` was checked first. One token, and it mirrors the defect the previous round fixed: that one stranded the source, this one stranded the pump. Worth stating plainly: this half is not guarded by a test. The failure is a leaked pending promise inside seroval's pump with no outward symptom — the source still runs its `finally`, the response still ends, nothing observable differs. It was found with instrumented park/release counters, and that is the evidence it rests on. The tests also did not park at all. Both read in a tight loop, so a read request is always pending, `desiredSize` never drops and the producer never reaches the gate — deleting `pull()` outright left all eight green, including the one whose comment claimed it would catch a deadlock. The new test pauses between reads, which is what puts the producer on the gate, and reads to completion with a deadline so a gate that never reopens fails fast instead of hanging. It counts arrivals by its own key rather than by the codec's node shapes. * test(web): say what the resume test actually pins Its comment claimed it was the case that would catch a deadlock. It is not: it reads in a tight loop, so a read request is always pending, `desiredSize` never drops and nothing parks — the same blindness review found in the original pair. What it pins is the cancel half. The pausing test is the liveness one.
1 parent 40af4d6 commit f06f7b1

3 files changed

Lines changed: 189 additions & 13 deletions

File tree

.changeset/stream-demand-gate.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@solidjs/web": patch
3+
---
4+
5+
Pull a streamed server-function result behind a demand gate (#3118). The
6+
response stream was built with no `pull` and no queuing strategy, and
7+
every codec node is enqueued the moment it is parsed, so the producer ran
8+
as fast as it could resolve whether or not anyone was reading: one slow
9+
consumer buffered the whole result in server memory, unbounded and
10+
invisible to application code. The consumer's reads now drive `pull`,
11+
which releases one source pull at a time, so an unread stream stays near
12+
the queue size instead of running away.
13+
14+
Scope: the gate sits on the source the runtime wraps, which is the
15+
result itself. An async iterable nested inside the result — `{ items:
16+
rows() }` — is pumped by the codec directly and is not yet gated. Ending
17+
the stream releases a parked pull, so an aborted, cancelled or failed
18+
stream still closes its source; a consumer that abandons a stream without
19+
cancelling it now leaves the producer parked rather than running it to
20+
completion.

packages/web/server-functions/src/server.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1681,14 +1681,45 @@ export function serializeResponseStream(value, codecOptions, signal) {
16811681
value = guardFailures(value);
16821682
let closeIterator = null;
16831683
let closed = false;
1684+
// Demand gate. seroval's pump pulls the source as fast as it resolves and
1685+
// enqueues every node the moment it is parsed, so without this a slow
1686+
// consumer never slows the producer: the whole result accumulates in the
1687+
// stream's queue, in server memory, unbounded. The consumer's reads drive
1688+
// `pull`, which releases one source pull at a time.
1689+
//
1690+
// `desiredSize > 0` means "fewer than one chunk queued": the stream takes
1691+
// no queuing strategy, so it runs on the default high-water mark of 1.
1692+
// That default is what sets the depth, and raising it is how you would
1693+
// trade memory for fewer round trips.
1694+
//
1695+
// One resolver is enough because the pump is sequential — the same reason
1696+
// the wrapper below only exposes `next()`. Two concurrent pulls would
1697+
// overwrite it and strand the first.
1698+
let streamController = null;
1699+
let releaseDemand = null;
1700+
const wantsMore = () => streamController.desiredSize > 0;
1701+
const awaitDemand = () => new Promise(resolve => (releaseDemand = resolve));
1702+
const supplyDemand = () => {
1703+
const resolve = releaseDemand;
1704+
releaseDemand = null;
1705+
if (resolve) resolve();
1706+
};
16841707
let cancelSerialize = null;
16851708
let onAbort = null;
1709+
// Ends the source and releases a pull parked on the demand gate. Every
1710+
// path that stops the stream has to run this: a parked pull holds the
1711+
// source open and nothing else will resolve it — `desiredSize` is 0 after
1712+
// close and null after error, so the gate never reopens on its own.
1713+
const finishSource = () => {
1714+
if (closeIterator) closeIterator();
1715+
supplyDemand();
1716+
};
16861717
const teardown = () => {
16871718
if (closed) return;
16881719
closed = true;
16891720
if (onAbort) signal.removeEventListener("abort", onAbort);
16901721
if (cancelSerialize) cancelSerialize();
1691-
if (closeIterator) closeIterator();
1722+
finishSource();
16921723
};
16931724
if (
16941725
value !== null &&
@@ -1716,8 +1747,17 @@ export function serializeResponseStream(value, codecOptions, signal) {
17161747
// torn down before the codec opened the value (abort raced the
17171748
// codec load): close the source immediately, never pull
17181749
if (closed) closeIterator();
1750+
const step = () => (finished ? { done: true, value: undefined } : it.next());
17191751
return {
1720-
next: () => (finished ? Promise.resolve({ done: true, value: undefined }) : it.next())
1752+
// Pulls straight through while the queue has room, and parks
1753+
// until a read makes room when it does not. `finished` is checked
1754+
// FIRST: teardown can land while a pull is in flight, and the
1755+
// release it fires then finds nothing parked — so a gate checked
1756+
// first would park the next pull on a resolver nobody will ever
1757+
// call, stranding the codec's pump. `finished` is re-read after
1758+
// the wait for the same reason from the other direction.
1759+
next: () =>
1760+
finished || wantsMore() ? Promise.resolve(step()) : awaitDemand().then(step)
17211761
};
17221762
}
17231763
};
@@ -1727,6 +1767,7 @@ export function serializeResponseStream(value, codecOptions, signal) {
17271767
// the top of shared.js), and a ReadableStream start may return a
17281768
// promise — reads wait for it, so the stream's contract is unchanged
17291769
async start(controller) {
1770+
streamController = controller;
17301771
if (signal) {
17311772
if (signal.aborted) {
17321773
teardown();
@@ -1766,12 +1807,14 @@ export function serializeResponseStream(value, codecOptions, signal) {
17661807
if (closed) return;
17671808
closed = true;
17681809
if (onAbort) signal.removeEventListener("abort", onAbort);
1810+
finishSource();
17691811
controller.close();
17701812
},
17711813
onError(error) {
17721814
if (closed) return;
17731815
closed = true;
17741816
if (onAbort) signal.removeEventListener("abort", onAbort);
1817+
finishSource();
17751818
// The head is committed by the time an encode failure arrives, so
17761819
// the status is spent and no error tag can be added — and merely
17771820
// erroring the stream truncates the body over a socket, which the
@@ -1798,6 +1841,9 @@ export function serializeResponseStream(value, codecOptions, signal) {
17981841
}
17991842
});
18001843
},
1844+
pull() {
1845+
supplyDemand();
1846+
},
18011847
cancel() {
18021848
teardown();
18031849
}

packages/web/test/server/server-functions-open-gaps.spec.tsx

Lines changed: 121 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* `test/lifecycle-matrix/MATRIX.md`: the marker is the point — the suite
55
* stays green while the gap is open and turns red the day it closes, at
66
* which point the marker comes off and the test becomes an ordinary guard.
7-
* Each carries the issue that tracks it: #3118 (open); #3117 (closed by
7+
* Each carries the issue that tracks it: #3118 (closed); #3117 (closed by
88
* the error trailer frame) and #3119 (closed by #3115's request bounds)
99
* remain as ordinary guards, with the trailer's full matrix alongside.
1010
*
@@ -161,17 +161,18 @@ describe("a result the codec cannot encode", () => {
161161
});
162162

163163
describe("a streamed result nobody is reading", () => {
164-
// GAP (#3118): the producer runs unboundedly ahead. The response stream is built
165-
// with no `pull` and no queuing strategy, and every codec node is
166-
// enqueued the moment it is parsed, so the producer runs as fast as it
167-
// can resolve whether or not anyone reads. On a large or infinite stream
168-
// one slow client buffers the whole result in server memory, invisibly
169-
// to application code.
164+
// Closed (#3118): the source is pulled behind a demand gate. The stream
165+
// was built with no `pull` and no queuing strategy, and every codec node
166+
// is enqueued the moment it is parsed, so the producer ran as fast as it
167+
// could resolve whether or not anyone read — one slow client buffered
168+
// the whole result in server memory, invisibly to application code. The
169+
// consumer's reads now drive `pull`, which releases one source pull at a
170+
// time. Ordinary guard now.
170171
//
171-
// Counted in event-loop turns rather than wall-clock: a bounded producer
172-
// stays near the queue size whatever the machine, an unbounded one
173-
// tracks the turn count.
174-
test.fails("does not let the producer run ahead of the consumer", async () => {
172+
// Counted in event-loop turns rather than wall-clock, so the assertion
173+
// means the same thing on any machine: a gated producer stays near the
174+
// queue size, an ungated one tracks the turn count.
175+
test("does not let the producer run ahead of the consumer", async () => {
175176
let produced = 0;
176177
registerServerFunction("gap-backpressure", async function* () {
177178
while (produced < 100_000) {
@@ -191,6 +192,115 @@ describe("a streamed result nobody is reading", () => {
191192

192193
expect(produced).toBeLessThan(50);
193194
});
195+
196+
// The gate has to REOPEN, not merely close, and nothing above proves it:
197+
// a consumer that reads in a tight loop always has a read request
198+
// pending, so `desiredSize` never drops and the producer never parks.
199+
// Pausing between reads is what puts it on the gate. Deleting `pull()`
200+
// outright leaves every other test in this file green and deadlocks this
201+
// one, which is the whole point of it.
202+
test("keeps delivering after the consumer pauses long enough to park it", async () => {
203+
registerServerFunction("gap-backpressure-park", async function* () {
204+
for (let n = 0; n < 12; n++) yield { n };
205+
});
206+
207+
const response = await handleServerFunctionRequest(scriptedPost("gap-backpressure-park"));
208+
const reader = response.body!.getReader();
209+
const decoder = new TextDecoder();
210+
let body = "";
211+
212+
for (;;) {
213+
// long enough for the queue to drain and the next pull to park
214+
for (let turn = 0; turn < 5; turn++) {
215+
await new Promise(resolve => setTimeout(resolve, 0));
216+
}
217+
const next = await Promise.race([
218+
reader.read(),
219+
new Promise<never>((_, reject) =>
220+
setTimeout(() => reject(new Error("the gate parked and never reopened")), 2000)
221+
)
222+
]);
223+
if (next.done) break;
224+
body += decoder.decode(next.value as Uint8Array);
225+
}
226+
227+
// every item arrived, so the gate released each park in turn. Counted
228+
// by this test's own key rather than by the codec's node shapes, which
229+
// are not what it is about.
230+
expect(body.split('["n"]').length - 1).toBe(12);
231+
});
232+
233+
// A pull parked on the gate holds the source open, and `desiredSize` is 0
234+
// after close and null after error — so the gate never reopens on its own
235+
// and every path that ends the stream has to release it. Without that the
236+
// source's cleanup silently never runs, once per failed request.
237+
test("a codec failure landing while a pull is parked still closes the source", async () => {
238+
let cleanedUp = false;
239+
let produced = 0;
240+
// The failure has to ride INSIDE a yielded chunk of the top-level
241+
// source. Putting it on a sibling branch makes the generator nested,
242+
// and a nested iterable never reaches the gate at all — which is why
243+
// `produced` is asserted too: it proves the source really parked, so
244+
// this cannot quietly decay into testing nothing.
245+
registerServerFunction("gap-backpressure-teardown", async function* () {
246+
try {
247+
produced++;
248+
yield {
249+
late: Promise.resolve().then(() => ({
250+
get boom(): never {
251+
throw new Error("unencodable, discovered after the gate parked");
252+
}
253+
}))
254+
};
255+
for (let n = 0; n < 20; n++) {
256+
produced++;
257+
yield { n };
258+
}
259+
} finally {
260+
cleanedUp = true;
261+
}
262+
});
263+
264+
const response = await handleServerFunctionRequest(scriptedPost("gap-backpressure-teardown"));
265+
const reader = response.body!.getReader();
266+
await reader.read();
267+
for (let turn = 0; turn < 20; turn++) {
268+
await new Promise(resolve => setTimeout(resolve, 0));
269+
}
270+
271+
expect(produced).toBe(1);
272+
expect(cleanedUp).toBe(true);
273+
});
274+
275+
test("resumes as the consumer reads, and stops when it leaves", async () => {
276+
let produced = 0;
277+
registerServerFunction("gap-backpressure-resume", async function* () {
278+
while (produced < 100_000) {
279+
produced++;
280+
yield { n: produced };
281+
await new Promise(resolve => setImmediate(resolve));
282+
}
283+
});
284+
285+
const response = await handleServerFunctionRequest(scriptedPost("gap-backpressure-resume"));
286+
const reader = response.body!.getReader();
287+
for (let read = 0; read < 20; read++) await reader.read();
288+
const whileReading = produced;
289+
await reader.cancel();
290+
const atCancel = produced;
291+
for (let turn = 0; turn < 50; turn++) {
292+
await new Promise(resolve => setImmediate(resolve));
293+
}
294+
295+
// This pins the CANCEL half — that a departed consumer stops the
296+
// producer. It does not catch a gate that never reopens: reading in a
297+
// tight loop keeps a read request pending, so `desiredSize` never
298+
// drops and nothing ever parks. The pausing test above is the one that
299+
// catches that, and it took two attempts to learn the difference.
300+
expect(whileReading).toBeGreaterThanOrEqual(10);
301+
// ...and a departed consumer stops it, give or take the pull in flight
302+
expect(produced).toBeLessThanOrEqual(atCancel + 1);
303+
});
194304
});
195305

196306
describe("the decode depth cap", () => {

0 commit comments

Comments
 (0)