@sleep2agi/commhub-server 0.8.8 → 0.9.0-preview.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/push.ts CHANGED
@@ -1,16 +1,89 @@
1
1
  // ── SSE Push: 实时推送事件给 Agent ──────────────────
2
2
  // Agent 连 GET /events/:session → 保持 SSE 长连接
3
3
  // send_task 写 inbox 后 → pushEvent() → 秒达
4
+ //
5
+ // Backpressure & half-open contract (Round-2/4 fix per 通信龙):
6
+ //
7
+ // 0. **CRITICAL**: `desiredSize` units are determined by the stream's
8
+ // queuing strategy. The web-streams default strategy is COUNT-based
9
+ // (each chunk = 1 unit), so a 1 MB chunk only nudges desiredSize by
10
+ // -1. That defeats the byte-cap entirely — pushing a million 1-byte
11
+ // chunks is what trips the threshold, not 1 MB of real bytes.
12
+ // Bun probe verified by 通信牛: default stream after 1 MiB chunk →
13
+ // desiredSize 0, after 2 MiB → -1; byte strategy after 1 MiB → 0,
14
+ // after 2 MiB → -1048576. We MUST construct the stream with an
15
+ // explicit byte-counting strategy:
16
+ // new ReadableStream(src, { highWaterMark: 0, size: c => c.byteLength })
17
+ // With highWaterMark=0, every enqueued byte counts against the cap
18
+ // directly. The HARD CEILING below then closes the stream at
19
+ // -MAX_QUEUE_BACKPRESSURE_BYTES bytes of queued data, which is what
20
+ // we actually want.
21
+ //
22
+ // 1. `ReadableStreamDefaultController.desiredSize` is the SSE queue
23
+ // headroom in BYTES (given the strategy above). Positive = room,
24
+ // 0 = at high-water-mark, negative = over by that many bytes.
25
+ // Without a bound, half-open TCP consumers (network drop, client
26
+ // crash without FIN) silently accumulate bytes in the queue forever
27
+ // — a public-hub OOM vector.
28
+ //
29
+ // 2. We bound it two ways:
30
+ //
31
+ // a. HARD CEILING — `desiredSize < -MAX_QUEUE_BACKPRESSURE_BYTES`
32
+ // → close the stream immediately. Single huge event can't blow
33
+ // up a stuck client.
34
+ //
35
+ // b. STUCK TIMEOUT — `desiredSize < 0` continuously for
36
+ // `STUCK_CLOSE_MS` → close. Catches slow-trickle leaks where
37
+ // the consumer accepts a byte every few seconds but can't keep
38
+ // up.
39
+ //
40
+ // 3. Liveness: every `LIVENESS_SWEEP_MS` we re-check every registered
41
+ // client even when nobody calls `pushEvent`, so a session that goes
42
+ // half-open during a quiet period still gets reaped — not waiting
43
+ // for the next inbox push.
4
44
 
5
45
  import { eventBus, type RenameCommittedEvent } from "./event_bus";
6
46
 
