@sleep2agi/commhub-server 0.9.0-preview.158 → 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 +1 -1
- package/src/mcp-inflight.test.ts +92 -0
- package/src/mcp-inflight.ts +38 -0
- package/src/push.test.ts +20 -10
- package/src/push.ts +39 -5
- package/src/server.ts +24 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
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/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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
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
|
-
|
|
1114
|
-
|
|
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",
|