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
- 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.
- 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.
- 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.
Summary
When a server function's result graph fails to encode and that graph also contains a promise that rejects, the promise
guardFailuresderives from it is abandoned in a rejected state. Nothing ever attaches a handler, so Node's defaultunhandledRejectionpolicy 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
Observed on
next@ee73e053, Node 24.19.0, against a freshly builtpackages/web:still alivenever prints. The response was already returned — the process dies afterwards.Where
packages/web/server-functions/src/server.ts:1934-1941guardFailureswraps 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
f739ec34fix(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:
Datesubclass, aWeakMap, a class instance of any kind;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;Rebuilt
packages/webbetween runs; source restored and verified clean afterwards.Options
catchonly ensures the derived promise is never parentless. This is what I measured.state.seenand settle what it abandoned. Correct, but it makes the failure path know about the guard's bookkeeping.unhandledRejectionhandler. 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
Reverting the fix makes
unhandledcarry the sanitized error, so the test earns its place.