@pylonsync/realtime 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/replication.d.ts +2 -0
- package/dist/wire.d.ts +5 -0
- package/package.json +1 -1
- package/src/connection-webtransport.test.ts +81 -0
- package/src/connection.ts +126 -27
- package/src/replication.ts +5 -0
- package/src/wire.test.ts +17 -0
- package/src/wire.ts +36 -0
package/dist/replication.d.ts
CHANGED
|
@@ -87,6 +87,8 @@ export interface ReplicationSummary {
|
|
|
87
87
|
updated: number[];
|
|
88
88
|
despawned: number[];
|
|
89
89
|
}
|
|
90
|
+
/** True when a replication frame is a full frame (it replaces the table). */
|
|
91
|
+
export declare function isFullFrame(frame: Uint8Array): boolean;
|
|
90
92
|
/**
|
|
91
93
|
* A datagram's frame number, tick, and input ack, without applying it.
|
|
92
94
|
* Throws on bytes that are not a datagram.
|
package/dist/wire.d.ts
CHANGED
|
@@ -131,6 +131,11 @@ export declare function decodeWebTransportInfo(body: unknown): WebTransportInfo;
|
|
|
131
131
|
* stream.
|
|
132
132
|
*/
|
|
133
133
|
export declare function encodeDatagramAcks(acks: ReadonlyArray<readonly [number, number]>): Uint8Array;
|
|
134
|
+
/**
|
|
135
|
+
* `encodeDatagramAcks` split into messages of at most `maxBytes` bytes and
|
|
136
|
+
* `MAX_ACKS_PER_MESSAGE` acks each, so every one fits in a datagram.
|
|
137
|
+
*/
|
|
138
|
+
export declare function encodeDatagramAckBatches(acks: ReadonlyArray<readonly [number, number]>, maxBytes: number): Uint8Array[];
|
|
134
139
|
/** A message for a WebTransport stream: a 4-byte big-endian length, then `bytes`. */
|
|
135
140
|
export declare function lengthPrefixed(bytes: Uint8Array): Uint8Array;
|
|
136
141
|
/** The first message on a WebTransport stream: who connects to which shard. */
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.20.0",
|
|
7
7
|
"description": "Pylon realtime shard client pieces with no framework: the wire protocol, codecs, and the entity replication decoder and table.",
|
|
8
8
|
"type": "module",
|
|
9
9
|
"main": "./src/index.ts",
|
|
@@ -541,3 +541,84 @@ test("a closing frame refuses fixed credentials, though the browser gets no code
|
|
|
541
541
|
expect(FakeWebTransport.sessions.length).toBe(1);
|
|
542
542
|
client.close();
|
|
543
543
|
});
|
|
544
|
+
|
|
545
|
+
test("a stream frame waits for its tick's datagrams, and the handlers get both with the tick's ack", async () => {
|
|
546
|
+
const { client, wt } = await openWithBaseline();
|
|
547
|
+
const seen: Array<{ spawned: number[]; updated: number[]; ack: number }> = [];
|
|
548
|
+
client.onReplication((_, summary, _tick, ack) => {
|
|
549
|
+
if (!summary.full) seen.push({ spawned: summary.spawned, updated: summary.updated, ack });
|
|
550
|
+
});
|
|
551
|
+
// Tick 2's stream frame: entity 4 spawns at x = 50 (q 5000).
|
|
552
|
+
const precision = [...new Uint8Array(new Float32Array([0.01]).buffer)];
|
|
553
|
+
const spawn = Uint8Array.from([1, 0, ...precision, 0, 1, 4, ...varint(10000), 0, 0, 0, 0]);
|
|
554
|
+
wt.sendFrames([frame(3, 4, 2, 3, spawn)]);
|
|
555
|
+
await settleSoon();
|
|
556
|
+
expect(client.entities.get(4)).toBeUndefined();
|
|
557
|
+
expect(seen).toEqual([]);
|
|
558
|
+
|
|
559
|
+
wt.sendDatagram(dg({ frame: 1, tick: 2, ack: 3, streamTick: 2, parts: 1 }, [[1, 150]]));
|
|
560
|
+
await until("tick 2", () => seen.length === 1);
|
|
561
|
+
expect(client.entities.get(4)?.qx).toBe(5000);
|
|
562
|
+
expect(seen[0]).toEqual({ spawned: [4], updated: [1], ack: 3 });
|
|
563
|
+
// Acked with the stream tick the table had then.
|
|
564
|
+
expect([...wt.datagramsIn[0]]).toEqual([1, 1, 1, 2]);
|
|
565
|
+
client.close();
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test("a session whose datagrams stop arriving closes", async () => {
|
|
569
|
+
const errors: string[] = [];
|
|
570
|
+
const { client, wt } = await openWithBaseline();
|
|
571
|
+
client.onError((e) => errors.push(e.message));
|
|
572
|
+
// Every tick misses a part, so none is ever whole.
|
|
573
|
+
for (let tick = 2; tick <= 103; tick++) {
|
|
574
|
+
wt.sendDatagram(dg({ frame: tick, tick, ack: 0, parts: 2 }, [[1, tick]]));
|
|
575
|
+
}
|
|
576
|
+
await until("the close", () => wt.clientClose !== null);
|
|
577
|
+
expect(errors).toContain("WebTransport to shard field: datagrams stopped arriving");
|
|
578
|
+
client.close();
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test("a session with no whole tick for 5 s closes, even when nothing arrives", async () => {
|
|
582
|
+
let clock = 1000;
|
|
583
|
+
const errors: string[] = [];
|
|
584
|
+
const client = connectShard("field", {
|
|
585
|
+
subscriberId: "p1",
|
|
586
|
+
baseUrl: "h",
|
|
587
|
+
ticket: "t1",
|
|
588
|
+
transport: "webtransport",
|
|
589
|
+
now: () => clock,
|
|
590
|
+
});
|
|
591
|
+
client.onError((e) => errors.push(e.message));
|
|
592
|
+
await until("the session", () => client.connected);
|
|
593
|
+
const wt = FakeWebTransport.sessions[FakeWebTransport.sessions.length - 1];
|
|
594
|
+
wt.sendFrames([frame(3, 4, 1, 0, fullFrame)]);
|
|
595
|
+
await until("the full frame", () => client.entities.size === 3);
|
|
596
|
+
// Silence: no stream frame, no datagram. The watchdog runs each second.
|
|
597
|
+
clock += 4000;
|
|
598
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
599
|
+
expect(wt.clientClose).toBeNull();
|
|
600
|
+
clock += 2000;
|
|
601
|
+
await until("the close", () => wt.clientClose !== null);
|
|
602
|
+
expect(errors).toContain("WebTransport to shard field: datagrams stopped arriving");
|
|
603
|
+
client.close();
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
test("a snapshot shard over WebTransport is not taken for a stall", async () => {
|
|
607
|
+
let clock = 1000;
|
|
608
|
+
const client = connectShard("field", {
|
|
609
|
+
subscriberId: "p1",
|
|
610
|
+
baseUrl: "h",
|
|
611
|
+
ticket: "t1",
|
|
612
|
+
transport: "webtransport",
|
|
613
|
+
now: () => clock,
|
|
614
|
+
});
|
|
615
|
+
await until("the session", () => client.connected);
|
|
616
|
+
const wt = FakeWebTransport.sessions[FakeWebTransport.sessions.length - 1];
|
|
617
|
+
wt.sendFrames([frame(1, 0, 1, 0, json({ n: 1 }))]);
|
|
618
|
+
await until("the snapshot", () => client.tick === 1);
|
|
619
|
+
clock += 10_000;
|
|
620
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
621
|
+
expect(wt.clientClose).toBeNull();
|
|
622
|
+
expect(client.connected).toBe(true);
|
|
623
|
+
client.close();
|
|
624
|
+
});
|
package/src/connection.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { ShardClock } from "./clock";
|
|
9
|
-
import { EntityTable, readDatagramHeader, type ReplicationSummary } from "./replication";
|
|
9
|
+
import { EntityTable, isFullFrame, readDatagramHeader, type ReplicationSummary } from "./replication";
|
|
10
10
|
import {
|
|
11
11
|
SHARD_PROTOCOL_VERSION,
|
|
12
12
|
ShardFrameKind,
|
|
@@ -14,8 +14,7 @@ import {
|
|
|
14
14
|
StreamFrames,
|
|
15
15
|
decodeWebTransportInfo,
|
|
16
16
|
type WebTransportInfo,
|
|
17
|
-
|
|
18
|
-
encodeDatagramAcks,
|
|
17
|
+
encodeDatagramAckBatches,
|
|
19
18
|
encodeWebTransportHello,
|
|
20
19
|
encodeWebTransportInput,
|
|
21
20
|
decodeShardPayload,
|
|
@@ -187,8 +186,8 @@ interface Link {
|
|
|
187
186
|
readonly open: boolean;
|
|
188
187
|
/** Send an input, as `encodeShardInput` made it. */
|
|
189
188
|
send(input: string | Uint8Array): void;
|
|
190
|
-
/** Send datagram acks (
|
|
191
|
-
ack(acks:
|
|
189
|
+
/** Send datagram acks: (datagram number, stream tick). WebTransport only. */
|
|
190
|
+
ack(acks: Array<[number, number]>): void;
|
|
192
191
|
/**
|
|
193
192
|
* What the server said before it closed the session (a closing frame):
|
|
194
193
|
* a WebTransport close from the server does not reach the browser's
|
|
@@ -207,8 +206,21 @@ interface PendingTick {
|
|
|
207
206
|
datagrams: Map<number, Uint8Array>;
|
|
208
207
|
}
|
|
209
208
|
|
|
210
|
-
/**
|
|
211
|
-
|
|
209
|
+
/** A stream frame waiting until its tick is whole. */
|
|
210
|
+
interface PendingFrame {
|
|
211
|
+
tick: number;
|
|
212
|
+
payload: Uint8Array;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* How long a WebTransport session may go without a whole tick (ms). Past
|
|
217
|
+
* that, its datagrams have stopped arriving: the client closes it. The
|
|
218
|
+
* server sends at least one datagram every tick.
|
|
219
|
+
*/
|
|
220
|
+
const STALL_MS = 5000;
|
|
221
|
+
|
|
222
|
+
/** Ticks kept waiting at most; more means the datagrams have stopped. */
|
|
223
|
+
const MAX_PENDING_TICKS = 100;
|
|
212
224
|
|
|
213
225
|
/** The parts of the browser's `WebTransport` the client uses. */
|
|
214
226
|
interface WebTransportSession {
|
|
@@ -216,6 +228,8 @@ interface WebTransportSession {
|
|
|
216
228
|
readonly closed: Promise<{ closeCode?: number; reason?: string } | undefined>;
|
|
217
229
|
readonly datagrams: {
|
|
218
230
|
readonly readable: ReadableStream<Uint8Array>;
|
|
231
|
+
/** The largest datagram the session can send now. */
|
|
232
|
+
readonly maxDatagramSize?: number;
|
|
219
233
|
readonly writable?: WritableStream<Uint8Array>;
|
|
220
234
|
createWritable?: () => WritableStream<Uint8Array>;
|
|
221
235
|
};
|
|
@@ -286,6 +300,15 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
|
|
|
286
300
|
// newer ticks that are not whole yet (see `pylon_replication::datagram`).
|
|
287
301
|
let wholeTick = -1;
|
|
288
302
|
const pending = new Map<number, PendingTick>();
|
|
303
|
+
// Stream frames (not full) that wait with their tick's datagrams, in
|
|
304
|
+
// order, and the tick of the newest one.
|
|
305
|
+
const pendingStream: PendingFrame[] = [];
|
|
306
|
+
let receivedStreamTick = -1;
|
|
307
|
+
// When the table last held a whole tick (or the session opened).
|
|
308
|
+
let lastWholeAt = 0;
|
|
309
|
+
// Whether this link replicates entities. Only then does the server send
|
|
310
|
+
// datagrams every tick; a snapshot shard sends none.
|
|
311
|
+
let replicating = false;
|
|
289
312
|
let lastKind: Link["kind"] | null = null;
|
|
290
313
|
let clientSeq = 0;
|
|
291
314
|
let closed = false;
|
|
@@ -422,6 +445,10 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
|
|
|
422
445
|
reportedTick = -1;
|
|
423
446
|
wholeTick = -1;
|
|
424
447
|
pending.clear();
|
|
448
|
+
pendingStream.length = 0;
|
|
449
|
+
receivedStreamTick = -1;
|
|
450
|
+
lastWholeAt = now();
|
|
451
|
+
replicating = false;
|
|
425
452
|
// Inputs sent on the old connection are never acknowledged on this
|
|
426
453
|
// one (acks restart with the connection).
|
|
427
454
|
sentAt.clear();
|
|
@@ -452,25 +479,54 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
|
|
|
452
479
|
};
|
|
453
480
|
|
|
454
481
|
/**
|
|
455
|
-
* Apply
|
|
456
|
-
* tick
|
|
457
|
-
*
|
|
458
|
-
*
|
|
482
|
+
* Apply everything buffered up to the newest whole tick, oldest tick
|
|
483
|
+
* first (each tick's stream frames, then its datagrams), then take that
|
|
484
|
+
* tick's ack, tell the handlers once, and ack the datagrams. A tick is
|
|
485
|
+
* whole when all its datagrams are here and so are the stream frames
|
|
486
|
+
* sent by then. Until then its changes wait, so the handlers never see a
|
|
487
|
+
* table newer than the ack they get with it (prediction relies on that).
|
|
459
488
|
*/
|
|
460
489
|
const applyWhole = (from: Link, at: number) => {
|
|
490
|
+
try {
|
|
491
|
+
applyWholeTick(from, at);
|
|
492
|
+
} catch (e) {
|
|
493
|
+
// Out of sync with the server. Reconnecting gets a full baseline.
|
|
494
|
+
dispatchError(e instanceof Error ? e : new Error(String(e)));
|
|
495
|
+
entities.clear();
|
|
496
|
+
from.close();
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const applyWholeTick = (from: Link, at: number) => {
|
|
461
501
|
let whole = -1;
|
|
462
502
|
for (const [tick, p] of pending) {
|
|
463
|
-
if (tick > whole && p.datagrams.size === p.parts &&
|
|
503
|
+
if (tick > whole && p.datagrams.size === p.parts && receivedStreamTick >= p.streamTick) {
|
|
464
504
|
whole = tick;
|
|
465
505
|
}
|
|
466
506
|
}
|
|
467
507
|
if (whole < 0) return;
|
|
468
508
|
const ack = (pending.get(whole) as PendingTick).ack;
|
|
469
|
-
const ticks =
|
|
509
|
+
const ticks = new Set<number>();
|
|
510
|
+
for (const t of pending.keys()) if (t <= whole) ticks.add(t);
|
|
511
|
+
for (const f of pendingStream) if (f.tick <= whole) ticks.add(f.tick);
|
|
512
|
+
const spawned = new Set<number>();
|
|
513
|
+
const despawned = new Set<number>();
|
|
470
514
|
const updated = new Set<number>();
|
|
471
515
|
const acks: Array<[number, number]> = [];
|
|
472
|
-
for (const t of ticks) {
|
|
473
|
-
|
|
516
|
+
for (const t of [...ticks].sort((a, b) => a - b)) {
|
|
517
|
+
while (pendingStream.length > 0 && pendingStream[0].tick === t) {
|
|
518
|
+
const f = pendingStream.shift() as PendingFrame;
|
|
519
|
+
const s = entities.apply(f.payload, f.tick);
|
|
520
|
+
for (const id of s.despawned) {
|
|
521
|
+
despawned.add(id);
|
|
522
|
+
spawned.delete(id);
|
|
523
|
+
updated.delete(id);
|
|
524
|
+
}
|
|
525
|
+
for (const id of s.spawned) spawned.add(id);
|
|
526
|
+
for (const id of s.updated) updated.add(id);
|
|
527
|
+
}
|
|
528
|
+
const p = pending.get(t);
|
|
529
|
+
if (!p) continue;
|
|
474
530
|
pending.delete(t);
|
|
475
531
|
for (const number of [...p.datagrams.keys()].sort((a, b) => a - b)) {
|
|
476
532
|
const s = entities.applyDatagram(p.datagrams.get(number) as Uint8Array);
|
|
@@ -478,14 +534,39 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
|
|
|
478
534
|
acks.push([s.frame, entities.streamTick]);
|
|
479
535
|
}
|
|
480
536
|
}
|
|
537
|
+
for (const id of spawned) updated.delete(id);
|
|
481
538
|
wholeTick = whole;
|
|
539
|
+
lastWholeAt = at;
|
|
482
540
|
takeAck(ack, at);
|
|
483
541
|
backoff = options.reconnectBackoffMs ?? 500;
|
|
484
|
-
report(
|
|
485
|
-
|
|
486
|
-
|
|
542
|
+
report(
|
|
543
|
+
{ full: false, spawned: [...spawned], updated: [...updated], despawned: [...despawned] },
|
|
544
|
+
whole,
|
|
545
|
+
);
|
|
546
|
+
if (acks.length > 0) from.ack(acks);
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Too many ticks without one whole: the datagrams stopped arriving (UDP
|
|
551
|
+
* blocked partway, say). Close the session; `"auto"` uses WebSockets
|
|
552
|
+
* from then on.
|
|
553
|
+
*/
|
|
554
|
+
const stalled = (from: Link) => {
|
|
555
|
+
if (
|
|
556
|
+
from !== link ||
|
|
557
|
+
!replicating ||
|
|
558
|
+
(now() - lastWholeAt <= STALL_MS &&
|
|
559
|
+
pending.size <= MAX_PENDING_TICKS &&
|
|
560
|
+
pendingStream.length <= MAX_PENDING_TICKS)
|
|
561
|
+
) {
|
|
562
|
+
return false;
|
|
487
563
|
}
|
|
564
|
+
dispatchError(new Error(`WebTransport to shard ${currentShard}: datagrams stopped arriving`));
|
|
565
|
+
if ((options.transport ?? "websocket") === "auto") webSocketOnly = true;
|
|
566
|
+
from.close();
|
|
567
|
+
return true;
|
|
488
568
|
};
|
|
569
|
+
|
|
489
570
|
/** A frame from the server: a WebSocket message, or one from the WebTransport stream. */
|
|
490
571
|
const onFrame = (from: Link, data: ArrayBuffer, at: number) => {
|
|
491
572
|
if (from !== link) return;
|
|
@@ -531,6 +612,14 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
|
|
|
531
612
|
// tick's frame is built, and would make the clock run early.
|
|
532
613
|
observeTick(frame.tick, at);
|
|
533
614
|
}
|
|
615
|
+
if (frame.kind === ShardFrameKind.Replication) replicating = true;
|
|
616
|
+
if (frame.kind === ShardFrameKind.Replication && from.kind === "webtransport" && !isFullFrame(frame.payload)) {
|
|
617
|
+
// It waits with its tick's datagrams (see `applyWhole`).
|
|
618
|
+
pendingStream.push({ tick: frame.tick, payload: frame.payload });
|
|
619
|
+
receivedStreamTick = Math.max(receivedStreamTick, frame.tick);
|
|
620
|
+
if (!stalled(from)) applyWhole(from, at);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
534
623
|
if (frame.kind === ShardFrameKind.Replication) {
|
|
535
624
|
let summary: ReplicationSummary;
|
|
536
625
|
try {
|
|
@@ -542,14 +631,15 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
|
|
|
542
631
|
from.close();
|
|
543
632
|
return;
|
|
544
633
|
}
|
|
545
|
-
//
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
if (from.kind === "webtransport" && summary.full) {
|
|
550
|
-
// Datagrams built before it describe a table that is gone.
|
|
634
|
+
// A full frame holds everything, so its ack describes the table.
|
|
635
|
+
takeAck(frame.ack, at);
|
|
636
|
+
if (from.kind === "webtransport") {
|
|
637
|
+
// What was built before it describes a table that is gone.
|
|
551
638
|
wholeTick = Math.max(wholeTick, frame.tick);
|
|
639
|
+
lastWholeAt = at;
|
|
640
|
+
receivedStreamTick = Math.max(receivedStreamTick, frame.tick);
|
|
552
641
|
for (const t of [...pending.keys()]) if (t <= frame.tick) pending.delete(t);
|
|
642
|
+
while (pendingStream.length > 0 && pendingStream[0].tick <= frame.tick) pendingStream.shift();
|
|
553
643
|
}
|
|
554
644
|
// A frame applied: the connection works, so the next reconnect
|
|
555
645
|
// starts from the short delay again. (Resetting on open would
|
|
@@ -587,16 +677,16 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
|
|
|
587
677
|
if (from !== link) return;
|
|
588
678
|
try {
|
|
589
679
|
const h = readDatagramHeader(datagram);
|
|
680
|
+
replicating = true;
|
|
590
681
|
if (h.tick <= wholeTick || h.parts < 1) return;
|
|
591
682
|
observeTick(h.tick, at);
|
|
592
683
|
let p = pending.get(h.tick);
|
|
593
684
|
if (!p) {
|
|
594
685
|
p = { parts: h.parts, streamTick: h.streamTick, ack: h.ack, datagrams: new Map() };
|
|
595
686
|
pending.set(h.tick, p);
|
|
596
|
-
if (pending.size > MAX_PENDING_TICKS) pending.delete(Math.min(...pending.keys()));
|
|
597
687
|
}
|
|
598
688
|
p.datagrams.set(h.frame, datagram);
|
|
599
|
-
applyWhole(from, at);
|
|
689
|
+
if (!stalled(from)) applyWhole(from, at);
|
|
600
690
|
} catch (e) {
|
|
601
691
|
// Not a datagram this client can read: start over with a baseline.
|
|
602
692
|
dispatchError(e instanceof Error ? e : new Error(String(e)));
|
|
@@ -781,7 +871,11 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
|
|
|
781
871
|
writer.write(encodeWebTransportInput(input)).catch(ignore);
|
|
782
872
|
},
|
|
783
873
|
ack(acks) {
|
|
784
|
-
|
|
874
|
+
// A datagram larger than the session allows never arrives.
|
|
875
|
+
const max = session.datagrams.maxDatagramSize ?? 1200;
|
|
876
|
+
for (const message of encodeDatagramAckBatches(acks, max)) {
|
|
877
|
+
datagrams.write(message).catch(ignore);
|
|
878
|
+
}
|
|
785
879
|
},
|
|
786
880
|
notice: null,
|
|
787
881
|
close() {
|
|
@@ -794,6 +888,10 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
|
|
|
794
888
|
},
|
|
795
889
|
};
|
|
796
890
|
began(l);
|
|
891
|
+
// Checks for a stall also while nothing arrives at all.
|
|
892
|
+
const watchdog = setInterval(() => {
|
|
893
|
+
if (link === l) stalled(l);
|
|
894
|
+
}, 1000);
|
|
797
895
|
|
|
798
896
|
void (async () => {
|
|
799
897
|
const reader = stream.readable.getReader();
|
|
@@ -833,6 +931,7 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
|
|
|
833
931
|
// `closed` does not get them from a server close.
|
|
834
932
|
const closedWith = (info: { closeCode?: number; reason?: string } | null) => {
|
|
835
933
|
isOpen = false;
|
|
934
|
+
clearInterval(watchdog);
|
|
836
935
|
const code = l.notice?.code ?? info?.closeCode;
|
|
837
936
|
const reason = l.notice?.reason ?? info?.reason ?? "";
|
|
838
937
|
const refused = code === WEBTRANSPORT_CLOSE.Policy && reason.startsWith("unauthorized");
|
package/src/replication.ts
CHANGED
|
@@ -105,6 +105,11 @@ export interface ReplicationSummary {
|
|
|
105
105
|
despawned: number[];
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/** True when a replication frame is a full frame (it replaces the table). */
|
|
109
|
+
export function isFullFrame(frame: Uint8Array): boolean {
|
|
110
|
+
return frame.length >= 2 && (frame[1] & FLAG_FULL) !== 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
108
113
|
/**
|
|
109
114
|
* A datagram's frame number, tick, and input ack, without applying it.
|
|
110
115
|
* Throws on bytes that are not a datagram.
|
package/src/wire.test.ts
CHANGED
|
@@ -2,11 +2,13 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { encode as msgpackEncode, decode as msgpackDecode } from "@msgpack/msgpack";
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
+
MAX_ACKS_PER_MESSAGE,
|
|
5
6
|
SHARD_HEADER_LEN,
|
|
6
7
|
ShardCodec,
|
|
7
8
|
ShardFrameKind,
|
|
8
9
|
decodeShardPayload,
|
|
9
10
|
decodeShardRejection,
|
|
11
|
+
encodeDatagramAckBatches,
|
|
10
12
|
encodeShardInput,
|
|
11
13
|
parseShardFrame,
|
|
12
14
|
} from "./wire";
|
|
@@ -70,3 +72,18 @@ test("inputs are MessagePack for MessagePack shards and JSON otherwise", () => {
|
|
|
70
72
|
expect(encodeShardInput(null, 5, 1)).toBe('{"input":5,"client_seq":1}');
|
|
71
73
|
expect(encodeShardInput(ShardCodec.Json, 5, 2)).toBe('{"input":5,"client_seq":2}');
|
|
72
74
|
});
|
|
75
|
+
|
|
76
|
+
test("ack batches stay within the datagram size", () => {
|
|
77
|
+
const acks = Array.from({ length: 1300 }, (_, i) => [2_000_000 + i, 1_000_000] as [number, number]);
|
|
78
|
+
const batches = encodeDatagramAckBatches(acks, 200);
|
|
79
|
+
let total = 0;
|
|
80
|
+
for (const b of batches) {
|
|
81
|
+
expect(b.length).toBeLessThanOrEqual(200);
|
|
82
|
+
expect(b[0]).toBe(1);
|
|
83
|
+
// The count is the second byte (under 128 here).
|
|
84
|
+
total += b[1];
|
|
85
|
+
}
|
|
86
|
+
expect(total).toBe(1300);
|
|
87
|
+
const big = encodeDatagramAckBatches(Array.from({ length: 1300 }, (_, i) => [i % 100, 1] as [number, number]), 1_000_000);
|
|
88
|
+
expect(big.length).toBe(Math.ceil(1300 / MAX_ACKS_PER_MESSAGE));
|
|
89
|
+
});
|
package/src/wire.ts
CHANGED
|
@@ -238,6 +238,42 @@ export function encodeDatagramAcks(acks: ReadonlyArray<readonly [number, number]
|
|
|
238
238
|
return Uint8Array.from(out);
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
+
/** Bytes `pushVarint` writes for `v`. */
|
|
242
|
+
function varintLen(v: number): number {
|
|
243
|
+
let n = 1;
|
|
244
|
+
while (v >= 0x80) {
|
|
245
|
+
v = Math.floor(v / 0x80);
|
|
246
|
+
n += 1;
|
|
247
|
+
}
|
|
248
|
+
return n;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* `encodeDatagramAcks` split into messages of at most `maxBytes` bytes and
|
|
253
|
+
* `MAX_ACKS_PER_MESSAGE` acks each, so every one fits in a datagram.
|
|
254
|
+
*/
|
|
255
|
+
export function encodeDatagramAckBatches(
|
|
256
|
+
acks: ReadonlyArray<readonly [number, number]>,
|
|
257
|
+
maxBytes: number,
|
|
258
|
+
): Uint8Array[] {
|
|
259
|
+
const out: Uint8Array[] = [];
|
|
260
|
+
let start = 0;
|
|
261
|
+
// Type byte plus the count, which is below 2^14 (two varint bytes).
|
|
262
|
+
let size = 3;
|
|
263
|
+
for (let i = 0; i < acks.length; i++) {
|
|
264
|
+
const [frame, applied] = acks[i];
|
|
265
|
+
const n = varintLen(frame) + varintLen(applied);
|
|
266
|
+
if (i > start && (size + n > maxBytes || i - start === MAX_ACKS_PER_MESSAGE)) {
|
|
267
|
+
out.push(encodeDatagramAcks(acks.slice(start, i)));
|
|
268
|
+
start = i;
|
|
269
|
+
size = 3;
|
|
270
|
+
}
|
|
271
|
+
size += n;
|
|
272
|
+
}
|
|
273
|
+
if (start < acks.length) out.push(encodeDatagramAcks(acks.slice(start)));
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
|
|
241
277
|
/** A message for a WebTransport stream: a 4-byte big-endian length, then `bytes`. */
|
|
242
278
|
export function lengthPrefixed(bytes: Uint8Array): Uint8Array {
|
|
243
279
|
const out = new Uint8Array(4 + bytes.length);
|