@nanobpm/urban-agent-client 0.1.0
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/README.md +98 -0
- package/dist/client.d.ts +211 -0
- package/dist/client.js +706 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +37 -0
- package/dist/protocol.d.ts +16 -0
- package/dist/protocol.js +15 -0
- package/dist/ring.d.ts +103 -0
- package/dist/ring.js +166 -0
- package/dist/testkit.d.ts +53 -0
- package/dist/testkit.js +95 -0
- package/dist/transport.d.ts +62 -0
- package/dist/transport.js +80 -0
- package/package.json +54 -0
- package/src/client.test.ts +829 -0
- package/src/client.ts +829 -0
- package/src/conformance.test.ts +151 -0
- package/src/index.ts +77 -0
- package/src/protocol.ts +46 -0
- package/src/ring.test.ts +155 -0
- package/src/ring.ts +228 -0
- package/src/testkit.ts +112 -0
- package/src/transport.test.ts +77 -0
- package/src/transport.ts +123 -0
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mock, test } from "node:test";
|
|
3
|
+
import { AgenticClient, connectAgenticChannel } from "./client.ts";
|
|
4
|
+
import { encodeFrame } from "./protocol.ts";
|
|
5
|
+
import type { Frame, ServePayload } from "./protocol.ts";
|
|
6
|
+
import { fakeTransportFactory } from "./testkit.ts";
|
|
7
|
+
import type { TransportCloseInfo, TransportFactory, TransportHooks } from "./transport.ts";
|
|
8
|
+
|
|
9
|
+
function serveFrame(instance: string, tokens: string[]): Uint8Array {
|
|
10
|
+
const payload: ServePayload = { instance, tokens };
|
|
11
|
+
const frame: Frame = { lane: "control", family: "serve", seq: 0, payload };
|
|
12
|
+
return encodeFrame(frame);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function newClient(overrides: Partial<Parameters<typeof connectAgenticChannel>[0]> = {}) {
|
|
16
|
+
const t = fakeTransportFactory();
|
|
17
|
+
const client = new AgenticClient({
|
|
18
|
+
url: "ws://test/agentic",
|
|
19
|
+
instance: "worker-1",
|
|
20
|
+
transport: t.factory,
|
|
21
|
+
reconnect: { enabled: false },
|
|
22
|
+
serveTimeoutMs: 0,
|
|
23
|
+
...overrides,
|
|
24
|
+
});
|
|
25
|
+
return { client, t };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test("REGISTER → SERVE resolves the register promise with the resolved tokens", async () => {
|
|
29
|
+
const { client, t } = newClient({ serveTimeoutMs: 1000 });
|
|
30
|
+
client.connect();
|
|
31
|
+
t.last().fireOpen();
|
|
32
|
+
|
|
33
|
+
const pending = client.register({ capability: { cognition: "high", weight: 3, family: "opus", host: "cli" } });
|
|
34
|
+
|
|
35
|
+
// The client sent a REGISTER frame carrying the capability (never a token).
|
|
36
|
+
const sent = t.last().sentFrames;
|
|
37
|
+
const register = sent.find((f) => f.family === "register");
|
|
38
|
+
assert.ok(register, "a register frame was sent");
|
|
39
|
+
assert.deepEqual(register?.payload, {
|
|
40
|
+
instance: "worker-1",
|
|
41
|
+
capability: { cognition: "high", weight: 3, family: "opus", host: "cli" },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Hub answers with SERVE.
|
|
45
|
+
t.last().deliver(serveFrame("worker-1", ["planning.spar#red", "implementation.impl"]));
|
|
46
|
+
const { serve } = await pending;
|
|
47
|
+
assert.deepEqual(serve, ["planning.spar#red", "implementation.impl"]);
|
|
48
|
+
assert.deepEqual(client.serve, ["planning.spar#red", "implementation.impl"]);
|
|
49
|
+
client.close();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a SERVE addressed to a different instance is ignored", async () => {
|
|
53
|
+
const scheduled: Array<() => void> = [];
|
|
54
|
+
const { client, t } = newClient({ serveTimeoutMs: 100, schedule: (fn) => scheduled.push(fn) });
|
|
55
|
+
client.connect();
|
|
56
|
+
t.last().fireOpen();
|
|
57
|
+
const pending = client.register({ capability: { cognition: "high" } });
|
|
58
|
+
t.last().deliver(serveFrame("someone-else", ["planning.spar#red"]));
|
|
59
|
+
// Drive the serve-timeout deterministically rather than depending on a real
|
|
60
|
+
// (unref'd) timer, which Node's test runner may never fire before it drains
|
|
61
|
+
// the loop and cancels the awaited rejection.
|
|
62
|
+
assert.equal(scheduled.length, 1, "a serve-timeout was scheduled");
|
|
63
|
+
scheduled.forEach((fn) => fn());
|
|
64
|
+
await assert.rejects(pending, /SERVE not received/);
|
|
65
|
+
client.close();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("heartbeat, relay and deregister emit correctly-shaped frames", () => {
|
|
69
|
+
const { client, t } = newClient();
|
|
70
|
+
client.connect();
|
|
71
|
+
t.last().fireOpen();
|
|
72
|
+
|
|
73
|
+
client.heartbeat();
|
|
74
|
+
client.relay("stdout", "hello");
|
|
75
|
+
client.relay("stdout", "world!");
|
|
76
|
+
client.deregister("done");
|
|
77
|
+
|
|
78
|
+
const frames = t.last().sentFrames;
|
|
79
|
+
const heartbeat = frames.find((f) => f.family === "heartbeat");
|
|
80
|
+
assert.deepEqual(heartbeat?.payload, { instance: "worker-1" });
|
|
81
|
+
assert.equal(heartbeat?.lane, "control");
|
|
82
|
+
|
|
83
|
+
const relays = frames.filter((f) => f.family === "relay");
|
|
84
|
+
assert.equal(relays.length, 2);
|
|
85
|
+
assert.equal(relays[0]?.lane, "bulk");
|
|
86
|
+
// Per-stream byte offset advances by the UTF-8 length of each chunk.
|
|
87
|
+
assert.deepEqual(relays[0]?.payload, { stream: "stdout", offset: 0, chunk: "hello" });
|
|
88
|
+
assert.deepEqual(relays[1]?.payload, { stream: "stdout", offset: 5, chunk: "world!" });
|
|
89
|
+
|
|
90
|
+
const dereg = frames.find((f) => f.family === "deregister");
|
|
91
|
+
assert.deepEqual(dereg?.payload, { instance: "worker-1", reason: "done" });
|
|
92
|
+
assert.ok(t.last().wasClosedLocally());
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a transport factory that throws surfaces onError and leaves 'connecting' instead of wedging", () => {
|
|
96
|
+
const failure = new Error("no global WebSocket available");
|
|
97
|
+
const errors: Error[] = [];
|
|
98
|
+
const closes: number[] = [];
|
|
99
|
+
const client = new AgenticClient({
|
|
100
|
+
url: "ws://test/agentic",
|
|
101
|
+
instance: "worker-1",
|
|
102
|
+
reconnect: { enabled: false },
|
|
103
|
+
serveTimeoutMs: 0,
|
|
104
|
+
transport: () => {
|
|
105
|
+
throw failure;
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
client.onError((e) => errors.push(e));
|
|
109
|
+
client.onClose(() => closes.push(1));
|
|
110
|
+
|
|
111
|
+
client.connect();
|
|
112
|
+
|
|
113
|
+
// The synchronous factory failure must not escape connect(): it is surfaced as
|
|
114
|
+
// a non-fatal error and drives a close, so the client leaves "connecting"
|
|
115
|
+
// (reconnect disabled → idle) instead of wedging with no transport and no signal.
|
|
116
|
+
assert.deepEqual(errors, [failure], "the factory failure surfaced via onError");
|
|
117
|
+
assert.equal(closes.length, 1, "onClose fired once for the failed attempt");
|
|
118
|
+
assert.equal(client.connectionState, "idle", "client did not wedge in 'connecting'");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
test("deregister while the channel is down sends no deregister frame (best-effort only when open)", () => {
|
|
123
|
+
const { client, t } = newClient();
|
|
124
|
+
client.connect(); // transport built but never opened
|
|
125
|
+
|
|
126
|
+
// Never fired open: the channel is down. A deregister must not enqueue an
|
|
127
|
+
// unsendable frame that close() would only drop — it is best-effort.
|
|
128
|
+
client.deregister("done");
|
|
129
|
+
|
|
130
|
+
const dereg = t.last().sentFrames.find((f) => f.family === "deregister");
|
|
131
|
+
assert.equal(dereg, undefined, "no deregister frame was sent while disconnected");
|
|
132
|
+
assert.equal(client.connectionState, "closed");
|
|
133
|
+
assert.equal(client.buffered, 0, "close() left nothing buffered");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("close() releases the outbound buffer so a terminal client pins no backlog", () => {
|
|
137
|
+
const { client } = newClient({ capability: { cognition: "high" } });
|
|
138
|
+
client.connect(); // transport built but never opened
|
|
139
|
+
|
|
140
|
+
// Accumulate a backlog during the outage: buffered relay frames.
|
|
141
|
+
for (let i = 0; i < 4; i++) {
|
|
142
|
+
client.relay("stdout", `chunk-${i}`);
|
|
143
|
+
}
|
|
144
|
+
assert.ok(client.buffered > 0, "frames buffered while the channel is down");
|
|
145
|
+
|
|
146
|
+
client.close();
|
|
147
|
+
|
|
148
|
+
// Terminal close must not pin the outage backlog in memory forever — the ring
|
|
149
|
+
// (and the per-stream relay offsets) can never be drained again, so they are
|
|
150
|
+
// released.
|
|
151
|
+
assert.equal(client.buffered, 0, "close() cleared the outbound ring");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("a closed client refuses every outbound-producing call instead of buffering frames that can never drain", async () => {
|
|
155
|
+
const errors: Error[] = [];
|
|
156
|
+
const { client } = newClient({ capability: { cognition: "high" } });
|
|
157
|
+
client.onError((e) => errors.push(e));
|
|
158
|
+
client.connect();
|
|
159
|
+
client.close();
|
|
160
|
+
|
|
161
|
+
// register() fails fast rather than creating a pending promise that never resolves.
|
|
162
|
+
await assert.rejects(client.register({ capability: { cognition: "high" } }), /closed client/);
|
|
163
|
+
|
|
164
|
+
// heartbeat/relay are refused (surfaced via onError) and buffer nothing.
|
|
165
|
+
client.heartbeat();
|
|
166
|
+
client.relay("stdout", "post-close");
|
|
167
|
+
assert.equal(client.buffered, 0, "a closed client buffers no frames");
|
|
168
|
+
assert.ok(
|
|
169
|
+
errors.some((e) => /cannot heartbeat on a closed client/.test(e.message)),
|
|
170
|
+
"heartbeat after close surfaces an error",
|
|
171
|
+
);
|
|
172
|
+
assert.ok(
|
|
173
|
+
errors.some((e) => /cannot relay on a closed client/.test(e.message)),
|
|
174
|
+
"relay after close surfaces an error",
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("relay offset tracks UTF-8 byte length per stream, independently", () => {
|
|
179
|
+
const { client, t } = newClient();
|
|
180
|
+
client.connect();
|
|
181
|
+
t.last().fireOpen();
|
|
182
|
+
client.relay("a", "é"); // 2 bytes UTF-8
|
|
183
|
+
client.relay("b", "x"); // separate stream, offset 0
|
|
184
|
+
client.relay("a", "z"); // offset now 2
|
|
185
|
+
// Astral char (U+1F600) is 4 UTF-8 bytes but 2 UTF-16 code units, so the
|
|
186
|
+
// offset must advance by the UTF-8 byte count (4), never the JS string length
|
|
187
|
+
// (2) — guards the byte-length helper against surrogate-pair miscounting.
|
|
188
|
+
client.relay("a", "😀"); // offset now 3
|
|
189
|
+
client.relay("a", "!"); // offset now 3 + 4 = 7
|
|
190
|
+
const relays = t.last().sentFrames.filter((f) => f.family === "relay");
|
|
191
|
+
assert.deepEqual(relays.map((f) => f.payload), [
|
|
192
|
+
{ stream: "a", offset: 0, chunk: "é" },
|
|
193
|
+
{ stream: "b", offset: 0, chunk: "x" },
|
|
194
|
+
{ stream: "a", offset: 2, chunk: "z" },
|
|
195
|
+
{ stream: "a", offset: 3, chunk: "😀" },
|
|
196
|
+
{ stream: "a", offset: 7, chunk: "!" },
|
|
197
|
+
]);
|
|
198
|
+
client.close();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("a relay dropped by the QoS overflow policy does not advance the stream offset", () => {
|
|
202
|
+
// With a single ring slot, the register buffered while the hub is down (control
|
|
203
|
+
// lane) fills the ring. A relay (bulk) enqueued now is the least-important frame
|
|
204
|
+
// in play, so the overflow policy DROPS the incoming relay rather than evict the
|
|
205
|
+
// higher-priority register. A dropped relay must consume no offset space, or the
|
|
206
|
+
// hub-side resume-from-offset (S5) would see a phantom gap for bytes never sent.
|
|
207
|
+
const { client, t } = newClient({ bufferCapacity: 1 });
|
|
208
|
+
client.connect(); // transport built, hub still down
|
|
209
|
+
client.register({ capability: { cognition: "high" } }).catch(() => {}); // control frame fills the slot; rejected on close()
|
|
210
|
+
assert.equal(client.buffered, 1, "the buffered register occupies the single ring slot");
|
|
211
|
+
|
|
212
|
+
client.relay("s", "dropped"); // bulk into a control-full ring → dropped
|
|
213
|
+
assert.equal(client.buffered, 1, "the dropped relay never entered the ring");
|
|
214
|
+
|
|
215
|
+
t.last().fireOpen(); // hub up: register drains, freeing the ring
|
|
216
|
+
client.relay("s", "sent"); // first relay actually accepted for stream "s"
|
|
217
|
+
const relays = t.last().sentFrames.filter((f) => f.family === "relay");
|
|
218
|
+
assert.deepEqual(
|
|
219
|
+
relays.map((f) => f.payload),
|
|
220
|
+
[{ stream: "s", offset: 0, chunk: "sent" }],
|
|
221
|
+
"offset starts at 0 — the earlier dropped relay consumed no offset space",
|
|
222
|
+
);
|
|
223
|
+
client.close();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("buffers while the hub is down and drains in QoS order on reconnect", () => {
|
|
227
|
+
const { client, t } = newClient({ reconnect: { enabled: false }, capability: { cognition: "high" } });
|
|
228
|
+
client.connect(); // transport built but not yet open
|
|
229
|
+
|
|
230
|
+
// Produce a bulk storm plus a heartbeat while the channel is closed.
|
|
231
|
+
for (let i = 0; i < 5; i++) {
|
|
232
|
+
client.relay("stdout", `chunk-${i}`);
|
|
233
|
+
}
|
|
234
|
+
client.heartbeat();
|
|
235
|
+
assert.equal(client.buffered, 6);
|
|
236
|
+
assert.equal(t.last().sent.length, 0, "nothing sent while closed");
|
|
237
|
+
|
|
238
|
+
// Channel comes up: everything drains.
|
|
239
|
+
t.last().fireOpen();
|
|
240
|
+
assert.equal(client.buffered, 0);
|
|
241
|
+
|
|
242
|
+
const families = t.last().sentFrames.map((f) => f.family);
|
|
243
|
+
// On open the client auto-re-registers (capability set); register + heartbeat
|
|
244
|
+
// (control) drain ahead of the buffered bulk relay storm.
|
|
245
|
+
assert.equal(families[0], "register");
|
|
246
|
+
assert.equal(families[1], "heartbeat");
|
|
247
|
+
assert.deepEqual(families.slice(2), ["relay", "relay", "relay", "relay", "relay"]);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("survives a mid-stream drop: unsent frames stay buffered and drain on the next open", () => {
|
|
251
|
+
const { client, t } = newClient({ reconnect: { enabled: false } });
|
|
252
|
+
client.connect();
|
|
253
|
+
t.last().fireOpen();
|
|
254
|
+
client.relay("stdout", "a");
|
|
255
|
+
assert.equal(client.buffered, 0);
|
|
256
|
+
|
|
257
|
+
// Hub drops; produce more while down.
|
|
258
|
+
t.last().drop();
|
|
259
|
+
assert.equal(client.connected, false);
|
|
260
|
+
client.relay("stdout", "b");
|
|
261
|
+
client.relay("stdout", "c");
|
|
262
|
+
assert.equal(client.buffered, 2);
|
|
263
|
+
|
|
264
|
+
// Reconnect manually (auto-reconnect disabled in this test).
|
|
265
|
+
client.connect();
|
|
266
|
+
t.last().fireOpen();
|
|
267
|
+
assert.equal(client.buffered, 0);
|
|
268
|
+
const chunks = t.last().sentFrames.filter((f) => f.family === "relay").map((f) => f.payload);
|
|
269
|
+
assert.deepEqual(chunks, [
|
|
270
|
+
{ stream: "stdout", offset: 1, chunk: "b" },
|
|
271
|
+
{ stream: "stdout", offset: 2, chunk: "c" },
|
|
272
|
+
]);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("auto-reconnects with the injected scheduler and re-registers", () => {
|
|
276
|
+
const scheduled: Array<() => void> = [];
|
|
277
|
+
const { client, t } = newClient({
|
|
278
|
+
reconnect: { enabled: true, initialDelayMs: 10 },
|
|
279
|
+
capability: { cognition: "high" },
|
|
280
|
+
schedule: (fn) => scheduled.push(fn),
|
|
281
|
+
});
|
|
282
|
+
client.connect();
|
|
283
|
+
t.last().fireOpen();
|
|
284
|
+
const firstTransport = t.last();
|
|
285
|
+
|
|
286
|
+
firstTransport.drop();
|
|
287
|
+
assert.equal(scheduled.length, 1, "a reconnect was scheduled");
|
|
288
|
+
|
|
289
|
+
// Fire the scheduled reconnect: a new transport is built.
|
|
290
|
+
scheduled[0]?.();
|
|
291
|
+
assert.equal(t.transports.length, 2);
|
|
292
|
+
t.last().fireOpen();
|
|
293
|
+
|
|
294
|
+
// The reconnected channel re-announces presence.
|
|
295
|
+
assert.equal(t.last().sentFrames[0]?.family, "register");
|
|
296
|
+
client.close();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("register while the hub is down buffers and resolves once it comes up", async () => {
|
|
300
|
+
const { client, t } = newClient({ reconnect: { enabled: false }, serveTimeoutMs: 1000 });
|
|
301
|
+
client.connect(); // not open
|
|
302
|
+
|
|
303
|
+
const pending = client.register({ capability: { cognition: "high" } });
|
|
304
|
+
assert.ok(client.buffered >= 1, "register frame is buffered while down");
|
|
305
|
+
|
|
306
|
+
t.last().fireOpen(); // drains the buffered register
|
|
307
|
+
t.last().deliver(serveFrame("worker-1", ["ci.gate"]));
|
|
308
|
+
const { serve } = await pending;
|
|
309
|
+
assert.deepEqual(serve, ["ci.gate"]);
|
|
310
|
+
client.close();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("malformed inbound bytes never crash the client — surfaced via onError", () => {
|
|
314
|
+
const { client, t } = newClient();
|
|
315
|
+
client.connect();
|
|
316
|
+
t.last().fireOpen();
|
|
317
|
+
const errors: Error[] = [];
|
|
318
|
+
client.onError((e) => errors.push(e));
|
|
319
|
+
|
|
320
|
+
t.last().deliver(new Uint8Array([0x00, 0x01, 0x02])); // garbage, too short
|
|
321
|
+
t.last().deliver(new Uint8Array()); // empty
|
|
322
|
+
// The client is still alive and usable.
|
|
323
|
+
client.heartbeat();
|
|
324
|
+
assert.ok(errors.length >= 2);
|
|
325
|
+
assert.ok(t.last().sentFrames.some((f) => f.family === "heartbeat"));
|
|
326
|
+
client.close();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("connectAgenticChannel returns an already-connecting client", () => {
|
|
330
|
+
const t = fakeTransportFactory();
|
|
331
|
+
const client = connectAgenticChannel({ url: "ws://test", transport: t.factory, reconnect: { enabled: false } });
|
|
332
|
+
assert.equal(t.transports.length, 1);
|
|
333
|
+
assert.equal(client.connectionState, "connecting");
|
|
334
|
+
client.close();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("close stops the client and rejects an in-flight register", async () => {
|
|
338
|
+
const { client, t } = newClient({ serveTimeoutMs: 0 });
|
|
339
|
+
client.connect();
|
|
340
|
+
t.last().fireOpen();
|
|
341
|
+
const pending = client.register({ capability: { cognition: "high" } });
|
|
342
|
+
client.close();
|
|
343
|
+
await assert.rejects(pending, /client closed/);
|
|
344
|
+
assert.equal(client.connectionState, "closed");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("a superseding register coalesces the buffered REGISTER — only the newest capability drains", async () => {
|
|
348
|
+
const { client, t } = newClient({ reconnect: { enabled: false }, serveTimeoutMs: 1000 });
|
|
349
|
+
client.connect(); // not open — registers buffer
|
|
350
|
+
|
|
351
|
+
const first = client.register({ capability: { cognition: "low" } });
|
|
352
|
+
const second = client.register({ capability: { cognition: "high" } });
|
|
353
|
+
|
|
354
|
+
// The stale REGISTER is dropped from the ring, not left to drain alongside the new one.
|
|
355
|
+
assert.equal(client.buffered, 1, "only one REGISTER is buffered after superseding");
|
|
356
|
+
await assert.rejects(first, /superseded/);
|
|
357
|
+
|
|
358
|
+
t.last().fireOpen();
|
|
359
|
+
const registers = t.last().sentFrames.filter((f) => f.family === "register");
|
|
360
|
+
assert.equal(registers.length, 1, "exactly one REGISTER drains on open");
|
|
361
|
+
assert.deepEqual(registers[0]?.payload, { instance: "worker-1", capability: { cognition: "high" } });
|
|
362
|
+
|
|
363
|
+
t.last().deliver(serveFrame("worker-1", ["ci.gate"]));
|
|
364
|
+
const { serve } = await second;
|
|
365
|
+
assert.deepEqual(serve, ["ci.gate"]);
|
|
366
|
+
client.close();
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("a throw-only transport (no onClose) still drives reconnect instead of wedging", () => {
|
|
370
|
+
const scheduled: Array<() => void> = [];
|
|
371
|
+
const { client, t } = newClient({
|
|
372
|
+
reconnect: { enabled: true, initialDelayMs: 10 },
|
|
373
|
+
schedule: (fn) => scheduled.push(fn),
|
|
374
|
+
});
|
|
375
|
+
client.connect();
|
|
376
|
+
t.last().fireOpen();
|
|
377
|
+
|
|
378
|
+
// The transport now fails every send WITHOUT firing onClose (contract-minimal).
|
|
379
|
+
t.last().throwOnSend = true;
|
|
380
|
+
const errors: Error[] = [];
|
|
381
|
+
client.onError((e) => errors.push(e));
|
|
382
|
+
|
|
383
|
+
client.relay("stdout", "boom"); // enqueue → pump → send throws
|
|
384
|
+
|
|
385
|
+
// The client must not sit "open" with a full buffer waiting for an onClose
|
|
386
|
+
// that never comes: it forces the disconnect and schedules a reconnect.
|
|
387
|
+
assert.equal(client.connected, false, "client left the open state on send failure");
|
|
388
|
+
assert.equal(scheduled.length, 1, "a reconnect was scheduled");
|
|
389
|
+
assert.ok(errors.some((e) => /send fail/.test(e.message)), "the send failure was surfaced");
|
|
390
|
+
assert.equal(client.buffered, 1, "the unsent frame stays buffered for the reconnect drain");
|
|
391
|
+
|
|
392
|
+
// Reconnect and confirm the buffered frame drains on the fresh channel.
|
|
393
|
+
scheduled[0]?.();
|
|
394
|
+
assert.equal(t.transports.length, 2);
|
|
395
|
+
t.last().fireOpen();
|
|
396
|
+
assert.equal(client.buffered, 0, "buffered frame drained after reconnect");
|
|
397
|
+
assert.ok(t.last().sentFrames.some((f) => f.family === "relay"));
|
|
398
|
+
client.close();
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("an invalid outbound register payload rejects the promise fast without buffering", async () => {
|
|
402
|
+
// An empty instance id fails the S0 register contract (bad-instance).
|
|
403
|
+
const { client, t } = newClient({ instance: "", serveTimeoutMs: 0 });
|
|
404
|
+
client.connect();
|
|
405
|
+
t.last().fireOpen();
|
|
406
|
+
|
|
407
|
+
await assert.rejects(
|
|
408
|
+
client.register({ capability: { cognition: "high" } }),
|
|
409
|
+
/register payload failed validation/,
|
|
410
|
+
);
|
|
411
|
+
// The unsendable frame was never buffered, and none was sent.
|
|
412
|
+
assert.equal(client.buffered, 0);
|
|
413
|
+
assert.equal(t.last().sentFrames.filter((f) => f.family === "register").length, 0);
|
|
414
|
+
client.close();
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("an invalid outbound relay payload is dropped with onError, not buffered", () => {
|
|
418
|
+
const { client, t } = newClient({ reconnect: { enabled: false } });
|
|
419
|
+
client.connect(); // not open
|
|
420
|
+
|
|
421
|
+
const errors: Error[] = [];
|
|
422
|
+
client.onError((e) => errors.push(e));
|
|
423
|
+
client.relay("", "data"); // empty stream fails the S0 relay contract
|
|
424
|
+
|
|
425
|
+
assert.equal(client.buffered, 0, "the invalid relay was not buffered");
|
|
426
|
+
assert.ok(errors.some((e) => /relay payload failed validation/.test(e.message)));
|
|
427
|
+
client.close();
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("a rejected relay consumes no offset space (advance-only-on-accept)", () => {
|
|
431
|
+
const { client, t } = newClient({ reconnect: { enabled: false } });
|
|
432
|
+
client.connect();
|
|
433
|
+
t.last().fireOpen();
|
|
434
|
+
|
|
435
|
+
const errors: Error[] = [];
|
|
436
|
+
client.onError((e) => errors.push(e));
|
|
437
|
+
|
|
438
|
+
// An empty stream fails the S0 relay contract, so the frame is rejected at
|
|
439
|
+
// enqueue time. The offset for a real stream must be untouched by it, and a
|
|
440
|
+
// rejected relay must never advance its own (empty-stream) offset bucket.
|
|
441
|
+
client.relay("stdout", "ok"); // accepted → stdout offset advances to 2
|
|
442
|
+
client.relay("", "dropped"); // rejected: empty stream
|
|
443
|
+
client.relay("stdout", "next"); // must resume at offset 2, unaffected by the reject
|
|
444
|
+
|
|
445
|
+
assert.ok(errors.some((e) => /relay payload failed validation/.test(e.message)));
|
|
446
|
+
const relays = t.last().sentFrames.filter((f) => f.family === "relay").map((f) => f.payload);
|
|
447
|
+
assert.deepEqual(
|
|
448
|
+
relays,
|
|
449
|
+
[
|
|
450
|
+
{ stream: "stdout", offset: 0, chunk: "ok" },
|
|
451
|
+
{ stream: "stdout", offset: 2, chunk: "next" },
|
|
452
|
+
],
|
|
453
|
+
"the rejected relay neither advanced nor corrupted any offset",
|
|
454
|
+
);
|
|
455
|
+
client.close();
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("capability set in options starts the auto-heartbeat timer on open, without an explicit register()", () => {
|
|
459
|
+
mock.timers.enable({ apis: ["setInterval"] });
|
|
460
|
+
try {
|
|
461
|
+
const { client, t } = newClient({
|
|
462
|
+
capability: { cognition: "high" },
|
|
463
|
+
heartbeatIntervalMs: 1000,
|
|
464
|
+
});
|
|
465
|
+
client.connect();
|
|
466
|
+
// Open auto-registers (capability was set in options); the documented
|
|
467
|
+
// auto-heartbeat must start here too, even though register() is never called.
|
|
468
|
+
t.last().fireOpen();
|
|
469
|
+
|
|
470
|
+
mock.timers.tick(1000);
|
|
471
|
+
|
|
472
|
+
const families = t.last().sentFrames.map((f) => f.family);
|
|
473
|
+
assert.ok(
|
|
474
|
+
families.includes("heartbeat"),
|
|
475
|
+
"auto-heartbeat fired for an auto-registered client without an explicit register()",
|
|
476
|
+
);
|
|
477
|
+
client.close();
|
|
478
|
+
} finally {
|
|
479
|
+
mock.timers.reset();
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test("close notifies onClose subscribers even when the transport's close is silent/async", () => {
|
|
484
|
+
const { client, t } = newClient();
|
|
485
|
+
client.connect();
|
|
486
|
+
t.last().fireOpen();
|
|
487
|
+
// Model a real WebSocket: close() does not synchronously surface onClose.
|
|
488
|
+
t.last().silentClose = true;
|
|
489
|
+
|
|
490
|
+
const closes: Array<{ local?: boolean }> = [];
|
|
491
|
+
client.onClose((info) => closes.push(info));
|
|
492
|
+
|
|
493
|
+
client.close();
|
|
494
|
+
|
|
495
|
+
assert.equal(closes.length, 1, "onClose fired exactly once on caller-initiated close");
|
|
496
|
+
assert.equal(closes[0]?.local, true, "the close is reported as local (caller-initiated)");
|
|
497
|
+
assert.equal(client.connectionState, "closed");
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test("close emits onClose exactly once even when the transport also fires its own onClose", () => {
|
|
501
|
+
const { client, t } = newClient();
|
|
502
|
+
client.connect();
|
|
503
|
+
t.last().fireOpen();
|
|
504
|
+
// FakeTransport.close() DOES fire onClose synchronously; the client also drives
|
|
505
|
+
// handleClose itself. The idempotency guard must collapse these to one emit.
|
|
506
|
+
const closes: Array<{ local?: boolean }> = [];
|
|
507
|
+
client.onClose((info) => closes.push(info));
|
|
508
|
+
|
|
509
|
+
client.close();
|
|
510
|
+
|
|
511
|
+
assert.equal(closes.length, 1, "exactly one close emitted despite two close signals");
|
|
512
|
+
assert.equal(client.connectionState, "closed");
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test("close notifies onClose even when the client never reached open (caller close while connecting)", () => {
|
|
516
|
+
const { client } = newClient();
|
|
517
|
+
client.connect(); // connecting — never fireOpen
|
|
518
|
+
assert.equal(client.connectionState, "connecting");
|
|
519
|
+
|
|
520
|
+
const closes: Array<{ local?: boolean }> = [];
|
|
521
|
+
client.onClose((info) => closes.push(info));
|
|
522
|
+
|
|
523
|
+
client.close();
|
|
524
|
+
|
|
525
|
+
// A caller-initiated close while still connecting must surface onClose, just
|
|
526
|
+
// like a remote drop while connecting already does — no silent shutdowns.
|
|
527
|
+
assert.equal(closes.length, 1, "onClose fired once even though the channel never opened");
|
|
528
|
+
assert.equal(closes[0]?.local, true, "reported as a local (caller-initiated) close");
|
|
529
|
+
assert.equal(client.connectionState, "closed");
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test("connect() is a no-op after close() — a shut-down client never reopens", () => {
|
|
533
|
+
const { client, t } = newClient();
|
|
534
|
+
client.connect();
|
|
535
|
+
t.last().fireOpen();
|
|
536
|
+
client.close();
|
|
537
|
+
assert.equal(client.connectionState, "closed");
|
|
538
|
+
assert.equal(t.transports.length, 1);
|
|
539
|
+
|
|
540
|
+
client.connect(); // must NOT reopen a terminally-closed client
|
|
541
|
+
|
|
542
|
+
assert.equal(client.connectionState, "closed", "still closed after a post-close connect()");
|
|
543
|
+
assert.equal(t.transports.length, 1, "no new transport was built after close()");
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test("fakeTransportFactory().last() throws a clear error before any transport is created", () => {
|
|
547
|
+
const t = fakeTransportFactory();
|
|
548
|
+
// The type signature promises a FakeTransport; returning undefined here would be
|
|
549
|
+
// a misleading runtime crash downstream, so last() must fail loudly and early.
|
|
550
|
+
assert.throws(() => t.last(), /before any transport was created/);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test("on open, a REGISTER buffered behind other control frames is coalesced to the front", () => {
|
|
554
|
+
const { client, t } = newClient({ reconnect: { enabled: false }, capability: { cognition: "high" } });
|
|
555
|
+
client.connect(); // not open — control frames buffer
|
|
556
|
+
|
|
557
|
+
client.heartbeat(); // control lane, buffered first
|
|
558
|
+
// A register queued while down lands behind the heartbeat in the control lane.
|
|
559
|
+
client.register({ capability: { cognition: "high" } }).catch(() => {});
|
|
560
|
+
assert.equal(client.buffered, 2);
|
|
561
|
+
|
|
562
|
+
t.last().fireOpen();
|
|
563
|
+
|
|
564
|
+
const control = t.last().sentFrames.filter((f) => f.lane === "control").map((f) => f.family);
|
|
565
|
+
assert.equal(control[0], "register", "REGISTER drains ahead of the buffered heartbeat");
|
|
566
|
+
assert.equal(
|
|
567
|
+
control.filter((f) => f === "register").length,
|
|
568
|
+
1,
|
|
569
|
+
"exactly one REGISTER drains (the buffered one was coalesced, not duplicated)",
|
|
570
|
+
);
|
|
571
|
+
client.close();
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
test("a send failure reports a remote (non-local) close even when transport.close() fires its own onClose", () => {
|
|
575
|
+
const { client, t } = newClient({ reconnect: { enabled: false } });
|
|
576
|
+
const closes: TransportCloseInfo[] = [];
|
|
577
|
+
client.onClose((info) => closes.push(info));
|
|
578
|
+
client.connect();
|
|
579
|
+
t.last().fireOpen();
|
|
580
|
+
|
|
581
|
+
// Arm a send failure. When forceReconnect tears the transport down, the
|
|
582
|
+
// FakeTransport's close() synchronously fires its own onClose({ local: true }).
|
|
583
|
+
// The client must still report this send failure as a REMOTE drop, not a
|
|
584
|
+
// caller-initiated (local) close, so onClose subscribers aren't misled.
|
|
585
|
+
t.last().throwOnSend = true;
|
|
586
|
+
client.relay("stdout", "boom"); // pump → send throws → forceReconnect({ local: false })
|
|
587
|
+
|
|
588
|
+
assert.equal(closes.length, 1, "exactly one close is reported");
|
|
589
|
+
assert.equal(closes[0]?.local, false, "a send failure is a remote drop, not a local close");
|
|
590
|
+
client.close();
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("auto-heartbeats coalesce while down so a long outage can't shed buffered relay", () => {
|
|
594
|
+
// A bounded ring: control-lane heartbeats are never evicted, so without
|
|
595
|
+
// coalescing an outage's worth of heartbeat ticks would pile up and shed the
|
|
596
|
+
// buffered bulk relay (worker output) via the QoS overflow policy.
|
|
597
|
+
const { client, t } = newClient({
|
|
598
|
+
reconnect: { enabled: false },
|
|
599
|
+
capability: { cognition: "high" },
|
|
600
|
+
bufferCapacity: 3,
|
|
601
|
+
});
|
|
602
|
+
client.connect(); // connecting, not yet open
|
|
603
|
+
|
|
604
|
+
client.relay("stdout", "work"); // one bulk relay buffered
|
|
605
|
+
for (let i = 0; i < 10; i++) {
|
|
606
|
+
client.heartbeat(); // a long outage's worth of heartbeat ticks
|
|
607
|
+
}
|
|
608
|
+
assert.equal(client.buffered, 2, "at most one heartbeat is buffered regardless of tick count");
|
|
609
|
+
|
|
610
|
+
t.last().fireOpen();
|
|
611
|
+
const families = t.last().sentFrames.map((f) => f.family);
|
|
612
|
+
assert.deepEqual(
|
|
613
|
+
families,
|
|
614
|
+
["register", "heartbeat", "relay"],
|
|
615
|
+
"the buffered relay survived and drains after the heartbeats coalesced",
|
|
616
|
+
);
|
|
617
|
+
client.close();
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
test("the default reconnect scheduler unrefs its backoff timer so it can't pin the event loop open", () => {
|
|
621
|
+
// Spy on the shared Timeout prototype's unref so we observe the DEFAULT
|
|
622
|
+
// scheduler (no `schedule` override) unref its backoff timer, just like the
|
|
623
|
+
// serve-timeout and heartbeat timers do.
|
|
624
|
+
const probe = setTimeout(() => {}, 0);
|
|
625
|
+
const timeoutProto = Object.getPrototypeOf(probe);
|
|
626
|
+
clearTimeout(probe);
|
|
627
|
+
const unrefSpy = mock.method(timeoutProto, "unref");
|
|
628
|
+
try {
|
|
629
|
+
const t = fakeTransportFactory();
|
|
630
|
+
const client = new AgenticClient({
|
|
631
|
+
url: "ws://test/agentic",
|
|
632
|
+
instance: "worker-1",
|
|
633
|
+
transport: t.factory,
|
|
634
|
+
reconnect: { enabled: true, initialDelayMs: 10 },
|
|
635
|
+
});
|
|
636
|
+
client.connect();
|
|
637
|
+
t.last().fireOpen();
|
|
638
|
+
|
|
639
|
+
unrefSpy.mock.resetCalls();
|
|
640
|
+
t.last().drop(); // remote drop → default scheduler schedules a reconnect
|
|
641
|
+
assert.ok(unrefSpy.mock.callCount() >= 1, "the default reconnect backoff timer was unref'd");
|
|
642
|
+
client.close();
|
|
643
|
+
} finally {
|
|
644
|
+
unrefSpy.mock.restore();
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
test("close() releases the outbound buffer BEFORE it emits onClose, so subscribers see a self-consistent terminal state", () => {
|
|
649
|
+
const { client } = newClient({ capability: { cognition: "high" } });
|
|
650
|
+
client.connect(); // transport built but never opened
|
|
651
|
+
|
|
652
|
+
// Accumulate an outage backlog: buffered relay frames + per-stream offsets.
|
|
653
|
+
for (let i = 0; i < 4; i++) {
|
|
654
|
+
client.relay("stdout", `chunk-${i}`);
|
|
655
|
+
}
|
|
656
|
+
assert.ok(client.buffered > 0, "frames buffered while the channel is down");
|
|
657
|
+
|
|
658
|
+
// An onClose subscriber must observe the released buffers close() documents,
|
|
659
|
+
// not a stale non-zero backlog. Capture what `buffered` reads at emit time.
|
|
660
|
+
let bufferedAtClose = -1;
|
|
661
|
+
client.onClose(() => {
|
|
662
|
+
bufferedAtClose = client.buffered;
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
client.close();
|
|
666
|
+
|
|
667
|
+
assert.equal(bufferedAtClose, 0, "onClose observed a released, self-consistent outbound buffer");
|
|
668
|
+
assert.equal(client.buffered, 0, "close() cleared the outbound ring");
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
test("close() releases the outbound buffer BEFORE the transport's synchronous onClose can surface it", () => {
|
|
672
|
+
// A transport seam is injectable and may legally fire onClose *synchronously*
|
|
673
|
+
// from close() (as FakeTransport does when open). This one always does so,
|
|
674
|
+
// even while the client still holds a backlog — the exact window round 11's
|
|
675
|
+
// handleClose reorder did NOT cover, because that path runs AFTER
|
|
676
|
+
// transport.close(). An onClose subscriber must still observe the released,
|
|
677
|
+
// self-consistent terminal state, not a stale non-zero backlog.
|
|
678
|
+
let hooks: TransportHooks | undefined;
|
|
679
|
+
const syncCloseTransport: TransportFactory = (_url, h) => {
|
|
680
|
+
hooks = h;
|
|
681
|
+
return {
|
|
682
|
+
send() {
|
|
683
|
+
throw new Error("never open: force the client to buffer");
|
|
684
|
+
},
|
|
685
|
+
close() {
|
|
686
|
+
// Fire onClose synchronously from within close(), like an open WebSocket
|
|
687
|
+
// that resolves its close on the same tick would be free to do.
|
|
688
|
+
h.onClose({ local: true });
|
|
689
|
+
},
|
|
690
|
+
};
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
const client = new AgenticClient({
|
|
694
|
+
url: "ws://test/agentic",
|
|
695
|
+
instance: "worker-1",
|
|
696
|
+
transport: syncCloseTransport,
|
|
697
|
+
reconnect: { enabled: false },
|
|
698
|
+
serveTimeoutMs: 0,
|
|
699
|
+
capability: { cognition: "high" },
|
|
700
|
+
});
|
|
701
|
+
client.connect(); // builds the transport; it never opens, so frames buffer
|
|
702
|
+
assert.ok(hooks !== undefined, "transport factory was invoked on connect()");
|
|
703
|
+
|
|
704
|
+
for (let i = 0; i < 4; i++) {
|
|
705
|
+
client.relay("stdout", `chunk-${i}`);
|
|
706
|
+
}
|
|
707
|
+
assert.ok(client.buffered > 0, "frames buffered while the channel is down");
|
|
708
|
+
|
|
709
|
+
let bufferedAtClose = -1;
|
|
710
|
+
client.onClose(() => {
|
|
711
|
+
if (bufferedAtClose === -1) {
|
|
712
|
+
bufferedAtClose = client.buffered; // capture the FIRST close the subscriber sees
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
client.close(); // transport.close() fires onClose synchronously, before handleClose
|
|
717
|
+
|
|
718
|
+
assert.equal(
|
|
719
|
+
bufferedAtClose,
|
|
720
|
+
0,
|
|
721
|
+
"the transport's synchronous onClose observed a released outbound buffer",
|
|
722
|
+
);
|
|
723
|
+
assert.equal(client.buffered, 0, "close() cleared the outbound ring");
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test("construction rejects timing/backoff options Node would coerce into a 0ms hot loop", () => {
|
|
727
|
+
// Node's setTimeout/setInterval treat a negative or NaN delay as 0, which would
|
|
728
|
+
// turn a misconfigured heartbeat or reconnect backoff into an event-loop-saturating
|
|
729
|
+
// tight loop. The client must fail fast at construction — the same fail-fast
|
|
730
|
+
// contract OutboundRing enforces on capacity — rather than degrade silently.
|
|
731
|
+
const base = { url: "ws://test/agentic", transport: fakeTransportFactory().factory };
|
|
732
|
+
|
|
733
|
+
assert.throws(() => new AgenticClient({ ...base, heartbeatIntervalMs: -1 }), RangeError);
|
|
734
|
+
assert.throws(() => new AgenticClient({ ...base, heartbeatIntervalMs: Number.NaN }), RangeError);
|
|
735
|
+
assert.throws(() => new AgenticClient({ ...base, serveTimeoutMs: -5 }), RangeError);
|
|
736
|
+
assert.throws(() => new AgenticClient({ ...base, serveTimeoutMs: Number.NaN }), RangeError);
|
|
737
|
+
assert.throws(() => new AgenticClient({ ...base, reconnect: { initialDelayMs: -1 } }), RangeError);
|
|
738
|
+
assert.throws(() => new AgenticClient({ ...base, reconnect: { initialDelayMs: Number.NaN } }), RangeError);
|
|
739
|
+
assert.throws(() => new AgenticClient({ ...base, reconnect: { maxDelayMs: -1 } }), RangeError);
|
|
740
|
+
assert.throws(() => new AgenticClient({ ...base, reconnect: { maxDelayMs: Number.POSITIVE_INFINITY } }), RangeError);
|
|
741
|
+
// A backoff factor < 1 shrinks the delay toward 0 on every retry — also a hot loop.
|
|
742
|
+
assert.throws(() => new AgenticClient({ ...base, reconnect: { factor: 0.5 } }), RangeError);
|
|
743
|
+
assert.throws(() => new AgenticClient({ ...base, reconnect: { factor: Number.NaN } }), RangeError);
|
|
744
|
+
// Reconnect delays have no "disabled" sentinel (enabled:false disables reconnect),
|
|
745
|
+
// so 0ms is only ever a hot loop: 0 * factor stays 0, and a 0ms maxDelayMs clamps
|
|
746
|
+
// every backoff back to 0. Both must be rejected (>= 1), unlike heartbeat/serveTimeout.
|
|
747
|
+
assert.throws(() => new AgenticClient({ ...base, reconnect: { initialDelayMs: 0 } }), RangeError);
|
|
748
|
+
assert.throws(() => new AgenticClient({ ...base, reconnect: { maxDelayMs: 0 } }), RangeError);
|
|
749
|
+
|
|
750
|
+
// The disabling sentinels stay legal: heartbeat 0 (off) and serveTimeout 0 (no timeout).
|
|
751
|
+
assert.doesNotThrow(() => new AgenticClient({ ...base, heartbeatIntervalMs: 0, serveTimeoutMs: 0 }));
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
test("a throwing onError subscriber can't crash internal error handling or starve siblings", () => {
|
|
755
|
+
// emitError runs inside internal error handling (e.g. a malformed inbound
|
|
756
|
+
// frame). A subscriber that throws there must be contained: it must neither
|
|
757
|
+
// propagate out of the handler (which could take the worker down) nor stop
|
|
758
|
+
// sibling subscribers from receiving the error.
|
|
759
|
+
const { client, t } = newClient();
|
|
760
|
+
client.connect();
|
|
761
|
+
t.last().fireOpen();
|
|
762
|
+
|
|
763
|
+
const seen: Error[] = [];
|
|
764
|
+
client.onError(() => {
|
|
765
|
+
throw new Error("subscriber blew up");
|
|
766
|
+
});
|
|
767
|
+
client.onError((e) => seen.push(e));
|
|
768
|
+
|
|
769
|
+
// Deliver garbage: decode fails and the client calls emitError internally.
|
|
770
|
+
assert.doesNotThrow(() => t.last().deliver(new Uint8Array([0x00, 0x01, 0x02])));
|
|
771
|
+
|
|
772
|
+
// The well-behaved sibling still received the decode error despite the
|
|
773
|
+
// earlier subscriber throwing, and the client is still usable afterwards.
|
|
774
|
+
assert.equal(seen.length, 1);
|
|
775
|
+
client.heartbeat();
|
|
776
|
+
assert.ok(t.last().sentFrames.some((f) => f.family === "heartbeat"));
|
|
777
|
+
client.close();
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
test("a throwing non-error subscriber is contained and doesn't starve siblings", () => {
|
|
781
|
+
// The containment contract is uniform across every emit* fan-out, not just
|
|
782
|
+
// onError: one bad frame subscriber must not break dispatch to the rest.
|
|
783
|
+
const { client, t } = newClient({ serveTimeoutMs: 1000 });
|
|
784
|
+
client.connect();
|
|
785
|
+
t.last().fireOpen();
|
|
786
|
+
|
|
787
|
+
const frames: Frame[] = [];
|
|
788
|
+
client.onFrame(() => {
|
|
789
|
+
throw new Error("frame subscriber blew up");
|
|
790
|
+
});
|
|
791
|
+
client.onFrame((f) => frames.push(f));
|
|
792
|
+
|
|
793
|
+
assert.doesNotThrow(() => t.last().deliver(serveFrame("worker-1", ["planning.spar"])));
|
|
794
|
+
assert.equal(frames.length, 1);
|
|
795
|
+
client.close();
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
test("close() is terminal even when a prior send-failure already consumed the close guard", () => {
|
|
799
|
+
// A send failure routes through forceReconnect({ local: false }) → handleClose,
|
|
800
|
+
// which sets closeHandled = true and (with reconnect enabled) leaves the client
|
|
801
|
+
// in "connecting" while a reconnect is scheduled. If the caller then calls
|
|
802
|
+
// close() during that window, handleClose early-returns on the closeHandled
|
|
803
|
+
// guard — so close() must enforce the terminal "closed" state itself. Otherwise
|
|
804
|
+
// isClosed stays false, post-close calls could buffer frames again, and (since
|
|
805
|
+
// closedByCaller is now set) the scheduled reconnect skips openTransport, wedging
|
|
806
|
+
// the client in "connecting" forever.
|
|
807
|
+
const scheduled: Array<() => void> = [];
|
|
808
|
+
const { client, t } = newClient({
|
|
809
|
+
reconnect: { enabled: true, initialDelayMs: 10 },
|
|
810
|
+
schedule: (fn) => scheduled.push(fn),
|
|
811
|
+
});
|
|
812
|
+
client.connect();
|
|
813
|
+
t.last().fireOpen();
|
|
814
|
+
|
|
815
|
+
// Arm and trigger a send failure: forceReconnect drives handleClose, which
|
|
816
|
+
// consumes the closeHandled guard and schedules a reconnect (captured, unfired).
|
|
817
|
+
t.last().throwOnSend = true;
|
|
818
|
+
client.relay("stdout", "boom");
|
|
819
|
+
assert.equal(client.connectionState, "connecting", "send failure left the client reconnecting");
|
|
820
|
+
assert.equal(scheduled.length, 1, "a reconnect was scheduled");
|
|
821
|
+
|
|
822
|
+
// Now the caller closes during the reconnect window.
|
|
823
|
+
client.close();
|
|
824
|
+
assert.equal(client.connectionState, "closed", "close() enforces the terminal state");
|
|
825
|
+
|
|
826
|
+
// Firing the previously-scheduled reconnect must not resurrect the closed client.
|
|
827
|
+
scheduled[0]?.();
|
|
828
|
+
assert.equal(client.connectionState, "closed", "a scheduled reconnect can't reopen a closed client");
|
|
829
|
+
});
|