Skip to content

Commit ea120a9

Browse files
秦奇qwencoder
andcommitted
fix(daemon): ACP-HTTP review round 2 — reconnect, ownership, leaks, negotiation
Folds in two independent reviews (correctness/concurrency + conformance/ security): - R1 (P0): session-stream reconnect was permanently dead (reused aborted AbortController) — fresh controller per stream + close prior stream. - R2 (P0): unhandled rejection from handle()/pumpSessionEvents after res.end(202) — wrapped in catch; isResponse path now try/caught. - R3 (P1): connection→session ownership (ownedSessions) — session stream 403s and per-session POSTs INVALID_PARAMS for unowned ids. - R4 (P1): dispose() wired into runQwenServe shutdown (was discarded). - R5 (P1): cancel pending permissions on session/connection teardown. - R6 (P1): cap pre-attach SSE buffers at 256 (drop-oldest). - R7/R8/R9 (P2): protocolVersion negotiation; Acp-Session-Id vs params.sessionId cross-check; session/cancel request-form reply; de-dup _meta.qwen. Tests expanded to 18 (ownership 403, unowned-prompt, header mismatch). Re-verified live: 21 session/update frames -> stopReason=end_turn. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
1 parent 46e1f3c commit ea120a9

7 files changed

Lines changed: 378 additions & 36 deletions

File tree

docs/design/daemon-acp-http/README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,3 +360,35 @@ clientId the bridge has never issued and stamp a fresh one (returned in
360360
(unregistered) id on `sendPrompt`. Fix: persist the bridge-stamped id on the
361361
`SessionBinding` and echo it on every per-session call (`sessionCtx`). Re-verified
362362
green above.
363+
364+
---
365+
366+
## 11. Review round 2 — fold-ins
367+
368+
Two independent reviews (correctness/concurrency + protocol-conformance/security) plus a self-read.
369+
All fixes verified by the expanded vitest suite (**18 tests**) + a fresh live smoke run
370+
(21 `session/update` frames → `stopReason=end_turn`).
371+
372+
| # | Severity | Finding | Fix |
373+
|---|----------|---------|-----|
374+
| R1 | **P0** | Session-stream **reconnect was permanently dead**: `SessionBinding.abort` was created once and reused; on stream close it was aborted forever, so a reconnect's `subscribeEvents(signal)` got an already-aborted signal and received zero events. | `attachSessionStream` now installs a **fresh** `AbortController` per stream (and closes any prior stream); `index.ts` pumps on that fresh signal. |
375+
| R2 | **P0** | `await dispatcher.handle()` ran **after** `res.end(202)`; a throwing bridge call (notably the un-try/caught `isResponse` path) would reject and surface as an unhandled rejection → possible daemon crash. | Wrapped the `isResponse` path in try/catch; `.catch()` on the awaited `handle(...)` and on `pumpSessionEvents(...)`. |
376+
| R3 | **P1** | **No connection→session ownership**: any authenticated connection could open the session SSE for, or prompt, *any* sessionId in the workspace (read-eavesdrop; prompt was only blocked incidentally by the unregistered-clientId error). | `AcpConnection.ownedSessions` populated by `session/new`/`load`/`resume`; session stream returns `403` and per-session POSTs return `INVALID_PARAMS` for unowned ids (`requireOwned`). |
377+
| R4 | **P1** | `mountAcpHttp` handle was discarded → TTL sweep timer + live SSE streams leaked on shutdown. | Handle parked on `app.locals`; `runQwenServe` close hook calls `dispose()` before `bridge.shutdown()` (mirrors the device-flow registry). |
378+
| R5 | **P1** | **Pending permission leak**: closing a session/connection with a permission outstanding left the bridge blocked awaiting a vote. | `closeSessionStream`/`destroy` cancel matching pending requests via an injected `onAbandonPending``cancelAbandonedPermission`. |
379+
| R6 | **P1** | Pre-attach frame buffers (`connBuffer`/`binding.buffer`) were unbounded. | Capped at 256 frames (drop-oldest), matching the EventBus `maxQueued`. |
380+
| R7 | **P2** | `initialize` ignored the client's requested `protocolVersion`. | Negotiates `min(requested, 1)`. |
381+
| R8 | **P2** | No `Acp-Session-Id``params.sessionId` cross-check (RFD §2.3). | POST asserts they agree; mismatch → `INVALID_PARAMS`. |
382+
| R9 | **P2** | `session/cancel` request-form (with id) never answered; duplicate top-level `_meta.qwen`. | Reply when an id is present; single `agentCapabilities._meta.qwen`. |
383+
384+
### Accepted / documented (not fixed in v1)
385+
386+
- **Prompt-result vs trailing `session/update` ordering** (P2): `handlePrompt` awaits `sendPrompt` then
387+
writes the result frame, while updates stream concurrently. In practice the bridge publishes all
388+
`session/update`s to the bus before `sendPrompt` resolves and both share one ordered SSE write
389+
chain, so the result lands last (confirmed: 21 updates then result). A strict barrier is a possible
390+
later hardening if a client reducer proves sensitive.
391+
- **Browser `EventSource` can't set `Authorization`**`/acp` GET streams require the bearer header,
392+
so browsers need the deferred WebSocket path (§7); CLI/Node clients are unaffected.
393+
- The daemon's real trust boundary remains the **bearer token + single-workspace bind** (same as the
394+
REST surface); R3's ownership check is defense-in-depth + contract correctness, not a tenant boundary.

