Skip to content

provideEvent's exactly-once contract is enforced on HTTP dispatch and nowhere else #3246

Description

@frenzzy
`provideEvent`'s exactly-once contract is enforced on HTTP dispatch and nowhere else

## Summary

The same defective `provideEvent` hook is a clean 500 over HTTP and a silent double-commit during SSR. `handleServerFunctionRequest` counts the hook's invocations at its dispatch seam and refuses both violations (#3172); `createServerReference`'s apply trap — the direct SSR leg — calls the same host-supplied hook with no count at all. A hook is one object an adapter installs once via `configureServerFunctionsServer`, so a hook broken in either direction is broken for every call the process makes; the only thing that varies is which leg meets it. On the direct leg a hook that runs the callback twice commits the function's side effects twice and hands the render the second run's value as an ordinary success, and a hook that never runs the callback returns `undefined` — indistinguishable from a function that returned nothing — into the page.

This is filed as one issue because both are one contract on one hook, but it merges two symptoms a reader may be searching for separately: **(1) a `provideEvent` hook that invokes the callback twice double-commits during a render with no error**, and **(2) a `provideEvent` hook that never invokes the callback makes a direct server-function call evaluate to `undefined` as a success**. Both are the HTTP leg's two refusals, absent on the direct leg.

## Reproduction

`packages/web/repro-provide-event.mjs`, run with plain `node` from `packages/web` after `pnpm build` (imports the built bundle directly so no workspace linking is involved). The CONTROL rows are the *same hook* over HTTP, where the guard exists.

```js
// Minimal repro: a defective `provideEvent` hook, met on both dispatch legs.
import { AsyncLocalStorage } from "node:async_hooks";
import {
  configureServerFunctionsServer,
  createServerReference,
  handleServerFunctionRequest,
  registerServerFunction,
  registerServerReference
} from "./server-functions/dist/server.js";

globalThis[Symbol.for("solid.RequestContext")] = new AsyncLocalStorage();

// The adapter's hook. A retry wrapper, or a misplaced await, and it runs
// the callback twice; both shapes below are hand-written-hook mistakes.
const twice = (event, fn) => {
  fn();
  return fn();
};
const never = () => undefined;

let charged = 0;
const chargeCard = () => {
  charged++;
  return `receipt-${charged}`;
};

registerServerFunction("charge", chargeCard);
const chargeDirect = createServerReference(registerServerReference("charge-direct", chargeCard));

const storage = globalThis[Symbol.for("solid.RequestContext")];
const underRender = run =>
  storage.run({ request: new Request("https://app.example/page"), locals: {} }, run);

function post(id) {
  return new Request(`https://app.example/_server/data/${id}`, {
    method: "POST",
    body: "[]",
    headers: {
      "Sec-Fetch-Site": "same-origin",
      "X-Server-Function-Format": "8",
      "X-Server-Function-Instance": "server-function:test"
    }
  });
}

const row = (label, outcome) => console.log(`${label.padEnd(46)} charges: ${charged}  ${outcome}`);

async function overHttp(label, provideEvent) {
  charged = 0;
  const response = await handleServerFunctionRequest(post("charge"), { provideEvent });
  row(label, `HTTP ${response.status}`);
}

function inRender(label, provideEvent) {
  charged = 0;
  configureServerFunctionsServer({ provideEvent });
  let outcome;
  try {
    outcome = `returned ${JSON.stringify(underRender(() => chargeDirect()))}`;
  } catch (error) {
    outcome = `threw: ${error.message.slice(0, 40)}`;
  }
  configureServerFunctionsServer({ provideEvent: null });
  row(label, outcome);
}

console.log("-- hook invokes the callback TWICE --");
await overHttp("CONTROL  over HTTP", twice);
inRender("SUBJECT  direct call during a render", twice);

console.log("-- hook NEVER invokes the callback --");
await overHttp("CONTROL  over HTTP", never);
inRender("SUBJECT  direct call during a render", never);

console.log("-- correct hook (als.run), for reference --");
const good = (event, fn) => new AsyncLocalStorage().run(event, fn);
await overHttp("CONTROL  over HTTP", good);
inRender("CONTROL  direct call during a render", good);

