@irtio/cli 0.5.2 → 0.7.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-keys-UTLYMZYN.js +222 -0
- package/dist/api.d.ts +52 -0
- package/dist/api.js +15 -0
- package/dist/bundle.js +1 -1
- package/dist/{chunk-GBNHBWES.js → chunk-BQBOBFBO.js} +10 -6
- package/dist/chunk-IDF46P7R.js +98 -0
- package/dist/{chunk-32QTPKVT.js → chunk-JL235KIE.js} +1 -1
- package/dist/chunk-OCVALOGK.js +31 -0
- package/dist/{chunk-KRQUAEN2.js → chunk-OTSFRVJN.js} +12 -6
- package/dist/chunk-UPHQM6NZ.js +72 -0
- package/dist/chunk-WFMRNGO5.js +481 -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-MXUYNGAO.js +118 -0
- package/dist/deploy.d.ts +151 -0
- package/dist/{deploy-3SABPL3T.js → deploy.js} +324 -44
- package/dist/{dev-QJOGXLKM.js → dev-AUZ4OLA3.js} +3066 -211
- package/dist/index.js +121 -25
- package/dist/init.d.ts +1 -1
- package/dist/init.js +20 -4
- package/dist/{keys-XBORZAPI.js → keys-NXRIBJZP.js} +8 -4
- package/dist/leaderboard-ZBJBKETM.js +406 -0
- package/dist/{login-3EXB4CGX.js → login-3RVN5PPN.js} +12 -5
- package/dist/{logs-2EOXLNWF.js → logs-AT7G6YRH.js} +9 -5
- package/dist/{migrate-WUO2GBMX.js → migrate-FMXTRVUV.js} +12 -8
- package/dist/ratings-XBLX2MUW.js +297 -0
- package/dist/{rollback-GA6UY772.js → rollback-LI4TDLQA.js} +9 -5
- package/dist/rooms-52Q5KBUS.js +411 -0
- package/dist/simulate.d.ts +254 -6
- package/dist/simulate.js +925 -64
- package/dist/{static-deploy-5TBH4VNA.js → static-deploy-7UCYINJB.js} +6 -4
- package/dist/status-JZGKH2P6.js +219 -0
- package/dist/usage-7S447INI.js +213 -0
- package/dist/{whoami-CI5D5RCC.js → whoami-UFSWPWK6.js} +8 -4
- package/package.json +23 -7
- package/dist/chunk-BPE452KF.js +0 -180
- package/dist/chunk-TV66QHFP.js +0 -167
- package/dist/rooms-B66LQIIF.js +0 -226
|
@@ -1,29 +1,43 @@
|
|
|
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-WFMRNGO5.js";
|
|
19
27
|
import {
|
|
20
28
|
readProjectConfig
|
|
21
|
-
} from "./chunk-
|
|
29
|
+
} from "./chunk-JL235KIE.js";
|
|
30
|
+
import {
|
|
31
|
+
BundleError,
|
|
32
|
+
bundleRoom
|
|
33
|
+
} from "./chunk-OTSFRVJN.js";
|
|
22
34
|
import {
|
|
23
35
|
HelpRequested,
|
|
24
36
|
helpFor,
|
|
25
37
|
helpRequested
|
|
26
|
-
} from "./chunk-
|
|
38
|
+
} from "./chunk-OCVALOGK.js";
|
|
39
|
+
import "./chunk-IDF46P7R.js";
|
|
40
|
+
import "./chunk-UPHQM6NZ.js";
|
|
27
41
|
|
|
28
42
|
// src/dev.ts
|
|
29
43
|
import { existsSync } from "fs";
|
|
@@ -48,13 +62,20 @@ var DEFAULT_LIMITS = {
|
|
|
48
62
|
relayIdleMs: 3e4,
|
|
49
63
|
relayReconnectGraceMs: 3e4,
|
|
50
64
|
relayMaxClients: 64,
|
|
65
|
+
relayMaxClientsFree: 16,
|
|
66
|
+
freeMaxAwakeRooms: 10,
|
|
51
67
|
saveRetain: DEFAULT_SAVE_RETAIN
|
|
52
68
|
};
|
|
53
69
|
|
|
54
70
|
// ../supervisor/src/codes.ts
|
|
55
71
|
import { randomInt } from "crypto";
|
|
56
|
-
|
|
57
|
-
|
|
72
|
+
import {
|
|
73
|
+
CODE_ALPHABET,
|
|
74
|
+
DEFAULT_ROOM_TYPE,
|
|
75
|
+
ROOM_ID_RE,
|
|
76
|
+
formatRoomId,
|
|
77
|
+
parseRoomId
|
|
78
|
+
} from "@irtio/protocol";
|
|
58
79
|
var TENANT_ROOM_ID = "__tenant";
|
|
59
80
|
var CODE_SHAPE_RE = /^[A-Za-z0-9]{4,5}$/;
|
|
60
81
|
function pick(n) {
|
|
@@ -86,9 +107,14 @@ function looksLikeRoomCode(id) {
|
|
|
86
107
|
return true;
|
|
87
108
|
}
|
|
88
109
|
function normalizeRoomId(raw) {
|
|
89
|
-
|
|
90
|
-
if (
|
|
91
|
-
|
|
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;
|
|
92
118
|
}
|
|
93
119
|
|
|
94
120
|
// ../supervisor/src/resilience.ts
|
|
@@ -132,27 +158,40 @@ async function putSnapshotWithRetry(store, key, bytes, metrics, log) {
|
|
|
132
158
|
// ../supervisor/src/server.ts
|
|
133
159
|
import { execFile } from "child_process";
|
|
134
160
|
import { randomBytes, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
161
|
+
import { readdirSync } from "fs";
|
|
135
162
|
import { mkdtemp, writeFile } from "fs/promises";
|
|
136
163
|
import { createServer } from "http";
|
|
137
164
|
import { tmpdir } from "os";
|
|
138
165
|
import * as nodePath from "path";
|
|
139
166
|
import { pathToFileURL as pathToFileURL2 } from "url";
|
|
140
167
|
import {
|
|
141
|
-
|
|
142
|
-
|
|
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,
|
|
143
174
|
PROTOCOL_VERSION,
|
|
175
|
+
ROOM_TYPE_RE,
|
|
144
176
|
decodeFrame,
|
|
145
177
|
decodeHello,
|
|
146
|
-
decodeMsg,
|
|
147
178
|
decodePing,
|
|
179
|
+
decodeVoiceMessage,
|
|
148
180
|
encodeErrorPayload as encodeErrorPayload2,
|
|
149
|
-
encodeFrame as
|
|
150
|
-
encodeMsg,
|
|
181
|
+
encodeFrame as encodeFrame3,
|
|
182
|
+
encodeMsg as encodeMsg2,
|
|
151
183
|
encodePong,
|
|
184
|
+
encodeVoiceMessage,
|
|
152
185
|
encodeWelcome,
|
|
153
186
|
formatError,
|
|
187
|
+
isMailboxAlarm,
|
|
188
|
+
isOutboxAlarm,
|
|
154
189
|
isRelayHash8,
|
|
190
|
+
isTypedMsg,
|
|
191
|
+
isVoiceMsg,
|
|
155
192
|
relaySchema as relaySchema2,
|
|
193
|
+
schemaPayload,
|
|
194
|
+
typedMsgFromClientOk,
|
|
156
195
|
withBuiltins
|
|
157
196
|
} from "@irtio/protocol";
|
|
158
197
|
import { inspectState } from "@irtio/runtime";
|
|
@@ -179,6 +218,14 @@ var AlarmSet = class {
|
|
|
179
218
|
/** `name` -> wall-clock ms the alarm is due at. Wall clock, not monotonic: it has to mean the
|
|
180
219
|
* same thing to a control plane on another machine and across a tenant restart. */
|
|
181
220
|
due = /* @__PURE__ */ new Map();
|
|
221
|
+
/**
|
|
222
|
+
* D59: `name` -> the opaque payload string that entry carries, for the names that have one.
|
|
223
|
+
*
|
|
224
|
+
* A separate map rather than a field on a richer value type, so that every alarm which is not a
|
|
225
|
+
* bus mailbox entry is stored exactly as it was: an alarm is a name and a time, and D26's whole
|
|
226
|
+
* surface stays that. Only mailbox entries (`BUS_MAILBOX_PREFIX`) ever put anything here.
|
|
227
|
+
*/
|
|
228
|
+
payloads = /* @__PURE__ */ new Map();
|
|
182
229
|
timer;
|
|
183
230
|
/** Called when one or more alarms come due, with the names in deterministic (name) order. */
|
|
184
231
|
deliver;
|
|
@@ -197,13 +244,34 @@ var AlarmSet = class {
|
|
|
197
244
|
this.deliver = void 0;
|
|
198
245
|
this.clearTimer();
|
|
199
246
|
}
|
|
200
|
-
/**
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
247
|
+
/**
|
|
248
|
+
* `atMs === undefined` cancels. Arming a name that is already armed replaces its due time.
|
|
249
|
+
*
|
|
250
|
+
* D59: `payload` attaches an opaque string to the entry, which is how a bus mailbox entry rides
|
|
251
|
+
* this machinery. **Replace-by-name is why mailbox names are minted unique**: two sends of the
|
|
252
|
+
* same payload to the same room have to deliver twice, and a stable name would have the second
|
|
253
|
+
* quietly overwrite the first.
|
|
254
|
+
*/
|
|
255
|
+
set(name, atMs, payload) {
|
|
256
|
+
if (atMs === void 0) {
|
|
257
|
+
this.due.delete(name);
|
|
258
|
+
this.payloads.delete(name);
|
|
259
|
+
} else {
|
|
260
|
+
this.due.set(name, atMs);
|
|
261
|
+
if (payload === void 0) this.payloads.delete(name);
|
|
262
|
+
else this.payloads.set(name, payload);
|
|
263
|
+
}
|
|
204
264
|
this.rearm();
|
|
205
265
|
this.onChange?.();
|
|
206
266
|
}
|
|
267
|
+
/** D59: the payload armed with `name`, or `undefined` for an ordinary alarm. */
|
|
268
|
+
payloadOf(name) {
|
|
269
|
+
return this.payloads.get(name);
|
|
270
|
+
}
|
|
271
|
+
/** D59: how many armed entries carry a payload, i.e. the depth of this room's mailbox. */
|
|
272
|
+
get mailboxDepth() {
|
|
273
|
+
return this.payloads.size;
|
|
274
|
+
}
|
|
207
275
|
get size() {
|
|
208
276
|
return this.due.size;
|
|
209
277
|
}
|
|
@@ -212,12 +280,19 @@ var AlarmSet = class {
|
|
|
212
280
|
/** Replaces the armed set wholesale — how a room start re-arms from the persisted sidecar. */
|
|
213
281
|
load(entries) {
|
|
214
282
|
this.due.clear();
|
|
215
|
-
|
|
283
|
+
this.payloads.clear();
|
|
284
|
+
for (const e of entries) {
|
|
285
|
+
this.due.set(e.name, e.dueAt);
|
|
286
|
+
if (e.payload !== void 0) this.payloads.set(e.name, e.payload);
|
|
287
|
+
}
|
|
216
288
|
this.rearm();
|
|
217
289
|
}
|
|
218
290
|
/** Snapshot for `/admin/rooms` and for tests. */
|
|
219
291
|
entries() {
|
|
220
|
-
return [...this.due.entries()].map(([name, dueAt]) =>
|
|
292
|
+
return [...this.due.entries()].map(([name, dueAt]) => {
|
|
293
|
+
const payload = this.payloads.get(name);
|
|
294
|
+
return { name, dueAt, ...payload !== void 0 ? { payload } : {} };
|
|
295
|
+
}).sort((a, b) => a.dueAt - b.dueAt || (a.name < b.name ? -1 : 1));
|
|
221
296
|
}
|
|
222
297
|
/** The earliest due time, or `undefined` when nothing is armed. What control is told. */
|
|
223
298
|
get earliestDueAt() {
|
|
@@ -236,6 +311,7 @@ var AlarmSet = class {
|
|
|
236
311
|
if (names.length === 0) return names;
|
|
237
312
|
for (const name of names) this.due.delete(name);
|
|
238
313
|
this.deliver?.(names);
|
|
314
|
+
for (const name of names) if (!this.due.has(name)) this.payloads.delete(name);
|
|
239
315
|
this.rearm();
|
|
240
316
|
this.onChange?.();
|
|
241
317
|
return names;
|
|
@@ -296,11 +372,70 @@ function decodeAlarms(bytes) {
|
|
|
296
372
|
const e = entry;
|
|
297
373
|
if (typeof e.name !== "string" || e.name === "") continue;
|
|
298
374
|
if (typeof e.dueAt !== "number" || !Number.isFinite(e.dueAt)) continue;
|
|
299
|
-
|
|
375
|
+
const payload = e.payload;
|
|
376
|
+
out.push({
|
|
377
|
+
name: e.name,
|
|
378
|
+
dueAt: e.dueAt,
|
|
379
|
+
...typeof payload === "string" ? { payload } : {}
|
|
380
|
+
});
|
|
300
381
|
}
|
|
301
382
|
return out;
|
|
302
383
|
}
|
|
303
384
|
|
|
385
|
+
// ../supervisor/src/relay-host.ts
|
|
386
|
+
import {
|
|
387
|
+
ErrorCode,
|
|
388
|
+
FrameType,
|
|
389
|
+
decodeMsg,
|
|
390
|
+
encodeFrame,
|
|
391
|
+
encodeMsg
|
|
392
|
+
} from "@irtio/protocol";
|
|
393
|
+
function relayJoin(room, peer) {
|
|
394
|
+
const relay = room.relayRoom;
|
|
395
|
+
if (!relay.has(peer.clientId) && relay.size >= room.maxClients) {
|
|
396
|
+
return { ok: false, code: "E_ROOM_FULL" };
|
|
397
|
+
}
|
|
398
|
+
const delta = relay.add(peer.clientId, peer.role, peer.name);
|
|
399
|
+
return { ok: true, tick: relay.tick, snapshot: relay.snapshot(), delta };
|
|
400
|
+
}
|
|
401
|
+
function relayForward(room, peer, type, payload) {
|
|
402
|
+
if (type !== FrameType.MSG) {
|
|
403
|
+
return { ok: false, reason: "relay rooms accept MSG only (no room code is deployed)" };
|
|
404
|
+
}
|
|
405
|
+
let target;
|
|
406
|
+
let body;
|
|
407
|
+
let typed;
|
|
408
|
+
try {
|
|
409
|
+
const msg = decodeMsg(payload);
|
|
410
|
+
target = msg.target;
|
|
411
|
+
body = msg.payload;
|
|
412
|
+
typed = msg.typed;
|
|
413
|
+
} catch {
|
|
414
|
+
return { ok: false, reason: "malformed MSG" };
|
|
415
|
+
}
|
|
416
|
+
if (target.kind === "server") {
|
|
417
|
+
return { ok: false, reason: "relay rooms have no server to address" };
|
|
418
|
+
}
|
|
419
|
+
if (target.kind === "voice") return { ok: true, dropped: true };
|
|
420
|
+
const out = encodeFrame(
|
|
421
|
+
FrameType.MSG,
|
|
422
|
+
encodeMsg({
|
|
423
|
+
target: { kind: "client", clientId: peer.clientId },
|
|
424
|
+
payload: body,
|
|
425
|
+
...typed ? { typed } : {}
|
|
426
|
+
})
|
|
427
|
+
);
|
|
428
|
+
for (const other of room.peers()) {
|
|
429
|
+
if (other === peer || other.state !== "joined") continue;
|
|
430
|
+
if (target.kind === "client" && other.clientId !== target.clientId) continue;
|
|
431
|
+
if (target.kind === "role" && other.role !== target.role) continue;
|
|
432
|
+
const sent = other.send(out);
|
|
433
|
+
if (sent > 0) room.countEgress(sent);
|
|
434
|
+
}
|
|
435
|
+
room.touched(room.relayRoom.advance());
|
|
436
|
+
return { ok: true };
|
|
437
|
+
}
|
|
438
|
+
|
|
304
439
|
// ../supervisor/src/auth.ts
|
|
305
440
|
import { createHmac, timingSafeEqual } from "crypto";
|
|
306
441
|
import { isLocalhostOrigin, originAllowed } from "@irtio/protocol";
|
|
@@ -372,6 +507,13 @@ function verifyJwt(issuers, token, projectId, now = Date.now()) {
|
|
|
372
507
|
if (typeof claims.iss !== "string" || !PLAYER_ISSUER_RE.test(claims.iss)) {
|
|
373
508
|
return malformed("iss is required: 1-64 of a-z 0-9 . _ - (never a colon)");
|
|
374
509
|
}
|
|
510
|
+
if (claims.iss === IRT_IDENTITY_ISSUER) {
|
|
511
|
+
return {
|
|
512
|
+
ok: false,
|
|
513
|
+
code: "E_TOKEN_BAD_ISSUER",
|
|
514
|
+
reason: `${IRT_IDENTITY_ISSUER} is reserved for platform identities`
|
|
515
|
+
};
|
|
516
|
+
}
|
|
375
517
|
if (typeof claims.aud !== "string") {
|
|
376
518
|
return malformed("aud (the project id) is required");
|
|
377
519
|
}
|
|
@@ -425,7 +567,7 @@ function mac(secret, payload) {
|
|
|
425
567
|
return createHmac("sha256", secret).update(payload).digest("base64url");
|
|
426
568
|
}
|
|
427
569
|
function signResume(secret, p) {
|
|
428
|
-
const payload = `${p.clientId}|${p.roomId}|${encodeURIComponent(p.role)}|${p.expMs}`;
|
|
570
|
+
const payload = `${p.clientId}|${p.roomId}|${encodeURIComponent(p.role)}|${p.expMs}|${p.iatMs}`;
|
|
429
571
|
return Buffer.from(`${payload}.${mac(secret, payload)}`, "utf8").toString("base64url");
|
|
430
572
|
}
|
|
431
573
|
function verifyResume(secret, token, now = Date.now()) {
|
|
@@ -444,17 +586,428 @@ function verifyResume(secret, token, now = Date.now()) {
|
|
|
444
586
|
const b = Buffer.from(expected, "utf8");
|
|
445
587
|
if (a.length !== b.length || !timingSafeEqual(a, b)) return void 0;
|
|
446
588
|
const parts = payload.split("|");
|
|
447
|
-
if (parts.length !==
|
|
448
|
-
const [clientId, roomId, encodedRole, expText] = parts;
|
|
589
|
+
if (parts.length !== 5) return void 0;
|
|
590
|
+
const [clientId, roomId, encodedRole, expText, iatText] = parts;
|
|
449
591
|
const expMs = Number(expText);
|
|
450
592
|
if (!Number.isFinite(expMs) || expMs <= now) return void 0;
|
|
593
|
+
const iatMs = Number(iatText);
|
|
594
|
+
if (!Number.isFinite(iatMs)) return void 0;
|
|
451
595
|
let role;
|
|
452
596
|
try {
|
|
453
597
|
role = decodeURIComponent(encodedRole);
|
|
454
598
|
} catch {
|
|
455
599
|
return void 0;
|
|
456
600
|
}
|
|
457
|
-
return { clientId, roomId, role, expMs };
|
|
601
|
+
return { clientId, roomId, role, expMs, iatMs };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// ../supervisor/src/bandwidth.ts
|
|
605
|
+
var FREE_BANDWIDTH_CEILING_BPS = 25e6;
|
|
606
|
+
var CARD_BANDWIDTH_CEILING_BPS = 1e8;
|
|
607
|
+
var BANDWIDTH_WINDOW_MS = 1e4;
|
|
608
|
+
var BUCKETS = 10;
|
|
609
|
+
var BUCKET_MS = BANDWIDTH_WINDOW_MS / BUCKETS;
|
|
610
|
+
function bandwidthCeilingBps(plan) {
|
|
611
|
+
return plan === "card" ? CARD_BANDWIDTH_CEILING_BPS : FREE_BANDWIDTH_CEILING_BPS;
|
|
612
|
+
}
|
|
613
|
+
var BandwidthCeiling = class {
|
|
614
|
+
constructor(now = Date.now) {
|
|
615
|
+
this.now = now;
|
|
616
|
+
}
|
|
617
|
+
now;
|
|
618
|
+
buckets = new Float64Array(BUCKETS);
|
|
619
|
+
total = 0;
|
|
620
|
+
head = 0;
|
|
621
|
+
headAt = 0;
|
|
622
|
+
started = false;
|
|
623
|
+
ceiling = 0;
|
|
624
|
+
engagedNow = false;
|
|
625
|
+
/** Rooms already warned during the current engagement. Cleared when the ceiling disengages. */
|
|
626
|
+
warned = /* @__PURE__ */ new Set();
|
|
627
|
+
/**
|
|
628
|
+
* Sets the plan whose ceiling is enforced. `undefined` — a box control has never spoken to —
|
|
629
|
+
* disables enforcement, which is the documented failure direction.
|
|
630
|
+
*/
|
|
631
|
+
setPlan(plan) {
|
|
632
|
+
this.ceiling = plan === void 0 ? 0 : bandwidthCeilingBps(plan);
|
|
633
|
+
if (this.ceiling === 0) this.disengage();
|
|
634
|
+
}
|
|
635
|
+
/** Counts bytes that left the box. Called from the same place the lease is spent. */
|
|
636
|
+
count(bytes) {
|
|
637
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return;
|
|
638
|
+
this.roll();
|
|
639
|
+
this.buckets[this.head] = (this.buckets[this.head] ?? 0) + bytes;
|
|
640
|
+
this.total += bytes;
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Whether forwarding must stop right now.
|
|
644
|
+
*
|
|
645
|
+
* Reads the clock as well as the counters, so a project that stops sending recovers as the
|
|
646
|
+
* window empties rather than only when it next sends something.
|
|
647
|
+
*/
|
|
648
|
+
get engaged() {
|
|
649
|
+
if (this.ceiling <= 0) return false;
|
|
650
|
+
this.roll();
|
|
651
|
+
const over = this.bitsPerSec > this.ceiling;
|
|
652
|
+
if (over && !this.engagedNow) this.engagedNow = true;
|
|
653
|
+
else if (!over && this.engagedNow) this.disengage();
|
|
654
|
+
return this.engagedNow;
|
|
655
|
+
}
|
|
656
|
+
/** The measured rate over the whole window, in bits per second. */
|
|
657
|
+
get bitsPerSec() {
|
|
658
|
+
this.roll();
|
|
659
|
+
return this.total * 8 / (BANDWIDTH_WINDOW_MS / 1e3);
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Whether this room still owes a warning for the current engagement — true exactly once per room
|
|
663
|
+
* per engagement, so a developer learns why their messages vanished without the ceiling turning
|
|
664
|
+
* into a log flood at frame rate.
|
|
665
|
+
*/
|
|
666
|
+
takeWarning(roomId) {
|
|
667
|
+
if (!this.engagedNow || this.warned.has(roomId)) return false;
|
|
668
|
+
this.warned.add(roomId);
|
|
669
|
+
return true;
|
|
670
|
+
}
|
|
671
|
+
state() {
|
|
672
|
+
return {
|
|
673
|
+
enforcing: this.ceiling > 0,
|
|
674
|
+
ceilingBps: this.ceiling,
|
|
675
|
+
bitsPerSec: this.bitsPerSec,
|
|
676
|
+
engaged: this.engaged
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
disengage() {
|
|
680
|
+
this.engagedNow = false;
|
|
681
|
+
this.warned.clear();
|
|
682
|
+
}
|
|
683
|
+
/** Advances the ring to now, zeroing the buckets that have aged out of the window. */
|
|
684
|
+
roll() {
|
|
685
|
+
const t = this.now();
|
|
686
|
+
if (!this.started) {
|
|
687
|
+
this.started = true;
|
|
688
|
+
this.headAt = t - t % BUCKET_MS;
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
let steps = Math.floor((t - this.headAt) / BUCKET_MS);
|
|
692
|
+
if (steps <= 0) return;
|
|
693
|
+
if (steps >= BUCKETS) {
|
|
694
|
+
this.buckets.fill(0);
|
|
695
|
+
this.total = 0;
|
|
696
|
+
this.head = 0;
|
|
697
|
+
} else {
|
|
698
|
+
while (steps-- > 0) {
|
|
699
|
+
this.head = (this.head + 1) % BUCKETS;
|
|
700
|
+
this.total -= this.buckets[this.head] ?? 0;
|
|
701
|
+
this.buckets[this.head] = 0;
|
|
702
|
+
}
|
|
703
|
+
if (this.total < 0) this.total = 0;
|
|
704
|
+
}
|
|
705
|
+
this.headAt = t - t % BUCKET_MS;
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
function bandwidthWarning(plan) {
|
|
709
|
+
const mbit = Math.round(bandwidthCeilingBps(plan) / 1e6);
|
|
710
|
+
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");
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// ../supervisor/src/bus.ts
|
|
714
|
+
import {
|
|
715
|
+
BUS_ERRORS,
|
|
716
|
+
BUS_LIMITS,
|
|
717
|
+
BUS_MAILBOX_PREFIX,
|
|
718
|
+
BUS_OUTBOX,
|
|
719
|
+
BUS_OUTBOX_PREFIX,
|
|
720
|
+
busChannelProblem,
|
|
721
|
+
busPayloadProblem
|
|
722
|
+
} from "@irtio/protocol";
|
|
723
|
+
function newBusBucket() {
|
|
724
|
+
return new TokenBucket(BUS_LIMITS.opsBurst, BUS_LIMITS.opsPerSecond);
|
|
725
|
+
}
|
|
726
|
+
function mintMailboxName(counter) {
|
|
727
|
+
return `${BUS_MAILBOX_PREFIX}${Date.now().toString(36)}.${counter.toString(36)}`;
|
|
728
|
+
}
|
|
729
|
+
function encodeMailboxEntry(entry) {
|
|
730
|
+
return JSON.stringify(entry);
|
|
731
|
+
}
|
|
732
|
+
function decodeMailboxEntry(payload) {
|
|
733
|
+
if (payload === void 0) return void 0;
|
|
734
|
+
let parsed;
|
|
735
|
+
try {
|
|
736
|
+
parsed = JSON.parse(payload);
|
|
737
|
+
} catch {
|
|
738
|
+
return void 0;
|
|
739
|
+
}
|
|
740
|
+
const e = parsed;
|
|
741
|
+
if (!e || typeof e.from !== "string" || typeof e.payload !== "string") return void 0;
|
|
742
|
+
const attempts = typeof e.attempts === "number" && Number.isFinite(e.attempts) ? e.attempts : 0;
|
|
743
|
+
return { from: e.from, payload: e.payload, attempts };
|
|
744
|
+
}
|
|
745
|
+
var BusRegistry = class {
|
|
746
|
+
channels = /* @__PURE__ */ new Map();
|
|
747
|
+
/** roomId -> the channels it is in, so dropping a room is one pass rather than a scan. */
|
|
748
|
+
byRoom = /* @__PURE__ */ new Map();
|
|
749
|
+
/**
|
|
750
|
+
* Adds `roomId` to `channel`. Returns a problem when the channel name is illegal or the room is
|
|
751
|
+
* already holding as many subscriptions as it may.
|
|
752
|
+
*/
|
|
753
|
+
subscribe(roomId, channel) {
|
|
754
|
+
const problem = busChannelProblem(channel);
|
|
755
|
+
if (problem) return problem;
|
|
756
|
+
const mine = this.byRoom.get(roomId);
|
|
757
|
+
if (mine !== void 0 && !mine.has(channel) && mine.size >= BUS_LIMITS.subscriptionsPerRoom) {
|
|
758
|
+
return {
|
|
759
|
+
code: BUS_ERRORS.tooManySubscriptions,
|
|
760
|
+
message: `a room may subscribe to at most ${BUS_LIMITS.subscriptionsPerRoom} channels`
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
let subs = this.channels.get(channel);
|
|
764
|
+
if (!subs) this.channels.set(channel, subs = /* @__PURE__ */ new Set());
|
|
765
|
+
subs.add(roomId);
|
|
766
|
+
(mine ?? this.byRoom.set(roomId, /* @__PURE__ */ new Set()).get(roomId)).add(channel);
|
|
767
|
+
return void 0;
|
|
768
|
+
}
|
|
769
|
+
unsubscribe(roomId, channel) {
|
|
770
|
+
const subs = this.channels.get(channel);
|
|
771
|
+
if (subs) {
|
|
772
|
+
subs.delete(roomId);
|
|
773
|
+
if (subs.size === 0) this.channels.delete(channel);
|
|
774
|
+
}
|
|
775
|
+
const mine = this.byRoom.get(roomId);
|
|
776
|
+
if (mine) {
|
|
777
|
+
mine.delete(channel);
|
|
778
|
+
if (mine.size === 0) this.byRoom.delete(roomId);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Drops every subscription this room holds. Called when a room is closed for good.
|
|
783
|
+
*
|
|
784
|
+
* Deliberately NOT called on hibernation. What makes "publish reaches awake subscribers only"
|
|
785
|
+
* true is the liveness check in the supervisor's fan-out, which posts to a subscriber only when
|
|
786
|
+
* it is `running` with a live worker; keeping the bookkeeping in step as well would mean
|
|
787
|
+
* chasing the eight separate places a worker can go away, and missing one of those would be a
|
|
788
|
+
* silent correctness bug rather than a leak. A hibernated room's stale entry costs a set member
|
|
789
|
+
* and is overwritten when its definition re-subscribes on the next wake.
|
|
790
|
+
*/
|
|
791
|
+
unsubscribeAll(roomId) {
|
|
792
|
+
const mine = this.byRoom.get(roomId);
|
|
793
|
+
if (!mine) return;
|
|
794
|
+
for (const channel of mine) {
|
|
795
|
+
const subs = this.channels.get(channel);
|
|
796
|
+
if (!subs) continue;
|
|
797
|
+
subs.delete(roomId);
|
|
798
|
+
if (subs.size === 0) this.channels.delete(channel);
|
|
799
|
+
}
|
|
800
|
+
this.byRoom.delete(roomId);
|
|
801
|
+
}
|
|
802
|
+
/** Every room subscribed to `channel` except `exclude`, which is how a publisher is never told
|
|
803
|
+
* its own message. Sorted, so a fan-out is deterministic and a test can assert an order. */
|
|
804
|
+
subscribers(channel, exclude) {
|
|
805
|
+
const subs = this.channels.get(channel);
|
|
806
|
+
if (!subs) return [];
|
|
807
|
+
return [...subs].filter((id) => id !== exclude).sort();
|
|
808
|
+
}
|
|
809
|
+
/** The channels `roomId` currently holds, for `/admin/rooms` and for tests. */
|
|
810
|
+
subscriptionsOf(roomId) {
|
|
811
|
+
return [...this.byRoom.get(roomId) ?? []].sort();
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
function mintOutboxName(counter) {
|
|
815
|
+
return `${BUS_OUTBOX_PREFIX}${Date.now().toString(36)}.${counter.toString(36)}`;
|
|
816
|
+
}
|
|
817
|
+
function encodeOutboxEntry(entry) {
|
|
818
|
+
return JSON.stringify(entry);
|
|
819
|
+
}
|
|
820
|
+
function decodeOutboxEntry(payload) {
|
|
821
|
+
if (payload === void 0) return void 0;
|
|
822
|
+
let parsed;
|
|
823
|
+
try {
|
|
824
|
+
parsed = JSON.parse(payload);
|
|
825
|
+
} catch {
|
|
826
|
+
return void 0;
|
|
827
|
+
}
|
|
828
|
+
const e = parsed;
|
|
829
|
+
if (!e || typeof e.to !== "string" || typeof e.from !== "string" || typeof e.payload !== "string" || typeof e.id !== "string") {
|
|
830
|
+
return void 0;
|
|
831
|
+
}
|
|
832
|
+
return {
|
|
833
|
+
to: e.to,
|
|
834
|
+
from: e.from,
|
|
835
|
+
payload: e.payload,
|
|
836
|
+
id: e.id,
|
|
837
|
+
firstAt: typeof e.firstAt === "number" && Number.isFinite(e.firstAt) ? e.firstAt : Date.now(),
|
|
838
|
+
attempts: typeof e.attempts === "number" && Number.isFinite(e.attempts) ? e.attempts : 0
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
function outboxRetryDelayMs(attempts) {
|
|
842
|
+
return Math.min(BUS_OUTBOX.firstRetryMs * 2 ** Math.max(0, attempts), BUS_OUTBOX.maxRetryMs);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// ../supervisor/src/caps.ts
|
|
846
|
+
function parseCapState(value) {
|
|
847
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
848
|
+
const raw = value;
|
|
849
|
+
if (typeof raw.project !== "string" || raw.project === "") return void 0;
|
|
850
|
+
if (!Array.isArray(raw.meters)) return void 0;
|
|
851
|
+
const meters = [];
|
|
852
|
+
for (const entry of raw.meters) {
|
|
853
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
854
|
+
const m = entry;
|
|
855
|
+
if (typeof m.meter !== "string" || m.meter === "") continue;
|
|
856
|
+
if (typeof m.used !== "number" || !Number.isFinite(m.used)) continue;
|
|
857
|
+
const limit = typeof m.limit === "number" && Number.isFinite(m.limit) ? m.limit : null;
|
|
858
|
+
const mode = m.mode === "hard" || m.mode === "soft" ? m.mode : "none";
|
|
859
|
+
const status = m.status === "crossed" || m.status === "warn" ? m.status : "under";
|
|
860
|
+
const lease = typeof m.lease === "number" && Number.isFinite(m.lease) && m.lease >= 0 ? Math.floor(m.lease) : void 0;
|
|
861
|
+
meters.push({
|
|
862
|
+
meter: m.meter,
|
|
863
|
+
used: m.used,
|
|
864
|
+
limit,
|
|
865
|
+
mode,
|
|
866
|
+
status,
|
|
867
|
+
...lease !== void 0 ? { lease } : {}
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
return {
|
|
871
|
+
project: raw.project,
|
|
872
|
+
plan: raw.plan === "card" ? "card" : "free",
|
|
873
|
+
at: typeof raw.at === "number" && Number.isFinite(raw.at) ? raw.at : Date.now(),
|
|
874
|
+
periodStart: typeof raw.periodStart === "string" ? raw.periodStart : "",
|
|
875
|
+
meters
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
var CapGate = class {
|
|
879
|
+
state;
|
|
880
|
+
/** Replaces the cached state wholesale. Returns the state it stored, for the admin reply. */
|
|
881
|
+
apply(state) {
|
|
882
|
+
this.state = state;
|
|
883
|
+
return state;
|
|
884
|
+
}
|
|
885
|
+
/** What the box currently believes, for `/admin/caps` reads and for tests. */
|
|
886
|
+
current() {
|
|
887
|
+
return this.state;
|
|
888
|
+
}
|
|
889
|
+
/** The plan the box currently believes this project is on. `undefined` before the first push. */
|
|
890
|
+
plan() {
|
|
891
|
+
return this.state?.plan;
|
|
892
|
+
}
|
|
893
|
+
/** M5 part 3.5: the leased budget for a meter, or `undefined` when it is not leased. */
|
|
894
|
+
leaseFor(meter) {
|
|
895
|
+
return this.state?.meters.find((m) => m.meter === meter)?.lease;
|
|
896
|
+
}
|
|
897
|
+
/** How stale the cached state is, in ms, or `undefined` when there is none. */
|
|
898
|
+
ageMs(nowMs = Date.now()) {
|
|
899
|
+
return this.state === void 0 ? void 0 : nowMs - this.state.at;
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* The refusal for a meter whose cap is a crossed HARD cap, or `undefined` when the work may
|
|
903
|
+
* proceed.
|
|
904
|
+
*
|
|
905
|
+
* A `soft` crossing returns nothing. That is D48's card-on-file rule in one line: a soft cap
|
|
906
|
+
* alerts and keeps serving, and the alerting happens in control where the mailer is.
|
|
907
|
+
*/
|
|
908
|
+
refusal(meter) {
|
|
909
|
+
const state = this.state;
|
|
910
|
+
if (state === void 0) return void 0;
|
|
911
|
+
const found = state.meters.find((m) => m.meter === meter);
|
|
912
|
+
if (!found || found.mode !== "hard" || found.status !== "crossed") return void 0;
|
|
913
|
+
return capRefusalMessage(meter, state.plan);
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
function capRefusalMessage(meter, plan) {
|
|
917
|
+
const what = meter === "egress_bytes" ? "data out" : meter === "room_hours" ? "room hours" : meter === "storage_bytes" ? "storage" : meter.replace(/_/g, " ");
|
|
918
|
+
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";
|
|
919
|
+
return `this project is at its ${what} cap for the current billing period; ${fix}`;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// ../supervisor/src/egress-lease.ts
|
|
923
|
+
var EGRESS_LEASE_CHUNK_BYTES = 5 * 1024 * 1024 * 1024;
|
|
924
|
+
var EgressLease = class {
|
|
925
|
+
remaining = 0;
|
|
926
|
+
spent = 0;
|
|
927
|
+
leasedEver = false;
|
|
928
|
+
period = "";
|
|
929
|
+
trippedNow = false;
|
|
930
|
+
/**
|
|
931
|
+
* Replaces the local budget with a freshly leased one.
|
|
932
|
+
*
|
|
933
|
+
* Wholesale, never additive, and that is the load-bearing choice: `remainingBytes` is computed
|
|
934
|
+
* by control from the project's allowance minus the usage it has actually received, so adding
|
|
935
|
+
* successive leases together would count the same allowance more than once. A new lease is a new
|
|
936
|
+
* statement of what is left, not a top-up.
|
|
937
|
+
*
|
|
938
|
+
* A lease of zero is a real lease and trips the wall. A lease for a different period untrips it,
|
|
939
|
+
* because the month rolled and the project's allowance came back.
|
|
940
|
+
*/
|
|
941
|
+
apply(lease) {
|
|
942
|
+
const granted = Number.isFinite(lease.remainingBytes) ? Math.max(0, Math.floor(lease.remainingBytes)) : 0;
|
|
943
|
+
this.remaining = granted;
|
|
944
|
+
this.spent = 0;
|
|
945
|
+
this.period = lease.periodStart;
|
|
946
|
+
this.leasedEver = true;
|
|
947
|
+
this.trippedNow = granted <= 0;
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Counts bytes that left the box, and returns `true` when this call is the one that trips the
|
|
951
|
+
* wall — so the caller can close sockets exactly once rather than on every subsequent frame.
|
|
952
|
+
*/
|
|
953
|
+
spend(bytes) {
|
|
954
|
+
if (!this.leasedEver) return false;
|
|
955
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return false;
|
|
956
|
+
this.spent += bytes;
|
|
957
|
+
this.remaining -= bytes;
|
|
958
|
+
if (this.remaining > 0 || this.trippedNow) return false;
|
|
959
|
+
this.trippedNow = true;
|
|
960
|
+
return true;
|
|
961
|
+
}
|
|
962
|
+
/** Whether forwarding must stop. False on a box that has never been leased anything. */
|
|
963
|
+
get tripped() {
|
|
964
|
+
return this.leasedEver && this.trippedNow;
|
|
965
|
+
}
|
|
966
|
+
/** Whether control has ever leased this box anything. */
|
|
967
|
+
get leased() {
|
|
968
|
+
return this.leasedEver;
|
|
969
|
+
}
|
|
970
|
+
state() {
|
|
971
|
+
return {
|
|
972
|
+
leased: this.leasedEver,
|
|
973
|
+
remainingBytes: Math.max(0, this.remaining),
|
|
974
|
+
spentBytes: this.spent,
|
|
975
|
+
tripped: this.tripped,
|
|
976
|
+
periodStart: this.period
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
};
|
|
980
|
+
function egressWallMessage(plan) {
|
|
981
|
+
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";
|
|
982
|
+
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}`;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// ../supervisor/src/heap-caps.ts
|
|
986
|
+
import {
|
|
987
|
+
DEFAULT_MEMORY_MB,
|
|
988
|
+
NATIVE_RESERVE_FRACTION as PROTOCOL_NATIVE_RESERVE_FRACTION,
|
|
989
|
+
SUPERVISOR_BASELINE_MIB as PROTOCOL_SUPERVISOR_BASELINE_MIB,
|
|
990
|
+
YOUNG_GEN_MB,
|
|
991
|
+
declarationsFit,
|
|
992
|
+
vmSizeFor
|
|
993
|
+
} from "@irtio/protocol";
|
|
994
|
+
var SUPERVISOR_BASELINE_MIB = PROTOCOL_SUPERVISOR_BASELINE_MIB;
|
|
995
|
+
var HEAP_WARN_FRACTION = 0.8;
|
|
996
|
+
function checkDeclaredFit(vmMemMib, declared, baselineMib = SUPERVISOR_BASELINE_MIB) {
|
|
997
|
+
const types = declared.map((d) => ({
|
|
998
|
+
type: d.type,
|
|
999
|
+
...d.memoryMb !== void 0 ? { memoryMb: d.memoryMb } : {},
|
|
1000
|
+
...d.maxAwake !== void 0 ? { maxAwake: d.maxAwake } : {},
|
|
1001
|
+
...d.physics === true ? { physics: true } : {}
|
|
1002
|
+
}));
|
|
1003
|
+
const verdict = declarationsFit(vmMemMib, types, { baselineMib });
|
|
1004
|
+
return {
|
|
1005
|
+
fits: verdict.fits,
|
|
1006
|
+
requiredMib: verdict.required.vmMib,
|
|
1007
|
+
budgetMib: vmMemMib,
|
|
1008
|
+
undeclared: declared.filter((d) => d.memoryMb === void 0).map((d) => d.type),
|
|
1009
|
+
detail: `${verdict.required.detail}, against a ${vmMemMib} MiB VM`
|
|
1010
|
+
};
|
|
458
1011
|
}
|
|
459
1012
|
|
|
460
1013
|
// ../supervisor/src/relay.ts
|
|
@@ -560,6 +1113,15 @@ var RelayRoom = class _RelayRoom {
|
|
|
560
1113
|
return this.snapshot();
|
|
561
1114
|
}
|
|
562
1115
|
};
|
|
1116
|
+
function isRelayPresenceSnapshot(bytes) {
|
|
1117
|
+
if (!bytes || bytes.length === 0) return false;
|
|
1118
|
+
try {
|
|
1119
|
+
decodeSnapshot(relaySchema, bytes);
|
|
1120
|
+
return true;
|
|
1121
|
+
} catch {
|
|
1122
|
+
return false;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
563
1125
|
|
|
564
1126
|
// ../supervisor/src/hibernate.ts
|
|
565
1127
|
function hibernateRoom(sup, room) {
|
|
@@ -637,9 +1199,9 @@ async function afterSleep(sup, room) {
|
|
|
637
1199
|
sup.dropRoomIfIdle(room);
|
|
638
1200
|
}
|
|
639
1201
|
async function wakeRoom(sup, room) {
|
|
640
|
-
|
|
641
|
-
await room.transition
|
|
642
|
-
|
|
1202
|
+
while (room.state === "waking" || room.state === "starting" || room.state === "hibernating") {
|
|
1203
|
+
await room.transition.catch(() => {
|
|
1204
|
+
});
|
|
643
1205
|
}
|
|
644
1206
|
if (room.state !== "hibernated") return;
|
|
645
1207
|
room.state = "waking";
|
|
@@ -703,7 +1265,7 @@ async function migrateRoom(sup, room, toVersion) {
|
|
|
703
1265
|
const oldKey = sup.storeKey(room);
|
|
704
1266
|
let chain;
|
|
705
1267
|
try {
|
|
706
|
-
chain = sup.migrationChain(fromVersion, toVersion);
|
|
1268
|
+
chain = sup.migrationChain(fromVersion, toVersion, room.roomType);
|
|
707
1269
|
} catch (err) {
|
|
708
1270
|
return failed(err instanceof Error ? err.message : String(err));
|
|
709
1271
|
}
|
|
@@ -779,7 +1341,7 @@ async function migrateRoom(sup, room, toVersion) {
|
|
|
779
1341
|
outcome = failed(`migration to v${toVersion} failed; room restarted on v${fromVersion}`);
|
|
780
1342
|
return;
|
|
781
1343
|
}
|
|
782
|
-
sup.failOutdatedSessions(room, toVersion);
|
|
1344
|
+
const swapped = sup.failOutdatedSessions(room, toVersion, fromVersion);
|
|
783
1345
|
await sup.rejoinAll(room);
|
|
784
1346
|
for (const clientId of room.pendingLeaves) {
|
|
785
1347
|
room.worker?.post({ t: "leave", clientId, reason: "timeout" });
|
|
@@ -792,7 +1354,11 @@ async function migrateRoom(sup, room, toVersion) {
|
|
|
792
1354
|
const ms = performance.now() - startedAt;
|
|
793
1355
|
room.metrics.lastWakeMs = ms;
|
|
794
1356
|
if (ms > room.metrics.maxWakeMs) room.metrics.maxWakeMs = ms;
|
|
795
|
-
sup.roomLog(
|
|
1357
|
+
sup.roomLog(
|
|
1358
|
+
room,
|
|
1359
|
+
"info",
|
|
1360
|
+
`migrated v${fromVersion} \u2192 v${toVersion}, gap ${ms.toFixed(1)} ms` + (swapped > 0 ? `, ${swapped} client(s) kept their socket and swapped schema` : "")
|
|
1361
|
+
);
|
|
796
1362
|
outcome = { roomId: room.id, outcome: "migrated" };
|
|
797
1363
|
})();
|
|
798
1364
|
room.transition = work.catch((err) => {
|
|
@@ -874,7 +1440,8 @@ function newRoomMetrics() {
|
|
|
874
1440
|
lastWakeMs: 0,
|
|
875
1441
|
maxWakeMs: 0,
|
|
876
1442
|
workerElu: 0,
|
|
877
|
-
droppedFramesOnRestart: 0
|
|
1443
|
+
droppedFramesOnRestart: 0,
|
|
1444
|
+
npcConnections: 0
|
|
878
1445
|
};
|
|
879
1446
|
}
|
|
880
1447
|
function emptyRoomsByState() {
|
|
@@ -898,15 +1465,222 @@ var LogRing = class {
|
|
|
898
1465
|
}
|
|
899
1466
|
};
|
|
900
1467
|
|
|
1468
|
+
// ../supervisor/src/npc/loopback.ts
|
|
1469
|
+
var CONNECTING = 0;
|
|
1470
|
+
var OPEN = 1;
|
|
1471
|
+
var CLOSING = 2;
|
|
1472
|
+
var CLOSED = 3;
|
|
1473
|
+
var LoopbackServerSocket = class {
|
|
1474
|
+
CONNECTING = CONNECTING;
|
|
1475
|
+
OPEN = OPEN;
|
|
1476
|
+
CLOSING = CLOSING;
|
|
1477
|
+
CLOSED = CLOSED;
|
|
1478
|
+
readyState = OPEN;
|
|
1479
|
+
/** Always 0: there is no kernel buffer, so an NPC is never a slow consumer. */
|
|
1480
|
+
bufferedAmount = 0;
|
|
1481
|
+
listeners = /* @__PURE__ */ new Map();
|
|
1482
|
+
peer;
|
|
1483
|
+
on(event, listener) {
|
|
1484
|
+
let set = this.listeners.get(event);
|
|
1485
|
+
if (!set) this.listeners.set(event, set = /* @__PURE__ */ new Set());
|
|
1486
|
+
set.add(listener);
|
|
1487
|
+
return this;
|
|
1488
|
+
}
|
|
1489
|
+
once(event, listener) {
|
|
1490
|
+
const wrapper = (...args) => {
|
|
1491
|
+
this.off(event, wrapper);
|
|
1492
|
+
listener(...args);
|
|
1493
|
+
};
|
|
1494
|
+
return this.on(event, wrapper);
|
|
1495
|
+
}
|
|
1496
|
+
off(event, listener) {
|
|
1497
|
+
this.listeners.get(event)?.delete(listener);
|
|
1498
|
+
return this;
|
|
1499
|
+
}
|
|
1500
|
+
emit(event, ...args) {
|
|
1501
|
+
for (const listener of [...this.listeners.get(event) ?? []]) listener(...args);
|
|
1502
|
+
}
|
|
1503
|
+
send(bytes) {
|
|
1504
|
+
if (this.readyState !== OPEN) return;
|
|
1505
|
+
const peer = this.peer;
|
|
1506
|
+
const copy = bytes.slice();
|
|
1507
|
+
setImmediate(() => peer?.onmessage?.(copy));
|
|
1508
|
+
}
|
|
1509
|
+
/** The supervisor pings every session; a loopback peer answers at once so it never times out. */
|
|
1510
|
+
ping() {
|
|
1511
|
+
setImmediate(() => this.emit("pong"));
|
|
1512
|
+
}
|
|
1513
|
+
close(code = 1e3, reason = "") {
|
|
1514
|
+
if (this.readyState === CLOSED || this.readyState === CLOSING) return;
|
|
1515
|
+
this.readyState = CLOSING;
|
|
1516
|
+
const peer = this.peer;
|
|
1517
|
+
setImmediate(() => {
|
|
1518
|
+
this.readyState = CLOSED;
|
|
1519
|
+
peer?.onclose?.({ code, reason });
|
|
1520
|
+
this.emit("close", code, reason);
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
terminate() {
|
|
1524
|
+
if (this.readyState === CLOSED) return;
|
|
1525
|
+
this.readyState = CLOSED;
|
|
1526
|
+
const peer = this.peer;
|
|
1527
|
+
setImmediate(() => {
|
|
1528
|
+
peer?.onclose?.({ code: 1006, reason: "terminated" });
|
|
1529
|
+
this.emit("close", 1006, "terminated");
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
};
|
|
1533
|
+
var ClientHalf = class {
|
|
1534
|
+
constructor(server) {
|
|
1535
|
+
this.server = server;
|
|
1536
|
+
}
|
|
1537
|
+
server;
|
|
1538
|
+
onopen = null;
|
|
1539
|
+
onmessage = null;
|
|
1540
|
+
onclose = null;
|
|
1541
|
+
onerror = null;
|
|
1542
|
+
open = true;
|
|
1543
|
+
send(bytes) {
|
|
1544
|
+
if (!this.open || this.server.readyState !== OPEN) return;
|
|
1545
|
+
const copy = bytes.slice();
|
|
1546
|
+
setImmediate(() => this.server.emit("message", copy));
|
|
1547
|
+
}
|
|
1548
|
+
close() {
|
|
1549
|
+
if (!this.open) return;
|
|
1550
|
+
this.open = false;
|
|
1551
|
+
const server = this.server;
|
|
1552
|
+
setImmediate(() => {
|
|
1553
|
+
if (server.readyState === CLOSED) return;
|
|
1554
|
+
server.readyState = CLOSED;
|
|
1555
|
+
server.emit("close", 1e3, "npc closed");
|
|
1556
|
+
this.onclose?.({ code: 1e3 });
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
};
|
|
1560
|
+
function loopbackPair() {
|
|
1561
|
+
const server = new LoopbackServerSocket();
|
|
1562
|
+
const client = new ClientHalf(server);
|
|
1563
|
+
server.peer = client;
|
|
1564
|
+
setImmediate(() => client.onopen?.());
|
|
1565
|
+
return { server, client };
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// ../supervisor/src/npc/runtime.ts
|
|
1569
|
+
var NPC_TRACE_LIMIT = 16;
|
|
1570
|
+
var NPC_JOIN_TIMEOUT_MS = 1e4;
|
|
1571
|
+
var NpcManager = class {
|
|
1572
|
+
constructor(host) {
|
|
1573
|
+
this.host = host;
|
|
1574
|
+
}
|
|
1575
|
+
host;
|
|
1576
|
+
live = /* @__PURE__ */ new Map();
|
|
1577
|
+
/** NPC sessions this manager believes are live in a room. Diagnostics and metrics. */
|
|
1578
|
+
countIn(roomId) {
|
|
1579
|
+
let n = 0;
|
|
1580
|
+
for (const entry of this.live.values()) {
|
|
1581
|
+
if (entry.roomId === roomId) n++;
|
|
1582
|
+
}
|
|
1583
|
+
return n;
|
|
1584
|
+
}
|
|
1585
|
+
has(clientId) {
|
|
1586
|
+
return this.live.has(clientId);
|
|
1587
|
+
}
|
|
1588
|
+
spawn(roomId, definition, clientId, config) {
|
|
1589
|
+
if (this.live.has(clientId)) {
|
|
1590
|
+
this.host.log(roomId, "warn", `irtio: npc ${clientId} is already running; spawn ignored`);
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
const npcs = definition.config.npcs;
|
|
1594
|
+
const script = npcs?.[config.brain.script];
|
|
1595
|
+
if (!script) {
|
|
1596
|
+
this.host.log(
|
|
1597
|
+
roomId,
|
|
1598
|
+
"error",
|
|
1599
|
+
`irtio: npc script ${JSON.stringify(config.brain.script)} is not in this deployment's npcs map`
|
|
1600
|
+
);
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
const entry = { roomId, clientId, runner: void 0, stopped: false };
|
|
1604
|
+
this.live.set(clientId, entry);
|
|
1605
|
+
const name = config.name ?? config.brain.script;
|
|
1606
|
+
void import("@irtio/bots").then(
|
|
1607
|
+
async ({ spawnBots }) => spawnBots(1, {
|
|
1608
|
+
schema: definition.schema,
|
|
1609
|
+
room: roomId,
|
|
1610
|
+
url: "loopback:npc",
|
|
1611
|
+
key: this.host.projectId,
|
|
1612
|
+
name,
|
|
1613
|
+
...config.role !== void 0 ? { role: config.role } : {},
|
|
1614
|
+
...config.seed !== void 0 ? { seed: config.seed } : {},
|
|
1615
|
+
traceLimit: NPC_TRACE_LIMIT,
|
|
1616
|
+
joinTimeoutMs: NPC_JOIN_TIMEOUT_MS,
|
|
1617
|
+
transport: {
|
|
1618
|
+
connect: () => this.host.openLoopback(roomId, clientId)
|
|
1619
|
+
},
|
|
1620
|
+
script: (bot) => script(bot)
|
|
1621
|
+
})
|
|
1622
|
+
).then(
|
|
1623
|
+
(runner) => {
|
|
1624
|
+
entry.runner = runner;
|
|
1625
|
+
if (entry.stopped) void this.stopRunner(entry);
|
|
1626
|
+
},
|
|
1627
|
+
(err) => {
|
|
1628
|
+
this.live.delete(clientId);
|
|
1629
|
+
this.host.log(
|
|
1630
|
+
roomId,
|
|
1631
|
+
"error",
|
|
1632
|
+
`irtio: npc ${JSON.stringify(name)} did not join: ${errorText(err)}`
|
|
1633
|
+
);
|
|
1634
|
+
}
|
|
1635
|
+
);
|
|
1636
|
+
}
|
|
1637
|
+
despawn(clientId) {
|
|
1638
|
+
const entry = this.live.get(clientId);
|
|
1639
|
+
if (!entry) return;
|
|
1640
|
+
this.live.delete(clientId);
|
|
1641
|
+
entry.stopped = true;
|
|
1642
|
+
if (entry.runner) void this.stopRunner(entry);
|
|
1643
|
+
}
|
|
1644
|
+
/** Every NPC in a room: the room hibernated, closed, restarted, or the tenant is shutting down. */
|
|
1645
|
+
despawnRoom(roomId) {
|
|
1646
|
+
for (const entry of [...this.live.values()]) {
|
|
1647
|
+
if (entry.roomId === roomId) this.despawn(entry.clientId);
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
despawnAll() {
|
|
1651
|
+
for (const entry of [...this.live.values()]) this.despawn(entry.clientId);
|
|
1652
|
+
}
|
|
1653
|
+
async stopRunner(entry) {
|
|
1654
|
+
const runner = entry.runner;
|
|
1655
|
+
entry.runner = void 0;
|
|
1656
|
+
if (!runner) return;
|
|
1657
|
+
try {
|
|
1658
|
+
await runner.stop();
|
|
1659
|
+
} catch (err) {
|
|
1660
|
+
this.host.log(entry.roomId, "warn", `irtio: npc ${entry.clientId} stop: ${errorText(err)}`);
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
};
|
|
1664
|
+
function errorText(err) {
|
|
1665
|
+
return err instanceof Error ? err.message : String(err);
|
|
1666
|
+
}
|
|
1667
|
+
|
|
901
1668
|
// ../supervisor/src/rooms.ts
|
|
1669
|
+
import {
|
|
1670
|
+
DEFAULT_ROOM_TYPE as DEFAULT_ROOM_TYPE2,
|
|
1671
|
+
parseRoomId as parseRoomId2,
|
|
1672
|
+
roomClassFor,
|
|
1673
|
+
roomHoursSourceFor
|
|
1674
|
+
} from "@irtio/protocol";
|
|
902
1675
|
var WAKE_QUEUE_CAP = 256;
|
|
903
|
-
|
|
1676
|
+
var SEEN_PLAYERS_CAP = 512;
|
|
1677
|
+
function relayConfig(limits, plan) {
|
|
904
1678
|
return {
|
|
905
1679
|
mode: "event",
|
|
906
1680
|
tickRate: 0,
|
|
907
1681
|
idleMs: limits.relayIdleMs,
|
|
908
1682
|
reconnectGraceMs: limits.relayReconnectGraceMs,
|
|
909
|
-
maxClients: limits.relayMaxClients
|
|
1683
|
+
maxClients: plan === "free" ? limits.relayMaxClientsFree : limits.relayMaxClients
|
|
910
1684
|
};
|
|
911
1685
|
}
|
|
912
1686
|
var RoomRecord = class {
|
|
@@ -923,6 +1697,14 @@ var RoomRecord = class {
|
|
|
923
1697
|
relayRoom;
|
|
924
1698
|
lastTick = 0;
|
|
925
1699
|
metrics = newRoomMetrics();
|
|
1700
|
+
/**
|
|
1701
|
+
* D65: the room's bandwidth ledger as of the last `stats` round trip, when this supervisor was
|
|
1702
|
+
* started with profiling on. Deliberately *beside* `metrics` and not in it: the host agent
|
|
1703
|
+
* flattens every numeric field of `RoomMetrics` into a rollup row per poll, and a ledger is
|
|
1704
|
+
* tens of rows that change names with the schema. It is a local development surface, not fleet
|
|
1705
|
+
* telemetry. `undefined` means nobody is profiling, never "no bytes".
|
|
1706
|
+
*/
|
|
1707
|
+
profile;
|
|
926
1708
|
logs = new LogRing();
|
|
927
1709
|
createdAt = Date.now();
|
|
928
1710
|
/** Frames that arrived while hibernating/waking, replayed in order after the re-joins. */
|
|
@@ -931,8 +1713,39 @@ var RoomRecord = class {
|
|
|
931
1713
|
droppedWakeFrames = 0;
|
|
932
1714
|
/** Sessions whose grace expired while the room had no worker; `leave` is sent on the next wake. */
|
|
933
1715
|
pendingLeaves = /* @__PURE__ */ new Set();
|
|
1716
|
+
/**
|
|
1717
|
+
* Bug #49: client ids the worker sent a frame for that this room has no session under, each
|
|
1718
|
+
* logged once. Cleared with the room; a room that keeps naming unknown clients is one warning
|
|
1719
|
+
* per id, not one per frame.
|
|
1720
|
+
*/
|
|
1721
|
+
unknownSendWarned = /* @__PURE__ */ new Set();
|
|
934
1722
|
/** Timestamps of worker restarts, pruned to the last minute (plan §3.3 cap). */
|
|
935
1723
|
restarts = [];
|
|
1724
|
+
/**
|
|
1725
|
+
* D55: whether the "heap near its cap" warning has already been logged for the current
|
|
1726
|
+
* crossing. Edge-triggered rather than level-triggered, so a room sitting above the line warns
|
|
1727
|
+
* once instead of on every metrics poll; cleared when it drops back under.
|
|
1728
|
+
*/
|
|
1729
|
+
heapWarned = false;
|
|
1730
|
+
/**
|
|
1731
|
+
* D63-e: the live half of the backfill flag, as `room.backfill.set` has left it.
|
|
1732
|
+
*
|
|
1733
|
+
* On the record rather than in the worker so it is not in the hibernation blob (the blob format
|
|
1734
|
+
* does not move for this), and **reset to the declaration whenever a worker reports ready** —
|
|
1735
|
+
* a fresh start and a wake alike.
|
|
1736
|
+
*
|
|
1737
|
+
* The reset is the deliberate part, and the alternative was considered. Carrying the value
|
|
1738
|
+
* across a wake would make the supervisor's copy and the room's own `room.backfill.open` say
|
|
1739
|
+
* different things the moment the worker restarted, because the worker's mirror is seeded from
|
|
1740
|
+
* the declaration and nothing pushes the old value back into it. Resetting keeps the two
|
|
1741
|
+
* honest. What it costs is nothing real: a room hibernates because it went idle, and a versus
|
|
1742
|
+
* room in the middle of a round is not idle, so the only rooms this reopens are rooms with
|
|
1743
|
+
* nobody in them.
|
|
1744
|
+
*
|
|
1745
|
+
* Starts `true` so a room that declared `backfill: true` and never touches the toggle is open,
|
|
1746
|
+
* which is the arena case and the common one.
|
|
1747
|
+
*/
|
|
1748
|
+
backfillOpen = true;
|
|
936
1749
|
/**
|
|
937
1750
|
* The deployment version this room was created under. New rooms take the newest; rooms created
|
|
938
1751
|
* before a deploy keep theirs until they idle (per-room drain, plan §3.3).
|
|
@@ -944,17 +1757,98 @@ var RoomRecord = class {
|
|
|
944
1757
|
* ever serialized into the hibernation blob.
|
|
945
1758
|
*/
|
|
946
1759
|
alarms = new AlarmSet();
|
|
1760
|
+
/**
|
|
1761
|
+
* D67-e: ids of cross-shard bus messages delivered to this room, newest last, bounded by the
|
|
1762
|
+
* supervisor. A message that arrives twice (a lost acknowledgement) is delivered twice, as
|
|
1763
|
+
* at-least-once promises, and its duplicate is logged by name from this set.
|
|
1764
|
+
*/
|
|
1765
|
+
busSeen = /* @__PURE__ */ new Set();
|
|
1766
|
+
/**
|
|
1767
|
+
* D59: this room's bus budget.
|
|
1768
|
+
*
|
|
1769
|
+
* On the record rather than in a map keyed by room id, so it lives and dies with the room and
|
|
1770
|
+
* cannot be a leak or a stale entry. This is the per-room rate-limit machinery D59 asks for and
|
|
1771
|
+
* that the supervisor did not have: every limiter before it (`ipBuckets`, `helloBuckets`, the
|
|
1772
|
+
* per-session frame buckets) is scoped to a connection, an IP or a project key, and none of them
|
|
1773
|
+
* metered what a *room* asked the host to do.
|
|
1774
|
+
*
|
|
1775
|
+
* It survives hibernation for the same reason the alarm set does: the record does. A room cannot
|
|
1776
|
+
* reset its budget by sleeping.
|
|
1777
|
+
*/
|
|
1778
|
+
busBucket = newBusBucket();
|
|
1779
|
+
/**
|
|
1780
|
+
* D63: every player id this room has held since it was created, newest last.
|
|
1781
|
+
*
|
|
1782
|
+
* `roomHoldsPlayer` is not enough for a rating report and the difference is the whole point: by
|
|
1783
|
+
* the time a room decides who won, the loser has very often already closed the tab. A report
|
|
1784
|
+
* that could only name players still connected would be a report no versus game could make. So
|
|
1785
|
+
* membership for ratings is "has this room ever held you", recorded here at join.
|
|
1786
|
+
*
|
|
1787
|
+
* On the record, not on the worker, so it survives a hibernation the same way the alarm set
|
|
1788
|
+
* does — a room that slept between the last round and the report must still be able to make it.
|
|
1789
|
+
* It is deliberately NOT serialized into the hibernation blob: a room whose tenant restarts
|
|
1790
|
+
* loses it, which costs a report and is the conservative direction to fail in.
|
|
1791
|
+
*
|
|
1792
|
+
* Bounded at `SEEN_PLAYERS_CAP`, oldest evicted first. An arena that has held ten thousand
|
|
1793
|
+
* players over a week must not carry ten thousand strings for the life of the process, and a
|
|
1794
|
+
* room reporting on a player who left five hundred players ago is not a match result.
|
|
1795
|
+
*/
|
|
1796
|
+
seenPlayers = /* @__PURE__ */ new Set();
|
|
947
1797
|
/**
|
|
948
1798
|
* D24: a save generation to start this room from instead of its live snapshot, set from the
|
|
949
1799
|
* placement instruction and consumed by the next start. One shot: a restore is an event, not a
|
|
950
1800
|
* standing property of the room.
|
|
951
1801
|
*/
|
|
952
1802
|
pendingRestoreSaveId;
|
|
1803
|
+
/**
|
|
1804
|
+
* D47: when this room's alive-time was last banked into the usage ledger, or `undefined` while
|
|
1805
|
+
* the room is not chargeable (hibernated/closed). Owned by `sampleRoomClocks`; deliberately
|
|
1806
|
+
* separate from `createdAt`, which counts wall time a hibernated room keeps accruing.
|
|
1807
|
+
*/
|
|
1808
|
+
usageSince;
|
|
953
1809
|
/** Serialises room-level async work (start / wake / restart / hibernate). */
|
|
954
1810
|
transition = Promise.resolve();
|
|
955
1811
|
/** Periodic crash-safety snapshot (event-mode bundle rooms) or the relay idle timer. */
|
|
956
1812
|
timer;
|
|
957
1813
|
config;
|
|
1814
|
+
/**
|
|
1815
|
+
* D58: the room type this room runs, parsed once from its id. `DEFAULT_ROOM_TYPE` for a plain
|
|
1816
|
+
* id, which is every id a project had before named room types.
|
|
1817
|
+
*
|
|
1818
|
+
* Parsed here and nowhere else downstream. Shard note 2: outside the definition lookup and the
|
|
1819
|
+
* limits derivation, everything keys on the whole opaque roomId, so this getter is the only
|
|
1820
|
+
* place the split leaks into the room's own lifetime.
|
|
1821
|
+
*/
|
|
1822
|
+
get roomType() {
|
|
1823
|
+
return parseRoomId2(this.id)?.type ?? DEFAULT_ROOM_TYPE2;
|
|
1824
|
+
}
|
|
1825
|
+
/** D47: the engine dimension room-hours rows carry; `''` for a room with no physics. */
|
|
1826
|
+
get engine() {
|
|
1827
|
+
return this.config.engine ?? "";
|
|
1828
|
+
}
|
|
1829
|
+
/**
|
|
1830
|
+
* D60: the size class this room is billed as, derived from its declared `memoryMb`.
|
|
1831
|
+
*
|
|
1832
|
+
* Derived rather than stored, and derived from the same number that drives the worker's heap
|
|
1833
|
+
* cap, so the bill and the enforcement cannot disagree about a room. A relay room is not a
|
|
1834
|
+
* class: it is served by a different thing entirely and carries its own source.
|
|
1835
|
+
*/
|
|
1836
|
+
get sizeClass() {
|
|
1837
|
+
return roomClassFor(this.config.memoryMb);
|
|
1838
|
+
}
|
|
1839
|
+
/**
|
|
1840
|
+
* D60: the room-hours `source` this room's hours are reported under. `room` for Small.
|
|
1841
|
+
*
|
|
1842
|
+
* **A relay room served by this supervisor reports `room`, not `relay`, deliberately.** The
|
|
1843
|
+
* $0.001 relay rate is honest cost accounting for a room served by the shared relay host: no RAM
|
|
1844
|
+
* reservation, no VM slot, just sockets and a presence map. A relay room running inside a tenant
|
|
1845
|
+
* VM has all of those costs — it is a supervisor in a Firecracker guest doing the work — and
|
|
1846
|
+
* billing it at the shared-host rate would price the discount before earning it. The `relay`
|
|
1847
|
+
* source is reserved for the process that actually deserves it. See the part 3 report.
|
|
1848
|
+
*/
|
|
1849
|
+
get usageSource() {
|
|
1850
|
+
return roomHoursSourceFor(this.sizeClass);
|
|
1851
|
+
}
|
|
958
1852
|
/** Sessions with a live socket that have completed their join. */
|
|
959
1853
|
connected() {
|
|
960
1854
|
return [...this.clients.values()].filter((s) => s.state === "joined" && s.open);
|
|
@@ -973,6 +1867,31 @@ var RoomRecord = class {
|
|
|
973
1867
|
log(level, ...args) {
|
|
974
1868
|
this.logs.push(level, args);
|
|
975
1869
|
}
|
|
1870
|
+
/** D63: remember that this room held `playerId`. Re-seeing one moves it to the newest slot. */
|
|
1871
|
+
notePlayerSeen(playerId) {
|
|
1872
|
+
this.seenPlayers.delete(playerId);
|
|
1873
|
+
this.seenPlayers.add(playerId);
|
|
1874
|
+
while (this.seenPlayers.size > SEEN_PLAYERS_CAP) {
|
|
1875
|
+
const oldest = this.seenPlayers.values().next().value;
|
|
1876
|
+
if (oldest === void 0) break;
|
|
1877
|
+
this.seenPlayers.delete(oldest);
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
/** D63: has this room ever held `playerId`? The rating report's membership check. */
|
|
1881
|
+
hasHeldPlayer(playerId) {
|
|
1882
|
+
return this.seenPlayers.has(playerId);
|
|
1883
|
+
}
|
|
1884
|
+
/**
|
|
1885
|
+
* D63-e: is this room accepting backfill right now?
|
|
1886
|
+
*
|
|
1887
|
+
* The declaration is the ceiling and `backfillOpen` is the live value, so a room that declared
|
|
1888
|
+
* nothing answers false however it toggled. Reported on the room list; never enforced at join —
|
|
1889
|
+
* `maxClients` is what refuses a join, and a lost race for the last seat is a normal outcome
|
|
1890
|
+
* the client retries.
|
|
1891
|
+
*/
|
|
1892
|
+
get backfillNow() {
|
|
1893
|
+
return this.config.backfill === true && this.backfillOpen;
|
|
1894
|
+
}
|
|
976
1895
|
info() {
|
|
977
1896
|
const dueAlarmAt = this.alarms.earliestDueAt;
|
|
978
1897
|
return {
|
|
@@ -984,7 +1903,13 @@ var RoomRecord = class {
|
|
|
984
1903
|
metrics: { ...this.metrics, workerElu: this.worker?.elu ?? 0 },
|
|
985
1904
|
createdAt: this.createdAt,
|
|
986
1905
|
...dueAlarmAt !== void 0 ? { dueAlarmAt } : {},
|
|
987
|
-
...this.alarms.size > 0 ? { alarms: this.alarms.size } : {}
|
|
1906
|
+
...this.alarms.size > 0 ? { alarms: this.alarms.size } : {},
|
|
1907
|
+
...this.profile !== void 0 ? { profile: this.profile } : {},
|
|
1908
|
+
// D63-d/e. `maxClients` is always reported (it is always known); `backfill` is reported
|
|
1909
|
+
// only when the room type declared it, so "absent" and "not a candidate" stay the same
|
|
1910
|
+
// thing all the way to control's nullable column.
|
|
1911
|
+
maxClients: this.config.maxClients,
|
|
1912
|
+
...this.config.backfill === true ? { backfill: this.backfillNow } : {}
|
|
988
1913
|
};
|
|
989
1914
|
}
|
|
990
1915
|
};
|
|
@@ -1010,15 +1935,52 @@ var RoomRegistry = class {
|
|
|
1010
1935
|
}
|
|
1011
1936
|
};
|
|
1012
1937
|
|
|
1938
|
+
// ../supervisor/src/schema-swap.ts
|
|
1939
|
+
import { diffSchemas, schemaFromCanonical } from "@irtio/schema";
|
|
1940
|
+
var verdicts = /* @__PURE__ */ new Map();
|
|
1941
|
+
var MAX_MEMO = 64;
|
|
1942
|
+
function isAdditiveMigration(fromJson, toJson) {
|
|
1943
|
+
if (fromJson === toJson) return true;
|
|
1944
|
+
const key = `${fromJson.length}|${fromJson}${toJson}`;
|
|
1945
|
+
const cached = verdicts.get(key);
|
|
1946
|
+
if (cached !== void 0) return cached;
|
|
1947
|
+
let verdict = false;
|
|
1948
|
+
try {
|
|
1949
|
+
const oldSchema = schemaFromCanonical(fromJson);
|
|
1950
|
+
const newSchema = schemaFromCanonical(toJson);
|
|
1951
|
+
const changes = diffSchemas(oldSchema, newSchema);
|
|
1952
|
+
verdict = changes.every((c) => c.kind === "additive") && !touchesPhysicsCollection(oldSchema, newSchema);
|
|
1953
|
+
} catch {
|
|
1954
|
+
verdict = false;
|
|
1955
|
+
}
|
|
1956
|
+
if (verdicts.size >= MAX_MEMO) verdicts.clear();
|
|
1957
|
+
verdicts.set(key, verdict);
|
|
1958
|
+
return verdict;
|
|
1959
|
+
}
|
|
1960
|
+
function touchesPhysicsCollection(oldSchema, newSchema) {
|
|
1961
|
+
const physicsNames = /* @__PURE__ */ new Set();
|
|
1962
|
+
for (const c of oldSchema.collections) if (c.physics !== void 0) physicsNames.add(c.name);
|
|
1963
|
+
for (const c of newSchema.collections) if (c.physics !== void 0) physicsNames.add(c.name);
|
|
1964
|
+
if (physicsNames.size === 0) return false;
|
|
1965
|
+
for (const name of physicsNames) {
|
|
1966
|
+
const before = oldSchema.collections.find((c) => c.name === name);
|
|
1967
|
+
const after = newSchema.collections.find((c) => c.name === name);
|
|
1968
|
+
if (!before || !after) return true;
|
|
1969
|
+
if (JSON.stringify(before) !== JSON.stringify(after)) return true;
|
|
1970
|
+
}
|
|
1971
|
+
return false;
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1013
1974
|
// ../supervisor/src/session.ts
|
|
1014
1975
|
import {
|
|
1015
|
-
ErrorCode,
|
|
1016
|
-
FrameType,
|
|
1976
|
+
ErrorCode as ErrorCode2,
|
|
1977
|
+
FrameType as FrameType2,
|
|
1017
1978
|
encodeErrorPayload,
|
|
1018
|
-
encodeFrame
|
|
1979
|
+
encodeFrame as encodeFrame2
|
|
1019
1980
|
} from "@irtio/protocol";
|
|
1020
1981
|
var CLOSE_POLICY = 1008;
|
|
1021
1982
|
var CLOSE_GRACE_MS = 250;
|
|
1983
|
+
var HELD_FRAMES_MAX = 16;
|
|
1022
1984
|
var Session = class {
|
|
1023
1985
|
constructor(ws, remoteAddress, origin, framesPerSec, maxBufferedBytes, onSlowConsumer) {
|
|
1024
1986
|
this.ws = ws;
|
|
@@ -1034,6 +1996,16 @@ var Session = class {
|
|
|
1034
1996
|
maxBufferedBytes;
|
|
1035
1997
|
onSlowConsumer;
|
|
1036
1998
|
state = "awaiting-hello";
|
|
1999
|
+
/**
|
|
2000
|
+
* Bug #49: frames the worker sent this session while it was still `'joining'`. The room's
|
|
2001
|
+
* `onJoin` runs inside `RoomCore.join`, so a `room.call(ctx.clientId)` made there reaches the
|
|
2002
|
+
* supervisor before the join outcome does, and the client accepts nothing before its `WELCOME`.
|
|
2003
|
+
* Held here, sent in order right after the welcome (`joinBundle`), discarded when the join is
|
|
2004
|
+
* rejected or the socket closes first. Capped at `HELD_FRAMES_MAX`; `droppedHeld` counts the
|
|
2005
|
+
* overflow so the first drop can be logged once.
|
|
2006
|
+
*/
|
|
2007
|
+
pendingOut = [];
|
|
2008
|
+
droppedHeld = 0;
|
|
1037
2009
|
clientId = "";
|
|
1038
2010
|
roomId = "";
|
|
1039
2011
|
/** Set once the HELLO resolved a room. */
|
|
@@ -1048,6 +2020,24 @@ var Session = class {
|
|
|
1048
2020
|
* session whose schema is older than the room's new version by name (`E_SCHEMA_MISMATCH`)
|
|
1049
2021
|
* rather than send it a resync WELCOME it cannot decode. */
|
|
1050
2022
|
schemaHash8;
|
|
2023
|
+
/**
|
|
2024
|
+
* D50: this client set `HELLO_SCHEMA_SWAP_BIT`, so it can rebuild its codec from a `SCHEMA`
|
|
2025
|
+
* frame and an additive migrate may swap it in place instead of closing it. Load-bearing in
|
|
2026
|
+
* the negative direction too: `decodeFrame` throws on an unknown type, so a session without
|
|
2027
|
+
* this flag must never be sent frame 13.
|
|
2028
|
+
*/
|
|
2029
|
+
schemaSwap = false;
|
|
2030
|
+
/**
|
|
2031
|
+
* D44: this session is a scripted NPC on a loopback socket. It skips the per-IP bucket and the
|
|
2032
|
+
* origin check on the way in (there is no address and no page), is tagged in metrics and in
|
|
2033
|
+
* `SessionInfo`, and is otherwise the ordinary session it looks like.
|
|
2034
|
+
*/
|
|
2035
|
+
npc = false;
|
|
2036
|
+
/**
|
|
2037
|
+
* D44: the client id the room already minted for this NPC, so `room.spawnNPC` could hand a
|
|
2038
|
+
* handle back synchronously. Absent for every dialled session, which mints its own.
|
|
2039
|
+
*/
|
|
2040
|
+
forcedClientId;
|
|
1051
2041
|
/** Set when the session was rejected/kicked: no grace window on close. */
|
|
1052
2042
|
fatal = false;
|
|
1053
2043
|
/** Slow consumers are closed but keep their grace window (a resume gets a fresh snapshot). */
|
|
@@ -1064,6 +2054,14 @@ var Session = class {
|
|
|
1064
2054
|
get open() {
|
|
1065
2055
|
return this.ws.readyState === this.ws.OPEN;
|
|
1066
2056
|
}
|
|
2057
|
+
/**
|
|
2058
|
+
* D47: the ONE place billable egress is counted. It is here rather than beside the five
|
|
2059
|
+
* `room.metrics.egressBytes += sent` call sites for two reasons the anti-lying tests pin: a
|
|
2060
|
+
* sixth copy of a five-place pattern is a sixth place to forget, and the five call sites are
|
|
2061
|
+
* *observability* — they count NPC loopback bytes, which never leave the box and must never be
|
|
2062
|
+
* billed. Set by the server for every session; the NPC exclusion is below, not in the callback.
|
|
2063
|
+
*/
|
|
2064
|
+
onEgress;
|
|
1067
2065
|
/**
|
|
1068
2066
|
* Sends one already-framed message. Returns the byte count sent (0 when dropped). A client whose
|
|
1069
2067
|
* `bufferedAmount` is over the limit is closed with `E_SLOW_CONSUMER` — that's exactly what
|
|
@@ -1076,15 +2074,16 @@ var Session = class {
|
|
|
1076
2074
|
return 0;
|
|
1077
2075
|
}
|
|
1078
2076
|
this.ws.send(bytes, { binary: true });
|
|
2077
|
+
if (!this.npc) this.onEgress?.(bytes.length);
|
|
1079
2078
|
return bytes.length;
|
|
1080
2079
|
}
|
|
1081
2080
|
sendFrame(type, payload) {
|
|
1082
|
-
return this.send(
|
|
2081
|
+
return this.send(encodeFrame2(type, payload));
|
|
1083
2082
|
}
|
|
1084
2083
|
sendError(code, message, fatal) {
|
|
1085
2084
|
this.sendFrame(
|
|
1086
|
-
|
|
1087
|
-
encodeErrorPayload({ code:
|
|
2085
|
+
FrameType2.ERROR,
|
|
2086
|
+
encodeErrorPayload({ code: ErrorCode2[code].code, message, fatal })
|
|
1088
2087
|
);
|
|
1089
2088
|
}
|
|
1090
2089
|
/** ERROR + close (1008). Fatal rejections skip the grace window on the following `close` event. */
|
|
@@ -1093,6 +2092,19 @@ var Session = class {
|
|
|
1093
2092
|
this.sendError(code, message, true);
|
|
1094
2093
|
this.closeSocket(CLOSE_POLICY, code);
|
|
1095
2094
|
}
|
|
2095
|
+
/**
|
|
2096
|
+
* M5 part 3.5 (Part C): `fail`, but with a chosen WebSocket close code.
|
|
2097
|
+
*
|
|
2098
|
+
* It exists for exactly one caller, the egress wall, and the reason is that the close code is
|
|
2099
|
+
* the only part of a refusal a browser is guaranteed to see. An ERROR frame can lose the race
|
|
2100
|
+
* with the close; `event.code` cannot. Everything else about a fatal rejection is unchanged, so
|
|
2101
|
+
* a wall close still skips the reconnect grace the way a policy close does.
|
|
2102
|
+
*/
|
|
2103
|
+
failWith(code, message, closeCode) {
|
|
2104
|
+
this.fatal = true;
|
|
2105
|
+
this.sendError(code, message, true);
|
|
2106
|
+
this.closeSocket(closeCode, code);
|
|
2107
|
+
}
|
|
1096
2108
|
closeSocket(code = CLOSE_POLICY, reason = "") {
|
|
1097
2109
|
this.clearHelloTimer();
|
|
1098
2110
|
if (this.ws.readyState === this.ws.CLOSED) return;
|
|
@@ -1128,11 +2140,99 @@ var Session = class {
|
|
|
1128
2140
|
name: this.name,
|
|
1129
2141
|
state: this.state,
|
|
1130
2142
|
remoteAddress: this.remoteAddress,
|
|
1131
|
-
origin: this.origin
|
|
2143
|
+
origin: this.origin,
|
|
2144
|
+
npc: this.npc
|
|
1132
2145
|
};
|
|
1133
2146
|
}
|
|
1134
2147
|
};
|
|
1135
2148
|
|
|
2149
|
+
// ../supervisor/src/usage.ts
|
|
2150
|
+
var USAGE_WINDOW_MS = 60 * 60 * 1e3;
|
|
2151
|
+
var CLOSED_WINDOW_GRACE_MS = 15 * 60 * 1e3;
|
|
2152
|
+
var ROOM_SAMPLE_MS = 1e3;
|
|
2153
|
+
function windowStartOf(nowMs) {
|
|
2154
|
+
return Math.floor(nowMs / USAGE_WINDOW_MS) * USAGE_WINDOW_MS;
|
|
2155
|
+
}
|
|
2156
|
+
function keyOf(meter, windowStart, source, engine) {
|
|
2157
|
+
return `${meter}\0${windowStart}\0${source}\0${engine}`;
|
|
2158
|
+
}
|
|
2159
|
+
var UsageAccumulator = class {
|
|
2160
|
+
cells = /* @__PURE__ */ new Map();
|
|
2161
|
+
cell(meter, source, engine, nowMs) {
|
|
2162
|
+
const windowStart = windowStartOf(nowMs);
|
|
2163
|
+
const key = keyOf(meter, windowStart, source, engine);
|
|
2164
|
+
let cell = this.cells.get(key);
|
|
2165
|
+
if (cell === void 0) {
|
|
2166
|
+
cell = { meter, windowStart, source, engine, value: 0 };
|
|
2167
|
+
this.cells.set(key, cell);
|
|
2168
|
+
}
|
|
2169
|
+
return cell;
|
|
2170
|
+
}
|
|
2171
|
+
/** Bytes that actually left the box on a real socket. NPC loopback bytes never reach here. */
|
|
2172
|
+
addEgressBytes(bytes, nowMs = Date.now()) {
|
|
2173
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return;
|
|
2174
|
+
this.cell("egress_bytes", "room", "", nowMs).value += bytes;
|
|
2175
|
+
}
|
|
2176
|
+
/**
|
|
2177
|
+
* Worker-alive milliseconds for one room, reported as fractional hours. `engine` is the room's
|
|
2178
|
+
* physics engine or `''` — a reported dimension, not a multiplier.
|
|
2179
|
+
*/
|
|
2180
|
+
addRoomMs(ms, engine, nowMs = Date.now(), source = "room") {
|
|
2181
|
+
if (!Number.isFinite(ms) || ms <= 0) return;
|
|
2182
|
+
this.cell("room_hours", source, engine, nowMs).value += ms / (60 * 60 * 1e3);
|
|
2183
|
+
}
|
|
2184
|
+
/**
|
|
2185
|
+
* Every window still worth reporting: the current one plus any closed window inside the grace.
|
|
2186
|
+
* Closed windows past the grace are dropped here, so the ledger is bounded without a timer.
|
|
2187
|
+
*/
|
|
2188
|
+
windows(nowMs = Date.now()) {
|
|
2189
|
+
const current = windowStartOf(nowMs);
|
|
2190
|
+
const out = [];
|
|
2191
|
+
for (const [key, cell] of this.cells) {
|
|
2192
|
+
if (cell.windowStart < current && nowMs - (cell.windowStart + USAGE_WINDOW_MS) > CLOSED_WINDOW_GRACE_MS) {
|
|
2193
|
+
this.cells.delete(key);
|
|
2194
|
+
continue;
|
|
2195
|
+
}
|
|
2196
|
+
out.push({
|
|
2197
|
+
meter: cell.meter,
|
|
2198
|
+
windowStart: cell.windowStart,
|
|
2199
|
+
source: cell.source,
|
|
2200
|
+
engine: cell.engine,
|
|
2201
|
+
value: cell.value
|
|
2202
|
+
});
|
|
2203
|
+
}
|
|
2204
|
+
out.sort(
|
|
2205
|
+
(a, b) => a.windowStart - b.windowStart || a.meter.localeCompare(b.meter) || a.source.localeCompare(b.source) || a.engine.localeCompare(b.engine)
|
|
2206
|
+
);
|
|
2207
|
+
return out;
|
|
2208
|
+
}
|
|
2209
|
+
/** Test/diagnostic read of one running total. */
|
|
2210
|
+
valueOf(meter, nowMs = Date.now(), source = "room", engine = "") {
|
|
2211
|
+
return this.cells.get(keyOf(meter, windowStartOf(nowMs), source, engine))?.value ?? 0;
|
|
2212
|
+
}
|
|
2213
|
+
};
|
|
2214
|
+
function chargeableRoomState(state) {
|
|
2215
|
+
return state === "starting" || state === "running" || state === "waking";
|
|
2216
|
+
}
|
|
2217
|
+
function sampleRoomClocks(rooms, usage, nowMs) {
|
|
2218
|
+
for (const room of rooms) {
|
|
2219
|
+
const chargeable = chargeableRoomState(room.state);
|
|
2220
|
+
if (!chargeable) {
|
|
2221
|
+
if (room.usageSince !== void 0) {
|
|
2222
|
+
usage.addRoomMs(nowMs - room.usageSince, room.engine, nowMs, room.usageSource);
|
|
2223
|
+
room.usageSince = void 0;
|
|
2224
|
+
}
|
|
2225
|
+
continue;
|
|
2226
|
+
}
|
|
2227
|
+
if (room.usageSince === void 0) {
|
|
2228
|
+
room.usageSince = nowMs;
|
|
2229
|
+
continue;
|
|
2230
|
+
}
|
|
2231
|
+
usage.addRoomMs(nowMs - room.usageSince, room.engine, nowMs, room.usageSource);
|
|
2232
|
+
room.usageSince = nowMs;
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
|
|
1136
2236
|
// ../supervisor/src/worker-host.ts
|
|
1137
2237
|
import { pathToFileURL } from "url";
|
|
1138
2238
|
import { Worker } from "worker_threads";
|
|
@@ -1190,7 +2290,7 @@ var WorkerHost = class {
|
|
|
1190
2290
|
}
|
|
1191
2291
|
}
|
|
1192
2292
|
onMessage(msg) {
|
|
1193
|
-
if ((msg.t === "serialized" || msg.t === "inspected" || msg.t === "stats") && msg.reqId !== void 0 && this.pending.has(msg.reqId)) {
|
|
2293
|
+
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)) {
|
|
1194
2294
|
const resolve2 = this.pending.get(msg.reqId);
|
|
1195
2295
|
this.pending.delete(msg.reqId);
|
|
1196
2296
|
resolve2?.(msg);
|
|
@@ -1261,6 +2361,27 @@ var WorkerHost = class {
|
|
|
1261
2361
|
);
|
|
1262
2362
|
return msg?.t === "inspected" ? msg : void 0;
|
|
1263
2363
|
}
|
|
2364
|
+
/** D41: arm the room's authoritative timeline recorder. `false` when the worker did not answer. */
|
|
2365
|
+
async startTimeline(options = {}, timeoutMs = 5e3) {
|
|
2366
|
+
const msg = await this.request(
|
|
2367
|
+
(reqId) => ({
|
|
2368
|
+
t: "record",
|
|
2369
|
+
reqId,
|
|
2370
|
+
...options.maxTicks !== void 0 ? { maxTicks: options.maxTicks } : {},
|
|
2371
|
+
...options.maxRecords !== void 0 ? { maxRecords: options.maxRecords } : {}
|
|
2372
|
+
}),
|
|
2373
|
+
timeoutMs
|
|
2374
|
+
);
|
|
2375
|
+
return msg?.t === "recording" && msg.ok;
|
|
2376
|
+
}
|
|
2377
|
+
/** D41: the recorded timeline. `undefined` when unarmed, or when the worker did not answer. */
|
|
2378
|
+
async timeline(timeoutMs = 1e4) {
|
|
2379
|
+
const msg = await this.request(
|
|
2380
|
+
(reqId) => ({ t: "timeline", reqId }),
|
|
2381
|
+
timeoutMs
|
|
2382
|
+
);
|
|
2383
|
+
return msg?.t === "timeline" ? msg.dump : void 0;
|
|
2384
|
+
}
|
|
1264
2385
|
/**
|
|
1265
2386
|
* D36: the room's own tick counters — `overruns` and `maxTickMs` above all. `undefined` when
|
|
1266
2387
|
* the worker died or did not answer in time, and the caller must report that as *unavailable*
|
|
@@ -1268,11 +2389,20 @@ var WorkerHost = class {
|
|
|
1268
2389
|
* and that is exactly the lie the `tick-health` invariant exists to prevent.
|
|
1269
2390
|
*/
|
|
1270
2391
|
async stats(timeoutMs = 2e3) {
|
|
2392
|
+
return (await this.statsMessage(timeoutMs))?.stats;
|
|
2393
|
+
}
|
|
2394
|
+
/**
|
|
2395
|
+
* The whole `stats` reply, including D55's `heap` ride-along. `stats()` above is the D36 shape
|
|
2396
|
+
* and stays as it was; this is the one callers use when they also want the worker's heap, which
|
|
2397
|
+
* is a fact about the thread rather than one of the room's own counters. Same round trip: the
|
|
2398
|
+
* heap number costs no extra message.
|
|
2399
|
+
*/
|
|
2400
|
+
async statsMessage(timeoutMs = 2e3) {
|
|
1271
2401
|
const msg = await this.request(
|
|
1272
2402
|
(reqId) => ({ t: "stats", reqId }),
|
|
1273
2403
|
timeoutMs
|
|
1274
2404
|
);
|
|
1275
|
-
return msg?.t === "stats" ? msg
|
|
2405
|
+
return msg?.t === "stats" ? msg : void 0;
|
|
1276
2406
|
}
|
|
1277
2407
|
/** Intentional shutdown: `onDead` is not fired. */
|
|
1278
2408
|
async terminate() {
|
|
@@ -1294,14 +2424,44 @@ var WorkerHost = class {
|
|
|
1294
2424
|
};
|
|
1295
2425
|
|
|
1296
2426
|
// ../supervisor/src/server.ts
|
|
2427
|
+
function relayHostRoomFor(sup, room) {
|
|
2428
|
+
const relayRoom = room.relayRoom;
|
|
2429
|
+
if (!relayRoom) throw new Error("irtio: relay host adapter on a room with no relay presence");
|
|
2430
|
+
return {
|
|
2431
|
+
id: room.id,
|
|
2432
|
+
relayRoom,
|
|
2433
|
+
peers: () => room.clients.values(),
|
|
2434
|
+
maxClients: room.config.maxClients,
|
|
2435
|
+
countEgress(bytes) {
|
|
2436
|
+
room.metrics.egressBytes += bytes;
|
|
2437
|
+
room.metrics.framesOut++;
|
|
2438
|
+
},
|
|
2439
|
+
touched(tick) {
|
|
2440
|
+
room.lastTick = tick;
|
|
2441
|
+
}
|
|
2442
|
+
};
|
|
2443
|
+
}
|
|
1297
2444
|
var WS_PING_MS = 2e4;
|
|
1298
2445
|
var WS_PING_MISSES = 2;
|
|
1299
2446
|
var MAX_STRIKES = 3;
|
|
1300
2447
|
var JOIN_TIMEOUT_MS = 15e3;
|
|
2448
|
+
var IDENTITY_KEY_WAIT_MS = 2e3;
|
|
2449
|
+
var MAX_IDENTITY_KEY_WAITERS = 64;
|
|
2450
|
+
var RESUME_TOKEN_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
2451
|
+
var BUS_SEEN_MAX = 256;
|
|
2452
|
+
var SHARD_ASSIGNMENT_RETRY_MS = [1e3, 3e4];
|
|
1301
2453
|
var KvUnavailable = class extends Error {
|
|
1302
2454
|
code = KV_ERRORS.unavailable;
|
|
1303
2455
|
name = "KvUnavailable";
|
|
1304
2456
|
};
|
|
2457
|
+
var LeaderboardUnavailable = class extends Error {
|
|
2458
|
+
code = LEADERBOARD_ERRORS.unavailable;
|
|
2459
|
+
name = "LeaderboardUnavailable";
|
|
2460
|
+
};
|
|
2461
|
+
var RatingUnavailable = class extends Error {
|
|
2462
|
+
code = RATING_ERRORS.unavailable;
|
|
2463
|
+
name = "RatingUnavailable";
|
|
2464
|
+
};
|
|
1305
2465
|
function codeOf(err) {
|
|
1306
2466
|
const code = err?.code;
|
|
1307
2467
|
return typeof code === "string" && code.startsWith("E_") ? code : void 0;
|
|
@@ -1345,6 +2505,7 @@ var SupervisorImpl = class {
|
|
|
1345
2505
|
`placement carries ${this.pendingRestores.size} pending restore(s): ` + [...this.pendingRestores].map(([r, sv]) => `${r}<-${sv}`).join(", ")
|
|
1346
2506
|
);
|
|
1347
2507
|
}
|
|
2508
|
+
this.identityKeys = config.identityKeys ?? [];
|
|
1348
2509
|
this.resumeSecret = config.resumeSecret ?? randomBytes(32).toString("base64url");
|
|
1349
2510
|
if (config.resumeSecret === void 0) {
|
|
1350
2511
|
this.log("warn", "no resumeSecret configured: resume tokens die with this process");
|
|
@@ -1355,6 +2516,15 @@ var SupervisorImpl = class {
|
|
|
1355
2516
|
this.limits.connectionsPerIpPerMin / 60
|
|
1356
2517
|
)
|
|
1357
2518
|
);
|
|
2519
|
+
this.npcs = new NpcManager({
|
|
2520
|
+
projectId: config.projectId,
|
|
2521
|
+
openLoopback: (roomId, clientId) => this.openLoopback(roomId, clientId),
|
|
2522
|
+
log: (roomId, level, message) => {
|
|
2523
|
+
const room = this.registry.get(roomId);
|
|
2524
|
+
if (room) this.roomLog(room, level, message);
|
|
2525
|
+
else this.log(level, message);
|
|
2526
|
+
}
|
|
2527
|
+
});
|
|
1358
2528
|
this.helloBuckets = new BucketMap(
|
|
1359
2529
|
() => new TokenBucket(this.limits.hellosPerMin, this.limits.hellosPerMin / 60)
|
|
1360
2530
|
);
|
|
@@ -1376,6 +2546,8 @@ var SupervisorImpl = class {
|
|
|
1376
2546
|
res.writeHead(404).end();
|
|
1377
2547
|
});
|
|
1378
2548
|
this.wss = new WebSocketServer({ server: this.http });
|
|
2549
|
+
this.wss.on("error", () => {
|
|
2550
|
+
});
|
|
1379
2551
|
this.wss.on("connection", (ws, req) => {
|
|
1380
2552
|
this.onConnection(ws, {
|
|
1381
2553
|
origin: header(req.headers.origin),
|
|
@@ -1408,9 +2580,21 @@ var SupervisorImpl = class {
|
|
|
1408
2580
|
wss;
|
|
1409
2581
|
readyPromise;
|
|
1410
2582
|
ipBuckets;
|
|
2583
|
+
/**
|
|
2584
|
+
* D59: this tenant's channel-to-subscriber map, beside the room registry it already keeps.
|
|
2585
|
+
*
|
|
2586
|
+
* One per supervisor, which is one per tenant, which is what makes a cross-project reach
|
|
2587
|
+
* unrepresentable rather than refused: there is no other registry to look in and no project
|
|
2588
|
+
* argument on any path that reaches this.
|
|
2589
|
+
*/
|
|
2590
|
+
bus = new BusRegistry();
|
|
2591
|
+
/** Monotonic within this process; feeds `mintMailboxName` so two sends never share a name. */
|
|
2592
|
+
mailboxCounter = 0;
|
|
1411
2593
|
helloBuckets;
|
|
1412
2594
|
resumeSecret;
|
|
1413
2595
|
clientIds = /* @__PURE__ */ new Set();
|
|
2596
|
+
/** D44: the scripted NPCs this tenant is running, and the bot runtime driving each. */
|
|
2597
|
+
npcs;
|
|
1414
2598
|
joinWaiters = /* @__PURE__ */ new Map();
|
|
1415
2599
|
readyWaiters = /* @__PURE__ */ new Map();
|
|
1416
2600
|
creating = /* @__PURE__ */ new Map();
|
|
@@ -1425,8 +2609,116 @@ var SupervisorImpl = class {
|
|
|
1425
2609
|
* reported on `/admin/idle` so the host-agent can escalate instead of reading "not idle yet"
|
|
1426
2610
|
* forever. Reset the moment a flush succeeds cleanly, or a client reconnects. */
|
|
1427
2611
|
flushFailures = 0;
|
|
2612
|
+
/**
|
|
2613
|
+
* D56: `true` once any room worker in this VM generation has reported that a Rapier world
|
|
2614
|
+
* stepped, which is the point V8 tier-compiled the physics WASM and paid its RSS transient.
|
|
2615
|
+
* Reported on `/admin/idle` so the host agent can hold the tenant's *first* VM snapshot until
|
|
2616
|
+
* the compile is inside the image instead of being redone after every wake.
|
|
2617
|
+
*
|
|
2618
|
+
* Sticky for the life of the process, and deliberately not reset by the idle path: the fact is
|
|
2619
|
+
* about this VM's memory, not about the current idle window, and the room that set it has
|
|
2620
|
+
* usually been hibernated away by the time the tenant goes idle.
|
|
2621
|
+
*/
|
|
2622
|
+
physicsWarm = false;
|
|
2623
|
+
/**
|
|
2624
|
+
* D56: measure the Rapier tier-up RSS transient where it actually matters — inside a placed
|
|
2625
|
+
* guest. The desktop re-measurement (2026-08-31) showed the transient peaks on V8's background
|
|
2626
|
+
* compile threads *seconds after* the first step returns (~107 MB above baseline on a many-core
|
|
2627
|
+
* machine, settling back), which a before/after sample around the step misses entirely; a
|
|
2628
|
+
* 1-vCPU guest's peak is the number `VM_MEM_MIB_PHYSICS` actually needs, and nobody has it.
|
|
2629
|
+
* So: on the first `physicsStepped`, sample process RSS (workers are threads, so this is the
|
|
2630
|
+
* whole VM's node process) every 250 ms for 12 s and log baseline, peak, and settled values.
|
|
2631
|
+
* One shot per process; timers unref'd so this never holds the tenant open.
|
|
2632
|
+
*/
|
|
2633
|
+
notePhysicsWarm() {
|
|
2634
|
+
if (this.physicsWarm) return;
|
|
2635
|
+
this.physicsWarm = true;
|
|
2636
|
+
const mib = (b) => (b / 1024 / 1024).toFixed(1);
|
|
2637
|
+
const baseline = process.memoryUsage().rss;
|
|
2638
|
+
const t0 = Date.now();
|
|
2639
|
+
let peak = baseline;
|
|
2640
|
+
let peakAtMs = 0;
|
|
2641
|
+
const sampler = setInterval(() => {
|
|
2642
|
+
const rss = process.memoryUsage().rss;
|
|
2643
|
+
if (rss > peak) {
|
|
2644
|
+
peak = rss;
|
|
2645
|
+
peakAtMs = Date.now() - t0;
|
|
2646
|
+
}
|
|
2647
|
+
}, 250);
|
|
2648
|
+
sampler.unref?.();
|
|
2649
|
+
const done = setTimeout(() => {
|
|
2650
|
+
clearInterval(sampler);
|
|
2651
|
+
const settled = process.memoryUsage().rss;
|
|
2652
|
+
this.log(
|
|
2653
|
+
"info",
|
|
2654
|
+
`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)`
|
|
2655
|
+
);
|
|
2656
|
+
}, 12e3);
|
|
2657
|
+
done.unref?.();
|
|
2658
|
+
}
|
|
1428
2659
|
idleTimer;
|
|
1429
2660
|
pingTimer;
|
|
2661
|
+
/**
|
|
2662
|
+
* D47: this tenant's usage ledger. One supervisor serves one project, so it needs no project
|
|
2663
|
+
* id; the host agent stamps one on the way out. Public so a test can read the running totals
|
|
2664
|
+
* the way `/admin/usage` does.
|
|
2665
|
+
*/
|
|
2666
|
+
usage = new UsageAccumulator();
|
|
2667
|
+
usageTimer;
|
|
2668
|
+
/**
|
|
2669
|
+
* D48: this tenant's cap state, pushed from control over `/admin/caps` and held in memory.
|
|
2670
|
+
*
|
|
2671
|
+
* Public for the same reason `usage` is: a test drives the real thing. Note what it is not: a
|
|
2672
|
+
* client of anything. It has no fetch, no database and no timer, so the pin test's claim that a
|
|
2673
|
+
* join touches no billing query is a property of the type rather than of a careful reading.
|
|
2674
|
+
*/
|
|
2675
|
+
caps = new CapGate();
|
|
2676
|
+
/**
|
|
2677
|
+
* M5 part 3.5 (Part C): the leased egress budget this box spends locally.
|
|
2678
|
+
*
|
|
2679
|
+
* Beside `caps` rather than inside it, because they answer different questions at different
|
|
2680
|
+
* moments. `caps` answers "may this project start new work", once per join, from a picture
|
|
2681
|
+
* control drew up to ninety seconds ago. This answers "may this byte leave", on every send,
|
|
2682
|
+
* from a budget the box is decrementing itself. Both are needed and neither subsumes the other.
|
|
2683
|
+
*/
|
|
2684
|
+
egressLease = new EgressLease();
|
|
2685
|
+
/**
|
|
2686
|
+
* Pricing amendment 1, decision 1: this project's sustained bandwidth ceiling.
|
|
2687
|
+
*
|
|
2688
|
+
* A third question beside the other two, asked at a third moment. `caps` answers "may this
|
|
2689
|
+
* project start new work", `egressLease` answers "may this byte leave at all this month", and
|
|
2690
|
+
* this answers "is this project sending too fast right now". Only the last one is recoverable
|
|
2691
|
+
* without anybody doing anything: it disengages on its own when the rate falls.
|
|
2692
|
+
*
|
|
2693
|
+
* Public for the same reason the other two are: a test drives the real thing.
|
|
2694
|
+
*/
|
|
2695
|
+
bandwidth = new BandwidthCeiling();
|
|
2696
|
+
/**
|
|
2697
|
+
* D53: the platform public keys this tenant will accept identity assertions from, pushed from
|
|
2698
|
+
* control over `/admin/identity-keys` exactly the way cap state is.
|
|
2699
|
+
*
|
|
2700
|
+
* What matters about this field is what it is NOT: there is no private key here, and no way to
|
|
2701
|
+
* obtain one. A supervisor — and therefore any hostile room code sharing its process — can
|
|
2702
|
+
* check that an assertion was minted by the platform and can do nothing else with these bytes.
|
|
2703
|
+
* That is the whole reason the assertion is Ed25519 rather than an HMAC like the BYO-JWT path,
|
|
2704
|
+
* where verification key and minting key are the same string.
|
|
2705
|
+
*
|
|
2706
|
+
* Empty (a tenant that has not been handed keys yet) refuses assertion joins by name —
|
|
2707
|
+
* `E_ASSERTION_UNVERIFIABLE`, meaning retry — rather than failing open. Cap state fails open on
|
|
2708
|
+
* purpose; a credential check must not.
|
|
2709
|
+
*/
|
|
2710
|
+
identityKeys = [];
|
|
2711
|
+
/**
|
|
2712
|
+
* M5 part 4: assertion HELLOs parked waiting for the first key push, and the cap on how many
|
|
2713
|
+
* may be parked at once.
|
|
2714
|
+
*
|
|
2715
|
+
* The cap is the safety argument. The wait sits AFTER the HELLO rate bucket, so nothing here
|
|
2716
|
+
* lets a caller open more sockets than the bucket already allows — but a parked socket is a
|
|
2717
|
+
* socket held open, and an unbounded list of them would turn a key-delivery gap into a way to
|
|
2718
|
+
* hold every connection a project is allowed. Past the cap the refusal is immediate, and it is
|
|
2719
|
+
* the same refusal, with the same code, that every one of these got before the wait existed.
|
|
2720
|
+
*/
|
|
2721
|
+
identityKeyWaiters = [];
|
|
1430
2722
|
/**
|
|
1431
2723
|
* D24: `roomId -> saveId` placement instructions that have not been consumed yet. Populated
|
|
1432
2724
|
* from `SupervisorConfig.restoreRooms` at boot — placement is the only channel into a tenant,
|
|
@@ -1444,6 +2736,7 @@ var SupervisorImpl = class {
|
|
|
1444
2736
|
}
|
|
1445
2737
|
this.bundle = this.newestBundle();
|
|
1446
2738
|
this.schemaHash = this.bundle?.schemaHash;
|
|
2739
|
+
this.checkDeclaredSizing();
|
|
1447
2740
|
this.workerEntry = this.config.workerEntry ?? resolveWorkerEntry();
|
|
1448
2741
|
await new Promise((resolve2, reject) => {
|
|
1449
2742
|
this.http.once("error", reject);
|
|
@@ -1455,6 +2748,8 @@ var SupervisorImpl = class {
|
|
|
1455
2748
|
});
|
|
1456
2749
|
this.pingTimer = setInterval(() => this.keepalive(), WS_PING_MS);
|
|
1457
2750
|
this.pingTimer.unref?.();
|
|
2751
|
+
this.usageTimer = setInterval(() => this.sampleUsage(), ROOM_SAMPLE_MS);
|
|
2752
|
+
this.usageTimer.unref?.();
|
|
1458
2753
|
const tenantIdleMs = this.config.tenantIdleMs ?? 3e5;
|
|
1459
2754
|
if (tenantIdleMs > 0) {
|
|
1460
2755
|
const every = Math.min(1e3, Math.max(50, Math.floor(tenantIdleMs / 4)));
|
|
@@ -1465,7 +2760,29 @@ var SupervisorImpl = class {
|
|
|
1465
2760
|
"info",
|
|
1466
2761
|
`supervisor listening on ${this.port} (${this.bundle ? "bundle" : "relay"} tenant)`
|
|
1467
2762
|
);
|
|
1468
|
-
|
|
2763
|
+
this.logCompileCacheState();
|
|
2764
|
+
if (this.isSharded()) {
|
|
2765
|
+
void this.resumeAlarmedRooms().catch((err) => {
|
|
2766
|
+
this.log("warn", "the shard-assigned boot scan failed", err);
|
|
2767
|
+
});
|
|
2768
|
+
} else {
|
|
2769
|
+
await this.resumeAlarmedRooms();
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
/**
|
|
2773
|
+
* D56 fix 3 verification: say at boot whether this process sees the compile cache baked into
|
|
2774
|
+
* the rootfs, so the first live evidence that the bake reached a guest is a log line instead
|
|
2775
|
+
* of an ssh session into a paused VM. One line, boot only; absent env means dev, and says so.
|
|
2776
|
+
*/
|
|
2777
|
+
logCompileCacheState() {
|
|
2778
|
+
const dir = process.env.NODE_COMPILE_CACHE;
|
|
2779
|
+
if (dir === void 0 || dir === "") return;
|
|
2780
|
+
try {
|
|
2781
|
+
const entries = readdirSync(dir, { recursive: true }).length;
|
|
2782
|
+
this.log("info", `compile cache at ${dir}: ${entries} entries`);
|
|
2783
|
+
} catch (err) {
|
|
2784
|
+
this.log("warn", `compile cache at ${dir}: unreadable (${err.message})`);
|
|
2785
|
+
}
|
|
1469
2786
|
}
|
|
1470
2787
|
/**
|
|
1471
2788
|
* D26, and the piece staging caught missing: **start every room that owes an alarm**, at boot.
|
|
@@ -1512,11 +2829,16 @@ var SupervisorImpl = class {
|
|
|
1512
2829
|
);
|
|
1513
2830
|
return;
|
|
1514
2831
|
}
|
|
1515
|
-
this.
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
2832
|
+
const mine = this.isSharded() ? await this.assignedRooms([...rooms].sort()) : [...rooms].sort();
|
|
2833
|
+
if (mine.length === 0) {
|
|
2834
|
+
this.log(
|
|
2835
|
+
"info",
|
|
2836
|
+
`no rooms to resume on this shard: ${rooms.size} candidate(s) belong elsewhere`
|
|
2837
|
+
);
|
|
2838
|
+
return;
|
|
2839
|
+
}
|
|
2840
|
+
this.log("info", `resuming ${mine.length} room(s) with pending alarms: ${mine.join(", ")}`);
|
|
2841
|
+
for (const roomId of mine) {
|
|
1520
2842
|
try {
|
|
1521
2843
|
await this.ensureRoom(roomId);
|
|
1522
2844
|
} catch (err) {
|
|
@@ -1524,6 +2846,42 @@ var SupervisorImpl = class {
|
|
|
1524
2846
|
}
|
|
1525
2847
|
}
|
|
1526
2848
|
}
|
|
2849
|
+
/** D67-d: true for a shard of a project with more than one; false for every tenant today. */
|
|
2850
|
+
isSharded() {
|
|
2851
|
+
return (this.config.shards ?? 1) > 1;
|
|
2852
|
+
}
|
|
2853
|
+
/**
|
|
2854
|
+
* D67-d: the subset of `candidates` this shard owns, from the control plane through the
|
|
2855
|
+
* gateway's `/shard/filter`. Retries 1, 2, 4 ... 30 s (capped, never giving up) on any failure,
|
|
2856
|
+
* logging "waiting for shard assignment" at warn each time, because a shard that guesses would
|
|
2857
|
+
* either double-start rooms another shard holds or leave alarms undelivered; waiting is the only
|
|
2858
|
+
* honest state. The sleep is injected so the schedule is tested with a recorded fake.
|
|
2859
|
+
*/
|
|
2860
|
+
async assignedRooms(candidates) {
|
|
2861
|
+
const shard = this.config.shard ?? 0;
|
|
2862
|
+
const sleep2 = this.config.shardRetrySleep ?? ((ms) => new Promise((resolve2) => {
|
|
2863
|
+
const t = setTimeout(resolve2, ms);
|
|
2864
|
+
t.unref?.();
|
|
2865
|
+
}));
|
|
2866
|
+
let delayMs = SHARD_ASSIGNMENT_RETRY_MS[0];
|
|
2867
|
+
for (let attempt = 1; ; attempt++) {
|
|
2868
|
+
if (this.closing) return [];
|
|
2869
|
+
try {
|
|
2870
|
+
const client = this.config.shardClient;
|
|
2871
|
+
if (!client) throw new Error("no shard client is configured on this tenant");
|
|
2872
|
+
const mine = await client.filter(shard, candidates);
|
|
2873
|
+
const known = new Set(candidates);
|
|
2874
|
+
return mine.filter((r) => known.has(r)).sort();
|
|
2875
|
+
} catch (err) {
|
|
2876
|
+
this.log(
|
|
2877
|
+
"warn",
|
|
2878
|
+
`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)}`
|
|
2879
|
+
);
|
|
2880
|
+
await sleep2(delayMs);
|
|
2881
|
+
delayMs = Math.min(delayMs * 2, SHARD_ASSIGNMENT_RETRY_MS[1]);
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
1527
2885
|
/**
|
|
1528
2886
|
* Everything a **VM-snapshot restore** owes the alarm clock, run from `/admin/resume` once the
|
|
1529
2887
|
* guest wall clock has been corrected.
|
|
@@ -1582,7 +2940,7 @@ var SupervisorImpl = class {
|
|
|
1582
2940
|
* rooms that are still serving the old code.
|
|
1583
2941
|
*/
|
|
1584
2942
|
async reloadBundle(bundlePath) {
|
|
1585
|
-
const loaded = await loadBundle(bundlePath, this.deploymentVersion);
|
|
2943
|
+
const loaded = await loadBundle(bundlePath, this.deploymentVersion, DEFAULT_ROOM_TYPE3);
|
|
1586
2944
|
for (const room of this.registry.values()) {
|
|
1587
2945
|
room.wakeQueue.length = 0;
|
|
1588
2946
|
room.droppedWakeFrames = 0;
|
|
@@ -1592,18 +2950,36 @@ var SupervisorImpl = class {
|
|
|
1592
2950
|
this.deployments.set(loaded.version, {
|
|
1593
2951
|
version: loaded.version,
|
|
1594
2952
|
schemaJson: loaded.schemaJson,
|
|
1595
|
-
bundle: loaded
|
|
2953
|
+
bundle: loaded,
|
|
2954
|
+
bundles: /* @__PURE__ */ new Map([[DEFAULT_ROOM_TYPE3, loaded]])
|
|
1596
2955
|
});
|
|
1597
2956
|
this.bundle = loaded;
|
|
1598
2957
|
this.schemaHash = loaded.schemaHash;
|
|
1599
2958
|
this.log("info", `bundle reloaded from ${bundlePath}`);
|
|
1600
2959
|
}
|
|
2960
|
+
/**
|
|
2961
|
+
* D47: banks every chargeable room's alive-time into the ledger. Called on
|
|
2962
|
+
* {@link ROOM_SAMPLE_MS}, on close, and immediately before `/admin/usage` is read, so what the
|
|
2963
|
+
* host agent ships is never a second stale.
|
|
2964
|
+
*/
|
|
2965
|
+
sampleUsage(now = Date.now()) {
|
|
2966
|
+
sampleRoomClocks(this.registry.values(), this.usage, now);
|
|
2967
|
+
}
|
|
2968
|
+
/** The `/admin/usage` body, and the ledger read the host agent ships. */
|
|
2969
|
+
usageWindows(now = Date.now()) {
|
|
2970
|
+
this.sampleUsage(now);
|
|
2971
|
+
return this.usage.windows(now);
|
|
2972
|
+
}
|
|
1601
2973
|
async close() {
|
|
1602
2974
|
if (this.closing) return;
|
|
1603
2975
|
this.closing = true;
|
|
1604
2976
|
this.quiesceAlarms();
|
|
2977
|
+
this.npcs.despawnAll();
|
|
2978
|
+
this.releaseIdentityKeyWaiters();
|
|
1605
2979
|
if (this.idleTimer) clearInterval(this.idleTimer);
|
|
1606
2980
|
if (this.pingTimer) clearInterval(this.pingTimer);
|
|
2981
|
+
this.sampleUsage();
|
|
2982
|
+
if (this.usageTimer) clearInterval(this.usageTimer);
|
|
1607
2983
|
try {
|
|
1608
2984
|
await flushAll(this);
|
|
1609
2985
|
} catch (err) {
|
|
@@ -1640,9 +3016,29 @@ var SupervisorImpl = class {
|
|
|
1640
3016
|
*
|
|
1641
3017
|
* They go into a ring under the reserved id `__tenant` (D38), so the whole existing pipeline
|
|
1642
3018
|
* carries them with no new route and no new frame.
|
|
3019
|
+
*
|
|
3020
|
+
* bug #53 (D67-g, which recorded the gap rather than closing it): on a **sharded** tenant every
|
|
3021
|
+
* ring line is tagged `[shard n]`. Every shard of a project ships its `__tenant` ring under the
|
|
3022
|
+
* same project and the same reserved room id, `log_events` has no shard column, and control
|
|
3023
|
+
* drops the frame's shard before the insert, so without the tag `irtio logs` shows two shards'
|
|
3024
|
+
* boot lines interleaved by time and calls them one restart. The tag is written here, at the
|
|
3025
|
+
* source, because this process is the only thing that knows which shard it is without being
|
|
3026
|
+
* told, and one string in the message beats a schema change on an append-only table.
|
|
3027
|
+
*
|
|
3028
|
+
* It is the mirror of `roomLog` below, and for the same reason: a ring line carries whatever its
|
|
3029
|
+
* stream's identity does not. A room's ring is keyed by the room, so its lines stay bare and the
|
|
3030
|
+
* console gets the `[room]` prefix; the tenant ring is keyed by `__tenant` alone, so its lines
|
|
3031
|
+
* carry the shard and the console stays bare — each shard is its own VM with its own console
|
|
3032
|
+
* file, which already says which shard is talking.
|
|
3033
|
+
*
|
|
3034
|
+
* A single-shard tenant (every tenant that exists today) takes the same bare `args` it always
|
|
3035
|
+
* did, byte for byte: `single-shard-pin.test.ts` holds that.
|
|
1643
3036
|
*/
|
|
1644
3037
|
log(level, ...args) {
|
|
1645
|
-
this.tenantLogs.push(
|
|
3038
|
+
this.tenantLogs.push(
|
|
3039
|
+
level,
|
|
3040
|
+
this.isSharded() ? [`[shard ${this.config.shard ?? 0}]`, ...args] : args
|
|
3041
|
+
);
|
|
1646
3042
|
this.emit(level, args);
|
|
1647
3043
|
}
|
|
1648
3044
|
/** The console/config sink, with no ring behind it. */
|
|
@@ -1651,6 +3047,25 @@ var SupervisorImpl = class {
|
|
|
1651
3047
|
else console[level === "info" ? "log" : level]("[irtio]", ...args);
|
|
1652
3048
|
}
|
|
1653
3049
|
/** A room event: it belongs in that room's ring, and nowhere near the `__tenant` one. */
|
|
3050
|
+
/**
|
|
3051
|
+
* D44: opens one NPC session and hands back the client half of its socket. It goes straight
|
|
3052
|
+
* through `onConnection` — the same function the WebSocket server calls — so an NPC and a
|
|
3053
|
+
* browser differ by a flag on the accept, and by nothing at all after it.
|
|
3054
|
+
*/
|
|
3055
|
+
openLoopback(roomId, clientId) {
|
|
3056
|
+
const { server, client } = loopbackPair();
|
|
3057
|
+
this.onConnection(server, {
|
|
3058
|
+
loopback: { clientId },
|
|
3059
|
+
origin: void 0,
|
|
3060
|
+
routerOrigin: void 0,
|
|
3061
|
+
forwardedFor: void 0,
|
|
3062
|
+
routerSecret: void 0,
|
|
3063
|
+
// Never a real address, and deliberately not `127.0.0.1`: an operator reading a session
|
|
3064
|
+
// list should be able to tell an NPC from something dialling the loopback interface.
|
|
3065
|
+
remoteAddress: `npc:${roomId}`
|
|
3066
|
+
});
|
|
3067
|
+
return client;
|
|
3068
|
+
}
|
|
1654
3069
|
roomLog(room, level, ...args) {
|
|
1655
3070
|
room.log(level, ...args);
|
|
1656
3071
|
this.emit(level, [`[${room.id}]`, ...args]);
|
|
@@ -1660,9 +3075,20 @@ var SupervisorImpl = class {
|
|
|
1660
3075
|
* shape, so a dev tenant's existing snapshots stay readable.
|
|
1661
3076
|
*/
|
|
1662
3077
|
/**
|
|
1663
|
-
* The
|
|
1664
|
-
*
|
|
1665
|
-
*
|
|
3078
|
+
* The admin API the host agent polls from outside the VM (week 5). It exists only when
|
|
3079
|
+
* `adminToken` is configured.
|
|
3080
|
+
*
|
|
3081
|
+
* Its original rule was that every route is a projection of `SupervisorAdmin` and the agent must
|
|
3082
|
+
* not be able to *change* anything about a tenant it is shipping telemetry for. That rule now
|
|
3083
|
+
* has five exceptions, each added deliberately and each documented at its own `case`:
|
|
3084
|
+
* `/admin/resume` (D31, the post-restore wall clock), `/admin/deploy` (the in-place deploy),
|
|
3085
|
+
* `/admin/caps` (D48), `/admin/identity-keys` (D53), `/admin/bus/deliver` (D67-e) and
|
|
3086
|
+
* `/admin/rooms/evict` (M6 lane A, D68-d). The surviving half of the rule is the part that
|
|
3087
|
+
* matters and it is unchanged: every one of them is a **push from control**, arriving over the
|
|
3088
|
+
* fleet socket and relayed by the agent with the derived admin token. None of them is something
|
|
3089
|
+
* a guest, a tenant or anything else that can reach this port may originate.
|
|
3090
|
+
*
|
|
3091
|
+
* `/admin/rooms/evict` is the one that disconnects players, so it is the one to read carefully.
|
|
1666
3092
|
*/
|
|
1667
3093
|
serveAdmin(req, res) {
|
|
1668
3094
|
const token = this.config.adminToken;
|
|
@@ -1693,7 +3119,10 @@ var SupervisorImpl = class {
|
|
|
1693
3119
|
// §10.8 escalation: consecutive flush passes that left a room unflushed. The host agent
|
|
1694
3120
|
// treats >=3 as worth an error-level log (once, deduped) instead of reading the tenant
|
|
1695
3121
|
// as merely slow to go idle.
|
|
1696
|
-
flushFailures: this.flushFailures
|
|
3122
|
+
flushFailures: this.flushFailures,
|
|
3123
|
+
// D56: has a Rapier world stepped in this VM yet? The agent gates a cold-booted
|
|
3124
|
+
// tenant's first snapshot on it. Additive; an agent that ignores it behaves as before.
|
|
3125
|
+
physicsWarm: this.physicsWarm
|
|
1697
3126
|
});
|
|
1698
3127
|
return true;
|
|
1699
3128
|
case "/admin/rooms":
|
|
@@ -1704,12 +3133,16 @@ var SupervisorImpl = class {
|
|
|
1704
3133
|
() => json({
|
|
1705
3134
|
at: Date.now(),
|
|
1706
3135
|
metrics: this.metrics(),
|
|
1707
|
-
rooms: this.rooms().map((r) => r.metrics)
|
|
3136
|
+
rooms: this.rooms().map((r) => ({ id: r.id, metrics: r.metrics })),
|
|
3137
|
+
// D47: the usage ledger rides the poll the agent already makes. A separate route
|
|
3138
|
+
// would be a fourth round trip per tenant per poll for four rows.
|
|
3139
|
+
usage: this.usageWindows()
|
|
1708
3140
|
}),
|
|
1709
3141
|
() => json({
|
|
1710
3142
|
at: Date.now(),
|
|
1711
3143
|
metrics: this.metrics(),
|
|
1712
|
-
rooms: this.rooms().map((r) => r.metrics)
|
|
3144
|
+
rooms: this.rooms().map((r) => ({ id: r.id, metrics: r.metrics })),
|
|
3145
|
+
usage: this.usageWindows()
|
|
1713
3146
|
})
|
|
1714
3147
|
);
|
|
1715
3148
|
return true;
|
|
@@ -1821,10 +3254,31 @@ var SupervisorImpl = class {
|
|
|
1821
3254
|
migrationPath = nodePath.join(dir, `m${d.version}.mjs`);
|
|
1822
3255
|
await writeFile(migrationPath, Buffer.from(d.migrationB64, "base64"));
|
|
1823
3256
|
}
|
|
3257
|
+
let rooms2;
|
|
3258
|
+
if (d.roomsB64 !== void 0) {
|
|
3259
|
+
if (typeof d.roomsB64 !== "object" || d.roomsB64 === null || Array.isArray(d.roomsB64)) {
|
|
3260
|
+
throw new Error("roomsB64 must be an object of room type to base64 bundle");
|
|
3261
|
+
}
|
|
3262
|
+
rooms2 = {};
|
|
3263
|
+
for (const [type, b64] of Object.entries(d.roomsB64)) {
|
|
3264
|
+
if (!ROOM_TYPE_RE.test(type)) {
|
|
3265
|
+
throw new Error(
|
|
3266
|
+
`roomsB64 names an illegal room type ${JSON.stringify(type)}`
|
|
3267
|
+
);
|
|
3268
|
+
}
|
|
3269
|
+
if (typeof b64 !== "string") {
|
|
3270
|
+
throw new Error(`roomsB64.${type} must be a base64 bundle`);
|
|
3271
|
+
}
|
|
3272
|
+
const typePath = nodePath.join(dir, `v${d.version}.${type}.mjs`);
|
|
3273
|
+
await writeFile(typePath, Buffer.from(b64, "base64"));
|
|
3274
|
+
rooms2[type] = typePath;
|
|
3275
|
+
}
|
|
3276
|
+
}
|
|
1824
3277
|
await this.addBundle({
|
|
1825
3278
|
version: d.version,
|
|
1826
3279
|
...typeof d.schemaJson === "string" ? { schemaJson: d.schemaJson } : {},
|
|
1827
3280
|
...bundlePath !== void 0 ? { bundlePath } : {},
|
|
3281
|
+
...rooms2 !== void 0 ? { rooms: rooms2 } : {},
|
|
1828
3282
|
...migrationPath !== void 0 ? { migrationPath } : {}
|
|
1829
3283
|
});
|
|
1830
3284
|
added.push(d.version);
|
|
@@ -1849,6 +3303,209 @@ var SupervisorImpl = class {
|
|
|
1849
3303
|
});
|
|
1850
3304
|
return true;
|
|
1851
3305
|
}
|
|
3306
|
+
// D48: the THIRD write on this otherwise read-only surface, and the reasoning is the same
|
|
3307
|
+
// one that carved out the other two. Cap state changes at runtime by design (a meter
|
|
3308
|
+
// crosses, a card is added, an owner raises a ceiling) and the boot-time env channel is
|
|
3309
|
+
// closed to it: env is hashed into the VM snapshot's identity (D29), so a cap that rode it
|
|
3310
|
+
// would cold-boot the fleet on every cap change. GET answers what this tenant currently
|
|
3311
|
+
// believes, which is the thing an operator wants when a join is being refused and nobody
|
|
3312
|
+
// can see why.
|
|
3313
|
+
// M5 part 7 (D67-e): the FOURTH write on this surface. A bus message another shard sent to
|
|
3314
|
+
// a room on this one, forwarded by the control plane through the host agent. Load-if-exists
|
|
3315
|
+
// then the local park; never a room from nothing. Answers the local `busSend` result so the
|
|
3316
|
+
// sender's promise resolves with the same refusals it would see on one shard.
|
|
3317
|
+
case "/admin/bus/deliver": {
|
|
3318
|
+
if (req.method !== "POST") {
|
|
3319
|
+
res.writeHead(405, { "content-type": "application/json" });
|
|
3320
|
+
res.end('{"error":"POST only"}');
|
|
3321
|
+
return true;
|
|
3322
|
+
}
|
|
3323
|
+
let body = "";
|
|
3324
|
+
req.on("data", (d) => {
|
|
3325
|
+
body += d.toString();
|
|
3326
|
+
});
|
|
3327
|
+
req.on("end", () => {
|
|
3328
|
+
void (async () => {
|
|
3329
|
+
let parsed;
|
|
3330
|
+
try {
|
|
3331
|
+
parsed = JSON.parse(body || "{}");
|
|
3332
|
+
} catch {
|
|
3333
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
3334
|
+
res.end('{"error":"body is not JSON"}');
|
|
3335
|
+
return;
|
|
3336
|
+
}
|
|
3337
|
+
const { to, from, payload, id } = parsed;
|
|
3338
|
+
if (typeof to !== "string" || typeof from !== "string" || typeof payload !== "string" || typeof id !== "string") {
|
|
3339
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
3340
|
+
res.end('{"error":"to, from, payload and id must be strings"}');
|
|
3341
|
+
return;
|
|
3342
|
+
}
|
|
3343
|
+
if (busPayloadProblem(payload)) {
|
|
3344
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
3345
|
+
res.end('{"error":"payload over the bus limit"}');
|
|
3346
|
+
return;
|
|
3347
|
+
}
|
|
3348
|
+
try {
|
|
3349
|
+
json(await this.deliverForwarded({ to, from, payload, id }));
|
|
3350
|
+
} catch (err) {
|
|
3351
|
+
this.log("error", "admin bus deliver failed", err);
|
|
3352
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
3353
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
|
|
3354
|
+
}
|
|
3355
|
+
})();
|
|
3356
|
+
});
|
|
3357
|
+
return true;
|
|
3358
|
+
}
|
|
3359
|
+
// ---- M6 lane A: rooms API ----
|
|
3360
|
+
// D68-d: the FIFTH write on this surface, and the only one that disconnects players. A
|
|
3361
|
+
// `DELETE ?force=true` on the rooms API reached control, control authorized it, and this is
|
|
3362
|
+
// control telling the guest to let the room go before the objects are removed underneath it.
|
|
3363
|
+
//
|
|
3364
|
+
// Same `adminToken` gate as every other route here, which is the whole access story: the
|
|
3365
|
+
// token is derived per project from the agent's own fleet token and never leaves the box, so
|
|
3366
|
+
// a tenant cannot forge it, and the frame that triggers this is `ControlToAgent` — a
|
|
3367
|
+
// direction a guest cannot speak on the fleet socket at all.
|
|
3368
|
+
//
|
|
3369
|
+
// Answers `{ outcome }`, never an error for "not here": a room this tenant is not holding is
|
|
3370
|
+
// `absent`, and control deletes the family either way.
|
|
3371
|
+
case "/admin/rooms/evict": {
|
|
3372
|
+
if (req.method !== "POST") {
|
|
3373
|
+
res.writeHead(405, { "content-type": "application/json" });
|
|
3374
|
+
res.end('{"error":"POST only"}');
|
|
3375
|
+
return true;
|
|
3376
|
+
}
|
|
3377
|
+
let body = "";
|
|
3378
|
+
req.on("data", (d) => {
|
|
3379
|
+
body += d.toString();
|
|
3380
|
+
});
|
|
3381
|
+
req.on("end", () => {
|
|
3382
|
+
void (async () => {
|
|
3383
|
+
let parsed;
|
|
3384
|
+
try {
|
|
3385
|
+
parsed = JSON.parse(body || "{}");
|
|
3386
|
+
} catch {
|
|
3387
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
3388
|
+
res.end('{"error":"body is not JSON"}');
|
|
3389
|
+
return;
|
|
3390
|
+
}
|
|
3391
|
+
if (typeof parsed.roomId !== "string" || parsed.roomId.length === 0) {
|
|
3392
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
3393
|
+
res.end('{"error":"roomId must be a non-empty string"}');
|
|
3394
|
+
return;
|
|
3395
|
+
}
|
|
3396
|
+
try {
|
|
3397
|
+
json({ outcome: await this.evictRoom(parsed.roomId) });
|
|
3398
|
+
} catch (err) {
|
|
3399
|
+
this.log("error", "admin room evict failed", err);
|
|
3400
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
3401
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
|
|
3402
|
+
}
|
|
3403
|
+
})();
|
|
3404
|
+
});
|
|
3405
|
+
return true;
|
|
3406
|
+
}
|
|
3407
|
+
// ---- end M6 lane A ----
|
|
3408
|
+
case "/admin/caps": {
|
|
3409
|
+
if (req.method === "GET") {
|
|
3410
|
+
const state = this.caps.current();
|
|
3411
|
+
json({
|
|
3412
|
+
present: state !== void 0,
|
|
3413
|
+
ageMs: this.caps.ageMs() ?? null,
|
|
3414
|
+
state: state ?? null
|
|
3415
|
+
});
|
|
3416
|
+
return true;
|
|
3417
|
+
}
|
|
3418
|
+
if (req.method !== "POST") {
|
|
3419
|
+
res.writeHead(405, { "content-type": "application/json" });
|
|
3420
|
+
res.end('{"error":"GET or POST"}');
|
|
3421
|
+
return true;
|
|
3422
|
+
}
|
|
3423
|
+
let body = "";
|
|
3424
|
+
req.on("data", (d) => {
|
|
3425
|
+
body += d.toString();
|
|
3426
|
+
});
|
|
3427
|
+
req.on("end", () => {
|
|
3428
|
+
let parsed;
|
|
3429
|
+
try {
|
|
3430
|
+
parsed = JSON.parse(body || "null");
|
|
3431
|
+
} catch {
|
|
3432
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
3433
|
+
res.end('{"error":"body is not JSON"}');
|
|
3434
|
+
return;
|
|
3435
|
+
}
|
|
3436
|
+
const state = parseCapState(parsed);
|
|
3437
|
+
if (!state) {
|
|
3438
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
3439
|
+
res.end('{"error":"body is not a cap state"}');
|
|
3440
|
+
return;
|
|
3441
|
+
}
|
|
3442
|
+
this.caps.apply(state);
|
|
3443
|
+
this.bandwidth.setPlan(state.plan);
|
|
3444
|
+
const leaseBytes = this.caps.leaseFor("egress_bytes");
|
|
3445
|
+
if (leaseBytes !== void 0) {
|
|
3446
|
+
const wasTripped = this.egressLease.tripped;
|
|
3447
|
+
this.egressLease.apply({
|
|
3448
|
+
remainingBytes: leaseBytes,
|
|
3449
|
+
periodStart: state.periodStart
|
|
3450
|
+
});
|
|
3451
|
+
if (this.egressLease.tripped && !wasTripped) this.tripEgressWall();
|
|
3452
|
+
else if (!this.egressLease.tripped && wasTripped) {
|
|
3453
|
+
this.log("info", "egress wall lifted: a fresh budget was leased");
|
|
3454
|
+
}
|
|
3455
|
+
}
|
|
3456
|
+
const crossed = state.meters.filter((m) => m.mode === "hard" && m.status === "crossed").map((m) => m.meter);
|
|
3457
|
+
if (crossed.length > 0) {
|
|
3458
|
+
this.log("warn", `usage caps applied: hard cap reached on ${crossed.join(", ")}`);
|
|
3459
|
+
}
|
|
3460
|
+
json({ ok: true, applied: state.meters.length, crossed });
|
|
3461
|
+
});
|
|
3462
|
+
return true;
|
|
3463
|
+
}
|
|
3464
|
+
/**
|
|
3465
|
+
* D53: the platform identity public keys, pushed the way cap state is and for the same
|
|
3466
|
+
* reason — an env var would be hashed into the VM snapshot's identity (D29) and every key
|
|
3467
|
+
* rotation would cold-boot the fleet.
|
|
3468
|
+
*
|
|
3469
|
+
* GET answers what this tenant currently holds (kids only; the PEMs are public but an
|
|
3470
|
+
* operator debugging a refused join wants the fingerprints, not a wall of base64). POST
|
|
3471
|
+
* replaces the set wholesale: two keys may be active at once, so a rotation is an overlap
|
|
3472
|
+
* rather than a flag day, exactly like `irtio keys jwt-secret`.
|
|
3473
|
+
*/
|
|
3474
|
+
case "/admin/identity-keys": {
|
|
3475
|
+
if (req.method === "GET") {
|
|
3476
|
+
json({ kids: this.identityKeys.map((k) => k.kid) });
|
|
3477
|
+
return true;
|
|
3478
|
+
}
|
|
3479
|
+
if (req.method !== "POST") {
|
|
3480
|
+
res.writeHead(405, { "content-type": "application/json" });
|
|
3481
|
+
res.end('{"error":"GET or POST"}');
|
|
3482
|
+
return true;
|
|
3483
|
+
}
|
|
3484
|
+
let body = "";
|
|
3485
|
+
req.on("data", (d) => {
|
|
3486
|
+
body += d.toString();
|
|
3487
|
+
});
|
|
3488
|
+
req.on("end", () => {
|
|
3489
|
+
let parsed;
|
|
3490
|
+
try {
|
|
3491
|
+
parsed = JSON.parse(body || "null");
|
|
3492
|
+
} catch {
|
|
3493
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
3494
|
+
res.end('{"error":"body is not JSON"}');
|
|
3495
|
+
return;
|
|
3496
|
+
}
|
|
3497
|
+
const keys = parseIdentityKeys(parsed);
|
|
3498
|
+
if (!keys) {
|
|
3499
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
3500
|
+
res.end('{"error":"body is not {keys: [{kid, publicKeyPem}]}"}');
|
|
3501
|
+
return;
|
|
3502
|
+
}
|
|
3503
|
+
this.identityKeys = keys;
|
|
3504
|
+
if (keys.length > 0) this.releaseIdentityKeyWaiters();
|
|
3505
|
+
json({ ok: true, kids: keys.map((k) => k.kid) });
|
|
3506
|
+
});
|
|
3507
|
+
return true;
|
|
3508
|
+
}
|
|
1852
3509
|
case "/admin/logs": {
|
|
1853
3510
|
const since = Number(url.searchParams.get("since") ?? 0) || 0;
|
|
1854
3511
|
const limit = Math.min(Number(url.searchParams.get("limit") ?? 200) || 200, 1e3);
|
|
@@ -1897,7 +3554,25 @@ var SupervisorImpl = class {
|
|
|
1897
3554
|
}
|
|
1898
3555
|
async registerDeployment(ref) {
|
|
1899
3556
|
const migrationUrl = ref.migrationPath !== void 0 ? toFileUrl(ref.migrationPath) : void 0;
|
|
1900
|
-
const
|
|
3557
|
+
const bundles = /* @__PURE__ */ new Map();
|
|
3558
|
+
if (ref.bundlePath !== void 0) {
|
|
3559
|
+
bundles.set(
|
|
3560
|
+
DEFAULT_ROOM_TYPE3,
|
|
3561
|
+
await loadBundle(ref.bundlePath, ref.version, DEFAULT_ROOM_TYPE3)
|
|
3562
|
+
);
|
|
3563
|
+
}
|
|
3564
|
+
for (const [type, path2] of Object.entries(ref.rooms ?? {})) {
|
|
3565
|
+
if (!ROOM_TYPE_RE.test(type)) {
|
|
3566
|
+
throw new Error(
|
|
3567
|
+
`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)`
|
|
3568
|
+
);
|
|
3569
|
+
}
|
|
3570
|
+
if (bundles.has(type)) {
|
|
3571
|
+
throw new Error(`irtio: deployment v${ref.version} defines the room type ${type} twice`);
|
|
3572
|
+
}
|
|
3573
|
+
bundles.set(type, await loadBundle(path2, ref.version, type));
|
|
3574
|
+
}
|
|
3575
|
+
const bundle = bundles.get(DEFAULT_ROOM_TYPE3);
|
|
1901
3576
|
const schemaJson = bundle?.schemaJson ?? ref.schemaJson;
|
|
1902
3577
|
if (schemaJson === void 0) {
|
|
1903
3578
|
throw new Error(
|
|
@@ -1907,14 +3582,210 @@ var SupervisorImpl = class {
|
|
|
1907
3582
|
this.deployments.set(ref.version, {
|
|
1908
3583
|
version: ref.version,
|
|
1909
3584
|
schemaJson,
|
|
3585
|
+
bundles,
|
|
1910
3586
|
...migrationUrl !== void 0 ? { migrationUrl } : {},
|
|
1911
3587
|
...bundle !== void 0 ? { bundle } : {}
|
|
1912
3588
|
});
|
|
1913
3589
|
}
|
|
3590
|
+
/**
|
|
3591
|
+
* The newest loaded bundle for a room type, or `undefined` when the tenant defines no such type.
|
|
3592
|
+
*
|
|
3593
|
+
* Deliberately distinct from "no bundle at all": a tenant with room types that is asked for a
|
|
3594
|
+
* type it does not have has made a naming mistake, and answering that with the relay rung would
|
|
3595
|
+
* silently give the client a schema-less room instead of an error it can read.
|
|
3596
|
+
*/
|
|
3597
|
+
bundleFor(type) {
|
|
3598
|
+
let best;
|
|
3599
|
+
for (const d of this.deployments.values()) {
|
|
3600
|
+
const b = d.bundles.get(type);
|
|
3601
|
+
if (b && (!best || b.version > best.version)) best = b;
|
|
3602
|
+
}
|
|
3603
|
+
return best;
|
|
3604
|
+
}
|
|
3605
|
+
/** Every room type this tenant defines, newest deployment first, sorted for stable messages. */
|
|
3606
|
+
roomTypes() {
|
|
3607
|
+
const types = /* @__PURE__ */ new Set();
|
|
3608
|
+
for (const d of this.deployments.values()) for (const type of d.bundles.keys()) types.add(type);
|
|
3609
|
+
return [...types].sort();
|
|
3610
|
+
}
|
|
3611
|
+
/**
|
|
3612
|
+
* M5 part 3.5: does what this project declared fit the VM it was given?
|
|
3613
|
+
*
|
|
3614
|
+
* **This check inverted in part 3.5, and what it does on failure inverted with it.** In part 2
|
|
3615
|
+
* the VM size was a class guess and the declarations were checked against it, so a mismatch was
|
|
3616
|
+
* a real possibility for an ordinary project and logging was the only defensible response:
|
|
3617
|
+
* refusing to boot would have stopped a project that was serving players, over arithmetic that
|
|
3618
|
+
* was deliberately pessimistic.
|
|
3619
|
+
*
|
|
3620
|
+
* Now the declarations *decide* the size, through the same `vmSizeFor` the host agent calls
|
|
3621
|
+
* before it builds the machine. An ordinary project therefore fits by construction and this
|
|
3622
|
+
* check can only fail one way: the project set an explicit `vmMemMib` override smaller than its
|
|
3623
|
+
* own declarations. That is not pessimism meeting reality, it is a project having stated two
|
|
3624
|
+
* incompatible things, and the honest answer to it pre-release is to refuse the room set at
|
|
3625
|
+
* boot rather than to serve rooms inside a machine that cannot hold them.
|
|
3626
|
+
*
|
|
3627
|
+
* No VM budget means a dev box, and dev keeps the generous defaults untouched.
|
|
3628
|
+
*/
|
|
3629
|
+
checkDeclaredSizing() {
|
|
3630
|
+
const vmMemMib = this.config.vmMemMib;
|
|
3631
|
+
if (vmMemMib === void 0) return;
|
|
3632
|
+
const types = this.roomTypes();
|
|
3633
|
+
if (types.length === 0) return;
|
|
3634
|
+
const declared = types.map((type) => {
|
|
3635
|
+
const config = this.bundleFor(type)?.config;
|
|
3636
|
+
return {
|
|
3637
|
+
type,
|
|
3638
|
+
memoryMb: config?.memoryMb,
|
|
3639
|
+
...config?.maxAwake !== void 0 ? { maxAwake: config.maxAwake } : {},
|
|
3640
|
+
...config?.engine !== void 0 ? { physics: true } : {}
|
|
3641
|
+
};
|
|
3642
|
+
});
|
|
3643
|
+
const fit = checkDeclaredFit(vmMemMib, declared);
|
|
3644
|
+
if (fit.fits) {
|
|
3645
|
+
this.log("info", `room type sizing fits the VM budget: ${fit.detail}`);
|
|
3646
|
+
return;
|
|
3647
|
+
}
|
|
3648
|
+
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(", ")}.` : "");
|
|
3649
|
+
this.log("error", message);
|
|
3650
|
+
throw new Error(`irtio: ${message}`);
|
|
3651
|
+
}
|
|
3652
|
+
/** D58: the worker limits a room of this type runs under: its declaration, or the default. */
|
|
3653
|
+
limitsFor(type) {
|
|
3654
|
+
const declared = this.bundleFor(type)?.config.memoryMb;
|
|
3655
|
+
if (declared === void 0) return this.limits;
|
|
3656
|
+
return {
|
|
3657
|
+
workerMaxOldGenMb: declared,
|
|
3658
|
+
workerMaxYoungGenMb: this.limits.workerMaxYoungGenMb
|
|
3659
|
+
};
|
|
3660
|
+
}
|
|
3661
|
+
/**
|
|
3662
|
+
* M5 part 3.5: how many rooms of a type are holding worker memory right now.
|
|
3663
|
+
*
|
|
3664
|
+
* Awake means "costs the VM its declared heap": starting, running, waking, and hibernating (a
|
|
3665
|
+
* room mid-sleep still has its worker). A hibernated or closed room holds nothing, which is why
|
|
3666
|
+
* `maxAwake` is a bound on this number and not on how many rooms a project may ever have.
|
|
3667
|
+
*/
|
|
3668
|
+
awakeOfType(type) {
|
|
3669
|
+
let n = 0;
|
|
3670
|
+
for (const room of this.registry.values()) {
|
|
3671
|
+
if (room.relay) continue;
|
|
3672
|
+
if (roomTypeOf(room.id) !== type) continue;
|
|
3673
|
+
if (room.state === "hibernated" || room.state === "closed") continue;
|
|
3674
|
+
n++;
|
|
3675
|
+
}
|
|
3676
|
+
return n;
|
|
3677
|
+
}
|
|
3678
|
+
/**
|
|
3679
|
+
* The refusal for starting a *new* room of a type that is already at its **declared** `maxAwake`,
|
|
3680
|
+
* or `undefined` when there is room.
|
|
3681
|
+
*
|
|
3682
|
+
* **This is what makes declared sizing honest rather than optimistic.** The tenant's VM was
|
|
3683
|
+
* built with `(memoryMb + youngGen) x maxAwake` MiB for this type, so room N+1 is a room the
|
|
3684
|
+
* machine was never sized to hold; admitting it would recreate exactly the "several under-cap
|
|
3685
|
+
* rooms sum past the VM" caveat part 1 recorded and part 3.5 exists to close.
|
|
3686
|
+
*
|
|
3687
|
+
* Four boundaries, stated rather than left implicit, because each of them narrows the claim.
|
|
3688
|
+
*
|
|
3689
|
+
* - **It applies only to a placed tenant.** No `IRT_VM_MEM_MIB` means a dev box, and a dev box
|
|
3690
|
+
* has headroom rather than a machine sized to a declaration — which is the same signal, and
|
|
3691
|
+
* the same reasoning, that leaves `DEFAULT_LIMITS` alone in `heap-caps.ts`. It is also what
|
|
3692
|
+
* `games/dive`'s end-to-end suite discovered the hard way: enforcing a production-shaped bound
|
|
3693
|
+
* against a developer's laptop refuses rooms nothing was going to run out of memory over. The
|
|
3694
|
+
* cost is that a developer meets this refusal in production rather than in dev, which is
|
|
3695
|
+
* recorded as a debt.
|
|
3696
|
+
*
|
|
3697
|
+
* - **Only a declared `maxAwake` is enforced.** An undeclared type is *sized* at one awake room,
|
|
3698
|
+
* which is the conservative number for arithmetic, but it is not *capped* at one. Capping it
|
|
3699
|
+
* would invent a limit no project asked for and would stop every existing multi-room project
|
|
3700
|
+
* dead at its second room. So for an undeclared type the pre-part-3.5 situation stands
|
|
3701
|
+
* unchanged: rooms are contained by their heap caps and their sum is not bounded. The bound is
|
|
3702
|
+
* something a project opts into by saying a number, and the sum is exact for the types that
|
|
3703
|
+
* did. This is narrower than the plan asked for and it is recorded as such.
|
|
3704
|
+
* - **Relay rooms are exempt.** They have no bundle, so they have no declaration, and their
|
|
3705
|
+
* client cap is a `SupervisorLimits` knob rather than a room-type one.
|
|
3706
|
+
* - **A wake is not refused, only a create.** Refusing to wake an existing room would leave a
|
|
3707
|
+
* room its players cannot get back into and its state stranded, which is the "silently dies
|
|
3708
|
+
* under a cap" failure D48 was built around avoiding. The consequence is that a project which
|
|
3709
|
+
* accumulates hibernated rooms can exceed its declared concurrency by waking them. Recorded as
|
|
3710
|
+
* a debt rather than hidden here: the bound bites where rooms are *created*, which is the
|
|
3711
|
+
* point a project controls.
|
|
3712
|
+
*/
|
|
3713
|
+
maxAwakeRefusal(roomId) {
|
|
3714
|
+
if (this.config.vmMemMib === void 0) return void 0;
|
|
3715
|
+
const type = roomTypeOf(roomId);
|
|
3716
|
+
const declared = this.bundleFor(type)?.config.maxAwake;
|
|
3717
|
+
if (declared === void 0) return void 0;
|
|
3718
|
+
if (this.awakeOfType(type) < declared) return void 0;
|
|
3719
|
+
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.`;
|
|
3720
|
+
}
|
|
3721
|
+
/**
|
|
3722
|
+
* M5 part 3.5 (Part C): how many rooms of any type are holding a worker right now.
|
|
3723
|
+
*
|
|
3724
|
+
* The same "awake" the per-type bound uses: starting, running, waking and hibernating hold a
|
|
3725
|
+
* worker; hibernated and closed do not. Relay rooms count, because a relay room is a real room
|
|
3726
|
+
* in the registry with real sockets attached, and the cap exists to bound exactly the burst a
|
|
3727
|
+
* crowd of relay rooms produces.
|
|
3728
|
+
*/
|
|
3729
|
+
awakeRooms() {
|
|
3730
|
+
let n = 0;
|
|
3731
|
+
for (const room of this.registry.values()) {
|
|
3732
|
+
if (room.state === "hibernated" || room.state === "closed") continue;
|
|
3733
|
+
n++;
|
|
3734
|
+
}
|
|
3735
|
+
return n;
|
|
3736
|
+
}
|
|
3737
|
+
/**
|
|
3738
|
+
* The refusal for starting a new room on a free project that is already at the concurrent
|
|
3739
|
+
* awake-rooms cap, or `undefined` when there is room.
|
|
3740
|
+
*
|
|
3741
|
+
* Free only, deliberately. A card project's exposure is bounded by money — the included tier,
|
|
3742
|
+
* then metered overage under a spend cap it controls — and adding a shape cap on top would be
|
|
3743
|
+
* refusing work a customer has agreed to pay for.
|
|
3744
|
+
*
|
|
3745
|
+
* A box with no cap state does not enforce this, for the same reason it enforces no other cap:
|
|
3746
|
+
* the failure direction is "serve", and a fleet socket blinking must not look like a free tier.
|
|
3747
|
+
*/
|
|
3748
|
+
freeAwakeRoomsRefusal() {
|
|
3749
|
+
if (this.caps.plan() !== "free") return void 0;
|
|
3750
|
+
const cap = this.limits.freeMaxAwakeRooms;
|
|
3751
|
+
if (this.awakeRooms() < cap) return void 0;
|
|
3752
|
+
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.`;
|
|
3753
|
+
}
|
|
3754
|
+
/**
|
|
3755
|
+
* M5 part 3.5 (Part C): the wall trips. Forwarding stops and every session closes.
|
|
3756
|
+
*
|
|
3757
|
+
* Three things are deliberate about what this does and does not do.
|
|
3758
|
+
*
|
|
3759
|
+
* **Sockets close, rooms do not.** Rooms hibernate on their ordinary idle path once their
|
|
3760
|
+
* clients are gone, which means state is snapshotted normally and comes back when the period
|
|
3761
|
+
* rolls or a card is added. A wall that destroyed rooms would turn a billing event into data
|
|
3762
|
+
* loss, which is the "silently dies under a cap" failure this milestone is built around
|
|
3763
|
+
* avoiding.
|
|
3764
|
+
*
|
|
3765
|
+
* **Every socket gets the distinct close code**, not just the ones that were sending. A player
|
|
3766
|
+
* who was idle when the wall tripped needs the same explanation as one who was not.
|
|
3767
|
+
*
|
|
3768
|
+
* **NPCs are closed too, and that is not a contradiction of their exemption.** An NPC's bytes
|
|
3769
|
+
* are loopback and never counted against the lease, so an NPC cannot spend a project into the
|
|
3770
|
+
* wall. But an NPC in a room whose players have all been disconnected is a worker burning
|
|
3771
|
+
* room-hours for an audience of nobody.
|
|
3772
|
+
*/
|
|
3773
|
+
tripEgressWall() {
|
|
3774
|
+
const plan = this.caps.plan() ?? "free";
|
|
3775
|
+
const message = egressWallMessage(plan);
|
|
3776
|
+
this.log(
|
|
3777
|
+
"warn",
|
|
3778
|
+
`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
|
|
3779
|
+
);
|
|
3780
|
+
for (const session of [...this.sessions]) {
|
|
3781
|
+
if (!session.open) continue;
|
|
3782
|
+
session.failWith("E_EGRESS_WALL", message, CLOSE_EGRESS_WALL);
|
|
3783
|
+
}
|
|
3784
|
+
}
|
|
1914
3785
|
/** Registers a newer deployment. Rooms already running keep theirs and drain (plan §3.3). */
|
|
1915
3786
|
async addBundle(ref) {
|
|
1916
|
-
if (ref.bundlePath === void 0 && ref.schemaJson === void 0) {
|
|
1917
|
-
throw new Error("addBundle: a deployment needs a bundlePath or a schemaJson");
|
|
3787
|
+
if (ref.bundlePath === void 0 && ref.schemaJson === void 0 && Object.keys(ref.rooms ?? {}).length === 0) {
|
|
3788
|
+
throw new Error("addBundle: a deployment needs a bundlePath, a rooms map or a schemaJson");
|
|
1918
3789
|
}
|
|
1919
3790
|
await this.registerDeployment(ref);
|
|
1920
3791
|
const newest = this.newestBundle();
|
|
@@ -1966,14 +3837,16 @@ var SupervisorImpl = class {
|
|
|
1966
3837
|
* `from` up to `to`. `undefined` when there is nothing to do; throws when a version in between
|
|
1967
3838
|
* is unknown, which would otherwise mean decoding bytes with the wrong schema.
|
|
1968
3839
|
*/
|
|
1969
|
-
migrationChain(from, to) {
|
|
3840
|
+
migrationChain(from, to, type = DEFAULT_ROOM_TYPE3) {
|
|
1970
3841
|
if (from >= to) return void 0;
|
|
1971
3842
|
const steps = [];
|
|
1972
3843
|
for (const d of [...this.deployments.values()].sort((a, b) => a.version - b.version)) {
|
|
1973
3844
|
if (d.version < from || d.version > to) continue;
|
|
1974
3845
|
steps.push({
|
|
1975
3846
|
version: d.version,
|
|
1976
|
-
|
|
3847
|
+
// D58: each type migrates against its own schema. The default type reads `d.schemaJson`,
|
|
3848
|
+
// which is exactly what every project got before types, so nothing single-room shifts.
|
|
3849
|
+
schemaJson: d.bundles.get(type)?.schemaJson ?? d.schemaJson,
|
|
1977
3850
|
...d.migrationUrl !== void 0 && d.version > from ? { migrationUrl: d.migrationUrl } : {}
|
|
1978
3851
|
});
|
|
1979
3852
|
}
|
|
@@ -2002,6 +3875,15 @@ var SupervisorImpl = class {
|
|
|
2002
3875
|
this.limits.maxBufferedBytes,
|
|
2003
3876
|
(s) => this.onSlowConsumer(s)
|
|
2004
3877
|
);
|
|
3878
|
+
if (meta.loopback) {
|
|
3879
|
+
session.npc = true;
|
|
3880
|
+
session.forcedClientId = meta.loopback.clientId;
|
|
3881
|
+
}
|
|
3882
|
+
session.onEgress = (bytes) => {
|
|
3883
|
+
this.usage.addEgressBytes(bytes);
|
|
3884
|
+
if (this.egressLease.spend(bytes)) this.tripEgressWall();
|
|
3885
|
+
this.bandwidth.count(bytes);
|
|
3886
|
+
};
|
|
2005
3887
|
this.sessions.add(session);
|
|
2006
3888
|
this.idleSince = 0;
|
|
2007
3889
|
this.idleFired = false;
|
|
@@ -2014,13 +3896,20 @@ var SupervisorImpl = class {
|
|
|
2014
3896
|
session.missedPongs = 0;
|
|
2015
3897
|
});
|
|
2016
3898
|
ws.on("message", (data) => this.onSocketMessage(session, data));
|
|
2017
|
-
if (!
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
3899
|
+
if (!session.npc) {
|
|
3900
|
+
if (!originAllowed(this.config.origins, this.config.allowNoOrigin !== false, origin)) {
|
|
3901
|
+
session.fail("E_ORIGIN", formatError("E_ORIGIN", { origin: origin ?? "(none)" }));
|
|
3902
|
+
return;
|
|
3903
|
+
}
|
|
3904
|
+
if (!this.ipBuckets.take(remoteAddress)) {
|
|
3905
|
+
session.fail("E_RATE_LIMITED", "too many connections from this address");
|
|
3906
|
+
return;
|
|
3907
|
+
}
|
|
3908
|
+
const capRefusal = this.caps.refusal("egress_bytes");
|
|
3909
|
+
if (capRefusal !== void 0) {
|
|
3910
|
+
session.fail("E_USAGE_CAP", capRefusal);
|
|
3911
|
+
return;
|
|
3912
|
+
}
|
|
2024
3913
|
}
|
|
2025
3914
|
session.helloTimer = setTimeout(() => {
|
|
2026
3915
|
session.helloTimer = void 0;
|
|
@@ -2037,7 +3926,7 @@ var SupervisorImpl = class {
|
|
|
2037
3926
|
let payload;
|
|
2038
3927
|
try {
|
|
2039
3928
|
const frame = decodeFrame(bytes);
|
|
2040
|
-
if (frame.type !==
|
|
3929
|
+
if (frame.type !== FrameType3.HELLO) {
|
|
2041
3930
|
session.fail("E_BAD_FRAME", formatError("E_BAD_FRAME", { frame: frame.type }));
|
|
2042
3931
|
return;
|
|
2043
3932
|
}
|
|
@@ -2056,10 +3945,10 @@ var SupervisorImpl = class {
|
|
|
2056
3945
|
session.slowConsumer = true;
|
|
2057
3946
|
try {
|
|
2058
3947
|
session.ws.send(
|
|
2059
|
-
|
|
2060
|
-
|
|
3948
|
+
encodeFrame3(
|
|
3949
|
+
FrameType3.ERROR,
|
|
2061
3950
|
encodeErrorPayload2({
|
|
2062
|
-
code:
|
|
3951
|
+
code: ErrorCode3.E_SLOW_CONSUMER.code,
|
|
2063
3952
|
message: formatError("E_SLOW_CONSUMER"),
|
|
2064
3953
|
fatal: true
|
|
2065
3954
|
})
|
|
@@ -2107,6 +3996,9 @@ var SupervisorImpl = class {
|
|
|
2107
3996
|
if (session.state !== "joined") return;
|
|
2108
3997
|
session.state = "disconnected";
|
|
2109
3998
|
room.metrics.connections = Math.max(0, room.metrics.connections - 1);
|
|
3999
|
+
if (session.npc) {
|
|
4000
|
+
room.metrics.npcConnections = Math.max(0, room.metrics.npcConnections - 1);
|
|
4001
|
+
}
|
|
2110
4002
|
if (session.fatal && !session.slowConsumer) {
|
|
2111
4003
|
this.expireSession(session, "kicked");
|
|
2112
4004
|
return;
|
|
@@ -2118,7 +4010,7 @@ var SupervisorImpl = class {
|
|
|
2118
4010
|
const delta = room.relayRoom.setConnected(session.clientId, false);
|
|
2119
4011
|
if (delta) {
|
|
2120
4012
|
room.lastTick = room.relayRoom.tick;
|
|
2121
|
-
this.broadcast(room,
|
|
4013
|
+
this.broadcast(room, encodeFrame3(FrameType3.DELTA, delta));
|
|
2122
4014
|
}
|
|
2123
4015
|
}
|
|
2124
4016
|
session.graceTimer = setTimeout(
|
|
@@ -2140,11 +4032,21 @@ var SupervisorImpl = class {
|
|
|
2140
4032
|
if (!room) return;
|
|
2141
4033
|
if (room.clients.get(session.clientId) === session) room.clients.delete(session.clientId);
|
|
2142
4034
|
this.clientIds.delete(session.clientId);
|
|
4035
|
+
if (this.config.voice) {
|
|
4036
|
+
void this.config.voice.close({
|
|
4037
|
+
projectId: this.config.projectId,
|
|
4038
|
+
roomId: room.id,
|
|
4039
|
+
clientId: session.clientId,
|
|
4040
|
+
npc: session.npc
|
|
4041
|
+
}).catch((err) => {
|
|
4042
|
+
this.log("warn", `voice close failed for ${session.clientId}: ${String(err)}`);
|
|
4043
|
+
});
|
|
4044
|
+
}
|
|
2143
4045
|
if (room.relay) {
|
|
2144
4046
|
const delta = room.relayRoom?.remove(session.clientId);
|
|
2145
4047
|
if (delta && room.relayRoom) {
|
|
2146
4048
|
room.lastTick = room.relayRoom.tick;
|
|
2147
|
-
this.broadcast(room,
|
|
4049
|
+
this.broadcast(room, encodeFrame3(FrameType3.DELTA, delta));
|
|
2148
4050
|
}
|
|
2149
4051
|
} else if (room.state === "running" && room.worker) {
|
|
2150
4052
|
room.worker.post({ t: "leave", clientId: session.clientId, reason });
|
|
@@ -2156,6 +4058,44 @@ var SupervisorImpl = class {
|
|
|
2156
4058
|
// -------------------------------------------------------------------------
|
|
2157
4059
|
// HELLO
|
|
2158
4060
|
// -------------------------------------------------------------------------
|
|
4061
|
+
/** Assertion HELLOs parked waiting for the platform keys. See the field for why it is bounded. */
|
|
4062
|
+
get identityKeyWaiterCount() {
|
|
4063
|
+
return this.identityKeyWaiters.length;
|
|
4064
|
+
}
|
|
4065
|
+
/** Wakes every parked assertion HELLO. Called when the first key push lands, and on close. */
|
|
4066
|
+
releaseIdentityKeyWaiters() {
|
|
4067
|
+
const waiters = this.identityKeyWaiters;
|
|
4068
|
+
this.identityKeyWaiters = [];
|
|
4069
|
+
for (const wake of waiters) wake();
|
|
4070
|
+
}
|
|
4071
|
+
/**
|
|
4072
|
+
* Waits, bounded, for this tenant to be handed a platform key.
|
|
4073
|
+
*
|
|
4074
|
+
* Three ways out and all three are the same to the caller: the keys arrive (the common case, in
|
|
4075
|
+
* about one loopback round trip), the deadline passes, or the parking list is already full. In
|
|
4076
|
+
* every one of them the caller goes straight back to `verifyAssertion`, which refuses with
|
|
4077
|
+
* `E_ASSERTION_UNVERIFIABLE` if the keys still are not there. Nothing here can turn a refusal
|
|
4078
|
+
* into an acceptance; the only thing it changes is how long a player waits before finding out.
|
|
4079
|
+
*/
|
|
4080
|
+
awaitIdentityKeys() {
|
|
4081
|
+
const waitMs = this.config.identityKeyWaitMs ?? IDENTITY_KEY_WAIT_MS;
|
|
4082
|
+
if (waitMs <= 0) return Promise.resolve();
|
|
4083
|
+
if (this.identityKeyWaiters.length >= MAX_IDENTITY_KEY_WAITERS) return Promise.resolve();
|
|
4084
|
+
return new Promise((resolve2) => {
|
|
4085
|
+
let done = false;
|
|
4086
|
+
const finish = () => {
|
|
4087
|
+
if (done) return;
|
|
4088
|
+
done = true;
|
|
4089
|
+
clearTimeout(timer);
|
|
4090
|
+
const at = this.identityKeyWaiters.indexOf(finish);
|
|
4091
|
+
if (at >= 0) this.identityKeyWaiters.splice(at, 1);
|
|
4092
|
+
resolve2();
|
|
4093
|
+
};
|
|
4094
|
+
const timer = setTimeout(finish, waitMs);
|
|
4095
|
+
timer.unref?.();
|
|
4096
|
+
this.identityKeyWaiters.push(finish);
|
|
4097
|
+
});
|
|
4098
|
+
}
|
|
2159
4099
|
async handleHello(session, payload) {
|
|
2160
4100
|
session.clearHelloTimer();
|
|
2161
4101
|
let hello;
|
|
@@ -2182,7 +4122,16 @@ var SupervisorImpl = class {
|
|
|
2182
4122
|
return;
|
|
2183
4123
|
}
|
|
2184
4124
|
let jwt;
|
|
2185
|
-
if (credential.kind === "
|
|
4125
|
+
if (credential.kind === "assertion") {
|
|
4126
|
+
if (this.identityKeys.length === 0) await this.awaitIdentityKeys();
|
|
4127
|
+
const verdict = verifyAssertion(this.identityKeys, credential.token, this.config.projectId);
|
|
4128
|
+
if (!verdict.ok) {
|
|
4129
|
+
const vars = verdict.code === "E_TOKEN_MALFORMED" ? { reason: verdict.reason } : verdict.code === "E_TOKEN_BAD_ALG" ? { alg: verdict.reason } : {};
|
|
4130
|
+
session.fail(verdict.code, formatError(verdict.code, vars));
|
|
4131
|
+
return;
|
|
4132
|
+
}
|
|
4133
|
+
jwt = { playerId: verdict.playerId };
|
|
4134
|
+
} else if (credential.kind === "jwt") {
|
|
2186
4135
|
const issuers = this.config.jwtIssuers;
|
|
2187
4136
|
if (issuers === void 0 || issuers.length === 0) {
|
|
2188
4137
|
session.fail("E_AUTH", "this project has no JWT secret \u2014 mint one: irtio keys jwt-secret");
|
|
@@ -2196,23 +4145,12 @@ var SupervisorImpl = class {
|
|
|
2196
4145
|
}
|
|
2197
4146
|
jwt = verdict;
|
|
2198
4147
|
}
|
|
2199
|
-
if (this.bundle) {
|
|
2200
|
-
const known = [...this.deployments.values()].some(
|
|
2201
|
-
(d) => d.bundle !== void 0 && bytesEqual(hello.schemaHash8, d.bundle.hash8)
|
|
2202
|
-
);
|
|
2203
|
-
if (!known) {
|
|
2204
|
-
session.fail("E_SCHEMA_MISMATCH", formatError("E_SCHEMA_MISMATCH"));
|
|
2205
|
-
return;
|
|
2206
|
-
}
|
|
2207
|
-
} else if (!isRelayHash8(hello.schemaHash8)) {
|
|
2208
|
-
session.fail("E_SCHEMA_MISMATCH", "this project has no room code deployed (relay tenant)");
|
|
2209
|
-
return;
|
|
2210
|
-
}
|
|
2211
4148
|
session.state = "joining";
|
|
2212
4149
|
session.role = jwt?.role ?? hello.role ?? "";
|
|
2213
4150
|
session.name = hello.name ?? "";
|
|
2214
4151
|
session.playerId = jwt?.playerId;
|
|
2215
4152
|
session.schemaHash8 = hello.schemaHash8;
|
|
4153
|
+
session.schemaSwap = hello.schemaSwap === true;
|
|
2216
4154
|
let resume;
|
|
2217
4155
|
if (hello.resumeToken !== void 0 && hello.resumeToken !== "") {
|
|
2218
4156
|
resume = verifyResume(this.resumeSecret, hello.resumeToken);
|
|
@@ -2238,10 +4176,60 @@ var SupervisorImpl = class {
|
|
|
2238
4176
|
} else {
|
|
2239
4177
|
roomId = newRoomCode((code) => this.registry.has(code));
|
|
2240
4178
|
}
|
|
4179
|
+
const roomType = roomTypeOf(roomId);
|
|
4180
|
+
const typed = this.roomTypes().length > 0;
|
|
4181
|
+
if (typed) {
|
|
4182
|
+
const known = [...this.deployments.values()].some((d) => {
|
|
4183
|
+
const b = d.bundles.get(roomType);
|
|
4184
|
+
return b !== void 0 && bytesEqual(hello.schemaHash8, b.hash8);
|
|
4185
|
+
});
|
|
4186
|
+
if (!known) {
|
|
4187
|
+
if (this.bundleFor(roomType) === void 0) {
|
|
4188
|
+
session.fail(
|
|
4189
|
+
"E_ROOM_NOT_FOUND",
|
|
4190
|
+
`this project has no room type ${JSON.stringify(roomType)}. It defines: ` + this.roomTypes().join(", ")
|
|
4191
|
+
);
|
|
4192
|
+
return;
|
|
4193
|
+
}
|
|
4194
|
+
session.fail(
|
|
4195
|
+
"E_SCHEMA_MISMATCH",
|
|
4196
|
+
`${formatError("E_SCHEMA_MISMATCH")} for room type ${roomType}`
|
|
4197
|
+
);
|
|
4198
|
+
return;
|
|
4199
|
+
}
|
|
4200
|
+
} else if (!isRelayHash8(hello.schemaHash8)) {
|
|
4201
|
+
session.fail("E_SCHEMA_MISMATCH", "this project has no room code deployed (relay tenant)");
|
|
4202
|
+
return;
|
|
4203
|
+
}
|
|
2241
4204
|
if (jwt?.roomId !== void 0 && jwt.roomId !== roomId) {
|
|
2242
4205
|
session.fail("E_TOKEN_WRONG_ROOM", formatError("E_TOKEN_WRONG_ROOM"));
|
|
2243
4206
|
return;
|
|
2244
4207
|
}
|
|
4208
|
+
if (!session.npc && !this.registry.has(roomId)) {
|
|
4209
|
+
const roomCapRefusal = this.caps.refusal("room_hours");
|
|
4210
|
+
if (roomCapRefusal !== void 0) {
|
|
4211
|
+
session.fail("E_USAGE_CAP", roomCapRefusal);
|
|
4212
|
+
return;
|
|
4213
|
+
}
|
|
4214
|
+
const tierRefusal = this.freeAwakeRoomsRefusal();
|
|
4215
|
+
if (tierRefusal !== void 0) {
|
|
4216
|
+
session.fail("E_TIER_LIMIT", tierRefusal);
|
|
4217
|
+
return;
|
|
4218
|
+
}
|
|
4219
|
+
if (this.egressLease.tripped) {
|
|
4220
|
+
session.failWith(
|
|
4221
|
+
"E_EGRESS_WALL",
|
|
4222
|
+
egressWallMessage(this.caps.plan() ?? "free"),
|
|
4223
|
+
CLOSE_EGRESS_WALL
|
|
4224
|
+
);
|
|
4225
|
+
return;
|
|
4226
|
+
}
|
|
4227
|
+
const awakeRefusal = this.maxAwakeRefusal(roomId);
|
|
4228
|
+
if (awakeRefusal !== void 0) {
|
|
4229
|
+
session.fail("E_TYPE_AT_CAPACITY", awakeRefusal);
|
|
4230
|
+
return;
|
|
4231
|
+
}
|
|
4232
|
+
}
|
|
2245
4233
|
const room = await this.ensureRoom(roomId);
|
|
2246
4234
|
if (!room) {
|
|
2247
4235
|
session.fail("E_INTERNAL", formatError("E_INTERNAL"));
|
|
@@ -2256,8 +4244,12 @@ var SupervisorImpl = class {
|
|
|
2256
4244
|
session.roomId = roomId;
|
|
2257
4245
|
let reconnecting = false;
|
|
2258
4246
|
if (resume) {
|
|
2259
|
-
reconnecting = true;
|
|
2260
4247
|
const existing = room.clients.get(resume.clientId);
|
|
4248
|
+
if (!existing && resume.iatMs >= room.createdAt) {
|
|
4249
|
+
session.fail("E_RESUME_EXPIRED", formatError("E_RESUME_EXPIRED"));
|
|
4250
|
+
return;
|
|
4251
|
+
}
|
|
4252
|
+
reconnecting = true;
|
|
2261
4253
|
if (existing && existing !== session) {
|
|
2262
4254
|
existing.clearGraceTimer();
|
|
2263
4255
|
existing.state = "gone";
|
|
@@ -2270,7 +4262,16 @@ var SupervisorImpl = class {
|
|
|
2270
4262
|
session.clientId = resume.clientId;
|
|
2271
4263
|
if (session.role === "") session.role = resume.role;
|
|
2272
4264
|
} else {
|
|
2273
|
-
|
|
4265
|
+
const forced = session.forcedClientId;
|
|
4266
|
+
if (forced !== void 0) {
|
|
4267
|
+
if (this.clientIds.has(forced)) {
|
|
4268
|
+
session.fail("E_INTERNAL", `npc client id ${forced} is already in use`);
|
|
4269
|
+
return;
|
|
4270
|
+
}
|
|
4271
|
+
session.clientId = forced;
|
|
4272
|
+
} else {
|
|
4273
|
+
session.clientId = newClientId((id) => this.clientIds.has(id));
|
|
4274
|
+
}
|
|
2274
4275
|
}
|
|
2275
4276
|
this.clientIds.add(session.clientId);
|
|
2276
4277
|
room.clients.set(session.clientId, session);
|
|
@@ -2286,21 +4287,37 @@ var SupervisorImpl = class {
|
|
|
2286
4287
|
if (inFlight) return inFlight;
|
|
2287
4288
|
const existing = this.registry.get(roomId);
|
|
2288
4289
|
if (existing) {
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
4290
|
+
for (; ; ) {
|
|
4291
|
+
if (existing.state === "hibernated" || existing.state === "hibernating") {
|
|
4292
|
+
await wakeRoom(this, existing);
|
|
4293
|
+
} else {
|
|
4294
|
+
await existing.transition.catch(() => {
|
|
4295
|
+
});
|
|
4296
|
+
}
|
|
4297
|
+
const state = existing.state;
|
|
4298
|
+
if (state !== "hibernated" && state !== "hibernating") break;
|
|
4299
|
+
}
|
|
2292
4300
|
return existing;
|
|
2293
4301
|
}
|
|
2294
4302
|
const create = (async () => {
|
|
2295
|
-
const
|
|
2296
|
-
const
|
|
4303
|
+
const relay = this.deployments.size === 0 || this.roomTypes().length === 0;
|
|
4304
|
+
const bundle = relay ? void 0 : this.bundleFor(roomTypeOf(roomId));
|
|
4305
|
+
if (!relay && bundle === void 0) {
|
|
4306
|
+
this.log(
|
|
4307
|
+
"warn",
|
|
4308
|
+
`no room type ${JSON.stringify(roomTypeOf(roomId))} in this project (join of ${roomId}); this project defines: ${this.roomTypes().join(", ")}`
|
|
4309
|
+
);
|
|
4310
|
+
return void 0;
|
|
4311
|
+
}
|
|
4312
|
+
const config = bundle ? bundle.config : relayConfig(this.limits, this.caps.plan());
|
|
4313
|
+
const room = new RoomRecord(roomId, bundle === void 0, config);
|
|
2297
4314
|
room.version = this.deploymentVersion;
|
|
2298
4315
|
room.pendingRestoreSaveId = this.pendingRestores.get(roomId);
|
|
2299
4316
|
this.pendingRestores.delete(roomId);
|
|
2300
4317
|
this.registry.set(room);
|
|
2301
4318
|
const restored = await this.loadPendingRestore(room);
|
|
2302
|
-
|
|
2303
|
-
|
|
4319
|
+
let found = restored ?? await this.findSnapshot(roomId);
|
|
4320
|
+
let snapshot = found?.bytes;
|
|
2304
4321
|
if (room.relay) {
|
|
2305
4322
|
room.relayRoom = snapshot ? RelayRoom.restore(snapshot) : RelayRoom.create();
|
|
2306
4323
|
room.lastTick = room.relayRoom.tick;
|
|
@@ -2308,6 +4325,15 @@ var SupervisorImpl = class {
|
|
|
2308
4325
|
this.armRelayIdle(room);
|
|
2309
4326
|
return room;
|
|
2310
4327
|
}
|
|
4328
|
+
if (found && found.version === 0 && room.version > 0 && isRelayPresenceSnapshot(snapshot)) {
|
|
4329
|
+
this.roomLog(
|
|
4330
|
+
room,
|
|
4331
|
+
"info",
|
|
4332
|
+
`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`
|
|
4333
|
+
);
|
|
4334
|
+
snapshot = void 0;
|
|
4335
|
+
found = void 0;
|
|
4336
|
+
}
|
|
2311
4337
|
if (found && found.version > room.version) {
|
|
2312
4338
|
this.roomLog(
|
|
2313
4339
|
room,
|
|
@@ -2320,7 +4346,7 @@ var SupervisorImpl = class {
|
|
|
2320
4346
|
let migrate;
|
|
2321
4347
|
if (found && found.version !== room.version) {
|
|
2322
4348
|
try {
|
|
2323
|
-
migrate = this.migrationChain(found.version, room.version);
|
|
4349
|
+
migrate = this.migrationChain(found.version, room.version, room.roomType);
|
|
2324
4350
|
} catch (err) {
|
|
2325
4351
|
this.roomLog(room, "error", "cannot migrate this room", err);
|
|
2326
4352
|
this.registry.delete(roomId);
|
|
@@ -2354,9 +4380,10 @@ var SupervisorImpl = class {
|
|
|
2354
4380
|
}
|
|
2355
4381
|
/** Spawns the worker and waits for `ready`. Leaves the room's state to the caller. */
|
|
2356
4382
|
async startWorker(room, snapshot, migrate) {
|
|
2357
|
-
const
|
|
4383
|
+
const type = room.roomType;
|
|
4384
|
+
const bundle = this.deployments.get(room.version)?.bundles.get(type) ?? this.bundleFor(type);
|
|
2358
4385
|
if (!bundle) return false;
|
|
2359
|
-
const worker = new WorkerHost(this.workerEntry, this.
|
|
4386
|
+
const worker = new WorkerHost(this.workerEntry, this.limitsFor(type), {
|
|
2360
4387
|
onMessage: (msg) => this.onWorkerMessage(room, msg),
|
|
2361
4388
|
onDead: (reason) => this.onWorkerDead(room, reason)
|
|
2362
4389
|
});
|
|
@@ -2377,8 +4404,15 @@ var SupervisorImpl = class {
|
|
|
2377
4404
|
roomId: room.id,
|
|
2378
4405
|
bundleUrl: bundle.bundleUrl,
|
|
2379
4406
|
...this.config.publicUrl !== void 0 ? { publicUrl: this.config.publicUrl } : {},
|
|
2380
|
-
|
|
2381
|
-
|
|
4407
|
+
// A copy, because `post` transfers the buffer and the caller may need its bytes again:
|
|
4408
|
+
// `migrateRoom` restarts the old version from the same snapshot when the chain throws,
|
|
4409
|
+
// and posting the detached buffer a second time killed that worker with a
|
|
4410
|
+
// DataCloneError. The crash restart then revived the room from the store, which is how
|
|
4411
|
+
// the containment test passed while an orphaned ready waiter timed out 30 s later.
|
|
4412
|
+
...snapshot ? { snapshot: new Uint8Array(snapshot) } : {},
|
|
4413
|
+
...migrate ? { migrate } : {},
|
|
4414
|
+
// D65: `irtio dev --profile` only.
|
|
4415
|
+
...this.config.profile === true ? { profile: true } : {}
|
|
2382
4416
|
});
|
|
2383
4417
|
});
|
|
2384
4418
|
if (!outcome || outcome.t !== "ready") {
|
|
@@ -2392,6 +4426,7 @@ var SupervisorImpl = class {
|
|
|
2392
4426
|
return false;
|
|
2393
4427
|
}
|
|
2394
4428
|
room.config = outcome.config;
|
|
4429
|
+
room.backfillOpen = true;
|
|
2395
4430
|
if (outcome.migrated) {
|
|
2396
4431
|
const m = outcome.migrated;
|
|
2397
4432
|
this.roomLog(
|
|
@@ -2436,6 +4471,8 @@ var SupervisorImpl = class {
|
|
|
2436
4471
|
const saveId = await this.saveRoom(room);
|
|
2437
4472
|
return { ok: true, value: saveId };
|
|
2438
4473
|
}
|
|
4474
|
+
case "busSend":
|
|
4475
|
+
return this.busSend(room, call.roomId, call.payload);
|
|
2439
4476
|
case "kvGet": {
|
|
2440
4477
|
const value = await this.requireKv().get(call.playerId, call.key);
|
|
2441
4478
|
return value === void 0 ? { ok: true } : { ok: true, value };
|
|
@@ -2446,7 +4483,66 @@ var SupervisorImpl = class {
|
|
|
2446
4483
|
case "kvDelete":
|
|
2447
4484
|
await this.requireKv().delete(call.playerId, call.key);
|
|
2448
4485
|
return { ok: true };
|
|
4486
|
+
case "lbSubmit": {
|
|
4487
|
+
if (!this.roomHoldsPlayer(room, call.playerId)) {
|
|
4488
|
+
return {
|
|
4489
|
+
ok: false,
|
|
4490
|
+
code: LEADERBOARD_ERRORS.notInRoom,
|
|
4491
|
+
message: `${call.playerId} is not a client of room ${room.id}; leaderboard submits are only accepted for players currently in the room`
|
|
4492
|
+
};
|
|
4493
|
+
}
|
|
4494
|
+
await this.requireLeaderboard().submit(call.board, call.playerId, call.score, call.bucket);
|
|
4495
|
+
return { ok: true };
|
|
4496
|
+
}
|
|
4497
|
+
case "ratingReport": {
|
|
4498
|
+
const problem = ratingQueueProblem(call.queue) ?? ratingResultsProblem(call.results);
|
|
4499
|
+
if (problem) return { ok: false, code: problem.code, message: problem.message };
|
|
4500
|
+
for (const r of call.results) {
|
|
4501
|
+
if (!room.hasHeldPlayer(r.playerId)) {
|
|
4502
|
+
return {
|
|
4503
|
+
ok: false,
|
|
4504
|
+
code: RATING_ERRORS.notInRoom,
|
|
4505
|
+
message: `${r.playerId} has never been a client of room ${room.id}; rating reports are only accepted for players this room has actually held`
|
|
4506
|
+
};
|
|
4507
|
+
}
|
|
4508
|
+
}
|
|
4509
|
+
await this.requireRatings().report(call.queue, call.results);
|
|
4510
|
+
return { ok: true };
|
|
4511
|
+
}
|
|
4512
|
+
case "ratingSet": {
|
|
4513
|
+
const problem = ratingQueueProblem(call.queue) ?? ratingPlayerProblem(call.playerId) ?? ratingValueProblem({
|
|
4514
|
+
rating: call.rating,
|
|
4515
|
+
...call.deviation !== void 0 ? { deviation: call.deviation } : {}
|
|
4516
|
+
});
|
|
4517
|
+
if (problem) return { ok: false, code: problem.code, message: problem.message };
|
|
4518
|
+
if (!room.hasHeldPlayer(call.playerId)) {
|
|
4519
|
+
return {
|
|
4520
|
+
ok: false,
|
|
4521
|
+
code: RATING_ERRORS.notInRoom,
|
|
4522
|
+
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`
|
|
4523
|
+
};
|
|
4524
|
+
}
|
|
4525
|
+
await this.requireRatings().set(call.queue, call.playerId, {
|
|
4526
|
+
rating: call.rating,
|
|
4527
|
+
...call.deviation !== void 0 ? { deviation: call.deviation } : {}
|
|
4528
|
+
});
|
|
4529
|
+
return { ok: true };
|
|
4530
|
+
}
|
|
4531
|
+
}
|
|
4532
|
+
}
|
|
4533
|
+
/**
|
|
4534
|
+
* Is `playerId` one of this room's current clients?
|
|
4535
|
+
*
|
|
4536
|
+
* The identity a session answers to is its verified `playerId` when it has one (a JWT or a
|
|
4537
|
+
* platform assertion) and its client id otherwise — the same `ctx.playerId` ladder the room
|
|
4538
|
+
* itself sees, so a room submitting under the id it was handed always passes, and a room
|
|
4539
|
+
* submitting under an id it invented never does.
|
|
4540
|
+
*/
|
|
4541
|
+
roomHoldsPlayer(room, playerId) {
|
|
4542
|
+
for (const session of room.clients.values()) {
|
|
4543
|
+
if ((session.playerId ?? session.clientId) === playerId) return true;
|
|
2449
4544
|
}
|
|
4545
|
+
return false;
|
|
2450
4546
|
}
|
|
2451
4547
|
requireKv() {
|
|
2452
4548
|
const kv = this.config.kv;
|
|
@@ -2457,6 +4553,24 @@ var SupervisorImpl = class {
|
|
|
2457
4553
|
}
|
|
2458
4554
|
return kv;
|
|
2459
4555
|
}
|
|
4556
|
+
requireLeaderboard() {
|
|
4557
|
+
const lb = this.config.leaderboard;
|
|
4558
|
+
if (!lb) {
|
|
4559
|
+
throw new LeaderboardUnavailable(
|
|
4560
|
+
"leaderboards are not configured for this tenant (no control plane behind it)"
|
|
4561
|
+
);
|
|
4562
|
+
}
|
|
4563
|
+
return lb;
|
|
4564
|
+
}
|
|
4565
|
+
requireRatings() {
|
|
4566
|
+
const ratings = this.config.ratings;
|
|
4567
|
+
if (!ratings) {
|
|
4568
|
+
throw new RatingUnavailable(
|
|
4569
|
+
"skill ratings are not configured for this tenant (no control plane behind it)"
|
|
4570
|
+
);
|
|
4571
|
+
}
|
|
4572
|
+
return ratings;
|
|
4573
|
+
}
|
|
2460
4574
|
/**
|
|
2461
4575
|
* D24: one save generation. Serializes exactly the way hibernation does — same
|
|
2462
4576
|
* `worker.serialize()`, same bytes, same `putSnapshotWithRetry` durability — writes it under
|
|
@@ -2588,54 +4702,382 @@ var SupervisorImpl = class {
|
|
|
2588
4702
|
}
|
|
2589
4703
|
}
|
|
2590
4704
|
/**
|
|
2591
|
-
* D26: re-arms this room's alarms from the sidecar and starts persisting changes to it.
|
|
2592
|
-
*
|
|
2593
|
-
* Anything already due fires immediately — an alarm is "at or after", never before, and a
|
|
2594
|
-
* tenant that was stopped for an hour owes the room an hour-late alarm rather than nothing.
|
|
4705
|
+
* D26: re-arms this room's alarms from the sidecar and starts persisting changes to it.
|
|
4706
|
+
*
|
|
4707
|
+
* Anything already due fires immediately — an alarm is "at or after", never before, and a
|
|
4708
|
+
* tenant that was stopped for an hour owes the room an hour-late alarm rather than nothing.
|
|
4709
|
+
*/
|
|
4710
|
+
async loadAlarms(room) {
|
|
4711
|
+
if (room.relay) return;
|
|
4712
|
+
let persisted = [];
|
|
4713
|
+
try {
|
|
4714
|
+
const bytes = await this.store.get(alarmsKey(this.storeKey(room)));
|
|
4715
|
+
if (bytes) persisted = decodeAlarms(bytes);
|
|
4716
|
+
} catch (err) {
|
|
4717
|
+
this.roomLog(room, "warn", "could not read this room's alarms; starting with none", err);
|
|
4718
|
+
}
|
|
4719
|
+
room.alarms.onChange = () => this.persistAlarms(room);
|
|
4720
|
+
room.alarms.attach((names) => this.deliverAlarms(room, names));
|
|
4721
|
+
if (persisted.length > 0) {
|
|
4722
|
+
room.alarms.load(persisted);
|
|
4723
|
+
const overdue = room.alarms.fireDue();
|
|
4724
|
+
if (overdue.length > 0) {
|
|
4725
|
+
this.roomLog(
|
|
4726
|
+
room,
|
|
4727
|
+
"info",
|
|
4728
|
+
`firing ${overdue.length} overdue alarm(s): ${overdue.join(", ")}`
|
|
4729
|
+
);
|
|
4730
|
+
}
|
|
4731
|
+
}
|
|
4732
|
+
}
|
|
4733
|
+
/**
|
|
4734
|
+
* Delivers due alarms into the room, **waking it first if it is asleep**. This is the whole
|
|
4735
|
+
* hibernated case: the timer kept counting because it lives on the record rather than in the
|
|
4736
|
+
* worker, and the wake is the same one a joining client would have triggered.
|
|
4737
|
+
*
|
|
4738
|
+
* The loop is not defensive padding — it is the fix for a real race that cost an hour to find.
|
|
4739
|
+
* `fireDue` removes an alarm before delivering it, so between the state check and the `await`
|
|
4740
|
+
* the room can slip into hibernation (`hibernateRoom` flips the state synchronously). A single
|
|
4741
|
+
* check-then-await therefore saw `running`, woke up to `hibernated`, and dropped an alarm that
|
|
4742
|
+
* no longer existed anywhere — a round timer that simply never fired, with a warning nobody was
|
|
4743
|
+
* watching. Re-checking after every transition covers the slip, and anything still undeliverable
|
|
4744
|
+
* is **re-armed** rather than discarded: a durable alarm that this process could not deliver is
|
|
4745
|
+
* the next placement's problem, not a lost one.
|
|
4746
|
+
*/
|
|
4747
|
+
// -------------------------------------------------------------------------
|
|
4748
|
+
// D59: the bus
|
|
4749
|
+
// -------------------------------------------------------------------------
|
|
4750
|
+
/**
|
|
4751
|
+
* Takes one token from a room's bus budget, or returns the refusal.
|
|
4752
|
+
*
|
|
4753
|
+
* Both verbs draw on the same bucket, on purpose: publish costs a fan-out and send costs a store
|
|
4754
|
+
* write, and a limiter that metered only one of them would be a limiter a room could route
|
|
4755
|
+
* around by choosing the other. This is also the whole of the per-room rate-limit machinery D59
|
|
4756
|
+
* asks for. Every limiter in the supervisor before it was scoped to a connection, an IP or a
|
|
4757
|
+
* project key, and none of them metered what a room asked the host to do.
|
|
4758
|
+
*/
|
|
4759
|
+
/**
|
|
4760
|
+
* D59: forget everything a room was subscribed to. Called when a room is gone for good.
|
|
4761
|
+
*
|
|
4762
|
+
* Note what this is NOT called for: hibernation. A hibernated room keeping a subscription entry
|
|
4763
|
+
* is harmless, because `busPublish` posts only to rooms that are `running` with a live worker,
|
|
4764
|
+
* and that liveness check — not this bookkeeping — is what makes "publish reaches awake
|
|
4765
|
+
* subscribers only" true. Doing it here as well would mean chasing eight separate places where a
|
|
4766
|
+
* worker goes away, and getting one wrong would be a silent correctness bug rather than a leak.
|
|
4767
|
+
*/
|
|
4768
|
+
busRoomClosed(roomId) {
|
|
4769
|
+
this.bus.unsubscribeAll(roomId);
|
|
4770
|
+
}
|
|
4771
|
+
/** D59: the channels a room currently holds, for `/admin/rooms` and for tests. */
|
|
4772
|
+
busSubscriptionsOf(roomId) {
|
|
4773
|
+
return this.bus.subscriptionsOf(roomId);
|
|
4774
|
+
}
|
|
4775
|
+
busBudget(room, verb) {
|
|
4776
|
+
if (room.busBucket.take()) return void 0;
|
|
4777
|
+
return {
|
|
4778
|
+
code: BUS_ERRORS.rateLimited,
|
|
4779
|
+
message: `room.bus.${verb}: this room is over its bus budget of ${BUS_LIMITS.opsPerSecond} operations per second`
|
|
4780
|
+
};
|
|
4781
|
+
}
|
|
4782
|
+
/**
|
|
4783
|
+
* `room.bus.publish`: fan out to awake subscribers, and nothing else.
|
|
4784
|
+
*
|
|
4785
|
+
* Every refusal here is a log line rather than a value, because the verb is fire-and-forget on
|
|
4786
|
+
* the runtime side and there is nothing to answer. The `from` handed to each subscriber is
|
|
4787
|
+
* `room.id`, the record's own id and not anything the worker supplied, so a room cannot publish
|
|
4788
|
+
* under another room's name.
|
|
4789
|
+
*/
|
|
4790
|
+
busPublish(room, channel, payload) {
|
|
4791
|
+
const problem = busChannelProblem(channel) ?? busPayloadProblem(payload) ?? this.busBudget(room, "publish");
|
|
4792
|
+
if (problem) {
|
|
4793
|
+
this.roomLog(room, "warn", `bus.publish: ${problem.code}: ${problem.message}`);
|
|
4794
|
+
return;
|
|
4795
|
+
}
|
|
4796
|
+
for (const id of this.bus.subscribers(channel, room.id)) {
|
|
4797
|
+
const target = this.registry.get(id);
|
|
4798
|
+
if (!target || target.state !== "running" || !target.worker) continue;
|
|
4799
|
+
target.worker.post({ t: "busEvent", channel, from: room.id, payload });
|
|
4800
|
+
}
|
|
4801
|
+
}
|
|
4802
|
+
/**
|
|
4803
|
+
* `room.bus.send`: park a durable, at-least-once message in the target's mailbox.
|
|
4804
|
+
*
|
|
4805
|
+
* The mailbox is the alarm set. Arming for `Date.now()` means `deliverAlarms` picks it up on the
|
|
4806
|
+
* next turn and, the part that makes the feature work at all, wakes the room first if it is
|
|
4807
|
+
* hibernated, because the alarm timer lives on the `RoomRecord` and outlives the worker.
|
|
4808
|
+
*
|
|
4809
|
+
* Refusals are values rather than log lines, unlike publish, because each one is a different
|
|
4810
|
+
* thing for the sender to do: shrink the payload, fix the roomId, back off, or slow down.
|
|
4811
|
+
*/
|
|
4812
|
+
async busSend(room, targetId, payload) {
|
|
4813
|
+
const refuse = (p) => ({
|
|
4814
|
+
ok: false,
|
|
4815
|
+
code: p.code,
|
|
4816
|
+
message: p.message
|
|
4817
|
+
});
|
|
4818
|
+
const budget = busPayloadProblem(payload) ?? this.busBudget(room, "send");
|
|
4819
|
+
if (budget) return refuse(budget);
|
|
4820
|
+
const normalized = normalizeRoomId(targetId);
|
|
4821
|
+
const target = normalized === void 0 ? void 0 : this.registry.get(normalized);
|
|
4822
|
+
if (target && target.state !== "closed") {
|
|
4823
|
+
return this.parkLocally(target, room.id, payload, targetId);
|
|
4824
|
+
}
|
|
4825
|
+
if (!this.isSharded() || normalized === void 0) {
|
|
4826
|
+
return refuse({
|
|
4827
|
+
code: BUS_ERRORS.noSuchRoom,
|
|
4828
|
+
message: `no room ${JSON.stringify(targetId)} in this project`
|
|
4829
|
+
});
|
|
4830
|
+
}
|
|
4831
|
+
if (room.alarms.mailboxDepth >= BUS_LIMITS.mailboxDepth) {
|
|
4832
|
+
return refuse({
|
|
4833
|
+
code: BUS_ERRORS.mailboxFull,
|
|
4834
|
+
message: `room ${JSON.stringify(room.id)} has ${BUS_LIMITS.mailboxDepth} undelivered bus messages in its mailbox and outbox; it is not keeping up`
|
|
4835
|
+
});
|
|
4836
|
+
}
|
|
4837
|
+
const name = mintOutboxName(this.mailboxCounter++);
|
|
4838
|
+
const entry = {
|
|
4839
|
+
to: normalized,
|
|
4840
|
+
from: room.id,
|
|
4841
|
+
payload,
|
|
4842
|
+
id: name,
|
|
4843
|
+
firstAt: this.busNow(),
|
|
4844
|
+
attempts: 0
|
|
4845
|
+
};
|
|
4846
|
+
room.alarms.set(name, this.busNow() + this.outboxDelay(0), encodeOutboxEntry(entry));
|
|
4847
|
+
const outcome = await this.forwardOutbox(room, name, entry);
|
|
4848
|
+
switch (outcome) {
|
|
4849
|
+
case "delivered":
|
|
4850
|
+
return { ok: true };
|
|
4851
|
+
case "no-such-room":
|
|
4852
|
+
return refuse({
|
|
4853
|
+
code: BUS_ERRORS.noSuchRoom,
|
|
4854
|
+
message: `no room ${JSON.stringify(targetId)} in this project, on any shard`
|
|
4855
|
+
});
|
|
4856
|
+
case "mailbox-full":
|
|
4857
|
+
room.alarms.set(name, void 0);
|
|
4858
|
+
return refuse({
|
|
4859
|
+
code: BUS_ERRORS.mailboxFull,
|
|
4860
|
+
message: `room ${JSON.stringify(targetId)} has ${BUS_LIMITS.mailboxDepth} undelivered bus messages; it is not keeping up`
|
|
4861
|
+
});
|
|
4862
|
+
default:
|
|
4863
|
+
return { ok: true };
|
|
4864
|
+
}
|
|
4865
|
+
}
|
|
4866
|
+
/** Today's local park-and-wake, factored out so the cross-shard target side can share it. */
|
|
4867
|
+
parkLocally(target, from, payload, targetId) {
|
|
4868
|
+
if (target.relay) {
|
|
4869
|
+
return {
|
|
4870
|
+
ok: false,
|
|
4871
|
+
code: BUS_ERRORS.noSuchRoom,
|
|
4872
|
+
message: `room ${JSON.stringify(targetId)} is a relay room and runs no bus handlers`
|
|
4873
|
+
};
|
|
4874
|
+
}
|
|
4875
|
+
if (target.alarms.mailboxDepth >= BUS_LIMITS.mailboxDepth) {
|
|
4876
|
+
return {
|
|
4877
|
+
ok: false,
|
|
4878
|
+
code: BUS_ERRORS.mailboxFull,
|
|
4879
|
+
message: `room ${JSON.stringify(targetId)} has ${BUS_LIMITS.mailboxDepth} undelivered bus messages; it is not keeping up`
|
|
4880
|
+
};
|
|
4881
|
+
}
|
|
4882
|
+
const entry = { from, payload, attempts: 0 };
|
|
4883
|
+
target.alarms.set(
|
|
4884
|
+
mintMailboxName(this.mailboxCounter++),
|
|
4885
|
+
Date.now(),
|
|
4886
|
+
encodeMailboxEntry(entry)
|
|
4887
|
+
);
|
|
4888
|
+
return { ok: true };
|
|
4889
|
+
}
|
|
4890
|
+
/** D67-e: the clock the outbox stamps and ages by; injected so the hour is tested, not waited. */
|
|
4891
|
+
busNow() {
|
|
4892
|
+
return this.config.busClock?.() ?? Date.now();
|
|
4893
|
+
}
|
|
4894
|
+
/** D67-e: the retry schedule, injectable for the same reason the clock is. */
|
|
4895
|
+
outboxDelay(attempts) {
|
|
4896
|
+
return this.config.busRetryDelayMs?.(attempts) ?? outboxRetryDelayMs(attempts);
|
|
4897
|
+
}
|
|
4898
|
+
/**
|
|
4899
|
+
* Whether the store holds anything for `roomId`: its live key, an `@v<N>` key, or anything
|
|
4900
|
+
* under `<key>/` (the alarm sidecar, saves). The listing is by prefix, so `abc` would match
|
|
4901
|
+
* `abcd`'s keys without the filter, which is the same filter control's directory applies.
|
|
2595
4902
|
*/
|
|
2596
|
-
async
|
|
2597
|
-
|
|
2598
|
-
let persisted = [];
|
|
4903
|
+
async roomExistsInStore(roomId) {
|
|
4904
|
+
const base = `${this.config.projectId}/${roomId}`;
|
|
2599
4905
|
try {
|
|
2600
|
-
const
|
|
2601
|
-
|
|
4906
|
+
const keys = await this.store.list(base);
|
|
4907
|
+
return keys.some((k) => k === base || k.startsWith(`${base}@v`) || k.startsWith(`${base}/`));
|
|
4908
|
+
} catch {
|
|
4909
|
+
return false;
|
|
4910
|
+
}
|
|
4911
|
+
}
|
|
4912
|
+
/**
|
|
4913
|
+
* One forward of an outbox entry through the control plane. Answers what the sender should
|
|
4914
|
+
* conclude: `delivered` and `no-such-room` drop the entry, `mailbox-full` is the target's
|
|
4915
|
+
* answer, and `retry` (the target's shard is starting, the hop failed) leaves the entry armed.
|
|
4916
|
+
*/
|
|
4917
|
+
async forwardOutbox(room, name, entry) {
|
|
4918
|
+
const client = this.config.shardClient;
|
|
4919
|
+
if (!client) {
|
|
4920
|
+
this.roomLog(
|
|
4921
|
+
room,
|
|
4922
|
+
"warn",
|
|
4923
|
+
`bus.send to ${entry.to}: no shard client on this tenant; will retry`
|
|
4924
|
+
);
|
|
4925
|
+
return "retry";
|
|
4926
|
+
}
|
|
4927
|
+
try {
|
|
4928
|
+
const result = await client.send({
|
|
4929
|
+
shard: this.config.shard ?? 0,
|
|
4930
|
+
to: entry.to,
|
|
4931
|
+
from: entry.from,
|
|
4932
|
+
payload: entry.payload,
|
|
4933
|
+
id: entry.id
|
|
4934
|
+
});
|
|
4935
|
+
if (result.ok) {
|
|
4936
|
+
room.alarms.set(name, void 0);
|
|
4937
|
+
return "delivered";
|
|
4938
|
+
}
|
|
4939
|
+
if (result.code === BUS_ERRORS.noSuchRoom) {
|
|
4940
|
+
room.alarms.set(name, void 0);
|
|
4941
|
+
return "no-such-room";
|
|
4942
|
+
}
|
|
4943
|
+
if (result.code === BUS_ERRORS.mailboxFull) return "mailbox-full";
|
|
4944
|
+
return "retry";
|
|
2602
4945
|
} catch (err) {
|
|
2603
|
-
this.roomLog(
|
|
4946
|
+
this.roomLog(
|
|
4947
|
+
room,
|
|
4948
|
+
"warn",
|
|
4949
|
+
`bus.send to ${entry.to} could not be forwarded (attempt ${entry.attempts + 1}); will retry`,
|
|
4950
|
+
err
|
|
4951
|
+
);
|
|
4952
|
+
return "retry";
|
|
2604
4953
|
}
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
4954
|
+
}
|
|
4955
|
+
/**
|
|
4956
|
+
* An outbox entry's alarm came due: forward it again, or dead-letter it. Runs on the record,
|
|
4957
|
+
* whatever the room's state, and never wakes the room: a retry is a host call, not room code,
|
|
4958
|
+
* so a hibernated sender stays asleep and is not billed for its own outbox.
|
|
4959
|
+
*/
|
|
4960
|
+
retryOutbox(room, name, raw) {
|
|
4961
|
+
const entry = decodeOutboxEntry(raw);
|
|
4962
|
+
if (!entry) {
|
|
4963
|
+
this.roomLog(room, "warn", `dropping an unreadable bus outbox entry (${name})`);
|
|
4964
|
+
return;
|
|
4965
|
+
}
|
|
4966
|
+
const now = this.busNow();
|
|
4967
|
+
if (now - entry.firstAt >= BUS_OUTBOX2.ttlMs) {
|
|
4968
|
+
this.roomLog(
|
|
4969
|
+
room,
|
|
4970
|
+
"warn",
|
|
4971
|
+
`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})`
|
|
4972
|
+
);
|
|
4973
|
+
return;
|
|
4974
|
+
}
|
|
4975
|
+
const next = { ...entry, attempts: entry.attempts + 1 };
|
|
4976
|
+
room.alarms.set(name, now + this.outboxDelay(next.attempts), encodeOutboxEntry(next));
|
|
4977
|
+
void this.forwardOutbox(room, name, next).then((outcome) => {
|
|
4978
|
+
if (outcome === "mailbox-full") {
|
|
2611
4979
|
this.roomLog(
|
|
2612
4980
|
room,
|
|
2613
4981
|
"info",
|
|
2614
|
-
`
|
|
4982
|
+
`bus.send to ${entry.to}: the target's mailbox is full; will retry`
|
|
2615
4983
|
);
|
|
2616
4984
|
}
|
|
4985
|
+
});
|
|
4986
|
+
}
|
|
4987
|
+
/**
|
|
4988
|
+
* D67-e, the target side: `POST /admin/bus/deliver` from the host agent, carrying a message
|
|
4989
|
+
* the control plane forwarded from another shard. Load-if-exists, then today's local park; never
|
|
4990
|
+
* `ensureRoom` from nothing, because a room the directory did not place here is not this
|
|
4991
|
+
* shard's to create. A message whose `id` this room has seen before is delivered again (a lost
|
|
4992
|
+
* acknowledgement, at-least-once) and the duplicate is logged by name.
|
|
4993
|
+
*/
|
|
4994
|
+
async deliverForwarded(message) {
|
|
4995
|
+
const normalized = normalizeRoomId(message.to);
|
|
4996
|
+
if (normalized === void 0) {
|
|
4997
|
+
return {
|
|
4998
|
+
ok: false,
|
|
4999
|
+
code: BUS_ERRORS.noSuchRoom,
|
|
5000
|
+
message: `no room ${JSON.stringify(message.to)}`
|
|
5001
|
+
};
|
|
5002
|
+
}
|
|
5003
|
+
let target = this.registry.get(normalized);
|
|
5004
|
+
if ((!target || target.state === "closed") && await this.roomExistsInStore(normalized)) {
|
|
5005
|
+
target = await this.ensureRoom(normalized);
|
|
5006
|
+
}
|
|
5007
|
+
if (!target || target.state === "closed") {
|
|
5008
|
+
return {
|
|
5009
|
+
ok: false,
|
|
5010
|
+
code: BUS_ERRORS.noSuchRoom,
|
|
5011
|
+
message: `no room ${JSON.stringify(message.to)} on this shard`
|
|
5012
|
+
};
|
|
5013
|
+
}
|
|
5014
|
+
if (target.busSeen.has(message.id)) {
|
|
5015
|
+
this.roomLog(
|
|
5016
|
+
target,
|
|
5017
|
+
"warn",
|
|
5018
|
+
`duplicate bus delivery ${message.id} from ${message.from}; delivering again`
|
|
5019
|
+
);
|
|
5020
|
+
} else {
|
|
5021
|
+
target.busSeen.add(message.id);
|
|
5022
|
+
if (target.busSeen.size > BUS_SEEN_MAX) {
|
|
5023
|
+
const oldest = target.busSeen.values().next().value;
|
|
5024
|
+
if (oldest !== void 0) target.busSeen.delete(oldest);
|
|
5025
|
+
}
|
|
2617
5026
|
}
|
|
5027
|
+
return this.parkLocally(target, message.from, message.payload, message.to);
|
|
2618
5028
|
}
|
|
2619
5029
|
/**
|
|
2620
|
-
* Delivers
|
|
2621
|
-
* hibernated case: the timer kept counting because it lives on the record rather than in the
|
|
2622
|
-
* worker, and the wake is the same one a joining client would have triggered.
|
|
5030
|
+
* Delivers one mailbox entry, or bounds and drops it.
|
|
2623
5031
|
*
|
|
2624
|
-
* The
|
|
2625
|
-
*
|
|
2626
|
-
*
|
|
2627
|
-
* check-then-await therefore saw `running`, woke up to `hibernated`, and dropped an alarm that
|
|
2628
|
-
* no longer existed anywhere — a round timer that simply never fired, with a warning nobody was
|
|
2629
|
-
* watching. Re-checking after every transition covers the slip, and anything still undeliverable
|
|
2630
|
-
* is **re-armed** rather than discarded: a durable alarm that this process could not deliver is
|
|
2631
|
-
* the next placement's problem, not a lost one.
|
|
5032
|
+
* The attempt counter is incremented *before* delivery and persisted with the entry, so a
|
|
5033
|
+
* message whose handler kills the room still counts the attempt. Counting after a successful
|
|
5034
|
+
* delivery would count nothing at all in exactly the case the bound exists for.
|
|
2632
5035
|
*/
|
|
2633
|
-
|
|
5036
|
+
deliverMailbox(room, name, raw) {
|
|
5037
|
+
const entry = decodeMailboxEntry(raw);
|
|
5038
|
+
if (!entry) {
|
|
5039
|
+
this.roomLog(room, "warn", `dropping an unreadable bus mailbox entry (${name})`);
|
|
5040
|
+
return;
|
|
5041
|
+
}
|
|
5042
|
+
const attempts = entry.attempts + 1;
|
|
5043
|
+
if (attempts > BUS_LIMITS.maxDeliveryAttempts) {
|
|
5044
|
+
this.roomLog(
|
|
5045
|
+
room,
|
|
5046
|
+
"error",
|
|
5047
|
+
`a bus message from ${JSON.stringify(entry.from)} could not be delivered in ${BUS_LIMITS.maxDeliveryAttempts} attempts and has been dropped`
|
|
5048
|
+
);
|
|
5049
|
+
return;
|
|
5050
|
+
}
|
|
5051
|
+
if (room.state === "running" && room.worker) {
|
|
5052
|
+
room.alarms.set(
|
|
5053
|
+
name,
|
|
5054
|
+
Date.now() + BUS_LIMITS.redeliveryDelayMs,
|
|
5055
|
+
encodeMailboxEntry({ ...entry, attempts })
|
|
5056
|
+
);
|
|
5057
|
+
room.worker.post({ t: "busMessage", name, from: entry.from, payload: entry.payload });
|
|
5058
|
+
return;
|
|
5059
|
+
}
|
|
5060
|
+
room.alarms.set(name, Date.now(), encodeMailboxEntry({ ...entry, attempts }));
|
|
5061
|
+
}
|
|
5062
|
+
deliverAlarms(room, allNames) {
|
|
5063
|
+
const names = [];
|
|
5064
|
+
for (const name of allNames) {
|
|
5065
|
+
if (isOutboxAlarm(name)) this.retryOutbox(room, name, room.alarms.payloadOf(name));
|
|
5066
|
+
else names.push(name);
|
|
5067
|
+
}
|
|
5068
|
+
if (names.length === 0) return;
|
|
5069
|
+
const mailboxPayloads = /* @__PURE__ */ new Map();
|
|
5070
|
+
for (const name of names) {
|
|
5071
|
+
if (isMailboxAlarm(name)) mailboxPayloads.set(name, room.alarms.payloadOf(name));
|
|
5072
|
+
}
|
|
2634
5073
|
void (async () => {
|
|
2635
5074
|
for (let attempt = 0; attempt < 4; attempt++) {
|
|
2636
5075
|
if (room.state === "closed") return;
|
|
2637
5076
|
if (room.state === "running" && room.worker) {
|
|
2638
|
-
for (const name of names)
|
|
5077
|
+
for (const name of names) {
|
|
5078
|
+
if (isMailboxAlarm(name)) this.deliverMailbox(room, name, mailboxPayloads.get(name));
|
|
5079
|
+
else room.worker.post({ t: "alarm", name });
|
|
5080
|
+
}
|
|
2639
5081
|
return;
|
|
2640
5082
|
}
|
|
2641
5083
|
if (room.state === "hibernated") {
|
|
@@ -2651,7 +5093,10 @@ var SupervisorImpl = class {
|
|
|
2651
5093
|
`alarm(s) ${names.join(", ")} came due while the room was ${room.state}; re-armed for the next start rather than dropped`
|
|
2652
5094
|
);
|
|
2653
5095
|
const now = Date.now();
|
|
2654
|
-
for (const name of names)
|
|
5096
|
+
for (const name of names) {
|
|
5097
|
+
if (isMailboxAlarm(name)) this.deliverMailbox(room, name, mailboxPayloads.get(name));
|
|
5098
|
+
else room.alarms.set(name, now);
|
|
5099
|
+
}
|
|
2655
5100
|
})();
|
|
2656
5101
|
}
|
|
2657
5102
|
/**
|
|
@@ -2726,9 +5171,11 @@ var SupervisorImpl = class {
|
|
|
2726
5171
|
closeRoom(room, code, message) {
|
|
2727
5172
|
if (room.state === "closed") return;
|
|
2728
5173
|
room.state = "closed";
|
|
5174
|
+
this.npcs.despawnRoom(room.id);
|
|
2729
5175
|
this.clearRoomTimer(room);
|
|
2730
5176
|
room.alarms.detach();
|
|
2731
5177
|
room.alarms.onChange = void 0;
|
|
5178
|
+
this.busRoomClosed(room.id);
|
|
2732
5179
|
for (const session of [...room.clients.values()]) {
|
|
2733
5180
|
session.fatal = true;
|
|
2734
5181
|
session.sendError(code, message, true);
|
|
@@ -2747,28 +5194,88 @@ var SupervisorImpl = class {
|
|
|
2747
5194
|
});
|
|
2748
5195
|
this.roomLog(room, "info", `room closed: ${message}`);
|
|
2749
5196
|
}
|
|
5197
|
+
// ---- M6 lane A: rooms API ----
|
|
5198
|
+
/**
|
|
5199
|
+
* M6 lane A (D68-d): stop one room and remove its stored keys, because control is about to
|
|
5200
|
+
* delete the rest of its family.
|
|
5201
|
+
*
|
|
5202
|
+
* Answers `'absent'` when this tenant is not holding the room, which is not a failure and is
|
|
5203
|
+
* most of the real cases: the room went to sleep, or this tenant restarted, and control's row
|
|
5204
|
+
* was a poll behind. Control deletes the family either way.
|
|
5205
|
+
*
|
|
5206
|
+
* **The race this exists to close, and why the `await` is the fix.** `closeRoom` already deletes
|
|
5207
|
+
* the live key and the alarm sidecar, and refuses to run twice. What it cannot do on its own is
|
|
5208
|
+
* stop a hibernation that is *already in flight*: by then `hibernateRoom` has read the worker's
|
|
5209
|
+
* snapshot and is inside `putSnapshotWithRetry`, so its `store.put` lands after `closeRoom`'s
|
|
5210
|
+
* `store.delete` and after control's `deleteRoomFamily` — and the room is resurrected as a live
|
|
5211
|
+
* key nothing will ever delete again, because its row is gone, so the reaper cannot see it and
|
|
5212
|
+
* `irtio rooms` cannot list it. Awaiting `room.transition` first removes that window: every path
|
|
5213
|
+
* that can write a snapshot (`hibernateRoom`, `hibernateRelay`, `migrateRoom`, `wakeRoom`)
|
|
5214
|
+
* publishes itself there and settles before the deletes run, and no NEW one can start, because
|
|
5215
|
+
* they all early-return on a state that is not `running` and `closeRoom` has set `closed`.
|
|
5216
|
+
*
|
|
5217
|
+
* The two deletes are awaited, unlike `closeRoom`'s own fire-and-forget pair, for the same
|
|
5218
|
+
* reason: control lists the bucket the instant this answers.
|
|
5219
|
+
*
|
|
5220
|
+
* `flushAll` (tenant idle) needs no guard of its own: `closeRoom` removes the record from the
|
|
5221
|
+
* registry and the flush walks the registry.
|
|
5222
|
+
*/
|
|
5223
|
+
async evictRoom(roomId) {
|
|
5224
|
+
const room = this.registry.get(roomId);
|
|
5225
|
+
if (room === void 0) return "absent";
|
|
5226
|
+
await room.transition.catch(() => void 0);
|
|
5227
|
+
this.closeRoom(room, "E_ROOM_DELETED", `room ${roomId} was deleted by an operator`);
|
|
5228
|
+
const key = this.storeKey(room);
|
|
5229
|
+
await this.store.delete(key).catch(() => void 0);
|
|
5230
|
+
await this.store.delete(alarmsKey(key)).catch(() => void 0);
|
|
5231
|
+
return "evicted";
|
|
5232
|
+
}
|
|
5233
|
+
// ---- end M6 lane A ----
|
|
2750
5234
|
// -------------------------------------------------------------------------
|
|
2751
5235
|
// Joining
|
|
2752
5236
|
// -------------------------------------------------------------------------
|
|
2753
5237
|
async joinBundle(room, session, reconnecting) {
|
|
2754
5238
|
const outcome = await this.requestJoin(room, session, reconnecting);
|
|
2755
5239
|
if (!outcome) {
|
|
5240
|
+
session.pendingOut.length = 0;
|
|
2756
5241
|
session.fail("E_INTERNAL", "the room did not answer the join");
|
|
2757
5242
|
return;
|
|
2758
5243
|
}
|
|
2759
5244
|
if (outcome.t === "joinRejected") {
|
|
5245
|
+
session.pendingOut.length = 0;
|
|
2760
5246
|
room.clients.delete(session.clientId);
|
|
2761
5247
|
this.clientIds.delete(session.clientId);
|
|
2762
5248
|
session.fail(outcome.code, outcome.reason ?? formatError(outcome.code, { roomId: room.id }));
|
|
2763
5249
|
return;
|
|
2764
5250
|
}
|
|
2765
|
-
if (!session.open)
|
|
5251
|
+
if (!session.open) {
|
|
5252
|
+
session.pendingOut.length = 0;
|
|
5253
|
+
session.state = "gone";
|
|
5254
|
+
if (room.clients.get(session.clientId) === session) room.clients.delete(session.clientId);
|
|
5255
|
+
this.clientIds.delete(session.clientId);
|
|
5256
|
+
const reason = session.fatal ? "kicked" : "timeout";
|
|
5257
|
+
if (room.state === "running" && room.worker) {
|
|
5258
|
+
room.worker.post({ t: "leave", clientId: session.clientId, reason });
|
|
5259
|
+
} else if (room.state !== "closed") {
|
|
5260
|
+
room.pendingLeaves.add(session.clientId);
|
|
5261
|
+
}
|
|
5262
|
+
return;
|
|
5263
|
+
}
|
|
2766
5264
|
session.role = outcome.role;
|
|
2767
5265
|
session.state = "joined";
|
|
5266
|
+
room.notePlayerSeen(session.playerId ?? session.clientId);
|
|
2768
5267
|
if (outcome.tick > room.lastTick) room.lastTick = outcome.tick;
|
|
2769
5268
|
room.metrics.connections++;
|
|
2770
5269
|
room.metrics.totalConnections++;
|
|
5270
|
+
if (session.npc) room.metrics.npcConnections++;
|
|
2771
5271
|
this.sendWelcome(session, outcome.tick, outcome.snapshot);
|
|
5272
|
+
for (const bytes of session.pendingOut.splice(0)) {
|
|
5273
|
+
const sent = session.send(bytes);
|
|
5274
|
+
if (sent > 0) {
|
|
5275
|
+
room.metrics.egressBytes += sent;
|
|
5276
|
+
room.metrics.framesOut++;
|
|
5277
|
+
}
|
|
5278
|
+
}
|
|
2772
5279
|
}
|
|
2773
5280
|
requestJoin(room, session, reconnecting) {
|
|
2774
5281
|
const worker = room.worker;
|
|
@@ -2791,43 +5298,53 @@ var SupervisorImpl = class {
|
|
|
2791
5298
|
...session.name !== "" ? { name: session.name } : {},
|
|
2792
5299
|
// D27: the verified JWT subject; absent for key joins (playerId stays the client id).
|
|
2793
5300
|
...session.playerId !== void 0 ? { playerId: session.playerId } : {},
|
|
2794
|
-
...reconnecting ? { reconnecting: true } : {}
|
|
5301
|
+
...reconnecting ? { reconnecting: true } : {},
|
|
5302
|
+
...session.npc ? { npc: true } : {}
|
|
2795
5303
|
});
|
|
2796
5304
|
});
|
|
2797
5305
|
}
|
|
5306
|
+
/**
|
|
5307
|
+
* D66: the admission decision now lives in `relay-host.ts`, so the process that will serve
|
|
5308
|
+
* relay-only tenants without a VM runs the same code. What stays here is what is the
|
|
5309
|
+
* *supervisor's* and not a relay's: the session registries, the welcome frame, and the idle
|
|
5310
|
+
* timer that leads to hibernation.
|
|
5311
|
+
*/
|
|
2798
5312
|
joinRelay(room, session, _reconnecting) {
|
|
2799
5313
|
const relay = room.relayRoom;
|
|
2800
5314
|
if (!relay) {
|
|
2801
5315
|
session.fail("E_INTERNAL", formatError("E_INTERNAL"));
|
|
2802
5316
|
return;
|
|
2803
5317
|
}
|
|
2804
|
-
|
|
5318
|
+
const outcome = relayJoin(relayHostRoomFor(this, room), session);
|
|
5319
|
+
if (!outcome.ok) {
|
|
2805
5320
|
room.clients.delete(session.clientId);
|
|
2806
5321
|
this.clientIds.delete(session.clientId);
|
|
2807
|
-
session.fail(
|
|
5322
|
+
session.fail(outcome.code, formatError(outcome.code, { roomId: room.id }));
|
|
2808
5323
|
return;
|
|
2809
5324
|
}
|
|
2810
|
-
|
|
2811
|
-
room.lastTick = relay.tick;
|
|
5325
|
+
room.lastTick = outcome.tick;
|
|
2812
5326
|
session.state = "joined";
|
|
5327
|
+
room.notePlayerSeen(session.playerId ?? session.clientId);
|
|
2813
5328
|
room.metrics.connections++;
|
|
2814
5329
|
room.metrics.totalConnections++;
|
|
2815
|
-
this.sendWelcome(session,
|
|
2816
|
-
this.broadcast(room,
|
|
5330
|
+
this.sendWelcome(session, outcome.tick, outcome.snapshot);
|
|
5331
|
+
this.broadcast(room, encodeFrame3(FrameType3.DELTA, outcome.delta), session.clientId);
|
|
2817
5332
|
this.armRelayIdle(room);
|
|
2818
5333
|
}
|
|
2819
5334
|
/** `WELCOME` with a fresh resume token — also used as the resync after a wake/restart. */
|
|
2820
5335
|
sendWelcome(session, tick, snapshot) {
|
|
2821
5336
|
const room = session.room;
|
|
2822
5337
|
if (!room) return;
|
|
5338
|
+
const now = Date.now();
|
|
2823
5339
|
const resumeToken = signResume(this.resumeSecret, {
|
|
2824
5340
|
clientId: session.clientId,
|
|
2825
5341
|
roomId: room.id,
|
|
2826
5342
|
role: session.role,
|
|
2827
|
-
expMs:
|
|
5343
|
+
expMs: now + RESUME_TOKEN_TTL_MS,
|
|
5344
|
+
iatMs: now
|
|
2828
5345
|
});
|
|
2829
|
-
const bytes =
|
|
2830
|
-
|
|
5346
|
+
const bytes = encodeFrame3(
|
|
5347
|
+
FrameType3.WELCOME,
|
|
2831
5348
|
encodeWelcome({
|
|
2832
5349
|
clientId: session.clientId,
|
|
2833
5350
|
role: session.role,
|
|
@@ -2835,8 +5352,11 @@ var SupervisorImpl = class {
|
|
|
2835
5352
|
snapshot,
|
|
2836
5353
|
resumeToken,
|
|
2837
5354
|
roomId: room.id,
|
|
2838
|
-
// 0 for a relay room
|
|
2839
|
-
|
|
5355
|
+
// 0 for a relay room: "unknown" — the client falls back to its 50 ms floor.
|
|
5356
|
+
tickRate: room.config.tickRate,
|
|
5357
|
+
// Always meaningful: bundle rooms default to 64 (@irtio/server DEFAULTS) and relay rooms
|
|
5358
|
+
// come from SupervisorLimits.relayMaxClients, so unlike tickRate this is never 0/unset.
|
|
5359
|
+
maxClients: room.config.maxClients
|
|
2840
5360
|
})
|
|
2841
5361
|
);
|
|
2842
5362
|
const sent = session.send(bytes);
|
|
@@ -2852,17 +5372,31 @@ var SupervisorImpl = class {
|
|
|
2852
5372
|
* the new client, instead of feeding it bytes it will mis-decode. Clients already on the new
|
|
2853
5373
|
* schema (the page reloaded after the client shipped) rejoin normally.
|
|
2854
5374
|
*/
|
|
2855
|
-
failOutdatedSessions(room, version) {
|
|
2856
|
-
const
|
|
2857
|
-
|
|
5375
|
+
failOutdatedSessions(room, version, fromVersion) {
|
|
5376
|
+
const type = room.roomType;
|
|
5377
|
+
const target = this.deployments.get(version);
|
|
5378
|
+
const targetBundle = target?.bundles.get(type);
|
|
5379
|
+
const hash8 = targetBundle?.hash8;
|
|
5380
|
+
if (!hash8 || !target) return 0;
|
|
5381
|
+
const from = fromVersion !== void 0 ? this.deployments.get(fromVersion) : void 0;
|
|
5382
|
+
const fromSchemaJson = from?.bundles.get(type)?.schemaJson;
|
|
5383
|
+
const targetSchemaJson = targetBundle.schemaJson;
|
|
5384
|
+
const swappable = fromSchemaJson !== void 0 && isAdditiveMigration(fromSchemaJson, targetSchemaJson);
|
|
5385
|
+
let swapped = 0;
|
|
2858
5386
|
for (const session of room.connected()) {
|
|
2859
|
-
if (session.schemaHash8
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
5387
|
+
if (session.schemaHash8 === void 0 || bytesEqual(session.schemaHash8, hash8)) continue;
|
|
5388
|
+
if (swappable && session.schemaSwap) {
|
|
5389
|
+
session.sendFrame(FrameType3.SCHEMA, schemaPayload(targetSchemaJson));
|
|
5390
|
+
session.schemaHash8 = hash8;
|
|
5391
|
+
swapped++;
|
|
5392
|
+
continue;
|
|
2864
5393
|
}
|
|
5394
|
+
session.fail(
|
|
5395
|
+
"E_SCHEMA_MISMATCH",
|
|
5396
|
+
"the room migrated to a newer deployment; reconnect with an updated client"
|
|
5397
|
+
);
|
|
2865
5398
|
}
|
|
5399
|
+
return swapped;
|
|
2866
5400
|
}
|
|
2867
5401
|
/** Re-joins every still-connected socket after a wake or a crash restart, with a resync WELCOME. */
|
|
2868
5402
|
async rejoinAll(room) {
|
|
@@ -2915,23 +5449,36 @@ var SupervisorImpl = class {
|
|
|
2915
5449
|
this.badFrame(session, "unknown frame type");
|
|
2916
5450
|
return;
|
|
2917
5451
|
}
|
|
2918
|
-
if (type ===
|
|
5452
|
+
if (type === FrameType3.PING) {
|
|
2919
5453
|
let t = 0;
|
|
2920
5454
|
try {
|
|
2921
5455
|
t = decodePing(payload).t;
|
|
2922
5456
|
} catch {
|
|
2923
5457
|
}
|
|
2924
|
-
const sent = session.sendFrame(
|
|
5458
|
+
const sent = session.sendFrame(FrameType3.PONG, encodePong({ t, serverTick: room.lastTick }));
|
|
2925
5459
|
if (sent > 0) {
|
|
2926
5460
|
room.metrics.egressBytes += sent;
|
|
2927
5461
|
room.metrics.framesOut++;
|
|
2928
5462
|
}
|
|
2929
5463
|
return;
|
|
2930
5464
|
}
|
|
2931
|
-
if (type ===
|
|
5465
|
+
if (type === FrameType3.LEAVE) {
|
|
2932
5466
|
this.handleLeaveFrame(session);
|
|
2933
5467
|
return;
|
|
2934
5468
|
}
|
|
5469
|
+
if (type === FrameType3.MSG && this.bandwidth.engaged) {
|
|
5470
|
+
if (this.bandwidth.takeWarning(room.id)) {
|
|
5471
|
+
this.roomLog(room, "warn", bandwidthWarning(this.caps.plan() ?? "free"));
|
|
5472
|
+
}
|
|
5473
|
+
return;
|
|
5474
|
+
}
|
|
5475
|
+
if (type === FrameType3.MSG && isVoiceMsg(payload)) {
|
|
5476
|
+
this.handleVoiceFrame(room, session, payload.subarray(1));
|
|
5477
|
+
return;
|
|
5478
|
+
}
|
|
5479
|
+
if (type === FrameType3.MSG && isTypedMsg(payload) && !typedMsgFromClientOk(payload)) {
|
|
5480
|
+
return;
|
|
5481
|
+
}
|
|
2935
5482
|
if (room.relay) {
|
|
2936
5483
|
this.relayFrame(room, session, type, payload);
|
|
2937
5484
|
return;
|
|
@@ -2970,43 +5517,81 @@ var SupervisorImpl = class {
|
|
|
2970
5517
|
} catch {
|
|
2971
5518
|
}
|
|
2972
5519
|
}
|
|
5520
|
+
/**
|
|
5521
|
+
* D66: the forwarding rules now live in `relay-host.ts` — see that module for why. What stays
|
|
5522
|
+
* here is the supervisor's half of a refusal (`badFrame` closes the connection its own way) and
|
|
5523
|
+
* the idle timer.
|
|
5524
|
+
*/
|
|
2973
5525
|
relayFrame(room, session, type, payload) {
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
if (
|
|
2977
|
-
this.badFrame(session,
|
|
5526
|
+
if (!room.relayRoom) return;
|
|
5527
|
+
const outcome = relayForward(relayHostRoomFor(this, room), session, type, payload);
|
|
5528
|
+
if (!outcome.ok) {
|
|
5529
|
+
this.badFrame(session, outcome.reason);
|
|
2978
5530
|
return;
|
|
2979
5531
|
}
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
5532
|
+
this.armRelayIdle(room);
|
|
5533
|
+
}
|
|
5534
|
+
/**
|
|
5535
|
+
* D51: one intercepted voice-signaling message. Never reaches room code — see the interception
|
|
5536
|
+
* in `routeFrame` and the module note on `VoiceRelay`.
|
|
5537
|
+
*
|
|
5538
|
+
* The supervisor's contribution here is deliberately thin: identity (which project, room and
|
|
5539
|
+
* client this is, and whether it is an NPC) and a way back to the socket. It does not parse the
|
|
5540
|
+
* message, so no signaling shape is pinned inside the tenant boundary.
|
|
5541
|
+
*/
|
|
5542
|
+
handleVoiceFrame(room, session, message) {
|
|
5543
|
+
const relay = this.config.voice;
|
|
5544
|
+
if (!relay) {
|
|
5545
|
+
this.sendVoice(
|
|
5546
|
+
session,
|
|
5547
|
+
encodeVoiceMessage({
|
|
5548
|
+
t: "error",
|
|
5549
|
+
code: "E_VOICE_UNAVAILABLE",
|
|
5550
|
+
message: "voice is not configured for this tenant"
|
|
5551
|
+
})
|
|
5552
|
+
);
|
|
2988
5553
|
return;
|
|
2989
5554
|
}
|
|
2990
|
-
if (
|
|
2991
|
-
this.
|
|
2992
|
-
|
|
5555
|
+
if (!session.npc) {
|
|
5556
|
+
const voiceCapRefusal = this.caps.refusal("voice_minutes");
|
|
5557
|
+
if (voiceCapRefusal !== void 0 && decodeVoiceMessage(message)?.t === "join") {
|
|
5558
|
+
this.sendVoice(
|
|
5559
|
+
session,
|
|
5560
|
+
encodeVoiceMessage({ t: "error", code: "E_USAGE_CAP", message: voiceCapRefusal })
|
|
5561
|
+
);
|
|
5562
|
+
return;
|
|
5563
|
+
}
|
|
2993
5564
|
}
|
|
2994
|
-
const
|
|
2995
|
-
|
|
2996
|
-
|
|
5565
|
+
const ctx = {
|
|
5566
|
+
projectId: this.config.projectId,
|
|
5567
|
+
roomId: room.id,
|
|
5568
|
+
clientId: session.clientId,
|
|
5569
|
+
npc: session.npc
|
|
5570
|
+
};
|
|
5571
|
+
void relay.signal(ctx, message, (reply) => this.sendVoice(session, reply)).catch((err) => {
|
|
5572
|
+
this.log("warn", `voice relay failed for ${session.clientId}: ${String(err)}`);
|
|
5573
|
+
this.sendVoice(
|
|
5574
|
+
session,
|
|
5575
|
+
encodeVoiceMessage({
|
|
5576
|
+
t: "error",
|
|
5577
|
+
code: "E_VOICE_FAILED",
|
|
5578
|
+
message: "voice signaling failed"
|
|
5579
|
+
})
|
|
5580
|
+
);
|
|
5581
|
+
});
|
|
5582
|
+
}
|
|
5583
|
+
/** Writes one voice message back to a single client, inside its `{ kind: 'voice' }` envelope. */
|
|
5584
|
+
sendVoice(session, message) {
|
|
5585
|
+
if (session.state !== "joined") return;
|
|
5586
|
+
const sent = session.sendFrame(
|
|
5587
|
+
FrameType3.MSG,
|
|
5588
|
+
encodeMsg2({ target: { kind: "voice" }, payload: message })
|
|
2997
5589
|
);
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
const sent = peer.send(out);
|
|
3003
|
-
if (sent > 0) {
|
|
3004
|
-
room.metrics.egressBytes += sent;
|
|
3005
|
-
room.metrics.framesOut++;
|
|
3006
|
-
}
|
|
5590
|
+
const room = session.room;
|
|
5591
|
+
if (sent > 0 && room) {
|
|
5592
|
+
room.metrics.egressBytes += sent;
|
|
5593
|
+
room.metrics.framesOut++;
|
|
3007
5594
|
}
|
|
3008
|
-
room.lastTick = relay.advance();
|
|
3009
|
-
this.armRelayIdle(room);
|
|
3010
5595
|
}
|
|
3011
5596
|
badFrame(session, message) {
|
|
3012
5597
|
session.badFrameStrikes++;
|
|
@@ -3040,7 +5625,30 @@ var SupervisorImpl = class {
|
|
|
3040
5625
|
case "send": {
|
|
3041
5626
|
noteTick(room, msg.bytes);
|
|
3042
5627
|
const session = room.clients.get(msg.clientId);
|
|
3043
|
-
if (!session
|
|
5628
|
+
if (!session) {
|
|
5629
|
+
if (!room.unknownSendWarned.has(msg.clientId)) {
|
|
5630
|
+
room.unknownSendWarned.add(msg.clientId);
|
|
5631
|
+
this.roomLog(
|
|
5632
|
+
room,
|
|
5633
|
+
"warn",
|
|
5634
|
+
`dropped a ${frameName(msg.bytes)} frame for unknown client ${msg.clientId}`
|
|
5635
|
+
);
|
|
5636
|
+
}
|
|
5637
|
+
return;
|
|
5638
|
+
}
|
|
5639
|
+
if (session.state === "joining") {
|
|
5640
|
+
if (session.pendingOut.length < HELD_FRAMES_MAX) {
|
|
5641
|
+
session.pendingOut.push(msg.bytes);
|
|
5642
|
+
} else if (session.droppedHeld++ === 0) {
|
|
5643
|
+
this.roomLog(
|
|
5644
|
+
room,
|
|
5645
|
+
"warn",
|
|
5646
|
+
`dropped a ${frameName(msg.bytes)} frame for ${msg.clientId}: more than ${HELD_FRAMES_MAX} frames sent while it was still joining`
|
|
5647
|
+
);
|
|
5648
|
+
}
|
|
5649
|
+
return;
|
|
5650
|
+
}
|
|
5651
|
+
if (session.state !== "joined") return;
|
|
3044
5652
|
const sent = session.send(msg.bytes);
|
|
3045
5653
|
if (sent > 0) {
|
|
3046
5654
|
room.metrics.egressBytes += sent;
|
|
@@ -3062,14 +5670,58 @@ var SupervisorImpl = class {
|
|
|
3062
5670
|
);
|
|
3063
5671
|
return;
|
|
3064
5672
|
case "sleep":
|
|
5673
|
+
this.npcs.despawnRoom(room.id);
|
|
3065
5674
|
void hibernateRoom(this, room);
|
|
3066
5675
|
return;
|
|
3067
5676
|
case "hostCall":
|
|
3068
5677
|
void this.runHostCall(room, msg.reqId, msg.call);
|
|
3069
5678
|
return;
|
|
5679
|
+
case "setBackfill":
|
|
5680
|
+
room.backfillOpen = msg.open;
|
|
5681
|
+
return;
|
|
3070
5682
|
case "setAlarm":
|
|
5683
|
+
if (isMailboxAlarm(msg.name)) {
|
|
5684
|
+
this.roomLog(
|
|
5685
|
+
room,
|
|
5686
|
+
"warn",
|
|
5687
|
+
`refusing to arm ${JSON.stringify(msg.name)}: names beginning with ${JSON.stringify(BUS_MAILBOX_PREFIX2)} belong to the bus`
|
|
5688
|
+
);
|
|
5689
|
+
return;
|
|
5690
|
+
}
|
|
3071
5691
|
room.alarms.set(msg.name, msg.atMs);
|
|
3072
5692
|
return;
|
|
5693
|
+
case "busPublish":
|
|
5694
|
+
this.busPublish(room, msg.channel, msg.payload);
|
|
5695
|
+
return;
|
|
5696
|
+
case "busSubscribe": {
|
|
5697
|
+
if (!msg.subscribed) {
|
|
5698
|
+
this.bus.unsubscribe(room.id, msg.channel);
|
|
5699
|
+
return;
|
|
5700
|
+
}
|
|
5701
|
+
const problem = this.busBudget(room, "subscribe") ?? this.bus.subscribe(room.id, msg.channel);
|
|
5702
|
+
if (problem) {
|
|
5703
|
+
this.roomLog(room, "warn", `bus.subscribe: ${problem.code}: ${problem.message}`);
|
|
5704
|
+
}
|
|
5705
|
+
return;
|
|
5706
|
+
}
|
|
5707
|
+
case "spawnNpc": {
|
|
5708
|
+
const definition = this.deployments.get(room.version)?.bundle?.definition;
|
|
5709
|
+
if (!definition) {
|
|
5710
|
+
this.roomLog(room, "error", "irtio: spawnNPC needs a room bundle; none is loaded");
|
|
5711
|
+
return;
|
|
5712
|
+
}
|
|
5713
|
+
this.npcs.spawn(room.id, definition, msg.clientId, msg.config);
|
|
5714
|
+
return;
|
|
5715
|
+
}
|
|
5716
|
+
case "despawnNpc":
|
|
5717
|
+
this.npcs.despawn(msg.clientId);
|
|
5718
|
+
return;
|
|
5719
|
+
case "busAck":
|
|
5720
|
+
room.alarms.set(msg.name, void 0);
|
|
5721
|
+
return;
|
|
5722
|
+
case "stats":
|
|
5723
|
+
if (msg.physicsStepped === true) this.notePhysicsWarm();
|
|
5724
|
+
return;
|
|
3073
5725
|
case "log":
|
|
3074
5726
|
room.log(msg.level, ...msg.args);
|
|
3075
5727
|
return;
|
|
@@ -3083,6 +5735,11 @@ var SupervisorImpl = class {
|
|
|
3083
5735
|
/** Worker died: restart from the latest snapshot and resync everyone (plan §3.3). */
|
|
3084
5736
|
onWorkerDead(room, reason) {
|
|
3085
5737
|
if (room.state === "closed" || this.closing) return;
|
|
5738
|
+
const initializing = this.readyWaiters.get(room);
|
|
5739
|
+
if (initializing) {
|
|
5740
|
+
initializing({ t: "initFailed", reason });
|
|
5741
|
+
return;
|
|
5742
|
+
}
|
|
3086
5743
|
this.roomLog(room, "error", `worker died (${reason}); restarting`);
|
|
3087
5744
|
room.worker = void 0;
|
|
3088
5745
|
room.metrics.restarts++;
|
|
@@ -3200,16 +5857,54 @@ var SupervisorImpl = class {
|
|
|
3200
5857
|
const live = this.registry.values().filter((r) => r.worker?.alive === true);
|
|
3201
5858
|
const answered = await Promise.all(
|
|
3202
5859
|
live.map(async (room) => {
|
|
3203
|
-
const
|
|
3204
|
-
if (!
|
|
5860
|
+
const msg = await room.worker?.statsMessage(timeoutMs);
|
|
5861
|
+
if (!msg) return void 0;
|
|
5862
|
+
const stats = msg.stats;
|
|
3205
5863
|
room.metrics.ticks = stats.ticks;
|
|
3206
5864
|
room.metrics.maxTickMs = stats.maxTickMs;
|
|
3207
5865
|
room.metrics.overruns = stats.overruns;
|
|
5866
|
+
room.metrics.messagesDropped = stats.messagesDropped;
|
|
5867
|
+
if (msg.heap !== void 0) {
|
|
5868
|
+
this.recordHeap(room, msg.heap.usedBytes, msg.heap.limitBytes);
|
|
5869
|
+
}
|
|
5870
|
+
if (msg.profile !== void 0) room.profile = msg.profile;
|
|
5871
|
+
if (msg.physicsStepped === true) this.notePhysicsWarm();
|
|
3208
5872
|
return room.id;
|
|
3209
5873
|
})
|
|
3210
5874
|
);
|
|
3211
5875
|
return answered.filter((id) => id !== void 0);
|
|
3212
5876
|
}
|
|
5877
|
+
/**
|
|
5878
|
+
* D55 step 2: the per-room heap reading, plus the warning that is the whole point of taking it.
|
|
5879
|
+
*
|
|
5880
|
+
* Before this, the first signal a developer got that a room was too big for its VM was the
|
|
5881
|
+
* tenant dying. The warning fires once per crossing of `HEAP_WARN_FRACTION` rather than on
|
|
5882
|
+
* every poll, and re-arms when the room drops back under the line, so a room that sits at 85%
|
|
5883
|
+
* says so once instead of filling the ring the operator would read it from. Room-scoped
|
|
5884
|
+
* (`roomLog`), because "which room" is the actionable half.
|
|
5885
|
+
*/
|
|
5886
|
+
recordHeap(room, usedBytes, limitBytes) {
|
|
5887
|
+
room.metrics.heapUsedBytes = usedBytes;
|
|
5888
|
+
room.metrics.heapLimitBytes = limitBytes;
|
|
5889
|
+
const capBytes = this.limits.workerMaxOldGenMb * 1024 * 1024;
|
|
5890
|
+
if (capBytes <= 0) return;
|
|
5891
|
+
const fraction = usedBytes / capBytes;
|
|
5892
|
+
if (fraction >= HEAP_WARN_FRACTION) {
|
|
5893
|
+
if (!room.heapWarned) {
|
|
5894
|
+
room.heapWarned = true;
|
|
5895
|
+
const usedMb = Math.round(usedBytes / (1024 * 1024));
|
|
5896
|
+
const limitMb = this.limits.workerMaxOldGenMb;
|
|
5897
|
+
const pct = Math.round(fraction * 100);
|
|
5898
|
+
this.roomLog(
|
|
5899
|
+
room,
|
|
5900
|
+
"warn",
|
|
5901
|
+
`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`
|
|
5902
|
+
);
|
|
5903
|
+
}
|
|
5904
|
+
return;
|
|
5905
|
+
}
|
|
5906
|
+
room.heapWarned = false;
|
|
5907
|
+
}
|
|
3213
5908
|
async inspect(roomId) {
|
|
3214
5909
|
const room = this.registry.get(roomId);
|
|
3215
5910
|
if (!room) return void 0;
|
|
@@ -3233,6 +5928,34 @@ var SupervisorImpl = class {
|
|
|
3233
5928
|
rss: inspected.rss
|
|
3234
5929
|
};
|
|
3235
5930
|
}
|
|
5931
|
+
/**
|
|
5932
|
+
* D41: arm a room's timeline recorder. Bundle rooms only: a relay room has no schema and no
|
|
5933
|
+
* authority over its state, so there is nothing authoritative to record, and answering `false`
|
|
5934
|
+
* is more honest than handing back an empty recording that would read as "nothing happened".
|
|
5935
|
+
*/
|
|
5936
|
+
async startTimeline(roomId, options = {}) {
|
|
5937
|
+
const room = this.registry.get(roomId);
|
|
5938
|
+
if (!room?.worker || room.state !== "running") return false;
|
|
5939
|
+
return room.worker.startTimeline(options);
|
|
5940
|
+
}
|
|
5941
|
+
async readTimeline(roomId) {
|
|
5942
|
+
const room = this.registry.get(roomId);
|
|
5943
|
+
if (!room?.worker || room.state !== "running") return void 0;
|
|
5944
|
+
return room.worker.timeline();
|
|
5945
|
+
}
|
|
5946
|
+
/**
|
|
5947
|
+
* D43: a save generation, and its bytes. `saveRoom` writes it (same serialization, same
|
|
5948
|
+
* retention, same durability as `room.save()`), and the object is then read straight back out
|
|
5949
|
+
* of the store, so what a truth-seam diff decodes is the stored artifact rather than a copy of
|
|
5950
|
+
* it made on the way past.
|
|
5951
|
+
*/
|
|
5952
|
+
async saveNow(roomId) {
|
|
5953
|
+
const room = this.registry.get(roomId);
|
|
5954
|
+
if (!room?.worker || room.state !== "running") return void 0;
|
|
5955
|
+
const saveId = await this.saveRoom(room);
|
|
5956
|
+
const bytes = await this.store.get(saveKey(this.storeKey(room), saveId));
|
|
5957
|
+
return bytes ? { saveId, bytes } : void 0;
|
|
5958
|
+
}
|
|
3236
5959
|
/**
|
|
3237
5960
|
* Ops/test hook: hard-kill a room's worker. The room takes the normal §3.3 crash path (restore
|
|
3238
5961
|
* from the latest snapshot, resync everyone, restart cap). `false` when the room has no worker.
|
|
@@ -3248,14 +5971,19 @@ function header(v) {
|
|
|
3248
5971
|
if (v === void 0) return void 0;
|
|
3249
5972
|
return Array.isArray(v) ? v[0] : v;
|
|
3250
5973
|
}
|
|
5974
|
+
function frameName(bytes) {
|
|
5975
|
+
const type = bytes[0];
|
|
5976
|
+
for (const [name, value] of Object.entries(FrameType3)) if (value === type) return name;
|
|
5977
|
+
return `type ${String(type)}`;
|
|
5978
|
+
}
|
|
3251
5979
|
function noteTick(room, bytes) {
|
|
3252
5980
|
if (bytes.length < 5) return;
|
|
3253
5981
|
const type = bytes[0];
|
|
3254
|
-
if (type !==
|
|
5982
|
+
if (type !== FrameType3.DELTA && type !== FrameType3.CORRECT) return;
|
|
3255
5983
|
const tick = new DataView(bytes.buffer, bytes.byteOffset + 1, 4).getUint32(0, true);
|
|
3256
5984
|
if (tick > room.lastTick) room.lastTick = tick;
|
|
3257
5985
|
}
|
|
3258
|
-
async function loadBundle(bundlePath, version) {
|
|
5986
|
+
async function loadBundle(bundlePath, version, type) {
|
|
3259
5987
|
const bundleUrl = bundlePath.startsWith("file:") ? bundlePath : pathToFileURL2(bundlePath).href;
|
|
3260
5988
|
let mod;
|
|
3261
5989
|
try {
|
|
@@ -3272,6 +6000,7 @@ async function loadBundle(bundlePath, version) {
|
|
|
3272
6000
|
const config = definition.config;
|
|
3273
6001
|
return {
|
|
3274
6002
|
version,
|
|
6003
|
+
type,
|
|
3275
6004
|
definition,
|
|
3276
6005
|
schemaHash: definition.schema.hash,
|
|
3277
6006
|
hash8: definition.schema.hash8,
|
|
@@ -3281,7 +6010,17 @@ async function loadBundle(bundlePath, version) {
|
|
|
3281
6010
|
tickRate: config.tickRate,
|
|
3282
6011
|
idleMs: config.idleMs,
|
|
3283
6012
|
reconnectGraceMs: config.reconnectGraceMs,
|
|
3284
|
-
maxClients: config.maxClients
|
|
6013
|
+
maxClients: config.maxClients,
|
|
6014
|
+
// D47: the engine dimension for room-hours. Additive and read-only — the supervisor never
|
|
6015
|
+
// acts on it, it only reports it so part 3 can price the physics multiplier.
|
|
6016
|
+
...typeof config.physics?.engine === "string" ? { engine: config.physics.engine } : {},
|
|
6017
|
+
// D58: the declared worker heap for this type. Absent means the tenant's derived default,
|
|
6018
|
+
// which is what every room got before types existed.
|
|
6019
|
+
...typeof config.memoryMb === "number" ? { memoryMb: config.memoryMb } : {},
|
|
6020
|
+
// M5 part 3.5: the declared concurrency. Enforced (`startRoom` refuses room N+1), not
|
|
6021
|
+
// reported — the VM was sized on the strength of this number, so a room past it is a room
|
|
6022
|
+
// the machine was never built to hold.
|
|
6023
|
+
...typeof config.maxAwake === "number" ? { maxAwake: config.maxAwake } : {}
|
|
3285
6024
|
},
|
|
3286
6025
|
bundleUrl,
|
|
3287
6026
|
schemaJson: definition.schema.canonical
|
|
@@ -3495,20 +6234,18 @@ async function warnIfClientImportsRoom(cwd, entry, config, log) {
|
|
|
3495
6234
|
);
|
|
3496
6235
|
}
|
|
3497
6236
|
}
|
|
3498
|
-
function
|
|
6237
|
+
function engineAbsolutePlugin(fromPackageDir, pkg) {
|
|
3499
6238
|
let resolved;
|
|
3500
6239
|
try {
|
|
3501
|
-
resolved = createRequire(path.join(fromPackageDir, "package.json")).resolve(
|
|
3502
|
-
"@dimforge/rapier3d-compat"
|
|
3503
|
-
);
|
|
6240
|
+
resolved = createRequire(path.join(fromPackageDir, "package.json")).resolve(pkg);
|
|
3504
6241
|
} catch {
|
|
3505
6242
|
return void 0;
|
|
3506
6243
|
}
|
|
3507
6244
|
const specifier = pathToFileURL3(resolved).href;
|
|
3508
6245
|
return {
|
|
3509
|
-
name:
|
|
6246
|
+
name: `irtio-dev-${pkg.replace(/[^a-z0-9]+/g, "-")}-absolute`,
|
|
3510
6247
|
setup(build2) {
|
|
3511
|
-
build2.onResolve({ filter:
|
|
6248
|
+
build2.onResolve({ filter: new RegExp(`^${pkg.replace(/[/\-]/g, "$&")}$`) }, () => ({
|
|
3512
6249
|
path: specifier,
|
|
3513
6250
|
external: true
|
|
3514
6251
|
}));
|
|
@@ -3533,7 +6270,8 @@ async function resolveWorkerEntry2(outDir) {
|
|
|
3533
6270
|
);
|
|
3534
6271
|
}
|
|
3535
6272
|
const outfile = path.join(outDir, "worker.mjs");
|
|
3536
|
-
const
|
|
6273
|
+
const runtimeDir = path.join(packagesDir, "runtime");
|
|
6274
|
+
const enginePlugins = ["@dimforge/rapier3d-compat", "matter-js"].map((pkg) => engineAbsolutePlugin(runtimeDir, pkg)).filter((p) => p !== void 0);
|
|
3537
6275
|
await esbuild.build({
|
|
3538
6276
|
entryPoints: [source],
|
|
3539
6277
|
bundle: true,
|
|
@@ -3545,18 +6283,18 @@ async function resolveWorkerEntry2(outDir) {
|
|
|
3545
6283
|
"@irtio/server": path.join(packagesDir, "server/src/index.ts"),
|
|
3546
6284
|
"@irtio/protocol": path.join(packagesDir, "protocol/src/index.ts")
|
|
3547
6285
|
},
|
|
3548
|
-
...
|
|
6286
|
+
...enginePlugins.length > 0 ? { plugins: enginePlugins } : {},
|
|
3549
6287
|
// Rapier stays external for the same reason it does in packages/supervisor/test/support.ts:
|
|
3550
6288
|
// it is a dependency of `@irtio/runtime`, resolved once at load time. Bundling a second copy
|
|
3551
6289
|
// here would give the dev worker its own WASM instance, separate from the one the room
|
|
3552
6290
|
// bundle resolves via node_modules. `rapierPlugin` (above) already externalizes it at an
|
|
3553
6291
|
// absolute path when `@irtio/runtime`'s own copy can be found; this string entry is the
|
|
3554
6292
|
// fallback for a published install where that resolution comes for free from node_modules.
|
|
3555
|
-
external: ["node:worker_threads", "node:url", "@dimforge/rapier3d-compat"]
|
|
6293
|
+
external: ["node:worker_threads", "node:url", "@dimforge/rapier3d-compat", "matter-js"]
|
|
3556
6294
|
});
|
|
3557
6295
|
return outfile;
|
|
3558
6296
|
}
|
|
3559
|
-
async function buildState(supervisor, projectId, bundle) {
|
|
6297
|
+
async function buildState(supervisor, projectId, bundle, startedAt) {
|
|
3560
6298
|
await supervisor.refreshTickHealth().catch(() => []);
|
|
3561
6299
|
const infos = supervisor.rooms();
|
|
3562
6300
|
const inspects = await Promise.all(
|
|
@@ -3569,6 +6307,11 @@ async function buildState(supervisor, projectId, bundle) {
|
|
|
3569
6307
|
logs.sort((a, b) => a.at - b.at);
|
|
3570
6308
|
return {
|
|
3571
6309
|
project: projectId,
|
|
6310
|
+
// bugs.md #16: which build of the room is this server actually serving, and since when.
|
|
6311
|
+
// The bundle hash alone is not enough to catch the failure that motivated it: a forgotten
|
|
6312
|
+
// `dev` from an earlier session serves an old bundle from an old process, and a reader
|
|
6313
|
+
// comparing two runs needs to see that the *process* did not restart either.
|
|
6314
|
+
startedAt,
|
|
3572
6315
|
bundle: {
|
|
3573
6316
|
file: bundle.file,
|
|
3574
6317
|
hash: bundle.hash,
|
|
@@ -3598,6 +6341,7 @@ function send(res, status, type, body) {
|
|
|
3598
6341
|
}
|
|
3599
6342
|
async function startDev(options = {}) {
|
|
3600
6343
|
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
6344
|
+
const startedAt = Date.now();
|
|
3601
6345
|
const log = options.log ?? ((line) => console.log(line));
|
|
3602
6346
|
const entry = resolveEntry(cwd, options.room);
|
|
3603
6347
|
const config = await readProjectConfig(cwd, "irtio dev", options.config);
|
|
@@ -3627,6 +6371,69 @@ async function startDev(options = {}) {
|
|
|
3627
6371
|
send(res, 200, "text/html; charset=utf-8", DEV_PAGE);
|
|
3628
6372
|
return true;
|
|
3629
6373
|
}
|
|
6374
|
+
if (route === "/__irt/record.json" || route === "/__irt/timeline.json") {
|
|
6375
|
+
const query = new URL(req.url ?? "", "http://localhost");
|
|
6376
|
+
const roomId = query.searchParams.get("room") ?? "";
|
|
6377
|
+
void (async () => {
|
|
6378
|
+
try {
|
|
6379
|
+
if (route === "/__irt/record.json") {
|
|
6380
|
+
const maxTicks = Number(query.searchParams.get("maxTicks"));
|
|
6381
|
+
const maxRecords = Number(query.searchParams.get("maxRecords"));
|
|
6382
|
+
const started = await supervisor.startTimeline(roomId, {
|
|
6383
|
+
...Number.isInteger(maxTicks) && maxTicks > 0 ? { maxTicks } : {},
|
|
6384
|
+
...Number.isInteger(maxRecords) && maxRecords > 0 ? { maxRecords } : {}
|
|
6385
|
+
});
|
|
6386
|
+
send(
|
|
6387
|
+
res,
|
|
6388
|
+
started ? 200 : 404,
|
|
6389
|
+
"application/json; charset=utf-8",
|
|
6390
|
+
JSON.stringify({
|
|
6391
|
+
recording: started,
|
|
6392
|
+
room: roomId,
|
|
6393
|
+
...started ? {} : { error: `no running room ${roomId} with a worker to record` }
|
|
6394
|
+
})
|
|
6395
|
+
);
|
|
6396
|
+
return;
|
|
6397
|
+
}
|
|
6398
|
+
const dump = await supervisor.readTimeline(roomId);
|
|
6399
|
+
send(
|
|
6400
|
+
res,
|
|
6401
|
+
dump === void 0 ? 404 : 200,
|
|
6402
|
+
"application/json; charset=utf-8",
|
|
6403
|
+
JSON.stringify(
|
|
6404
|
+
dump ?? { error: `room ${roomId} has no recorded timeline`, room: roomId }
|
|
6405
|
+
)
|
|
6406
|
+
);
|
|
6407
|
+
} catch (err) {
|
|
6408
|
+
send(res, 500, "application/json; charset=utf-8", JSON.stringify({ error: String(err) }));
|
|
6409
|
+
}
|
|
6410
|
+
})();
|
|
6411
|
+
return true;
|
|
6412
|
+
}
|
|
6413
|
+
if (route === "/__irt/save.json") {
|
|
6414
|
+
const query = new URL(req.url ?? "", "http://localhost");
|
|
6415
|
+
const roomId = query.searchParams.get("room") ?? "";
|
|
6416
|
+
void (async () => {
|
|
6417
|
+
try {
|
|
6418
|
+
const saved = await supervisor.saveNow(roomId);
|
|
6419
|
+
send(
|
|
6420
|
+
res,
|
|
6421
|
+
saved === void 0 ? 404 : 200,
|
|
6422
|
+
"application/json; charset=utf-8",
|
|
6423
|
+
JSON.stringify(
|
|
6424
|
+
saved === void 0 ? { error: `no running room ${roomId} to save`, room: roomId } : {
|
|
6425
|
+
room: roomId,
|
|
6426
|
+
saveId: saved.saveId,
|
|
6427
|
+
bytes: Buffer.from(saved.bytes).toString("base64")
|
|
6428
|
+
}
|
|
6429
|
+
)
|
|
6430
|
+
);
|
|
6431
|
+
} catch (err) {
|
|
6432
|
+
send(res, 500, "application/json; charset=utf-8", JSON.stringify({ error: String(err) }));
|
|
6433
|
+
}
|
|
6434
|
+
})();
|
|
6435
|
+
return true;
|
|
6436
|
+
}
|
|
3630
6437
|
if (route === "/__irt/state.json") {
|
|
3631
6438
|
void (async () => {
|
|
3632
6439
|
try {
|
|
@@ -3634,7 +6441,7 @@ async function startDev(options = {}) {
|
|
|
3634
6441
|
res,
|
|
3635
6442
|
200,
|
|
3636
6443
|
"application/json; charset=utf-8",
|
|
3637
|
-
JSON.stringify(await buildState(supervisor, projectId, bundle))
|
|
6444
|
+
JSON.stringify(await buildState(supervisor, projectId, bundle, startedAt))
|
|
3638
6445
|
);
|
|
3639
6446
|
} catch (err) {
|
|
3640
6447
|
send(res, 500, "application/json; charset=utf-8", JSON.stringify({ error: String(err) }));
|
|
@@ -3655,6 +6462,7 @@ async function startDev(options = {}) {
|
|
|
3655
6462
|
store: new DiskStore(path.join(cwd, ".irtio", "snapshots")),
|
|
3656
6463
|
publicUrl: `http://localhost:${port}`,
|
|
3657
6464
|
httpHandler,
|
|
6465
|
+
...options.profile === true ? { profile: true } : {},
|
|
3658
6466
|
log: (level, ...args) => {
|
|
3659
6467
|
const line = args.map((a) => typeof a === "string" ? a : safeJson(a)).join(" ");
|
|
3660
6468
|
log(level === "error" ? pc.red(line) : level === "warn" ? pc.yellow(line) : pc.dim(line));
|
|
@@ -3746,11 +6554,13 @@ options:
|
|
|
3746
6554
|
--port <n> port to listen on (default 7070; 0 picks a free one)
|
|
3747
6555
|
--no-watch do not rebuild on change
|
|
3748
6556
|
-c, --config <f> the project file to read (default irtio.json)
|
|
6557
|
+
--profile print a bandwidth breakdown per room once a second
|
|
6558
|
+
--profile-top <n> rows per table (default 12; implies --profile)
|
|
3749
6559
|
-h, --help print this
|
|
3750
6560
|
`;
|
|
3751
6561
|
function parseDevArgs(args) {
|
|
3752
6562
|
if (helpRequested(args)) throw helpFor(USAGE);
|
|
3753
|
-
const parsed = { watch: true };
|
|
6563
|
+
const parsed = { watch: true, profile: false };
|
|
3754
6564
|
for (let i = 0; i < args.length; i++) {
|
|
3755
6565
|
const arg = args[i];
|
|
3756
6566
|
const eq = arg.indexOf("=");
|
|
@@ -3776,6 +6586,18 @@ function parseDevArgs(args) {
|
|
|
3776
6586
|
case "--no-watch":
|
|
3777
6587
|
parsed.watch = false;
|
|
3778
6588
|
break;
|
|
6589
|
+
case "--profile":
|
|
6590
|
+
parsed.profile = true;
|
|
6591
|
+
break;
|
|
6592
|
+
case "--profile-top": {
|
|
6593
|
+
const n = Number(value());
|
|
6594
|
+
if (!Number.isInteger(n) || n < 1 || n > 200) {
|
|
6595
|
+
throw new Error("irtio dev: --profile-top must be a row count between 1 and 200");
|
|
6596
|
+
}
|
|
6597
|
+
parsed.profile = true;
|
|
6598
|
+
parsed.profileTop = n;
|
|
6599
|
+
break;
|
|
6600
|
+
}
|
|
3779
6601
|
case "-c":
|
|
3780
6602
|
case "--config":
|
|
3781
6603
|
parsed.config = value();
|
|
@@ -3795,7 +6617,8 @@ async function dev(args) {
|
|
|
3795
6617
|
watch: parsed.watch,
|
|
3796
6618
|
...parsed.room !== void 0 ? { room: parsed.room } : {},
|
|
3797
6619
|
...parsed.port !== void 0 ? { port: parsed.port } : {},
|
|
3798
|
-
...parsed.config !== void 0 ? { config: parsed.config } : {}
|
|
6620
|
+
...parsed.config !== void 0 ? { config: parsed.config } : {},
|
|
6621
|
+
...parsed.profile ? { profile: true } : {}
|
|
3799
6622
|
});
|
|
3800
6623
|
} catch (err) {
|
|
3801
6624
|
if (err instanceof HelpRequested) {
|
|
@@ -3813,10 +6636,42 @@ async function dev(args) {
|
|
|
3813
6636
|
`share links: http://localhost:${server.port}/?room=CODE (codes appear when a client joins)`
|
|
3814
6637
|
)
|
|
3815
6638
|
);
|
|
6639
|
+
let profileTimer;
|
|
6640
|
+
if (parsed.profile) {
|
|
6641
|
+
let history = /* @__PURE__ */ new Map();
|
|
6642
|
+
let busy = false;
|
|
6643
|
+
profileTimer = setInterval(() => {
|
|
6644
|
+
if (busy) return;
|
|
6645
|
+
busy = true;
|
|
6646
|
+
void (async () => {
|
|
6647
|
+
try {
|
|
6648
|
+
await server.supervisor.refreshTickHealth();
|
|
6649
|
+
const at = Date.now();
|
|
6650
|
+
const readings = server.supervisor.rooms().map((room) => ({
|
|
6651
|
+
id: room.id,
|
|
6652
|
+
profile: room.profile,
|
|
6653
|
+
egressBytes: room.metrics.egressBytes,
|
|
6654
|
+
ingressBytes: room.metrics.ingressBytes,
|
|
6655
|
+
at
|
|
6656
|
+
}));
|
|
6657
|
+
const tick = profileTick(readings, history, {
|
|
6658
|
+
...parsed.profileTop !== void 0 ? { top: parsed.profileTop } : {}
|
|
6659
|
+
});
|
|
6660
|
+
history = tick.history;
|
|
6661
|
+
for (const line of tick.lines) console.log(line);
|
|
6662
|
+
} catch {
|
|
6663
|
+
} finally {
|
|
6664
|
+
busy = false;
|
|
6665
|
+
}
|
|
6666
|
+
})();
|
|
6667
|
+
}, 1e3);
|
|
6668
|
+
profileTimer.unref?.();
|
|
6669
|
+
}
|
|
3816
6670
|
let stopping = false;
|
|
3817
6671
|
const shutdown = () => {
|
|
3818
6672
|
if (stopping) return;
|
|
3819
6673
|
stopping = true;
|
|
6674
|
+
if (profileTimer) clearInterval(profileTimer);
|
|
3820
6675
|
void (async () => {
|
|
3821
6676
|
await server.stop();
|
|
3822
6677
|
process.exit(0);
|