@termwright/conformance 0.2.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.
@@ -0,0 +1,567 @@
1
+ /**
2
+ * Adversarial peer — origin spec §20.3.
3
+ *
4
+ * A raw socket client that speaks the wire protocol by hand: it imports
5
+ * `node:net` and `node:crypto` and **nothing from termwright**. That is the
6
+ * point. If this fixture used `encodeFrame`/`encodeMarker` it could only ever
7
+ * produce traffic the implementation already believes in; re-deriving the
8
+ * framing and the marker MAC from the specification is what makes a mismatch
9
+ * between spec and implementation visible.
10
+ *
11
+ * Protocol of the fixture itself, so suites stay deterministic:
12
+ *
13
+ * 1. it connects, completes a normal handshake and publishes a valid
14
+ * revision 1 (tree + marker), then prints `PEER READY <scenario>`;
15
+ * 2. on the key `g` it performs the hostile act and prints `PEER FIRED`;
16
+ * 3. on `q` it exits 0.
17
+ *
18
+ * Scenarios that attack the handshake itself (`bad-token`, `bad-version`,
19
+ * `no-hello`) skip step 1 and fire on connect.
20
+ *
21
+ * Usage: `node adversarial-peer.mjs <scenario>`.
22
+ */
23
+
24
+ import { connect } from 'node:net';
25
+ import { createHmac } from 'node:crypto';
26
+
27
+ const scenario = process.argv[2] ?? 'none';
28
+ /**
29
+ * Delays the hello, so a suite can place the handshake inside or outside the
30
+ * driver's late-attach grace. A child that boots slower than the negotiation
31
+ * window is routine; one that boots slower than the grace is not, and the two
32
+ * must land differently.
33
+ */
34
+ const delayArg = process.argv.find((argument) => argument.startsWith('--hello-delay='));
35
+ const helloDelayMs = delayArg === undefined ? 0 : Number(delayArg.slice('--hello-delay='.length));
36
+ /**
37
+ * Holds every socket write back by a fixed lag, so the pty stream overtakes it.
38
+ *
39
+ * A unix socket hands the driver everything this peer wrote long before the pty
40
+ * has re-encoded a single byte, which hides any test that waits for a line of
41
+ * output and then reads state only a socket frame can carry. Where the socket
42
+ * is slower than the terminal — Windows named pipes, which neither coalesce
43
+ * writes nor deliver them on the pty's schedule — that test asserts on
44
+ * something still in flight. The flag makes the same ordering reproducible on
45
+ * any platform. The lag is constant rather than per-write, so a flood of two
46
+ * hundred revisions still finishes inside the suite's budget.
47
+ */
48
+ const lagArg = process.argv.find((argument) => argument.startsWith('--socket-lag='));
49
+ const socketLagMs = lagArg === undefined ? 0 : Number(lagArg.slice('--socket-lag='.length));
50
+ /**
51
+ * The mirror image: throttles the *terminal* stream instead of the socket.
52
+ *
53
+ * Markers ride stdout and trees ride the socket, so a terminal slower than the
54
+ * socket means a revision's marker can arrive after the driver has given up
55
+ * waiting for it. A ceiling on bytes per second — rather than a fixed delay —
56
+ * is the faithful model of a pty that re-encodes everything: under a flood a
57
+ * backlog builds and markers fall seconds behind their trees, and once the
58
+ * flood stops the backlog drains and ordinary pairing should resume. Whether it
59
+ * does is the whole question.
60
+ */
61
+ const bpsArg = process.argv.find((argument) => argument.startsWith('--stdout-bps='));
62
+ const stdoutBps = bpsArg === undefined ? 0 : Number(bpsArg.slice('--stdout-bps='.length));
63
+ /**
64
+ * Bytes of screen repaint to emit before each revision's marker.
65
+ *
66
+ * The other half of the same story, and the half that is the driver's own: a
67
+ * platform which repaints the whole screen per frame — ConPTY — hands the
68
+ * driver far more bytes than the application wrote, and the driver's emulator
69
+ * queue, not the transport, is what falls behind. A marker sitting in that
70
+ * queue is one the driver has received but not yet read, which is why a half's
71
+ * expiry clock starts at the drain barrier rather than on arrival. This knob
72
+ * makes that backlog on any platform.
73
+ */
74
+ const repaintArg = process.argv.find((argument) => argument.startsWith('--repaint='));
75
+ const repaintBytes = repaintArg === undefined ? 0 : Number(repaintArg.slice('--repaint='.length));
76
+ const REPAINT = repaintBytes > 0 ? `\x1b[H${'.'.repeat(repaintBytes)}\r` : '';
77
+ const endpoint = process.env['TERMWRIGHT_ENDPOINT'];
78
+ const token = process.env['TERMWRIGHT_TOKEN'];
79
+
80
+ /** Ceilings from the protocol's DEFAULT_LIMITS, restated rather than imported. */
81
+ const MAX_FRAME_BYTES = 1024 * 1024;
82
+ const MAX_NODES = 5_000;
83
+ const MAX_DEPTH = 64;
84
+ const MAX_LOG_RECORD_BYTES = 32 * 1024;
85
+
86
+ /** Scenarios that need the log channel negotiated in the handshake. */
87
+ const NEEDS_LOGS = new Set(['log-seq-duplicate', 'log-seq-gap', 'log-oversized', 'log-flood']);
88
+
89
+ /** Scenarios that announce `tree-diffs` and push deltas instead of whole trees. */
90
+ const NEEDS_DELTAS = new Set([
91
+ 'delta-sequence',
92
+ 'delta-bad-base',
93
+ 'delta-cursor-clear',
94
+ 'delta-flood',
95
+ 'delta-removed-missing',
96
+ 'delta-before-snapshot',
97
+ ]);
98
+
99
+ let logSeq = 0;
100
+ let logBudget = null;
101
+
102
+ let sessionId = null;
103
+ let socket = null;
104
+ let published = 0;
105
+
106
+ if (stdoutBps > 0) {
107
+ const write = process.stdout.write.bind(process.stdout);
108
+ const TICK_MS = 50;
109
+ const perTick = Math.max(1, Math.floor((stdoutBps * TICK_MS) / 1000));
110
+ let pending = '';
111
+ setInterval(() => {
112
+ if (pending.length === 0) return;
113
+ write(pending.slice(0, perTick));
114
+ pending = pending.slice(perTick);
115
+ }, TICK_MS);
116
+ process.stdout.write = (chunk) => {
117
+ pending += chunk;
118
+ return true;
119
+ };
120
+ }
121
+
122
+ const say = (line) => process.stdout.write(`${line}\r\n`);
123
+
124
+ /** 4-byte big-endian length prefix + UTF-8 JSON. */
125
+ function frame(value) {
126
+ const body = Buffer.from(JSON.stringify(value), 'utf8');
127
+ const header = Buffer.alloc(4);
128
+ header.writeUInt32BE(body.length, 0);
129
+ return Buffer.concat([header, body]);
130
+ }
131
+
132
+ /**
133
+ * `OSC 8487 ; twm;{revision};{mac}` closed by BEL, or by ST when asked.
134
+ *
135
+ * MAC = base64url(HMAC-SHA256(token, `${sessionId}:${revision}`))[0..16). Both
136
+ * terminators are legal, so the peer can emit either: a receiver that only
137
+ * accepted the one an implementation happens to write would reject a
138
+ * conforming adapter, and that is a rule worth exercising from the outside.
139
+ */
140
+ function marker(revision, forSession = sessionId, terminator = '\x07') {
141
+ const mac = createHmac('sha256', token)
142
+ .update(`${forSession}:${revision}`, 'utf8')
143
+ .digest()
144
+ .subarray(0, 16)
145
+ .toString('base64url');
146
+ return `\x1b]8487;twm;${revision};${mac}${terminator}`;
147
+ }
148
+
149
+ function tree(revision, nodes, overrides = {}) {
150
+ return {
151
+ v: 1,
152
+ sessionId,
153
+ revision,
154
+ columns: 80,
155
+ rows: 24,
156
+ rootIds: ['n1'],
157
+ nodes,
158
+ ...overrides,
159
+ };
160
+ }
161
+
162
+ function validNodes(label) {
163
+ return [
164
+ { id: 'n1', role: 'region', name: 'Peer', bounds: { row: 0, column: 0, width: 20, height: 2 } },
165
+ {
166
+ id: 'n2',
167
+ parentId: 'n1',
168
+ role: 'button',
169
+ name: label,
170
+ testId: 'peer-button',
171
+ bounds: { row: 1, column: 0, width: 10, height: 1 },
172
+ actions: ['activate', 'focus'],
173
+ },
174
+ ];
175
+ }
176
+
177
+ /** Publishes a well-formed revision: snapshot, commit, then the render marker. */
178
+ function publish(revision, label = 'Peer') {
179
+ socket.write(frame({ type: 'snapshot', snapshot: tree(revision, validNodes(label)) }));
180
+ socket.write(frame({ type: 'revision-commit', revision }));
181
+ process.stdout.write(REPAINT + marker(revision));
182
+ published = revision;
183
+ }
184
+
185
+ const SCENARIOS = {
186
+ 'bad-token': () => {
187
+ socket.write(frame(hello({ token: 'not-the-token' })));
188
+ },
189
+ 'bad-version': () => {
190
+ socket.write(frame(hello({ protocol: 'termwright/99' })));
191
+ },
192
+ 'no-hello': () => {
193
+ socket.write(frame({ type: 'snapshot', snapshot: tree(1, validNodes('Peer')) }));
194
+ },
195
+
196
+ 'duplicate-hello': () => {
197
+ socket.write(frame(hello()));
198
+ },
199
+ 'oversized-frame': () => {
200
+ // A header claiming eight megabytes, with no body behind it: the ceiling
201
+ // must be enforced on the declared length, before a byte is buffered.
202
+ const header = Buffer.alloc(4);
203
+ header.writeUInt32BE(MAX_FRAME_BYTES * 8, 0);
204
+ socket.write(header);
205
+ },
206
+ 'partial-frame': () => {
207
+ const header = Buffer.alloc(4);
208
+ header.writeUInt32BE(4096, 0);
209
+ socket.write(Buffer.concat([header, Buffer.from('{"type":"sna', 'utf8')]));
210
+ },
211
+ 'duplicate-frames': () => {
212
+ const message = frame({ type: 'snapshot', snapshot: tree(2, validNodes('Twice')) });
213
+ socket.write(message);
214
+ socket.write(message);
215
+ process.stdout.write(marker(2));
216
+ },
217
+ cycle: () => {
218
+ send(
219
+ tree(2, [
220
+ { id: 'n1', role: 'region', name: 'Peer' },
221
+ { id: 'n2', parentId: 'n3', role: 'button', name: 'A' },
222
+ { id: 'n3', parentId: 'n2', role: 'button', name: 'B' },
223
+ ]),
224
+ );
225
+ },
226
+ 'missing-parent': () => {
227
+ send(
228
+ tree(2, [
229
+ { id: 'n1', role: 'region', name: 'Peer' },
230
+ { id: 'n2', parentId: 'ghost', role: 'button', name: 'Orphan' },
231
+ ]),
232
+ );
233
+ },
234
+ 'impossible-bounds': () => {
235
+ send(
236
+ tree(2, [
237
+ {
238
+ id: 'n1',
239
+ role: 'region',
240
+ name: 'Peer',
241
+ bounds: { row: 9_000, column: 9_000, width: 5, height: 5 },
242
+ },
243
+ ]),
244
+ );
245
+ },
246
+ 'decreasing-revision': () => {
247
+ publish(3, 'Third');
248
+ setTimeout(() => publish(2, 'Second'), 50);
249
+ },
250
+ 'marker-without-tree': () => {
251
+ process.stdout.write(marker(5));
252
+ },
253
+ 'tree-without-marker': () => {
254
+ socket.write(frame({ type: 'snapshot', snapshot: tree(4, validNodes('Unpaired')) }));
255
+ socket.write(frame({ type: 'revision-commit', revision: 4 }));
256
+ },
257
+ 'rapid-rerender': () => {
258
+ for (let revision = 2; revision <= 200; revision += 1) publish(revision, `Rev${revision}`);
259
+ },
260
+ flood: () => {
261
+ const noise = `${'x'.repeat(4096)}\r\n`;
262
+ for (let chunk = 0; chunk < 512; chunk += 1) process.stdout.write(noise);
263
+ for (let revision = 2; revision <= 100; revision += 1) {
264
+ socket.write(frame({ type: 'snapshot', snapshot: tree(revision, validNodes(`Flood${revision}`)) }));
265
+ }
266
+ },
267
+ 'disconnect-mid-render': () => {
268
+ socket.write(frame({ type: 'snapshot', snapshot: tree(2, validNodes('Torn')) }));
269
+ socket.destroy();
270
+ },
271
+ 'hostile-unicode': () => {
272
+ // A lone high surrogate survives JSON.stringify as an escape, so the bytes
273
+ // on the wire are ASCII and the receiver is the one that has to fail closed.
274
+ send(tree(2, [{ id: 'n1', role: 'region', name: 'lone \ud800 surrogate' }]));
275
+ },
276
+ 'foreign-session': () => {
277
+ send(tree(2, validNodes('Foreign'), { sessionId: 'someone-elses-session' }));
278
+ },
279
+ 'unknown-message': () => {
280
+ socket.write(frame({ type: 'take-over-the-terminal', payload: 'please' }));
281
+ },
282
+ 'not-json': () => {
283
+ const body = Buffer.from('this is not json at all', 'utf8');
284
+ const header = Buffer.alloc(4);
285
+ header.writeUInt32BE(body.length, 0);
286
+ socket.write(Buffer.concat([header, body]));
287
+ },
288
+ 'deep-nesting': () => {
289
+ let value = 'leaf';
290
+ for (let depth = 0; depth < MAX_DEPTH * 2; depth += 1) value = { nested: value };
291
+ socket.write(frame({ type: 'snapshot', snapshot: value }));
292
+ },
293
+ 'too-many-nodes': () => {
294
+ const nodes = [{ id: 'n1', role: 'region', name: 'Peer' }];
295
+ for (let index = 2; index <= MAX_NODES + 1_000; index += 1) {
296
+ nodes.push({ id: `n${index}`, parentId: 'n1', role: 'text', name: `t${index}` });
297
+ }
298
+ send(tree(2, nodes));
299
+ },
300
+ 'second-connection': () => {
301
+ // One adapter per session. A second channel from the same process is the
302
+ // benign shape of that mistake; the driver must refuse it and say so
303
+ // without disturbing the adapter that is already attached.
304
+ const second = connect(endpoint, () => {
305
+ second.write(frame(hello()));
306
+ });
307
+ second.on('error', () => say('PEER SECOND SOCKET ERROR'));
308
+ second.on('close', () => say('PEER SECOND SOCKET CLOSED'));
309
+ // Buffered and length-decoded, not regexed out of one chunk: the reply and
310
+ // the socket's own destruction race, and a split read would silently lose
311
+ // the error this scenario exists to observe.
312
+ let rest = Buffer.alloc(0);
313
+ second.on('data', (chunk) => {
314
+ rest = Buffer.concat([rest, chunk]);
315
+ for (;;) {
316
+ if (rest.length < 4) break;
317
+ const length = rest.readUInt32BE(0);
318
+ if (rest.length < 4 + length) break;
319
+ const message = JSON.parse(rest.subarray(4, 4 + length).toString('utf8'));
320
+ rest = rest.subarray(4 + length);
321
+ if (message.type === 'error') say(`PEER SECOND GOT ERROR ${message.code}`);
322
+ }
323
+ });
324
+ },
325
+ 'log-no-negotiation': () => {
326
+ // The handshake never announced `logs`, so the budget was never granted.
327
+ socket.write(frame({ type: 'log', record: logRecord(1, 'uninvited') }));
328
+ },
329
+ 'log-seq-duplicate': () => {
330
+ sendLog(1, 'first');
331
+ sendLog(1, 'same seq again');
332
+ sendLog(2, 'after the duplicate');
333
+ },
334
+ 'log-seq-gap': () => {
335
+ sendLog(1, 'before the gap');
336
+ // Four records the adapter dropped at the source; the gap is how it says so.
337
+ sendLog(6, 'after the gap');
338
+ },
339
+ 'log-oversized': () => {
340
+ socket.write(
341
+ frame({ type: 'log', record: logRecord(1, 'x'.repeat(MAX_LOG_RECORD_BYTES + 1024)) }),
342
+ );
343
+ },
344
+ 'log-flood': () => {
345
+ // Far past any sane per-second budget, sent in one turn.
346
+ for (let seq = 1; seq <= 500; seq += 1) sendLog(seq, `flood ${seq}`);
347
+ },
348
+ 'delta-sequence': () => {
349
+ sendDelta(renameDelta(1, 2, 'Second'));
350
+ sendDelta(renameDelta(2, 3, 'Third'));
351
+ sendDelta(renameDelta(3, 4, 'Fourth'));
352
+ },
353
+ 'delta-bad-base': () => {
354
+ // Base 999 was never held, so this cannot be patched onto anything.
355
+ sendDelta(renameDelta(999, 1000, 'Impossible'));
356
+ },
357
+ 'delta-cursor-clear': () => {
358
+ // A delta can set a cursor but never clear it. `c` sends the full tree that
359
+ // clears it, so the two halves are separate steps rather than a race.
360
+ sendDelta(renameDelta(1, 2, 'Cursor', { cursor: { row: 3, column: 7, visible: true } }));
361
+ },
362
+ 'delta-flood': () => {
363
+ for (let revision = 2; revision <= 200; revision += 1) {
364
+ sendDelta(renameDelta(revision - 1, revision, `Rev${revision}`));
365
+ }
366
+ },
367
+ 'delta-removed-missing': () => {
368
+ sendDelta({ baseRevision: 1, revision: 2, changed: [], removed: ['ghost'] });
369
+ },
370
+ 'delta-before-snapshot': () => {
371
+ // Handled specially at handshake time: nothing was published first.
372
+ sendDelta(renameDelta(1, 2, 'Premature'));
373
+ },
374
+ 'marker-st-terminator': () => {
375
+ // ST rather than BEL. The tree must pair exactly as it does with BEL.
376
+ socket.write(frame({ type: 'snapshot', snapshot: tree(2, validNodes('Terminated')) }));
377
+ process.stdout.write(marker(2, sessionId, '\x1b\\'));
378
+ published = 2;
379
+ },
380
+ 'peer-error': () => {
381
+ // The other direction: the adapter reports a protocol error at us. The
382
+ // driver must surface the code the peer chose, not one of its own.
383
+ socket.write(frame({ type: 'error', code: 'internal', message: 'the adapter gave up' }));
384
+ },
385
+ 'foreign-marker': () => {
386
+ // A marker MAC bound to a different session must not commit anything here.
387
+ socket.write(frame({ type: 'snapshot', snapshot: tree(2, validNodes('Forged')) }));
388
+ process.stdout.write(marker(2, 'another-session'));
389
+ },
390
+ };
391
+
392
+ function hello(overrides = {}) {
393
+ const capabilities = ['tree', 'bounds', 'states', 'actions', 'render-revisions'];
394
+ // `log-no-negotiation` deliberately does NOT announce it: the point of that
395
+ // scenario is sending records the driver never invited.
396
+ if (NEEDS_LOGS.has(scenario)) capabilities.push('logs');
397
+ if (NEEDS_DELTAS.has(scenario)) capabilities.push('tree-diffs');
398
+ return {
399
+ type: 'hello',
400
+ protocol: 'termwright/1',
401
+ token,
402
+ adapter: { name: 'adversarial-peer', version: '0.1.0' },
403
+ capabilities,
404
+ ...overrides,
405
+ };
406
+ }
407
+
408
+ /** One well-formed record, with the seq the caller asks for. */
409
+ function logRecord(seq, message = `record ${seq}`) {
410
+ return { ts: Date.now(), level: 'info', message, logger: 'peer', seq };
411
+ }
412
+
413
+ function sendLog(seq, message) {
414
+ socket.write(frame({ type: 'log', record: logRecord(seq, message) }));
415
+ logSeq = Math.max(logSeq, seq);
416
+ }
417
+
418
+ /**
419
+ * Sends a delta and its marker. The peer keeps no model of its own beyond the
420
+ * revision counter: composing is the receiver's job, and a producer that also
421
+ * composed would only prove it agrees with itself.
422
+ */
423
+ function sendDelta(delta) {
424
+ // The delta is the message: the body sits beside the discriminator rather
425
+ // than nested under it.
426
+ socket.write(frame({ type: 'tree-delta', ...delta }));
427
+ process.stdout.write(REPAINT + marker(delta.revision));
428
+ published = Math.max(published, delta.revision);
429
+ }
430
+
431
+ /** A delta that renames the button, so a composed tree is observable on screen. */
432
+ function renameDelta(baseRevision, revision, label, overrides = {}) {
433
+ return {
434
+ baseRevision,
435
+ revision,
436
+ changed: [
437
+ {
438
+ id: 'n2',
439
+ parentId: 'n1',
440
+ role: 'button',
441
+ name: label,
442
+ testId: 'peer-button',
443
+ bounds: { row: 1, column: 0, width: 10, height: 1 },
444
+ actions: ['activate', 'focus'],
445
+ },
446
+ ],
447
+ removed: [],
448
+ ...overrides,
449
+ };
450
+ }
451
+
452
+ /** Sends a snapshot together with its marker, so only the tree can be at fault. */
453
+ function send(snapshot) {
454
+ socket.write(frame({ type: 'snapshot', snapshot }));
455
+ process.stdout.write(marker(snapshot.revision));
456
+ }
457
+
458
+ const ATTACKS_HANDSHAKE = new Set(['bad-token', 'bad-version', 'no-hello']);
459
+
460
+ function fire() {
461
+ const attack = SCENARIOS[scenario];
462
+ if (attack === undefined) {
463
+ say(`PEER UNKNOWN SCENARIO ${scenario}`);
464
+ return;
465
+ }
466
+ try {
467
+ attack();
468
+ say('PEER FIRED');
469
+ } catch (error) {
470
+ // A peer that cannot even build its attack must say so rather than die
471
+ // silently and leave the suite waiting on a timeout.
472
+ say(`PEER FAILED ${String(error && error.message ? error.message : error)}`);
473
+ }
474
+ }
475
+
476
+ process.stdin.setRawMode?.(true);
477
+ process.stdin.resume();
478
+ process.stdin.on('data', (chunk) => {
479
+ const text = chunk.toString('utf8');
480
+ if (text.includes('q')) {
481
+ say('BYE');
482
+ process.exit(0);
483
+ }
484
+ if (text.includes('g')) fire();
485
+ if (text.includes('p')) publish(published + 1, `Manual${published + 1}`);
486
+ // The cursor-clearing half of `delta-cursor-clear`: only a full tree can do
487
+ // it, and `tree()` builds one without a cursor.
488
+ if (text.includes('c')) publish(published + 1, 'NoCursor');
489
+ });
490
+
491
+ say(`PEER START ${scenario}`);
492
+
493
+ if (endpoint === undefined || token === undefined) {
494
+ say('PEER DORMANT');
495
+ } else {
496
+ socket = connect(endpoint, () => {
497
+ if (socketLagMs > 0) {
498
+ const write = socket.write.bind(socket);
499
+ const held = [];
500
+ let scheduled = false;
501
+ socket.write = (chunk) => {
502
+ held.push(chunk);
503
+ if (!scheduled) {
504
+ scheduled = true;
505
+ setTimeout(() => {
506
+ scheduled = false;
507
+ while (held.length > 0) write(held.shift());
508
+ }, socketLagMs);
509
+ }
510
+ return true;
511
+ };
512
+ }
513
+ if (ATTACKS_HANDSHAKE.has(scenario)) {
514
+ fire();
515
+ say(`PEER READY ${scenario}`);
516
+ return;
517
+ }
518
+ if (helloDelayMs > 0) {
519
+ say(`PEER DELAYING HELLO ${helloDelayMs}`);
520
+ setTimeout(() => {
521
+ socket.write(frame(hello()));
522
+ say('PEER SENT HELLO');
523
+ }, helloDelayMs);
524
+ return;
525
+ }
526
+ socket.write(frame(hello()));
527
+ });
528
+ socket.on('error', () => say('PEER SOCKET ERROR'));
529
+ socket.on('close', () => say('PEER SOCKET CLOSED'));
530
+
531
+ let pending = Buffer.alloc(0);
532
+ socket.on('data', (chunk) => {
533
+ pending = Buffer.concat([pending, chunk]);
534
+ for (;;) {
535
+ if (pending.length < 4) break;
536
+ const length = pending.readUInt32BE(0);
537
+ if (pending.length < 4 + length) break;
538
+ const message = JSON.parse(pending.subarray(4, 4 + length).toString('utf8'));
539
+ pending = pending.subarray(4 + length);
540
+ if (message.type === 'hello-ack') {
541
+ sessionId = message.sessionId;
542
+ logBudget = message.logs ?? null;
543
+ say(`PEER LOGS ${logBudget === null ? 'denied' : `enabled ${logBudget.maxRecordsPerSecond}/s`}`);
544
+ // `delta-before-snapshot` deliberately skips the opening tree: its
545
+ // whole point is a delta with nothing to compose onto.
546
+ if (scenario !== 'delta-before-snapshot') publish(1);
547
+ say(`PEER READY ${scenario}`);
548
+ }
549
+ if (message.type === 'get-tree') {
550
+ // Answering is what makes a resync observable end to end: the driver
551
+ // asks, the peer supplies, the session returns to a known tree.
552
+ const revision = published + 1;
553
+ socket.write(
554
+ frame({
555
+ type: 'get-tree-result',
556
+ requestId: message.requestId,
557
+ snapshot: tree(revision, validNodes('Resynced')),
558
+ }),
559
+ );
560
+ published = revision;
561
+ process.stdout.write(marker(revision));
562
+ say(`PEER SENT FULL TREE ${revision}`);
563
+ }
564
+ if (message.type === 'error') say(`PEER GOT ERROR ${message.code}`);
565
+ }
566
+ });
567
+ }