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
31 changes: 31 additions & 0 deletions .qwen/e2e-tests/2026-08-12-daemon-acp-http-pre-attach-bounds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Daemon ACP HTTP pre-attach bounds

## Scope

Verify that connection/session responses produced before an ACP HTTP SSE or WebSocket owner is ready are bounded by serialized bytes and frame count across every workspace mount. The test does not claim to bound ordinary live transport queues or transient `JSON.stringify` amplification.

## Baseline

Run the harness against the parent of this change. Initialize one ACP HTTP connection without attaching its response stream, then make the fake bridge produce 128 distinct 1 MiB results. Confirm retained heap/RSS grows with every payload and that the connection remains registered. Repeat with primary and dynamic workspace connections to confirm their retained buffers add together without a daemon-global boundary.

## Verification

1. Start `qwen serve` with ACP HTTP enabled, one primary workspace, and one dynamically registered trusted workspace.
2. For each workspace, initialize a logical connection but delay its connection/session stream attachment.
3. Produce distinct large responses until the per-connection 64 MiB boundary is crossed. Expect only the admitting connection to close; a shared WebSocket must receive close code 1013. Confirm the other workspace can still initialize, open a stream, and complete a small request.
4. With several connections below their individual limits, compete for the shared 4,096-frame/256-MiB budget. Expect the connection attempting the global N+1 admission to close without evicting frames from another connection.
5. Attach a deliberately stalled SSE writer after frames are buffered. Confirm status moves the frames from buffered to pending delivery while `usedFrames` and `usedBytes` remain charged. Close the socket, settle the write, and confirm all counters return to the pre-test baseline.
Comment thread
doudouOUC marked this conversation as resolved.
6. Buffer several successful `session/new`, `session/load`, `session/resume`, or `session/fork` results, then close or overflow the connection before delivery. Confirm fresh sessions and persisted forks are removed, newly attached clients are detached, existing ownership remains intact, and none of the provisional sessions accept a prompt before response delivery.
Comment thread
doudouOUC marked this conversation as resolved.
7. Send notification forms of `session/new`, `session/load`, `session/resume`, and `session/fork`. Confirm no session is created, restored, attached, or forked.
8. Read `GET /daemon/status?detail=full`. Verify fixed limits, global current/high-water count and bytes, pending-delivery frames, guard failures, per-mount failure attribution, and per-connection owned count/bytes.
9. Remove the dynamic workspace and close all test connections. Confirm global budget usage returns to the primary baseline.

## Commands

```bash
(cd packages/acp-bridge && npx vitest run src/bridge.test.ts src/spawnChannel.test.ts)
Comment thread
doudouOUC marked this conversation as resolved.
(cd packages/cli && npx vitest run src/serve/acp-http/pre-attach-budget.test.ts src/serve/acp-http/connection-registry.test.ts src/serve/acp-http/sse-stream.test.ts src/serve/acp-http/ws-stream.test.ts src/serve/acp-http/transport.test.ts src/serve/daemon-status.test.ts)
(cd packages/sdk-typescript && npx vitest run test/unit/daemon-public-surface.test.ts)
npm run build && npm run typecheck && npm run lint
git diff --check
```
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,9 @@ The outbound check happens after `JSON.stringify` and UTF-8 encoding. It prevent

**Workspace-supplied config files are read without a size gate.** `fs.readFileSync(path, 'utf-8')` on workspace `.qwen/settings.json` (`packages/cli/src/config/settings.ts:557,733`), trusted folders, the serve fast path (synchronous, so it also blocks the event loop), and every discovered `QWEN.md`, twenty concurrently (`packages/core/src/utils/memoryDiscovery.ts:225,245`). Registering a workspace containing a two-gigabyte `settings.json` exhausts the daemon with no session, no prompt, and no agent — the cheapest attack in the set, and the one furthest from anything a heap ledger would notice.

