Add TypeScript RUDP reconnect loop
This commit is contained in:
parent
6a369d1050
commit
3805352dbb
6 changed files with 186 additions and 3 deletions
|
|
@ -238,6 +238,11 @@ Current progress:
|
|||
counter and duplicate backoff constants. It still owns LiteNetLib socket
|
||||
mechanics and UI-facing reconnect state; the controller owns retry attempt
|
||||
scheduling.
|
||||
- TypeScript now has a `CultNetRudpReconnectLoop` that consumes the shared
|
||||
controller while keeping socket construction caller-owned. A closed RUDP
|
||||
transport schedules the next attempt with the portable policy, opens a fresh
|
||||
caller-provided transport when the controller says it may retry, and resets
|
||||
attempt state when the caller marks the connection established.
|
||||
- Kotlin now mirrors the C#/TypeScript/Python local authority lease gate:
|
||||
peer cards may advertise `authorityLeaseId`, but `CultMeshAuthorityLeaseCatalog`
|
||||
owns role/shard/time authorization so discovery contact cannot impersonate
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ CultMesh.
|
|||
| --- | --- | --- | --- |
|
||||
| C# | Reference implementation and production game/server substrate | CultCache typed documents and SoA, CultNet schema-v0, LiteNetLib production path, shared transport profiles, shared RUDP socket transport, RUDP reconnect policy/controller, LiteNetLib client reconnect-controller adoption, CultMesh node/session/catalog/stream/simulation surfaces | No need to abandon LiteNetLib before the CultNet adapter fully owns production service adoption |
|
||||
| Python | Package runtime, local daemon endpoint, raw interop peer, typed state participant | CultCache/CultNet/CultMesh package surface, schema catalogs, shard catalogs/logs, local TCP CultMesh server, session/prediction/simulation fact helpers, authority leases, streams, RUDP codec/session/socket transport, RUDP reconnect policy/controller, schema-v0 MessagePack over RUDP evidence, benchmark evidence against C# cache paths | Universal C# throughput, full C# daemon/server stack, and RUDP as the only public service pipe |
|
||||
| TypeScript | Node/browser-adjacent projection and interop runtime | CultCache parity tests, CultNet schema-v0 peer, transport profiles, TCP-framed transport port, shared RUDP socket transport, RUDP reconnect policy/controller, local CultMesh node/catalog/authority/stream ergonomics | Full C# game session/server stack |
|
||||
| TypeScript | Node/browser-adjacent projection and interop runtime | CultCache parity tests, CultNet schema-v0 peer, transport profiles, TCP-framed transport port, shared RUDP socket transport, RUDP reconnect policy/controller, RUDP reconnect-loop helper, local CultMesh node/catalog/authority/stream ergonomics | Full C# game session/server stack |
|
||||
| Rust | Low-level cache/transport/runtime substrate | CultCache/CultNet interop lane, transport profiles, TCP-framed port, shared RUDP codec/session/socket transport, RUDP reconnect policy/controller, schema-v0 MessagePack over RUDP evidence, small CultMesh facade for peer cards, authority leases, RUDP endpoint parsing, and peer-card RUDP client construction | Full CultMesh game-session/server facade, stream catalog, or production game-session server |
|
||||
| Kotlin | JVM/Android client substrate | Typed MessagePack cache, schema/shard catalogs, authority leases, stream catalog, branded `CultMesh` facade, RUDP client/server sugar, RUDP reconnect policy/controller, schema-v0 MessagePack over RUDP evidence | C# SoA store, full C# server daemon, or Unity hot-loop memory layout |
|
||||
|
||||
|
|
@ -63,8 +63,9 @@ CultMesh.
|
|||
- Extend RUDP interop evidence through daemon/socket reconnect loops and the
|
||||
wider schema-message matrix. RUDP profiles now advertise the portable
|
||||
reconnect policy, runtime-local tests prove the shared controller across the
|
||||
targeted runtimes, and the C# LiteNetLib client uses that controller; broader
|
||||
service adoption remains a separate claim.
|
||||
targeted runtimes, and the C# LiteNetLib client plus TypeScript RUDP
|
||||
reconnect loop use that controller; broader service adoption remains a
|
||||
separate claim.
|
||||
- Keep Kotlin ergonomic parity moving with Android/JVM-shaped APIs instead of
|
||||
backfilling C# server responsibilities it should not own.
|
||||
- Keep performance claims evidence-bound: C# owns SoA; Python has benchmark
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ The library currently includes:
|
|||
- `CultCacheTS` replication helpers for document put/delete/snapshot flows
|
||||
- schema discovery catalog messages and a registry for asking what contracts a
|
||||
peer can actually exchange safely
|
||||
- RUDP socket transport, reconnect policy helpers, and
|
||||
`CultNetRudpReconnectLoop` for controller-owned retry scheduling around a
|
||||
caller-owned socket factory
|
||||
- generated TypeScript contracts and runtime verifiers from canonical JSON
|
||||
schemas owned elsewhere in the swarm
|
||||
- an exact mirrored runtime validator and generated TS contract surface for
|
||||
|
|
|
|||
|
|
@ -75,11 +75,14 @@ export {
|
|||
createRudpTransportProfile,
|
||||
decodeRudpPacket,
|
||||
encodeRudpPacket,
|
||||
CultNetRudpReconnectLoop,
|
||||
CultNetRudpSession,
|
||||
CultNetRudpSocketTransportConnection,
|
||||
type CultNetRudpDeliveredFrame,
|
||||
type CultNetRudpPacket,
|
||||
type CultNetRudpPacketType,
|
||||
type CultNetRudpReconnectLoopOptions,
|
||||
type CultNetRudpReconnectTransport,
|
||||
type CultNetRudpReceiveResult,
|
||||
type CultNetRudpSocketTransportOptions,
|
||||
type CultNetRudpSessionOptions,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ import { type RemoteInfo, type Socket } from "node:dgram";
|
|||
|
||||
import type { CultNetTransportProfile } from "./contracts";
|
||||
import {
|
||||
CultNetReconnectController,
|
||||
createCultNetReconnectPolicy,
|
||||
type CultNetReconnectDecision,
|
||||
type CultNetReconnectPolicy,
|
||||
type CultNetTransportConnection,
|
||||
type CultNetTransportFrame,
|
||||
|
|
@ -109,6 +111,21 @@ export interface CultNetRudpSocketTransportOptions {
|
|||
reconnectPolicy?: CultNetReconnectPolicy;
|
||||
}
|
||||
|
||||
export interface CultNetRudpReconnectTransport extends EventEmitter {
|
||||
connect(payload?: Uint8Array): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface CultNetRudpReconnectLoopOptions {
|
||||
reconnectPolicy?: CultNetReconnectPolicy;
|
||||
createTransport: () => CultNetRudpReconnectTransport;
|
||||
connectPayload?: Uint8Array;
|
||||
nowMs?: () => number;
|
||||
jitterMs?: () => number;
|
||||
setTimer?: (callback: () => void, delayMs: number) => unknown;
|
||||
clearTimer?: (handle: unknown) => void;
|
||||
}
|
||||
|
||||
const packetTypeToCode: Record<CultNetRudpPacketType, number> = {
|
||||
connect: 1,
|
||||
accept: 2,
|
||||
|
|
@ -767,6 +784,96 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement
|
|||
}
|
||||
}
|
||||
|
||||
export class CultNetRudpReconnectLoop extends EventEmitter {
|
||||
readonly reconnectController: CultNetReconnectController;
|
||||
readonly #createTransport: () => CultNetRudpReconnectTransport;
|
||||
readonly #connectPayload: Uint8Array;
|
||||
readonly #nowMs: () => number;
|
||||
readonly #jitterMs: () => number;
|
||||
readonly #setTimer: (callback: () => void, delayMs: number) => unknown;
|
||||
readonly #clearTimer: (handle: unknown) => void;
|
||||
#transport: CultNetRudpReconnectTransport | undefined;
|
||||
#timer: unknown;
|
||||
#stopped = true;
|
||||
|
||||
constructor(options: CultNetRudpReconnectLoopOptions) {
|
||||
super();
|
||||
this.reconnectController = new CultNetReconnectController(options.reconnectPolicy ?? createCultNetReconnectPolicy());
|
||||
this.#createTransport = options.createTransport;
|
||||
this.#connectPayload = options.connectPayload ?? new Uint8Array();
|
||||
this.#nowMs = options.nowMs ?? Date.now;
|
||||
this.#jitterMs = options.jitterMs ?? (() => 0);
|
||||
this.#setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
|
||||
this.#clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle as NodeJS.Timeout));
|
||||
}
|
||||
|
||||
get transport(): CultNetRudpReconnectTransport | undefined {
|
||||
return this.#transport;
|
||||
}
|
||||
|
||||
start(): CultNetRudpReconnectTransport {
|
||||
this.#stopped = false;
|
||||
this.reconnectController.reset();
|
||||
return this.#openTransport();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.#stopped = true;
|
||||
if (this.#timer !== undefined) {
|
||||
this.#clearTimer(this.#timer);
|
||||
this.#timer = undefined;
|
||||
}
|
||||
const transport = this.#transport;
|
||||
this.#transport = undefined;
|
||||
transport?.close();
|
||||
this.reconnectController.reset();
|
||||
}
|
||||
|
||||
markConnected(): void {
|
||||
this.reconnectController.reset();
|
||||
}
|
||||
|
||||
#openTransport(): CultNetRudpReconnectTransport {
|
||||
const transport = this.#createTransport();
|
||||
this.#transport = transport;
|
||||
transport.once("close", () => {
|
||||
if (this.#transport === transport) {
|
||||
this.#transport = undefined;
|
||||
}
|
||||
this.#scheduleReconnect();
|
||||
});
|
||||
transport.on("error", (error) => this.emit("error", error));
|
||||
this.emit("transport", transport);
|
||||
try {
|
||||
transport.connect(this.#connectPayload);
|
||||
} catch (error) {
|
||||
this.emit("error", error instanceof Error ? error : new Error(String(error)));
|
||||
this.#scheduleReconnect();
|
||||
}
|
||||
return transport;
|
||||
}
|
||||
|
||||
#scheduleReconnect(): void {
|
||||
if (this.#stopped || this.#timer !== undefined) {
|
||||
return;
|
||||
}
|
||||
const decision = this.reconnectController.recordFailure(this.#nowMs(), this.#jitterMs());
|
||||
this.emit("reconnectScheduled", decision satisfies CultNetReconnectDecision);
|
||||
if (!decision.shouldRetry) {
|
||||
this.emit("reconnectExhausted", decision);
|
||||
return;
|
||||
}
|
||||
this.#timer = this.#setTimer(() => {
|
||||
this.#timer = undefined;
|
||||
if (this.#stopped || !this.reconnectController.canAttempt(this.#nowMs())) {
|
||||
return;
|
||||
}
|
||||
this.emit("reconnecting", decision);
|
||||
this.#openTransport();
|
||||
}, decision.delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
export function createRudpTransportProfile(
|
||||
runtimeId: string,
|
||||
options: RudpTransportProfileOptions = {},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { Duplex } from "node:stream";
|
||||
import dgram, { type Socket } from "node:dgram";
|
||||
import { rmSync, mkdtempSync } from "node:fs";
|
||||
|
|
@ -17,6 +18,7 @@ import {
|
|||
CultNetClientSecurityOptions,
|
||||
CultNetDocumentRegistry,
|
||||
CultNetPeer,
|
||||
CultNetRudpReconnectLoop,
|
||||
CultNetRudpSession,
|
||||
CultNetRudpSocketTransportConnection,
|
||||
CultNetSchemaRegistry,
|
||||
|
|
@ -50,6 +52,22 @@ import {
|
|||
type InteropNote,
|
||||
} from "./interop/cultnet-interop-shared";
|
||||
|
||||
class FakeRudpReconnectTransport extends EventEmitter {
|
||||
public connectCalls = 0;
|
||||
public closeCalls = 0;
|
||||
public lastPayload: Uint8Array | undefined;
|
||||
|
||||
public connect(payload = new Uint8Array()): void {
|
||||
this.connectCalls += 1;
|
||||
this.lastPayload = payload;
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this.closeCalls += 1;
|
||||
this.emit("close");
|
||||
}
|
||||
}
|
||||
|
||||
class LinkedDuplex extends Duplex {
|
||||
peer?: LinkedDuplex;
|
||||
|
||||
|
|
@ -330,6 +348,52 @@ test("reconnect controller schedules attempts and reset with the shared policy",
|
|||
assert.equal(controller.canAttempt(99_000), true);
|
||||
});
|
||||
|
||||
test("rudp reconnect loop consumes the shared reconnect controller", () => {
|
||||
let nowMs = 10_000;
|
||||
let capturedTimer: (() => void) | undefined;
|
||||
const transports: FakeRudpReconnectTransport[] = [];
|
||||
const loop = new CultNetRudpReconnectLoop({
|
||||
reconnectPolicy: createCultNetReconnectPolicy({ maxAttempts: 2 }),
|
||||
connectPayload: Buffer.from("join", "utf8"),
|
||||
createTransport: () => {
|
||||
const transport = new FakeRudpReconnectTransport();
|
||||
transports.push(transport);
|
||||
return transport;
|
||||
},
|
||||
nowMs: () => nowMs,
|
||||
jitterMs: () => 17,
|
||||
setTimer: (callback, delayMs) => {
|
||||
assert.equal(delayMs, 1_017);
|
||||
capturedTimer = callback;
|
||||
return "timer";
|
||||
},
|
||||
clearTimer: () => {
|
||||
capturedTimer = undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const first = loop.start() as FakeRudpReconnectTransport;
|
||||
assert.equal(first.connectCalls, 1);
|
||||
assert.equal(Buffer.from(first.lastPayload ?? []).toString("utf8"), "join");
|
||||
|
||||
first.emit("close");
|
||||
assert.equal(loop.reconnectController.attempt, 1);
|
||||
assert.equal(loop.reconnectController.nextAttemptAtMs, 11_017);
|
||||
assert.equal(typeof capturedTimer, "function");
|
||||
|
||||
nowMs = 11_017;
|
||||
capturedTimer?.();
|
||||
assert.equal(transports.length, 2);
|
||||
assert.equal(transports[1]?.connectCalls, 1);
|
||||
|
||||
loop.markConnected();
|
||||
assert.equal(loop.reconnectController.attempt, 0);
|
||||
|
||||
loop.stop();
|
||||
assert.equal(transports[1]?.closeCalls, 1);
|
||||
assert.equal(loop.transport, undefined);
|
||||
});
|
||||
|
||||
test("rudp session handshake acks reliable connect and accept packets", () => {
|
||||
const client = new CultNetRudpSession({ connectionId: 0x0a0b0c0d, initialSequence: 1, resendDelayMs: 50 });
|
||||
const server = new CultNetRudpSession({ connectionId: 0x0a0b0c0d, initialSequence: 100, resendDelayMs: 50 });
|
||||
|
|
|
|||
Loading…
Reference in a new issue