@driftengine/network 3.61.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 +202 -0
- package/NOTICE +9 -0
- package/README.md +125 -0
- package/dist/authority.d.ts +170 -0
- package/dist/authority.js +235 -0
- package/dist/fingerprint.d.ts +64 -0
- package/dist/fingerprint.js +108 -0
- package/dist/fixture.d.ts +32 -0
- package/dist/fixture.js +136 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +32 -0
- package/dist/inputLog.d.ts +117 -0
- package/dist/inputLog.js +232 -0
- package/dist/lockstep.d.ts +181 -0
- package/dist/lockstep.js +291 -0
- package/dist/loopback.d.ts +115 -0
- package/dist/loopback.js +207 -0
- package/dist/rewind.d.ts +206 -0
- package/dist/rewind.js +212 -0
- package/dist/scriptSession.d.ts +77 -0
- package/dist/scriptSession.js +81 -0
- package/dist/simNumber.d.ts +103 -0
- package/dist/simNumber.js +156 -0
- package/dist/snapshotter.d.ts +43 -0
- package/dist/snapshotter.js +81 -0
- package/dist/transport.d.ts +63 -0
- package/dist/transport.js +40 -0
- package/dist/webrtc.d.ts +74 -0
- package/dist/webrtc.js +108 -0
- package/dist/websocket.d.ts +70 -0
- package/dist/websocket.js +105 -0
- package/dist/wire.d.ts +94 -0
- package/dist/wire.js +238 -0
- package/package.json +61 -0
- package/src/authority.ts +340 -0
- package/src/fingerprint.ts +143 -0
- package/src/fixture.ts +188 -0
- package/src/index.ts +95 -0
- package/src/inputLog.ts +262 -0
- package/src/lockstep.ts +374 -0
- package/src/loopback.ts +250 -0
- package/src/rewind.ts +337 -0
- package/src/scriptSession.ts +137 -0
- package/src/simNumber.ts +176 -0
- package/src/snapshotter.ts +98 -0
- package/src/transport.ts +69 -0
- package/src/webrtc.ts +141 -0
- package/src/websocket.ts +140 -0
- package/src/wire.ts +269 -0
package/src/lockstep.ts
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every peer simulates every tick, and a wrong guess is unwound.
|
|
3
|
+
*
|
|
4
|
+
* **Classic lockstep stalls; this predicts.** The original design waits until every participant's
|
|
5
|
+
* input for a tick has arrived and only then advances, which makes the whole session run at the
|
|
6
|
+
* latency of its worst link and turns a single dropped packet into a visible freeze for everyone.
|
|
7
|
+
* With a rewind loop underneath, a missing input can be guessed and corrected — so the session runs
|
|
8
|
+
* at local speed and pays for a wrong guess only when the guess was wrong.
|
|
9
|
+
*
|
|
10
|
+
* ---
|
|
11
|
+
*
|
|
12
|
+
* ## The input delay, and why it is not just latency tolerance
|
|
13
|
+
*
|
|
14
|
+
* A local input for tick *T* is published for tick *T + delay*. That buys `delay` ticks for it to
|
|
15
|
+
* reach everyone before anybody needs it, so on a link inside that budget **nobody predicts and
|
|
16
|
+
* nobody rewinds** — the session is exact, not merely convergent.
|
|
17
|
+
*
|
|
18
|
+
* What it costs is that a player's own control is `delay` ticks late on their own screen. That is a
|
|
19
|
+
* real cost and it is a game's to choose: two ticks is 33 ms and unnoticeable in a racing game,
|
|
20
|
+
* and unacceptable in a fighting game where it is the whole argument for rollback.
|
|
21
|
+
*
|
|
22
|
+
* ## Halting is a feature
|
|
23
|
+
*
|
|
24
|
+
* Two things stop the session rather than degrade it, because both mean the local world can no
|
|
25
|
+
* longer be made correct:
|
|
26
|
+
*
|
|
27
|
+
* - **An input arrives for a tick outside the rewind window.** It cannot be applied, so this peer's
|
|
28
|
+
* world is permanently different from the one that input describes.
|
|
29
|
+
* - **A fingerprint disagrees at a confirmed tick.** Two peers computed different worlds from the
|
|
30
|
+
* same inputs, which is a determinism bug rather than a networking one, and continuing produces
|
|
31
|
+
* two games that both believe they are right.
|
|
32
|
+
*
|
|
33
|
+
* A session that carried on would be showing each player a plausible world with no relationship to
|
|
34
|
+
* anyone else's. Halting names the tick, which is where the debugging starts.
|
|
35
|
+
*/
|
|
36
|
+
import { Fingerprint } from './fingerprint.ts';
|
|
37
|
+
import type { InputLog } from './inputLog.ts';
|
|
38
|
+
import type { RewindLoop } from './rewind.ts';
|
|
39
|
+
import { BROADCAST, type PeerId, type Transport } from './transport.ts';
|
|
40
|
+
import {
|
|
41
|
+
MESSAGE_FINGERPRINT,
|
|
42
|
+
MESSAGE_INPUT,
|
|
43
|
+
createDecoded,
|
|
44
|
+
decode,
|
|
45
|
+
encodeFingerprint,
|
|
46
|
+
encodeInput,
|
|
47
|
+
} from './wire.ts';
|
|
48
|
+
|
|
49
|
+
export type LockstepStatus = 'running' | 'halted';
|
|
50
|
+
|
|
51
|
+
export interface LockstepOptions<S> {
|
|
52
|
+
readonly transport: Transport;
|
|
53
|
+
readonly loop: RewindLoop<S>;
|
|
54
|
+
readonly inputs: InputLog;
|
|
55
|
+
/** This peer's id, and the index its inputs occupy in the log. */
|
|
56
|
+
readonly self: PeerId;
|
|
57
|
+
readonly participants: number;
|
|
58
|
+
/**
|
|
59
|
+
* How many ticks ahead a local input is published. Two is 33 ms at 60 Hz.
|
|
60
|
+
*
|
|
61
|
+
* See the header: inside this budget the session is exact rather than convergent.
|
|
62
|
+
*/
|
|
63
|
+
readonly inputDelay?: number;
|
|
64
|
+
/**
|
|
65
|
+
* How often a fingerprint is published, in ticks. Every tick is wasteful and every hundred is
|
|
66
|
+
* slow to notice; the default is every eight, which at 60 Hz is seven a second.
|
|
67
|
+
*/
|
|
68
|
+
readonly fingerprintEvery?: number;
|
|
69
|
+
/** Publish fingerprints at all. Off needs no digest from the snapshotter. */
|
|
70
|
+
readonly compareStates?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* How many recent inputs ride along in every packet. Four by default.
|
|
73
|
+
*
|
|
74
|
+
* **This is what makes lockstep survive loss, and one is not enough.** An input is only useful
|
|
75
|
+
* for the tick it names, so asking for a lost one and waiting a round trip delivers it after that
|
|
76
|
+
* tick has gone — the peer's world is then permanently different and nothing local repairs it.
|
|
77
|
+
* Sending the last few in every packet covers a run of drops before anybody notices, for a
|
|
78
|
+
* handful of bytes on a message that already has a header.
|
|
79
|
+
*
|
|
80
|
+
* Four covers three consecutive losses. A link losing four in a row at 60 Hz has stopped being a
|
|
81
|
+
* link.
|
|
82
|
+
*
|
|
83
|
+
* **It may reach further back than the rewind window without any arrangement between the two.**
|
|
84
|
+
* The session forgets inputs below the oldest tick a rewind can reach, so past that depth every
|
|
85
|
+
* packet's oldest words name ticks the log can no longer say anything about. Those are skipped on
|
|
86
|
+
* the strength of what this world already applied, not on what the log still holds — see the
|
|
87
|
+
* watermark in `receive`. Choose this number for the link and the rewind depth for the game.
|
|
88
|
+
*/
|
|
89
|
+
readonly redundancy?: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface Desync {
|
|
93
|
+
readonly tick: number;
|
|
94
|
+
readonly peer: PeerId;
|
|
95
|
+
readonly ours: string;
|
|
96
|
+
readonly theirs: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export class LockstepSession<S> {
|
|
100
|
+
readonly self: PeerId;
|
|
101
|
+
readonly participants: number;
|
|
102
|
+
readonly inputDelay: number;
|
|
103
|
+
|
|
104
|
+
private readonly transport: Transport;
|
|
105
|
+
private readonly loop: RewindLoop<S>;
|
|
106
|
+
private readonly inputs: InputLog;
|
|
107
|
+
private readonly fingerprintEvery: number;
|
|
108
|
+
private readonly compareStates: boolean;
|
|
109
|
+
|
|
110
|
+
/** One buffer for the process. Sized for the largest message this session sends. */
|
|
111
|
+
private readonly outgoing: Uint8Array;
|
|
112
|
+
/** The last `redundancy` inputs this peer submitted, oldest first, flat. */
|
|
113
|
+
private readonly recent: Uint8Array;
|
|
114
|
+
private readonly redundancy: number;
|
|
115
|
+
/** The tick the first payload in `recent` belongs to, or -1 before anything is submitted. */
|
|
116
|
+
private recentFrom = -1;
|
|
117
|
+
private recentCount = 0;
|
|
118
|
+
private readonly decoded = createDecoded();
|
|
119
|
+
private readonly scratchDigest = new Fingerprint();
|
|
120
|
+
|
|
121
|
+
/** Digests we published, by tick, so a peer's claim has something to be compared against. */
|
|
122
|
+
private readonly ourDigests = new Map<number, string>();
|
|
123
|
+
|
|
124
|
+
private statusValue: LockstepStatus = 'running';
|
|
125
|
+
private desyncValue: Desync | null = null;
|
|
126
|
+
private haltReason = '';
|
|
127
|
+
private confirmedValue = -1;
|
|
128
|
+
/**
|
|
129
|
+
* The highest tick this world has ever had every participant's real input for.
|
|
130
|
+
*
|
|
131
|
+
* **Separate from `confirmedValue`, and monotonic where that one is not.** `confirmedThrough`
|
|
132
|
+
* answers for the window the log currently holds, and the session moves that window forward every
|
|
133
|
+
* tick — so the moment retention passes a tick, the log stops being able to say anything about it
|
|
134
|
+
* and the live watermark can drop. What was applied stays applied, which is the fact a redundant
|
|
135
|
+
* copy has to be judged against, so it is kept here rather than asked of the log.
|
|
136
|
+
*/
|
|
137
|
+
private appliedThrough = -1;
|
|
138
|
+
|
|
139
|
+
constructor(options: LockstepOptions<S>) {
|
|
140
|
+
this.transport = options.transport;
|
|
141
|
+
this.loop = options.loop;
|
|
142
|
+
this.inputs = options.inputs;
|
|
143
|
+
this.self = options.self;
|
|
144
|
+
this.participants = Math.max(1, options.participants);
|
|
145
|
+
this.inputDelay = Math.max(0, Math.trunc(options.inputDelay ?? 2));
|
|
146
|
+
this.fingerprintEvery = Math.max(1, Math.trunc(options.fingerprintEvery ?? 8));
|
|
147
|
+
this.compareStates = options.compareStates ?? true;
|
|
148
|
+
this.redundancy = Math.max(1, Math.trunc(options.redundancy ?? 4));
|
|
149
|
+
this.recent = new Uint8Array(this.redundancy * this.inputs.inputBytes);
|
|
150
|
+
this.outgoing = new Uint8Array(64 + this.redundancy * this.inputs.inputBytes);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
get status(): LockstepStatus {
|
|
154
|
+
return this.statusValue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Why the session halted, or an empty string. */
|
|
158
|
+
get reason(): string {
|
|
159
|
+
return this.haltReason;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The disagreement that halted it, naming the tick it began at. */
|
|
163
|
+
get desync(): Desync | null {
|
|
164
|
+
return this.desyncValue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The highest tick every participant's real input has arrived for. */
|
|
168
|
+
get confirmed(): number {
|
|
169
|
+
return this.confirmedValue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Publish this peer's input for `tick + inputDelay`, and record it locally at the same tick.
|
|
174
|
+
*
|
|
175
|
+
* **Recorded locally as well as sent, and that is not redundancy.** A peer's own input is not
|
|
176
|
+
* echoed back to it, so without this the local simulation would predict its own controls — and a
|
|
177
|
+
* player would feel their own input arrive late and get corrected, which is the one thing
|
|
178
|
+
* prediction is supposed to prevent.
|
|
179
|
+
*/
|
|
180
|
+
submit(tick: number, payload: Uint8Array): void {
|
|
181
|
+
if (this.statusValue !== 'running') return;
|
|
182
|
+
const at = tick + this.inputDelay;
|
|
183
|
+
|
|
184
|
+
if (!this.inputs.holds(at)) {
|
|
185
|
+
this.halt(`an input for tick ${at} is outside the log's window`);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
this.inputs.set(this.self, at, payload);
|
|
189
|
+
this.remember(at, payload);
|
|
190
|
+
|
|
191
|
+
const length = encodeInput(
|
|
192
|
+
this.outgoing,
|
|
193
|
+
this.self,
|
|
194
|
+
this.recentFrom,
|
|
195
|
+
this.recent,
|
|
196
|
+
this.inputs.inputBytes,
|
|
197
|
+
this.recentCount,
|
|
198
|
+
);
|
|
199
|
+
if (length < 0) {
|
|
200
|
+
this.halt(`an input of ${payload.length} bytes does not fit this session's buffer`);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
this.transport.send(BROADCAST, this.outgoing.subarray(0, length));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Keep this input, and the few before it, for the next packet to carry.
|
|
208
|
+
*
|
|
209
|
+
* Shifted rather than kept in a ring, because the run has to be **contiguous and in order** on
|
|
210
|
+
* the wire: a receiver reads it as "these are the inputs for ticks `firstTick` onward", and a ring
|
|
211
|
+
* would have to be unwrapped before sending anyway. `redundancy` is four, so the shift is three
|
|
212
|
+
* copies of a handful of bytes.
|
|
213
|
+
*/
|
|
214
|
+
private remember(tick: number, payload: Uint8Array): void {
|
|
215
|
+
const stride = this.inputs.inputBytes;
|
|
216
|
+
|
|
217
|
+
/* A gap — a tick submitted out of order, or the first one — restarts the run. */
|
|
218
|
+
if (this.recentFrom < 0 || tick !== this.recentFrom + this.recentCount) {
|
|
219
|
+
this.recentFrom = tick;
|
|
220
|
+
this.recentCount = 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (this.recentCount === this.redundancy) {
|
|
224
|
+
this.recent.copyWithin(0, stride);
|
|
225
|
+
this.recentFrom += 1;
|
|
226
|
+
this.recentCount -= 1;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const at = this.recentCount * stride;
|
|
230
|
+
for (let i = 0; i < stride; i++) this.recent[at + i] = payload[i] ?? 0;
|
|
231
|
+
this.recentCount += 1;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Take everything the transport has and apply it.
|
|
236
|
+
*
|
|
237
|
+
* Called once per tick, before advancing. Never from a transport callback — see `transport.ts`
|
|
238
|
+
* for why a message must not arrive in the middle of a step.
|
|
239
|
+
*/
|
|
240
|
+
poll(): void {
|
|
241
|
+
if (this.statusValue !== 'running') return;
|
|
242
|
+
this.transport.drain((from, message) => this.receive(from, message));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Advance one tick, then publish a fingerprint if this is a tick to publish one at.
|
|
247
|
+
*
|
|
248
|
+
* The rewind loop performs any pending correction first, so a late input that arrived during
|
|
249
|
+
* `poll` is applied before this tick runs rather than after it.
|
|
250
|
+
*/
|
|
251
|
+
advance(dt: number, tick: number): void {
|
|
252
|
+
if (this.statusValue !== 'running') return;
|
|
253
|
+
this.loop.advance(dt, tick);
|
|
254
|
+
|
|
255
|
+
this.confirmedValue = this.inputs.confirmedThrough(tick);
|
|
256
|
+
if (this.confirmedValue > this.appliedThrough) this.appliedThrough = this.confirmedValue;
|
|
257
|
+
|
|
258
|
+
/* Everything below the oldest tick a rewind can reach is never needed again. */
|
|
259
|
+
const earliest = this.loop.earliest;
|
|
260
|
+
if (earliest > 0) this.inputs.retain(earliest);
|
|
261
|
+
|
|
262
|
+
this.publishDigest();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Publish a hash of the state at a **confirmed** tick, which is the only state worth comparing.
|
|
267
|
+
*
|
|
268
|
+
* **Not the live world, and this cost a healthy session at tick 32 during development.** The
|
|
269
|
+
* newest tick is speculative: each peer has predicted inputs the other already knows, so their
|
|
270
|
+
* live worlds differ constantly and correctly. A snapshot at a tick every participant's input has
|
|
271
|
+
* arrived for was computed from the same inputs on both ends, and a disagreement there is a real
|
|
272
|
+
* one.
|
|
273
|
+
*
|
|
274
|
+
* The slot for tick *T* holds the state as *T* began, so it is final once every input below *T*
|
|
275
|
+
* is confirmed — which is what `confirmedThrough` answers.
|
|
276
|
+
*/
|
|
277
|
+
private publishDigest(): void {
|
|
278
|
+
if (!this.compareStates) return;
|
|
279
|
+
const at = this.confirmedValue;
|
|
280
|
+
if (at < 0 || at % this.fingerprintEvery !== 0) return;
|
|
281
|
+
if (this.ourDigests.has(at)) return;
|
|
282
|
+
|
|
283
|
+
const digest = this.loop.digestOf(at);
|
|
284
|
+
if (digest === null) return;
|
|
285
|
+
|
|
286
|
+
this.ourDigests.set(at, digest);
|
|
287
|
+
this.forgetDigestsBefore(this.loop.earliest);
|
|
288
|
+
const length = encodeFingerprint(this.outgoing, this.self, at, digest);
|
|
289
|
+
if (length > 0) this.transport.send(BROADCAST, this.outgoing.subarray(0, length));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Stop the session with a reason. Public so a consumer can halt on their own condition. */
|
|
293
|
+
halt(reason: string): void {
|
|
294
|
+
if (this.statusValue === 'halted') return;
|
|
295
|
+
this.statusValue = 'halted';
|
|
296
|
+
this.haltReason = reason;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private receive(from: PeerId, message: Uint8Array): void {
|
|
300
|
+
if (!decode(message, this.decoded)) return;
|
|
301
|
+
|
|
302
|
+
if (this.decoded.kind === MESSAGE_INPUT) {
|
|
303
|
+
const participant = this.decoded.participant;
|
|
304
|
+
if (participant === this.self) return;
|
|
305
|
+
|
|
306
|
+
const stride = this.decoded.payloadBytes;
|
|
307
|
+
for (let i = 0; i < this.decoded.count; i++) {
|
|
308
|
+
const tick = this.decoded.tick + i;
|
|
309
|
+
const at = this.decoded.payloadAt + i * stride;
|
|
310
|
+
const payload = message.subarray(at, at + stride);
|
|
311
|
+
|
|
312
|
+
/*
|
|
313
|
+
* **A tick already past the window is skipped rather than fatal, when the rest of the run
|
|
314
|
+
* is still usable.** Redundancy means most of what arrives is a repeat, and the oldest entry
|
|
315
|
+
* of a run naturally falls out of the window first — halting on that would end every
|
|
316
|
+
* session the moment the redundancy window outran the rewind window. What is fatal is an
|
|
317
|
+
* input this peer never had and can no longer apply, which is the case below.
|
|
318
|
+
*/
|
|
319
|
+
if (this.inputs.isConfirmed(participant, tick)) continue;
|
|
320
|
+
/*
|
|
321
|
+
* **And the same skip for a tick the log can no longer answer for, which is the common
|
|
322
|
+
* case rather than the edge one.** `isConfirmed` speaks for the window the log holds, and
|
|
323
|
+
* retention drops everything below the oldest tick a rewind can reach — so on any session
|
|
324
|
+
* whose redundancy reaches further back than that, the oldest word of a run arrives after
|
|
325
|
+
* the slot that would have recognised it is gone, and a link that lost nothing halts.
|
|
326
|
+
* A tick at or below this watermark had every participant's real input and was stepped
|
|
327
|
+
* with it: a second copy cannot change this world and so cannot be evidence of a
|
|
328
|
+
* divergence. Above the watermark nothing changes, halt included, and there it is right —
|
|
329
|
+
* an input this peer never had and can no longer apply really is fatal.
|
|
330
|
+
*/
|
|
331
|
+
if (tick <= this.appliedThrough) continue;
|
|
332
|
+
if (this.loop.supply(participant, tick, payload)) continue;
|
|
333
|
+
if (!this.inputs.holds(tick)) {
|
|
334
|
+
this.halt(
|
|
335
|
+
`an input from peer ${participant} for tick ${tick} arrived after the rewind window ` +
|
|
336
|
+
'had passed it, so this world can no longer be made to match theirs',
|
|
337
|
+
);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (this.decoded.kind === MESSAGE_FINGERPRINT) {
|
|
345
|
+
const ours = this.ourDigests.get(this.decoded.tick);
|
|
346
|
+
/* A tick we never published a digest for, or have already forgotten. Not a disagreement. */
|
|
347
|
+
if (ours === undefined) return;
|
|
348
|
+
if (ours === this.decoded.digest) return;
|
|
349
|
+
|
|
350
|
+
this.desyncValue = {
|
|
351
|
+
tick: this.decoded.tick,
|
|
352
|
+
peer: from === BROADCAST ? this.decoded.participant : from,
|
|
353
|
+
ours,
|
|
354
|
+
theirs: this.decoded.digest,
|
|
355
|
+
};
|
|
356
|
+
this.halt(
|
|
357
|
+
`state diverged at tick ${this.decoded.tick}: this peer has ${ours}, peer ` +
|
|
358
|
+
`${this.decoded.participant} has ${this.decoded.digest}`,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private forgetDigestsBefore(tick: number): void {
|
|
364
|
+
if (tick <= 0) return;
|
|
365
|
+
for (const at of this.ourDigests.keys()) {
|
|
366
|
+
if (at < tick) this.ourDigests.delete(at);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** The shared digest builder, for a consumer whose `digestAt` wants one without allocating. */
|
|
371
|
+
get digestBuilder(): Fingerprint {
|
|
372
|
+
return this.scratchDigest;
|
|
373
|
+
}
|
|
374
|
+
}
|
package/src/loopback.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A transport with no network, and a link you can make as bad as you like on purpose.
|
|
3
|
+
*
|
|
4
|
+
* **This is the load-bearing piece of the whole track's testing.** A rollback test run against a
|
|
5
|
+
* real network is not a test: it cannot be run twice, a failure cannot be reproduced, and a green
|
|
6
|
+
* result says only that this minute's conditions did not happen to trigger anything. Every
|
|
7
|
+
* interesting property here — that a late input replays to the same world, that a lockstep pair
|
|
8
|
+
* converges, that a desync is caught at the right tick — needs a link whose every delay, drop and
|
|
9
|
+
* reordering is decided by a seed.
|
|
10
|
+
*
|
|
11
|
+
* Track O's `packages/ai/src/provider/latency.ts` is the precedent, and for the same reason: a
|
|
12
|
+
* deterministic provider with programmable latency is what made an agent loop testable without a
|
|
13
|
+
* model behind it.
|
|
14
|
+
*
|
|
15
|
+
* ---
|
|
16
|
+
*
|
|
17
|
+
* ## Time is supplied, not read
|
|
18
|
+
*
|
|
19
|
+
* `advance(ms)` moves this transport's clock. It never reads one. A transport that called
|
|
20
|
+
* `performance.now()` would make every test depend on how fast the machine running it happened to
|
|
21
|
+
* be, which is the property that makes a suite flaky on a loaded CI box and green on a laptop.
|
|
22
|
+
*
|
|
23
|
+
* ## The generator here is not the engine's
|
|
24
|
+
*
|
|
25
|
+
* A small xorshift, deliberately **not** `mulberry32`. The engine's seeded sequence is a frozen
|
|
26
|
+
* promise about stored replays and ghosts; this is a test instrument's schedule. Sharing the
|
|
27
|
+
* algorithm would invite somebody to assume the two are related, and the day one of them has to
|
|
28
|
+
* change, the other would be dragged along.
|
|
29
|
+
*/
|
|
30
|
+
import {
|
|
31
|
+
BROADCAST,
|
|
32
|
+
type MessageSink,
|
|
33
|
+
type PeerId,
|
|
34
|
+
type Transport,
|
|
35
|
+
type TransportState,
|
|
36
|
+
} from './transport.ts';
|
|
37
|
+
|
|
38
|
+
export interface Impairment {
|
|
39
|
+
/** One-way delay in milliseconds, before jitter. */
|
|
40
|
+
readonly latencyMs?: number;
|
|
41
|
+
/** Delay varies uniformly by ± this many milliseconds. */
|
|
42
|
+
readonly jitterMs?: number;
|
|
43
|
+
/** Fraction of messages dropped, 0 to 1. */
|
|
44
|
+
readonly loss?: number;
|
|
45
|
+
/** Fraction of messages delivered a further `reorderMs` late, which is what reorders them. */
|
|
46
|
+
readonly reorder?: number;
|
|
47
|
+
readonly reorderMs?: number;
|
|
48
|
+
/** Fraction of messages delivered twice. */
|
|
49
|
+
readonly duplicate?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface LoopbackOptions {
|
|
53
|
+
readonly self: PeerId;
|
|
54
|
+
readonly impairment?: Impairment;
|
|
55
|
+
/** Anything reproducible. The same seed gives the same delivery schedule, exactly. */
|
|
56
|
+
readonly seed?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface Pending {
|
|
60
|
+
from: PeerId;
|
|
61
|
+
to: PeerId;
|
|
62
|
+
at: number;
|
|
63
|
+
bytes: Uint8Array;
|
|
64
|
+
live: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A set of endpoints that can reach each other, holding the clock they share.
|
|
69
|
+
*
|
|
70
|
+
* The network owns the clock rather than each endpoint owning one, because two endpoints whose
|
|
71
|
+
* clocks advanced separately would let a test deliver a message before it was sent.
|
|
72
|
+
*/
|
|
73
|
+
export class LoopbackNetwork {
|
|
74
|
+
private readonly endpoints = new Map<PeerId, LoopbackTransport>();
|
|
75
|
+
private nowMs = 0;
|
|
76
|
+
|
|
77
|
+
get now(): number {
|
|
78
|
+
return this.nowMs;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Make an endpoint. Its impairment is its own, so a link can be bad in one direction only. */
|
|
82
|
+
open(options: LoopbackOptions): LoopbackTransport {
|
|
83
|
+
const transport = new LoopbackTransport(this, options);
|
|
84
|
+
this.endpoints.set(options.self, transport);
|
|
85
|
+
return transport;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Move the shared clock, delivering everything whose time has come.
|
|
90
|
+
*
|
|
91
|
+
* Delivery happens in **send order among messages due at the same instant**, which is what makes
|
|
92
|
+
* a schedule reproducible: two messages with identical arrival times would otherwise be delivered
|
|
93
|
+
* in whatever order a queue happened to hold them.
|
|
94
|
+
*/
|
|
95
|
+
advance(ms: number): void {
|
|
96
|
+
this.nowMs += ms;
|
|
97
|
+
for (const endpoint of this.endpoints.values()) endpoint.deliverDue(this.nowMs);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Every endpoint, for a caller draining them all. */
|
|
101
|
+
reach(to: PeerId): LoopbackTransport[] {
|
|
102
|
+
if (to !== BROADCAST) {
|
|
103
|
+
const one = this.endpoints.get(to);
|
|
104
|
+
return one === undefined ? [] : [one];
|
|
105
|
+
}
|
|
106
|
+
return [...this.endpoints.values()];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export class LoopbackTransport implements Transport {
|
|
111
|
+
readonly self: PeerId;
|
|
112
|
+
|
|
113
|
+
private readonly network: LoopbackNetwork;
|
|
114
|
+
private readonly impairment: Required<Impairment>;
|
|
115
|
+
private readonly inflight: Pending[] = [];
|
|
116
|
+
private readonly arrived: { from: PeerId; bytes: Uint8Array }[] = [];
|
|
117
|
+
private stateValue: TransportState = 'open';
|
|
118
|
+
private randomState: number;
|
|
119
|
+
/** Sends made, and how many the impairment threw away. For a test asserting the link was bad. */
|
|
120
|
+
private sentCount = 0;
|
|
121
|
+
private droppedCount = 0;
|
|
122
|
+
|
|
123
|
+
constructor(network: LoopbackNetwork, options: LoopbackOptions) {
|
|
124
|
+
this.network = network;
|
|
125
|
+
this.self = options.self;
|
|
126
|
+
this.randomState = (options.seed ?? 1) | 0 || 1;
|
|
127
|
+
const given = options.impairment ?? {};
|
|
128
|
+
this.impairment = {
|
|
129
|
+
latencyMs: given.latencyMs ?? 0,
|
|
130
|
+
jitterMs: given.jitterMs ?? 0,
|
|
131
|
+
loss: given.loss ?? 0,
|
|
132
|
+
reorder: given.reorder ?? 0,
|
|
133
|
+
reorderMs: given.reorderMs ?? 0,
|
|
134
|
+
duplicate: given.duplicate ?? 0,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
get state(): TransportState {
|
|
139
|
+
return this.stateValue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
get sent(): number {
|
|
143
|
+
return this.sentCount;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
get dropped(): number {
|
|
147
|
+
return this.droppedCount;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Queue a message for everyone it is addressed to, impaired.
|
|
152
|
+
*
|
|
153
|
+
* **The bytes are copied here and nowhere else.** A caller sending from a reusable buffer — which
|
|
154
|
+
* every caller in this package does — would otherwise have the buffer overwritten before delivery,
|
|
155
|
+
* and the message that arrived would be whatever was sent most recently. That bug looks exactly
|
|
156
|
+
* like a reordering bug and is not one.
|
|
157
|
+
*/
|
|
158
|
+
send(to: PeerId, message: Uint8Array): void {
|
|
159
|
+
if (this.stateValue !== 'open') return;
|
|
160
|
+
this.sentCount += 1;
|
|
161
|
+
|
|
162
|
+
if (this.random() < this.impairment.loss) {
|
|
163
|
+
this.droppedCount += 1;
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const copy = message.slice();
|
|
168
|
+
let delay = this.impairment.latencyMs;
|
|
169
|
+
if (this.impairment.jitterMs > 0) {
|
|
170
|
+
delay += (this.random() * 2 - 1) * this.impairment.jitterMs;
|
|
171
|
+
}
|
|
172
|
+
if (this.random() < this.impairment.reorder) delay += this.impairment.reorderMs;
|
|
173
|
+
if (delay < 0) delay = 0;
|
|
174
|
+
|
|
175
|
+
for (const endpoint of this.network.reach(to)) {
|
|
176
|
+
if (endpoint === this) continue;
|
|
177
|
+
endpoint.accept({
|
|
178
|
+
from: this.self,
|
|
179
|
+
to,
|
|
180
|
+
at: this.network.now + delay,
|
|
181
|
+
bytes: copy,
|
|
182
|
+
live: true,
|
|
183
|
+
});
|
|
184
|
+
if (this.random() < this.impairment.duplicate) {
|
|
185
|
+
endpoint.accept({
|
|
186
|
+
from: this.self,
|
|
187
|
+
to,
|
|
188
|
+
at: this.network.now + delay,
|
|
189
|
+
bytes: copy,
|
|
190
|
+
live: true,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
drain(into: MessageSink): void {
|
|
197
|
+
for (const message of this.arrived) into(message.from, message.bytes);
|
|
198
|
+
this.arrived.length = 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
close(): void {
|
|
202
|
+
this.stateValue = 'closed';
|
|
203
|
+
this.inflight.length = 0;
|
|
204
|
+
this.arrived.length = 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Called by the network. Not for a consumer. */
|
|
208
|
+
accept(pending: Pending): void {
|
|
209
|
+
if (this.stateValue !== 'open') return;
|
|
210
|
+
this.inflight.push(pending);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Called by the network when the clock moves. Not for a consumer. */
|
|
214
|
+
deliverDue(nowMs: number): void {
|
|
215
|
+
if (this.inflight.length === 0) return;
|
|
216
|
+
|
|
217
|
+
/*
|
|
218
|
+
* Stable by arrival time, then by the order they were queued. Sorting by time alone leaves two
|
|
219
|
+
* messages due at the same instant in an order the engine's sort happens to choose, and a
|
|
220
|
+
* schedule that depends on a sort's stability is a schedule that is not reproducible.
|
|
221
|
+
*/
|
|
222
|
+
let due = 0;
|
|
223
|
+
for (const pending of this.inflight) if (pending.at <= nowMs) due += 1;
|
|
224
|
+
if (due === 0) return;
|
|
225
|
+
|
|
226
|
+
const ready: Pending[] = [];
|
|
227
|
+
const keep: Pending[] = [];
|
|
228
|
+
for (const pending of this.inflight) {
|
|
229
|
+
if (pending.at <= nowMs) ready.push(pending);
|
|
230
|
+
else keep.push(pending);
|
|
231
|
+
}
|
|
232
|
+
ready.sort((a, b) => a.at - b.at);
|
|
233
|
+
|
|
234
|
+
this.inflight.length = 0;
|
|
235
|
+
for (const pending of keep) this.inflight.push(pending);
|
|
236
|
+
for (const pending of ready) this.arrived.push({ from: pending.from, bytes: pending.bytes });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* A xorshift32 in [0, 1). Deliberately not the engine's generator; see the header.
|
|
241
|
+
*/
|
|
242
|
+
private random(): number {
|
|
243
|
+
let x = this.randomState;
|
|
244
|
+
x ^= x << 13;
|
|
245
|
+
x ^= x >>> 17;
|
|
246
|
+
x ^= x << 5;
|
|
247
|
+
this.randomState = x | 0;
|
|
248
|
+
return ((x >>> 0) % 0x1000000) / 0x1000000;
|
|
249
|
+
}
|
|
250
|
+
}
|