You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
## Summary`bodySizeLimit` bounds whatever number the peer put in `Content-Length`, not the bytes that actually arrive. Dispatch decided how to pay for a POST body from the declaration alone — `if (!(declared > 0))` at `server.ts:2950` — so any positive digit string skipped the counting read entirely: with a 1 MiB cap configured, `Content-Length: 10` on a 2 MiB body answers 200 and hands the whole 2 097 152-byte argument to the function. The same gate also decides whether the request gets an upload lifecycle, because the signal/reader coupling added in `e220cfae` (#3217/#3218/#3219) lives *inside*`bufferBodyWithin`: a client that hangs up mid-upload only settles and only cancels its source when the body declared no length, so the abort fix currently covers chunked uploads and not the conforming-`Content-Length` POST that every browser, every `fetch` with a string/FormData body, and the shipped client stub sends.
These are one defect, not two. They are the same omission read from two sides — the gate is what makes `bufferBodyWithin` a side road — and one hunk closes both: counting the arriving bytes *is* the read that carries the teardown.
Filed as one issue because a reader may arrive searching for either symptom: **"bodySizeLimit not enforced / body cap ignored when the request sends a Content-Length"** and **"server function never responds after the client disconnects mid-upload; upload source never cancelled"**.
## Reproduction`@solidjs/web@2.0.0-rc.6`, node v24.19.0, run against the built `server-functions/dist/server.js`.
```js// repro-body-cap.mjsimport { AsyncLocalStorage } from"node:async_hooks";
import {
handleServerFunctionRequest,
registerServerFunction
} from"@solidjs/web/server-functions/server";
globalThis[Symbol.for("solid.RequestContext")] =newAsyncLocalStorage();
constMIB=1024*1024;
constLIMIT=1*MIB; // the cap the app configureslet bytesReachingFunction =null;
registerServerFunction("sink", asyncpayload=> {
bytesReachingFunction =typeof payload ==="string"?payload.length:-1;
return"reached";
});
constHEADERS= {
"Sec-Fetch-Site":"same-origin",
"X-Server-Function-Instance":"server-function:test",
"X-Server-Function-Format":"8"// JSON argument encoding
};
asyncfunctionpost(bodyBytes, declaration) {
bytesReachingFunction =null;
constbody=JSON.stringify(["x".repeat(bodyBytes)]);
constheaders= { ...HEADERS };
// undici only computes Content-Length at fetch time, so a Request built// here declares exactly this and nothing otherwise.if (declaration !==null) headers["content-length"] = declaration;
constres=awaithandleServerFunctionRequest(
newRequest("https://app.example/_server/data/sink", { method:"POST", body, headers }),
{ bodySizeLimit:LIMIT }
);
console.log(
`cap=1MiB body=${(Buffer.byteLength(body) /MIB).toFixed(2)}MiB `+`Content-Length: ${declaration ??"(absent)"}`.padEnd(30) +`-> ${res.status} bytesReachingFunction=${bytesReachingFunction ??"none"}` );}console.log("--- A. the cap is decided by the declaration ---");await post(2 * MIB, null); // CONTROL: chunked upload, cap holdsawait post(2 * MIB, String(2 * MIB + 4)); // CONTROL: honest over-declaration, refused unreadawait post(4096, "4100"); // CONTROL: ordinary POST under the capawait post(2 * MIB, "10"); // REPRO: under-declared 2 MiB bodyconsole.log("\n--- B. the same gate carries the abort teardown ---");async function abortMidUpload(declaration) { let cancelled = false; let start; const pulled = new Promise(r => (start = r)); const abort = new AbortController(); const source = new ReadableStream({ start: c => c.enqueue(new Uint8Array([91])), // "[" pull: () => (start(), new Promise(() => {})), // the client is still uploading cancel: () => (cancelled = true) }); const headers = { ...HEADERS }; if (declaration !== null) headers["content-length"] = declaration; const settled = handleServerFunctionRequest( new Request("https://app.example/_server/data/sink", { method: "POST", body: source, duplex: "half", signal: abort.signal, headers }) ).then(r => `status=${r.status}`, e => `threw=${e?.name}`); await pulled; await new Promise(r => setTimeout(r, 60)); abort.abort(new DOMException("client gone", "AbortError")); // the peer hangs up const outcome = await Promise.race([ settled, new Promise(r => setTimeout(() => r("PENDING"), 800)) ]); console.log(`Content-Length: ${declaration ??"(absent)"}`.padEnd(30) +`-> settled=${outcome} uploadSourceCancelled=${cancelled}` );}await abortMidUpload(null); // CONTROL: chunked uploadawait abortMidUpload("200"); // REPRO: the declaration every browser sendsprocess.exit(0);
Measured output:
--- A. the cap is decided by the declaration ---
cap=1MiB body=2.00MiB Content-Length: (absent) -> 413 bytesReachingFunction=none
cap=1MiB body=2.00MiB Content-Length: 2097156 -> 413 bytesReachingFunction=none
cap=1MiB body=0.00MiB Content-Length: 4100 -> 200 bytesReachingFunction=4096
cap=1MiB body=2.00MiB Content-Length: 10 -> 200 bytesReachingFunction=2097152
--- B. the same gate carries the abort teardown ---
Content-Length: (absent) -> settled=status=400 uploadSourceCancelled=true
Content-Length: 200 -> settled=PENDING uploadSourceCancelled=false
The first three rows of A are the controls, and they are the point as much as the repro: an undeclared oversized body is refused after the counting read, an honest over-declaration is refused before a byte is read, and an ordinary 4 KiB POST is delivered whole. Only the last row differs, and the only thing that differs about it is a header. In B the two rows are the same request with the same abort at the same moment; the declaration is the only difference, and it decides whether the handler ever answers.
For contrast, the same script against a tree with the gate removed gives 413 / bytesReachingFunction=none on A's last row and settled=status=400 uploadSourceCancelled=true on both B rows.
Where
Line numbers are packages/web/server-functions/src/server.ts at f0f7531b.
server.ts:2932-2978, gate at server.ts:2950 — if (!(declared > 0)). Everything below it (the counting read, the 413, the 400-on-abort) is inside the gate; a positive conforming declaration skips all of it and request is passed through untouched.
server.ts:1223-1272 — bufferBodyWithin, whose docstring still describes itself as the handler for "a POST body that declared no length (chunked transfer)". The signal/reader coupling is at server.ts:1236-1246, inside that function and therefore inside the gate.
929642bc — "fix: negative Content-Length no longer bypasses bodySizeLimit (A negative Content-Length satisfies neither bodySizeLimit guard, so the body streams in uncapped #3153)" is the neighbouring fix that hardened this guard and left the trust standing: it narrowed the parse to a conforming digit string and respelled the gate !(declared > 0), closing Content-Length: -1. Its own commit message establishes the premise this issue turns on — that the header arrives from "hand-rolled adapters, laxer edge parsers, and rewriting proxies" and is not evidence. -1 from that producer is not believed; 10 on a 2 MiB body is.
Be clear about (a) first: I did not reproduce it through a stock node:http server, and I do not believe it is reachable there. llhttp frames the request body by the declaration and rejects Content-Length together with Transfer-Encoding, so a socket carrying 2 MiB behind Content-Length: 10 is truncated at 10 bytes long before this code runs. This is defence in depth on the path #3153 already decided to defend, not a live bypass of a Node deployment.
The exposure is every producer that builds the Request itself and is not llhttp: adapters for non-Node runtimes, a proxy or edge worker that rewrites the body without rewriting the header, an integration assembling a Request from its own transport, a test harness. That population is not hypothetical — it is exactly the one 929642bc named when it decided this header is not evidence — but reaching it takes an unusual integration or a non-browser client, and the report is not worth more than that. What is unconditionally true is that the invariant the option advertises (bodySizeLimit bounds what gets buffered and decoded before your code can decline it, #3115) is not the invariant the code holds, and a reader of the option cannot tell.
(b) is the half that costs a running process, and it sits on the ordinary road. Fetch genuinely does not couple a Request's signal to its body stream — that is why #3218 exists — so when the host abandons the request, nothing wakes the pending read. In the measurement above the handler simply never settles: the request task, its buffered chunks and the upload source stay resident for the life of the process, and a peer that can abort can open another. The honest limit here is that I measured handleServerFunctionRequest directly, not a full adapter end to end: an adapter that independently errors the body stream when the socket closes will mask the leak, and adapters differ on that. Where the adapter does not, #3218's fix is installed on the chunked side road while the traffic — every browser POST, every fetch with a string or FormData body, the shipped client stub — takes the declared one.
Options
Route every capped POST through the counting read; keep the declaration only as a pre-read refusal. Delete the gate. A conforming declaration already over the limit still answers 413 without reading a byte (that is the one thing a declaration can honestly do, and server-functions-request-bounds.spec.tsx:77 pins it); everything else is bounded by the bytes that arrive, and the abort coupling is installed once, on the only road. Cost: the codec-framed road currently streams into deserializeStream, and this replaces that with one buffer of at most bodySizeLimit bytes. The other decode roads (.text(), .formData(), .arrayBuffer()) already materialize the whole payload before dispatch, so for them the added cost is one copy of an already-bounded payload, not a new materialization. bodySizeLimit: Infinity remains the way to opt a route out of buffering entirely.
Keep the fast path, but pipe the declared body through a counting TransformStream that errors past the limit. Preserves streaming decode for the codec road. Costs a stream wrapper on every capped request, and leaves two roads that each have to get the signal/reader coupling right — which is the shape that produced this issue in the first place.
Fix only (b): hoist the signal/reader coupling above the gate. Settles the abandoned request and leaves the cap decided by the peer. It is also not much cheaper than (1): coupling the signal to the body needs a reader on the declared road, which is most of the counting read already.
Change nothing; document it. State in bodySizeLimit's docs that it trusts a conforming Content-Length and that enforcing the framing is the adapter's job. Zero runtime cost and honest to a reader — but it contradicts the decision 929642bc made about the same header from the same producer, and it does not touch (b).
Push the bound into the adapters. Arguably where the framing knowledge actually lives. But Solid ships the option, so the option would then not mean what its name says, and every adapter would have to relearn it.
Two of these are judgement calls that belong to you rather than to a reporter. The first is whether bodySizeLimit is a promise about bytes buffered or a hint the HTTP layer is expected to enforce — that is the choice between (1) and (4), and it is a behaviour change versus a documentation change. The second, if you take (1), is whether trading the codec road's streaming decode for one bounded buffer is acceptable, or whether (2)'s counting transform earns its extra moving part.
My recommendation is (1), on Solid's own minimalism terms: it deletes code rather than adding a mechanism, it leaves one road where there were two, the upload lifecycle is wired in exactly one place instead of needing to be kept in sync, and the declaration keeps precisely the one job it can do without being believed. (b) has no separate fix in this shape — closing (a) requires counting the arriving bytes, and that read is what carries the teardown.
Regression test
Two specs, both against the built bundles (wired up in vite.config.server.mjs like the other server-function specs). Doc comments trimmed here for length.
import{AsyncLocalStorage}from"node:async_hooks";import{afterAll,beforeAll,describe,expect,it}from"vitest";import{handleServerFunctionRequest,registerServerFunction}from"@solidjs/web/server-functions/server";constRequestContext=Symbol.for("solid.RequestContext");constBODY_FORMAT_HEADER="X-Server-Function-Format";constJSON_FORMAT="8";constLIMIT=1024*1024;letreceived: number|null=null;beforeAll(()=>{(globalThisasany)[RequestContext]=newAsyncLocalStorage();registerServerFunction("cap-declaration-sink",async(payload: unknown)=>{received=typeofpayload==="string" ? payload.length : -1;return"reached";});});afterAll(()=>{delete(globalThisasany)[RequestContext];});// A 2 MiB argument string — twice the cap the calls below configure — and// its well-behaved counterpart, an ordinary 4 KiB POST.constoversized=JSON.stringify(["x".repeat(2*LIMIT)]);constmodest=JSON.stringify(["y".repeat(4096)]);asyncfunctionpost(body: string,declaration: string|null){received=null;constheaders: Record<string,string>={"Sec-Fetch-Site": "same-origin","X-Server-Function-Instance": "server-function:test",[BODY_FORMAT_HEADER]: JSON_FORMAT};// undici only computes Content-Length at fetch time, so a Request built// here declares exactly what this line declares, and nothing when it// declares nothing — the two roads through the gate, side by side.if(declaration!==null)headers["content-length"]=declaration;constresponse=awaithandleServerFunctionRequest(newRequest("https://app.example/_server/data/cap-declaration-sink",{method: "POST",
body,
headers
}),{bodySizeLimit: LIMIT});return{status: response.status,reachedFunction: received};}constlabel=(declaration: string|null)=>`Content-Length: ${declaration??"(absent)"}`;functionrow(declaration: string|null,r: {status: number;reachedFunction: number|null}){return`${label(declaration)} -> status=${r.status} bytesReachingFunction=${r.reachedFunction===null ? "none" : r.reachedFunction}`;}describe("the body cap against an under-declared Content-Length",()=>{it("bounds the bytes it buffers by what arrives, not by what the declaration claims",async()=>{// One table, because the controls are the point as much as the repros:// an honest declaration under the cap must still dispatch, intact, and// an honest over-declaration must still be refused before a byte is// read. Closing the hole may not cost either.constcases: Array<[string|null,string,string]>=[// declaration body expected row[null,oversized,"status=413 bytesReachingFunction=none"],["0",oversized,"status=413 bytesReachingFunction=none"],["10",oversized,"status=413 bytesReachingFunction=none"],["1024",oversized,"status=413 bytesReachingFunction=none"],[String(LIMIT),oversized,"status=413 bytesReachingFunction=none"],["999999999999",oversized,"status=413 bytesReachingFunction=none"],// an honest declaration of the oversized body: refused before the read[String(Buffer.byteLength(oversized)),oversized,"status=413 bytesReachingFunction=none"],// the control: an ordinary browser POST under the cap, delivered whole[String(Buffer.byteLength(modest)),modest,"status=200 bytesReachingFunction=4096"]];constrows: string[]=[];for(const[declaration,body]ofcases){rows.push(row(declaration,awaitpost(body,declaration)));}expect(rows).toEqual(cases.map(([declaration,,expected])=>`${label(declaration)} -> ${expected}`));});});
import{AsyncLocalStorage}from"node:async_hooks";import{afterAll,beforeAll,describe,expect,it,vi}from"vitest";import{handleServerFunctionRequest,registerServerFunction}from"@solidjs/web/server-functions/server";constRequestContext=Symbol.for("solid.RequestContext");constBODY_FORMAT_HEADER="X-Server-Function-Format";constJSON_FORMAT="8";constdispatched=vi.fn(async()=>"reached");beforeAll(()=>{(globalThisasany)[RequestContext]=newAsyncLocalStorage();registerServerFunction("abort-coupling-sink",dispatched);});afterAll(()=>{delete(globalThisasany)[RequestContext];});constPENDING=Symbol("pending");asyncfunctionwithin<T>(promise: Promise<T>,ms: number){lettimer!: ReturnType<typeofsetTimeout>;constoutcome=awaitPromise.race([promise,newPromise<typeofPENDING>(resolve=>{timer=setTimeout(()=>resolve(PENDING),ms);})]);clearTimeout(timer);returnoutcome;}/** * Starts an upload that enqueues one byte and then stalls forever, aborts * the request once dispatch is actually reading it, and reports what the * runtime did with the abort. */asyncfunctionabortMidUpload(declaration: string|null){dispatched.mockClear();constabort=newAbortController();letsourceController!: ReadableStreamDefaultController<Uint8Array>;letcancelled=false;letcancelReason: any=null;letsignalPullStarted!: ()=>void;constpullStarted=newPromise<void>(resolve=>(signalPullStarted=resolve));constbody=newReadableStream({start(controller){sourceController=controller;controller.enqueue(newUint8Array([91]));// "["},pull(){// the upload the client is still sending when it disappearssignalPullStarted();returnnewPromise<void>(()=>{});},cancel(reason){cancelled=true;cancelReason=reason;}});constheaders: Record<string,string>={"Sec-Fetch-Site": "same-origin","X-Server-Function-Instance": "server-function:test",[BODY_FORMAT_HEADER]: JSON_FORMAT};// A conforming declaration is the ONLY difference between the two rows.if(declaration!==null)headers["content-length"]=declaration;constpending=handleServerFunctionRequest(newRequest("https://app.example/_server/data/abort-coupling-sink",{method: "POST",
body,duplex: "half",signal: abort.signal,
headers
}asRequestInit)).then(response=>`status=${response.status}`asconst,error=>`threw=${error?.name??error}`asconst);awaitpullStarted;awaitnewPromise(resolve=>setTimeout(resolve,60));abort.abort(newDOMException("client gone","AbortError"));constoutcome=awaitwithin(pending,800);// Release the stalled source so a failing row cannot leave the reader (or// the test run) parked, and so the assertions below describe the state at// the deadline rather than after cleanup.if(outcome===PENDING){sourceController.error(newError("test cleanup"));awaitwithin(pending,1000);}return{settled: outcome===PENDING ? "PENDING" : outcome,ran: dispatched.mock.calls.length,
cancelled,reason: cancelReason?.name??(cancelReason===null ? "none" : String(cancelReason))};}functionrow(declaration: string|null,r: {settled: string;ran: number;cancelled: boolean;reason: string}){return`content-length ${declaration??"(absent)"}: settled=${r.settled} ran=${r.ran} sourceCancelled=${r.cancelled} cancelReason=${r.reason}`;}describe("an aborted upload",()=>{it("settles the request and cancels the source whether or not the body declared a length",async()=>{constundeclared=awaitabortMidUpload(null);constdeclared=awaitabortMidUpload("200");// Rendered as a pair so the failure names the asymmetry itself: the// undeclared row is the behaviour #3218 already secured, the declared// row is the same request on the road the browsers use.expect([row(null,undeclared),row("200",declared)]).toEqual(["content-length (absent): settled=status=400 ran=0 sourceCancelled=true cancelReason=AbortError","content-length 200: settled=status=400 ran=0 sourceCancelled=true cancelReason=AbortError"]);});it("does not park the handler forever when the body declared a length",async()=>{// The half of the invariant that costs a process: even setting the// cancellation aside, the response promise must resolve.constdeclared=awaitabortMidUpload("200");expect(row("200",declared)).not.toContain("settled=PENDING");expect(declared.settled).toBe("status=400");});});
Both go red against the single gate — restoring if (!(declared > 0)) around the counting read at server.ts:2950 fails all three tests, and nothing else in the suite changes (server-functions-request-bounds.spec.tsx, including its pre-read over-declaration refusal at line 77, stays at 24/24):
Measured output:
The first three rows of A are the controls, and they are the point as much as the repro: an undeclared oversized body is refused after the counting read, an honest over-declaration is refused before a byte is read, and an ordinary 4 KiB POST is delivered whole. Only the last row differs, and the only thing that differs about it is a header. In B the two rows are the same request with the same abort at the same moment; the declaration is the only difference, and it decides whether the handler ever answers.
For contrast, the same script against a tree with the gate removed gives 413 /
bytesReachingFunction=noneon A's last row andsettled=status=400 uploadSourceCancelled=trueon both B rows.Where
Line numbers are
packages/web/server-functions/src/server.tsatf0f7531b.server.ts:2932-2978, gate atserver.ts:2950—if (!(declared > 0)). Everything below it (the counting read, the 413, the 400-on-abort) is inside the gate; a positive conforming declaration skips all of it andrequestis passed through untouched.server.ts:1223-1272—bufferBodyWithin, whose docstring still describes itself as the handler for "a POST body that declared no length (chunked transfer)". The signal/reader coupling is atserver.ts:1236-1246, inside that function and therefore inside the gate.Provenance:
51392f36— "feat(web): bound server-function request payloads (A server function accepts a body of any size and any number of arguments #3115, The decode depth cap is opt-out: the caller picks the format that skips it #3119)" introducedbufferBodyWithinand gated it onif (!declared), on the stated reasoning that "a declared Content-Length is trusted (the HTTP server's framing enforces it)".929642bc— "fix: negative Content-Length no longer bypasses bodySizeLimit (A negative Content-Length satisfies neither bodySizeLimit guard, so the body streams in uncapped #3153)" is the neighbouring fix that hardened this guard and left the trust standing: it narrowed the parse to a conforming digit string and respelled the gate!(declared > 0), closingContent-Length: -1. Its own commit message establishes the premise this issue turns on — that the header arrives from "hand-rolled adapters, laxer edge parsers, and rewriting proxies" and is not evidence.-1from that producer is not believed;10on a 2 MiB body is.e220cfae— "fix(web): own server function body teardown" (A client disconnect on a chunked upload rejects out of handleServerFunctionRequest instead of answering a status #3217/bufferBodyWithin ignores request.signal and has no time bound: an aborted upload buffers forever, a slowloris body is bounded only by size #3218/The 413 refusal cancels a tee branch, never the upload source #3219) wiredrequest.signalto the reader, but wired it insidebufferBodyWithin. Before that commit there was no coupling to be missing on the declared road; after it, the coupling exists and reaches only the bodies the gate sends through.Why it matters
Be clear about (a) first: I did not reproduce it through a stock
node:httpserver, and I do not believe it is reachable there. llhttp frames the request body by the declaration and rejectsContent-Lengthtogether withTransfer-Encoding, so a socket carrying 2 MiB behindContent-Length: 10is truncated at 10 bytes long before this code runs. This is defence in depth on the path #3153 already decided to defend, not a live bypass of a Node deployment.The exposure is every producer that builds the
Requestitself and is not llhttp: adapters for non-Node runtimes, a proxy or edge worker that rewrites the body without rewriting the header, an integration assembling aRequestfrom its own transport, a test harness. That population is not hypothetical — it is exactly the one929642bcnamed when it decided this header is not evidence — but reaching it takes an unusual integration or a non-browser client, and the report is not worth more than that. What is unconditionally true is that the invariant the option advertises (bodySizeLimitbounds what gets buffered and decoded before your code can decline it, #3115) is not the invariant the code holds, and a reader of the option cannot tell.(b) is the half that costs a running process, and it sits on the ordinary road. Fetch genuinely does not couple a
Request's signal to its body stream — that is why #3218 exists — so when the host abandons the request, nothing wakes the pending read. In the measurement above the handler simply never settles: the request task, its buffered chunks and the upload source stay resident for the life of the process, and a peer that can abort can open another. The honest limit here is that I measuredhandleServerFunctionRequestdirectly, not a full adapter end to end: an adapter that independently errors the body stream when the socket closes will mask the leak, and adapters differ on that. Where the adapter does not, #3218's fix is installed on the chunked side road while the traffic — every browser POST, everyfetchwith a string or FormData body, the shipped client stub — takes the declared one.Options
server-functions-request-bounds.spec.tsx:77pins it); everything else is bounded by the bytes that arrive, and the abort coupling is installed once, on the only road. Cost: the codec-framed road currently streams intodeserializeStream, and this replaces that with one buffer of at mostbodySizeLimitbytes. The other decode roads (.text(),.formData(),.arrayBuffer()) already materialize the whole payload before dispatch, so for them the added cost is one copy of an already-bounded payload, not a new materialization.bodySizeLimit: Infinityremains the way to opt a route out of buffering entirely.TransformStreamthat errors past the limit. Preserves streaming decode for the codec road. Costs a stream wrapper on every capped request, and leaves two roads that each have to get the signal/reader coupling right — which is the shape that produced this issue in the first place.bodySizeLimit's docs that it trusts a conformingContent-Lengthand that enforcing the framing is the adapter's job. Zero runtime cost and honest to a reader — but it contradicts the decision929642bcmade about the same header from the same producer, and it does not touch (b).Two of these are judgement calls that belong to you rather than to a reporter. The first is whether
bodySizeLimitis a promise about bytes buffered or a hint the HTTP layer is expected to enforce — that is the choice between (1) and (4), and it is a behaviour change versus a documentation change. The second, if you take (1), is whether trading the codec road's streaming decode for one bounded buffer is acceptable, or whether (2)'s counting transform earns its extra moving part.My recommendation is (1), on Solid's own minimalism terms: it deletes code rather than adding a mechanism, it leaves one road where there were two, the upload lifecycle is wired in exactly one place instead of needing to be kept in sync, and the declaration keeps precisely the one job it can do without being believed. (b) has no separate fix in this shape — closing (a) requires counting the arriving bytes, and that read is what carries the teardown.
Regression test
Two specs, both against the built bundles (wired up in
vite.config.server.mjslike the other server-function specs). Doc comments trimmed here for length.packages/web/test/server/server-functions-body-cap-declaration-trust.spec.tsx:packages/web/test/server/server-functions-abort-conforming-length.spec.tsx:Both go red against the single gate — restoring
if (!(declared > 0))around the counting read atserver.ts:2950fails all three tests, and nothing else in the suite changes (server-functions-request-bounds.spec.tsx, including its pre-read over-declaration refusal at line 77, stays at 24/24):