diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2b1783b..d2e869e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/runtime/modemCode.ts b/packages/core/src/runtime/modemCode.ts new file mode 100644 index 0000000..e7eedf4 --- /dev/null +++ b/packages/core/src/runtime/modemCode.ts @@ -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)}`; +} diff --git a/packages/core/test/modemCode.test.ts b/packages/core/test/modemCode.test.ts new file mode 100644 index 0000000..79f9f23 --- /dev/null +++ b/packages/core/test/modemCode.test.ts @@ -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(); + 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'); + }); +}); diff --git a/packages/doc/src/settings.ts b/packages/doc/src/settings.ts index bbd532c..3da1c0d 100644 --- a/packages/doc/src/settings.ts +++ b/packages/doc/src/settings.ts @@ -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) => + 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) => node.get('ports') as Y.Array>; /** diff --git a/packages/doc/src/sheet.ts b/packages/doc/src/sheet.ts index bb6b8b4..4651a51 100644 --- a/packages/doc/src/sheet.ts +++ b/packages/doc/src/sheet.ts @@ -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'); diff --git a/packages/doc/test/sheet.test.ts b/packages/doc/test/sheet.test.ts index c8b66d9..57f174d 100644 --- a/packages/doc/test/sheet.test.ts +++ b/packages/doc/test/sheet.test.ts @@ -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>(ROOTS.nodes).get(notebook)!.get('settings') as Y.Map).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(); diff --git a/packages/protocol/src/sim.ts b/packages/protocol/src/sim.ts index be285ac..2afe501 100644 --- a/packages/protocol/src/sim.ts +++ b/packages/protocol/src/sim.ts @@ -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> } | { 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; diff --git a/packages/server/src/modemRelay.ts b/packages/server/src/modemRelay.ts new file mode 100644 index 0000000..bc7de76 --- /dev/null +++ b/packages/server/src/modemRelay.ts @@ -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>) => 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(); + /** The pairs: an offering modem's key → the joining modem's. */ + private pairs = new Map(); + /** Each modem's partner, both ways. */ + private partners = new Map(); + /** What each room was last told, by modem. */ + private readonly told = new Map(); + + 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(); + 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(); + 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): void { + const byProject = new Map>(); + 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 }; +} diff --git a/packages/server/src/simChannel.ts b/packages/server/src/simChannel.ts index 8d92ed0..1d1ab07 100644 --- a/packages/server/src/simChannel.ts +++ b/packages/server/src/simChannel.ts @@ -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> }; 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 }; diff --git a/packages/server/src/simRooms.ts b/packages/server/src/simRooms.ts index 45b6994..1911002 100644 --- a/packages/server/src/simRooms.ts +++ b/packages/server/src/simRooms.ts @@ -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>(); 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). */ diff --git a/packages/server/src/simWorker.ts b/packages/server/src/simWorker.ts index 75f0c82..e9dc83a 100644 --- a/packages/server/src/simWorker.ts +++ b/packages/server/src/simWorker.ts @@ -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 }); diff --git a/packages/server/test/modemRelay.test.ts b/packages/server/test/modemRelay.test.ts new file mode 100644 index 0000000..b2bb484 --- /dev/null +++ b/packages/server/test/modemRelay.test.ts @@ -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 }[] = []; + 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' } }]); + }); +}); diff --git a/packages/server/test/sim.test.ts b/packages/server/test/sim.test.ts index 9815192..f266492 100644 --- a/packages/server/test/sim.test.ts +++ b/packages/server/test/sim.test.ts @@ -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 => 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()]; diff --git a/packages/sim/src/room.ts b/packages/sim/src/room.ts index 5da0bfc..995e52b 100644 --- a/packages/sim/src/room.ts +++ b/packages/sim/src/room.ts @@ -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(); + /** How the relay says the modems of other projects stand. */ + private relayStates = new Map(); + /** 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 })); 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>): 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 { + const statuses: Record = {}; + 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; } diff --git a/packages/sim/test/room.test.ts b/packages/sim/test/room.test.ts index 904e64e..de25b66 100644 --- a/packages/sim/test/room.test.ts +++ b/packages/sim/test/room.test.ts @@ -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>('nodes').get('modem')!.get('settings') as Y.Map>).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');