47
+ // ─────────────────────────────────────────────────────────────────────
48
+ // Tunables (env-overridable for tests and ops)
49
+ // ─────────────────────────────────────────────────────────────────────
50
+ function envNum(k: string, fallback: number): number {
51
+ const v = process.env[k];
52
+ if (!v) return fallback;
53
+ const n = Number(v);
54
+ return Number.isFinite(n) && n > 0 ? n : fallback;
55
+ }
56
+
57
+ /** Hard ceiling on per-client SSE queue backpressure before we drop the
58
+ * client. 1 MB by default — generous for normal bursts, lethal for the
59
+ * half-open consumer that has stopped reading entirely. */
60
+ const MAX_QUEUE_BACKPRESSURE_BYTES = envNum("ANET_SSE_MAX_QUEUE_BYTES", 1_000_000);
61
+
62
+ /** How long `desiredSize` can stay negative (i.e. consumer behind HWM)
63
+ * before we declare the client dead. 60s by default — past 2× the
64
+ * keepalive interval, so a live-but-slow consumer wouldn't trip it. */
65
+ const STUCK_CLOSE_MS = envNum("ANET_SSE_STUCK_CLOSE_MS", 60_000);
66
+
67
+ /** Keepalive cadence. Doubles as half-open liveness probe — if enqueue
68
+ * silently piles up bytes here, the stuck-timeout kicks in. */
69
+ const KEEPALIVE_MS = envNum("ANET_SSE_KEEPALIVE_MS", 30_000);
70
+
71
+ /** Liveness sweep cadence — proactive scan even when no events flow. */
72
+ const LIVENESS_SWEEP_MS = envNum("ANET_SSE_LIVENESS_SWEEP_MS", 15_000);
73
+
7
74
  type SSEClient = {
8
75
  controller: ReadableStreamDefaultController;
9
76
  encoder: TextEncoder;
10
- // 30s keepalive interval — stored so cancel() can clear it without
11
- // relying on stringly-typed any-cast. Set in start(), cleared in cancel()
12
- // or by the interval itself on enqueue failure.
13
- keepalive?: ReturnType<typeof setInterval>;
77
+ /** When desiredSize first went negative; cleared on successful drain. */
78
+ stuckSince: number | null;
79
+ /** Once true, the stream has been closed and the client should be
80
+ * evicted from the map on the next sweep / push. Guards against
81
+ * double-close. */
82
+ closed: boolean;
83
+ /** keepalive timer handle so it can be cleared on close. */
84
+ keepaliveTimer?: ReturnType<typeof setInterval>;
85
+ /** Stable key for log/diagnostic context. */
86
+ key: string;
14
87
  };
15
88
 
16
89
  // 一个 session 可能有多个 SSE 连接(重连时短暂并存)
@@ -20,49 +93,189 @@ function ts(): string {
20
93
  return new Date().toTimeString().slice(0, 8);
21
94
  }
22
95
 
