fix(serve): correct queue tagging + plumb maxQueued through SDK

Address both P2 findings from the Codex review pass on PR #4237.

**Bug 1: `BoundedAsyncQueue.forcedInBuf` position-invariant break**

The previous `forcedInBuf` counter only tracked LIVE-vs-FORCED
correctly when all forced entries lived at the FRONT of the buffer
(subscribe-time `Last-Event-ID` replay). The new mid-stream
`slow_client_warning` path force-pushes to the BACK of the queue
while the queue is still open, which the existing accounting was
not designed for:

  - publish 6 events at maxQueued=8 → 75% threshold trips →
    force-push warning at the back → buf=[1..6, warning],
    forcedInBuf=1.
  - consumer shifts `1` → forcedInBuf decremented to 0 (incorrect:
    `1` was a live frame, not the forced one).
  - consumer drains 2..6 + warning → buf=[], forcedInBuf=0, true
    live count = 0, but `size` getter and `push()` cap check then
    use `buf.length - forcedInBuf` which drifts over subsequent
    refills, causing premature warn / eviction before the cap is
    actually reached.

Replace the position-dependent counter with a per-entry
`{value, forced}` tag. `liveCount` is incremented in `push()` /
decremented in `next()` only when the shifted entry was non-forced
— position becomes irrelevant. `size` getter returns `liveCount`
directly. The class doc comment is rewritten to call out that the
new tag is the position-independent replacement for the old
"forced frames must stay at the front" invariant.

Regression test in `eventBus.test.ts` reproduces the codex trace
(warn at 75%, drain past warning, refill to cap) and asserts no
premature eviction.

**Bug 2: SDK does not expose `?maxQueued`**

`docs/users/qwen-serve.md` and `docs/developers/qwen-serve-protocol.md`
both document `?maxQueued=N` as something SDK clients can request,
but `SubscribeOptions` on `DaemonClient` only declared `lastEventId`
+ `signal`, and `subscribeEvents()` always fetched `/events` without
a query string. Typed-SDK consumers had no way to opt in without
hand-crafting URLs.

  - Add `SubscribeOptions.maxQueued?: number` with JSDoc noting the
    daemon range `[16, 2048]` and the pre-flight requirement on
    `caps.features.slow_client_warning`.
  - `DaemonClient.subscribeEvents` builds the URL with an optional
    `?maxQueued=<n>` segment. No client-side range validation —
    the daemon's `parseMaxQueuedQuery` is the source of truth and
    returns structured `400 invalid_max_queued`; duplicating the
    bounds in two layers would diverge on the next tweak.
  - `DaemonSessionSubscribeOptions extends SubscribeOptions` so the
    new field flows through `DaemonSessionClient` automatically.

Three new SDK tests:
  - subscribeEvents appends `?maxQueued=N` when set
  - omits the query string when absent (existing behavior preserved)
  - propagates a `400 invalid_max_queued` unchanged

Tests: 214 focused tests across eventBus / bridge / SDK
DaemonClient / DaemonSessionClient / daemonEvents, plus 111 in the
server suite. All green; the new eventBus regression case proves
the position-invariant fix.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
This commit is contained in:
doudouOUC 2026-05-17 17:45:13 +08:00
parent a6ccde5b18
commit bae42c88bc
4 changed files with 166 additions and 43 deletions

View file

