@irtio/bots 0.5.1 → 0.5.2

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/index.d.ts CHANGED
@@ -14,15 +14,37 @@ import { Correction, Room, RelayRoom, Transport, JoinOptions } from '@irtio/clie
14
14
  */
15
15
 
16
16
  /** The invariants every simulation checks, in report order. */
17
- declare const INVARIANT_NAMES: readonly ["schema-validity", "visibility-leak", "bandwidth", "handler-error", "correction-storm", "misprediction", "snaps", "disconnects"];
17
+ declare const INVARIANT_NAMES: readonly ["schema-validity", "visibility-leak", "bandwidth", "handler-error", "correction-storm", "misprediction", "snaps", "disconnects", "tick-health"];
18
18
  type InvariantName = (typeof INVARIANT_NAMES)[number];
19
+ /**
20
+ * D36: three states, not two. `unavailable` exists because the server's own tick counters cannot
21
+ * always be fetched, and reporting `ok` for a number nobody read is exactly the class of lie the
22
+ * M2 exit tests were built to catch. An `unavailable` invariant does not fail a run.
23
+ */
24
+ type InvariantState = 'ok' | 'violation' | 'unavailable';
19
25
  interface InvariantResult {
20
26
  readonly name: InvariantName;
27
+ /** False only for `violation`. An `unavailable` invariant is not a failure. */
21
28
  readonly ok: boolean;
29
+ readonly state: InvariantState;
22
30
  readonly violations: number;
23
31
  /** One line a human can act on: the threshold, the peak, the first offenders. */
24
32
  readonly detail: string;
25
33
  }
34
+ /**
35
+ * D36: what the server said about its own tick loop over the run window, as the caller managed to
36
+ * read it. Every field is optional because every field can be missing, and the report says which.
37
+ */
38
+ interface TickHealthReading {
39
+ /** Tick overruns inside the run window (a delta, not a lifetime count). */
40
+ readonly overruns?: number;
41
+ /** The worst single tick in ms the server saw. Lifetime, not windowed. */
42
+ readonly maxTickMs?: number;
43
+ /** Why the numbers could not be read. Present ⇒ the invariant reports `unavailable`. */
44
+ readonly unavailable?: string;
45
+ /** Where the numbers came from, so a reader can go and check. */
46
+ readonly source?: string;
47
+ }
26
48
  interface SpatialVisibilityContext {
27
49
  /**
28
50
  * The state the anchor and the judged positions are read from. Server truth when a test has it
@@ -60,6 +82,12 @@ interface InvariantThresholds {
60
82
  readonly mispredictionMagnitudeMax: number;
61
83
  /** `snaps`: tolerated cap-exceeded reconciliations (corrections past the resim window), per run. */
62
84
  readonly snapsMax: number;
85
+ /**
86
+ * `tick-health`: server tick overruns tolerated inside the run window. Zero by default, which
87
+ * is strict on purpose — a room dropping even one tick's backlog is shedding work a player
88
+ * paid for — and one `--overruns-max` away from being usable on a deliberately busy room.
89
+ */
90
+ readonly overrunsMax: number;
63
91
  }
64
92
  declare const DEFAULT_THRESHOLDS: InvariantThresholds;
65
93
  /**
@@ -213,13 +241,19 @@ interface ObserverOptions {
213
241
  declare class BotObserver {
214
242
  private readonly options;
215
243
  readonly ring: TraceRing;
216
- readonly violations: Map<"schema-validity" | "visibility-leak" | "bandwidth" | "handler-error" | "correction-storm" | "misprediction" | "snaps" | "disconnects", string[]>;
217
- readonly counts: Map<"schema-validity" | "visibility-leak" | "bandwidth" | "handler-error" | "correction-storm" | "misprediction" | "snaps" | "disconnects", number>;
244
+ readonly violations: Map<"schema-validity" | "visibility-leak" | "bandwidth" | "handler-error" | "correction-storm" | "misprediction" | "snaps" | "disconnects" | "tick-health", string[]>;
245
+ readonly counts: Map<"schema-validity" | "visibility-leak" | "bandwidth" | "handler-error" | "correction-storm" | "misprediction" | "snaps" | "disconnects" | "tick-health", number>;
218
246
  id: string;
219
247
  role: string;
220
248
  roomId: string;
221
249
  /** Set before `leave()`, so a deliberate teardown is not reported as a disconnect. */