packages/cli/src/serve/acpHttp/connectionRegistry.ts

Lines changed: 89 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,24 @@
77
import { randomUUID } from 'node:crypto';
88
import type { SseStream } from './sseStream.js';
99

10+
/**
11+
* Per-stream cap on frames buffered before the client attaches its SSE
12+
* stream. Mirrors the EventBus's `maxQueued` backpressure cap so a client
13+
* that drives requests without ever opening a stream can't grow daemon
14+
* memory without bound. Oldest frames are dropped past the cap.
15+
*/
16+
const MAX_BUFFERED_FRAMES = 256;
17+
18+
/**
19+
* Invoked when a session/connection tears down while an agent→client
20+
* request (e.g. a permission prompt) is still outstanding, so the bridge
21+
* isn't left blocked awaiting a vote that will never arrive.
22+
*/
23+
export type AbandonPendingFn = (
24+
req: PendingClientRequest,
25+
clientId: string | undefined,
26+
) => void;
27+
1028
/**
1129
* Tracks one logical ACP-over-HTTP connection (RFD #721). A connection is
1230
* minted at `initialize`, keyed by `Acp-Connection-Id`, and may host many
@@ -27,7 +45,13 @@ export interface SessionBinding {
2745
stream?: SseStream;
2846
/** Frames emitted before the session stream attached, flushed on attach. */
2947
buffer: unknown[];
30-
/** Aborts the bridge event subscription when the stream/connection closes. */
48+
/**
49+
* Aborts the bridge event subscription tied to the CURRENT session
50+
* stream. Replaced with a fresh controller on every re-attach — a
51+
* controller, once aborted (on stream close), can never resume, so
52+
* reusing it across reconnects would leave the new stream permanently
53+
* event-starved.
54+
*/
3155
abort: AbortController;
3256
}
3357