96
+ function clientKey(sessionName: string, networkId?: string | null): string {
97
+ return `${networkId || "global"}:${sessionName}`;
98
+ }
99
+
100
+ // ─────────────────────────────────────────────────────────────────────
101
+ // Close + dispatch helpers
102
+ // ─────────────────────────────────────────────────────────────────────
103
+
104
+ /** Close a client's stream (idempotent) and clear its keepalive timer.
105
+ * Does NOT remove from the map — leave that to the caller so it can
106
+ * prune the right index without re-finding it. */
107
+ function closeClient(client: SSEClient, reason: string): void {
108
+ if (client.closed) return;
109
+ client.closed = true;
110
+ if (client.keepaliveTimer) clearInterval(client.keepaliveTimer);
111
+ try {
112
+ client.controller.close();
113
+ } catch {
114
+ // Already closed by the runtime, or controller errored. Either way
115
+ // we're done with it.
116
+ }
117
+ console.log(`[${ts()}] SSE ✕ ${client.key} closed (reason=${reason})`);
118
+ }
119
+
120
+ /** Best-effort enqueue with backpressure guard. Returns:
121
+ * - "ok" → bytes accepted, queue healthy
122
+ * - "dead" → client closed; caller must remove from map */
123
+ function tryEnqueueBytes(client: SSEClient, bytes: Uint8Array): "ok" | "dead" {
124
+ if (client.closed) return "dead";
125
+
126
+ const ctrl = client.controller;
127
+ // desiredSize may be null on a closed/errored stream — defensive
128
+ // null-check, then treat null as "no headroom" since we can't enqueue
129
+ // safely anyway.
130
+ const desired = ctrl.desiredSize;
131
+ if (desired === null) {
132
+ closeClient(client, "controller-null-desired");
133
+ return "dead";
134
+ }
135
+
136
+ // Hard ceiling: too far behind HWM, single push could OOM us. Close.
137
+ if (desired < -MAX_QUEUE_BACKPRESSURE_BYTES) {
138
+ closeClient(client, `queue-overflow-${Math.abs(desired)}b`);
139
+ return "dead";
140
+ }
141
+
142
+ // Track stuck-since-when for the soft timeout below.
143
+ if (desired < 0) {
144
+ if (client.stuckSince === null) {
145
+ client.stuckSince = Date.now();
146
+ } else if (Date.now() - client.stuckSince > STUCK_CLOSE_MS) {
147
+ closeClient(client, `stuck-${Date.now() - client.stuckSince}ms`);
148
+ return "dead";
149
+ }
150
+ } else {
151
+ client.stuckSince = null;
152
+ }
153
+
154
+ try {
155
+ ctrl.enqueue(bytes);
156
+ return "ok";
157
+ } catch (e: any) {
158
+ closeClient(client, `enqueue-throw:${e?.message || e}`);
159
+ return "dead";
160
+ }
161
+ }
162
+
163
+ /** Walk every client and close any that are stuck/half-open. Run on a
164
+ * timer so half-open detection doesn't depend on event traffic. */
165
+ function sweepLiveness(): void {
166
+ const now = Date.now();
167
+ for (const [key, arr] of clients) {
168
+ const alive: SSEClient[] = [];
169
+ for (const c of arr) {
170
+ if (c.closed) continue;
171
+ const desired = c.controller.desiredSize;
172
+ if (desired === null) {
173
+ closeClient(c, "sweep-null-desired");
174
+ continue;
175
+ }
176
+ if (desired < -MAX_QUEUE_BACKPRESSURE_BYTES) {
177
+ closeClient(c, `sweep-overflow-${Math.abs(desired)}b`);
178
+ continue;
179
+ }
180
+ if (desired < 0) {
181
+ if (c.stuckSince === null) c.stuckSince = now;
182
+ else if (now - c.stuckSince > STUCK_CLOSE_MS) {
183
+ closeClient(c, `sweep-stuck-${now - c.stuckSince}ms`);
184
+ continue;
185
+ }
186
+ } else {
187
+ c.stuckSince = null;
188
+ }
189
+ alive.push(c);
190
+ }
191
+ if (alive.length === 0) clients.delete(key);
192
+ else if (alive.length !== arr.length) clients.set(key, alive);
193
+ }
194
+ }
195
+
196
+ // Liveness sweep timer. Started lazily on first stream creation; never
197
+ // stopped in production (one timer for the lifetime of the process).
198
+ let livenessSweepTimer: ReturnType<typeof setInterval> | null = null;
199
+ function ensureLivenessSweep(): void {
200
+ if (livenessSweepTimer) return;
201
+ livenessSweepTimer = setInterval(() => {
202
+ try {
203
+ sweepLiveness();
204
+ } catch (e: any) {
205
+ console.log(`[${ts()}] SSE sweep failed: ${e?.message || e}`);
206
+ }
207
+ }, LIVENESS_SWEEP_MS);
208
+ // Don't keep the event loop alive just for the sweep.
209
+ (livenessSweepTimer as any)?.unref?.();
210
+ }
211
+
212
+ // ─────────────────────────────────────────────────────────────────────
213
+ // Public API
214
+ // ─────────────────────────────────────────────────────────────────────
215
+
23
216
  /** 创建 SSE Response 并注册到 clients map */