222
250
  stopping: boolean;
251
+ /**
252
+ * D36: the last connection status this bot's client reported, recorded whatever it was. This
253
+ * is the *fact* of where the connection is — the room-gone watcher and the join-timeout
254
+ * message read it — while `disconnects` below stays the judgement, with its old rules.
255
+ */
256
+ lastStatus: string;
223
257
  framesIn: number;
224
258
  framesOut: number;
225
259
  bytesIn: number;
@@ -422,6 +456,11 @@ interface BuildReportOptions {
422
456
  readonly lags: readonly number[];
423
457
  readonly thresholds?: InvariantThresholds;
424
458
  readonly tracePath?: string | undefined;
459
+ /**
460
+ * D36: the server's own view of its tick loop over the run window. Absent ⇒ `tick-health`
461
+ * reports `unavailable`, which is the honest answer for a caller that could not read it.
462
+ */
463
+ readonly tickHealth?: TickHealthReading | undefined;
425
464
  }
426
465
  /** Folds the observers into the report `irtio simulate` prints and tests assert on. */
427
466
  declare function buildReport(options: BuildReportOptions): SimulationReport;
@@ -444,6 +483,21 @@ declare function buildReport(options: BuildReportOptions): SimulationReport;
444
483
  declare const DEFAULT_SEED = 96016;
445
484
  /** Trace ring size per bot. 20 bots × 4096 ≈ 80k headers, a few megabytes at worst. */
446
485
  declare const DEFAULT_TRACE_LIMIT = 4096;
486
+ /**
487
+ * D36 defect A: the client's patience with `E_STARTING` is documented, correct, and unbounded —
488
+ * `session.start()` has no deadline, so a room that answers `starting` forever never settles the
489
+ * join and the run's duration timer (armed *after* the join) never even gets set. The bound
490
+ * belongs here rather than in the client, because other callers rely on that patience.
491
+ */
492
+ declare const DEFAULT_JOIN_TIMEOUT_MS = 20000;
493
+ /**
494
+ * D36: how long every bot may be disconnected at once before the run is called dead. A room that
495
+ * went away takes every socket with it; a room that restarted takes them for a moment and hands
496
+ * them back. This is the width of "for a moment".
497
+ */
498
+ declare const DEFAULT_ROOM_GONE_GRACE_MS = 5000;
499
+ /** Why a run stopped. `undefined` until it has. */
500
+ type RunEnd = 'scripts' | 'duration' | 'room-gone';
447
501
  /** The room a bot holds: a full `Room` with a schema, or a schemaless `RelayRoom`. */
448
502
  type BotRoom<S> = S extends AnySchema ? Room<S> : RelayRoom;
