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