@robota-sdk/agent-transport-link 0.0.0-stage → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,616 @@
1
+ /**
2
+ * The owner link over a real loopback socket pair, as a runtime's control endpoint and its two kinds
3
+ * of client use it: the spawner links with its spawner key, learns the binding and certifies the
4
+ * runtime; the owner then links with its handover, unlocks it with a read grant and uses the owner
5
+ * vocabulary. The runtime closes, and says nothing, on a handshake that fails, a request that does
6
+ * not decode, a frame over its bound and a handshake that never finishes.
7
+ */
8
+ import { Buffer } from 'node:buffer';
9
+ import { once } from 'node:events';
10
+ import type { Socket } from 'node:net';
11
+ import { Duplex, PassThrough } from 'node:stream';
12
+
13
+ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
14
+
15
+ import {
16
+ LINK_HELLO_BYTES,
17
+ LINK_PROOF_MAX_BYTES,
18
+ createLinkInitiator,
19
+ createRuntimeIdentity,
20
+ startSpawnerExchange,
21
+ type IOwnerHandover,
22
+ type IRuntimeIdentity,
23
+ type SpawnerKey,
24
+ } from '@robota-sdk/agent-keyring';
25
+ import { MAX_INBOUND_FRAME_BYTES } from '@robota-sdk/agent-transport';
26
+
27
+ import {
28
+ LINK_REQUEST_LINE,
29
+ OwnerLinkClosedError,
30
+ OwnerRequestRefusedError,
31
+ openOwnerLink,
32
+ openSpawnerLink,
33
+ serveRuntimeLink,
34
+ type IAdmittedRuntimeLink,
35
+ type IRuntimeCustodyState,
36
+ type IRuntimeLinkPorts,
37
+ } from '../index.js';
38
+ import { MAX_REQUESTS_IN_FLIGHT } from '../link-bounds.js';
39
+ import {
40
+ decodeRuntimeMessage,
41
+ encodeOwnerRequest,
42
+ encodeRuntimeMessage,
43
+ } from '../owner-vocabulary.js';
44
+ import {
45
+ BINDING,
46
+ connect,
47
+ controlEndpoint,
48
+ frame,
49
+ frameReader,
50
+ handRuntime,
51
+ keyrings,
52
+ listen,
53
+ recordingRuntime,
54
+ until,
55
+ type IListening,
56
+ } from './harness.js';
57
+
58
+ let owner: IOwnerHandover;
59
+ let sealingPublicKey: Uint8Array;
60
+ const servers: IListening[] = [];
61
+
62
+ beforeAll(async () => {
63
+ ({ owner, sealingPublicKey } = await keyrings());
64
+ });
65
+ afterAll(() => owner.dispose());
66
+ afterEach(async () => {
67
+ for (const server of servers.splice(0)) await server.close();
68
+ });
69
+
70
+ interface IStartedRuntime {
71
+ readonly identity: IRuntimeIdentity;
72
+ readonly spawnerKey: SpawnerKey;
73
+ readonly instancePublicKey: Uint8Array;
74
+ readonly runtime: ReturnType<typeof recordingRuntime>;
75
+ readonly port: number;
76
+ }
77
+
78
+ /**
79
+ * A runtime that ran its spawner exchange and serves its control endpoint; `onAdmit` runs inside
80
+ * the runtime's `admit`, where a runtime may already want to send something.
81
+ */
82
+ async function startRuntime(
83
+ options: {
84
+ readonly handshakeTimeoutMs?: number;
85
+ readonly onAdmit?: (link: IAdmittedRuntimeLink) => void;
86
+ } = {},
87
+ ): Promise<IStartedRuntime> {
88
+ const identity = createRuntimeIdentity();
89
+ const exchange = startSpawnerExchange();
90
+ const { spawnerKey, instancePublicKey } = exchange.complete(
91
+ identity.answerSpawnerExchange(exchange.message),
92
+ );
93
+ const runtime = recordingRuntime(identity);
94
+ const admit = (link: IAdmittedRuntimeLink): IRuntimeLinkPorts => {
95
+ const ports = runtime.admit(link);
96
+ options.onAdmit?.(link);
97
+ return ports;
98
+ };
99
+ const server = await listen(controlEndpoint(identity, admit, options));
100
+ servers.push(server);
101
+ return { identity, spawnerKey, instancePublicKey, runtime, port: server.port };
102
+ }
103
+
104
+ /** A runtime its spawner has already certified for the owner; the spawner wipes its key unless told not to. */
105
+ async function startCertifiedRuntime(
106
+ options: { readonly keepSpawnerKey?: boolean } = {},
107
+ ): Promise<IStartedRuntime> {
108
+ const started = await startRuntime();
109
+ const spawner = await openSpawnerLink(
110
+ await connect(started.port),
111
+ started.spawnerKey,
112
+ started.instancePublicKey,
113
+ );
114
+ await spawner.installTrustRoot(owner.certify(spawner.instancePublicKey, spawner.binding));
115
+ spawner.close();
116
+ if (options.keepSpawnerKey !== true) started.spawnerKey.wipe();
117
+ return started;
118
+ }
119
+
120
+ describe('a spawner link', () => {
121
+ it('admits the spawner, which learns the binding and certifies the runtime', async () => {
122
+ const { identity, spawnerKey, instancePublicKey, runtime, port } = await startRuntime();
123
+ const client = await openSpawnerLink(await connect(port), spawnerKey, instancePublicKey);
124
+ expect(client.standing).toBe('spawner');
125
+ expect(client.binding).toEqual(BINDING);
126
+ expect(client.custody).toEqual({ unlocked: false, certified: false });
127
+ expect(runtime.record.links.map((link) => link.standing)).toEqual(['spawner']);
128
+
129
+ await client.installTrustRoot(owner.certify(client.instancePublicKey, client.binding));
130
+ expect(identity.certified).toBe(true);
131
+ expect(runtime.record.calls).toEqual(['install-trust-root']);
132
+
133
+ client.close();
134
+ await client.closed;
135
+ await until(() => runtime.record.closed === 1);
136
+ });
137
+
138
+ it('keeps the link when a listener throws', async () => {
139
+ const { spawnerKey, instancePublicKey, runtime, port } = await startRuntime();
140
+ const client = await openSpawnerLink(await connect(port), spawnerKey, instancePublicKey);
141
+ const received: unknown[] = [];
142
+ client.session.onMessage(() => {
143
+ throw new Error('a listener bug');
144
+ });
145
+ client.session.onMessage((message) => received.push(message));
146
+ for (const delta of ['a', 'b']) {
147
+ runtime.record.links[0]!.sendSession(JSON.stringify({ type: 'text_delta', delta }));
148
+ }
149
+ await until(() => received.length === 2);
150
+ expect(runtime.record.links[0]!.closed).toBe(false);
151
+ client.close();
152
+ });
153
+
154
+ it('carries the session protocol both ways', async () => {
155
+ const { spawnerKey, instancePublicKey, runtime, port } = await startRuntime();
156
+ const client = await openSpawnerLink(await connect(port), spawnerKey, instancePublicKey);
157
+ client.session.send({ type: 'submit', prompt: 'hello' });
158
+ await until(() => runtime.record.sessionData.length === 1);
159
+ expect(JSON.parse(runtime.record.sessionData[0]!)).toEqual({ type: 'submit', prompt: 'hello' });
160
+
161
+ const received: unknown[] = [];
162
+ client.session.onMessage((message) => received.push(message));
163
+ runtime.record.links[0]!.sendSession(JSON.stringify({ type: 'text_delta', delta: 'hi' }));
164
+ await until(() => received.length === 1);
165
+ expect(received).toEqual([{ type: 'text_delta', delta: 'hi' }]);
166
+ client.close();
167
+ });
168
+ });
169
+
170
+ describe('what a runtime sends from inside admit', () => {
171
+ const early = (link: IAdmittedRuntimeLink): void => {
172
+ link.sendSession(JSON.stringify({ type: 'text_delta', delta: 'early' }));
173
+ link.notifyCustody({ unlocked: true, certified: false });
174
+ };
175
+
176
+ it('goes out after the custody notice, in the order it was sent', async () => {
177
+ const { spawnerKey, instancePublicKey, port } = await startRuntime({ onAdmit: early });
178
+ const socket = await connect(port);
179
+ const reader = frameReader(socket);
180
+ const initiator = createLinkInitiator({ mode: 'spawner', spawnerKey, instancePublicKey });
181
+ socket.write(Buffer.concat([Buffer.from(LINK_REQUEST_LINE), frame(initiator.hello())]));
182
+ const { proof, link } = initiator.answer((await reader.next())!);
183
+ socket.write(frame(proof));
184
+
185
+ const opened = [];
186
+ for (let index = 0; index < 3; index += 1) opened.push(link.open((await reader.next())!));
187
+ expect(opened.map((frame) => frame.kind)).toEqual(['owner', 'session', 'owner']);
188
+ expect(decodeRuntimeMessage(opened[0]!.plaintext)).toEqual({
189
+ op: 'custody',
190
+ state: { unlocked: false, certified: false },
191
+ });
192
+ expect(JSON.parse(opened[1]!.plaintext.toString('utf8'))).toEqual({
193
+ type: 'text_delta',
194
+ delta: 'early',
195
+ });
196
+ expect(decodeRuntimeMessage(opened[2]!.plaintext)).toEqual({
197
+ op: 'custody',
198
+ state: { unlocked: true, certified: false },
199
+ });
200
+ socket.destroy();
201
+ });
202
+
203
+ it('does not make the client refuse the link it was just admitted to', async () => {
204
+ const { spawnerKey, instancePublicKey, runtime, port } = await startRuntime({ onAdmit: early });
205
+ const client = await openSpawnerLink(await connect(port), spawnerKey, instancePublicKey);
206
+ // Listeners attached as the open resolves hear what was sent behind the notice.
207
+ const received: unknown[] = [];
208
+ client.session.onMessage((message) => received.push(message));
209
+ await until(() => client.custody.unlocked && received.length === 1, 1_000);
210
+ expect(received).toEqual([{ type: 'text_delta', delta: 'early' }]);
211
+ expect(runtime.record.links[0]!.closed).toBe(false);
212
+ client.close();
213
+ });
214
+ });
215
+
216
+ describe('what a runtime sends right behind its custody notice', () => {
217
+ /** A certified runtime, by hand, that writes the notice and a session message in one write. */
218
+ async function behindTheNotice(finish: (socket: Socket, bytes: Buffer) => void) {
219
+ const identity = createRuntimeIdentity();
220
+ const exchange = startSpawnerExchange();
221
+ exchange.complete(identity.answerSpawnerExchange(exchange.message)).spawnerKey.wipe();
222
+ identity.installTrustRoot(owner.certify(identity.instancePublicKey, BINDING), BINDING);
223
+ const server = await listen(
224
+ handRuntime(identity, (socket, link) => {
225
+ const notice = encodeRuntimeMessage({
226
+ op: 'custody',
227
+ state: { unlocked: false, certified: true },
228
+ });
229
+ const message = Buffer.from(JSON.stringify({ type: 'text_delta', delta: 'behind' }));
230
+ finish(
231
+ socket,
232
+ Buffer.concat([frame(link.seal('owner', notice)), frame(link.seal('session', message))]),
233
+ );
234
+ }),
235
+ );
236
+ servers.push(server);
237
+ return server.port;
238
+ }
239
+
240
+ it('reaches listeners attached as the open resolves', async () => {
241
+ const port = await behindTheNotice((socket, bytes) => socket.write(bytes));
242
+ const client = await openOwnerLink(await connect(port), owner);
243
+ const received: unknown[] = [];
244
+ client.session.onMessage((message) => received.push(message));
245
+ await until(() => received.length === 1, 1_000);
246
+ expect(received).toEqual([{ type: 'text_delta', delta: 'behind' }]);
247
+ client.close();
248
+ });
249
+
250
+ it('reaches them though the runtime closed the stream right behind it', async () => {
251
+ const port = await behindTheNotice((socket, bytes) => socket.end(bytes));
252
+ const client = await openOwnerLink(await connect(port), owner);
253
+ const received: unknown[] = [];
254
+ client.session.onMessage((message) => received.push(message));
255
+ await until(() => received.length === 1, 1_000);
256
+ expect(received).toEqual([{ type: 'text_delta', delta: 'behind' }]);
257
+ await client.closed;
258
+ });
259
+ });
260
+
261
+ /** A text-delta message from the runtime, serialized, whose `delta` is `text`. */
262
+ const textDelta = (text: string): string => JSON.stringify({ type: 'text_delta', delta: text });
263
+ /** A submit from a client, serialized, whose `prompt` is `text`. */
264
+ const submit = (text: string): string => JSON.stringify({ type: 'submit', prompt: text });
265
+ /** `text` repeated so that `make(text)` is exactly `bytes` UTF-8 bytes long. */
266
+ function sized(make: (text: string) => string, bytes: number): string {
267
+ return make('x'.repeat(bytes - Buffer.byteLength(make(''))));
268
+ }
269
+
270
+ describe('a session message at and over the bound the receiving decoder applies', () => {
271
+ const over = (make: (text: string) => string): string[] => [
272
+ sized(make, MAX_INBOUND_FRAME_BYTES + 1),
273
+ // Under the decoder's limit in characters, over it in bytes: the local attach path refuses it too.
274
+ make('\u20ac'.repeat(Math.ceil(MAX_INBOUND_FRAME_BYTES / 3))),
275
+ ];
276
+
277
+ it('from the runtime: an over-bound send throws, the link stays open, and the next message arrives', async () => {
278
+ const { spawnerKey, instancePublicKey, runtime, port } = await startRuntime();
279
+ const client = await openSpawnerLink(await connect(port), spawnerKey, instancePublicKey);
280
+ const received: unknown[] = [];
281
+ client.session.onMessage((message) => received.push(message));
282
+ const link = runtime.record.links[0]!;
283
+
284
+ link.sendSession(sized(textDelta, MAX_INBOUND_FRAME_BYTES));
285
+ for (const data of over(textDelta)) expect(() => link.sendSession(data)).toThrow(RangeError);
286
+ link.sendSession(textDelta('after'));
287
+
288
+ await until(() => received.length === 2);
289
+ expect(received.at(-1)).toEqual({ type: 'text_delta', delta: 'after' });
290
+ expect(link.closed).toBe(false);
291
+ client.close();
292
+ });
293
+
294
+ it('from the client: an over-bound send throws, the link stays open, and the next message arrives', async () => {
295
+ const { spawnerKey, instancePublicKey, runtime, port } = await startRuntime();
296
+ const client = await openSpawnerLink(await connect(port), spawnerKey, instancePublicKey);
297
+
298
+ client.session.send(JSON.parse(sized(submit, MAX_INBOUND_FRAME_BYTES)));
299
+ for (const data of over(submit)) {
300
+ expect(() => client.session.send(JSON.parse(data))).toThrow(RangeError);
301
+ }
302
+ client.session.send({ type: 'submit', prompt: 'after' });
303
+
304
+ await until(() => runtime.record.sessionData.length === 2);
305
+ expect(JSON.parse(runtime.record.sessionData.at(-1)!)).toEqual({
306
+ type: 'submit',
307
+ prompt: 'after',
308
+ });
309
+ expect(runtime.record.links[0]!.closed).toBe(false);
310
+ client.close();
311
+ });
312
+ });
313
+
314
+ describe('an owner link', () => {
315
+ it('admits the owner of a certified runtime and unlocks it with a read grant', async () => {
316
+ const { runtime, port } = await startCertifiedRuntime();
317
+ const client = await openOwnerLink(await connect(port), owner);
318
+ expect(client.standing).toBe('owner');
319
+ expect(client.custody).toEqual({ unlocked: false, certified: true });
320
+
321
+ expect(await client.unlock()).toBe('verified');
322
+ expect(Buffer.from(runtime.record.grantedKeys[0]!.sealing.publicKey)).toEqual(
323
+ Buffer.from(sealingPublicKey),
324
+ );
325
+ expect('attach' in runtime.record.grantedKeys[0]!).toBe(false);
326
+ client.close();
327
+ });
328
+
329
+ it('asks and answers the owner vocabulary, and hears custody notices', async () => {
330
+ const { runtime, port } = await startCertifiedRuntime();
331
+ const client = await openOwnerLink(await connect(port), owner);
332
+ await client.lock('screen-lock');
333
+ expect(await client.question('sess_1')).toEqual({
334
+ kind: 'accept',
335
+ digest: 'd'.repeat(64),
336
+ notice: 'This session was not verified as yours.',
337
+ question: 'Resume sess_1 and mark it as yours?',
338
+ });
339
+ await client.openAccepted('sess_1', 'd'.repeat(64));
340
+ expect(await client.control({ command: 'rename', fields: { name: 'Mine' } })).toEqual({
341
+ status: 'renamed',
342
+ name: 'Mine',
343
+ });
344
+ expect(runtime.record.calls.slice(-3)).toEqual([
345
+ ['lock', 'screen-lock'],
346
+ ['open-accepted', 'sess_1', 'd'.repeat(64)],
347
+ ['control', 'rename'],
348
+ ]);
349
+
350
+ const notices: IRuntimeCustodyState[] = [];
351
+ client.onCustody((state) => notices.push(state));
352
+ runtime.record.links.at(-1)!.notifyCustody({ unlocked: true, certified: true });
353
+ await until(() => notices.length === 1);
354
+ expect(client.custody).toEqual({ unlocked: true, certified: true });
355
+ client.close();
356
+ });
357
+
358
+ it('reports a refusal the runtime decided, with its code and message', async () => {
359
+ const { port } = await startCertifiedRuntime();
360
+ const client = await openOwnerLink(await connect(port), owner);
361
+ const refusal = await client
362
+ .installTrustRoot(owner.certify(client.instancePublicKey, client.binding))
363
+ .catch((error: unknown) => error);
364
+ expect(refusal).toBeInstanceOf(OwnerRequestRefusedError);
365
+ expect(refusal).toMatchObject({
366
+ code: 'not-allowed',
367
+ message: 'Only the spawner installs a trust root.',
368
+ });
369
+ // The link survives a refusal.
370
+ await client.lock('explicit');
371
+ client.close();
372
+ });
373
+ });
374
+
375
+ describe('the runtime refuses, and says nothing', () => {
376
+ it('spawner standing once certified, and owner standing before', async () => {
377
+ const certified = await startCertifiedRuntime({ keepSpawnerKey: true });
378
+ await expect(
379
+ openSpawnerLink(
380
+ await connect(certified.port),
381
+ certified.spawnerKey,
382
+ certified.instancePublicKey,
383
+ ),
384
+ ).rejects.toBeInstanceOf(OwnerLinkClosedError);
385
+ expect(certified.runtime.record.links.map((link) => link.standing)).toEqual(['spawner']);
386
+ const uncertified = await startRuntime();
387
+ await expect(openOwnerLink(await connect(uncertified.port), owner)).rejects.toBeInstanceOf(
388
+ OwnerLinkClosedError,
389
+ );
390
+ expect(uncertified.runtime.record.links).toEqual([]);
391
+ });
392
+
393
+ it('a wrong proof: the link closes after the challenge, and nothing is admitted', async () => {
394
+ const { runtime, port } = await startCertifiedRuntime();
395
+ const admittedBefore = runtime.record.links.length;
396
+ const socket = await connect(port);
397
+ const reader = frameReader(socket);
398
+ const initiator = createLinkInitiator({ mode: 'owner', handover: owner });
399
+ socket.write(Buffer.concat([Buffer.from(LINK_REQUEST_LINE), frame(initiator.hello())]));
400
+ expect(await reader.next()).toBeDefined();
401
+ socket.write(frame(Buffer.alloc(64, 1)));
402
+ expect(await reader.next()).toBeUndefined();
403
+ expect(runtime.record.links.length).toBe(admittedBefore);
404
+ });
405
+
406
+ it('a request that does not decode: the link closes before the desk sees it', async () => {
407
+ const { runtime, port } = await startCertifiedRuntime();
408
+ const socket = await connect(port);
409
+ const reader = frameReader(socket);
410
+ const initiator = createLinkInitiator({ mode: 'owner', handover: owner });
411
+ socket.write(Buffer.concat([Buffer.from(LINK_REQUEST_LINE), frame(initiator.hello())]));
412
+ const { proof, link } = initiator.answer((await reader.next())!);
413
+ socket.write(frame(proof));
414
+ expect(link.open((await reader.next())!).kind).toBe('owner');
415
+ const callsBefore = runtime.record.calls.length;
416
+ socket.write(frame(link.seal('owner', Buffer.from('{"op":"unlock","requestId":"r1"}'))));
417
+ expect(await reader.next()).toBeUndefined();
418
+ expect(runtime.record.calls.length).toBe(callsBefore);
419
+ await until(() => runtime.record.closed >= 2);
420
+ });
421
+
422
+ it('a frame announced over its bound, before any of it is read', async () => {
423
+ const { runtime, port } = await startRuntime();
424
+ const socket = await connect(port);
425
+ const reader = frameReader(socket);
426
+ const oversized = Buffer.alloc(4);
427
+ oversized.writeUInt32BE(0xffffffff);
428
+ socket.write(Buffer.concat([Buffer.from(LINK_REQUEST_LINE), oversized]));
429
+ expect(await reader.next()).toBeUndefined();
430
+ expect(reader.received()).toHaveLength(0);
431
+ expect(runtime.record.links).toEqual([]);
432
+ });
433
+
434
+ it('a frame announced over its bound at the hello step, which is the size of a hello', async () => {
435
+ const { runtime, port } = await startRuntime();
436
+ const socket = await connect(port);
437
+ const reader = frameReader(socket);
438
+ const announced = Buffer.alloc(4);
439
+ announced.writeUInt32BE(LINK_HELLO_BYTES + 1);
440
+ socket.write(Buffer.concat([Buffer.from(LINK_REQUEST_LINE), announced]));
441
+ // Closed on the length alone, long before the handshake would time out.
442
+ await until(() => reader.closed(), 2_000);
443
+ expect(reader.received()).toHaveLength(0);
444
+ expect(runtime.record.links).toEqual([]);
445
+ });
446
+
447
+ it('a frame announced over its bound at the proof step, which is the size of a proof', async () => {
448
+ const { spawnerKey, instancePublicKey, runtime, port } = await startRuntime();
449
+ const socket = await connect(port);
450
+ const reader = frameReader(socket);
451
+ const initiator = createLinkInitiator({ mode: 'spawner', spawnerKey, instancePublicKey });
452
+ socket.write(Buffer.concat([Buffer.from(LINK_REQUEST_LINE), frame(initiator.hello())]));
453
+ const challenge = (await reader.next())!;
454
+ const announced = Buffer.alloc(4);
455
+ announced.writeUInt32BE(LINK_PROOF_MAX_BYTES + 1);
456
+ socket.write(announced);
457
+ await until(() => reader.closed(), 2_000);
458
+ expect(reader.received()).toHaveLength(frame(challenge).length);
459
+ expect(runtime.record.links).toEqual([]);
460
+ });
461
+
462
+ it('a handshake that does not finish in time', async () => {
463
+ const { port } = await startRuntime({ handshakeTimeoutMs: 50 });
464
+ const socket = await connect(port);
465
+ const reader = frameReader(socket);
466
+ socket.write(LINK_REQUEST_LINE);
467
+ expect(await reader.next()).toBeUndefined();
468
+ expect(reader.received()).toHaveLength(0);
469
+ });
470
+ });
471
+
472
+ describe('the runtime closes', () => {
473
+ it('a peer that sends more requests than it may have waiting', async () => {
474
+ const { runtime, port } = await startCertifiedRuntime();
475
+ const socket = await connect(port);
476
+ const reader = frameReader(socket);
477
+ const initiator = createLinkInitiator({ mode: 'owner', handover: owner });
478
+ socket.write(Buffer.concat([Buffer.from(LINK_REQUEST_LINE), frame(initiator.hello())]));
479
+ const { proof, link } = initiator.answer((await reader.next())!);
480
+ socket.write(frame(proof));
481
+ expect(link.open((await reader.next())!).kind).toBe('owner');
482
+
483
+ // `held` is never answered, so each of these still waits when the next arrives.
484
+ for (let id = 1; id <= MAX_REQUESTS_IN_FLIGHT + 1; id += 1) {
485
+ const request = { op: 'question', requestId: `r${id}`, sessionId: 'held' } as const;
486
+ socket.write(frame(link.seal('owner', encodeOwnerRequest(request))));
487
+ }
488
+ expect(await reader.next()).toBeUndefined();
489
+ expect(runtime.record.grantedKeys).toEqual([]);
490
+ });
491
+ });
492
+
493
+ describe('the requests a client has waiting at once', () => {
494
+ it('may be as many as the runtime allows; one more is refused locally and the link carries on', async () => {
495
+ const { runtime, port } = await startCertifiedRuntime();
496
+ runtime.record.gateUnlock = true;
497
+ const client = await openOwnerLink(await connect(port), owner);
498
+ const waiting = [
499
+ client.unlock(),
500
+ ...Array.from({ length: MAX_REQUESTS_IN_FLIGHT - 1 }, () => client.question('gated')),
501
+ ];
502
+ await until(() => runtime.record.gates.length === MAX_REQUESTS_IN_FLIGHT);
503
+
504
+ for (const refused of [client.question('gated'), client.unlock()]) {
505
+ await expect(refused).rejects.toBeInstanceOf(OwnerRequestRefusedError);
506
+ await expect(refused).rejects.toMatchObject({ code: 'busy' });
507
+ }
508
+ // Nothing was sent for the refused ones, and the link is still open.
509
+ await new Promise((resolve) => setImmediate(resolve));
510
+ expect(runtime.record.gates).toHaveLength(MAX_REQUESTS_IN_FLIGHT);
511
+ expect(runtime.record.links.at(-1)!.closed).toBe(false);
512
+
513
+ for (const open of runtime.record.gates.splice(0)) open();
514
+ expect(await Promise.all(waiting)).toEqual(['verified', ...Array(63).fill({ kind: 'open' })]);
515
+ runtime.record.gateUnlock = false;
516
+ // A slot is free again.
517
+ expect(await client.question('sess_2')).toMatchObject({ kind: 'accept' });
518
+ client.close();
519
+ });
520
+ });
521
+
522
+ describe('the client', () => {
523
+ it('reports a runtime that admitted it but did not announce its custody as a closed link', async () => {
524
+ const identity = createRuntimeIdentity();
525
+ const exchange = startSpawnerExchange();
526
+ exchange.complete(identity.answerSpawnerExchange(exchange.message)).spawnerKey.wipe();
527
+ identity.installTrustRoot(owner.certify(identity.instancePublicKey, BINDING), BINDING);
528
+ const server = await listen(
529
+ handRuntime(identity, (socket, link) => {
530
+ // A sealed frame that does not open, where the custody notice belongs.
531
+ socket.write(frame(Buffer.concat([Buffer.from([2]), Buffer.alloc(32, 9)])));
532
+ void link;
533
+ }),
534
+ );
535
+ servers.push(server);
536
+ await expect(openOwnerLink(await connect(server.port), owner)).rejects.toBeInstanceOf(
537
+ OwnerLinkClosedError,
538
+ );
539
+ });
540
+
541
+ it('rejects what it was waiting for when the link closes', async () => {
542
+ const { runtime, port } = await startCertifiedRuntime();
543
+ const client = await openOwnerLink(await connect(port), owner);
544
+ const pending = client.question('held');
545
+ runtime.record.links.at(-1)!.close();
546
+ await expect(pending).rejects.toBeInstanceOf(OwnerLinkClosedError);
547
+ await client.closed;
548
+ await expect(client.lock('explicit')).rejects.toBeInstanceOf(OwnerLinkClosedError);
549
+ });
550
+ });
551
+
552
+ /** How an opening ended, or `'never settled'`: an open that waits forever is the failure under test. */
553
+ async function outcomeOf(opening: Promise<unknown>, withinMs = 2_000): Promise<unknown> {
554
+ let timer: ReturnType<typeof setTimeout> | undefined;
555
+ const never = new Promise<string>((resolve) => {
556
+ timer = setTimeout(() => resolve('never settled'), withinMs);
557
+ });
558
+ try {
559
+ return await Promise.race([opening.then(() => 'opened', (error: unknown) => error), never]);
560
+ } finally {
561
+ clearTimeout(timer);
562
+ }
563
+ }
564
+
565
+ /** A stream that takes writes, never answers, and ignores `destroy`. */
566
+ class StuckStream extends Duplex {
567
+ override _read(): void {}
568
+ override _write(_chunk: unknown, _encoding: string, done: () => void): void {
569
+ done();
570
+ }
571
+ override destroy(): this {
572
+ return this;
573
+ }
574
+ }
575
+
576
+ describe('a stream that cannot answer', () => {
577
+ it('rejects an opening on a stream that is already closed, as a closed link', async () => {
578
+ const { spawnerKey, instancePublicKey, port } = await startRuntime();
579
+ const openings = [
580
+ (stream: Socket) =>
581
+ openSpawnerLink(stream, spawnerKey, instancePublicKey, { timeoutMs: 200 }),
582
+ (stream: Socket) => openOwnerLink(stream, owner, { timeoutMs: 200 }),
583
+ ];
584
+ for (const open of openings) {
585
+ const socket = await connect(port);
586
+ const closed = once(socket, 'close');
587
+ socket.destroy();
588
+ await closed;
589
+ expect(await outcomeOf(open(socket))).toBeInstanceOf(OwnerLinkClosedError);
590
+ }
591
+ });
592
+
593
+ it('rejects an opening at its timeout as a closed link, whatever the stream does when destroyed', async () => {
594
+ const { spawnerKey, instancePublicKey } = await startRuntime();
595
+ const opening = openSpawnerLink(new StuckStream(), spawnerKey, instancePublicKey, {
596
+ timeoutMs: 50,
597
+ });
598
+ expect(await outcomeOf(opening)).toBeInstanceOf(OwnerLinkClosedError);
599
+ });
600
+
601
+ it('serves a stream that is already closed by admitting nothing and releasing its handshake timer', async () => {
602
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
603
+ try {
604
+ const stream = new PassThrough();
605
+ stream.destroy();
606
+ await once(stream, 'close');
607
+ const admit = vi.fn();
608
+ serveRuntimeLink(stream, { identity: createRuntimeIdentity(), binding: BINDING, admit });
609
+ await new Promise((resolve) => setImmediate(resolve));
610
+ expect(admit).not.toHaveBeenCalled();
611
+ expect(vi.getTimerCount()).toBe(0);
612
+ } finally {
613
+ vi.useRealTimers();
614
+ }
615
+ });
616
+ });