Describe the bug
createSSRResponse(renderToStream(...), event) leaves the render running in two cases where nobody will read it.
- The body of the resolved
Response is cancelled. Its cancel() only sets a local closed flag (server.ts#L6897-L6899), and enqueue then returns early on every write (#L6864-L6871), so the sink never throws and guardSink never reaches abandon("sink") (#L2181-L2198). Fragments keep resolving, a serialized async iterator keeps being pulled, its finally and the components' onCleanup wait for the render to end on its own (never, with an endless source), and every chunk goes to a sink that drops it. The plugin's Node handler cancels this body when the client goes away and for HEAD (http.ts#L97-L108), and its comment says that streaming a long (or endless) body into the void "just burns the render".
- A pre-flush redirect. When the stub has a
Location at shell flush (set on event.response before the render, or with httpHeader("Location", ...) in a component), the sink resolves a bodyless redirect and sets the same flag (#L6880-L6886). The JSDoc says "the render is abandoned" (#L6822-L6823), but the render runs on as in case 1.
#3660 fixed the same thing for serverComponentResponse in rc.10. Its changelog entry says "previously cancel() only dropped writes and the render ran on until its sources happened to end". Cancelling the readable view of renderToStream also tears the render down (#L3180-L3186, plus #3628 for serialized iterators). The body of createSSRResponse is still at "previously".
signal doesn't cover these cases. Its JSDoc reserves it for a host whose transport "cannot report a dead consumer through the sink or the readable view" (#L1907-L1919), and createSSRResponse has no signal option of its own, so it has to go to renderToStream. On HEAD and on a redirect the client is still there and the response ends normally, so a host that aborts request.signal only when the response didn't finish, like the plugin's Node handler (http.ts#L54-L61), never aborts it. In a Node http server wired that way, the signal never fired on HEAD or on a 302, and the render kept pulling as in the table below. For a client that disconnects mid-body, passing the signal does stop the render.
With a finite tree the waste ends when the render does. With the iterator capped at 20 pulls, a cancelled body or a pre-flush redirect kept the render going until about 300 ms, which is how long a full read of the same page takes.
Your Example Website or App
Node script under Steps. No JSX and no build step.
Steps to Reproduce the Bug or Issue
npm i solid-js@2.0.0-rc.13 @solidjs/web@2.0.0-rc.13
- Save the script below as
repro.mjs.
node repro.mjs
repro.mjs
// repro.mjs: plain Node ESM, no JSX, no build step.
// npm i solid-js@2.0.0-rc.13 @solidjs/web@2.0.0-rc.13
// node repro.mjs prints the table (each case runs in its own process)
// node repro.mjs <case> prints one case as JSON
// Tree: <main><Loading><Ticker /></Loading><Loading><Late /></Loading></main>
// Ticker reads an endless serialized async iterator (one pull every 10 ms).
// Late reads a value that settles at 300 ms.
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { Loading, createMemo, onCleanup } from "solid-js";
import {
createComponent,
createRequestEvent,
createSSRResponse,
escape,
renderToStream,
scope,
ssr,
ssrHydrationKey
} from "@solidjs/web";
const delay = ms => new Promise(r => setTimeout(r, ms));
const stats = { pulls: 0, finallies: 0, cleanups: 0, writes: [] };
async function* ticks() {
try {
for (let n = 0; ; n++) {
await delay(10);
stats.pulls++;
yield `tick ${n}`;
}
} finally {
stats.finallies++;
}
}
function Ticker() {
onCleanup(() => stats.cleanups++);
const tick = createMemo(() => ticks());
return ssr(["<p", ">", "</p>"], ssrHydrationKey(), scope(() => escape(tick())));
}
function Late() {
onCleanup(() => stats.cleanups++);
const text = createMemo(async () => {
await delay(300);
return "late-done";
});
return ssr(["<p", ">", "</p>"], ssrHydrationKey(), scope(() => escape(text())));
}
const App = () =>
ssr(
["<main", "><!--$-->", "<!--/--><!--$-->", "<!--/--></main>"],
ssrHydrationKey(),
escape(createComponent(Loading, { fallback: "loading", get children() { return createComponent(Ticker, {}); } })),
escape(createComponent(Loading, { fallback: "loading", get children() { return createComponent(Late, {}); } }))
);
// Sees every chunk the render hands createSSRResponse's sink, before the sink drops it.
const transformChunk = chunk => {
stats.writes.push({ at: performance.now(), late: chunk.includes("late-done") });
return chunk;
};
const until = async cond => {
const deadline = performance.now() + 2000;
while (!cond()) {
if (performance.now() > deadline) throw new Error("timed out waiting for the iterator to start");
await delay(5);
}
};
const response = location => {
const event = createRequestEvent(new Request("http://localhost/"));
if (location) event.response.headers.set("Location", location);
return createSSRResponse(renderToStream(App), event, { transformChunk });
};
// Takes the action, then reads the counters 1 s later.
async function measure(status, action) {
const pullsAtAction = stats.pulls;
const actionAt = performance.now();
await action();
await delay(1000);
const after = stats.writes.filter(w => w.at > actionAt);
return {
status,
pulls: `${pullsAtAction} -> ${stats.pulls}`,
finally: stats.finallies,
onCleanup: `${stats.cleanups}/2`,
sinkWritesAfter: after.length,
lateWrittenAfter: after.some(w => w.late) ? "yes" : "no"
};
}
const cases = {
"readable-cancel": {
label: "`readable` view, reader cancelled after the shell (control, #3628)",
async run() {
const reader = renderToStream(App).readable.getReader();
await reader.read();
await until(() => stats.pulls >= 2);
const row = await measure("-", () => reader.cancel());
return { ...row, sinkWritesAfter: "n/a", lateWrittenAfter: "n/a" };
}
},
"body-read-cancel": {
label: "createSSRResponse body cancelled after reading the shell (client gone)",
async run() {
const res = await response();
const reader = res.body.getReader();
await reader.read();
await until(() => stats.pulls >= 2);
return measure(res.status, () => reader.cancel());
}
},
"body-unread-cancel": {
label: "createSSRResponse body cancelled before reading (what the plugin does for HEAD)",
async run() {
const res = await response();
await until(() => stats.pulls >= 2);
return measure(res.status, () => res.body.cancel());
}
},
"redirect": {
label: "`Location` set before the render (pre-flush redirect)",
async run() {
const res = await response("/login");
return measure(`${res.status}, body ${res.body}`, () => {});
}
}
};
const [only] = process.argv.slice(2);
if (only) {
console.log(JSON.stringify(await cases[only].run()));
// Renders that were never torn down keep timers alive.
process.exit(0);
}
const columns = ["status", "pulls", "finally", "onCleanup", "sinkWritesAfter", "lateWrittenAfter"];
console.log("| case | status | iterator pulls (at action -> 1 s later) | iterator `finally` | `onCleanup` | render writes after action (seen by `transformChunk`) | late fragment written after action |");
console.log("| --- | --- | --- | --- | --- | --- | --- |");
for (const [name, { label }] of Object.entries(cases)) {
const out = execFileSync(process.execPath, [fileURLToPath(import.meta.url), name], { encoding: "utf8", timeout: 10000 });
const row = JSON.parse(out);
console.log(`| ${label} | ${columns.map(c => row[c]).join(" | ")} |`);
}
Output with rc.13:
| case |
status |
iterator pulls (at action -> 1 s later) |
iterator finally |
onCleanup |
render writes after action (seen by transformChunk) |
late fragment written after action |
readable view, reader cancelled after the shell (control, #3628) |
- |
2 -> 3 |
1 |
2/2 |
n/a |
n/a |
| createSSRResponse body cancelled after reading the shell (client gone) |
200 |
2 -> 87 |
0 |
0/2 |
87 |
yes |
| createSSRResponse body cancelled before reading (what the plugin does for HEAD) |
200 |
2 -> 87 |
0 |
0/2 |
85 |
yes |
Location set before the render (pre-flush redirect) |
302, body null |
0 -> 86 |
0 |
0/2 |
86 |
yes |
The pull and write counts move by a few between runs (one pull every 10 ms for the whole second); the zeros and the control row don't. The redirect row starts at 0 pulls because that response resolves at the shell flush, before the first pull. next at e44b2e4, built locally, gives the same table.
Expected behavior
Cancelling the body of a createSSRResponse response tears the render down the way cancelling the readable view does: async sources are returned, onCleanup runs and nothing more is written. A pre-flush redirect does the same, as its JSDoc says.
Screenshots or Videos
N/A (server only).
Platform
- OS: macOS 27.0
- Runtime: Node.js v24.21.0 (no browser involved)
- Version:
solid-js 2.0.0-rc.13, @solidjs/web 2.0.0-rc.13. Same result on next at e44b2e4, where the body's cancel() and the pre-flush redirect branch are the same code as in the rc.13 dist.
Additional context
Possible seam: pipe() already reads a module-private symbol off this sink for the pre-shell case (SHELL_ABANDONED, #L1872, #L3305-L3314). The reverse would let the body's cancel() and the redirect branch call into abandon(...), the way the body of serverComponentResponse does (frame-sink.ts#L2456-L2461). That call has to be a no-op after end(): a body can still be cancelled while queued chunks drain, and a completed render isn't dead (#L2267-L2271, #L2038). The existing pre-flush redirect tests (cookies.spec.js, ssr-async-rejection-3569.spec.tsx) check the status and Location, not that the render stops.
What I'd rather ask about is the label. In dev and observe builds, abandon("consumer") emits SSR_STREAM_ABANDONED at warn (#L2037-L2051), which the comment above it describes as the client having left (#L1991-L1995). abandon() with no reason settles the render as "error" and runs the failure completions (#L2057, #L2099-L2101). An auth redirect is neither, and HEAD has the same problem: with "consumer", every HEAD in a dev build would log a disconnect. Should these tear down silently, get a reason of their own, or reuse "consumer"? Or did "the render is abandoned" only mean that the output is discarded?
Related:
I can send a PR with tests next to stream-cancel-iterators-3626.spec.tsx once the label is settled. If you'd rather write it yourself, that works too.
Describe the bug
createSSRResponse(renderToStream(...), event)leaves the render running in two cases where nobody will read it.Responseis cancelled. Itscancel()only sets a localclosedflag (server.ts#L6897-L6899), andenqueuethen returns early on every write (#L6864-L6871), so the sink never throws andguardSinknever reachesabandon("sink")(#L2181-L2198). Fragments keep resolving, a serialized async iterator keeps being pulled, itsfinallyand the components'onCleanupwait for the render to end on its own (never, with an endless source), and every chunk goes to a sink that drops it. The plugin's Node handler cancels this body when the client goes away and for HEAD (http.ts#L97-L108), and its comment says that streaming a long (or endless) body into the void "just burns the render".Locationat shell flush (set onevent.responsebefore the render, or withhttpHeader("Location", ...)in a component), the sink resolves a bodyless redirect and sets the same flag (#L6880-L6886). The JSDoc says "the render is abandoned" (#L6822-L6823), but the render runs on as in case 1.#3660 fixed the same thing for
serverComponentResponsein rc.10. Its changelog entry says "previouslycancel()only dropped writes and the render ran on until its sources happened to end". Cancelling thereadableview ofrenderToStreamalso tears the render down (#L3180-L3186, plus #3628 for serialized iterators). The body ofcreateSSRResponseis still at "previously".signaldoesn't cover these cases. Its JSDoc reserves it for a host whose transport "cannot report a dead consumer through the sink or the readable view" (#L1907-L1919), andcreateSSRResponsehas nosignaloption of its own, so it has to go torenderToStream. On HEAD and on a redirect the client is still there and the response ends normally, so a host that abortsrequest.signalonly when the response didn't finish, like the plugin's Node handler (http.ts#L54-L61), never aborts it. In a Nodehttpserver wired that way, the signal never fired on HEAD or on a 302, and the render kept pulling as in the table below. For a client that disconnects mid-body, passing the signal does stop the render.With a finite tree the waste ends when the render does. With the iterator capped at 20 pulls, a cancelled body or a pre-flush redirect kept the render going until about 300 ms, which is how long a full read of the same page takes.
Your Example Website or App
Node script under Steps. No JSX and no build step.
Steps to Reproduce the Bug or Issue
npm i solid-js@2.0.0-rc.13 @solidjs/web@2.0.0-rc.13repro.mjs.node repro.mjsrepro.mjs
Output with rc.13:
finallyonCleanuptransformChunk)readableview, reader cancelled after the shell (control, #3628)Locationset before the render (pre-flush redirect)The pull and write counts move by a few between runs (one pull every 10 ms for the whole second); the zeros and the control row don't. The redirect row starts at 0 pulls because that response resolves at the shell flush, before the first pull.
nextat e44b2e4, built locally, gives the same table.Expected behavior
Cancelling the body of a
createSSRResponseresponse tears the render down the way cancelling thereadableview does: async sources are returned,onCleanupruns and nothing more is written. A pre-flush redirect does the same, as its JSDoc says.Screenshots or Videos
N/A (server only).
Platform
solid-js2.0.0-rc.13,@solidjs/web2.0.0-rc.13. Same result onnextat e44b2e4, where the body'scancel()and the pre-flush redirect branch are the same code as in the rc.13 dist.Additional context
Possible seam:
pipe()already reads a module-private symbol off this sink for the pre-shell case (SHELL_ABANDONED, #L1872, #L3305-L3314). The reverse would let the body'scancel()and the redirect branch call intoabandon(...), the way the body ofserverComponentResponsedoes (frame-sink.ts#L2456-L2461). That call has to be a no-op afterend(): a body can still be cancelled while queued chunks drain, and a completed render isn'tdead(#L2267-L2271, #L2038). The existing pre-flush redirect tests (cookies.spec.js,ssr-async-rejection-3569.spec.tsx) check the status andLocation, not that the render stops.What I'd rather ask about is the label. In dev and observe builds,
abandon("consumer")emitsSSR_STREAM_ABANDONEDatwarn(#L2037-L2051), which the comment above it describes as the client having left (#L1991-L1995).abandon()with no reason settles the render as"error"and runs the failure completions (#L2057, #L2099-L2101). An auth redirect is neither, and HEAD has the same problem: with"consumer", every HEAD in a dev build would log a disconnect. Should these tear down silently, get a reason of their own, or reuse"consumer"? Or did "the render is abandoned" only mean that the output is discarded?Related:
next()whose page "keeps running into a buffer nobody reads". This doesn't close that one: a droppedResponsewhose body is never cancelled keeps rendering at the same rate as the rows above, on rc.13 andnext. Withcancel()fixed, the containment could stop it by cancelling the body.signalafter the shell leaves the body open) is the other direction across the same sink. I left it out of this issue.I can send a PR with tests next to
stream-cancel-iterators-3626.spec.tsxonce the label is settled. If you'd rather write it yourself, that works too.