feat: F5.6c measured and accepted on test-filius; tickets up to 5 s ahead of F's clock

test-filius refused every ticket as from the future: its clock was 886 ms
behind the dashboard's machine, with NTP still 8 ms (one ticket in 125, as a
ticket carries whole seconds). F now takes a ticket signed up to 5 s ahead
(TICKET_AHEAD_SECONDS); itsdangerous refuses one second, and the oracle
keeps that on record.

tools/live measures and accepts on a live F, with fresh projects each run:
m3 (200 rooms of the largest example with pings: probe p95 37 ms), m4 (63 KB
document; dragging makes an update per pointer move, 59/s), m5 (an hour
through the proxy), acceptance (two browsers, 14 checks, passed three times)
and clock (F's clock against ours).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 19:22:22 +00:00
co-authored by Claude Opus 5.5
parent 087721cba3
commit 416d5fdea3
15 changed files with 630 additions and 7 deletions
+161
View File
@@ -0,0 +1,161 @@
// Acceptance of F5 on a live server F (plan §F5): two real browsers in one
// fresh project. At the same time both move a device; one places a computer,
// the other cables it while the first moves another; then one starts the
// simulation, the other makes a file and pings in a computer's command line,
// the first sees that same command line and types in it too; the second
// stops. In the end Mia's drawing, Ben's drawing and the document F stored
// are the same, and the file made in the simulation is in the document.
// Every run makes its own project and deletes it (Scratch's lesson 9).
//
// npx vite-node tools/live/acceptance.ts
import { randomUUID } from 'node:crypto';
import { chromium, type Browser, type Page } from '@playwright/test';
import { nodeFiles, readProject } from '@mdg-filius/doc';
import { drag, onDevice } from '../../packages/editor/browser/editorPage.js';
import { cleanUp, joinDoc, seededProject, signIn, signInAddress } from './lib.js';
const results: [string, boolean, string][] = [];
const check = (name: string, ok: boolean, detail = '') => {
results.push([name, ok, detail]);
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail === '' ? '' : ` (${detail})`}`);
};
async function eventually(name: string, test: () => Promise<boolean>, ms = 10_000, detail?: () => Promise<string>) {
for (const end = Date.now() + ms; ;) {
if (await test().catch(() => false)) return check(name, true);
if (Date.now() > end) return check(name, false, detail === undefined ? 'timed out' : await detail());
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
const boxes = (page: Page) => page.locator('g.device[data-node]').evaluateAll((all) => Object.fromEntries(all.map((each) => [each.getAttribute('data-node'), each.getAttribute('data-box')])));
const linkCount = (page: Page) => page.locator('path[data-link]').count();
const canvas = (page: Page) => page.locator('svg.filius-canvas');
async function person(browser: Browser, project: string, name: string, user: string): Promise<Page> {
const context = await browser.newContext({ viewport: { width: 1400, height: 900 } });
const page = await context.newPage();
page.on('pageerror', (error) => check(`${name}: no page error`, false, error.message));
await page.goto(signInAddress(project, { name, user }));
await page.waitForSelector('svg.filius-canvas');
pages.push(page);
return page;
}
async function commandLine(page: Page, id: string) {
const at = await onDevice(page, id);
await page.mouse.click(at.x, at.y);
const desktop = page.locator(`section[data-desktop="${id}"]`);
await desktop.locator('[data-program="commandPrompt"]').click();
const window = desktop.locator('[data-program-window="commandPrompt"]');
return { input: window.locator('input[name="terminalInput"]'), text: window.locator('.terminal-text') };
}
const browser = await chromium.launch();
const pages: Page[] = [];
try {
const { project, example } = await seededProject('java-examples/internetwork_komplett.fls');
const node = (name: string) => example.graph.nodes.find((each) => each.displayName === name)!.id;
// A computer's address is its device's setting (a router's are its cards').
const ipOf = (name: string) => example.deviceConfigurations.get(node(name))!.ipAddress;
const [miaUser, benUser] = [randomUUID(), randomUUID()];
const mia = await person(browser, project, 'Mia', miaUser);
const ben = await person(browser, project, 'Ben', benUser);
await eventually('Mia and Ben see each other', async () => (await mia.locator(`[data-person="${benUser}"]`).isVisible()) && (await ben.locator(`[data-person="${miaUser}"]`).isVisible()));
const devices = await mia.locator('g.device').count();
const links = await linkCount(mia);
// Both move a device at the same time.
const before = await boxes(mia);
const shift = async (page: Page, id: string, dx: number, dy: number) => {
const from = await onDevice(page, id);
await drag(page, from, { x: from.x + dx, y: from.y + dy });
};
// Onto free ground: a device moved onto another would take the next click meant for the one below.
await Promise.all([shift(mia, node('Rechner 3.10'), -50, 60), shift(ben, node('Notebook 4.10'), 80, 60)]);
await eventually('both moves arrive at both', async () => {
const [m, b] = [await boxes(mia), await boxes(ben)];
return JSON.stringify(m) === JSON.stringify(b) && m[node('Rechner 3.10')] !== before[node('Rechner 3.10')] && m[node('Notebook 4.10')] !== before[node('Notebook 4.10')];
});
// Ben places a computer; Mia cables it to Switch 1 while Ben moves the web server.
const known = new Set(Object.keys(await boxes(ben)));
const palette = (await ben.locator('[data-palette="pc"]').boundingBox())!;
const surface = (await canvas(ben).boundingBox())!;
await drag(ben, { x: palette.x + palette.width / 2, y: palette.y + palette.height / 2 }, { x: surface.x + 330, y: surface.y + 230 });
await ben.keyboard.press('Escape');
let placed = '';
await eventually('the computer Ben placed is at Mia too', async () => {
placed = Object.keys(await boxes(ben)).find((id) => !known.has(id)) ?? '';
return placed !== '' && (await mia.locator(`g.device[data-node="${placed}"]`).count()) === 1;
});
await mia.locator('[data-palette="cable"]').click();
await Promise.all([(async () => drag(mia, await onDevice(mia, placed), await onDevice(mia, node('Switch 1'))))(), shift(ben, node('Webserver 5.10'), 70, -40)]);
await mia.locator('[data-palette="cable"]').click();
await eventually(
'the cable and the move arrive at both',
async () => (await linkCount(mia)) === links + 1 && (await linkCount(ben)) === links + 1 && JSON.stringify(await boxes(mia)) === JSON.stringify(await boxes(ben)),
);
// Mia starts; the simulation runs for both, named after her.
await mia.locator('button[name="simulationMode"]').click();
await eventually(
'the simulation runs for both, started by Mia',
async () => (await canvas(ben).getAttribute('data-mode')) === 'simulation' && (await ben.locator('[data-note="startedBy"]').textContent()) === 'Simulation gestartet von Mia',
);
// Ben makes a file and pings in Notebook 1.10's command line; Mia sees the same and types in it too.
const notebook = node('Notebook 1.10');
const benLine = await commandLine(ben, notebook);
await benLine.input.fill('touch acceptance.txt');
await benLine.input.press('Enter');
await benLine.input.fill(`ping ${ipOf('Webserver 5.10')}`);
await benLine.input.press('Enter');
const miaLine = await commandLine(mia, notebook);
await eventually(
"Ben's ping is in Mia's command line",
async () => /4 Paket\(e\) empfangen/.test((await miaLine.text.textContent()) ?? ''),
20_000,
async () => ((await miaLine.text.textContent()) ?? '').slice(-200),
);
await miaLine.input.fill('ls');
await miaLine.input.press('Enter');
await eventually("Mia's ls, with Ben's file, is in Ben's command line", async () => /acceptance\.txt/.test((await benLine.text.textContent()) ?? ''));
// Ben stops: both are back in design mode.
await ben.locator('button[name="designMode"]').click();
await eventually('both are back in design mode', async () => (await canvas(mia).getAttribute('data-mode')) === 'design' && (await canvas(ben).getAttribute('data-mode')) === 'design');
// The same everywhere: both drawings and the document F stored.
const [m, b] = [await boxes(mia), await boxes(ben)];
check("Mia's drawing is Ben's", JSON.stringify(m) === JSON.stringify(b));
check('one more device and one more cable', Object.keys(m).length === devices + 1 && (await linkCount(mia)) === links + 1, `${Object.keys(m).length} devices, ${await linkCount(mia)} cables`);
await new Promise((resolve) => setTimeout(resolve, 2000));
const stored = await joinDoc(project, await signIn(project, { name: 'Prüfung' }));
const read = readProject(stored.doc);
check("F's document has Mia's devices", read.graph.nodes.length === Object.keys(m).length && read.graph.nodes.every((each) => m[each.id] !== undefined), `${read.graph.nodes.length} devices`);
check("F's document has Mia's cables", read.graph.links.length === links + 1, `${read.graph.links.length} cables`);
const moved = ['Rechner 3.10', 'Notebook 4.10', 'Webserver 5.10'].map((name) => {
const was = example.graph.nodes.find((each) => each.displayName === name)!.position;
const now = read.graph.nodes.find((each) => each.id === node(name))!.position;
return now.x !== was.x || now.y !== was.y;
});
check("F's document has all three moves", moved.every(Boolean), JSON.stringify(moved));
check(
"the file Ben made in the simulation is in F's document",
nodeFiles(stored.doc, notebook)
?.allEntries()
.some((each) => each.path.endsWith('/acceptance.txt')) === true,
);
stored.close();
} catch (error) {
check('ran to the end', false, error instanceof Error ? error.message.split('\n')[0]! : String(error));
// What each saw when it stopped, for the one who looks next.
for (const [index, page] of pages.entries()) await page.screenshot({ path: `acceptance-${index === 0 ? 'mia' : 'ben'}.png` }).catch(() => undefined);
} finally {
await browser.close();
await cleanUp();
}
const failed = results.filter(([, ok]) => !ok).length;
console.log(failed === 0 ? `\nF5 accepted: ${results.length} checks` : `\n${failed} of ${results.length} checks failed`);
process.exit(failed === 0 ? 0 : 1);
+28
View File
@@ -0,0 +1,28 @@
// How far server F's clock is from this machine's: the moment F's Date header
// turns to the next second, against our clock at that moment (the middle of
// the two requests around it). A ticket carries whole seconds, and F refuses
// one from its future, so F must not be behind the dashboard's machine.
//
// node tools/live/clock.mjs [host] [port]
import http from 'node:http';
const [host = '172.22.0.38', port = '8081'] = process.argv.slice(2);
const agent = new http.Agent({ keepAlive: true, maxSockets: 1 });
const ask = () =>
new Promise((resolve, reject) => {
const sent = Date.now();
http
.get({ host, port, path: '/healthz', agent }, (r) => {
r.resume();
r.on('end', () => resolve({ sent, got: Date.now(), date: Date.parse(r.headers.date) }));
})
.on('error', reject);
});
let last = await ask();
const offsets = [];
for (const end = Date.now() + 6000; Date.now() < end;) {
const now = await ask();
if (now.date !== last.date) offsets.push(Math.round(now.date - (last.got + now.sent) / 2) + `±${Math.ceil((now.sent - last.got) / 2)}`);
last = now;
}
agent.destroy();
console.log(`${host}:${port} clock minus ours (ms): ${offsets.join(' ')}`);
+171
View File
@@ -0,0 +1,171 @@
// Measuring on test-filius: projects through the service, people through
// tickets, the shared document through /sync, rooms through /sim. The
// secrets come from /root/.filius-test.env and are never printed.
import { readFileSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import * as Y from 'yjs';
import { openProjectArchive } from '@mdg-filius/fls';
import { writeProject } from '@mdg-filius/doc';
import { signTicket } from '../../packages/server/test/tickets.js';
import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider';
import WebSocket from 'ws';
const env = Object.fromEntries(
readFileSync('/root/.filius-test.env', 'utf8')
.split('\n')
.filter((l) => l.includes('='))
.map((l) => [l.slice(0, l.indexOf('=')).trim(), l.slice(l.indexOf('=') + 1).trim()]),
);
export const HOST = process.env.FILIUS_HOST ?? 'test-filius.hub.mdg-hamburg.de';
export const BASE = `https://${HOST}`;
const SECRET = env.FILIUS_TICKET_SECRET!;
const KEY = env.FILIUS_SERVICE_KEY!;
export const made: string[] = [];
/**
* How many seconds before now the tickets are signed. F before
* TICKET_AHEAD_SECONDS refused a ticket from its future, and test-filius'
* clock was behind this machine's (886 ms, then 8 ms with NTP).
*/
const SKEW_SECONDS = Number(process.env.SKEW_SECONDS ?? 2);
export async function svc(method: string, path: string, body?: unknown): Promise<{ status: number; json: any }> {
const r = await fetch(`${BASE}${path}`, { method, headers: { 'x-filius-service-key': KEY, 'content-type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
return { status: r.status, json: await r.json().catch(() => null) };
}
export async function newProject(): Promise<string> {
const id = randomUUID();
const r = await svc('POST', '/svc/projects', { project: id });
if (r.status !== 201) throw new Error(`project not made: ${r.status}`);
made.push(id);
return id;
}
export async function cleanUp(): Promise<void> {
for (const id of made.splice(0)) await svc('DELETE', `/svc/projects/${id}`);
}
/** The address a person's browser opens: a fresh ticket, then the editor on the project. */
export function signInAddress(project: string, { user = randomUUID(), name = 'Messung', level = 'write' }: { user?: string; name?: string; level?: string } = {}): string {
const ticket = signTicket({ u: user, n: name.toLowerCase().replace(/\W/g, ''), d: name, c: '#3366cc', lang: 'de', p: project, lvl: level }, SECRET, Math.floor(Date.now() / 1000) - SKEW_SECONDS);
return `${BASE}/__auth?ticket=${encodeURIComponent(ticket)}&next=${encodeURIComponent(`/?project=${project}`)}`;
}
export async function signIn(project: string, { user = randomUUID(), name = 'Messung', level = 'write' }: { user?: string; name?: string; level?: string } = {}): Promise<string> {
const ticket = signTicket({ u: user, n: name.toLowerCase().replace(/\W/g, ''), d: name, c: '#3366cc', lang: 'de', p: project, lvl: level }, SECRET, Math.floor(Date.now() / 1000) - SKEW_SECONDS);
const r = await fetch(`${BASE}/__auth?ticket=${encodeURIComponent(ticket)}&next=/`, { redirect: 'manual' });
if (r.status !== 302) throw new Error(`not signed in: ${r.status}`);
return r.headers.get('set-cookie')!.split(';')[0]!;
}
export function readExample(file: string) {
return openProjectArchive(new Uint8Array(readFileSync(new URL(`../../corpus/${file}`, import.meta.url))), () => randomUUID());
}
export interface Shared {
doc: Y.Doc;
provider: any;
close(): void;
}
export function joinDoc(project: string, cookie: string): Promise<Shared> {
const doc = new Y.Doc();
class Browser extends WebSocket {
constructor(url: string, protocols?: string | string[]) {
super(url, protocols, { headers: { cookie, origin: BASE } });
this.on('error', () => {});
}
}
const socket = new HocuspocusProviderWebsocket({ url: `wss://${HOST}/sync`, WebSocketPolyfill: Browser });
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('not synced in 15 s')), 15_000);
const provider = new HocuspocusProvider({
websocketProvider: socket,
name: project,
document: doc,
token: 'cookie',
onSynced: () => {
clearTimeout(timer);
resolve({
doc,
provider,
close: () => {
provider.destroy();
socket.destroy();
},
});
},
onAuthenticationFailed: ({ reason }: any) => reject(new Error(`refused ${reason}`)),
});
provider.attach();
});
}
export async function whenStored(shared: Shared, ms = 15_000): Promise<void> {
for (let waited = 0; shared.provider.unsyncedChanges > 0; waited += 20) {
if (waited > ms) throw new Error('not stored');
await new Promise((r) => setTimeout(r, 20));
}
}
/** A project of F with an example written into it, as one person's editor would. */
export async function seededProject(file: string) {
const project = await newProject();
const example = readExample(file);
const shared = await joinDoc(project, await signIn(project));
writeProject(shared.doc, example);
await whenStored(shared);
shared.close();
return { project, example };
}
export interface Room {
send(m: object): void;
messages: any[];
waitFor(test: (m: any) => boolean, ms?: number): Promise<any>;
onMessage: ((m: any) => void) | undefined;
close(): void;
closed: Promise<string>;
}
export function openRoom(project: string, cookie: string): Promise<Room> {
return new Promise((resolve, reject) => {
const socket = new WebSocket(`wss://${HOST}/sim?project=${project}`, { headers: { cookie, origin: BASE } });
const messages: any[] = [];
const waiters: { test: (m: any) => boolean; resolve: (m: any) => void }[] = [];
let closedWith: (r: string) => void = () => {};
const room: Room = {
send: (m) => socket.send(JSON.stringify({ v: 1, ...m })),
messages,
onMessage: undefined,
waitFor: (test, ms = 10_000) =>
new Promise((resolve, reject) => {
const found = messages.find(test);
if (found) return resolve(found);
const timer = setTimeout(() => reject(new Error('waited in vain')), ms);
waiters.push({
test,
resolve: (m) => {
clearTimeout(timer);
resolve(m);
},
});
}),
close: () => socket.close(),
closed: new Promise((r) => (closedWith = r)),
};
socket.on('message', (data: Buffer) => {
const m = JSON.parse(data.toString());
room.onMessage?.(m);
if (m.kind !== 'activity') messages.push(m);
for (const w of [...waiters])
if (w.test(m)) {
waiters.splice(waiters.indexOf(w), 1);
w.resolve(m);
}
});
socket.on('open', () => resolve(room));
socket.on('error', (e: Error) => reject(e));
socket.on('close', (code: number, reason: Buffer) => closedWith(`${code} ${reason}`));
});
}
+93
View File
@@ -0,0 +1,93 @@
// M3 on a live server F: how many simulation rooms it carries. Each room is a
// fresh project with the largest example, one person, the simulation at
// 100 %, the cables' activity watched (as an editor does) and a ping across
// the network every 6 s. The probe: once a second per room, an action for a
// device that does not exist — the room answers it at once, between two of
// its thread's ticks, so the time to the answer is how far behind the
// room's thread is (plus the way there and back). Rooms are added in steps;
// each step is held, then measured. Every project made is deleted at the end.
//
// npx vite-node tools/live/m3.ts [steps, e.g. 1,10,25,50] [seconds per step]
import { cleanUp, openRoom, seededProject, signIn, svc, type Room } from './lib.js';
const EXAMPLE = 'java-examples/Internet_Komplett_mit_eMail_Webserver_Intranet_Portforwarding_Firewall_DHCP_DE.fls';
const steps = (process.argv[2] ?? '1,10,25,50,100,150,200').split(',').map(Number);
const seconds = Number(process.argv[3] ?? 45);
const SETTLE_SECONDS = 10;
interface Running {
readonly room: Room;
readonly timers: NodeJS.Timeout[];
}
const rooms: Running[] = [];
let samples: number[] = [];
let lost = 0;
let errors = 0;
const pending = new Map<string, number>();
let counter = 0;
async function addRoom(): Promise<void> {
const { project, example } = await seededProject(EXAMPLE);
const room = await openRoom(project, await signIn(project, { name: `Raum ${rooms.length + 1}` }));
const ipad = example.graph.nodes.find((node) => node.displayName === 'iPad 1')!;
room.onMessage = (message) => {
if (message.kind === 'error' && typeof message.reqId === 'string' && pending.has(message.reqId)) {
samples.push(performance.now() - pending.get(message.reqId)!);
pending.delete(message.reqId);
} else if (message.kind === 'error' && message.code !== 'unknownNode') errors += 1;
};
room.send({ kind: 'speed', value: 100 });
room.send({ kind: 'start' });
await room.waitFor((message) => message.kind === 'state' && message.mode === 'simulation', 20_000);
room.send({ kind: 'sub', topics: ['activity', `app:${ipad.id}/terminal`] });
const probe = () => {
const reqId = `p${counter++}`;
pending.set(reqId, performance.now());
room.send({ kind: 'input', node: 'probe', app: 'terminal', action: 'enter', args: { line: '' }, reqId });
};
const ping = () => room.send({ kind: 'input', node: ipad.id, app: 'terminal', action: 'enter', args: { line: 'ping 120.130.140.80' }, reqId: `ping${counter++}` });
const timers: NodeJS.Timeout[] = [setInterval(ping, 6000)];
// Spread over the second, so that the probes do not all arrive at one tick.
timers.push(setTimeout(() => timers.push(setInterval(probe, 1000)), Math.random() * 1000));
rooms.push({ room, timers });
ping();
}
const quantile = (sorted: number[], q: number) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))] ?? NaN;
try {
console.log('rooms probes p50 ms p95 ms p99 ms max ms lost errors F rooms seeding s');
for (const target of steps) {
const seeding = performance.now();
while (rooms.length < target) {
await addRoom();
await new Promise((resolve) => setTimeout(resolve, Math.random() * 50));
}
const seeded = (performance.now() - seeding) / 1000;
await new Promise((resolve) => setTimeout(resolve, SETTLE_SECONDS * 1000));
samples = [];
lost = 0;
errors = 0;
for (const sent of pending.values()) if (performance.now() - sent > 10_000) lost += 1;
pending.clear();
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
lost += [...pending.values()].filter((sent) => performance.now() - sent > 5_000).length;
const sorted = [...samples].sort((a, b) => a - b);
const health = await (await fetch('https://test-filius.hub.mdg-hamburg.de/healthz')).json();
console.log(
[rooms.length, sorted.length, quantile(sorted, 0.5), quantile(sorted, 0.95), quantile(sorted, 0.99), sorted.at(-1) ?? NaN]
.map((value, index) => (index < 2 ? String(value) : value.toFixed(0)).padStart(index === 0 ? 5 : 7))
.join(' ') + ` ${String(lost).padStart(4)} ${String(errors).padStart(6)} ${String(health.rooms).padStart(7)} ${seeded.toFixed(0).padStart(9)}`,
);
}
} finally {
for (const { room, timers } of rooms) {
timers.forEach((timer) => clearInterval(timer));
room.close();
}
await cleanUp();
const health = await (await fetch('https://test-filius.hub.mdg-hamburg.de/healthz')).json();
console.log('after clean-up:', JSON.stringify(health), (await svc('GET', '/svc/sessions')).status);
}
+59
View File
@@ -0,0 +1,59 @@
// M4 on a live server F: how large the shared document of the largest example
// is, and how many updates dragging a device makes. Mia drags a device in a
// real browser for two seconds at 60 pointer moves a second (as a finger or
// a mouse does); a second person, watching through /sync, counts the updates
// that arrive and their bytes. Then the document's size as F holds it: fresh,
// after the drags, and as one update (what a new person loads).
//
// npx vite-node tools/live/m4.ts
import * as Y from 'yjs';
import { chromium } from '@playwright/test';
import { onDevice } from '../../packages/editor/browser/editorPage.js';
import { cleanUp, joinDoc, seededProject, signIn, signInAddress } from './lib.js';
const EXAMPLE = 'java-examples/Internet_Komplett_mit_eMail_Webserver_Intranet_Portforwarding_Firewall_DHCP_DE.fls';
const MOVES_PER_SECOND = 60;
const SECONDS = 2;
const browser = await chromium.launch();
try {
const { project, example } = await seededProject(EXAMPLE);
const watcher = await joinDoc(project, await signIn(project, { name: 'Zuschauer' }));
console.log(`fresh document: ${Y.encodeStateAsUpdate(watcher.doc).length} bytes, ${example.graph.nodes.length} devices, ${example.graph.links.length} cables`);
const page = await (await browser.newContext({ viewport: { width: 1400, height: 900 } })).newPage();
await page.goto(signInAddress(project, { name: 'Mia' }));
await page.waitForSelector('svg.filius-canvas');
const arrivals: { at: number; bytes: number }[] = [];
watcher.doc.on('update', (update: Uint8Array, origin: unknown) => {
if (origin !== null && typeof origin === 'object') arrivals.push({ at: performance.now(), bytes: update.length });
});
for (const name of ['Heimrouter', 'webserver.de (120.130.140.80)', 'iPad 1']) {
arrivals.length = 0;
const from = await onDevice(page, example.graph.nodes.find((node) => node.displayName === name)!.id);
await page.mouse.move(from.x, from.y);
await page.mouse.down();
const start = performance.now();
const moves = MOVES_PER_SECOND * SECONDS;
for (let step = 1; step <= moves; step++) {
await page.mouse.move(from.x + 150 * Math.sin((step / moves) * Math.PI), from.y + 80 * (step / moves));
const due = start + (step * 1000) / MOVES_PER_SECOND;
await new Promise((resolve) => setTimeout(resolve, Math.max(0, due - performance.now())));
}
await page.mouse.up();
const dragged = (performance.now() - start) / 1000;
await new Promise((resolve) => setTimeout(resolve, 1500));
const bytes = arrivals.reduce((sum, each) => sum + each.bytes, 0);
console.log(
`drag ${name.padEnd(30)} ${dragged.toFixed(1)} s: ${arrivals.length} updates (${(arrivals.length / dragged).toFixed(0)}/s), ${bytes} bytes, ${(bytes / Math.max(1, arrivals.length)).toFixed(0)} per update`,
);
}
console.log(`after the drags: ${Y.encodeStateAsUpdate(watcher.doc).length} bytes as one update`);
const fresh = await joinDoc(project, await signIn(project, { name: 'Neu' }));
console.log(`a new person loads: ${Y.encodeStateAsUpdate(fresh.doc).length} bytes`);
fresh.close();
watcher.close();
} finally {
await browser.close();
await cleanUp();
}
+47
View File
@@ -0,0 +1,47 @@
// M5 on a live server F: does a WebSocket through the openresty hold an hour?
// One person in a fresh project, with /sync (the document) and /sim (the
// room) open and nothing to say — the proxy's limit is between two bytes,
// so only the pings of F (/sim, 30 s) and Hocuspocus (/sync) keep them. Every
// five minutes a line; at the end a probe through /sim and a change through
// /sync, to show that both still carry.
//
// npx vite-node tools/live/m5.ts [minutes]
import { cleanUp, joinDoc, newProject, openRoom, signIn } from './lib.js';
const minutes = Number(process.argv[2] ?? 65);
const started = Date.now();
const elapsed = () => `${((Date.now() - started) / 60_000).toFixed(1)} min`;
try {
const project = await newProject();
const cookie = await signIn(project, { name: 'Dauer' });
const shared = await joinDoc(project, cookie);
const room = await openRoom(project, cookie);
let syncClosed = '';
shared.provider.on('close', ({ event }: { event: { code: number; reason: string } }) => (syncClosed ||= `${event.code} ${event.reason} at ${elapsed()}`));
let simClosed = '';
void room.closed.then((reason) => (simClosed = `${reason} at ${elapsed()}`));
console.log(`open: /sync and /sim, holding ${minutes} min`);
for (let minute = 5; minute <= minutes; minute += 5) {
await new Promise((resolve) => setTimeout(resolve, Math.min(5, minutes - minute + 5) * 60_000));
console.log(`${elapsed()}: /sync ${syncClosed || 'open'}, /sim ${simClosed || 'open'}`);
}
room.send({ kind: 'input', node: 'probe', app: 'terminal', action: 'enter', args: { line: '' }, reqId: 'last' });
const answer = await room
.waitFor((message) => message.reqId === 'last', 10_000)
.then(
() => 'answered',
() => 'no answer',
);
shared.doc.getMap('meta').set('m5', Date.now());
const stored = await new Promise<string>((resolve) => {
const end = Date.now() + 10_000;
const look = () => (shared.provider.unsyncedChanges === 0 ? resolve('stored') : Date.now() > end ? resolve('not stored') : setTimeout(look, 50));
look();
});
console.log(`after ${elapsed()}: /sim probe ${answer}, /sync change ${stored}; closed: /sync ${syncClosed || 'never'}, /sim ${simClosed || 'never'}`);
room.close();
shared.close();
} finally {
await cleanUp();
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"composite": false,
"declaration": false,
"lib": ["ES2023", "DOM"],
"types": ["node"]
},
"include": ["."]
}
+2
View File
@@ -64,6 +64,8 @@ def main(out):
("sixtySecondsOld", plain, SIGNED_AT + 60),
("sixtyOneSecondsOld", plain, SIGNED_AT + 61),
("fromTheFuture", plain, SIGNED_AT - 1),
("fiveSecondsAhead", plain, SIGNED_AT - 5),
("sixSecondsAhead", plain, SIGNED_AT - 6),
("otherSecret", sign(claims(), secret="another-secret"), SIGNED_AT),
("scratchSalt", sign(claims(), salt="mdg-scratch-ticket"), SIGNED_AT),
("tamperedPayload", tampered(plain), SIGNED_AT),