Skip to content

The client transport buffers every response twice and never ends the connection it opened #3244

Description

@frenzzy
## Summary

Every server function call buffers its response body twice and, when it finishes, never ends the connection it opened. The client stub decodes `response.clone()`, and `extractBody` clones *again* before reading, so of the three branches the payload flows through only one is ever read — the other two queue the whole body for nobody. Separately, the `AbortController` the transport mints "so a streaming result can be ENDED, not just abandoned" is wired into exactly one place, the async-iterator wrapper's `return()`. A call that ends *normally* — a `for await` drained to its last item, or any result that is not an async iterable at all — never fires it, never cancels the reader, and holds the connection for as long as the peer holds it. Against a peer that does not close its body after the payload, eight successful calls leave eight connections open.

This is one issue, not two adjacent ones: cancelling one branch of a tee does not cancel the fetch (measured below), so the transport can only end a call once it stops decoding a clone. The layering change is a precondition of the teardown, and the clone hunks redden both specs.

If you are searching for one of these symptoms, they are all this issue: *response body cloned twice per call*; *unread `Response.clone()` queues the whole payload*; *`for await` over a server-function stream never aborts the fetch*; *successful server-function calls keep their connection open*; *browser per-origin connection pool wedged by calls that all reported success*.

## Reproduction

Against a build of `next` at `f0f7531b`. The stub's `fetch` is routed straight into the handler, and the transport wraps the body so it behaves like a connection rather than a buffer: `init.signal` closes it the way a browser's fetch does, and `hold` models a peer that delivers the whole payload and then keeps the socket open (a proxy, a CDN, a hung origin).

