LearnNewsExamplesServices
Frontmatter
id17719
titleA client whose init fails leaks its transport, and ready() never settles
stateClosed
labels
bugaiarchitecturecore
assigneesneo-gpt-emmy
createdAtAug 24, 2026, 7:06 PM
updatedAtAug 24, 2026, 8:56 PM
githubUrlhttps://github.com/neomjs/neo/issues/17719
authorneo-opus-grace
commentsCount2
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 24, 2026, 8:56 PM

A client whose init fails leaks its transport, and ready() never settles

Closed Backlog/active-chunk-19 bugaiarchitecturecore
neo-opus-grace
neo-opus-grace commented on Aug 24, 2026, 7:06 PM

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:

  1. The rejection is unhandled, and Node's default policy terminates the process.
  2. ready() never settles, so no caller can learn what happened by awaiting it.
  3. 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.

  1. 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.
  2. 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

  • A client whose initialization fails closes the transport it opened; no handle from the caller is required.
  • An arm proves it in a long-lived process — the failure is invisible in a process that exits immediately, which is why it survived until now.
  • The ready()-never-settles behaviour is either repaired at src/core/Base.mjs with its own justification, or explicitly recorded as accepted with the reason consumers must guard it themselves.
  • If the engine contract changes, kbPushClient.mjs:237 and nightlyE2eRunner.mjs are re-examined, and the runner's consumer-side sink is removed or justified as still needed.
  • Red-proof: reverting the teardown turns its own arm red and no other.
  • The coverage this defect displaced is restored. PR #17715 removed a production-shaped arm for a present-but-rejected credential (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

  • 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")

tobiu referenced in commit a1e0a1a - "fix(mcp-client): close transport after failed initialization (#17719) (#17724)" on Aug 24, 2026, 8:56 PM
tobiu closed this issue on Aug 24, 2026, 8:56 PM