Summary
A single POST to any server-function endpoint can kill the Node process. If an argument in the payload decodes to an already-rejected promise, nothing takes ownership of that promise: it is handed to the server function as an argument, the function does not await an argument it never expected to be a promise, and under Node's default --unhandled-rejections=throw the rejection becomes an uncaught exception that ends the process — taking every other in-flight request with it. The function still runs and the response is still 200; the process dies behind it. The request body is 115 bytes and needs nothing privileged beyond being able to send a raw HTTP request.
This issue merges two symptoms that were reported separately, so a search for either lands here:
- "a decoded atomic promise (seroval node type 12) escapes as an unhandled rejection" — the type-12 node settles synchronously inside
fromCrossJSON, so createJSONDeserializer.abort's sweep never sees it.
- "
abort's value.p.then(undefined, () => {}) half-guard" — the defusing line inside abort that covers only the other promise spelling. It is not a separate cleanup: once ownership is taken where promises are minted, that line is the second half of the same guard, and deleting it is part of collapsing the guard into one place.
Both spellings put the bare promise in the deserializer's refs map, which is why one guard at the mint covers both.
Reproduction
packages/web/rejected-promise-arg-repro.mjs, run with plain node from packages/web (no test runner, so Node's real unhandled-rejection policy is in force). It forks one child per case and reports each child's exit code. The control row is the identical payload with one byte changed — the settlement flag "s" — so the contrast isolates the rejection, not the shape.
// A server function argument that decodes to an already-rejected promise.
import { AsyncLocalStorage } from "node:async_hooks";
import { fork } from "node:child_process";
const CASES = {
// CONTROL: identical bytes but for the settlement flag `"s"`.
control: { where: "body", node: `{"t":12,"i":1,"s":1,"f":{"t":1,"s":"fine"}}` },
body: {
where: "body",
node: `{"t":12,"i":1,"s":0,"f":{"t":13,"i":2,"s":0,"m":"pwned","p":{"k":[],"v":[]}}}`
},
query: {
where: "query",
node: `{"t":12,"i":1,"s":0,"f":{"t":13,"i":2,"s":0,"m":"pwned","p":{"k":[],"v":[]}}}`
},
nested: {
where: "body",
node:
`{"t":10,"i":1,"p":{"k":["deep"],"v":[` +
`{"t":12,"i":2,"s":0,"f":{"t":13,"i":3,"s":0,"m":"pwned","p":{"k":[],"v":[]}}}` +
`],"s":1}}`
}
};
const name = process.argv[2];
if (!name) {
for (const key of Object.keys(CASES)) {
const code = await new Promise(done => {
const child = fork(new URL(import.meta.url), [key], { stdio: "inherit" });
child.on("exit", done);
});
console.log(` ${key.padEnd(8)} exit code ${code}\n`);
}
process.exit(0);
}
globalThis[Symbol.for("solid.RequestContext")] = new AsyncLocalStorage();
const { handleServerFunctionRequest, registerServerFunction } = await import(
"@solidjs/web/server-functions/server"
);
registerServerFunction("greet", async name => `hello ${String(name)}`);
const frame = p =>
`;0x${new TextEncoder().encode(p).byteLength.toString(16).padStart(8, "0")};${p}`;
const { where, node } = CASES[name];
const payload = frame(`{"t":9,"i":0,"a":[${node}],"o":0}`);
const request =
where === "body"
? new Request("https://app.example/_server/data/greet", {
method: "POST",
headers: {
"Content-Type": "text/plain",
"X-Server-Function-Format": "0",
"Sec-Fetch-Site": "same-origin"
},
body: payload
})
: new Request(
`https://app.example/_server/data/greet?args=${encodeURIComponent(payload)}`,
{ method: "POST", headers: { "Sec-Fetch-Site": "same-origin" } }
);
const response = await handleServerFunctionRequest(request, {
provideEvent: (_event, run) => run()
});
console.log(
` ${name.padEnd(8)} ${String(new TextEncoder().encode(payload).byteLength).padStart(3)} bytes` +
` in ${where.padEnd(5)} -> HTTP ${response.status} ${JSON.stringify(await response.text())}`
);
await new Promise(resolve => setTimeout(resolve, 50));
console.log(` ${name.padEnd(8)} process still alive 50ms after the response`);
Measured on a clean worktree of f0f7531b, Node v24.19.0, seroval 1.5.5 (interior stack frames elided, marked):
control 81 bytes in body -> HTTP 200 "hello [object Promise]"
control process still alive 50ms after the response
control exit code 0
body 115 bytes in body -> HTTP 200 "hello [object Promise]"
.../seroval/dist/esm/production/index.mjs:3
Error: pwned
[8 seroval frames elided]
at deserializeJSONChunk (.../packages/web/serialization/dist/decode.js:100:12)
at interpretChunk (.../packages/web/server-functions/dist/server.js:484:14)
Node.js v24.19.0
body exit code 1
query 115 bytes in query -> HTTP 200 "hello [object Promise]"
Error: pwned
Node.js v24.19.0
query exit code 1
nested 161 bytes in body -> HTTP 200 "hello [object Object]"
Error: pwned
Node.js v24.19.0
nested exit code 1
The control is the row that behaves: same node type, same framing, same route, exit code 0. Every rejected row answers 200 and then exits 1. Note the nested row: the promise one level down inside an ordinary object argument kills the process just the same, so this is not a top-level-argument special case.
With the fix applied to the same worktree and rebuilt, all four rows read exit code 0 and print process still alive 50ms after the response.
Two facts worth stating alongside the repro, both measured:
- The encoder never emits this node. Serializing
[Promise.resolve("fine")] through createJSONSerializer produces {"t":22,...} (PromiseConstructor) plus a {"t":23,...} patch chunk — never {"t":12} (SerovalNodeType.Promise, seroval dist/types/core/constants.d.ts:25). The atomic spelling is decode-only capability; no honest Solid peer writes it. The wire is text, so a peer writes whatever it likes.
- Arguments are decoded before the function body runs.
parseArguments runs in dispatch ahead of the call, so a function that authenticates inside its own body has already lost.
Where
Against pristine f0f7531b:
packages/web/serialization/src/serializer-decode.ts:306-308 — deserializeJSONChunk returns fromCrossJSON(node, { refs, ...resolved }) and takes no ownership of any promise the call minted. This is the defect.
packages/web/serialization/src/serializer-decode.ts:320-334 — deserializeJSONChunk.abort. Its guard admits only the {p, s, f} resolver triple; line 330, value.p.then(undefined, () => {}), is the defusing half that exists precisely because a decoded promise can be a rejection nobody holds. It cannot reach the atomic spelling: that promise has already settled and Node has already reported it by the time abort runs.
Consumers of that boundary, both legs:
packages/web/server-functions/src/shared.ts:1192-1195 (decoder created), :1217-1218 (abort wired to stream end/error) — inside deserializeStream, reached from extractBody (shared.ts:907).
- Request leg:
packages/web/server-functions/src/server.ts:1316, parseArguments.
- Response leg:
packages/web/server-functions/src/shared.ts:1264, decodeResponse.
Provenance: this code entered the repo in 89a0531 ("Absorb expressions into Solid and collapse the rxcore seam.", 2026-08-25), which lifted the runtime out of @dom-expressions/runtime — before that, packages/solid-web/serialization/src/index.ts was a bare re-export (9b4dd76d, 2026-07-14), so there is no earlier revision of the body in this history. 7182195 ("Migrate the absorbed DOM runtime to TypeScript and flatten it into feature folders.", 2026-08-25) moved it to its present path and ported it to TS. The one-leg guard is the shape that was absorbed; no neighbouring fix created it.
Verified with:
git log --oneline -S 'value.p.then(undefined, () => {})' -- packages/web/serialization/src/serializer-decode.ts
git log --diff-filter=A --oneline -1 -- packages/web/src/serializer-decode.js
The fix in the tree I am proposing sits at packages/web/serialization/src/serializer-decode.ts:320-372 (the owned cursor, ownDecodedPromises(), and the try/finally around every fromCrossJSON) and :374-392 (abort drops its own defusing line and keeps only its settling job).
Why it matters
The realistic path: an app deploys server functions on Node with the default unhandled-rejection policy. Anyone who can send a raw HTTP request to the origin sends the 115-byte body above at an endpoint whose function id they read out of the client bundle the compiler emits. The pod exits. Every concurrent request on that pod fails, on every tenant it serves. Repeat at will; there is no rate limiting on process death. This is availability only — no auth bypass, no data disclosure, no code execution.
The honest limits on reachability, all of which I measured:
- A browser page cannot do this cross-origin. The request is refused with
403 without Sec-Fetch-Site: same-origin (measured: status 403 "", process alive). Sec-Fetch-Site is a forbidden header name, so page script cannot forge it. It is one flag to curl, one line in any server-side HTTP client, and it is set correctly by anything that is genuinely same-origin. So the attacker is "anyone who can make an HTTP request to the origin", not "any web page a victim visits". That is a real reduction in severity and it should be read that way.
- It depends on Node's default policy. Under
--unhandled-rejections=warn, or with a process.on("unhandledRejection") handler installed (which many production adapters and error reporters do install), the result is a log line instead of an exit. Deployments that already do either of those are not killed — they just log an error they cannot explain.
- Non-Node runtimes differ. In a browser an unhandled rejection is a console error. Workerd/Deno have their own policies. The process-death outcome is the Node story.
- The response leg is exposed by the same boundary, since
decodeResponse goes through the same deserializeStream. That matters for a Node-side consumer of a server-function response — SSR, a server-to-server call, a test harness — decoding a hostile or compromised peer's body. In the browser it is a console error.
I am not claiming a remote-code-execution class of bug. I am claiming that a well-formed request that the runtime answers 200 can end the process, and that the boundary that decodes a peer's bytes should not be able to hand the runtime a rejection nobody holds.
Options
A. Take ownership at the mint (what the attached fix does). Keep a cursor of how many refs entries have been claimed; after every fromCrossJSON call, in a finally, attach .then(undefined, () => {}) to each newly-minted Promise in refs. Then delete abort's value.p.then(...) line, which the mint sweep now subsumes.
Trade-off: one extra rejection handler and one microtask per decoded promise; the refs cursor keeps it amortized O(1) per node rather than O(refs) per chunk (seroval never reassigns a ref id — a second write throws "Conflicted ref id" — so the map only grows and iterates in insertion order). It hides nothing: p still rejects for whoever actually awaits it; only the "nobody at all" case is covered. It is exactly what the encode side already does for the promises it mints (guardFailures in server.ts: "Keep a fallback owner on the promise WE minted"), so the two halves of the codec end up stating the same rule.
B. Refuse the atomic promise node on decode. Since no Solid encoder writes {"t":12}, reject it as a malformed payload (a 400 on the argument leg).
Trade-off: narrower blast radius, and arguably the more principled answer to "why does our decoder read grammar we never write". But it is a change to the wire grammar Solid accepts, it needs a node-type walk that seroval does not expose cheaply for cross-JSON, it breaks any non-Solid peer legitimately using the full seroval grammar, and it does not remove the need for a guard on the constructor-pair leg — so abort keeps its defusing line and there are still two guards for one rule. Whether Solid's decoder promises to read all of seroval's grammar or only what Solid writes is a maintainer's call, and it is the one decision here that is a policy choice rather than a bug fix.
C. Push it to the host. Document that Node deployments must run with --unhandled-rejections=warn or install a process-level handler.
Trade-off: zero runtime code and zero cost. But it makes every adapter and every app responsible for a detail of the codec they cannot see, and the policy people actually run is the default one. Runtime guard vs. documented adapter requirement is also legitimately the maintainer's call — I think the runtime should carry it, but the argument for C is that Solid does not otherwise take responsibility for a host's process policy.
D. Guard at the server-function argument boundary instead. Walk the decoded parsed array and attach a catch to any promise in it, the way stripUnsafeArgumentKeys already walks that graph.
Trade-off: keeps the codec free of defensive handlers. But it fixes only the request leg — decodeResponse and every other consumer of createJSONDeserializer stay exposed — and it duplicates a walk the codec gets for free at the mint, where it already knows exactly which values it just created.
Recommendation: A. In Solid's minimalism terms it wins because it is the only option that makes the count of guards go down. The rule is "a promise this decoder minted has an owner"; A states it once, at the single place both spellings pass through, and lets an existing line be deleted rather than adding a second one beside it. B and D each leave two guards for one rule; C leaves zero and moves the rule off the library. A also puts the guard where the information is: at the mint the decoder knows precisely what it just created, which is why the cursor makes it cheap, and why it covers the nested case in the repro without a graph walk.
The proof that the deletion in A is part of the fix and not an unrelated tidy-up: with the mint sweep stubbed out but abort's defusing line already removed, a truncated stream carrying a constructor-pair promise dies with
chunks emitted: 1; decoded[0] is a Promise: true
Error: truncated
at .../pair-probe-tmp.mjs:16:14
Node.js v24.19.0
With the mint sweep in place, the same probe prints process still alive after abort. The two halves are one guard.
Regression test
packages/web/test/server/server-functions-rejected-promise-arguments.spec.tsx — three cases: the rejected promise arriving in the codec body (with the fulfilled-flag control asserted first, since the two frames are the same bytes but for "s"), the same frame riding the url's args, and the same promise one level down inside a plain object argument. Each takes over process.on("unhandledRejection") for the length of one dispatch and asserts nothing escaped — the process-level answer is the finding, so it is asserted by name rather than left to vitest's own file-level error channel. Like the other server-function specs it runs against the built bundles.
const REJECTED = `{"t":12,"i":1,"s":0,"f":{"t":13,"i":2,"s":0,"m":"pwned","p":{"k":[],"v":[]}}}`;
const FULFILLED = `{"t":12,"i":1,"s":1,"f":{"t":1,"s":"fine"}}`;
const NESTED_REJECTED =
`{"t":10,"i":1,"p":{"k":["deep"],"v":[` +
`{"t":12,"i":2,"s":0,"f":{"t":13,"i":3,"s":0,"m":"pwned","p":{"k":[],"v":[]}}}` +
`],"s":1}}`;
/**
* Runs one dispatch owning `unhandledRejection`, and reports what escaped.
* Two macrotask turns: Node emits the event after the microtask queue
* drains, and the argument graph settles inside the dispatch's own await.
*/
async function watchRejections(run: () => Promise<Response>) {
const previous = process.listeners("unhandledRejection");
process.removeAllListeners("unhandledRejection");
const escaped: string[] = [];
const capture = (reason: unknown) => escaped.push(String(reason));
process.on("unhandledRejection", capture);
try {
const response = await run();
await new Promise(resolve => setTimeout(resolve, 0));
await new Promise(resolve => setTimeout(resolve, 0));
return { escaped, status: response.status };
} finally {
process.off("unhandledRejection", capture);
for (const listener of previous) process.on("unhandledRejection", listener as any);
}
}
describe("a decoded argument that is an already-rejected promise", () => {
it("does not escape as an unhandled rejection when it arrives in the body", async () => {
// the control first: the same node with the fulfilled flag, which is
// the only byte that differs, and which nothing about this boundary
// should treat specially
const control = registerProbe();
const fulfilled = await watchRejections(() =>
handleServerFunctionRequest(codecBody(control.id, FULFILLED), { provideEvent })
);
expect(
fulfilled.escaped,
`the fulfilled control escaped: ${fulfilled.escaped.join(", ")}`
).toEqual([]);
const probe = registerProbe();
const { escaped, status } = await watchRejections(() =>
handleServerFunctionRequest(codecBody(probe.id, REJECTED), { provideEvent })
);
expect(
escaped,
`status=${status} ran=${probe.ran} argumentIsPromise=${probe.seen instanceof Promise}` +
` — an unhandled rejection here is process death under Node's default policy`
).toEqual([]);
});
it("does not escape when the codec frame rides the url's args instead", async () => {
const probe = registerProbe();
const { escaped, status } = await watchRejections(() =>
handleServerFunctionRequest(codecQuery(probe.id, REJECTED), { provideEvent })
);
expect(escaped, `status=${status} ran=${probe.ran}`).toEqual([]);
});
it("does not escape when it sits inside an ordinary object argument", async () => {
const probe = registerProbe();
const { escaped, status } = await watchRejections(() =>
handleServerFunctionRequest(codecBody(probe.id, NESTED_REJECTED), { provideEvent })
);
expect(
escaped,
`status=${status} ran=${probe.ran} — the guard must cover the whole argument` +
` graph, the way stripUnsafeArgumentKeys already walks it`
).toEqual([]);
});
});
It goes red against the mint sweep: with ownDecodedPromises's value.then(undefined, () => {}) reverted and everything else in place, all three cases fail with expected [ 'Error: pwned' ] to deeply equal [] (Test Files 1 failed (1) / Tests 3 failed (3)); with the sweep in place, Tests 3 passed (3).
Summary
A single POST to any server-function endpoint can kill the Node process. If an argument in the payload decodes to an already-rejected promise, nothing takes ownership of that promise: it is handed to the server function as an argument, the function does not await an argument it never expected to be a promise, and under Node's default
--unhandled-rejections=throwthe rejection becomes an uncaught exception that ends the process — taking every other in-flight request with it. The function still runs and the response is still200; the process dies behind it. The request body is 115 bytes and needs nothing privileged beyond being able to send a raw HTTP request.This issue merges two symptoms that were reported separately, so a search for either lands here:
fromCrossJSON, socreateJSONDeserializer.abort's sweep never sees it.abort'svalue.p.then(undefined, () => {})half-guard" — the defusing line insideabortthat covers only the other promise spelling. It is not a separate cleanup: once ownership is taken where promises are minted, that line is the second half of the same guard, and deleting it is part of collapsing the guard into one place.Both spellings put the bare promise in the deserializer's
refsmap, which is why one guard at the mint covers both.Reproduction
packages/web/rejected-promise-arg-repro.mjs, run with plainnodefrompackages/web(no test runner, so Node's real unhandled-rejection policy is in force). It forks one child per case and reports each child's exit code. Thecontrolrow is the identical payload with one byte changed — the settlement flag"s"— so the contrast isolates the rejection, not the shape.Measured on a clean worktree of
f0f7531b, Node v24.19.0, seroval 1.5.5 (interior stack frames elided, marked):The control is the row that behaves: same node type, same framing, same route,
exit code 0. Every rejected row answers200and then exits1. Note thenestedrow: the promise one level down inside an ordinary object argument kills the process just the same, so this is not a top-level-argument special case.With the fix applied to the same worktree and rebuilt, all four rows read
exit code 0and printprocess still alive 50ms after the response.Two facts worth stating alongside the repro, both measured:
[Promise.resolve("fine")]throughcreateJSONSerializerproduces{"t":22,...}(PromiseConstructor) plus a{"t":23,...}patch chunk — never{"t":12}(SerovalNodeType.Promise, serovaldist/types/core/constants.d.ts:25). The atomic spelling is decode-only capability; no honest Solid peer writes it. The wire is text, so a peer writes whatever it likes.parseArgumentsruns in dispatch ahead of the call, so a function that authenticates inside its own body has already lost.Where
Against pristine
f0f7531b:packages/web/serialization/src/serializer-decode.ts:306-308—deserializeJSONChunkreturnsfromCrossJSON(node, { refs, ...resolved })and takes no ownership of any promise the call minted. This is the defect.packages/web/serialization/src/serializer-decode.ts:320-334—deserializeJSONChunk.abort. Its guard admits only the{p, s, f}resolver triple; line 330,value.p.then(undefined, () => {}), is the defusing half that exists precisely because a decoded promise can be a rejection nobody holds. It cannot reach the atomic spelling: that promise has already settled and Node has already reported it by the timeabortruns.Consumers of that boundary, both legs:
packages/web/server-functions/src/shared.ts:1192-1195(decoder created),:1217-1218(abortwired to stream end/error) — insidedeserializeStream, reached fromextractBody(shared.ts:907).packages/web/server-functions/src/server.ts:1316,parseArguments.packages/web/server-functions/src/shared.ts:1264,decodeResponse.Provenance: this code entered the repo in 89a0531 ("Absorb expressions into Solid and collapse the rxcore seam.", 2026-08-25), which lifted the runtime out of
@dom-expressions/runtime— before that,packages/solid-web/serialization/src/index.tswas a bare re-export (9b4dd76d, 2026-07-14), so there is no earlier revision of the body in this history. 7182195 ("Migrate the absorbed DOM runtime to TypeScript and flatten it into feature folders.", 2026-08-25) moved it to its present path and ported it to TS. The one-leg guard is the shape that was absorbed; no neighbouring fix created it.Verified with:
The fix in the tree I am proposing sits at
packages/web/serialization/src/serializer-decode.ts:320-372(theownedcursor,ownDecodedPromises(), and thetry/finallyaround everyfromCrossJSON) and:374-392(abortdrops its own defusing line and keeps only its settling job).Why it matters
The realistic path: an app deploys server functions on Node with the default unhandled-rejection policy. Anyone who can send a raw HTTP request to the origin sends the 115-byte body above at an endpoint whose function id they read out of the client bundle the compiler emits. The pod exits. Every concurrent request on that pod fails, on every tenant it serves. Repeat at will; there is no rate limiting on process death. This is availability only — no auth bypass, no data disclosure, no code execution.
The honest limits on reachability, all of which I measured:
403withoutSec-Fetch-Site: same-origin(measured:status 403 "", process alive).Sec-Fetch-Siteis a forbidden header name, so page script cannot forge it. It is one flag tocurl, one line in any server-side HTTP client, and it is set correctly by anything that is genuinely same-origin. So the attacker is "anyone who can make an HTTP request to the origin", not "any web page a victim visits". That is a real reduction in severity and it should be read that way.--unhandled-rejections=warn, or with aprocess.on("unhandledRejection")handler installed (which many production adapters and error reporters do install), the result is a log line instead of an exit. Deployments that already do either of those are not killed — they just log an error they cannot explain.decodeResponsegoes through the samedeserializeStream. That matters for a Node-side consumer of a server-function response — SSR, a server-to-server call, a test harness — decoding a hostile or compromised peer's body. In the browser it is a console error.I am not claiming a remote-code-execution class of bug. I am claiming that a well-formed request that the runtime answers
200can end the process, and that the boundary that decodes a peer's bytes should not be able to hand the runtime a rejection nobody holds.Options
A. Take ownership at the mint (what the attached fix does). Keep a cursor of how many
refsentries have been claimed; after everyfromCrossJSONcall, in afinally, attach.then(undefined, () => {})to each newly-mintedPromiseinrefs. Then deleteabort'svalue.p.then(...)line, which the mint sweep now subsumes.Trade-off: one extra rejection handler and one microtask per decoded promise; the
refscursor keeps it amortized O(1) per node rather than O(refs) per chunk (seroval never reassigns a ref id — a second write throws "Conflicted ref id" — so the map only grows and iterates in insertion order). It hides nothing:pstill rejects for whoever actually awaits it; only the "nobody at all" case is covered. It is exactly what the encode side already does for the promises it mints (guardFailuresinserver.ts: "Keep a fallback owner on the promise WE minted"), so the two halves of the codec end up stating the same rule.B. Refuse the atomic promise node on decode. Since no Solid encoder writes
{"t":12}, reject it as a malformed payload (a400on the argument leg).Trade-off: narrower blast radius, and arguably the more principled answer to "why does our decoder read grammar we never write". But it is a change to the wire grammar Solid accepts, it needs a node-type walk that seroval does not expose cheaply for cross-JSON, it breaks any non-Solid peer legitimately using the full seroval grammar, and it does not remove the need for a guard on the constructor-pair leg — so
abortkeeps its defusing line and there are still two guards for one rule. Whether Solid's decoder promises to read all of seroval's grammar or only what Solid writes is a maintainer's call, and it is the one decision here that is a policy choice rather than a bug fix.C. Push it to the host. Document that Node deployments must run with
--unhandled-rejections=warnor install a process-level handler.Trade-off: zero runtime code and zero cost. But it makes every adapter and every app responsible for a detail of the codec they cannot see, and the policy people actually run is the default one. Runtime guard vs. documented adapter requirement is also legitimately the maintainer's call — I think the runtime should carry it, but the argument for C is that Solid does not otherwise take responsibility for a host's process policy.
D. Guard at the server-function argument boundary instead. Walk the decoded
parsedarray and attach a catch to any promise in it, the waystripUnsafeArgumentKeysalready walks that graph.Trade-off: keeps the codec free of defensive handlers. But it fixes only the request leg —
decodeResponseand every other consumer ofcreateJSONDeserializerstay exposed — and it duplicates a walk the codec gets for free at the mint, where it already knows exactly which values it just created.Recommendation: A. In Solid's minimalism terms it wins because it is the only option that makes the count of guards go down. The rule is "a promise this decoder minted has an owner"; A states it once, at the single place both spellings pass through, and lets an existing line be deleted rather than adding a second one beside it. B and D each leave two guards for one rule; C leaves zero and moves the rule off the library. A also puts the guard where the information is: at the mint the decoder knows precisely what it just created, which is why the cursor makes it cheap, and why it covers the nested case in the repro without a graph walk.
The proof that the deletion in A is part of the fix and not an unrelated tidy-up: with the mint sweep stubbed out but
abort's defusing line already removed, a truncated stream carrying a constructor-pair promise dies withWith the mint sweep in place, the same probe prints
process still alive after abort. The two halves are one guard.Regression test
packages/web/test/server/server-functions-rejected-promise-arguments.spec.tsx— three cases: the rejected promise arriving in the codec body (with the fulfilled-flag control asserted first, since the two frames are the same bytes but for"s"), the same frame riding the url'sargs, and the same promise one level down inside a plain object argument. Each takes overprocess.on("unhandledRejection")for the length of one dispatch and asserts nothing escaped — the process-level answer is the finding, so it is asserted by name rather than left to vitest's own file-level error channel. Like the other server-function specs it runs against the built bundles.It goes red against the mint sweep: with
ownDecodedPromises'svalue.then(undefined, () => {})reverted and everything else in place, all three cases fail withexpected [ 'Error: pwned' ] to deeply equal [](Test Files 1 failed (1) / Tests 3 failed (3)); with the sweep in place,Tests 3 passed (3).