449
503
  interface UntilOptions {
@@ -501,6 +555,18 @@ interface SpawnOptionsBase {
501
555
  readonly mispredictionMagnitudeMax?: number;
502
556
  /** `snaps` tolerance: cap-exceeded reconciliations per bot. Default: infinite. */
503
557
  readonly snapsMax?: number;
558
+ /** `tick-health` threshold: server tick overruns tolerated in the run window. Default 0. */
559
+ readonly overrunsMax?: number;
560
+ /**
561
+ * D36: how long one bot's initial join may take before `spawnBots` gives up on the whole run.
562
+ * Default {@link DEFAULT_JOIN_TIMEOUT_MS}; `0` restores the old unbounded wait.
563
+ */
564
+ readonly joinTimeoutMs?: number;
565
+ /**
566
+ * D36: how long every bot may be disconnected at once before the run ends as `room-gone`.
567
+ * Default {@link DEFAULT_ROOM_GONE_GRACE_MS}; `0` disables the watcher.
568
+ */
569
+ readonly roomGoneGraceMs?: number;
504
570
  /** @internal Wrap `webSocketTransport` to get at the socket (the reconnection scenarios). */
505
571
  readonly transport?: Transport;
506
572
  }
@@ -531,6 +597,17 @@ interface BotRunner<S = undefined> extends Iterable<Bot<S>> {
531
597
  bot: number;
532
598
  error: unknown;
533
599
  }[];
600
+ /**
601
+ * D36: what ended the run, once something has. `scripts` (every script returned), `duration`
602
+ * (the deadline fired) or `room-gone` (every bot was disconnected for longer than the grace).
603
+ */
604
+ readonly endedBy: RunEnd | undefined;
605
+ /**
606
+ * D36: hand the runner the server's own tick counters for the run window, taken however the
607
+ * caller can take them. Every report built afterwards carries the `tick-health` invariant;
608
+ * without this call it reports `unavailable`.
609
+ */
610
+ recordTickHealth(reading: TickHealthReading): void;
534
611
  /** Resolves when every script has finished; rejects with the first one that threw. */
535
612
  done(): Promise<void>;
536
613
  /** Stops the scripts, leaves every room, and returns the final report. */
@@ -538,6 +615,17 @@ interface BotRunner<S = undefined> extends Iterable<Bot<S>> {
538
615
  /** The report as of now — safe to call mid-run. */
539
616
  report(): SimulationReport;
540
617
  }
618
+ /**
619
+ * D36: a join that never settled. Thrown by `spawnBots`, and the reason `irtio simulate` can say
620
+ * "the run was not performed" instead of exiting 13 with nothing printed.
621
+ */
622
+ declare class JoinTimeoutError extends Error {
623
+ readonly bot: number;
624
+ readonly timeoutMs: number;
625
+ readonly lastStatus: string;
626
+ readonly name = "JoinTimeoutError";
627
+ constructor(bot: number, timeoutMs: number, lastStatus: string);
628
+ }
541
629
  /** Spawns `n` real clients on one room, runs `script` on each, and watches every invariant. */
542
630
  declare function spawnBots<S extends AnySchema>(n: number, options: SpawnOptions<S>): Promise<BotRunner<S>>;
543
631
  declare function spawnBots(n: number, options?: RelaySpawnOptions): Promise<BotRunner<undefined>>;
@@ -591,4 +679,4 @@ interface RelayEchoScriptOptions {
591
679
  */
592
680
  declare function relayEchoScript(options?: RelayEchoScriptOptions): BotScript<undefined>;
593
681
 
594
- export { type Bot, BotObserver, type BotRoom, type BotRunner, type BotScript, type BotStats, type BuildReportOptions, type ConvergenceStats, DEFAULT_SEED, DEFAULT_THRESHOLDS, DEFAULT_TRACE_LIMIT, INVARIANT_NAMES, type InvariantName, type InvariantResult, type InvariantThresholds, type ObserverOptions, type RandomScriptOptions, type RelayEchoScriptOptions, type RelaySpawnOptions, type Rng, type SimulationReport, type SimulationTotals, type SpatialVisibilityContext, type SpawnOptions, type Trace, type TraceDump, type TraceEntry, TraceRing, type UntilOptions, type ValueContext, WriteLog, buildReport, convergenceStats, deltaVisibilityLeaks, frameName, frameVisibilityLeaks, freshValue, makeRng, makeTrace, nextValue, randomScript, relayEchoScript, snapshotVisibilityLeaks, spawnBots };
682
+ export { type Bot, BotObserver, type BotRoom, type BotRunner, type BotScript, type BotStats, type BuildReportOptions, type ConvergenceStats, DEFAULT_JOIN_TIMEOUT_MS, DEFAULT_ROOM_GONE_GRACE_MS, DEFAULT_SEED, DEFAULT_THRESHOLDS, DEFAULT_TRACE_LIMIT, INVARIANT_NAMES, type InvariantName, type InvariantResult, type InvariantState, type InvariantThresholds, JoinTimeoutError, type ObserverOptions, type RandomScriptOptions, type RelayEchoScriptOptions, type RelaySpawnOptions, type Rng, type RunEnd, type SimulationReport, type SimulationTotals, type SpatialVisibilityContext, type SpawnOptions, type TickHealthReading, type Trace, type TraceDump, type TraceEntry, TraceRing, type UntilOptions, type ValueContext, WriteLog, buildReport, convergenceStats, deltaVisibilityLeaks, frameName, frameVisibilityLeaks, freshValue, makeRng, makeTrace, nextValue, randomScript, relayEchoScript, snapshotVisibilityLeaks, spawnBots };
package/dist/index.js CHANGED
@@ -13,14 +13,16 @@ var INVARIANT_NAMES = [
13
13
  "correction-storm",
14
14
  "misprediction",
15
15
  "snaps",
16
- "disconnects"
16
+ "disconnects",
17
+ "tick-health"
17
18
  ];
18
19
  var DEFAULT_THRESHOLDS = {
19
20
  budgetBytesPerSec: 128e3,
20
21
  correctionsPerSecMax: 5,
21
22
  handlerErrorsMax: 0,
22
23
  mispredictionMagnitudeMax: Number.POSITIVE_INFINITY,
23
- snapsMax: Number.POSITIVE_INFINITY
24
+ snapsMax: Number.POSITIVE_INFINITY,
25
+ overrunsMax: 0
24
26
  };
25
27
  var widenedSchemas = /* @__PURE__ */ new WeakMap();
26
28
  function widenGrids(ext, slack) {
@@ -270,6 +272,12 @@ var BotObserver = class {
270
272
  roomId = "";
271
273
  /** Set before `leave()`, so a deliberate teardown is not reported as a disconnect. */
272
274
  stopping = false;
275
+ /**
276
+ * D36: the last connection status this bot's client reported, recorded whatever it was. This
277
+ * is the *fact* of where the connection is — the room-gone watcher and the join-timeout
278
+ * message read it — while `disconnects` below stays the judgement, with its old rules.
279
+ */
280
+ lastStatus = "connecting";
273
281
  framesIn = 0;
274
282
  framesOut = 0;
275
283
  bytesIn = 0;
@@ -353,6 +361,7 @@ var BotObserver = class {
353
361
  }
354
362
  /** `room.on('status')`: transitions away from a healthy connection, ignored during teardown. */
355
363
  onStatus(status) {
364
+ this.lastStatus = status;
356
365
  if (this.stopping) return;
357
366
  if (status !== "reconnecting" && status !== "closed") return;
358
367
  this.disconnects++;
@@ -809,7 +818,39 @@ function detailFor(name, observers, violations, thresholds) {
809
818
  const count = observers.reduce((sum, o) => sum + o.disconnects, 0);
810
819
  return count === 0 ? "every bot stayed connected" : `${count} disconnect(s)${examples(observers, name)}`;
811
820
  }
821
+ case "tick-health":
822
+ return "";
823
+ }
824
+ }
825
+ function tickHealthResult(reading, thresholds) {
826
+ const where = reading?.source !== void 0 ? ` (from ${reading.source})` : "";
827
+ if (reading === void 0 || reading.unavailable !== void 0) {
828
+ return {
829
+ name: "tick-health",
830
+ ok: true,
831
+ state: "unavailable",
832
+ violations: 0,
833
+ detail: reading?.unavailable ?? "the server was not asked for its tick counters, so this run says nothing about them"
834
+ };
835
+ }
836
+ if (reading.overruns === void 0) {
837
+ return {
838
+ name: "tick-health",
839
+ ok: true,
840
+ state: "unavailable",
841
+ violations: 0,
842
+ detail: `no overrun count came back${where}`
843
+ };
812
844
  }
845
+ const worst = reading.maxTickMs !== void 0 ? `, worst tick ${round(reading.maxTickMs)} ms` : "";
846
+ const violated = reading.overruns > thresholds.overrunsMax;
847
+ return {
848
+ name: "tick-health",
849
+ ok: !violated,
850
+ state: violated ? "violation" : "ok",
851
+ violations: violated ? reading.overruns : 0,
852
+ detail: `${reading.overruns} tick overrun(s) in the run window, tolerated ${thresholds.overrunsMax}${worst}${where}` + (violated ? " \u2014 the room fell behind and dropped the backlog" : "")
853
+ };
813
854
  }
814
855
  function buildReport(options) {
815
856
  const { observers, roomId, lags } = options;
@@ -817,9 +858,17 @@ function buildReport(options) {
817
858
  const durationMs = Math.max(1, options.durationMs);
818
859
  const seconds = durationMs / 1e3;
819
860
  const invariants = INVARIANT_NAMES.map((name) => {
861
+ if (name === "tick-health") return tickHealthResult(options.tickHealth, thresholds);
820
862
  const violations = total(observers, name);
821
863
  const ok = name === "handler-error" ? violations <= thresholds.handlerErrorsMax : violations === 0;
822
- return { name, ok, violations, detail: detailFor(name, observers, violations, thresholds) };
864
+ const state = ok ? "ok" : "violation";
865
+ return {
866
+ name,
867
+ ok,
868
+ state,
869
+ violations,
870
+ detail: detailFor(name, observers, violations, thresholds)
871
+ };
823
872
  });
824
873
  const perBot = observers.map((o) => o.stats);
825
874
  const totals = {
@@ -947,11 +996,15 @@ function relayEchoScript(options = {}) {
947
996
  // src/spawn.ts
948
997
  import {
949
998
  joinRelay,
950
- joinRoom
999
+ joinRoom,
1000
+ webSocketTransport
951
1001
  } from "@irtio/client";
952
1002
  import { relaySchema, withBuiltins } from "@irtio/protocol";
953
1003
  var DEFAULT_SEED = 96016;
954
1004
  var DEFAULT_TRACE_LIMIT = 4096;
1005
+ var DEFAULT_JOIN_TIMEOUT_MS = 2e4;
1006
+ var DEFAULT_ROOM_GONE_GRACE_MS = 5e3;
1007
+ var ROOM_GONE_POLL_MS = 250;
955
1008
  function per(value, index) {
956
1009
  if (value === void 0) return void 0;
957
1010
  return typeof value === "function" ? value(index) : value;
@@ -1025,6 +1078,42 @@ var BotImpl = class {
1025
1078
  }
1026
1079
  }
1027
1080
  };
1081
+ var JoinTimeoutError = class extends Error {
1082
+ constructor(bot, timeoutMs, lastStatus) {
1083
+ super(
1084
+ `irtio bots: bot ${bot} did not finish joining within ${timeoutMs} ms (last status: ${lastStatus})` + (lastStatus === "starting" ? " \u2014 the room kept answering E_STARTING, so it never came up" : "")
1085
+ );
1086
+ this.bot = bot;
1087
+ this.timeoutMs = timeoutMs;
1088
+ this.lastStatus = lastStatus;
1089
+ }
1090
+ bot;
1091
+ timeoutMs;
1092
+ lastStatus;
1093
+ name = "JoinTimeoutError";
1094
+ };
1095
+ async function withJoinTimeout(joining, index, timeoutMs, observer, sockets) {
1096
+ if (timeoutMs <= 0) return joining;
1097
+ let timer;
1098
+ try {
1099
+ return await Promise.race([
1100
+ joining,
1101
+ new Promise((_resolve, reject) => {
1102
+ timer = setTimeout(() => {
1103
+ for (const socket of sockets) {
1104
+ try {
1105
+ socket.close();
1106
+ } catch {
1107
+ }
1108
+ }
1109
+ reject(new JoinTimeoutError(index, timeoutMs, observer.lastStatus));
1110
+ }, timeoutMs);
1111
+ })
1112
+ ]);
1113
+ } finally {
1114
+ if (timer !== void 0) clearTimeout(timer);
1115
+ }
1116
+ }
1028
1117
  async function spawnBots(n, options = {}) {
1029
1118
  if (!Number.isInteger(n) || n < 1) {
1030
1119
  throw new Error(`irtio bots: spawnBots needs at least one bot, got ${String(n)}`);
@@ -1035,7 +1124,8 @@ async function spawnBots(n, options = {}) {
1035
1124
  correctionsPerSecMax: options.correctionsPerSecMax ?? DEFAULT_THRESHOLDS.correctionsPerSecMax,
1036
1125
  handlerErrorsMax: options.handlerErrorsMax ?? DEFAULT_THRESHOLDS.handlerErrorsMax,
1037
1126
  mispredictionMagnitudeMax: options.mispredictionMagnitudeMax ?? DEFAULT_THRESHOLDS.mispredictionMagnitudeMax,
1038
- snapsMax: options.snapsMax ?? DEFAULT_THRESHOLDS.snapsMax
1127
+ snapsMax: options.snapsMax ?? DEFAULT_THRESHOLDS.snapsMax,
1128
+ overrunsMax: options.overrunsMax ?? DEFAULT_THRESHOLDS.overrunsMax
1039
1129
  };
1040
1130
  const ext = options.schema ? withBuiltins(options.schema) : relaySchema;
1041
1131
  const seed = options.seed ?? DEFAULT_SEED;
@@ -1044,6 +1134,9 @@ async function spawnBots(n, options = {}) {
1044
1134
  const lags = [];
1045
1135
  const observers = [];
1046
1136
  const bots = [];
1137
+ const joinTimeoutMs = options.joinTimeoutMs ?? DEFAULT_JOIN_TIMEOUT_MS;
1138
+ const roomGoneGraceMs = options.roomGoneGraceMs ?? DEFAULT_ROOM_GONE_GRACE_MS;
1139
+ const baseTransport = options.transport ?? webSocketTransport;
1047
1140
  async function joinOne(index, roomId2) {
1048
1141
  const observer = new BotObserver({
1049
1142
  index,
@@ -1056,22 +1149,31 @@ async function spawnBots(n, options = {}) {
1056
1149
  });
1057
1150
  const role = per(options.role, index);
1058
1151
  const name = per(options.name, index);
1152
+ const sockets = [];
1153
+ const transport = {
1154
+ connect(url) {
1155
+ const socket = baseTransport.connect(url);
1156
+ sockets.push(socket);
1157
+ return socket;
1158
+ }
1159
+ };
1059
1160
  const common = {
1060
1161
  room: roomId2,
1061
1162
  ...options.url !== void 0 ? { url: options.url } : {},
1062
1163
  ...options.key !== void 0 ? { key: options.key } : {},
1063
1164
  ...role !== void 0 ? { role } : {},
1064
1165
  ...name !== void 0 ? { name } : {},
1065
- ...options.transport !== void 0 ? { transport: options.transport } : {},
1166
+ transport,
1066
1167
  onFrame: (dir, type, bytes) => observer.onFrame(dir, type, bytes),
1067
1168
  onStatus: (status) => observer.onStatus(status)
1068
1169
  };
1069
- const room = options.schema ? await joinRoom(options.schema, {
1170
+ const joining = options.schema ? joinRoom(options.schema, {
1070
1171
  ...common,
1071
1172
  ...options.flushMs !== void 0 ? { writeIntervalMs: options.flushMs } : {},
1072
1173
  ...options.rpc !== void 0 ? { rpc: options.rpc } : {},
1073
1174
  ...options.physics !== void 0 ? { physics: options.physics } : {}
1074
- }) : await joinRelay(common);
1175
+ }) : joinRelay(common);
1176
+ const room = await withJoinTimeout(joining, index, joinTimeoutMs, observer, sockets);
1075
1177
  room.on("correct", (correction) => observer.onCorrection(correction));
1076
1178
  const prediction = room.prediction;
1077
1179
  if (prediction) observer.predictsBody = (c, id) => prediction.predicts(c, id);
@@ -1082,8 +1184,20 @@ async function spawnBots(n, options = {}) {
1082
1184
  bots.push(first);
1083
1185
  const roomId = first.room.id;
1084
1186
  if (n > 1) {
1085
- const rest = await Promise.all(Array.from({ length: n - 1 }, (_, i) => joinOne(i + 1, roomId)));
1086
- bots.push(...rest);
1187
+ const rest = await Promise.allSettled(
1188
+ Array.from({ length: n - 1 }, (_, i) => joinOne(i + 1, roomId))
1189
+ );
1190
+ const failure = rest.find((r) => r.status === "rejected");
1191
+ if (failure !== void 0) {
1192
+ for (const settled of rest) {
1193
+ if (settled.status === "fulfilled") settled.value.room.leave();
1194
+ }
1195
+ first.room.leave();
1196
+ throw failure.reason;
1197
+ }
1198
+ for (const settled of rest) {
1199
+ if (settled.status === "fulfilled") bots.push(settled.value);
1200
+ }
1087
1201
  }
1088
1202
  const scriptErrors = [];
1089
1203
  let firstError;
@@ -1101,36 +1215,69 @@ async function spawnBots(n, options = {}) {
1101
1215
  }
1102
1216
  });
1103
1217
  const finished = Promise.all(scripts).then(() => void 0);
1218
+ let endedBy;
1104
1219
  let deadline;
1105
1220
  if (options.durationMs !== void 0) {
1106
1221
  deadline = setTimeout(() => {
1222
+ endedBy ??= "duration";
1107
1223
  for (const bot of bots) bot.stop();
1108
1224
  }, options.durationMs);
1109
- deadline.unref?.();
1110
1225
  }
1226
+ let goneSince = 0;
1227
+ let goneTimer;
1228
+ if (roomGoneGraceMs > 0) {
1229
+ goneTimer = setInterval(() => {
1230
+ const down = observers.every(
1231
+ (o) => o.lastStatus === "reconnecting" || o.lastStatus === "closed"
1232
+ );
1233
+ if (!down) {
1234
+ goneSince = 0;
1235
+ return;
1236
+ }
1237
+ goneSince ||= Date.now();
1238
+ if (Date.now() - goneSince < roomGoneGraceMs) return;
1239
+ endedBy ??= "room-gone";
1240
+ for (const bot of bots) bot.stop();
1241
+ }, ROOM_GONE_POLL_MS);
1242
+ goneTimer.unref?.();
1243
+ }
1244
+ void finished.then(() => {
1245
+ endedBy ??= "scripts";
1246
+ if (deadline) clearTimeout(deadline);
1247
+ if (goneTimer) clearInterval(goneTimer);
1248
+ });
1111
1249
  let stoppedAt;
1250
+ let tickHealth;
1112
1251
  const rings = () => observers.map((o) => o.ring);
1113
1252
  const runner = {
1114
1253
  bots,
1115
1254
  roomId,
1116
1255
  trace: makeTrace(startedAt, rings),
1117
1256
  scriptErrors,
1257
+ get endedBy() {
1258
+ return endedBy;
1259
+ },
1118
1260
  [Symbol.iterator]: () => bots[Symbol.iterator](),
1119
1261
  async done() {
1120
1262
  await finished;
1121
1263
  if (firstError !== void 0) throw firstError;
1122
1264
  },
1265
+ recordTickHealth(reading) {
1266
+ tickHealth = reading;
1267
+ },
1123
1268
  report() {
1124
1269
  return buildReport({
1125
1270
  observers,
1126
1271
  roomId,
1127
1272
  durationMs: (stoppedAt ?? Date.now()) - startedAt,
1128
1273
  lags,
1129
- thresholds
1274
+ thresholds,
1275
+ tickHealth
1130
1276
  });
1131
1277
  },
1132
1278
  async stop() {
1133
1279
  if (deadline) clearTimeout(deadline);
1280
+ if (goneTimer) clearInterval(goneTimer);
1134
1281
  for (const bot of bots) bot.stop();
1135
1282
  await finished;
1136
1283
  stoppedAt ??= Date.now();
@@ -1143,10 +1290,13 @@ async function spawnBots(n, options = {}) {
1143
1290
  }
1144
1291
  export {
1145
1292
  BotObserver,
1293
+ DEFAULT_JOIN_TIMEOUT_MS,
1294
+ DEFAULT_ROOM_GONE_GRACE_MS,
1146
1295
  DEFAULT_SEED,
1147
1296
  DEFAULT_THRESHOLDS,
1148
1297
  DEFAULT_TRACE_LIMIT,
1149
1298
  INVARIANT_NAMES,
1299
+ JoinTimeoutError,
1150
1300
  TraceRing,
1151
1301
  WriteLog,
1152
1302
  buildReport,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/bots",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "irtio bot runtime: N real clients, scripted behaviours, trace recorder, built-in invariants",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -19,10 +19,10 @@
19
19
  "dist"
20
20
  ],
21
21
  "dependencies": {
22
- "@irtio/client": "0.5.1",
23
- "@irtio/protocol": "0.5.1",
24
- "@irtio/runtime": "0.5.1",
25
- "@irtio/schema": "0.5.1"
22
+ "@irtio/client": "0.5.2",
23
+ "@irtio/runtime": "0.5.2",
24
+ "@irtio/schema": "0.5.2",
25
+ "@irtio/protocol": "0.5.2"
26
26
  },
27
27
  "scripts": {
28
28
  "build": "tsup",