```sh
node --expose-gc repro.mjs <checkout>/packages/web
// repro.mjs
import { AsyncLocalStorage } from "node:async_hooks";
const W = process.argv[2];
const { handleServerFunctionRequest, registerServerFunction } = await import(
  `${W}/server-functions/dist/server.js`
);
const { createServerReference } = await import(`${W}/server-functions/dist/client.js`);
globalThis[Symbol.for("solid.RequestContext")] = new AsyncLocalStorage();
const MiB = 1048576;

function transport({ hold = false } = {}) {
  const c = { opened: 0, closed: 0, get open() { return this.opened - this.closed; } };
  globalThis.fetch = async (input, init) => {
    const request = new Request(new URL(input.toString(), "http://localhost"), init);
    request.headers.set("Sec-Fetch-Site", "same-origin");
    const upstream = await handleServerFunctionRequest(request);
    if (!upstream.body) return upstream;
    c.opened++;
    const reader = upstream.body.getReader();
    let settled = false;
    const close = () => { if (settled) return; settled = true; c.closed++; reader.cancel().catch(() => {}); };
    init?.signal?.addEventListener("abort", close);          // fetch abort closes the socket
    return new Response(new ReadableStream({
      async pull(controller) {
        const { done, value } = await reader.read();
        if (done) {
          if (hold) return new Promise(() => {});             // the peer holds it open
          close(); controller.close(); return;
        }
        controller.enqueue(value);
      },
      cancel: close
    }), { status: upstream.status, headers: upstream.headers });
  };
  return c;
}
const settle = () => new Promise(r => setTimeout(r, 50));

registerServerFunction("value", async () => new Date(0));   // framed body, nothing left pending
registerServerFunction("stream", async function* () { yield 1; yield 2; yield 3; });

// 1. how many times is one response body teed, and who reads the branches?
const clone = Response.prototype.clone;
for (const id of ["value", "stream"]) {
  transport();
  const teed = [];
  Response.prototype.clone = function () { const t = clone.call(this); if (this.body) teed.push(t); return t; };
  const result = await createServerReference(id)();
  if (result?.[Symbol.asyncIterator]) for await (const _ of result) {}
  Response.prototype.clone = clone;
  console.log(`tees   ${id.padEnd(6)} teed=${teed.length}  never-read=${teed.filter(t => !t.bodyUsed).length}`);
}

// 2. peak memory for one streamed result (sampled per frame)
registerServerFunction("big", async function* () { for (let i = 0; i < 128; i++) yield "x".repeat(MiB); });
transport();
for (let i = 0; i < 4; i++) global.gc();
const base = process.memoryUsage(); let peak = 0, bytes = 0;
for await (const frame of await createServerReference("big")()) {
  bytes += frame.length;
  const m = process.memoryUsage();
  peak = Math.max(peak, m.heapUsed + m.external - base.heapUsed - base.external);
}
console.log(`memory 128 MiB payload: peak heap+external=${(peak / MiB).toFixed(1)} MiB (${(peak / MiB / 128).toFixed(2)}x)  delivered=${bytes / MiB} MiB`);

// 3. does a finished call give its connection back?
const rows = [
  ["stream, abandoned with break  (CONTROL)", true,  async () => { for await (const _ of await createServerReference("stream")()) break; }],
  ["value, peer ends the body     (CONTROL)", false, async () => { await createServerReference("value")(); }],
  ["stream, drained to the last item       ", true,  async () => { for await (const _ of await createServerReference("stream")()) {} }],
  ["value (a Date: framed, nothing pending)", true,  async () => { await createServerReference("value")(); }],
  ["8 resolved calls in a row              ", true,  async () => { for (let i = 0; i < 8; i++) await createServerReference("value")(); }]
];
for (const [label, hold, run] of rows) {
  const c = transport({ hold });
  await run();
  await settle();
  console.log(`conns  ${label}  opened=${c.opened} still-open=${c.open}`);
}
process.exit(0);

Measured on next @ f0f7531b:

tees   value  teed=2  never-read=1
tees   stream teed=2  never-read=1
memory 128 MiB payload: peak heap+external=443.4 MiB (3.46x)  delivered=128 MiB
conns  stream, abandoned with break  (CONTROL)  opened=1 still-open=0
conns  value, peer ends the body     (CONTROL)  opened=1 still-open=0
conns  stream, drained to the last item         opened=1 still-open=1
conns  value (a Date: framed, nothing pending)  opened=1 still-open=1
conns  8 resolved calls in a row                opened=8 still-open=8

Two control rows, both of which behave correctly today, are what make the rest a defect rather than a design:

  • abandoning a stream with break closes the connection — that is return() firing the controller, the one leg that is wired. Draining the same call to its last item, which has strictly less left to say, keeps it.
  • a well-behaved peer that ends its body closes the connection regardless. The failure needs a peer that does not.

teed=2 never-read=1 undercounts the waste by one: the outer response is itself the other branch of the first tee and is never read either, so the payload flows through three branches and one reader.

With the patch under Options (same script, same machine):

tees   value  teed=0  never-read=0
tees   stream teed=0  never-read=0
memory 128 MiB payload: peak heap+external=328.5 MiB (2.57x)  delivered=128 MiB
conns  stream, abandoned with break  (CONTROL)  opened=1 still-open=0
conns  value, peer ends the body     (CONTROL)  opened=1 still-open=0
conns  stream, drained to the last item         opened=1 still-open=0
conns  value (a Date: framed, nothing pending)  opened=1 still-open=0
conns  8 resolved calls in a row                opened=8 still-open=0

Honest reading of the memory row: this harness runs the producer, the encoder and the consumer in one process, so the absolute multiple is inflated and is not a claim about a real deployment. The delta is the claim, and it is stable — four runs each gave 428–443 MiB before and 328–348 MiB after, 85–115 MiB less on a 128 MiB payload. The connection rows are exact counts, not measurements.

Why cancelling a clone cannot fix the teardown (this is what makes the two halves one change):

let sourceCancelled = false;
const src = new ReadableStream({ pull: c => c.enqueue(new Uint8Array(64)), cancel: () => (sourceCancelled = true) });
const res = new Response(src);
const clone = res.clone();                       // res and clone are the two tee branches
const reader = clone.body.getReader();
await reader.read();
let settled = false;
reader.cancel().then(() => (settled = true));    // cancel ONE branch
await new Promise(r => setTimeout(r, 50));
console.log(`one branch:  settled=${settled} sourceCancelled=${sourceCancelled}`);
res.body.cancel();
await new Promise(r => setTimeout(r, 50));
console.log(`both:        settled=${settled} sourceCancelled=${sourceCancelled}`);
one branch:  settled=false sourceCancelled=false
both:        settled=true sourceCancelled=true

The cancel does not even settle until the other branch goes too. As long as the transport decodes a clone, no cancel it can issue reaches the fetch.

Where

All line numbers on next @ f0f7531b.

The clones

  • packages/web/server-functions/src/client.ts:781const result = await decodeResponse(response.clone()); (tee JSXFragment / jsx-dom-expressions  #1; the transport then reads neither branch itself)
  • packages/web/server-functions/src/shared.ts:903const clone = source.clone(); in extractBody, used by every case at 905–930 (tee Why ES6 Proxies? #2)
  • packages/web/server-functions/src/shared.ts:1264decodeResponse passes the caller's response straight to extractBody. Its own contract at :1247–1248 already promises the clone — "@PARAM response the transport response; its body is read from a clone, so the original stays readable" — but it is the building block underneath that provides it, which is why the transport gets a clone it never asked for.

The teardown

  • packages/web/server-functions/src/client.ts:668const controller = options.signal ? undefined : new AbortController();, with the comment at :660–667 stating the purpose: "so a streaming result can be ENDED, not just abandoned"
  • packages/web/server-functions/src/client.ts:794-795 — the wrapper: next: () => it.next() (bare passthrough) beside return: value => (controller.abort(), …). Only the abandoned leg tears down.
  • packages/web/server-functions/src/shared.ts:963ChunkReader has no way to stop; it takes the body's reader and holds the lock for the life of the call
  • packages/web/server-functions/src/shared.ts:1173-1224deserializeStream interprets the head chunk, wires reader.drain(...) and returns. Nothing ever decides the read is over; the drain ends only when the peer ends the body.
  • packages/web/serialization/src/serializer-decode.ts:303-330createJSONDeserializer exposes abort but nothing that answers is anything still waiting on a later chunk, which is the question a reader must answer before it may stop.

Provenance. git log -S on each of the five lines above names exactly two commits, both from 2026-08-25:

89a0531c  Absorb expressions into Solid and collapse the rxcore seam.
71821959  Migrate the absorbed DOM runtime to TypeScript and flatten it into feature folders.

89a0531c is where the server-function runtime was vendored into this tree (packages/web/src/server-functions/{client,shared}.js); it added the controller, both iterator legs and both clones in the same file, at lines 345, 405, 406 and 392 of the added client.js. 71821959 carried them into TypeScript unchanged the same day. So this is the shape the runtime arrived with — no later fix created it, and no commit between then and f0f7531b touched any of these lines.

Why it matters

The clones are unconditional: every call, both encodings, every result. There is no peer behaviour or integration needed to hit it. What is honest to say about the cost is that it is invisible on the payloads most apps return — a few kilobytes teed three ways is a few kilobytes — and it becomes real on the shape this transport exists to support: a streamed result, where an unread branch queues the whole stream rather than a header, for the duration of the read. That is a memory cost, not a correctness one.

The teardown is the part that can take an app down, and it has a real precondition worth stating plainly: it needs a peer that does not close the response body after the payload. A well-behaved origin that ends its body ends the connection anyway — that is the second CONTROL row above, and it is why this has not been reported as an outage. The peers that do not are ordinary rather than exotic: an intermediary holding the socket for reuse, a CDN, an origin whose handler has stopped producing but whose process has not returned. In a browser over HTTP/1.1, six connections per origin means six such calls wedge the origin — and every one of them resolved successfully, so nothing in the application has any reason to suspect the origin is now unreachable. The repro's last row is that shape.

Two things narrow it further, and both should be said:

  • an application cannot work around this. It never receives the Response (unless it claims the call through the response-handler seam), and ChunkReader holds the body lock, so there is no handle to cancel. The one escape hatch is passing your own signal in the call options — at which point cancellation is yours, which is exactly the design and exactly what these calls do not have.
  • a result that is still waiting on something — a promise inside it that has not resolved — must keep its connection. That call is not over. Any fix has to distinguish the two, which is why this is not simply "cancel when the head chunk is decoded".

Options

On the clones

  1. Drop only extractBody's clone. One tee per call instead of two, and the branch that survives (the client's response.clone()) is read. Cheapest diff. It does not enable the teardown: the transport still reads a branch, and the micro-repro above shows cancelling a branch reaches nothing.
  2. Drop only the client's clone. Same arithmetic, same dead end, and it leaves the building block cloning for callers that already own the body.
  3. Move the clone up a layer: extractBody consumes, decodeResponse clones. The building block reads the body it is given; the integration-facing entry — where a router hands over a response it still owns and may read again — makes the copy its own docstring already promises. The transport then calls extractBody directly and owns its body. One clone for integrations, zero for the transport, and the teardown becomes possible. (recommended)
  4. Remove the clone entirely and let callers clone. Smallest runtime, but it silently breaks decodeResponse's documented promise for every existing integration; a router that reads the response afterwards starts failing on a locked body with no error that points here.

On the teardown

  1. Document it — "a server function call holds its connection until the peer closes the body". Defensible only if you consider this the peer's contract; the cost is that the framework's own control over the call ends at return().
  2. Mirror the existing return() teardown onto next(): abort on done and on a throw. Covers the drained-stream leg with three lines and no new concepts. Does nothing for a non-iterable result, which never goes through the wrapper at all.
  3. End the read in the decoder when nothing is pending: ChunkReader.cancel(), and deserializeStream interpreting the head chunk first so the value can decide whether a later chunk could still change it. Covers the non-iterable results (6) cannot see. Needs the decoder to answer "is anything still waiting", which is (8).
  4. A pending() predicate on the deserializer, built on the same awaitsLaterChunk test the existing abort sweep uses, so "still waiting" has one definition rather than two. It is conservative by construction — a settled resolver stays in the refs map and still answers true — and conservative in the safe direction: saying nothing is waiting is a licence to stop reading, and saying it late only means waiting for the peer, which is what every reader does today. The alternative shape, "cancel unless the result is an async iterable", is a heuristic that would truncate a plain object holding an unresolved promise.

Recommendation: (3) + (6) + (7) + (8), as one change. In Solid's minimalism terms, (3) is not new machinery but a correction of which layer owns the body — the building block consumes, the public wrapper preserves — and it deletes code rather than adding it. (6) removes an asymmetry that is hard to defend once stated: the abandoned stream tears down, the finished one does not. (7)+(8) put the decision where the evidence is: the payload's own frames say whether the answer is complete, and the peer's willingness to close its socket is not evidence about that.

Two calls that are yours to make, not mine:

  • Behaviour change vs. documentation on the drained-stream leg. Aborting a fully consumed stream fires request.signal on the server for a call that succeeded. On a stream whose closing frame has arrived, the generator has already finished, so in practice the signal lands on a producer with nothing left to do — but server code that reads request.signal as "the client went away" will now see it on a success path. If that is unacceptable, the narrower version is to release the reader without aborting the controller (note that in a browser cancelling a fetch body terminates the fetch anyway, so the distinction mostly disappears there).
  • Runtime vs. adapter. One could argue connection lifetime belongs to whatever owns the fetch, i.e. the integration. The reason I put it in the runtime is the reachability note above — the application is never handed the body — but if the intended answer is "supply your own signal", that is a documentation decision and it should be documented at the call options, not left implicit.

Regression test

Two specs, both red on f0f7531b (5 tests, 5 failures) and green with the change:

× reads every body it tees, on each encoding a result can ride
    AssertionError: buffer-json: 1 of 2 teed bodies were never read: expected 1 to be +0
× does not tee a streamed result once per layer it passes through
    AssertionError: 1 of 2 teed bodies queued the whole stream for nobody: expected 1 to be +0
× ends the connection when a streamed result is drained to its last item
    AssertionError: expected { opened: 1, cancelled: +0, …(2) } to match object { opened: 1, cancelled: 1, open: +0 }
× ends the connection when the result is not an async iterable
    AssertionError: expected { opened: 1, cancelled: +0, …(2) } to match object { opened: 1, cancelled: 1, open: +0 }
× does not wedge a browser's per-origin connection pool
    AssertionError: 8 of 8 connections still open after 8 resolved calls: expected 8 to be +0

The buffering spec goes red against any layering in which the transport reads a clone rather than the body it opened; the teardown spec goes red against a call that finishes without cancelling its reader.

packages/web/test/server/server-functions-response-buffering.spec.tsx:

/**
 * The transport must not tee a response body it never reads.
 *
 * `Response.clone()` is not free and it is not a copy: it tees the body, and
 * the branch nobody drains queues every byte that passes through the branch
 * somebody does. One unread clone therefore costs the whole payload in
 * memory, for as long as the read branch runs.
 *
 * The client makes two of them per call. It decodes `response.clone()`, and
 * `extractBody` clones AGAIN before reading. Nothing ever reads the outer
 * response, and nothing ever reads the intermediate clone, so a streamed
 * result is buffered twice over on top of the copy the caller asked for.
 *
 * The invariant pinned here is the one that survives whichever clone turns
 * out to be load-bearing: every clone the transport makes on the way to a
 * result must be READ. Exactly one of the two has a reason to exist —
 * `decodeResponse` is the integration-facing entry, where the caller still
 * owns the response it handed over and may read it again, and its contract
 * says so out loud — so one clone per call is allowed and zero is allowed;
 * two, with the first abandoned, is the waste.
 *
 * Deliberately structural rather than a heap measurement: the tee is the
 * mechanism, and a byte count taken inside a worker pool measures the other
 * tests as much as this one.
 *
 * Like the other server-function specs, these run against the built bundles
 * (server-functions/dist/*, wired up in vite.config.server.mjs).
 */
import { AsyncLocalStorage } from "node:async_hooks";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
  handleServerFunctionRequest,
  registerServerFunction
} from "@solidjs/web/server-functions/server";
import { createServerReference } from "@solidjs/web/server-functions/client";

const RequestContext = Symbol.for("solid.RequestContext");

beforeAll(() => {
  (globalThis as any)[RequestContext] = new AsyncLocalStorage();
});

afterAll(() => {
  delete (globalThis as any)[RequestContext];
});

const disconnects: (() => void)[] = [];
afterEach(() => {
  while (disconnects.length) disconnects.pop()!();
});

/** Routes the client stub's fetch straight into the built handler. */
function connectTransport() {
  const original = globalThis.fetch;
  globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
    const address = input instanceof Request ? input.url : input.toString();
    const request = new Request(
      new URL(address, "http://localhost"),
      input instanceof Request ? input : init
    );
    request.headers.set("Sec-Fetch-Site", "same-origin");
    return handleServerFunctionRequest(request);
  }) as typeof fetch;
  disconnects.push(() => {
    globalThis.fetch = original;
  });
}

/**
 * Records every body tee that happens while `run` is in flight. A clone
 * left with `bodyUsed === false` is a queue that filled for nobody.
 */
async function tees<T>(run: () => Promise<T>): Promise<{ value: T; clones: Response[] }> {
  const original = Response.prototype.clone;
  const clones: Response[] = [];
  Response.prototype.clone = function clone(this: Response) {
    const teed = original.call(this);
    if (this.body) clones.push(teed);
    return teed;
  };
  try {
    const value = await run();
    return { value, clones };
  } finally {
    Response.prototype.clone = original;
  }
}

const abandoned = (clones: Response[]) => clones.filter(clone => !clone.bodyUsed).length;

describe("server-function response buffering", () => {
  it("reads every body it tees, on each encoding a result can ride", async () => {
    // one per format the response side negotiates: the JSON fast path, the
    // streaming codec, and a value with a natural HTTP encoding of its own
    registerServerFunction("buffer-json", async () => ({ items: [1, 2, 3] }));
    registerServerFunction("buffer-serialized", async () => new Date(0));
    registerServerFunction("buffer-native", async () => "here");

    for (const [id, expected] of [
      ["buffer-json", { items: [1, 2, 3] }],
      ["buffer-serialized", new Date(0)],
      ["buffer-native", "here"]
    ] as const) {
      connectTransport();
      const { value, clones } = await tees(() => createServerReference(id)());
      // the result itself is the control: whatever the fix does to the
      // clones, the decoded value may not move
      expect(value).toEqual(expected);
      expect(
        abandoned(clones),
        `${id}: ${abandoned(clones)} of ${clones.length} teed bodies were never read`
      ).toBe(0);
      expect(clones.length, `${id} teed its body ${clones.length} times`).toBeLessThanOrEqual(1);
    }
  });

  it("does not tee a streamed result once per layer it passes through", async () => {
    // The shape the cost is paid on: a body that arrives over many frames,
    // where each abandoned tee queues the whole stream rather than a header.
    registerServerFunction("buffer-stream", async function* () {
      for (let index = 0; index < 64; index++) yield "x".repeat(1024);
    });
    connectTransport();
    const { value, clones } = await tees(() => createServerReference("buffer-stream")());
    let bytes = 0;
    for await (const frame of value as AsyncIterable<string>) bytes += frame.length;
    expect(bytes).toBe(64 * 1024);
    expect(
      abandoned(clones),
      `${abandoned(clones)} of ${clones.length} teed bodies queued the whole stream for nobody`
    ).toBe(0);
  });
});

packages/web/test/server/server-functions-connection-teardown.spec.tsx:

/**
 * A call that is over must END its connection, and it must decide that from
 * the payload rather than from the peer closing the body.
 *
 * The transport mints an AbortController per call precisely so a streaming
 * result can be ENDED and not merely abandoned — aborting the fetch closes
 * the response body here and fires `request.signal` on the server. That
 * controller is wired into exactly one place: the `return()` of the
 * async-iterator wrapper, the leg a `break` in a `for await` walks. Every
 * other way a call finishes leaves it unfired, so nothing ever cancels the
 * reader; and because the codec's ChunkReader holds the body lock, the
 * application cannot cancel it either. The connection stays open for as
 * long as the peer keeps it open.
 *
 * That is not a leak while the peer behaves — a server that ends its body
 * ends the connection. The trigger is any peer that does not: a hung
 * origin, a proxy, a CDN holding the socket after the payload. In a browser
 * over HTTP/1.1 the six-connections-per-origin cap turns six such calls
 * into a wedged origin while every one of them reported success.
 *
 * Two ends are pinned here, both cases where NOTHING is outstanding:
 *
 *  - a streamed result drained to completion. `for await` calls `return()`
 *    on a `break` but not on a natural end, so the guard that exists on the
 *    abandoned leg was never mirrored onto the finished one — the same call,
 *    consumed to the last item, keeps its connection.
 *  - a result that is not an async iterable at all. Once the head chunk has
 *    been interpreted and the value holds no unsettled references, there is
 *    nothing left for a later chunk to say.
 *
 * A result still awaiting values — a promise inside it that has not
 * resolved — is deliberately NOT pinned: that call is not over, and its
 * connection is load-bearing.
 *
 * The transport only owns the signal when the caller brought none; a
 * caller-supplied signal already owns the wire and cancellation stays
 * theirs, which is the escape hatch these calls do not have.
 *
 * Like the other server-function specs, these run against the built bundles
 * (server-functions/dist/*, wired up in vite.config.server.mjs).
 */
import { AsyncLocalStorage } from "node:async_hooks";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
  handleServerFunctionRequest,
  registerServerFunction
} from "@solidjs/web/server-functions/server";
import { createServerReference } from "@solidjs/web/server-functions/client";

const RequestContext = Symbol.for("solid.RequestContext");

beforeAll(() => {
  (globalThis as any)[RequestContext] = new AsyncLocalStorage();
});

afterAll(() => {
  delete (globalThis as any)[RequestContext];
});

type Connections = {
  /** Bodies handed to the transport. */
  opened: number;
  /** Bodies the transport cancelled, or that `init.signal` closed. */
  cancelled: number;
  /** Bodies the peer itself finished. */
  endedByPeer: number;
  readonly open: number;
};

const disconnects: (() => void)[] = [];
afterEach(() => {
  while (disconnects.length) disconnects.pop()!();
});

/**
 * A transport that behaves like a connection rather than a buffer: the
 * response body is a socket, `init.signal` closes it the way a browser's
 * fetch does, and `hold` models the peer that never sends the terminating
 * frame — the payload arrives complete and the socket stays open behind it.
 */
function connectTransport({ hold = false }: { hold?: boolean } = {}): Connections {
  const original = globalThis.fetch;
  const counts: Connections = {
    opened: 0,
    cancelled: 0,
    endedByPeer: 0,
    get open() {
      return this.opened - this.cancelled - this.endedByPeer;
    }
  };
  globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
    const address = input instanceof Request ? input.url : input.toString();
    const request = new Request(
      new URL(address, "http://localhost"),
      input instanceof Request ? input : init
    );
    request.headers.set("Sec-Fetch-Site", "same-origin");
    const upstream = await handleServerFunctionRequest(request);
    if (!upstream.body) return upstream;
    counts.opened++;
    const reader = upstream.body.getReader();
    let settled = false;
    const close = () => {
      if (settled) return;
      settled = true;
      counts.cancelled++;
      reader.cancel().catch(() => {});
    };
    init?.signal?.addEventListener("abort", close);
    const body = new ReadableStream({
      async pull(controller) {
        const { done, value } = await reader.read();
        if (done) {
          // the peer holding on: the payload is all there, the socket is not
          // closed, and only the reader's own cancel can reclaim it
          if (hold) return new Promise<void>(() => {});
          if (!settled) {
            settled = true;
            counts.endedByPeer++;
          }
          controller.close();
          return;
        }
        controller.enqueue(value);
      },
      cancel: close
    });
    return new Response(body, { status: upstream.status, headers: upstream.headers });
  }) as typeof fetch;
  disconnects.push(() => {
    globalThis.fetch = original;
  });
  return counts;
}

/** Lets the transport's own teardown, if any, run before the count is read. */
const settle = () => new Promise(resolve => setTimeout(resolve, 50));

describe("server-function connection teardown", () => {
  it("ends the connection when a streamed result is drained to its last item", async () => {
    registerServerFunction("teardown-stream", async function* () {
      yield 1;
      yield 2;
      yield 3;
    });

    // The leg that works, kept here because it is what makes the other one a
    // defect rather than a design: abandoning the stream fires the
    // controller through the wrapper's `return()`.
    const abandoned = connectTransport({ hold: true });
    for await (const value of (await createServerReference(
      "teardown-stream"
    )()) as AsyncIterable<number>) {
      expect(value).toBe(1);
      break;
    }
    await settle();
    expect({ ...abandoned, open: abandoned.open }).toMatchObject({
      opened: 1,
      cancelled: 1,
      open: 0
    });

    // The same call consumed to the end. `for await` calls `return()` only
    // on an early exit, so the finished stream — which has strictly less
    // left to say than the abandoned one — keeps its connection forever.
    const drained = connectTransport({ hold: true });
    const seen: number[] = [];
    for await (const value of (await createServerReference(
      "teardown-stream"
    )()) as AsyncIterable<number>) {
      seen.push(value);
    }
    expect(seen).toEqual([1, 2, 3]);
    await settle();
    expect({ ...drained, open: drained.open }).toMatchObject({
      opened: 1,
      cancelled: 1,
      open: 0
    });
  });

  it("ends the connection when the result is not an async iterable", async () => {
    // A `Date` needs the streaming codec (JSON cannot carry it), so the call
    // reads a framed body — but the value holds no unsettled reference, so
    // the head chunk is the whole answer and no later chunk can add to it.
    registerServerFunction("teardown-value", async () => new Date(0));

    const held = connectTransport({ hold: true });
    const value = await createServerReference("teardown-value")();
    expect(value).toBeInstanceOf(Date);
    expect((value as Date).getTime()).toBe(0);
    await settle();
    expect({ ...held, open: held.open }).toMatchObject({ opened: 1, cancelled: 1, open: 0 });
  });

  it("does not wedge a browser's per-origin connection pool", async () => {
    // HTTP/1.1 allows six connections per origin. Six successful calls
    // against a peer that holds its sockets must not be able to stop the
    // seventh — every one of these resolved, so nothing in the application
    // has any reason to suspect the origin is now unreachable.
    registerServerFunction("teardown-pool", async () => new Date(0));
    const pool = connectTransport({ hold: true });
    for (let call = 0; call < 8; call++) {
      expect(await createServerReference("teardown-pool")()).toBeInstanceOf(Date);
    }
    await settle();
    expect(
      pool.open,
      `${pool.open} of ${pool.opened} connections still open after 8 resolved calls`
    ).toBe(0);
  });
});

Environment

@solidjs/web next @ f0f7531b
Node v24.19.0
OS macOS (darwin 25.6.0)

Activity

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