Skip to content

A rejecting promise in a result graph that fails to encode takes down the Node process #3216

Description

@frenzzy

Summary

When a server function's result graph fails to encode and that graph also contains a promise that rejects, the promise guardFailures derives from it is abandoned in a rejected state. Nothing ever attaches a handler, so Node's default unhandledRejection policy terminates the process.

One request, from ordinary application code, takes the server down — and it fires after the response has already been sent, so the crash is not attributable to the request that caused it in any access log.

Reproduction

// NODE_ENV=production node repro.mjs
import { AsyncLocalStorage } from "node:async_hooks";
globalThis[Symbol.for("solid.RequestContext")] = new AsyncLocalStorage();
const { handleServerFunctionRequest, registerServerFunction } =
  await import("@solidjs/web/server-functions/server");

class UserRow {           // an ORM entity: a class instance the codec refuses
  constructor(id) { this.id = id; }
}

registerServerFunction("report", () => ({
  rows:  [new UserRow(1)],                     // makes the encode fail
  stats: Promise.reject(new Error("db down"))  // rejects; nobody handles it
}));

const response = await handleServerFunctionRequest(
  new Request("http://localhost/_server/data/report", {
    method: "POST",
    body: "[]",
    headers: {
      "Sec-Fetch-Site": "same-origin",
      "content-type": "application/json",
      "x-server-function-format": "8"
    }
  })
);
console.log("status:", response.status, "body:", await response.text());
setTimeout(() => console.log("still alive"), 500);

Observed on next @ ee73e053, Node 24.19.0, against a freshly built packages/web:

status: 200 body: ;0x00000024;!{"message":"Internal Server Error"}

Error: Internal Server Error
    at sanitizeServerError (.../server-functions/dist/server.js:1581:10)
Node.js v24.19.0
>>> EXIT CODE: 1

still alive never prints. The response was already returned — the process dies afterwards.

Where

packages/web/server-functions/src/server.ts:1934-1941

const guardedPromise = Promise.resolve(value).then(
  resolved => guardFailures(resolved, state),
  error => {
    throw sanitizeServerError(error);   // <- re-thrown into a promise nobody keeps
  }
);
state.seen.set(value, guardedPromise);
return guardedPromise;

guardFailures wraps every promise in the graph so a rejection is sanitized before it reaches the wire. That contract holds only while the encode consumes the derived promise. When the encode aborts for an unrelated reason — here, a sibling the codec refuses — the derived promise is dropped while rejected, and the rejection has nowhere to go.

Introduced in f739ec34 fix(web): sanitize failures that escape through the result graph (#3113), which added the guard walk. The guard is right; it just has no owner when the encode does not finish.

Why the trigger is ordinary

The two ingredients are independently common in real code, and neither looks dangerous:

  • an un-encodable value in the graph — an ORM entity, a Date subclass, a WeakMap, a class instance of any kind;
  • a promise that rejects — any un-awaited query handed back for streaming.

Encode failures are already handled gracefully (200 + an in-band error trailer; the client correctly rejects — I checked, there is no false success here). The crash is a separate consequence of the same failure.

Mutation test

Attaching an owner to the derived promise flips the verdict, and only for this cause:

   state.seen.set(value, guardedPromise);
+  guardedPromise.catch(() => {});
   return guardedPromise;
with .catch()  -> EXIT=0   ("still alive" prints)
restored       -> EXIT=1

Rebuilt packages/web between runs; source restored and verified clean afterwards.

Options

  1. Attach a no-op owner where the guard is created (the diff above). One line, at the exact place the unowned promise is minted. The rejection still reaches the encode through the returned reference when the encode does run; the catch only ensures the derived promise is never parentless. This is what I measured.
  2. Have the encode failure path walk state.seen and settle what it abandoned. Correct, but it makes the failure path know about the guard's bookkeeping.
  3. Leave it to the host and document that a Solid server needs an unhandledRejection handler. This pushes a runtime invariant onto every adapter, and the default Node policy is termination.

(1) reads as the minimal one to me, but the choice of whether an abandoned guard should be silently swallowed or reported is yours — swallowing it means a genuinely lost rejection is never observed anywhere.

Regression test

it("does not abandon a rejected guard when the encode fails", async () => {
  const unhandled = [];
  process.on("unhandledRejection", e => unhandled.push(e));
  registerServerFunction(id, () => ({
    rows:  [new (class Row { constructor() { this.id = 1; } })()],
    stats: Promise.reject(new Error("db down"))
  }));
  const response = await handleServerFunctionRequest(post(id));
  await response.text();
  await new Promise(r => setImmediate(r));   // let the rejection settle
  expect(unhandled).toEqual([]);
});

Reverting the fix makes unhandled carry the sanitized error, so the test earns its place.

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