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:
@@ -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);
|
||||
@@ -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(' ')}`);
|
||||
@@ -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}`));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"composite": false,
|
||||
"declaration": false,
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["."]
|
||||
}
|
||||
Reference in New Issue
Block a user