@irtio/cli 0.5.1 → 0.6.0
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/dist/api.d.ts +52 -0
- package/dist/api.js +15 -0
- package/dist/bundle.js +1 -1
- package/dist/{static-deploy-BP3MDXCP.js → chunk-3HQMVCYA.js} +89 -48
- package/dist/chunk-DKWG7MGO.js +93 -0
- package/dist/{chunk-KRQUAEN2.js → chunk-OTSFRVJN.js} +12 -6
- package/dist/{chunk-I37DLT7K.js → chunk-RNAH5T4W.js} +14 -3
- package/dist/chunk-RQSJZWQC.js +452 -0
- package/dist/{chunk-NVUKSP5U.js → chunk-UPHQM6NZ.js} +12 -1
- package/dist/chunk-ZD4ND6X6.js +31 -0
- package/dist/chunk-ZK5JLUD4.js +94 -0
- package/dist/credentials.d.ts +61 -0
- package/dist/credentials.js +20 -0
- package/dist/delete-project-VENS2B44.js +118 -0
- package/dist/deploy.d.ts +149 -0
- package/dist/deploy.js +567 -0
- package/dist/{dev-7UZZGE4U.js → dev-QM26ONKS.js} +3095 -294
- package/dist/index.js +113 -28
- package/dist/init.d.ts +2 -1
- package/dist/init.js +42 -1
- package/dist/{keys-KEKO3EJ6.js → keys-JHLMEGRA.js} +49 -18
- package/dist/leaderboard-SYPSBPS3.js +352 -0
- package/dist/{login-OV2EFNTJ.js → login-2M73HBZT.js} +25 -1
- package/dist/{logs-5OUDPAIX.js → logs-2W7CPZO5.js} +42 -17
- package/dist/{migrate-UD245ULI.js → migrate-T3DZJREY.js} +44 -19
- package/dist/ratings-VG32WFDG.js +297 -0
- package/dist/{rollback-CLQVYFHW.js → rollback-SO74MVZV.js} +41 -17
- package/dist/{rooms-OJ3JLYHD.js → rooms-VI33P4RA.js} +73 -20
- package/dist/simulate.d.ts +215 -3
- package/dist/simulate.js +865 -64
- package/dist/static-deploy-KOWFKWZA.js +19 -0
- package/dist/status-HF3ZEKB7.js +219 -0
- package/dist/usage-4G23QXCH.js +213 -0
- package/dist/{whoami-S73O6KJF.js → whoami-KTMTQNHM.js} +21 -2
- package/package.json +24 -7
- package/dist/chunk-BPE452KF.js +0 -180
- package/dist/chunk-D7CDJRFF.js +0 -24
- package/dist/deploy-YVCVDVMS.js +0 -396
|
@@ -1,26 +1,48 @@
|
|
|
1
|
+
import {
|
|
2
|
+
profileTick
|
|
3
|
+
} from "./chunk-ZK5JLUD4.js";
|
|
1
4
|
import {
|
|
2
5
|
clientImportsRoom
|
|
3
6
|
} from "./chunk-TQU6345E.js";
|
|
4
|
-
import {
|
|
5
|
-
BundleError,
|
|
6
|
-
bundleRoom
|
|
7
|
-
} from "./chunk-KRQUAEN2.js";
|
|
8
7
|
import {
|
|
9
8
|
DEFAULT_SAVE_RETAIN,
|
|
10
9
|
DiskStore,
|
|
10
|
+
IRT_IDENTITY_ISSUER,
|
|
11
11
|
KV_ERRORS,
|
|
12
|
+
LEADERBOARD_ERRORS,
|
|
12
13
|
PLAYER_ISSUER_RE,
|
|
13
14
|
PRE_MIGRATION_SAVE_ID,
|
|
15
|
+
RATING_ERRORS,
|
|
14
16
|
listSaves,
|
|
15
17
|
mintSaveId,
|
|
18
|
+
parseIdentityKeys,
|
|
16
19
|
pruneSaves,
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
ratingPlayerProblem,
|
|
21
|
+
ratingQueueProblem,
|
|
22
|
+
ratingResultsProblem,
|
|
23
|
+
ratingValueProblem,
|
|
24
|
+
saveKey,
|
|
25
|
+
verifyAssertion
|
|
26
|
+
} from "./chunk-RQSJZWQC.js";
|
|
27
|
+
import {
|
|
28
|
+
readProjectConfig
|
|
29
|
+
} from "./chunk-DKWG7MGO.js";
|
|
30
|
+
import {
|
|
31
|
+
BundleError,
|
|
32
|
+
bundleRoom
|
|
33
|
+
} from "./chunk-OTSFRVJN.js";
|
|
34
|
+
import {
|
|
35
|
+
HelpRequested,
|
|
36
|
+
helpFor,
|
|
37
|
+
helpRequested
|
|
38
|
+
} from "./chunk-ZD4ND6X6.js";
|
|
39
|
+
import "./chunk-RNAH5T4W.js";
|
|
40
|
+
import "./chunk-UPHQM6NZ.js";
|
|
19
41
|
|
|
20
42
|
// src/dev.ts
|
|
21
43
|
import { existsSync } from "fs";
|
|
22
44
|
import { watch } from "fs";
|
|
23
|
-
import { mkdir
|
|
45
|
+
import { mkdir } from "fs/promises";
|
|
24
46
|
import { createRequire } from "module";
|
|
25
47
|
import * as path from "path";
|
|
26
48
|
import { fileURLToPath, pathToFileURL as pathToFileURL3 } from "url";
|
|
@@ -40,9 +62,61 @@ var DEFAULT_LIMITS = {
|
|
|
40
62
|
relayIdleMs: 3e4,
|
|
41
63
|
relayReconnectGraceMs: 3e4,
|
|
42
64
|
relayMaxClients: 64,
|
|
65
|
+
relayMaxClientsFree: 16,
|
|
66
|
+
freeMaxAwakeRooms: 10,
|
|
43
67
|
saveRetain: DEFAULT_SAVE_RETAIN
|
|
44
68
|
};
|
|
45
69
|
|
|
70
|
+
// ../supervisor/src/codes.ts
|
|
71
|
+
import { randomInt } from "crypto";
|
|
72
|
+
import {
|
|
73
|
+
CODE_ALPHABET,
|
|
74
|
+
DEFAULT_ROOM_TYPE,
|
|
75
|
+
ROOM_ID_RE,
|
|
76
|
+
formatRoomId,
|
|
77
|
+
parseRoomId
|
|
78
|
+
} from "@irtio/protocol";
|
|
79
|
+
var TENANT_ROOM_ID = "__tenant";
|
|
80
|
+
var CODE_SHAPE_RE = /^[A-Za-z0-9]{4,5}$/;
|
|
81
|
+
function pick(n) {
|
|
82
|
+
let out = "";
|
|
83
|
+
for (let i = 0; i < n; i++) out += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
function newRoomCode(taken) {
|
|
87
|
+
for (let len = 4; len <= 8; len++) {
|
|
88
|
+
for (let attempt = 0; attempt < 64; attempt++) {
|
|
89
|
+
const code = pick(len);
|
|
90
|
+
if (!taken(code)) return code;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
throw new Error("newRoomCode: exhausted the room code space");
|
|
94
|
+
}
|
|
95
|
+
function newClientId(taken) {
|
|
96
|
+
for (let attempt = 0; attempt < 128; attempt++) {
|
|
97
|
+
const id = `c${pick(10)}`;
|
|
98
|
+
if (!taken(id)) return id;
|
|
99
|
+
}
|
|
100
|
+
throw new Error("newClientId: could not allocate a unique client id");
|
|
101
|
+
}
|
|
102
|
+
function looksLikeRoomCode(id) {
|
|
103
|
+
if (!CODE_SHAPE_RE.test(id)) return false;
|
|
104
|
+
for (const ch of id.toUpperCase()) {
|
|
105
|
+
if (!CODE_ALPHABET.includes(ch)) return false;
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
function normalizeRoomId(raw) {
|
|
110
|
+
const parsed = parseRoomId(raw);
|
|
111
|
+
if (!parsed) return void 0;
|
|
112
|
+
if (parsed.id === TENANT_ROOM_ID) return void 0;
|
|
113
|
+
const id = looksLikeRoomCode(parsed.id) ? parsed.id.toUpperCase() : parsed.id;
|
|
114
|
+
return formatRoomId(parsed.type, id);
|
|
115
|
+
}
|
|
116
|
+
function roomTypeOf(roomId) {
|
|
117
|
+
return parseRoomId(roomId)?.type ?? DEFAULT_ROOM_TYPE;
|
|
118
|
+
}
|
|
119
|
+
|
|
46
120
|
// ../supervisor/src/resilience.ts
|
|
47
121
|
var SNAPSHOT_FLUSH_MAX_RETRIES = 3;
|
|
48
122
|
var SNAPSHOT_FLUSH_RETRY_DELAYS_MS = [100, 200, 400];
|
|
@@ -84,27 +158,38 @@ async function putSnapshotWithRetry(store, key, bytes, metrics, log) {
|
|
|
84
158
|
// ../supervisor/src/server.ts
|
|
85
159
|
import { execFile } from "child_process";
|
|
86
160
|
import { randomBytes, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
161
|
+
import { readdirSync } from "fs";
|
|
87
162
|
import { mkdtemp, writeFile } from "fs/promises";
|
|
88
163
|
import { createServer } from "http";
|
|
89
164
|
import { tmpdir } from "os";
|
|
90
165
|
import * as nodePath from "path";
|
|
91
166
|
import { pathToFileURL as pathToFileURL2 } from "url";
|
|
92
167
|
import {
|
|
93
|
-
|
|
94
|
-
|
|
168
|
+
BUS_MAILBOX_PREFIX as BUS_MAILBOX_PREFIX2,
|
|
169
|
+
BUS_OUTBOX as BUS_OUTBOX2,
|
|
170
|
+
CLOSE_EGRESS_WALL,
|
|
171
|
+
DEFAULT_ROOM_TYPE as DEFAULT_ROOM_TYPE3,
|
|
172
|
+
ErrorCode as ErrorCode3,
|
|
173
|
+
FrameType as FrameType3,
|
|
95
174
|
PROTOCOL_VERSION,
|
|
175
|
+
ROOM_TYPE_RE,
|
|
96
176
|
decodeFrame,
|
|
97
177
|
decodeHello,
|
|
98
|
-
decodeMsg,
|
|
99
178
|
decodePing,
|
|
179
|
+
decodeVoiceMessage,
|
|
100
180
|
encodeErrorPayload as encodeErrorPayload2,
|
|
101
|
-
encodeFrame as
|
|
102
|
-
encodeMsg,
|
|
181
|
+
encodeFrame as encodeFrame3,
|
|
182
|
+
encodeMsg as encodeMsg2,
|
|
103
183
|
encodePong,
|
|
184
|
+
encodeVoiceMessage,
|
|
104
185
|
encodeWelcome,
|
|
105
186
|
formatError,
|
|
187
|
+
isMailboxAlarm,
|
|
188
|
+
isOutboxAlarm,
|
|
106
189
|
isRelayHash8,
|
|
190
|
+
isVoiceMsg,
|
|
107
191
|
relaySchema as relaySchema2,
|
|
192
|
+
schemaPayload,
|
|
108
193
|
withBuiltins
|
|
109
194
|
} from "@irtio/protocol";
|
|
110
195
|
import { inspectState } from "@irtio/runtime";
|
|
@@ -131,6 +216,14 @@ var AlarmSet = class {
|
|
|
131
216
|
/** `name` -> wall-clock ms the alarm is due at. Wall clock, not monotonic: it has to mean the
|
|
132
217
|
* same thing to a control plane on another machine and across a tenant restart. */
|
|
133
218
|
due = /* @__PURE__ */ new Map();
|
|
219
|
+
/**
|
|
220
|
+
* D59: `name` -> the opaque payload string that entry carries, for the names that have one.
|
|
221
|
+
*
|
|
222
|
+
* A separate map rather than a field on a richer value type, so that every alarm which is not a
|
|
223
|
+
* bus mailbox entry is stored exactly as it was: an alarm is a name and a time, and D26's whole
|
|
224
|
+
* surface stays that. Only mailbox entries (`BUS_MAILBOX_PREFIX`) ever put anything here.
|
|
225
|
+
*/
|
|
226
|
+
payloads = /* @__PURE__ */ new Map();
|
|
134
227
|
timer;
|
|
135
228
|
/** Called when one or more alarms come due, with the names in deterministic (name) order. */
|
|
136
229
|
deliver;
|
|
@@ -149,13 +242,34 @@ var AlarmSet = class {
|
|
|
149
242
|
this.deliver = void 0;
|
|
150
243
|
this.clearTimer();
|
|
151
244
|
}
|
|
152
|
-
/**
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
245
|
+
/**
|
|
246
|
+
* `atMs === undefined` cancels. Arming a name that is already armed replaces its due time.
|
|
247
|
+
*
|
|
248
|
+
* D59: `payload` attaches an opaque string to the entry, which is how a bus mailbox entry rides
|
|
249
|
+
* this machinery. **Replace-by-name is why mailbox names are minted unique**: two sends of the
|
|
250
|
+
* same payload to the same room have to deliver twice, and a stable name would have the second
|
|
251
|
+
* quietly overwrite the first.
|
|
252
|
+
*/
|
|
253
|
+
set(name, atMs, payload) {
|
|
254
|
+
if (atMs === void 0) {
|
|
255
|
+
this.due.delete(name);
|
|
256
|
+
this.payloads.delete(name);
|
|
257
|
+
} else {
|
|
258
|
+
this.due.set(name, atMs);
|
|
259
|
+
if (payload === void 0) this.payloads.delete(name);
|
|
260
|
+
else this.payloads.set(name, payload);
|
|
261
|
+
}
|
|
156
262
|
this.rearm();
|
|
157
263
|
this.onChange?.();
|
|
158
264
|
}
|
|
265
|
+
/** D59: the payload armed with `name`, or `undefined` for an ordinary alarm. */
|
|
266
|
+
payloadOf(name) {
|
|
267
|
+
return this.payloads.get(name);
|
|
268
|
+
}
|
|
269
|
+
/** D59: how many armed entries carry a payload, i.e. the depth of this room's mailbox. */
|
|
270
|
+
get mailboxDepth() {
|
|
271
|
+
return this.payloads.size;
|
|
272
|
+
}
|
|
159
273
|
get size() {
|
|
160
274
|
return this.due.size;
|
|
161
275
|
}
|
|
@@ -164,12 +278,19 @@ var AlarmSet = class {
|
|
|
164
278
|
/** Replaces the armed set wholesale — how a room start re-arms from the persisted sidecar. */
|
|
165
279
|
load(entries) {
|
|
166
280
|
this.due.clear();
|
|
167
|
-
|
|
281
|
+
this.payloads.clear();
|
|
282
|
+
for (const e of entries) {
|
|
283
|
+
this.due.set(e.name, e.dueAt);
|
|
284
|
+
if (e.payload !== void 0) this.payloads.set(e.name, e.payload);
|
|
285
|
+
}
|
|
168
286
|
this.rearm();
|
|
169
287
|
}
|
|
170
288
|
/** Snapshot for `/admin/rooms` and for tests. */
|
|
171
289
|
entries() {
|
|
172
|
-
return [...this.due.entries()].map(([name, dueAt]) =>
|
|
290
|
+
return [...this.due.entries()].map(([name, dueAt]) => {
|
|
291
|
+
const payload = this.payloads.get(name);
|
|
292
|
+
return { name, dueAt, ...payload !== void 0 ? { payload } : {} };
|
|
293
|
+
}).sort((a, b) => a.dueAt - b.dueAt || (a.name < b.name ? -1 : 1));
|
|
173
294
|
}
|
|
174
295
|
/** The earliest due time, or `undefined` when nothing is armed. What control is told. */
|
|
175
296
|
get earliestDueAt() {
|
|
@@ -188,6 +309,7 @@ var AlarmSet = class {
|
|
|
188
309
|
if (names.length === 0) return names;
|
|
189
310
|
for (const name of names) this.due.delete(name);
|
|
190
311
|
this.deliver?.(names);
|
|
312
|
+
for (const name of names) if (!this.due.has(name)) this.payloads.delete(name);
|
|
191
313
|
this.rearm();
|
|
192
314
|
this.onChange?.();
|
|
193
315
|
return names;
|
|
@@ -248,11 +370,64 @@ function decodeAlarms(bytes) {
|
|
|
248
370
|
const e = entry;
|
|
249
371
|
if (typeof e.name !== "string" || e.name === "") continue;
|
|
250
372
|
if (typeof e.dueAt !== "number" || !Number.isFinite(e.dueAt)) continue;
|
|
251
|
-
|
|
373
|
+
const payload = e.payload;
|
|
374
|
+
out.push({
|
|
375
|
+
name: e.name,
|
|
376
|
+
dueAt: e.dueAt,
|
|
377
|
+
...typeof payload === "string" ? { payload } : {}
|
|
378
|
+
});
|
|
252
379
|
}
|
|
253
380
|
return out;
|
|
254
381
|
}
|
|
255
382
|
|
|
383
|
+
// ../supervisor/src/relay-host.ts
|
|
384
|
+
import {
|
|
385
|
+
ErrorCode,
|
|
386
|
+
FrameType,
|
|
387
|
+
decodeMsg,
|
|
388
|
+
encodeFrame,
|
|
389
|
+
encodeMsg
|
|
390
|
+
} from "@irtio/protocol";
|
|
391
|
+
function relayJoin(room, peer) {
|
|
392
|
+
const relay = room.relayRoom;
|
|
393
|
+
if (!relay.has(peer.clientId) && relay.size >= room.maxClients) {
|
|
394
|
+
return { ok: false, code: "E_ROOM_FULL" };
|
|
395
|
+
}
|
|
396
|
+
const delta = relay.add(peer.clientId, peer.role, peer.name);
|
|
397
|
+
return { ok: true, tick: relay.tick, snapshot: relay.snapshot(), delta };
|
|
398
|
+
}
|
|
399
|
+
function relayForward(room, peer, type, payload) {
|
|
400
|
+
if (type !== FrameType.MSG) {
|
|
401
|
+
return { ok: false, reason: "relay rooms accept MSG only (no room code is deployed)" };
|
|
402
|
+
}
|
|
403
|
+
let target;
|
|
404
|
+
let body;
|
|
405
|
+
try {
|
|
406
|
+
const msg = decodeMsg(payload);
|
|
407
|
+
target = msg.target;
|
|
408
|
+
body = msg.payload;
|
|
409
|
+
} catch {
|
|
410
|
+
return { ok: false, reason: "malformed MSG" };
|
|
411
|
+
}
|
|
412
|
+
if (target.kind === "server") {
|
|
413
|
+
return { ok: false, reason: "relay rooms have no server to address" };
|
|
414
|
+
}
|
|
415
|
+
if (target.kind === "voice") return { ok: true, dropped: true };
|
|
416
|
+
const out = encodeFrame(
|
|
417
|
+
FrameType.MSG,
|
|
418
|
+
encodeMsg({ target: { kind: "client", clientId: peer.clientId }, payload: body })
|
|
419
|
+
);
|
|
420
|
+
for (const other of room.peers()) {
|
|
421
|
+
if (other === peer || other.state !== "joined") continue;
|
|
422
|
+
if (target.kind === "client" && other.clientId !== target.clientId) continue;
|
|
423
|
+
if (target.kind === "role" && other.role !== target.role) continue;
|
|
424
|
+
const sent = other.send(out);
|
|
425
|
+
if (sent > 0) room.countEgress(sent);
|
|
426
|
+
}
|
|
427
|
+
room.touched(room.relayRoom.advance());
|
|
428
|
+
return { ok: true };
|
|
429
|
+
}
|
|
430
|
+
|
|
256
431
|
// ../supervisor/src/auth.ts
|
|
257
432
|
import { createHmac, timingSafeEqual } from "crypto";
|
|
258
433
|
import { isLocalhostOrigin, originAllowed } from "@irtio/protocol";
|
|
@@ -324,6 +499,13 @@ function verifyJwt(issuers, token, projectId, now = Date.now()) {
|
|
|
324
499
|
if (typeof claims.iss !== "string" || !PLAYER_ISSUER_RE.test(claims.iss)) {
|
|
325
500
|
return malformed("iss is required: 1-64 of a-z 0-9 . _ - (never a colon)");
|
|
326
501
|
}
|
|
502
|
+
if (claims.iss === IRT_IDENTITY_ISSUER) {
|
|
503
|
+
return {
|
|
504
|
+
ok: false,
|
|
505
|
+
code: "E_TOKEN_BAD_ISSUER",
|
|
506
|
+
reason: `${IRT_IDENTITY_ISSUER} is reserved for platform identities`
|
|
507
|
+
};
|
|
508
|
+
}
|
|
327
509
|
if (typeof claims.aud !== "string") {
|
|
328
510
|
return malformed("aud (the project id) is required");
|
|
329
511
|
}
|
|
@@ -377,7 +559,7 @@ function mac(secret, payload) {
|
|
|
377
559
|
return createHmac("sha256", secret).update(payload).digest("base64url");
|
|
378
560
|
}
|
|
379
561
|
function signResume(secret, p) {
|
|
380
|
-
const payload = `${p.clientId}|${p.roomId}|${encodeURIComponent(p.role)}|${p.expMs}`;
|
|
562
|
+
const payload = `${p.clientId}|${p.roomId}|${encodeURIComponent(p.role)}|${p.expMs}|${p.iatMs}`;
|
|
381
563
|
return Buffer.from(`${payload}.${mac(secret, payload)}`, "utf8").toString("base64url");
|
|
382
564
|
}
|
|
383
565
|
function verifyResume(secret, token, now = Date.now()) {
|
|
@@ -396,55 +578,428 @@ function verifyResume(secret, token, now = Date.now()) {
|
|
|
396
578
|
const b = Buffer.from(expected, "utf8");
|
|
397
579
|
if (a.length !== b.length || !timingSafeEqual(a, b)) return void 0;
|
|
398
580
|
const parts = payload.split("|");
|
|
399
|
-
if (parts.length !==
|
|
400
|
-
const [clientId, roomId, encodedRole, expText] = parts;
|
|
581
|
+
if (parts.length !== 5) return void 0;
|
|
582
|
+
const [clientId, roomId, encodedRole, expText, iatText] = parts;
|
|
401
583
|
const expMs = Number(expText);
|
|
402
584
|
if (!Number.isFinite(expMs) || expMs <= now) return void 0;
|
|
585
|
+
const iatMs = Number(iatText);
|
|
586
|
+
if (!Number.isFinite(iatMs)) return void 0;
|
|
403
587
|
let role;
|
|
404
588
|
try {
|
|
405
589
|
role = decodeURIComponent(encodedRole);
|
|
406
590
|
} catch {
|
|
407
591
|
return void 0;
|
|
408
592
|
}
|
|
409
|
-
return { clientId, roomId, role, expMs };
|
|
593
|
+
return { clientId, roomId, role, expMs, iatMs };
|
|
410
594
|
}
|
|
411
595
|
|
|
412
|
-
// ../supervisor/src/
|
|
413
|
-
|
|
414
|
-
var
|
|
415
|
-
var
|
|
416
|
-
var
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
return out;
|
|
596
|
+
// ../supervisor/src/bandwidth.ts
|
|
597
|
+
var FREE_BANDWIDTH_CEILING_BPS = 25e6;
|
|
598
|
+
var CARD_BANDWIDTH_CEILING_BPS = 1e8;
|
|
599
|
+
var BANDWIDTH_WINDOW_MS = 1e4;
|
|
600
|
+
var BUCKETS = 10;
|
|
601
|
+
var BUCKET_MS = BANDWIDTH_WINDOW_MS / BUCKETS;
|
|
602
|
+
function bandwidthCeilingBps(plan) {
|
|
603
|
+
return plan === "card" ? CARD_BANDWIDTH_CEILING_BPS : FREE_BANDWIDTH_CEILING_BPS;
|
|
421
604
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
605
|
+
var BandwidthCeiling = class {
|
|
606
|
+
constructor(now = Date.now) {
|
|
607
|
+
this.now = now;
|
|
608
|
+
}
|
|
609
|
+
now;
|
|
610
|
+
buckets = new Float64Array(BUCKETS);
|
|
611
|
+
total = 0;
|
|
612
|
+
head = 0;
|
|
613
|
+
headAt = 0;
|
|
614
|
+
started = false;
|
|
615
|
+
ceiling = 0;
|
|
616
|
+
engagedNow = false;
|
|
617
|
+
/** Rooms already warned during the current engagement. Cleared when the ceiling disengages. */
|
|
618
|
+
warned = /* @__PURE__ */ new Set();
|
|
619
|
+
/**
|
|
620
|
+
* Sets the plan whose ceiling is enforced. `undefined` — a box control has never spoken to —
|
|
621
|
+
* disables enforcement, which is the documented failure direction.
|
|
622
|
+
*/
|
|
623
|
+
setPlan(plan) {
|
|
624
|
+
this.ceiling = plan === void 0 ? 0 : bandwidthCeilingBps(plan);
|
|
625
|
+
if (this.ceiling === 0) this.disengage();
|
|
626
|
+
}
|
|
627
|
+
/** Counts bytes that left the box. Called from the same place the lease is spent. */
|
|
628
|
+
count(bytes) {
|
|
629
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return;
|
|
630
|
+
this.roll();
|
|
631
|
+
this.buckets[this.head] = (this.buckets[this.head] ?? 0) + bytes;
|
|
632
|
+
this.total += bytes;
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Whether forwarding must stop right now.
|
|
636
|
+
*
|
|
637
|
+
* Reads the clock as well as the counters, so a project that stops sending recovers as the
|
|
638
|
+
* window empties rather than only when it next sends something.
|
|
639
|
+
*/
|
|
640
|
+
get engaged() {
|
|
641
|
+
if (this.ceiling <= 0) return false;
|
|
642
|
+
this.roll();
|
|
643
|
+
const over = this.bitsPerSec > this.ceiling;
|
|
644
|
+
if (over && !this.engagedNow) this.engagedNow = true;
|
|
645
|
+
else if (!over && this.engagedNow) this.disengage();
|
|
646
|
+
return this.engagedNow;
|
|
647
|
+
}
|
|
648
|
+
/** The measured rate over the whole window, in bits per second. */
|
|
649
|
+
get bitsPerSec() {
|
|
650
|
+
this.roll();
|
|
651
|
+
return this.total * 8 / (BANDWIDTH_WINDOW_MS / 1e3);
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Whether this room still owes a warning for the current engagement — true exactly once per room
|
|
655
|
+
* per engagement, so a developer learns why their messages vanished without the ceiling turning
|
|
656
|
+
* into a log flood at frame rate.
|
|
657
|
+
*/
|
|
658
|
+
takeWarning(roomId) {
|
|
659
|
+
if (!this.engagedNow || this.warned.has(roomId)) return false;
|
|
660
|
+
this.warned.add(roomId);
|
|
661
|
+
return true;
|
|
662
|
+
}
|
|
663
|
+
state() {
|
|
664
|
+
return {
|
|
665
|
+
enforcing: this.ceiling > 0,
|
|
666
|
+
ceilingBps: this.ceiling,
|
|
667
|
+
bitsPerSec: this.bitsPerSec,
|
|
668
|
+
engaged: this.engaged
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
disengage() {
|
|
672
|
+
this.engagedNow = false;
|
|
673
|
+
this.warned.clear();
|
|
674
|
+
}
|
|
675
|
+
/** Advances the ring to now, zeroing the buckets that have aged out of the window. */
|
|
676
|
+
roll() {
|
|
677
|
+
const t = this.now();
|
|
678
|
+
if (!this.started) {
|
|
679
|
+
this.started = true;
|
|
680
|
+
this.headAt = t - t % BUCKET_MS;
|
|
681
|
+
return;
|
|
427
682
|
}
|
|
683
|
+
let steps = Math.floor((t - this.headAt) / BUCKET_MS);
|
|
684
|
+
if (steps <= 0) return;
|
|
685
|
+
if (steps >= BUCKETS) {
|
|
686
|
+
this.buckets.fill(0);
|
|
687
|
+
this.total = 0;
|
|
688
|
+
this.head = 0;
|
|
689
|
+
} else {
|
|
690
|
+
while (steps-- > 0) {
|
|
691
|
+
this.head = (this.head + 1) % BUCKETS;
|
|
692
|
+
this.total -= this.buckets[this.head] ?? 0;
|
|
693
|
+
this.buckets[this.head] = 0;
|
|
694
|
+
}
|
|
695
|
+
if (this.total < 0) this.total = 0;
|
|
696
|
+
}
|
|
697
|
+
this.headAt = t - t % BUCKET_MS;
|
|
428
698
|
}
|
|
429
|
-
|
|
699
|
+
};
|
|
700
|
+
function bandwidthWarning(plan) {
|
|
701
|
+
const mbit = Math.round(bandwidthCeilingBps(plan) / 1e6);
|
|
702
|
+
return `sustained data-out is above this project's ${mbit} Mbit/s fair-use ceiling, so some messages are being dropped until the rate falls; ` + (plan === "free" ? "send less per tick, or add a card for the 100 Mbit/s ceiling" : "send less per tick, or ask support to raise the ceiling for this project");
|
|
430
703
|
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
704
|
+
|
|
705
|
+
// ../supervisor/src/bus.ts
|
|
706
|
+
import {
|
|
707
|
+
BUS_ERRORS,
|
|
708
|
+
BUS_LIMITS,
|
|
709
|
+
BUS_MAILBOX_PREFIX,
|
|
710
|
+
BUS_OUTBOX,
|
|
711
|
+
BUS_OUTBOX_PREFIX,
|
|
712
|
+
busChannelProblem,
|
|
713
|
+
busPayloadProblem
|
|
714
|
+
} from "@irtio/protocol";
|
|
715
|
+
function newBusBucket() {
|
|
716
|
+
return new TokenBucket(BUS_LIMITS.opsBurst, BUS_LIMITS.opsPerSecond);
|
|
717
|
+
}
|
|
718
|
+
function mintMailboxName(counter) {
|
|
719
|
+
return `${BUS_MAILBOX_PREFIX}${Date.now().toString(36)}.${counter.toString(36)}`;
|
|
720
|
+
}
|
|
721
|
+
function encodeMailboxEntry(entry) {
|
|
722
|
+
return JSON.stringify(entry);
|
|
723
|
+
}
|
|
724
|
+
function decodeMailboxEntry(payload) {
|
|
725
|
+
if (payload === void 0) return void 0;
|
|
726
|
+
let parsed;
|
|
727
|
+
try {
|
|
728
|
+
parsed = JSON.parse(payload);
|
|
729
|
+
} catch {
|
|
730
|
+
return void 0;
|
|
435
731
|
}
|
|
436
|
-
|
|
732
|
+
const e = parsed;
|
|
733
|
+
if (!e || typeof e.from !== "string" || typeof e.payload !== "string") return void 0;
|
|
734
|
+
const attempts = typeof e.attempts === "number" && Number.isFinite(e.attempts) ? e.attempts : 0;
|
|
735
|
+
return { from: e.from, payload: e.payload, attempts };
|
|
437
736
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
737
|
+
var BusRegistry = class {
|
|
738
|
+
channels = /* @__PURE__ */ new Map();
|
|
739
|
+
/** roomId -> the channels it is in, so dropping a room is one pass rather than a scan. */
|
|
740
|
+
byRoom = /* @__PURE__ */ new Map();
|
|
741
|
+
/**
|
|
742
|
+
* Adds `roomId` to `channel`. Returns a problem when the channel name is illegal or the room is
|
|
743
|
+
* already holding as many subscriptions as it may.
|
|
744
|
+
*/
|
|
745
|
+
subscribe(roomId, channel) {
|
|
746
|
+
const problem = busChannelProblem(channel);
|
|
747
|
+
if (problem) return problem;
|
|
748
|
+
const mine = this.byRoom.get(roomId);
|
|
749
|
+
if (mine !== void 0 && !mine.has(channel) && mine.size >= BUS_LIMITS.subscriptionsPerRoom) {
|
|
750
|
+
return {
|
|
751
|
+
code: BUS_ERRORS.tooManySubscriptions,
|
|
752
|
+
message: `a room may subscribe to at most ${BUS_LIMITS.subscriptionsPerRoom} channels`
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
let subs = this.channels.get(channel);
|
|
756
|
+
if (!subs) this.channels.set(channel, subs = /* @__PURE__ */ new Set());
|
|
757
|
+
subs.add(roomId);
|
|
758
|
+
(mine ?? this.byRoom.set(roomId, /* @__PURE__ */ new Set()).get(roomId)).add(channel);
|
|
759
|
+
return void 0;
|
|
442
760
|
}
|
|
443
|
-
|
|
761
|
+
unsubscribe(roomId, channel) {
|
|
762
|
+
const subs = this.channels.get(channel);
|
|
763
|
+
if (subs) {
|
|
764
|
+
subs.delete(roomId);
|
|
765
|
+
if (subs.size === 0) this.channels.delete(channel);
|
|
766
|
+
}
|
|
767
|
+
const mine = this.byRoom.get(roomId);
|
|
768
|
+
if (mine) {
|
|
769
|
+
mine.delete(channel);
|
|
770
|
+
if (mine.size === 0) this.byRoom.delete(roomId);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Drops every subscription this room holds. Called when a room is closed for good.
|
|
775
|
+
*
|
|
776
|
+
* Deliberately NOT called on hibernation. What makes "publish reaches awake subscribers only"
|
|
777
|
+
* true is the liveness check in the supervisor's fan-out, which posts to a subscriber only when
|
|
778
|
+
* it is `running` with a live worker; keeping the bookkeeping in step as well would mean
|
|
779
|
+
* chasing the eight separate places a worker can go away, and missing one of those would be a
|
|
780
|
+
* silent correctness bug rather than a leak. A hibernated room's stale entry costs a set member
|
|
781
|
+
* and is overwritten when its definition re-subscribes on the next wake.
|
|
782
|
+
*/
|
|
783
|
+
unsubscribeAll(roomId) {
|
|
784
|
+
const mine = this.byRoom.get(roomId);
|
|
785
|
+
if (!mine) return;
|
|
786
|
+
for (const channel of mine) {
|
|
787
|
+
const subs = this.channels.get(channel);
|
|
788
|
+
if (!subs) continue;
|
|
789
|
+
subs.delete(roomId);
|
|
790
|
+
if (subs.size === 0) this.channels.delete(channel);
|
|
791
|
+
}
|
|
792
|
+
this.byRoom.delete(roomId);
|
|
793
|
+
}
|
|
794
|
+
/** Every room subscribed to `channel` except `exclude`, which is how a publisher is never told
|
|
795
|
+
* its own message. Sorted, so a fan-out is deterministic and a test can assert an order. */
|
|
796
|
+
subscribers(channel, exclude) {
|
|
797
|
+
const subs = this.channels.get(channel);
|
|
798
|
+
if (!subs) return [];
|
|
799
|
+
return [...subs].filter((id) => id !== exclude).sort();
|
|
800
|
+
}
|
|
801
|
+
/** The channels `roomId` currently holds, for `/admin/rooms` and for tests. */
|
|
802
|
+
subscriptionsOf(roomId) {
|
|
803
|
+
return [...this.byRoom.get(roomId) ?? []].sort();
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
function mintOutboxName(counter) {
|
|
807
|
+
return `${BUS_OUTBOX_PREFIX}${Date.now().toString(36)}.${counter.toString(36)}`;
|
|
444
808
|
}
|
|
445
|
-
function
|
|
446
|
-
|
|
447
|
-
|
|
809
|
+
function encodeOutboxEntry(entry) {
|
|
810
|
+
return JSON.stringify(entry);
|
|
811
|
+
}
|
|
812
|
+
function decodeOutboxEntry(payload) {
|
|
813
|
+
if (payload === void 0) return void 0;
|
|
814
|
+
let parsed;
|
|
815
|
+
try {
|
|
816
|
+
parsed = JSON.parse(payload);
|
|
817
|
+
} catch {
|
|
818
|
+
return void 0;
|
|
819
|
+
}
|
|
820
|
+
const e = parsed;
|
|
821
|
+
if (!e || typeof e.to !== "string" || typeof e.from !== "string" || typeof e.payload !== "string" || typeof e.id !== "string") {
|
|
822
|
+
return void 0;
|
|
823
|
+
}
|
|
824
|
+
return {
|
|
825
|
+
to: e.to,
|
|
826
|
+
from: e.from,
|
|
827
|
+
payload: e.payload,
|
|
828
|
+
id: e.id,
|
|
829
|
+
firstAt: typeof e.firstAt === "number" && Number.isFinite(e.firstAt) ? e.firstAt : Date.now(),
|
|
830
|
+
attempts: typeof e.attempts === "number" && Number.isFinite(e.attempts) ? e.attempts : 0
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
function outboxRetryDelayMs(attempts) {
|
|
834
|
+
return Math.min(BUS_OUTBOX.firstRetryMs * 2 ** Math.max(0, attempts), BUS_OUTBOX.maxRetryMs);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// ../supervisor/src/caps.ts
|
|
838
|
+
function parseCapState(value) {
|
|
839
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
840
|
+
const raw = value;
|
|
841
|
+
if (typeof raw.project !== "string" || raw.project === "") return void 0;
|
|
842
|
+
if (!Array.isArray(raw.meters)) return void 0;
|
|
843
|
+
const meters = [];
|
|
844
|
+
for (const entry of raw.meters) {
|
|
845
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
846
|
+
const m = entry;
|
|
847
|
+
if (typeof m.meter !== "string" || m.meter === "") continue;
|
|
848
|
+
if (typeof m.used !== "number" || !Number.isFinite(m.used)) continue;
|
|
849
|
+
const limit = typeof m.limit === "number" && Number.isFinite(m.limit) ? m.limit : null;
|
|
850
|
+
const mode = m.mode === "hard" || m.mode === "soft" ? m.mode : "none";
|
|
851
|
+
const status = m.status === "crossed" || m.status === "warn" ? m.status : "under";
|
|
852
|
+
const lease = typeof m.lease === "number" && Number.isFinite(m.lease) && m.lease >= 0 ? Math.floor(m.lease) : void 0;
|
|
853
|
+
meters.push({
|
|
854
|
+
meter: m.meter,
|
|
855
|
+
used: m.used,
|
|
856
|
+
limit,
|
|
857
|
+
mode,
|
|
858
|
+
status,
|
|
859
|
+
...lease !== void 0 ? { lease } : {}
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
return {
|
|
863
|
+
project: raw.project,
|
|
864
|
+
plan: raw.plan === "card" ? "card" : "free",
|
|
865
|
+
at: typeof raw.at === "number" && Number.isFinite(raw.at) ? raw.at : Date.now(),
|
|
866
|
+
periodStart: typeof raw.periodStart === "string" ? raw.periodStart : "",
|
|
867
|
+
meters
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
var CapGate = class {
|
|
871
|
+
state;
|
|
872
|
+
/** Replaces the cached state wholesale. Returns the state it stored, for the admin reply. */
|
|
873
|
+
apply(state) {
|
|
874
|
+
this.state = state;
|
|
875
|
+
return state;
|
|
876
|
+
}
|
|
877
|
+
/** What the box currently believes, for `/admin/caps` reads and for tests. */
|
|
878
|
+
current() {
|
|
879
|
+
return this.state;
|
|
880
|
+
}
|
|
881
|
+
/** The plan the box currently believes this project is on. `undefined` before the first push. */
|
|
882
|
+
plan() {
|
|
883
|
+
return this.state?.plan;
|
|
884
|
+
}
|
|
885
|
+
/** M5 part 3.5: the leased budget for a meter, or `undefined` when it is not leased. */
|
|
886
|
+
leaseFor(meter) {
|
|
887
|
+
return this.state?.meters.find((m) => m.meter === meter)?.lease;
|
|
888
|
+
}
|
|
889
|
+
/** How stale the cached state is, in ms, or `undefined` when there is none. */
|
|
890
|
+
ageMs(nowMs = Date.now()) {
|
|
891
|
+
return this.state === void 0 ? void 0 : nowMs - this.state.at;
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* The refusal for a meter whose cap is a crossed HARD cap, or `undefined` when the work may
|
|
895
|
+
* proceed.
|
|
896
|
+
*
|
|
897
|
+
* A `soft` crossing returns nothing. That is D48's card-on-file rule in one line: a soft cap
|
|
898
|
+
* alerts and keeps serving, and the alerting happens in control where the mailer is.
|
|
899
|
+
*/
|
|
900
|
+
refusal(meter) {
|
|
901
|
+
const state = this.state;
|
|
902
|
+
if (state === void 0) return void 0;
|
|
903
|
+
const found = state.meters.find((m) => m.meter === meter);
|
|
904
|
+
if (!found || found.mode !== "hard" || found.status !== "crossed") return void 0;
|
|
905
|
+
return capRefusalMessage(meter, state.plan);
|
|
906
|
+
}
|
|
907
|
+
};
|
|
908
|
+
function capRefusalMessage(meter, plan) {
|
|
909
|
+
const what = meter === "egress_bytes" ? "data out" : meter === "room_hours" ? "room hours" : meter === "storage_bytes" ? "storage" : meter.replace(/_/g, " ");
|
|
910
|
+
const fix = plan === "free" ? "add a card in the billing page, or wait for the period to reset on the 1st" : "raise or remove the ceiling for this project in the billing page";
|
|
911
|
+
return `this project is at its ${what} cap for the current billing period; ${fix}`;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// ../supervisor/src/egress-lease.ts
|
|
915
|
+
var EGRESS_LEASE_CHUNK_BYTES = 5 * 1024 * 1024 * 1024;
|
|
916
|
+
var EgressLease = class {
|
|
917
|
+
remaining = 0;
|
|
918
|
+
spent = 0;
|
|
919
|
+
leasedEver = false;
|
|
920
|
+
period = "";
|
|
921
|
+
trippedNow = false;
|
|
922
|
+
/**
|
|
923
|
+
* Replaces the local budget with a freshly leased one.
|
|
924
|
+
*
|
|
925
|
+
* Wholesale, never additive, and that is the load-bearing choice: `remainingBytes` is computed
|
|
926
|
+
* by control from the project's allowance minus the usage it has actually received, so adding
|
|
927
|
+
* successive leases together would count the same allowance more than once. A new lease is a new
|
|
928
|
+
* statement of what is left, not a top-up.
|
|
929
|
+
*
|
|
930
|
+
* A lease of zero is a real lease and trips the wall. A lease for a different period untrips it,
|
|
931
|
+
* because the month rolled and the project's allowance came back.
|
|
932
|
+
*/
|
|
933
|
+
apply(lease) {
|
|
934
|
+
const granted = Number.isFinite(lease.remainingBytes) ? Math.max(0, Math.floor(lease.remainingBytes)) : 0;
|
|
935
|
+
this.remaining = granted;
|
|
936
|
+
this.spent = 0;
|
|
937
|
+
this.period = lease.periodStart;
|
|
938
|
+
this.leasedEver = true;
|
|
939
|
+
this.trippedNow = granted <= 0;
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Counts bytes that left the box, and returns `true` when this call is the one that trips the
|
|
943
|
+
* wall — so the caller can close sockets exactly once rather than on every subsequent frame.
|
|
944
|
+
*/
|
|
945
|
+
spend(bytes) {
|
|
946
|
+
if (!this.leasedEver) return false;
|
|
947
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return false;
|
|
948
|
+
this.spent += bytes;
|
|
949
|
+
this.remaining -= bytes;
|
|
950
|
+
if (this.remaining > 0 || this.trippedNow) return false;
|
|
951
|
+
this.trippedNow = true;
|
|
952
|
+
return true;
|
|
953
|
+
}
|
|
954
|
+
/** Whether forwarding must stop. False on a box that has never been leased anything. */
|
|
955
|
+
get tripped() {
|
|
956
|
+
return this.leasedEver && this.trippedNow;
|
|
957
|
+
}
|
|
958
|
+
/** Whether control has ever leased this box anything. */
|
|
959
|
+
get leased() {
|
|
960
|
+
return this.leasedEver;
|
|
961
|
+
}
|
|
962
|
+
state() {
|
|
963
|
+
return {
|
|
964
|
+
leased: this.leasedEver,
|
|
965
|
+
remainingBytes: Math.max(0, this.remaining),
|
|
966
|
+
spentBytes: this.spent,
|
|
967
|
+
tripped: this.tripped,
|
|
968
|
+
periodStart: this.period
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
};
|
|
972
|
+
function egressWallMessage(plan) {
|
|
973
|
+
const fix = plan === "free" ? "add a card in the billing page, or wait for the period to reset on the 1st" : "raise the monthly overage spend cap for this project in the billing page";
|
|
974
|
+
return `this project has used its data-out allowance for the current billing period, so ${plan === "free" ? "forwarding has stopped" : "forwarding has stopped at the spend cap"}; ${fix}`;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// ../supervisor/src/heap-caps.ts
|
|
978
|
+
import {
|
|
979
|
+
DEFAULT_MEMORY_MB,
|
|
980
|
+
NATIVE_RESERVE_FRACTION as PROTOCOL_NATIVE_RESERVE_FRACTION,
|
|
981
|
+
SUPERVISOR_BASELINE_MIB as PROTOCOL_SUPERVISOR_BASELINE_MIB,
|
|
982
|
+
YOUNG_GEN_MB,
|
|
983
|
+
declarationsFit,
|
|
984
|
+
vmSizeFor
|
|
985
|
+
} from "@irtio/protocol";
|
|
986
|
+
var SUPERVISOR_BASELINE_MIB = PROTOCOL_SUPERVISOR_BASELINE_MIB;
|
|
987
|
+
var HEAP_WARN_FRACTION = 0.8;
|
|
988
|
+
function checkDeclaredFit(vmMemMib, declared, baselineMib = SUPERVISOR_BASELINE_MIB) {
|
|
989
|
+
const types = declared.map((d) => ({
|
|
990
|
+
type: d.type,
|
|
991
|
+
...d.memoryMb !== void 0 ? { memoryMb: d.memoryMb } : {},
|
|
992
|
+
...d.maxAwake !== void 0 ? { maxAwake: d.maxAwake } : {},
|
|
993
|
+
...d.physics === true ? { physics: true } : {}
|
|
994
|
+
}));
|
|
995
|
+
const verdict = declarationsFit(vmMemMib, types, { baselineMib });
|
|
996
|
+
return {
|
|
997
|
+
fits: verdict.fits,
|
|
998
|
+
requiredMib: verdict.required.vmMib,
|
|
999
|
+
budgetMib: vmMemMib,
|
|
1000
|
+
undeclared: declared.filter((d) => d.memoryMb === void 0).map((d) => d.type),
|
|
1001
|
+
detail: `${verdict.required.detail}, against a ${vmMemMib} MiB VM`
|
|
1002
|
+
};
|
|
448
1003
|
}
|
|
449
1004
|
|
|
450
1005
|
// ../supervisor/src/relay.ts
|
|
@@ -550,6 +1105,15 @@ var RelayRoom = class _RelayRoom {
|
|
|
550
1105
|
return this.snapshot();
|
|
551
1106
|
}
|
|
552
1107
|
};
|
|
1108
|
+
function isRelayPresenceSnapshot(bytes) {
|
|
1109
|
+
if (!bytes || bytes.length === 0) return false;
|
|
1110
|
+
try {
|
|
1111
|
+
decodeSnapshot(relaySchema, bytes);
|
|
1112
|
+
return true;
|
|
1113
|
+
} catch {
|
|
1114
|
+
return false;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
553
1117
|
|
|
554
1118
|
// ../supervisor/src/hibernate.ts
|
|
555
1119
|
function hibernateRoom(sup, room) {
|
|
@@ -627,9 +1191,9 @@ async function afterSleep(sup, room) {
|
|
|
627
1191
|
sup.dropRoomIfIdle(room);
|
|
628
1192
|
}
|
|
629
1193
|
async function wakeRoom(sup, room) {
|
|
630
|
-
|
|
631
|
-
await room.transition
|
|
632
|
-
|
|
1194
|
+
while (room.state === "waking" || room.state === "starting" || room.state === "hibernating") {
|
|
1195
|
+
await room.transition.catch(() => {
|
|
1196
|
+
});
|
|
633
1197
|
}
|
|
634
1198
|
if (room.state !== "hibernated") return;
|
|
635
1199
|
room.state = "waking";
|
|
@@ -693,7 +1257,7 @@ async function migrateRoom(sup, room, toVersion) {
|
|
|
693
1257
|
const oldKey = sup.storeKey(room);
|
|
694
1258
|
let chain;
|
|
695
1259
|
try {
|
|
696
|
-
chain = sup.migrationChain(fromVersion, toVersion);
|
|
1260
|
+
chain = sup.migrationChain(fromVersion, toVersion, room.roomType);
|
|
697
1261
|
} catch (err) {
|
|
698
1262
|
return failed(err instanceof Error ? err.message : String(err));
|
|
699
1263
|
}
|
|
@@ -769,7 +1333,7 @@ async function migrateRoom(sup, room, toVersion) {
|
|
|
769
1333
|
outcome = failed(`migration to v${toVersion} failed; room restarted on v${fromVersion}`);
|
|
770
1334
|
return;
|
|
771
1335
|
}
|
|
772
|
-
sup.failOutdatedSessions(room, toVersion);
|
|
1336
|
+
const swapped = sup.failOutdatedSessions(room, toVersion, fromVersion);
|
|
773
1337
|
await sup.rejoinAll(room);
|
|
774
1338
|
for (const clientId of room.pendingLeaves) {
|
|
775
1339
|
room.worker?.post({ t: "leave", clientId, reason: "timeout" });
|
|
@@ -782,7 +1346,11 @@ async function migrateRoom(sup, room, toVersion) {
|
|
|
782
1346
|
const ms = performance.now() - startedAt;
|
|
783
1347
|
room.metrics.lastWakeMs = ms;
|
|
784
1348
|
if (ms > room.metrics.maxWakeMs) room.metrics.maxWakeMs = ms;
|
|
785
|
-
sup.roomLog(
|
|
1349
|
+
sup.roomLog(
|
|
1350
|
+
room,
|
|
1351
|
+
"info",
|
|
1352
|
+
`migrated v${fromVersion} \u2192 v${toVersion}, gap ${ms.toFixed(1)} ms` + (swapped > 0 ? `, ${swapped} client(s) kept their socket and swapped schema` : "")
|
|
1353
|
+
);
|
|
786
1354
|
outcome = { roomId: room.id, outcome: "migrated" };
|
|
787
1355
|
})();
|
|
788
1356
|
room.transition = work.catch((err) => {
|
|
@@ -864,7 +1432,8 @@ function newRoomMetrics() {
|
|
|
864
1432
|
lastWakeMs: 0,
|
|
865
1433
|
maxWakeMs: 0,
|
|
866
1434
|
workerElu: 0,
|
|
867
|
-
droppedFramesOnRestart: 0
|
|
1435
|
+
droppedFramesOnRestart: 0,
|
|
1436
|
+
npcConnections: 0
|
|
868
1437
|
};
|
|
869
1438
|
}
|
|
870
1439
|
function emptyRoomsByState() {
|
|
@@ -888,15 +1457,222 @@ var LogRing = class {
|
|
|
888
1457
|
}
|
|
889
1458
|
};
|
|
890
1459
|
|
|
1460
|
+
// ../supervisor/src/npc/loopback.ts
|
|
1461
|
+
var CONNECTING = 0;
|
|
1462
|
+
var OPEN = 1;
|
|
1463
|
+
var CLOSING = 2;
|
|
1464
|
+
var CLOSED = 3;
|
|
1465
|
+
var LoopbackServerSocket = class {
|
|
1466
|
+
CONNECTING = CONNECTING;
|
|
1467
|
+
OPEN = OPEN;
|
|
1468
|
+
CLOSING = CLOSING;
|
|
1469
|
+
CLOSED = CLOSED;
|
|
1470
|
+
readyState = OPEN;
|
|
1471
|
+
/** Always 0: there is no kernel buffer, so an NPC is never a slow consumer. */
|
|
1472
|
+
bufferedAmount = 0;
|
|
1473
|
+
listeners = /* @__PURE__ */ new Map();
|
|
1474
|
+
peer;
|
|
1475
|
+
on(event, listener) {
|
|
1476
|
+
let set = this.listeners.get(event);
|
|
1477
|
+
if (!set) this.listeners.set(event, set = /* @__PURE__ */ new Set());
|
|
1478
|
+
set.add(listener);
|
|
1479
|
+
return this;
|
|
1480
|
+
}
|
|
1481
|
+
once(event, listener) {
|
|
1482
|
+
const wrapper = (...args) => {
|
|
1483
|
+
this.off(event, wrapper);
|
|
1484
|
+
listener(...args);
|
|
1485
|
+
};
|
|
1486
|
+
return this.on(event, wrapper);
|
|
1487
|
+
}
|
|
1488
|
+
off(event, listener) {
|
|
1489
|
+
this.listeners.get(event)?.delete(listener);
|
|
1490
|
+
return this;
|
|
1491
|
+
}
|
|
1492
|
+
emit(event, ...args) {
|
|
1493
|
+
for (const listener of [...this.listeners.get(event) ?? []]) listener(...args);
|
|
1494
|
+
}
|
|
1495
|
+
send(bytes) {
|
|
1496
|
+
if (this.readyState !== OPEN) return;
|
|
1497
|
+
const peer = this.peer;
|
|
1498
|
+
const copy = bytes.slice();
|
|
1499
|
+
setImmediate(() => peer?.onmessage?.(copy));
|
|
1500
|
+
}
|
|
1501
|
+
/** The supervisor pings every session; a loopback peer answers at once so it never times out. */
|
|
1502
|
+
ping() {
|
|
1503
|
+
setImmediate(() => this.emit("pong"));
|
|
1504
|
+
}
|
|
1505
|
+
close(code = 1e3, reason = "") {
|
|
1506
|
+
if (this.readyState === CLOSED || this.readyState === CLOSING) return;
|
|
1507
|
+
this.readyState = CLOSING;
|
|
1508
|
+
const peer = this.peer;
|
|
1509
|
+
setImmediate(() => {
|
|
1510
|
+
this.readyState = CLOSED;
|
|
1511
|
+
peer?.onclose?.({ code, reason });
|
|
1512
|
+
this.emit("close", code, reason);
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
terminate() {
|
|
1516
|
+
if (this.readyState === CLOSED) return;
|
|
1517
|
+
this.readyState = CLOSED;
|
|
1518
|
+
const peer = this.peer;
|
|
1519
|
+
setImmediate(() => {
|
|
1520
|
+
peer?.onclose?.({ code: 1006, reason: "terminated" });
|
|
1521
|
+
this.emit("close", 1006, "terminated");
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
};
|
|
1525
|
+
var ClientHalf = class {
|
|
1526
|
+
constructor(server) {
|
|
1527
|
+
this.server = server;
|
|
1528
|
+
}
|
|
1529
|
+
server;
|
|
1530
|
+
onopen = null;
|
|
1531
|
+
onmessage = null;
|
|
1532
|
+
onclose = null;
|
|
1533
|
+
onerror = null;
|
|
1534
|
+
open = true;
|
|
1535
|
+
send(bytes) {
|
|
1536
|
+
if (!this.open || this.server.readyState !== OPEN) return;
|
|
1537
|
+
const copy = bytes.slice();
|
|
1538
|
+
setImmediate(() => this.server.emit("message", copy));
|
|
1539
|
+
}
|
|
1540
|
+
close() {
|
|
1541
|
+
if (!this.open) return;
|
|
1542
|
+
this.open = false;
|
|
1543
|
+
const server = this.server;
|
|
1544
|
+
setImmediate(() => {
|
|
1545
|
+
if (server.readyState === CLOSED) return;
|
|
1546
|
+
server.readyState = CLOSED;
|
|
1547
|
+
server.emit("close", 1e3, "npc closed");
|
|
1548
|
+
this.onclose?.({ code: 1e3 });
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
};
|
|
1552
|
+
function loopbackPair() {
|
|
1553
|
+
const server = new LoopbackServerSocket();
|
|
1554
|
+
const client = new ClientHalf(server);
|
|
1555
|
+
server.peer = client;
|
|
1556
|
+
setImmediate(() => client.onopen?.());
|
|
1557
|
+
return { server, client };
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
// ../supervisor/src/npc/runtime.ts
|
|
1561
|
+
var NPC_TRACE_LIMIT = 16;
|
|
1562
|
+
var NPC_JOIN_TIMEOUT_MS = 1e4;
|
|
1563
|
+
var NpcManager = class {
|
|
1564
|
+
constructor(host) {
|
|
1565
|
+
this.host = host;
|
|
1566
|
+
}
|
|
1567
|
+
host;
|
|
1568
|
+
live = /* @__PURE__ */ new Map();
|
|
1569
|
+
/** NPC sessions this manager believes are live in a room. Diagnostics and metrics. */
|
|
1570
|
+
countIn(roomId) {
|
|
1571
|
+
let n = 0;
|
|
1572
|
+
for (const entry of this.live.values()) {
|
|
1573
|
+
if (entry.roomId === roomId) n++;
|
|
1574
|
+
}
|
|
1575
|
+
return n;
|
|
1576
|
+
}
|
|
1577
|
+
has(clientId) {
|
|
1578
|
+
return this.live.has(clientId);
|
|
1579
|
+
}
|
|
1580
|
+
spawn(roomId, definition, clientId, config) {
|
|
1581
|
+
if (this.live.has(clientId)) {
|
|
1582
|
+
this.host.log(roomId, "warn", `irtio: npc ${clientId} is already running; spawn ignored`);
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
const npcs = definition.config.npcs;
|
|
1586
|
+
const script = npcs?.[config.brain.script];
|
|
1587
|
+
if (!script) {
|
|
1588
|
+
this.host.log(
|
|
1589
|
+
roomId,
|
|
1590
|
+
"error",
|
|
1591
|
+
`irtio: npc script ${JSON.stringify(config.brain.script)} is not in this deployment's npcs map`
|
|
1592
|
+
);
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
const entry = { roomId, clientId, runner: void 0, stopped: false };
|
|
1596
|
+
this.live.set(clientId, entry);
|
|
1597
|
+
const name = config.name ?? config.brain.script;
|
|
1598
|
+
void import("@irtio/bots").then(
|
|
1599
|
+
async ({ spawnBots }) => spawnBots(1, {
|
|
1600
|
+
schema: definition.schema,
|
|
1601
|
+
room: roomId,
|
|
1602
|
+
url: "loopback:npc",
|
|
1603
|
+
key: this.host.projectId,
|
|
1604
|
+
name,
|
|
1605
|
+
...config.role !== void 0 ? { role: config.role } : {},
|
|
1606
|
+
...config.seed !== void 0 ? { seed: config.seed } : {},
|
|
1607
|
+
traceLimit: NPC_TRACE_LIMIT,
|
|
1608
|
+
joinTimeoutMs: NPC_JOIN_TIMEOUT_MS,
|
|
1609
|
+
transport: {
|
|
1610
|
+
connect: () => this.host.openLoopback(roomId, clientId)
|
|
1611
|
+
},
|
|
1612
|
+
script: (bot) => script(bot)
|
|
1613
|
+
})
|
|
1614
|
+
).then(
|
|
1615
|
+
(runner) => {
|
|
1616
|
+
entry.runner = runner;
|
|
1617
|
+
if (entry.stopped) void this.stopRunner(entry);
|
|
1618
|
+
},
|
|
1619
|
+
(err) => {
|
|
1620
|
+
this.live.delete(clientId);
|
|
1621
|
+
this.host.log(
|
|
1622
|
+
roomId,
|
|
1623
|
+
"error",
|
|
1624
|
+
`irtio: npc ${JSON.stringify(name)} did not join: ${errorText(err)}`
|
|
1625
|
+
);
|
|
1626
|
+
}
|
|
1627
|
+
);
|
|
1628
|
+
}
|
|
1629
|
+
despawn(clientId) {
|
|
1630
|
+
const entry = this.live.get(clientId);
|
|
1631
|
+
if (!entry) return;
|
|
1632
|
+
this.live.delete(clientId);
|
|
1633
|
+
entry.stopped = true;
|
|
1634
|
+
if (entry.runner) void this.stopRunner(entry);
|
|
1635
|
+
}
|
|
1636
|
+
/** Every NPC in a room: the room hibernated, closed, restarted, or the tenant is shutting down. */
|
|
1637
|
+
despawnRoom(roomId) {
|
|
1638
|
+
for (const entry of [...this.live.values()]) {
|
|
1639
|
+
if (entry.roomId === roomId) this.despawn(entry.clientId);
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
despawnAll() {
|
|
1643
|
+
for (const entry of [...this.live.values()]) this.despawn(entry.clientId);
|
|
1644
|
+
}
|
|
1645
|
+
async stopRunner(entry) {
|
|
1646
|
+
const runner = entry.runner;
|
|
1647
|
+
entry.runner = void 0;
|
|
1648
|
+
if (!runner) return;
|
|
1649
|
+
try {
|
|
1650
|
+
await runner.stop();
|
|
1651
|
+
} catch (err) {
|
|
1652
|
+
this.host.log(entry.roomId, "warn", `irtio: npc ${entry.clientId} stop: ${errorText(err)}`);
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
};
|
|
1656
|
+
function errorText(err) {
|
|
1657
|
+
return err instanceof Error ? err.message : String(err);
|
|
1658
|
+
}
|
|
1659
|
+
|
|
891
1660
|
// ../supervisor/src/rooms.ts
|
|
1661
|
+
import {
|
|
1662
|
+
DEFAULT_ROOM_TYPE as DEFAULT_ROOM_TYPE2,
|
|
1663
|
+
parseRoomId as parseRoomId2,
|
|
1664
|
+
roomClassFor,
|
|
1665
|
+
roomHoursSourceFor
|
|
1666
|
+
} from "@irtio/protocol";
|
|
892
1667
|
var WAKE_QUEUE_CAP = 256;
|
|
893
|
-
|
|
1668
|
+
var SEEN_PLAYERS_CAP = 512;
|
|
1669
|
+
function relayConfig(limits, plan) {
|
|
894
1670
|
return {
|
|
895
1671
|
mode: "event",
|
|
896
1672
|
tickRate: 0,
|
|
897
1673
|
idleMs: limits.relayIdleMs,
|
|
898
1674
|
reconnectGraceMs: limits.relayReconnectGraceMs,
|
|
899
|
-
maxClients: limits.relayMaxClients
|
|
1675
|
+
maxClients: plan === "free" ? limits.relayMaxClientsFree : limits.relayMaxClients
|
|
900
1676
|
};
|
|
901
1677
|
}
|
|
902
1678
|
var RoomRecord = class {
|
|
@@ -913,6 +1689,14 @@ var RoomRecord = class {
|
|
|
913
1689
|
relayRoom;
|
|
914
1690
|
lastTick = 0;
|
|
915
1691
|
metrics = newRoomMetrics();
|
|
1692
|
+
/**
|
|
1693
|
+
* D65: the room's bandwidth ledger as of the last `stats` round trip, when this supervisor was
|
|
1694
|
+
* started with profiling on. Deliberately *beside* `metrics` and not in it: the host agent
|
|
1695
|
+
* flattens every numeric field of `RoomMetrics` into a rollup row per poll, and a ledger is
|
|
1696
|
+
* tens of rows that change names with the schema. It is a local development surface, not fleet
|
|
1697
|
+
* telemetry. `undefined` means nobody is profiling, never "no bytes".
|
|
1698
|
+
*/
|
|
1699
|
+
profile;
|
|
916
1700
|
logs = new LogRing();
|
|
917
1701
|
createdAt = Date.now();
|
|
918
1702
|
/** Frames that arrived while hibernating/waking, replayed in order after the re-joins. */
|
|
@@ -921,8 +1705,39 @@ var RoomRecord = class {
|
|
|
921
1705
|
droppedWakeFrames = 0;
|
|
922
1706
|
/** Sessions whose grace expired while the room had no worker; `leave` is sent on the next wake. */
|
|
923
1707
|
pendingLeaves = /* @__PURE__ */ new Set();
|
|
1708
|
+
/**
|
|
1709
|
+
* Bug #49: client ids the worker sent a frame for that this room has no session under, each
|
|
1710
|
+
* logged once. Cleared with the room; a room that keeps naming unknown clients is one warning
|
|
1711
|
+
* per id, not one per frame.
|
|
1712
|
+
*/
|
|
1713
|
+
unknownSendWarned = /* @__PURE__ */ new Set();
|
|
924
1714
|
/** Timestamps of worker restarts, pruned to the last minute (plan §3.3 cap). */
|
|
925
1715
|
restarts = [];
|
|
1716
|
+
/**
|
|
1717
|
+
* D55: whether the "heap near its cap" warning has already been logged for the current
|
|
1718
|
+
* crossing. Edge-triggered rather than level-triggered, so a room sitting above the line warns
|
|
1719
|
+
* once instead of on every metrics poll; cleared when it drops back under.
|
|
1720
|
+
*/
|
|
1721
|
+
heapWarned = false;
|
|
1722
|
+
/**
|
|
1723
|
+
* D63-e: the live half of the backfill flag, as `room.backfill.set` has left it.
|
|
1724
|
+
*
|
|
1725
|
+
* On the record rather than in the worker so it is not in the hibernation blob (the blob format
|
|
1726
|
+
* does not move for this), and **reset to the declaration whenever a worker reports ready** —
|
|
1727
|
+
* a fresh start and a wake alike.
|
|
1728
|
+
*
|
|
1729
|
+
* The reset is the deliberate part, and the alternative was considered. Carrying the value
|
|
1730
|
+
* across a wake would make the supervisor's copy and the room's own `room.backfill.open` say
|
|
1731
|
+
* different things the moment the worker restarted, because the worker's mirror is seeded from
|
|
1732
|
+
* the declaration and nothing pushes the old value back into it. Resetting keeps the two
|
|
1733
|
+
* honest. What it costs is nothing real: a room hibernates because it went idle, and a versus
|
|
1734
|
+
* room in the middle of a round is not idle, so the only rooms this reopens are rooms with
|
|
1735
|
+
* nobody in them.
|
|
1736
|
+
*
|
|
1737
|
+
* Starts `true` so a room that declared `backfill: true` and never touches the toggle is open,
|
|
1738
|
+
* which is the arena case and the common one.
|
|
1739
|
+
*/
|
|
1740
|
+
backfillOpen = true;
|
|
926
1741
|
/**
|
|
927
1742
|
* The deployment version this room was created under. New rooms take the newest; rooms created
|
|
928
1743
|
* before a deploy keep theirs until they idle (per-room drain, plan §3.3).
|
|
@@ -934,17 +1749,98 @@ var RoomRecord = class {
|
|
|
934
1749
|
* ever serialized into the hibernation blob.
|
|
935
1750
|
*/
|
|
936
1751
|
alarms = new AlarmSet();
|
|
1752
|
+
/**
|
|
1753
|
+
* D67-e: ids of cross-shard bus messages delivered to this room, newest last, bounded by the
|
|
1754
|
+
* supervisor. A message that arrives twice (a lost acknowledgement) is delivered twice, as
|
|
1755
|
+
* at-least-once promises, and its duplicate is logged by name from this set.
|
|
1756
|
+
*/
|
|
1757
|
+
busSeen = /* @__PURE__ */ new Set();
|
|
1758
|
+
/**
|
|
1759
|
+
* D59: this room's bus budget.
|
|
1760
|
+
*
|
|
1761
|
+
* On the record rather than in a map keyed by room id, so it lives and dies with the room and
|
|
1762
|
+
* cannot be a leak or a stale entry. This is the per-room rate-limit machinery D59 asks for and
|
|
1763
|
+
* that the supervisor did not have: every limiter before it (`ipBuckets`, `helloBuckets`, the
|
|
1764
|
+
* per-session frame buckets) is scoped to a connection, an IP or a project key, and none of them
|
|
1765
|
+
* metered what a *room* asked the host to do.
|
|
1766
|
+
*
|
|
1767
|
+
* It survives hibernation for the same reason the alarm set does: the record does. A room cannot
|
|
1768
|
+
* reset its budget by sleeping.
|
|
1769
|
+
*/
|
|
1770
|
+
busBucket = newBusBucket();
|
|
1771
|
+
/**
|
|
1772
|
+
* D63: every player id this room has held since it was created, newest last.
|
|
1773
|
+
*
|
|
1774
|
+
* `roomHoldsPlayer` is not enough for a rating report and the difference is the whole point: by
|
|
1775
|
+
* the time a room decides who won, the loser has very often already closed the tab. A report
|
|
1776
|
+
* that could only name players still connected would be a report no versus game could make. So
|
|
1777
|
+
* membership for ratings is "has this room ever held you", recorded here at join.
|
|
1778
|
+
*
|
|
1779
|
+
* On the record, not on the worker, so it survives a hibernation the same way the alarm set
|
|
1780
|
+
* does — a room that slept between the last round and the report must still be able to make it.
|
|
1781
|
+
* It is deliberately NOT serialized into the hibernation blob: a room whose tenant restarts
|
|
1782
|
+
* loses it, which costs a report and is the conservative direction to fail in.
|
|
1783
|
+
*
|
|
1784
|
+
* Bounded at `SEEN_PLAYERS_CAP`, oldest evicted first. An arena that has held ten thousand
|
|
1785
|
+
* players over a week must not carry ten thousand strings for the life of the process, and a
|
|
1786
|
+
* room reporting on a player who left five hundred players ago is not a match result.
|
|
1787
|
+
*/
|
|
1788
|
+
seenPlayers = /* @__PURE__ */ new Set();
|
|
937
1789
|
/**
|
|
938
1790
|
* D24: a save generation to start this room from instead of its live snapshot, set from the
|
|
939
1791
|
* placement instruction and consumed by the next start. One shot: a restore is an event, not a
|
|
940
1792
|
* standing property of the room.
|
|
941
1793
|
*/
|
|
942
1794
|
pendingRestoreSaveId;
|
|
1795
|
+
/**
|
|
1796
|
+
* D47: when this room's alive-time was last banked into the usage ledger, or `undefined` while
|
|
1797
|
+
* the room is not chargeable (hibernated/closed). Owned by `sampleRoomClocks`; deliberately
|
|
1798
|
+
* separate from `createdAt`, which counts wall time a hibernated room keeps accruing.
|
|
1799
|
+
*/
|
|
1800
|
+
usageSince;
|
|
943
1801
|
/** Serialises room-level async work (start / wake / restart / hibernate). */
|
|
944
1802
|
transition = Promise.resolve();
|
|
945
1803
|
/** Periodic crash-safety snapshot (event-mode bundle rooms) or the relay idle timer. */
|
|
946
1804
|
timer;
|
|
947
1805
|
config;
|
|
1806
|
+
/**
|
|
1807
|
+
* D58: the room type this room runs, parsed once from its id. `DEFAULT_ROOM_TYPE` for a plain
|
|
1808
|
+
* id, which is every id a project had before named room types.
|
|
1809
|
+
*
|
|
1810
|
+
* Parsed here and nowhere else downstream. Shard note 2: outside the definition lookup and the
|
|
1811
|
+
* limits derivation, everything keys on the whole opaque roomId, so this getter is the only
|
|
1812
|
+
* place the split leaks into the room's own lifetime.
|
|
1813
|
+
*/
|
|
1814
|
+
get roomType() {
|
|
1815
|
+
return parseRoomId2(this.id)?.type ?? DEFAULT_ROOM_TYPE2;
|
|
1816
|
+
}
|
|
1817
|
+
/** D47: the engine dimension room-hours rows carry; `''` for a room with no physics. */
|
|
1818
|
+
get engine() {
|
|
1819
|
+
return this.config.engine ?? "";
|
|
1820
|
+
}
|
|
1821
|
+
/**
|
|
1822
|
+
* D60: the size class this room is billed as, derived from its declared `memoryMb`.
|
|
1823
|
+
*
|
|
1824
|
+
* Derived rather than stored, and derived from the same number that drives the worker's heap
|
|
1825
|
+
* cap, so the bill and the enforcement cannot disagree about a room. A relay room is not a
|
|
1826
|
+
* class: it is served by a different thing entirely and carries its own source.
|
|
1827
|
+
*/
|
|
1828
|
+
get sizeClass() {
|
|
1829
|
+
return roomClassFor(this.config.memoryMb);
|
|
1830
|
+
}
|
|
1831
|
+
/**
|
|
1832
|
+
* D60: the room-hours `source` this room's hours are reported under. `room` for Small.
|
|
1833
|
+
*
|
|
1834
|
+
* **A relay room served by this supervisor reports `room`, not `relay`, deliberately.** The
|
|
1835
|
+
* $0.001 relay rate is honest cost accounting for a room served by the shared relay host: no RAM
|
|
1836
|
+
* reservation, no VM slot, just sockets and a presence map. A relay room running inside a tenant
|
|
1837
|
+
* VM has all of those costs — it is a supervisor in a Firecracker guest doing the work — and
|
|
1838
|
+
* billing it at the shared-host rate would price the discount before earning it. The `relay`
|
|
1839
|
+
* source is reserved for the process that actually deserves it. See the part 3 report.
|
|
1840
|
+
*/
|
|
1841
|
+
get usageSource() {
|
|
1842
|
+
return roomHoursSourceFor(this.sizeClass);
|
|
1843
|
+
}
|
|
948
1844
|
/** Sessions with a live socket that have completed their join. */
|
|
949
1845
|
connected() {
|
|
950
1846
|
return [...this.clients.values()].filter((s) => s.state === "joined" && s.open);
|
|
@@ -963,6 +1859,31 @@ var RoomRecord = class {
|
|
|
963
1859
|
log(level, ...args) {
|
|
964
1860
|
this.logs.push(level, args);
|
|
965
1861
|
}
|
|
1862
|
+
/** D63: remember that this room held `playerId`. Re-seeing one moves it to the newest slot. */
|
|
1863
|
+
notePlayerSeen(playerId) {
|
|
1864
|
+
this.seenPlayers.delete(playerId);
|
|
1865
|
+
this.seenPlayers.add(playerId);
|
|
1866
|
+
while (this.seenPlayers.size > SEEN_PLAYERS_CAP) {
|
|
1867
|
+
const oldest = this.seenPlayers.values().next().value;
|
|
1868
|
+
if (oldest === void 0) break;
|
|
1869
|
+
this.seenPlayers.delete(oldest);
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
/** D63: has this room ever held `playerId`? The rating report's membership check. */
|
|
1873
|
+
hasHeldPlayer(playerId) {
|
|
1874
|
+
return this.seenPlayers.has(playerId);
|
|
1875
|
+
}
|
|
1876
|
+
/**
|
|
1877
|
+
* D63-e: is this room accepting backfill right now?
|
|
1878
|
+
*
|
|
1879
|
+
* The declaration is the ceiling and `backfillOpen` is the live value, so a room that declared
|
|
1880
|
+
* nothing answers false however it toggled. Reported on the room list; never enforced at join —
|
|
1881
|
+
* `maxClients` is what refuses a join, and a lost race for the last seat is a normal outcome
|
|
1882
|
+
* the client retries.
|
|
1883
|
+
*/
|
|
1884
|
+
get backfillNow() {
|
|
1885
|
+
return this.config.backfill === true && this.backfillOpen;
|
|
1886
|
+
}
|
|
966
1887
|
info() {
|
|
967
1888
|
const dueAlarmAt = this.alarms.earliestDueAt;
|
|
968
1889
|
return {
|
|
@@ -974,7 +1895,13 @@ var RoomRecord = class {
|
|
|
974
1895
|
metrics: { ...this.metrics, workerElu: this.worker?.elu ?? 0 },
|
|
975
1896
|
createdAt: this.createdAt,
|
|
976
1897
|
...dueAlarmAt !== void 0 ? { dueAlarmAt } : {},
|
|
977
|
-
...this.alarms.size > 0 ? { alarms: this.alarms.size } : {}
|
|
1898
|
+
...this.alarms.size > 0 ? { alarms: this.alarms.size } : {},
|
|
1899
|
+
...this.profile !== void 0 ? { profile: this.profile } : {},
|
|
1900
|
+
// D63-d/e. `maxClients` is always reported (it is always known); `backfill` is reported
|
|
1901
|
+
// only when the room type declared it, so "absent" and "not a candidate" stay the same
|
|
1902
|
+
// thing all the way to control's nullable column.
|
|
1903
|
+
maxClients: this.config.maxClients,
|
|
1904
|
+
...this.config.backfill === true ? { backfill: this.backfillNow } : {}
|
|
978
1905
|
};
|
|
979
1906
|
}
|
|
980
1907
|
};
|
|
@@ -1000,15 +1927,52 @@ var RoomRegistry = class {
|
|
|
1000
1927
|
}
|
|
1001
1928
|
};
|
|
1002
1929
|
|
|
1930
|
+
// ../supervisor/src/schema-swap.ts
|
|
1931
|
+
import { diffSchemas, schemaFromCanonical } from "@irtio/schema";
|
|
1932
|
+
var verdicts = /* @__PURE__ */ new Map();
|
|
1933
|
+
var MAX_MEMO = 64;
|
|
1934
|
+
function isAdditiveMigration(fromJson, toJson) {
|
|
1935
|
+
if (fromJson === toJson) return true;
|
|
1936
|
+
const key = `${fromJson.length}|${fromJson}${toJson}`;
|
|
1937
|
+
const cached = verdicts.get(key);
|
|
1938
|
+
if (cached !== void 0) return cached;
|
|
1939
|
+
let verdict = false;
|
|
1940
|
+
try {
|
|
1941
|
+
const oldSchema = schemaFromCanonical(fromJson);
|
|
1942
|
+
const newSchema = schemaFromCanonical(toJson);
|
|
1943
|
+
const changes = diffSchemas(oldSchema, newSchema);
|
|
1944
|
+
verdict = changes.every((c) => c.kind === "additive") && !touchesPhysicsCollection(oldSchema, newSchema);
|
|
1945
|
+
} catch {
|
|
1946
|
+
verdict = false;
|
|
1947
|
+
}
|
|
1948
|
+
if (verdicts.size >= MAX_MEMO) verdicts.clear();
|
|
1949
|
+
verdicts.set(key, verdict);
|
|
1950
|
+
return verdict;
|
|
1951
|
+
}
|
|
1952
|
+
function touchesPhysicsCollection(oldSchema, newSchema) {
|
|
1953
|
+
const physicsNames = /* @__PURE__ */ new Set();
|
|
1954
|
+
for (const c of oldSchema.collections) if (c.physics !== void 0) physicsNames.add(c.name);
|
|
1955
|
+
for (const c of newSchema.collections) if (c.physics !== void 0) physicsNames.add(c.name);
|
|
1956
|
+
if (physicsNames.size === 0) return false;
|
|
1957
|
+
for (const name of physicsNames) {
|
|
1958
|
+
const before = oldSchema.collections.find((c) => c.name === name);
|
|
1959
|
+
const after = newSchema.collections.find((c) => c.name === name);
|
|
1960
|
+
if (!before || !after) return true;
|
|
1961
|
+
if (JSON.stringify(before) !== JSON.stringify(after)) return true;
|
|
1962
|
+
}
|
|
1963
|
+
return false;
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1003
1966
|
// ../supervisor/src/session.ts
|
|
1004
1967
|
import {
|
|
1005
|
-
ErrorCode,
|
|
1006
|
-
FrameType,
|
|
1968
|
+
ErrorCode as ErrorCode2,
|
|
1969
|
+
FrameType as FrameType2,
|
|
1007
1970
|
encodeErrorPayload,
|
|
1008
|
-
encodeFrame
|
|
1971
|
+
encodeFrame as encodeFrame2
|
|
1009
1972
|
} from "@irtio/protocol";
|
|
1010
1973
|
var CLOSE_POLICY = 1008;
|
|
1011
1974
|
var CLOSE_GRACE_MS = 250;
|
|
1975
|
+
var HELD_FRAMES_MAX = 16;
|
|
1012
1976
|
var Session = class {
|
|
1013
1977
|
constructor(ws, remoteAddress, origin, framesPerSec, maxBufferedBytes, onSlowConsumer) {
|
|
1014
1978
|
this.ws = ws;
|
|
@@ -1024,6 +1988,16 @@ var Session = class {
|
|
|
1024
1988
|
maxBufferedBytes;
|
|
1025
1989
|
onSlowConsumer;
|
|
1026
1990
|
state = "awaiting-hello";
|
|
1991
|
+
/**
|
|
1992
|
+
* Bug #49: frames the worker sent this session while it was still `'joining'`. The room's
|
|
1993
|
+
* `onJoin` runs inside `RoomCore.join`, so a `room.call(ctx.clientId)` made there reaches the
|
|
1994
|
+
* supervisor before the join outcome does, and the client accepts nothing before its `WELCOME`.
|
|
1995
|
+
* Held here, sent in order right after the welcome (`joinBundle`), discarded when the join is
|
|
1996
|
+
* rejected or the socket closes first. Capped at `HELD_FRAMES_MAX`; `droppedHeld` counts the
|
|
1997
|
+
* overflow so the first drop can be logged once.
|
|
1998
|
+
*/
|
|
1999
|
+
pendingOut = [];
|
|
2000
|
+
droppedHeld = 0;
|
|
1027
2001
|
clientId = "";
|
|
1028
2002
|
roomId = "";
|
|
1029
2003
|
/** Set once the HELLO resolved a room. */
|
|
@@ -1038,6 +2012,24 @@ var Session = class {
|
|
|
1038
2012
|
* session whose schema is older than the room's new version by name (`E_SCHEMA_MISMATCH`)
|
|
1039
2013
|
* rather than send it a resync WELCOME it cannot decode. */
|
|
1040
2014
|
schemaHash8;
|
|
2015
|
+
/**
|
|
2016
|
+
* D50: this client set `HELLO_SCHEMA_SWAP_BIT`, so it can rebuild its codec from a `SCHEMA`
|
|
2017
|
+
* frame and an additive migrate may swap it in place instead of closing it. Load-bearing in
|
|
2018
|
+
* the negative direction too: `decodeFrame` throws on an unknown type, so a session without
|
|
2019
|
+
* this flag must never be sent frame 13.
|
|
2020
|
+
*/
|
|
2021
|
+
schemaSwap = false;
|
|
2022
|
+
/**
|
|
2023
|
+
* D44: this session is a scripted NPC on a loopback socket. It skips the per-IP bucket and the
|
|
2024
|
+
* origin check on the way in (there is no address and no page), is tagged in metrics and in
|
|
2025
|
+
* `SessionInfo`, and is otherwise the ordinary session it looks like.
|
|
2026
|
+
*/
|
|
2027
|
+
npc = false;
|
|
2028
|
+
/**
|
|
2029
|
+
* D44: the client id the room already minted for this NPC, so `room.spawnNPC` could hand a
|
|
2030
|
+
* handle back synchronously. Absent for every dialled session, which mints its own.
|
|
2031
|
+
*/
|
|
2032
|
+
forcedClientId;
|
|
1041
2033
|
/** Set when the session was rejected/kicked: no grace window on close. */
|
|
1042
2034
|
fatal = false;
|
|
1043
2035
|
/** Slow consumers are closed but keep their grace window (a resume gets a fresh snapshot). */
|
|
@@ -1054,6 +2046,14 @@ var Session = class {
|
|
|
1054
2046
|
get open() {
|
|
1055
2047
|
return this.ws.readyState === this.ws.OPEN;
|
|
1056
2048
|
}
|
|
2049
|
+
/**
|
|
2050
|
+
* D47: the ONE place billable egress is counted. It is here rather than beside the five
|
|
2051
|
+
* `room.metrics.egressBytes += sent` call sites for two reasons the anti-lying tests pin: a
|
|
2052
|
+
* sixth copy of a five-place pattern is a sixth place to forget, and the five call sites are
|
|
2053
|
+
* *observability* — they count NPC loopback bytes, which never leave the box and must never be
|
|
2054
|
+
* billed. Set by the server for every session; the NPC exclusion is below, not in the callback.
|
|
2055
|
+
*/
|
|
2056
|
+
onEgress;
|
|
1057
2057
|
/**
|
|
1058
2058
|
* Sends one already-framed message. Returns the byte count sent (0 when dropped). A client whose
|
|
1059
2059
|
* `bufferedAmount` is over the limit is closed with `E_SLOW_CONSUMER` — that's exactly what
|
|
@@ -1066,15 +2066,16 @@ var Session = class {
|
|
|
1066
2066
|
return 0;
|
|
1067
2067
|
}
|
|
1068
2068
|
this.ws.send(bytes, { binary: true });
|
|
2069
|
+
if (!this.npc) this.onEgress?.(bytes.length);
|
|
1069
2070
|
return bytes.length;
|
|
1070
2071
|
}
|
|
1071
2072
|
sendFrame(type, payload) {
|
|
1072
|
-
return this.send(
|
|
2073
|
+
return this.send(encodeFrame2(type, payload));
|
|
1073
2074
|
}
|
|
1074
2075
|
sendError(code, message, fatal) {
|
|
1075
2076
|
this.sendFrame(
|
|
1076
|
-
|
|
1077
|
-
encodeErrorPayload({ code:
|
|
2077
|
+
FrameType2.ERROR,
|
|
2078
|
+
encodeErrorPayload({ code: ErrorCode2[code].code, message, fatal })
|
|
1078
2079
|
);
|
|
1079
2080
|
}
|
|
1080
2081
|
/** ERROR + close (1008). Fatal rejections skip the grace window on the following `close` event. */
|
|
@@ -1083,6 +2084,19 @@ var Session = class {
|
|
|
1083
2084
|
this.sendError(code, message, true);
|
|
1084
2085
|
this.closeSocket(CLOSE_POLICY, code);
|
|
1085
2086
|
}
|
|
2087
|
+
/**
|
|
2088
|
+
* M5 part 3.5 (Part C): `fail`, but with a chosen WebSocket close code.
|
|
2089
|
+
*
|
|
2090
|
+
* It exists for exactly one caller, the egress wall, and the reason is that the close code is
|
|
2091
|
+
* the only part of a refusal a browser is guaranteed to see. An ERROR frame can lose the race
|
|
2092
|
+
* with the close; `event.code` cannot. Everything else about a fatal rejection is unchanged, so
|
|
2093
|
+
* a wall close still skips the reconnect grace the way a policy close does.
|
|
2094
|
+
*/
|
|
2095
|
+
failWith(code, message, closeCode) {
|
|
2096
|
+
this.fatal = true;
|
|
2097
|
+
this.sendError(code, message, true);
|
|
2098
|
+
this.closeSocket(closeCode, code);
|
|
2099
|
+
}
|
|
1086
2100
|
closeSocket(code = CLOSE_POLICY, reason = "") {
|
|
1087
2101
|
this.clearHelloTimer();
|
|
1088
2102
|
if (this.ws.readyState === this.ws.CLOSED) return;
|
|
@@ -1118,11 +2132,99 @@ var Session = class {
|
|
|
1118
2132
|
name: this.name,
|
|
1119
2133
|
state: this.state,
|
|
1120
2134
|
remoteAddress: this.remoteAddress,
|
|
1121
|
-
origin: this.origin
|
|
2135
|
+
origin: this.origin,
|
|
2136
|
+
npc: this.npc
|
|
1122
2137
|
};
|
|
1123
2138
|
}
|
|
1124
2139
|
};
|
|
1125
2140
|
|
|
2141
|
+
// ../supervisor/src/usage.ts
|
|
2142
|
+
var USAGE_WINDOW_MS = 60 * 60 * 1e3;
|
|
2143
|
+
var CLOSED_WINDOW_GRACE_MS = 15 * 60 * 1e3;
|
|
2144
|
+
var ROOM_SAMPLE_MS = 1e3;
|
|
2145
|
+
function windowStartOf(nowMs) {
|
|
2146
|
+
return Math.floor(nowMs / USAGE_WINDOW_MS) * USAGE_WINDOW_MS;
|
|
2147
|
+
}
|
|
2148
|
+
function keyOf(meter, windowStart, source, engine) {
|
|
2149
|
+
return `${meter}\0${windowStart}\0${source}\0${engine}`;
|
|
2150
|
+
}
|
|
2151
|
+
var UsageAccumulator = class {
|
|
2152
|
+
cells = /* @__PURE__ */ new Map();
|
|
2153
|
+
cell(meter, source, engine, nowMs) {
|
|
2154
|
+
const windowStart = windowStartOf(nowMs);
|
|
2155
|
+
const key = keyOf(meter, windowStart, source, engine);
|
|
2156
|
+
let cell = this.cells.get(key);
|
|
2157
|
+
if (cell === void 0) {
|
|
2158
|
+
cell = { meter, windowStart, source, engine, value: 0 };
|
|
2159
|
+
this.cells.set(key, cell);
|
|
2160
|
+
}
|
|
2161
|
+
return cell;
|
|
2162
|
+
}
|
|
2163
|
+
/** Bytes that actually left the box on a real socket. NPC loopback bytes never reach here. */
|
|
2164
|
+
addEgressBytes(bytes, nowMs = Date.now()) {
|
|
2165
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return;
|
|
2166
|
+
this.cell("egress_bytes", "room", "", nowMs).value += bytes;
|
|
2167
|
+
}
|
|
2168
|
+
/**
|
|
2169
|
+
* Worker-alive milliseconds for one room, reported as fractional hours. `engine` is the room's
|
|
2170
|
+
* physics engine or `''` — a reported dimension, not a multiplier.
|
|
2171
|
+
*/
|
|
2172
|
+
addRoomMs(ms, engine, nowMs = Date.now(), source = "room") {
|
|
2173
|
+
if (!Number.isFinite(ms) || ms <= 0) return;
|
|
2174
|
+
this.cell("room_hours", source, engine, nowMs).value += ms / (60 * 60 * 1e3);
|
|
2175
|
+
}
|
|
2176
|
+
/**
|
|
2177
|
+
* Every window still worth reporting: the current one plus any closed window inside the grace.
|
|
2178
|
+
* Closed windows past the grace are dropped here, so the ledger is bounded without a timer.
|
|
2179
|
+
*/
|
|
2180
|
+
windows(nowMs = Date.now()) {
|
|
2181
|
+
const current = windowStartOf(nowMs);
|
|
2182
|
+
const out = [];
|
|
2183
|
+
for (const [key, cell] of this.cells) {
|
|
2184
|
+
if (cell.windowStart < current && nowMs - (cell.windowStart + USAGE_WINDOW_MS) > CLOSED_WINDOW_GRACE_MS) {
|
|
2185
|
+
this.cells.delete(key);
|
|
2186
|
+
continue;
|
|
2187
|
+
}
|
|
2188
|
+
out.push({
|
|
2189
|
+
meter: cell.meter,
|
|
2190
|
+
windowStart: cell.windowStart,
|
|
2191
|
+
source: cell.source,
|
|
2192
|
+
engine: cell.engine,
|
|
2193
|
+
value: cell.value
|
|
2194
|
+
});
|
|
2195
|
+
}
|
|
2196
|
+
out.sort(
|
|
2197
|
+
(a, b) => a.windowStart - b.windowStart || a.meter.localeCompare(b.meter) || a.source.localeCompare(b.source) || a.engine.localeCompare(b.engine)
|
|
2198
|
+
);
|
|
2199
|
+
return out;
|
|
2200
|
+
}
|
|
2201
|
+
/** Test/diagnostic read of one running total. */
|
|
2202
|
+
valueOf(meter, nowMs = Date.now(), source = "room", engine = "") {
|
|
2203
|
+
return this.cells.get(keyOf(meter, windowStartOf(nowMs), source, engine))?.value ?? 0;
|
|
2204
|
+
}
|
|
2205
|
+
};
|
|
2206
|
+
function chargeableRoomState(state) {
|
|
2207
|
+
return state === "starting" || state === "running" || state === "waking";
|
|
2208
|
+
}
|
|
2209
|
+
function sampleRoomClocks(rooms, usage, nowMs) {
|
|
2210
|
+
for (const room of rooms) {
|
|
2211
|
+
const chargeable = chargeableRoomState(room.state);
|
|
2212
|
+
if (!chargeable) {
|
|
2213
|
+
if (room.usageSince !== void 0) {
|
|
2214
|
+
usage.addRoomMs(nowMs - room.usageSince, room.engine, nowMs, room.usageSource);
|
|
2215
|
+
room.usageSince = void 0;
|
|
2216
|
+
}
|
|
2217
|
+
continue;
|
|
2218
|
+
}
|
|
2219
|
+
if (room.usageSince === void 0) {
|
|
2220
|
+
room.usageSince = nowMs;
|
|
2221
|
+
continue;
|
|
2222
|
+
}
|
|
2223
|
+
usage.addRoomMs(nowMs - room.usageSince, room.engine, nowMs, room.usageSource);
|
|
2224
|
+
room.usageSince = nowMs;
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
|
|
1126
2228
|
// ../supervisor/src/worker-host.ts
|
|
1127
2229
|
import { pathToFileURL } from "url";
|
|
1128
2230
|
import { Worker } from "worker_threads";
|
|
@@ -1180,7 +2282,7 @@ var WorkerHost = class {
|
|
|
1180
2282
|
}
|
|
1181
2283
|
}
|
|
1182
2284
|
onMessage(msg) {
|
|
1183
|
-
if ((msg.t === "serialized" || msg.t === "inspected") && this.pending.has(msg.reqId)) {
|
|
2285
|
+
if ((msg.t === "serialized" || msg.t === "inspected" || msg.t === "stats" || msg.t === "recording" || msg.t === "timeline") && msg.reqId !== void 0 && this.pending.has(msg.reqId)) {
|
|
1184
2286
|
const resolve2 = this.pending.get(msg.reqId);
|
|
1185
2287
|
this.pending.delete(msg.reqId);
|
|
1186
2288
|
resolve2?.(msg);
|
|
@@ -1251,6 +2353,49 @@ var WorkerHost = class {
|
|
|
1251
2353
|
);
|
|
1252
2354
|
return msg?.t === "inspected" ? msg : void 0;
|
|
1253
2355
|
}
|
|
2356
|
+
/** D41: arm the room's authoritative timeline recorder. `false` when the worker did not answer. */
|
|
2357
|
+
async startTimeline(options = {}, timeoutMs = 5e3) {
|
|
2358
|
+
const msg = await this.request(
|
|
2359
|
+
(reqId) => ({
|
|
2360
|
+
t: "record",
|
|
2361
|
+
reqId,
|
|
2362
|
+
...options.maxTicks !== void 0 ? { maxTicks: options.maxTicks } : {},
|
|
2363
|
+
...options.maxRecords !== void 0 ? { maxRecords: options.maxRecords } : {}
|
|
2364
|
+
}),
|
|
2365
|
+
timeoutMs
|
|
2366
|
+
);
|
|
2367
|
+
return msg?.t === "recording" && msg.ok;
|
|
2368
|
+
}
|
|
2369
|
+
/** D41: the recorded timeline. `undefined` when unarmed, or when the worker did not answer. */
|
|
2370
|
+
async timeline(timeoutMs = 1e4) {
|
|
2371
|
+
const msg = await this.request(
|
|
2372
|
+
(reqId) => ({ t: "timeline", reqId }),
|
|
2373
|
+
timeoutMs
|
|
2374
|
+
);
|
|
2375
|
+
return msg?.t === "timeline" ? msg.dump : void 0;
|
|
2376
|
+
}
|
|
2377
|
+
/**
|
|
2378
|
+
* D36: the room's own tick counters — `overruns` and `maxTickMs` above all. `undefined` when
|
|
2379
|
+
* the worker died or did not answer in time, and the caller must report that as *unavailable*
|
|
2380
|
+
* rather than as zero: a room shedding ticks and a room nobody asked look identical otherwise,
|
|
2381
|
+
* and that is exactly the lie the `tick-health` invariant exists to prevent.
|
|
2382
|
+
*/
|
|
2383
|
+
async stats(timeoutMs = 2e3) {
|
|
2384
|
+
return (await this.statsMessage(timeoutMs))?.stats;
|
|
2385
|
+
}
|
|
2386
|
+
/**
|
|
2387
|
+
* The whole `stats` reply, including D55's `heap` ride-along. `stats()` above is the D36 shape
|
|
2388
|
+
* and stays as it was; this is the one callers use when they also want the worker's heap, which
|
|
2389
|
+
* is a fact about the thread rather than one of the room's own counters. Same round trip: the
|
|
2390
|
+
* heap number costs no extra message.
|
|
2391
|
+
*/
|
|
2392
|
+
async statsMessage(timeoutMs = 2e3) {
|
|
2393
|
+
const msg = await this.request(
|
|
2394
|
+
(reqId) => ({ t: "stats", reqId }),
|
|
2395
|
+
timeoutMs
|
|
2396
|
+
);
|
|
2397
|
+
return msg?.t === "stats" ? msg : void 0;
|
|
2398
|
+
}
|
|
1254
2399
|
/** Intentional shutdown: `onDead` is not fired. */
|
|
1255
2400
|
async terminate() {
|
|
1256
2401
|
if (this.disposed) return;
|
|
@@ -1271,14 +2416,44 @@ var WorkerHost = class {
|
|
|
1271
2416
|
};
|
|
1272
2417
|
|
|
1273
2418
|
// ../supervisor/src/server.ts
|
|
2419
|
+
function relayHostRoomFor(sup, room) {
|
|
2420
|
+
const relayRoom = room.relayRoom;
|
|
2421
|
+
if (!relayRoom) throw new Error("irtio: relay host adapter on a room with no relay presence");
|
|
2422
|
+
return {
|
|
2423
|
+
id: room.id,
|
|
2424
|
+
relayRoom,
|
|
2425
|
+
peers: () => room.clients.values(),
|
|
2426
|
+
maxClients: room.config.maxClients,
|
|
2427
|
+
countEgress(bytes) {
|
|
2428
|
+
room.metrics.egressBytes += bytes;
|
|
2429
|
+
room.metrics.framesOut++;
|
|
2430
|
+
},
|
|
2431
|
+
touched(tick) {
|
|
2432
|
+
room.lastTick = tick;
|
|
2433
|
+
}
|
|
2434
|
+
};
|
|
2435
|
+
}
|
|
1274
2436
|
var WS_PING_MS = 2e4;
|
|
1275
2437
|
var WS_PING_MISSES = 2;
|
|
1276
2438
|
var MAX_STRIKES = 3;
|
|
1277
2439
|
var JOIN_TIMEOUT_MS = 15e3;
|
|
2440
|
+
var IDENTITY_KEY_WAIT_MS = 2e3;
|
|
2441
|
+
var MAX_IDENTITY_KEY_WAITERS = 64;
|
|
2442
|
+
var RESUME_TOKEN_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
2443
|
+
var BUS_SEEN_MAX = 256;
|
|
2444
|
+
var SHARD_ASSIGNMENT_RETRY_MS = [1e3, 3e4];
|
|
1278
2445
|
var KvUnavailable = class extends Error {
|
|
1279
2446
|
code = KV_ERRORS.unavailable;
|
|
1280
2447
|
name = "KvUnavailable";
|
|
1281
2448
|
};
|
|
2449
|
+
var LeaderboardUnavailable = class extends Error {
|
|
2450
|
+
code = LEADERBOARD_ERRORS.unavailable;
|
|
2451
|
+
name = "LeaderboardUnavailable";
|
|
2452
|
+
};
|
|
2453
|
+
var RatingUnavailable = class extends Error {
|
|
2454
|
+
code = RATING_ERRORS.unavailable;
|
|
2455
|
+
name = "RatingUnavailable";
|
|
2456
|
+
};
|
|
1282
2457
|
function codeOf(err) {
|
|
1283
2458
|
const code = err?.code;
|
|
1284
2459
|
return typeof code === "string" && code.startsWith("E_") ? code : void 0;
|
|
@@ -1322,6 +2497,7 @@ var SupervisorImpl = class {
|
|
|
1322
2497
|
`placement carries ${this.pendingRestores.size} pending restore(s): ` + [...this.pendingRestores].map(([r, sv]) => `${r}<-${sv}`).join(", ")
|
|
1323
2498
|
);
|
|
1324
2499
|
}
|
|
2500
|
+
this.identityKeys = config.identityKeys ?? [];
|
|
1325
2501
|
this.resumeSecret = config.resumeSecret ?? randomBytes(32).toString("base64url");
|
|
1326
2502
|
if (config.resumeSecret === void 0) {
|
|
1327
2503
|
this.log("warn", "no resumeSecret configured: resume tokens die with this process");
|
|
@@ -1332,6 +2508,15 @@ var SupervisorImpl = class {
|
|
|
1332
2508
|
this.limits.connectionsPerIpPerMin / 60
|
|
1333
2509
|
)
|
|
1334
2510
|
);
|
|
2511
|
+
this.npcs = new NpcManager({
|
|
2512
|
+
projectId: config.projectId,
|
|
2513
|
+
openLoopback: (roomId, clientId) => this.openLoopback(roomId, clientId),
|
|
2514
|
+
log: (roomId, level, message) => {
|
|
2515
|
+
const room = this.registry.get(roomId);
|
|
2516
|
+
if (room) this.roomLog(room, level, message);
|
|
2517
|
+
else this.log(level, message);
|
|
2518
|
+
}
|
|
2519
|
+
});
|
|
1335
2520
|
this.helloBuckets = new BucketMap(
|
|
1336
2521
|
() => new TokenBucket(this.limits.hellosPerMin, this.limits.hellosPerMin / 60)
|
|
1337
2522
|
);
|
|
@@ -1353,6 +2538,8 @@ var SupervisorImpl = class {
|
|
|
1353
2538
|
res.writeHead(404).end();
|
|
1354
2539
|
});
|
|
1355
2540
|
this.wss = new WebSocketServer({ server: this.http });
|
|
2541
|
+
this.wss.on("error", () => {
|
|
2542
|
+
});
|
|
1356
2543
|
this.wss.on("connection", (ws, req) => {
|
|
1357
2544
|
this.onConnection(ws, {
|
|
1358
2545
|
origin: header(req.headers.origin),
|
|
@@ -1370,6 +2557,11 @@ var SupervisorImpl = class {
|
|
|
1370
2557
|
limits;
|
|
1371
2558
|
store;
|
|
1372
2559
|
processMetrics = newProcessMetrics();
|
|
2560
|
+
/**
|
|
2561
|
+
* D38: the ring behind the reserved `__tenant` room id. Same bounded ring every room gets, so
|
|
2562
|
+
* a noisy boot costs the same as a noisy room and nothing here can grow without limit.
|
|
2563
|
+
*/
|
|
2564
|
+
tenantLogs = new LogRing();
|
|
1373
2565
|
/** The newest runnable deployment; every new room is created under it. */
|
|
1374
2566
|
bundle;
|
|
1375
2567
|
schemaHash;
|
|
@@ -1380,9 +2572,21 @@ var SupervisorImpl = class {
|
|
|
1380
2572
|
wss;
|
|
1381
2573
|
readyPromise;
|
|
1382
2574
|
ipBuckets;
|
|
2575
|
+
/**
|
|
2576
|
+
* D59: this tenant's channel-to-subscriber map, beside the room registry it already keeps.
|
|
2577
|
+
*
|
|
2578
|
+
* One per supervisor, which is one per tenant, which is what makes a cross-project reach
|
|
2579
|
+
* unrepresentable rather than refused: there is no other registry to look in and no project
|
|
2580
|
+
* argument on any path that reaches this.
|
|
2581
|
+
*/
|
|
2582
|
+
bus = new BusRegistry();
|
|
2583
|
+
/** Monotonic within this process; feeds `mintMailboxName` so two sends never share a name. */
|
|
2584
|
+
mailboxCounter = 0;
|
|
1383
2585
|
helloBuckets;
|
|
1384
2586
|
resumeSecret;
|
|
1385
2587
|
clientIds = /* @__PURE__ */ new Set();
|
|
2588
|
+
/** D44: the scripted NPCs this tenant is running, and the bot runtime driving each. */
|
|
2589
|
+
npcs;
|
|
1386
2590
|
joinWaiters = /* @__PURE__ */ new Map();
|
|
1387
2591
|
readyWaiters = /* @__PURE__ */ new Map();
|
|
1388
2592
|
creating = /* @__PURE__ */ new Map();
|
|
@@ -1397,8 +2601,116 @@ var SupervisorImpl = class {
|
|
|
1397
2601
|
* reported on `/admin/idle` so the host-agent can escalate instead of reading "not idle yet"
|
|
1398
2602
|
* forever. Reset the moment a flush succeeds cleanly, or a client reconnects. */
|
|
1399
2603
|
flushFailures = 0;
|
|
2604
|
+
/**
|
|
2605
|
+
* D56: `true` once any room worker in this VM generation has reported that a Rapier world
|
|
2606
|
+
* stepped, which is the point V8 tier-compiled the physics WASM and paid its RSS transient.
|
|
2607
|
+
* Reported on `/admin/idle` so the host agent can hold the tenant's *first* VM snapshot until
|
|
2608
|
+
* the compile is inside the image instead of being redone after every wake.
|
|
2609
|
+
*
|
|
2610
|
+
* Sticky for the life of the process, and deliberately not reset by the idle path: the fact is
|
|
2611
|
+
* about this VM's memory, not about the current idle window, and the room that set it has
|
|
2612
|
+
* usually been hibernated away by the time the tenant goes idle.
|
|
2613
|
+
*/
|
|
2614
|
+
physicsWarm = false;
|
|
2615
|
+
/**
|
|
2616
|
+
* D56: measure the Rapier tier-up RSS transient where it actually matters — inside a placed
|
|
2617
|
+
* guest. The desktop re-measurement (2026-08-31) showed the transient peaks on V8's background
|
|
2618
|
+
* compile threads *seconds after* the first step returns (~107 MB above baseline on a many-core
|
|
2619
|
+
* machine, settling back), which a before/after sample around the step misses entirely; a
|
|
2620
|
+
* 1-vCPU guest's peak is the number `VM_MEM_MIB_PHYSICS` actually needs, and nobody has it.
|
|
2621
|
+
* So: on the first `physicsStepped`, sample process RSS (workers are threads, so this is the
|
|
2622
|
+
* whole VM's node process) every 250 ms for 12 s and log baseline, peak, and settled values.
|
|
2623
|
+
* One shot per process; timers unref'd so this never holds the tenant open.
|
|
2624
|
+
*/
|
|
2625
|
+
notePhysicsWarm() {
|
|
2626
|
+
if (this.physicsWarm) return;
|
|
2627
|
+
this.physicsWarm = true;
|
|
2628
|
+
const mib = (b) => (b / 1024 / 1024).toFixed(1);
|
|
2629
|
+
const baseline = process.memoryUsage().rss;
|
|
2630
|
+
const t0 = Date.now();
|
|
2631
|
+
let peak = baseline;
|
|
2632
|
+
let peakAtMs = 0;
|
|
2633
|
+
const sampler = setInterval(() => {
|
|
2634
|
+
const rss = process.memoryUsage().rss;
|
|
2635
|
+
if (rss > peak) {
|
|
2636
|
+
peak = rss;
|
|
2637
|
+
peakAtMs = Date.now() - t0;
|
|
2638
|
+
}
|
|
2639
|
+
}, 250);
|
|
2640
|
+
sampler.unref?.();
|
|
2641
|
+
const done = setTimeout(() => {
|
|
2642
|
+
clearInterval(sampler);
|
|
2643
|
+
const settled = process.memoryUsage().rss;
|
|
2644
|
+
this.log(
|
|
2645
|
+
"info",
|
|
2646
|
+
`rapier first step: rss ${mib(baseline)} MiB at step, peak +${mib(peak - baseline)} MiB at ${peakAtMs} ms, settled +${mib(settled - baseline)} MiB after 12 s (D56 tier-up transient; VM_MEM_MIB_PHYSICS evidence)`
|
|
2647
|
+
);
|
|
2648
|
+
}, 12e3);
|
|
2649
|
+
done.unref?.();
|
|
2650
|
+
}
|
|
1400
2651
|
idleTimer;
|
|
1401
2652
|
pingTimer;
|
|
2653
|
+
/**
|
|
2654
|
+
* D47: this tenant's usage ledger. One supervisor serves one project, so it needs no project
|
|
2655
|
+
* id; the host agent stamps one on the way out. Public so a test can read the running totals
|
|
2656
|
+
* the way `/admin/usage` does.
|
|
2657
|
+
*/
|
|
2658
|
+
usage = new UsageAccumulator();
|
|
2659
|
+
usageTimer;
|
|
2660
|
+
/**
|
|
2661
|
+
* D48: this tenant's cap state, pushed from control over `/admin/caps` and held in memory.
|
|
2662
|
+
*
|
|
2663
|
+
* Public for the same reason `usage` is: a test drives the real thing. Note what it is not: a
|
|
2664
|
+
* client of anything. It has no fetch, no database and no timer, so the pin test's claim that a
|
|
2665
|
+
* join touches no billing query is a property of the type rather than of a careful reading.
|
|
2666
|
+
*/
|
|
2667
|
+
caps = new CapGate();
|
|
2668
|
+
/**
|
|
2669
|
+
* M5 part 3.5 (Part C): the leased egress budget this box spends locally.
|
|
2670
|
+
*
|
|
2671
|
+
* Beside `caps` rather than inside it, because they answer different questions at different
|
|
2672
|
+
* moments. `caps` answers "may this project start new work", once per join, from a picture
|
|
2673
|
+
* control drew up to ninety seconds ago. This answers "may this byte leave", on every send,
|
|
2674
|
+
* from a budget the box is decrementing itself. Both are needed and neither subsumes the other.
|
|
2675
|
+
*/
|
|
2676
|
+
egressLease = new EgressLease();
|
|
2677
|
+
/**
|
|
2678
|
+
* Pricing amendment 1, decision 1: this project's sustained bandwidth ceiling.
|
|
2679
|
+
*
|
|
2680
|
+
* A third question beside the other two, asked at a third moment. `caps` answers "may this
|
|
2681
|
+
* project start new work", `egressLease` answers "may this byte leave at all this month", and
|
|
2682
|
+
* this answers "is this project sending too fast right now". Only the last one is recoverable
|
|
2683
|
+
* without anybody doing anything: it disengages on its own when the rate falls.
|
|
2684
|
+
*
|
|
2685
|
+
* Public for the same reason the other two are: a test drives the real thing.
|
|
2686
|
+
*/
|
|
2687
|
+
bandwidth = new BandwidthCeiling();
|
|
2688
|
+
/**
|
|
2689
|
+
* D53: the platform public keys this tenant will accept identity assertions from, pushed from
|
|
2690
|
+
* control over `/admin/identity-keys` exactly the way cap state is.
|
|
2691
|
+
*
|
|
2692
|
+
* What matters about this field is what it is NOT: there is no private key here, and no way to
|
|
2693
|
+
* obtain one. A supervisor — and therefore any hostile room code sharing its process — can
|
|
2694
|
+
* check that an assertion was minted by the platform and can do nothing else with these bytes.
|
|
2695
|
+
* That is the whole reason the assertion is Ed25519 rather than an HMAC like the BYO-JWT path,
|
|
2696
|
+
* where verification key and minting key are the same string.
|
|
2697
|
+
*
|
|
2698
|
+
* Empty (a tenant that has not been handed keys yet) refuses assertion joins by name —
|
|
2699
|
+
* `E_ASSERTION_UNVERIFIABLE`, meaning retry — rather than failing open. Cap state fails open on
|
|
2700
|
+
* purpose; a credential check must not.
|
|
2701
|
+
*/
|
|
2702
|
+
identityKeys = [];
|
|
2703
|
+
/**
|
|
2704
|
+
* M5 part 4: assertion HELLOs parked waiting for the first key push, and the cap on how many
|
|
2705
|
+
* may be parked at once.
|
|
2706
|
+
*
|
|
2707
|
+
* The cap is the safety argument. The wait sits AFTER the HELLO rate bucket, so nothing here
|
|
2708
|
+
* lets a caller open more sockets than the bucket already allows — but a parked socket is a
|
|
2709
|
+
* socket held open, and an unbounded list of them would turn a key-delivery gap into a way to
|
|
2710
|
+
* hold every connection a project is allowed. Past the cap the refusal is immediate, and it is
|
|
2711
|
+
* the same refusal, with the same code, that every one of these got before the wait existed.
|
|
2712
|
+
*/
|
|
2713
|
+
identityKeyWaiters = [];
|
|
1402
2714
|
/**
|
|
1403
2715
|
* D24: `roomId -> saveId` placement instructions that have not been consumed yet. Populated
|
|
1404
2716
|
* from `SupervisorConfig.restoreRooms` at boot — placement is the only channel into a tenant,
|
|
@@ -1416,6 +2728,7 @@ var SupervisorImpl = class {
|
|
|
1416
2728
|
}
|
|
1417
2729
|
this.bundle = this.newestBundle();
|
|
1418
2730
|
this.schemaHash = this.bundle?.schemaHash;
|
|
2731
|
+
this.checkDeclaredSizing();
|
|
1419
2732
|
this.workerEntry = this.config.workerEntry ?? resolveWorkerEntry();
|
|
1420
2733
|
await new Promise((resolve2, reject) => {
|
|
1421
2734
|
this.http.once("error", reject);
|
|
@@ -1427,6 +2740,8 @@ var SupervisorImpl = class {
|
|
|
1427
2740
|
});
|
|
1428
2741
|
this.pingTimer = setInterval(() => this.keepalive(), WS_PING_MS);
|
|
1429
2742
|
this.pingTimer.unref?.();
|
|
2743
|
+
this.usageTimer = setInterval(() => this.sampleUsage(), ROOM_SAMPLE_MS);
|
|
2744
|
+
this.usageTimer.unref?.();
|
|
1430
2745
|
const tenantIdleMs = this.config.tenantIdleMs ?? 3e5;
|
|
1431
2746
|
if (tenantIdleMs > 0) {
|
|
1432
2747
|
const every = Math.min(1e3, Math.max(50, Math.floor(tenantIdleMs / 4)));
|
|
@@ -1437,7 +2752,29 @@ var SupervisorImpl = class {
|
|
|
1437
2752
|
"info",
|
|
1438
2753
|
`supervisor listening on ${this.port} (${this.bundle ? "bundle" : "relay"} tenant)`
|
|
1439
2754
|
);
|
|
1440
|
-
|
|
2755
|
+
this.logCompileCacheState();
|
|
2756
|
+
if (this.isSharded()) {
|
|
2757
|
+
void this.resumeAlarmedRooms().catch((err) => {
|
|
2758
|
+
this.log("warn", "the shard-assigned boot scan failed", err);
|
|
2759
|
+
});
|
|
2760
|
+
} else {
|
|
2761
|
+
await this.resumeAlarmedRooms();
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
/**
|
|
2765
|
+
* D56 fix 3 verification: say at boot whether this process sees the compile cache baked into
|
|
2766
|
+
* the rootfs, so the first live evidence that the bake reached a guest is a log line instead
|
|
2767
|
+
* of an ssh session into a paused VM. One line, boot only; absent env means dev, and says so.
|
|
2768
|
+
*/
|
|
2769
|
+
logCompileCacheState() {
|
|
2770
|
+
const dir = process.env.NODE_COMPILE_CACHE;
|
|
2771
|
+
if (dir === void 0 || dir === "") return;
|
|
2772
|
+
try {
|
|
2773
|
+
const entries = readdirSync(dir, { recursive: true }).length;
|
|
2774
|
+
this.log("info", `compile cache at ${dir}: ${entries} entries`);
|
|
2775
|
+
} catch (err) {
|
|
2776
|
+
this.log("warn", `compile cache at ${dir}: unreadable (${err.message})`);
|
|
2777
|
+
}
|
|
1441
2778
|
}
|
|
1442
2779
|
/**
|
|
1443
2780
|
* D26, and the piece staging caught missing: **start every room that owes an alarm**, at boot.
|
|
@@ -1484,11 +2821,16 @@ var SupervisorImpl = class {
|
|
|
1484
2821
|
);
|
|
1485
2822
|
return;
|
|
1486
2823
|
}
|
|
1487
|
-
this.
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
2824
|
+
const mine = this.isSharded() ? await this.assignedRooms([...rooms].sort()) : [...rooms].sort();
|
|
2825
|
+
if (mine.length === 0) {
|
|
2826
|
+
this.log(
|
|
2827
|
+
"info",
|
|
2828
|
+
`no rooms to resume on this shard: ${rooms.size} candidate(s) belong elsewhere`
|
|
2829
|
+
);
|
|
2830
|
+
return;
|
|
2831
|
+
}
|
|
2832
|
+
this.log("info", `resuming ${mine.length} room(s) with pending alarms: ${mine.join(", ")}`);
|
|
2833
|
+
for (const roomId of mine) {
|
|
1492
2834
|
try {
|
|
1493
2835
|
await this.ensureRoom(roomId);
|
|
1494
2836
|
} catch (err) {
|
|
@@ -1496,6 +2838,42 @@ var SupervisorImpl = class {
|
|
|
1496
2838
|
}
|
|
1497
2839
|
}
|
|
1498
2840
|
}
|
|
2841
|
+
/** D67-d: true for a shard of a project with more than one; false for every tenant today. */
|
|
2842
|
+
isSharded() {
|
|
2843
|
+
return (this.config.shards ?? 1) > 1;
|
|
2844
|
+
}
|
|
2845
|
+
/**
|
|
2846
|
+
* D67-d: the subset of `candidates` this shard owns, from the control plane through the
|
|
2847
|
+
* gateway's `/shard/filter`. Retries 1, 2, 4 ... 30 s (capped, never giving up) on any failure,
|
|
2848
|
+
* logging "waiting for shard assignment" at warn each time, because a shard that guesses would
|
|
2849
|
+
* either double-start rooms another shard holds or leave alarms undelivered; waiting is the only
|
|
2850
|
+
* honest state. The sleep is injected so the schedule is tested with a recorded fake.
|
|
2851
|
+
*/
|
|
2852
|
+
async assignedRooms(candidates) {
|
|
2853
|
+
const shard = this.config.shard ?? 0;
|
|
2854
|
+
const sleep2 = this.config.shardRetrySleep ?? ((ms) => new Promise((resolve2) => {
|
|
2855
|
+
const t = setTimeout(resolve2, ms);
|
|
2856
|
+
t.unref?.();
|
|
2857
|
+
}));
|
|
2858
|
+
let delayMs = SHARD_ASSIGNMENT_RETRY_MS[0];
|
|
2859
|
+
for (let attempt = 1; ; attempt++) {
|
|
2860
|
+
if (this.closing) return [];
|
|
2861
|
+
try {
|
|
2862
|
+
const client = this.config.shardClient;
|
|
2863
|
+
if (!client) throw new Error("no shard client is configured on this tenant");
|
|
2864
|
+
const mine = await client.filter(shard, candidates);
|
|
2865
|
+
const known = new Set(candidates);
|
|
2866
|
+
return mine.filter((r) => known.has(r)).sort();
|
|
2867
|
+
} catch (err) {
|
|
2868
|
+
this.log(
|
|
2869
|
+
"warn",
|
|
2870
|
+
`waiting for shard assignment (shard ${shard} of ${this.config.shards ?? 1}, attempt ${attempt}, ${candidates.length} candidate(s), retry in ${delayMs} ms): ${err instanceof Error ? err.message : String(err)}`
|
|
2871
|
+
);
|
|
2872
|
+
await sleep2(delayMs);
|
|
2873
|
+
delayMs = Math.min(delayMs * 2, SHARD_ASSIGNMENT_RETRY_MS[1]);
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
1499
2877
|
/**
|
|
1500
2878
|
* Everything a **VM-snapshot restore** owes the alarm clock, run from `/admin/resume` once the
|
|
1501
2879
|
* guest wall clock has been corrected.
|
|
@@ -1554,7 +2932,7 @@ var SupervisorImpl = class {
|
|
|
1554
2932
|
* rooms that are still serving the old code.
|
|
1555
2933
|
*/
|
|
1556
2934
|
async reloadBundle(bundlePath) {
|
|
1557
|
-
const loaded = await loadBundle(bundlePath, this.deploymentVersion);
|
|
2935
|
+
const loaded = await loadBundle(bundlePath, this.deploymentVersion, DEFAULT_ROOM_TYPE3);
|
|
1558
2936
|
for (const room of this.registry.values()) {
|
|
1559
2937
|
room.wakeQueue.length = 0;
|
|
1560
2938
|
room.droppedWakeFrames = 0;
|
|
@@ -1564,18 +2942,36 @@ var SupervisorImpl = class {
|
|
|
1564
2942
|
this.deployments.set(loaded.version, {
|
|
1565
2943
|
version: loaded.version,
|
|
1566
2944
|
schemaJson: loaded.schemaJson,
|
|
1567
|
-
bundle: loaded
|
|
2945
|
+
bundle: loaded,
|
|
2946
|
+
bundles: /* @__PURE__ */ new Map([[DEFAULT_ROOM_TYPE3, loaded]])
|
|
1568
2947
|
});
|
|
1569
2948
|
this.bundle = loaded;
|
|
1570
2949
|
this.schemaHash = loaded.schemaHash;
|
|
1571
2950
|
this.log("info", `bundle reloaded from ${bundlePath}`);
|
|
1572
2951
|
}
|
|
2952
|
+
/**
|
|
2953
|
+
* D47: banks every chargeable room's alive-time into the ledger. Called on
|
|
2954
|
+
* {@link ROOM_SAMPLE_MS}, on close, and immediately before `/admin/usage` is read, so what the
|
|
2955
|
+
* host agent ships is never a second stale.
|
|
2956
|
+
*/
|
|
2957
|
+
sampleUsage(now = Date.now()) {
|
|
2958
|
+
sampleRoomClocks(this.registry.values(), this.usage, now);
|
|
2959
|
+
}
|
|
2960
|
+
/** The `/admin/usage` body, and the ledger read the host agent ships. */
|
|
2961
|
+
usageWindows(now = Date.now()) {
|
|
2962
|
+
this.sampleUsage(now);
|
|
2963
|
+
return this.usage.windows(now);
|
|
2964
|
+
}
|
|
1573
2965
|
async close() {
|
|
1574
2966
|
if (this.closing) return;
|
|
1575
2967
|
this.closing = true;
|
|
1576
2968
|
this.quiesceAlarms();
|
|
2969
|
+
this.npcs.despawnAll();
|
|
2970
|
+
this.releaseIdentityKeyWaiters();
|
|
1577
2971
|
if (this.idleTimer) clearInterval(this.idleTimer);
|
|
1578
2972
|
if (this.pingTimer) clearInterval(this.pingTimer);
|
|
2973
|
+
this.sampleUsage();
|
|
2974
|
+
if (this.usageTimer) clearInterval(this.usageTimer);
|
|
1579
2975
|
try {
|
|
1580
2976
|
await flushAll(this);
|
|
1581
2977
|
} catch (err) {
|
|
@@ -1601,13 +2997,50 @@ var SupervisorImpl = class {
|
|
|
1601
2997
|
// -------------------------------------------------------------------------
|
|
1602
2998
|
// Logging
|
|
1603
2999
|
// -------------------------------------------------------------------------
|
|
3000
|
+
/**
|
|
3001
|
+
* D38: a tenant-level event now enters the log pipeline instead of only reaching the console.
|
|
3002
|
+
*
|
|
3003
|
+
* `/admin/logs` reads per-room rings, so before this every event that was not about a room —
|
|
3004
|
+
* boot, resume, deploy, placement, alarm recovery, storage failures — was invisible to the host
|
|
3005
|
+
* agent, to the control plane, and to `irtio logs`. Those are precisely the lines somebody
|
|
3006
|
+
* reaches for when a project is not working, and the only way to see them was to be on the box
|
|
3007
|
+
* reading the guest console.
|
|
3008
|
+
*
|
|
3009
|
+
* They go into a ring under the reserved id `__tenant` (D38), so the whole existing pipeline
|
|
3010
|
+
* carries them with no new route and no new frame.
|
|
3011
|
+
*/
|
|
1604
3012
|
log(level, ...args) {
|
|
3013
|
+
this.tenantLogs.push(level, args);
|
|
3014
|
+
this.emit(level, args);
|
|
3015
|
+
}
|
|
3016
|
+
/** The console/config sink, with no ring behind it. */
|
|
3017
|
+
emit(level, args) {
|
|
1605
3018
|
if (this.config.log) this.config.log(level, ...args);
|
|
1606
3019
|
else console[level === "info" ? "log" : level]("[irtio]", ...args);
|
|
1607
3020
|
}
|
|
3021
|
+
/** A room event: it belongs in that room's ring, and nowhere near the `__tenant` one. */
|
|
3022
|
+
/**
|
|
3023
|
+
* D44: opens one NPC session and hands back the client half of its socket. It goes straight
|
|
3024
|
+
* through `onConnection` — the same function the WebSocket server calls — so an NPC and a
|
|
3025
|
+
* browser differ by a flag on the accept, and by nothing at all after it.
|
|
3026
|
+
*/
|
|
3027
|
+
openLoopback(roomId, clientId) {
|
|
3028
|
+
const { server, client } = loopbackPair();
|
|
3029
|
+
this.onConnection(server, {
|
|
3030
|
+
loopback: { clientId },
|
|
3031
|
+
origin: void 0,
|
|
3032
|
+
routerOrigin: void 0,
|
|
3033
|
+
forwardedFor: void 0,
|
|
3034
|
+
routerSecret: void 0,
|
|
3035
|
+
// Never a real address, and deliberately not `127.0.0.1`: an operator reading a session
|
|
3036
|
+
// list should be able to tell an NPC from something dialling the loopback interface.
|
|
3037
|
+
remoteAddress: `npc:${roomId}`
|
|
3038
|
+
});
|
|
3039
|
+
return client;
|
|
3040
|
+
}
|
|
1608
3041
|
roomLog(room, level, ...args) {
|
|
1609
3042
|
room.log(level, ...args);
|
|
1610
|
-
this.
|
|
3043
|
+
this.emit(level, [`[${room.id}]`, ...args]);
|
|
1611
3044
|
}
|
|
1612
3045
|
/**
|
|
1613
3046
|
* Week-5 snapshot keys carry the deployment version (`@v3`); version `0` keeps the week-3 key
|
|
@@ -1647,18 +3080,32 @@ var SupervisorImpl = class {
|
|
|
1647
3080
|
// §10.8 escalation: consecutive flush passes that left a room unflushed. The host agent
|
|
1648
3081
|
// treats >=3 as worth an error-level log (once, deduped) instead of reading the tenant
|
|
1649
3082
|
// as merely slow to go idle.
|
|
1650
|
-
flushFailures: this.flushFailures
|
|
3083
|
+
flushFailures: this.flushFailures,
|
|
3084
|
+
// D56: has a Rapier world stepped in this VM yet? The agent gates a cold-booted
|
|
3085
|
+
// tenant's first snapshot on it. Additive; an agent that ignores it behaves as before.
|
|
3086
|
+
physicsWarm: this.physicsWarm
|
|
1651
3087
|
});
|
|
1652
3088
|
return true;
|
|
1653
3089
|
case "/admin/rooms":
|
|
1654
3090
|
json({ rooms: this.rooms(), deploymentVersion: this.deploymentVersion });
|
|
1655
3091
|
return true;
|
|
1656
3092
|
case "/admin/metrics":
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
3093
|
+
void this.refreshTickHealth().then(
|
|
3094
|
+
() => json({
|
|
3095
|
+
at: Date.now(),
|
|
3096
|
+
metrics: this.metrics(),
|
|
3097
|
+
rooms: this.rooms().map((r) => ({ id: r.id, metrics: r.metrics })),
|
|
3098
|
+
// D47: the usage ledger rides the poll the agent already makes. A separate route
|
|
3099
|
+
// would be a fourth round trip per tenant per poll for four rows.
|
|
3100
|
+
usage: this.usageWindows()
|
|
3101
|
+
}),
|
|
3102
|
+
() => json({
|
|
3103
|
+
at: Date.now(),
|
|
3104
|
+
metrics: this.metrics(),
|
|
3105
|
+
rooms: this.rooms().map((r) => ({ id: r.id, metrics: r.metrics })),
|
|
3106
|
+
usage: this.usageWindows()
|
|
3107
|
+
})
|
|
3108
|
+
);
|
|
1662
3109
|
return true;
|
|
1663
3110
|
// D31 (week 7): the ONE write on this otherwise read-only surface, and a deliberate
|
|
1664
3111
|
// exception to its "the agent must not be able to change anything" rule: after a
|
|
@@ -1768,10 +3215,31 @@ var SupervisorImpl = class {
|
|
|
1768
3215
|
migrationPath = nodePath.join(dir, `m${d.version}.mjs`);
|
|
1769
3216
|
await writeFile(migrationPath, Buffer.from(d.migrationB64, "base64"));
|
|
1770
3217
|
}
|
|
3218
|
+
let rooms2;
|
|
3219
|
+
if (d.roomsB64 !== void 0) {
|
|
3220
|
+
if (typeof d.roomsB64 !== "object" || d.roomsB64 === null || Array.isArray(d.roomsB64)) {
|
|
3221
|
+
throw new Error("roomsB64 must be an object of room type to base64 bundle");
|
|
3222
|
+
}
|
|
3223
|
+
rooms2 = {};
|
|
3224
|
+
for (const [type, b64] of Object.entries(d.roomsB64)) {
|
|
3225
|
+
if (!ROOM_TYPE_RE.test(type)) {
|
|
3226
|
+
throw new Error(
|
|
3227
|
+
`roomsB64 names an illegal room type ${JSON.stringify(type)}`
|
|
3228
|
+
);
|
|
3229
|
+
}
|
|
3230
|
+
if (typeof b64 !== "string") {
|
|
3231
|
+
throw new Error(`roomsB64.${type} must be a base64 bundle`);
|
|
3232
|
+
}
|
|
3233
|
+
const typePath = nodePath.join(dir, `v${d.version}.${type}.mjs`);
|
|
3234
|
+
await writeFile(typePath, Buffer.from(b64, "base64"));
|
|
3235
|
+
rooms2[type] = typePath;
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
1771
3238
|
await this.addBundle({
|
|
1772
3239
|
version: d.version,
|
|
1773
3240
|
...typeof d.schemaJson === "string" ? { schemaJson: d.schemaJson } : {},
|
|
1774
3241
|
...bundlePath !== void 0 ? { bundlePath } : {},
|
|
3242
|
+
...rooms2 !== void 0 ? { rooms: rooms2 } : {},
|
|
1775
3243
|
...migrationPath !== void 0 ? { migrationPath } : {}
|
|
1776
3244
|
});
|
|
1777
3245
|
added.push(d.version);
|
|
@@ -1796,32 +3264,187 @@ var SupervisorImpl = class {
|
|
|
1796
3264
|
});
|
|
1797
3265
|
return true;
|
|
1798
3266
|
}
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
}
|
|
3267
|
+
// D48: the THIRD write on this otherwise read-only surface, and the reasoning is the same
|
|
3268
|
+
// one that carved out the other two. Cap state changes at runtime by design (a meter
|
|
3269
|
+
// crosses, a card is added, an owner raises a ceiling) and the boot-time env channel is
|
|
3270
|
+
// closed to it: env is hashed into the VM snapshot's identity (D29), so a cap that rode it
|
|
3271
|
+
// would cold-boot the fleet on every cap change. GET answers what this tenant currently
|
|
3272
|
+
// believes, which is the thing an operator wants when a join is being refused and nobody
|
|
3273
|
+
// can see why.
|
|
3274
|
+
// M5 part 7 (D67-e): the FOURTH write on this surface. A bus message another shard sent to
|
|
3275
|
+
// a room on this one, forwarded by the control plane through the host agent. Load-if-exists
|
|
3276
|
+
// then the local park; never a room from nothing. Answers the local `busSend` result so the
|
|
3277
|
+
// sender's promise resolves with the same refusals it would see on one shard.
|
|
3278
|
+
case "/admin/bus/deliver": {
|
|
3279
|
+
if (req.method !== "POST") {
|
|
3280
|
+
res.writeHead(405, { "content-type": "application/json" });
|
|
3281
|
+
res.end('{"error":"POST only"}');
|
|
3282
|
+
return true;
|
|
1813
3283
|
}
|
|
1814
|
-
|
|
1815
|
-
|
|
3284
|
+
let body = "";
|
|
3285
|
+
req.on("data", (d) => {
|
|
3286
|
+
body += d.toString();
|
|
3287
|
+
});
|
|
3288
|
+
req.on("end", () => {
|
|
3289
|
+
void (async () => {
|
|
3290
|
+
let parsed;
|
|
3291
|
+
try {
|
|
3292
|
+
parsed = JSON.parse(body || "{}");
|
|
3293
|
+
} catch {
|
|
3294
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
3295
|
+
res.end('{"error":"body is not JSON"}');
|
|
3296
|
+
return;
|
|
3297
|
+
}
|
|
3298
|
+
const { to, from, payload, id } = parsed;
|
|
3299
|
+
if (typeof to !== "string" || typeof from !== "string" || typeof payload !== "string" || typeof id !== "string") {
|
|
3300
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
3301
|
+
res.end('{"error":"to, from, payload and id must be strings"}');
|
|
3302
|
+
return;
|
|
3303
|
+
}
|
|
3304
|
+
if (busPayloadProblem(payload)) {
|
|
3305
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
3306
|
+
res.end('{"error":"payload over the bus limit"}');
|
|
3307
|
+
return;
|
|
3308
|
+
}
|
|
3309
|
+
try {
|
|
3310
|
+
json(await this.deliverForwarded({ to, from, payload, id }));
|
|
3311
|
+
} catch (err) {
|
|
3312
|
+
this.log("error", "admin bus deliver failed", err);
|
|
3313
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
3314
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
|
|
3315
|
+
}
|
|
3316
|
+
})();
|
|
3317
|
+
});
|
|
1816
3318
|
return true;
|
|
1817
3319
|
}
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
3320
|
+
case "/admin/caps": {
|
|
3321
|
+
if (req.method === "GET") {
|
|
3322
|
+
const state = this.caps.current();
|
|
3323
|
+
json({
|
|
3324
|
+
present: state !== void 0,
|
|
3325
|
+
ageMs: this.caps.ageMs() ?? null,
|
|
3326
|
+
state: state ?? null
|
|
3327
|
+
});
|
|
3328
|
+
return true;
|
|
3329
|
+
}
|
|
3330
|
+
if (req.method !== "POST") {
|
|
3331
|
+
res.writeHead(405, { "content-type": "application/json" });
|
|
3332
|
+
res.end('{"error":"GET or POST"}');
|
|
3333
|
+
return true;
|
|
3334
|
+
}
|
|
3335
|
+
let body = "";
|
|
3336
|
+
req.on("data", (d) => {
|
|
3337
|
+
body += d.toString();
|
|
3338
|
+
});
|
|
3339
|
+
req.on("end", () => {
|
|
3340
|
+
let parsed;
|
|
3341
|
+
try {
|
|
3342
|
+
parsed = JSON.parse(body || "null");
|
|
3343
|
+
} catch {
|
|
3344
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
3345
|
+
res.end('{"error":"body is not JSON"}');
|
|
3346
|
+
return;
|
|
3347
|
+
}
|
|
3348
|
+
const state = parseCapState(parsed);
|
|
3349
|
+
if (!state) {
|
|
3350
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
3351
|
+
res.end('{"error":"body is not a cap state"}');
|
|
3352
|
+
return;
|
|
3353
|
+
}
|
|
3354
|
+
this.caps.apply(state);
|
|
3355
|
+
this.bandwidth.setPlan(state.plan);
|
|
3356
|
+
const leaseBytes = this.caps.leaseFor("egress_bytes");
|
|
3357
|
+
if (leaseBytes !== void 0) {
|
|
3358
|
+
const wasTripped = this.egressLease.tripped;
|
|
3359
|
+
this.egressLease.apply({
|
|
3360
|
+
remainingBytes: leaseBytes,
|
|
3361
|
+
periodStart: state.periodStart
|
|
3362
|
+
});
|
|
3363
|
+
if (this.egressLease.tripped && !wasTripped) this.tripEgressWall();
|
|
3364
|
+
else if (!this.egressLease.tripped && wasTripped) {
|
|
3365
|
+
this.log("info", "egress wall lifted: a fresh budget was leased");
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
const crossed = state.meters.filter((m) => m.mode === "hard" && m.status === "crossed").map((m) => m.meter);
|
|
3369
|
+
if (crossed.length > 0) {
|
|
3370
|
+
this.log("warn", `usage caps applied: hard cap reached on ${crossed.join(", ")}`);
|
|
3371
|
+
}
|
|
3372
|
+
json({ ok: true, applied: state.meters.length, crossed });
|
|
3373
|
+
});
|
|
3374
|
+
return true;
|
|
3375
|
+
}
|
|
3376
|
+
/**
|
|
3377
|
+
* D53: the platform identity public keys, pushed the way cap state is and for the same
|
|
3378
|
+
* reason — an env var would be hashed into the VM snapshot's identity (D29) and every key
|
|
3379
|
+
* rotation would cold-boot the fleet.
|
|
3380
|
+
*
|
|
3381
|
+
* GET answers what this tenant currently holds (kids only; the PEMs are public but an
|
|
3382
|
+
* operator debugging a refused join wants the fingerprints, not a wall of base64). POST
|
|
3383
|
+
* replaces the set wholesale: two keys may be active at once, so a rotation is an overlap
|
|
3384
|
+
* rather than a flag day, exactly like `irtio keys jwt-secret`.
|
|
3385
|
+
*/
|
|
3386
|
+
case "/admin/identity-keys": {
|
|
3387
|
+
if (req.method === "GET") {
|
|
3388
|
+
json({ kids: this.identityKeys.map((k) => k.kid) });
|
|
3389
|
+
return true;
|
|
3390
|
+
}
|
|
3391
|
+
if (req.method !== "POST") {
|
|
3392
|
+
res.writeHead(405, { "content-type": "application/json" });
|
|
3393
|
+
res.end('{"error":"GET or POST"}');
|
|
3394
|
+
return true;
|
|
3395
|
+
}
|
|
3396
|
+
let body = "";
|
|
3397
|
+
req.on("data", (d) => {
|
|
3398
|
+
body += d.toString();
|
|
3399
|
+
});
|
|
3400
|
+
req.on("end", () => {
|
|
3401
|
+
let parsed;
|
|
3402
|
+
try {
|
|
3403
|
+
parsed = JSON.parse(body || "null");
|
|
3404
|
+
} catch {
|
|
3405
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
3406
|
+
res.end('{"error":"body is not JSON"}');
|
|
3407
|
+
return;
|
|
3408
|
+
}
|
|
3409
|
+
const keys = parseIdentityKeys(parsed);
|
|
3410
|
+
if (!keys) {
|
|
3411
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
3412
|
+
res.end('{"error":"body is not {keys: [{kid, publicKeyPem}]}"}');
|
|
3413
|
+
return;
|
|
3414
|
+
}
|
|
3415
|
+
this.identityKeys = keys;
|
|
3416
|
+
if (keys.length > 0) this.releaseIdentityKeyWaiters();
|
|
3417
|
+
json({ ok: true, kids: keys.map((k) => k.kid) });
|
|
3418
|
+
});
|
|
3419
|
+
return true;
|
|
3420
|
+
}
|
|
3421
|
+
case "/admin/logs": {
|
|
3422
|
+
const since = Number(url.searchParams.get("since") ?? 0) || 0;
|
|
3423
|
+
const limit = Math.min(Number(url.searchParams.get("limit") ?? 200) || 200, 1e3);
|
|
3424
|
+
const entries = [];
|
|
3425
|
+
const streams = [TENANT_ROOM_ID, ...this.registry.values().map((r) => r.id)];
|
|
3426
|
+
for (const roomId of streams) {
|
|
3427
|
+
for (const entry of this.logs(roomId)) {
|
|
3428
|
+
if (entry.at <= since) continue;
|
|
3429
|
+
entries.push({
|
|
3430
|
+
at: entry.at,
|
|
3431
|
+
level: entry.level,
|
|
3432
|
+
roomId,
|
|
3433
|
+
message: entry.args.map((x) => typeof x === "string" ? x : String(x)).join(" ")
|
|
3434
|
+
});
|
|
3435
|
+
}
|
|
3436
|
+
}
|
|
3437
|
+
entries.sort((x, y) => x.at - y.at);
|
|
3438
|
+
json({ entries: entries.slice(-limit) });
|
|
3439
|
+
return true;
|
|
3440
|
+
}
|
|
3441
|
+
default:
|
|
3442
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
3443
|
+
res.end('{"error":"not found"}');
|
|
3444
|
+
return true;
|
|
3445
|
+
}
|
|
3446
|
+
}
|
|
3447
|
+
storeKey(room, version) {
|
|
1825
3448
|
const id = typeof room === "string" ? room : room.id;
|
|
1826
3449
|
const v = version ?? (typeof room === "string" ? 0 : room.version);
|
|
1827
3450
|
const base = `${this.config.projectId}/${id}`;
|
|
@@ -1843,7 +3466,25 @@ var SupervisorImpl = class {
|
|
|
1843
3466
|
}
|
|
1844
3467
|
async registerDeployment(ref) {
|
|
1845
3468
|
const migrationUrl = ref.migrationPath !== void 0 ? toFileUrl(ref.migrationPath) : void 0;
|
|
1846
|
-
const
|
|
3469
|
+
const bundles = /* @__PURE__ */ new Map();
|
|
3470
|
+
if (ref.bundlePath !== void 0) {
|
|
3471
|
+
bundles.set(
|
|
3472
|
+
DEFAULT_ROOM_TYPE3,
|
|
3473
|
+
await loadBundle(ref.bundlePath, ref.version, DEFAULT_ROOM_TYPE3)
|
|
3474
|
+
);
|
|
3475
|
+
}
|
|
3476
|
+
for (const [type, path2] of Object.entries(ref.rooms ?? {})) {
|
|
3477
|
+
if (!ROOM_TYPE_RE.test(type)) {
|
|
3478
|
+
throw new Error(
|
|
3479
|
+
`irtio: deployment v${ref.version} names a room type ${JSON.stringify(type)}, which is not a legal type name (lowercase, starting with a letter or digit, up to 24 characters)`
|
|
3480
|
+
);
|
|
3481
|
+
}
|
|
3482
|
+
if (bundles.has(type)) {
|
|
3483
|
+
throw new Error(`irtio: deployment v${ref.version} defines the room type ${type} twice`);
|
|
3484
|
+
}
|
|
3485
|
+
bundles.set(type, await loadBundle(path2, ref.version, type));
|
|
3486
|
+
}
|
|
3487
|
+
const bundle = bundles.get(DEFAULT_ROOM_TYPE3);
|
|
1847
3488
|
const schemaJson = bundle?.schemaJson ?? ref.schemaJson;
|
|
1848
3489
|
if (schemaJson === void 0) {
|
|
1849
3490
|
throw new Error(
|
|
@@ -1853,14 +3494,210 @@ var SupervisorImpl = class {
|
|
|
1853
3494
|
this.deployments.set(ref.version, {
|
|
1854
3495
|
version: ref.version,
|
|
1855
3496
|
schemaJson,
|
|
3497
|
+
bundles,
|
|
1856
3498
|
...migrationUrl !== void 0 ? { migrationUrl } : {},
|
|
1857
3499
|
...bundle !== void 0 ? { bundle } : {}
|
|
1858
3500
|
});
|
|
1859
3501
|
}
|
|
3502
|
+
/**
|
|
3503
|
+
* The newest loaded bundle for a room type, or `undefined` when the tenant defines no such type.
|
|
3504
|
+
*
|
|
3505
|
+
* Deliberately distinct from "no bundle at all": a tenant with room types that is asked for a
|
|
3506
|
+
* type it does not have has made a naming mistake, and answering that with the relay rung would
|
|
3507
|
+
* silently give the client a schema-less room instead of an error it can read.
|
|
3508
|
+
*/
|
|
3509
|
+
bundleFor(type) {
|
|
3510
|
+
let best;
|
|
3511
|
+
for (const d of this.deployments.values()) {
|
|
3512
|
+
const b = d.bundles.get(type);
|
|
3513
|
+
if (b && (!best || b.version > best.version)) best = b;
|
|
3514
|
+
}
|
|
3515
|
+
return best;
|
|
3516
|
+
}
|
|
3517
|
+
/** Every room type this tenant defines, newest deployment first, sorted for stable messages. */
|
|
3518
|
+
roomTypes() {
|
|
3519
|
+
const types = /* @__PURE__ */ new Set();
|
|
3520
|
+
for (const d of this.deployments.values()) for (const type of d.bundles.keys()) types.add(type);
|
|
3521
|
+
return [...types].sort();
|
|
3522
|
+
}
|
|
3523
|
+
/**
|
|
3524
|
+
* M5 part 3.5: does what this project declared fit the VM it was given?
|
|
3525
|
+
*
|
|
3526
|
+
* **This check inverted in part 3.5, and what it does on failure inverted with it.** In part 2
|
|
3527
|
+
* the VM size was a class guess and the declarations were checked against it, so a mismatch was
|
|
3528
|
+
* a real possibility for an ordinary project and logging was the only defensible response:
|
|
3529
|
+
* refusing to boot would have stopped a project that was serving players, over arithmetic that
|
|
3530
|
+
* was deliberately pessimistic.
|
|
3531
|
+
*
|
|
3532
|
+
* Now the declarations *decide* the size, through the same `vmSizeFor` the host agent calls
|
|
3533
|
+
* before it builds the machine. An ordinary project therefore fits by construction and this
|
|
3534
|
+
* check can only fail one way: the project set an explicit `vmMemMib` override smaller than its
|
|
3535
|
+
* own declarations. That is not pessimism meeting reality, it is a project having stated two
|
|
3536
|
+
* incompatible things, and the honest answer to it pre-release is to refuse the room set at
|
|
3537
|
+
* boot rather than to serve rooms inside a machine that cannot hold them.
|
|
3538
|
+
*
|
|
3539
|
+
* No VM budget means a dev box, and dev keeps the generous defaults untouched.
|
|
3540
|
+
*/
|
|
3541
|
+
checkDeclaredSizing() {
|
|
3542
|
+
const vmMemMib = this.config.vmMemMib;
|
|
3543
|
+
if (vmMemMib === void 0) return;
|
|
3544
|
+
const types = this.roomTypes();
|
|
3545
|
+
if (types.length === 0) return;
|
|
3546
|
+
const declared = types.map((type) => {
|
|
3547
|
+
const config = this.bundleFor(type)?.config;
|
|
3548
|
+
return {
|
|
3549
|
+
type,
|
|
3550
|
+
memoryMb: config?.memoryMb,
|
|
3551
|
+
...config?.maxAwake !== void 0 ? { maxAwake: config.maxAwake } : {},
|
|
3552
|
+
...config?.engine !== void 0 ? { physics: true } : {}
|
|
3553
|
+
};
|
|
3554
|
+
});
|
|
3555
|
+
const fit = checkDeclaredFit(vmMemMib, declared);
|
|
3556
|
+
if (fit.fits) {
|
|
3557
|
+
this.log("info", `room type sizing fits the VM budget: ${fit.detail}`);
|
|
3558
|
+
return;
|
|
3559
|
+
}
|
|
3560
|
+
const message = `this project's room types do not fit its VM: ${fit.detail}. Arithmetic sizing gives a project the VM its declarations ask for, so the only way to reach this is an explicit vmMemMib override smaller than the declarations under it. These types need ${fit.requiredMib} MiB and the override booked ${fit.budgetMib}. Raise or remove the override, or lower a memoryMb or maxAwake declaration.` + (fit.undeclared.length > 0 ? ` Counted at the platform default because they declare no memoryMb: ${fit.undeclared.join(", ")}.` : "");
|
|
3561
|
+
this.log("error", message);
|
|
3562
|
+
throw new Error(`irtio: ${message}`);
|
|
3563
|
+
}
|
|
3564
|
+
/** D58: the worker limits a room of this type runs under: its declaration, or the default. */
|
|
3565
|
+
limitsFor(type) {
|
|
3566
|
+
const declared = this.bundleFor(type)?.config.memoryMb;
|
|
3567
|
+
if (declared === void 0) return this.limits;
|
|
3568
|
+
return {
|
|
3569
|
+
workerMaxOldGenMb: declared,
|
|
3570
|
+
workerMaxYoungGenMb: this.limits.workerMaxYoungGenMb
|
|
3571
|
+
};
|
|
3572
|
+
}
|
|
3573
|
+
/**
|
|
3574
|
+
* M5 part 3.5: how many rooms of a type are holding worker memory right now.
|
|
3575
|
+
*
|
|
3576
|
+
* Awake means "costs the VM its declared heap": starting, running, waking, and hibernating (a
|
|
3577
|
+
* room mid-sleep still has its worker). A hibernated or closed room holds nothing, which is why
|
|
3578
|
+
* `maxAwake` is a bound on this number and not on how many rooms a project may ever have.
|
|
3579
|
+
*/
|
|
3580
|
+
awakeOfType(type) {
|
|
3581
|
+
let n = 0;
|
|
3582
|
+
for (const room of this.registry.values()) {
|
|
3583
|
+
if (room.relay) continue;
|
|
3584
|
+
if (roomTypeOf(room.id) !== type) continue;
|
|
3585
|
+
if (room.state === "hibernated" || room.state === "closed") continue;
|
|
3586
|
+
n++;
|
|
3587
|
+
}
|
|
3588
|
+
return n;
|
|
3589
|
+
}
|
|
3590
|
+
/**
|
|
3591
|
+
* The refusal for starting a *new* room of a type that is already at its **declared** `maxAwake`,
|
|
3592
|
+
* or `undefined` when there is room.
|
|
3593
|
+
*
|
|
3594
|
+
* **This is what makes declared sizing honest rather than optimistic.** The tenant's VM was
|
|
3595
|
+
* built with `(memoryMb + youngGen) x maxAwake` MiB for this type, so room N+1 is a room the
|
|
3596
|
+
* machine was never sized to hold; admitting it would recreate exactly the "several under-cap
|
|
3597
|
+
* rooms sum past the VM" caveat part 1 recorded and part 3.5 exists to close.
|
|
3598
|
+
*
|
|
3599
|
+
* Four boundaries, stated rather than left implicit, because each of them narrows the claim.
|
|
3600
|
+
*
|
|
3601
|
+
* - **It applies only to a placed tenant.** No `IRT_VM_MEM_MIB` means a dev box, and a dev box
|
|
3602
|
+
* has headroom rather than a machine sized to a declaration — which is the same signal, and
|
|
3603
|
+
* the same reasoning, that leaves `DEFAULT_LIMITS` alone in `heap-caps.ts`. It is also what
|
|
3604
|
+
* `games/dive`'s end-to-end suite discovered the hard way: enforcing a production-shaped bound
|
|
3605
|
+
* against a developer's laptop refuses rooms nothing was going to run out of memory over. The
|
|
3606
|
+
* cost is that a developer meets this refusal in production rather than in dev, which is
|
|
3607
|
+
* recorded as a debt.
|
|
3608
|
+
*
|
|
3609
|
+
* - **Only a declared `maxAwake` is enforced.** An undeclared type is *sized* at one awake room,
|
|
3610
|
+
* which is the conservative number for arithmetic, but it is not *capped* at one. Capping it
|
|
3611
|
+
* would invent a limit no project asked for and would stop every existing multi-room project
|
|
3612
|
+
* dead at its second room. So for an undeclared type the pre-part-3.5 situation stands
|
|
3613
|
+
* unchanged: rooms are contained by their heap caps and their sum is not bounded. The bound is
|
|
3614
|
+
* something a project opts into by saying a number, and the sum is exact for the types that
|
|
3615
|
+
* did. This is narrower than the plan asked for and it is recorded as such.
|
|
3616
|
+
* - **Relay rooms are exempt.** They have no bundle, so they have no declaration, and their
|
|
3617
|
+
* client cap is a `SupervisorLimits` knob rather than a room-type one.
|
|
3618
|
+
* - **A wake is not refused, only a create.** Refusing to wake an existing room would leave a
|
|
3619
|
+
* room its players cannot get back into and its state stranded, which is the "silently dies
|
|
3620
|
+
* under a cap" failure D48 was built around avoiding. The consequence is that a project which
|
|
3621
|
+
* accumulates hibernated rooms can exceed its declared concurrency by waking them. Recorded as
|
|
3622
|
+
* a debt rather than hidden here: the bound bites where rooms are *created*, which is the
|
|
3623
|
+
* point a project controls.
|
|
3624
|
+
*/
|
|
3625
|
+
maxAwakeRefusal(roomId) {
|
|
3626
|
+
if (this.config.vmMemMib === void 0) return void 0;
|
|
3627
|
+
const type = roomTypeOf(roomId);
|
|
3628
|
+
const declared = this.bundleFor(type)?.config.maxAwake;
|
|
3629
|
+
if (declared === void 0) return void 0;
|
|
3630
|
+
if (this.awakeOfType(type) < declared) return void 0;
|
|
3631
|
+
return `room type ${JSON.stringify(type)} already has ${declared} room${declared === 1 ? "" : "s"} awake, which is what it declared. The tenant VM was sized for exactly that many, so starting another would put this project over the memory it asked for. Raise \`maxAwake\` in this type's defineRoom config and deploy; the change takes effect at the next placement.`;
|
|
3632
|
+
}
|
|
3633
|
+
/**
|
|
3634
|
+
* M5 part 3.5 (Part C): how many rooms of any type are holding a worker right now.
|
|
3635
|
+
*
|
|
3636
|
+
* The same "awake" the per-type bound uses: starting, running, waking and hibernating hold a
|
|
3637
|
+
* worker; hibernated and closed do not. Relay rooms count, because a relay room is a real room
|
|
3638
|
+
* in the registry with real sockets attached, and the cap exists to bound exactly the burst a
|
|
3639
|
+
* crowd of relay rooms produces.
|
|
3640
|
+
*/
|
|
3641
|
+
awakeRooms() {
|
|
3642
|
+
let n = 0;
|
|
3643
|
+
for (const room of this.registry.values()) {
|
|
3644
|
+
if (room.state === "hibernated" || room.state === "closed") continue;
|
|
3645
|
+
n++;
|
|
3646
|
+
}
|
|
3647
|
+
return n;
|
|
3648
|
+
}
|
|
3649
|
+
/**
|
|
3650
|
+
* The refusal for starting a new room on a free project that is already at the concurrent
|
|
3651
|
+
* awake-rooms cap, or `undefined` when there is room.
|
|
3652
|
+
*
|
|
3653
|
+
* Free only, deliberately. A card project's exposure is bounded by money — the included tier,
|
|
3654
|
+
* then metered overage under a spend cap it controls — and adding a shape cap on top would be
|
|
3655
|
+
* refusing work a customer has agreed to pay for.
|
|
3656
|
+
*
|
|
3657
|
+
* A box with no cap state does not enforce this, for the same reason it enforces no other cap:
|
|
3658
|
+
* the failure direction is "serve", and a fleet socket blinking must not look like a free tier.
|
|
3659
|
+
*/
|
|
3660
|
+
freeAwakeRoomsRefusal() {
|
|
3661
|
+
if (this.caps.plan() !== "free") return void 0;
|
|
3662
|
+
const cap = this.limits.freeMaxAwakeRooms;
|
|
3663
|
+
if (this.awakeRooms() < cap) return void 0;
|
|
3664
|
+
return `this project is on the free plan, which allows ${cap} rooms awake at the same time, and ${cap} are awake now. Rooms that go idle free a slot within a minute. Add a card in the billing page to lift the limit.`;
|
|
3665
|
+
}
|
|
3666
|
+
/**
|
|
3667
|
+
* M5 part 3.5 (Part C): the wall trips. Forwarding stops and every session closes.
|
|
3668
|
+
*
|
|
3669
|
+
* Three things are deliberate about what this does and does not do.
|
|
3670
|
+
*
|
|
3671
|
+
* **Sockets close, rooms do not.** Rooms hibernate on their ordinary idle path once their
|
|
3672
|
+
* clients are gone, which means state is snapshotted normally and comes back when the period
|
|
3673
|
+
* rolls or a card is added. A wall that destroyed rooms would turn a billing event into data
|
|
3674
|
+
* loss, which is the "silently dies under a cap" failure this milestone is built around
|
|
3675
|
+
* avoiding.
|
|
3676
|
+
*
|
|
3677
|
+
* **Every socket gets the distinct close code**, not just the ones that were sending. A player
|
|
3678
|
+
* who was idle when the wall tripped needs the same explanation as one who was not.
|
|
3679
|
+
*
|
|
3680
|
+
* **NPCs are closed too, and that is not a contradiction of their exemption.** An NPC's bytes
|
|
3681
|
+
* are loopback and never counted against the lease, so an NPC cannot spend a project into the
|
|
3682
|
+
* wall. But an NPC in a room whose players have all been disconnected is a worker burning
|
|
3683
|
+
* room-hours for an audience of nobody.
|
|
3684
|
+
*/
|
|
3685
|
+
tripEgressWall() {
|
|
3686
|
+
const plan = this.caps.plan() ?? "free";
|
|
3687
|
+
const message = egressWallMessage(plan);
|
|
3688
|
+
this.log(
|
|
3689
|
+
"warn",
|
|
3690
|
+
`egress wall tripped: the leased data-out budget is spent. Closing ${this.sessions.size} session${this.sessions.size === 1 ? "" : "s"} with close code ${CLOSE_EGRESS_WALL}. ` + message
|
|
3691
|
+
);
|
|
3692
|
+
for (const session of [...this.sessions]) {
|
|
3693
|
+
if (!session.open) continue;
|
|
3694
|
+
session.failWith("E_EGRESS_WALL", message, CLOSE_EGRESS_WALL);
|
|
3695
|
+
}
|
|
3696
|
+
}
|
|
1860
3697
|
/** Registers a newer deployment. Rooms already running keep theirs and drain (plan §3.3). */
|
|
1861
3698
|
async addBundle(ref) {
|
|
1862
|
-
if (ref.bundlePath === void 0 && ref.schemaJson === void 0) {
|
|
1863
|
-
throw new Error("addBundle: a deployment needs a bundlePath or a schemaJson");
|
|
3699
|
+
if (ref.bundlePath === void 0 && ref.schemaJson === void 0 && Object.keys(ref.rooms ?? {}).length === 0) {
|
|
3700
|
+
throw new Error("addBundle: a deployment needs a bundlePath, a rooms map or a schemaJson");
|
|
1864
3701
|
}
|
|
1865
3702
|
await this.registerDeployment(ref);
|
|
1866
3703
|
const newest = this.newestBundle();
|
|
@@ -1912,14 +3749,16 @@ var SupervisorImpl = class {
|
|
|
1912
3749
|
* `from` up to `to`. `undefined` when there is nothing to do; throws when a version in between
|
|
1913
3750
|
* is unknown, which would otherwise mean decoding bytes with the wrong schema.
|
|
1914
3751
|
*/
|
|
1915
|
-
migrationChain(from, to) {
|
|
3752
|
+
migrationChain(from, to, type = DEFAULT_ROOM_TYPE3) {
|
|
1916
3753
|
if (from >= to) return void 0;
|
|
1917
3754
|
const steps = [];
|
|
1918
3755
|
for (const d of [...this.deployments.values()].sort((a, b) => a.version - b.version)) {
|
|
1919
3756
|
if (d.version < from || d.version > to) continue;
|
|
1920
3757
|
steps.push({
|
|
1921
3758
|
version: d.version,
|
|
1922
|
-
|
|
3759
|
+
// D58: each type migrates against its own schema. The default type reads `d.schemaJson`,
|
|
3760
|
+
// which is exactly what every project got before types, so nothing single-room shifts.
|
|
3761
|
+
schemaJson: d.bundles.get(type)?.schemaJson ?? d.schemaJson,
|
|
1923
3762
|
...d.migrationUrl !== void 0 && d.version > from ? { migrationUrl: d.migrationUrl } : {}
|
|
1924
3763
|
});
|
|
1925
3764
|
}
|
|
@@ -1948,6 +3787,15 @@ var SupervisorImpl = class {
|
|
|
1948
3787
|
this.limits.maxBufferedBytes,
|
|
1949
3788
|
(s) => this.onSlowConsumer(s)
|
|
1950
3789
|
);
|
|
3790
|
+
if (meta.loopback) {
|
|
3791
|
+
session.npc = true;
|
|
3792
|
+
session.forcedClientId = meta.loopback.clientId;
|
|
3793
|
+
}
|
|
3794
|
+
session.onEgress = (bytes) => {
|
|
3795
|
+
this.usage.addEgressBytes(bytes);
|
|
3796
|
+
if (this.egressLease.spend(bytes)) this.tripEgressWall();
|
|
3797
|
+
this.bandwidth.count(bytes);
|
|
3798
|
+
};
|
|
1951
3799
|
this.sessions.add(session);
|
|
1952
3800
|
this.idleSince = 0;
|
|
1953
3801
|
this.idleFired = false;
|
|
@@ -1960,13 +3808,20 @@ var SupervisorImpl = class {
|
|
|
1960
3808
|
session.missedPongs = 0;
|
|
1961
3809
|
});
|
|
1962
3810
|
ws.on("message", (data) => this.onSocketMessage(session, data));
|
|
1963
|
-
if (!
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
3811
|
+
if (!session.npc) {
|
|
3812
|
+
if (!originAllowed(this.config.origins, this.config.allowNoOrigin !== false, origin)) {
|
|
3813
|
+
session.fail("E_ORIGIN", formatError("E_ORIGIN", { origin: origin ?? "(none)" }));
|
|
3814
|
+
return;
|
|
3815
|
+
}
|
|
3816
|
+
if (!this.ipBuckets.take(remoteAddress)) {
|
|
3817
|
+
session.fail("E_RATE_LIMITED", "too many connections from this address");
|
|
3818
|
+
return;
|
|
3819
|
+
}
|
|
3820
|
+
const capRefusal = this.caps.refusal("egress_bytes");
|
|
3821
|
+
if (capRefusal !== void 0) {
|
|
3822
|
+
session.fail("E_USAGE_CAP", capRefusal);
|
|
3823
|
+
return;
|
|
3824
|
+
}
|
|
1970
3825
|
}
|
|
1971
3826
|
session.helloTimer = setTimeout(() => {
|
|
1972
3827
|
session.helloTimer = void 0;
|
|
@@ -1983,7 +3838,7 @@ var SupervisorImpl = class {
|
|
|
1983
3838
|
let payload;
|
|
1984
3839
|
try {
|
|
1985
3840
|
const frame = decodeFrame(bytes);
|
|
1986
|
-
if (frame.type !==
|
|
3841
|
+
if (frame.type !== FrameType3.HELLO) {
|
|
1987
3842
|
session.fail("E_BAD_FRAME", formatError("E_BAD_FRAME", { frame: frame.type }));
|
|
1988
3843
|
return;
|
|
1989
3844
|
}
|
|
@@ -2002,10 +3857,10 @@ var SupervisorImpl = class {
|
|
|
2002
3857
|
session.slowConsumer = true;
|
|
2003
3858
|
try {
|
|
2004
3859
|
session.ws.send(
|
|
2005
|
-
|
|
2006
|
-
|
|
3860
|
+
encodeFrame3(
|
|
3861
|
+
FrameType3.ERROR,
|
|
2007
3862
|
encodeErrorPayload2({
|
|
2008
|
-
code:
|
|
3863
|
+
code: ErrorCode3.E_SLOW_CONSUMER.code,
|
|
2009
3864
|
message: formatError("E_SLOW_CONSUMER"),
|
|
2010
3865
|
fatal: true
|
|
2011
3866
|
})
|
|
@@ -2053,6 +3908,9 @@ var SupervisorImpl = class {
|
|
|
2053
3908
|
if (session.state !== "joined") return;
|
|
2054
3909
|
session.state = "disconnected";
|
|
2055
3910
|
room.metrics.connections = Math.max(0, room.metrics.connections - 1);
|
|
3911
|
+
if (session.npc) {
|
|
3912
|
+
room.metrics.npcConnections = Math.max(0, room.metrics.npcConnections - 1);
|
|
3913
|
+
}
|
|
2056
3914
|
if (session.fatal && !session.slowConsumer) {
|
|
2057
3915
|
this.expireSession(session, "kicked");
|
|
2058
3916
|
return;
|
|
@@ -2064,7 +3922,7 @@ var SupervisorImpl = class {
|
|
|
2064
3922
|
const delta = room.relayRoom.setConnected(session.clientId, false);
|
|
2065
3923
|
if (delta) {
|
|
2066
3924
|
room.lastTick = room.relayRoom.tick;
|
|
2067
|
-
this.broadcast(room,
|
|
3925
|
+
this.broadcast(room, encodeFrame3(FrameType3.DELTA, delta));
|
|
2068
3926
|
}
|
|
2069
3927
|
}
|
|
2070
3928
|
session.graceTimer = setTimeout(
|
|
@@ -2086,11 +3944,21 @@ var SupervisorImpl = class {
|
|
|
2086
3944
|
if (!room) return;
|
|
2087
3945
|
if (room.clients.get(session.clientId) === session) room.clients.delete(session.clientId);
|
|
2088
3946
|
this.clientIds.delete(session.clientId);
|
|
3947
|
+
if (this.config.voice) {
|
|
3948
|
+
void this.config.voice.close({
|
|
3949
|
+
projectId: this.config.projectId,
|
|
3950
|
+
roomId: room.id,
|
|
3951
|
+
clientId: session.clientId,
|
|
3952
|
+
npc: session.npc
|
|
3953
|
+
}).catch((err) => {
|
|
3954
|
+
this.log("warn", `voice close failed for ${session.clientId}: ${String(err)}`);
|
|
3955
|
+
});
|
|
3956
|
+
}
|
|
2089
3957
|
if (room.relay) {
|
|
2090
3958
|
const delta = room.relayRoom?.remove(session.clientId);
|
|
2091
3959
|
if (delta && room.relayRoom) {
|
|
2092
3960
|
room.lastTick = room.relayRoom.tick;
|
|
2093
|
-
this.broadcast(room,
|
|
3961
|
+
this.broadcast(room, encodeFrame3(FrameType3.DELTA, delta));
|
|
2094
3962
|
}
|
|
2095
3963
|
} else if (room.state === "running" && room.worker) {
|
|
2096
3964
|
room.worker.post({ t: "leave", clientId: session.clientId, reason });
|
|
@@ -2102,6 +3970,44 @@ var SupervisorImpl = class {
|
|
|
2102
3970
|
// -------------------------------------------------------------------------
|
|
2103
3971
|
// HELLO
|
|
2104
3972
|
// -------------------------------------------------------------------------
|
|
3973
|
+
/** Assertion HELLOs parked waiting for the platform keys. See the field for why it is bounded. */
|
|
3974
|
+
get identityKeyWaiterCount() {
|
|
3975
|
+
return this.identityKeyWaiters.length;
|
|
3976
|
+
}
|
|
3977
|
+
/** Wakes every parked assertion HELLO. Called when the first key push lands, and on close. */
|
|
3978
|
+
releaseIdentityKeyWaiters() {
|
|
3979
|
+
const waiters = this.identityKeyWaiters;
|
|
3980
|
+
this.identityKeyWaiters = [];
|
|
3981
|
+
for (const wake of waiters) wake();
|
|
3982
|
+
}
|
|
3983
|
+
/**
|
|
3984
|
+
* Waits, bounded, for this tenant to be handed a platform key.
|
|
3985
|
+
*
|
|
3986
|
+
* Three ways out and all three are the same to the caller: the keys arrive (the common case, in
|
|
3987
|
+
* about one loopback round trip), the deadline passes, or the parking list is already full. In
|
|
3988
|
+
* every one of them the caller goes straight back to `verifyAssertion`, which refuses with
|
|
3989
|
+
* `E_ASSERTION_UNVERIFIABLE` if the keys still are not there. Nothing here can turn a refusal
|
|
3990
|
+
* into an acceptance; the only thing it changes is how long a player waits before finding out.
|
|
3991
|
+
*/
|
|
3992
|
+
awaitIdentityKeys() {
|
|
3993
|
+
const waitMs = this.config.identityKeyWaitMs ?? IDENTITY_KEY_WAIT_MS;
|
|
3994
|
+
if (waitMs <= 0) return Promise.resolve();
|
|
3995
|
+
if (this.identityKeyWaiters.length >= MAX_IDENTITY_KEY_WAITERS) return Promise.resolve();
|
|
3996
|
+
return new Promise((resolve2) => {
|
|
3997
|
+
let done = false;
|
|
3998
|
+
const finish = () => {
|
|
3999
|
+
if (done) return;
|
|
4000
|
+
done = true;
|
|
4001
|
+
clearTimeout(timer);
|
|
4002
|
+
const at = this.identityKeyWaiters.indexOf(finish);
|
|
4003
|
+
if (at >= 0) this.identityKeyWaiters.splice(at, 1);
|
|
4004
|
+
resolve2();
|
|
4005
|
+
};
|
|
4006
|
+
const timer = setTimeout(finish, waitMs);
|
|
4007
|
+
timer.unref?.();
|
|
4008
|
+
this.identityKeyWaiters.push(finish);
|
|
4009
|
+
});
|
|
4010
|
+
}
|
|
2105
4011
|
async handleHello(session, payload) {
|
|
2106
4012
|
session.clearHelloTimer();
|
|
2107
4013
|
let hello;
|
|
@@ -2128,7 +4034,16 @@ var SupervisorImpl = class {
|
|
|
2128
4034
|
return;
|
|
2129
4035
|
}
|
|
2130
4036
|
let jwt;
|
|
2131
|
-
if (credential.kind === "
|
|
4037
|
+
if (credential.kind === "assertion") {
|
|
4038
|
+
if (this.identityKeys.length === 0) await this.awaitIdentityKeys();
|
|
4039
|
+
const verdict = verifyAssertion(this.identityKeys, credential.token, this.config.projectId);
|
|
4040
|
+
if (!verdict.ok) {
|
|
4041
|
+
const vars = verdict.code === "E_TOKEN_MALFORMED" ? { reason: verdict.reason } : verdict.code === "E_TOKEN_BAD_ALG" ? { alg: verdict.reason } : {};
|
|
4042
|
+
session.fail(verdict.code, formatError(verdict.code, vars));
|
|
4043
|
+
return;
|
|
4044
|
+
}
|
|
4045
|
+
jwt = { playerId: verdict.playerId };
|
|
4046
|
+
} else if (credential.kind === "jwt") {
|
|
2132
4047
|
const issuers = this.config.jwtIssuers;
|
|
2133
4048
|
if (issuers === void 0 || issuers.length === 0) {
|
|
2134
4049
|
session.fail("E_AUTH", "this project has no JWT secret \u2014 mint one: irtio keys jwt-secret");
|
|
@@ -2142,23 +4057,12 @@ var SupervisorImpl = class {
|
|
|
2142
4057
|
}
|
|
2143
4058
|
jwt = verdict;
|
|
2144
4059
|
}
|
|
2145
|
-
if (this.bundle) {
|
|
2146
|
-
const known = [...this.deployments.values()].some(
|
|
2147
|
-
(d) => d.bundle !== void 0 && bytesEqual(hello.schemaHash8, d.bundle.hash8)
|
|
2148
|
-
);
|
|
2149
|
-
if (!known) {
|
|
2150
|
-
session.fail("E_SCHEMA_MISMATCH", formatError("E_SCHEMA_MISMATCH"));
|
|
2151
|
-
return;
|
|
2152
|
-
}
|
|
2153
|
-
} else if (!isRelayHash8(hello.schemaHash8)) {
|
|
2154
|
-
session.fail("E_SCHEMA_MISMATCH", "this project has no room code deployed (relay tenant)");
|
|
2155
|
-
return;
|
|
2156
|
-
}
|
|
2157
4060
|
session.state = "joining";
|
|
2158
4061
|
session.role = jwt?.role ?? hello.role ?? "";
|
|
2159
4062
|
session.name = hello.name ?? "";
|
|
2160
4063
|
session.playerId = jwt?.playerId;
|
|
2161
4064
|
session.schemaHash8 = hello.schemaHash8;
|
|
4065
|
+
session.schemaSwap = hello.schemaSwap === true;
|
|
2162
4066
|
let resume;
|
|
2163
4067
|
if (hello.resumeToken !== void 0 && hello.resumeToken !== "") {
|
|
2164
4068
|
resume = verifyResume(this.resumeSecret, hello.resumeToken);
|
|
@@ -2184,10 +4088,60 @@ var SupervisorImpl = class {
|
|
|
2184
4088
|
} else {
|
|
2185
4089
|
roomId = newRoomCode((code) => this.registry.has(code));
|
|
2186
4090
|
}
|
|
4091
|
+
const roomType = roomTypeOf(roomId);
|
|
4092
|
+
const typed = this.roomTypes().length > 0;
|
|
4093
|
+
if (typed) {
|
|
4094
|
+
const known = [...this.deployments.values()].some((d) => {
|
|
4095
|
+
const b = d.bundles.get(roomType);
|
|
4096
|
+
return b !== void 0 && bytesEqual(hello.schemaHash8, b.hash8);
|
|
4097
|
+
});
|
|
4098
|
+
if (!known) {
|
|
4099
|
+
if (this.bundleFor(roomType) === void 0) {
|
|
4100
|
+
session.fail(
|
|
4101
|
+
"E_ROOM_NOT_FOUND",
|
|
4102
|
+
`this project has no room type ${JSON.stringify(roomType)}. It defines: ` + this.roomTypes().join(", ")
|
|
4103
|
+
);
|
|
4104
|
+
return;
|
|
4105
|
+
}
|
|
4106
|
+
session.fail(
|
|
4107
|
+
"E_SCHEMA_MISMATCH",
|
|
4108
|
+
`${formatError("E_SCHEMA_MISMATCH")} for room type ${roomType}`
|
|
4109
|
+
);
|
|
4110
|
+
return;
|
|
4111
|
+
}
|
|
4112
|
+
} else if (!isRelayHash8(hello.schemaHash8)) {
|
|
4113
|
+
session.fail("E_SCHEMA_MISMATCH", "this project has no room code deployed (relay tenant)");
|
|
4114
|
+
return;
|
|
4115
|
+
}
|
|
2187
4116
|
if (jwt?.roomId !== void 0 && jwt.roomId !== roomId) {
|
|
2188
4117
|
session.fail("E_TOKEN_WRONG_ROOM", formatError("E_TOKEN_WRONG_ROOM"));
|
|
2189
4118
|
return;
|
|
2190
4119
|
}
|
|
4120
|
+
if (!session.npc && !this.registry.has(roomId)) {
|
|
4121
|
+
const roomCapRefusal = this.caps.refusal("room_hours");
|
|
4122
|
+
if (roomCapRefusal !== void 0) {
|
|
4123
|
+
session.fail("E_USAGE_CAP", roomCapRefusal);
|
|
4124
|
+
return;
|
|
4125
|
+
}
|
|
4126
|
+
const tierRefusal = this.freeAwakeRoomsRefusal();
|
|
4127
|
+
if (tierRefusal !== void 0) {
|
|
4128
|
+
session.fail("E_TIER_LIMIT", tierRefusal);
|
|
4129
|
+
return;
|
|
4130
|
+
}
|
|
4131
|
+
if (this.egressLease.tripped) {
|
|
4132
|
+
session.failWith(
|
|
4133
|
+
"E_EGRESS_WALL",
|
|
4134
|
+
egressWallMessage(this.caps.plan() ?? "free"),
|
|
4135
|
+
CLOSE_EGRESS_WALL
|
|
4136
|
+
);
|
|
4137
|
+
return;
|
|
4138
|
+
}
|
|
4139
|
+
const awakeRefusal = this.maxAwakeRefusal(roomId);
|
|
4140
|
+
if (awakeRefusal !== void 0) {
|
|
4141
|
+
session.fail("E_TYPE_AT_CAPACITY", awakeRefusal);
|
|
4142
|
+
return;
|
|
4143
|
+
}
|
|
4144
|
+
}
|
|
2191
4145
|
const room = await this.ensureRoom(roomId);
|
|
2192
4146
|
if (!room) {
|
|
2193
4147
|
session.fail("E_INTERNAL", formatError("E_INTERNAL"));
|
|
@@ -2202,8 +4156,12 @@ var SupervisorImpl = class {
|
|
|
2202
4156
|
session.roomId = roomId;
|
|
2203
4157
|
let reconnecting = false;
|
|
2204
4158
|
if (resume) {
|
|
2205
|
-
reconnecting = true;
|
|
2206
4159
|
const existing = room.clients.get(resume.clientId);
|
|
4160
|
+
if (!existing && resume.iatMs >= room.createdAt) {
|
|
4161
|
+
session.fail("E_RESUME_EXPIRED", formatError("E_RESUME_EXPIRED"));
|
|
4162
|
+
return;
|
|
4163
|
+
}
|
|
4164
|
+
reconnecting = true;
|
|
2207
4165
|
if (existing && existing !== session) {
|
|
2208
4166
|
existing.clearGraceTimer();
|
|
2209
4167
|
existing.state = "gone";
|
|
@@ -2216,7 +4174,16 @@ var SupervisorImpl = class {
|
|
|
2216
4174
|
session.clientId = resume.clientId;
|
|
2217
4175
|
if (session.role === "") session.role = resume.role;
|
|
2218
4176
|
} else {
|
|
2219
|
-
|
|
4177
|
+
const forced = session.forcedClientId;
|
|
4178
|
+
if (forced !== void 0) {
|
|
4179
|
+
if (this.clientIds.has(forced)) {
|
|
4180
|
+
session.fail("E_INTERNAL", `npc client id ${forced} is already in use`);
|
|
4181
|
+
return;
|
|
4182
|
+
}
|
|
4183
|
+
session.clientId = forced;
|
|
4184
|
+
} else {
|
|
4185
|
+
session.clientId = newClientId((id) => this.clientIds.has(id));
|
|
4186
|
+
}
|
|
2220
4187
|
}
|
|
2221
4188
|
this.clientIds.add(session.clientId);
|
|
2222
4189
|
room.clients.set(session.clientId, session);
|
|
@@ -2232,21 +4199,37 @@ var SupervisorImpl = class {
|
|
|
2232
4199
|
if (inFlight) return inFlight;
|
|
2233
4200
|
const existing = this.registry.get(roomId);
|
|
2234
4201
|
if (existing) {
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
4202
|
+
for (; ; ) {
|
|
4203
|
+
if (existing.state === "hibernated" || existing.state === "hibernating") {
|
|
4204
|
+
await wakeRoom(this, existing);
|
|
4205
|
+
} else {
|
|
4206
|
+
await existing.transition.catch(() => {
|
|
4207
|
+
});
|
|
4208
|
+
}
|
|
4209
|
+
const state = existing.state;
|
|
4210
|
+
if (state !== "hibernated" && state !== "hibernating") break;
|
|
4211
|
+
}
|
|
2238
4212
|
return existing;
|
|
2239
4213
|
}
|
|
2240
4214
|
const create = (async () => {
|
|
2241
|
-
const
|
|
2242
|
-
const
|
|
4215
|
+
const relay = this.deployments.size === 0 || this.roomTypes().length === 0;
|
|
4216
|
+
const bundle = relay ? void 0 : this.bundleFor(roomTypeOf(roomId));
|
|
4217
|
+
if (!relay && bundle === void 0) {
|
|
4218
|
+
this.log(
|
|
4219
|
+
"warn",
|
|
4220
|
+
`no room type ${JSON.stringify(roomTypeOf(roomId))} in this project (join of ${roomId}); this project defines: ${this.roomTypes().join(", ")}`
|
|
4221
|
+
);
|
|
4222
|
+
return void 0;
|
|
4223
|
+
}
|
|
4224
|
+
const config = bundle ? bundle.config : relayConfig(this.limits, this.caps.plan());
|
|
4225
|
+
const room = new RoomRecord(roomId, bundle === void 0, config);
|
|
2243
4226
|
room.version = this.deploymentVersion;
|
|
2244
4227
|
room.pendingRestoreSaveId = this.pendingRestores.get(roomId);
|
|
2245
4228
|
this.pendingRestores.delete(roomId);
|
|
2246
4229
|
this.registry.set(room);
|
|
2247
4230
|
const restored = await this.loadPendingRestore(room);
|
|
2248
|
-
|
|
2249
|
-
|
|
4231
|
+
let found = restored ?? await this.findSnapshot(roomId);
|
|
4232
|
+
let snapshot = found?.bytes;
|
|
2250
4233
|
if (room.relay) {
|
|
2251
4234
|
room.relayRoom = snapshot ? RelayRoom.restore(snapshot) : RelayRoom.create();
|
|
2252
4235
|
room.lastTick = room.relayRoom.tick;
|
|
@@ -2254,6 +4237,15 @@ var SupervisorImpl = class {
|
|
|
2254
4237
|
this.armRelayIdle(room);
|
|
2255
4238
|
return room;
|
|
2256
4239
|
}
|
|
4240
|
+
if (found && found.version === 0 && room.version > 0 && isRelayPresenceSnapshot(snapshot)) {
|
|
4241
|
+
this.roomLog(
|
|
4242
|
+
room,
|
|
4243
|
+
"info",
|
|
4244
|
+
`ignoring a relay presence snapshot at v0 for a v${room.version} coded room; a presence list is not room state, so this room starts cold`
|
|
4245
|
+
);
|
|
4246
|
+
snapshot = void 0;
|
|
4247
|
+
found = void 0;
|
|
4248
|
+
}
|
|
2257
4249
|
if (found && found.version > room.version) {
|
|
2258
4250
|
this.roomLog(
|
|
2259
4251
|
room,
|
|
@@ -2266,7 +4258,7 @@ var SupervisorImpl = class {
|
|
|
2266
4258
|
let migrate;
|
|
2267
4259
|
if (found && found.version !== room.version) {
|
|
2268
4260
|
try {
|
|
2269
|
-
migrate = this.migrationChain(found.version, room.version);
|
|
4261
|
+
migrate = this.migrationChain(found.version, room.version, room.roomType);
|
|
2270
4262
|
} catch (err) {
|
|
2271
4263
|
this.roomLog(room, "error", "cannot migrate this room", err);
|
|
2272
4264
|
this.registry.delete(roomId);
|
|
@@ -2300,9 +4292,10 @@ var SupervisorImpl = class {
|
|
|
2300
4292
|
}
|
|
2301
4293
|
/** Spawns the worker and waits for `ready`. Leaves the room's state to the caller. */
|
|
2302
4294
|
async startWorker(room, snapshot, migrate) {
|
|
2303
|
-
const
|
|
4295
|
+
const type = room.roomType;
|
|
4296
|
+
const bundle = this.deployments.get(room.version)?.bundles.get(type) ?? this.bundleFor(type);
|
|
2304
4297
|
if (!bundle) return false;
|
|
2305
|
-
const worker = new WorkerHost(this.workerEntry, this.
|
|
4298
|
+
const worker = new WorkerHost(this.workerEntry, this.limitsFor(type), {
|
|
2306
4299
|
onMessage: (msg) => this.onWorkerMessage(room, msg),
|
|
2307
4300
|
onDead: (reason) => this.onWorkerDead(room, reason)
|
|
2308
4301
|
});
|
|
@@ -2323,8 +4316,15 @@ var SupervisorImpl = class {
|
|
|
2323
4316
|
roomId: room.id,
|
|
2324
4317
|
bundleUrl: bundle.bundleUrl,
|
|
2325
4318
|
...this.config.publicUrl !== void 0 ? { publicUrl: this.config.publicUrl } : {},
|
|
2326
|
-
|
|
2327
|
-
|
|
4319
|
+
// A copy, because `post` transfers the buffer and the caller may need its bytes again:
|
|
4320
|
+
// `migrateRoom` restarts the old version from the same snapshot when the chain throws,
|
|
4321
|
+
// and posting the detached buffer a second time killed that worker with a
|
|
4322
|
+
// DataCloneError. The crash restart then revived the room from the store, which is how
|
|
4323
|
+
// the containment test passed while an orphaned ready waiter timed out 30 s later.
|
|
4324
|
+
...snapshot ? { snapshot: new Uint8Array(snapshot) } : {},
|
|
4325
|
+
...migrate ? { migrate } : {},
|
|
4326
|
+
// D65: `irtio dev --profile` only.
|
|
4327
|
+
...this.config.profile === true ? { profile: true } : {}
|
|
2328
4328
|
});
|
|
2329
4329
|
});
|
|
2330
4330
|
if (!outcome || outcome.t !== "ready") {
|
|
@@ -2338,6 +4338,7 @@ var SupervisorImpl = class {
|
|
|
2338
4338
|
return false;
|
|
2339
4339
|
}
|
|
2340
4340
|
room.config = outcome.config;
|
|
4341
|
+
room.backfillOpen = true;
|
|
2341
4342
|
if (outcome.migrated) {
|
|
2342
4343
|
const m = outcome.migrated;
|
|
2343
4344
|
this.roomLog(
|
|
@@ -2382,6 +4383,8 @@ var SupervisorImpl = class {
|
|
|
2382
4383
|
const saveId = await this.saveRoom(room);
|
|
2383
4384
|
return { ok: true, value: saveId };
|
|
2384
4385
|
}
|
|
4386
|
+
case "busSend":
|
|
4387
|
+
return this.busSend(room, call.roomId, call.payload);
|
|
2385
4388
|
case "kvGet": {
|
|
2386
4389
|
const value = await this.requireKv().get(call.playerId, call.key);
|
|
2387
4390
|
return value === void 0 ? { ok: true } : { ok: true, value };
|
|
@@ -2392,8 +4395,67 @@ var SupervisorImpl = class {
|
|
|
2392
4395
|
case "kvDelete":
|
|
2393
4396
|
await this.requireKv().delete(call.playerId, call.key);
|
|
2394
4397
|
return { ok: true };
|
|
4398
|
+
case "lbSubmit": {
|
|
4399
|
+
if (!this.roomHoldsPlayer(room, call.playerId)) {
|
|
4400
|
+
return {
|
|
4401
|
+
ok: false,
|
|
4402
|
+
code: LEADERBOARD_ERRORS.notInRoom,
|
|
4403
|
+
message: `${call.playerId} is not a client of room ${room.id}; leaderboard submits are only accepted for players currently in the room`
|
|
4404
|
+
};
|
|
4405
|
+
}
|
|
4406
|
+
await this.requireLeaderboard().submit(call.board, call.playerId, call.score);
|
|
4407
|
+
return { ok: true };
|
|
4408
|
+
}
|
|
4409
|
+
case "ratingReport": {
|
|
4410
|
+
const problem = ratingQueueProblem(call.queue) ?? ratingResultsProblem(call.results);
|
|
4411
|
+
if (problem) return { ok: false, code: problem.code, message: problem.message };
|
|
4412
|
+
for (const r of call.results) {
|
|
4413
|
+
if (!room.hasHeldPlayer(r.playerId)) {
|
|
4414
|
+
return {
|
|
4415
|
+
ok: false,
|
|
4416
|
+
code: RATING_ERRORS.notInRoom,
|
|
4417
|
+
message: `${r.playerId} has never been a client of room ${room.id}; rating reports are only accepted for players this room has actually held`
|
|
4418
|
+
};
|
|
4419
|
+
}
|
|
4420
|
+
}
|
|
4421
|
+
await this.requireRatings().report(call.queue, call.results);
|
|
4422
|
+
return { ok: true };
|
|
4423
|
+
}
|
|
4424
|
+
case "ratingSet": {
|
|
4425
|
+
const problem = ratingQueueProblem(call.queue) ?? ratingPlayerProblem(call.playerId) ?? ratingValueProblem({
|
|
4426
|
+
rating: call.rating,
|
|
4427
|
+
...call.deviation !== void 0 ? { deviation: call.deviation } : {}
|
|
4428
|
+
});
|
|
4429
|
+
if (problem) return { ok: false, code: problem.code, message: problem.message };
|
|
4430
|
+
if (!room.hasHeldPlayer(call.playerId)) {
|
|
4431
|
+
return {
|
|
4432
|
+
ok: false,
|
|
4433
|
+
code: RATING_ERRORS.notInRoom,
|
|
4434
|
+
message: `${call.playerId} has never been a client of room ${room.id}; a rating may only be set for a player this room has actually held`
|
|
4435
|
+
};
|
|
4436
|
+
}
|
|
4437
|
+
await this.requireRatings().set(call.queue, call.playerId, {
|
|
4438
|
+
rating: call.rating,
|
|
4439
|
+
...call.deviation !== void 0 ? { deviation: call.deviation } : {}
|
|
4440
|
+
});
|
|
4441
|
+
return { ok: true };
|
|
4442
|
+
}
|
|
2395
4443
|
}
|
|
2396
4444
|
}
|
|
4445
|
+
/**
|
|
4446
|
+
* Is `playerId` one of this room's current clients?
|
|
4447
|
+
*
|
|
4448
|
+
* The identity a session answers to is its verified `playerId` when it has one (a JWT or a
|
|
4449
|
+
* platform assertion) and its client id otherwise — the same `ctx.playerId` ladder the room
|
|
4450
|
+
* itself sees, so a room submitting under the id it was handed always passes, and a room
|
|
4451
|
+
* submitting under an id it invented never does.
|
|
4452
|
+
*/
|
|
4453
|
+
roomHoldsPlayer(room, playerId) {
|
|
4454
|
+
for (const session of room.clients.values()) {
|
|
4455
|
+
if ((session.playerId ?? session.clientId) === playerId) return true;
|
|
4456
|
+
}
|
|
4457
|
+
return false;
|
|
4458
|
+
}
|
|
2397
4459
|
requireKv() {
|
|
2398
4460
|
const kv = this.config.kv;
|
|
2399
4461
|
if (!kv) {
|
|
@@ -2403,6 +4465,24 @@ var SupervisorImpl = class {
|
|
|
2403
4465
|
}
|
|
2404
4466
|
return kv;
|
|
2405
4467
|
}
|
|
4468
|
+
requireLeaderboard() {
|
|
4469
|
+
const lb = this.config.leaderboard;
|
|
4470
|
+
if (!lb) {
|
|
4471
|
+
throw new LeaderboardUnavailable(
|
|
4472
|
+
"leaderboards are not configured for this tenant (no control plane behind it)"
|
|
4473
|
+
);
|
|
4474
|
+
}
|
|
4475
|
+
return lb;
|
|
4476
|
+
}
|
|
4477
|
+
requireRatings() {
|
|
4478
|
+
const ratings = this.config.ratings;
|
|
4479
|
+
if (!ratings) {
|
|
4480
|
+
throw new RatingUnavailable(
|
|
4481
|
+
"skill ratings are not configured for this tenant (no control plane behind it)"
|
|
4482
|
+
);
|
|
4483
|
+
}
|
|
4484
|
+
return ratings;
|
|
4485
|
+
}
|
|
2406
4486
|
/**
|
|
2407
4487
|
* D24: one save generation. Serializes exactly the way hibernation does — same
|
|
2408
4488
|
* `worker.serialize()`, same bytes, same `putSnapshotWithRetry` durability — writes it under
|
|
@@ -2534,54 +4614,382 @@ var SupervisorImpl = class {
|
|
|
2534
4614
|
}
|
|
2535
4615
|
}
|
|
2536
4616
|
/**
|
|
2537
|
-
* D26: re-arms this room's alarms from the sidecar and starts persisting changes to it.
|
|
2538
|
-
*
|
|
2539
|
-
* Anything already due fires immediately — an alarm is "at or after", never before, and a
|
|
2540
|
-
* tenant that was stopped for an hour owes the room an hour-late alarm rather than nothing.
|
|
4617
|
+
* D26: re-arms this room's alarms from the sidecar and starts persisting changes to it.
|
|
4618
|
+
*
|
|
4619
|
+
* Anything already due fires immediately — an alarm is "at or after", never before, and a
|
|
4620
|
+
* tenant that was stopped for an hour owes the room an hour-late alarm rather than nothing.
|
|
4621
|
+
*/
|
|
4622
|
+
async loadAlarms(room) {
|
|
4623
|
+
if (room.relay) return;
|
|
4624
|
+
let persisted = [];
|
|
4625
|
+
try {
|
|
4626
|
+
const bytes = await this.store.get(alarmsKey(this.storeKey(room)));
|
|
4627
|
+
if (bytes) persisted = decodeAlarms(bytes);
|
|
4628
|
+
} catch (err) {
|
|
4629
|
+
this.roomLog(room, "warn", "could not read this room's alarms; starting with none", err);
|
|
4630
|
+
}
|
|
4631
|
+
room.alarms.onChange = () => this.persistAlarms(room);
|
|
4632
|
+
room.alarms.attach((names) => this.deliverAlarms(room, names));
|
|
4633
|
+
if (persisted.length > 0) {
|
|
4634
|
+
room.alarms.load(persisted);
|
|
4635
|
+
const overdue = room.alarms.fireDue();
|
|
4636
|
+
if (overdue.length > 0) {
|
|
4637
|
+
this.roomLog(
|
|
4638
|
+
room,
|
|
4639
|
+
"info",
|
|
4640
|
+
`firing ${overdue.length} overdue alarm(s): ${overdue.join(", ")}`
|
|
4641
|
+
);
|
|
4642
|
+
}
|
|
4643
|
+
}
|
|
4644
|
+
}
|
|
4645
|
+
/**
|
|
4646
|
+
* Delivers due alarms into the room, **waking it first if it is asleep**. This is the whole
|
|
4647
|
+
* hibernated case: the timer kept counting because it lives on the record rather than in the
|
|
4648
|
+
* worker, and the wake is the same one a joining client would have triggered.
|
|
4649
|
+
*
|
|
4650
|
+
* The loop is not defensive padding — it is the fix for a real race that cost an hour to find.
|
|
4651
|
+
* `fireDue` removes an alarm before delivering it, so between the state check and the `await`
|
|
4652
|
+
* the room can slip into hibernation (`hibernateRoom` flips the state synchronously). A single
|
|
4653
|
+
* check-then-await therefore saw `running`, woke up to `hibernated`, and dropped an alarm that
|
|
4654
|
+
* no longer existed anywhere — a round timer that simply never fired, with a warning nobody was
|
|
4655
|
+
* watching. Re-checking after every transition covers the slip, and anything still undeliverable
|
|
4656
|
+
* is **re-armed** rather than discarded: a durable alarm that this process could not deliver is
|
|
4657
|
+
* the next placement's problem, not a lost one.
|
|
4658
|
+
*/
|
|
4659
|
+
// -------------------------------------------------------------------------
|
|
4660
|
+
// D59: the bus
|
|
4661
|
+
// -------------------------------------------------------------------------
|
|
4662
|
+
/**
|
|
4663
|
+
* Takes one token from a room's bus budget, or returns the refusal.
|
|
4664
|
+
*
|
|
4665
|
+
* Both verbs draw on the same bucket, on purpose: publish costs a fan-out and send costs a store
|
|
4666
|
+
* write, and a limiter that metered only one of them would be a limiter a room could route
|
|
4667
|
+
* around by choosing the other. This is also the whole of the per-room rate-limit machinery D59
|
|
4668
|
+
* asks for. Every limiter in the supervisor before it was scoped to a connection, an IP or a
|
|
4669
|
+
* project key, and none of them metered what a room asked the host to do.
|
|
4670
|
+
*/
|
|
4671
|
+
/**
|
|
4672
|
+
* D59: forget everything a room was subscribed to. Called when a room is gone for good.
|
|
4673
|
+
*
|
|
4674
|
+
* Note what this is NOT called for: hibernation. A hibernated room keeping a subscription entry
|
|
4675
|
+
* is harmless, because `busPublish` posts only to rooms that are `running` with a live worker,
|
|
4676
|
+
* and that liveness check — not this bookkeeping — is what makes "publish reaches awake
|
|
4677
|
+
* subscribers only" true. Doing it here as well would mean chasing eight separate places where a
|
|
4678
|
+
* worker goes away, and getting one wrong would be a silent correctness bug rather than a leak.
|
|
4679
|
+
*/
|
|
4680
|
+
busRoomClosed(roomId) {
|
|
4681
|
+
this.bus.unsubscribeAll(roomId);
|
|
4682
|
+
}
|
|
4683
|
+
/** D59: the channels a room currently holds, for `/admin/rooms` and for tests. */
|
|
4684
|
+
busSubscriptionsOf(roomId) {
|
|
4685
|
+
return this.bus.subscriptionsOf(roomId);
|
|
4686
|
+
}
|
|
4687
|
+
busBudget(room, verb) {
|
|
4688
|
+
if (room.busBucket.take()) return void 0;
|
|
4689
|
+
return {
|
|
4690
|
+
code: BUS_ERRORS.rateLimited,
|
|
4691
|
+
message: `room.bus.${verb}: this room is over its bus budget of ${BUS_LIMITS.opsPerSecond} operations per second`
|
|
4692
|
+
};
|
|
4693
|
+
}
|
|
4694
|
+
/**
|
|
4695
|
+
* `room.bus.publish`: fan out to awake subscribers, and nothing else.
|
|
4696
|
+
*
|
|
4697
|
+
* Every refusal here is a log line rather than a value, because the verb is fire-and-forget on
|
|
4698
|
+
* the runtime side and there is nothing to answer. The `from` handed to each subscriber is
|
|
4699
|
+
* `room.id`, the record's own id and not anything the worker supplied, so a room cannot publish
|
|
4700
|
+
* under another room's name.
|
|
4701
|
+
*/
|
|
4702
|
+
busPublish(room, channel, payload) {
|
|
4703
|
+
const problem = busChannelProblem(channel) ?? busPayloadProblem(payload) ?? this.busBudget(room, "publish");
|
|
4704
|
+
if (problem) {
|
|
4705
|
+
this.roomLog(room, "warn", `bus.publish: ${problem.code}: ${problem.message}`);
|
|
4706
|
+
return;
|
|
4707
|
+
}
|
|
4708
|
+
for (const id of this.bus.subscribers(channel, room.id)) {
|
|
4709
|
+
const target = this.registry.get(id);
|
|
4710
|
+
if (!target || target.state !== "running" || !target.worker) continue;
|
|
4711
|
+
target.worker.post({ t: "busEvent", channel, from: room.id, payload });
|
|
4712
|
+
}
|
|
4713
|
+
}
|
|
4714
|
+
/**
|
|
4715
|
+
* `room.bus.send`: park a durable, at-least-once message in the target's mailbox.
|
|
4716
|
+
*
|
|
4717
|
+
* The mailbox is the alarm set. Arming for `Date.now()` means `deliverAlarms` picks it up on the
|
|
4718
|
+
* next turn and, the part that makes the feature work at all, wakes the room first if it is
|
|
4719
|
+
* hibernated, because the alarm timer lives on the `RoomRecord` and outlives the worker.
|
|
4720
|
+
*
|
|
4721
|
+
* Refusals are values rather than log lines, unlike publish, because each one is a different
|
|
4722
|
+
* thing for the sender to do: shrink the payload, fix the roomId, back off, or slow down.
|
|
4723
|
+
*/
|
|
4724
|
+
async busSend(room, targetId, payload) {
|
|
4725
|
+
const refuse = (p) => ({
|
|
4726
|
+
ok: false,
|
|
4727
|
+
code: p.code,
|
|
4728
|
+
message: p.message
|
|
4729
|
+
});
|
|
4730
|
+
const budget = busPayloadProblem(payload) ?? this.busBudget(room, "send");
|
|
4731
|
+
if (budget) return refuse(budget);
|
|
4732
|
+
const normalized = normalizeRoomId(targetId);
|
|
4733
|
+
const target = normalized === void 0 ? void 0 : this.registry.get(normalized);
|
|
4734
|
+
if (target && target.state !== "closed") {
|
|
4735
|
+
return this.parkLocally(target, room.id, payload, targetId);
|
|
4736
|
+
}
|
|
4737
|
+
if (!this.isSharded() || normalized === void 0) {
|
|
4738
|
+
return refuse({
|
|
4739
|
+
code: BUS_ERRORS.noSuchRoom,
|
|
4740
|
+
message: `no room ${JSON.stringify(targetId)} in this project`
|
|
4741
|
+
});
|
|
4742
|
+
}
|
|
4743
|
+
if (room.alarms.mailboxDepth >= BUS_LIMITS.mailboxDepth) {
|
|
4744
|
+
return refuse({
|
|
4745
|
+
code: BUS_ERRORS.mailboxFull,
|
|
4746
|
+
message: `room ${JSON.stringify(room.id)} has ${BUS_LIMITS.mailboxDepth} undelivered bus messages in its mailbox and outbox; it is not keeping up`
|
|
4747
|
+
});
|
|
4748
|
+
}
|
|
4749
|
+
const name = mintOutboxName(this.mailboxCounter++);
|
|
4750
|
+
const entry = {
|
|
4751
|
+
to: normalized,
|
|
4752
|
+
from: room.id,
|
|
4753
|
+
payload,
|
|
4754
|
+
id: name,
|
|
4755
|
+
firstAt: this.busNow(),
|
|
4756
|
+
attempts: 0
|
|
4757
|
+
};
|
|
4758
|
+
room.alarms.set(name, this.busNow() + this.outboxDelay(0), encodeOutboxEntry(entry));
|
|
4759
|
+
const outcome = await this.forwardOutbox(room, name, entry);
|
|
4760
|
+
switch (outcome) {
|
|
4761
|
+
case "delivered":
|
|
4762
|
+
return { ok: true };
|
|
4763
|
+
case "no-such-room":
|
|
4764
|
+
return refuse({
|
|
4765
|
+
code: BUS_ERRORS.noSuchRoom,
|
|
4766
|
+
message: `no room ${JSON.stringify(targetId)} in this project, on any shard`
|
|
4767
|
+
});
|
|
4768
|
+
case "mailbox-full":
|
|
4769
|
+
room.alarms.set(name, void 0);
|
|
4770
|
+
return refuse({
|
|
4771
|
+
code: BUS_ERRORS.mailboxFull,
|
|
4772
|
+
message: `room ${JSON.stringify(targetId)} has ${BUS_LIMITS.mailboxDepth} undelivered bus messages; it is not keeping up`
|
|
4773
|
+
});
|
|
4774
|
+
default:
|
|
4775
|
+
return { ok: true };
|
|
4776
|
+
}
|
|
4777
|
+
}
|
|
4778
|
+
/** Today's local park-and-wake, factored out so the cross-shard target side can share it. */
|
|
4779
|
+
parkLocally(target, from, payload, targetId) {
|
|
4780
|
+
if (target.relay) {
|
|
4781
|
+
return {
|
|
4782
|
+
ok: false,
|
|
4783
|
+
code: BUS_ERRORS.noSuchRoom,
|
|
4784
|
+
message: `room ${JSON.stringify(targetId)} is a relay room and runs no bus handlers`
|
|
4785
|
+
};
|
|
4786
|
+
}
|
|
4787
|
+
if (target.alarms.mailboxDepth >= BUS_LIMITS.mailboxDepth) {
|
|
4788
|
+
return {
|
|
4789
|
+
ok: false,
|
|
4790
|
+
code: BUS_ERRORS.mailboxFull,
|
|
4791
|
+
message: `room ${JSON.stringify(targetId)} has ${BUS_LIMITS.mailboxDepth} undelivered bus messages; it is not keeping up`
|
|
4792
|
+
};
|
|
4793
|
+
}
|
|
4794
|
+
const entry = { from, payload, attempts: 0 };
|
|
4795
|
+
target.alarms.set(
|
|
4796
|
+
mintMailboxName(this.mailboxCounter++),
|
|
4797
|
+
Date.now(),
|
|
4798
|
+
encodeMailboxEntry(entry)
|
|
4799
|
+
);
|
|
4800
|
+
return { ok: true };
|
|
4801
|
+
}
|
|
4802
|
+
/** D67-e: the clock the outbox stamps and ages by; injected so the hour is tested, not waited. */
|
|
4803
|
+
busNow() {
|
|
4804
|
+
return this.config.busClock?.() ?? Date.now();
|
|
4805
|
+
}
|
|
4806
|
+
/** D67-e: the retry schedule, injectable for the same reason the clock is. */
|
|
4807
|
+
outboxDelay(attempts) {
|
|
4808
|
+
return this.config.busRetryDelayMs?.(attempts) ?? outboxRetryDelayMs(attempts);
|
|
4809
|
+
}
|
|
4810
|
+
/**
|
|
4811
|
+
* Whether the store holds anything for `roomId`: its live key, an `@v<N>` key, or anything
|
|
4812
|
+
* under `<key>/` (the alarm sidecar, saves). The listing is by prefix, so `abc` would match
|
|
4813
|
+
* `abcd`'s keys without the filter, which is the same filter control's directory applies.
|
|
4814
|
+
*/
|
|
4815
|
+
async roomExistsInStore(roomId) {
|
|
4816
|
+
const base = `${this.config.projectId}/${roomId}`;
|
|
4817
|
+
try {
|
|
4818
|
+
const keys = await this.store.list(base);
|
|
4819
|
+
return keys.some((k) => k === base || k.startsWith(`${base}@v`) || k.startsWith(`${base}/`));
|
|
4820
|
+
} catch {
|
|
4821
|
+
return false;
|
|
4822
|
+
}
|
|
4823
|
+
}
|
|
4824
|
+
/**
|
|
4825
|
+
* One forward of an outbox entry through the control plane. Answers what the sender should
|
|
4826
|
+
* conclude: `delivered` and `no-such-room` drop the entry, `mailbox-full` is the target's
|
|
4827
|
+
* answer, and `retry` (the target's shard is starting, the hop failed) leaves the entry armed.
|
|
4828
|
+
*/
|
|
4829
|
+
async forwardOutbox(room, name, entry) {
|
|
4830
|
+
const client = this.config.shardClient;
|
|
4831
|
+
if (!client) {
|
|
4832
|
+
this.roomLog(
|
|
4833
|
+
room,
|
|
4834
|
+
"warn",
|
|
4835
|
+
`bus.send to ${entry.to}: no shard client on this tenant; will retry`
|
|
4836
|
+
);
|
|
4837
|
+
return "retry";
|
|
4838
|
+
}
|
|
4839
|
+
try {
|
|
4840
|
+
const result = await client.send({
|
|
4841
|
+
shard: this.config.shard ?? 0,
|
|
4842
|
+
to: entry.to,
|
|
4843
|
+
from: entry.from,
|
|
4844
|
+
payload: entry.payload,
|
|
4845
|
+
id: entry.id
|
|
4846
|
+
});
|
|
4847
|
+
if (result.ok) {
|
|
4848
|
+
room.alarms.set(name, void 0);
|
|
4849
|
+
return "delivered";
|
|
4850
|
+
}
|
|
4851
|
+
if (result.code === BUS_ERRORS.noSuchRoom) {
|
|
4852
|
+
room.alarms.set(name, void 0);
|
|
4853
|
+
return "no-such-room";
|
|
4854
|
+
}
|
|
4855
|
+
if (result.code === BUS_ERRORS.mailboxFull) return "mailbox-full";
|
|
4856
|
+
return "retry";
|
|
4857
|
+
} catch (err) {
|
|
4858
|
+
this.roomLog(
|
|
4859
|
+
room,
|
|
4860
|
+
"warn",
|
|
4861
|
+
`bus.send to ${entry.to} could not be forwarded (attempt ${entry.attempts + 1}); will retry`,
|
|
4862
|
+
err
|
|
4863
|
+
);
|
|
4864
|
+
return "retry";
|
|
4865
|
+
}
|
|
4866
|
+
}
|
|
4867
|
+
/**
|
|
4868
|
+
* An outbox entry's alarm came due: forward it again, or dead-letter it. Runs on the record,
|
|
4869
|
+
* whatever the room's state, and never wakes the room: a retry is a host call, not room code,
|
|
4870
|
+
* so a hibernated sender stays asleep and is not billed for its own outbox.
|
|
2541
4871
|
*/
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
if (bytes) persisted = decodeAlarms(bytes);
|
|
2548
|
-
} catch (err) {
|
|
2549
|
-
this.roomLog(room, "warn", "could not read this room's alarms; starting with none", err);
|
|
4872
|
+
retryOutbox(room, name, raw) {
|
|
4873
|
+
const entry = decodeOutboxEntry(raw);
|
|
4874
|
+
if (!entry) {
|
|
4875
|
+
this.roomLog(room, "warn", `dropping an unreadable bus outbox entry (${name})`);
|
|
4876
|
+
return;
|
|
2550
4877
|
}
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
4878
|
+
const now = this.busNow();
|
|
4879
|
+
if (now - entry.firstAt >= BUS_OUTBOX2.ttlMs) {
|
|
4880
|
+
this.roomLog(
|
|
4881
|
+
room,
|
|
4882
|
+
"warn",
|
|
4883
|
+
`a bus message to ${JSON.stringify(entry.to)} could not be delivered across shards in ${entry.attempts} attempt(s) over an hour and has been dropped (${entry.id})`
|
|
4884
|
+
);
|
|
4885
|
+
return;
|
|
4886
|
+
}
|
|
4887
|
+
const next = { ...entry, attempts: entry.attempts + 1 };
|
|
4888
|
+
room.alarms.set(name, now + this.outboxDelay(next.attempts), encodeOutboxEntry(next));
|
|
4889
|
+
void this.forwardOutbox(room, name, next).then((outcome) => {
|
|
4890
|
+
if (outcome === "mailbox-full") {
|
|
2557
4891
|
this.roomLog(
|
|
2558
4892
|
room,
|
|
2559
4893
|
"info",
|
|
2560
|
-
`
|
|
4894
|
+
`bus.send to ${entry.to}: the target's mailbox is full; will retry`
|
|
2561
4895
|
);
|
|
2562
4896
|
}
|
|
4897
|
+
});
|
|
4898
|
+
}
|
|
4899
|
+
/**
|
|
4900
|
+
* D67-e, the target side: `POST /admin/bus/deliver` from the host agent, carrying a message
|
|
4901
|
+
* the control plane forwarded from another shard. Load-if-exists, then today's local park; never
|
|
4902
|
+
* `ensureRoom` from nothing, because a room the directory did not place here is not this
|
|
4903
|
+
* shard's to create. A message whose `id` this room has seen before is delivered again (a lost
|
|
4904
|
+
* acknowledgement, at-least-once) and the duplicate is logged by name.
|
|
4905
|
+
*/
|
|
4906
|
+
async deliverForwarded(message) {
|
|
4907
|
+
const normalized = normalizeRoomId(message.to);
|
|
4908
|
+
if (normalized === void 0) {
|
|
4909
|
+
return {
|
|
4910
|
+
ok: false,
|
|
4911
|
+
code: BUS_ERRORS.noSuchRoom,
|
|
4912
|
+
message: `no room ${JSON.stringify(message.to)}`
|
|
4913
|
+
};
|
|
4914
|
+
}
|
|
4915
|
+
let target = this.registry.get(normalized);
|
|
4916
|
+
if ((!target || target.state === "closed") && await this.roomExistsInStore(normalized)) {
|
|
4917
|
+
target = await this.ensureRoom(normalized);
|
|
4918
|
+
}
|
|
4919
|
+
if (!target || target.state === "closed") {
|
|
4920
|
+
return {
|
|
4921
|
+
ok: false,
|
|
4922
|
+
code: BUS_ERRORS.noSuchRoom,
|
|
4923
|
+
message: `no room ${JSON.stringify(message.to)} on this shard`
|
|
4924
|
+
};
|
|
4925
|
+
}
|
|
4926
|
+
if (target.busSeen.has(message.id)) {
|
|
4927
|
+
this.roomLog(
|
|
4928
|
+
target,
|
|
4929
|
+
"warn",
|
|
4930
|
+
`duplicate bus delivery ${message.id} from ${message.from}; delivering again`
|
|
4931
|
+
);
|
|
4932
|
+
} else {
|
|
4933
|
+
target.busSeen.add(message.id);
|
|
4934
|
+
if (target.busSeen.size > BUS_SEEN_MAX) {
|
|
4935
|
+
const oldest = target.busSeen.values().next().value;
|
|
4936
|
+
if (oldest !== void 0) target.busSeen.delete(oldest);
|
|
4937
|
+
}
|
|
2563
4938
|
}
|
|
4939
|
+
return this.parkLocally(target, message.from, message.payload, message.to);
|
|
2564
4940
|
}
|
|
2565
4941
|
/**
|
|
2566
|
-
* Delivers
|
|
2567
|
-
* hibernated case: the timer kept counting because it lives on the record rather than in the
|
|
2568
|
-
* worker, and the wake is the same one a joining client would have triggered.
|
|
4942
|
+
* Delivers one mailbox entry, or bounds and drops it.
|
|
2569
4943
|
*
|
|
2570
|
-
* The
|
|
2571
|
-
*
|
|
2572
|
-
*
|
|
2573
|
-
* check-then-await therefore saw `running`, woke up to `hibernated`, and dropped an alarm that
|
|
2574
|
-
* no longer existed anywhere — a round timer that simply never fired, with a warning nobody was
|
|
2575
|
-
* watching. Re-checking after every transition covers the slip, and anything still undeliverable
|
|
2576
|
-
* is **re-armed** rather than discarded: a durable alarm that this process could not deliver is
|
|
2577
|
-
* the next placement's problem, not a lost one.
|
|
4944
|
+
* The attempt counter is incremented *before* delivery and persisted with the entry, so a
|
|
4945
|
+
* message whose handler kills the room still counts the attempt. Counting after a successful
|
|
4946
|
+
* delivery would count nothing at all in exactly the case the bound exists for.
|
|
2578
4947
|
*/
|
|
2579
|
-
|
|
4948
|
+
deliverMailbox(room, name, raw) {
|
|
4949
|
+
const entry = decodeMailboxEntry(raw);
|
|
4950
|
+
if (!entry) {
|
|
4951
|
+
this.roomLog(room, "warn", `dropping an unreadable bus mailbox entry (${name})`);
|
|
4952
|
+
return;
|
|
4953
|
+
}
|
|
4954
|
+
const attempts = entry.attempts + 1;
|
|
4955
|
+
if (attempts > BUS_LIMITS.maxDeliveryAttempts) {
|
|
4956
|
+
this.roomLog(
|
|
4957
|
+
room,
|
|
4958
|
+
"error",
|
|
4959
|
+
`a bus message from ${JSON.stringify(entry.from)} could not be delivered in ${BUS_LIMITS.maxDeliveryAttempts} attempts and has been dropped`
|
|
4960
|
+
);
|
|
4961
|
+
return;
|
|
4962
|
+
}
|
|
4963
|
+
if (room.state === "running" && room.worker) {
|
|
4964
|
+
room.alarms.set(
|
|
4965
|
+
name,
|
|
4966
|
+
Date.now() + BUS_LIMITS.redeliveryDelayMs,
|
|
4967
|
+
encodeMailboxEntry({ ...entry, attempts })
|
|
4968
|
+
);
|
|
4969
|
+
room.worker.post({ t: "busMessage", name, from: entry.from, payload: entry.payload });
|
|
4970
|
+
return;
|
|
4971
|
+
}
|
|
4972
|
+
room.alarms.set(name, Date.now(), encodeMailboxEntry({ ...entry, attempts }));
|
|
4973
|
+
}
|
|
4974
|
+
deliverAlarms(room, allNames) {
|
|
4975
|
+
const names = [];
|
|
4976
|
+
for (const name of allNames) {
|
|
4977
|
+
if (isOutboxAlarm(name)) this.retryOutbox(room, name, room.alarms.payloadOf(name));
|
|
4978
|
+
else names.push(name);
|
|
4979
|
+
}
|
|
4980
|
+
if (names.length === 0) return;
|
|
4981
|
+
const mailboxPayloads = /* @__PURE__ */ new Map();
|
|
4982
|
+
for (const name of names) {
|
|
4983
|
+
if (isMailboxAlarm(name)) mailboxPayloads.set(name, room.alarms.payloadOf(name));
|
|
4984
|
+
}
|
|
2580
4985
|
void (async () => {
|
|
2581
4986
|
for (let attempt = 0; attempt < 4; attempt++) {
|
|
2582
4987
|
if (room.state === "closed") return;
|
|
2583
4988
|
if (room.state === "running" && room.worker) {
|
|
2584
|
-
for (const name of names)
|
|
4989
|
+
for (const name of names) {
|
|
4990
|
+
if (isMailboxAlarm(name)) this.deliverMailbox(room, name, mailboxPayloads.get(name));
|
|
4991
|
+
else room.worker.post({ t: "alarm", name });
|
|
4992
|
+
}
|
|
2585
4993
|
return;
|
|
2586
4994
|
}
|
|
2587
4995
|
if (room.state === "hibernated") {
|
|
@@ -2597,7 +5005,10 @@ var SupervisorImpl = class {
|
|
|
2597
5005
|
`alarm(s) ${names.join(", ")} came due while the room was ${room.state}; re-armed for the next start rather than dropped`
|
|
2598
5006
|
);
|
|
2599
5007
|
const now = Date.now();
|
|
2600
|
-
for (const name of names)
|
|
5008
|
+
for (const name of names) {
|
|
5009
|
+
if (isMailboxAlarm(name)) this.deliverMailbox(room, name, mailboxPayloads.get(name));
|
|
5010
|
+
else room.alarms.set(name, now);
|
|
5011
|
+
}
|
|
2601
5012
|
})();
|
|
2602
5013
|
}
|
|
2603
5014
|
/**
|
|
@@ -2672,9 +5083,11 @@ var SupervisorImpl = class {
|
|
|
2672
5083
|
closeRoom(room, code, message) {
|
|
2673
5084
|
if (room.state === "closed") return;
|
|
2674
5085
|
room.state = "closed";
|
|
5086
|
+
this.npcs.despawnRoom(room.id);
|
|
2675
5087
|
this.clearRoomTimer(room);
|
|
2676
5088
|
room.alarms.detach();
|
|
2677
5089
|
room.alarms.onChange = void 0;
|
|
5090
|
+
this.busRoomClosed(room.id);
|
|
2678
5091
|
for (const session of [...room.clients.values()]) {
|
|
2679
5092
|
session.fatal = true;
|
|
2680
5093
|
session.sendError(code, message, true);
|
|
@@ -2699,22 +5112,45 @@ var SupervisorImpl = class {
|
|
|
2699
5112
|
async joinBundle(room, session, reconnecting) {
|
|
2700
5113
|
const outcome = await this.requestJoin(room, session, reconnecting);
|
|
2701
5114
|
if (!outcome) {
|
|
5115
|
+
session.pendingOut.length = 0;
|
|
2702
5116
|
session.fail("E_INTERNAL", "the room did not answer the join");
|
|
2703
5117
|
return;
|
|
2704
5118
|
}
|
|
2705
5119
|
if (outcome.t === "joinRejected") {
|
|
5120
|
+
session.pendingOut.length = 0;
|
|
2706
5121
|
room.clients.delete(session.clientId);
|
|
2707
5122
|
this.clientIds.delete(session.clientId);
|
|
2708
5123
|
session.fail(outcome.code, outcome.reason ?? formatError(outcome.code, { roomId: room.id }));
|
|
2709
5124
|
return;
|
|
2710
5125
|
}
|
|
2711
|
-
if (!session.open)
|
|
5126
|
+
if (!session.open) {
|
|
5127
|
+
session.pendingOut.length = 0;
|
|
5128
|
+
session.state = "gone";
|
|
5129
|
+
if (room.clients.get(session.clientId) === session) room.clients.delete(session.clientId);
|
|
5130
|
+
this.clientIds.delete(session.clientId);
|
|
5131
|
+
const reason = session.fatal ? "kicked" : "timeout";
|
|
5132
|
+
if (room.state === "running" && room.worker) {
|
|
5133
|
+
room.worker.post({ t: "leave", clientId: session.clientId, reason });
|
|
5134
|
+
} else if (room.state !== "closed") {
|
|
5135
|
+
room.pendingLeaves.add(session.clientId);
|
|
5136
|
+
}
|
|
5137
|
+
return;
|
|
5138
|
+
}
|
|
2712
5139
|
session.role = outcome.role;
|
|
2713
5140
|
session.state = "joined";
|
|
5141
|
+
room.notePlayerSeen(session.playerId ?? session.clientId);
|
|
2714
5142
|
if (outcome.tick > room.lastTick) room.lastTick = outcome.tick;
|
|
2715
5143
|
room.metrics.connections++;
|
|
2716
5144
|
room.metrics.totalConnections++;
|
|
5145
|
+
if (session.npc) room.metrics.npcConnections++;
|
|
2717
5146
|
this.sendWelcome(session, outcome.tick, outcome.snapshot);
|
|
5147
|
+
for (const bytes of session.pendingOut.splice(0)) {
|
|
5148
|
+
const sent = session.send(bytes);
|
|
5149
|
+
if (sent > 0) {
|
|
5150
|
+
room.metrics.egressBytes += sent;
|
|
5151
|
+
room.metrics.framesOut++;
|
|
5152
|
+
}
|
|
5153
|
+
}
|
|
2718
5154
|
}
|
|
2719
5155
|
requestJoin(room, session, reconnecting) {
|
|
2720
5156
|
const worker = room.worker;
|
|
@@ -2737,43 +5173,53 @@ var SupervisorImpl = class {
|
|
|
2737
5173
|
...session.name !== "" ? { name: session.name } : {},
|
|
2738
5174
|
// D27: the verified JWT subject; absent for key joins (playerId stays the client id).
|
|
2739
5175
|
...session.playerId !== void 0 ? { playerId: session.playerId } : {},
|
|
2740
|
-
...reconnecting ? { reconnecting: true } : {}
|
|
5176
|
+
...reconnecting ? { reconnecting: true } : {},
|
|
5177
|
+
...session.npc ? { npc: true } : {}
|
|
2741
5178
|
});
|
|
2742
5179
|
});
|
|
2743
5180
|
}
|
|
5181
|
+
/**
|
|
5182
|
+
* D66: the admission decision now lives in `relay-host.ts`, so the process that will serve
|
|
5183
|
+
* relay-only tenants without a VM runs the same code. What stays here is what is the
|
|
5184
|
+
* *supervisor's* and not a relay's: the session registries, the welcome frame, and the idle
|
|
5185
|
+
* timer that leads to hibernation.
|
|
5186
|
+
*/
|
|
2744
5187
|
joinRelay(room, session, _reconnecting) {
|
|
2745
5188
|
const relay = room.relayRoom;
|
|
2746
5189
|
if (!relay) {
|
|
2747
5190
|
session.fail("E_INTERNAL", formatError("E_INTERNAL"));
|
|
2748
5191
|
return;
|
|
2749
5192
|
}
|
|
2750
|
-
|
|
5193
|
+
const outcome = relayJoin(relayHostRoomFor(this, room), session);
|
|
5194
|
+
if (!outcome.ok) {
|
|
2751
5195
|
room.clients.delete(session.clientId);
|
|
2752
5196
|
this.clientIds.delete(session.clientId);
|
|
2753
|
-
session.fail(
|
|
5197
|
+
session.fail(outcome.code, formatError(outcome.code, { roomId: room.id }));
|
|
2754
5198
|
return;
|
|
2755
5199
|
}
|
|
2756
|
-
|
|
2757
|
-
room.lastTick = relay.tick;
|
|
5200
|
+
room.lastTick = outcome.tick;
|
|
2758
5201
|
session.state = "joined";
|
|
5202
|
+
room.notePlayerSeen(session.playerId ?? session.clientId);
|
|
2759
5203
|
room.metrics.connections++;
|
|
2760
5204
|
room.metrics.totalConnections++;
|
|
2761
|
-
this.sendWelcome(session,
|
|
2762
|
-
this.broadcast(room,
|
|
5205
|
+
this.sendWelcome(session, outcome.tick, outcome.snapshot);
|
|
5206
|
+
this.broadcast(room, encodeFrame3(FrameType3.DELTA, outcome.delta), session.clientId);
|
|
2763
5207
|
this.armRelayIdle(room);
|
|
2764
5208
|
}
|
|
2765
5209
|
/** `WELCOME` with a fresh resume token — also used as the resync after a wake/restart. */
|
|
2766
5210
|
sendWelcome(session, tick, snapshot) {
|
|
2767
5211
|
const room = session.room;
|
|
2768
5212
|
if (!room) return;
|
|
5213
|
+
const now = Date.now();
|
|
2769
5214
|
const resumeToken = signResume(this.resumeSecret, {
|
|
2770
5215
|
clientId: session.clientId,
|
|
2771
5216
|
roomId: room.id,
|
|
2772
5217
|
role: session.role,
|
|
2773
|
-
expMs:
|
|
5218
|
+
expMs: now + RESUME_TOKEN_TTL_MS,
|
|
5219
|
+
iatMs: now
|
|
2774
5220
|
});
|
|
2775
|
-
const bytes =
|
|
2776
|
-
|
|
5221
|
+
const bytes = encodeFrame3(
|
|
5222
|
+
FrameType3.WELCOME,
|
|
2777
5223
|
encodeWelcome({
|
|
2778
5224
|
clientId: session.clientId,
|
|
2779
5225
|
role: session.role,
|
|
@@ -2781,8 +5227,11 @@ var SupervisorImpl = class {
|
|
|
2781
5227
|
snapshot,
|
|
2782
5228
|
resumeToken,
|
|
2783
5229
|
roomId: room.id,
|
|
2784
|
-
// 0 for a relay room
|
|
2785
|
-
|
|
5230
|
+
// 0 for a relay room: "unknown" — the client falls back to its 50 ms floor.
|
|
5231
|
+
tickRate: room.config.tickRate,
|
|
5232
|
+
// Always meaningful: bundle rooms default to 64 (@irtio/server DEFAULTS) and relay rooms
|
|
5233
|
+
// come from SupervisorLimits.relayMaxClients, so unlike tickRate this is never 0/unset.
|
|
5234
|
+
maxClients: room.config.maxClients
|
|
2786
5235
|
})
|
|
2787
5236
|
);
|
|
2788
5237
|
const sent = session.send(bytes);
|
|
@@ -2798,17 +5247,31 @@ var SupervisorImpl = class {
|
|
|
2798
5247
|
* the new client, instead of feeding it bytes it will mis-decode. Clients already on the new
|
|
2799
5248
|
* schema (the page reloaded after the client shipped) rejoin normally.
|
|
2800
5249
|
*/
|
|
2801
|
-
failOutdatedSessions(room, version) {
|
|
2802
|
-
const
|
|
2803
|
-
|
|
5250
|
+
failOutdatedSessions(room, version, fromVersion) {
|
|
5251
|
+
const type = room.roomType;
|
|
5252
|
+
const target = this.deployments.get(version);
|
|
5253
|
+
const targetBundle = target?.bundles.get(type);
|
|
5254
|
+
const hash8 = targetBundle?.hash8;
|
|
5255
|
+
if (!hash8 || !target) return 0;
|
|
5256
|
+
const from = fromVersion !== void 0 ? this.deployments.get(fromVersion) : void 0;
|
|
5257
|
+
const fromSchemaJson = from?.bundles.get(type)?.schemaJson;
|
|
5258
|
+
const targetSchemaJson = targetBundle.schemaJson;
|
|
5259
|
+
const swappable = fromSchemaJson !== void 0 && isAdditiveMigration(fromSchemaJson, targetSchemaJson);
|
|
5260
|
+
let swapped = 0;
|
|
2804
5261
|
for (const session of room.connected()) {
|
|
2805
|
-
if (session.schemaHash8
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
5262
|
+
if (session.schemaHash8 === void 0 || bytesEqual(session.schemaHash8, hash8)) continue;
|
|
5263
|
+
if (swappable && session.schemaSwap) {
|
|
5264
|
+
session.sendFrame(FrameType3.SCHEMA, schemaPayload(targetSchemaJson));
|
|
5265
|
+
session.schemaHash8 = hash8;
|
|
5266
|
+
swapped++;
|
|
5267
|
+
continue;
|
|
2810
5268
|
}
|
|
5269
|
+
session.fail(
|
|
5270
|
+
"E_SCHEMA_MISMATCH",
|
|
5271
|
+
"the room migrated to a newer deployment; reconnect with an updated client"
|
|
5272
|
+
);
|
|
2811
5273
|
}
|
|
5274
|
+
return swapped;
|
|
2812
5275
|
}
|
|
2813
5276
|
/** Re-joins every still-connected socket after a wake or a crash restart, with a resync WELCOME. */
|
|
2814
5277
|
async rejoinAll(room) {
|
|
@@ -2861,23 +5324,33 @@ var SupervisorImpl = class {
|
|
|
2861
5324
|
this.badFrame(session, "unknown frame type");
|
|
2862
5325
|
return;
|
|
2863
5326
|
}
|
|
2864
|
-
if (type ===
|
|
5327
|
+
if (type === FrameType3.PING) {
|
|
2865
5328
|
let t = 0;
|
|
2866
5329
|
try {
|
|
2867
5330
|
t = decodePing(payload).t;
|
|
2868
5331
|
} catch {
|
|
2869
5332
|
}
|
|
2870
|
-
const sent = session.sendFrame(
|
|
5333
|
+
const sent = session.sendFrame(FrameType3.PONG, encodePong({ t, serverTick: room.lastTick }));
|
|
2871
5334
|
if (sent > 0) {
|
|
2872
5335
|
room.metrics.egressBytes += sent;
|
|
2873
5336
|
room.metrics.framesOut++;
|
|
2874
5337
|
}
|
|
2875
5338
|
return;
|
|
2876
5339
|
}
|
|
2877
|
-
if (type ===
|
|
5340
|
+
if (type === FrameType3.LEAVE) {
|
|
2878
5341
|
this.handleLeaveFrame(session);
|
|
2879
5342
|
return;
|
|
2880
5343
|
}
|
|
5344
|
+
if (type === FrameType3.MSG && this.bandwidth.engaged) {
|
|
5345
|
+
if (this.bandwidth.takeWarning(room.id)) {
|
|
5346
|
+
this.roomLog(room, "warn", bandwidthWarning(this.caps.plan() ?? "free"));
|
|
5347
|
+
}
|
|
5348
|
+
return;
|
|
5349
|
+
}
|
|
5350
|
+
if (type === FrameType3.MSG && isVoiceMsg(payload)) {
|
|
5351
|
+
this.handleVoiceFrame(room, session, payload.subarray(1));
|
|
5352
|
+
return;
|
|
5353
|
+
}
|
|
2881
5354
|
if (room.relay) {
|
|
2882
5355
|
this.relayFrame(room, session, type, payload);
|
|
2883
5356
|
return;
|
|
@@ -2916,43 +5389,81 @@ var SupervisorImpl = class {
|
|
|
2916
5389
|
} catch {
|
|
2917
5390
|
}
|
|
2918
5391
|
}
|
|
5392
|
+
/**
|
|
5393
|
+
* D66: the forwarding rules now live in `relay-host.ts` — see that module for why. What stays
|
|
5394
|
+
* here is the supervisor's half of a refusal (`badFrame` closes the connection its own way) and
|
|
5395
|
+
* the idle timer.
|
|
5396
|
+
*/
|
|
2919
5397
|
relayFrame(room, session, type, payload) {
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
if (
|
|
2923
|
-
this.badFrame(session,
|
|
5398
|
+
if (!room.relayRoom) return;
|
|
5399
|
+
const outcome = relayForward(relayHostRoomFor(this, room), session, type, payload);
|
|
5400
|
+
if (!outcome.ok) {
|
|
5401
|
+
this.badFrame(session, outcome.reason);
|
|
2924
5402
|
return;
|
|
2925
5403
|
}
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
5404
|
+
this.armRelayIdle(room);
|
|
5405
|
+
}
|
|
5406
|
+
/**
|
|
5407
|
+
* D51: one intercepted voice-signaling message. Never reaches room code — see the interception
|
|
5408
|
+
* in `routeFrame` and the module note on `VoiceRelay`.
|
|
5409
|
+
*
|
|
5410
|
+
* The supervisor's contribution here is deliberately thin: identity (which project, room and
|
|
5411
|
+
* client this is, and whether it is an NPC) and a way back to the socket. It does not parse the
|
|
5412
|
+
* message, so no signaling shape is pinned inside the tenant boundary.
|
|
5413
|
+
*/
|
|
5414
|
+
handleVoiceFrame(room, session, message) {
|
|
5415
|
+
const relay = this.config.voice;
|
|
5416
|
+
if (!relay) {
|
|
5417
|
+
this.sendVoice(
|
|
5418
|
+
session,
|
|
5419
|
+
encodeVoiceMessage({
|
|
5420
|
+
t: "error",
|
|
5421
|
+
code: "E_VOICE_UNAVAILABLE",
|
|
5422
|
+
message: "voice is not configured for this tenant"
|
|
5423
|
+
})
|
|
5424
|
+
);
|
|
2934
5425
|
return;
|
|
2935
5426
|
}
|
|
2936
|
-
if (
|
|
2937
|
-
this.
|
|
2938
|
-
|
|
5427
|
+
if (!session.npc) {
|
|
5428
|
+
const voiceCapRefusal = this.caps.refusal("voice_minutes");
|
|
5429
|
+
if (voiceCapRefusal !== void 0 && decodeVoiceMessage(message)?.t === "join") {
|
|
5430
|
+
this.sendVoice(
|
|
5431
|
+
session,
|
|
5432
|
+
encodeVoiceMessage({ t: "error", code: "E_USAGE_CAP", message: voiceCapRefusal })
|
|
5433
|
+
);
|
|
5434
|
+
return;
|
|
5435
|
+
}
|
|
2939
5436
|
}
|
|
2940
|
-
const
|
|
2941
|
-
|
|
2942
|
-
|
|
5437
|
+
const ctx = {
|
|
5438
|
+
projectId: this.config.projectId,
|
|
5439
|
+
roomId: room.id,
|
|
5440
|
+
clientId: session.clientId,
|
|
5441
|
+
npc: session.npc
|
|
5442
|
+
};
|
|
5443
|
+
void relay.signal(ctx, message, (reply) => this.sendVoice(session, reply)).catch((err) => {
|
|
5444
|
+
this.log("warn", `voice relay failed for ${session.clientId}: ${String(err)}`);
|
|
5445
|
+
this.sendVoice(
|
|
5446
|
+
session,
|
|
5447
|
+
encodeVoiceMessage({
|
|
5448
|
+
t: "error",
|
|
5449
|
+
code: "E_VOICE_FAILED",
|
|
5450
|
+
message: "voice signaling failed"
|
|
5451
|
+
})
|
|
5452
|
+
);
|
|
5453
|
+
});
|
|
5454
|
+
}
|
|
5455
|
+
/** Writes one voice message back to a single client, inside its `{ kind: 'voice' }` envelope. */
|
|
5456
|
+
sendVoice(session, message) {
|
|
5457
|
+
if (session.state !== "joined") return;
|
|
5458
|
+
const sent = session.sendFrame(
|
|
5459
|
+
FrameType3.MSG,
|
|
5460
|
+
encodeMsg2({ target: { kind: "voice" }, payload: message })
|
|
2943
5461
|
);
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
const sent = peer.send(out);
|
|
2949
|
-
if (sent > 0) {
|
|
2950
|
-
room.metrics.egressBytes += sent;
|
|
2951
|
-
room.metrics.framesOut++;
|
|
2952
|
-
}
|
|
5462
|
+
const room = session.room;
|
|
5463
|
+
if (sent > 0 && room) {
|
|
5464
|
+
room.metrics.egressBytes += sent;
|
|
5465
|
+
room.metrics.framesOut++;
|
|
2953
5466
|
}
|
|
2954
|
-
room.lastTick = relay.advance();
|
|
2955
|
-
this.armRelayIdle(room);
|
|
2956
5467
|
}
|
|
2957
5468
|
badFrame(session, message) {
|
|
2958
5469
|
session.badFrameStrikes++;
|
|
@@ -2986,7 +5497,30 @@ var SupervisorImpl = class {
|
|
|
2986
5497
|
case "send": {
|
|
2987
5498
|
noteTick(room, msg.bytes);
|
|
2988
5499
|
const session = room.clients.get(msg.clientId);
|
|
2989
|
-
if (!session
|
|
5500
|
+
if (!session) {
|
|
5501
|
+
if (!room.unknownSendWarned.has(msg.clientId)) {
|
|
5502
|
+
room.unknownSendWarned.add(msg.clientId);
|
|
5503
|
+
this.roomLog(
|
|
5504
|
+
room,
|
|
5505
|
+
"warn",
|
|
5506
|
+
`dropped a ${frameName(msg.bytes)} frame for unknown client ${msg.clientId}`
|
|
5507
|
+
);
|
|
5508
|
+
}
|
|
5509
|
+
return;
|
|
5510
|
+
}
|
|
5511
|
+
if (session.state === "joining") {
|
|
5512
|
+
if (session.pendingOut.length < HELD_FRAMES_MAX) {
|
|
5513
|
+
session.pendingOut.push(msg.bytes);
|
|
5514
|
+
} else if (session.droppedHeld++ === 0) {
|
|
5515
|
+
this.roomLog(
|
|
5516
|
+
room,
|
|
5517
|
+
"warn",
|
|
5518
|
+
`dropped a ${frameName(msg.bytes)} frame for ${msg.clientId}: more than ${HELD_FRAMES_MAX} frames sent while it was still joining`
|
|
5519
|
+
);
|
|
5520
|
+
}
|
|
5521
|
+
return;
|
|
5522
|
+
}
|
|
5523
|
+
if (session.state !== "joined") return;
|
|
2990
5524
|
const sent = session.send(msg.bytes);
|
|
2991
5525
|
if (sent > 0) {
|
|
2992
5526
|
room.metrics.egressBytes += sent;
|
|
@@ -3008,14 +5542,58 @@ var SupervisorImpl = class {
|
|
|
3008
5542
|
);
|
|
3009
5543
|
return;
|
|
3010
5544
|
case "sleep":
|
|
5545
|
+
this.npcs.despawnRoom(room.id);
|
|
3011
5546
|
void hibernateRoom(this, room);
|
|
3012
5547
|
return;
|
|
3013
5548
|
case "hostCall":
|
|
3014
5549
|
void this.runHostCall(room, msg.reqId, msg.call);
|
|
3015
5550
|
return;
|
|
5551
|
+
case "setBackfill":
|
|
5552
|
+
room.backfillOpen = msg.open;
|
|
5553
|
+
return;
|
|
3016
5554
|
case "setAlarm":
|
|
5555
|
+
if (isMailboxAlarm(msg.name)) {
|
|
5556
|
+
this.roomLog(
|
|
5557
|
+
room,
|
|
5558
|
+
"warn",
|
|
5559
|
+
`refusing to arm ${JSON.stringify(msg.name)}: names beginning with ${JSON.stringify(BUS_MAILBOX_PREFIX2)} belong to the bus`
|
|
5560
|
+
);
|
|
5561
|
+
return;
|
|
5562
|
+
}
|
|
3017
5563
|
room.alarms.set(msg.name, msg.atMs);
|
|
3018
5564
|
return;
|
|
5565
|
+
case "busPublish":
|
|
5566
|
+
this.busPublish(room, msg.channel, msg.payload);
|
|
5567
|
+
return;
|
|
5568
|
+
case "busSubscribe": {
|
|
5569
|
+
if (!msg.subscribed) {
|
|
5570
|
+
this.bus.unsubscribe(room.id, msg.channel);
|
|
5571
|
+
return;
|
|
5572
|
+
}
|
|
5573
|
+
const problem = this.busBudget(room, "subscribe") ?? this.bus.subscribe(room.id, msg.channel);
|
|
5574
|
+
if (problem) {
|
|
5575
|
+
this.roomLog(room, "warn", `bus.subscribe: ${problem.code}: ${problem.message}`);
|
|
5576
|
+
}
|
|
5577
|
+
return;
|
|
5578
|
+
}
|
|
5579
|
+
case "spawnNpc": {
|
|
5580
|
+
const definition = this.deployments.get(room.version)?.bundle?.definition;
|
|
5581
|
+
if (!definition) {
|
|
5582
|
+
this.roomLog(room, "error", "irtio: spawnNPC needs a room bundle; none is loaded");
|
|
5583
|
+
return;
|
|
5584
|
+
}
|
|
5585
|
+
this.npcs.spawn(room.id, definition, msg.clientId, msg.config);
|
|
5586
|
+
return;
|
|
5587
|
+
}
|
|
5588
|
+
case "despawnNpc":
|
|
5589
|
+
this.npcs.despawn(msg.clientId);
|
|
5590
|
+
return;
|
|
5591
|
+
case "busAck":
|
|
5592
|
+
room.alarms.set(msg.name, void 0);
|
|
5593
|
+
return;
|
|
5594
|
+
case "stats":
|
|
5595
|
+
if (msg.physicsStepped === true) this.notePhysicsWarm();
|
|
5596
|
+
return;
|
|
3019
5597
|
case "log":
|
|
3020
5598
|
room.log(msg.level, ...msg.args);
|
|
3021
5599
|
return;
|
|
@@ -3029,6 +5607,11 @@ var SupervisorImpl = class {
|
|
|
3029
5607
|
/** Worker died: restart from the latest snapshot and resync everyone (plan §3.3). */
|
|
3030
5608
|
onWorkerDead(room, reason) {
|
|
3031
5609
|
if (room.state === "closed" || this.closing) return;
|
|
5610
|
+
const initializing = this.readyWaiters.get(room);
|
|
5611
|
+
if (initializing) {
|
|
5612
|
+
initializing({ t: "initFailed", reason });
|
|
5613
|
+
return;
|
|
5614
|
+
}
|
|
3032
5615
|
this.roomLog(room, "error", `worker died (${reason}); restarting`);
|
|
3033
5616
|
room.worker = void 0;
|
|
3034
5617
|
room.metrics.restarts++;
|
|
@@ -3127,8 +5710,72 @@ var SupervisorImpl = class {
|
|
|
3127
5710
|
};
|
|
3128
5711
|
}
|
|
3129
5712
|
logs(roomId) {
|
|
5713
|
+
if (roomId === TENANT_ROOM_ID) return this.tenantLogs.list();
|
|
3130
5714
|
return this.registry.get(roomId)?.logs.list() ?? [];
|
|
3131
5715
|
}
|
|
5716
|
+
/**
|
|
5717
|
+
* D36: pull every live room's tick counters out of its worker and into `RoomMetrics`.
|
|
5718
|
+
*
|
|
5719
|
+
* The counters exist already — `RoomCore.stats.overruns` has been incremented next to the
|
|
5720
|
+
* `tick overrun` log line since week 2 — but they never left the worker thread, so the one
|
|
5721
|
+
* signal that says "this room is shedding work" reached nothing a client, a CLI or an operator
|
|
5722
|
+
* could read. Every `overruns` a report shows now came through here.
|
|
5723
|
+
*
|
|
5724
|
+
* A room that does not answer is left exactly as it was, with `overruns` still `undefined` if
|
|
5725
|
+
* it has never answered. That is the whole point of the field being optional: `tick-health`
|
|
5726
|
+
* reports `unavailable` for it, rather than a zero nobody measured.
|
|
5727
|
+
*/
|
|
5728
|
+
async refreshTickHealth(timeoutMs = 2e3) {
|
|
5729
|
+
const live = this.registry.values().filter((r) => r.worker?.alive === true);
|
|
5730
|
+
const answered = await Promise.all(
|
|
5731
|
+
live.map(async (room) => {
|
|
5732
|
+
const msg = await room.worker?.statsMessage(timeoutMs);
|
|
5733
|
+
if (!msg) return void 0;
|
|
5734
|
+
const stats = msg.stats;
|
|
5735
|
+
room.metrics.ticks = stats.ticks;
|
|
5736
|
+
room.metrics.maxTickMs = stats.maxTickMs;
|
|
5737
|
+
room.metrics.overruns = stats.overruns;
|
|
5738
|
+
if (msg.heap !== void 0) {
|
|
5739
|
+
this.recordHeap(room, msg.heap.usedBytes, msg.heap.limitBytes);
|
|
5740
|
+
}
|
|
5741
|
+
if (msg.profile !== void 0) room.profile = msg.profile;
|
|
5742
|
+
if (msg.physicsStepped === true) this.notePhysicsWarm();
|
|
5743
|
+
return room.id;
|
|
5744
|
+
})
|
|
5745
|
+
);
|
|
5746
|
+
return answered.filter((id) => id !== void 0);
|
|
5747
|
+
}
|
|
5748
|
+
/**
|
|
5749
|
+
* D55 step 2: the per-room heap reading, plus the warning that is the whole point of taking it.
|
|
5750
|
+
*
|
|
5751
|
+
* Before this, the first signal a developer got that a room was too big for its VM was the
|
|
5752
|
+
* tenant dying. The warning fires once per crossing of `HEAP_WARN_FRACTION` rather than on
|
|
5753
|
+
* every poll, and re-arms when the room drops back under the line, so a room that sits at 85%
|
|
5754
|
+
* says so once instead of filling the ring the operator would read it from. Room-scoped
|
|
5755
|
+
* (`roomLog`), because "which room" is the actionable half.
|
|
5756
|
+
*/
|
|
5757
|
+
recordHeap(room, usedBytes, limitBytes) {
|
|
5758
|
+
room.metrics.heapUsedBytes = usedBytes;
|
|
5759
|
+
room.metrics.heapLimitBytes = limitBytes;
|
|
5760
|
+
const capBytes = this.limits.workerMaxOldGenMb * 1024 * 1024;
|
|
5761
|
+
if (capBytes <= 0) return;
|
|
5762
|
+
const fraction = usedBytes / capBytes;
|
|
5763
|
+
if (fraction >= HEAP_WARN_FRACTION) {
|
|
5764
|
+
if (!room.heapWarned) {
|
|
5765
|
+
room.heapWarned = true;
|
|
5766
|
+
const usedMb = Math.round(usedBytes / (1024 * 1024));
|
|
5767
|
+
const limitMb = this.limits.workerMaxOldGenMb;
|
|
5768
|
+
const pct = Math.round(fraction * 100);
|
|
5769
|
+
this.roomLog(
|
|
5770
|
+
room,
|
|
5771
|
+
"warn",
|
|
5772
|
+
`room heap at ${pct}% of its worker cap (${usedMb} MB of ${limitMb} MB); past the cap the worker is killed and the room restarts, and three restarts in a minute close it`
|
|
5773
|
+
);
|
|
5774
|
+
}
|
|
5775
|
+
return;
|
|
5776
|
+
}
|
|
5777
|
+
room.heapWarned = false;
|
|
5778
|
+
}
|
|
3132
5779
|
async inspect(roomId) {
|
|
3133
5780
|
const room = this.registry.get(roomId);
|
|
3134
5781
|
if (!room) return void 0;
|
|
@@ -3152,6 +5799,34 @@ var SupervisorImpl = class {
|
|
|
3152
5799
|
rss: inspected.rss
|
|
3153
5800
|
};
|
|
3154
5801
|
}
|
|
5802
|
+
/**
|
|
5803
|
+
* D41: arm a room's timeline recorder. Bundle rooms only: a relay room has no schema and no
|
|
5804
|
+
* authority over its state, so there is nothing authoritative to record, and answering `false`
|
|
5805
|
+
* is more honest than handing back an empty recording that would read as "nothing happened".
|
|
5806
|
+
*/
|
|
5807
|
+
async startTimeline(roomId, options = {}) {
|
|
5808
|
+
const room = this.registry.get(roomId);
|
|
5809
|
+
if (!room?.worker || room.state !== "running") return false;
|
|
5810
|
+
return room.worker.startTimeline(options);
|
|
5811
|
+
}
|
|
5812
|
+
async readTimeline(roomId) {
|
|
5813
|
+
const room = this.registry.get(roomId);
|
|
5814
|
+
if (!room?.worker || room.state !== "running") return void 0;
|
|
5815
|
+
return room.worker.timeline();
|
|
5816
|
+
}
|
|
5817
|
+
/**
|
|
5818
|
+
* D43: a save generation, and its bytes. `saveRoom` writes it (same serialization, same
|
|
5819
|
+
* retention, same durability as `room.save()`), and the object is then read straight back out
|
|
5820
|
+
* of the store, so what a truth-seam diff decodes is the stored artifact rather than a copy of
|
|
5821
|
+
* it made on the way past.
|
|
5822
|
+
*/
|
|
5823
|
+
async saveNow(roomId) {
|
|
5824
|
+
const room = this.registry.get(roomId);
|
|
5825
|
+
if (!room?.worker || room.state !== "running") return void 0;
|
|
5826
|
+
const saveId = await this.saveRoom(room);
|
|
5827
|
+
const bytes = await this.store.get(saveKey(this.storeKey(room), saveId));
|
|
5828
|
+
return bytes ? { saveId, bytes } : void 0;
|
|
5829
|
+
}
|
|
3155
5830
|
/**
|
|
3156
5831
|
* Ops/test hook: hard-kill a room's worker. The room takes the normal §3.3 crash path (restore
|
|
3157
5832
|
* from the latest snapshot, resync everyone, restart cap). `false` when the room has no worker.
|
|
@@ -3167,14 +5842,19 @@ function header(v) {
|
|
|
3167
5842
|
if (v === void 0) return void 0;
|
|
3168
5843
|
return Array.isArray(v) ? v[0] : v;
|
|
3169
5844
|
}
|
|
5845
|
+
function frameName(bytes) {
|
|
5846
|
+
const type = bytes[0];
|
|
5847
|
+
for (const [name, value] of Object.entries(FrameType3)) if (value === type) return name;
|
|
5848
|
+
return `type ${String(type)}`;
|
|
5849
|
+
}
|
|
3170
5850
|
function noteTick(room, bytes) {
|
|
3171
5851
|
if (bytes.length < 5) return;
|
|
3172
5852
|
const type = bytes[0];
|
|
3173
|
-
if (type !==
|
|
5853
|
+
if (type !== FrameType3.DELTA && type !== FrameType3.CORRECT) return;
|
|
3174
5854
|
const tick = new DataView(bytes.buffer, bytes.byteOffset + 1, 4).getUint32(0, true);
|
|
3175
5855
|
if (tick > room.lastTick) room.lastTick = tick;
|
|
3176
5856
|
}
|
|
3177
|
-
async function loadBundle(bundlePath, version) {
|
|
5857
|
+
async function loadBundle(bundlePath, version, type) {
|
|
3178
5858
|
const bundleUrl = bundlePath.startsWith("file:") ? bundlePath : pathToFileURL2(bundlePath).href;
|
|
3179
5859
|
let mod;
|
|
3180
5860
|
try {
|
|
@@ -3191,6 +5871,7 @@ async function loadBundle(bundlePath, version) {
|
|
|
3191
5871
|
const config = definition.config;
|
|
3192
5872
|
return {
|
|
3193
5873
|
version,
|
|
5874
|
+
type,
|
|
3194
5875
|
definition,
|
|
3195
5876
|
schemaHash: definition.schema.hash,
|
|
3196
5877
|
hash8: definition.schema.hash8,
|
|
@@ -3200,7 +5881,17 @@ async function loadBundle(bundlePath, version) {
|
|
|
3200
5881
|
tickRate: config.tickRate,
|
|
3201
5882
|
idleMs: config.idleMs,
|
|
3202
5883
|
reconnectGraceMs: config.reconnectGraceMs,
|
|
3203
|
-
maxClients: config.maxClients
|
|
5884
|
+
maxClients: config.maxClients,
|
|
5885
|
+
// D47: the engine dimension for room-hours. Additive and read-only — the supervisor never
|
|
5886
|
+
// acts on it, it only reports it so part 3 can price the physics multiplier.
|
|
5887
|
+
...typeof config.physics?.engine === "string" ? { engine: config.physics.engine } : {},
|
|
5888
|
+
// D58: the declared worker heap for this type. Absent means the tenant's derived default,
|
|
5889
|
+
// which is what every room got before types existed.
|
|
5890
|
+
...typeof config.memoryMb === "number" ? { memoryMb: config.memoryMb } : {},
|
|
5891
|
+
// M5 part 3.5: the declared concurrency. Enforced (`startRoom` refuses room N+1), not
|
|
5892
|
+
// reported — the VM was sized on the strength of this number, so a room past it is a room
|
|
5893
|
+
// the machine was never built to hold.
|
|
5894
|
+
...typeof config.maxAwake === "number" ? { maxAwake: config.maxAwake } : {}
|
|
3204
5895
|
},
|
|
3205
5896
|
bundleUrl,
|
|
3206
5897
|
schemaJson: definition.schema.canonical
|
|
@@ -3402,40 +6093,8 @@ function resolveEntry(cwd, room) {
|
|
|
3402
6093
|
create one (export default defineRoom(schema, {...})) or pass --room <file>`
|
|
3403
6094
|
);
|
|
3404
6095
|
}
|
|
3405
|
-
async function
|
|
3406
|
-
const
|
|
3407
|
-
if (!existsSync(file)) return "dev";
|
|
3408
|
-
let parsed;
|
|
3409
|
-
try {
|
|
3410
|
-
parsed = JSON.parse(await readFile(file, "utf8"));
|
|
3411
|
-
} catch (err) {
|
|
3412
|
-
throw new Error(`irtio dev: ${file} is not valid JSON: ${String(err)}`);
|
|
3413
|
-
}
|
|
3414
|
-
const project = parsed?.project;
|
|
3415
|
-
if (project === void 0) return "dev";
|
|
3416
|
-
if (typeof project !== "string" || project.length === 0) {
|
|
3417
|
-
throw new Error(`irtio dev: ${file} has a "project" that is not a non-empty string`);
|
|
3418
|
-
}
|
|
3419
|
-
return project;
|
|
3420
|
-
}
|
|
3421
|
-
async function readClientEntry(cwd) {
|
|
3422
|
-
const file = path.join(cwd, "irtio.json");
|
|
3423
|
-
if (!existsSync(file)) return void 0;
|
|
3424
|
-
let parsed;
|
|
3425
|
-
try {
|
|
3426
|
-
parsed = JSON.parse(await readFile(file, "utf8"));
|
|
3427
|
-
} catch (err) {
|
|
3428
|
-
throw new Error(`irtio dev: ${file} is not valid JSON: ${String(err)}`);
|
|
3429
|
-
}
|
|
3430
|
-
const client = parsed?.client;
|
|
3431
|
-
if (client === void 0) return void 0;
|
|
3432
|
-
if (typeof client !== "string" || client.length === 0) {
|
|
3433
|
-
throw new Error(`irtio dev: ${file} has a "client" that is not a non-empty string`);
|
|
3434
|
-
}
|
|
3435
|
-
return client;
|
|
3436
|
-
}
|
|
3437
|
-
async function warnIfClientImportsRoom(cwd, entry, log) {
|
|
3438
|
-
const client = await readClientEntry(cwd);
|
|
6096
|
+
async function warnIfClientImportsRoom(cwd, entry, config, log) {
|
|
6097
|
+
const client = config.client;
|
|
3439
6098
|
if (client === void 0) return;
|
|
3440
6099
|
const clientFile = path.resolve(cwd, client);
|
|
3441
6100
|
if (await clientImportsRoom(clientFile, entry)) {
|
|
@@ -3446,20 +6105,18 @@ async function warnIfClientImportsRoom(cwd, entry, log) {
|
|
|
3446
6105
|
);
|
|
3447
6106
|
}
|
|
3448
6107
|
}
|
|
3449
|
-
function
|
|
6108
|
+
function engineAbsolutePlugin(fromPackageDir, pkg) {
|
|
3450
6109
|
let resolved;
|
|
3451
6110
|
try {
|
|
3452
|
-
resolved = createRequire(path.join(fromPackageDir, "package.json")).resolve(
|
|
3453
|
-
"@dimforge/rapier3d-compat"
|
|
3454
|
-
);
|
|
6111
|
+
resolved = createRequire(path.join(fromPackageDir, "package.json")).resolve(pkg);
|
|
3455
6112
|
} catch {
|
|
3456
6113
|
return void 0;
|
|
3457
6114
|
}
|
|
3458
6115
|
const specifier = pathToFileURL3(resolved).href;
|
|
3459
6116
|
return {
|
|
3460
|
-
name:
|
|
6117
|
+
name: `irtio-dev-${pkg.replace(/[^a-z0-9]+/g, "-")}-absolute`,
|
|
3461
6118
|
setup(build2) {
|
|
3462
|
-
build2.onResolve({ filter:
|
|
6119
|
+
build2.onResolve({ filter: new RegExp(`^${pkg.replace(/[/\-]/g, "$&")}$`) }, () => ({
|
|
3463
6120
|
path: specifier,
|
|
3464
6121
|
external: true
|
|
3465
6122
|
}));
|
|
@@ -3484,7 +6141,8 @@ async function resolveWorkerEntry2(outDir) {
|
|
|
3484
6141
|
);
|
|
3485
6142
|
}
|
|
3486
6143
|
const outfile = path.join(outDir, "worker.mjs");
|
|
3487
|
-
const
|
|
6144
|
+
const runtimeDir = path.join(packagesDir, "runtime");
|
|
6145
|
+
const enginePlugins = ["@dimforge/rapier3d-compat", "matter-js"].map((pkg) => engineAbsolutePlugin(runtimeDir, pkg)).filter((p) => p !== void 0);
|
|
3488
6146
|
await esbuild.build({
|
|
3489
6147
|
entryPoints: [source],
|
|
3490
6148
|
bundle: true,
|
|
@@ -3496,29 +6154,35 @@ async function resolveWorkerEntry2(outDir) {
|
|
|
3496
6154
|
"@irtio/server": path.join(packagesDir, "server/src/index.ts"),
|
|
3497
6155
|
"@irtio/protocol": path.join(packagesDir, "protocol/src/index.ts")
|
|
3498
6156
|
},
|
|
3499
|
-
...
|
|
6157
|
+
...enginePlugins.length > 0 ? { plugins: enginePlugins } : {},
|
|
3500
6158
|
// Rapier stays external for the same reason it does in packages/supervisor/test/support.ts:
|
|
3501
6159
|
// it is a dependency of `@irtio/runtime`, resolved once at load time. Bundling a second copy
|
|
3502
6160
|
// here would give the dev worker its own WASM instance, separate from the one the room
|
|
3503
6161
|
// bundle resolves via node_modules. `rapierPlugin` (above) already externalizes it at an
|
|
3504
6162
|
// absolute path when `@irtio/runtime`'s own copy can be found; this string entry is the
|
|
3505
6163
|
// fallback for a published install where that resolution comes for free from node_modules.
|
|
3506
|
-
external: ["node:worker_threads", "node:url", "@dimforge/rapier3d-compat"]
|
|
6164
|
+
external: ["node:worker_threads", "node:url", "@dimforge/rapier3d-compat", "matter-js"]
|
|
3507
6165
|
});
|
|
3508
6166
|
return outfile;
|
|
3509
6167
|
}
|
|
3510
|
-
async function buildState(supervisor, projectId, bundle) {
|
|
6168
|
+
async function buildState(supervisor, projectId, bundle, startedAt) {
|
|
6169
|
+
await supervisor.refreshTickHealth().catch(() => []);
|
|
3511
6170
|
const infos = supervisor.rooms();
|
|
3512
6171
|
const inspects = await Promise.all(
|
|
3513
6172
|
infos.map((room) => supervisor.inspect(room.id).catch(() => void 0))
|
|
3514
6173
|
);
|
|
3515
6174
|
const logs = [];
|
|
3516
|
-
for (const
|
|
3517
|
-
for (const entry of supervisor.logs(
|
|
6175
|
+
for (const roomId of [TENANT_ROOM_ID, ...infos.map((r) => r.id)]) {
|
|
6176
|
+
for (const entry of supervisor.logs(roomId)) logs.push({ ...entry, roomId });
|
|
3518
6177
|
}
|
|
3519
6178
|
logs.sort((a, b) => a.at - b.at);
|
|
3520
6179
|
return {
|
|
3521
6180
|
project: projectId,
|
|
6181
|
+
// bugs.md #16: which build of the room is this server actually serving, and since when.
|
|
6182
|
+
// The bundle hash alone is not enough to catch the failure that motivated it: a forgotten
|
|
6183
|
+
// `dev` from an earlier session serves an old bundle from an old process, and a reader
|
|
6184
|
+
// comparing two runs needs to see that the *process* did not restart either.
|
|
6185
|
+
startedAt,
|
|
3522
6186
|
bundle: {
|
|
3523
6187
|
file: bundle.file,
|
|
3524
6188
|
hash: bundle.hash,
|
|
@@ -3548,13 +6212,15 @@ function send(res, status, type, body) {
|
|
|
3548
6212
|
}
|
|
3549
6213
|
async function startDev(options = {}) {
|
|
3550
6214
|
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
6215
|
+
const startedAt = Date.now();
|
|
3551
6216
|
const log = options.log ?? ((line) => console.log(line));
|
|
3552
6217
|
const entry = resolveEntry(cwd, options.room);
|
|
3553
|
-
const
|
|
6218
|
+
const config = await readProjectConfig(cwd, "irtio dev", options.config);
|
|
6219
|
+
const projectId = config.project ?? "dev";
|
|
3554
6220
|
const outDir = path.join(cwd, ".irtio", "dev");
|
|
3555
6221
|
const port = options.port ?? DEFAULT_PORT;
|
|
3556
6222
|
await mkdir(outDir, { recursive: true });
|
|
3557
|
-
await warnIfClientImportsRoom(cwd, entry, log);
|
|
6223
|
+
await warnIfClientImportsRoom(cwd, entry, config, log);
|
|
3558
6224
|
const bundleOptions = {
|
|
3559
6225
|
entry,
|
|
3560
6226
|
outDir,
|
|
@@ -3576,6 +6242,69 @@ async function startDev(options = {}) {
|
|
|
3576
6242
|
send(res, 200, "text/html; charset=utf-8", DEV_PAGE);
|
|
3577
6243
|
return true;
|
|
3578
6244
|
}
|
|
6245
|
+
if (route === "/__irt/record.json" || route === "/__irt/timeline.json") {
|
|
6246
|
+
const query = new URL(req.url ?? "", "http://localhost");
|
|
6247
|
+
const roomId = query.searchParams.get("room") ?? "";
|
|
6248
|
+
void (async () => {
|
|
6249
|
+
try {
|
|
6250
|
+
if (route === "/__irt/record.json") {
|
|
6251
|
+
const maxTicks = Number(query.searchParams.get("maxTicks"));
|
|
6252
|
+
const maxRecords = Number(query.searchParams.get("maxRecords"));
|
|
6253
|
+
const started = await supervisor.startTimeline(roomId, {
|
|
6254
|
+
...Number.isInteger(maxTicks) && maxTicks > 0 ? { maxTicks } : {},
|
|
6255
|
+
...Number.isInteger(maxRecords) && maxRecords > 0 ? { maxRecords } : {}
|
|
6256
|
+
});
|
|
6257
|
+
send(
|
|
6258
|
+
res,
|
|
6259
|
+
started ? 200 : 404,
|
|
6260
|
+
"application/json; charset=utf-8",
|
|
6261
|
+
JSON.stringify({
|
|
6262
|
+
recording: started,
|
|
6263
|
+
room: roomId,
|
|
6264
|
+
...started ? {} : { error: `no running room ${roomId} with a worker to record` }
|
|
6265
|
+
})
|
|
6266
|
+
);
|
|
6267
|
+
return;
|
|
6268
|
+
}
|
|
6269
|
+
const dump = await supervisor.readTimeline(roomId);
|
|
6270
|
+
send(
|
|
6271
|
+
res,
|
|
6272
|
+
dump === void 0 ? 404 : 200,
|
|
6273
|
+
"application/json; charset=utf-8",
|
|
6274
|
+
JSON.stringify(
|
|
6275
|
+
dump ?? { error: `room ${roomId} has no recorded timeline`, room: roomId }
|
|
6276
|
+
)
|
|
6277
|
+
);
|
|
6278
|
+
} catch (err) {
|
|
6279
|
+
send(res, 500, "application/json; charset=utf-8", JSON.stringify({ error: String(err) }));
|
|
6280
|
+
}
|
|
6281
|
+
})();
|
|
6282
|
+
return true;
|
|
6283
|
+
}
|
|
6284
|
+
if (route === "/__irt/save.json") {
|
|
6285
|
+
const query = new URL(req.url ?? "", "http://localhost");
|
|
6286
|
+
const roomId = query.searchParams.get("room") ?? "";
|
|
6287
|
+
void (async () => {
|
|
6288
|
+
try {
|
|
6289
|
+
const saved = await supervisor.saveNow(roomId);
|
|
6290
|
+
send(
|
|
6291
|
+
res,
|
|
6292
|
+
saved === void 0 ? 404 : 200,
|
|
6293
|
+
"application/json; charset=utf-8",
|
|
6294
|
+
JSON.stringify(
|
|
6295
|
+
saved === void 0 ? { error: `no running room ${roomId} to save`, room: roomId } : {
|
|
6296
|
+
room: roomId,
|
|
6297
|
+
saveId: saved.saveId,
|
|
6298
|
+
bytes: Buffer.from(saved.bytes).toString("base64")
|
|
6299
|
+
}
|
|
6300
|
+
)
|
|
6301
|
+
);
|
|
6302
|
+
} catch (err) {
|
|
6303
|
+
send(res, 500, "application/json; charset=utf-8", JSON.stringify({ error: String(err) }));
|
|
6304
|
+
}
|
|
6305
|
+
})();
|
|
6306
|
+
return true;
|
|
6307
|
+
}
|
|
3579
6308
|
if (route === "/__irt/state.json") {
|
|
3580
6309
|
void (async () => {
|
|
3581
6310
|
try {
|
|
@@ -3583,7 +6312,7 @@ async function startDev(options = {}) {
|
|
|
3583
6312
|
res,
|
|
3584
6313
|
200,
|
|
3585
6314
|
"application/json; charset=utf-8",
|
|
3586
|
-
JSON.stringify(await buildState(supervisor, projectId, bundle))
|
|
6315
|
+
JSON.stringify(await buildState(supervisor, projectId, bundle, startedAt))
|
|
3587
6316
|
);
|
|
3588
6317
|
} catch (err) {
|
|
3589
6318
|
send(res, 500, "application/json; charset=utf-8", JSON.stringify({ error: String(err) }));
|
|
@@ -3604,6 +6333,7 @@ async function startDev(options = {}) {
|
|
|
3604
6333
|
store: new DiskStore(path.join(cwd, ".irtio", "snapshots")),
|
|
3605
6334
|
publicUrl: `http://localhost:${port}`,
|
|
3606
6335
|
httpHandler,
|
|
6336
|
+
...options.profile === true ? { profile: true } : {},
|
|
3607
6337
|
log: (level, ...args) => {
|
|
3608
6338
|
const line = args.map((a) => typeof a === "string" ? a : safeJson(a)).join(" ");
|
|
3609
6339
|
log(level === "error" ? pc.red(line) : level === "warn" ? pc.yellow(line) : pc.dim(line));
|
|
@@ -3685,8 +6415,23 @@ async function startDev(options = {}) {
|
|
|
3685
6415
|
}
|
|
3686
6416
|
};
|
|
3687
6417
|
}
|
|
6418
|
+
var USAGE = `usage: irtio dev [options]
|
|
6419
|
+
|
|
6420
|
+
Bundles the room file, runs a real supervisor on it locally, and serves the inspector on the same
|
|
6421
|
+
port. Rebuilds and resets the rooms whenever the room code changes.
|
|
6422
|
+
|
|
6423
|
+
options:
|
|
6424
|
+
--room <file> the room entry (default: irtio/room.ts, irtio/room.js, room.ts)
|
|
6425
|
+
--port <n> port to listen on (default 7070; 0 picks a free one)
|
|
6426
|
+
--no-watch do not rebuild on change
|
|
6427
|
+
-c, --config <f> the project file to read (default irtio.json)
|
|
6428
|
+
--profile print a bandwidth breakdown per room once a second
|
|
6429
|
+
--profile-top <n> rows per table (default 12; implies --profile)
|
|
6430
|
+
-h, --help print this
|
|
6431
|
+
`;
|
|
3688
6432
|
function parseDevArgs(args) {
|
|
3689
|
-
|
|
6433
|
+
if (helpRequested(args)) throw helpFor(USAGE);
|
|
6434
|
+
const parsed = { watch: true, profile: false };
|
|
3690
6435
|
for (let i = 0; i < args.length; i++) {
|
|
3691
6436
|
const arg = args[i];
|
|
3692
6437
|
const eq = arg.indexOf("=");
|
|
@@ -3712,6 +6457,22 @@ function parseDevArgs(args) {
|
|
|
3712
6457
|
case "--no-watch":
|
|
3713
6458
|
parsed.watch = false;
|
|
3714
6459
|
break;
|
|
6460
|
+
case "--profile":
|
|
6461
|
+
parsed.profile = true;
|
|
6462
|
+
break;
|
|
6463
|
+
case "--profile-top": {
|
|
6464
|
+
const n = Number(value());
|
|
6465
|
+
if (!Number.isInteger(n) || n < 1 || n > 200) {
|
|
6466
|
+
throw new Error("irtio dev: --profile-top must be a row count between 1 and 200");
|
|
6467
|
+
}
|
|
6468
|
+
parsed.profile = true;
|
|
6469
|
+
parsed.profileTop = n;
|
|
6470
|
+
break;
|
|
6471
|
+
}
|
|
6472
|
+
case "-c":
|
|
6473
|
+
case "--config":
|
|
6474
|
+
parsed.config = value();
|
|
6475
|
+
break;
|
|
3715
6476
|
default:
|
|
3716
6477
|
throw new Error(`irtio dev: unknown option ${JSON.stringify(arg)}`);
|
|
3717
6478
|
}
|
|
@@ -3719,15 +6480,22 @@ function parseDevArgs(args) {
|
|
|
3719
6480
|
return parsed;
|
|
3720
6481
|
}
|
|
3721
6482
|
async function dev(args) {
|
|
3722
|
-
|
|
6483
|
+
let parsed;
|
|
3723
6484
|
let server;
|
|
3724
6485
|
try {
|
|
6486
|
+
parsed = parseDevArgs(args);
|
|
3725
6487
|
server = await startDev({
|
|
3726
6488
|
watch: parsed.watch,
|
|
3727
6489
|
...parsed.room !== void 0 ? { room: parsed.room } : {},
|
|
3728
|
-
...parsed.port !== void 0 ? { port: parsed.port } : {}
|
|
6490
|
+
...parsed.port !== void 0 ? { port: parsed.port } : {},
|
|
6491
|
+
...parsed.config !== void 0 ? { config: parsed.config } : {},
|
|
6492
|
+
...parsed.profile ? { profile: true } : {}
|
|
3729
6493
|
});
|
|
3730
6494
|
} catch (err) {
|
|
6495
|
+
if (err instanceof HelpRequested) {
|
|
6496
|
+
console.log(err.usage);
|
|
6497
|
+
return;
|
|
6498
|
+
}
|
|
3731
6499
|
console.error(pc.red(err instanceof Error ? err.message : String(err)));
|
|
3732
6500
|
process.exitCode = 1;
|
|
3733
6501
|
return;
|
|
@@ -3739,10 +6507,42 @@ async function dev(args) {
|
|
|
3739
6507
|
`share links: http://localhost:${server.port}/?room=CODE (codes appear when a client joins)`
|
|
3740
6508
|
)
|
|
3741
6509
|
);
|
|
6510
|
+
let profileTimer;
|
|
6511
|
+
if (parsed.profile) {
|
|
6512
|
+
let history = /* @__PURE__ */ new Map();
|
|
6513
|
+
let busy = false;
|
|
6514
|
+
profileTimer = setInterval(() => {
|
|
6515
|
+
if (busy) return;
|
|
6516
|
+
busy = true;
|
|
6517
|
+
void (async () => {
|
|
6518
|
+
try {
|
|
6519
|
+
await server.supervisor.refreshTickHealth();
|
|
6520
|
+
const at = Date.now();
|
|
6521
|
+
const readings = server.supervisor.rooms().map((room) => ({
|
|
6522
|
+
id: room.id,
|
|
6523
|
+
profile: room.profile,
|
|
6524
|
+
egressBytes: room.metrics.egressBytes,
|
|
6525
|
+
ingressBytes: room.metrics.ingressBytes,
|
|
6526
|
+
at
|
|
6527
|
+
}));
|
|
6528
|
+
const tick = profileTick(readings, history, {
|
|
6529
|
+
...parsed.profileTop !== void 0 ? { top: parsed.profileTop } : {}
|
|
6530
|
+
});
|
|
6531
|
+
history = tick.history;
|
|
6532
|
+
for (const line of tick.lines) console.log(line);
|
|
6533
|
+
} catch {
|
|
6534
|
+
} finally {
|
|
6535
|
+
busy = false;
|
|
6536
|
+
}
|
|
6537
|
+
})();
|
|
6538
|
+
}, 1e3);
|
|
6539
|
+
profileTimer.unref?.();
|
|
6540
|
+
}
|
|
3742
6541
|
let stopping = false;
|
|
3743
6542
|
const shutdown = () => {
|
|
3744
6543
|
if (stopping) return;
|
|
3745
6544
|
stopping = true;
|
|
6545
|
+
if (profileTimer) clearInterval(profileTimer);
|
|
3746
6546
|
void (async () => {
|
|
3747
6547
|
await server.stop();
|
|
3748
6548
|
process.exit(0);
|
|
@@ -3754,6 +6554,7 @@ async function dev(args) {
|
|
|
3754
6554
|
});
|
|
3755
6555
|
}
|
|
3756
6556
|
export {
|
|
6557
|
+
USAGE,
|
|
3757
6558
|
dev,
|
|
3758
6559
|
parseDevArgs,
|
|
3759
6560
|
startDev
|