@sleep2agi/commhub-server 0.9.0-preview.158 → 0.9.0-preview.159

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.158",
3
+ "version": "0.9.0-preview.159",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/push.test.ts CHANGED
@@ -351,26 +351,36 @@ describe("real ReadableStream — byte-level backpressure (round-2 fix)", () =>
351
351
  await res.body!.cancel();
352
352
  });
353
353
 
354
- test("non-reading consumer past MAX bytes triggers hard ceiling close", async () => {
354
+ test("non-reading consumer stops growing at COALESCE_AT and is reaped by the stuck timeout", async () => {
355
+ // #2363 —— 语义变更:落后超过 COALESCE_AT_BYTES 后**跳过非关键事件**,不再一路排队到
356
+ // 1 MB 硬顶。这样 150 条慢订阅者不会把内存放大到 连接数 × 1 MB(实测 2 GB 尖峰)。
357
+ // 兜底仍然存在:硬顶对这一帧的**关键帧**生效;长期不读的由 STUCK_CLOSE_MS 回收。
358
+ const coalesce = __SSE_INTERNALS_FOR_TEST.COALESCE_AT_BYTES;
355
359
  const ceiling = __SSE_INTERNALS_FOR_TEST.MAX_QUEUE_BACKPRESSURE_BYTES;
356
360
  const res = createSSEStream("byte-cap-probe", "net-bcp");
357
361
  const key = "net-bcp:byte-cap-probe";
358
362
  const c = __SSE_INTERNALS_FOR_TEST.clientsMap.get(key)![0];
359
363
 
360
- // Push events whose total byte size exceeds the hard ceiling.
361
- // With chunk-count strategy this would NOT close (each event is 1
362
- // chunk; desiredSize would be at -N for N events, never -1M).
363
- // With byte strategy this SHOULD close once accumulated bytes
364
- // exceed ceiling.
365
- const chunkSize = 100_000; // 100KB per push
366
- const needed = Math.ceil(ceiling / chunkSize) + 2; // overshoot a bit
364
+ const chunkSize = 100_000; // 100KB per push, larger than COALESCE_AT
367
365
  const big = "x".repeat(chunkSize);
368
- for (let i = 0; i < needed; i++) {
366
+ const before = __SSE_INTERNALS_FOR_TEST.degradedDropCount();
367
+ for (let i = 0; i < Math.ceil(ceiling / chunkSize) + 2; i++) {
369
368
  if (c.closed) break;
370
369
  pushEvent("byte-cap-probe", { type: "big", payload: big }, "net-bcp");
371
370
  }
371
+ // 关键断言 1:**没有被立刻关闭**(跳过的语义是“不排队”,不是“断开”)
372
+ expect(c.closed).toBe(false);
373
+ // 关键断言 2:队列没有一路涨到硬顶 —— 落后量被 COALESCE_AT 挡住
374
+ const desired = c.controller.desiredSize ?? 0;
375
+ expect(-desired).toBeLessThan(ceiling);
376
+ expect(-desired).toBeLessThanOrEqual(coalesce + chunkSize * 2);
377
+ // 关键断言 3:跳过被**计数**(不静默)
378
+ expect(__SSE_INTERNALS_FOR_TEST.degradedDropCount()).toBeGreaterThan(before);
379
+
380
+ // 关键断言 4:长期不读仍会被收回(沿用原有 stuck 回收)
381
+ c.stuckSince = Date.now() - (__SSE_INTERNALS_FOR_TEST.STUCK_CLOSE_MS + 1);
382
+ __SSE_INTERNALS_FOR_TEST.tryEnqueueBytes(c, new Uint8Array(1), { critical: true });
372
383
  expect(c.closed).toBe(true);
373
- expect(__SSE_INTERNALS_FOR_TEST.clientsMap.has(key)).toBe(false);
374
384
 
375
385
  try { await res.body!.cancel(); } catch { /* already closed */ }
376
386
  });
package/src/push.ts CHANGED
@@ -59,6 +59,24 @@ function envNum(k: string, fallback: number): number {
59
59
  * half-open consumer that has stopped reading entirely. */
60
60
  const MAX_QUEUE_BACKPRESSURE_BYTES = envNum("ANET_SSE_MAX_QUEUE_BYTES", 1_000_000);
61
61
 
62
+ /** #2363 —— 订阅者“已经落后多少字节”就不再给它非关键事件。
63
+ *
64
+ * 背景(2026-10-26 实测):150 条慢订阅者 + 30 msg/s 广播,RSS 峰值可到 2 GB;
65
+ * 每条连接的上限只是 1 MB,但 300 条 × 1 MB 就是 GB 级。与其把上限一路压到底,
66
+ * 更合理的是:**明显落后的连接直接跳过非关键事件**(keepalive 与 connected 必须保留,
67
+ * 否则 liveness 探测和重连握手会失效),并计数 `sse_dropped_degraded`。
68
+ *
69
+ * 这不是“丢消息”的新语义:SSE 本来就是 best-effort,客户端重连后会拉一次快照;
70
+ * 真正的问题是把“慢消费者”变成“Hub 的内存账”。 */
71
+ const COALESCE_AT_BYTES = envNum("ANET_SSE_COALESCE_AT_BYTES", 65_536);
72
+
73
+ /** #2363 —— 所有广播共用同一个 TextEncoder:编码结果只读、不跨事件复用同一 Uint8Array。 */
74
+ const SHARED_ENCODER = new TextEncoder();
75
+
76
+ /** 被跳过的事件计数(进程内累计,供 /health 与测试读取)。 */
77
+ let droppedDegraded = 0;
78
+ export function sseDegradedDropCount(): number { return droppedDegraded; }
79
+
62
80
  /** How long `desiredSize` can stay negative (i.e. consumer behind HWM)
63
81
  * before we declare the client dead. 60s by default — past 2× the
64
82
  * keepalive interval, so a live-but-slow consumer wouldn't trip it. */
@@ -200,7 +218,7 @@ function closeClient(client: SSEClient, reason: string): void {
200
218
  /** Best-effort enqueue with backpressure guard. Returns:
201
219
  * - "ok" → bytes accepted, queue healthy
202
220
  * - "dead" → client closed; caller must remove from map */
203
- function tryEnqueueBytes(client: SSEClient, bytes: Uint8Array): "ok" | "dead" {
221
+ function tryEnqueueBytes(client: SSEClient, bytes: Uint8Array, opts: { critical?: boolean } = {}): "ok" | "dead" {
204
222
  if (client.closed) return "dead";
205
223
 
206
224
  const ctrl = client.controller;
@@ -219,6 +237,14 @@ function tryEnqueueBytes(client: SSEClient, bytes: Uint8Array): "ok" | "dead" {
219
237
  return "dead";
220
238
  }
221
239
 
240
+ // #2363 —— 落后超过 COALESCE_AT_BYTES 且不是关键帧:跳过,不排队、也不关连接
241
+ // (上限之上的仍然按硬顶关闭,见上一段)。这样“慢消费者”不再把内存账单放大到
242
+ // 连接数 × 单连接上限,同时保留原有“彻底读不动就断开回收”的语义。
243
+ if (!opts.critical && desired < -COALESCE_AT_BYTES) {
244
+ droppedDegraded += 1;
245
+ return "ok";
246
+ }
247
+
222
248
  // Track stuck-since-when for the soft timeout below.
223
249
  if (desired < 0) {
224
250
  if (client.stuckSince === null) {
@@ -459,6 +485,7 @@ function createStreamForKey(
459
485
  tryEnqueueBytes(
460
486
  client,
461
487
  encoder.encode(`data: ${JSON.stringify(initialEvent)}\n\n`),
488
+ { critical: true },
462
489
  );
463
490
 
464
491
  // Periodic keepalive doubles as a half-open probe — if the
@@ -469,7 +496,7 @@ function createStreamForKey(
469
496
  if (client.keepaliveTimer) clearInterval(client.keepaliveTimer);
470
497
  return;
471
498
  }
472
- const result = tryEnqueueBytes(client, encoder.encode(`: keepalive\n\n`));
499
+ const result = tryEnqueueBytes(client, encoder.encode(`: keepalive\n\n`), { critical: true });
473
500
  if (result === "ok" && isUserStreamClient(client)) touchUserSeen(client.subscriberUserId!);
474
501
  if (result === "dead") {
475
502
  // closeClient already cleared the timer; just make sure we
@@ -580,6 +607,9 @@ export function pushEvent(sessionName: string, event: Record<string, unknown>, n
580
607
  if (!arr || arr.length === 0) return;
581
608
 
582
609
  const data = `data: ${JSON.stringify(event)}\n\n`;
610
+ // #2363 —— 一次编码,所有订阅者共用同一份字节:150 条连接 × 每秒几十条事件时,
611
+ // 每连接重新 encode 一次会产生大量短命 Uint8Array(实测 150×30/s 时 RSS 峰值 2 GB)。
612
+ const bytes = SHARED_ENCODER.encode(data);
583
613
  let needPrune = false;
584
614
 
585
615
  for (const c of arr) {
@@ -587,7 +617,7 @@ export function pushEvent(sessionName: string, event: Record<string, unknown>, n
587
617
  needPrune = true;
588
618
  continue;
589
619
  }
590
- const result = tryEnqueueBytes(c, c.encoder.encode(data));
620
+ const result = tryEnqueueBytes(c, bytes);
591
621
  if (result === "dead") needPrune = true;
592
622
  }
593
623
 
@@ -614,6 +644,7 @@ export function pushNetworkObserverEvent(
614
644
  if (!arr || arr.length === 0) return;
615
645
 
616
646
  const data = `data: ${JSON.stringify({ ...event, network_id: networkId, scope: "network" })}\n\n`;
647
+ const bytes = SHARED_ENCODER.encode(data); // #2363:一次编码,所有 observer 共用
617
648
  let needPrune = false;
618
649
 
619
650
  for (const c of arr) {
@@ -626,7 +657,7 @@ export function pushNetworkObserverEvent(
626
657
  try { pass = c.eventFilter(event); } catch { pass = false; }
627
658
  if (!pass) continue;
628
659
  }
629
- const result = tryEnqueueBytes(c, c.encoder.encode(data));
660
+ const result = tryEnqueueBytes(c, bytes);
630
661
  if (result === "dead") needPrune = true;
631
662
  }
632
663
 
@@ -661,6 +692,7 @@ export function pushUserEvent(
661
692
  if (!arr || arr.length === 0) return;
662
693
 
663
694
  const data = `data: ${JSON.stringify({ ...event, network_id: networkId, user_id: userId, scope: "user" })}\n\n`;
695
+ const bytes = SHARED_ENCODER.encode(data); // #2363 同 pushEvent:一次编码共用
664
696
  let needPrune = false;
665
697
 
666
698
  for (const c of arr) {
@@ -668,7 +700,7 @@ export function pushUserEvent(
668
700
  needPrune = true;
669
701
  continue;
670
702
  }
671
- const result = tryEnqueueBytes(c, c.encoder.encode(data));
703
+ const result = tryEnqueueBytes(c, bytes);
672
704
  if (result === "dead") needPrune = true;
673
705
  }
674
706
 
@@ -797,6 +829,8 @@ export const __SSE_INTERNALS_FOR_TEST = {
797
829
  sweepLiveness,
798
830
  closeClient,
799
831
  MAX_QUEUE_BACKPRESSURE_BYTES,
832
+ COALESCE_AT_BYTES,
833
+ degradedDropCount: sseDegradedDropCount,
800
834
  STUCK_CLOSE_MS,
801
835
  KEEPALIVE_MS,
802
836
  LIVENESS_SWEEP_MS,