Measured on next at f0f7531b (merge of #3229):

-- hook invokes the callback TWICE --
CONTROL  over HTTP                             charges: 1  HTTP 500
SUBJECT  direct call during a render           charges: 2  returned "receipt-2"
-- hook NEVER invokes the callback --
CONTROL  over HTTP                             charges: 0  HTTP 500
SUBJECT  direct call during a render           charges: 0  returned undefined
-- correct hook (als.run), for reference --
CONTROL  over HTTP                             charges: 1  HTTP 200
CONTROL  direct call during a render           charges: 1  returned "receipt-1"

Read the first block: over HTTP the card is charged once and the request fails loudly; through the render the card is charged twice and the render is handed "receipt-2" as a normal return value. The third block is the healthy hook, and it is identical on both legs — the difference is the guard, not the leg.

With the guard moved to the hook contract, the same script prints charges: 1 threw: provideEvent invoked the server function and charges: 0 threw: provideEvent returned without invoking t for the two SUBJECT rows; the CONTROL rows are unchanged.

Where

Line numbers are from f0f7531b.

  • packages/web/server-functions/src/server.ts:911-941 — the apply trap in createServerReference. Line 931, let result = provideEvent(evt, () => { ... }), calls the host hook with no invocation count and no post-return re-check. This unguarded call has been the shape of the direct leg since the runtime was flattened into this file in 71821959 ("Migrate the absorbed DOM runtime to TypeScript and flatten it into feature folders"), and was reshaped in place — still uncounted — by 0a2fcf0c ("fix(web): scope deferred server function work").
  • packages/web/server-functions/src/server.ts:3159-3196 — the HTTP tail's inline counter (let invocations = 0, the in-flight refusal at 3170-3182, the post-return re-check at 3183-3196). This is the correct behaviour, and it is the whole of it.
  • The asymmetry was created by a neighbouring fix: c7c0ffb7 ("fix(web): enforce provideEvent's exactly-once invocation contract (provideEvent's contract is unenforced: calling fn twice double-commits a mutation under a 200 #3172)", 2026-09-01) added the counter and its four tests exclusively inside handleServerFunctionRequest. Before it, both legs were equally unguarded; after it, only one leg is.
  • The contract the guard enforces is stated once, for the hook, not for a leg: packages/web/server-functions/src/server.ts:255-264 (ServerFunctionsServerConfig.provideEvent) and again at 436-438 for the per-handler override. Nothing in either doc scopes it to HTTP dispatch.
  • Reachability of the two legs differs in one way worth stating: the per-handler options.provideEvent (server.ts:3021) is HTTP-only, so the direct leg always runs the configured hook — the one an adapter installs process-wide.

Why it matters

The realistic path is an integration that hand-writes provideEvent rather than passing provideRequestEvent straight through: an adapter that wraps the scope in a retry, a hook that establishes the scope in one branch and forgets to invoke the callback in the other (if (ctx) return ctx.run(event, fn) with no else — that is the never row above), or a hook that calls fn() once to warm something and once more to produce the result. Those mistakes are exactly what #3172 was filed about; the argument for catching them has already been accepted, and this is the leg where catching them was omitted.

The consequences differ by leg in the direction that favours the bug hiding. Over HTTP, the wrong hook produces a 500 on the first request in development and is fixed before deploy. During a document render there is no client, no status line and no log entry to notice it by: the double-committed mutation is a second charge, a second row, a second outbound message, and the page renders as if nothing happened. The zero-invocation shape is quieter still — the render receives undefined and, in a codebase where a server function legitimately returns nothing, no one is looking.

The honest limits:

  • This needs a defective host-supplied hook. The default provider (the AsyncLocalStorage instance on globalThis[Symbol.for("solid.RequestContext")], server.ts:651-659) invokes the callback exactly once, and so does SolidStart's provideRequestEvent. No stock app reaches this by accident.
  • It is not a vulnerability. The hook is installed by the host at startup, not influenced by a request; nothing an attacker sends changes how many times fn is called. The harm is data integrity under a misconfigured integration, not an attacker-controlled path.
  • It is not a regression in the direct leg's own behaviour. The direct leg has never had this guard. What changed with c7c0ffb7 is that the contract became enforced rather than documented, and half of it stayed unenforced — which is worse than either consistent state, because a passing HTTP test suite now reads as evidence the hook is sound.

Options

  1. Extract the guard and use it from both legs. Lift the counter out of the HTTP tail into a provideEventOnce(provide, event, run) helper next to provideEvent, and enter through it from both dispatch() and the apply trap. Cost: the direct leg gains a counter increment and one comparison per call, and it must stay synchronous for a synchronous function — the re-check can only be deferred when the hook's result is a native promise. Benefit: one implementation, one pair of messages, and the invariant is stated where the contract is, so a third dispatch path added later inherits it.
  2. Duplicate the counter into the apply trap. Smallest diff, no shared helper. Cost: two copies of the same four-branch logic and two copies of the message strings, which is exactly the drift that produced this issue — the next amendment to the contract lands on one copy.
  3. Guard only the zero-invocation case on the direct leg. Cheapest, and it removes the undefined-as-success symptom, which is the one most likely to be mistaken for a Solid bug. Cost: leaves the double-commit — the more expensive half — untouched. Not recommended, but it is the minimal option if the double-invocation check is judged too intrusive for a hot in-render path.
  4. Document the asymmetry instead of closing it. Amend the provideEvent doc comment to say the contract is enforced on HTTP dispatch only, and leave the direct leg trusting. Cost: a hook is one object, so the split is invisible to the person who has to act on it; the doc would be telling adapters to test both legs by hand.
  5. Push the guard out to the adapter. Say the runtime trusts the hook and adapters validate their own. Cost: every adapter reimplements it, and the runtime already decided otherwise in provideEvent's contract is unenforced: calling fn twice double-commits a mutation under a 200 #3172 — this option is really a proposal to revert that.

Recommendation: option 1. In Solid's terms it is the smallest true statement of the rule: the guard is a property of the hook contract, so it belongs at the one place the hook is honoured, and adopting it makes the HTTP tail smaller — its inline counter is deleted, not duplicated. It also keeps the two error messages single-sourced, which matters because they are the only diagnosis an adapter author gets.

Two calls that are genuinely the maintainer's, not this report's:

  • Behaviour change vs. documentation. Option 1 turns a currently-succeeding render into a thrown error for hosts running a defective hook. That is the intended outcome — the render was committing twice — but it is a behaviour change on a leg that has never thrown here, and it is the maintainer's call whether it lands as a fix or waits for a major.
  • How far the re-check should chase a deferred invocation. Keeping the direct leg transparent means only a native promise result can be awaited before re-checking the count. A hook that returns a custom thenable and defers fn() into a microtask is therefore reported as "never invoked" (measured: it throws provideEvent returned without invoking... with the body never run). That hook is off-contract on the direct leg anyway — a synchronous call must get its value back, not a thenable — but whether the guard should say so in those words, or adopt arbitrary thenables and give up the transparency, is a judgement about which failure is more useful.

Regression test

packages/web/test/server/server-functions-direct-provide-event-once.spec.tsx — three cases against the built bundles, on the direct leg only (the HTTP leg's four are already in server-functions-open-gaps.spec.tsx under "provideEvent's invocation contract is enforced (#3172)", including "a hook that swallows the second-call refusal still fails the request", which must keep passing after the counter moves).

describe("the exactly-once contract on the direct SSR leg", () => {
  it("refuses a hook that invokes the function twice, before the body runs again", () => {
    let bodyRan = 0;
    configureServerFunctionsServer({
      // the shape a retry wrapper or a misplaced await produces
      provideEvent: (event, fn) => {
        fn();
        return fn();
      }
    });
    const call = createServerSideReference(
      registerServerReference("provide-twice-direct", () => {
        bodyRan++;
        return bodyRan;
      })
    );

    // today: no throw at all — the body commits twice and the render is
    // handed the second run's value as an ordinary success.
    expect(() => underRender(() => (call as any)())).toThrow(/more than once/);
    expect(bodyRan).toBe(1);
  });

  it("refuses a hook that never invokes the function, instead of answering undefined", () => {
    let bodyRan = 0;
    configureServerFunctionsServer({
      // off-contract on purpose, so the cast is the test's subject
      provideEvent: (() => undefined) as any
    });
    const call = createServerSideReference(
      registerServerReference("provide-never-direct", () => {
        bodyRan++;
        return "the value";
      })
    );

    // today: returns undefined, which a render cannot tell from a function
    // that returned nothing
    expect(() => underRender(() => (call as any)())).toThrow(/without invoking/);
    expect(bodyRan).toBe(0);
  });

  it("still returns a synchronous function's value synchronously under a correct hook", () => {
    let bodyRan = 0;
    configureServerFunctionsServer({
      provideEvent: (event, fn) => fn()
    });
    const call = createServerSideReference(
      registerServerReference("provide-once-direct", (n: number) => {
        bodyRan++;
        return n * 2;
      })
    );

    // the guard must not turn the direct leg async or wrap its result:
    // paired with the negatives above so a guard that refuses everything
    // cannot go green
    expect(underRender(() => (call as any)(21))).toBe(42);
    expect(bodyRan).toBe(1);
  });
});

Against f0f7531b the first two go red (expected [Function] to throw an error, with bodyRan at 2 and the return value undefined respectively) while the third stays green, so the pair pins the defect without letting a guard that refuses every call — or one that costs the direct leg its synchronous return — pass as a fix.

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