@ -204,6 +204,68 @@ describe('EventBus', () => {
abort.abort();
});
it('warn-at-back forced frame does NOT skew the live cap for subsequent publishes (codex P2)', async () => {
// Regression for the `forcedInBuf` position-invariant bug Codex
// flagged: a mid-stream slow_client_warning force-pushed to the
// BACK of the queue, then drained past, would previously cause
// `next()` to decrement the forced counter on a LIVE shift,
// making subsequent `push()` cap checks under-count live items
// and warn/evict the client before they actually had `maxQueued`
// live items in queue.
const bus = new EventBus();
const abort = new AbortController();
const iter = bus.subscribe({ maxQueued: 8, signal: abort.signal });
const it = iter[Symbol.asyncIterator]();
// Episode 1: fill to 6 → warn at 75%. buf = [1..6, warning].
for (let i = 1; i <= 6; i++) bus.publish({ type: 'foo', data: i });
// Drain ALL 7 items (events 1..6 + warning frame). Live cap should
// now be 0 — the warning was a forced frame and must NOT have
// counted as a live drain.
const drained: BridgeEvent[] = [];
for (let i = 0; i < 7; i++) drained.push((await it.next()).value);
expect(
drained.filter((e) => e.type === 'slow_client_warning'),
).toHaveLength(1);
// Refill to EXACTLY maxQueued (8). Pre-fix: the post-drain live
// count was wrong, so somewhere between pushes 5 and 7 the 75%
// threshold (live=6) fired a second warning prematurely or the
// push at 7 was even rejected. Post-fix: live count is the truth,
// and the second warning fires exactly at push 8 (live=8, queue
// full → push 8 fills the cap and either succeeds at the cap line
// or trips the warn check first).
let rejected = 0;
for (let i = 7; i <= 14; i++) {
// Stop publishing once the queue refuses — the 8th live publish
// is the maxQueued ceiling.
const ok = bus.publish({ type: 'foo', data: i }) !== undefined;
if (!ok) rejected++;
}
void rejected; // EventBus.publish never returns false; rejection
// happens inside the bus when subscriber queues fill.
// Drain everything that's still alive in the iter. The exact frame
// shape varies (depending on whether the bus also force-pushed a
// second warning + evicted), but the ASSERTION we need is: the
// sub didn't get evicted on a phantom premature overflow — i.e.
// we received MORE THAN 1 live frame in this episode (pre-fix,
// the live count drift evicted after 0-1 frames).
const episode2: BridgeEvent[] = [];
for (let i = 0; i < 9; i++) {
const { value, done } = await it.next();
if (done) break;
episode2.push(value);
}
const live2 = episode2.filter((e) => e.id !== undefined && e.id >= 7);
// Pre-fix: live2 would be <8 because the queue evicted prematurely
// after the buggy live count drift. Post-fix: all 8 live frames
// (ids 7..14) get through cleanly.
expect(live2.length).toBeGreaterThanOrEqual(8);
abort.abort();
});
it('default ring size is 8000 (#3803 §02 target)', async () => {
const bus = new EventBus();
for (let i = 1; i <= 8001; i++) bus.publish({ type: 'foo', data: i });

View file

@ -446,50 +446,53 @@ function emptyAsyncIterable<T>(): AsyncIterable<T> {
* that signal to evict slow subscribers.
*
* The cap (`maxSize`) applies only to LIVE items pushed via `push()`. Items
* inserted via `forcePush()` (the `Last-Event-ID` replay path on subscribe
* and the terminal `client_evicted` frame) are tracked separately and don't
* inserted via `forcePush()` (the `Last-Event-ID` replay path on subscribe,
* the terminal `client_evicted` frame, and the mid-stream
* `slow_client_warning` frame) carry a `forced` tag per entry and never
* count toward the cap. Without this split, a reconnect with a large
* backlog would force-push ~ringSize entries into `buf`, push `buf.length`
* past `maxSize`, and the very next live publish would evict the
* just-resumed subscriber defeating the resume contract.
*
* Previously this class tracked `forcedInBuf` as a count, which was
* correct only when forced frames stayed contiguous at the FRONT of the
* buffer (subscribe-time replay). The `slow_client_warning` path
* force-pushes mid-stream to the BACK of the queue, so the count-based
* approach drifted: a live shift would decrement `forcedInBuf`, then a
* later cap check on a live push would under-count the live backlog and
* warn/evict the client before there were actually `maxSize` live
* items. The per-entry `forced` tag below is the position-independent
* fix.
*/
interface BoundedQueueEntry<T> {
value: T;
/** True for replay / eviction / slow_client_warning frames (don't count toward cap). */
forced: boolean;
}
class BoundedAsyncQueue<T> {
private readonly buf: T[] = [];
private readonly buf: Array<BoundedQueueEntry<T>> = [];
private readonly resolvers: Array<(v: IteratorResult<T>) => void> = [];
private closed = false;
/**
* Number of force-pushed items still in `buf`. The cap check in
* `push()` only applies to LIVE items; this counter tells us how
* many slots in `buf` are replay-injected and shouldn't count.
*
* Position invariant: under the bus's two callers,
* 1. subscribe-time replay (`Last-Event-ID` resume) forcePush
* fires BEFORE any live `push()`, so replay items are at the
* front of `buf`;
* 2. eviction terminal frame forcePush fires AFTER `push()`
* rejection, then `close()` is called immediately, so the
* eviction frame is at the BACK of `buf`.
*
* `next()` decrements `forcedInBuf` whenever the counter is > 0 on
* shift, which is correct for case (1). For case (2) it slightly
* misaccounts (decrements on the first live shift), but that's
* harmless: the queue is closed so no `push()` runs the cap check
* again. The counter only matters for live cap enforcement.
* O(1) snapshot of how many LIVE (non-forced) entries are in `buf`.
* Maintained directly by `push()`/`next()`: any time a forced entry
* is added or removed `liveCount` is untouched; any time a live entry
* is added or removed `liveCount` moves with it. Replaces the
* position-dependent `forcedInBuf` heuristic `liveCount` is correct
* no matter where in the queue the forced entries are.
*/
private forcedInBuf = 0;
private liveCount = 0;
constructor(private readonly maxSize: number) {}
/**
* Number of LIVE (non-force-pushed) items currently waiting in the
* buffer. Mirrors the cap check in `push()`: replay/eviction frames
* inserted via `forcePush` don't count toward the backpressure
* threshold the bus uses to decide when to emit
* `slow_client_warning`. Returns 0 (not negative) if the buffer
* happens to be all-force-pushed.
* buffer. Backpressure decisions in `EventBus.publish()` (the
* `slow_client_warning` threshold) read this value.
*/
get size(): number {
return Math.max(0, this.buf.length - this.forcedInBuf);
return this.liveCount;
}
/** Returns true if accepted, false if dropped due to overflow. */
@ -501,12 +504,14 @@ class BoundedAsyncQueue<T> {
return true;
}
// Cap is on the LIVE backlog only.
if (this.buf.length - this.forcedInBuf >= this.maxSize) return false;
this.buf.push(value);
if (this.liveCount >= this.maxSize) return false;
this.buf.push({ value, forced: false });
this.liveCount += 1;
return true;
}
/** Bypasses the size cap. Used for replay frames and terminal eviction. */
/** Bypasses the size cap. Used for replay frames, eviction terminal,
* and slow-client warnings. */
forcePush(value: T): void {
if (this.closed) return;
const r = this.resolvers.shift();
@ -514,8 +519,7 @@ class BoundedAsyncQueue<T> {
r({ value, done: false });
return;
}
this.buf.push(value);
this.forcedInBuf += 1;
this.buf.push({ value, forced: true });
}
/**
@ -539,7 +543,7 @@ class BoundedAsyncQueue<T> {
// Truncate the buffer so subsequent `next()` calls see the
// closed sentinel immediately.
this.buf.length = 0;
this.forcedInBuf = 0;
this.liveCount = 0;
}
while (this.resolvers.length > 0) {
this.resolvers.shift()!({
@ -554,12 +558,9 @@ class BoundedAsyncQueue<T> {
// queue whose element type legitimately includes `undefined`. The bus
// never pushes undefined today, but the queue is generic.
if (this.buf.length > 0) {
const value = this.buf.shift() as T;
// Force-pushed entries are FIFO at the front of `buf` (forcePush
// only happens at subscribe time, before any live push). So as long
// as `forcedInBuf > 0` the shifted item is a replay frame.
if (this.forcedInBuf > 0) this.forcedInBuf -= 1;
return Promise.resolve({ value, done: false });
const entry = this.buf.shift() as BoundedQueueEntry<T>;
if (!entry.forced) this.liveCount -= 1;
return Promise.resolve({ value: entry.value, done: false });
}
if (this.closed) {
return Promise.resolve({

View file

@ -139,6 +139,18 @@ export interface SubscribeOptions {
lastEventId?: number;
/** Aborts the subscription cleanly. */
signal?: AbortSignal;
/**
* Per-subscriber backlog cap requested from the daemon. Forwarded as
* `?maxQueued=N` on `GET /session/:id/events`. Daemon-side range is
* `[16, 2048]` (default 256); out-of-range or non-decimal values get
* a `400 invalid_max_queued` response. Old daemons without the
* `slow_client_warning` capability silently ignore the param SDK
* clients should pre-flight `caps.features.slow_client_warning`
* before opting in. Useful for cold reconnects with a large
* `Last-Event-ID: 0` replay backlog so the force-pushed replay
* frames don't trip the warn / eviction path on the first publish.
*/
maxQueued?: number;
}
export class DaemonClient {
@ -510,12 +522,19 @@ export class DaemonClient {
const fetchSignal = opts.signal
? composeAbortSignals([opts.signal, connectCtrl.signal])
: connectCtrl.signal;
// Build the SSE URL, optionally with `?maxQueued=N`. We don't
// validate the value client-side — the daemon's
// `parseMaxQueuedQuery` is the source of truth on the range
// `[16, 2048]` and returns a structured `400 invalid_max_queued`
// for anything outside, so duplicating the bounds here would
// diverge if the daemon's range ever shifts.
let url = `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/events`;
if (opts.maxQueued !== undefined) {
url += `?maxQueued=${encodeURIComponent(String(opts.maxQueued))}`;
}
let res: Response;
try {
res = await this._fetch(
`${this.baseUrl}/session/${encodeURIComponent(sessionId)}/events`,
{ headers, signal: fetchSignal },
);
res = await this._fetch(url, { headers, signal: fetchSignal });
} finally {
if (connectTimer !== undefined) clearTimeout(connectTimer);
}

View file

@ -546,6 +546,47 @@ describe('DaemonClient', () => {
const iter = client.subscribeEvents('missing');
await expect(iter.next()).rejects.toMatchObject({ status: 404 });
});
it('appends ?maxQueued=N when SubscribeOptions.maxQueued is set', async () => {
const { fetch, calls } = recordingFetch(() => sseResponse(''));
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
for await (const _ of client.subscribeEvents('s-1', {
maxQueued: 512,
})) {
/* unreachable */
}
expect(calls[0]?.url).toBe(
'http://daemon/session/s-1/events?maxQueued=512',
);
});
it('omits the query string when maxQueued is undefined', async () => {
const { fetch, calls } = recordingFetch(() => sseResponse(''));
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
for await (const _ of client.subscribeEvents('s-1', {
lastEventId: 7,
})) {
/* unreachable */
}
// Bare events URL — no `?` introduced when the caller didn't ask.
expect(calls[0]?.url).toBe('http://daemon/session/s-1/events');
expect(calls[0]?.headers['last-event-id']).toBe('7');
});
it('propagates a server 400 invalid_max_queued unchanged', async () => {
const { fetch } = recordingFetch(() =>
jsonResponse(400, {
error: '`maxQueued` must be in [16, 2048]',
code: 'invalid_max_queued',
}),
);
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
const iter = client.subscribeEvents('s-1', { maxQueued: 9999 });
await expect(iter.next()).rejects.toMatchObject({
status: 400,
body: { code: 'invalid_max_queued' },
});
});
});
describe('listWorkspaceSessions', () => {