Context
Surfaced while addressing @neo-gpt-emmy's RA-2 on PR #17715. Filed separately because it is a property of ai/mcp/client/Client.mjs and src/core/Base.mjs, not of the consumer that exposed it, and because it is currently unobservable in production — which is exactly the kind of defect that gets rediscovered rather than remembered.
The Problem
Neo.create(Client, …) constructs a client and opens its transport, then runs initAsync in a detached promise (src/core/Base.mjs:314) with no rejection handler. #readyPromise is settled solely by afterSetIsReady (:305), so it has no reject path at all.
When readiness fails — a rejected credential, an unreachable ingress, a failed handshake — three things follow:
- The rejection is unhandled, and Node's default policy terminates the process.
ready() never settles, so no caller can learn what happened by awaiting it.
- The client is never returned to the caller, so nobody holds a reference to close it. Its transport stays open and rejects again later.
(3) is what this ticket owns. (1) and (2) are worked around in the nightly runner by a run-scoped rejection sink, which is a consumer-side patch for a construction-side property.
Measured, against the live local ingress with a deliberately invalid NEO_MCP_REMOTE_TOKEN:
- plain Node: fatal
StreamableHTTPError: … GitHub PAT validation failed (HTTP 401), exit 1, before any consumer catch runs
- the same call inside a long-lived worker (a playwright unit worker): the run completes, and the abandoned transport then rejects a second time, after the consumer has already finished
The second observation is the leak. It is invisible in the nightly runner's production shape only because that process exits immediately after the run; any host that keeps the process alive — a daemon, a test worker, a REPL, a future scheduler that runs more than one task — sees it.
The Architectural Reality
| surface |
file |
property |
| detached init |
src/core/Base.mjs:314 |
Promise.resolve().then(async () => { await me.initAsync(); me.isReady = true }) — no catch |
| resolve-only ready |
src/core/Base.mjs:305 |
#readyPromise resolved by afterSetIsReady; never rejected |
| transport owner |
ai/mcp/client/Client.mjs |
createTransport() in initAsync; close() is the only teardown and needs the instance |
| same exposure |
ai/scripts/maintenance/kbPushClient.mjs:237 |
await client.ready?.() — survivable there only because a human is watching a CLI |
The nightly runner's own consumer-side mitigation is at ai/scripts/lifecycle/nightlyE2eRunner.mjs (PR #17715); it converts the crash into a recorded failure but cannot close a client it never received.
The Fix
Two independent halves. The first is the smaller and is this ticket's core; the second is the general one and may warrant its own decision record.
- A failed client cannot leak its transport.
Client should tear down the transport it opened when initialization does not complete, so no reference is required from a caller who never got one.
- Initialization rejection should be observable. Wiring
initAsync rejection into #readyPromise would make ready() reject rather than hang — the correct general repair, and an engine-wide behaviour change every Neo.create consumer inherits. It is named here rather than assumed: it is a src/core/Base.mjs contract change and deserves its own review, possibly its own ADR.
A consumer-side rejection sink (what PR #17715 ships) is a workaround, not the repair. It should be reviewable for removal once (2) lands.
Acceptance Criteria
Out of Scope
- The nightly runner's delivery semantics — PR #17715 / #17714 own those.
- Changing MCP transports, auth, or the client's tool surface.
- Retrofitting every
Neo.create consumer; this establishes the property, it does not audit the fleet.
Avoided Traps
- Fix it only in the nightly runner. Rejected — that is where it was found, not where it lives. Two other consumers have the same exposure today.
- Treat "production never sees it" as "not a defect". Rejected. Production does not see it because the process exits, not because the transport is closed; the first long-lived consumer inherits it silently.
- Fold the
Base.mjs change in quietly. Rejected: it changes behaviour for every Neo.create in the codebase and must be decided in the open.
Related
Found via: PR #17715 review by @neo-gpt-emmy (RA-2) · consumer: #17714 · same exposure: ai/scripts/maintenance/kbPushClient.mjs
Live latest-open sweep: checked the latest 12 open issues plus a targeted search at 2026-08-24T17:05Z; no equivalent ticket.
Origin Session ID: 728a756d-71df-48e6-8dad-0bac498ca23e
Retrieval Hint: query_raw_memories("Neo.create detached initAsync ready never rejects client transport leak")
Context
Surfaced while addressing @neo-gpt-emmy's RA-2 on PR #17715. Filed separately because it is a property of
ai/mcp/client/Client.mjsandsrc/core/Base.mjs, not of the consumer that exposed it, and because it is currently unobservable in production — which is exactly the kind of defect that gets rediscovered rather than remembered.The Problem
Neo.create(Client, …)constructs a client and opens its transport, then runsinitAsyncin a detached promise (src/core/Base.mjs:314) with no rejection handler.#readyPromiseis settled solely byafterSetIsReady(:305), so it has no reject path at all.When readiness fails — a rejected credential, an unreachable ingress, a failed handshake — three things follow:
ready()never settles, so no caller can learn what happened by awaiting it.(3) is what this ticket owns. (1) and (2) are worked around in the nightly runner by a run-scoped rejection sink, which is a consumer-side patch for a construction-side property.
Measured, against the live local ingress with a deliberately invalid
NEO_MCP_REMOTE_TOKEN:StreamableHTTPError: … GitHub PAT validation failed (HTTP 401), exit 1, before any consumercatchrunsThe second observation is the leak. It is invisible in the nightly runner's production shape only because that process exits immediately after the run; any host that keeps the process alive — a daemon, a test worker, a REPL, a future scheduler that runs more than one task — sees it.
The Architectural Reality
src/core/Base.mjs:314Promise.resolve().then(async () => { await me.initAsync(); me.isReady = true })— nocatchsrc/core/Base.mjs:305#readyPromiseresolved byafterSetIsReady; never rejectedai/mcp/client/Client.mjscreateTransport()ininitAsync;close()is the only teardown and needs the instanceai/scripts/maintenance/kbPushClient.mjs:237await client.ready?.()— survivable there only because a human is watching a CLIThe nightly runner's own consumer-side mitigation is at
ai/scripts/lifecycle/nightlyE2eRunner.mjs(PR #17715); it converts the crash into a recorded failure but cannot close a client it never received.The Fix
Two independent halves. The first is the smaller and is this ticket's core; the second is the general one and may warrant its own decision record.
Clientshould tear down the transport it opened when initialization does not complete, so no reference is required from a caller who never got one.initAsyncrejection into#readyPromisewould makeready()reject rather than hang — the correct general repair, and an engine-wide behaviour change everyNeo.createconsumer inherits. It is named here rather than assumed: it is asrc/core/Base.mjscontract change and deserves its own review, possibly its own ADR.A consumer-side rejection sink (what PR #17715 ships) is a workaround, not the repair. It should be reviewable for removal once (2) lands.
Acceptance Criteria
ready()-never-settles behaviour is either repaired atsrc/core/Base.mjswith its own justification, or explicitly recorded as accepted with the reason consumers must guard it themselves.kbPushClient.mjs:237andnightlyE2eRunner.mjsare re-examined, and the runner's consumer-side sink is removed or justified as still needed.nightlyE2eRunner.spec.mjs) because a playwright worker outlives the run and this leak made the abandoned transport reject after every assertion in it had passed — red for the worker's lifetime rather than the runner's behaviour. Its evidence currently lives only as a reproducible command in that PR's body, which is weaker than an arm and decays faster. Once the transport is torn down, that arm goes back into the suite. This is the A+FU residual of #17714 and the reason it is named here rather than remembered: removed coverage with no owner is indistinguishable from coverage nobody wanted.Out of Scope
Neo.createconsumer; this establishes the property, it does not audit the fleet.Avoided Traps
Base.mjschange in quietly. Rejected: it changes behaviour for everyNeo.createin the codebase and must be decided in the open.Related
Found via: PR #17715 review by @neo-gpt-emmy (RA-2) · consumer: #17714 · same exposure:
ai/scripts/maintenance/kbPushClient.mjsLive latest-open sweep: checked the latest 12 open issues plus a targeted search at 2026-08-24T17:05Z; no equivalent ticket.
Origin Session ID: 728a756d-71df-48e6-8dad-0bac498ca23e
Retrieval Hint:
query_raw_memories("Neo.create detached initAsync ready never rejects client transport leak")