You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
## 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.mjsimport{AsyncLocalStorage}from"node:async_hooks";constW=process.argv[2];const{ handleServerFunctionRequest, registerServerFunction }=awaitimport(`${W}/server-functions/dist/server.js`);const{ createServerReference }=awaitimport(`${W}/server-functions/dist/client.js`);globalThis[Symbol.for("solid.RequestContext")]=newAsyncLocalStorage();constMiB=1048576;functiontransport({ hold =false}={}){constc={opened: 0,closed: 0,getopen(){returnthis.opened-this.closed;}};globalThis.fetch=async(input,init)=>{constrequest=newRequest(newURL(input.toString(),"http://localhost"),init);request.headers.set("Sec-Fetch-Site","same-origin");constupstream=awaithandleServerFunctionRequest(request);if(!upstream.body)returnupstream;c.opened++;constreader=upstream.body.getReader();letsettled=false;constclose=()=>{if(settled)return;settled=true;c.closed++;reader.cancel().catch(()=>{});};init?.signal?.addEventListener("abort",close);// fetch abort closes the socketreturnnewResponse(newReadableStream({asyncpull(controller){const{ done, value }=awaitreader.read();if(done){if(hold)returnnewPromise(()=>{});// the peer holds it openclose();controller.close();return;}controller.enqueue(value);},cancel: close}),{status: upstream.status,headers: upstream.headers});};returnc;}constsettle=()=>newPromise(r=>setTimeout(r,50));registerServerFunction("value",async()=>newDate(0));// framed body, nothing left pendingregisterServerFunction("stream",asyncfunction*(){yield1;yield2;yield3;});// 1. how many times is one response body teed, and who reads the branches?constclone=Response.prototype.clone;for(constidof["value","stream"]){transport();constteed=[];Response.prototype.clone=function(){constt=clone.call(this);if(this.body)teed.push(t);returnt;};constresult=awaitcreateServerReference(id)();if(result?.[Symbol.asyncIterator])forawait(const_ofresult){}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",asyncfunction*(){for(leti=0;i<128;i++)yield"x".repeat(MiB);});transport();for(leti=0;i<4;i++)global.gc();constbase=process.memoryUsage();letpeak=0,bytes=0;forawait(constframeofawaitcreateServerReference("big")()){bytes+=frame.length;constm=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?constrows=[["stream, abandoned with break (CONTROL)",true,async()=>{forawait(const_ofawaitcreateServerReference("stream")())break;}],["value, peer ends the body (CONTROL)",false,async()=>{awaitcreateServerReference("value")();}],["stream, drained to the last item ",true,async()=>{forawait(const_ofawaitcreateServerReference("stream")()){}}],["value (a Date: framed, nothing pending)",true,async()=>{awaitcreateServerReference("value")();}],["8 resolved calls in a row ",true,async()=>{for(leti=0;i<8;i++)awaitcreateServerReference("value")();}]];for(const[label,hold,run]ofrows){constc=transport({ hold });awaitrun();awaitsettle();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):
letsourceCancelled=false;constsrc=newReadableStream({pull: c=>c.enqueue(newUint8Array(64)),cancel: ()=>(sourceCancelled=true)});constres=newResponse(src);constclone=res.clone();// res and clone are the two tee branchesconstreader=clone.body.getReader();awaitreader.read();letsettled=false;reader.cancel().then(()=>(settled=true));// cancel ONE branchawaitnewPromise(r=>setTimeout(r,50));console.log(`one branch: settled=${settled} sourceCancelled=${sourceCancelled}`);res.body.cancel();awaitnewPromise(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:781 — const 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:903 — const clone = source.clone(); in extractBody, used by every case at 905–930 (tee Why ES6 Proxies? #2)
packages/web/server-functions/src/shared.ts:1264 — decodeResponse 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:668 — const 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:963 — ChunkReader 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-1224 — deserializeStream 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-330 — createJSONDeserializer 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
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.
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.
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)
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
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().
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.
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).
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.
/** * 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";constRequestContext=Symbol.for("solid.RequestContext");beforeAll(()=>{(globalThisasany)[RequestContext]=newAsyncLocalStorage();});afterAll(()=>{delete(globalThisasany)[RequestContext];});constdisconnects: (()=>void)[]=[];afterEach(()=>{while(disconnects.length)disconnects.pop()!();});/** Routes the client stub's fetch straight into the built handler. */functionconnectTransport(){constoriginal=globalThis.fetch;globalThis.fetch=((input: RequestInfo|URL,init?: RequestInit)=>{constaddress=inputinstanceofRequest ? input.url : input.toString();constrequest=newRequest(newURL(address,"http://localhost"),inputinstanceofRequest ? input : init);request.headers.set("Sec-Fetch-Site","same-origin");returnhandleServerFunctionRequest(request);})astypeoffetch;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. */asyncfunctiontees<T>(run: ()=>Promise<T>): Promise<{value: T;clones: Response[]}>{constoriginal=Response.prototype.clone;constclones: Response[]=[];Response.prototype.clone=functionclone(this: Response){constteed=original.call(this);if(this.body)clones.push(teed);returnteed;};try{constvalue=awaitrun();return{ value, clones };}finally{Response.prototype.clone=original;}}constabandoned=(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 ownregisterServerFunction("buffer-json",async()=>({items: [1,2,3]}));registerServerFunction("buffer-serialized",async()=>newDate(0));registerServerFunction("buffer-native",async()=>"here");for(const[id,expected]of[["buffer-json",{items: [1,2,3]}],["buffer-serialized",newDate(0)],["buffer-native","here"]]asconst){connectTransport();const{ value, clones }=awaittees(()=>createServerReference(id)());// the result itself is the control: whatever the fix does to the// clones, the decoded value may not moveexpect(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",asyncfunction*(){for(letindex=0;index<64;index++)yield"x".repeat(1024);});connectTransport();const{ value, clones }=awaittees(()=>createServerReference("buffer-stream")());letbytes=0;forawait(constframeofvalueasAsyncIterable<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);});});
/** * 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";constRequestContext=Symbol.for("solid.RequestContext");beforeAll(()=>{(globalThisasany)[RequestContext]=newAsyncLocalStorage();});afterAll(()=>{delete(globalThisasany)[RequestContext];});typeConnections={/** 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;readonlyopen: number;};constdisconnects: (()=>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. */functionconnectTransport({ hold =false}: {hold?: boolean}={}): Connections{constoriginal=globalThis.fetch;constcounts: Connections={opened: 0,cancelled: 0,endedByPeer: 0,getopen(){returnthis.opened-this.cancelled-this.endedByPeer;}};globalThis.fetch=(async(input: RequestInfo|URL,init?: RequestInit)=>{constaddress=inputinstanceofRequest ? input.url : input.toString();constrequest=newRequest(newURL(address,"http://localhost"),inputinstanceofRequest ? input : init);request.headers.set("Sec-Fetch-Site","same-origin");constupstream=awaithandleServerFunctionRequest(request);if(!upstream.body)returnupstream;counts.opened++;constreader=upstream.body.getReader();letsettled=false;constclose=()=>{if(settled)return;settled=true;counts.cancelled++;reader.cancel().catch(()=>{});};init?.signal?.addEventListener("abort",close);constbody=newReadableStream({asyncpull(controller){const{ done, value }=awaitreader.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 itif(hold)returnnewPromise<void>(()=>{});if(!settled){settled=true;counts.endedByPeer++;}controller.close();return;}controller.enqueue(value);},cancel: close});returnnewResponse(body,{status: upstream.status,headers: upstream.headers});})astypeoffetch;disconnects.push(()=>{globalThis.fetch=original;});returncounts;}/** Lets the transport's own teardown, if any, run before the count is read. */constsettle=()=>newPromise(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",asyncfunction*(){yield1;yield2;yield3;});// 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()`.constabandoned=connectTransport({hold: true});forawait(constvalueof(awaitcreateServerReference("teardown-stream")())asAsyncIterable<number>){expect(value).toBe(1);break;}awaitsettle();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.constdrained=connectTransport({hold: true});constseen: number[]=[];forawait(constvalueof(awaitcreateServerReference("teardown-stream")())asAsyncIterable<number>){seen.push(value);}expect(seen).toEqual([1,2,3]);awaitsettle();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()=>newDate(0));constheld=connectTransport({hold: true});constvalue=awaitcreateServerReference("teardown-value")();expect(value).toBeInstanceOf(Date);expect((valueasDate).getTime()).toBe(0);awaitsettle();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()=>newDate(0));constpool=connectTransport({hold: true});for(letcall=0;call<8;call++){expect(awaitcreateServerReference("teardown-pool")()).toBeInstanceOf(Date);}awaitsettle();expect(pool.open,`${pool.open} of ${pool.opened} connections still open after 8 resolved calls`).toBe(0);});});
Measured on
next@f0f7531b:Two control rows, both of which behave correctly today, are what make the rest a defect rather than a design:
breakcloses the connection — that isreturn()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.teed=2 never-read=1undercounts the waste by one: the outerresponseis 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):
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):
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:781—const 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:903—const clone = source.clone();inextractBody, used by every case at 905–930 (tee Why ES6 Proxies? #2)packages/web/server-functions/src/shared.ts:1264—decodeResponsepasses the caller's response straight toextractBody. 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:668—const 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) besidereturn: value => (controller.abort(), …). Only the abandoned leg tears down.packages/web/server-functions/src/shared.ts:963—ChunkReaderhas no way to stop; it takes the body's reader and holds the lock for the life of the callpackages/web/server-functions/src/shared.ts:1173-1224—deserializeStreaminterprets the head chunk, wiresreader.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-330—createJSONDeserializerexposesabortbut 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 -Son each of the five lines above names exactly two commits, both from 2026-08-25:89a0531cis 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 addedclient.js.71821959carried 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 andf0f7531btouched 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:
Response(unless it claims the call through the response-handler seam), andChunkReaderholds the body lock, so there is no handle to cancel. The one escape hatch is passing your ownsignalin the call options — at which point cancellation is yours, which is exactly the design and exactly what these calls do not have.Options
On the clones
extractBody's clone. One tee per call instead of two, and the branch that survives (the client'sresponse.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.extractBodyconsumes,decodeResponseclones. 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 callsextractBodydirectly and owns its body. One clone for integrations, zero for the transport, and the teardown becomes possible. (recommended)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
return().return()teardown ontonext(): abort ondoneand 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.ChunkReader.cancel(), anddeserializeStreaminterpreting 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).pending()predicate on the deserializer, built on the sameawaitsLaterChunktest the existingabortsweep 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 answerstrue— 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:
request.signalon 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 readsrequest.signalas "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).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: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:packages/web/test/server/server-functions-connection-teardown.spec.tsx:Environment
@solidjs/webnext@f0f7531b