@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/package.json +2 -2
- package/src/auth-kdf-migration.test.ts +378 -0
- package/src/auth-tokens.test.ts +18 -9
- package/src/auth.ts +92 -6
- package/src/config-apply-sec1.test.ts +561 -0
- package/src/config-apply-validate.test.ts +260 -0
- package/src/config-apply-validate.ts +171 -0
- package/src/cross-tenant-injection.test.ts +335 -0
- package/src/db-adapter.ts +2 -2
- package/src/db.ts +356 -7
- package/src/index.ts +61 -17
- package/src/push.test.ts +383 -2
- package/src/push.ts +291 -55
- package/src/retention.test.ts +450 -0
- package/src/retention.ts +269 -0
- package/src/stale-sweeper.test.ts +181 -0
- package/src/stale-sweeper.ts +114 -0
- package/src/tools.ts +597 -64
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
clearInterval(
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
336
|
+
let needPrune = false;
|
|
115
337
|
|
|
116
|
-
for (
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
+
};
|