Skip to content

Generator server function bodies run outside provideEvent: they read the puller's request event, and direct calls clobber each other's locals #3222

Description

@frenzzy

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 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:

  1. Through HTTP dispatch, the body reads the ambient event instead of the dispatch event.
  2. On the direct SSR path, concurrent generator calls clobber each other's locals and the render event's — reintroducing, for generator bodies, exactly the failure 30f9387d (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 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.

const tenant = () => getRequestEvent()?.request?.headers?.get("X-Tenant") ?? "NO-EVENT";
registerServerFunction("gen", async function* () { yield tenant(); yield tenant(); });
registerServerFunction("fn",  async ()          => [tenant(), tenant()]);

// dispatch event says REQUEST; an ambient scope says AMBIENT
await ALS.run(ambientEvent, () =>
  handleServerFunctionRequest(request, { createEvent: () => requestEvent }));

next @ ee73e053:

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":

fn: async function* (who) {
  getRequestEvent().locals.writer = who;
  await delay(who === "A" ? 30 : 5);
  yield `${who} reads locals.writer=${getRequestEvent().locals.writer}`;
}
async fn  : ["A reads locals.writer=A","B reads locals.writer=B"]   render.locals = {"writer":"none"}
generator : ["A reads locals.writer=B","B reads locals.writer=B"]   render.locals = {"writer":"B"}

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.

Where

  • packages/web/server-functions/src/server.ts:2997 — HTTP dispatch: const run = () => serverFunction(...parsed) inside provideEvent.
  • 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

  1. 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.
  2. Scope at the live() boundary only. Smaller, but leaves a bare async function* server function wrong, and registerServerFunction accepts one today.
  3. 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, async function* () {
    yield getRequestEvent()?.request.headers.get("X-Tenant") ?? "NO-EVENT";
  });
  const response = await ALS.run(otherEvent, () =>
    handleServerFunctionRequest(requestFor("REQUEST"), { createEvent: () => requestEvent }));
  expect(await firstYield(response)).toBe("REQUEST");
});

it("does not let a direct generator call write another call's locals", async () => {
  const [a, b] = await ALS.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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions