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
+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()];