Skip to content

Commit c59253f

Browse files
秦奇qwencoder
andcommitted
fix(daemon): ACP-HTTP review round 3 — PR bot fold-ins (QwenLM#4472)
Addresses the automated PR reviews: - B1 (P0): abort in-flight prompt on session/cancel + teardown (promptAbort on SessionBinding) — stops orphaned agent runs burning model quota. - B2 (P0): thread fromLoopback (kernel remoteAddress) into sessionCtx so the local-only permission policy works for loopback ACP clients. - B3 (P0): SSE write failure logs + closes the stream (no zombie streams). - B4 (P0): sweep logs reaps; pumpSessionEvents touch()es; maxConnections cap (64) -> 503 on flood. - B5 (P1): throw on missing bridge-stamped clientId (no silent fallback); FakeBridge stamps one. - B6/B7 (P1): validate cwd (string/<=4096/absolute) + prompt (non-empty object array), mirroring the REST surface. - B8 (P1): toRpcError maps known bridge errors to coded, client-safe shapes. - B9 (P1): daemon-originated JSON-RPC ids are strings (_qwen_perm_N), collision-proof vs client ids. - B10 (P2): resolveClientResponse param type; conn-stream onClose log; DELETE missing-header 400; SseStream onClose try/catch; load/resume/close + DELETE-400 tests. Suite 18 -> 22 tests, all green. Re-verified live (16 session/update -> end_turn). Base-branch acpAgent.ts typecheck findings are out of scope (introduced by QwenLM#4353; tracked separately). Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
1 parent ea120a9 commit c59253f

5 files changed

Lines changed: 356 additions & 46 deletions

File tree

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,3 +392,31 @@ All fixes verified by the expanded vitest suite (**18 tests**) + a fresh live sm
392392
so browsers need the deferred WebSocket path (§7); CLI/Node clients are unaffected.
393393
- The daemon's real trust boundary remains the **bearer token + single-workspace bind** (same as the
394394
REST surface); R3's ownership check is defense-in-depth + contract correctness, not a tenant boundary.
395+
396+
---
397+
398+
## 12. Review round 3 — PR bot fold-ins (#4472)
399+
400+
Two automated PR reviewers plus the summary bot.
401+
All fixes verified by the suite (now **22 tests**) + a fresh live run (16 `session/update``end_turn`).
402+
403+
| # | Severity | Finding | Fix |
404+
|---|----------|---------|-----|
405+
| B1 | **P0** | `handlePrompt`'s `AbortController` was never aborted — a disconnecting/cancelling client left the agent running (burned model quota, blocked the session FIFO). Flagged by both bots + 5 sub-agents. | `promptAbort` parked on `SessionBinding`; aborted by `session/cancel` and by session/connection teardown (`closeSessionStream`/`destroy`). |
406+
| B2 | **P0** | `sessionCtx` missing `fromLoopback` → every ACP permission vote treated as remote; `local-only` policy would reject loopback clients. | Capture loopback at `initialize` (kernel `remoteAddress`, not forgeable headers) → `AcpConnection.fromLoopback` → threaded through `sessionCtx`. |
407+
| B3 | **P0** | SSE write failures silently swallowed → zombie streams (heartbeats fire, zero events delivered, no logs). | First write failure logs + closes the stream. |
408+
| B4 | **P0** | Idle sweep destroyed connections with no log + no connection cap (initialize-flood). | Sweep logs each reap; `pumpSessionEvents` calls `touch()` (long quiet prompts aren't reaped); `maxConnections` cap (64) → `503`. |
409+
| B5 | **P1** | `sessionCtx` silently fell back to the connection's unregistered clientId when the binding lacked one (untested, always-fired in `FakeBridge`). | Throw on missing stamped clientId (invariant violation); `FakeBridge` now stamps one. |
410+
| B6 | **P1** | `session/new|load|resume` accepted `cwd` unvalidated (REST validates string/length/absolute — amplification DoS). | Shared `parseOptionalWorkspaceCwd` (string, ≤4096, absolute). |
411+
| B7 | **P1** | `session/prompt` forwarded an unvalidated `prompt` to the bridge. | `validatePrompt` (non-empty array of objects), mirroring REST. |
412+
| B8 | **P1** | Raw bridge error messages echoed to the client. | `toRpcError` maps known bridge errors to coded, client-safe shapes; unknown → generic `Internal error` (full detail still to stderr). |
413+
| B9 | **P1** | `nextId` used sequential negatives — a client legally using negative ids could collide in `pending`. | Daemon-originated ids are now strings (`_qwen_perm_N`), disjoint from any client id. |
414+
| B10 | **P2** | `resolveClientResponse` param type excluded `JsonRpcError`; conn-scoped SSE stream had no `onClose`; `DELETE` with no header was a silent 202; `SseStream.close` ran `onClose` outside try/catch; `session/load`·`resume`·`close` untested. | Widened param to `JsonRpcResponse`; conn stream logs on close; `DELETE` missing header → `400`; `onClose` wrapped in try/catch; added load/resume/close + DELETE-400 tests. |
415+
416+
**Out of scope (base-branch `daemon_mode_b_main`, not this diff)** — the second reviewer flagged
417+
typecheck errors in `acpAgent.ts` (`entryCount`/`entrySummary`/`sessionClose`) and other pre-existing
418+
items it explicitly attributed to the base branch (introduced by #4353). Tracked separately; not
419+
touched here.
420+
421+
**Still deferred** (documented): per-connection secret for `DELETE`/connection ownership (token remains
422+
the boundary); WebSocket + HTTP/2 (§7); strict prompt-result vs trailing-update barrier (§11).

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

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ import type { SseStream } from './sseStream.js';
1515
*/
1616
const MAX_BUFFERED_FRAMES = 256;
1717

18+
/** Default cap on concurrent live connections (mirrors a bounded resource). */
19+
const DEFAULT_MAX_CONNECTIONS = 64;
20+
21+
function logStderr(line: string): void {
22+
process.stderr.write(`${line}\n`);
23+
}
24+
1825
/**
1926
* Invoked when a session/connection tears down while an agent→client
2027
* request (e.g. a permission prompt) is still outstanding, so the bridge
@@ -53,6 +60,13 @@ export interface SessionBinding {
5360
* event-starved.
5461
*/
5562
abort: AbortController;
63+
/**
64+
* Aborts the in-flight `session/prompt` for this session. Set by
65+
* `handlePrompt` while a prompt runs; aborted on `session/cancel` and on
66+
* session/connection teardown so a disconnecting client doesn't leave
67+
* the agent burning model quota on a result nobody will read.
68+
*/
69+
promptAbort?: AbortController;
5670
}
5771

5872
/** An agent→client request awaiting the client's JSON-RPC response. */
@@ -77,27 +91,40 @@ export class AcpConnection {
7791
* connection can't drive or eavesdrop on a session it never claimed.
7892
*/
7993
readonly ownedSessions = new Set<string>();
80-
readonly pending = new Map<number, PendingClientRequest>();
94+
/** Agent→client requests awaiting a client response, keyed by JSON-RPC id. */
95+
readonly pending = new Map<string, PendingClientRequest>();
8196
/** Daemon-issued client id reused across this connection's bridge calls. */
8297
readonly clientId: string;
98+
/**
99+
* True when the `initialize` POST arrived from a kernel-stamped loopback
100+
* peer. Threaded into per-session bridge contexts so the `local-only`
101+
* permission policy can gate votes by transport — mirrors the REST
102+
* surface's `detectFromLoopback(req)`. NOT derived from forgeable
103+
* headers (`X-Forwarded-For` etc).
104+
*/
105+
readonly fromLoopback: boolean;
83106
lastActiveMs: number = Date.now();
84107
private idCounter = 0;
85108

86109
constructor(
87110
connectionId: string | undefined,
111+
fromLoopback: boolean,
88112
private readonly onAbandonPending?: AbandonPendingFn,
89113
) {
90114
this.connectionId = connectionId ?? randomUUID();
91115
this.clientId = randomUUID();
116+
this.fromLoopback = fromLoopback;
92117
}
93118

94-
/** Allocate a fresh JSON-RPC id for an agent→client request. */
95-
nextId(): number {
96-
// Negative ids keep our outbound request ids disjoint from the
97-
// client's (clients conventionally use positive ids), so a client
98-
// that echoes ids can't collide with our permission requests.
99-
this.idCounter -= 1;
100-
return this.idCounter;
119+
/**
120+
* Allocate a fresh JSON-RPC id for an agent→client request. STRING-typed
121+
* (`_qwen_perm_N`) so it can never collide with a client-originated id —
122+
* JSON-RPC 2.0 permits clients to use any number (incl. negatives) or
123+
* string, so a numeric namespace wasn't actually safe.
124+
*/
125+
nextId(): string {
126+
this.idCounter += 1;
127+
return `_qwen_perm_${this.idCounter}`;
101128
}
102129

103130
touch(): void {
@@ -173,6 +200,7 @@ export class AcpConnection {
173200
const binding = this.sessions.get(sessionId);
174201
if (!binding) return;
175202
binding.abort.abort();
203+
binding.promptAbort?.abort();
176204
binding.stream?.close();
177205
this.abandonPendingForSession(sessionId, binding.clientId);
178206
this.sessions.delete(sessionId);
@@ -182,6 +210,7 @@ export class AcpConnection {
182210
destroy(): void {
183211
for (const binding of this.sessions.values()) {
184212
binding.abort.abort();
213+
binding.promptAbort?.abort();
185214
binding.stream?.close();
186215
this.abandonPendingForSession(binding.sessionId, binding.clientId);
187216
}
@@ -220,14 +249,23 @@ export class ConnectionRegistry {
220249

221250
constructor(
222251
private readonly onAbandonPending?: AbandonPendingFn,
252+
private readonly maxConnections = DEFAULT_MAX_CONNECTIONS,
223253
private readonly idleTtlMs = 30 * 60_000,
224254
) {
225255
this.sweepTimer = setInterval(() => this.sweep(), 60_000);
226256
this.sweepTimer.unref();
227257
}
228258

229-
create(): AcpConnection {
230-
const conn = new AcpConnection(undefined, this.onAbandonPending);
259+
/**
260+
* Mint a connection, or return `undefined` when the live-connection cap
261+
* is reached (the caller answers `503`). Bounds an `initialize` flood from
262+
* growing the registry without limit through the full TTL window.
263+
*/
264+
create(fromLoopback: boolean): AcpConnection | undefined {
265+
if (this.maxConnections > 0 && this.byId.size >= this.maxConnections) {
266+
return undefined;
267+
}
268+
const conn = new AcpConnection(undefined, fromLoopback, this.onAbandonPending);
231269
this.byId.set(conn.connectionId, conn);
232270
return conn;
233271
}
@@ -258,7 +296,17 @@ export class ConnectionRegistry {
258296
private sweep(): void {
259297
const cutoff = Date.now() - this.idleTtlMs;
260298
for (const [id, conn] of this.byId) {
261-
if (conn.lastActiveMs < cutoff) this.delete(id);
299+
if (conn.lastActiveMs >= cutoff) continue;
300+
// Observability: a reaped connection silently dropping its SSE
301+
// streams is otherwise invisible to operators chasing "my client
302+
// froze". Note that `touch()` fires on inbound HTTP AND on event
303+
// delivery (pumpSessionEvents), so a long quiet prompt isn't reaped.
304+
logStderr(
305+
`qwen serve: /acp reaping idle connection ${id} ` +
306+
`(idle > ${Math.round(this.idleTtlMs / 60_000)}m, ` +
307+
`${conn.sessions.size} session(s))`,
308+
);
309+
this.delete(id);
262310
}
263311
}
264312
}

0 commit comments

Comments
 (0)