Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 146 additions & 1 deletion packages/acp-bridge/src/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12194,7 +12194,7 @@ describe('createAcpSessionBridge', () => {
expect(b.attached).toBe(true);
expect(bridge.sessionCount).toBe(1);
// B disconnects — but A is alive. detachClient must NOT reap.
await bridge.detachClient(b.sessionId);
await bridge.detachClient(b.sessionId, b.clientId);
// Session survives — A would have 404'd on every subsequent
// request otherwise.
expect(bridge.sessionCount).toBe(1);
Expand Down Expand Up @@ -12223,7 +12223,152 @@ describe('createAcpSessionBridge', () => {
expect(bridge.sessionCount).toBe(1); // bailed, no reap
// B disconnects: detachClient decrements attachCount→0 AND
// sees the tombstone → completes the deferred reap.
await bridge.detachClient(b.sessionId, b.clientId);
expect(bridge.sessionCount).toBe(0);
await bridge.shutdown();
});

it('duplicate detach with same clientId decrements attachCount only once (DAEMON-006)', async () => {
// A spawns (owner, attachCount stays 0); B and C attach
// (attachCount: 2). B's detach arrives TWICE (e.g. route
// handler + disconnect reaper both firing). The second detach
// finds no attach-ref in the ledger and must NOT decrement —
// otherwise it steals C's ref, attachCount hits 0 with C still
// connected, and A's requireZeroAttaches kill reaps a session
// C is actively using.
const factory: ChannelFactory = async () => makeChannel().channel;
const bridge = makeBridge({
channelFactory: factory,
sessionScope: 'single',
});
const a = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const b = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
expect(b.attached).toBe(true);
const c = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
expect(c.attached).toBe(true);
expect(c.clientId).not.toBe(b.clientId);
await bridge.detachClient(b.sessionId, b.clientId);
await bridge.detachClient(b.sessionId, b.clientId);
// attachCount must still be 1 (C's ref intact): the owner's
// zero-attaches kill bails and C's session survives.
await bridge.killSession(a.sessionId, { requireZeroAttaches: true });
expect(bridge.sessionCount).toBe(1);
await bridge.shutdown();
});

it('detach with unknown clientId does not decrement attachCount (DAEMON-006)', async () => {
// A stray DELETE with a bogus X-Qwen-Client-Id must not steal
// B's attach ref: the owner's requireZeroAttaches kill must
// still bail on attachCount === 1.
const factory: ChannelFactory = async () => makeChannel().channel;
const bridge = makeBridge({
channelFactory: factory,
sessionScope: 'single',
});
const a = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const b = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
expect(b.attached).toBe(true);
await bridge.detachClient(b.sessionId, 'client_does-not-exist');
await bridge.killSession(a.sessionId, { requireZeroAttaches: true });
expect(bridge.sessionCount).toBe(1);
await bridge.shutdown();
});

it('anonymous detach (no clientId) does not decrement attachCount (DAEMON-006)', async () => {
// A DELETE without X-Qwen-Client-Id returns 204 but releases
// nothing — attach/spawn responses always hand out a clientId,
// and clients that lost theirs are reaped by the idle backstop.
const factory: ChannelFactory = async () => makeChannel().channel;
const bridge = makeBridge({
channelFactory: factory,
sessionScope: 'single',
});
const a = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const b = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
expect(b.attached).toBe(true);
await bridge.detachClient(b.sessionId);
await bridge.killSession(a.sessionId, { requireZeroAttaches: true });
expect(bridge.sessionCount).toBe(1);
await bridge.shutdown();
});

it('spawn owner detaching itself does not steal an attacher ref (DAEMON-006)', async () => {
// Owner registrations never contribute to attachCount, so the
// owner's own DELETE detach must leave B's ref intact — while
// still dropping the owner's registration so close-on-last-
// detach stays reachable.
const factory: ChannelFactory = async () => makeChannel().channel;
const bridge = makeBridge({
channelFactory: factory,
sessionScope: 'single',
});
const a = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const b = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
expect(b.attached).toBe(true);
// Owner says goodbye with its own clientId.
await bridge.detachClient(a.sessionId, a.clientId);
expect(bridge.sessionCount).toBe(1);
// attachCount must still be 1 (B's ref survived the owner
// detach): the zero-attaches kill bails.
await bridge.killSession(a.sessionId, { requireZeroAttaches: true });
expect(bridge.sessionCount).toBe(1);
// Owner's registration WAS removed: once B leaves too, the
// deferred-reap tombstone completes and the session closes.
await bridge.detachClient(b.sessionId, b.clientId);
expect(bridge.sessionCount).toBe(0);
await bridge.shutdown();
});

it('deferred reap fires exactly on the real last attacher detach (DAEMON-006 regression)', async () => {
// With the tombstone set, noise detaches (unknown/anonymous)
// must NOT complete the reap early; the genuine last attacher's
// clientId-bearing detach must.
const factory: ChannelFactory = async () => makeChannel().channel;
const bridge = makeBridge({
channelFactory: factory,
sessionScope: 'single',
});
const a = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const b = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
expect(b.attached).toBe(true);
await bridge.killSession(a.sessionId, { requireZeroAttaches: true });
expect(bridge.sessionCount).toBe(1); // bailed, tombstone set
// Noise: neither of these releases B's ledger ref.
await bridge.detachClient(b.sessionId, 'client_unknown');
await bridge.detachClient(b.sessionId);
expect(bridge.sessionCount).toBe(1);
// The real last attacher leaves → deferred reap completes.
await bridge.detachClient(b.sessionId, b.clientId);
expect(bridge.sessionCount).toBe(0);
await bridge.shutdown();
});

it('repeated attach under one clientId detaches ref-by-ref (DAEMON-006)', async () => {
// Echoing the same clientId on a second attach refcounts the
// ledger entry; each detach releases exactly one ref, and the
// deferred reap fires only when the LAST ref is gone.
const factory: ChannelFactory = async () => makeChannel().channel;
const bridge = makeBridge({
channelFactory: factory,
sessionScope: 'single',
});
const a = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const b = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
expect(b.attached).toBe(true);
const c = await bridge.spawnOrAttach({
workspaceCwd: WS_A,
clientId: b.clientId,
});
expect(c.attached).toBe(true);
expect(c.clientId).toBe(b.clientId); // echoed id was honored
// attachCount is now 2; tombstone the owner's kill.
await bridge.killSession(a.sessionId, { requireZeroAttaches: true });
expect(bridge.sessionCount).toBe(1);
// First detach releases one ref → attachCount 2→1, no reap.
await bridge.detachClient(b.sessionId, b.clientId);
expect(bridge.sessionCount).toBe(1);
// Second detach releases the last ref → attachCount 0 → reap.
await bridge.detachClient(b.sessionId, b.clientId);
expect(bridge.sessionCount).toBe(0);
await bridge.shutdown();
});
Expand Down
65 changes: 63 additions & 2 deletions packages/acp-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,17 @@ interface SessionEntry {
* counter is observed atomically across the awaiting boundary.
*/
attachCount: number;
/**
* Per-clientId attach reference ledger. Every `attachCount`
* contribution that materialized into a registered clientId is
* recorded here; `detachClient` may only decrement `attachCount`
* by releasing a ref from this ledger. Owner-style registrations
* (spawn owner, restore initiator) never contribute to
* `attachCount` and are deliberately absent, so a detach with an
* owner clientId — or a duplicate/unknown/anonymous detach —
* cannot steal another attacher's count.
*/
attachRefs: Map<string, number>;
/**
* BkwQP: tombstone for the spawn-owner-disconnect path. When the
* spawn owner's HTTP response can't be written and they call
Expand Down Expand Up @@ -1816,12 +1827,44 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
}
};

// Record one attach-ref for `clientId` in the entry's ledger. Call
// only at sites where the registered clientId corresponds to an
// `attachCount` contribution (a direct `++` or a pre-folded coalesce
// reservation) — never for owner-style registrations.
const recordAttachRef = (entry: SessionEntry, clientId: string): void => {
entry.attachRefs.set(clientId, (entry.attachRefs.get(clientId) ?? 0) + 1);
};

// Release one attach-ref for `clientId`. Returns true only when a
// ledger ref was actually released; callers must gate every
// `attachCount` decrement on that result so duplicate, unknown or
// owner-clientId detaches cannot steal another attacher's count.
const releaseAttachRef = (entry: SessionEntry, clientId: string): boolean => {
const refs = entry.attachRefs.get(clientId);
if (refs === undefined || refs <= 0) return false;
if (refs === 1) {
entry.attachRefs.delete(clientId);
} else {
entry.attachRefs.set(clientId, refs - 1);
}
return true;
};

const rollbackAttachRegistration = async (
entry: SessionEntry,
clientId: string,
attachCountDelta = 1,
): Promise<void> => {
entry.attachCount = Math.max(0, entry.attachCount - attachCountDelta);
// The initiator's own contribution is only rolled back if it was
// actually recorded in the attach ledger; the remaining
// `attachCountDelta - 1` covers coalesce reservations that never
// registered a clientId (their promise rejects), so they carry no
// ledger entry to release.
const released = releaseAttachRef(entry, clientId) ? 1 : 0;
entry.attachCount = Math.max(
0,
entry.attachCount - (released + (attachCountDelta - 1)),
);
unregisterClient(entry, clientId);
if (
entry.spawnOwnerWantedKill &&
Expand Down Expand Up @@ -3502,6 +3545,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
clientIds: new Map(),
clientLastSeenAt: new Map(),
attachCount: 0,
attachRefs: new Map(),
spawnOwnerWantedKill: false,
promptActive: false,
retryAllowed: false,
Expand Down Expand Up @@ -3816,6 +3860,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
if (existing) {
existing.attachCount++;
const clientId = registerClient(existing, req.clientId);
recordAttachRef(existing, clientId);
if (req.approvalMode) {
await applyApprovalModeForAttach(existing, req.approvalMode, clientId);
}
Expand Down Expand Up @@ -3886,6 +3931,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// ACP state propagates to coalesced waiters (BQ9tV-equivalent
// for restore waiter consistency).
const clientId = registerClient(entry, req.clientId);
// This coalescer's attachCount contribution was pre-folded via
// `coalesceState.count`, so only the ledger is updated here.
recordAttachRef(entry, clientId);
if (req.approvalMode) {
await applyApprovalModeForAttach(entry, req.approvalMode, clientId);
}
Expand Down Expand Up @@ -4049,6 +4097,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// (they read it off the registered entry on the next tick).
racedEntry.attachCount += 1 + coalesceState.count;
const clientId = registerClient(racedEntry, req.clientId);
recordAttachRef(racedEntry, clientId);
if (req.approvalMode) {
try {
await applyApprovalMode(
Expand Down Expand Up @@ -4524,6 +4573,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// microtask," which is what we get here.
existing.attachCount++;
const clientId = registerClient(existing, req.clientId);
recordAttachRef(existing, clientId);
// If the caller passed a modelServiceId on attach, the session
// may currently be running a DIFFERENT model. Honor the request
// by issuing setSessionModel — same call we'd use on
Expand Down Expand Up @@ -4601,6 +4651,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
);
}
const clientId = registerClient(attachedEntry, req.clientId);
recordAttachRef(attachedEntry, clientId);
if (req.modelServiceId) {
// Same swallow as above — we picked up an in-flight
// spawn, the session is real, model-switch failure
Expand Down Expand Up @@ -7584,7 +7635,17 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// without sending a detach request.
const entry = byId.get(sessionId);
if (!entry) return;
if (entry.attachCount > 0) entry.attachCount--;
// Only a detach that releases a recorded attach-ref may decrement
// `attachCount`. Duplicate detaches, unknown/anonymous clientIds
// and owner-style registrations (spawn owner, restore initiator)
// carry no ledger ref, so they can no longer steal another
// attacher's count and trigger a premature kill. The
// registration ref is still dropped unconditionally below —
// unregisterClient is idempotent and an owner's explicit goodbye
// must keep the close-on-last-detach path reachable.
if (clientId !== undefined && releaseAttachRef(entry, clientId)) {
if (entry.attachCount > 0) entry.attachCount--;
}
unregisterClient(entry, clientId);
if (
entry.spawnOwnerWantedKill &&
Expand Down
Loading