@@ -46,13 +70,23 @@ export class AcpConnection {
4670
/** Frames emitted before the connection stream attached, flushed on attach. */
4771
private readonly connBuffer: unknown[] = [];
4872
readonly sessions = new Map<string, SessionBinding>();
73+
/**
74+
* Sessions this connection created (`session/new`) or explicitly
75+
* attached to (`session/load`/`resume`). Per-session operations
76+
* (subscribe, prompt, cancel, …) are gated on membership here so one
77+
* connection can't drive or eavesdrop on a session it never claimed.
78+
*/
79+
readonly ownedSessions = new Set<string>();
4980
readonly pending = new Map<number, PendingClientRequest>();
5081
/** Daemon-issued client id reused across this connection's bridge calls. */
5182
readonly clientId: string;
5283
lastActiveMs: number = Date.now();
5384
private idCounter = 0;
5485

55-
constructor(connectionId?: string) {
86+
constructor(
87+
connectionId: string | undefined,
88+
private readonly onAbandonPending?: AbandonPendingFn,
89+
) {
5690
this.connectionId = connectionId ?? randomUUID();
5791
this.clientId = randomUUID();
5892
}
@@ -70,6 +104,14 @@ export class AcpConnection {
70104
this.lastActiveMs = Date.now();
71105
}
72106

107+
ownSession(sessionId: string): void {
108+
this.ownedSessions.add(sessionId);
109+
}
110+
111+
ownsSession(sessionId: string): boolean {
112+
return this.ownedSessions.has(sessionId);
113+
}
114+
73115
getOrCreateSession(sessionId: string): SessionBinding {
74116
let binding = this.sessions.get(sessionId);
75117
if (!binding) {
@@ -84,12 +126,15 @@ export class AcpConnection {
84126
if (this.connStream && !this.connStream.isClosed) {
85127
void this.connStream.send(frame);
86128
} else {
87-
this.connBuffer.push(frame);
129+
pushCapped(this.connBuffer, frame);
88130
}
89131
}
90132

91133
/** Attach the connection-scoped stream and flush any buffered frames. */
92134
attachConnStream(stream: SseStream): void {
135+
// Close any prior connection stream so its heartbeat interval + socket
136+
// don't leak when a client reconnects the connection-scoped GET.
137+
if (this.connStream && this.connStream !== stream) this.connStream.close();
93138
this.connStream = stream;
94139
for (const frame of this.connBuffer.splice(0)) void stream.send(frame);
95140
}
@@ -100,13 +145,25 @@ export class AcpConnection {
100145
if (binding.stream && !binding.stream.isClosed) {
101146
void binding.stream.send(frame);
102147
} else {
103-
binding.buffer.push(frame);
148+
pushCapped(binding.buffer, frame);
104149
}
105150
}
106151

107-
/** Attach a session-scoped stream and flush any buffered frames. */
108-
attachSessionStream(sessionId: string, stream: SseStream): SessionBinding {
152+
/**
153+
* Attach a session-scoped stream: close any prior stream, abort the prior
154+
* subscription, install the caller's FRESH AbortController (the old one is
155+
* aborted and can never resume — reusing it would leave the new stream
156+
* event-starved), flush buffered frames, and return the binding.
157+
*/
158+
attachSessionStream(
159+
sessionId: string,
160+
stream: SseStream,
161+
abort: AbortController,
162+
): SessionBinding {
109163
const binding = this.getOrCreateSession(sessionId);
164+
if (binding.stream && binding.stream !== stream) binding.stream.close();
165+
binding.abort.abort();
166+
binding.abort = abort;
110167
binding.stream = stream;
111168
for (const frame of binding.buffer.splice(0)) void stream.send(frame);
112169
return binding;
@@ -117,18 +174,39 @@ export class AcpConnection {
117174
if (!binding) return;
118175
binding.abort.abort();
119176
binding.stream?.close();
177+
this.abandonPendingForSession(sessionId, binding.clientId);
120178
this.sessions.delete(sessionId);
179+
this.ownedSessions.delete(sessionId);
121180
}
122181

123182
destroy(): void {
124183
for (const binding of this.sessions.values()) {
125184
binding.abort.abort();
126185
binding.stream?.close();
186+
this.abandonPendingForSession(binding.sessionId, binding.clientId);
127187
}
128188
this.sessions.clear();
189+
this.ownedSessions.clear();
129190
this.pending.clear();
130191
this.connStream?.close();
131192
}
193+
194+
/** Cancel + drop any pending agent→client requests for a closing session. */
195+
private abandonPendingForSession(
196+
sessionId: string,
197+
clientId: string | undefined,
198+
): void {
199+
for (const [id, req] of this.pending) {
200+
if (req.sessionId !== sessionId) continue;
201+
this.pending.delete(id);
202+
this.onAbandonPending?.(req, clientId);
203+
}
204+
}
205+
}
206+
207+
function pushCapped(buf: unknown[], frame: unknown): void {
208+
if (buf.length >= MAX_BUFFERED_FRAMES) buf.shift();
209+
buf.push(frame);
132210
}
133211

134212
/**
@@ -140,13 +218,16 @@ export class ConnectionRegistry {
140218
private readonly byId = new Map<string, AcpConnection>();
141219
private readonly sweepTimer: ReturnType<typeof setInterval>;
142220

143-
constructor(private readonly idleTtlMs = 30 * 60_000) {
221+
constructor(
222+
private readonly onAbandonPending?: AbandonPendingFn,
223+
private readonly idleTtlMs = 30 * 60_000,
224+
) {
144225
this.sweepTimer = setInterval(() => this.sweep(), 60_000);
145226
this.sweepTimer.unref();
146227
}
147228

148229
create(): AcpConnection {
149-
const conn = new AcpConnection();
230+
const conn = new AcpConnection(undefined, this.onAbandonPending);
150231
this.byId.set(conn.connectionId, conn);
151232
return conn;
152233
}

0 commit comments

Comments
 (0)