feat: F5.3a the simulation rooms on server F

One room per project for everyone in it, held by a few worker_threads,
each room with a replica of the document that Hocuspocus' direct
connection keeps open: changes go in, what the room writes comes back.
/sim?project=... signs in as /sync does and says refusals and partings
as the socket's closing; only a write grant changes the room. The state
names who started the simulation. After the last person the room stays
60 s, then stops, writes back and ends. Freezing stops a running
simulation and refuses to start one, deleting ends the room without
writing back, revoking lets the person go from the room too; a failed
thread ends its rooms, stopping the service stops them all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 14:29:11 +00:00
co-authored by Claude Opus 5.5
parent 5ded84cb33
commit 8cfc518f0c
17 changed files with 1033 additions and 43 deletions
+4 -2
View File
@@ -1,11 +1,13 @@
// Server F (plan §8): the shared documents, the sign-in and the dashboard's
// service; later the simulation rooms.
// Server F (plan §8): the shared documents, the simulation rooms, the
// sign-in and the dashboard's service.
export * from './auth.js';
export * from './log.js';
export * from './persistence.js';
export * from './server.js';
export * from './service.js';
export * from './sessions.js';
export * from './simChannel.js';
export * from './simRooms.js';
export * from './store.js';
export * from './sync.js';
export * from './ticket.js';
+3 -1
View File
@@ -4,7 +4,8 @@
// origins the editor is served from, separated by commas). Without one of
// them F does not start: an empty secret would let anyone sign. The cookie
// goes over HTTPS only, unless FILIUS_INSECURE_COOKIE=1 (a developer's
// machine without HTTPS).
// machine without HTTPS). The rooms' thread is the bundle beside this one
// (dist/simWorker.mjs).
import { jsonLog } from './log.js';
import { startServer } from './server.js';
@@ -33,6 +34,7 @@ const server = await startServer({
serviceKey: config.FILIUS_SERVICE_KEY!,
origins: config.FILIUS_ORIGINS!.split(',').map((origin) => origin.trim()),
secureCookie,
workerUrl: new URL('./simWorker.mjs', import.meta.url),
});
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
process.once(signal, () => {
+75 -10
View File
@@ -1,24 +1,37 @@
// Server F's front door (plan §8.2): one HTTP server — /healthz for the
// proxy, /__auth for the editor's frame (ticket in, cookie out), /svc/* for
// the dashboard (with the service key), the WebSocket /sync for the shared
// documents; anything else is not found. A WebSocket from a page of another
// the dashboard (with the service key), the WebSockets /sync for the shared
// documents and /sim?project=… for the simulation rooms (the session's grant
// decides there as at /sync; a refusal closes the socket with its reason);
// anything else is not found. A WebSocket from a page of another
// origin is refused (a page elsewhere under the same domain would bring the
// person's cookie along: SameSite only tells sites apart). Closed, it first
// takes no one new (a client lets go and comes back at once, which would
// keep its document open), then lets everyone go, waits until every
// document is stored and unloaded, and stops.
// keep its document open), stops every room (what it left written back),
// then lets everyone go, waits until every document is stored and unloaded,
// and stops. A /sim socket that answers no ping for PING_MILLISECONDS is
// let go (the proxy keeps an idle socket an hour; a tablet that went away
// says nothing).
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import type { AddressInfo, Socket } from 'node:net';
import { WebSocketServer } from 'ws';
import { SIM_REFUSED_CODE, type SyncRefusal } from '@mdg-filius/protocol';
import { WebSocketServer, type WebSocket } from 'ws';
import { exchangeTicket } from './auth.js';
import { errorText, type Log } from './log.js';
import { handleService } from './service.js';
import { createSync, type Sync, type SyncOptions } from './sync.js';
import { holdMessages, SimRooms } from './simRooms.js';
import { cookieValue, createSync, SESSION_COOKIE, type Sync, type SyncOptions } from './sync.js';
import { isUuid } from './ticket.js';
export const PING_MILLISECONDS = 30_000;
/** A message to a room at most. */
export const SIM_MESSAGE_BYTES = 1024 * 1024;
export interface FiliusServer {
readonly port: number;
readonly sync: Sync;
readonly sims: SimRooms;
close(): Promise<void>;
}
@@ -31,6 +44,11 @@ export interface ServerOptions extends SyncOptions {
/** The origins the editor is served from (FILIUS_ORIGINS): a WebSocket from another is refused. */
readonly origins: readonly string[];
readonly secureCookie: boolean;
/** The bundled room thread (dist/simWorker.mjs). */
readonly workerUrl: URL;
readonly simThreads?: number;
readonly lingerMilliseconds?: number;
readonly pingMilliseconds?: number;
}
function refuse(socket: Socket, status: string): void {
@@ -45,12 +63,48 @@ function json(response: ServerResponse, status: number, value: object): void {
export async function startServer(options: ServerOptions): Promise<FiliusServer> {
const sync = createSync(options);
const { hocuspocus } = sync;
const sims = new SimRooms({
hocuspocus,
log: options.log,
workerUrl: options.workerUrl,
...(options.simThreads === undefined ? {} : { threads: options.simThreads }),
...(options.lingerMilliseconds === undefined ? {} : { lingerMilliseconds: options.lingerMilliseconds }),
});
const sockets = new WebSocketServer({ noServer: true });
const simSockets = new WebSocketServer({ noServer: true, maxPayload: SIM_MESSAGE_BYTES });
const alive = new WeakSet<WebSocket>();
const pings = setInterval(() => {
for (const socket of simSockets.clients) {
if (!alive.delete(socket)) socket.terminate();
else socket.ping();
}
}, options.pingMilliseconds ?? PING_MILLISECONDS);
let closing = false;
const admit = async (socket: WebSocket, request: IncomingMessage) => {
alive.add(socket);
socket.on('pong', () => alive.add(socket));
const held = holdMessages(socket);
const refuse = (reason: SyncRefusal, user?: string) => {
options.log.info('sim.refused', { reason, ...(user === undefined ? {} : { user }) });
held.release();
socket.close(SIM_REFUSED_CODE, reason);
};
const project = new URL(request.url ?? '/', 'http://filius').searchParams.get('project');
if (!isUuid(project)) return refuse('notAProject');
const key = cookieValue(request.headers.cookie, SESSION_COOKIE);
const access = key === undefined ? ({ kind: 'notSignedIn' } as const) : await options.sessions.access(key, project);
if (access.kind !== 'granted') return refuse(access.kind, access.kind === 'notSignedIn' ? undefined : access.person.login);
try {
await sims.join(socket, project, { user: access.person.user, login: access.person.login, name: access.person.name, write: access.level === 'write' }, access.frozen, held);
} catch (error) {
options.log.warn('sim.openFailed', { project, error: errorText(error) });
refuse('unknownProject', access.person.login);
}
};
const handle = async (request: IncomingMessage, response: ServerResponse, path: string) => {
if (request.method === 'GET' && path === '/healthz') json(response, 200, { status: 'ok', documents: hocuspocus.getDocumentsCount(), connections: hocuspocus.getConnectionsCount() });
if (request.method === 'GET' && path === '/healthz') json(response, 200, { status: 'ok', documents: hocuspocus.getDocumentsCount(), connections: hocuspocus.getConnectionsCount(), rooms: sims.stats().rooms });
else if (request.method === 'GET' && path === '/__auth') await exchangeTicket(request, response, options);
else if (path.startsWith('/svc/')) await handleService(request, response, path, { ...options, sync });
else if (path.startsWith('/svc/')) await handleService(request, response, path, { ...options, sync, sims });
else json(response, 404, { error: 'notFound' });
};
const http: Server = createServer((request, response) => {
@@ -63,7 +117,7 @@ export async function startServer(options: ServerOptions): Promise<FiliusServer>
});
http.on('upgrade', (request, socket, head) => {
const path = new URL(request.url ?? '/', 'http://filius').pathname;
if (path !== '/sync') {
if (path !== '/sync' && path !== '/sim') {
refuse(socket as Socket, '404 Not Found');
return;
}
@@ -77,7 +131,14 @@ export async function startServer(options: ServerOptions): Promise<FiliusServer>
refuse(socket as Socket, '503 Service Unavailable');
return;
}
sockets.handleUpgrade(request, socket, head, (ws) => hocuspocus.handleConnection(ws, request));
if (path === '/sim')
simSockets.handleUpgrade(request, socket, head, (ws) =>
admit(ws, request).catch((error: unknown) => {
options.log.error('sim.admitFailed', { error: errorText(error) });
ws.close(1011, 'internal');
}),
);
else sockets.handleUpgrade(request, socket, head, (ws) => hocuspocus.handleConnection(ws, request));
});
await new Promise<void>((resolve) => http.listen(options.port ?? 0, options.host ?? '0.0.0.0', resolve));
const port = (http.address() as AddressInfo).port;
@@ -85,8 +146,12 @@ export async function startServer(options: ServerOptions): Promise<FiliusServer>
return {
port,
sync,
sims,
async close() {
closing = true;
clearInterval(pings);
await sims.close();
simSockets.close();
hocuspocus.closeConnections();
// Hocuspocus' close is a message on the socket, after which a client signs in again on it: the sockets go too.
for (const socket of sockets.clients) socket.close(1001, 'service stopping');
+18 -11
View File
@@ -1,10 +1,12 @@
// The dashboard's way into F (plan §8.5): /svc/*, with the service key and
// without a person — the dashboard has decided who may, F does it. Make a
// project (an empty FILIUS project), delete one (whoever is in it is let
// go), freeze or thaw one (whoever is in it reads from then on, or writes
// again with a write grant), end a person's grant to a project or all their
// sessions (whoever is connected is let go), and list who is signed in to
// what (the dashboard checks it against its shares). Snapshots, restoring,
// project (an empty FILIUS project), delete one (whoever is in it or in its
// room is let go, the room ends without writing back), freeze or thaw one
// (whoever is in it reads from then on, or writes again with a write grant;
// a running simulation stops, what it left written back), end a person's
// grant to a project or all their sessions (whoever is connected, to the
// document or the room, is let go), and list who is signed in to what (the
// dashboard checks it against its shares). Snapshots, restoring,
// copying and the .fls come with the commits (F6).
import { createHash, timingSafeEqual } from 'node:crypto';
@@ -14,6 +16,7 @@ import { emptyProject, writeProject } from '@mdg-filius/doc';
import type { SyncClosing } from '@mdg-filius/protocol';
import type { Log } from './log.js';
import type { SessionStore } from './sessions.js';
import type { SimRooms } from './simRooms.js';
import type { DocStore } from './store.js';
import { accessMessage, type ConnectionContext, type Sync } from './sync.js';
import { isUuid } from './ticket.js';
@@ -28,6 +31,7 @@ export interface ServiceOptions {
readonly store: DocStore;
readonly sessions: SessionStore;
readonly sync: Sync;
readonly sims: SimRooms;
readonly log: Log;
readonly serviceKey: string;
}
@@ -92,7 +96,7 @@ export async function handleService(request: IncomingMessage, response: ServerRe
}
}
async function route(request: IncomingMessage, path: string, { store, sessions, sync, log }: ServiceOptions): Promise<[number, object]> {
async function route(request: IncomingMessage, path: string, { store, sessions, sync, sims, log }: ServiceOptions): Promise<[number, object]> {
const close = (connections: ReturnType<Sync['connections']>, reason: SyncClosing) => {
for (const connection of connections) connection.close({ code: FORBIDDEN, reason });
return connections.length;
@@ -111,7 +115,7 @@ async function route(request: IncomingMessage, path: string, { store, sessions,
// Not stored any more, then gone (the grants with it), then everyone let go: coming back, they find no project.
await sync.discard(id);
if (!(await store.remove(id))) throw new HttpError(404, 'unknownProject');
const disconnected = close(sync.connections(id), 'deleted');
const disconnected = (await sims.drop(id)) + close(sync.connections(id), 'deleted');
log.info('svc.deleted', { project: id, disconnected });
return [200, { deleted: true, disconnected }];
}
@@ -120,6 +124,7 @@ async function route(request: IncomingMessage, path: string, { store, sessions,
const frozen = (await body(request)).frozen;
if (typeof frozen !== 'boolean') throw new HttpError(400, 'frozenNotABoolean');
if (!(await store.freeze(id, frozen))) throw new HttpError(404, 'unknownProject');
await sims.freeze(id, frozen);
for (const connection of sync.connections(id)) {
const readOnly = frozen || contextOf(connection).level === 'read';
if (connection.readOnly === readOnly) continue;
@@ -135,10 +140,12 @@ async function route(request: IncomingMessage, path: string, { store, sessions,
const only = given.project === undefined ? undefined : uuid(given.project, 'notAProject');
// The grant first: whoever is let go and comes back at once is refused.
const revoked = await sessions.revoke(user, only);
const disconnected = close(
sync.connections(only).filter((connection) => contextOf(connection).person.user === user),
'revoked',
);
const disconnected =
sims.disconnect(user, only) +
close(
sync.connections(only).filter((connection) => contextOf(connection).person.user === user),
'revoked',
);
log.info('svc.revoked', { user, ...(only === undefined ? {} : { project: only }), revoked, disconnected });
return [200, { revoked, disconnected }];
}
+28
View File
@@ -0,0 +1,28 @@
// The messages between server F's main thread and a thread that holds
// simulation rooms (plan §8.2: rooms in worker_threads, so that a large
// network does not hold up the shared documents). A thread holds several
// rooms, each with a replica of its project's document: the document's
// updates go both ways, a person's messages go in, the room's come out,
// each for one person (a room tells its people apart; the main thread
// numbers them).
import type { HostMessage } from '@mdg-filius/protocol';
export type ToRoomThread =
/** A room for a project, its document as it is now. */
| { readonly type: 'open'; readonly room: string; readonly state: Uint8Array }
| { readonly type: 'doc'; readonly room: string; readonly update: Uint8Array }
/** A person in the room, by name. */
| { readonly type: 'connect'; readonly room: string; readonly client: number; readonly name: string }
| { readonly type: 'client'; readonly room: string; readonly client: number; readonly message: unknown }
| { readonly type: 'disconnect'; readonly room: string; readonly client: number }
/** The simulation stopped (what it left written back first); answered by `stopped`. */
| { readonly type: 'stop'; readonly room: string }
/** 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 };
export type FromRoomThread =
| { readonly type: 'doc'; readonly room: string; readonly update: Uint8Array }
| { readonly type: 'host'; readonly room: string; readonly client: number; readonly message: HostMessage }
| { readonly type: 'stopped'; readonly room: string }
| { readonly type: 'closed'; readonly room: string };
+339
View File
@@ -0,0 +1,339 @@
// The simulation rooms on F (plan §6): one room for a project, shared by
// everyone in it, held by one of a few threads (worker_threads; the one
// with the fewest rooms takes a new one). A room keeps its project's
// document open on F (a direct connection: what the simulation leaves goes
// into the document even when no one has it open) and gets every change of
// it; what the room writes comes back as the room's own change.
//
// Who may do what: everyone in the project watches; only with a write grant
// does one start, stop, set the speed, hold "Pakete verwerfen", clear the
// capture or act in a window (WRITING_KINDS; else `readOnly`). A frozen
// project's simulation does not start (`frozen`); freezing stops a running
// one, what it left written back first.
//
// 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
// fails ends its rooms: their people are let go (SIM_ROOM_FAILED_CODE) and
// find a new room, in design mode, when they come back.
import { availableParallelism } from 'node:os';
import { Worker } from 'node:worker_threads';
import type { DirectConnection, Hocuspocus } from '@hocuspocus/server';
import * as Y from 'yjs';
import { SIM_CLOSED_CODE, SIM_ROOM_FAILED_CODE, WRITING_KINDS, type ClientMessageKind, type HostMessage, type SyncClosing } from '@mdg-filius/protocol';
import type WebSocket from 'ws';
import { errorText, type Log } from './log.js';
import type { FromRoomThread, ToRoomThread } from './simChannel.js';
export const LINGER_MILLISECONDS = 60_000;
/** Who is in a room. */
export interface SimPerson {
readonly user: string;
readonly login: string;
readonly name: string;
/** A write grant: may change the room. */
readonly write: boolean;
}
export interface SimRoomsOptions {
readonly hocuspocus: Hocuspocus;
readonly log: Log;
/** The bundled room thread (dist/simWorker.mjs). */
readonly workerUrl: URL;
/** How many threads hold rooms; all cores but one by default. */
readonly threads?: number;
readonly lingerMilliseconds?: number;
}
interface Thread {
worker: Worker;
readonly rooms: Set<Room>;
}
interface Room {
readonly project: string;
readonly thread: Thread;
readonly direct: DirectConnection;
readonly doc: Y.Doc;
readonly clients: Map<number, { readonly socket: WebSocket; readonly person: SimPerson }>;
readonly forward: (update: Uint8Array, origin: unknown) => void;
/** What waits for the thread's answer to `stop` and `close`. */
readonly waiting: { stopped: (() => void)[]; closed: (() => void)[] };
frozen: boolean;
linger: ReturnType<typeof setTimeout> | undefined;
ended: boolean;
}
type RawData = Buffer | ArrayBuffer | Buffer[];
/** What a socket sent while F looked the person up, held until the room hears it. */
export interface HeldMessages {
readonly messages: readonly RawData[];
/** Stops holding: the room's listener takes over. */
release(): void;
}
/** Holds a socket's messages from now on. */
export function holdMessages(socket: WebSocket): HeldMessages {
const messages: RawData[] = [];
const hold = (data: RawData) => messages.push(data);
socket.on('message', hold);
return { messages, release: () => socket.off('message', hold) };
}
const text = (data: RawData) => (Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data as ArrayBuffer)).toString('utf8');
export class SimRooms {
private readonly threads: Thread[] = [];
private readonly rooms = new Map<string, Promise<Room>>();
private readonly ending = new Map<string, Promise<void>>();
private nextClient = 1;
private closing = false;
constructor(private readonly options: SimRoomsOptions) {
const count = options.threads ?? Math.max(1, availableParallelism() - 1);
for (let index = 0; index < count; index++) this.threads.push(this.spawn());
}
private spawn(): Thread {
const thread: Thread = { worker: new Worker(this.options.workerUrl), rooms: new Set() };
thread.worker.on('message', (message: FromRoomThread) => this.fromThread(thread, message));
thread.worker.on('error', (error) => this.options.log.error('sim.threadError', { error: errorText(error) }));
thread.worker.on('exit', (code) => {
if (!this.closing) this.threadFailed(thread, code);
});
return thread;
}
private threadFailed(thread: Thread, code: number): void {
this.options.log.error('sim.threadFailed', { code, rooms: thread.rooms.size });
for (const room of thread.rooms) {
for (const { socket } of room.clients.values()) socket.close(SIM_ROOM_FAILED_CODE, 'roomFailed');
this.forget(room);
void room.direct.disconnect();
}
this.threads[this.threads.indexOf(thread)] = this.spawn();
}
private post(room: Room, message: ToRoomThread): void {
room.thread.worker.postMessage(message);
}
private fromThread(thread: Thread, message: FromRoomThread): void {
const room = [...thread.rooms].find((each) => each.project === message.room);
if (room === undefined) return;
switch (message.type) {
case 'doc':
Y.applyUpdate(room.doc, message.update, room);
return;
case 'host':
room.clients.get(message.client)?.socket.send(JSON.stringify(message.message));
return;
case 'stopped':
case 'closed':
room.waiting[message.type].shift()?.();
return;
}
}
/** The room of a project, made when there is none (its document loaded; refused if F does not know the project). */
private async roomOf(project: string, frozen: boolean): Promise<Room> {
// A room ending is let go first: its document's direct connection must not meet Hocuspocus unloading it.
await this.ending.get(project);
let made = this.rooms.get(project);
if (made === undefined) {
made = this.open(project, frozen);
this.rooms.set(project, made);
made.catch(() => this.rooms.delete(project));
}
return made;
}
private async open(project: string, frozen: boolean): Promise<Room> {
const direct = await this.options.hocuspocus.openDirectConnection(project, { room: true });
const thread = this.threads.reduce((fewest, each) => (each.rooms.size < fewest.rooms.size ? each : fewest));
const doc = direct.document!;
const room: Room = {
project,
thread,
direct,
doc,
clients: new Map(),
forward: (update, origin) => {
if (origin !== room) this.post(room, { type: 'doc', room: project, update });
},
waiting: { stopped: [], closed: [] },
frozen,
linger: undefined,
ended: false,
};
thread.rooms.add(room);
this.post(room, { type: 'open', room: project, state: Y.encodeStateAsUpdate(doc) });
doc.on('update', room.forward);
this.options.log.info('sim.roomOpened', { project });
return room;
}
/**
* A person in a project's room: the socket's messages go to the room, the
* room's to the socket, with what it sent before (`held`, while F looked
* the person up). Rejected if F does not know the project.
*/
async join(socket: WebSocket, project: string, person: SimPerson, frozen: boolean, held: HeldMessages = holdMessages(socket)): Promise<void> {
const room = await this.roomOf(project, frozen);
// Ended while it was made (its project deleted, its thread failed): the next room.
if (room.ended) return this.join(socket, project, person, frozen, held);
// Gone while the room was made: a room no one came to ends as one everyone left.
if (socket.readyState !== socket.OPEN) {
held.release();
if (room.clients.size === 0 && room.linger === undefined && !room.ended) this.lingerOn(room);
return;
}
const client = this.nextClient++;
room.clients.set(client, { socket, person });
if (room.linger !== undefined) clearTimeout(room.linger);
room.linger = undefined;
this.post(room, { type: 'connect', room: project, client, name: person.name });
this.options.log.info('sim.joined', { project, user: person.login, write: person.write });
const receive = (data: RawData) => this.receive(room, client, person, data);
held.release();
for (const data of held.messages) receive(data);
socket.on('message', receive);
socket.on('close', () => this.leave(room, client));
}
private receive(room: Room, client: number, person: SimPerson, data: RawData): void {
const answer = (message: Omit<Extract<HostMessage, { kind: 'error' }>, 'v'>) => room.clients.get(client)?.socket.send(JSON.stringify({ v: 1, ...message }));
let message: unknown;
try {
message = JSON.parse(text(data));
} catch {
answer({ kind: 'error', code: 'badMessage', detail: 'not JSON' });
return;
}
const { kind, reqId } = (typeof message === 'object' && message !== null ? message : {}) as { kind?: unknown; reqId?: unknown };
const request = typeof reqId === 'string' ? { reqId } : {};
if (WRITING_KINDS.includes(kind as ClientMessageKind)) {
if (!person.write) {
answer({ kind: 'error', code: 'readOnly', ...request });
return;
}
if (kind === 'start' && room.frozen) {
answer({ kind: 'error', code: 'frozen', ...request });
return;
}
if (kind === 'start' || kind === 'stop') this.options.log.info(`sim.${kind}`, { project: room.project, user: person.login });
}
this.post(room, { type: 'client', room: room.project, client, message });
}
private leave(room: Room, client: number): void {
if (!room.clients.delete(client) || room.ended) return;
this.post(room, { type: 'disconnect', room: room.project, client });
if (room.clients.size === 0) this.lingerOn(room);
}
private lingerOn(room: Room): void {
room.linger = setTimeout(() => void this.end(room, true), this.options.lingerMilliseconds ?? LINGER_MILLISECONDS);
}
/** The room no longer reachable: a new one is made for whoever comes. */
private forget(room: Room): void {
room.ended = true;
if (room.linger !== undefined) clearTimeout(room.linger);
room.doc.off('update', room.forward);
room.thread.rooms.delete(room);
void this.rooms.get(room.project)?.then(
(current) => {
if (current === room) this.rooms.delete(room.project);
},
() => {},
);
for (const resolve of [...room.waiting.stopped, ...room.waiting.closed]) resolve();
}
private ask(room: Room, message: Extract<ToRoomThread, { type: 'stop' | 'close' }>): Promise<void> {
return new Promise((resolve) => {
room.waiting[message.type === 'stop' ? 'stopped' : 'closed'].push(resolve);
this.post(room, message);
});
}
/** Ends a room: stopped first with `writeBack` (what it left in the document), else as it is; its document let go. */
private end(room: Room, writeBack: boolean): Promise<void> {
if (room.ended) return this.ending.get(room.project) ?? Promise.resolve();
const done = (async () => {
this.rooms.delete(room.project);
await this.ask(room, { type: 'close', room: room.project, writeBack });
this.forget(room);
await room.direct.disconnect();
this.options.log.info('sim.roomClosed', { project: room.project, writeBack });
})();
room.ended = true;
this.ending.set(room.project, done);
void done.finally(() => {
if (this.ending.get(room.project) === done) this.ending.delete(room.project);
});
return done;
}
private async existing(project: string): Promise<Room | undefined> {
const room = await this.rooms.get(project)?.catch(() => undefined);
return room === undefined || room.ended ? undefined : room;
}
/** A project frozen (its running simulation stopped, what it left written back) or thawed. */
async freeze(project: string, frozen: boolean): Promise<void> {
const room = await this.existing(project);
if (room === undefined) return;
room.frozen = frozen;
if (frozen) await this.ask(room, { type: 'stop', room: project });
}
/** A project deleted: its room ends without writing back, its people are let go; how many. */
async drop(project: string): Promise<number> {
const room = await this.existing(project);
if (room === undefined) return 0;
const sockets = [...room.clients.values()].map(({ socket }) => socket);
await this.end(room, false);
return this.letGo(sockets, 'deleted');
}
/** A person's grant ended, to one project or to all: their sockets let go; how many. */
disconnect(user: string, project?: string): number {
const sockets: WebSocket[] = [];
for (const thread of this.threads)
for (const room of thread.rooms) {
if (project !== undefined && room.project !== project) continue;
for (const { socket, person } of room.clients.values()) if (person.user === user) sockets.push(socket);
}
return this.letGo(sockets, 'revoked');
}
private letGo(sockets: readonly WebSocket[], reason: SyncClosing): number {
for (const socket of sockets) socket.close(SIM_CLOSED_CODE, reason);
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[] } {
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) };
}
/** The threads' workers (for the stats' tests: one may be ended to see the rooms go). */
get workers(): readonly Worker[] {
return this.threads.map((thread) => thread.worker);
}
/** Every room stopped and ended (what it left written back), every person let go, the threads ended. */
async close(): Promise<void> {
const rooms = this.threads.flatMap((thread) => [...thread.rooms]);
await Promise.all(rooms.map((room) => this.end(room, true)));
this.closing = true;
for (const room of rooms) for (const { socket } of room.clients.values()) socket.close(1001, 'service stopping');
await Promise.all(this.threads.map((thread) => thread.worker.terminate()));
}
}
+73
View File
@@ -0,0 +1,73 @@
// A thread of server F that holds simulation rooms (simChannel.ts): each
// room a replica of its project's document and the room that reads it, all
// ticking together every TICK_MILLISECONDS. Bundled on its own
// (dist/simWorker.mjs), started by the main thread.
import { parentPort } from 'node:worker_threads';
import * as Y from 'yjs';
import { SimulationRoom, TICK_MILLISECONDS } from '@mdg-filius/sim';
import type { FromRoomThread, ToRoomThread } from './simChannel.js';
/** The origin of what came from the main thread: not sent back. */
const MAIN_ORIGIN = 'main';
interface HeldRoom {
readonly doc: Y.Doc;
readonly room: SimulationRoom;
/** The main thread's number of a person → the room's. */
readonly clients: Map<number, number>;
}
const port = parentPort!;
const rooms = new Map<string, HeldRoom>();
const post = (message: FromRoomThread) => port.postMessage(message);
port.on('message', (message: ToRoomThread) => {
if (message.type === 'open') {
const doc = new Y.Doc();
Y.applyUpdate(doc, message.state, MAIN_ORIGIN);
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) });
rooms.set(message.room, { doc, room, clients: new Map() });
return;
}
const held = rooms.get(message.room);
if (held === undefined) return;
switch (message.type) {
case 'doc':
Y.applyUpdate(held.doc, message.update, MAIN_ORIGIN);
return;
case 'connect':
held.clients.set(
message.client,
held.room.connect((output) => post({ type: 'host', room: message.room, client: message.client, message: output }), message.name),
);
return;
case 'client': {
const client = held.clients.get(message.client);
if (client !== undefined) held.room.receive(client, message.message);
return;
}
case 'disconnect': {
const client = held.clients.get(message.client);
held.clients.delete(message.client);
if (client !== undefined) held.room.disconnect(client);
return;
}
case 'stop':
held.room.stop();
post({ type: 'stopped', room: message.room });
return;
case 'close':
if (message.writeBack) held.room.stop();
rooms.delete(message.room);
post({ type: 'closed', room: message.room });
return;
}
});
setInterval(() => {
for (const held of rooms.values()) held.room.tick();
}, TICK_MILLISECONDS);