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
provideEvent(event, fn) scopes the request event for the duration of the call. Calling a generator function only allocates the iterator — the body runs on the first next(), which happens after that scope has exited. So a generator server function's body does not see its own request event; it sees whatever ambient context the puller happens to be in.
Two consequences, both measured:
Through HTTP dispatch, the body reads the ambient event instead of the dispatch event.
live() is the documented spelling for this shape, with an async function* in its own doc example (server.ts:994), and documentation/solid-2.0/12-ssr-http.md:88 promises getRequestEvent() works in "server function bodies" without qualification.
Measured — 1. dispatch
Same registration, same request; the only difference is generator vs async function. The async function is the control.
gen status=200 ran=1 bodySaw=["AMBIENT","AMBIENT"]
fn status=200 ran=1 bodySaw=["REQUEST","REQUEST"]
With no ambient scope at all the generator body sees undefined and warns — fail-closed, but still not its own event. So an integration that resolves auth or tenancy in createEvent, or supplies its own provideEvent, has that policy silently skipped for every generator body.
Measured — 2. direct SSR path, and this one is unconditional
Two concurrent calls through createServerReference, staggered so they interleave, inside a render scope whose locals.writer starts as "none":
The async-function row is the #3156 fix working as documented. The generator row is both failures its comment describes — "two concurrent direct calls assigning locals.x overwrote each other AND the render, silently and interleaving-dependent" (server.ts:845-851).
The per-call copy at server.ts:852 is real; the generator body just never runs inside the scope that installs it.
packages/web/server-functions/src/server.ts:852-857 — direct path: const evt = { ...ogEvt, locals: { ...ogEvt.locals } } then provideEvent(evt, …).
In both, provideEvent wraps the call, and for a generator the call is only the allocation.
Node semantics, standalone, to isolate the mechanism from the runtime:
generator created in scope A, drained outside : ["undefined","undefined"]
generator created in scope A, drained in B : ["tenant-B","tenant-B"]
plain async fn in scope A (control) : ["tenant-A","tenant-A"]
Severity, stated honestly
The default HTTP failure is fail-closed: no ambient scope means getRequestEvent() returns undefined, so getRequestEvent()!.locals.tenant throws rather than returning someone else's tenant. A confidentiality breach through dispatch needs the puller's ambient event to differ from the dispatch event.
The direct-SSR locals clobbering above needs no such condition — it is cross-call and reproduces on the plain documented shape.
Options
Wrap an iterable result so each pull re-enters provideEvent — at both call sites. This is the shape that matches what provideEvent already promises, and it makes live()'s own documented example correct.
Scope at the live() boundary only. Smaller, but leaves a bare async function* server function wrong, and registerServerFunction accepts one today.
Refuse generator bodies outside live() and document the constraint. Honest, but narrows a shape the docs currently promise works.
The same question applies wherever the codec invokes a deferred callback after the scope exits — result getters walked by guardFailures, and stream pulls. A ReadableStream with highWaterMark: 0 shows the same NO-EVENT, so the streaming cases that pass today appear to pass by scheduling accident rather than by construction. Whether one wrapper should cover all of those or each is its own decision is yours; (1) is the minimal fix for the generator half.
Regression test
it("runs a generator body inside its own request event",async()=>{registerServerFunction(id,asyncfunction*(){yieldgetRequestEvent()?.request.headers.get("X-Tenant")??"NO-EVENT";});constresponse=awaitALS.run(otherEvent,()=>handleServerFunctionRequest(requestFor("REQUEST"),{createEvent: ()=>requestEvent}));expect(awaitfirstYield(response)).toBe("REQUEST");});it("does not let a direct generator call write another call's locals",async()=>{const[a,b]=awaitALS.run(render,()=>Promise.all([drain(ref("A")),drain(ref("B"))]));expect([a,b]).toEqual(["A reads locals.writer=A","B reads locals.writer=B"]);expect(render.locals.writer).toBe("none");});
Both go red on next today and green under a per-pull scope.
Summary
provideEvent(event, fn)scopes the request event for the duration of the call. Calling a generator function only allocates the iterator — the body runs on the firstnext(), which happens after that scope has exited. So a generator server function's body does not see its own request event; it sees whatever ambient context the puller happens to be in.Two consequences, both measured:
localsand the render event's — reintroducing, for generator bodies, exactly the failure30f9387d(fix: copy locals per derived event for direct server-function calls, Concurrent direct server-function calls share the render's locals object and overwrite each other #3156) was written to kill.live()is the documented spelling for this shape, with anasync function*in its own doc example (server.ts:994), anddocumentation/solid-2.0/12-ssr-http.md:88promisesgetRequestEvent()works in "server function bodies" without qualification.Measured — 1. dispatch
Same registration, same request; the only difference is generator vs async function. The async function is the control.
next@ee73e053:With no ambient scope at all the generator body sees
undefinedand warns — fail-closed, but still not its own event. So an integration that resolves auth or tenancy increateEvent, or supplies its ownprovideEvent, has that policy silently skipped for every generator body.Measured — 2. direct SSR path, and this one is unconditional
Two concurrent calls through
createServerReference, staggered so they interleave, inside a render scope whoselocals.writerstarts as"none":The async-function row is the #3156 fix working as documented. The generator row is both failures its comment describes — "two concurrent direct calls assigning
locals.xoverwrote each other AND the render, silently and interleaving-dependent" (server.ts:845-851).The per-call copy at
server.ts:852is real; the generator body just never runs inside the scope that installs it.Where
packages/web/server-functions/src/server.ts:2997— HTTP dispatch:const run = () => serverFunction(...parsed)insideprovideEvent.packages/web/server-functions/src/server.ts:852-857— direct path:const evt = { ...ogEvt, locals: { ...ogEvt.locals } }thenprovideEvent(evt, …).In both,
provideEventwraps the call, and for a generator the call is only the allocation.Node semantics, standalone, to isolate the mechanism from the runtime:
Severity, stated honestly
The default HTTP failure is fail-closed: no ambient scope means
getRequestEvent()returnsundefined, sogetRequestEvent()!.locals.tenantthrows rather than returning someone else's tenant. A confidentiality breach through dispatch needs the puller's ambient event to differ from the dispatch event.The direct-SSR
localsclobbering above needs no such condition — it is cross-call and reproduces on the plain documented shape.Options
provideEvent— at both call sites. This is the shape that matches whatprovideEventalready promises, and it makeslive()'s own documented example correct.live()boundary only. Smaller, but leaves a bareasync function*server function wrong, andregisterServerFunctionaccepts one today.live()and document the constraint. Honest, but narrows a shape the docs currently promise works.The same question applies wherever the codec invokes a deferred callback after the scope exits — result getters walked by
guardFailures, and streampulls. AReadableStreamwithhighWaterMark: 0shows the sameNO-EVENT, so the streaming cases that pass today appear to pass by scheduling accident rather than by construction. Whether one wrapper should cover all of those or each is its own decision is yours; (1) is the minimal fix for the generator half.Regression test
Both go red on
nexttoday and green under a per-pull scope.