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

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.159",
3
+ "version": "0.9.0-preview.160",
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",
@@ -0,0 +1,92 @@
1
+ // #2363 —— MCP 在飞计数的单测 + 源码断言。
2
+ //
3
+ // 计数值本身很小,但它是“RSS 尖峰能不能被解释”的唯一在线指标:`sse_connections`
4
+ // 只反映长连接,看不到同秒打进来的一批短请求。这里钉住三件事:
5
+ // 1. enter/leave 必须配平(含重复 leave 不把计数打成负数);
6
+ // 2. 峰值要能记住高水位,并能被显式重置;
7
+ // 3. server.ts 必须在**任何 await 之前**进入计数,并且在“正常关闭 / 抛异常”
8
+ // 两条路径上都退出计数——少一条就会让指标永远偏高,比没有指标更糟。
9
+
10
+ import { describe, expect, test, beforeEach } from "bun:test";
11
+ import { readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import {
14
+ enterMcpInflight,
15
+ leaveMcpInflight,
16
+ getMcpInflightStats,
17
+ resetMcpInflightPeak,
18
+ } from "./mcp-inflight.js";
19
+
20
+ beforeEach(() => {
21
+ // 模块级单例:每个用例从 0 开始,避免用例顺序影响。
22
+ while (getMcpInflightStats().inflight > 0) leaveMcpInflight();
23
+ resetMcpInflightPeak();
24
+ });
25
+
26
+ describe("#2363 mcp inflight counter", () => {
27
+ test("enter/leave 配平,peak 记住高水位且不自己回落", () => {
28
+ expect(getMcpInflightStats()).toEqual({ inflight: 0, peak: 0 });
29
+ enterMcpInflight();
30
+ enterMcpInflight();
31
+ enterMcpInflight();
32
+ expect(getMcpInflightStats()).toEqual({ inflight: 3, peak: 3 });
33
+ leaveMcpInflight();
34
+ leaveMcpInflight();
35
+ // 当前只有 1 条在飞,但峰值仍是 3 —— 峰值是“撞过悬崖没有”的证据。
36
+ expect(getMcpInflightStats()).toEqual({ inflight: 1, peak: 3 });
37
+ leaveMcpInflight();
38
+ expect(getMcpInflightStats()).toEqual({ inflight: 0, peak: 3 });
39
+ });
40
+
41
+ test("多余的 leave 不会把计数打成负数", () => {
42
+ leaveMcpInflight();
43
+ leaveMcpInflight();
44
+ expect(getMcpInflightStats().inflight).toBe(0);
45
+ });
46
+
47
+ test("reset 把峰值对齐到当前在飞数(不会把峰值压到低于现况)", () => {
48
+ enterMcpInflight();
49
+ enterMcpInflight();
50
+ expect(getMcpInflightStats().peak).toBe(2);
51
+ leaveMcpInflight();
52
+ resetMcpInflightPeak();
53
+ expect(getMcpInflightStats()).toEqual({ inflight: 1, peak: 1 });
54
+ });
55
+ });
56
+
57
+ describe("#2363 server wiring (source assertions)", () => {
58
+ const src = readFileSync(join(import.meta.dir, "server.ts"), "utf8");
59
+
60
+ test("进入计数发生在创建 transport 之前(请求体解析阶段也要计入)", () => {
61
+ const enterAt = src.indexOf("enterMcpInflight();");
62
+ const transportAt = src.indexOf("new WebStandardStreamableHTTPServerTransport({", enterAt);
63
+ expect(enterAt).toBeGreaterThan(0);
64
+ expect(transportAt).toBeGreaterThan(enterAt);
65
+ });
66
+
67
+ test("两条退出路径都在:handleRequest 抛异常时、以及 MCP server 关闭后", () => {
68
+ // 抛异常路径
69
+ expect(src).toContain("settleMcpInflight();\n throw e;");
70
+ // 正常路径(setImmediate 里 close 完成后结算,且重复调用安全)
71
+ expect(src).toContain("mcpServer.close().catch(() => {}).finally(settleMcpInflight)");
72
+ // 幂等闸门:只允许真正减一次
73
+ expect(src).toContain("if (mcpInflightSettled) return;");
74
+ });
75
+
76
+ test("/health 暴露的是聚合值(匿名也安全),且没有 new 的按会话明细", () => {
77
+ expect(src).toContain("mcp_inflight: getMcpInflightStats().inflight,");
78
+ expect(src).toContain("mcp_inflight_peak: getMcpInflightStats().peak,");
79
+ expect(src).not.toContain("mcp_inflight_sessions");
80
+ });
81
+
82
+ test("这一版只观测:/mcp 入口不得出现 429 / 拒绝分支", () => {
83
+ const mcpStart = src.indexOf('if (url.pathname === "/mcp") {');
84
+ const mcpEnd = src.indexOf("// ── #461 SSE network observer stream ──", mcpStart);
85
+ expect(mcpStart).toBeGreaterThan(0);
86
+ expect(mcpEnd).toBeGreaterThan(mcpStart);
87
+ const block = src.slice(mcpStart, mcpEnd);
88
+ expect(block).not.toContain("429");
89
+ expect(block).not.toContain("hub_busy");
90
+ expect(block).not.toContain("Retry-After");
91
+ });
92
+ });
@@ -0,0 +1,38 @@
1
+ // #2363 —— MCP 在飞计数(先只观测,不拦请求)。
2
+ //
3
+ // 为什么需要它:隔离 Hub 上用**生产库副本**实测,RSS 与“同时在飞的 MCP 请求数”
4
+ // 成正比(≈1.2–1.4 MB/条;`send_message` 与 `get_inbox` 同形),而与吞吐无关——
5
+ // 顺序发 3,255 条/60s(≈54 条/秒)时 RSS 稳定在 151–156 MB,而 50/100/200 并发
6
+ // 的峰值是 249/446/689 MB。`/health` 里的 `sse_connections` 只反映长连接,看不到
7
+ // 这个量,所以生产上“为什么 RSS 突然抬到 450 MB”一直只能事后猜。
8
+ //
9
+ // 本模块只做计数与峰值,**不拒绝、不排队、不改任何响应**——限流(有界队列 + 429)
10
+ // 是另一步,需要先有一天的真实分布再定上限。见 docs/runbooks/hub-memory-and-sse.md。
11
+
12
+ let inflight = 0;
13
+ let peak = 0;
14
+
15
+ /** /mcp 进入时调用(在任何 await 之前,保证计入请求体解析阶段)。 */
16
+ export function enterMcpInflight(): void {
17
+ inflight += 1;
18
+ if (inflight > peak) peak = inflight;
19
+ }
20
+
21
+ /** /mcp 结束时调用(响应对象产出且 MCP server 已关闭);重复调用安全。 */
22
+ export function leaveMcpInflight(): void {
23
+ if (inflight > 0) inflight -= 1;
24
+ }
25
+
26
+ /**
27
+ * 当前在飞数与**自上次 reset 以来的峰值**。
28
+ * 峰值是“滚动高水位”,不会自己回落——运维看它判断是否撞到过悬崖,
29
+ * 看 `inflight` 判断现在是否还在里面。
30
+ */
31
+ export function getMcpInflightStats(): { inflight: number; peak: number } {
32
+ return { inflight, peak };
33
+ }
34
+
35
+ /** 把峰值重置为当前在飞数(运维定位完一次尖峰后调用,或测试用)。 */
36
+ export function resetMcpInflightPeak(): void {
37
+ peak = inflight;
38
+ }
package/src/server.ts CHANGED
@@ -28,6 +28,7 @@ import { ensureNetworkRulesOnce, readNetworkRules, writeNetworkRules } from "./n
28
28
  import { deriveTeamRulesView } from "./team-rules-status.js";
29
29
  import { recentNodeIdentityConflicts } from "./node-identity-conflict.js";
30
30
  import { createSSEStream, createNetworkObserverStream, createUserEventStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX, closeUserStreamsInNetwork, getUserPresence, onUserPresenceChange, pushUserEventToNetwork } from "./push.js";
31
+ import { enterMcpInflight, leaveMcpInflight, getMcpInflightStats } from "./mcp-inflight.js";
31
32
  import { assertNodeActive } from "./lifecycle-guard.js";
32
33
  import { pendingInboxCount } from "./inbox-count.js";
33
34
  import { notifyExpiredTasks, type ExpiredTaskRow } from "./task-expiry-notice.js";
@@ -1092,6 +1093,16 @@ return Bun.serve({
1092
1093
  callerIdentityRejected = true;
1093
1094
  }
1094
1095
  }
1096
+ // #2363 —— 在飞计数:从进入 /mcp 到 MCP server 关闭。Hub 的内存与“同时在飞的
1097
+ // 请求数”成正比(隔离实测 ≈1.2–1.4 MB/条;顺序 54 条/秒时 RSS 完全平稳),
1098
+ // 所以这个数比 req/s 更能解释 RSS 尖峰。只观测,不拦请求。
1099
+ enterMcpInflight();
1100
+ let mcpInflightSettled = false;
1101
+ const settleMcpInflight = (): void => {
1102
+ if (mcpInflightSettled) return;
1103
+ mcpInflightSettled = true;
1104
+ leaveMcpInflight();
1105
+ };
1095
1106
  const transport = new WebStandardStreamableHTTPServerTransport({
1096
1107
  sessionIdGenerator: undefined,
1097
1108
  });
@@ -1110,10 +1121,16 @@ return Bun.serve({
1110
1121
  }
1111
1122
  // #478:tools/list 按调用者过滤;X-Anet-Tools: all / ?tools=all 时节点令牌也列出协议工具。
1112
1123
  const mcpServer = createServer(clientIP, enforceNetId, authCtx?.userId || null, callerAlias, !!token?.startsWith("ntok_"), authCtx?.tokenId || null, wantsAllTools(req, url), callerIdentityRejected);
1113
- await mcpServer.connect(transport);
1114
- const response = await transport.handleRequest(req, mcpParsedBody === undefined ? undefined : { parsedBody: mcpParsedBody });
1124
+ let response: Response;
1125
+ try {
1126
+ await mcpServer.connect(transport);
1127
+ response = await transport.handleRequest(req, mcpParsedBody === undefined ? undefined : { parsedBody: mcpParsedBody });
1128
+ } catch (e) {
1129
+ settleMcpInflight();
1130
+ throw e;
1131
+ }
1115
1132
  // Disconnect after response to prevent McpServer leak
1116
- setImmediate(() => mcpServer.close().catch(() => {}));
1133
+ setImmediate(() => { mcpServer.close().catch(() => {}).finally(settleMcpInflight); });
1117
1134
  // #426: the MCP SDK's WebStandardStreamableHTTPServerTransport builds
1118
1135
  // its own Response with `Content-Type: text/event-stream` (or
1119
1136
  // `application/json` for JSON-mode) but WITHOUT charset. Legacy
@@ -2765,6 +2782,10 @@ return Bun.serve({
2765
2782
  transport: "streamable-http",
2766
2783
  sessions_count: count?.cnt ?? 0,
2767
2784
  sse_connections: sse.total,
2785
+ // #2363 —— MCP 在飞数(只观测)。RSS 与它成正比(≈1.2–1.4 MB/条),
2786
+ // 而 `sse_connections` 只反映长连接,看不到“同秒打进来的一批请求”。
2787
+ mcp_inflight: getMcpInflightStats().inflight,
2788
+ mcp_inflight_peak: getMcpInflightStats().peak,
2768
2789
  auth: DEV_OPEN ? "dev-open" : "user-token",
2769
2790
  security: DEV_OPEN ? "dev-open" : "secured",
2770
2791
  tmux: TMUX_ENABLED ? "enabled" : "disabled",