Skip to content

RFC 10 follow-up: support per-call server function cancellation #3057

Closed

Description

@birkskyum

Problem

RFC 10 rejects a per-call transport API partly because abort signals had no known consumer.

Router navigation provides a concrete consumer. A loader can start a server function call and then be superseded by another navigation. Routers already expose an AbortSignal for this lifecycle, but there is no way to attach that signal to the fetch performed by a server function reference. The global prepareRequest hook cannot represent invocation-specific cancellation safely.

Proposed scope

Add a narrow mechanism for passing an AbortSignal to one server function invocation. One possible shape is:

invokeServerFunction(fn, { signal }, ...args);

The exact API is open. The mechanism should:

  • cancel the client transport request
  • work with GET and POST references
  • compose with rich arguments and single-flight responses
  • retain the existing plain callable API
  • avoid restoring arbitrary per-call headers or RequestInit policy

On HTTP dispatch, cancellation already has a natural server representation through event.request.signal. Direct SSR calls need a documented behavior but should not require an HTTP loopback.

Why core

Only the server-function transport can attach invocation-specific lifecycle state to its internal fetch. Routers can supply the signal, but cannot implement the transport mechanism outside core.

Activity

  1. ryansolid commented on Aug 25, 2026

    @ryansolid
    Member

    We took a hard look at this against solid-router's actual architecture, and the decision is not to add a per-call cancellation API. Reasoning, so the record is complete:

    The stated consumer doesn't have a signal to hand us. Solid-router's supersession model is transition-based, not abort-based: a superseded navigation's in-flight results are discarded by latest-wins reactivity. There's no AbortController anywhere in the router — the loader-abort lifecycle in the proposal describes how other routers work, not this one.

    Per-call is the wrong grain for the calls that matter anyway. The requests worth cancelling flow through query(), which dedupes and caches — an in-flight fetch isn't owned by any single caller, so one caller's signal must never kill the promise other subscribers share. If cancellation is ever bought here, it lands as refcounted teardown inside the router's cache layer (last subscriber leaving aborts), talking to the transport over an internal seam like the existing RPC one — not as a public per-invocation option. End users would only ever observe superseded requests disappearing, never hold a controller.

    The expensive case already has its verb. Long-lived streams — the one place cancellation stops real ongoing work — are covered: liveQuery teardown returns the async iterator and the transport aborts the call. Cancellation rides the value protocol, scoped to the thing being cancelled.

    What the discussion did settle, and what stays on record for the future: your issue prompted us to articulate the scope taxonomy behind RFC 10's surfaces — registration-scoped (server policy), declaration-scoped (call-path transport, withMeta/GET), and invocation-scoped (per-call identity: today args, and only a lifecycle signal if this door ever opens). A signal genuinely can't be declared or configured, so it passes the admission test the rejected per-call headers/RequestInit fail — the objection here is consumer and grain, not category. If someone brings a consumer whose calls are un-shared and measurements showing the waste matters, this reopens on that basis, through the internal-seam shape above. (One naming note for that future: .with is taken — it's partial application on actions.)

    Thanks for pushing on this — it produced the sharpest articulation of these boundaries we've had, and directly shaped the rework direction on #3058 as well.

  2. ryansolid commented on Aug 25, 2026

    @ryansolid
    Member

    Reopening to keep this as the tracking point rather than a closed door — the analysis above stands (no public per-call API; the proposed shape is wrong-grained for deduped calls), but the underlying capability has a defined path to acceptance: a consumer with un-shared calls and measurements showing the waste matters, delivered through router-primitive-level refcounted cancellation over an internal transport seam, signal-only. Open until proven or obsoleted.

  3. birkskyum commented on Aug 25, 2026

    @birkskyum
    MemberAuthor

    I tested this against the current Solid 2 + TanStack template and found a concrete shared-call consumer.

    Setup:

    • @solidjs/web@2.0.0-rc.2
    • @solidjs/vite-plugin@3.0.0-next.33
    • @tanstack/solid-query@6.0.0-rc.0 / query-core 5.101.0
    • production build using the template's Node adapter
    • a Solid server function records event.request.signal and waits 2 seconds
    • two QueryObservers subscribe to the same query, producing one shared server-function request
    • the observers are released one at a time

    With the current Solid transport:

    after first release:
      query signal aborted: false
      server: pending
    
    after last release:
      query signal aborted: true
      server: pending
    
    after 2 seconds:
      query signal aborted: true
      server: completed (2000.77 ms)
    

    As a control, I used prepareRequest to put that same query-owned signal on the internal fetch:

    after first release:
      query signal aborted: false
      server: pending
    
    after last release:
      query signal aborted: true
      server: aborted (294.93 ms)
    
    after 2 seconds:
      query signal aborted: true
      server: still aborted
    

    This confirms the ownership grain is correct. TanStack Query keeps the shared request alive while one observer remains and aborts only after the final observer releases it. Today that signal stops at the query boundary, so the Solid request and server work continue.

    The prepareRequest wiring was only a probe. A mutable ambient signal is not a proposed API and would not compose safely with other transport configuration. The product shape can remain the one described above: a signal-only internal RPC/transport seam consumed by the TanStack query/router integration, with TanStack owning sharing and teardown. It does not require a public per-call RequestInit API.

    This is a synthetic 2-second workload rather than a production benchmark, but it gives us a repeatable end-to-end protocol reproduction. The current fullstack template already establishes TanStack Query as the owner of data calls: https://github.com/solidjs/templates/blob/main/solid-v2/fullstack-tanstack/src/lib/queries.ts

  4. ryansolid commented on Aug 27, 2026

    @ryansolid
    Member

    This is now shipped on next (475744c), and it's worth recording how the posture changed, because the original close said "no per-call mechanism — revisit only with a concrete use case in hand." The use case you supplied (a data layer superseding a query needs the in-flight request cancelled) held up under scrutiny: it's a fact about one call, so no declaration (GET/withMeta) or session hook (prepareRequest) can express it. The call-scoped slot in RFC 10's lifetime taxonomy now has its tenant:

    import { invoke } from "@solidjs/web/server-functions";
    
    const user = await invoke(getUser, { signal: controller.signal }, id);

    Shape. Function.prototype.call's silhouette — the options bag sits in the thisArg slot, the call's own arguments spread after it. The mental model we're documenting: declaration wrappers are bind (new reference, context travels with it), invoke is call (one application, no residue on the reference). A curried form was considered and rejected — invoke(fn, options) returning a callable is structurally a bind, and would have quietly revived withOptions.

    Options are admission-tested, not open-ended. An option belongs here only if it varies between calls of the same function and cannot be declared or configured. Three pass today: signal (aborting rejects the call and cancels the request; timeouts compose via AbortSignal.timeout/AbortSignal.any), keepalive (unload-time calls), priority (fetch priority hint). Everything longer-lived is refused with a redirect to its home — invoke throws the pointer rather than silently dropping: headers → prepareRequest/withMeta/arguments (cache-key integrity), method → GET(fn) (CSRF posture), retries/dedupe → the data layer, wired through signal. This is the line that keeps the invocator from becoming the per-call RequestInit passthrough the RFC rejected — that rejection stands.

    Composition. Dispatch rides a registered-symbol invocation channel (SERVER_FUNCTION_INVOKE) that's part of the reference contract, forwarded by wrappers the way declaration metadata already is. GET(fn) invokes over its query encoding (URL-length POST fallback included); live(fn) ends its iteration on abort, across reconnects. Data-layer wrappers (router query/action) adapt the channel to their own policy — a caching wrapper decides what a caller's abort means for a shared in-flight call — which is the follow-up PR on the router side.

    Server mirror. In-process calls honor signal by rejecting the caller (the work runs to completion unless the function observes a signal of its own, same as a server behind HTTP); the transport hints are no-ops there, since they describe a wire that doesn't exist.

    RFC 10 is amended accordingly (lifetime table, an invoke section with the admission test and redirect table, and the alternatives record updated to show the conditional cut resolving). Thanks for pressing on this one — the constraint that made it hard (transparent variadic signatures leave no room at the call site) is exactly what makes the rest of the design good, and the invocator is the honest price.

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