Recorded and deferred with evidence: SSE and WebSocket write chains respect backpressure but do not bound queued bytes (`acp-http/sse-stream.ts:110-128`, `ws-stream.ts:58-82`); ACP pre-attach frame buffers mirror the EventBus's `maxQueued` but not its `maxQueuedBytes` (`connection-registry.ts:18,30`); the organized session list materializes 50,000 summaries; several per-workspace caches outlive their workspace.
**ACP HTTP pre-attach buffers are the next bounded-container increment.** Connection and session replies are serialized once at production time and retained only as UTF-8 `Buffer`s. Each stream owns at most 256 buffered frames, each logical connection owns at most 1,024 frames and 64 MiB, and one process-global budget shared by primary and dynamic workspace registries owns at most 4,096 frames and 256 MiB. Attach transfers a lease to pending delivery; it is released only after the SSE write chain or WebSocket send callback settles. Count or byte overflow does not evict an older frame: it retires the exact session, or the whole logical connection when the queue is connection-scoped or shares a WebSocket. Fresh and newly attached session ownership remains provisional until the granting response is locally delivered, so teardown or overflow can roll back every definitively undelivered grant without exposing a session the client never learned it owned. If SSE accepts a complete ownership-granting frame but closes before its final write callback, the outcome is unknown and the daemon preserves the session rather than deleting it: a live logical connection conservatively commits ownership, while connection teardown detaches the client but leaves persisted state available for resume. Server response serialization failures are contained to the offending frame instead of being classified as resource exhaustion for the whole connection. Existing live SSE and WebSocket frames, and transient single-frame serialization amplification, remain separate container work.
Comment thread
doudouOUC marked this conversation as resolved.

Recorded and deferred with evidence: live SSE and WebSocket write chains respect backpressure but do not bound queued bytes (`acp-http/sse-stream.ts`, `ws-stream.ts`); the organized session list materializes 50,000 summaries; several per-workspace caches outlive their workspace.

### Part 4 — Small aggregate quotas where multiplicity matters

Expand Down Expand Up @@ -189,6 +191,8 @@ The compatibility discussion that belongs here is for the child-capacity policy

Workspace registration, persisted restoration, and `POST /workspaces` are unchanged. The daemon-owned ACP transport now refuses a complete frame above 64 MiB; a decoded queue, active-handler set, pre-SDK outbound operation set, outstanding request set, or prepared-response set above its 256-message/64-MiB charge; an incomplete or clean protocol EOF while the child is still owned; string request ids above 256 bytes; response ids that do not match an admitted outstanding request; method or error-message scalars above 1 KiB; and JSON structures above the documented depth/node/array limits. Parse, envelope, and known-method schema violations are also transport-fatal after metadata-only logging, so every refusal retires only that workspace channel generation instead of leaving an SDK request pending or an SDK write queue growing. Standalone and public `ndJsonStream`/bridge callers remain opt-in and keep their previous transport and error-wire behavior when no limits or transport guard are supplied.

ACP HTTP pre-attach queues no longer silently evict their oldest frame. The 257th frame on one stream, or a connection/global count or byte refusal, closes the exact owner; a shared WebSocket closes with code 1013. Buffered frames are serialized at production time, so later mutation of the source object no longer changes the wire result. `session/new`, `session/load`, `session/resume`, and `session/fork` notifications no longer mutate state, and request-form ownership is usable only after its response is locally delivered. Clients observe an overload through the SSE/WS close because a full queue cannot safely enqueue its own error response. Public standalone ACP behavior and the workspace/session count defaults are unchanged.

`maxSessions` and `maxTotalSessions` keep their current defaults and derivation, and this change gives them no new bound. An earlier draft claimed `maxTotalSessions` was transitively bounded because `workspaceCount` would be capped by the budget; that is false against this PR, where the workspace cap remains the fixed `MAX_REGISTERED_WORKSPACES = 25` and nothing derives a limit from the budget at all. Sessions still multiplex onto one child per workspace, so per-session memory sits inside a child heap that nothing currently bounds beyond V8's own ceiling. The documentation for `maxSessions` should be read as a fairness and file-descriptor lever, not a memory one.

