Repository navigation
fix(core): close the trigger connection on unsubscribe() and contain handler rejections - #4903
Conversation
…ndler rejections triggers.unsubscribe() unsubscribed the channel but never disconnected the pusher-js client, so the websocket kept Node alive. It also created a client and fetched credentials when nothing was subscribed, and concurrent subscribe() calls could each create a client, orphaning one. Give each subscription period its own client: concurrent subscribe() calls share one connection attempt, unsubscribe() aborts setup still in flight, unbinds the SDK's handlers and disconnects. A fresh client per period keeps a cancelled pending channel or a late channel-auth reply on the old socket from reviving an obsolete subscription. Route trigger and subscription-error callbacks through one boundary that contains synchronous throws and rejected promises and logs a printable description of the failure. Supersedes #4814 and #4813. Co-authored-by: DDT <[email protected]> Co-authored-by: Tanish Gudise <[email protected]>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Risk: No findings This PR rewrites the realtime trigger subscription lifecycle in @composio/core: each subscribe→unsubscribe period gets its own pusher-js client (with an AbortController cancelling in-flight setup), and all user callbacks now flow through one invokeUserCallback() boundary that contains synchronous throws and rejected promises. I traced every subscribe/unsubscribe/openConnection interleaving, the new error-containment and logging path, and the unchanged credential-resolution surfaces, and found no security vulnerability introduced — the change is a net hardening (no handler receives events after unsubscribe, and async callback rejections can no longer crash the host process). Sentinel reviewed |
|
The Please review and fix the vulnerabilities. You can try running: pnpm audit --fix --prodAudit output |
[Medium impact] The PR appears safe to merge; no actionable issues were found. SummaryThis PR closes trigger connections on
No actionable findings were accepted. Tests were inspected, not run during this review. Acknowledgment:
|
This PR:
triggers.unsubscribe()leaves the realtime connection open, so the Node process never exits #4812subscribe()→ nextunsubscribe()) its own pusher-js client: concurrentsubscribe()calls share one connection attempt, andunsubscribe()aborts setup still in flight, unbinds the SDK's handlers and disconnectsonSubscriptionErrorthrough oneinvokeUserCallback()boundary that contains synchronous throws and rejected promises and logs a printable description (describeThrown())pusher:subscription_errorfailures from fix(core): contain async Pusher subscription errors #4448, and also closes a remaining gap there: a rejection whose valueString()cannot convert used to throw inside the.catchand escape again@composio/core/@composio/slimDesignated PR reviewer: @jkomyno.
Root causes
These are two independent defects.
Connection never closed (#4812).
PusherService.unsubscribe()unsubscribed the channel but nothing calleddisconnect(), so the pusher-js websocket kept Node's event loop alive. It also calledgetPusherClient(), so anunsubscribe()before anysubscribe()fetched credentials and opened a connection.getPusherClient()checkedthis.pusherClientbefore two awaits (credentials,import('pusher-js')), so concurrent firstsubscribe()calls each created a client and the last assignment orphaned the others, whichunsubscribe()could never reach.Reusing one client across periods (the alternative discussed in #4812) is unsafe with pusher-js 8.6.0:
disconnect()keeps every channel in the client's table, with its bound handlers, and the nextconnect()resubscribes all of them (subscribeAll()).unsubscribe()on a channel whose auth is still pending only setssubscriptionCancelled; the channel stays, and a latersubscribe()of the same name reinstates it with the old handlers. The harness below shows this onnext: a handler removed byunsubscribe()still received the next event.A fresh client per period isolates all three: late replies and cancelled channels belong to a disconnected client whose
send_eventis a no-op, and its handlers are unbound.Async handler rejections (#4810).
Triggers.subscribe()andPusherService'ssafeCallbackcalled the handler inside a synchronoustry/catch. Anasynchandler returns a promise, so its rejection skipped thecatch, nothing handled it, and Node's defaultunhandledRejectionbehavior ended the process. Separately, the loggerJSON.stringifys object arguments, so a thrownErrorwas logged as{}.Ownership and cancellation model
PusherServiceholds at most oneattempt(anAbortControllerplus the sharedreadypromise) and, once created, theconnection(client, channel, channel name). Both are cleared together byunsubscribe().openConnection()checkssignal.abortedafter its last await; the client creation and channel subscription after it are synchronous, sounsubscribe()cannot interleave with them.subscribe()whose attempt was aborted resolves without subscribing, including when the aborted setup later fails (for example, the credentials request rejects). Asubscribe()that resumes after the client was created but afterunsubscribe()ran installs no handlers.subscribe()retries.unsubscribe()ends every subscription of theTriggersinstance, as documented ("Unsubscribe from all the triggers"), so it always closes the connection; there is no other channel on that client to keep it open.Verification
Focused regression tests (
ts/packages/core/test/services/pusher.test.ts,ts/packages/core/test/models/triggers.test.ts), using a fake pusher-js that keeps its binding semantics. 15 of them fail onnextsource and pass with this change:subscribe()resumesErrorand unprintable rejection values, and an unprintable rejection fromonSubscriptionErrorBuilt SDK against unmodified
pusher-js8.6.0 and a local Pusher-protocol (v7) server over TLS, which validates channel signatures against the socket they arrive on. A preload only redirects the TLS connection for the fake cluster host to localhost. Node 24.17.0, one process per scenario; "exits" means the process ended on its own after the scenario finished:next❌ Error in trigger callback: <message>, keeps running, exits 0Commands:
pnpm --filter @composio/core test: 73 files, 1842 passed, 2 expected failurespnpm --filter @composio/core typecheckoxlinton the changed files: no new warnings (one existing warning inTriggers.ts)pnpm validate:changesetsLimitations
Pusher.instancesarray, and itsConnectionManagerbindsonline/offlinelisteners on the module-levelNetworkobject thatdisconnect()does not remove, so each disconnected client stays reachable. Neither has a documented release API, and the listeners can't be told apart from other clients' listeners. With pusher-js alone, each connect/subscribe/disconnect cycle retained about 14 KB. Through the SDK, the per-cycle growth was about 14.6 KB over 300 cycles, which is mostly that client. The SDK unbinds its own handlers, so retained clients don't keep user callbacks alive. A script that subscribes once is unaffected; an app that cycles subscribe/unsubscribe many times keeps one disconnected client per cycle.TriggerSubscription.stop()already disconnects, and fix: follow pysher thread lifecycle in wait_forever #4829 covered the connection-thread lifecycle. The Python half of [Bug]: Async trigger handlers: a rejection ends the Node process (TS), and the handler never runs (Python) #4810 (anasync defhandler is never awaited) is a separate defect that needs a decision between rejecting async handlers and running them, so this PR doesn't close that issue.triggers.subscribe()keeps its(data) => voidhandler type; it already accepts async functions, and widening the return type would reject handlers that return other values.Credits
Thanks to DDT (@Hugo-DDT) for #4814 and to Tanish Gudise (@TanishGudise) for #4813 and the investigation in #4812 and #4810. Both are co-authors of the commit.
allChannels()check with an aborted attempt per period and a fresh client per period, which also covers asubscribe()that resumes afterunsubscribe()and the cancelled-channel and late-auth cases.Promise.resolve(...).catch(...)containment and the failure-description fallbacks, now shared by both callback paths asdescribeThrown(). Its regression tests are included unchanged.Pusher.instancesandNetworkretention finding and the late-auth concern, shaped the design and the harness above. Thanks also to PRABHU KIRAN VANDRANKI (@VANDRANKI) for the community reviews.