@homie-rocks/studio 0.46.5 → 0.46.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +12 -1
- package/lib/crowd-cloud.mjs +75 -17
- package/lib/crowd-diagnostics.mjs +1 -0
- package/lib/crowd-load.mjs +15 -3
- package/lib/crowd-probe/README.md +58 -0
- package/lib/crowd-probe/client.mjs +3 -0
- package/lib/crowd-probe/reference-work.mjs +45 -0
- package/lib/crowd-probe/run-trial.sh +207 -0
- package/lib/crowd-probe/worker.mjs +37 -10
- package/lib/crowd-summary.mjs +74 -7
- package/lib/crowd-worker.mjs +12 -4
- package/lib/template-history.json +42 -0
- package/package.json +1 -1
- package/rules/host.ts +4 -1
- package/worker/build-id.mjs +1 -1
- package/worker/gate.mjs +72 -25
- package/worker/index.mjs +26 -8
- package/worker/office.mjs +2 -2
- package/worker/room-store.mjs +71 -11
- package/worker/room.mjs +16 -3
- package/worker/version.mjs +1 -1
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* It proves the room and its Gates at that many players. It says nothing of the last hop to a real player.
|
|
12
12
|
*/
|
|
13
13
|
import { Tally, crowdClient } from './client.mjs';
|
|
14
|
+
import { referenceWork } from './reference-work.mjs';
|
|
14
15
|
|
|
15
16
|
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
16
17
|
|
|
@@ -18,20 +19,41 @@ export default {
|
|
|
18
19
|
async fetch(request, env, ctx) {
|
|
19
20
|
const url = new URL(request.url);
|
|
20
21
|
if (!env.PROBE_KEY || request.headers.get('x-probe-key') !== env.PROBE_KEY) return new Response('not found', { status: 404 });
|
|
21
|
-
if (url.pathname === '/hello') return Response.json({ ok: true, probe:
|
|
22
|
+
if (url.pathname === '/hello') return Response.json({ ok: true, probe: 2 });
|
|
23
|
+
if (url.pathname === '/calibrate') {
|
|
24
|
+
// The reference work, `rounds` times, in one event. This Worker cannot time it: its clock stands still while
|
|
25
|
+
// its code runs and reads a timer's due time when the timer fires, so whoever asked holds the stopwatch (the
|
|
26
|
+
// time this request took, less the time one with no rounds took). `own` is what this Worker's clock said the
|
|
27
|
+
// rounds took, across an awaited timer, for the record of what such a figure is worth.
|
|
28
|
+
const rounds = Math.min(20, Math.max(0, Math.floor(Number(url.searchParams.get('rounds') ?? 0)) || 0));
|
|
29
|
+
await sleep(2);
|
|
30
|
+
const at = Date.now(); let check = 0;
|
|
31
|
+
for (let i = 0; i < rounds; i += 1) check = referenceWork();
|
|
32
|
+
await sleep(1);
|
|
33
|
+
return Response.json({ ok: true, colo: request.cf?.colo ?? null, rounds, check, own: Date.now() - at });
|
|
34
|
+
}
|
|
22
35
|
const origin = url.searchParams.get('origin'), game = url.searchParams.get('game') ?? 'crowd-circuit', room = url.searchParams.get('room');
|
|
23
36
|
const from = Number(url.searchParams.get('from') ?? 0), count = Math.min(5, Math.max(1, Number(url.searchParams.get('count') ?? 5)));
|
|
24
37
|
const joinAt = Number(url.searchParams.get('joinAt')), rampMs = Number(url.searchParams.get('rampMs') ?? 50);
|
|
25
38
|
const measureAt = Number(url.searchParams.get('measureAt')), endAt = Number(url.searchParams.get('endAt'));
|
|
39
|
+
// `compress=1`: offer permessage-deflate as a browser does, so the studio's Gates compress for these players too.
|
|
40
|
+
const compress = url.searchParams.get('compress') === '1';
|
|
41
|
+
// `slowEvery=K`: every Kth player is a slow one (so every Gate holds some, beside healthy players). Each takes what it is sent at six frames a second, in
|
|
42
|
+
// order, as a connection that carries no more does: it falls behind, says so (the tick it holds), and the
|
|
43
|
+
// question is what that costs the players beside it. (A Worker cannot slow a socket's reads; a real slow
|
|
44
|
+
// connection is played from a computer: `crowd-load.mjs --slow-every`.)
|
|
45
|
+
const slowEvery = Math.max(0, Math.floor(Number(url.searchParams.get('slowEvery') ?? 0)) || 0);
|
|
26
46
|
if (!/^https:\/\/[a-z0-9.-]+$/.test(origin ?? '') || !/^[A-Za-z0-9_-]{1,40}$/.test(room ?? '') || ![joinAt, measureAt, endAt].every(Number.isFinite) || endAt - joinAt > 900_000) return new Response('bad request', { status: 400 });
|
|
27
47
|
const { readable, writable } = new TransformStream(), writer = writable.getWriter(), encoder = new TextEncoder();
|
|
28
48
|
const line = (o) => writer.write(encoder.encode(JSON.stringify(o) + '\n')).catch(() => {});
|
|
29
49
|
const tallies = { ack: new Tally(), gaps: new Tally(), loops: new Tally() }, players = [];
|
|
50
|
+
// A slow player's own figures, apart: its acknowledgements are late by what it is.
|
|
51
|
+
const slowTallies = { ack: new Tally(), gaps: new Tally(), loops: new Tally() };
|
|
30
52
|
const run = async () => {
|
|
31
53
|
const join = async (i) => {
|
|
32
54
|
const key = crypto.randomUUID().replaceAll('-', '');
|
|
33
|
-
const player = { socket: null, client: null };
|
|
34
|
-
player.client = crowdClient({ index: i, game, tallies, send: (text) => { try { player.socket?.send(text); } catch { /* closing */ } } });
|
|
55
|
+
const player = { socket: null, client: null, compressed: false, slow: slowEvery > 0 && i % slowEvery === 0, held: [], nextAt: 0 };
|
|
56
|
+
player.client = crowdClient({ index: i, game, tallies: player.slow ? slowTallies : tallies, send: (text) => { try { player.socket?.send(text); } catch { /* closing */ } } });
|
|
35
57
|
players.push(player);
|
|
36
58
|
try {
|
|
37
59
|
await sleep(Math.max(0, joinAt + i * rampMs - Date.now()));
|
|
@@ -40,31 +62,36 @@ export default {
|
|
|
40
62
|
const match = (await page.text()).match(/window\.HOMIE_NET=(\{[^<]+\})<\/script>/);
|
|
41
63
|
if (!page.ok || !match) throw new Error(`bootstrap HTTP ${page.status}`);
|
|
42
64
|
const cfg = JSON.parse(match[1]);
|
|
43
|
-
const answer = await fetch(cfg.url.replace(/^ws/, 'http'), { headers: { Upgrade: 'websocket', Origin: 'null' } });
|
|
65
|
+
const answer = await fetch(cfg.url.replace(/^ws/, 'http'), { headers: { Upgrade: 'websocket', Origin: 'null', ...(compress ? { 'Sec-WebSocket-Extensions': 'permessage-deflate; client_max_window_bits' } : {}) } });
|
|
66
|
+
player.compressed = /permessage-deflate/.test(answer.headers?.get?.('sec-websocket-extensions') ?? '');
|
|
44
67
|
const socket = answer.webSocket;
|
|
45
68
|
if (!socket) throw new Error(`upgrade HTTP ${answer.status}`);
|
|
46
69
|
socket.accept(); player.socket = socket;
|
|
47
|
-
socket.addEventListener('message', (event) => { if (typeof event.data
|
|
70
|
+
socket.addEventListener('message', (event) => { if (typeof event.data !== 'string') return; if (player.slow) player.held.push(event.data); else player.client.message(event.data, Date.now()); });
|
|
48
71
|
socket.addEventListener('close', (event) => player.client.close(event.code, event.reason));
|
|
49
72
|
socket.addEventListener('error', () => player.client.close(1006, 'error'));
|
|
50
73
|
player.client.open(Date.now());
|
|
51
74
|
} catch (error) { player.client.c.errors.push(String(error?.message ?? error).slice(0, 120)); player.client.close(0, 'never connected'); }
|
|
52
75
|
};
|
|
53
76
|
const joins = Promise.all(Array.from({ length: count }, (_, n) => join(from + n)));
|
|
54
|
-
let measuring = false, lastLine = 0, lastTick = Date.now(), worstLag = 0;
|
|
77
|
+
let measuring = false, lastLine = 0, lastWhole = 0, lastTick = Date.now(), worstLag = 0;
|
|
78
|
+
// Everything measured so far: the last line of a step, and every thirty seconds on the way, so a request that
|
|
79
|
+
// is cut off before its end has still said most of what it saw.
|
|
80
|
+
const whole = (t, now) => ({ t, from, count, colo: request.cf?.colo ?? null, measuredMs: Math.max(0, Math.min(now, endAt) - measureAt), worstTimerLagMs: worstLag, players: players.map((p) => ({ ...p.client.facts(), compressed: p.compressed, slow: p.slow, ...(p.slow ? { waiting: p.held.length } : {}) })), tallies: { ack: tallies.ack.bins, gaps: tallies.gaps.bins, loops: tallies.loops.bins }, slowAck: slowTallies.ack.bins });
|
|
55
81
|
while (Date.now() < endAt) {
|
|
56
82
|
const now = Date.now();
|
|
57
83
|
// This Worker's own lateness: how long past ten milliseconds its timer came back.
|
|
58
84
|
worstLag = Math.max(worstLag, now - lastTick - 10); lastTick = now;
|
|
59
85
|
if (!measuring && now >= measureAt) { measuring = true; for (const p of players) p.client.measure(); }
|
|
60
|
-
for (const p of players) p.client.tick(now);
|
|
86
|
+
for (const p of players) { if (p.slow && p.held.length && now >= p.nextAt) { p.nextAt = now + 160; p.client.message(p.held.shift(), now); } p.client.tick(now); }
|
|
87
|
+
if (measuring && now - lastWhole >= 30000) { lastWhole = now; await line(whole('partial', now)); }
|
|
61
88
|
if (now - lastLine >= 5000) { lastLine = now; await line({ t: 'sample', at: now, from, joined: players.filter((p) => p.client.c.first !== null).length, open: players.filter((p) => p.socket && !p.client.c.closed).length, measuring }); }
|
|
62
89
|
await sleep(10);
|
|
63
90
|
}
|
|
64
|
-
await joins;
|
|
65
|
-
const
|
|
91
|
+
await Promise.race([joins, sleep(5000)]);
|
|
92
|
+
const last = whole('final', endAt);
|
|
66
93
|
for (const p of players) { try { p.socket?.close(1000, 'probe step complete'); } catch { /* gone */ } }
|
|
67
|
-
await line(
|
|
94
|
+
await line(last);
|
|
68
95
|
};
|
|
69
96
|
ctx.waitUntil(run().catch((error) => line({ t: 'failed', from, error: String(error?.stack ?? error).slice(0, 400) })).finally(() => writer.close().catch(() => {})));
|
|
70
97
|
return new Response(readable, { headers: { 'content-type': 'application/x-ndjson', 'cache-control': 'no-store' } });
|
package/lib/crowd-summary.mjs
CHANGED
|
@@ -32,10 +32,17 @@ export function summarize(report, rows = [], link = null) {
|
|
|
32
32
|
from: report.generator === 'cloudflare' ? 'cloudflare' : 'home',
|
|
33
33
|
n: report.n, total: report.total, room: report.room, toolkit: report.toolkit ?? null, stopReason: report.stopReason ?? null,
|
|
34
34
|
joined: report.joined, connectedAtEnd: report.connectedAtEnd, disconnects: report.disconnects,
|
|
35
|
+
// In-Cloudflare steps: probe requests whose report never came (their players were in the room all the same).
|
|
36
|
+
lostReports: report.lostReports ?? null,
|
|
37
|
+
// What the step varied: compression offered or not.
|
|
38
|
+
variant: (report.variant ?? (report.compression?.offered === false ? 'plain' : 'deflate')) + (report.slowPlayers ? ` +${report.slowPlayers} slow` : ''),
|
|
39
|
+
// With deliberately slow players in the step: what everybody else got.
|
|
40
|
+
healthy: report.slowPlayers ? report.healthy : null,
|
|
35
41
|
client: {
|
|
36
42
|
inputAckMs: { p50: report.inputAckMs?.p50 ?? null, p95: report.inputAckMs?.p95 ?? null, p99: report.inputAckMs?.p99 ?? null },
|
|
37
43
|
// A player's tick progression and the frames they received, a second: the median player, and the worst.
|
|
38
|
-
|
|
44
|
+
// (From the in-Cloudflare probe this is by a Worker's clock, which runs slow when the Worker is late: not shown.)
|
|
45
|
+
tickHz: report.tickHzClock === 'probe' ? { median: null, min: null } : { median: round(report.tickHz?.median), min: round(report.tickHz?.min) },
|
|
39
46
|
deliveredHz: { median: round(report.deliveredHz?.median), min: round(report.deliveredHz?.min) },
|
|
40
47
|
receiveGapMs: { p95: report.receiveGapMs?.p95 ?? null, p99: report.receiveGapMs?.p99 ?? null },
|
|
41
48
|
ackExpired: report.ackExpired ?? null,
|
|
@@ -78,9 +85,28 @@ export function summarize(report, rows = [], link = null) {
|
|
|
78
85
|
roomTickHzAtGates: { median: round(pick(gates.map((g) => g.roomTickHz), 0.5)), p05: round(pick(gates.map((g) => g.roomTickHz), 0.05)) }, worldGapMsMax: pick(gates.map((g) => g.worldGapMs), 1),
|
|
79
86
|
// The room's clock less a Gate's (median Gate), and how long the room says it held a Gate's frame.
|
|
80
87
|
roomClockOffsetMs: pick(gates.map((g) => g.roomClock?.offsetMs), 0.5), channelDelayMs: pick(gates.map((g) => g.roomClock?.delayMs), 0.5),
|
|
88
|
+
// A Gate's own lateness: the worst its 20 ms timer came late (the worst Gate-second), and the share of Gate-seconds with any timer over 20 ms late.
|
|
89
|
+
gateLoopLateMs: { median: pick(gates.map((g) => g.loopLateMs), 0.5), p95: pick(gates.map((g) => g.loopLateMs), 0.95) }, gateBusyShare: gates.length ? round(gates.filter((g) => (g.loopLateTimers ?? 0) > 0).length / gates.length, 3) : null,
|
|
81
90
|
gateRttMs: { median: round(pick(gates.map((g) => g.rttMs), 0.5), 0), p95: round(pick(gates.map((g) => g.rttMs), 0.95), 0) },
|
|
82
91
|
inputOffsetMs: pick(gates.map((g) => g.inputOffsetMs), 0.5),
|
|
83
92
|
clientLagTicks: { p95: pick(gates.map((g) => g.clientLagTicks?.p95), 0.95), max: pick(gates.map((g) => g.clientLagTicks?.max), 1) },
|
|
93
|
+
// Players more than a second behind at one moment, the worst second (counted, whether or not they were paced).
|
|
94
|
+
behindPlayers: pick(during.map((r) => (r.gates ?? []).reduce((a, g) => a + (g.behind ?? 0), 0)), 1),
|
|
95
|
+
// Of every world every Gate wrote: the share over 100, 200 and 400 ms old, and worlds a Gate-second that came
|
|
96
|
+
// more than 75, 150 and 300 ms after the one before.
|
|
97
|
+
worldsOld: (() => { const n = gates.reduce((a, g) => a + (g.worldsOld?.n ?? 0), 0), of = (key) => (n ? round(gates.reduce((a, g) => a + (g.worldsOld?.[key] ?? 0), 0) / n, 4) : null); return n ? { worlds: n, over100: of('over100'), over200: of('over200'), over400: of('over400') } : null; })(),
|
|
98
|
+
worldGaps: (() => { const with_ = gates.filter((g) => g.worldGaps), of = (key) => (with_.length ? round(with_.reduce((a, g) => a + g.worldGaps[key], 0) / with_.length, 3) : null); return with_.length ? { over75: of('over75'), over150: of('over150'), over300: of('over300') } : null; })(),
|
|
99
|
+
// What crossed the room's channels a second: rows (a row is a player's message, mostly) and frames in, frames
|
|
100
|
+
// and megabytes out, and worlds replaced before they were sent because eight were unacknowledged.
|
|
101
|
+
roomTraffic: (() => {
|
|
102
|
+
const first = during[0], last = during.at(-1), seconds = first && last ? (last.at - first.at) / 1000 : 0;
|
|
103
|
+
if (!(seconds > 0)) return null;
|
|
104
|
+
const total = (row, f) => (row.transport ?? []).reduce((a, t) => a + (f(t) ?? 0), 0), rate = (f) => round((total(last, f) - total(first, f)) / seconds, 1);
|
|
105
|
+
const heard = (last.transport ?? []).some((t) => t.in);
|
|
106
|
+
return { inRows: heard ? rate((t) => t.in?.rows) : null, inFrames: heard ? rate((t) => t.in?.frames) : null, inKB: heard ? rate((t) => (t.in?.bytes ?? 0) / 1000) : null, outFrames: rate((t) => t.frames), outMB: rate((t) => t.bytes / 1e6), replaced: rate((t) => t.coalesced) };
|
|
107
|
+
})(),
|
|
108
|
+
// Checkpoints the room wrote without holding its frames for them.
|
|
109
|
+
unconfirmedSaves: during.at(-1)?.save?.unconfirmed ?? null,
|
|
84
110
|
},
|
|
85
111
|
};
|
|
86
112
|
}
|
|
@@ -105,11 +131,11 @@ export function table(steps) {
|
|
|
105
131
|
const head = ['N', 'from', 'joined/end/drops', 'ack p50/p95/p99 ms', 'loop p50/p95 ms', 'lead (late)', 'client tick Hz', 'client delivered Hz', 'wire MB/s (link)', 'KB/s a player', 'compressed (ratio)', 'driver lag ms', 'server late', 'Gates', 'room Hz at Gates med/p05', 'Gate delivered Hz med/p05', 'Gate rtt med/p95 ms', 'world age p50/p95 ms', 'paced', 'diag ok/failed'];
|
|
106
132
|
const lines = [`| ${head.join(' | ')} |`, `| ${head.map(() => '---').join(' | ')} |`];
|
|
107
133
|
for (const s of steps) lines.push(`| ${[
|
|
108
|
-
s.n, s.from
|
|
134
|
+
s.n, `${s.from} ${s.variant}${s.lostReports ? ` (${s.lostReports} reports lost)` : ''}`, `${cell(s.joined)}/${cell(s.connectedAtEnd)}/${cell(s.disconnects)}`,
|
|
109
135
|
`${cell(s.client.inputAckMs.p50)}/${cell(s.client.inputAckMs.p95)}/${cell(s.client.inputAckMs.p99)}`,
|
|
110
136
|
`${cell(s.client.inputLoopMs?.p50)}/${cell(s.client.inputLoopMs?.p95)}`,
|
|
111
137
|
`${cell(s.client.inputLead?.ticksMedian)} (${cell(s.client.inputLead?.lateShare)})`,
|
|
112
|
-
cell(s.client.tickHz.median), cell(s.client.deliveredHz.median),
|
|
138
|
+
cell(s.client.tickHz.median), `${cell(s.client.deliveredHz.median)}${s.healthy ? ` (healthy: ${cell(round(s.healthy.deliveredHz?.median))}, worst ${cell(round(s.healthy.deliveredHz?.min))})` : ''}`,
|
|
113
139
|
`${cell(s.client.wireMBps)} (${cell(s.link.downMBps)}${s.link.limited ? ', LINK-LIMITED' : ''})`,
|
|
114
140
|
cell(s.client.wireKBsPerPlayer),
|
|
115
141
|
s.client.compression ? `${s.client.compression.sockets}/${s.client.compression.of} (${cell(s.client.compression.ratio)})` : cell(s.client.compressedSockets),
|
|
@@ -123,18 +149,59 @@ export function table(steps) {
|
|
|
123
149
|
return lines.join('\n');
|
|
124
150
|
}
|
|
125
151
|
|
|
152
|
+
/**
|
|
153
|
+
* The second table: what the room carried, a line a step. Read with the first.
|
|
154
|
+
* - "rows in": players' messages reaching the room a second. "worlds out": frames to Gates a second, megabytes,
|
|
155
|
+
* and worlds replaced before sending because eight were unacknowledged on a channel (a room that is behind).
|
|
156
|
+
* - "worlds old": of every world every Gate wrote, the share over 100 / 200 / 400 ms old.
|
|
157
|
+
* - "behind": players more than a second behind at one moment, the worst second. "paced": how many were being
|
|
158
|
+
* sent fewer frames for it, the worst second.
|
|
159
|
+
* There is no column for the room's processor time: Cloudflare's count of it could not be read from a tail (the
|
|
160
|
+
* requests that are a Gate's channel reported under 1% for a room that was out of time), and nothing an object
|
|
161
|
+
* measures about itself can stand in, because its clock stands still while it runs.
|
|
162
|
+
*/
|
|
163
|
+
export function threadTable(steps) {
|
|
164
|
+
const cell = (v) => (v === null || v === undefined ? '—' : String(v));
|
|
165
|
+
const pct = (v) => (Number.isFinite(v) ? `${Math.round(v * 1000) / 10}%` : '—');
|
|
166
|
+
const head = ['N', 'from', 'rows in/s', 'worlds out/s (MB/s, replaced/s)', 'unconfirmed checkpoints', 'worlds old >100/>200/>400', 'gaps >75/>150/>300 a Gate-s', 'behind', 'paced'];
|
|
167
|
+
const lines = [`| ${head.join(' | ')} |`, `| ${head.map(() => '---').join(' | ')} |`];
|
|
168
|
+
for (const s of steps) {
|
|
169
|
+
const t = s.server.roomTraffic, old = s.server.worldsOld, gaps = s.server.worldGaps;
|
|
170
|
+
lines.push(`| ${[
|
|
171
|
+
s.n, `${s.from} ${s.variant}`,
|
|
172
|
+
cell(t?.inRows), t ? `${cell(t.outFrames)} (${cell(t.outMB)}, ${cell(t.replaced)})` : '—',
|
|
173
|
+
cell(s.server.unconfirmedSaves),
|
|
174
|
+
old ? `${pct(old.over100)}/${pct(old.over200)}/${pct(old.over400)}` : '—', gaps ? `${cell(gaps.over75)}/${cell(gaps.over150)}/${cell(gaps.over300)}` : '—',
|
|
175
|
+
cell(s.server.behindPlayers), cell(s.server.pacedPlayers),
|
|
176
|
+
].join(' | ')} |`);
|
|
177
|
+
}
|
|
178
|
+
return lines.join('\n');
|
|
179
|
+
}
|
|
180
|
+
|
|
126
181
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
127
182
|
const dir = resolve(process.argv[2] ?? '.');
|
|
128
183
|
const read = (file) => (existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null);
|
|
129
184
|
const steps = [];
|
|
130
|
-
for (const name of readdirSync(dir).filter((n) => /^n\d+(-
|
|
185
|
+
for (const name of readdirSync(dir).filter((n) => /^n\d+(-[a-z0-9-]+)?$/.test(n)).sort((a, b) => parseInt(a.slice(1), 10) - parseInt(b.slice(1), 10) || a.localeCompare(b))) {
|
|
131
186
|
const report = read(join(dir, name, 'report.json'));
|
|
132
187
|
if (!report) continue;
|
|
133
188
|
const file = join(dir, name, 'diagnostics.jsonl');
|
|
134
189
|
const rows = existsSync(file) ? readFileSync(file, 'utf8').split('\n').filter(Boolean).flatMap((line) => { try { return [JSON.parse(line)]; } catch { return []; } }) : [];
|
|
135
|
-
steps.push(summarize(report, rows, read(join(dir, name, 'link.json'))));
|
|
190
|
+
steps.push({ ...summarize(report, rows, read(join(dir, name, 'link.json'))), folder: name });
|
|
136
191
|
}
|
|
192
|
+
// The same step played more than once (n1000-cloud, n1000-cloud-r2, ...): the spread of what matters.
|
|
193
|
+
const groups = new Map();
|
|
194
|
+
for (const step of steps) { const key = `${step.n} ${step.from} ${step.variant}`; if (!groups.has(key)) groups.set(key, []); groups.get(key).push(step); }
|
|
195
|
+
const spread = [...groups].filter(([, runs]) => runs.length > 1).map(([key, runs]) => {
|
|
196
|
+
const of = (f) => { const a = runs.map(f).filter(Number.isFinite).sort((x, y) => x - y); return a.length ? `${a[0]} to ${a.at(-1)}` : '—'; };
|
|
197
|
+
return `| ${key} | ${runs.length} | ${of((r) => r.client.deliveredHz.median)} | ${of((r) => r.client.inputAckMs.p95)} | ${of((r) => r.server.gateRttMs.p95)} | ${of((r) => r.server.worldAgeMs.p95)} | ${of((r) => r.server.worldsOld?.over200)} | ${of((r) => r.server.lateInputs)} | ${of((r) => r.server.gateDeliveredHz.median)} |`;
|
|
198
|
+
});
|
|
199
|
+
const spreadTable = spread.length ? `\n\nRepeated steps, lowest to highest:\n\n| step | runs | client delivered Hz | ack p95 ms | Gate rtt p95 ms | world age p95 ms | share of worlds over 200 ms old | server late | Gate delivered Hz |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n${spread.join('\n')}` : '';
|
|
137
200
|
writeFileSync(join(dir, 'summary.json'), JSON.stringify(steps, null, 2));
|
|
138
|
-
|
|
139
|
-
|
|
201
|
+
// How fast Cloudflare ran the reference work against this computer (crowd-cloud.mjs --calibrate), when the run measured it.
|
|
202
|
+
const calibrations = readdirSync(dir).filter((n) => /^calibration.*\.json$/.test(n)).map((n) => read(join(dir, n))).filter((c) => c?.ratio);
|
|
203
|
+
const calibration = calibrations.length ? `\n\nThe reference work (lib/crowd-probe/reference-work.mjs), a round: this computer ${calibrations.map((c) => Math.min(...c.localMs)).join(', ')} ms (its quickest); inside Cloudflare, by this computer's stopwatch, ${calibrations.map((c) => `${c.cloudMsARound.min} to ${c.cloudMsARound.max} (median ${c.cloudMsARound.median})`).join('; ')} ms. Cloudflare over this computer: ${calibrations.map((c) => `${c.ratio.min} to ${c.ratio.max} (median ${c.ratio.median})`).join('; ')}. The Worker's own clock said the same rounds took ${calibrations.map((c) => (c.workerOwnClockMs ?? []).join(', ')).join('; ')} ms in all.` : '';
|
|
204
|
+
const text = `${table(steps)}\n\nWhat the room carried:\n\n${threadTable(steps)}${spreadTable}${calibration}`;
|
|
205
|
+
writeFileSync(join(dir, 'summary.md'), `${text}\n`);
|
|
206
|
+
console.log(text);
|
|
140
207
|
}
|
package/lib/crowd-worker.mjs
CHANGED
|
@@ -46,6 +46,7 @@ export function createLoad({
|
|
|
46
46
|
workers = 1,
|
|
47
47
|
lead = 3,
|
|
48
48
|
compress = true,
|
|
49
|
+
slowEvery = 0,
|
|
49
50
|
onProblem = () => {}
|
|
50
51
|
}) {
|
|
51
52
|
const clients = [],
|
|
@@ -53,6 +54,8 @@ export function createLoad({
|
|
|
53
54
|
first = new Histogram(),
|
|
54
55
|
welcome = new Histogram(),
|
|
55
56
|
ack = new Histogram(),
|
|
57
|
+
// A deliberately slow player's own figures, apart: its acknowledgements are seconds late by what it is.
|
|
58
|
+
slowAck = new Histogram(),
|
|
56
59
|
gaps = new Histogram(),
|
|
57
60
|
// From the tick a player held when it stamped an input to the tick it
|
|
58
61
|
// reached the rules, ms: what the system took, whatever the lead.
|
|
@@ -144,7 +147,7 @@ export function createLoad({
|
|
|
144
147
|
c.lastTick = snap.k;
|
|
145
148
|
c.lastAt = now;
|
|
146
149
|
c.frames++;
|
|
147
|
-
gaps.add(gap);
|
|
150
|
+
if (!c.slow) gaps.add(gap);
|
|
148
151
|
}
|
|
149
152
|
const control = snap.c?.find((row) => row[0] === c.seat);
|
|
150
153
|
// The room says how early this player's inputs arrive, in sixteenths of a
|
|
@@ -155,7 +158,7 @@ export function createLoad({
|
|
|
155
158
|
// early, one fewer when it was over a tick and a half.
|
|
156
159
|
if (control && control[3] !== -128 && control[3] !== undefined) {
|
|
157
160
|
const early = control[3] / 16;
|
|
158
|
-
if (measuring) { if (early <= 0) lateAcks++; onTime++; loops.add((c.lead - early) * 50); }
|
|
161
|
+
if (measuring && !c.slow) { if (early <= 0) lateAcks++; onTime++; loops.add((c.lead - early) * 50); }
|
|
159
162
|
(c.early ??= []).push(early); if (c.early.length > 40) c.early.shift();
|
|
160
163
|
if (c.early.length >= 20 && now - (c.leadAt ?? 0) > 2000) {
|
|
161
164
|
const low = c.early.slice().sort((a, b) => a - b)[Math.floor(c.early.length / 20)];
|
|
@@ -169,7 +172,7 @@ export function createLoad({
|
|
|
169
172
|
if (tick > control[2]) break;
|
|
170
173
|
c.pending.delete(tick);
|
|
171
174
|
if (measuring) {
|
|
172
|
-
if (tick === control[2]) ack.add(now - at);
|
|
175
|
+
if (tick === control[2]) (c.slow ? slowAck : ack).add(now - at);
|
|
173
176
|
else skipped++;
|
|
174
177
|
}
|
|
175
178
|
}
|
|
@@ -230,6 +233,9 @@ export function createLoad({
|
|
|
230
233
|
});
|
|
231
234
|
c.ws.on('open', () => {
|
|
232
235
|
c.helloAt = performance.now();
|
|
236
|
+
// A deliberately slow player: this socket stops reading for four seconds in every five, as a connection
|
|
237
|
+
// that cannot keep up does. What it costs the players beside it is the question.
|
|
238
|
+
if (slowEvery > 0 && i % slowEvery === 0) { c.slow = true; c.slowTimer = setInterval(() => { c.ws._socket?.pause(); setTimeout(() => c.ws._socket?.resume(), 4000); }, 5000); }
|
|
233
239
|
c.compressed = /permessage-deflate/.test(c.ws.extensions ?? '');
|
|
234
240
|
send(c, {
|
|
235
241
|
t: 'hello',
|
|
@@ -434,6 +440,7 @@ export function createLoad({
|
|
|
434
440
|
measuring = false;
|
|
435
441
|
stopping = true;
|
|
436
442
|
clearInterval(timer);
|
|
443
|
+
for (const c of clients) clearInterval(c.slowTimer);
|
|
437
444
|
loop.disable();
|
|
438
445
|
for (const c of clients) c.ws?.close(1000, 'load step complete');
|
|
439
446
|
await new Promise((r) => setTimeout(r, 250));
|
|
@@ -441,7 +448,7 @@ export function createLoad({
|
|
|
441
448
|
return {
|
|
442
449
|
...end,
|
|
443
450
|
errors: [...errors].map(([text, count]) => ({ text, count })),
|
|
444
|
-
histograms: { first, welcome, ack, gaps, loops },
|
|
451
|
+
histograms: { first, welcome, ack, slowAck, gaps, loops },
|
|
445
452
|
inputReports: onTime,
|
|
446
453
|
inputReportsLate: lateAcks,
|
|
447
454
|
leadTicks: clients.filter((c) => !c.retired).map((c) => c.lead),
|
|
@@ -456,6 +463,7 @@ export function createLoad({
|
|
|
456
463
|
priorSeat: c.priorSeat,
|
|
457
464
|
sameSeat: c.priorSeat === null ? null : c.seat === c.priorSeat,
|
|
458
465
|
retired: Boolean(c.retired),
|
|
466
|
+
slow: Boolean(c.slow),
|
|
459
467
|
firstStateMs: c.first === null ? null : c.first - c.began,
|
|
460
468
|
helloAfterMs: c.helloAt ? c.helloAt - c.began : null,
|
|
461
469
|
closeCode: c.closeCode,
|
|
@@ -2859,6 +2859,48 @@
|
|
|
2859
2859
|
}
|
|
2860
2860
|
}
|
|
2861
2861
|
}
|
|
2862
|
+
},
|
|
2863
|
+
"0.46.6": {
|
|
2864
|
+
"files": {
|
|
2865
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
2866
|
+
"README.md": "684b3cca8001673c",
|
|
2867
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
2868
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
2869
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
2870
|
+
"videos/README.md": "16babc3437d5545b",
|
|
2871
|
+
"posts/README.md": "37841aae476bd540",
|
|
2872
|
+
"site/README.md": "732894273a32dc3f",
|
|
2873
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
2874
|
+
},
|
|
2875
|
+
"sections": {
|
|
2876
|
+
"AGENTS.md": {
|
|
2877
|
+
"(intro)": "394cbbe934e80722",
|
|
2878
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
2879
|
+
"## Layout": "f3a280b8c96cd068",
|
|
2880
|
+
"## Apps: one screen, roles and parts": "8596f2b25edda3e2",
|
|
2881
|
+
"## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
|
|
2882
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
2883
|
+
"## Making games": "bb715272fd1a615b",
|
|
2884
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
2885
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
2886
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
2887
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
2888
|
+
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
2889
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
2890
|
+
"## Rules": "27881d11d3ff3803",
|
|
2891
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
2892
|
+
"## Beta": "9d02638207a26e73"
|
|
2893
|
+
}
|
|
2894
|
+
},
|
|
2895
|
+
"named": {
|
|
2896
|
+
"Homie Arcade": {
|
|
2897
|
+
"sections": {
|
|
2898
|
+
"AGENTS.md": {
|
|
2899
|
+
"## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
}
|
|
2862
2904
|
}
|
|
2863
2905
|
}
|
|
2864
2906
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.46.
|
|
3
|
+
"version": "0.46.6",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/rules/host.ts
CHANGED
|
@@ -80,6 +80,8 @@ export interface HostOptions {
|
|
|
80
80
|
clock: HostClock;
|
|
81
81
|
/** Synchronous save: the caller's storage output gate holds this tick's outgoing frames until confirmed. */
|
|
82
82
|
store?: { save(bytes: Uint8Array, commitment?: { force: boolean; seconds: number; tickHz: number; fields: { kinds: number[][]; shared: number[] } }): void };
|
|
83
|
+
/** Whether a snapshot is also handed over as text (the default). A room that sends the whole world to nobody answers false. */
|
|
84
|
+
snapshotText?: () => boolean;
|
|
83
85
|
/** Table recovery reserves this epoch in storage before starting this host. Ordinary deterministic restores omit it. */
|
|
84
86
|
restoreEpoch?: number;
|
|
85
87
|
startDelayMs?: number;
|
|
@@ -764,7 +766,8 @@ export function createHost(o: HostOptions): Host {
|
|
|
764
766
|
// apart to whoever interpolates between them. The stamp always rises: a replica drops one that does not.
|
|
765
767
|
lastSt = Math.max(lastSt + 1, Math.round(Math.min(now, dueOf(core.tick))));
|
|
766
768
|
const snap = { t: 'snap', from: null, e: epoch, k: core.tick, st: lastSt, d: data, c: rows };
|
|
767
|
-
|
|
769
|
+
// The whole world as text, for whoever is sent it whole. A room that sends it to nobody says so and is spared it.
|
|
770
|
+
const encoded = o.snapshotText?.() === false ? undefined : JSON.stringify(snap);
|
|
768
771
|
timing.record('encode', encodeAt);
|
|
769
772
|
const sendAt = timing.now();
|
|
770
773
|
o.send(snap, encoded);
|
package/worker/build-id.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
|
|
2
|
-
export const STUDIO_BUILD = Object.freeze({"version":"0.46.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.46.6","sha256":"efd3ddbf72033f5d24b8107a41db2bc1a518f1d3f92963f5afd77c5c0e2f5059"});
|
package/worker/gate.mjs
CHANGED
|
@@ -36,9 +36,11 @@ function parseFrame(event) {
|
|
|
36
36
|
* as full as it can serve, and a room opens only the Gates its players fill.
|
|
37
37
|
* A studio chooses its room's size and nothing else. */
|
|
38
38
|
export const GATE_PLAYERS = 50;
|
|
39
|
-
|
|
39
|
+
/** `per`: players a Gate, for a trial that is finding the right number (`HOMIE_GATE_PLAYERS` on the Worker). A studio never sets it. */
|
|
40
|
+
export const gatePlayers = (env) => { const n = Number(env?.HOMIE_GATE_PLAYERS); return Number.isInteger(n) && n >= 4 && n <= 200 ? n : GATE_PLAYERS; };
|
|
41
|
+
export function roomLayout(seats, per = GATE_PLAYERS) {
|
|
40
42
|
// A room of up to 64 is its Table alone, as it has been since 0.45.0.
|
|
41
|
-
const gates = seats <= 64 ? 1 : Math.ceil(seats /
|
|
43
|
+
const gates = seats <= 64 ? 1 : Math.ceil(seats / per);
|
|
42
44
|
return { gates, concentrators: 0 };
|
|
43
45
|
}
|
|
44
46
|
/** The Gate a key lands on when the room cannot be asked: spread evenly, as 0.45.0 did. */
|
|
@@ -52,17 +54,17 @@ function hashedGate(key, gates) {
|
|
|
52
54
|
* Gate, not twenty with two or three players each, every one of them costing the room a channel. `held` is each
|
|
53
55
|
* Gate's players now and `promised` those sent to it who have not arrived yet.
|
|
54
56
|
*/
|
|
55
|
-
export function nextGate(gates, held, promised = new Map()) {
|
|
57
|
+
export function nextGate(gates, held, promised = new Map(), per = GATE_PLAYERS) {
|
|
56
58
|
let least = 0, fewest = Infinity;
|
|
57
59
|
for (let i = 0; i < gates; i++) {
|
|
58
60
|
const n = (held.get(i) ?? 0) + (promised.get(i) ?? 0);
|
|
59
|
-
if (n <
|
|
61
|
+
if (n < per) return i;
|
|
60
62
|
if (n < fewest) { fewest = n; least = i; }
|
|
61
63
|
}
|
|
62
64
|
return least;
|
|
63
65
|
}
|
|
64
66
|
export function gateFor(env, game, room, seats, key) {
|
|
65
|
-
const layout = roomLayout(seats);
|
|
67
|
+
const layout = roomLayout(seats, gatePlayers(env));
|
|
66
68
|
// One logical Gate is embedded in the Table for a small room. Same relay,
|
|
67
69
|
// admission and delivery protocol; no extra Durable Object or seat clamp.
|
|
68
70
|
const table = () => env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
|
|
@@ -211,7 +213,9 @@ export function multiplexSession(socket, connect, { onMetrics = () => {}, delay
|
|
|
211
213
|
const scheduleAdmission = () => {
|
|
212
214
|
if (!closed && admissions.length && attaching < 8 && out.buffered() < 512 * 1024 && admissionTimer === null) admissionTimer = setTimeout(pump, 1);
|
|
213
215
|
};
|
|
214
|
-
|
|
216
|
+
// What this Gate has sent the room: frames, the rows in them (a row is a player's message, mostly) and their size.
|
|
217
|
+
const heard = { frames: 0, rows: 0, bytes: 0 };
|
|
218
|
+
const transportFacts = () => ({ ...out.facts(), in: { ...heard }, admissions: admissions.length, attaching });
|
|
215
219
|
const shutdown = () => {
|
|
216
220
|
if (closed) return;
|
|
217
221
|
closed = true; out.close();
|
|
@@ -226,6 +230,7 @@ export function multiplexSession(socket, connect, { onMetrics = () => {}, delay
|
|
|
226
230
|
if (closed) return;
|
|
227
231
|
const rows = parseFrame(event);
|
|
228
232
|
if (!Array.isArray(rows)) throw new Error('invalid multiplex batch');
|
|
233
|
+
heard.frames += 1; heard.rows += rows.length; heard.bytes += event.data.length;
|
|
229
234
|
for (const [op, id, value, ip, agent] of expandBatch(rows)) {
|
|
230
235
|
if (op === 'metrics') { onMetrics(id, value); continue; }
|
|
231
236
|
if (op === 'open') {
|
|
@@ -291,26 +296,38 @@ export function acceptMultiplex(request, connect, options) {
|
|
|
291
296
|
* A socket to a slow player has no back-pressure a Worker can read, so a Gate
|
|
292
297
|
* that writes every tick to a link that carries fewer leaves that player
|
|
293
298
|
* seconds behind for good: every frame arrives, each one later than the last.
|
|
294
|
-
* A client names the newest tick it holds (`g`, on its inputs and pings).
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
299
|
+
* A client names the newest tick it holds (`g`, on its inputs and pings).
|
|
300
|
+
*
|
|
301
|
+
* Being behind is not the reason to send less: a connection that stalls for a
|
|
302
|
+
* second and then delivers what it held catches up by itself, and sending it
|
|
303
|
+
* less only makes it worse (0.46.2 paced all 600 players of a healthy room
|
|
304
|
+
* when their one home link hiccuped). The reason is being behind and NOT
|
|
305
|
+
* catching up: more than a second behind on two reports half a second apart,
|
|
306
|
+
* the second not a quarter of a second nearer than the first. Then this
|
|
307
|
+
* player's rate halves, down to one a second, and halves again while that
|
|
308
|
+
* stays so. Within half a second of the room, the rate doubles back, a step a
|
|
309
|
+
* second. Each frame is a delta
|
|
310
|
+
* from the last one SENT, so a skipped tick costs nothing later, and the
|
|
311
|
+
* player's own body is in every frame. A client that names nothing is sent
|
|
312
|
+
* every tick, as before.
|
|
300
313
|
*/
|
|
301
314
|
export function deliveryPace(hz = 20) {
|
|
302
|
-
let interval = 1, sent = -Infinity,
|
|
315
|
+
let interval = 1, sent = -Infinity, changedAt = -Infinity, lag = null, behind = null;
|
|
303
316
|
const most = Math.max(1, Math.round(hz));
|
|
304
317
|
return {
|
|
305
318
|
/** The client holds tick `tick`; the room is at `world`. `now` in ms. */
|
|
306
319
|
report(tick, world, now) {
|
|
307
320
|
if (!Number.isFinite(tick) || !Number.isFinite(world)) return;
|
|
308
321
|
// A new room epoch restarts its ticks: a tick from the old one says nothing.
|
|
309
|
-
|
|
310
|
-
lag
|
|
311
|
-
if (lag
|
|
312
|
-
if (lag
|
|
313
|
-
|
|
322
|
+
lag = tick > world ? null : world - tick;
|
|
323
|
+
if (lag === null) { behind = null; return; }
|
|
324
|
+
if (lag <= hz / 2) { behind = null; if (interval > 1 && now - changedAt >= 1000) { interval = Math.max(1, interval / 2); changedAt = now; } return; }
|
|
325
|
+
if (lag <= hz) { behind = null; return; }
|
|
326
|
+
if (behind === null) { behind = { lag, at: now }; return; }
|
|
327
|
+
if (now - behind.at < 500) return;
|
|
328
|
+
// Catching up means a quarter of a second nearer in half a second; anything less is a link that cannot keep this rate.
|
|
329
|
+
if (lag > behind.lag - hz / 4 && now - changedAt >= 500 && interval < most) { interval = Math.min(most, interval * 2); changedAt = now; }
|
|
330
|
+
behind = { lag, at: now };
|
|
314
331
|
},
|
|
315
332
|
due(tick) { return tick < sent || tick - sent >= interval; },
|
|
316
333
|
sent(tick) { sent = tick; },
|
|
@@ -324,8 +341,19 @@ const COASTS = /^\{"t":"hello".{0,2000}?"coast":1[,}]/;
|
|
|
324
341
|
const percentile = (sorted, p) => sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] : null;
|
|
325
342
|
|
|
326
343
|
export class Gate {
|
|
327
|
-
constructor(ctx, env) { this.ctx = ctx; this.env = env; this.clients = new Map(); this.admissions = new Map(); this.sequence = 0; this.link = null; this.opening = null; this.timing = stageTimings(); this.metricsAt = 0; this.window = this.freshWindow(); this.worldTick = null; }
|
|
328
|
-
freshWindow() { return { worlds: 0, frames: 0, bytes: 0, skipped: 0, inputs: 0, worldAge: [], tickFrom: null, tickFromAt: 0, tickAt: 0, gapMax: 0 }; }
|
|
344
|
+
constructor(ctx, env) { this.ctx = ctx; this.env = env; this.clients = new Map(); this.admissions = new Map(); this.sequence = 0; this.link = null; this.opening = null; this.timing = stageTimings(); this.metricsAt = 0; this.window = this.freshWindow(); this.worldTick = null; this.behind = new Set(); this.idle = null; }
|
|
345
|
+
freshWindow() { return { worlds: 0, frames: 0, bytes: 0, skipped: 0, inputs: 0, worldAge: [], tickFrom: null, tickFromAt: 0, tickAt: 0, gapMax: 0, gaps: [0, 0, 0], late: 0, busy: 0 }; }
|
|
346
|
+
/** This Gate's own lateness: a timer every 20 ms, and how long past it each one came. A Gate that is out of time is late to its own timer. */
|
|
347
|
+
watchLoop() {
|
|
348
|
+
if (this.loopTimer) return;
|
|
349
|
+
let due = Date.now() + 20;
|
|
350
|
+
this.loopTimer = setInterval(() => {
|
|
351
|
+
const now = Date.now(), late = Math.max(0, now - due); due = now + 20;
|
|
352
|
+
this.window.late = Math.max(this.window.late, late); if (late > 20) this.window.busy += 1;
|
|
353
|
+
if (!this.link || !this.clients.size) { clearInterval(this.loopTimer); this.loopTimer = null; }
|
|
354
|
+
}, 20);
|
|
355
|
+
this.loopTimer?.unref?.();
|
|
356
|
+
}
|
|
329
357
|
/** `[sent, received, answered]`: this Gate's clock when its frame left, the room's when it read it and when it answered. */
|
|
330
358
|
roomClock([sent, received, answered]) {
|
|
331
359
|
const now = Date.now(), offset = ((received - sent) + (answered - now)) / 2, delay = (now - sent) - (answered - received);
|
|
@@ -380,6 +408,13 @@ export class Gate {
|
|
|
380
408
|
} catch (error) { lost({ error, reason: 'invalid room frame' }); try { socket.close(1011, 'room link failed'); } catch {} }
|
|
381
409
|
});
|
|
382
410
|
socket.addEventListener('close', lost); socket.addEventListener('error', lost);
|
|
411
|
+
// A Gate nobody is on gives its channel back after ten seconds: an open channel keeps the room and this Gate in
|
|
412
|
+
// memory (and billed) with nothing to carry. The next player to arrive opens another, as the first did.
|
|
413
|
+
this.rest = () => {
|
|
414
|
+
if (this.link !== link || this.clients.size) return;
|
|
415
|
+
this.link = null; this.worlds = null; link.close();
|
|
416
|
+
try { socket.close(1000, 'no players'); } catch { /* gone */ }
|
|
417
|
+
};
|
|
383
418
|
this.link = link; return link;
|
|
384
419
|
})();
|
|
385
420
|
try { return await this.opening; } finally { this.opening = null; }
|
|
@@ -388,7 +423,7 @@ export class Gate {
|
|
|
388
423
|
deliver(snap, settings, hz, drop) {
|
|
389
424
|
const w = this.window, now = Date.now(); w.worlds++;
|
|
390
425
|
if (w.tickFrom === null) { w.tickFrom = this.worldTick ?? snap.k; w.tickFromAt = this.worldAt ?? now; }
|
|
391
|
-
if (this.worldAt) w.gapMax = Math.max(w.gapMax,
|
|
426
|
+
if (this.worldAt) { const gap = now - this.worldAt; w.gapMax = Math.max(w.gapMax, gap); if (gap > 75) w.gaps[0] += 1; if (gap > 150) w.gaps[1] += 1; if (gap > 300) w.gaps[2] += 1; }
|
|
392
427
|
this.worldTick = snap.k; this.worldAt = now; w.tickAt = now;
|
|
393
428
|
// By this Gate's clock, corrected for what the room's clock is measured to differ by.
|
|
394
429
|
if (Number.isFinite(snap.st)) w.worldAge.push(Math.max(0, now - snap.st + (this.clock?.offsetMs ?? 0)));
|
|
@@ -431,7 +466,7 @@ export class Gate {
|
|
|
431
466
|
async connect(request, server) {
|
|
432
467
|
const link = await this.upstream(new URL(request.url));
|
|
433
468
|
const id = String(++this.sequence);
|
|
434
|
-
this.clients.set(id, server);
|
|
469
|
+
this.clients.set(id, server); clearTimeout(this.idle); this.idle = null; this.watchLoop();
|
|
435
470
|
let opened = false;
|
|
436
471
|
const deadline = setTimeout(() => {
|
|
437
472
|
this.admissions.delete(id);
|
|
@@ -458,12 +493,18 @@ export class Gate {
|
|
|
458
493
|
}
|
|
459
494
|
const now = Date.now();
|
|
460
495
|
const said = REPORTED_TICK.exec(event.data);
|
|
461
|
-
if (said && server.pace && this.worldTick !== null)
|
|
496
|
+
if (said && server.pace && this.worldTick !== null) {
|
|
497
|
+
server.pace.report(Number(said[1]), this.worldTick, now);
|
|
498
|
+
// Counted, never acted on: players more than a second behind at this moment. Many at once on one Gate is a
|
|
499
|
+
// connection they share or something upstream of them all; each is still paced by their own reports alone
|
|
500
|
+
// (a full shared link is cured by sending each of them less, and nothing else cures it).
|
|
501
|
+
if ((server.pace.facts().lag ?? 0) > 20) this.behind.add(id); else this.behind.delete(id);
|
|
502
|
+
}
|
|
462
503
|
this.window.inputs++;
|
|
463
504
|
// Only the public Gate stamps ingress; a player cannot supply it inside their JSON.
|
|
464
505
|
link.send(['data', id, event.data, Number.isFinite(event.receivedAt) ? event.receivedAt : now]);
|
|
465
506
|
});
|
|
466
|
-
const left = () => { clearTimeout(deadline); this.admissions.delete(id); if (this.clients.delete(id) && opened) link.send(['close', id]); };
|
|
507
|
+
const left = () => { this.behind.delete(id); clearTimeout(deadline); this.admissions.delete(id); if (this.clients.delete(id) && opened) link.send(['close', id]); if (!this.clients.size && !this.idle) { this.idle = setTimeout(() => { this.idle = null; this.rest?.(); }, 10_000); this.idle?.unref?.(); } };
|
|
467
508
|
server.addEventListener('close', left); server.addEventListener('error', left);
|
|
468
509
|
}
|
|
469
510
|
/** What this Gate did in the last second, for whoever is measuring the room. */
|
|
@@ -480,15 +521,21 @@ export class Gate {
|
|
|
480
521
|
bytesPerSecond: Math.round(w.bytes / seconds), inputsPerSecond: Math.round(w.inputs / seconds), skipped: w.skipped,
|
|
481
522
|
// Room stamp to this Gate writing it to players, ms (both are Cloudflare's clock at the start of an event).
|
|
482
523
|
worldAgeMs: { p50: percentile(ages, 0.5), p95: percentile(ages, 0.95), max: ages.at(-1) ?? null },
|
|
524
|
+
// Of this second's worlds: how many were over 100, 200 and 400 ms old when this Gate wrote them, and how many came
|
|
525
|
+
// more than 75, 150 and 300 ms after the one before (a world is due every 50).
|
|
526
|
+
worldsOld: { n: ages.length, over100: ages.filter(a => a > 100).length, over200: ages.filter(a => a > 200).length, over400: ages.filter(a => a > 400).length },
|
|
527
|
+
worldGaps: { over75: w.gaps[0], over150: w.gaps[1], over300: w.gaps[2] },
|
|
483
528
|
// How far behind the room players say they are, in ticks, and how many are being sent fewer frames for it.
|
|
484
529
|
clientLagTicks: { reporting: lags.length, p50: percentile(lags, 0.5), p95: percentile(lags, 0.95), max: lags.at(-1) ?? null },
|
|
485
|
-
paced: paces.filter(p => p.interval > 1).length,
|
|
530
|
+
paced: paces.filter(p => p.interval > 1).length, behind: this.behind.size,
|
|
486
531
|
coasting: subscribed.filter(c => c.coasts).length,
|
|
487
532
|
// Ticks the room's worlds advanced a second, by THIS Gate's clock: the room's own clock cannot see itself run late.
|
|
488
533
|
roomTickHz: w.tickFrom !== null && w.tickAt > w.tickFromAt ? Math.round((this.worldTick - w.tickFrom) / (w.tickAt - w.tickFromAt) * 100000) / 100 : null,
|
|
489
534
|
worldGapMs: w.gapMax,
|
|
490
535
|
// The room's clock less this Gate's, and the channel's delay, from stamps exchanged on the channel (as NTP does).
|
|
491
536
|
roomClock: this.clock ?? null,
|
|
537
|
+
// The worst this Gate's own 20 ms timer came late in the second, and how many of the fifty came over 20 ms late.
|
|
538
|
+
loopLateMs: w.late, loopLateTimers: w.busy,
|
|
492
539
|
};
|
|
493
540
|
}
|
|
494
541
|
publishMetrics(url, link) {
|