@robota-sdk/agent-transport 3.0.0-beta.100

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.
Files changed (93) hide show
  1. package/CHANGELOG.md +1137 -0
  2. package/LICENSE +661 -0
  3. package/README.md +121 -0
  4. package/dist/browser/client.d.ts +2 -0
  5. package/dist/browser/client.js +1 -0
  6. package/dist/browser/index.d.ts +624 -0
  7. package/dist/browser/index.d.ts.map +1 -0
  8. package/dist/browser/index.js +2 -0
  9. package/dist/browser/index.js.map +1 -0
  10. package/dist/browser/observer-messages-4fRQZU2D.d.ts +499 -0
  11. package/dist/browser/observer-messages-4fRQZU2D.d.ts.map +1 -0
  12. package/dist/browser/observer-messages-O8jTPEQO.js +2 -0
  13. package/dist/browser/observer-messages-O8jTPEQO.js.map +1 -0
  14. package/dist/node/index.cjs +1 -0
  15. package/dist/node/index.d.cts +1115 -0
  16. package/dist/node/index.d.cts.map +1 -0
  17. package/dist/node/index.d.ts +1115 -0
  18. package/dist/node/index.d.ts.map +1 -0
  19. package/dist/node/index.js +2 -0
  20. package/dist/node/index.js.map +1 -0
  21. package/dist/node/node.cjs +1 -0
  22. package/dist/node/node.d.cts +317 -0
  23. package/dist/node/node.d.cts.map +1 -0
  24. package/dist/node/node.d.ts +317 -0
  25. package/dist/node/node.d.ts.map +1 -0
  26. package/dist/node/node.js +2 -0
  27. package/dist/node/node.js.map +1 -0
  28. package/package.json +122 -0
  29. package/src/__tests__/__fixtures__/korean-text.json +6 -0
  30. package/src/__tests__/access-token-verifier.test.ts +672 -0
  31. package/src/__tests__/admission.test.ts +130 -0
  32. package/src/__tests__/app-tool-messages.test.ts +463 -0
  33. package/src/__tests__/bearer-resource-gate.test.ts +120 -0
  34. package/src/__tests__/channel-frames.test.ts +215 -0
  35. package/src/__tests__/driver-attribution-capability.test.ts +83 -0
  36. package/src/__tests__/file-transfer.test.ts +281 -0
  37. package/src/__tests__/handoff-chunking.test.ts +261 -0
  38. package/src/__tests__/handoff-manifest.test.ts +52 -0
  39. package/src/__tests__/history-attached-budget.test.ts +216 -0
  40. package/src/__tests__/message-decoders.test.ts +870 -0
  41. package/src/__tests__/outbound-delivery.test.ts +558 -0
  42. package/src/__tests__/peer-message-ledger.test.ts +200 -0
  43. package/src/__tests__/permission-summary.test.ts +18 -0
  44. package/src/__tests__/personal-usage-report.test.ts +193 -0
  45. package/src/__tests__/project-read-messages.test.ts +118 -0
  46. package/src/__tests__/resume-buffer.test.ts +93 -0
  47. package/src/__tests__/session-detail-loop-status.test.ts +169 -0
  48. package/src/__tests__/session-directory-messages.test.ts +322 -0
  49. package/src/__tests__/session-error-classification.test.ts +113 -0
  50. package/src/__tests__/session-event-delivery.test.ts +101 -0
  51. package/src/__tests__/session-history-wire.test.ts +364 -0
  52. package/src/__tests__/session-message-handler-e5.test.ts +99 -0
  53. package/src/__tests__/session-message-handler-observe.test.ts +223 -0
  54. package/src/__tests__/session-message-handler.test.ts +726 -0
  55. package/src/__tests__/session-resume-bridge.test.ts +472 -0
  56. package/src/__tests__/settings-messages.test.ts +193 -0
  57. package/src/__tests__/stdio-carrier.test.ts +85 -0
  58. package/src/__tests__/submit-prompt-shape.test.ts +78 -0
  59. package/src/__tests__/usage-report-carrier.test.ts +98 -0
  60. package/src/__tests__/ws-broadcast-events.test.ts +94 -0
  61. package/src/app-tool-messages.ts +140 -0
  62. package/src/background-messages.ts +169 -0
  63. package/src/channel-frames.ts +182 -0
  64. package/src/client.ts +17 -0
  65. package/src/handoff-chunking.ts +275 -0
  66. package/src/index.ts +135 -0
  67. package/src/loop-control-messages.ts +34 -0
  68. package/src/message-decoders.ts +485 -0
  69. package/src/message-parser.ts +33 -0
  70. package/src/node/access-token-verifier.ts +363 -0
  71. package/src/node/admission.ts +153 -0
  72. package/src/node/bearer-resource-gate.ts +331 -0
  73. package/src/node/file-transfer.ts +530 -0
  74. package/src/node/handoff-manifest.ts +77 -0
  75. package/src/node/index.ts +45 -0
  76. package/src/node/stdio-carrier.ts +59 -0
  77. package/src/observer-messages.ts +47 -0
  78. package/src/open-prompts.ts +76 -0
  79. package/src/outbound-delivery.ts +240 -0
  80. package/src/peer-message-ledger.ts +193 -0
  81. package/src/permission-summary.ts +18 -0
  82. package/src/project-read-messages.ts +74 -0
  83. package/src/protocol-session.ts +56 -0
  84. package/src/resume-buffer.ts +123 -0
  85. package/src/session-directory-messages.ts +162 -0
  86. package/src/session-error-classification.ts +91 -0
  87. package/src/session-events.ts +290 -0
  88. package/src/session-message-handler.ts +413 -0
  89. package/src/session-query-messages.ts +160 -0
  90. package/src/session-resume-bridge.ts +275 -0
  91. package/src/settings-messages.ts +103 -0
  92. package/src/usage-messages.ts +195 -0
  93. package/src/wire-messages.ts +436 -0
