@irtio/bots 0.1.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/LICENSE +21 -0
- package/dist/index.d.ts +469 -0
- package/dist/index.js +858 -0
- package/package.json +31 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 irtio contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import { AnySchema, Delta, PlainState, TypeDesc } from '@irtio/schema';
|
|
2
|
+
import { Room, RelayRoom, Transport, JoinOptions } from '@irtio/client';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The built-in invariants, and the pure checkers behind the two that need real protocol knowledge.
|
|
6
|
+
*
|
|
7
|
+
* `visibilityLeak` is the one worth the most: a role-scoped collection must never reach a client
|
|
8
|
+
* outside its roles, adds and removes included. The rule is `visibleNames` from `@irtio/runtime`
|
|
9
|
+
* — the *same* function the server encodes views with, imported rather than reimplemented, so the
|
|
10
|
+
* check cannot drift into agreeing with a bug.
|
|
11
|
+
*
|
|
12
|
+
* Everything here is a pure function of decoded frames: a unit test fabricates a `DELTA` and asks
|
|
13
|
+
* the checker, with no socket, no server and no bot anywhere in sight.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** The invariants every simulation checks, in report order. */
|
|
17
|
+
declare const INVARIANT_NAMES: readonly ["schema-validity", "visibility-leak", "bandwidth", "handler-error", "correction-storm", "disconnects"];
|
|
18
|
+
type InvariantName = (typeof INVARIANT_NAMES)[number];
|
|
19
|
+
interface InvariantResult {
|
|
20
|
+
readonly name: InvariantName;
|
|
21
|
+
readonly ok: boolean;
|
|
22
|
+
readonly violations: number;
|
|
23
|
+
/** One line a human can act on: the threshold, the peak, the first offenders. */
|
|
24
|
+
readonly detail: string;
|
|
25
|
+
}
|
|
26
|
+
/** Thresholds the counting invariants compare against. */
|
|
27
|
+
interface InvariantThresholds {
|
|
28
|
+
/** `bandwidth`: inbound bytes per second, per bot. */
|
|
29
|
+
readonly budgetBytesPerSec: number;
|
|
30
|
+
/** `correction-storm`: CORRECT frames per second, per bot — the cheater-mode signal. */
|
|
31
|
+
readonly correctionsPerSecMax: number;
|
|
32
|
+
/** `handler-error`: tolerated REPLY errors + non-fatal ERROR frames across the whole run. */
|
|
33
|
+
readonly handlerErrorsMax: number;
|
|
34
|
+
}
|
|
35
|
+
declare const DEFAULT_THRESHOLDS: InvariantThresholds;
|
|
36
|
+
/**
|
|
37
|
+
* Collections a `DELTA` named that `role` is not allowed to see. An op count of zero is not a
|
|
38
|
+
* leak — an empty collection entry carries no information — but any add, update or remove is.
|
|
39
|
+
*/
|
|
40
|
+
declare function deltaVisibilityLeaks(ext: AnySchema, role: string, delta: Delta): string[];
|
|
41
|
+
/**
|
|
42
|
+
* The same rule for a join snapshot. The wire format always has a slot for every collection —
|
|
43
|
+
* a hidden one is encoded empty — so the leak is *content*: an entity collection with records in
|
|
44
|
+
* it, or a singleton holding anything other than its zero record.
|
|
45
|
+
*/
|
|
46
|
+
declare function snapshotVisibilityLeaks(ext: AnySchema, role: string, state: PlainState): string[];
|
|
47
|
+
/**
|
|
48
|
+
* The socket-free entry point for the frames that carry a delta (`DELTA`, `CORRECT`): hand it the
|
|
49
|
+
* frame type and its payload and it decodes and checks. Any other type returns `[]` — a join
|
|
50
|
+
* snapshot goes through `snapshotVisibilityLeaks` instead, because a `WELCOME` payload wraps one
|
|
51
|
+
* rather than being one. A payload that will not decode throws, and the caller counts that as a
|
|
52
|
+
* `schema-validity` violation rather than a visibility one.
|
|
53
|
+
*/
|
|
54
|
+
declare function frameVisibilityLeaks(ext: AnySchema, role: string, type: number, payload: Uint8Array): string[];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The trace recorder: every frame in and out, per bot, with a timestamp and the decoded frame
|
|
58
|
+
* type — the thing you actually read when a simulation goes red.
|
|
59
|
+
*
|
|
60
|
+
* Bounded on purpose. A 20-bot 10 s run at 30 Hz is ~10 000 frames and a naive array of payloads
|
|
61
|
+
* would be tens of megabytes, so each bot gets a fixed-size ring of *headers only* (no payload
|
|
62
|
+
* bytes, just their length). `save()` dumps the merged ring as JSON.
|
|
63
|
+
*/
|
|
64
|
+
/** `'DELTA'`, `'WRITE'`, … or `'UNKNOWN(42)'` for a type this protocol version does not define. */
|
|
65
|
+
declare function frameName(type: number): string;
|
|
66
|
+
interface TraceEntry {
|
|
67
|
+
/** Milliseconds since the run started. */
|
|
68
|
+
readonly at: number;
|
|
69
|
+
readonly bot: number;
|
|
70
|
+
readonly dir: 'in' | 'out';
|
|
71
|
+
readonly type: number;
|
|
72
|
+
readonly frame: string;
|
|
73
|
+
readonly bytes: number;
|
|
74
|
+
/** Set when the frame is worth a word: a decode failure, an error code, an rpc name. */
|
|
75
|
+
readonly note?: string;
|
|
76
|
+
}
|
|
77
|
+
/** Fixed-capacity ring. Oldest entries are overwritten and counted in `dropped`. */
|
|
78
|
+
declare class TraceRing {
|
|
79
|
+
readonly capacity: number;
|
|
80
|
+
private readonly items;
|
|
81
|
+
private next;
|
|
82
|
+
private count;
|
|
83
|
+
dropped: number;
|
|
84
|
+
constructor(capacity: number);
|
|
85
|
+
push(entry: TraceEntry): void;
|
|
86
|
+
/** Oldest first. */
|
|
87
|
+
entries(): TraceEntry[];
|
|
88
|
+
}
|
|
89
|
+
/** What `trace.save(path)` writes. */
|
|
90
|
+
interface TraceDump {
|
|
91
|
+
/** `Date.now()` at the start of the run, so `entry.at` can be turned back into wall time. */
|
|
92
|
+
readonly startedAt: number;
|
|
93
|
+
readonly bots: number;
|
|
94
|
+
/** Frames the ring overwrote — a non-zero value means the trace is a tail, not the whole run. */
|
|
95
|
+
readonly dropped: number;
|
|
96
|
+
readonly entries: readonly TraceEntry[];
|
|
97
|
+
}
|
|
98
|
+
/** A read-only view over one or more rings. */
|
|
99
|
+
interface Trace {
|
|
100
|
+
/** Merged, oldest first. */
|
|
101
|
+
entries(): TraceEntry[];
|
|
102
|
+
readonly dropped: number;
|
|
103
|
+
/** Writes `TraceDump` JSON. */
|
|
104
|
+
save(path: string): Promise<void>;
|
|
105
|
+
}
|
|
106
|
+
/** Builds a `Trace` over rings resolved lazily, so it keeps working as bots come and go. */
|
|
107
|
+
declare function makeTrace(startedAt: number, rings: () => readonly TraceRing[]): Trace;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* One bot's continuous invariant checking, driven entirely by the client's `onFrame` seam.
|
|
111
|
+
*
|
|
112
|
+
* The observer never touches the room object: it decodes the wire a second time, independently of
|
|
113
|
+
* `@irtio/client`. That is the point — if the SDK's decoder and the server's encoder ever agree on
|
|
114
|
+
* something wrong, a checker built on `room.state` agrees with them and a checker built on the
|
|
115
|
+
* bytes does not.
|
|
116
|
+
*/
|
|
117
|
+
|
|
118
|
+
/** Per-bot counters the report prints verbatim. */
|
|
119
|
+
interface BotStats {
|
|
120
|
+
readonly index: number;
|
|
121
|
+
/** The client id from `WELCOME` — `''` until the bot has joined. */
|
|
122
|
+
readonly id: string;
|
|
123
|
+
readonly role: string;
|
|
124
|
+
readonly framesIn: number;
|
|
125
|
+
readonly framesOut: number;
|
|
126
|
+
readonly bytesIn: number;
|
|
127
|
+
readonly bytesOut: number;
|
|
128
|
+
readonly corrections: number;
|
|
129
|
+
/** Outbound `CALL` frames: RPCs this bot asked the server for. */
|
|
130
|
+
readonly calls: number;
|
|
131
|
+
/** Error replies plus non-fatal `ERROR` frames. */
|
|
132
|
+
readonly errors: number;
|
|
133
|
+
readonly disconnects: number;
|
|
134
|
+
readonly peakBytesInPerSec: number;
|
|
135
|
+
readonly peakCorrectionsPerSec: number;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Shared across bots: the bridge that turns "bot A wrote x=7" plus "bot B was told x=7" into a
|
|
139
|
+
* convergence lag. Keyed by value, so a write the server clamps simply never matches and is
|
|
140
|
+
* evicted — under-sampling is fine, a wrong number is not.
|
|
141
|
+
*/
|
|
142
|
+
declare class WriteLog {
|
|
143
|
+
private readonly limit;
|
|
144
|
+
private readonly entries;
|
|
145
|
+
constructor(limit?: number);
|
|
146
|
+
record(key: string, at: number, bot: number): void;
|
|
147
|
+
/** The time *another* bot wrote this exact value, consuming the entry. */
|
|
148
|
+
match(key: string, bot: number): number | undefined;
|
|
149
|
+
}
|
|
150
|
+
interface ObserverOptions {
|
|
151
|
+
readonly index: number;
|
|
152
|
+
/** The runtime-extended schema the wire is encoded with (`withBuiltins`, or `relaySchema`). */
|
|
153
|
+
readonly ext: AnySchema;
|
|
154
|
+
/** `Date.now()` at the start of the run; every trace timestamp is relative to it. */
|
|
155
|
+
readonly startedAt: number;
|
|
156
|
+
readonly traceLimit: number;
|
|
157
|
+
readonly thresholds: InvariantThresholds;
|
|
158
|
+
readonly writeLog: WriteLog;
|
|
159
|
+
/** Convergence lag samples, shared across the run. */
|
|
160
|
+
readonly lags: number[];
|
|
161
|
+
}
|
|
162
|
+
declare class BotObserver {
|
|
163
|
+
private readonly options;
|
|
164
|
+
readonly ring: TraceRing;
|
|
165
|
+
readonly violations: Map<"schema-validity" | "visibility-leak" | "bandwidth" | "handler-error" | "correction-storm" | "disconnects", string[]>;
|
|
166
|
+
readonly counts: Map<"schema-validity" | "visibility-leak" | "bandwidth" | "handler-error" | "correction-storm" | "disconnects", number>;
|
|
167
|
+
id: string;
|
|
168
|
+
role: string;
|
|
169
|
+
roomId: string;
|
|
170
|
+
/** Set before `leave()`, so a deliberate teardown is not reported as a disconnect. */
|
|
171
|
+
stopping: boolean;
|
|
172
|
+
framesIn: number;
|
|
173
|
+
framesOut: number;
|
|
174
|
+
bytesIn: number;
|
|
175
|
+
bytesOut: number;
|
|
176
|
+
corrections: number;
|
|
177
|
+
calls: number;
|
|
178
|
+
errors: number;
|
|
179
|
+
disconnects: number;
|
|
180
|
+
peakBytesInPerSec: number;
|
|
181
|
+
peakCorrectionsPerSec: number;
|
|
182
|
+
private readonly bytesWindow;
|
|
183
|
+
private readonly correctWindow;
|
|
184
|
+
private readonly cooldown;
|
|
185
|
+
constructor(options: ObserverOptions);
|
|
186
|
+
get stats(): BotStats;
|
|
187
|
+
/** Counts a violation always; keeps an example, rate-limited per invariant when asked. */
|
|
188
|
+
private violate;
|
|
189
|
+
/** `room.on('status')`: transitions away from a healthy connection, ignored during teardown. */
|
|
190
|
+
onStatus(status: string): void;
|
|
191
|
+
/** The `onFrame` hook. `bytes` is the whole frame, envelope byte included. */
|
|
192
|
+
onFrame(dir: 'in' | 'out', type: number, bytes: Uint8Array): void;
|
|
193
|
+
/** Decodes the payload independently. Throws on a bad frame; the caller counts that. */
|
|
194
|
+
private inspect;
|
|
195
|
+
private onWelcome;
|
|
196
|
+
private onDelta;
|
|
197
|
+
private onCorrect;
|
|
198
|
+
private onError;
|
|
199
|
+
private onReply;
|
|
200
|
+
private eachScalar;
|
|
201
|
+
/** Our own `WRITE`: remember what we sent, so whoever sees it can date it. */
|
|
202
|
+
private recordWrites;
|
|
203
|
+
/** Somebody else's write arriving here: that round trip is the convergence lag. */
|
|
204
|
+
private matchWrites;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Seeded randomness and schema-driven value generation.
|
|
209
|
+
*
|
|
210
|
+
* Every bot gets its own generator seeded from `seed + index`, so a whole run replays exactly:
|
|
211
|
+
* a red simulation is worth nothing if the next run takes a different path.
|
|
212
|
+
*
|
|
213
|
+
* Value generation reads `@irtio/schema`'s `TypeDesc` directly rather than duplicating the type
|
|
214
|
+
* menu, so a new type kind is a compile error here instead of a silent gap.
|
|
215
|
+
*/
|
|
216
|
+
|
|
217
|
+
/** A seeded pseudo-random source behind `bot.random()`. */
|
|
218
|
+
interface Rng {
|
|
219
|
+
/** Uniform in [0, 1). */
|
|
220
|
+
next(): number;
|
|
221
|
+
/** Uniform integer in [lo, hi] (inclusive). */
|
|
222
|
+
int(lo: number, hi: number): number;
|
|
223
|
+
/** Uniform in [lo, hi). */
|
|
224
|
+
float(lo: number, hi: number): number;
|
|
225
|
+
/** `true` with probability `p`. */
|
|
226
|
+
chance(p: number): boolean;
|
|
227
|
+
/** One element, or `undefined` for an empty array. */
|
|
228
|
+
pick<T>(items: readonly T[]): T | undefined;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* mulberry32: 32 bits of state, good enough for behaviour scripts and — unlike `Math.random` —
|
|
232
|
+
* reproducible. Not cryptographic, and never used for anything that needs to be.
|
|
233
|
+
*/
|
|
234
|
+
declare function makeRng(seed: number): Rng;
|
|
235
|
+
/** How a value is generated: a small walk (normal play) or a teleport (`--cheat`). */
|
|
236
|
+
interface ValueContext {
|
|
237
|
+
/** Magnitude of one step for unbounded numeric fields. */
|
|
238
|
+
readonly step: number;
|
|
239
|
+
/** Produce values a server validator should reject. */
|
|
240
|
+
readonly cheat: boolean;
|
|
241
|
+
/**
|
|
242
|
+
* The value this field started at. Numeric walks are mean-reverting toward it, which keeps a
|
|
243
|
+
* long run inside whatever world bounds the room's validator enforces without the bot having to
|
|
244
|
+
* know them — the type system only declares a *representable* range, never a legal one.
|
|
245
|
+
*/
|
|
246
|
+
readonly origin?: number | undefined;
|
|
247
|
+
/** Ids that a `ref` field may legally point at. */
|
|
248
|
+
readonly refIds?: readonly string[] | undefined;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* The next value for a field that currently holds `current`. Always inside the type's declared
|
|
252
|
+
* range, so `track()` accepts it and the *server* — not the client — is the one that gets to say
|
|
253
|
+
* a value is illegal. In cheat mode the value is still type-valid and wildly out of play.
|
|
254
|
+
*/
|
|
255
|
+
declare function nextValue(rng: Rng, desc: TypeDesc, current: unknown, ctx: ValueContext): unknown;
|
|
256
|
+
/** A fresh valid value for a field with no current value — RPC params, mostly. */
|
|
257
|
+
declare function freshValue(rng: Rng, desc: TypeDesc, ctx: ValueContext): unknown;
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The end-of-run summary: one line per invariant, one row per bot, and the handful of aggregate
|
|
261
|
+
* numbers a load run cares about (frames/s, per-bot bandwidth, corrections, convergence lag).
|
|
262
|
+
*
|
|
263
|
+
* Assembly lives apart from the observers so a report can be built at any moment — `irtio
|
|
264
|
+
* simulate` builds one after the deadline, a test builds one mid-run to assert on it.
|
|
265
|
+
*/
|
|
266
|
+
|
|
267
|
+
/** Convergence lag, sampled from real writes crossing to another bot's view. */
|
|
268
|
+
interface ConvergenceStats {
|
|
269
|
+
readonly samples: number;
|
|
270
|
+
readonly p50Ms: number;
|
|
271
|
+
readonly p95Ms: number;
|
|
272
|
+
readonly maxMs: number;
|
|
273
|
+
}
|
|
274
|
+
interface SimulationTotals {
|
|
275
|
+
readonly framesIn: number;
|
|
276
|
+
readonly framesOut: number;
|
|
277
|
+
readonly bytesIn: number;
|
|
278
|
+
readonly bytesOut: number;
|
|
279
|
+
readonly corrections: number;
|
|
280
|
+
readonly calls: number;
|
|
281
|
+
readonly errors: number;
|
|
282
|
+
}
|
|
283
|
+
interface SimulationReport {
|
|
284
|
+
/** True when every invariant passed. `irtio simulate` exits 1 when it is not. */
|
|
285
|
+
readonly ok: boolean;
|
|
286
|
+
readonly bots: number;
|
|
287
|
+
readonly roomId: string;
|
|
288
|
+
readonly durationMs: number;
|
|
289
|
+
readonly invariants: readonly InvariantResult[];
|
|
290
|
+
readonly perBot: readonly BotStats[];
|
|
291
|
+
readonly totals: SimulationTotals;
|
|
292
|
+
/** Frames per second across every bot, both directions. */
|
|
293
|
+
readonly framesPerSecond: number;
|
|
294
|
+
readonly bytesInPerSecondPerBot: number;
|
|
295
|
+
readonly bytesOutPerSecondPerBot: number;
|
|
296
|
+
readonly convergence: ConvergenceStats | undefined;
|
|
297
|
+
/** The headline number: median ms from one bot's write to another bot seeing it. */
|
|
298
|
+
readonly convergenceLagMs: number | undefined;
|
|
299
|
+
readonly tracePath: string | undefined;
|
|
300
|
+
}
|
|
301
|
+
/** `undefined` when nothing converged during the run — an honest gap beats a fabricated zero. */
|
|
302
|
+
declare function convergenceStats(lags: readonly number[]): ConvergenceStats | undefined;
|
|
303
|
+
interface BuildReportOptions {
|
|
304
|
+
readonly observers: readonly BotObserver[];
|
|
305
|
+
readonly roomId: string;
|
|
306
|
+
readonly durationMs: number;
|
|
307
|
+
readonly lags: readonly number[];
|
|
308
|
+
readonly thresholds?: InvariantThresholds;
|
|
309
|
+
readonly tracePath?: string | undefined;
|
|
310
|
+
}
|
|
311
|
+
/** Folds the observers into the report `irtio simulate` prints and tests assert on. */
|
|
312
|
+
declare function buildReport(options: BuildReportOptions): SimulationReport;
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* `spawnBots` — N real `@irtio/client` sessions in one Node process, each running a script, each
|
|
316
|
+
* watched by a `BotObserver`.
|
|
317
|
+
*
|
|
318
|
+
* "Real" is the whole point. A bot is not a mock: it speaks the same wire, through the same SDK,
|
|
319
|
+
* with the same write batching and the same reconnection logic a browser gets. The only seams the
|
|
320
|
+
* runtime uses are the ones `joinRoom` already offers for tests — `onFrame` for the trace,
|
|
321
|
+
* `transport` for the reconnection scenarios — so a bug a simulation finds is a bug a player hits.
|
|
322
|
+
*
|
|
323
|
+
* Bot 0 joins first and creates the room when no code was given; everyone else joins the code it
|
|
324
|
+
* came back with. Otherwise twenty bots would create twenty empty rooms and every invariant would
|
|
325
|
+
* pass while nothing was tested.
|
|
326
|
+
*/
|
|
327
|
+
|
|
328
|
+
/** Default seed: a run with no `seed` still replays, it just replays *this* sequence. */
|
|
329
|
+
declare const DEFAULT_SEED = 96016;
|
|
330
|
+
/** Trace ring size per bot. 20 bots × 4096 ≈ 80k headers, a few megabytes at worst. */
|
|
331
|
+
declare const DEFAULT_TRACE_LIMIT = 4096;
|
|
332
|
+
/** The room a bot holds: a full `Room` with a schema, or a schemaless `RelayRoom`. */
|
|
333
|
+
type BotRoom<S> = S extends AnySchema ? Room<S> : RelayRoom;
|
|
334
|
+
interface UntilOptions {
|
|
335
|
+
/** Named in the timeout message. */
|
|
336
|
+
readonly label?: string;
|
|
337
|
+
readonly timeoutMs?: number;
|
|
338
|
+
readonly everyMs?: number;
|
|
339
|
+
}
|
|
340
|
+
interface Bot<S = undefined> {
|
|
341
|
+
readonly index: number;
|
|
342
|
+
readonly room: BotRoom<S>;
|
|
343
|
+
/** The client id — `room.me`, repeated here because scripts reach for it constantly. */
|
|
344
|
+
readonly id: string;
|
|
345
|
+
readonly role: string;
|
|
346
|
+
/** Seeded per bot index: the same run replays the same way. */
|
|
347
|
+
random(): number;
|
|
348
|
+
/** The generator behind `random()`, for scripts that want integers and picks. */
|
|
349
|
+
readonly rng: Rng;
|
|
350
|
+
/** Sleeps, or returns immediately once the bot has been asked to stop. */
|
|
351
|
+
wait(ms: number): Promise<void>;
|
|
352
|
+
/** Polls (never a fixed sleep). Returns early if the bot is stopped. */
|
|
353
|
+
until(predicate: () => boolean | Promise<boolean>, options?: UntilOptions): Promise<void>;
|
|
354
|
+
/** Asks this bot's script to wind down. The socket closes when the runner stops. */
|
|
355
|
+
stop(): void;
|
|
356
|
+
readonly stopped: boolean;
|
|
357
|
+
/** This bot's slice of the trace. */
|
|
358
|
+
readonly trace: Trace;
|
|
359
|
+
readonly stats: BotStats;
|
|
360
|
+
}
|
|
361
|
+
type BotScript<S = undefined> = (bot: Bot<S>) => void | Promise<void>;
|
|
362
|
+
interface SpawnOptionsBase {
|
|
363
|
+
/** Endpoint. Defaults to `@irtio/client`'s resolution (`ws://localhost:7070` in Node). */
|
|
364
|
+
readonly url?: string;
|
|
365
|
+
readonly key?: string;
|
|
366
|
+
/** Room code. Omitted: bot 0 creates one and the rest join it. */
|
|
367
|
+
readonly room?: string;
|
|
368
|
+
/** A string for every bot, or a function of the bot index (1 host + 8 controllers). */
|
|
369
|
+
readonly role?: string | ((index: number) => string);
|
|
370
|
+
readonly name?: string | ((index: number) => string);
|
|
371
|
+
/** `writeIntervalMs` on each client. */
|
|
372
|
+
readonly flushMs?: number;
|
|
373
|
+
/** Base seed; bot `i` uses `seed + i`. */
|
|
374
|
+
readonly seed?: number;
|
|
375
|
+
/** Frames kept per bot. */
|
|
376
|
+
readonly traceLimit?: number;
|
|
377
|
+
/** Stop every script after this long. Omitted: the scripts decide when they are done. */
|
|
378
|
+
readonly durationMs?: number;
|
|
379
|
+
/** `bandwidth` invariant budget, inbound bytes per second per bot. */
|
|
380
|
+
readonly budgetBytesPerSec?: number;
|
|
381
|
+
/** `correction-storm` threshold, CORRECTs per second per bot. */
|
|
382
|
+
readonly correctionsPerSecMax?: number;
|
|
383
|
+
/** `handler-error` tolerance for a run that expects some RPCs to be refused. */
|
|
384
|
+
readonly handlerErrorsMax?: number;
|
|
385
|
+
/** @internal Wrap `webSocketTransport` to get at the socket (the reconnection scenarios). */
|
|
386
|
+
readonly transport?: Transport;
|
|
387
|
+
}
|
|
388
|
+
interface SpawnOptions<S extends AnySchema> extends SpawnOptionsBase {
|
|
389
|
+
readonly schema: S;
|
|
390
|
+
readonly script?: BotScript<S>;
|
|
391
|
+
/** Implementations for server → client RPCs, shared by every bot. */
|
|
392
|
+
readonly rpc?: JoinOptions<S>['rpc'];
|
|
393
|
+
}
|
|
394
|
+
/** Schema-less relay: no schema, so `joinRelay` and a `RelayRoom`. */
|
|
395
|
+
interface RelaySpawnOptions extends SpawnOptionsBase {
|
|
396
|
+
readonly schema?: undefined;
|
|
397
|
+
readonly script?: BotScript<undefined>;
|
|
398
|
+
}
|
|
399
|
+
interface BotRunner<S = undefined> extends Iterable<Bot<S>> {
|
|
400
|
+
readonly bots: readonly Bot<S>[];
|
|
401
|
+
readonly roomId: string;
|
|
402
|
+
/** Every bot's frames, merged and time-ordered. `trace.save(path)` writes JSON. */
|
|
403
|
+
readonly trace: Trace;
|
|
404
|
+
/** Anything a script threw, in the order it happened. */
|
|
405
|
+
readonly scriptErrors: readonly {
|
|
406
|
+
bot: number;
|
|
407
|
+
error: unknown;
|
|
408
|
+
}[];
|
|
409
|
+
/** Resolves when every script has finished; rejects with the first one that threw. */
|
|
410
|
+
done(): Promise<void>;
|
|
411
|
+
/** Stops the scripts, leaves every room, and returns the final report. */
|
|
412
|
+
stop(): Promise<SimulationReport>;
|
|
413
|
+
/** The report as of now — safe to call mid-run. */
|
|
414
|
+
report(): SimulationReport;
|
|
415
|
+
}
|
|
416
|
+
/** Spawns `n` real clients on one room, runs `script` on each, and watches every invariant. */
|
|
417
|
+
declare function spawnBots<S extends AnySchema>(n: number, options: SpawnOptions<S>): Promise<BotRunner<S>>;
|
|
418
|
+
declare function spawnBots(n: number, options?: RelaySpawnOptions): Promise<BotRunner<undefined>>;
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The generic behaviour script: play the room without knowing anything about it.
|
|
422
|
+
*
|
|
423
|
+
* Everything the script does is derived from the schema — which collections a client may own, what
|
|
424
|
+
* each field's declared type will accept, which RPCs take no answer. That is what makes `irtio
|
|
425
|
+
* simulate` work on a room it has never seen.
|
|
426
|
+
*
|
|
427
|
+
* Two deliberate choices are worth knowing about:
|
|
428
|
+
*
|
|
429
|
+
* - **Numeric writes are a mean-reverting walk, not a uniform draw over the type range.** A type
|
|
430
|
+
* declares what is *representable*, never what is *legal*: `f32` says nothing about a 1000-unit
|
|
431
|
+
* world or a 50-unit speed limit. A bot that teleported every tick would be corrected every tick
|
|
432
|
+
* and the correction-storm invariant would be useless. Small steps around the spawn value keep a
|
|
433
|
+
* well-behaved bot well-behaved on any room.
|
|
434
|
+
* - **`cheat: true` inverts exactly that.** It writes type-valid, play-illegal values — the far end
|
|
435
|
+
* of an integer range, a five-order-of-magnitude jump on a float — which is what a modified
|
|
436
|
+
* client does, and which every server validator should answer with a `CORRECT`.
|
|
437
|
+
*/
|
|
438
|
+
|
|
439
|
+
interface RandomScriptOptions {
|
|
440
|
+
/** Write values a validator should reject, and expect corrections back. */
|
|
441
|
+
readonly cheat?: boolean;
|
|
442
|
+
/** Milliseconds between behaviour steps. Default 50 — one per client flush window. */
|
|
443
|
+
readonly stepMs?: number;
|
|
444
|
+
/** Magnitude of one numeric step for a well-behaved bot. Default 8. */
|
|
445
|
+
readonly step?: number;
|
|
446
|
+
/** Chance per step of calling a random void server RPC. Default 0.05. */
|
|
447
|
+
readonly rpcChance?: number;
|
|
448
|
+
/** Fields touched per owned instance per step. Default 2. */
|
|
449
|
+
readonly fieldsPerStep?: number;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* A behaviour script for any schema: random writes to the instances this bot owns, and the odd
|
|
453
|
+
* void RPC with valid params. Runs until the bot is stopped.
|
|
454
|
+
*/
|
|
455
|
+
declare function randomScript<S extends AnySchema>(schema: S, options?: RandomScriptOptions): BotScript<S>;
|
|
456
|
+
interface RelayEchoScriptOptions {
|
|
457
|
+
/** Milliseconds between broadcasts. Default 200. */
|
|
458
|
+
readonly everyMs?: number;
|
|
459
|
+
/** Bytes per message. Default 16. */
|
|
460
|
+
readonly bytes?: number;
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* The simplest behaviour: broadcast a small payload on a timer. A relay room has no state to
|
|
464
|
+
* write and no RPCs to call, so messages are the only thing a bot can do — and the only thing
|
|
465
|
+
* worth checking a relay tenant on.
|
|
466
|
+
*/
|
|
467
|
+
declare function relayEchoScript(options?: RelayEchoScriptOptions): BotScript<undefined>;
|
|
468
|
+
|
|
469
|
+
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 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 };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,858 @@
|
|
|
1
|
+
// src/invariants.ts
|
|
2
|
+
import { FrameType } from "@irtio/protocol";
|
|
3
|
+
import { visibleNames } from "@irtio/runtime";
|
|
4
|
+
import {
|
|
5
|
+
decodeDelta,
|
|
6
|
+
defaultRecord
|
|
7
|
+
} from "@irtio/schema";
|
|
8
|
+
var INVARIANT_NAMES = [
|
|
9
|
+
"schema-validity",
|
|
10
|
+
"visibility-leak",
|
|
11
|
+
"bandwidth",
|
|
12
|
+
"handler-error",
|
|
13
|
+
"correction-storm",
|
|
14
|
+
"disconnects"
|
|
15
|
+
];
|
|
16
|
+
var DEFAULT_THRESHOLDS = {
|
|
17
|
+
budgetBytesPerSec: 128e3,
|
|
18
|
+
correctionsPerSecMax: 5,
|
|
19
|
+
handlerErrorsMax: 0
|
|
20
|
+
};
|
|
21
|
+
function deltaVisibilityLeaks(ext, role, delta) {
|
|
22
|
+
const allowed = visibleNames(ext, role);
|
|
23
|
+
const leaks = [];
|
|
24
|
+
for (const collection of delta.collections) {
|
|
25
|
+
if (allowed.has(collection.name)) continue;
|
|
26
|
+
if (collection.ops.length === 0) continue;
|
|
27
|
+
const ops = collection.ops.map((op) => op.op).join(",");
|
|
28
|
+
leaks.push(`delta@${delta.tick} carried ${collection.name} (${ops}) to role ${role || '""'}`);
|
|
29
|
+
}
|
|
30
|
+
return leaks;
|
|
31
|
+
}
|
|
32
|
+
function isDefaultRecord(desc, value) {
|
|
33
|
+
const zero = defaultRecord(desc);
|
|
34
|
+
for (const field of desc.fields) {
|
|
35
|
+
if (JSON.stringify(value[field.name]) !== JSON.stringify(zero[field.name])) return false;
|
|
36
|
+
}
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
function snapshotVisibilityLeaks(ext, role, state) {
|
|
40
|
+
const allowed = visibleNames(ext, role);
|
|
41
|
+
const leaks = [];
|
|
42
|
+
for (const desc of ext.collections) {
|
|
43
|
+
if (allowed.has(desc.name)) continue;
|
|
44
|
+
if (desc.kind === "entity") {
|
|
45
|
+
const size = state[desc.name]?.size ?? 0;
|
|
46
|
+
if (size > 0) leaks.push(`snapshot carried ${size} ${desc.name} to role ${role || '""'}`);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const value = state[desc.name];
|
|
50
|
+
if (value && !isDefaultRecord(desc, value)) {
|
|
51
|
+
leaks.push(`snapshot carried singleton ${desc.name} to role ${role || '""'}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return leaks;
|
|
55
|
+
}
|
|
56
|
+
function frameVisibilityLeaks(ext, role, type, payload) {
|
|
57
|
+
if (type !== FrameType.DELTA && type !== FrameType.CORRECT) return [];
|
|
58
|
+
return deltaVisibilityLeaks(ext, role, decodeDelta(ext, payload));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/observer.ts
|
|
62
|
+
import {
|
|
63
|
+
FrameType as FrameType3,
|
|
64
|
+
decodeErrorPayload,
|
|
65
|
+
decodeFrame,
|
|
66
|
+
decodeReply,
|
|
67
|
+
decodeWelcome,
|
|
68
|
+
errorByCode
|
|
69
|
+
} from "@irtio/protocol";
|
|
70
|
+
import {
|
|
71
|
+
decodeDelta as decodeDelta2,
|
|
72
|
+
decodeSnapshot
|
|
73
|
+
} from "@irtio/schema";
|
|
74
|
+
|
|
75
|
+
// src/trace.ts
|
|
76
|
+
import { writeFile } from "fs/promises";
|
|
77
|
+
import { FrameType as FrameType2 } from "@irtio/protocol";
|
|
78
|
+
var FRAME_NAMES = new Map(
|
|
79
|
+
Object.entries(FrameType2).map(([name, value]) => [value, name])
|
|
80
|
+
);
|
|
81
|
+
function frameName(type) {
|
|
82
|
+
return FRAME_NAMES.get(type) ?? `UNKNOWN(${type})`;
|
|
83
|
+
}
|
|
84
|
+
var TraceRing = class {
|
|
85
|
+
constructor(capacity) {
|
|
86
|
+
this.capacity = capacity;
|
|
87
|
+
this.items = new Array(Math.max(0, capacity));
|
|
88
|
+
}
|
|
89
|
+
capacity;
|
|
90
|
+
items;
|
|
91
|
+
next = 0;
|
|
92
|
+
count = 0;
|
|
93
|
+
dropped = 0;
|
|
94
|
+
push(entry) {
|
|
95
|
+
if (this.capacity <= 0) {
|
|
96
|
+
this.dropped++;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (this.count === this.capacity) this.dropped++;
|
|
100
|
+
this.items[this.next] = entry;
|
|
101
|
+
this.next = (this.next + 1) % this.capacity;
|
|
102
|
+
if (this.count < this.capacity) this.count++;
|
|
103
|
+
}
|
|
104
|
+
/** Oldest first. */
|
|
105
|
+
entries() {
|
|
106
|
+
const out = [];
|
|
107
|
+
const start = this.count === this.capacity ? this.next : 0;
|
|
108
|
+
for (let i = 0; i < this.count; i++) {
|
|
109
|
+
const entry = this.items[(start + i) % this.capacity];
|
|
110
|
+
if (entry) out.push(entry);
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
function makeTrace(startedAt, rings) {
|
|
116
|
+
const dump = () => {
|
|
117
|
+
const all = rings();
|
|
118
|
+
const entries = all.flatMap((ring) => ring.entries()).sort((a, b) => a.at - b.at);
|
|
119
|
+
return {
|
|
120
|
+
startedAt,
|
|
121
|
+
bots: all.length,
|
|
122
|
+
dropped: all.reduce((sum, ring) => sum + ring.dropped, 0),
|
|
123
|
+
entries
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
return {
|
|
127
|
+
entries: () => [...dump().entries],
|
|
128
|
+
get dropped() {
|
|
129
|
+
return rings().reduce((sum, ring) => sum + ring.dropped, 0);
|
|
130
|
+
},
|
|
131
|
+
async save(path) {
|
|
132
|
+
await writeFile(path, `${JSON.stringify(dump(), null, 2)}
|
|
133
|
+
`, "utf8");
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// src/observer.ts
|
|
139
|
+
var WriteLog = class {
|
|
140
|
+
constructor(limit = 4096) {
|
|
141
|
+
this.limit = limit;
|
|
142
|
+
}
|
|
143
|
+
limit;
|
|
144
|
+
entries = /* @__PURE__ */ new Map();
|
|
145
|
+
record(key, at, bot) {
|
|
146
|
+
if (this.entries.size >= this.limit) {
|
|
147
|
+
const oldest = this.entries.keys().next();
|
|
148
|
+
if (!oldest.done) this.entries.delete(oldest.value);
|
|
149
|
+
}
|
|
150
|
+
this.entries.set(key, { at, bot });
|
|
151
|
+
}
|
|
152
|
+
/** The time *another* bot wrote this exact value, consuming the entry. */
|
|
153
|
+
match(key, bot) {
|
|
154
|
+
const found = this.entries.get(key);
|
|
155
|
+
if (!found || found.bot === bot) return void 0;
|
|
156
|
+
this.entries.delete(key);
|
|
157
|
+
return found.at;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
function writeKey(collection, id, field, value) {
|
|
161
|
+
const kind = typeof value;
|
|
162
|
+
if (kind !== "number" && kind !== "string" && kind !== "boolean") return void 0;
|
|
163
|
+
return [collection, id, field, String(value)].join("\0");
|
|
164
|
+
}
|
|
165
|
+
var SecondWindow = class {
|
|
166
|
+
at = [];
|
|
167
|
+
amount = [];
|
|
168
|
+
sum = 0;
|
|
169
|
+
add(now, amount) {
|
|
170
|
+
this.at.push(now);
|
|
171
|
+
this.amount.push(amount);
|
|
172
|
+
this.sum += amount;
|
|
173
|
+
while (this.at.length > 0 && now - (this.at[0] ?? now) > 1e3) {
|
|
174
|
+
this.sum -= this.amount.shift() ?? 0;
|
|
175
|
+
this.at.shift();
|
|
176
|
+
}
|
|
177
|
+
return this.sum;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
var DETAIL_LIMIT = 5;
|
|
181
|
+
var VIOLATION_COOLDOWN_MS = 1e3;
|
|
182
|
+
var BotObserver = class {
|
|
183
|
+
constructor(options) {
|
|
184
|
+
this.options = options;
|
|
185
|
+
this.ring = new TraceRing(options.traceLimit);
|
|
186
|
+
}
|
|
187
|
+
options;
|
|
188
|
+
ring;
|
|
189
|
+
violations = /* @__PURE__ */ new Map();
|
|
190
|
+
counts = /* @__PURE__ */ new Map();
|
|
191
|
+
id = "";
|
|
192
|
+
role = "";
|
|
193
|
+
roomId = "";
|
|
194
|
+
/** Set before `leave()`, so a deliberate teardown is not reported as a disconnect. */
|
|
195
|
+
stopping = false;
|
|
196
|
+
framesIn = 0;
|
|
197
|
+
framesOut = 0;
|
|
198
|
+
bytesIn = 0;
|
|
199
|
+
bytesOut = 0;
|
|
200
|
+
corrections = 0;
|
|
201
|
+
calls = 0;
|
|
202
|
+
errors = 0;
|
|
203
|
+
disconnects = 0;
|
|
204
|
+
peakBytesInPerSec = 0;
|
|
205
|
+
peakCorrectionsPerSec = 0;
|
|
206
|
+
bytesWindow = new SecondWindow();
|
|
207
|
+
correctWindow = new SecondWindow();
|
|
208
|
+
cooldown = /* @__PURE__ */ new Map();
|
|
209
|
+
get stats() {
|
|
210
|
+
return {
|
|
211
|
+
index: this.options.index,
|
|
212
|
+
id: this.id,
|
|
213
|
+
role: this.role,
|
|
214
|
+
framesIn: this.framesIn,
|
|
215
|
+
framesOut: this.framesOut,
|
|
216
|
+
bytesIn: this.bytesIn,
|
|
217
|
+
bytesOut: this.bytesOut,
|
|
218
|
+
corrections: this.corrections,
|
|
219
|
+
calls: this.calls,
|
|
220
|
+
errors: this.errors,
|
|
221
|
+
disconnects: this.disconnects,
|
|
222
|
+
peakBytesInPerSec: this.peakBytesInPerSec,
|
|
223
|
+
peakCorrectionsPerSec: this.peakCorrectionsPerSec
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/** Counts a violation always; keeps an example, rate-limited per invariant when asked. */
|
|
227
|
+
violate(name, message, rateLimit = false) {
|
|
228
|
+
this.counts.set(name, (this.counts.get(name) ?? 0) + 1);
|
|
229
|
+
const now = Date.now();
|
|
230
|
+
if (rateLimit && now - (this.cooldown.get(name) ?? Number.NEGATIVE_INFINITY) < VIOLATION_COOLDOWN_MS) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
this.cooldown.set(name, now);
|
|
234
|
+
const list = this.violations.get(name) ?? [];
|
|
235
|
+
if (list.length < DETAIL_LIMIT) list.push(`bot ${this.options.index}: ${message}`);
|
|
236
|
+
this.violations.set(name, list);
|
|
237
|
+
}
|
|
238
|
+
/** `room.on('status')`: transitions away from a healthy connection, ignored during teardown. */
|
|
239
|
+
onStatus(status) {
|
|
240
|
+
if (this.stopping) return;
|
|
241
|
+
if (status !== "reconnecting" && status !== "closed") return;
|
|
242
|
+
this.disconnects++;
|
|
243
|
+
this.violate("disconnects", `status became ${status}`);
|
|
244
|
+
}
|
|
245
|
+
/** The `onFrame` hook. `bytes` is the whole frame, envelope byte included. */
|
|
246
|
+
onFrame(dir, type, bytes) {
|
|
247
|
+
const now = Date.now();
|
|
248
|
+
let note;
|
|
249
|
+
if (dir === "in") {
|
|
250
|
+
this.framesIn++;
|
|
251
|
+
this.bytesIn += bytes.length;
|
|
252
|
+
const perSec = this.bytesWindow.add(now, bytes.length);
|
|
253
|
+
if (perSec > this.peakBytesInPerSec) this.peakBytesInPerSec = perSec;
|
|
254
|
+
if (perSec > this.options.thresholds.budgetBytesPerSec) {
|
|
255
|
+
this.violate(
|
|
256
|
+
"bandwidth",
|
|
257
|
+
`${perSec} B/s inbound over the ${this.options.thresholds.budgetBytesPerSec} B/s budget`,
|
|
258
|
+
true
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
} else {
|
|
262
|
+
this.framesOut++;
|
|
263
|
+
this.bytesOut += bytes.length;
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
note = this.inspect(dir, type, bytes, now);
|
|
267
|
+
} catch (err) {
|
|
268
|
+
note = err instanceof Error ? err.message : String(err);
|
|
269
|
+
this.violate("schema-validity", `${frameName(type)} did not decode: ${note}`);
|
|
270
|
+
}
|
|
271
|
+
this.ring.push({
|
|
272
|
+
at: now - this.options.startedAt,
|
|
273
|
+
bot: this.options.index,
|
|
274
|
+
dir,
|
|
275
|
+
type,
|
|
276
|
+
frame: frameName(type),
|
|
277
|
+
bytes: bytes.length,
|
|
278
|
+
...note !== void 0 ? { note } : {}
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
/** Decodes the payload independently. Throws on a bad frame; the caller counts that. */
|
|
282
|
+
inspect(dir, type, bytes, now) {
|
|
283
|
+
const payload = decodeFrame(bytes).payload;
|
|
284
|
+
if (dir === "out") {
|
|
285
|
+
if (type === FrameType3.CALL) this.calls++;
|
|
286
|
+
if (type === FrameType3.WRITE) this.recordWrites(decodeDelta2(this.options.ext, payload), now);
|
|
287
|
+
return void 0;
|
|
288
|
+
}
|
|
289
|
+
switch (type) {
|
|
290
|
+
case FrameType3.WELCOME:
|
|
291
|
+
return this.onWelcome(decodeWelcome(payload));
|
|
292
|
+
case FrameType3.DELTA:
|
|
293
|
+
return this.onDelta(decodeDelta2(this.options.ext, payload), now);
|
|
294
|
+
case FrameType3.CORRECT:
|
|
295
|
+
return this.onCorrect(decodeDelta2(this.options.ext, payload), now);
|
|
296
|
+
case FrameType3.ERROR:
|
|
297
|
+
return this.onError(payload);
|
|
298
|
+
case FrameType3.REPLY:
|
|
299
|
+
return this.onReply(payload);
|
|
300
|
+
default:
|
|
301
|
+
return void 0;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
onWelcome(welcome) {
|
|
305
|
+
this.id = welcome.clientId;
|
|
306
|
+
this.role = welcome.role;
|
|
307
|
+
this.roomId = welcome.roomId;
|
|
308
|
+
const snapshot = decodeSnapshot(this.options.ext, welcome.snapshot);
|
|
309
|
+
for (const leak of snapshotVisibilityLeaks(this.options.ext, this.role, snapshot.state)) {
|
|
310
|
+
this.violate("visibility-leak", leak);
|
|
311
|
+
}
|
|
312
|
+
return `joined ${welcome.roomId} as ${welcome.clientId}/${welcome.role || "default"}`;
|
|
313
|
+
}
|
|
314
|
+
onDelta(delta, now) {
|
|
315
|
+
for (const leak of deltaVisibilityLeaks(this.options.ext, this.role, delta)) {
|
|
316
|
+
this.violate("visibility-leak", leak);
|
|
317
|
+
}
|
|
318
|
+
this.matchWrites(delta, now);
|
|
319
|
+
return void 0;
|
|
320
|
+
}
|
|
321
|
+
onCorrect(delta, now) {
|
|
322
|
+
this.corrections++;
|
|
323
|
+
for (const leak of deltaVisibilityLeaks(this.options.ext, this.role, delta)) {
|
|
324
|
+
this.violate("visibility-leak", leak);
|
|
325
|
+
}
|
|
326
|
+
const perSec = this.correctWindow.add(now, 1);
|
|
327
|
+
if (perSec > this.peakCorrectionsPerSec) this.peakCorrectionsPerSec = perSec;
|
|
328
|
+
if (perSec > this.options.thresholds.correctionsPerSecMax) {
|
|
329
|
+
this.violate(
|
|
330
|
+
"correction-storm",
|
|
331
|
+
`${perSec} corrections/s over the ${this.options.thresholds.correctionsPerSecMax}/s threshold`,
|
|
332
|
+
true
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
return `corrected ${delta.collections.map((c) => c.name).join(",")}`;
|
|
336
|
+
}
|
|
337
|
+
onError(payload) {
|
|
338
|
+
const error = decodeErrorPayload(payload);
|
|
339
|
+
let name;
|
|
340
|
+
try {
|
|
341
|
+
name = errorByCode(error.code).name;
|
|
342
|
+
} catch {
|
|
343
|
+
name = `code ${error.code}`;
|
|
344
|
+
}
|
|
345
|
+
if (!error.fatal) {
|
|
346
|
+
this.errors++;
|
|
347
|
+
this.violate("handler-error", `${name}: ${error.message}`);
|
|
348
|
+
}
|
|
349
|
+
return `${name}${error.fatal ? " (fatal)" : ""}`;
|
|
350
|
+
}
|
|
351
|
+
onReply(payload) {
|
|
352
|
+
const reply = decodeReply(payload);
|
|
353
|
+
if (reply.ok) return void 0;
|
|
354
|
+
this.errors++;
|
|
355
|
+
this.violate("handler-error", `reply #${reply.reqId} rejected: ${reply.error}`);
|
|
356
|
+
return `rejected: ${reply.error}`;
|
|
357
|
+
}
|
|
358
|
+
// -- convergence lag ------------------------------------------------------
|
|
359
|
+
eachScalar(delta, fn) {
|
|
360
|
+
for (const collection of delta.collections) {
|
|
361
|
+
for (const op of collection.ops) {
|
|
362
|
+
const values = op.op === "add" ? op.value : op.op === "update" ? op.patch : void 0;
|
|
363
|
+
if (!values) continue;
|
|
364
|
+
for (const [field, value] of Object.entries(values)) fn(collection.name, op, field, value);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/** Our own `WRITE`: remember what we sent, so whoever sees it can date it. */
|
|
369
|
+
recordWrites(delta, now) {
|
|
370
|
+
this.eachScalar(delta, (collection, op, field, value) => {
|
|
371
|
+
const key = writeKey(collection, op.id, field, value);
|
|
372
|
+
if (key) this.options.writeLog.record(key, now, this.options.index);
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
/** Somebody else's write arriving here: that round trip is the convergence lag. */
|
|
376
|
+
matchWrites(delta, now) {
|
|
377
|
+
this.eachScalar(delta, (collection, op, field, value) => {
|
|
378
|
+
const key = writeKey(collection, op.id, field, value);
|
|
379
|
+
if (!key) return;
|
|
380
|
+
const sentAt = this.options.writeLog.match(key, this.options.index);
|
|
381
|
+
if (sentAt !== void 0) this.options.lags.push(now - sentAt);
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
// src/random.ts
|
|
387
|
+
var INT_RANGE = {
|
|
388
|
+
u8: [0, 255],
|
|
389
|
+
u16: [0, 65535],
|
|
390
|
+
u32: [0, 4294967295],
|
|
391
|
+
i32: [-2147483648, 2147483647]
|
|
392
|
+
};
|
|
393
|
+
function makeRng(seed) {
|
|
394
|
+
let state = seed >>> 0;
|
|
395
|
+
const next = () => {
|
|
396
|
+
state = state + 1831565813 >>> 0;
|
|
397
|
+
let t = state;
|
|
398
|
+
t = Math.imul(t ^ t >>> 15, t | 1);
|
|
399
|
+
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
|
400
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
401
|
+
};
|
|
402
|
+
return {
|
|
403
|
+
next,
|
|
404
|
+
int: (lo, hi) => hi <= lo ? lo : lo + Math.floor(next() * (hi - lo + 1)),
|
|
405
|
+
float: (lo, hi) => lo + next() * (hi - lo),
|
|
406
|
+
chance: (p) => next() < p,
|
|
407
|
+
pick: (items) => items.length === 0 ? void 0 : items[Math.floor(next() * items.length)]
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
var MEAN_REVERSION = 0.05;
|
|
411
|
+
var CHEAT_JUMP = 1e5;
|
|
412
|
+
function walk(rng, current, ctx) {
|
|
413
|
+
if (ctx.cheat) return current + (rng.chance(0.5) ? CHEAT_JUMP : -CHEAT_JUMP);
|
|
414
|
+
const pull = ctx.origin === void 0 ? 0 : (ctx.origin - current) * MEAN_REVERSION;
|
|
415
|
+
return current + rng.float(-ctx.step, ctx.step) + pull;
|
|
416
|
+
}
|
|
417
|
+
function clampInt(v, lo, hi) {
|
|
418
|
+
return Math.min(hi, Math.max(lo, Math.round(v)));
|
|
419
|
+
}
|
|
420
|
+
var ALPHABET = "abcdefghijklmnopqrstuvwxyz";
|
|
421
|
+
function token(rng, max) {
|
|
422
|
+
const n = Math.min(max, rng.int(1, 8));
|
|
423
|
+
let s = "";
|
|
424
|
+
for (let i = 0; i < n; i++) s += ALPHABET[rng.int(0, ALPHABET.length - 1)] ?? "x";
|
|
425
|
+
return s;
|
|
426
|
+
}
|
|
427
|
+
function nextValue(rng, desc, current, ctx) {
|
|
428
|
+
if (desc.opt && rng.chance(0.1)) return void 0;
|
|
429
|
+
switch (desc.kind) {
|
|
430
|
+
case "bool":
|
|
431
|
+
return typeof current === "boolean" ? !current : rng.chance(0.5);
|
|
432
|
+
case "u8":
|
|
433
|
+
case "u16":
|
|
434
|
+
case "u32":
|
|
435
|
+
case "i32": {
|
|
436
|
+
const [lo, hi] = INT_RANGE[desc.kind];
|
|
437
|
+
if (ctx.cheat) return rng.chance(0.5) ? lo : hi;
|
|
438
|
+
const base = typeof current === "number" ? current : 0;
|
|
439
|
+
return clampInt(walk(rng, base, ctx), lo, hi);
|
|
440
|
+
}
|
|
441
|
+
case "f32":
|
|
442
|
+
case "f64": {
|
|
443
|
+
const base = typeof current === "number" && Number.isFinite(current) ? current : 0;
|
|
444
|
+
return walk(rng, base, ctx);
|
|
445
|
+
}
|
|
446
|
+
case "str":
|
|
447
|
+
return desc.max === 0 ? "" : token(rng, desc.max);
|
|
448
|
+
case "enum":
|
|
449
|
+
return rng.pick(desc.values) ?? desc.values[0] ?? "";
|
|
450
|
+
case "ref":
|
|
451
|
+
return rng.pick(ctx.refIds ?? []) ?? "";
|
|
452
|
+
case "list": {
|
|
453
|
+
const n = Math.min(desc.max, rng.int(0, 3));
|
|
454
|
+
const out = [];
|
|
455
|
+
for (let i = 0; i < n; i++) out.push(nextValue(rng, desc.item, void 0, ctx));
|
|
456
|
+
return out;
|
|
457
|
+
}
|
|
458
|
+
case "struct": {
|
|
459
|
+
const out = {};
|
|
460
|
+
const from = typeof current === "object" && current !== null ? current : {};
|
|
461
|
+
for (const [name, field] of Object.entries(desc.fields)) {
|
|
462
|
+
out[name] = nextValue(rng, field, from[name], ctx);
|
|
463
|
+
}
|
|
464
|
+
return out;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
function freshValue(rng, desc, ctx) {
|
|
469
|
+
return nextValue(rng, desc, void 0, { ...ctx, cheat: false, origin: void 0 });
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// src/report.ts
|
|
473
|
+
function percentile(sorted, p) {
|
|
474
|
+
if (sorted.length === 0) return 0;
|
|
475
|
+
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(p / 100 * sorted.length) - 1));
|
|
476
|
+
return sorted[index] ?? 0;
|
|
477
|
+
}
|
|
478
|
+
function convergenceStats(lags) {
|
|
479
|
+
if (lags.length === 0) return void 0;
|
|
480
|
+
const sorted = [...lags].sort((a, b) => a - b);
|
|
481
|
+
return {
|
|
482
|
+
samples: sorted.length,
|
|
483
|
+
p50Ms: percentile(sorted, 50),
|
|
484
|
+
p95Ms: percentile(sorted, 95),
|
|
485
|
+
maxMs: sorted[sorted.length - 1] ?? 0
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
function round(value, places = 1) {
|
|
489
|
+
const factor = 10 ** places;
|
|
490
|
+
return Math.round(value * factor) / factor;
|
|
491
|
+
}
|
|
492
|
+
function examples(observers, name) {
|
|
493
|
+
const lines = observers.flatMap((o) => o.violations.get(name) ?? []).slice(0, 5);
|
|
494
|
+
return lines.length === 0 ? "" : `; ${lines.join("; ")}`;
|
|
495
|
+
}
|
|
496
|
+
function total(observers, name) {
|
|
497
|
+
return observers.reduce((sum, o) => sum + (o.counts.get(name) ?? 0), 0);
|
|
498
|
+
}
|
|
499
|
+
function peak(observers, pick) {
|
|
500
|
+
return observers.reduce((max, o) => Math.max(max, pick(o)), 0);
|
|
501
|
+
}
|
|
502
|
+
function detailFor(name, observers, violations, thresholds) {
|
|
503
|
+
switch (name) {
|
|
504
|
+
case "schema-validity":
|
|
505
|
+
return violations === 0 ? "every snapshot and delta decoded" : `${violations} frame(s) failed to decode${examples(observers, name)}`;
|
|
506
|
+
case "visibility-leak":
|
|
507
|
+
return violations === 0 ? "no collection reached a role that cannot see it" : `${violations} leak(s)${examples(observers, name)}`;
|
|
508
|
+
case "bandwidth": {
|
|
509
|
+
const worst = peak(observers, (o) => o.peakBytesInPerSec);
|
|
510
|
+
return `peak ${worst} B/s in per bot, budget ${thresholds.budgetBytesPerSec} B/s${violations === 0 ? "" : examples(observers, name)}`;
|
|
511
|
+
}
|
|
512
|
+
case "handler-error": {
|
|
513
|
+
const errors = observers.reduce((sum, o) => sum + o.errors, 0);
|
|
514
|
+
return `${errors} error reply/frame(s), tolerated ${thresholds.handlerErrorsMax}${violations === 0 ? "" : examples(observers, name)}`;
|
|
515
|
+
}
|
|
516
|
+
case "correction-storm": {
|
|
517
|
+
const corrections = observers.reduce((sum, o) => sum + o.corrections, 0);
|
|
518
|
+
const worst = peak(observers, (o) => o.peakCorrectionsPerSec);
|
|
519
|
+
return `${corrections} correction(s), peak ${worst}/s per bot, threshold ${thresholds.correctionsPerSecMax}/s${violations === 0 ? "" : examples(observers, name)}`;
|
|
520
|
+
}
|
|
521
|
+
case "disconnects": {
|
|
522
|
+
const count = observers.reduce((sum, o) => sum + o.disconnects, 0);
|
|
523
|
+
return count === 0 ? "every bot stayed connected" : `${count} disconnect(s)${examples(observers, name)}`;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
function buildReport(options) {
|
|
528
|
+
const { observers, roomId, lags } = options;
|
|
529
|
+
const thresholds = options.thresholds ?? DEFAULT_THRESHOLDS;
|
|
530
|
+
const durationMs = Math.max(1, options.durationMs);
|
|
531
|
+
const seconds = durationMs / 1e3;
|
|
532
|
+
const invariants = INVARIANT_NAMES.map((name) => {
|
|
533
|
+
const violations = total(observers, name);
|
|
534
|
+
const ok = name === "handler-error" ? violations <= thresholds.handlerErrorsMax : violations === 0;
|
|
535
|
+
return { name, ok, violations, detail: detailFor(name, observers, violations, thresholds) };
|
|
536
|
+
});
|
|
537
|
+
const perBot = observers.map((o) => o.stats);
|
|
538
|
+
const totals = {
|
|
539
|
+
framesIn: perBot.reduce((sum, b) => sum + b.framesIn, 0),
|
|
540
|
+
framesOut: perBot.reduce((sum, b) => sum + b.framesOut, 0),
|
|
541
|
+
bytesIn: perBot.reduce((sum, b) => sum + b.bytesIn, 0),
|
|
542
|
+
bytesOut: perBot.reduce((sum, b) => sum + b.bytesOut, 0),
|
|
543
|
+
corrections: perBot.reduce((sum, b) => sum + b.corrections, 0),
|
|
544
|
+
calls: perBot.reduce((sum, b) => sum + b.calls, 0),
|
|
545
|
+
errors: perBot.reduce((sum, b) => sum + b.errors, 0)
|
|
546
|
+
};
|
|
547
|
+
const bots = Math.max(1, perBot.length);
|
|
548
|
+
const convergence = convergenceStats(lags);
|
|
549
|
+
return {
|
|
550
|
+
ok: invariants.every((i) => i.ok),
|
|
551
|
+
bots: perBot.length,
|
|
552
|
+
roomId,
|
|
553
|
+
durationMs,
|
|
554
|
+
invariants,
|
|
555
|
+
perBot,
|
|
556
|
+
totals,
|
|
557
|
+
framesPerSecond: round((totals.framesIn + totals.framesOut) / seconds),
|
|
558
|
+
bytesInPerSecondPerBot: round(totals.bytesIn / seconds / bots),
|
|
559
|
+
bytesOutPerSecondPerBot: round(totals.bytesOut / seconds / bots),
|
|
560
|
+
convergence,
|
|
561
|
+
convergenceLagMs: convergence?.p50Ms,
|
|
562
|
+
tracePath: options.tracePath
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// src/script.ts
|
|
567
|
+
import { rpcTable } from "@irtio/protocol";
|
|
568
|
+
function ownableCollections(schema) {
|
|
569
|
+
return schema.collections.filter(
|
|
570
|
+
(c) => c.kind === "entity" && !c.serverOwned
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
function voidServerRpcs(schema) {
|
|
574
|
+
return rpcTable(schema).filter((r) => r.direction === "server" && r.returns === void 0);
|
|
575
|
+
}
|
|
576
|
+
function asCollection(value) {
|
|
577
|
+
return value && typeof value.ownerOf === "function" ? value : void 0;
|
|
578
|
+
}
|
|
579
|
+
function randomScript(schema, options = {}) {
|
|
580
|
+
const cheat = options.cheat ?? false;
|
|
581
|
+
const stepMs = options.stepMs ?? 50;
|
|
582
|
+
const step = options.step ?? 8;
|
|
583
|
+
const rpcChance = options.rpcChance ?? 0.05;
|
|
584
|
+
const fieldsPerStep = options.fieldsPerStep ?? 2;
|
|
585
|
+
const collections = ownableCollections(schema);
|
|
586
|
+
const rpcs = voidServerRpcs(schema);
|
|
587
|
+
return async (bot) => {
|
|
588
|
+
const room = bot.room;
|
|
589
|
+
const origins = /* @__PURE__ */ new Map();
|
|
590
|
+
while (!bot.stopped) {
|
|
591
|
+
for (const desc of collections) {
|
|
592
|
+
const collection = asCollection(room.state[desc.name]);
|
|
593
|
+
if (!collection) continue;
|
|
594
|
+
for (const id of [...collection.ids()]) {
|
|
595
|
+
if (collection.ownerOf(id) !== room.me) continue;
|
|
596
|
+
const instance = collection.get(id);
|
|
597
|
+
if (!instance) continue;
|
|
598
|
+
writeFields(bot, desc, id, instance, origins);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
if (rpcs.length > 0 && bot.rng.chance(rpcChance)) await callRandomRpc(bot, room.call, rpcs);
|
|
602
|
+
await bot.wait(stepMs);
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
function writeFields(bot, desc, id, instance, origins) {
|
|
606
|
+
const count = Math.min(fieldsPerStep, desc.fields.length);
|
|
607
|
+
for (let i = 0; i < count; i++) {
|
|
608
|
+
const field = bot.rng.pick(desc.fields);
|
|
609
|
+
if (!field) continue;
|
|
610
|
+
const current = instance[field.name];
|
|
611
|
+
const key = `${desc.name} ${id} ${field.name}`;
|
|
612
|
+
if (typeof current === "number" && !origins.has(key)) origins.set(key, current);
|
|
613
|
+
const value = nextValue(bot.rng, field.type, current, {
|
|
614
|
+
step,
|
|
615
|
+
cheat,
|
|
616
|
+
origin: origins.get(key)
|
|
617
|
+
});
|
|
618
|
+
instance[field.name] = value;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
async function callRandomRpc(bot, call, table) {
|
|
622
|
+
const rpc = bot.rng.pick(table);
|
|
623
|
+
const fn = rpc ? call[rpc.name] : void 0;
|
|
624
|
+
if (!rpc || !fn) return;
|
|
625
|
+
const params = {};
|
|
626
|
+
for (const param of rpc.params) {
|
|
627
|
+
params[param.name] = freshValue(bot.rng, param.type, { step, cheat: false });
|
|
628
|
+
}
|
|
629
|
+
await fn(params).catch(() => void 0);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
function relayEchoScript(options = {}) {
|
|
633
|
+
const everyMs = options.everyMs ?? 200;
|
|
634
|
+
const size = options.bytes ?? 16;
|
|
635
|
+
return async (bot) => {
|
|
636
|
+
while (!bot.stopped) {
|
|
637
|
+
const payload = new Uint8Array(size);
|
|
638
|
+
for (let i = 0; i < size; i++) payload[i] = bot.rng.int(0, 255);
|
|
639
|
+
payload[0] = bot.index & 255;
|
|
640
|
+
bot.room.message("all", payload);
|
|
641
|
+
await bot.wait(everyMs);
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// src/spawn.ts
|
|
647
|
+
import {
|
|
648
|
+
joinRelay,
|
|
649
|
+
joinRoom
|
|
650
|
+
} from "@irtio/client";
|
|
651
|
+
import { relaySchema, withBuiltins } from "@irtio/protocol";
|
|
652
|
+
var DEFAULT_SEED = 96016;
|
|
653
|
+
var DEFAULT_TRACE_LIMIT = 4096;
|
|
654
|
+
function per(value, index) {
|
|
655
|
+
if (value === void 0) return void 0;
|
|
656
|
+
return typeof value === "function" ? value(index) : value;
|
|
657
|
+
}
|
|
658
|
+
var BotImpl = class {
|
|
659
|
+
constructor(index, room, observer, seed, startedAt) {
|
|
660
|
+
this.index = index;
|
|
661
|
+
this.room = room;
|
|
662
|
+
this.observer = observer;
|
|
663
|
+
this.startedAt = startedAt;
|
|
664
|
+
this.rng = makeRng(seed);
|
|
665
|
+
}
|
|
666
|
+
index;
|
|
667
|
+
room;
|
|
668
|
+
observer;
|
|
669
|
+
startedAt;
|
|
670
|
+
stopped = false;
|
|
671
|
+
rng;
|
|
672
|
+
stopListeners = /* @__PURE__ */ new Set();
|
|
673
|
+
get id() {
|
|
674
|
+
return this.observer.id;
|
|
675
|
+
}
|
|
676
|
+
get role() {
|
|
677
|
+
return this.observer.role;
|
|
678
|
+
}
|
|
679
|
+
random() {
|
|
680
|
+
return this.rng.next();
|
|
681
|
+
}
|
|
682
|
+
get trace() {
|
|
683
|
+
return makeTrace(this.startedAt, () => [this.observer.ring]);
|
|
684
|
+
}
|
|
685
|
+
get stats() {
|
|
686
|
+
return this.observer.stats;
|
|
687
|
+
}
|
|
688
|
+
stop() {
|
|
689
|
+
if (this.stopped) return;
|
|
690
|
+
this.stopped = true;
|
|
691
|
+
for (const listener of [...this.stopListeners]) listener();
|
|
692
|
+
this.stopListeners.clear();
|
|
693
|
+
}
|
|
694
|
+
onStop(listener) {
|
|
695
|
+
this.stopListeners.add(listener);
|
|
696
|
+
return () => {
|
|
697
|
+
this.stopListeners.delete(listener);
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
wait(ms) {
|
|
701
|
+
if (this.stopped) return Promise.resolve();
|
|
702
|
+
return new Promise((resolve) => {
|
|
703
|
+
const timer = setTimeout(finish, ms);
|
|
704
|
+
timer.unref?.();
|
|
705
|
+
const off = this.onStop(finish);
|
|
706
|
+
function finish() {
|
|
707
|
+
clearTimeout(timer);
|
|
708
|
+
off();
|
|
709
|
+
resolve();
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
async until(predicate, options = {}) {
|
|
714
|
+
const label = options.label ?? "a condition";
|
|
715
|
+
const everyMs = options.everyMs ?? 10;
|
|
716
|
+
const deadline = Date.now() + (options.timeoutMs ?? 1e4);
|
|
717
|
+
for (; ; ) {
|
|
718
|
+
if (this.stopped) return;
|
|
719
|
+
if (await predicate()) return;
|
|
720
|
+
if (Date.now() > deadline) {
|
|
721
|
+
throw new Error(`irtio bots: bot ${this.index} timed out waiting for ${label}`);
|
|
722
|
+
}
|
|
723
|
+
await this.wait(everyMs);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
async function spawnBots(n, options = {}) {
|
|
728
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
729
|
+
throw new Error(`irtio bots: spawnBots needs at least one bot, got ${String(n)}`);
|
|
730
|
+
}
|
|
731
|
+
const startedAt = Date.now();
|
|
732
|
+
const thresholds = {
|
|
733
|
+
budgetBytesPerSec: options.budgetBytesPerSec ?? DEFAULT_THRESHOLDS.budgetBytesPerSec,
|
|
734
|
+
correctionsPerSecMax: options.correctionsPerSecMax ?? DEFAULT_THRESHOLDS.correctionsPerSecMax,
|
|
735
|
+
handlerErrorsMax: options.handlerErrorsMax ?? DEFAULT_THRESHOLDS.handlerErrorsMax
|
|
736
|
+
};
|
|
737
|
+
const ext = options.schema ? withBuiltins(options.schema) : relaySchema;
|
|
738
|
+
const seed = options.seed ?? DEFAULT_SEED;
|
|
739
|
+
const traceLimit = options.traceLimit ?? DEFAULT_TRACE_LIMIT;
|
|
740
|
+
const writeLog = new WriteLog();
|
|
741
|
+
const lags = [];
|
|
742
|
+
const observers = [];
|
|
743
|
+
const bots = [];
|
|
744
|
+
async function joinOne(index, roomId2) {
|
|
745
|
+
const observer = new BotObserver({
|
|
746
|
+
index,
|
|
747
|
+
ext,
|
|
748
|
+
startedAt,
|
|
749
|
+
traceLimit,
|
|
750
|
+
thresholds,
|
|
751
|
+
writeLog,
|
|
752
|
+
lags
|
|
753
|
+
});
|
|
754
|
+
const role = per(options.role, index);
|
|
755
|
+
const name = per(options.name, index);
|
|
756
|
+
const common = {
|
|
757
|
+
room: roomId2,
|
|
758
|
+
...options.url !== void 0 ? { url: options.url } : {},
|
|
759
|
+
...options.key !== void 0 ? { key: options.key } : {},
|
|
760
|
+
...role !== void 0 ? { role } : {},
|
|
761
|
+
...name !== void 0 ? { name } : {},
|
|
762
|
+
...options.transport !== void 0 ? { transport: options.transport } : {},
|
|
763
|
+
onFrame: (dir, type, bytes) => observer.onFrame(dir, type, bytes),
|
|
764
|
+
onStatus: (status) => observer.onStatus(status)
|
|
765
|
+
};
|
|
766
|
+
const room = options.schema ? await joinRoom(options.schema, {
|
|
767
|
+
...common,
|
|
768
|
+
...options.flushMs !== void 0 ? { writeIntervalMs: options.flushMs } : {},
|
|
769
|
+
...options.rpc !== void 0 ? { rpc: options.rpc } : {}
|
|
770
|
+
}) : await joinRelay(common);
|
|
771
|
+
observers[index] = observer;
|
|
772
|
+
return new BotImpl(index, room, observer, seed + index, startedAt);
|
|
773
|
+
}
|
|
774
|
+
const first = await joinOne(0, options.room ?? "");
|
|
775
|
+
bots.push(first);
|
|
776
|
+
const roomId = first.room.id;
|
|
777
|
+
if (n > 1) {
|
|
778
|
+
const rest = await Promise.all(Array.from({ length: n - 1 }, (_, i) => joinOne(i + 1, roomId)));
|
|
779
|
+
bots.push(...rest);
|
|
780
|
+
}
|
|
781
|
+
const scriptErrors = [];
|
|
782
|
+
let firstError;
|
|
783
|
+
const script = options.script;
|
|
784
|
+
const scripts = bots.map(async (bot) => {
|
|
785
|
+
if (!script) {
|
|
786
|
+
await bot.until(() => bot.stopped, { label: "the run to end", timeoutMs: 24 * 36e5 });
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
try {
|
|
790
|
+
await script(bot);
|
|
791
|
+
} catch (error) {
|
|
792
|
+
scriptErrors.push({ bot: bot.index, error });
|
|
793
|
+
firstError ??= error;
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
const finished = Promise.all(scripts).then(() => void 0);
|
|
797
|
+
let deadline;
|
|
798
|
+
if (options.durationMs !== void 0) {
|
|
799
|
+
deadline = setTimeout(() => {
|
|
800
|
+
for (const bot of bots) bot.stop();
|
|
801
|
+
}, options.durationMs);
|
|
802
|
+
deadline.unref?.();
|
|
803
|
+
}
|
|
804
|
+
let stoppedAt;
|
|
805
|
+
const rings = () => observers.map((o) => o.ring);
|
|
806
|
+
const runner = {
|
|
807
|
+
bots,
|
|
808
|
+
roomId,
|
|
809
|
+
trace: makeTrace(startedAt, rings),
|
|
810
|
+
scriptErrors,
|
|
811
|
+
[Symbol.iterator]: () => bots[Symbol.iterator](),
|
|
812
|
+
async done() {
|
|
813
|
+
await finished;
|
|
814
|
+
if (firstError !== void 0) throw firstError;
|
|
815
|
+
},
|
|
816
|
+
report() {
|
|
817
|
+
return buildReport({
|
|
818
|
+
observers,
|
|
819
|
+
roomId,
|
|
820
|
+
durationMs: (stoppedAt ?? Date.now()) - startedAt,
|
|
821
|
+
lags,
|
|
822
|
+
thresholds
|
|
823
|
+
});
|
|
824
|
+
},
|
|
825
|
+
async stop() {
|
|
826
|
+
if (deadline) clearTimeout(deadline);
|
|
827
|
+
for (const bot of bots) bot.stop();
|
|
828
|
+
await finished;
|
|
829
|
+
stoppedAt ??= Date.now();
|
|
830
|
+
for (const observer of observers) observer.stopping = true;
|
|
831
|
+
for (const bot of bots) bot.room.leave();
|
|
832
|
+
return this.report();
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
return runner;
|
|
836
|
+
}
|
|
837
|
+
export {
|
|
838
|
+
BotObserver,
|
|
839
|
+
DEFAULT_SEED,
|
|
840
|
+
DEFAULT_THRESHOLDS,
|
|
841
|
+
DEFAULT_TRACE_LIMIT,
|
|
842
|
+
INVARIANT_NAMES,
|
|
843
|
+
TraceRing,
|
|
844
|
+
WriteLog,
|
|
845
|
+
buildReport,
|
|
846
|
+
convergenceStats,
|
|
847
|
+
deltaVisibilityLeaks,
|
|
848
|
+
frameName,
|
|
849
|
+
frameVisibilityLeaks,
|
|
850
|
+
freshValue,
|
|
851
|
+
makeRng,
|
|
852
|
+
makeTrace,
|
|
853
|
+
nextValue,
|
|
854
|
+
randomScript,
|
|
855
|
+
relayEchoScript,
|
|
856
|
+
snapshotVisibilityLeaks,
|
|
857
|
+
spawnBots
|
|
858
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@irtio/bots",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "irtio bot runtime: N real clients, scripted behaviours, trace recorder, built-in invariants",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"main": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist"
|
|
20
|
+
],
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@irtio/client": "0.1.0",
|
|
23
|
+
"@irtio/protocol": "0.1.0",
|
|
24
|
+
"@irtio/schema": "0.1.0",
|
|
25
|
+
"@irtio/runtime": "0.1.0"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsup",
|
|
29
|
+
"test": "vitest run"
|
|
30
|
+
}
|
|
31
|
+
}
|