Repository navigation
JS RPC: AbortTrigger returned by ExternalPusher.pushAbortSignal() keeps the callee's session alive #8073
Description
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:
- WorkerEntrypoint callee: the caller gets the return value, but the callee's trace shows
outcome: "canceled"instead ofok. - Durable Object callee: the same
canceledoutcome. The callee'sonabortlistener doesn't fire, which is correct since dropping the trigger doesn't abort the signal (Don't abort RPC AbortSignals when the trigger is dropped #7888). - Before Don't abort RPC AbortSignals when the trigger is dropped #7888 and related fixes: the hang detector could fire first and the callee's outcome was
exception.
Cloudflare's internal test suite has regression tests for both cases. They currently pin the canceled outcome as known-wrong behaviour.
Why it happens
- Serializing an
AbortSignalfor RPC (AbortSignal::serialize,src/workerd/api/basics.c++) calls the callee'sExternalPusher.pushAbortSignal()and keeps the returnedtriggerin anAbortTriggerRpcClient, one per clone, in the signal'srpcRegistrations, so a laterabort()can be forwarded. ExternalPusherImpl::pushAbortSignal()(src/workerd/io/external-pusher.c++) returns(signal :AbortSignal, trigger :AbortTrigger)(src/workerd/io/worker-interface.capnp).triggeris a promise that resolves to anAbortTriggerRpcServeronce the callee unwraps the signal.- The call goes through the session's top-level capability. That capability is wrapped in a
CompletionMembrane(src/workerd/util/completion-membrane.h; applied insrc/workerd/api/worker-rpc.c++), whose job is to resolvedonePromiseonce no capabilities cross between client and server for the session. - So the returned
triggeris wrapped in the membrane too. As long as the caller'sAbortSignal(or any clone) holds it, the membrane sees a live capability,donePromisedoesn'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
triggeroutside the membrane, the caller can callabort()after the callee's IoContext is gone.AbortTriggerRpcServer::abort()stores the reason in the sharedPendingAbortReasonBoxand fulfills a promise awaited byunwrapAbortSignalImpl(). 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
ExternalPusherbe exempt in the same way? It looks like onlyAbortTriggeris 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().signalto an entrypoint method that ignores it and returns. Check the callee's outcome isok, 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
signalisn't wrapped. - Existing related coverage:
src/workerd/api/tests/abortsignal-test.jsandeOrderTestinsrc/workerd/api/tests/js-rpc-test.js(call ordering with pushed externals, using anAbortSignal).
Activity