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
A single-flight slice that the wire cannot carry destroys the entire response, including the return value of a mutation that has already committed. foldFlightData contains a collector that throws — its own comment states the rule: "one cache's collector failing must not cost the mutation's outcome or the other caches' slices" — but a collector fails in two ways, and the other one is returning a value the codec has no node for (a cache entry still holding a function, a class instance, a live DB handle). That failure lands far outside the per-source try, in encodeResult, once the slices are one inseparable envelope. The client's decode throws; the mutation's own return value and every sibling cache's slice go with it; and the answer is 200 with no X-Server-Function-Error tag, so no CDN, proxy or log records a failure either.
This merges three symptoms a reader may be searching for separately: (a) a committed mutation whose caller receives Internal Server Error; (b) a healthy cache's slice destroyed by an unrelated sibling cache in the same round trip; (c)X-Single-Flight naming sources the payload does not carry, pointing client consumers at nothing.
Reproduction
packages/web/repro-flight-slice.mjs, run with plain node from packages/web (default resolution → the production bundle, server-functions/dist/server.js):
// Minimal repro: one un-encodable single-flight slice and what it costs.// Run with plain `node repro-flight-slice.mjs` from packages/web.import{AsyncLocalStorage}from"node:async_hooks";import{SINGLE_FLIGHT_HEADER,decodeResponse,handleServerFunctionRequest,registerFlightDataSource,registerServerFunction}from"@solidjs/web/server-functions/server";globalThis[Symbol.for("solid.RequestContext")]=newAsyncLocalStorage();letcharges=0;registerServerFunction("checkout",async()=>{charges++;// the mutation commits herereturn{orderId: "o-1",charged: true};});// A healthy query cache. Its entry holds a Date, as any ORM row does.registerFlightDataSource("orders",()=>({"/orders": [{at: newDate(0)}]}));// A cache entry that still holds a live handle. A function does it; so does// a class instance or a DB connection.registerFlightDataSource("session",()=>({refresh: function(){}}));constcall=sources=>handleServerFunctionRequest(newRequest("https://app.example/_server/data/checkout",{method: "POST",headers: {"Sec-Fetch-Site": "same-origin","X-Server-Function-Instance": "server-function:test",[SINGLE_FLIGHT_HEADER]: sources}}));asyncfunctionrow(label,sources){constresponse=awaitcall(sources);letdecoded=null;letdecodeError=null;try{decoded=awaitdecodeResponse(response);}catch(error){decodeError=String(error);}console.log([label.padEnd(26),`status=${response.status}`,`errorTag=${response.headers.get("X-Server-Function-Error")??"none"}`,`header=${response.headers.get(SINGLE_FLIGHT_HEADER)??"none"}`,`charges=${charges}`,`decode=${decodeError??"ok"}`,`value=${decodeError ? "LOST" : JSON.stringify(decoded?.value??decoded)}`,`slices=${decodeError ? "LOST" : JSON.stringify(Object.keys(decoded?.data??{}))}`].join(" "));}awaitrow("CONTROL orders","orders");awaitrow("SUBJECT session","session");awaitrow("SUBJECT orders,session","orders,session");constsubject=awaitcall("orders,session");console.log(`\nSUBJECT wire body: ${JSON.stringify(awaitsubject.text())}`);
Measured output, f0f7531b:
CONTROL orders status=200 errorTag=none header=orders charges=1 decode=ok value={"orderId":"o-1","charged":true} slices=["orders"]
SUBJECT session status=200 errorTag=none header=session charges=2 decode=Error: Internal Server Error value=LOST slices=LOST
SUBJECT orders,session status=200 errorTag=none header=orders,session charges=3 decode=Error: Internal Server Error value=LOST slices=LOST
SUBJECT wire body: ";0x00000024;!{\"message\":\"Internal Server Error\"}"
Three facts in that table:
CONTROL is the same mutation with the same healthy cache, and it works — including the Date, which only the codec road can carry. Nothing about single-flight or the codec is broken generally.
SUBJECT / session is one slice, and the mutation's own return value is gone. The bad slice does not merely lose itself.
SUBJECT / orders,session loses the healthy sibling too, and the response still advertises X-Single-Flight: orders,session.
charges increments on every row: each of these responses is over a mutation that ran to completion. The status is 200 and X-Server-Function-Error is absent on all three.
The dev bundle (server-functions/dist/server.dev.js, same script, same commit) is not much better — the message names neither the source nor the value:
SUBJECT orders,session status=200 errorTag=none header=orders,session charges=3 decode=Error: Server function result could not be encoded: Seroval Error (specific: 1) value=LOST slices=LOST
Where
Line numbers are against f0f7531b.
packages/web/server-functions/src/server.ts:1370-1390 — foldFlightData. The try/catch at :1381-1385 is the containment; folded.push([source, slice]) at :1382 admits any value the hook returned, and :1389 names it in SINGLE_FLIGHT_HEADER. Introduced by ec523607 "fix(web): contain flight-data collector errors per source" (2026-08-29), whose diff is exactly this try/catch around the hook call — it fixed the throw and stopped there.
packages/web/server-functions/src/server.ts:2374 — encodeResult, where the un-carriable value actually fails, one object too late to attribute it to a source.
packages/web/server-functions/src/server.ts:2328 — serializeResponseStream's onError, which turns the failure into the in-band error trailer the repro's wire body shows. Its own comment is accurate about why: "The head is committed by the time an encode failure arrives, so the status is spent and no error tag can be added."
packages/web/serialization/src/serializer.ts:230-247 — serializeJSON, the codec's only entry point at this commit. It reports an unsupported type through onError, i.e. after the head is on the wire. There is no way to ask the codec "can you carry this" before committing to a stream.
Provenance, stated carefully:
The multi-slice envelope came from 653dd41e "feat(web): multi-source single-flight — named flight-data sources" (2026-08-28). Before it the fold carried one slice, so an un-encodable slice cost the mutation's value but had no siblings to take with it. That commit widened the blast radius; it did not create the loss of the mutation's own value.
The observed symptom is shaped by 2f18c56f "feat(web): deliver an encode failure as a failure, not undefined (A result the codec cannot encode reaches the caller as undefined #3117)" (2026-08-30). Before it, an encode failure truncated the body and the peer decoded undefined; after it, the peer throws. That neighbouring fix is an improvement and is not the bug — it is why the repro shows a decode error rather than a silent undefined. Both readings destroy the committed mutation's result.
Why it matters
The realistic path: a non-idempotent mutation (the charge in the repro) commits, the response carries an un-encodable slice from some cache, the caller's await throws, and the UI reports failure for a mutation that succeeded. The user retries the charge. Because the response is 200 with no error header, nothing between the handler and the browser — proxy, CDN, error-rate alert — sees a failure to attribute it to.
The honest limits on reachability:
It needs a flight-data collector. No collectFlightData hook and no registerFlightDataSource means no envelope and no bug. Plain server functions returning plain data are unaffected. In practice the collector is registered by a router or a query cache, not by application code.
It needs a slice value the codec has no node for. Dates, Maps, promises, streams and errors are all carried; the failure shapes are functions, class instances whose internals the codec cannot reach, and live handles. That is a bug in the integration's cache entry, and an integration author would reasonably call it their mistake.
It is not attacker-controlled in any general way. The un-carriable value comes from the integration's own cache, not from the request. Treat this as data loss and a misreported outcome, not as a security issue.
What makes it worth fixing anyway is the asymmetry the fold itself already accepted: an integration whose collector throws costs one slice, and an integration whose collector returns badly costs the mutation. Those are the same author mistake with two very different prices.
Options
Document it and leave the runtime alone. Cheapest, and defensible — the collector contract can simply say "return encodable data". The cost is that the diagnostic is unusable: production says Internal Server Error, dev says Seroval Error (specific: 1), and neither names the source, the key, or the value. An author would have to bisect their caches to find the guilty one.
Probe each slice before it joins the envelope, and drop the un-carriable one. Restores the price the fold's comment already sets for a failing collector. Costs a second walk of any slice that is not JSON-safe, and needs a codec-side capability question (serializeJSON cannot answer without committing to a stream). Behaviour change: a slice can now be missing from a 200, which for a cache means "revalidate the normal way" — the same outcome a throwing collector already produces.
Encode each slice into its own sub-envelope so a failure is isolated on the wire. Strictly more information — the client could learn which source failed and act on it. Also strictly more protocol: a payload-shape change, a client decode change, and a per-slice framing cost paid by every single-flight response to guard a case that is a bug when it happens.
Catch the failure in encodeResult and re-encode with data dropped. No new codec API. But on the streaming road the failure arrives after the head is committed (server.ts:2328 says so), so the retry would have to happen before any bytes; and it drops every slice, since by then nothing knows which source contributed the offending value.
Keep the destruction but make it loud — set the error tag, use a failing status. Does not help: the mutation already committed, and the client still cannot recover the return value.
Push the contract to the adapter/integration layer — have the router validate what its collectors return before handing it to core. Keeps the runtime minimal, at the price of every integration re-deriving the answer, which plugins make impossible to derive correctly from outside.
Recommendation: (2), with the codec answering the question rather than a hand-maintained type table. In Solid's minimalism terms it is the smallest change that makes an existing rule true: the fold already decided what a failing collector may cost, and this applies that decision to the second way a collector fails, in the same try, with no new protocol and no new client code. The supported set is not re-derivable outside the codec — plugins alone make it unknowable, and a built-in table would drift into dropping data the wire can carry (the repro's Date is exactly that trap). A JSON-safe short-circuit keeps the common single-flight response — plain data, which already rides the JSON fast road in encodeResult — from touching the codec twice, so the probe's cost falls only on slices that were headed for the codec anyway. One implementation detail is load-bearing rather than incidental: the probe must walk plain containers itself, through property descriptors, and ask the codec only about leaves, because reading an accessor mints a value nobody guards (#3176 — a rejecting promise from a getter takes the process down before the codec sees the object). An accessor therefore cannot be verified from a probe and must not condemn a slice.
Two judgements are the maintainer's, not the reporter's:
Silence vs. signal on the drop. A console.error naming the source is the minimum. Whether the client should also learn that a source it asked for was dropped — a header listing failed sources, so a consumer can fall back rather than sit on stale data — is a protocol decision with a compatibility cost.
Runtime guard vs. dev-only enforcement. A dev-build throw would teach the author at the moment they write the bad cache entry, and leave production untouched. That trades a production data-loss guard for a sharper development signal; which one Solid wants is a call about where the framework's responsibility ends.
/** * A flight slice that cannot be encoded must cost only that slice. * * `foldFlightData` already states the containment rule in its own comment — * "one cache's collector failing must not cost the mutation's outcome or * the other caches' slices" — and implements it around the CALL to each * hook. But a collector has two ways to fail, and only one of them is a * throw: the other is returning a value the wire cannot carry. A query * cache handing back an entry that still holds a function, a class instance * or a live DB handle encodes nothing, and that failure lands far outside * the per-source try — in `encodeResult`, once the envelope is one object * and the individual slices are no longer separable. * * The cost is paid by a mutation that ALREADY COMMITTED. The charge went * through; the client's decode throws; the mutation's own return value and * every sibling cache's slice are destroyed with it; and the answer is a * 200 carrying no failure tag, so no CDN, load balancer or log sees a * failure either. The user retries a charge that already succeeded. * * The sibling case pins the other edge of the same rule: the slice that * survives carries a `Date`, so containment cannot be bought by narrowing * the fold to what JSON alone can carry. The codec exists precisely so a * result needn't be JSON, and cache entries built from ORM rows are the * common case, not the exotic one. * * Like the other server-function specs, these run against the built bundles * (server-functions/dist/*, wired up in vite.config.server.mjs). */import{AsyncLocalStorage}from"node:async_hooks";import{afterAll,afterEach,beforeAll,describe,expect,it}from"vitest";import{SINGLE_FLIGHT_HEADER,decodeResponse,handleServerFunctionRequest,registerFlightDataSource,registerServerFunction}from"@solidjs/web/server-functions/server";constRequestContext=Symbol.for("solid.RequestContext");beforeAll(()=>{(globalThisasany)[RequestContext]=newAsyncLocalStorage();});afterAll(()=>{delete(globalThisasany)[RequestContext];});constunregisters: (()=>void)[]=[];afterEach(()=>{while(unregisters.length)unregisters.pop()!();});/** A scripted mutation that advertised `sources` on the request leg. */functionflightRequest(id: string,sources: string){returnnewRequest(`http://localhost/_server/data/${id}`,{method: "POST",headers: {"Sec-Fetch-Site": "same-origin","X-Server-Function-Instance": "server-function:test",[SINGLE_FLIGHT_HEADER]: sources}});}/** * The whole response as the client sees it: what the transport's own decode * makes of the body, plus the wire facts a failure would have to announce * itself through. */asyncfunctionreadAsClient(response: Response){constraw=awaitresponse.clone().text();letpayload: any;letdecodeError: unknown;try{payload=awaitdecodeResponse(response);}catch(error){decodeError=error;}return{status: response.status,errorTag: response.headers.get("X-Server-Function-Error"),foldedSources: response.headers.get(SINGLE_FLIGHT_HEADER),decodeError: decodeError===undefined ? null : String(decodeError),
payload,
raw
};}describe("single-flight slice encoding is contained per source",()=>{it("does not destroy a committed mutation's result when the only slice cannot be encoded",async()=>{letmutationRan=0;registerServerFunction("sf-encode-solo",async()=>{mutationRan++;return{orderId: "o-1",charged: true};});// A cache entry that still holds a live handle. Nothing about it is// exotic — a function, a class instance or a DB connection does it.unregisters.push(registerFlightDataSource("badCache",()=>({handler: function(){}})));constseen=awaitreadAsClient(awaithandleServerFunctionRequest(flightRequest("sf-encode-solo","badCache")));expect(mutationRan,"the mutation must have run — that is the whole point").toBe(1);// The client's own decode is the ground truth for "the caller saw a// failure": it throws on the codec's error trailer.expect(seen.decodeError,`decode threw for a mutation that committed: ${seen.raw}`).toBe(null);expect(seen.payload?.value??seen.payload,`the mutation's return value: ${seen.raw}`).toEqual({orderId: "o-1",charged: true});});it("does not take a healthy cache's slice down with an un-encodable sibling",async()=>{letmutationRan=0;registerServerFunction("sf-encode-pair",async()=>{mutationRan++;return"committed";});// The healthy slice carries a Date on purpose: it is exactly the shape// that separates "the wire cannot carry this" from "JSON cannot carry// this". A cache entry off an ORM row is full of them, so a containment// rule that answered the second question would take single-flight away// from most applications to close the first.unregisters.push(registerFlightDataSource("goodCache",()=>({"/orders": [{at: newDate(0)}]})));unregisters.push(registerFlightDataSource("badCache",()=>({handler: function(){}})));constseen=awaitreadAsClient(awaithandleServerFunctionRequest(flightRequest("sf-encode-pair","goodCache,badCache")));expect(mutationRan).toBe(1);expect(seen.decodeError,`decode threw; wire body was: ${seen.raw}`).toBe(null);expect(seen.payload?.value,`payload: ${seen.raw}`).toBe("committed");expect(seen.payload?.data?.goodCache,`the healthy cache's slice was destroyed by its sibling: ${seen.raw}`).toEqual({"/orders": [{at: newDate(0)}]});});it("names in the response header only the sources whose slices the payload carries",async()=>{// The client routes slices to consumers by this header. Advertising a// source that is not in the envelope points a consumer at nothing.registerServerFunction("sf-encode-header",async()=>"committed");unregisters.push(registerFlightDataSource("goodCache",()=>({"/orders": ["fresh"]})));unregisters.push(registerFlightDataSource("badCache",()=>({handler: function(){}})));constseen=awaitreadAsClient(awaithandleServerFunctionRequest(flightRequest("sf-encode-header","goodCache,badCache")));constnamed=seen.foldedSources ? seen.foldedSources.split(",") : [];constcarried=Object.keys(seen.payload?.data??{});expect(named,`header named [${named}] but the payload carries [${carried}]; body: ${seen.raw}`).toEqual(carried);});});
All three cases go red against f0f7531b — and against a tree that keeps the carriability probe but removes only the fold's use of it — with expected 'Error: Internal Server Error' to be null on a body of ;0x00000024;!{"message":"Internal Server Error"}, and with the header case reporting header named [goodCache,badCache] but the payload carries [].
Summary
A single-flight slice that the wire cannot carry destroys the entire response, including the return value of a mutation that has already committed.
foldFlightDatacontains a collector that throws — its own comment states the rule: "one cache's collector failing must not cost the mutation's outcome or the other caches' slices" — but a collector fails in two ways, and the other one is returning a value the codec has no node for (a cache entry still holding a function, a class instance, a live DB handle). That failure lands far outside the per-sourcetry, inencodeResult, once the slices are one inseparable envelope. The client's decode throws; the mutation's own return value and every sibling cache's slice go with it; and the answer is200with noX-Server-Function-Errortag, so no CDN, proxy or log records a failure either.This merges three symptoms a reader may be searching for separately: (a) a committed mutation whose caller receives
Internal Server Error; (b) a healthy cache's slice destroyed by an unrelated sibling cache in the same round trip; (c)X-Single-Flightnaming sources the payload does not carry, pointing client consumers at nothing.Reproduction
packages/web/repro-flight-slice.mjs, run with plainnodefrompackages/web(default resolution → the production bundle,server-functions/dist/server.js):Measured output,
f0f7531b:Three facts in that table:
Date, which only the codec road can carry. Nothing about single-flight or the codec is broken generally.X-Single-Flight: orders,session.chargesincrements on every row: each of these responses is over a mutation that ran to completion. The status is200andX-Server-Function-Erroris absent on all three.The dev bundle (
server-functions/dist/server.dev.js, same script, same commit) is not much better — the message names neither the source nor the value:Where
Line numbers are against
f0f7531b.packages/web/server-functions/src/server.ts:1370-1390—foldFlightData. Thetry/catchat:1381-1385is the containment;folded.push([source, slice])at:1382admits any value the hook returned, and:1389names it inSINGLE_FLIGHT_HEADER. Introduced byec523607"fix(web): contain flight-data collector errors per source" (2026-08-29), whose diff is exactly thistry/catcharound the hook call — it fixed the throw and stopped there.packages/web/server-functions/src/server.ts:2374—encodeResult, where the un-carriable value actually fails, one object too late to attribute it to a source.packages/web/server-functions/src/server.ts:2328—serializeResponseStream'sonError, which turns the failure into the in-band error trailer the repro's wire body shows. Its own comment is accurate about why: "The head is committed by the time an encode failure arrives, so the status is spent and no error tag can be added."packages/web/serialization/src/serializer.ts:230-247—serializeJSON, the codec's only entry point at this commit. It reports an unsupported type throughonError, i.e. after the head is on the wire. There is no way to ask the codec "can you carry this" before committing to a stream.Provenance, stated carefully:
653dd41e"feat(web): multi-source single-flight — named flight-data sources" (2026-08-28). Before it the fold carried one slice, so an un-encodable slice cost the mutation's value but had no siblings to take with it. That commit widened the blast radius; it did not create the loss of the mutation's own value.2f18c56f"feat(web): deliver an encode failure as a failure, not undefined (A result the codec cannot encode reaches the caller as undefined #3117)" (2026-08-30). Before it, an encode failure truncated the body and the peer decodedundefined; after it, the peer throws. That neighbouring fix is an improvement and is not the bug — it is why the repro shows a decode error rather than a silentundefined. Both readings destroy the committed mutation's result.Why it matters
The realistic path: a non-idempotent mutation (the charge in the repro) commits, the response carries an un-encodable slice from some cache, the caller's
awaitthrows, and the UI reports failure for a mutation that succeeded. The user retries the charge. Because the response is200with no error header, nothing between the handler and the browser — proxy, CDN, error-rate alert — sees a failure to attribute it to.The honest limits on reachability:
collectFlightDatahook and noregisterFlightDataSourcemeans no envelope and no bug. Plain server functions returning plain data are unaffected. In practice the collector is registered by a router or a query cache, not by application code.What makes it worth fixing anyway is the asymmetry the fold itself already accepted: an integration whose collector throws costs one slice, and an integration whose collector returns badly costs the mutation. Those are the same author mistake with two very different prices.
Options
Internal Server Error, dev saysSeroval Error (specific: 1), and neither names the source, the key, or the value. An author would have to bisect their caches to find the guilty one.serializeJSONcannot answer without committing to a stream). Behaviour change: a slice can now be missing from a200, which for a cache means "revalidate the normal way" — the same outcome a throwing collector already produces.encodeResultand re-encode withdatadropped. No new codec API. But on the streaming road the failure arrives after the head is committed (server.ts:2328says so), so the retry would have to happen before any bytes; and it drops every slice, since by then nothing knows which source contributed the offending value.Recommendation: (2), with the codec answering the question rather than a hand-maintained type table. In Solid's minimalism terms it is the smallest change that makes an existing rule true: the fold already decided what a failing collector may cost, and this applies that decision to the second way a collector fails, in the same
try, with no new protocol and no new client code. The supported set is not re-derivable outside the codec — plugins alone make it unknowable, and a built-in table would drift into dropping data the wire can carry (the repro'sDateis exactly that trap). A JSON-safe short-circuit keeps the common single-flight response — plain data, which already rides the JSON fast road inencodeResult— from touching the codec twice, so the probe's cost falls only on slices that were headed for the codec anyway. One implementation detail is load-bearing rather than incidental: the probe must walk plain containers itself, through property descriptors, and ask the codec only about leaves, because reading an accessor mints a value nobody guards (#3176 — a rejecting promise from a getter takes the process down before the codec sees the object). An accessor therefore cannot be verified from a probe and must not condemn a slice.Two judgements are the maintainer's, not the reporter's:
console.errornaming the source is the minimum. Whether the client should also learn that a source it asked for was dropped — a header listing failed sources, so a consumer can fall back rather than sit on stale data — is a protocol decision with a compatibility cost.Regression test
packages/web/test/server/server-functions-flight-slice-encoding.spec.tsx:All three cases go red against
f0f7531b— and against a tree that keeps the carriability probe but removes only the fold's use of it — withexpected 'Error: Internal Server Error' to be nullon a body of;0x00000024;!{"message":"Internal Server Error"}, and with the header case reportingheader named [goodCache,badCache] but the payload carries [].