Skip to content

JS RPC: AbortTrigger returned by ExternalPusher.pushAbortSignal() keeps the callee's session alive #8073

Open

Description

@npaun

Problem

When a Worker passes an AbortSignal over JS RPC, the AbortTrigger capability that the callee hands back keeps the RPC session open after the callee has returned. The callee's request then ends only when the caller finishes and the session is torn down, and it's recorded as canceled rather than ok.

Observed with a callee that takes a signal and returns immediately without touching it:

Cloudflare's internal test suite has regression tests for both cases. They currently pin the canceled outcome as known-wrong behaviour.

Why it happens

  1. Serializing an AbortSignal for RPC (AbortSignal::serialize, src/workerd/api/basics.c++) calls the callee's ExternalPusher.pushAbortSignal() and keeps the returned trigger in an AbortTriggerRpcClient, one per clone, in the signal's rpcRegistrations, so a later abort() can be forwarded.
  2. ExternalPusherImpl::pushAbortSignal() (src/workerd/io/external-pusher.c++) returns (signal :AbortSignal, trigger :AbortTrigger) (src/workerd/io/worker-interface.capnp). trigger is a promise that resolves to an AbortTriggerRpcServer once the callee unwraps the signal.
  3. The call goes through the session's top-level capability. That capability is wrapped in a CompletionMembrane (src/workerd/util/completion-membrane.h; applied in src/workerd/api/worker-rpc.c++), whose job is to resolve donePromise once no capabilities cross between client and server for the session.
  4. So the returned trigger is wrapped in the membrane too. As long as the caller's AbortSignal (or any clone) holds it, the membrane sees a live capability, donePromise doesn't resolve, and the callee's custom event stays open after the method has returned.

Proposal

Have CompletionMembrane explicitly recognize ExternalPusher.pushAbortSignal() and skip wrapping the returned AbortTrigger. Holding a trigger only means "I may want to abort later"; it shouldn't keep the session or the callee's request alive.

One way to do it: in inboundCall(), when interfaceId/methodId match ExternalPusher.pushAbortSignal, redirect the call to a small forwarding capability. capnp doesn't auto-wrap the parameters or results of a redirected call (see the inboundCall() docs in capnp/membrane.h), so the forwarder can return trigger unwrapped. Per those docs, outboundCall() should make the same decision for consistency.

The returned AbortSignal still has to be wrapped in the membrane. The caller passes it back across the boundary inside the serialized value, and ExternalPusherImpl::unwrapAbortSignalImpl() identifies it with abortSignalSet.getLocalServer(cap). That only works when the membrane unwraps it on the way back in. If it leaves unwrapped, it comes back wrapped, isn't recognized as a member of the CapabilityServerSet, and unwrapping fails with pushed external is not an AbortSignal. So the forwarder should wrap signal in the same membrane itself before returning, and leave only trigger unwrapped.

Open questions

  • Trigger lifetime after the callee's request ends. With trigger outside the membrane, the caller can call abort() after the callee's IoContext is gone. AbortTriggerRpcServer::abort() stores the reason in the shared PendingAbortReasonBox and fulfills a promise awaited by unwrapAbortSignalImpl(). We need to confirm that's safe, or at worst a no-op, once that request has finished, including for Durable Objects that may have hibernated.
  • Other pushed externals. Should anything else returned through ExternalPusher be exempt in the same way? It looks like only AbortTrigger is a long-lived back-channel; streams and channel tokens should keep counting toward session liveness.

Tests

  • Add a workerd test where a caller passes AbortController().signal to an entrypoint method that ignores it and returns. Check the callee's outcome is ok, and that the session ends when the callee returns, not when the caller finishes.
  • Keep coverage that the signal still round-trips: abort after the call and check the callee sees it, and pass a received signal back to its origin. That second case is the one that breaks if signal isn't wrapped.
  • Existing related coverage: src/workerd/api/tests/abortsignal-test.js and eOrderTest in src/workerd/api/tests/js-rpc-test.js (call ordering with pushed externals, using an AbortSignal).

Activity

  1. self-assigned this
    on Oct 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

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