feat(sim): F4.2 the sim protocol, the simulation room and LocalSimHost; M9 measured

packages/protocol holds the one list of /sim messages and checks every
field, naming what is wrong. packages/sim has the simulation room for F and
the web worker: the document's snapshot at start, the engine's clock on the
wall clock with Java's speed as cable delay, subscriptions to cable
activity, a card's new frames and a program's new window lines, capture and
clock kept over a stop, the command line as first action, a core failure
told to every client. LocalSimHost mirrors the document into the worker and
back without echo. M9 in Chromium: 17 examples give the same capture as in
Node, character for character; the room runs in a real worker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 08:32:20 +00:00
co-authored by Claude Opus 5.5
parent 800e5de1d3
commit 0fd42f10e2
30 changed files with 1465 additions and 1 deletions
+309
View File
@@ -0,0 +1,309 @@
// The simulation room against the engine run by hand: what a client gets —
// a card's frames, a program's window, the cables' activity, the room's
// state — is what the engine holds, sent once each and only to the clients
// that subscribed. On FILIUS' example internetwork_komplett: Notebook 1.10
// pings Webserver 5.10 across three routers from its command line.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { NetworkRuntimeEngine, type Id } from '@mdg-filius/core';
import { docSnapshot, readProject, writeProject } from '@mdg-filius/doc';
import { openProjectArchive } from '@mdg-filius/fls';
import type { HostMessage, TraceFrame } from '@mdg-filius/protocol';
import { MAXIMUM_CATCH_UP_MILLISECONDS, SimulationRoom, TICK_MILLISECONDS, traceFramesOf } from '../src/index.js';
const corpus = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus');
const SEED = 14;
function exampleDoc(file = 'java-examples/internetwork_komplett.fls'): Y.Doc {
let counter = 0;
const project = openProjectArchive(new Uint8Array(readFileSync(join(corpus, file))), () => `00000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`);
const doc = new Y.Doc();
writeProject(doc, project);
return doc;
}
/** A room on a clock the test moves, and a client that keeps what it gets. */
function setUp(doc = exampleDoc()) {
let now = 1_000_000;
const room = new SimulationRoom(doc, { now: () => now, seed: () => SEED });
const got: HostMessage[] = [];
const client = room.connect((message) => got.push(message));
const project = readProject(doc);
const node = (name: string) => project.graph.nodes.find((each) => each.displayName === name)!;
return {
room,
got,
client,
node,
send: (message: object) => room.receive(client, { v: 1, ...message }),
/** Ticks for so many milliseconds of wall time. */
run: (milliseconds: number) => {
for (let elapsed = 0; elapsed < milliseconds; elapsed += TICK_MILLISECONDS) {
now += TICK_MILLISECONDS;
room.tick();
}
},
jump: (milliseconds: number) => {
now += milliseconds;
room.tick();
},
};
}
const ofKind = <K extends HostMessage['kind']>(got: readonly HostMessage[], kind: K) => got.filter((message): message is Extract<HostMessage, { kind: K }> => message.kind === kind);
describe('a simulation room', () => {
it('tells a new client its state, and every client a change', () => {
const { room, got, send } = setUp();
const other: HostMessage[] = [];
room.connect((message) => other.push(message));
expect(got).toEqual([{ v: 1, kind: 'state', mode: 'design', speed: 90, t: 0 }]);
send({ kind: 'start' });
send({ kind: 'speed', value: 250 });
send({ kind: 'stop' });
for (const each of [got, other]) {
expect(ofKind(each, 'state').map(({ mode, speed }) => [mode, speed])).toEqual([
['design', 90],
['simulation', 90],
['simulation', 100],
['design', 100],
]);
}
});
it("sends a card's frames and a terminal's lines as the engine has them", () => {
const { got, send, node, run } = setUp();
const notebook = node('Notebook 1.10');
const port = notebook.ports[0]!.id;
send({ kind: 'sub', topics: [`trace:${port}`, `app:${notebook.id}/terminal`] });
send({ kind: 'start' });
run(1_000);
send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' });
run(8_000);
// The same story on an engine run by hand: started alike, advanced tick by tick, the line typed at the same time.
const engine = new NetworkRuntimeEngine(SEED);
engine.handle({ type: 'start', snapshot: docSnapshot(exampleDoc()), seed: SEED, initialTimeMilliseconds: 0 });
engine.setSimulationSpeed(90);
for (let t = TICK_MILLISECONDS; t <= 1_000; t += TICK_MILLISECONDS) engine.handle({ type: 'advance', toMilliseconds: t });
engine.terminal(notebook.id)!.enter('ping 141.99.5.10');
for (let t = 1_000 + TICK_MILLISECONDS; t <= 9_000; t += TICK_MILLISECONDS) engine.handle({ type: 'advance', toMilliseconds: t });
expect(ofKind(got, 'ack')).toEqual([{ v: 1, kind: 'ack', reqId: 'r1' }]);
const traces = ofKind(got, 'trace');
expect(traces[0]).toMatchObject({ portId: port, reset: true });
expect(traces.slice(1).every((each) => !each.reset && each.frames.length > 0)).toBe(true);
const frames: TraceFrame[] = traces.flatMap((each) => each.frames);
expect(frames).toEqual(traceFramesOf(engine.capture.frames(port)));
expect(frames.length).toBeGreaterThan(8);
const windows = ofKind(got, 'app');
let lines: unknown[] = [];
for (const each of windows) {
expect(each.from).toBe(each.from === 0 ? 0 : lines.length);
lines = [...lines.slice(0, each.from), ...each.lines];
}
expect(lines).toEqual(engine.terminal(notebook.id)!.log.all);
expect(lines.length).toBeGreaterThan(4);
});
it('sends the cables that carry frames when they change, and only to who asked', () => {
const { room, got, send, node, run } = setUp();
const quiet: HostMessage[] = [];
room.connect((message) => quiet.push(message));
send({ kind: 'sub', topics: ['activity'] });
send({ kind: 'start' });
run(1_000);
const notebook = node('Notebook 1.10');
send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' });
run(10_000);
const activity = ofKind(got, 'activity');
expect(activity[0]).toEqual({ v: 1, kind: 'activity', links: [] });
expect(activity.some((each) => each.links.length >= 2)).toBe(true);
// Each message differs from the one before; the last, after the ping, says all is quiet.
for (let index = 1; index < activity.length; index += 1) expect(activity[index]!.links).not.toEqual(activity[index - 1]!.links);
expect(activity.at(-1)!.links).toEqual([]);
expect(ofKind(quiet, 'activity')).toEqual([]);
});
it('stops sending a card it no longer watches', () => {
const { got, send, node, run } = setUp();
const notebook = node('Notebook 1.10');
send({ kind: 'sub', topics: [`trace:${notebook.ports[0]!.id}`] });
send({ kind: 'start' });
send({ kind: 'unsub', topics: [`trace:${notebook.ports[0]!.id}`] });
const before = got.length;
send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' });
run(5_000);
expect(ofKind(got.slice(before), 'trace')).toEqual([]);
});
it('keeps the capture over a stop and goes on at the time it stopped', () => {
const { got, send, node, run } = setUp();
const notebook = node('Notebook 1.10');
const port = notebook.ports[0]!.id;
send({ kind: 'start' });
send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' });
run(5_000);
send({ kind: 'stop' });
const stoppedAt = ofKind(got, 'state').at(-1)!.t;
expect(stoppedAt).toBe(5_000);
send({ kind: 'start' });
expect(ofKind(got, 'state').at(-1)!.t).toBe(stoppedAt);
send({ kind: 'sub', topics: [`trace:${port}`] });
const reset = ofKind(got, 'trace').at(-1)!;
expect(reset.reset).toBe(true);
expect(reset.frames.length).toBeGreaterThan(8);
});
it('empties a window when its program starts anew', () => {
const { got, send, node, run } = setUp();
const notebook = node('Notebook 1.10');
send({ kind: 'sub', topics: [`app:${notebook.id}/terminal`] });
expect(ofKind(got, 'app')).toEqual([{ v: 1, kind: 'app', node: notebook.id, app: 'terminal', from: 0, lines: [] }]);
send({ kind: 'start' });
send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ipconfig' }, reqId: 'r1' });
run(400);
expect(ofKind(got, 'app').at(-1)!.lines.length).toBeGreaterThan(0);
send({ kind: 'stop' });
expect(ofKind(got, 'app').at(-1)).toMatchObject({ from: 0, lines: [] });
send({ kind: 'start' });
expect(ofKind(got, 'app').at(-1)).toMatchObject({ from: 0 });
});
it('answers an action that cannot be done with its reason', () => {
const { got, send, node } = setUp();
const notebook = node('Notebook 1.10').id;
const reason = (action: object) => {
send({ kind: 'input', reqId: 'r', args: {}, ...action });
return got.at(-1);
};
expect(reason({ node: notebook, app: 'terminal', action: 'enter', args: { line: 'ipconfig' } })).toMatchObject({ kind: 'error', code: 'notRunning', reqId: 'r' });
send({ kind: 'start' });
expect(reason({ node: 'nobody', app: 'terminal', action: 'enter', args: { line: 'x' } })).toMatchObject({ code: 'unknownNode' });
expect(reason({ node: node('Switch 1').id, app: 'terminal', action: 'enter', args: { line: 'x' } })).toMatchObject({ code: 'unknownApp' });
expect(reason({ node: notebook, app: 'terminal', action: 'paint' })).toMatchObject({ code: 'unknownAction' });
expect(reason({ node: notebook, app: 'webBrowser', action: 'open' })).toMatchObject({ code: 'unknownAction' });
expect(reason({ node: notebook, app: 'terminal', action: 'enter', args: { line: 7 } })).toMatchObject({ code: 'badArgs' });
expect(reason({ node: notebook, app: 'terminal', action: 'interrupt' })).toEqual({ v: 1, kind: 'ack', reqId: 'r' });
send({ kind: 'nonsense', reqId: 'q' });
expect(got.at(-1)).toEqual({ v: 1, kind: 'error', code: 'unknownKind', detail: 'kind nonsense', reqId: 'q' });
send({ kind: 'speed' });
expect(got.at(-1)).toEqual({ v: 1, kind: 'error', code: 'badMessage', detail: 'speed is no number' });
});
it('makes a cable slower with a lower speed, as Java does', () => {
const roundTrip = (speed: number) => {
const { got, send, node, run } = setUp();
const notebook = node('Notebook 1.10');
send({ kind: 'sub', topics: [`trace:${notebook.ports[0]!.id}`] });
send({ kind: 'speed', value: speed });
send({ kind: 'start' });
send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' });
run(10_000);
const rows = ofKind(got, 'trace').flatMap((each) => each.frames).flatMap((frame) => frame.rows).filter((row) => row.protocol === 'ICMP');
return rows[1]!.timeMilliseconds - rows[0]!.timeMilliseconds;
};
expect(roundTrip(80)).toBeGreaterThan(roundTrip(90));
expect(roundTrip(90)).toBeGreaterThan(roundTrip(100));
});
it('catches up at most two seconds after the clock jumped', () => {
const { got, send, jump } = setUp();
send({ kind: 'start' });
jump(60_000);
send({ kind: 'stop' });
expect(ofKind(got, 'state').at(-1)!.t).toBe(MAXIMUM_CATCH_UP_MILLISECONDS);
});
it('starts with the document as it is then', () => {
const doc = exampleDoc();
const { send, node, room, got } = setUp(doc);
const notebook: Id = node('Notebook 1.10').id;
send({ kind: 'start' });
doc.getMap<Y.Map<unknown>>('nodes').get(notebook)!.set('name', 'Neu');
send({ kind: 'input', node: notebook, app: 'terminal', action: 'enter', args: { line: 'ipconfig' }, reqId: 'r1' });
expect(got.at(-1)).toMatchObject({ kind: 'ack' });
send({ kind: 'stop' });
// The room snapshots at the start: a node removed while it runs is gone at the next start.
doc.getMap('nodes').delete(notebook);
send({ kind: 'start' });
send({ kind: 'input', node: notebook, app: 'terminal', action: 'enter', args: { line: 'ipconfig' }, reqId: 'r2' });
expect(got.at(-1)).toMatchObject({ kind: 'error', code: 'unknownNode', reqId: 'r2' });
expect(room.currentMode).toBe('simulation');
});
it('tells every client when the core fails, and goes back to design mode', () => {
// FILIUS' modem example: remote links are not in the core yet, it throws at the first frame to a modem.
const { room, got, send, run, node } = setUp(exampleDoc('java-examples/modem_beispiel.fls'));
const other: HostMessage[] = [];
room.connect((message) => other.push(message));
const ping = () => send({ kind: 'input', node: node('Notebook 0.10').id, app: 'terminal', action: 'enter', args: { line: 'ping 192.168.0.11' }, reqId: 'r' });
send({ kind: 'start' });
ping();
run(10_000);
for (const each of [got, other]) {
expect(ofKind(each, 'error')).toEqual([{ v: 1, kind: 'error', code: 'engineFailed', detail: expect.stringContaining('remote links') }]);
expect(ofKind(each, 'state').at(-1)).toMatchObject({ mode: 'design' });
}
expect(room.currentMode).toBe('design');
// It can start again (and fail again, telling it again).
send({ kind: 'start' });
ping();
run(10_000);
expect(ofKind(got, 'error')).toHaveLength(2);
});
it('sends a card anew to a client that watches it again', () => {
const { got, send, node, run } = setUp();
const notebook = node('Notebook 1.10');
const topic = `trace:${notebook.ports[0]!.id}`;
send({ kind: 'start' });
send({ kind: 'sub', topics: [topic] });
send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' });
run(3_000);
send({ kind: 'unsub', topics: [topic] });
run(3_000);
send({ kind: 'sub', topics: [topic] });
const again = ofKind(got, 'trace').at(-1)!;
expect(again.reset).toBe(true);
expect(again.frames[0]!.number).toBe(1);
});
it('changes the speed of a running simulation', () => {
const roundTrip = (change: boolean) => {
const { got, send, node, run } = setUp();
const notebook = node('Notebook 1.10');
send({ kind: 'sub', topics: [`trace:${notebook.ports[0]!.id}`] });
send({ kind: 'start' });
run(1_000);
if (change) send({ kind: 'speed', value: 100 });
send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' });
run(10_000);
const rows = ofKind(got, 'trace').flatMap((each) => each.frames).flatMap((frame) => frame.rows).filter((row) => row.protocol === 'ICMP');
return rows[1]!.timeMilliseconds - rows[0]!.timeMilliseconds;
};
expect(roundTrip(true)).toBeLessThan(roundTrip(false));
});
it('reads the document anew at the start after a failure', () => {
const doc = exampleDoc('java-examples/modem_beispiel.fls');
const { got, send, run, node } = setUp(doc);
const ping = () => send({ kind: 'input', node: node('Notebook 0.10').id, app: 'terminal', action: 'enter', args: { line: 'ping 192.168.0.11' }, reqId: 'r' });
send({ kind: 'start' });
ping();
run(10_000);
expect(ofKind(got, 'error')).toHaveLength(1);
// Without the modems' cables, nothing reaches a modem: the next start runs.
doc.getMap('links').clear();
send({ kind: 'start' });
ping();
run(10_000);
expect(ofKind(got, 'error')).toHaveLength(1);
expect(ofKind(got, 'state').at(-1)).toMatchObject({ mode: 'simulation' });
});
});