Repository navigation
A stream nested inside the result is neither demand-gated nor torn down on cancel #3125
Description
Summary
serializeResponseStream wraps the result itself. An async iterable nested one level inside the result — { items: rows() } rather than return rows() — is consumed by the codec directly, and so gets neither of the two things the wrapper provides:
- no demand gate — it produces ahead of the consumer without bound;
- no teardown —
reader.cancel()and the request'sAbortSignalnever reach it,iterator.return()is never called, and the generator'sfinallynever runs.
The second is a permanent resource leak on every abandoned request, and I'd argue it is the more serious half.
Tested against next @ 320f1f5c, built from source, Node 24.19.
The design this argues with
serializeResponseStream's docblock states the boundary deliberately:
…stops pending serialization and tears down a top-level async-iterable value — the producer's
iterator.return()runs, so generatorfinallyblocks execute instead of the server pumping a stream nobody is reading. Top-level only: that is the value-tier shape ("return a stream from the server function"); iterables nested inside user objects are consumed by the codec directly and stay untouched.
So "top-level only" is a stated scope, not an oversight. What I want to question is whether the nested shape is really outside the value tier: a read that returns a cursor alongside anything else — a total, a page marker, a continuation token — puts the iterable one level down, and that is where most real handlers put it. If nested streaming is meant to be unsupported, the current behaviour is a silent success rather than a refusal, which is the part worth changing either way.
Reproduction
import { AsyncLocalStorage } from "node:async_hooks";
globalThis[Symbol.for("solid.RequestContext")] = new AsyncLocalStorage();
import {
registerServerFunction, handleServerFunctionRequest
} from "@solidjs/web/server-functions/server";
let produced = 0, finallyRan = false;
registerServerFunction("nested", async () => {
const rows = (async function* () {
try { for (;;) { produced++; yield { n: produced }; await new Promise(r => setImmediate(r)); } }
finally { finallyRan = true; }
})();
return { items: rows }; // top-level `return rows` is gated; this is not
});
const response = await handleServerFunctionRequest(
new Request("http://localhost/_server/data/nested", {
method: "POST",
body: "[]",
headers: {
"Sec-Fetch-Site": "same-origin",
"X-Server-Function-Instance": "i",
"content-type": "application/json", // without these two: 400 before
"x-server-function-format": "8" // dispatch, bodiless, no error header
}
})
);
const reader = response.body.getReader();
await reader.read();
const atCancel = produced;
await reader.cancel();
for (let i = 0; i < 400; i++) await new Promise(r => setImmediate(r));
console.log({ atCancel, now: produced, finallyRan });1 — backpressure
Same generator, same consumer (one read, then idle), the only difference being return rows versus return { items: rows }:
| produced during the stall | memory | |
|---|---|---|
| top-level (gated) | 1 in 500 ms | +0.04 MiB heap |
{ items: rows } (nested) |
200 000 — the whole cap — in 300 ms | +171 MiB heap / +549 MiB rss |
An uncapped nested generator OOM-kills the worker in under 70 s.
That the measurement is real: forcing wantsMore = () => true makes the top-level case OOM identically. Nested behaves as a disabled gate, not as a weaker one.
2 — teardown
produced at cancel : 1
produced 400 turns later: 401 (+400 AFTER cancel)
generator finally ran : false
The consumer is gone and the response is torn down, but the generator keeps producing at full speed with iterator.return() never called — so a finally that would release a DB cursor, a file handle or a connection never runs. Aborting the request signal behaves the same (+470 produced in the 600 ms after abort()).
Why one issue and not two
The two symptoms have one cause — only the top-level source is wrapped — and one fix locus. Splitting them would duplicate all of the above and invite a fix that closes the backpressure gap while leaving producers running after cancel, which is the worse of the two. They should be decided together even if they land separately.
Options
- Extend the wrapper to nested sources.
guardFailuresalready walks the result graph and already wraps async iterables it finds, and its wrapper forwardsreturn: iterator.return && (() => iterator.return())— so the teardown channel exists. What is missing is that the demand gate is a single-source construct:serializeResponseStreamholds onereleaseDemand, and several concurrently-pumped sources need a waiter collection plus a policy for which source apull()should wake. - Fix it at the codec. seroval's
createStreamFromAsyncIterablestarts apush()loop per iterable eagerly, anddestroyStreamParsenever callsiterator.return(). Teardown fixed there would flow throughguardFailures' existing wrapper with no runtime change on this side, and it is the right home for "the codec owns pumping, so the codec owns stopping". Cross-repo, so slower. - Refuse nested iterables, per the docblock's stated scope — a legible encode error instead of an ungated silent success. Smallest change, and honest; costs the
{ items, total }shape entirely, which may be too much. - Gate nested sources without teardown — a partial that I'd argue against: it would cap memory while leaving the connection leak, and the leak is the harder failure to diagnose in production.
My preference is (2) for teardown plus (1) for the gate, but the demand-collection design in (1) is a real piece of work and the call on whether nested streaming is in scope at all is yours — (3) is a coherent answer too.
Happy to send a PR once you've decided the direction; the shape depends entirely on that choice.
Activity
[-]The stream demand gate covers the result itself, not an iterable nested inside it[/-][+]A stream nested inside the result is neither demand-gated nor torn down on cancel[/+]fix: demand-gate and tear down nested streams in server function resu…
ryansolid commentedon Aug 31, 2026
Fixed in 8d17083 (will ship in 2.0.0-rc.5) — and thank you for an exceptionally well-built report; the measurements and the "one cause, one fix locus" framing made the call easy.
We went with your option 1, implemented directly since the seam turned out to be smaller than the issue anticipated:
guardFailuresalready walks the graph and wraps every channel before the codec sees the value, so the demand gate and the teardown registry now thread through that walk instead of wrapping only the top-level value. Concretely:ReadableStreams alike — registers an idempotent closer with the response teardown.reader.cancel()and the request'sAbortSignalnow reach nested producers:iterator.return()runs and generatorfinallyblocks execute.releaseDemandbecame a waiter list woken wholesale per pull — waking one would strand the rest the first time a woken source stepped todone(no chunk enqueued, no further pull). Per-pull production stays bounded by the live source count.Your repro shapes are pinned as regression tests: nested backpressure (
producedstays near the queue size instead of 200k), nestedfinallyon both cancel and abort, and a two-source drain that proves the shared gate reopens for all parked sources.On your option 2: seroval's
destroyStreamParsenever callingiterator.return()is still worth fixing upstream as defense-in-depth, but the cancel signal originates in this transport, so nothing here depends on it.