Skip to content

A stream nested inside the result is neither demand-gated nor torn down on cancel #3125

Closed

Description

@frenzzy

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:

  1. no demand gate — it produces ahead of the consumer without bound;
  2. no teardown — reader.cancel() and the request's AbortSignal never reach it, iterator.return() is never called, and the generator's finally never 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 generator finally blocks 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

  1. Extend the wrapper to nested sources. guardFailures already walks the result graph and already wraps async iterables it finds, and its wrapper forwards return: iterator.return && (() => iterator.return()) — so the teardown channel exists. What is missing is that the demand gate is a single-source construct: serializeResponseStream holds one releaseDemand, and several concurrently-pumped sources need a waiter collection plus a policy for which source a pull() should wake.
  2. Fix it at the codec. seroval's createStreamFromAsyncIterable starts a push() loop per iterable eagerly, and destroyStreamParse never calls iterator.return(). Teardown fixed there would flow through guardFailures' 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.
  3. 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.
  4. 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

  1. changed the title [-]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[/+] on Aug 31, 2026
  2. ryansolid commented on Aug 31, 2026

    @ryansolid
    Member

    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: guardFailures already 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:

    • Teardown: every source the codec opens — nested async iterables and nested ReadableStreams alike — registers an idempotent closer with the response teardown. reader.cancel() and the request's AbortSignal now reach nested producers: iterator.return() runs and generator finally blocks execute.
    • Demand gate: nested sources park behind the same response-queue demand as the top level. The single releaseDemand became a waiter list woken wholesale per pull — waking one would strand the rest the first time a woken source stepped to done (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 (produced stays near the queue size instead of 200k), nested finally on 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 destroyStreamParse never calling iterator.return() is still worth fixing upstream as defense-in-depth, but the cancel signal originates in this transport, so nothing here depends on it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions