@robota-sdk/agent-transport 3.0.0-beta.78 → 3.0.0-beta.81
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 +84 -26
- package/dist/browser/client.d.ts +2 -0
- package/dist/browser/client.js +1 -0
- package/dist/browser/index.d.ts +520 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +2 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/message-decoders-BB8n-H2X.d.ts +252 -0
- package/dist/browser/message-decoders-BB8n-H2X.d.ts.map +1 -0
- package/dist/browser/message-decoders-CDmDoZPR.js +2 -0
- package/dist/browser/message-decoders-CDmDoZPR.js.map +1 -0
- package/dist/node/index.cjs +1 -1
- package/dist/node/index.d.cts +766 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +765 -27
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +1 -1
- package/dist/node/index.js.map +1 -1
- package/dist/node/node.cjs +1 -0
- package/dist/node/node.d.cts +109 -0
- package/dist/node/node.d.cts.map +1 -0
- package/dist/node/node.d.ts +109 -0
- package/dist/node/node.d.ts.map +1 -0
- package/dist/node/node.js +2 -0
- package/dist/node/node.js.map +1 -0
- package/package.json +68 -47
- package/src/__tests__/access-token-verifier.test.ts +672 -0
- package/src/__tests__/admission.test.ts +130 -0
- package/src/__tests__/channel-frames.test.ts +214 -0
- package/src/__tests__/driver-attribution-capability.test.ts +83 -0
- package/src/__tests__/handoff-chunking.test.ts +215 -0
- package/src/__tests__/handoff-manifest.test.ts +52 -0
- package/src/__tests__/message-decoders.test.ts +249 -0
- package/src/__tests__/outbound-delivery.test.ts +558 -0
- package/src/__tests__/peer-message-ledger.test.ts +200 -0
- package/src/__tests__/personal-usage-report.test.ts +193 -0
- package/src/__tests__/resume-buffer.test.ts +93 -0
- package/src/__tests__/session-event-delivery.test.ts +101 -0
- package/src/__tests__/session-message-handler-e5.test.ts +99 -0
- package/src/__tests__/session-message-handler.test.ts +590 -0
- package/src/__tests__/session-resume-bridge.test.ts +406 -0
- package/src/__tests__/submit-prompt-shape.test.ts +78 -0
- package/src/__tests__/usage-report-carrier.test.ts +98 -0
- package/src/__tests__/ws-broadcast-events.test.ts +84 -0
- package/src/background-messages.ts +169 -0
- package/src/channel-frames.ts +182 -0
- package/src/client.ts +9 -0
- package/src/handoff-chunking.ts +235 -0
- package/src/index.ts +112 -3
- package/src/message-decoders.ts +229 -0
- package/src/message-parser.ts +33 -0
- package/src/node/access-token-verifier.ts +363 -0
- package/src/node/admission.ts +153 -0
- package/src/node/handoff-manifest.ts +77 -0
- package/src/node/index.ts +10 -0
- package/src/outbound-delivery.ts +231 -0
- package/src/peer-message-ledger.ts +193 -0
- package/src/protocol-session.ts +28 -0
- package/src/resume-buffer.ts +123 -0
- package/src/session-events.ts +198 -0
- package/src/session-message-handler.ts +282 -0
- package/src/session-query-messages.ts +42 -0
- package/src/session-resume-bridge.ts +252 -0
- package/src/usage-messages.ts +195 -0
- package/src/wire-messages.ts +165 -0
- package/dist/node/headless/index.cjs +0 -1
- package/dist/node/headless/index.d.ts +0 -2
- package/dist/node/headless/index.js +0 -1
- package/dist/node/headless-DG_MEbf0.js +0 -19
- package/dist/node/headless-DG_MEbf0.js.map +0 -1
- package/dist/node/headless-xq77JBq0.cjs +0 -18
- package/dist/node/index-CrJ2Lhn3.d.ts +0 -96
- package/dist/node/index-CrJ2Lhn3.d.ts.map +0 -1
- package/dist/node/index-JIvzrtaA.d.ts +0 -52
- package/dist/node/index-JIvzrtaA.d.ts.map +0 -1
- package/dist/node/index-JTD_xtEQ.d.ts +0 -96
- package/dist/node/index-JTD_xtEQ.d.ts.map +0 -1
- package/dist/node/index-y_HBOfzO.d.ts +0 -52
- package/dist/node/index-y_HBOfzO.d.ts.map +0 -1
- package/dist/node/programmatic/index.cjs +0 -1
- package/dist/node/programmatic/index.d.ts +0 -2
- package/dist/node/programmatic/index.js +0 -1
- package/dist/node/programmatic-6wLf68qJ.cjs +0 -1
- package/dist/node/programmatic-70dxIXtS.js +0 -2
- package/dist/node/programmatic-70dxIXtS.js.map +0 -1
- package/dist/node/testing/index.cjs +0 -1
- package/dist/node/testing/index.d.ts +0 -2
- package/dist/node/testing/index.js +0 -1
- package/src/__tests__/programmatic/programmatic-ask-user.test.ts +0 -32
- package/src/__tests__/programmatic/programmatic-driver.test.ts +0 -107
- package/src/__tests__/programmatic/programmatic-flows.test.ts +0 -140
- package/src/__tests__/transport-registry.stop-all.test.ts +0 -53
- package/src/headless/HeadlessInteractionChannel.ts +0 -129
- package/src/headless/__tests__/headless-channel-options.test.ts +0 -106
- package/src/headless/__tests__/headless-goal-runner.test.ts +0 -108
- package/src/headless/__tests__/headless-provider-failure.integration.test.ts +0 -143
- package/src/headless/__tests__/headless-runner-initialization.test.ts +0 -45
- package/src/headless/__tests__/headless-runner.test.ts +0 -508
- package/src/headless/__tests__/headless-skill-activation.integration.test.ts +0 -430
- package/src/headless/__tests__/headless-transport.test.ts +0 -267
- package/src/headless/cli-input.ts +0 -50
- package/src/headless/headless-runner.ts +0 -230
- package/src/headless/headless-stream-json.ts +0 -148
- package/src/headless/headless-transport.ts +0 -43
- package/src/headless/index.ts +0 -8
- package/src/headless/print-terminal.ts +0 -57
- package/src/programmatic/ProgrammaticInteractionChannel.ts +0 -82
- package/src/programmatic/createProgrammaticAgent.ts +0 -71
- package/src/programmatic/index.ts +0 -5
- package/src/testing/index.ts +0 -10
- package/src/transport-registry.ts +0 -103
|
@@ -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
|
+
});
|