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 server function called directly during SSR gets a derived request event, and scopeDeferredResult binds a returned generator or stream to it so the body still runs under that event when the consumer pulls it later. It only ever looked at the top level. A deferred body handed back one container down — return { rows: cursor() }, return [cursor()] — is bound to nothing, so the body runs under whatever async context the consumer happens to be in, which during a document render is the render's ambient event. Two concurrent direct calls then read and write each other's request state (call A reads the tenant, auth and DB handle call B just wrote), and a single call is enough to mutate the render's own locals, which the per-call copy from #3156 exists to keep out of reach.
This merges two symptoms that read as separate bugs: cross-call request-state bleed between concurrent direct SSR calls, and a server-function body writing through to the render's locals. Both are the same missing descent.
Reproduction
packages/web/test/server/repro.spec.tsx — run with the repo's server test config, which aliases @solidjs/web/server-functions/server to the built bundle:
import{AsyncLocalStorage}from"node:async_hooks";import{beforeAll,it}from"vitest";import{createRequestEvent,getRequestEvent}from"@solidjs/web";import{createServerReference}from"@solidjs/web/server-functions/server";constRequestContext=Symbol.for("solid.RequestContext");constrequestContext=newAsyncLocalStorage<any>();beforeAll(()=>{(globalThisasany)[RequestContext]=requestContext;});constdelay=(ms: number)=>newPromise(r=>setTimeout(r,ms));// the body both cases share: write who I am, park, read back what I wroteasyncfunction*witness(who: string){getRequestEvent()!.locals.writer=who;awaitdelay(who==="A" ? 30 : 5);// A parks long enough for B to land inside ityield`${who} read back: ${getRequestEvent()!.locals.writer}`;}functionref(id: string,fn: (who: string)=>any){returncreateServerReference({ id,name: id, fn }asany)as(who: string)=>Promise<any>;}constcontrol=ref("control",async(who: string)=>witness(who));// returned directlyconstnested=ref("nested",async(who: string)=>({rows: witness(who)}));// one container downasyncfunctiondrain(it: AsyncIterable<string>){constout: string[]=[];forawait(constvofit)out.push(v);returnout;}it("repro",async()=>{for(const[label,call,pick]of[["CONTROL return witness(who) ",control,(r: any)=>r],["BUG return { rows: witness() }",nested,(r: any)=>r.rows]]asconst){constrender=createRequestEvent(newRequest("https://app.example/page"));render.locals.writer="render-owned";constvalues=awaitrequestContext.run(render,()=>Promise.all([call("A").then(r=>drain(pick(r))),call("B").then(r=>drain(pick(r)))]));process.stdout.write([label,JSON.stringify(values.flat()),"| render.locals.writer =",JSON.stringify(render.locals.writer)].join(" ")+"\n");}});
The CONTROL row returns the identical generator at the top level and is correctly isolated: each call reads back its own write and the render's locals is untouched. Move the same generator one property down and call A reads "B" — B's write, made while A was parked — and the render's writer has been overwritten by a call that should not have been able to see it.
The render-locals half needs no concurrency at all. One call, one nested generator:
SINGLE CALL render.locals.writer = "fn" // expected "render-owned"
Where
Baseline f0f7531b:
packages/web/server-functions/src/server.ts:667 — function scopeDeferredResult(value, scope). It tests value itself for a promise, a ReadableStream, an async iterator or a sync iterator, and returns value unchanged when none match. A container holding one of those matches nothing and is returned as-is.
Call sites that inherit the gap: server.ts:945 (the direct-call proxy binding the wrapper's result), server.ts:954 and server.ts:958 (transformDirectResult on the direct road). server.ts:2138 (the codec road's sync-generator branch) is unaffected — its value is always a generator, never a carrier.
Provenance: introduced by the fix it belongs to.
$ git log --oneline -1 -S 'function scopeDeferredResult' -- packages/web/server-functions/src/server.ts
0a2fcf0c fix(web): scope deferred server function work
0a2fcf0c (2026-09-02) is the #3222 fix — it created scopeDeferredResult and the accompanying server-functions-request-event-scope.spec.tsx, which pins the top-level case only. The neighbouring commit whose guarantee this defeats is 30f9387d (2026-08-31), "fix: copy locals per derived event for direct server-function calls (#3156)": the per-call copy is only per-call while the body actually runs under the derived event.
The precedent for descending is already in the tree on the other road: 8d170831, "fix: demand-gate and tear down nested streams in server function results (#3125)", taught the codec's walk to descend into containers for exactly this reason, and 1d2d1e56, "fix: iterative guardFailures walk — deep results are not phantom 500s (#3160)", is why that walk is iterative rather than recursive.
Why it matters
The path is ordinary, not exotic. A direct SSR call is the normal SolidStart shape — a createAsync/query reaching a server function in-process during a document render — and returning a cursor or a stream inside an envelope ({ rows, total }, { feed }, [stream]) is the natural way to hand back a paged or streamed result alongside its metadata. No unusual integration and no non-browser client is required; a plain SSR page render reaches it.
Two honest limits:
It needs a deferred body. A result that is fully materialized at return time — arrays, plain data, an already-awaited page of rows — is unaffected, because nothing runs after the call scope has gone. The window is generators, custom iterators and ReadableStreams.
The HTTP road is fine. A wire-dispatched call is encoded by the codec, whose own walk descends and threads state.scope; the harm is specific to calls made in-process during a render.
Severity within those limits is real: event.locals is where integrations park tenant, session, auth and DB handles, so a cross-read is a request-isolation failure, not a cosmetic one. It is also silent and timing-dependent — it needs two calls in the same render to actually overlap — which is what makes it worth a regression test rather than a code comment.
Options
Descend containers in place and bind each deferred slot. Split the leaf binder out (bindDeferredBody) and walk array / Map / Set / Error / plain-object carriers, swapping only the deferred slots for their bound wrappers. Cost is one walk over every direct-call result; the carrier keeps its identity and shape, so the caller holds the value the author returned. Iterative, because a legal result nests arbitrarily deep and guardFailures recurses unbounded, so a deep result reports a successful call as a 500 #3160 already established that a recursive walk here overflows on one.
Rebuild the carrier with bound slots instead of mutating it. Avoids touching frozen or sealed objects, but hands the caller a value they never returned: identity, instanceof, class instances and private fields all change under them. Strictly worse for a value the author owns.
Bind lazily behind a Proxy over the result, so nothing is walked until a slot is read. Defers the cost, but puts a proxy on every direct-call result, and a proxy is observable — identity comparisons, structuredClone, Object.freeze all notice. More machinery than the harm justifies.
Change nothing at runtime; document that a deferred body must be returned at the top level. Zero cost and no new walk. Against it: the failure is silent and concurrency-dependent, so the documentation is only read after the incident, and the codec road already descends into this exact shape — the two roads would give different answers for the same returned value. Whether cross-road consistency is worth a walk on the direct path, or whether this is a documented constraint, is a maintainer's call, and it is the one real fork here.
Leave it to the adapter — let SolidStart re-scope nested bodies. Puts request isolation in the integration layer rather than the runtime that minted the derived event, and every other adapter would have to repeat it.
Recommended: option 1. In Solid's terms it is the smaller change, not the larger one — it removes a special case rather than adding a mechanism. The runtime already walks a result graph for exactly this class of value on the codec road (#3125) and already walks argument graphs in place on the argument road (stripUnsafeKeys); the direct road not walking is the inconsistency, and the fix makes one rule — a deferred body is bound to its call's event — hold wherever the body sits.
Two sub-decisions inside option 1 are also judgement calls worth naming, since they are places the two roads deliberately differ:
Accessors are skipped, own data properties only. The codec invokes enumerable getters because the codec reads them itself; on the direct road nobody reads them but the caller, and invoking one here would run authored code for a value nobody asked for. Defensible either way.
A sealed, non-writable slot keeps its unbound body. Binding it needs a substitution the slot refuses, and rebuilding the carrier around it is option 2. That leaves one narrow shape exactly where it already was, rather than silently changing the value's shape to fix it.
Map values are rebound in place; Map keys are descended into but never rebound, since replacing a key rehashes the entry.
/** * #3222 ON THE DIRECT SSR ROAD, ONE CONTAINER DOWN. * * Calling a generator only allocates it; calling a stream's reader is what * runs its pull. So the request scope around the CALL does not own either * body — the consumer drives it later, from whatever async context it * happens to be in, which during SSR is the render's ambient event. * `scopeDeferredResult` exists to bind those deferred operations to the * per-call event instead, and `server-functions-request-event-scope.spec.tsx` * pins it for a body the function RETURNS DIRECTLY. * * It only ever looks at that top level. A generator or stream handed back * inside an object or an array — `return { rows: cursor() }`, the shape the * codec road's guard walk was taught to descend into for exactly this * reason — is left bound to nothing, and #3222's harm comes straight back: * * - the body reads and WRITES the render's `locals` rather than the * per-call copy #3156 made for it, so two concurrent direct calls see * each other's request state (call A reading call B's tenant, auth, * DB handle); * - the render's own `locals` is mutated by a call that was supposed to * be unable to reach it. * * Each test runs two concurrent calls whose bodies interleave by design * (A sleeps longer than B, so B's write lands while A is parked). Correctly * scoped, each call reads back its OWN write — [["A:A"], ["B:B"]]; sharing * the ambient event gives A whatever B wrote last — [["A:B"], ["B:B"]]. * The call counter proves both bodies actually ran, so an assertion cannot * pass on a result nobody produced. * * 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,beforeAll,describe,expect,it}from"vitest";import{createRequestEvent,getRequestEvent}from"@solidjs/web";import{createServerReference}from"@solidjs/web/server-functions/server";constRequestContext=Symbol.for("solid.RequestContext");constrequestContext=newAsyncLocalStorage<any>();beforeAll(()=>{(globalThisasany)[RequestContext]=requestContext;});afterAll(()=>{delete(globalThisasany)[RequestContext];});constdelay=(ms: number)=>newPromise(resolve=>setTimeout(resolve,ms));/** A's body parks long enough for B's whole call to land inside it. */constpause=(who: string)=>delay(who==="A" ? 30 : 5);functionrenderEvent(){constevent=createRequestEvent(newRequest("https://app.example/page"));event.locals.writer="none";returnevent;}asyncfunctiondrain<T>(source: AsyncIterable<T>){constvalues: T[]=[];forawait(constvalueofsource)values.push(value);returnvalues;}asyncfunctionreadAll<T>(stream: ReadableStream<T>){constreader=stream.getReader();constvalues: T[]=[];for(;;){conststep=awaitreader.read();if(step.done)returnvalues;values.push(step.value);}}/** The body every case shares: write, park, read back what I wrote. */asyncfunction*witness(who: string){getRequestEvent()!.locals.writer=who;awaitpause(who);yield`${who}:${getRequestEvent()!.locals.writer}`;}describe("a deferred body nested in the result of a direct SSR call (#3222)",()=>{it("isolates concurrent calls whose generator is nested in an object",async()=>{letcalls=0;constreference=createServerReference({id: "nested-scope-object",name: "nestedScopeObject",fn: async(who: string)=>{calls++;return{rows: witness(who)};}}asany)as(who: string)=>Promise<{rows: AsyncIterable<string>}>;constrender=renderEvent();constvalues=awaitrequestContext.run(render,()=>Promise.all([reference("A").then(result=>drain(result.rows)),reference("B").then(result=>drain(result.rows))]));expect(calls).toBe(2);expect({ values,renderWriter: render.locals.writer}).toEqual({values: [["A:A"],["B:B"]],// #3156's per-call copy is only per-call while the body runs under// the derived event; unscoped, the nested generator writes through// to the render itselfrenderWriter: "none"});});it("isolates concurrent calls whose generator is nested in an array",async()=>{letcalls=0;constreference=createServerReference({id: "nested-scope-array",name: "nestedScopeArray",fn: async(who: string)=>{calls++;return[witness(who)];}}asany)as(who: string)=>Promise<AsyncIterable<string>[]>;constrender=renderEvent();constvalues=awaitrequestContext.run(render,()=>Promise.all([reference("A").then(result=>drain(result[0])),reference("B").then(result=>drain(result[0]))]));expect(calls).toBe(2);expect({ values,renderWriter: render.locals.writer}).toEqual({values: [["A:A"],["B:B"]],renderWriter: "none"});});it("isolates concurrent calls whose stream is nested in an object",async()=>{letcalls=0;constreference=createServerReference({id: "nested-scope-stream",name: "nestedScopeStream",fn: async(who: string)=>{calls++;return{feed: newReadableStream<string>({asyncpull(controller){getRequestEvent()!.locals.writer=who;awaitpause(who);controller.enqueue(`${who}:${getRequestEvent()!.locals.writer}`);controller.close();}},{highWaterMark: 0})};}}asany)as(who: string)=>Promise<{feed: ReadableStream<string>}>;constrender=renderEvent();constvalues=awaitrequestContext.run(render,()=>Promise.all([reference("A").then(result=>readAll(result.feed)),reference("B").then(result=>readAll(result.feed))]));expect(calls).toBe(2);expect({ values,renderWriter: render.locals.writer}).toEqual({values: [["A:A"],["B:B"]],renderWriter: "none"});});});
It goes red against any scopeDeferredResult that binds only the top-level value — all three cases fail, each reporting renderWriter: "B" instead of "none" and "A:B" instead of "A:A":
Summary
A server function called directly during SSR gets a derived request event, and
scopeDeferredResultbinds a returned generator or stream to it so the body still runs under that event when the consumer pulls it later. It only ever looked at the top level. A deferred body handed back one container down —return { rows: cursor() },return [cursor()]— is bound to nothing, so the body runs under whatever async context the consumer happens to be in, which during a document render is the render's ambient event. Two concurrent direct calls then read and write each other's request state (call A reads the tenant, auth and DB handle call B just wrote), and a single call is enough to mutate the render's ownlocals, which the per-call copy from #3156 exists to keep out of reach.This merges two symptoms that read as separate bugs: cross-call request-state bleed between concurrent direct SSR calls, and a server-function body writing through to the render's
locals. Both are the same missing descent.Reproduction
packages/web/test/server/repro.spec.tsx— run with the repo's server test config, which aliases@solidjs/web/server-functions/serverto the built bundle:The CONTROL row returns the identical generator at the top level and is correctly isolated: each call reads back its own write and the render's
localsis untouched. Move the same generator one property down and call A reads"B"— B's write, made while A was parked — and the render'swriterhas been overwritten by a call that should not have been able to see it.The render-
localshalf needs no concurrency at all. One call, one nested generator:Where
Baseline
f0f7531b:packages/web/server-functions/src/server.ts:667—function scopeDeferredResult(value, scope). It testsvalueitself for a promise, aReadableStream, an async iterator or a sync iterator, and returnsvalueunchanged when none match. A container holding one of those matches nothing and is returned as-is.server.ts:945(the direct-call proxy binding the wrapper's result),server.ts:954andserver.ts:958(transformDirectResulton the direct road).server.ts:2138(the codec road's sync-generator branch) is unaffected — its value is always a generator, never a carrier.Provenance: introduced by the fix it belongs to.
0a2fcf0c(2026-09-02) is the #3222 fix — it createdscopeDeferredResultand the accompanyingserver-functions-request-event-scope.spec.tsx, which pins the top-level case only. The neighbouring commit whose guarantee this defeats is30f9387d(2026-08-31), "fix: copy locals per derived event for direct server-function calls (#3156)": the per-call copy is only per-call while the body actually runs under the derived event.The precedent for descending is already in the tree on the other road:
8d170831, "fix: demand-gate and tear down nested streams in server function results (#3125)", taught the codec's walk to descend into containers for exactly this reason, and1d2d1e56, "fix: iterative guardFailures walk — deep results are not phantom 500s (#3160)", is why that walk is iterative rather than recursive.Why it matters
The path is ordinary, not exotic. A direct SSR call is the normal SolidStart shape — a
createAsync/queryreaching a server function in-process during a document render — and returning a cursor or a stream inside an envelope ({ rows, total },{ feed },[stream]) is the natural way to hand back a paged or streamed result alongside its metadata. No unusual integration and no non-browser client is required; a plain SSR page render reaches it.Two honest limits:
ReadableStreams.state.scope; the harm is specific to calls made in-process during a render.Severity within those limits is real:
event.localsis where integrations park tenant, session, auth and DB handles, so a cross-read is a request-isolation failure, not a cosmetic one. It is also silent and timing-dependent — it needs two calls in the same render to actually overlap — which is what makes it worth a regression test rather than a code comment.Options
bindDeferredBody) and walk array /Map/Set/Error/ plain-object carriers, swapping only the deferred slots for their bound wrappers. Cost is one walk over every direct-call result; the carrier keeps its identity and shape, so the caller holds the value the author returned. Iterative, because a legal result nests arbitrarily deep and guardFailures recurses unbounded, so a deep result reports a successful call as a 500 #3160 already established that a recursive walk here overflows on one.instanceof, class instances and private fields all change under them. Strictly worse for a value the author owns.structuredClone,Object.freezeall notice. More machinery than the harm justifies.Recommended: option 1. In Solid's terms it is the smaller change, not the larger one — it removes a special case rather than adding a mechanism. The runtime already walks a result graph for exactly this class of value on the codec road (#3125) and already walks argument graphs in place on the argument road (
stripUnsafeKeys); the direct road not walking is the inconsistency, and the fix makes one rule — a deferred body is bound to its call's event — hold wherever the body sits.Two sub-decisions inside option 1 are also judgement calls worth naming, since they are places the two roads deliberately differ:
Mapvalues are rebound in place;Mapkeys are descended into but never rebound, since replacing a key rehashes the entry.Regression test
packages/web/test/server/server-functions-nested-deferred-scope.spec.tsx:It goes red against any
scopeDeferredResultthat binds only the top-level value — all three cases fail, each reportingrenderWriter: "B"instead of"none"and"A:B"instead of"A:A":