feat(server): the relay joins modems of different projects by a code (K3)

A modem that offers a connection to another project is found by six
characters derived from its project and itself (modemCode): a copy of the
project has another code, so no two modems offer the same one. The room
names its modems of other projects to F's relay at a start and none at a
stop; the relay pairs each offer with the first modem that joins its code,
one to one as Java's server modem takes one client, and passes the frames
between the rooms' threads. Every client hears how each modem stands
(`modems`: connected, waiting, taken, unpaired, ambiguous, detached, or
unavailable without F). The document keeps the connection and the code.

Measured on F with two threads: a ping from one project's notebook to the
other's goes through; a third project that wants the same code reads
taken, and takes it when the second stops; a deleted project's modem
leaves with its room.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 18:24:19 +00:00
co-authored by Claude Opus 5.5
parent c85b0e45da
commit 7b943d3d96
15 changed files with 726 additions and 25 deletions
+1
View File
@@ -6,6 +6,7 @@ export * from './model/link.js';
export * from './model/node.js';
export * from './model/virtualFileSystem.js';
export * from './runtime/config.js';
export * from './runtime/modemCode.js';
export * from './runtime/dns.js';
export * from './runtime/engine.js';
export * from './runtime/ipv4.js';
+42
View File
@@ -0,0 +1,42 @@
// A modem's code (plan FILIUS_MODEM_PLAN E1, E2): a modem that offers a
// connection to another project is found by six characters instead of
// Java's IP address and port. The code is derived from the project and the
// modem, not stored: a copy of the project (handed out, duplicated) has
// another project number and so another code, and no two modems offer the
// same one. The editor shows it, server F pairs by it — both derive it here
// (RemoteLinkRelay names the role; config.ts).
//
// The characters leave out what one misreads (0/O, 1/I); typed in, small
// letters, spaces and dashes count as nothing.
export const MODEM_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
export const MODEM_CODE_LENGTH = 6;
const FNV_OFFSET = 0xcbf29ce484222325n;
const FNV_PRIME = 0x100000001b3n;
/** The code a modem of `project` offers (FNV-1a over both, its upper 30 bits in five-bit steps). */
export function modemCode(project: string, node: string): string {
let hash = FNV_OFFSET;
const text = `filius-modem\n${project}\n${node}`;
for (let index = 0; index < text.length; index += 1) {
hash ^= BigInt(text.charCodeAt(index));
hash = BigInt.asUintN(64, hash * FNV_PRIME);
}
let code = '';
for (let index = 0; index < MODEM_CODE_LENGTH; index += 1) code += MODEM_CODE_ALPHABET[Number((hash >> BigInt(59 - 5 * index)) & 31n)];
return code;
}
const CODE_PATTERN = new RegExp(`^[${MODEM_CODE_ALPHABET}]{${MODEM_CODE_LENGTH}}$`);
/** A code as typed in, without spaces and dashes, in capitals; undefined when it is none. */
export function modemCodeOf(text: string): string | undefined {
const code = text.toUpperCase().replace(/[\s-]/g, '');
return CODE_PATTERN.test(code) ? code : undefined;
}
/** A code as people read it: two groups of three. */
export function modemCodeText(code: string): string {
return `${code.slice(0, 3)}-${code.slice(3)}`;
}
+40
View File
@@ -0,0 +1,40 @@
// A modem's code (modemCode.ts): six characters derived from the project and
// the modem, the same wherever it is derived; read back as people type it.
import { describe, expect, it } from 'vitest';
import { MODEM_CODE_ALPHABET, MODEM_CODE_LENGTH, modemCode, modemCodeOf, modemCodeText } from '../src/index.js';
const uuid = (number: number) => `00000000-0000-4000-8000-${number.toString(16).padStart(12, '0')}`;
describe("a modem's code", () => {
it('is six characters one does not misread, the same for the same modem', () => {
const code = modemCode(uuid(1), uuid(2));
expect(code).toMatch(new RegExp(`^[${MODEM_CODE_ALPHABET}]{${MODEM_CODE_LENGTH}}$`));
expect(MODEM_CODE_ALPHABET).not.toMatch(/[01IO]/);
expect(modemCode(uuid(1), uuid(2))).toBe(code);
// Pinned: the editor and server F must derive the same code from the same project and modem.
expect(code).toBe('VKUZ5Q');
});
it('differs for a copy of the project and for another modem, and spreads over every character', () => {
expect(modemCode(uuid(1), uuid(2))).not.toBe(modemCode(uuid(3), uuid(2)));
expect(modemCode(uuid(1), uuid(2))).not.toBe(modemCode(uuid(1), uuid(3)));
const codes = Array.from({ length: 20_000 }, (_, index) => modemCode(uuid(index), uuid(7)));
// 2^30 codes: 20 000 projects meet a handful of times at most.
expect(new Set(codes).size).toBeGreaterThan(19_990);
for (let position = 0; position < MODEM_CODE_LENGTH; position += 1) {
const counts = new Map<string, number>();
for (const code of codes) counts.set(code[position]!, (counts.get(code[position]!) ?? 0) + 1);
expect(counts.size, `characters at ${position}`).toBe(MODEM_CODE_ALPHABET.length);
// Each character about 625 times; none below half of that.
expect(Math.min(...counts.values()), `rarest character at ${position}`).toBeGreaterThan(300);
}
});
it('is read as typed, without spaces, dashes and small letters, and written in two groups', () => {
expect(modemCodeOf(' k7f-3qx ')).toBe('K7F3QX');
expect(modemCodeOf('K7F 3QX')).toBe('K7F3QX');
for (const text of ['', 'K7F3Q', 'K7F3QXY', 'K7F3Q0', 'K7F3QO', 'K7F3Q1', 'K7F3QI', 'K7F3Q!']) expect(modemCodeOf(text), text).toBeUndefined();
expect(modemCodeText('K7F3QX')).toBe('K7F-3QX');
});
});
+28 -2
View File
@@ -41,6 +41,7 @@ import {
firewallConfiguration,
firewallRule,
forwardsIPv4ByDefault,
modemCodeOf,
type FirewallAction,
type FirewallProtocol,
type DhcpStaticAssignment,
@@ -226,12 +227,37 @@ export function setModemPort(doc: Y.Doc, id: Id, text: string, origin: unknown):
doc,
id,
checkField('port', text),
(node, value) =>
settingMap(node, 'remoteLink', { pairIdentifier: '', latencyMilliseconds: DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS, isEnabled: true }).set('pairIdentifier', modemPair(Number(value))),
(node, value) => modemSetting(node).set('pairIdentifier', modemPair(Number(value))),
origin,
);
}
/** How a modem finds its partner (FILIUS_MODEM_PLAN): the pair on its port in this project, or another project's modem — offering a code, or joining one. */
export type ModemConnection = 'inProject' | 'offer' | 'join';
const modemSetting = (node: Y.Map<unknown>) =>
settingMap(node, 'remoteLink', { pairIdentifier: '', latencyMilliseconds: DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS, isEnabled: true });
/** A modem's connection; joining starts without a code. Its port stays for the pair in the project. */
export function setModemConnection(doc: Y.Doc, id: Id, connection: ModemConnection, origin: unknown): void {
const node = nodes(doc).get(id);
if (node === undefined || kindOf(node) !== 'remoteLink') return;
doc.transact(() => {
const setting = modemSetting(node);
if (connection === 'inProject') setting.delete('relay');
else setting.set('relay', toY(connection === 'offer' ? { role: 'offer' } : { role: 'join', code: '' }));
}, origin);
}
/** The code of the modem a joining modem connects to, as typed (spaces, dashes, small letters allowed); refused when it is no code. */
export function setModemCode(doc: Y.Doc, id: Id, text: string, origin: unknown): CheckedField {
const node = nodes(doc).get(id);
const code = modemCodeOf(text);
if (node === undefined || code === undefined) return { invalid: true };
doc.transact(() => modemSetting(node).set('relay', toY({ role: 'join', code })), origin);
return { value: code };
}
const portsOf = (node: Y.Map<unknown>) => node.get('ports') as Y.Array<Y.Map<unknown>>;
/**
+18 -2
View File
@@ -19,9 +19,11 @@ import {
type HostLabelPolicy,
type ManualRoute,
type NodeKind,
type RemoteLinkRelay,
type RouteRow,
} from '@mdg-filius/core';
import { modemPort } from '@mdg-filius/fls';
import type { ModemConnection } from './settings.js';
import { inOrder, ROOTS } from './projectDoc.js';
import { fromY } from './values.js';
import { cardAddress, nodeLabel } from './view.js';
@@ -79,7 +81,12 @@ export interface SwitchSheet {
export interface ModemSheet {
readonly kind: 'remoteLink';
readonly name: string;
/** The pair in the project: two modems on one port. */
readonly port: number;
/** How it finds its partner (setModemConnection). */
readonly connection: ModemConnection;
/** Joining: the code of the modem it connects to ('' while none is given). */
readonly code: string;
}
/** A router's or home router's card. */
@@ -239,8 +246,17 @@ export function deviceSheet(doc: Y.Doc, id: string): DeviceSheet | undefined {
cloud: config?.get('showsAsCloud') === true,
};
}
case 'remoteLink':
return { kind, name, port: modemPort(setting(node, 'remoteLink')?.get('pairIdentifier') as string | undefined) };
case 'remoteLink': {
const config = setting(node, 'remoteLink');
const relay = fromY(config?.get('relay')) as RemoteLinkRelay | undefined;
return {
kind,
name,
port: modemPort(config?.get('pairIdentifier') as string | undefined),
connection: relay?.role ?? 'inProject',
code: relay?.role === 'join' ? relay.code : '',
};
}
case 'router': {
const interfaces = interfacesOf(doc, id, node);
const gateway = text(setting(node, 'device'), 'defaultGateway');
+42 -1
View File
@@ -18,12 +18,15 @@ import {
connect,
createUndoManager,
deviceSheet,
docSnapshot,
readProject,
renameDevice,
ROOTS,
setDhcpClient,
setHostAddress,
setHostLabels,
setModemCode,
setModemConnection,
setHostWireless,
setIpForwarding,
setModemPort,
@@ -498,7 +501,7 @@ describe("a switch's and a modem's sheet", () => {
it('pairs a modem by its port, and renames a router', () => {
const doc = new Y.Doc();
const modem = add(doc, 'remoteLink', 'Modem');
expect(deviceSheet(doc, modem)).toEqual({ kind: 'remoteLink', name: 'Modem', port: 12_345 });
expect(deviceSheet(doc, modem)).toEqual({ kind: 'remoteLink', name: 'Modem', port: 12_345, connection: 'inProject', code: '' });
expect(setModemPort(doc, modem, '65536', 'me')).toEqual({ invalid: true });
expect(setModemPort(doc, modem, ' 80', 'me')).toEqual({ value: '80' });
expect(readProject(doc).remoteLinkConfigurations.get(modem)).toEqual({ pairIdentifier: 'java-modem-port-80', latencyMilliseconds: 20, isEnabled: true });
@@ -510,6 +513,44 @@ describe("a switch's and a modem's sheet", () => {
});
});
describe("a modem's connection to another project", () => {
it('offers a code, joins one as typed, and goes back to the pair on its port', () => {
const doc = new Y.Doc();
const modem = add(doc, 'remoteLink', 'Modem');
setModemConnection(doc, modem, 'offer', 'me');
expect(deviceSheet(doc, modem)).toMatchObject({ connection: 'offer', code: '' });
expect(readProject(doc).remoteLinkConfigurations.get(modem)).toMatchObject({ relay: { role: 'offer' } });
expect(docSnapshot(doc).remoteLinkConfigurationsByNodeId.get(modem)).toMatchObject({ relay: { role: 'offer' } });
setModemConnection(doc, modem, 'join', 'me');
expect(deviceSheet(doc, modem)).toMatchObject({ connection: 'join', code: '' });
expect(setModemCode(doc, modem, 'k7f-3qx', 'me')).toEqual({ value: 'K7F3QX' });
expect(deviceSheet(doc, modem)).toMatchObject({ connection: 'join', code: 'K7F3QX' });
expect(docSnapshot(doc).remoteLinkConfigurationsByNodeId.get(modem)).toMatchObject({ relay: { role: 'join', code: 'K7F3QX' } });
// Misread characters (0, O, 1, I) and a wrong length are no code; the code stays.
for (const text of ['K7F3Q0', 'K7F3QI', 'K7F3Q', 'K7F3QXY']) expect(setModemCode(doc, modem, text, 'me')).toEqual({ invalid: true });
expect(deviceSheet(doc, modem)).toMatchObject({ code: 'K7F3QX' });
setModemConnection(doc, modem, 'inProject', 'me');
expect(deviceSheet(doc, modem)).toMatchObject({ connection: 'inProject', code: '', port: 12_345 });
expect(readProject(doc).remoteLinkConfigurations.get(modem)).not.toHaveProperty('relay');
});
it('is each one step of the undo, and touches nothing but a modem', () => {
const doc = new Y.Doc();
const modem = add(doc, 'remoteLink', 'Modem');
const notebook = add(doc, 'notebook');
const undo = createUndoManager(doc, 'me');
setModemConnection(doc, modem, 'join', 'me');
undo.stopCapturing();
setModemCode(doc, modem, 'ABCDEF', 'me');
undo.undo();
expect(deviceSheet(doc, modem)).toMatchObject({ connection: 'join', code: '' });
undo.undo();
expect(deviceSheet(doc, modem)).toMatchObject({ connection: 'inProject' });
setModemConnection(doc, notebook, 'offer', 'me');
expect((doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(notebook)!.get('settings') as Y.Map<unknown>).has('remoteLink')).toBe(false);
});
});
describe("the sheet's changes", () => {
it('are each one step of the person’s undo, the wireless join with its connection', () => {
const doc = new Y.Doc();
+15 -1
View File
@@ -61,7 +61,7 @@ export type ClientMessage =
readonly reqId: string;
};
export const HOST_MESSAGE_KINDS = ['state', 'activity', 'trace', 'dropped', 'app', 'view', 'node', 'ack', 'error'] as const;
export const HOST_MESSAGE_KINDS = ['state', 'activity', 'trace', 'dropped', 'app', 'view', 'node', 'modems', 'ack', 'error'] as const;
export type HostMessageKind = (typeof HOST_MESSAGE_KINDS)[number];
export type SimMode = 'design' | 'simulation';
@@ -126,9 +126,23 @@ export type HostMessage =
* on subscribing, when it changes, and none while the simulation never ran.
*/
| { readonly v: 1; readonly kind: 'node'; readonly node: string; readonly settings: NodeSettings }
/** How each modem stands, by node: to every client when it changes and when it connects; none while the simulation does not run. */
| { readonly v: 1; readonly kind: 'modems'; readonly modems: Readonly<Record<string, ModemStatus>> }
| { readonly v: 1; readonly kind: 'ack'; readonly reqId: string }
| { readonly v: 1; readonly kind: 'error'; readonly code: ErrorCode; readonly reqId?: string; readonly detail?: string };
/**
* How a modem stands while the simulation runs (shown under it):
* `connected` to its partner (in the project, or another project's);
* `unpaired`: no other modem on its port in the project; `ambiguous`: more
* than two there; `detached`: its cable or its partner's missing; for a
* modem of another project `waiting` (no partner: the other simulation does
* not run, or no modem has the code), `taken` (the modem with the code is
* connected with another) or `unavailable` (no server F: a room of one's own
* page cannot reach another project).
*/
export type ModemStatus = 'connected' | 'unpaired' | 'ambiguous' | 'detached' | 'waiting' | 'taken' | 'unavailable';
/** What Java's desktop shows of a computer's network (NetworkInfoPanel, the window's title). */
export interface NodeSettings {
readonly ipAddress: string;
+114
View File
@@ -0,0 +1,114 @@
// The relay between modems of different projects on server F
// (FILIUS_MODEM_PLAN, E1–E4): the rooms' threads name their modems of other
// projects when a simulation starts and none when it stops; the relay pairs
// a modem that joins a code with the modem that offers it (the code derived
// from that modem's project and itself, modemCode), and hands each frame to
// the partner's room. One offering modem takes one joining modem, as Java's
// server modem takes one client: the one that came first; another that joins
// the same code stands `taken` until the first leaves.
//
// Each room hears how its modems stand when that changes. Nothing is kept
// beyond the running simulations; a room that ends takes its modems along.
import { modemCode, type EthernetFrame, type RemoteLinkRelay } from '@mdg-filius/core';
import type { RelayState } from '@mdg-filius/sim';
/** A modem in a project's room. */
export interface ModemAddress {
readonly project: string;
readonly node: string;
}
export interface ModemRelayOptions {
/** Tells a room how its modems stand (those that changed). */
readonly states: (project: string, states: Readonly<Record<string, RelayState>>) => void;
/** Hands a frame to a modem in its room. */
readonly frame: (to: ModemAddress, frame: EthernetFrame) => void;
/** Two modems paired, or parted (for the log). */
readonly paired?: (offer: ModemAddress, join: ModemAddress, paired: boolean) => void;
}
const keyOf = ({ project, node }: ModemAddress) => `${project}\n${node}`;
interface Registered {
readonly address: ModemAddress;
readonly relay: RemoteLinkRelay;
}
export class ModemRelay {
/** Every registered modem, in the order they came (the first to join a code gets it). */
private readonly modems = new Map<string, Registered>();
/** The pairs: an offering modem's key → the joining modem's. */
private pairs = new Map<string, string>();
/** Each modem's partner, both ways. */
private partners = new Map<string, ModemAddress>();
/** What each room was last told, by modem. */
private readonly told = new Map<string, RelayState>();
constructor(private readonly options: ModemRelayOptions) {}
/** A project's modems of other projects now (none: its simulation stopped or its room ended). */
setModems(project: string, modems: readonly { readonly node: string; readonly relay: RemoteLinkRelay }[]): void {
for (const [key, registered] of this.modems) if (registered.address.project === project) this.modems.delete(key);
for (const { node, relay } of modems) this.modems.set(keyOf({ project, node }), { address: { project, node }, relay });
for (const key of this.told.keys()) if (!this.modems.has(key)) this.told.delete(key);
this.pair();
}
/** A frame a modem passed on, to its partner; lost when it has none. */
frame(from: ModemAddress, frame: EthernetFrame): void {
const partner = this.partners.get(keyOf(from));
if (partner !== undefined) this.options.frame(partner, frame);
}
/** How many modems are registered and how many pairs they make. */
stats(): { readonly modems: number; readonly pairs: number } {
return { modems: this.modems.size, pairs: this.pairs.size };
}
/**
* Pairs the modems anew: each offer takes the first modem that joins its
* code. The map holds them in the order they came (a simulation started
* anew comes last), so a pair that stands stays: its joining modem came
* before any other that waits for the code.
*/
private pair(): void {
const offers = new Map<string, string>();
for (const [key, { address, relay }] of this.modems) if (relay.role === 'offer') offers.set(modemCode(address.project, address.node), key);
const before = this.pairs;
const pairs = new Map<string, string>();
for (const [key, { relay }] of this.modems) {
const offer = relay.role === 'join' ? offers.get(relay.code) : undefined;
if (offer !== undefined && !pairs.has(offer)) pairs.set(offer, key);
}
this.pairs = pairs;
this.partners = new Map(
[...pairs].flatMap(([offer, join]) => [
[offer, addressOf(join)],
[join, addressOf(offer)],
]),
);
for (const [offer, join] of before) if (pairs.get(offer) !== join) this.options.paired?.(addressOf(offer), addressOf(join), false);
for (const [offer, join] of pairs) if (before.get(offer) !== join) this.options.paired?.(addressOf(offer), addressOf(join), true);
this.tell(offers);
}
/** Each room the states of its modems that changed. */
private tell(offers: ReadonlyMap<string, string>): void {
const byProject = new Map<string, Record<string, RelayState>>();
for (const [key, { address, relay }] of this.modems) {
const state: RelayState = this.partners.has(key) ? 'connected' : relay.role === 'join' && offers.has(relay.code) ? 'taken' : 'waiting';
if (this.told.get(key) === state) continue;
this.told.set(key, state);
const states = byProject.get(address.project) ?? {};
states[address.node] = state;
byProject.set(address.project, states);
}
for (const [project, states] of byProject) this.options.states(project, states);
}
}
function addressOf(key: string): ModemAddress {
const [project, node] = key.split('\n') as [string, string];
return { project, node };
}
+16 -3
View File
@@ -6,9 +6,14 @@
// each for one person (a room tells its people apart; the main thread
// numbers them). A room's large binary files (blobs) are F's: the thread
// asks for the ones it has not got, and gives F the ones the simulation
// makes before the document's change that names them.
// makes before the document's change that names them. A room's modems of
// other projects go through the main thread's relay (modemRelay.ts): the
// thread names them, the frames they pass go out, their partners' frames and
// how they stand come in.
import type { EthernetFrame } from '@mdg-filius/core';
import type { HostMessage } from '@mdg-filius/protocol';
import type { RelayModem, RelayState } from '@mdg-filius/sim';
/** How often, and how long apart, a thread asks again for a blob F has not got (yet). */
export const BLOB_ASKS = 5;
@@ -27,7 +32,11 @@ export type ToRoomThread =
/** The room ended — stopped first with `writeBack`, else as it is (its project is gone); answered by `closed`. */
| { readonly type: 'close'; readonly room: string; readonly writeBack: boolean }
/** The blobs asked for: those F has, and the names of those it has not. */
| { readonly type: 'blobs'; readonly room: string; readonly blobs: readonly { readonly sha: string; readonly bytes: Uint8Array }[]; readonly missing: readonly string[] };
| { readonly type: 'blobs'; readonly room: string; readonly blobs: readonly { readonly sha: string; readonly bytes: Uint8Array }[]; readonly missing: readonly string[] }
/** A frame from the partner of one of the room's modems. */
| { readonly type: 'relayFrame'; readonly room: string; readonly node: string; readonly frame: EthernetFrame }
/** How the room's modems of other projects stand now (those that changed). */
| { readonly type: 'relayStates'; readonly room: string; readonly states: Readonly<Record<string, RelayState>> };
export type FromRoomThread =
| { readonly type: 'doc'; readonly room: string; readonly update: Uint8Array }
@@ -37,4 +46,8 @@ export type FromRoomThread =
| { readonly type: 'needBlobs'; readonly room: string; readonly shas: readonly string[] }
/** A blob the simulation made (before the change that names it). */
| { readonly type: 'blob'; readonly room: string; readonly bytes: Uint8Array }
| { readonly type: 'closed'; readonly room: string };
| { readonly type: 'closed'; readonly room: string }
/** The room's modems of other projects: at a start; none at a stop. */
| { readonly type: 'relayModems'; readonly room: string; readonly modems: readonly RelayModem[] }
/** A frame one of them passed on. */
| { readonly type: 'relayFrame'; readonly room: string; readonly node: string; readonly frame: EthernetFrame };
+35 -3
View File
@@ -15,6 +15,11 @@
// project's simulation does not start, nor start anew (`frozen`); freezing stops a running
// one, what it left written back first.
//
// A room's modems of other projects meet in one relay (modemRelay.ts): the
// threads name them at a start and a stop, the relay pairs them by their
// code and passes the frames between the rooms' threads; a room that ends
// takes its modems along.
//
// When the last person leaves, the room stays LINGER_MILLISECONDS (a tab
// changed, a tablet locked, a short drop of the network: plan §6.2), then
// stops — what the simulation left written back — and ends. A thread that
@@ -29,6 +34,7 @@ import { SIM_CLOSED_CODE, SIM_ROOM_FAILED_CODE, WRITING_KINDS, type ClientMessag
import type WebSocket from 'ws';
import type { BlobStore } from './blobs.js';
import { errorText, type Log } from './log.js';
import { ModemRelay } from './modemRelay.js';
import type { FromRoomThread, ToRoomThread } from './simChannel.js';
export const LINGER_MILLISECONDS = 60_000;
@@ -99,6 +105,12 @@ export class SimRooms {
private readonly ending = new Map<string, Promise<void>>();
private nextClient = 1;
private closing = false;
/** The modems of other projects, paired across the rooms. */
private readonly relay = new ModemRelay({
states: (project, states) => this.toRoom(project, (room) => ({ type: 'relayStates', room: room.project, states })),
frame: ({ project, node }, frame) => this.toRoom(project, (room) => ({ type: 'relayFrame', room: room.project, node, frame })),
paired: (offer, join, paired) => this.options.log.info(paired ? 'sim.modemsPaired' : 'sim.modemsParted', { offer: offer.project, join: join.project }),
});
constructor(private readonly options: SimRoomsOptions) {
const count = options.threads ?? Math.max(1, availableParallelism() - 1);
@@ -129,6 +141,14 @@ export class SimRooms {
room.thread.worker.postMessage(message);
}
/** A message to a project's room, if it has one (the relay's: a room that ended takes nothing). */
private toRoom(project: string, message: (room: Room) => ToRoomThread): void {
for (const thread of this.threads)
for (const room of thread.rooms) {
if (room.project === project && !room.ended) this.post(room, message(room));
}
}
private fromThread(thread: Thread, message: FromRoomThread): void {
const room = [...thread.rooms].find((each) => each.project === message.room);
if (room === undefined) return;
@@ -164,6 +184,12 @@ export class SimRooms {
case 'closed':
room.waiting[message.type].shift()?.();
return;
case 'relayModems':
this.relay.setModems(room.project, message.modems);
return;
case 'relayFrame':
this.relay.frame({ project: room.project, node: message.node }, message.frame);
return;
}
}
@@ -272,6 +298,7 @@ export class SimRooms {
/** The room no longer reachable: a new one is made for whoever comes. */
private forget(room: Room): void {
room.ended = true;
this.relay.setModems(room.project, []);
if (room.linger !== undefined) clearTimeout(room.linger);
room.doc.off('update', room.forward);
room.thread.rooms.delete(room);
@@ -349,10 +376,15 @@ export class SimRooms {
return sockets.length;
}
/** How many rooms, people in them, and rooms each thread holds. */
stats(): { readonly rooms: number; readonly people: number; readonly threads: readonly number[] } {
/** How many rooms, people in them, rooms each thread holds, and the modems of other projects with the pairs they make. */
stats(): { readonly rooms: number; readonly people: number; readonly threads: readonly number[]; readonly modems: { readonly modems: number; readonly pairs: number } } {
const rooms = this.threads.flatMap((thread) => [...thread.rooms]);
return { rooms: rooms.length, people: rooms.reduce((sum, room) => sum + room.clients.size, 0), threads: this.threads.map((thread) => thread.rooms.size) };
return {
rooms: rooms.length,
people: rooms.reduce((sum, room) => sum + room.clients.size, 0),
threads: this.threads.map((thread) => thread.rooms.size),
modems: this.relay.stats(),
};
}
/** The threads' workers (for the stats' tests: one may be ended to see the rooms go). */
+16 -2
View File
@@ -6,7 +6,8 @@
// waits until the ones asked for have come (or F said it has not got them),
// so that the simulation starts with the files' bytes. One F has not got —
// yet: the editor that made it may still be taking it there — is asked for
// again a few times; when it comes, the running simulation reads it.
// again a few times; when it comes, the running simulation reads it. A
// room's modems of other projects talk through the main thread's relay.
import { parentPort } from 'node:worker_threads';
import * as Y from 'yjs';
@@ -56,7 +57,14 @@ port.on('message', (message: ToRoomThread) => {
doc.on('update', (update: Uint8Array, origin: unknown) => {
if (origin !== MAIN_ORIGIN && rooms.get(message.room)?.doc === doc) post({ type: 'doc', room: message.room, update });
});
const room = new SimulationRoom(doc, { now: () => performance.now(), seed: () => Math.floor(Math.random() * 2 ** 31) });
const room = new SimulationRoom(doc, {
now: () => performance.now(),
seed: () => Math.floor(Math.random() * 2 ** 31),
relay: {
modems: (modems) => post({ type: 'relayModems', room: message.room, modems }),
frame: (node, frame) => post({ type: 'relayFrame', room: message.room, node, frame }),
},
});
const held: HeldRoom = { doc, room, clients: new Map(), blobs, asked: new Set(), unavailable: new Set(), misses: new Map(), waiting: [] };
rooms.set(message.room, held);
askForBlobs(message.room, held);
@@ -112,6 +120,12 @@ port.on('message', (message: ToRoomThread) => {
if (client !== undefined) held.room.disconnect(client);
return;
}
case 'relayFrame':
held.room.relayed(message.node, message.frame);
return;
case 'relayStates':
held.room.relayStatesChanged(message.states);
return;
case 'stop':
held.room.stop();
post({ type: 'stopped', room: message.room });
+117
View File
@@ -0,0 +1,117 @@
// The relay between modems of different projects (modemRelay.ts): pairs by
// the offering modem's code, one to one, the first that joins keeps it;
// frames go to the partner; each room hears how its modems stand when that
// changes.
import { describe, expect, it } from 'vitest';
import type { EthernetFrame } from '@mdg-filius/core';
import { modemCode } from '@mdg-filius/core';
import type { RelayState } from '@mdg-filius/sim';
import { ModemRelay, type ModemAddress } from '../src/modemRelay.js';
const frame: EthernetFrame = {
identity: 1,
sourceMacAddress: '02:00:00:00:00:01',
destinationMacAddress: 'FF:FF:FF:FF:FF:FF',
payload: { kind: 'arp', packet: { operation: 'request', senderMacAddress: '02:00:00:00:00:01', senderIPAddress: '10.0.0.1', targetMacAddress: 'FF:FF:FF:FF:FF:FF', targetIPAddress: '10.0.0.2' } },
};
function relay() {
const told: { project: string; states: Record<string, RelayState> }[] = [];
const delivered: { to: ModemAddress; frame: EthernetFrame }[] = [];
const log: string[] = [];
const modems = new ModemRelay({
states: (project, states) => told.push({ project, states: { ...states } }),
frame: (to, each) => delivered.push({ to, frame: each }),
paired: (offer, join, paired) => log.push(`${paired ? 'paired' : 'parted'} ${offer.project}/${offer.node} ${join.project}/${join.node}`),
});
return { modems, told, delivered, log };
}
const code = modemCode('teacher', 'm1');
describe('the relay between modems of different projects', () => {
it('pairs a modem that joins a code with the modem that offers it, and passes frames both ways', () => {
const { modems, told, delivered, log } = relay();
modems.setModems('teacher', [{ node: 'm1', relay: { role: 'offer' } }]);
expect(told).toEqual([{ project: 'teacher', states: { m1: 'waiting' } }]);
modems.setModems('group', [{ node: 'g1', relay: { role: 'join', code } }]);
expect(told.slice(1)).toEqual([
{ project: 'teacher', states: { m1: 'connected' } },
{ project: 'group', states: { g1: 'connected' } },
]);
expect(log).toEqual(['paired teacher/m1 group/g1']);
modems.frame({ project: 'group', node: 'g1' }, frame);
modems.frame({ project: 'teacher', node: 'm1' }, frame);
expect(delivered.map(({ to }) => to)).toEqual([
{ project: 'teacher', node: 'm1' },
{ project: 'group', node: 'g1' },
]);
expect(modems.stats()).toEqual({ modems: 2, pairs: 1 });
});
it('waits for a code no running modem offers, and loses what the waiting modem passes', () => {
const { modems, told, delivered } = relay();
modems.setModems('group', [{ node: 'g1', relay: { role: 'join', code: 'ZZZZZZ' } }]);
modems.frame({ project: 'group', node: 'g1' }, frame);
expect(told).toEqual([{ project: 'group', states: { g1: 'waiting' } }]);
expect(delivered).toEqual([]);
});
it('takes one modem per code, as Java takes one client: the first keeps it, the next is told it is taken', () => {
const { modems, told, log } = relay();
modems.setModems('teacher', [{ node: 'm1', relay: { role: 'offer' } }]);
modems.setModems('first', [{ node: 'a', relay: { role: 'join', code } }]);
modems.setModems('second', [{ node: 'b', relay: { role: 'join', code } }]);
expect(told.at(-1)).toEqual({ project: 'second', states: { b: 'taken' } });
// The first group starts anew: its pair goes, the waiting one takes the code; the first now waits behind it.
modems.setModems('first', []);
modems.setModems('first', [{ node: 'a', relay: { role: 'join', code } }]);
expect(log).toEqual(['paired teacher/m1 first/a', 'parted teacher/m1 first/a', 'paired teacher/m1 second/b']);
expect(told.slice(-1)).toEqual([{ project: 'first', states: { a: 'taken' } }]);
});
it('keeps a pair standing when another modem joins the same code', () => {
const { modems, log } = relay();
modems.setModems('first', [{ node: 'a', relay: { role: 'join', code } }]);
modems.setModems('teacher', [{ node: 'm1', relay: { role: 'offer' } }]);
modems.setModems('second', [{ node: 'b', relay: { role: 'join', code } }]);
modems.setModems('teacher', [{ node: 'm1', relay: { role: 'offer' } }]);
expect(log).toEqual(['paired teacher/m1 first/a']);
});
it('parts a pair when the offering simulation stops; the joining modem waits again', () => {
const { modems, told, delivered, log } = relay();
modems.setModems('teacher', [{ node: 'm1', relay: { role: 'offer' } }]);
modems.setModems('group', [{ node: 'g1', relay: { role: 'join', code } }]);
modems.setModems('teacher', []);
expect(told.at(-1)).toEqual({ project: 'group', states: { g1: 'waiting' } });
expect(log.at(-1)).toBe('parted teacher/m1 group/g1');
modems.frame({ project: 'group', node: 'g1' }, frame);
expect(delivered).toEqual([]);
expect(modems.stats()).toEqual({ modems: 1, pairs: 0 });
});
it('tells a room only what changed', () => {
const { modems, told } = relay();
modems.setModems('teacher', [
{ node: 'm1', relay: { role: 'offer' } },
{ node: 'm2', relay: { role: 'offer' } },
]);
modems.setModems('group', [{ node: 'g1', relay: { role: 'join', code } }]);
expect(told).toEqual([
{ project: 'teacher', states: { m1: 'waiting', m2: 'waiting' } },
{ project: 'teacher', states: { m1: 'connected' } },
{ project: 'group', states: { g1: 'connected' } },
]);
});
it('pairs two modems of one project, one offering, one joining its code', () => {
const { modems, told } = relay();
modems.setModems('one', [
{ node: 'm1', relay: { role: 'offer' } },
{ node: 'm2', relay: { role: 'join', code: modemCode('one', 'm1') } },
]);
expect(told).toEqual([{ project: 'one', states: { m1: 'connected', m2: 'connected' } }]);
});
});
+64 -4
View File
@@ -6,14 +6,16 @@
// watches and changes nothing; the refusals, with their reasons; the room
// staying a while after the last person left, then stopping and ending;
// freezing, deleting and revoking reaching the room; a failed thread
// ending its rooms; the service stopping with rooms running.
// ending its rooms; the service stopping with rooms running; two projects'
// networks joined by their modems across two threads.
import { randomUUID } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import WebSocket from 'ws';
import * as Y from 'yjs';
import { changeFiles, nodeFiles, readProject, writeProject } from '@mdg-filius/doc';
import { modemCode } from '@mdg-filius/core';
import { addDevice, changeFiles, connect, emptyProject, nodeFiles, readProject, setHostAddress, setModemCode, setModemConnection, setSoftware, writeProject } from '@mdg-filius/doc';
import { openProjectArchive } from '@mdg-filius/fls';
import { SIM_CLOSED_CODE, SIM_REFUSED_CODE, SIM_ROOM_FAILED_CODE, WRITING_KINDS, type HostMessage } from '@mdg-filius/protocol';
import { LINGER_MILLISECONDS, PostgresSessions, PostgresStore } from '../src/index.js';
@@ -49,6 +51,28 @@ async function exampleProject(): Promise<{ readonly project: string; readonly no
return { project, node: (name) => imported.graph.nodes.find((each) => each.displayName === name)!.id };
}
/**
* A project of a computer at `address` cabled to a modem of another project:
* offering, or joining `code`; the ids of both.
*/
async function modemProject(address: string, relay: { readonly role: 'offer' } | { readonly role: 'join'; readonly code: string }) {
const project = randomUUID();
const doc = new Y.Doc();
writeProject(doc, emptyProject());
const [computer, modem] = [randomUUID(), randomUUID()];
addDevice(doc, { kind: 'notebook', id: computer, name: 'Notebook', x: 0, y: 0, newId: randomUUID }, 'setup');
addDevice(doc, { kind: 'remoteLink', id: modem, name: 'Modem', x: 200, y: 0, newId: randomUUID }, 'setup');
connect(doc, { node: computer }, { node: modem }, randomUUID(), 'setup');
setHostAddress(doc, computer, 'ipAddress', address, 'setup');
setSoftware(doc, computer, { install: ['commandPrompt'], remove: [] }, 'de', 'setup');
setModemConnection(doc, modem, relay.role, 'setup');
if (relay.role === 'join') setModemCode(doc, modem, relay.code, 'setup');
expect(await store.create(project, Y.encodeStateAsUpdate(doc))).toBe(true);
return { project, computer, modem };
}
const modemsOf = (client: SimClient) => client.got.filter((message): message is Extract<HostMessage, { kind: 'modems' }> => message.kind === 'modems').at(-1)?.modems;
const start = (options: { readonly linger?: number } = {}) => startTestServer(store, sessions, options.linger === undefined ? {} : { lingerMilliseconds: options.linger });
interface SimClient {
@@ -131,7 +155,7 @@ describe('a simulation room on F', () => {
['sim.restart', 'ben'],
['sim.stop', 'ben'],
]);
expect(server.sims.stats()).toEqual({ rooms: 1, people: 2, threads: [1] });
expect(server.sims.stats()).toEqual({ rooms: 1, people: 2, threads: [1], modems: { modems: 0, pairs: 0 } });
// What the dashboard's monitoring reads (F9): the same, and the process — only with the service key.
const status = await fetch(`${server.base}/svc/status`, { headers: { 'x-filius-service-key': SERVICE_KEY } });
expect(status.status).toBe(200);
@@ -351,7 +375,7 @@ describe('a simulation room on F', () => {
const again = await enterRoom(server, project, { cookie: mia.cookie });
await until(() => again.state() !== undefined);
expect(again.state()).toMatchObject({ mode: 'design' });
expect(server.sims.stats()).toEqual({ rooms: 1, people: 1, threads: [1] });
expect(server.sims.stats()).toEqual({ rooms: 1, people: 1, threads: [1], modems: { modems: 0, pairs: 0 } });
again.socket.close();
await server.close();
});
@@ -389,6 +413,42 @@ describe('a simulation room on F', () => {
await server.close();
});
it("joins two projects' networks through their modems, across two threads; a third that wants the same code finds it taken", async () => {
const server = await startTestServer(store, sessions, { simThreads: 2 });
const teacher = await modemProject('10.0.0.1', { role: 'offer' });
const group = await modemProject('10.0.0.2', { role: 'join', code: modemCode(teacher.project, teacher.modem) });
const late = await modemProject('10.0.0.3', { role: 'join', code: modemCode(teacher.project, teacher.modem) });
const mia = await person(server, teacher.project, 'Mia');
const ben = await person(server, group.project, 'Ben');
await until(() => mia.state() !== undefined && ben.state() !== undefined);
expect(server.sims.stats().threads).toEqual([1, 1]);
mia.send({ kind: 'start' });
await until(() => modemsOf(mia)?.[teacher.modem] === 'waiting');
ben.send({ kind: 'start' });
await until(() => modemsOf(ben)?.[group.modem] === 'connected' && modemsOf(mia)?.[teacher.modem] === 'connected');
expect(server.log.lines.find((line) => line.event === 'sim.modemsPaired')).toMatchObject({ offer: teacher.project, join: group.project });
// A ping from Ben's project to the computer in Mia's.
ben.send({ kind: 'sub', topics: [`app:${group.computer}/terminal`] });
ben.send({ kind: 'input', node: group.computer, app: 'terminal', action: 'enter', args: { line: 'ping 10.0.0.1' }, reqId: 'p' });
await until(() => appLines(ben).some((line) => /4 Paket\(e\) gesendet, 4 Paket\(e\) empfangen/.test(line.text ?? '')), 20_000);
// Rita's project wants the same code: taken while Ben's has it.
const rita = await person(server, late.project, 'Rita');
await until(() => rita.state() !== undefined);
rita.send({ kind: 'start' });
await until(() => modemsOf(rita)?.[late.modem] === 'taken');
expect(server.sims.stats().modems).toEqual({ modems: 3, pairs: 1 });
// Ben stops: the pair parts, Rita's modem takes the code.
ben.send({ kind: 'stop' });
await until(() => modemsOf(rita)?.[late.modem] === 'connected');
expect(modemsOf(ben)).toEqual({});
// Mia's project is deleted, its running room ends without stopping: its modem goes with it.
expect((await service(server, 'DELETE', `/svc/projects/${teacher.project}`)).status).toBe(200);
await until(() => modemsOf(rita)?.[late.modem] === 'waiting');
expect(server.sims.stats().modems).toEqual({ modems: 1, pairs: 0 });
for (const each of [mia, ben, rita]) each.socket.close();
await server.close();
}, 40_000);
it('gives a new room to the thread with the fewest', async () => {
const server = await startTestServer(store, sessions, { simThreads: 2 });
const [first, second, third] = [await exampleProject(), await exampleProject(), await exampleProject()];
+96 -1
View File
@@ -29,6 +29,14 @@
// installed or removed in the running engine at once, as Java starts or ends
// it; any other change comes in at the next start.
//
// Modems (FILIUS_MODEM_PLAN): a pair in the project is the engine's alone. A
// modem of another project needs server F's relay (`relay`): the room names
// its relay modems when the simulation starts and none when it stops, hands
// on what they pass, and takes what comes back (relayed); F tells it how they
// stand (relayStates). Every client hears how each modem stands (`modems`)
// when that changes — without a relay (a room of one's own page) a modem of
// another project is `unavailable`.
//
// What a run leaves that Java's project keeps (files, which servers run,
// accounts, mails, a DHCP client's address …) goes back into the document,
// as `origin: 'sim'`: at most once a second while it runs, after each action
@@ -59,6 +67,8 @@ import {
stableGeneratedMacAddress,
type CapturedFrame,
type CaptureRow,
type EthernetFrame,
type RemoteLinkRelay,
type Id,
type InstallableProgram,
type LogLine,
@@ -73,6 +83,7 @@ import {
type ClientMessage,
type ErrorCode,
type HostMessage,
type ModemStatus,
type NodeSettings,
type SimMode,
type Topic,
@@ -105,6 +116,25 @@ export interface RoomOptions {
readonly writeBackMilliseconds?: number;
/** The time of day, in milliseconds since 1970, for the viewers' times (Date.now by default). */
readonly epoch?: () => number;
/** Server F's relay to the modems of other projects; none in a room of one's own page. */
readonly relay?: RoomRelay;
}
/** A modem of another project, as the simulation started. */
export interface RelayModem {
readonly node: Id;
readonly relay: RemoteLinkRelay;
}
/** How a modem of another project stands, as the relay knows it. */
export type RelayState = 'connected' | 'waiting' | 'taken';
/** What a room tells server F's relay. */
export interface RoomRelay {
/** The room's modems of other projects, when the simulation starts; none when it stops. */
modems(modems: readonly RelayModem[]): void;
/** A frame one of them passes on. */
frame(node: Id, frame: EthernetFrame): void;
}
interface Client {
@@ -159,12 +189,19 @@ export class SimulationRoom {
private lastWriteBack = 0;
/** Each computer's programs as the running engine has them. */
private software = new Map<Id, readonly InstallableProgram[]>();
/** How the relay says the modems of other projects stand. */
private relayStates = new Map<Id, RelayState>();
/** The modems' standing last sent to the clients, as text. */
private modemsSent = '{}';
constructor(
private readonly doc: Y.Doc,
private readonly options: RoomOptions,
) {
this.engine = new NetworkRuntimeEngine(options.seed(), undefined, { wallClock: options.now });
this.engine = new NetworkRuntimeEngine(options.seed(), undefined, {
wallClock: options.now,
...(options.relay === undefined ? {} : { relay: (node: Id, frame: EthernetFrame) => options.relay!.frame(node, frame) }),
});
doc.on('update', () => {
if (this.mode === 'simulation') this.guarded(() => this.syncSoftware());
});
@@ -338,6 +375,7 @@ export class SimulationRoom {
const id = this.nextClient++;
this.clients.set(id, { send, name, topics: new Set(), traceSent: new Map(), droppedSent: undefined, appSent: new Map(), nodeSent: new Map(), viewSent: new Map() });
send(this.state());
if (this.modemsSent !== '{}') send(message({ kind: 'modems', modems: JSON.parse(this.modemsSent) as Record<Id, ModemStatus> }));
return id;
}
@@ -461,7 +499,14 @@ export class SimulationRoom {
this.startedBy = startedBy;
this.lastWall = this.options.now();
this.lastWriteBack = this.lastWall;
this.relayStates.clear();
this.options.relay?.modems(
[...snapshot.remoteLinkConfigurationsByNodeId].flatMap(([node, configuration]) =>
configuration.relay !== undefined && configuration.isEnabled ? [{ node, relay: configuration.relay }] : [],
),
);
this.broadcast(this.state());
this.sendModems();
this.flush([...this.clients.values()], false);
}
@@ -474,10 +519,58 @@ export class SimulationRoom {
this.mode = 'design';
this.startedBy = undefined;
this.lastWall = undefined;
this.options.relay?.modems([]);
this.broadcast(this.state());
this.sendModems();
this.flush([...this.clients.values()], false);
}
/** A frame from the partner of one of the room's modems of another project (server F's relay); a stopped engine takes none. */
relayed(node: Id, frame: EthernetFrame): void {
this.guarded(() => this.engine.receiveRelayedFrame(node, frame));
}
/** How the relay says the room's modems of other projects stand now (those it names). */
relayStatesChanged(states: Readonly<Record<Id, RelayState>>): void {
for (const [node, state] of Object.entries(states)) this.relayStates.set(node, state);
this.sendModems();
}
/** How each modem stands while the simulation runs; none in the design. */
private modemStatuses(): Record<Id, ModemStatus> {
const statuses: Record<Id, ModemStatus> = {};
if (this.mode !== 'simulation') return statuses;
for (const node of this.engine.state.topologySnapshot.nodes) {
const condition = this.engine.remoteLinkCondition(node.id);
if (condition === undefined) continue;
switch (condition.kind) {
case 'active':
statuses[node.id] = 'connected';
break;
case 'relay':
statuses[node.id] = this.options.relay === undefined ? 'unavailable' : (this.relayStates.get(node.id) ?? 'waiting');
break;
case 'unpaired':
case 'ambiguous':
case 'detached':
statuses[node.id] = condition.kind;
break;
default:
break;
}
}
return statuses;
}
/** The modems' standing to every client, when it changed. */
private sendModems(): void {
const modems = this.modemStatuses();
const text = JSON.stringify(modems);
if (text === this.modemsSent) return;
this.modemsSent = text;
this.broadcast(message({ kind: 'modems', modems }));
}
/**
* Starts the simulation anew, as though nothing had run before: stopped as
* by stop (what the devices wrote stays in the document), every card's
@@ -529,7 +622,9 @@ export class SimulationRoom {
this.mode = 'design';
this.startedBy = undefined;
this.lastWall = undefined;
this.options.relay?.modems([]);
this.broadcast(this.state());
this.sendModems();
this.flush([...this.clients.values()], false);
return false;
}
+82 -6
View File
@@ -9,11 +9,11 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it, vi } from 'vitest';
import * as Y from 'yjs';
import { NetworkRuntimeEngine, type Id } from '@mdg-filius/core';
import { docSnapshot, readProject, writeProject } from '@mdg-filius/doc';
import { NetworkRuntimeEngine, type EthernetFrame, type Id } from '@mdg-filius/core';
import { addDevice, connect, docSnapshot, emptyProject, readProject, setHostAddress, setModemConnection, 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';
import { MAXIMUM_CATCH_UP_MILLISECONDS, SimulationRoom, TICK_MILLISECONDS, traceFramesOf, type RelayModem, type RoomRelay } from '../src/index.js';
const corpus = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus');
const SEED = 14;
@@ -26,7 +26,6 @@ function exampleDoc(file = 'java-examples/internetwork_komplett.fls'): Y.Doc {
return doc;
}
/** A room on a clock the test moves, and a client that keeps what it gets. */
/**
* A core that fails at the first frame `node` sends — as it once did at a
* modem, before the core had modems. Every test lets it go again.
@@ -43,9 +42,10 @@ afterEach(() => {
vi.restoreAllMocks();
});
function setUp(doc = exampleDoc()) {
/** A room on a clock the test moves, and a client that keeps what it gets; with `relay`, F's relay to other projects. */
function setUp(doc = exampleDoc(), relay?: RoomRelay) {
let now = 1_000_000;
const room = new SimulationRoom(doc, { now: () => now, seed: () => SEED, epoch: () => 1_700_000_000_000 + now });
const room = new SimulationRoom(doc, { now: () => now, seed: () => SEED, epoch: () => 1_700_000_000_000 + now, ...(relay === undefined ? {} : { relay }) });
const got: HostMessage[] = [];
const client = room.connect((message) => got.push(message));
const project = readProject(doc);
@@ -336,6 +336,82 @@ describe('a simulation room', () => {
expect(text).toContain('4 Paket(e) gesendet, 4 Paket(e) empfangen');
});
it("tells every client how the modems stand: FILIUS' pair connected while it runs, nothing in the design", () => {
const { room, got, send, node } = setUp(exampleDoc('java-examples/modem_beispiel.fls'));
const modems = readProject(exampleDoc('java-examples/modem_beispiel.fls')).graph.nodes.filter((each) => each.kind === 'remoteLink');
expect(modems).toHaveLength(2);
send({ kind: 'start' });
expect(ofKind(got, 'modems')).toEqual([{ v: 1, kind: 'modems', modems: Object.fromEntries(modems.map((each) => [node(each.displayName).id, 'connected'])) }]);
// One who comes later hears it at once.
const later: HostMessage[] = [];
room.connect((message) => later.push(message));
expect(ofKind(later, 'modems')).toEqual(ofKind(got, 'modems'));
send({ kind: 'stop' });
expect(ofKind(got, 'modems').at(-1)).toEqual({ v: 1, kind: 'modems', modems: {} });
});
it('names its modems of other projects to the relay at a start and none at a stop, passes their frames on and takes what comes back', () => {
const doc = new Y.Doc();
writeProject(doc, emptyProject());
let counter = 0;
const newId = () => `00000000-0000-0000-0000-${(++counter).toString(16).padStart(12, 'a')}`;
const [computer, modem] = [newId(), newId()];
addDevice(doc, { kind: 'notebook', id: computer, name: 'Notebook', x: 0, y: 0, newId }, 'setup');
addDevice(doc, { kind: 'remoteLink', id: modem, name: 'Modem', x: 200, y: 0, newId }, 'setup');
connect(doc, { node: computer }, { node: modem }, newId(), 'setup');
setHostAddress(doc, computer, 'ipAddress', '10.0.0.1', 'setup');
setModemConnection(doc, modem, 'offer', 'setup');
const named: (readonly RelayModem[])[] = [];
const passed: EthernetFrame[] = [];
const { room, got, send, run } = setUp(doc, { modems: (modems) => named.push(modems), frame: (_node, frame) => passed.push(frame) });
send({ kind: 'start' });
expect(named).toEqual([[{ node: modem, relay: { role: 'offer' } }]]);
expect(ofKind(got, 'modems').at(-1)!.modems).toEqual({ [modem]: 'waiting' });
room.relayStatesChanged({ [modem]: 'connected' });
room.relayStatesChanged({ [modem]: 'connected' });
// Said once: what did not change is not sent again.
expect(ofKind(got, 'modems').map((each) => each.modems)).toEqual([{ [modem]: 'waiting' }, { [modem]: 'connected' }]);
// An ARP request from the other project: the notebook answers through the modem.
room.relayed(modem, {
identity: 1,
sourceMacAddress: '02:00:00:00:99:01',
destinationMacAddress: 'FF:FF:FF:FF:FF:FF',
payload: { kind: 'arp', packet: { operation: 'request', senderMacAddress: '02:00:00:00:99:01', senderIPAddress: '10.0.0.2', targetMacAddress: 'FF:FF:FF:FF:FF:FF', targetIPAddress: '10.0.0.1' } },
});
run(1_000);
expect(passed.map((frame) => frame.payload.kind === 'arp' && frame.payload.packet.operation)).toEqual(['reply']);
expect(passed[0]!.destinationMacAddress).toBe('02:00:00:00:99:01');
send({ kind: 'stop' });
expect(named.at(-1)).toEqual([]);
// Stopped, it takes nothing.
room.relayed(modem, passed[0]!);
expect(ofKind(got, 'error')).toEqual([]);
});
it('names no modem that is switched off', () => {
const doc = new Y.Doc();
writeProject(doc, emptyProject());
addDevice(doc, { kind: 'remoteLink', id: 'modem', name: 'Modem', x: 0, y: 0, newId: () => 'port' }, 'setup');
setModemConnection(doc, 'modem', 'offer', 'setup');
((doc.getMap<Y.Map<unknown>>('nodes').get('modem')!.get('settings') as Y.Map<Y.Map<unknown>>).get('remoteLink')!).set('isEnabled', false);
const named: (readonly RelayModem[])[] = [];
const { send } = setUp(doc, { modems: (modems) => named.push(modems), frame: () => {} });
send({ kind: 'start' });
expect(named).toEqual([[]]);
});
it('says a modem of another project is unavailable without a relay (a room of one\'s own page)', () => {
const doc = new Y.Doc();
writeProject(doc, emptyProject());
addDevice(doc, { kind: 'remoteLink', id: 'modem', name: 'Modem', x: 0, y: 0, newId: () => 'port' }, 'setup');
addDevice(doc, { kind: 'pc', id: 'pc', name: 'Rechner', x: 0, y: 0, newId: () => 'pc-port' }, 'setup');
connect(doc, { node: 'pc' }, { node: 'modem' }, 'cable', 'setup');
setModemConnection(doc, 'modem', 'join', 'setup');
const { got, send } = setUp(doc);
send({ kind: 'start' });
expect(ofKind(got, 'modems').at(-1)!.modems).toEqual({ modem: 'unavailable' });
});
it('sends a card anew to a client that watches it again', () => {
const { got, send, node, run } = setUp();
const notebook = node('Notebook 1.10');