`limits.memory` and `runtime.memory` on `GET /daemon/status` are additive and optional in the SDK mirror, so older daemons parse against newer clients.
Expand Down
2 changes: 1 addition & 1 deletion docs/design/daemon-acp-http/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@ All fixes verified by the expanded vitest suite (**18 tests**) + a fresh live sm
| 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`). |
| 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). |
| 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`. |
| R6 | **P1** | Pre-attach frame buffers (`connBuffer`/`binding.buffer`) were unbounded. | Capped at 256 frames (drop-oldest), matching the EventBus `maxQueued`. |
| R6 | **P1** | Pre-attach frame buffers (`connBuffer`/`binding.buffer`) were unbounded. | Initially capped at 256 frames; current behavior also enforces connection/global count and byte budgets and closes the exact owner instead of silently dropping an older frame. |
| R7 | **P2** | `initialize` ignored the client's requested `protocolVersion`. | Negotiates `min(requested, 1)`. |
| R8 | **P2** | No `Acp-Session-Id` ↔ `params.sessionId` cross-check (RFD §2.3). | POST asserts they agree; mismatch → `INVALID_PARAMS`. |
| 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`. |
Expand Down
24 changes: 20 additions & 4 deletions docs/design/daemon-acp-http/sse-resumable-stream.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,11 @@ the monotonic sequence the client resumes from.
WebSocket is a stateful connection, no SSE replay (consistent with
`AcpWsTransport.supportsReplay = false`).
4. **`connection-registry.ts`** — `sendSession(sessionId, frame, id?)`
threads `id` to `stream.send`. The per-session pre-attach **buffer**
stores `{ frame, id? }` pairs so a buffered frame keeps its cursor when
flushed on attach. (The connection-scoped buffer is unchanged — those
frames are JSON-RPC responses with no bus id.)
threads `id` to the transport. The per-session pre-attach **buffer**
stores one serialized UTF-8 payload with its optional cursor and budget
lease, so a buffered frame keeps its cursor without retaining the source
object or serializing it again on attach. Connection-scoped replies use the
same representation.
5. **`dispatch.ts`**
- `translateEvent` passes `event.id` through every `sendSession` /
`binding.stream.send` call for bus events.
Expand Down Expand Up @@ -183,6 +184,21 @@ operator logging can't drift.

## Backward compatibility

Pre-attach queues are bounded by both count and serialized payload bytes. One
stream owns at most 256 frames, one logical connection at most 1,024 frames and
64 MiB, and all ACP HTTP mounts share a process-global 4,096-frame/256-MiB
budget. A fresh attach transfers the lease to the transport writer and releases
it only after local delivery or definitive failure. If SSE accepts a complete
frame but closes before its final write callback, delivery is outcome-unknown;
an ownership-granting response preserves the session rather than deleting it.
If the logical connection is still live, ownership is conservatively
committed; during connection teardown, the client is detached while persisted
session data remains available for resume. Resume still discards
id-bearing buffered events in favor of authoritative ring replay and preserves
id-less reply ordering, but that discard now releases the retained byte lease.
Overflow closes the exact session; connection-scoped or shared-WebSocket
overflow closes the logical connection instead of evicting an older frame.

- **Old clients that don't send `Last-Event-ID`** → `lastEventId` is
`undefined` → `subscribeEvents` starts live, exactly as today.
- **Adding `id:` lines is backward-compatible SSE** — a client that ignores
Expand Down
71 changes: 71 additions & 0 deletions packages/acp-bridge/src/json-string-bytes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, expect, it, vi } from 'vitest';
import { estimateJsonStringBytes } from './json-string-bytes.js';

describe('estimateJsonStringBytes', () => {
it('matches JSON.stringify UTF-8 bytes for every UTF-16 code unit', () => {
for (let code = 0; code <= 0xffff; code++) {
const value = String.fromCharCode(code);
Comment thread
doudouOUC marked this conversation as resolved.
expect(estimateJsonStringBytes(value, Number.MAX_SAFE_INTEGER)).toBe(
Buffer.byteLength(JSON.stringify(value)),
);
}
});

it('matches JSON.stringify for paired surrogates and mixed escaping', () => {
const samples = [
'"\\\b\f\n\r\t',
'\u0000\u001f',
'\ud800',
'\udc00',
'\ud83d\ude00',
'ASCII é 中 \ud83d\ude00 \ud800',
];
for (const value of samples) {
expect(estimateJsonStringBytes(value, Number.MAX_SAFE_INTEGER)).toBe(
Buffer.byteLength(JSON.stringify(value)),
);
}
});

it('matches JSON.stringify for deterministic random strings', () => {
let state = 0x5eed1234;
const nextCodeUnit = () => {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
return state & 0xffff;
};
for (let sample = 0; sample < 1000; sample++) {
const length = nextCodeUnit() % 128;
let value = '';
for (let index = 0; index < length; index++) {
value += String.fromCharCode(nextCodeUnit());
}
expect(estimateJsonStringBytes(value, Number.MAX_SAFE_INTEGER)).toBe(
Buffer.byteLength(JSON.stringify(value)),
);
}
});

it('returns limit + 1 as soon as the encoded string exceeds the limit', () => {
expect(estimateJsonStringBytes('\u0001'.repeat(100), 20)).toBe(21);
});

it('uses native byte counting for large strings that need no escaping', () => {
const charCodeAt = vi.spyOn(String.prototype, 'charCodeAt');
Comment thread
doudouOUC marked this conversation as resolved.
try {
const value = 'x'.repeat(8 * 1024 * 1024);
expect(estimateJsonStringBytes(value, 1024)).toBe(1025);
expect(estimateJsonStringBytes(value, Number.MAX_SAFE_INTEGER)).toBe(
value.length + 2,
);
expect(charCodeAt).not.toHaveBeenCalled();
} finally {
charCodeAt.mockRestore();
}
});
});
50 changes: 50 additions & 0 deletions packages/acp-bridge/src/json-string-bytes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

export function estimateJsonStringBytes(
Comment thread
doudouOUC marked this conversation as resolved.
value: string,
limitBytes: number,
): number {
const unescapedBytes = Buffer.byteLength(value, 'utf8') + 2;
if (unescapedBytes > limitBytes) return limitBytes + 1;
if (!/["\\]|[^ -\ud7ff\ue000-\uffff]/u.test(value)) {
return unescapedBytes;
}
let bytes = 2;
for (let index = 0; index < value.length; index++) {
const code = value.charCodeAt(index);
if (code === 0x22 || code === 0x5c) {
bytes += 2;
} else if (code <= 0x1f) {
bytes +=
code === 0x08 ||
code === 0x09 ||
code === 0x0a ||
code === 0x0c ||
code === 0x0d
? 2
: 6;
} else if (code >= 0xd800 && code <= 0xdbff) {
const next = value.charCodeAt(index + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
bytes += 4;
index++;
} else {
bytes += 6;
}
} else if (code >= 0xdc00 && code <= 0xdfff) {
bytes += 6;
} else if (code <= 0x7f) {
bytes++;
} else if (code <= 0x7ff) {
bytes += 2;
} else {
bytes += 3;
}
if (bytes > limitBytes) return limitBytes + 1;
}
return bytes;
}
22 changes: 22 additions & 0 deletions packages/acp-bridge/src/spawnChannel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,28 @@ describe('createSpawnChannelFactory env policy', () => {
});
});

it('charges JSON string escaping before admitting prepared responses', async () => {
const child = createFakeChildProcess();
mockSpawn.mockReturnValue(child);
const channel = await createSpawnChannelFactory({
pipeLimits: {
maxFrameBytes: 64_000,
maxQueuedMessages: 2,
maxQueuedBytes: 6_000,
},
})('/tmp/project');
const response = {
content: '\u0001'.repeat(700),
};

expect(() =>
channel.transportGuard?.reservePreparedResponse(response),
).toThrow('NDJSON decoded queue is full');
await expect(channel.transportFailed).resolves.toMatchObject({
code: 'ndjson_queue_limit_exceeded',
});
});

it('keeps the default factory unbounded and validates opt-in limits early', () => {
expect(DAEMON_ACP_NDJSON_LIMITS).toEqual({
maxFrameBytes: 64 * 1024 * 1024,
Expand Down
Loading
Loading