@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 +92 -4
- package/dist/index.js +162 -12
- package/package.json +5 -5
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
|
-
|
|
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
|
-
|
|
1166
|
+
transport,
|
|
1066
1167
|
onFrame: (dir, type, bytes) => observer.onFrame(dir, type, bytes),
|
|
1067
1168
|
onStatus: (status) => observer.onStatus(status)
|
|
1068
1169
|
};
|
|
1069
|
-
const
|
|
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
|
-
}) :
|
|
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.
|
|
1086
|
-
|
|
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.
|
|
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.
|
|
23
|
-
"@irtio/
|
|
24
|
-
"@irtio/
|
|
25
|
-
"@irtio/
|
|
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",
|