fix: avoid spurious unhandledRejection when transport fails mid-sendRequest#122
Open
zahalzel wants to merge 1 commit intoagentclientprotocol:mainfrom
Open
fix: avoid spurious unhandledRejection when transport fails mid-sendRequest#122zahalzel wants to merge 1 commit intoagentclientprotocol:mainfrom
zahalzel wants to merge 1 commit intoagentclientprotocol:mainfrom
Conversation
…equest If the transport fails (or #receive observes stream closure) while sendRequest is awaiting #sendMessage, #close() rejects the response promise before the caller's await has attached a handler. Node then reports a spurious unhandledRejection (followed by PromiseRejectionHandledWarning), even though the caller's await does observe the rejection. Attach a noop .catch to responsePromise so the rejection is considered handled at the time it's raised. The rejection is still delivered to the caller verbatim via 'return responsePromise'.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Connection.sendRequestcan leak an unhandled promise rejection when the underlying transport fails during theawait this.#sendMessage(...)call. The caller'sawaitdoes observe the rejection, but Node's rejection tracker has already firedunhandledRejection(followed byPromiseRejectionHandledWarning) becauseresponsePromisehad no handler at the moment#close()rejected it.The fix attaches a noop
.catchtoresponsePromiseimmediately after it's created, so the rejection is considered handled when raised. The rejection is still delivered to the caller verbatim viareturn responsePromise.Root cause
Inside
sendRequest:Race:
responsePromiseis created;{resolve, reject}stored in#pendingResponses.await #sendMessage(...)yields.#receive()exits →#close(err)iterates pending responses and rejectsresponsePromise.responsePromise(viareturn responsePromise) happens one macrotask later when#sendMessage's write queue finally settles.unhandledRejectionat the end of macrotask (3) — the handler isn't attached until macrotask (4).Symptom
Downstream tests using strict unhandled-rejection policies (vitest default,
node --unhandled-rejections=strict) fail:Minimal repro
Save as
repro.mjsin a scratch dir with@agentclientprotocol/sdk@0.19.0installed:Before this patch (on
main):(process exits 1)
After this patch:
(process exits 0)
Tests
npm test): 95 tests across 4 files.acp.test.ts, but vitest's internal unhandled-rejection tracking makes it tricky to observe this specific race from inside adescribeblock —process.on('unhandledRejection')listeners registered inside a test don't reliably see the rejection because vitest's own handlers intercept first. I can add one if you have a pattern for catching this kind of thing in the existing suite; otherwise the manual repro above is the clearest demonstration.Why this fix and not restructuring
sendRequestAlternatives considered:
#pendingResponsesafterawait #sendMessage. Wrong: messages can arrive before the write call returns, so the response map needs the entry before we send.#close— the rejection already has a handler at the.reject()call site (the promise itself); the issue is purely that no external handler was attached yet.Promise.withResolvers+ explicit tracking — more invasive for the same effect.The noop
.catchis the minimal change: it marks the promise as "will be handled" without changing any caller-visible behavior. The caller'sawaitstill sees the rejection throughreturn responsePromise.Downstream
This is blocking agent-loom PR #62 (adds an
AcpClientwrapper around the SDK for drivingcopilot --acp). Loom currently deletes the spawn-based missing-binary test case as a workaround; if this lands I'd like to restore it.Happy to iterate on the test approach or the comment wording.