24
217
  export function createSSEStream(sessionName: string, networkId?: string | null): Response {
25
218
  const encoder = new TextEncoder();
26
- let ctrl: ReadableStreamDefaultController;
27
219
  const key = clientKey(sessionName, networkId);
220
+ let client: SSEClient;
28
221
 
29
- const stream = new ReadableStream({
222
+ const stream = new ReadableStream<Uint8Array>({
30
223
  start(controller) {
31
- ctrl = controller;
32
- const client: SSEClient = { controller, encoder };
224
+ client = {
225
+ controller,
226
+ encoder,
227
+ stuckSince: null,
228
+ closed: false,
229
+ key,
230
+ };
33
231
 
34
- if (!clients.has(key)) {
35
- clients.set(key, []);
36
- }
232
+ if (!clients.has(key)) clients.set(key, []);
37
233
  clients.get(key)!.push(client);
38
234
  console.log(`[${ts()}] SSE ← ${key} connected (${clients.get(key)!.length} clients)`);
39
235
 
40
- // 发送初始心跳
41
- controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "connected", session: sessionName, network_id: networkId ?? null })}\n\n`));
236
+ // Send initial connected frame through the backpressure guard so
237
+ // even the first byte respects the contract.
238
+ tryEnqueueBytes(
239
+ client,
240
+ encoder.encode(`data: ${JSON.stringify({ type: "connected", session: sessionName, network_id: networkId ?? null })}\n\n`),
241
+ );
42
242
 
43
- // Periodic keepalive every 30s to prevent proxy/LB idle timeout
44
- const keepalive = setInterval(() => {
45
- try {
46
- controller.enqueue(encoder.encode(`: keepalive\n\n`));
47
- } catch {
48
- clearInterval(keepalive);
243
+ // Periodic keepalive doubles as a half-open probe — if the
244
+ // consumer is gone, this enqueue piles bytes in the queue until
245
+ // tryEnqueueBytes catches the ceiling / stuck timeout.
246
+ client.keepaliveTimer = setInterval(() => {
247
+ if (client.closed) {
248
+ if (client.keepaliveTimer) clearInterval(client.keepaliveTimer);
249
+ return;
49
250
  }
50
- }, 30_000);
51
- client.keepalive = keepalive;
251
+ const result = tryEnqueueBytes(client, encoder.encode(`: keepalive\n\n`));
252
+ if (result === "dead") {
253
+ // closeClient already cleared the timer; just make sure we
254
+ // also prune from the map.
255
+ pruneClosed(key);
256
+ }
257
+ }, KEEPALIVE_MS);
258
+ (client.keepaliveTimer as any)?.unref?.();
259
+
260
+ ensureLivenessSweep();
52
261
  },
53
262
  cancel() {
54
- // 断线清理
55
- const arr = clients.get(key);
56
- if (arr) {
57
- const idx = arr.findIndex(c => c.controller === ctrl);
58
- if (idx !== -1) {
59
- if (arr[idx].keepalive) clearInterval(arr[idx].keepalive);
60
- arr.splice(idx, 1);
61
- }
62
- if (arr.length === 0) clients.delete(key);
63
- console.log(`[${ts()}] SSE ✕ ${key} disconnected (${arr.length} remaining)`);
263
+ // Consumer side hangup — straightforward path, mark closed and
264
+ // prune. The half-open path goes through closeClient instead.
265
+ if (client) {
266
+ closeClient(client, "cancel");
267
+ pruneClosed(key);
64
268
  }
65
269
  },
270
+ }, {
271
+ // BYTE-COUNTING strategy. Without this, desiredSize is a chunk
272
+ // counter and the byte-cap below is meaningless (see file header
273
+ // comment #0). highWaterMark=0 means: after enqueuing N bytes, the
274
+ // queue is N bytes over the mark, so `desiredSize === -N`. Pair
275
+ // this with the hard ceiling in tryEnqueueBytes to actually close
276
+ // a half-open consumer at MAX_QUEUE_BACKPRESSURE_BYTES.
277
+ highWaterMark: 0,
278
+ size: (chunk: Uint8Array) => chunk.byteLength,
66
279
  });
67
280
 
68
281
  return new Response(stream, {
@@ -74,8 +287,17 @@ export function createSSEStream(sessionName: string, networkId?: string | null):
74
287
  });
75
288
  }
76
289
 
77
- function clientKey(sessionName: string, networkId?: string | null): string {
78
- return `${networkId || "global"}:${sessionName}`;
290
+ /** Remove closed entries for one key, drop the map slot if empty. */
291
+ function pruneClosed(key: string): void {
292
+ const arr = clients.get(key);
293
+ if (!arr) return;
294
+ const alive = arr.filter((c) => !c.closed);
295
+ if (alive.length === 0) {
296
+ clients.delete(key);
297
+ console.log(`[${ts()}] SSE ✕ ${key} disconnected (0 remaining)`);
298
+ } else if (alive.length !== arr.length) {
299
+ clients.set(key, alive);
300
+ }
79
301
  }
80
302
 
81
303
  function rekeyClient(oldSessionName: string, newSessionName: string, networkId?: string | null): number {
@@ -85,6 +307,10 @@ function rekeyClient(oldSessionName: string, newSessionName: string, networkId?:
85
307
  const oldClients = clients.get(oldKey);
86
308
  if (!oldClients || oldClients.length === 0) return 0;
87
309
 
310
+ // Update the cached key on each moved client so later closeClient
311
+ // logs report the right session.
312
+ for (const c of oldClients) c.key = newKey;
313
+
88
314
  const existing = clients.get(newKey) || [];
89
315
  clients.set(newKey, existing.concat(oldClients));
90
316
  clients.delete(oldKey);
@@ -102,39 +328,34 @@ eventBus.on("rename-committed", (event: RenameCommittedEvent) => {
102
328
 
103
329
  /** 推送事件给指定 session 的所有 SSE 连接 */
104
330
  export function pushEvent(sessionName: string, event: Record<string, unknown>, networkId?: string | null): void {
105
- // Resolve the key once — both lookup and post-cleanup `clients.delete` MUST
106
- // use the same key, otherwise an emptied bucket would leak (the explicit
107
- // arg wins over event.network_id, falling back so legacy callers that pass
108
- // network_id inside `event` still hit the right bucket).
109
331
  const key = clientKey(sessionName, networkId ?? (event.network_id as string | null | undefined));
110
332
  const arr = clients.get(key);
111
333
  if (!arr || arr.length === 0) return;
112
334
 
113
335
  const data = `data: ${JSON.stringify(event)}\n\n`;
114
- const dead: number[] = [];
336
+ let needPrune = false;
115
337
 
116
- for (let i = 0; i < arr.length; i++) {
117
- try {
118
- arr[i].controller.enqueue(arr[i].encoder.encode(data));
119
- } catch {
120
- dead.push(i);
338
+ for (const c of arr) {
339
+ if (c.closed) {
340
+ needPrune = true;
341
+ continue;
121
342
  }
343
+ const result = tryEnqueueBytes(c, c.encoder.encode(data));
344
+ if (result === "dead") needPrune = true;
122
345
  }
123
346
 
124
- // 清理死连接 — also clear their keepalive timers so the dead client doesn't
125
- // keep an interval handle alive past its controller (small leak previously
126
- // bounded by the next failed enqueue catching itself, but cleanup-on-detect
127
- // is cheaper than waiting 30s).
128
- for (let i = dead.length - 1; i >= 0; i--) {
129
- const c = arr[dead[i]];
130
- if (c.keepalive) clearInterval(c.keepalive);
131
- arr.splice(dead[i], 1);
132
- }
133
- if (arr.length === 0) clients.delete(key);
347
+ if (needPrune) pruneClosed(key);
134
348
  }
135
349
 
136
350
  export function __resetSSEClientsForTest(): void {
351
+ for (const arr of clients.values()) {
352
+ for (const c of arr) closeClient(c, "reset-for-test");
353
+ }
137
354
  clients.clear();
355
+ if (livenessSweepTimer) {
356
+ clearInterval(livenessSweepTimer);
357
+ livenessSweepTimer = null;
358
+ }
138
359
  }
139
360
 
140
361
  /** 获取当前 SSE 连接统计 */
@@ -142,8 +363,23 @@ export function getSSEStats(): { total: number; sessions: Record<string, number>
142
363
  let total = 0;
143
364
  const sessions: Record<string, number> = {};
144
365
  for (const [name, arr] of clients) {
145
- sessions[name] = arr.length;
146
- total += arr.length;
366
+ const alive = arr.filter((c) => !c.closed);
367
+ if (alive.length === 0) continue;
368
+ sessions[name] = alive.length;
369
+ total += alive.length;
147
370
  }
148
371
  return { total, sessions };
149
372
  }
373
+
374
+ // Test hooks — exported so the test file can simulate half-open / stuck
375
+ // consumers without spinning up a real TCP socket.
376
+ export const __SSE_INTERNALS_FOR_TEST = {
377
+ tryEnqueueBytes,
378
+ sweepLiveness,
379
+ closeClient,
380
+ MAX_QUEUE_BACKPRESSURE_BYTES,
381
+ STUCK_CLOSE_MS,
382
+ KEEPALIVE_MS,
383
+ LIVENESS_SWEEP_MS,
384
+ clientsMap: clients,
385
+ };