@@ -0,0 +1,558 @@
1
+ /**
2
+ * ARCH-030 — the connection-scoped outbound delivery boundary.
3
+ *
4
+ * Every case here was RED before the boundary existed. The two escape hatches it closes are different
5
+ * and both had to be covered: a reply resolving from a Promise continuation escaped as an
6
+ * `unhandledRejection`, while a synchronous reply threw straight out of `onMessage` into the carrier's
7
+ * inbound listener. Guarding only the first would have left six families still escaping.
8
+ */
9
+
10
+ import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
11
+ import { describe, expect, it } from 'vitest';
12
+
13
+ import { createOutboundDelivery, createPendingStallClock } from '../outbound-delivery.js';
14
+ import { createSessionMessageHandler } from '../session-message-handler.js';
15
+
16
+ import type { TOutboundDeliver } from '../outbound-delivery.js';
17
+ import type { TServerMessage } from '../wire-messages.js';
18
+ import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
19
+
20
+ interface ICarrierProbe {
21
+ readonly deliver: TOutboundDeliver;
22
+ readonly delivered: TServerMessage[];
23
+ readonly failures: Array<{ message: string; event: TServerMessage['type'] }>;
24
+ disconnect: () => void;
25
+ }
26
+
27
+ /** A carrier whose sink starts throwing the real `WsSessionDelivery` error once disconnected. */
28
+ function createCarrierProbe(): ICarrierProbe {
29
+ const delivered: TServerMessage[] = [];
30
+ const failures: Array<{ message: string; event: TServerMessage['type'] }> = [];
31
+ let open = true;
32
+ const probe: ICarrierProbe = {
33
+ delivered,
34
+ failures,
35
+ disconnect: () => {
36
+ open = false;
37
+ },
38
+ deliver: createOutboundDelivery(
39
+ (message) => {
40
+ if (!open) throw new Error('WebSocket is not open');
41
+ delivered.push(message);
42
+ },
43
+ (error, event) => failures.push({ message: error.message, event }),
44
+ ),
45
+ };
46
+ return probe;
47
+ }
48
+
49
+ /**
50
+ * Collect unhandled rejections for the duration of `run`. Node reports them on a later tick, so the
51
+ * helper drains the microtask/macrotask queue before restoring the previous listeners.
52
+ */
53
+ async function withUnhandledRejectionCapture(run: () => void | Promise<void>): Promise<unknown[]> {
54
+ const captured: unknown[] = [];
55
+ const existing = process.listeners('unhandledRejection');
56
+ for (const listener of existing) process.off('unhandledRejection', listener);
57
+ const collect = (reason: unknown): void => {
58
+ captured.push(reason);
59
+ };
60
+ process.on('unhandledRejection', collect);
61
+ try {
62
+ await run();
63
+ await new Promise((resolve) => setTimeout(resolve, 20));
64
+ } finally {
65
+ process.off('unhandledRejection', collect);
66
+ for (const listener of existing)
67
+ process.on('unhandledRejection', listener as NodeJS.UnhandledRejectionListener);
68
+ }
69
+ return captured;
70
+ }
71
+
72
+ /** A deferred whose resolution the test controls, so "after disconnect" is deterministic. */
73
+ function deferred<T>(): {
74
+ promise: Promise<T>;
75
+ resolve: (value: T) => void;
76
+ reject: (e: Error) => void;
77
+ } {
78
+ let resolve!: (value: T) => void;
79
+ let reject!: (e: Error) => void;
80
+ const promise = new Promise<T>((res, rej) => {
81
+ resolve = res;
82
+ reject = rej;
83
+ });
84
+ return { promise, resolve, reject };
85
+ }
86
+
87
+ describe('createOutboundDelivery', () => {
88
+ it('reports a carrier failure instead of throwing at the caller', () => {
89
+ const probe = createCarrierProbe();
90
+ probe.disconnect();
91
+
92
+ expect(() => probe.deliver({ type: 'history_cleared' })).not.toThrow();
93
+ expect(probe.failures).toEqual([
94
+ { message: 'WebSocket is not open', event: 'history_cleared' },
95
+ ]);
96
+ });
97
+
98
+ it('latches: a connection reports at most one delivery failure', () => {
99
+ const probe = createCarrierProbe();
100
+ probe.disconnect();
101
+
102
+ probe.deliver({ type: 'history_cleared' });
103
+ probe.deliver({ type: 'executing', executing: true });
104
+ probe.deliver({ type: 'protocol_error', message: 'third' });
105
+
106
+ expect(probe.failures).toHaveLength(1);
107
+ // The three carriers each grew their own latch to suppress the repeats; it belongs here instead.
108
+ expect(probe.failures[0]?.event).toBe('history_cleared');
109
+ });
110
+
111
+ it('isolates an error handler that itself throws', () => {
112
+ const deliver = createOutboundDelivery(
113
+ () => {
114
+ throw new Error('carrier down');
115
+ },
116
+ () => {
117
+ throw new Error('observer exploded');
118
+ },
119
+ );
120
+
121
+ // A diagnostic cannot reverse a session operation that has already committed.
122
+ expect(() => deliver({ type: 'history_cleared' })).not.toThrow();
123
+ });
124
+
125
+ it('normalizes a non-Error throw', () => {
126
+ const failures: Error[] = [];
127
+ const deliver = createOutboundDelivery(
128
+ () => {
129
+ // eslint-disable-next-line @typescript-eslint/only-throw-error -- a carrier can throw anything
130
+ throw 'data channel is closing';
131
+ },
132
+ (error) => failures.push(error),
133
+ );
134
+
135
+ deliver({ type: 'history_cleared' });
136
+ expect(failures[0]).toBeInstanceOf(Error);
137
+ expect(failures[0]?.message).toBe('data channel is closing');
138
+ });
139
+
140
+ it('refuses a raw sink where a boundary is required — the mechanical floor', () => {
141
+ const session = createTestInteractiveSession();
142
+ const rawSend = (_message: TServerMessage): void => undefined;
143
+
144
+ createSessionMessageHandler({
145
+ session,
146
+ // @ts-expect-error ARCH-030: only `createOutboundDelivery` produces a `TOutboundDeliver`. If this
147
+ // ever compiles, the brand is gone and a twelfth reply family can reach the wire unguarded.
148
+ deliver: rawSend,
149
+ });
150
+ });
151
+ });
152
+
153
+ describe('reply families that resolve after the carrier disconnected (ARCH-030)', () => {
154
+ /** The five families whose reply lands from a Promise continuation. */
155
+ const asynchronousFamilies: ReadonlyArray<{
156
+ readonly name: string;
157
+ readonly frame: Record<string, unknown>;
158
+ readonly expectedEvent: TServerMessage['type'];
159
+ readonly session: (release: Promise<never> | Promise<unknown>) => Partial<IInteractiveSession>;
160
+ }> = [
161
+ {
162
+ name: 'command result',
163
+ frame: { type: 'command', name: 'status' },
164
+ expectedEvent: 'command_result',
165
+ session: (release) => ({
166
+ executeCommand: () => release.then(() => ({ success: true, message: 'done' })),
167
+ }),
168
+ },
169
+ {
170
+ name: 'submit rejection',
171
+ frame: { type: 'submit', prompt: 'hello' },
172
+ expectedEvent: 'protocol_error',
173
+ session: (release) => ({
174
+ submit: () => release.then(() => Promise.reject(new Error('submit failed'))),
175
+ }),
176
+ },
177
+ {
178
+ name: 'background log read',
179
+ frame: { type: 'read-background-task-log', taskId: 'task_1' },
180
+ expectedEvent: 'background_task_log',
181
+ session: (release) => ({
182
+ readBackgroundTaskLog: () => release.then(() => ({ taskId: 'task_1', lines: [] })),
183
+ }),
184
+ },
185
+ {
186
+ name: 'job-group wait',
187
+ frame: { type: 'wait-background-job-group', groupId: 'group_1' },
188
+ expectedEvent: 'background_job_group',
189
+ session: (release) => ({
190
+ waitBackgroundJobGroup: () =>
191
+ release.then(() => ({
192
+ id: 'group_1',
193
+ parentSessionId: 'session_1',
194
+ waitPolicy: 'wait_all' as const,
195
+ taskIds: [],
196
+ status: 'completed' as const,
197
+ createdAt: '2026-08-16T00:00:00.000Z',
198
+ updatedAt: '2026-08-16T00:00:00.000Z',
199
+ results: [],
200
+ })),
201
+ }),
202
+ },
203
+ {
204
+ name: 'background control result',
205
+ frame: { type: 'cancel-background-task', taskId: 'task_1' },
206
+ expectedEvent: 'background_task_control_result',
207
+ session: (release) => ({
208
+ cancelBackgroundTask: () => release.then(() => undefined),
209
+ }),
210
+ },
211
+ ];
212
+
213
+ for (const family of asynchronousFamilies) {
214
+ it(`reports and does not leak an unhandled rejection — ${family.name}`, async () => {
215
+ const probe = createCarrierProbe();
216
+ const gate = deferred<void>();
217
+ const session = createTestInteractiveSession(
218
+ family.session(gate.promise) as Partial<IInteractiveSession>,
219
+ );
220
+ const handler = createSessionMessageHandler({ session, deliver: probe.deliver });
221
+
222
+ const rejections = await withUnhandledRejectionCapture(async () => {
223
+ handler.onMessage(JSON.stringify(family.frame));
224
+ probe.disconnect();
225
+ gate.resolve();
226
+ });
227
+
228
+ expect(rejections).toEqual([]);
229
+ expect(probe.failures).toEqual([
230
+ { message: 'WebSocket is not open', event: family.expectedEvent },
231
+ ]);
232
+ handler.cleanup();
233
+ });
234
+ }
235
+
236
+ /** The six families whose reply is synchronous — they used to throw out of `onMessage` itself. */
237
+ const synchronousFrames: ReadonlyArray<{
238
+ name: string;
239
+ raw: string;
240
+ event: TServerMessage['type'];
241
+ }> = [
242
+ { name: 'parse error', raw: 'not json at all', event: 'protocol_error' },
243
+ {
244
+ name: 'unknown message type',
245
+ raw: JSON.stringify({ type: 'nonsense' }),
246
+ event: 'protocol_error',
247
+ },
248
+ {
249
+ name: 'submit validation error',
250
+ raw: JSON.stringify({ type: 'submit', prompt: '' }),
251
+ event: 'protocol_error',
252
+ },
253
+ { name: 'session query', raw: JSON.stringify({ type: 'get-executing' }), event: 'executing' },
254
+ {
255
+ name: 'background query snapshot',
256
+ raw: JSON.stringify({ type: 'get-background-tasks' }),
257
+ event: 'background_tasks',
258
+ },
259
+ {
260
+ name: 'background validation error',
261
+ raw: JSON.stringify({ type: 'get-background-task', taskId: '' }),
262
+ event: 'protocol_error',
263
+ },
264
+ ];
265
+
266
+ for (const frame of synchronousFrames) {
267
+ it(`does not throw out of onMessage — ${frame.name}`, () => {
268
+ const probe = createCarrierProbe();
269
+ const handler = createSessionMessageHandler({
270
+ session: createTestInteractiveSession(),
271
+ deliver: probe.deliver,
272
+ });
273
+ probe.disconnect();
274
+
275
+ expect(() => handler.onMessage(frame.raw)).not.toThrow();
276
+ expect(probe.failures).toEqual([{ message: 'WebSocket is not open', event: frame.event }]);
277
+ handler.cleanup();
278
+ });
279
+ }
280
+
281
+ it('leaves the committed session operation successful and drops later frames silently', async () => {
282
+ const probe = createCarrierProbe();
283
+ const gate = deferred<void>();
284
+ const executed: string[] = [];
285
+ const session = createTestInteractiveSession({
286
+ executeCommand: (name: string) =>
287
+ gate.promise.then(() => {
288
+ executed.push(name);
289
+ return { success: true, message: 'done' };
290
+ }),
291
+ } as Partial<IInteractiveSession>);
292
+ const handler = createSessionMessageHandler({ session, deliver: probe.deliver });
293
+
294
+ const rejections = await withUnhandledRejectionCapture(async () => {
295
+ handler.onMessage(JSON.stringify({ type: 'command', name: 'status' }));
296
+ probe.disconnect();
297
+ gate.resolve();
298
+ });
299
+
300
+ expect(rejections).toEqual([]);
301
+ expect(executed).toEqual(['status']); // the command ran; delivery failing did not reverse it
302
+ expect(probe.failures).toHaveLength(1);
303
+
304
+ // The latch, observed from outside: a further inbound frame is dropped, neither reported again
305
+ // nor thrown out of `onMessage`.
306
+ expect(() => handler.onMessage(JSON.stringify({ type: 'get-executing' }))).not.toThrow();
307
+ expect(probe.failures).toHaveLength(1);
308
+ handler.cleanup();
309
+ });
310
+
311
+ it('keeps the session-event fan-out on the same boundary', () => {
312
+ const emit = new Map<string, (payload: unknown) => void>();
313
+ const probe = createCarrierProbe();
314
+ const session = createTestInteractiveSession({
315
+ on: ((event: string, handler: (payload: unknown) => void) => {
316
+ emit.set(event, handler);
317
+ }) as IInteractiveSession['on'],
318
+ off: ((event: string) => {
319
+ emit.delete(event);
320
+ }) as IInteractiveSession['off'],
321
+ });
322
+ createSessionMessageHandler({ session, deliver: probe.deliver });
323
+ probe.disconnect();
324
+
325
+ expect(() => emit.get('history_cleared')?.(undefined)).not.toThrow();
326
+ expect(probe.failures).toEqual([
327
+ { message: 'WebSocket is not open', event: 'history_cleared' },
328
+ ]);
329
+ // …and the fan-out shares the reply path's latch rather than having a second one.
330
+ emit.get('thinking')?.(true);
331
+ expect(probe.failures).toHaveLength(1);
332
+ });
333
+ });
334
+
335
+ /**
336
+ * ARCH-030 / issue #1734 — the boundary can be ASKED about backpressure, and never invents an answer.
337
+ *
338
+ * `deliver` returning is not delivery: both carriers hand this boundary a fire-and-forget sink, so a
339
+ * frame that has "been sent" may be sitting in a socket buffer the boundary cannot see. Before this,
340
+ * a non-reading peer — the contract case the issue names — was not observable at all, and any budget
341
+ * built here would have counted what the boundary HANDED OVER rather than what the peer had not read.
342
+ *
343
+ * These pin the two things that make the number worth having: it comes from the carrier, and
344
+ * "cannot say" is distinguishable from "nothing pending".
345
+ */
346
+ describe('outbound backpressure reporting (ARCH-030 / issue #1734)', () => {
347
+ it('reports the carrier own reading, not a count the boundary kept', () => {
348
+ let carrierPending = 0;
349
+ const deliver = createOutboundDelivery(
350
+ () => {
351
+ // The carrier accepted it and has NOT written it — exactly the state the boundary cannot see
352
+ // by counting its own calls.
353
+ carrierPending += 100;
354
+ },
355
+ () => undefined,
356
+ () => carrierPending,
357
+ );
358
+
359
+ expect(deliver.pendingBytes()).toBe(0);
360
+ deliver({ type: 'protocol_error', message: 'x' } as never);
361
+ deliver({ type: 'protocol_error', message: 'y' } as never);
362
+ expect(deliver.pendingBytes()).toBe(200);
363
+ });
364
+
365
+ it('a peer that stops reading makes the number grow while every deliver still returns', () => {
366
+ let carrierPending = 0;
367
+ const deliver = createOutboundDelivery(
368
+ () => {
369
+ carrierPending += 10;
370
+ },
371
+ () => undefined,
372
+ () => carrierPending,
373
+ );
374
+
375
+ for (let i = 0; i < 50; i += 1) deliver({ type: 'protocol_error', message: 'x' } as never);
376
+
377
+ // Nothing threw and nothing reported a failure — which is the whole point: a non-reading peer is
378
+ // indistinguishable from a healthy one through `deliver` alone.
379
+ expect(deliver.pendingBytes()).toBe(500);
380
+ });
381
+
382
+ it('answers `undefined` — not 0 — when the carrier supplied no reading', () => {
383
+ const deliver = createOutboundDelivery(
384
+ () => undefined,
385
+ () => undefined,
386
+ );
387
+
388
+ // `0` would let "unknown" satisfy any threshold later placed on this number, which is the defect
389
+ // a budget over the wrong quantity would reintroduce.
390
+ expect(deliver.pendingBytes()).toBeUndefined();
391
+ expect(deliver.pendingBytes()).not.toBe(0);
392
+ });
393
+
394
+ it('re-reads at call time, so a value that changes underneath is not captured once', () => {
395
+ let carrierPending = 7;
396
+ const deliver = createOutboundDelivery(
397
+ () => undefined,
398
+ () => undefined,
399
+ () => carrierPending,
400
+ );
401
+
402
+ expect(deliver.pendingBytes()).toBe(7);
403
+ carrierPending = 0; // the peer drained
404
+ expect(deliver.pendingBytes()).toBe(0);
405
+ });
406
+ });
407
+
408
+ describe('ARCH-030: the byte budget the boundary enforces itself', () => {
409
+ it('refuses and closes when the carrier is holding more than the budget', () => {
410
+ // The contract case the reopened scope names: a peer that accepted frames and stopped reading.
411
+ // Detected by what the CARRIER is still holding, not by anything this boundary counted.
412
+ const sent: TServerMessage[] = [];
413
+ const failures: Array<{ error: Error; event: string }> = [];
414
+ let pending = 0;
415
+ const deliver = createOutboundDelivery(
416
+ (message) => sent.push(message),
417
+ (error, event) => failures.push({ error, event }),
418
+ () => pending,
419
+ 100,
420
+ );
421
+
422
+ deliver({ type: 'protocol_error', message: 'under budget' });
423
+ expect(sent).toHaveLength(1);
424
+
425
+ pending = 101;
426
+ deliver({ type: 'protocol_error', message: 'over budget' });
427
+
428
+ expect(sent).toHaveLength(1); // not sent
429
+ expect(failures).toHaveLength(1);
430
+ expect(failures[0]?.error.message).toContain('101 byte(s) pending, limit 100');
431
+ });
432
+
433
+ it('checks the budget BEFORE sending, so the boundary is not the last contributor to the overflow', () => {
434
+ // Checking after would let one more frame onto a carrier already past its limit, and would report
435
+ // an overflow the boundary had just helped cause.
436
+ const sent: TServerMessage[] = [];
437
+ const deliver = createOutboundDelivery(
438
+ (message) => sent.push(message),
439
+ () => {},
440
+ () => 500,
441
+ 100,
442
+ );
443
+
444
+ deliver({ type: 'protocol_error', message: 'first frame, already over' });
445
+ expect(sent).toHaveLength(0);
446
+ });
447
+
448
+ it('applies no budget to a carrier that cannot report backpressure', () => {
449
+ // `undefined` is unknown, not zero. A default of 0 would refuse every frame on such a carrier —
450
+ // turning "cannot measure" into "always over budget".
451
+ const sent: TServerMessage[] = [];
452
+ const deliver = createOutboundDelivery(
453
+ (message) => sent.push(message),
454
+ () => {},
455
+ () => undefined,
456
+ // A limit BELOW zero, deliberately. With `0`, a mutant collapsing `undefined` to `0` still
457
+ // passes — `0 > 0` is false — so the case would assert nothing about the collapse it exists to
458
+ // forbid. Below zero, only "unknown means no budget applies" can explain the frame going out.
459
+ -1,
460
+ );
461
+
462
+ deliver({ type: 'protocol_error', message: 'no reading available' });
463
+ expect(sent).toHaveLength(1);
464
+ });
465
+ });
466
+
467
+ /**
468
+ * Issue #2306: the byte budget bounds how MUCH a peer holds, not how LONG. A peer that reads just
469
+ * slowly enough to stay under 8 MiB forever is never over it. These pin the drain-time budget.
470
+ */
471
+ describe('#2306: the drain-time budget the boundary enforces itself', () => {
472
+ function build(maxPendingMs: number): {
473
+ deliver: TOutboundDeliver;
474
+ sent: TServerMessage[];
475
+ failures: string[];
476
+ setPending: (bytes: number | undefined) => void;
477
+ setNow: (ms: number) => void;
478
+ } {
479
+ const sent: TServerMessage[] = [];
480
+ const failures: string[] = [];
481
+ let pending: number | undefined = 0;
482
+ let now = 0;
483
+ const deliver = createOutboundDelivery(
484
+ (message) => sent.push(message),
485
+ (error) => failures.push(error.message),
486
+ () => pending,
487
+ 10_000_000, // well above every reading below: only the clock can close these
488
+ createPendingStallClock(maxPendingMs, () => now),
489
+ );
490
+ return {
491
+ deliver,
492
+ sent,
493
+ failures,
494
+ setPending: (bytes) => {
495
+ pending = bytes;
496
+ },
497
+ setNow: (ms) => {
498
+ now = ms;
499
+ },
500
+ };
501
+ }
502
+
503
+ it('closes a peer that stays under the byte budget but never drains for longer than the limit', () => {
504
+ // The falsification the issue names: a 1 MiB backlog read at one byte per second stays under any
505
+ // byte budget forever. Only a clock can tell it from a healthy peer.
506
+ const t = build(1_000);
507
+ t.setPending(1_048_576);
508
+ t.deliver({ type: 'protocol_error', message: 'clock starts' });
509
+ t.setNow(500);
510
+ t.setPending(1_048_000); // drained a little — not to zero
511
+ t.deliver({ type: 'protocol_error', message: 'still under' });
512
+ expect(t.sent).toHaveLength(2);
513
+
514
+ t.setNow(1_001);
515
+ t.setPending(1_047_000);
516
+ t.deliver({ type: 'protocol_error', message: 'over the drain budget' });
517
+
518
+ expect(t.sent).toHaveLength(2); // not sent
519
+ expect(t.failures).toHaveLength(1);
520
+ expect(t.failures[0]).toContain('pending for 1001 ms, limit 1000 ms');
521
+ t.deliver({ type: 'protocol_error', message: 'latched' });
522
+ expect(t.failures).toHaveLength(1);
523
+ });
524
+
525
+ it('resets the clock only when the carrier is observed EMPTY, not merely smaller', () => {
526
+ const t = build(1_000);
527
+ t.setPending(10);
528
+ t.deliver({ type: 'protocol_error', message: 'starts' });
529
+ t.setNow(900);
530
+ t.setPending(0); // fully drained — a legitimately slow peer between bursts
531
+ t.deliver({ type: 'protocol_error', message: 'reset' });
532
+ t.setNow(1_800);
533
+ t.setPending(10); // non-empty again: a NEW stall, 0 ms old, not a 1800 ms one
534
+ t.deliver({ type: 'protocol_error', message: 'fresh clock' });
535
+ t.setNow(2_700);
536
+ t.setPending(1);
537
+ t.deliver({ type: 'protocol_error', message: '900 ms into the second stall' });
538
+
539
+ expect(t.sent).toHaveLength(4);
540
+ expect(t.failures).toHaveLength(0);
541
+ });
542
+
543
+ it('an unknown reading neither starts nor resets the clock', () => {
544
+ const t = build(1_000);
545
+ t.setPending(undefined);
546
+ t.deliver({ type: 'protocol_error', message: 'unknown' });
547
+ t.setNow(5_000);
548
+ t.deliver({ type: 'protocol_error', message: 'still unknown, still fine' });
549
+ expect(t.failures).toHaveLength(0);
550
+
551
+ t.setPending(5); // the clock starts HERE, at t=5000, not at t=0
552
+ t.deliver({ type: 'protocol_error', message: 'starts' });
553
+ t.setNow(5_500);
554
+ t.deliver({ type: 'protocol_error', message: '500 ms in' });
555
+ expect(t.sent).toHaveLength(4);
556
+ expect(t.failures).toHaveLength(0);
557
+ });
558
+ });