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