Skip to content

[2.0 rc.11] createSSRResponse never settles when the render fails before the shell flushes #3719

Description

@everton-dgn

Describe the bug

When a streamed render fails before its shell flushes (the render reaches failRender, onError sees handling: "failed"), the Promise<Response> returned by createSSRResponse(renderToStream(...), event) never settles. It neither resolves nor rejects, so the request hangs until the client or the host times out.

The same renderToStream result completes through every other consumer, as #3569 made it do: pipe() ends the sink after zero writes, await resolves with "", and pipeTo()/readable close. createSSRResponse is built on pipe(), but its end() ignores an end that arrives before the first write.

Two ordinary trees reach this:

Hosts that answer every page through createSSRResponse hang the request in both cases. @solidjs/vite-plugin (3.0.0-next.46) is one of them: in its default stream mode, the generated server handler passes the renderToStream result, unawaited, to createSSRResponse(result, event, ...).

Your Example Website or App

Self-contained script below. It has no JSX, so it runs with plain node against the published packages.

Steps to Reproduce the Bug or Issue

// repro.mjs: plain Node ESM, no JSX, no build step.
// Needs solid-js@2.0.0-rc.11 and @solidjs/web@2.0.0-rc.11 (Node picks dist/server.js).
// Usage: node repro.mjs <root|loading> <response|pipe|await>
import { Loading, NotReadyError, createMemo } from "solid-js";
import {
  createRequestEvent,
  createSSRResponse,
  escape,
  renderToStream,
  scope,
  ssr,
  ssrHydrationKey
} from "@solidjs/web";

const [tree = "root", consumer = "response"] = process.argv.slice(2);
const delay = (ms, value) => new Promise(r => setTimeout(() => r(value), ms));

// root: <main><p>{data()}</p></main>, no <Loading> or <Errored> above the read.
const RootRejection = () => {
  const data = createMemo(async () => {
    await delay(10);
    throw new Error("fetch failed");
  });
  const p = ssr(["<p", ">", "</p>"], ssrHydrationKey(), scope(() => escape(data())));
  return ssr(["<main", ">", "</main>"], ssrHydrationKey(), escape(p));
};

// loading: <div>{held()}<Loading fallback="loading"><NeverConverges /></Loading></div>
// The #3569 (b) shape: the boundary trips its convergence budget while a root
// read still holds the shell, and it has no parent handler.
const NeverConverges = () => {
  throw new NotReadyError(Promise.resolve());
};
const LoadingFailure = () => {
  const held = createMemo(() => delay(200, "shell"));
  return ssr(
    ["<div", ">", "", "</div>"],
    ssrHydrationKey(),
    scope(() => escape(held())),
    escape(Loading({ fallback: "loading", get children() { return NeverConverges(); } }))
  );
};

const timer = setTimeout(() => {
  console.log("still pending after 3000 ms");
  process.exit(1);
}, 3000);

const result = renderToStream(tree === "loading" ? LoadingFailure : RootRejection, {
  onError(error, context) {
    console.log(`onError: handling=${context.handling} ${error.message.slice(0, 60)}`);
  }
});

if (consumer === "response") {
  const event = createRequestEvent(new Request("http://localhost/"));
  const response = await createSSRResponse(result, event);
  console.log(`resolved: status=${response.status} body=${JSON.stringify(await response.text())}`);
} else if (consumer === "pipe") {
  let writes = 0;
  await new Promise(end => result.pipe({ write() { writes++; }, end }));
  console.log(`sink ended after ${writes} write(s)`);
} else {
  console.log(`awaited: ${JSON.stringify(await result)}`);
}
clearTimeout(timer);
Command Output
node repro.mjs root response onError: handling=failed fetch failed, then still pending after 3000 ms
node repro.mjs root pipe onError: handling=failed fetch failed, sink ended after 0 write(s)
node repro.mjs root await onError: handling=failed fetch failed, awaited: ""
node repro.mjs loading response onError: handling=failed <Loading> boundary discovery did not converge after 10001 pa..., then still pending after 3000 ms
node repro.mjs loading pipe same onError, sink ended after 0 write(s)
node repro.mjs loading await same onError, awaited: ""

A wider matrix, one Node process per cell, each cell run both with no hook and with configureServerErrors({ onError }) (identical results either way):

Tree createSSRResponse pipe() await pipeTo()
read resolves (control) 200, full body 1 write, ended full HTML closed
read rejects, no boundary pending 0 writes, ended "" closed, 0 chunks
<Loading> that never converges, shell held by a root read pending 0 writes, ended "" closed, 0 chunks
read rejects under <Errored> 200, fallback 1 write, ended fallback HTML closed
read rejects under <Loading> 200, loading fallback, then the rejected fragment 2 writes, ended HTML closed
read rejects under <Loading>, deferStream: true 200 1 write, ended HTML closed
read rejects under <Errored><Loading> 200 2 writes, ended HTML closed
<Errored><Loading> that never converges, shell held 200, shell plus the serialized error 1 write, ended HTML closed

