Summary
detachClient in packages/acp-bridge/src/bridge.ts unconditionally decrements entry.attachCount and then unregisters the client. The decrement is completely decoupled from whether the detach actually released a live attach reference, so several perfectly legal call patterns steal another attacher's count:
- Duplicate detach: the same client detaching twice (e.g. route handler + disconnect reaper both firing) decrements twice for a single attach.
- Unknown clientId: a stray
DELETE with a bogus X-Qwen-Client-Id still decrements.
- Anonymous detach: a
DELETE without X-Qwen-Client-Id still decrements.
- Spawn owner self-detach: the spawn owner never contributes to
attachCount (it stays 0 for the owner), yet its own DELETE detach decrements the counter that belongs to other attachers.
Impact
Once the counter is stolen down to 0 while real attachers are still connected, two kill paths fire early:
spawnOwnerWantedKill (the deferred-reap tombstone set when the owner's killSession({requireZeroAttaches: true}) bails) completes the reap and kills a session that still has live clients.
- The close-on-last-detach path (
clientIds.size === 0) becomes reachable earlier than it should.
Either way a live client's session is torn down underneath it — every subsequent request from that client 404s.
Reproduction (unit level)
With client A spawning (owner) and client B attached (attachCount === 1):
await bridge.detachClient(b.sessionId, b.clientId); // 1 -> 0, fine
await bridge.detachClient(b.sessionId, b.clientId); // duplicate: decrements again on pre-fix code, stealing the next attacher's count
Or more directly: after killSession(a.sessionId, { requireZeroAttaches: true }) bails and sets the tombstone, a detach with an unknown/anonymous clientId completes the deferred reap even though B never left.
Expected behavior
Every attachCount decrement must correspond to the release of a real attach reference, keyed by clientId. Detaches that don't hold such a reference (duplicates, unknown ids, anonymous requests, owner-style registrations) must leave the counter untouched — while still removing the client registration itself, which is idempotent.
Environment
- Affects the ACP bridge daemon serve mode (
packages/acp-bridge/src/bridge.ts), present on current main.
Summary
detachClientinpackages/acp-bridge/src/bridge.tsunconditionally decrementsentry.attachCountand then unregisters the client. The decrement is completely decoupled from whether the detach actually released a live attach reference, so several perfectly legal call patterns steal another attacher's count:DELETEwith a bogusX-Qwen-Client-Idstill decrements.DELETEwithoutX-Qwen-Client-Idstill decrements.attachCount(it stays 0 for the owner), yet its ownDELETEdetach decrements the counter that belongs to other attachers.Impact
Once the counter is stolen down to 0 while real attachers are still connected, two kill paths fire early:
spawnOwnerWantedKill(the deferred-reap tombstone set when the owner'skillSession({requireZeroAttaches: true})bails) completes the reap and kills a session that still has live clients.clientIds.size === 0) becomes reachable earlier than it should.Either way a live client's session is torn down underneath it — every subsequent request from that client 404s.
Reproduction (unit level)
With client A spawning (owner) and client B attached (
attachCount === 1):Or more directly: after
killSession(a.sessionId, { requireZeroAttaches: true })bails and sets the tombstone, a detach with an unknown/anonymous clientId completes the deferred reap even though B never left.Expected behavior
Every
attachCountdecrement must correspond to the release of a real attach reference, keyed by clientId. Detaches that don't hold such a reference (duplicates, unknown ids, anonymous requests, owner-style registrations) must leave the counter untouched — while still removing the client registration itself, which is idempotent.Environment
packages/acp-bridge/src/bridge.ts), present on currentmain.