Skip to content

[2.0 rc.13] createSSRResponse keeps rendering after its body is cancelled and after a pre-flush redirect #3768

Description

@everton-dgn

Describe the bug

createSSRResponse(renderToStream(...), event) leaves the render running in two cases where nobody will read it.

  1. 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".
  2. 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

  1. npm i solid-js@2.0.0-rc.13 @solidjs/web@2.0.0-rc.13
  2. Save the script below as repro.mjs.
  3. 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.

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