Only the two trees that reach failRender before the first write hang, and only through createSSRResponse.

Expected behavior

createSSRResponse settles once the render has failed, so the host can answer the request. Resolving with a 5xx Response (or rejecting) would both work. A 200 with an empty body would be worse than the hang, because caches and CDNs would keep it as a valid page.

Analysis

Permalinks are to next at bf87f27 (the rc.12 version bump). createSSRResponse is byte-identical between the rc.11 release commit ee49b3e and bf87f27, and no pending changeset touches it.

  1. The root-hole retry throws inside pipe()'s flush, which contains it through failRootRender (server.ts#L3279-L3286, #L2100-L2115) and then failRender (#L2087-L2094). The <Loading> shape gets there through finalizeError, which has no parent handler (hydration.ts#L360-L413, call at L396).
  2. abandon() hands writable to the failure completions. Before the shell, writable is still undefined, so they run with sink === undefined (#L2010-L2076, see L2072-L2075).
  3. pipe()'s completion then ends the raw sink: sink ? sink.end() : w.end() (#L3264-L3274). This is the [2.0 rc.9] await renderToStream(): an async value that rejects as a direct child of <Loading> throws "reading 'emit'" and the promise never settles #3569 behavior, and ssr-async-rejection-3569.spec.tsx asserts it (#L394-L403).
  4. createSSRResponse only calls resolve from its first write (or from the pre-flush Location branch there). Its end() starts with if (closed || !controller) return;, and controller only exists after that first write (#L6666-L6736, end() at L6715-L6734). An end() with nothing written returns early and the promise is never settled.

This stack trace, printed from a raw pipe() sink's end() with the rc.11 dist, confirms the order for the root case:

Error: end() called
    at Object.end (stack-probe.mjs:11:23)
    at .../@solidjs/web/dist/server.js:2258:33      // pipe(): sink ? sink.end() : w.end()
    at abandon (.../@solidjs/web/dist/server.js:1556:19)
    at failRender (.../@solidjs/web/dist/server.js:1565:5)
    at failRootRender (.../@solidjs/web/dist/server.js:1568:5)
    at .../@solidjs/web/dist/server.js:2268:15      // pipe() flush: catch around doShell()
    at attempt (.../@solidjs/web/dist/server.js:2145:7)

Test coverage: the #3569 (b) tests cover await, raw pipe() and readable. The createSSRResponse tests in http-components.spec.tsx only cover an error caught by <Errored>, so this path has no test.

Suggested fix

Settle the promise when end() arrives before any write, following the pre-flush Location branch in the same function:

       end() {
-        if (closed || !controller) return;
+        if (closed) return;
+        if (!flushed) {
+          // The render failed before the shell: pipe() ended the sink with nothing written.
+          closed = true;
+          if (stub) commitResponseStub(stub, { event });
+          const head = deriveHead(stub, responseInit);
+          const status = stub && stub.headers.get("Location") ? getExpectedRedirectStatus(stub) : 500;
+          resolve(new Response(null, { status, headers: head.headers }));
+          return;
+        }
         const location = stub && stub.headers.get("Location");

I'd resolve with a 500 rather than reject: it keeps createSSRResponse from ever rejecting (no new failure path for callers that don't catch it), and onError has already reported the cause. Rejecting would let host middleware render its own error page, but callers would then have to handle a rejection.

Two details in the diff:

  • The 500 overrides stub.status on purpose: a status set before the failure should not survive a render that produced no page. In practice httpStatus()/httpHeader() declarations made inside the render are already gone by then, because abandon() disposes the render before the failure completions run and the uncommitted declarations revert on cleanup.
  • A Location still on the stub (set outside the render, for example by middleware) keeps the redirect, in the same order as the pre-flush branch in write(). Without that check the host would get a 500 carrying a Location header.

I applied the diff above to a copy of the rc.11 dist. Both hanging trees then resolve with 500 and an empty body, and every other cell in the matrix is unchanged. With event.response.headers.set("Location", "/login") before the render, the same failure resolves with 302 and location: /login; with httpStatus(404) or httpHeader("Location", ...) declared inside the render, it resolves with 500. A regression test fits next to the #3569 (b) tests: the failingPreShell() fixture consumed through createSSRResponse.

A related case with a different cause: aborting options.signal before the shell also leaves createSSRResponse pending. abandon("signal") skips the failure completions on purpose, since the client has left, so this may be intended. Still, middleware that awaits next() never unwinds in that case.

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.11, @solidjs/web 2.0.0-rc.11. createSSRResponse is unchanged on next (bf87f27, 2.0.0-rc.12 not yet published).

Refs #3569.

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