One distribution named cultcache-py carried three libraries. Rust and TypeScript layer them as cultcache < cultnet < cultmesh; Python now does the same, one package per layer, module names unchanged so every `from cultmesh_py import ...` keeps working. Two imports ran against that layering and are resolved by moving code to where the dependency direction says it lives, not by adding a shim: - cultnet_py.interop_peer imported the Verse/peer catalog, authority lease, stream, and simulation-fact contracts from cultmesh_py. They are wire contracts a bare peer has to speak without owning a node, which is why cultnet-rs and cultnet-ts keep them in the net layer. They now live in cultnet_py.cultmesh_contracts; cultmesh_py re-exports them. - cultcache_py.benchmark imported cultnet_py. It measures framed CultNet parse beside cache paths, so it and compare_csharp move to cultnet_py. verify checks all three packages and moves to cultmesh_py. The single 6,693-line test module is split by what each test touches: a test using any cultmesh_py name lands in cultmesh-py, else cultnet_py in cultnet-py, else cultcache-py. Each suite runs with only its layer and the layers below on the path, so a test that reaches up fails to import rather than quietly binding the packages back together. 11 / 53 / 78 = the same 142. Workflows install all three; the publish job is one matrix over the three packages keyed by tag prefix. The TS test harnesses put all three src directories on PYTHONPATH. cultcache-py bumps to 0.2.0 because it no longer contains cultnet_py or cultmesh_py; the new packages start at 0.1.0. Nothing was ever published, so no PyPI consumer moves. cultcache-py 11 tests, path: cultcache-py/src cultnet-py 53 tests, path: + cultnet-py/src cultmesh-py 78 tests, path: + cultmesh-py/src python -m cultmesh_py.verify -> ok, all five checks cultnet_py.interop_peer imports with cultmesh_py absent Not run here: the cultnet-ts interop ring and cultcache-ts store matrix need dotnet/kotlin peers; their PYTHONPATH change is four lines. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
4019 lines
146 KiB
TypeScript
4019 lines
146 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { execFile, spawn, spawnSync, type ChildProcessByStdio } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import dgram, { type Socket } from "node:dgram";
|
|
import { once } from "node:events";
|
|
import { existsSync, rmSync } from "node:fs";
|
|
import { connect as connectTcp, createServer } from "node:net";
|
|
import { homedir, networkInterfaces, tmpdir } from "node:os";
|
|
import { delimiter, dirname, join, resolve } from "node:path";
|
|
import type { Readable } from "node:stream";
|
|
import { test } from "node:test";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { promisify } from "node:util";
|
|
import { decode, encode } from "@msgpack/msgpack";
|
|
import { encodeFrame, LengthPrefixedMessageFramer } from "../../src/framing";
|
|
import { CultNetRudpSocketTransportConnection, decodeRudpPacket } from "../../src";
|
|
import { INTEROP_SCHEMA_VERSION } from "./cultnet-interop-shared";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
const cargoCommand = process.env.CARGO ?? (process.platform === "win32" ? join(homedir(), ".cargo", "bin", "cargo.exe") : "cargo");
|
|
const dotnetCommand = process.env.DOTNET ?? (process.platform === "win32" ? join("C:", "Program Files", "dotnet", "dotnet.exe") : "dotnet");
|
|
const pythonCommand = process.env.PYTHON ?? resolvePythonCommand();
|
|
const cultNetTsRoot = resolve(__dirname, "../../..");
|
|
const cultLibRoot = findAncestor(cultNetTsRoot, "CultLib.sln") ?? resolve(cultNetTsRoot, "..", "CultLib");
|
|
const cultcachePyRoot = resolve(cultLibRoot, "packages", "cultcache-py");
|
|
const cultcachePySrc = ["cultcache-py", "cultnet-py", "cultmesh-py"]
|
|
.map((name) => resolve(cultLibRoot, "packages", name, "src"))
|
|
.join(delimiter);
|
|
const cultmeshKotlinRoot = resolve(cultLibRoot, "packages", "cultmesh-kotlin");
|
|
const kotlinHome = process.env.KOTLIN_HOME ?? "C:\\Program Files\\Android\\Android Studio\\plugins\\Kotlin\\kotlinc";
|
|
const kotlinJavaHome = process.env.KOTLIN_JAVA_HOME ?? "C:\\Program Files\\Android\\Android Studio\\jbr";
|
|
const kotlinJavaCommand = process.env.JAVA ?? join(kotlinJavaHome, "bin", process.platform === "win32" ? "java.exe" : "java");
|
|
const kotlinStdlibPath = join(kotlinHome, "lib", "kotlin-stdlib.jar");
|
|
const kotlinJarPath = resolve(cultLibRoot, "artifacts", "cultmesh-kotlin", "cultmesh-kotlin.jar");
|
|
const kotlinClasspath = [kotlinJarPath, kotlinStdlibPath].join(delimiter);
|
|
const cultnetRsRoot = existsSync(resolve(cultLibRoot, "packages", "cultnet-rs"))
|
|
? resolve(cultLibRoot, "packages", "cultnet-rs")
|
|
: resolve(cultNetTsRoot, "..", "cultnet-rs");
|
|
|
|
const tsPeerScript = resolve(cultNetTsRoot, "dist-test", "test", "interop", "cultnet-interop-peer.js");
|
|
const interopSchemaPath = resolve(cultNetTsRoot, "integration", "contracts", "cultnet.interop-note.schema.json");
|
|
const interopNoteSchemaId = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-note.schema.json";
|
|
const witnessArtifactBundleSchemaId = "https://github.com/GameCult/cultnet-ts/contracts/cultnet.witness-artifact-bundle.schema.json";
|
|
const simulationFactSchemaId = "gamecult.mesh.simulation_fact";
|
|
const csharpProjectPath = resolve(
|
|
cultLibRoot,
|
|
"tests",
|
|
"GameCult.Networking.InteropPeer",
|
|
"GameCult.Networking.InteropPeer.csproj",
|
|
);
|
|
const csharpDllPath = resolve(
|
|
cultLibRoot,
|
|
"bin",
|
|
"GameCult.Networking.InteropPeer",
|
|
"Debug",
|
|
"net10.0",
|
|
"GameCult.Networking.InteropPeer.dll",
|
|
);
|
|
const rustBinaryPath = resolve(
|
|
cultnetRsRoot,
|
|
"target",
|
|
"debug",
|
|
"examples",
|
|
process.platform === "win32" ? "cultnet_interop_peer.exe" : "cultnet_interop_peer",
|
|
);
|
|
|
|
const discoveryGroup = "239.77.44.11";
|
|
const serveReadyTimeoutMs = 90_000;
|
|
const jsonCommandTimeoutMs = 180_000;
|
|
const processCleanupTimeoutMs = 5_000;
|
|
let rustInteropPeerBuild: Promise<void> | undefined;
|
|
let csharpInteropPeerBuild: Promise<void> | undefined;
|
|
let kotlinInteropPeerBuild: Promise<void> | undefined;
|
|
|
|
function resolvePythonCommand(): string {
|
|
const codexPythonCommand = join(homedir(), ".cache", "codex-runtimes", "codex-primary-runtime", "dependencies", "python", "python.exe");
|
|
const candidates = process.platform === "win32"
|
|
? [
|
|
...(existsSync(codexPythonCommand) ? [codexPythonCommand] : []),
|
|
"py",
|
|
"python",
|
|
"python3",
|
|
]
|
|
: ["python3", "python"];
|
|
|
|
for (const candidate of candidates) {
|
|
const result = spawnSync(candidate, ["--version"], { stdio: "ignore" });
|
|
if (result.status === 0) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
return process.platform === "win32" ? "python" : "python3";
|
|
}
|
|
|
|
interface RudpServerInteropPeerSpec {
|
|
name: string;
|
|
connectionId: number;
|
|
build: () => Promise<void>;
|
|
spawn: (options?: RudpServerSpawnOptions) => RunningPythonRudpPeer;
|
|
joinPayload: string;
|
|
clientPayload: string;
|
|
expectedPayload: string;
|
|
}
|
|
|
|
interface RudpServerSpawnOptions {
|
|
clientPayload?: string;
|
|
serverPayload?: string;
|
|
serverExtraPayload?: string;
|
|
disconnectReason?: string;
|
|
maxFragmentBytes?: number;
|
|
}
|
|
|
|
function rudpServerInteropPeers(): RudpServerInteropPeerSpec[] {
|
|
return [
|
|
{
|
|
name: "Python",
|
|
connectionId: 0x55667788,
|
|
build: async () => undefined,
|
|
spawn: spawnPythonRudpPeer,
|
|
joinPayload: "ts-join",
|
|
clientPayload: "client-state",
|
|
expectedPayload: "python-state",
|
|
},
|
|
{
|
|
name: "Rust",
|
|
connectionId: 0x22446688,
|
|
build: buildRustInteropPeer,
|
|
spawn: spawnRustRudpServer,
|
|
joinPayload: "ts-rust-join",
|
|
clientPayload: "ts-rust-client-state",
|
|
expectedPayload: "rust-server-state",
|
|
},
|
|
{
|
|
name: "C#",
|
|
connectionId: 0x33557799,
|
|
build: buildCSharpInteropPeer,
|
|
spawn: spawnCSharpRudpServer,
|
|
joinPayload: "ts-csharp-join",
|
|
clientPayload: "ts-csharp-client-state",
|
|
expectedPayload: "csharp-server-state",
|
|
},
|
|
{
|
|
name: "Kotlin",
|
|
connectionId: 0x446688aa,
|
|
build: buildKotlinInteropPeer,
|
|
spawn: spawnKotlinRudpServer,
|
|
joinPayload: "ts-kotlin-join",
|
|
clientPayload: "ts-kotlin-client-state",
|
|
expectedPayload: "kotlin-server-state",
|
|
},
|
|
];
|
|
}
|
|
|
|
const pythonRudpPeerScript = String.raw`
|
|
import os
|
|
import socket
|
|
import time
|
|
|
|
from cultnet_py import (
|
|
CultNetRudpSocketMode,
|
|
CultNetRudpSocketTransportConnection,
|
|
CultNetRudpSocketTransportOptions,
|
|
)
|
|
|
|
expected_client_payload = os.environ.get("RUDP_CLIENT_PAYLOAD", "client-state").encode()
|
|
server_payload = os.environ.get("RUDP_SERVER_PAYLOAD", "python-state").encode()
|
|
server_extra_payload_raw = os.environ.get("RUDP_SERVER_EXTRA_PAYLOAD")
|
|
server_extra_payload = server_extra_payload_raw.encode() if server_extra_payload_raw else None
|
|
disconnect_reason_raw = os.environ.get("RUDP_DISCONNECT_REASON")
|
|
disconnect_reason = disconnect_reason_raw.encode() if disconnect_reason_raw else None
|
|
max_fragment_bytes_raw = os.environ.get("RUDP_MAX_FRAGMENT_BYTES")
|
|
max_fragment_bytes = int(max_fragment_bytes_raw) if max_fragment_bytes_raw else None
|
|
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
sock.bind(("127.0.0.1", 0))
|
|
sock.settimeout(0.02)
|
|
transport = CultNetRudpSocketTransportConnection(CultNetRudpSocketTransportOptions(
|
|
runtime_id="python-rudp-interop",
|
|
socket=sock,
|
|
mode=CultNetRudpSocketMode.SERVER,
|
|
connection_id=0x55667788,
|
|
initial_sequence=100,
|
|
resend_delay_ms=25,
|
|
max_fragment_bytes=max_fragment_bytes,
|
|
))
|
|
print(f"PORT {sock.getsockname()[1]}", flush=True)
|
|
|
|
deadline = time.time() + 5
|
|
while time.time() < deadline:
|
|
frame = transport.receive_once()
|
|
transport.poll_resends()
|
|
if frame is None:
|
|
time.sleep(0.005)
|
|
continue
|
|
if frame.channel_id != "schema" or frame.payload != expected_client_payload:
|
|
transport.close()
|
|
raise SystemExit(f"unexpected RUDP frame: {frame.channel_id!r} {frame.payload!r}")
|
|
transport.send("schema", server_payload)
|
|
if server_extra_payload is not None:
|
|
transport.send("schema", server_extra_payload)
|
|
if disconnect_reason is not None:
|
|
transport.disconnect(disconnect_reason)
|
|
print("OK", flush=True)
|
|
ack_deadline = time.time() + 0.25
|
|
while time.time() < ack_deadline:
|
|
transport.receive_once()
|
|
transport.poll_resends()
|
|
time.sleep(0.005)
|
|
transport.close()
|
|
raise SystemExit(0)
|
|
|
|
transport.close()
|
|
raise SystemExit("timed out waiting for RUDP frame")
|
|
`;
|
|
|
|
const pythonRudpClientScript = String.raw`
|
|
import socket
|
|
import sys
|
|
import time
|
|
|
|
from cultnet_py import (
|
|
CultNetRudpSocketMode,
|
|
CultNetRudpSocketTransportConnection,
|
|
CultNetRudpSocketTransportOptions,
|
|
)
|
|
|
|
remote_port = int(sys.argv[1])
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
sock.bind(("127.0.0.1", 0))
|
|
sock.settimeout(0.02)
|
|
transport = CultNetRudpSocketTransportConnection(CultNetRudpSocketTransportOptions(
|
|
runtime_id="python-rudp-client-interop",
|
|
socket=sock,
|
|
mode=CultNetRudpSocketMode.CLIENT,
|
|
remote_addr=("127.0.0.1", remote_port),
|
|
connection_id=0x66554433,
|
|
initial_sequence=1,
|
|
resend_delay_ms=25,
|
|
))
|
|
transport.connect(b"python-join")
|
|
|
|
sent = False
|
|
deadline = time.time() + 5
|
|
while time.time() < deadline:
|
|
frame = transport.receive_once()
|
|
transport.poll_resends()
|
|
if transport.connected and not sent:
|
|
transport.send("schema", b"python-client-state")
|
|
sent = True
|
|
if frame is None:
|
|
time.sleep(0.005)
|
|
continue
|
|
if frame.channel_id != "schema" or frame.payload != b"ts-server-state":
|
|
transport.close()
|
|
raise SystemExit(f"unexpected RUDP frame: {frame.channel_id!r} {frame.payload!r}")
|
|
print("OK", flush=True)
|
|
transport.close()
|
|
raise SystemExit(0)
|
|
|
|
transport.close()
|
|
raise SystemExit("timed out waiting for TypeScript RUDP frame")
|
|
`;
|
|
|
|
const pythonRudpMessagePeerScript = String.raw`
|
|
import msgpack
|
|
import socket
|
|
import time
|
|
|
|
from cultnet_py import (
|
|
CultNetRudpSocketMode,
|
|
CultNetRudpSocketTransportConnection,
|
|
CultNetRudpSocketTransportOptions,
|
|
create_rudp_transport_profile,
|
|
)
|
|
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
sock.bind(("127.0.0.1", 0))
|
|
sock.settimeout(0.02)
|
|
transport = CultNetRudpSocketTransportConnection(CultNetRudpSocketTransportOptions(
|
|
runtime_id="python-rudp-message-interop",
|
|
socket=sock,
|
|
mode=CultNetRudpSocketMode.SERVER,
|
|
connection_id=0x55667789,
|
|
initial_sequence=100,
|
|
resend_delay_ms=25,
|
|
))
|
|
print(f"PORT {sock.getsockname()[1]}", flush=True)
|
|
|
|
deadline = time.time() + 5
|
|
while time.time() < deadline:
|
|
frame = transport.receive_once()
|
|
transport.poll_resends()
|
|
if frame is None:
|
|
time.sleep(0.005)
|
|
continue
|
|
if frame.channel_id != "schema":
|
|
transport.close()
|
|
raise SystemExit(f"unexpected RUDP channel: {frame.channel_id!r}")
|
|
message = msgpack.unpackb(frame.payload, raw=False)
|
|
if message.get("schemaVersion") != "cultnet.schema_catalog_request.v0":
|
|
transport.close()
|
|
raise SystemExit(f"unexpected schema message: {message!r}")
|
|
response = {
|
|
"schemaVersion": "cultnet.hello.v0",
|
|
"runtimeId": "python-rudp-message-interop",
|
|
"runtimeKind": "python",
|
|
"supportedDocumentTypes": [],
|
|
"supportedMutationContracts": [],
|
|
"supportedMessageVersions": ["cultnet.hello.v0", "cultnet.schema_catalog_request.v0"],
|
|
"transportProfiles": [create_rudp_transport_profile(
|
|
"python-rudp-message-interop",
|
|
host="127.0.0.1",
|
|
port=sock.getsockname()[1],
|
|
)],
|
|
"supportsSchemaCatalog": True,
|
|
}
|
|
transport.send("schema", msgpack.packb(response, use_bin_type=True))
|
|
print("OK", flush=True)
|
|
ack_deadline = time.time() + 0.25
|
|
while time.time() < ack_deadline:
|
|
transport.receive_once()
|
|
transport.poll_resends()
|
|
time.sleep(0.005)
|
|
transport.close()
|
|
raise SystemExit(0)
|
|
|
|
transport.close()
|
|
raise SystemExit("timed out waiting for RUDP schema-v0 message")
|
|
`;
|
|
|
|
const pythonRudpMessageClientScript = String.raw`
|
|
import msgpack
|
|
import socket
|
|
import sys
|
|
import time
|
|
|
|
from cultnet_py import (
|
|
CultNetRudpSocketMode,
|
|
CultNetRudpSocketTransportConnection,
|
|
CultNetRudpSocketTransportOptions,
|
|
)
|
|
|
|
remote_port = int(sys.argv[1])
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
sock.bind(("127.0.0.1", 0))
|
|
sock.settimeout(0.02)
|
|
transport = CultNetRudpSocketTransportConnection(CultNetRudpSocketTransportOptions(
|
|
runtime_id="python-rudp-message-client-interop",
|
|
socket=sock,
|
|
mode=CultNetRudpSocketMode.CLIENT,
|
|
remote_addr=("127.0.0.1", remote_port),
|
|
connection_id=0x66554434,
|
|
initial_sequence=1,
|
|
resend_delay_ms=25,
|
|
))
|
|
transport.connect(b"python-message-join")
|
|
|
|
sent = False
|
|
deadline = time.time() + 5
|
|
while time.time() < deadline:
|
|
frame = transport.receive_once()
|
|
transport.poll_resends()
|
|
if transport.connected and not sent:
|
|
transport.send("schema", msgpack.packb({
|
|
"schemaVersion": "cultnet.schema_catalog_request.v0",
|
|
"messageId": "python-ts-schema-message",
|
|
"includeSchemaJson": False,
|
|
"schemaIds": [],
|
|
"kinds": ["wire_message"],
|
|
}, use_bin_type=True))
|
|
sent = True
|
|
if frame is None:
|
|
time.sleep(0.005)
|
|
continue
|
|
if frame.channel_id != "schema":
|
|
transport.close()
|
|
raise SystemExit(f"unexpected RUDP channel: {frame.channel_id!r}")
|
|
message = msgpack.unpackb(frame.payload, raw=False)
|
|
if message.get("schemaVersion") != "cultnet.hello.v0":
|
|
transport.close()
|
|
raise SystemExit(f"unexpected schema message: {message!r}")
|
|
if message.get("runtimeId") != "ts-python-rudp-message-server":
|
|
transport.close()
|
|
raise SystemExit(f"unexpected runtime id: {message!r}")
|
|
print("OK", flush=True)
|
|
transport.close()
|
|
raise SystemExit(0)
|
|
|
|
transport.close()
|
|
raise SystemExit("timed out waiting for TypeScript RUDP schema-v0 message")
|
|
`;
|
|
|
|
test("CultNet TS/Rust/C#/Python peers discover each other and exchange raw state over the shared schema-v0 lane", async (t) => {
|
|
logInteropPhase("schema-v0", "build peers");
|
|
await buildInteropPeers();
|
|
cleanInteropStores([
|
|
"rust-peer",
|
|
"rust-client-dial",
|
|
"ts-peer",
|
|
"ts-client-dial",
|
|
"ts-rudp-client-dial",
|
|
"ts-rust-dial",
|
|
"ts-csharp-dial",
|
|
"python-peer",
|
|
"python-client-dial",
|
|
"python-rudp-client-dial",
|
|
]);
|
|
|
|
const discoveryPort = await getFreePort();
|
|
const tsPort = await getFreePort();
|
|
const rustPort = await getFreePort();
|
|
const csharpPort = await getFreePort();
|
|
const pythonPort = await getFreePort();
|
|
const advertiseHost = findAdvertiseHost();
|
|
|
|
const servers: RunningServeProcess[] = [];
|
|
// Registered before anything is spawned. A peer whose readiness rejects
|
|
// would otherwise skip this and strand every peer already running.
|
|
t.after(async () => {
|
|
await Promise.all(servers.map(stopProcess));
|
|
});
|
|
|
|
logInteropPhase("schema-v0", "start rust peer");
|
|
servers.push(await spawnServeProcess("rust", {
|
|
command: rustBinaryPath,
|
|
args: [
|
|
"serve",
|
|
"--runtime-id", "rust-peer",
|
|
"--runtime-kind", "rust",
|
|
"--display-name", "Rust Peer",
|
|
"--agent-id", "rust-agent",
|
|
"--bind-host", "127.0.0.1",
|
|
"--advertise-host", advertiseHost,
|
|
"--tcp-port", String(rustPort),
|
|
"--discovery-port", String(discoveryPort),
|
|
"--discovery-group", discoveryGroup,
|
|
"--schema-path", interopSchemaPath,
|
|
],
|
|
cwd: cultnetRsRoot,
|
|
}));
|
|
await servers[servers.length - 1].ready;
|
|
|
|
logInteropPhase("schema-v0", "start typescript peer");
|
|
servers.push(await spawnServeProcess("ts", {
|
|
command: process.execPath,
|
|
args: [
|
|
tsPeerScript,
|
|
"serve",
|
|
"--runtime-id", "ts-peer",
|
|
"--runtime-kind", "node",
|
|
"--display-name", "TypeScript Peer",
|
|
"--agent-id", "ts-agent",
|
|
"--bind-host", "127.0.0.1",
|
|
"--advertise-host", advertiseHost,
|
|
"--tcp-port", String(tsPort),
|
|
"--discovery-port", String(discoveryPort),
|
|
"--discovery-group", discoveryGroup,
|
|
"--schema-path", interopSchemaPath,
|
|
],
|
|
cwd: cultNetTsRoot,
|
|
}));
|
|
await servers[servers.length - 1].ready;
|
|
|
|
logInteropPhase("schema-v0", "start csharp peer");
|
|
servers.push(await spawnServeProcess("csharp", {
|
|
command: dotnetCommand,
|
|
args: [
|
|
csharpDllPath,
|
|
"serve",
|
|
"--runtime-id", "csharp-peer",
|
|
"--runtime-kind", "dotnet",
|
|
"--display-name", "CSharp Peer",
|
|
"--agent-id", "csharp-agent",
|
|
"--bind-host", "127.0.0.1",
|
|
"--advertise-host", advertiseHost,
|
|
"--tcp-port", String(csharpPort),
|
|
"--discovery-port", String(discoveryPort),
|
|
"--discovery-group", discoveryGroup,
|
|
"--schema-path", interopSchemaPath,
|
|
],
|
|
cwd: cultLibRoot,
|
|
}));
|
|
await servers[servers.length - 1].ready;
|
|
|
|
logInteropPhase("schema-v0", "start python peer");
|
|
servers.push(await spawnServeProcess("python", {
|
|
command: pythonCommand,
|
|
args: [
|
|
"-m", "cultnet_py.interop_peer",
|
|
"serve",
|
|
"--runtime-id", "python-peer",
|
|
"--runtime-kind", "python",
|
|
"--display-name", "Python Peer",
|
|
"--agent-id", "python-agent",
|
|
"--bind-host", "127.0.0.1",
|
|
"--advertise-host", advertiseHost,
|
|
"--tcp-port", String(pythonPort),
|
|
"--discovery-port", String(discoveryPort),
|
|
"--discovery-group", discoveryGroup,
|
|
"--schema-path", interopSchemaPath,
|
|
],
|
|
cwd: cultcachePyRoot,
|
|
env: { PYTHONPATH: cultcachePySrc },
|
|
}));
|
|
await servers[servers.length - 1].ready;
|
|
|
|
logInteropPhase("schema-v0", "typescript dials rust");
|
|
const tsDial = await runJsonCommand("ts-dial", process.execPath, [
|
|
tsPeerScript,
|
|
"dial",
|
|
"--runtime-id", "ts-client",
|
|
"--runtime-kind", "node",
|
|
"--display-name", "TS Dialer",
|
|
"--agent-id", "ts-client-agent",
|
|
"--target-host", "127.0.0.1",
|
|
"--target-port", String(rustPort),
|
|
"--schema-path", interopSchemaPath,
|
|
], cultNetTsRoot);
|
|
assert.equal(tsDial.remoteHello.runtimeId, "rust-peer");
|
|
assert.equal(tsDial.hasInteropSchema, true);
|
|
assert.equal(tsDial.retrievedNote.authorRuntimeId, "rust-peer");
|
|
assert.ok(tsDial.mutatedNote.tags.includes("decorated:ts-client"));
|
|
assert.equal(tsDial.mutationReceipt.accepted, true);
|
|
assert.equal(tsDial.fireReceipt.shotsFired, 1);
|
|
|
|
logInteropPhase("schema-v0", "typescript dials typescript over rudp");
|
|
const tsRudpDial = await runJsonCommand("ts-rudp-dial", process.execPath, [
|
|
tsPeerScript,
|
|
"dial",
|
|
"--runtime-id", "ts-rudp-client",
|
|
"--runtime-kind", "node",
|
|
"--display-name", "TS RUDP Dialer",
|
|
"--agent-id", "ts-rudp-client-agent",
|
|
"--target-host", "127.0.0.1",
|
|
"--target-rudp-port", String(tsPort),
|
|
"--schema-path", interopSchemaPath,
|
|
], cultNetTsRoot);
|
|
assert.equal(tsRudpDial.transport, "rudp");
|
|
assert.equal(tsRudpDial.remoteHello.runtimeId, "ts-peer");
|
|
assert.equal(tsRudpDial.hasInteropSchema, true);
|
|
assert.equal(tsRudpDial.retrievedNote.authorRuntimeId, "ts-peer");
|
|
assert.ok(tsRudpDial.mutatedNote.tags.includes("decorated:ts-rudp-client"));
|
|
assert.equal(tsRudpDial.fireReceipt.accepted, true);
|
|
|
|
logInteropPhase("schema-v0", "rust dials csharp");
|
|
const rustDial = await runJsonCommand("rust-dial", rustBinaryPath, [
|
|
"dial",
|
|
"--runtime-id", "rust-client",
|
|
"--runtime-kind", "rust",
|
|
"--display-name", "Rust Dialer",
|
|
"--agent-id", "rust-client-agent",
|
|
"--target-host", "127.0.0.1",
|
|
"--target-port", String(csharpPort),
|
|
"--schema-path", interopSchemaPath,
|
|
], cultnetRsRoot);
|
|
assert.equal(rustDial.remoteHello.runtimeId, "csharp-peer");
|
|
assert.equal(rustDial.hasInteropSchema, true);
|
|
assert.equal(rustDial.retrievedNote.authorRuntimeId, "csharp-peer");
|
|
assert.ok(rustDial.mutatedNote.tags.includes("decorated:rust-client"));
|
|
assert.equal(rustDial.fireReceipt.ammoRemaining, 29);
|
|
|
|
logInteropPhase("schema-v0", "csharp dials typescript");
|
|
const csharpDial = await runJsonCommand("csharp-dial", dotnetCommand, [
|
|
csharpDllPath,
|
|
"dial",
|
|
"--runtime-id", "csharp-client",
|
|
"--runtime-kind", "dotnet",
|
|
"--display-name", "CSharp Dialer",
|
|
"--agent-id", "csharp-client-agent",
|
|
"--target-host", "127.0.0.1",
|
|
"--target-port", String(tsPort),
|
|
"--schema-path", interopSchemaPath,
|
|
], cultLibRoot);
|
|
assert.equal(csharpDial.remoteHello.runtimeId, "ts-peer");
|
|
assert.equal(csharpDial.hasInteropSchema, true);
|
|
assert.equal(csharpDial.retrievedNote.authorRuntimeId, "ts-peer");
|
|
assert.ok(csharpDial.mutatedNote.tags.includes("decorated:csharp-client"));
|
|
assert.equal(csharpDial.fireReceipt.accepted, true);
|
|
|
|
logInteropPhase("schema-v0", "python dials rust");
|
|
const pythonDial = await runJsonCommand("python-dial", pythonCommand, [
|
|
"-m", "cultnet_py.interop_peer",
|
|
"dial",
|
|
"--runtime-id", "python-client",
|
|
"--runtime-kind", "python",
|
|
"--display-name", "Python Dialer",
|
|
"--agent-id", "python-client-agent",
|
|
"--target-host", "127.0.0.1",
|
|
"--target-port", String(rustPort),
|
|
"--schema-path", interopSchemaPath,
|
|
], cultcachePyRoot, { PYTHONPATH: cultcachePySrc });
|
|
assert.equal(pythonDial.remoteHello.runtimeId, "rust-peer");
|
|
assert.equal(pythonDial.hasInteropSchema, true);
|
|
assert.equal(pythonDial.retrievedNote.authorRuntimeId, "rust-peer");
|
|
assert.ok(pythonDial.mutatedNote.tags.includes("decorated:python-client"));
|
|
assert.equal(pythonDial.fireReceipt.accepted, true);
|
|
|
|
logInteropPhase("schema-v0", "python dials python over rudp");
|
|
const pythonRudpDial = await runJsonCommand("python-rudp-dial", pythonCommand, [
|
|
"-m", "cultnet_py.interop_peer",
|
|
"dial",
|
|
"--runtime-id", "python-rudp-client",
|
|
"--runtime-kind", "python",
|
|
"--display-name", "Python RUDP Dialer",
|
|
"--agent-id", "python-rudp-client-agent",
|
|
"--target-host", "127.0.0.1",
|
|
"--target-rudp-port", String(pythonPort),
|
|
"--schema-path", interopSchemaPath,
|
|
], cultcachePyRoot, { PYTHONPATH: cultcachePySrc });
|
|
assert.equal(pythonRudpDial.transport, "rudp");
|
|
assert.equal(pythonRudpDial.remoteHello.runtimeId, "python-peer");
|
|
assert.equal(pythonRudpDial.hasInteropSchema, true);
|
|
assert.equal(pythonRudpDial.retrievedNote.authorRuntimeId, "python-peer");
|
|
assert.ok(pythonRudpDial.mutatedNote.tags.includes("decorated:python-rudp-client"));
|
|
assert.equal(pythonRudpDial.fireReceipt.accepted, true);
|
|
|
|
logInteropPhase("schema-v0", "typescript dials python");
|
|
const tsDialPython = await runJsonCommand("ts-dial-python", process.execPath, [
|
|
tsPeerScript,
|
|
"dial",
|
|
"--runtime-id", "ts-python-client",
|
|
"--runtime-kind", "node",
|
|
"--display-name", "TS Python Dialer",
|
|
"--agent-id", "ts-python-client-agent",
|
|
"--target-host", "127.0.0.1",
|
|
"--target-port", String(pythonPort),
|
|
"--schema-path", interopSchemaPath,
|
|
], cultNetTsRoot);
|
|
assert.equal(tsDialPython.remoteHello.runtimeId, "python-peer");
|
|
assert.equal(tsDialPython.hasInteropSchema, true);
|
|
assert.equal(tsDialPython.retrievedNote.authorRuntimeId, "python-peer");
|
|
assert.ok(tsDialPython.mutatedNote.tags.includes("decorated:ts-python-client"));
|
|
assert.equal(tsDialPython.fireReceipt.ammoRemaining, 29);
|
|
|
|
logInteropPhase("schema-v0", "python wire catalog");
|
|
const pythonWireCatalog = await requestCultMeshFromPython(pythonPort, {
|
|
schemaVersion: "cultnet.schema_catalog_request.v0",
|
|
messageId: "python-wire-catalog",
|
|
includeSchemaJson: true,
|
|
kinds: ["wire_message"],
|
|
});
|
|
assert.equal(pythonWireCatalog.schemaVersion, "cultnet.schema_catalog_response.v0");
|
|
assert.equal(pythonWireCatalog.messageId, "python-wire-catalog");
|
|
const pythonWireVersions = new Set(pythonWireCatalog.schemas.map((schema: any) => schema.schemaVersion));
|
|
for (const schemaVersion of [
|
|
"cultnet.document_delete.v0",
|
|
"cultnet.database_change_raw.v0",
|
|
"cultnet.shard_log_response.v0",
|
|
"cultnet.simulation_consensus_candidate.v0",
|
|
"cultmesh.peer_exchange_response.v0",
|
|
]) {
|
|
assert.ok(pythonWireVersions.has(schemaVersion), `Python wire catalog should include ${schemaVersion}`);
|
|
}
|
|
const pythonDeleteDescriptor = pythonWireCatalog.schemas.find((schema: any) => schema.schemaVersion === "cultnet.document_delete.v0");
|
|
assert.equal(pythonDeleteDescriptor.kind, "wire_message");
|
|
assert.ok(pythonDeleteDescriptor.wireContracts.includes("cultnet.schema.v0"));
|
|
assert.ok(String(pythonDeleteDescriptor.schemaJson).includes("cultnet.document_delete.v0"));
|
|
const pythonDeleteSchema = JSON.parse(String(pythonDeleteDescriptor.schemaJson));
|
|
assert.deepEqual(pythonDeleteSchema.required, ["schemaVersion", "messageId", "schemaId", "recordKey"]);
|
|
assert.equal(pythonDeleteSchema.properties.recordKey.type, "string");
|
|
|
|
logInteropPhase("schema-v0", "python verse catalog");
|
|
const pythonVerseCatalog = await requestCultMeshFromPython(pythonPort, {
|
|
schemaVersion: "cultmesh.verse_catalog_request.v0",
|
|
messageId: "python-verses",
|
|
transportVersion: "cultmesh.v0",
|
|
});
|
|
assert.equal(pythonVerseCatalog.schemaVersion, "cultmesh.verse_catalog_response.v0");
|
|
assert.equal(pythonVerseCatalog.messageId, "python-verses");
|
|
assert.equal(pythonVerseCatalog.verses[0].verseId, "python-interop");
|
|
assert.equal(pythonVerseCatalog.verses[0].compatibility.transportVersion, "cultmesh.v0");
|
|
|
|
const pythonPeerExchange = await requestCultMeshFromPython(pythonPort, {
|
|
schemaVersion: "cultmesh.peer_exchange_request.v0",
|
|
messageId: "python-peers",
|
|
verseId: "python-interop",
|
|
roles: ["read-replica"],
|
|
});
|
|
assert.equal(pythonPeerExchange.schemaVersion, "cultmesh.peer_exchange_response.v0");
|
|
assert.equal(pythonPeerExchange.messageId, "python-peers");
|
|
assert.equal(pythonPeerExchange.peers[0].peerId, "python-peer");
|
|
assert.ok(pythonPeerExchange.peers[0].roles.includes("read-replica"));
|
|
|
|
logInteropPhase("schema-v0", "python client helpers");
|
|
const pythonCultMeshClient = await runPythonCultMeshClient(pythonPort);
|
|
assert.equal(pythonCultMeshClient.verses[0].verseId, "python-interop");
|
|
assert.equal(pythonCultMeshClient.verses[0].transportVersion, "cultmesh.v0");
|
|
assert.equal(pythonCultMeshClient.peers[0].peerId, "python-peer");
|
|
assert.ok(pythonCultMeshClient.peers[0].roles.includes("read-replica"));
|
|
|
|
const pythonCultNetClient = await runPythonCultNetRawClient(pythonPort);
|
|
assert.ok(pythonCultNetClient.wireSchemaCount >= 1);
|
|
assert.ok(pythonCultNetClient.snapshotRecordKeys.includes("note:python-peer"));
|
|
assert.equal(pythonCultNetClient.shards[0].shardId, "interop");
|
|
|
|
const pythonCultMeshNodeSync = await runPythonCultMeshNodeSync(pythonPort);
|
|
assert.equal(pythonCultMeshNodeSync.synced[0].recordKey, "note:python-peer");
|
|
assert.equal(pythonCultMeshNodeSync.note.authorRuntimeId, "python-peer");
|
|
|
|
const pythonCultMeshNodeEmit = await runPythonCultMeshNodeEmit(pythonPort);
|
|
assert.equal(pythonCultMeshNodeEmit.synced[0].recordKey, "note:python-node-emit");
|
|
assert.equal(pythonCultMeshNodeEmit.note.authorRuntimeId, "python-node-emit");
|
|
assert.equal(pythonCultMeshNodeEmit.note.title, "Python node emitted raw put");
|
|
|
|
logInteropPhase("schema-v0", "python prediction/subscription");
|
|
const pythonPrediction = await runPythonCultMeshPredictionReconcile(pythonPort);
|
|
assert.ok(pythonPrediction.reconciledKeys.includes("note:python-node-emit"));
|
|
assert.equal(pythonPrediction.note.title, "Python node emitted raw put");
|
|
assert.equal(pythonPrediction.note.authorRuntimeId, "python-node-emit");
|
|
|
|
const pythonSubscription = await runPythonCultNetSubscription(pythonPort);
|
|
assert.equal(pythonSubscription.changeKind, "added");
|
|
assert.equal(pythonSubscription.recordKey, "note:python-sub-client");
|
|
assert.equal(pythonSubscription.subscriptionId, "python-public-sub");
|
|
|
|
const pythonChange = await requestPythonDatabaseChange(pythonPort, {
|
|
schemaVersion: "cultnet.database_subscribe.v0",
|
|
messageId: "python-subscribe",
|
|
subscriptionId: "python-sub",
|
|
schemaIds: [interopNoteSchemaId],
|
|
recordKeys: ["note:ts-python-sub"],
|
|
includeSnapshot: false,
|
|
}, {
|
|
schemaVersion: "cultnet.document_put_raw.v0",
|
|
messageId: "python-sub-put",
|
|
document: {
|
|
schemaId: interopNoteSchemaId,
|
|
recordKey: "note:ts-python-sub",
|
|
storedAt: "2026-06-13T00:00:00Z",
|
|
payloadEncoding: "messagepack",
|
|
payload: encode([
|
|
INTEROP_SCHEMA_VERSION,
|
|
"note:ts-python-sub",
|
|
"ts-python-sub",
|
|
"Subscription note",
|
|
"Python streamed this raw change through the CultNet database lane.",
|
|
["interop", "subscription"],
|
|
]),
|
|
sourceRuntimeId: "ts-python-sub",
|
|
},
|
|
});
|
|
assert.equal(pythonChange.schemaVersion, "cultnet.database_change_raw.v0");
|
|
assert.equal(pythonChange.subscriptionId, "python-sub");
|
|
assert.equal(pythonChange.changeKind, "added");
|
|
assert.equal(pythonChange.document.schemaId, interopNoteSchemaId);
|
|
assert.equal(pythonChange.document.recordKey, "note:ts-python-sub");
|
|
|
|
const pythonShard = await requestPythonShardCatalogAndLog(pythonPort, {
|
|
schemaVersion: "cultnet.shard_catalog_request.v0",
|
|
messageId: "python-shards",
|
|
schemaIds: [interopNoteSchemaId],
|
|
recordKeys: ["note:ts-python-log"],
|
|
}, {
|
|
schemaVersion: "cultnet.document_put_raw.v0",
|
|
messageId: "python-shard-put",
|
|
document: {
|
|
schemaId: interopNoteSchemaId,
|
|
recordKey: "note:ts-python-log",
|
|
storedAt: "2026-06-13T00:00:01Z",
|
|
payloadEncoding: "messagepack",
|
|
payload: encode([
|
|
INTEROP_SCHEMA_VERSION,
|
|
"note:ts-python-log",
|
|
"ts-python-log",
|
|
"Shard log note",
|
|
"Python exposes accepted raw puts through the CultNet shard log lane.",
|
|
["interop", "shard-log"],
|
|
]),
|
|
sourceRuntimeId: "ts-python-log",
|
|
},
|
|
}, {
|
|
schemaVersion: "cultnet.shard_log_request.v0",
|
|
messageId: "python-log",
|
|
shardId: "interop",
|
|
shardEpoch: 1,
|
|
afterSequence: 0,
|
|
});
|
|
assert.equal(pythonShard.catalog.schemaVersion, "cultnet.shard_catalog_response.v0");
|
|
assert.equal(pythonShard.catalog.messageId, "python-shards");
|
|
assert.equal(pythonShard.catalog.shards[0].shardId, "interop");
|
|
assert.equal(pythonShard.catalog.shards[0].ownerRuntimeId, "python-peer");
|
|
assert.equal(pythonShard.catalog.shards[0].epoch, 1);
|
|
assert.equal(pythonShard.log.schemaVersion, "cultnet.shard_log_response.v0");
|
|
assert.equal(pythonShard.log.messageId, "python-log");
|
|
assert.equal(pythonShard.log.shardId, "interop");
|
|
assert.equal(pythonShard.log.shardEpoch, 1);
|
|
assert.equal(pythonShard.log.resyncRequired, false);
|
|
assert.ok(pythonShard.log.entries.some((entry: any) =>
|
|
entry.changeKind === "added"
|
|
&& entry.put?.messageId === "python-shard-put"
|
|
&& entry.put?.document?.recordKey === "note:ts-python-log"
|
|
));
|
|
|
|
logInteropPhase("schema-v0", "python delete/simulation/witness");
|
|
const pythonDelete = await requestPythonDatabaseDelete(pythonPort, {
|
|
schemaVersion: "cultnet.database_subscribe.v0",
|
|
messageId: "python-delete-subscribe",
|
|
subscriptionId: "python-delete-sub",
|
|
schemaIds: [interopNoteSchemaId],
|
|
recordKeys: ["note:ts-python-delete"],
|
|
includeSnapshot: false,
|
|
}, {
|
|
schemaVersion: "cultnet.document_put_raw.v0",
|
|
messageId: "python-delete-put",
|
|
document: {
|
|
schemaId: interopNoteSchemaId,
|
|
recordKey: "note:ts-python-delete",
|
|
storedAt: "2026-06-13T00:00:01Z",
|
|
payloadEncoding: "messagepack",
|
|
payload: encode([
|
|
INTEROP_SCHEMA_VERSION,
|
|
"note:ts-python-delete",
|
|
"ts-python-delete",
|
|
"Delete note",
|
|
"Python should remove this through the CultNet delete lane.",
|
|
["interop", "delete"],
|
|
]),
|
|
sourceRuntimeId: "ts-python-delete",
|
|
},
|
|
}, {
|
|
schemaVersion: "cultnet.document_delete.v0",
|
|
messageId: "python-delete",
|
|
schemaId: interopNoteSchemaId,
|
|
recordKey: "note:ts-python-delete",
|
|
shardId: "interop",
|
|
shardEpoch: 1,
|
|
}, {
|
|
schemaVersion: "cultnet.shard_log_request.v0",
|
|
messageId: "python-delete-log",
|
|
shardId: "interop",
|
|
shardEpoch: 1,
|
|
afterSequence: 0,
|
|
});
|
|
assert.equal(pythonDelete.change.schemaVersion, "cultnet.database_change_raw.v0");
|
|
assert.equal(pythonDelete.change.subscriptionId, "python-delete-sub");
|
|
assert.equal(pythonDelete.change.changeKind, "removed");
|
|
assert.equal(pythonDelete.change.schemaId, interopNoteSchemaId);
|
|
assert.equal(pythonDelete.change.recordKey, "note:ts-python-delete");
|
|
assert.ok(pythonDelete.log.entries.some((entry: any) =>
|
|
entry.changeKind === "removed"
|
|
&& entry.delete?.messageId === "python-delete"
|
|
&& entry.delete?.schemaId === interopNoteSchemaId
|
|
&& entry.delete?.recordKey === "note:ts-python-delete"
|
|
&& entry.delete?.shardEpoch === 1
|
|
));
|
|
|
|
const claimHash = computeSimulationClaimHash("frame:42", "subject:python-target", "hit");
|
|
const pythonCandidate = await requestPythonSimulationCandidate(pythonPort, {
|
|
schemaVersion: "cultnet.simulation_observation.v0",
|
|
messageId: "python-observation",
|
|
observation: {
|
|
witnessRuntimeId: "ts-witness",
|
|
shardId: "interop",
|
|
shardEpoch: 1,
|
|
frame: 42,
|
|
subjectId: "python-target",
|
|
claimKind: "hit",
|
|
claimHash,
|
|
claimSummary: "python-target was hit by the TypeScript witness",
|
|
weight: 1,
|
|
observedAt: "2026-06-13T00:00:02Z",
|
|
},
|
|
});
|
|
assert.equal(pythonCandidate.schemaVersion, "cultnet.simulation_consensus_candidate.v0");
|
|
assert.equal(pythonCandidate.messageId, "python-observation");
|
|
assert.equal(pythonCandidate.shardId, "interop");
|
|
assert.equal(pythonCandidate.shardEpoch, 1);
|
|
assert.equal(pythonCandidate.frame, 42);
|
|
assert.equal(pythonCandidate.subjectId, "python-target");
|
|
assert.equal(pythonCandidate.claimKind, "hit");
|
|
assert.equal(pythonCandidate.claimHash, claimHash);
|
|
assert.equal(pythonCandidate.witnessCount, 1);
|
|
assert.equal(pythonCandidate.supportWeight, 1);
|
|
assert.equal(pythonCandidate.totalWeight, 1);
|
|
assert.equal(pythonCandidate.hasQuorum, true);
|
|
assert.equal(pythonCandidate.confidence, 1);
|
|
const simulationFactKey = createSimulationFactKey(pythonCandidate);
|
|
const pythonFactSnapshot = await requestPythonSnapshot(pythonPort, {
|
|
schemaVersion: "cultnet.snapshot_request.v0",
|
|
messageId: "python-simulation-fact-snapshot",
|
|
schemaIds: [simulationFactSchemaId],
|
|
recordKeys: [simulationFactKey],
|
|
});
|
|
assert.equal(pythonFactSnapshot.schemaVersion, "cultnet.snapshot_response_raw.v0");
|
|
assert.equal(pythonFactSnapshot.documents[0].schemaId, simulationFactSchemaId);
|
|
assert.equal(pythonFactSnapshot.documents[0].recordKey, simulationFactKey);
|
|
const simulationFactSlots = decode(pythonFactSnapshot.documents[0].payload) as any[];
|
|
assert.equal(simulationFactSlots[0], simulationFactKey.slice("simulation:".length));
|
|
assert.equal(simulationFactSlots[1], "interop");
|
|
assert.equal(simulationFactSlots[3], 42);
|
|
assert.equal(simulationFactSlots[4], "python-target");
|
|
assert.equal(simulationFactSlots[5], "hit");
|
|
assert.equal(simulationFactSlots[6], claimHash);
|
|
assert.equal(simulationFactSlots[8], 1);
|
|
|
|
const pythonWitness = await putAndSnapshotPythonWitnessBundle(pythonPort, {
|
|
schemaVersion: "cultnet.document_put_raw.v0",
|
|
messageId: "python-witness-put",
|
|
document: {
|
|
schemaId: witnessArtifactBundleSchemaId,
|
|
recordKey: "bundle:ts-python-witness",
|
|
storedAt: "2026-06-13T00:00:03Z",
|
|
payloadEncoding: "messagepack",
|
|
payload: encode([
|
|
"bundle:ts-python-witness",
|
|
"interop-proof",
|
|
"2026-06-13T00:00:03Z",
|
|
{
|
|
documentType: "cultnet.interop-note",
|
|
subjectId: "note:python-peer",
|
|
schemaVersion: INTEROP_SCHEMA_VERSION,
|
|
schemaId: interopNoteSchemaId,
|
|
},
|
|
[{ role: "payload", schemaId: interopNoteSchemaId, schemaVersion: INTEROP_SCHEMA_VERSION }],
|
|
[{ role: "log", uri: "cultcache://bundle:ts-python-witness/log", mediaType: "text/plain" }],
|
|
[{ stage: "roundtrip", startedAt: "2026-06-13T00:00:03Z", completedAt: "2026-06-13T00:00:04Z", latencyMs: 1 }],
|
|
{ pipelineId: "interop", runId: "ts-python-witness", runtimeId: "ts-witness", agentId: "ts-agent" },
|
|
]),
|
|
sourceRuntimeId: "ts-witness",
|
|
},
|
|
});
|
|
assert.equal(pythonWitness.catalog.schemaVersion, "cultnet.schema_catalog_response.v0");
|
|
assert.equal(pythonWitness.catalog.schemas[0].schemaId, witnessArtifactBundleSchemaId);
|
|
assert.equal(pythonWitness.catalog.schemas[0].documentType, "cultnet.witness_artifact_bundle");
|
|
assert.ok(String(pythonWitness.catalog.schemas[0].schemaJson).includes("CultNet Witness Artifact Bundle"));
|
|
assert.equal(pythonWitness.snapshot.schemaVersion, "cultnet.snapshot_response_raw.v0");
|
|
assert.equal(pythonWitness.snapshot.documents[0].schemaId, witnessArtifactBundleSchemaId);
|
|
assert.equal(pythonWitness.snapshot.documents[0].recordKey, "bundle:ts-python-witness");
|
|
const witnessSlots = decode(pythonWitness.snapshot.documents[0].payload) as any[];
|
|
assert.equal(witnessSlots[0], "bundle:ts-python-witness");
|
|
assert.equal(witnessSlots[1], "interop-proof");
|
|
assert.equal(witnessSlots[3].subjectId, "note:python-peer");
|
|
assert.equal(witnessSlots[7].runtimeId, "ts-witness");
|
|
|
|
const expectedPeers = ["csharp-peer", "python-peer", "rust-peer", "ts-peer"];
|
|
|
|
logInteropPhase("schema-v0", "typescript discovery probe");
|
|
await expectProbePeers("ts-probe", process.execPath, [
|
|
tsPeerScript,
|
|
"probe",
|
|
"--runtime-id", "ts-prober",
|
|
"--discovery-port", String(discoveryPort),
|
|
"--discovery-group", discoveryGroup,
|
|
"--timeout-ms", "3000",
|
|
], cultNetTsRoot, {}, expectedPeers);
|
|
|
|
logInteropPhase("schema-v0", "rust discovery probe");
|
|
await expectProbePeers("rust-probe", rustBinaryPath, [
|
|
"probe",
|
|
"--runtime-id", "rust-prober",
|
|
"--discovery-port", String(discoveryPort),
|
|
"--discovery-group", discoveryGroup,
|
|
"--timeout-ms", "3000",
|
|
], cultnetRsRoot, {}, expectedPeers);
|
|
|
|
logInteropPhase("schema-v0", "csharp discovery probe");
|
|
await expectProbePeers("csharp-probe", dotnetCommand, [
|
|
csharpDllPath,
|
|
"probe",
|
|
"--runtime-id", "csharp-prober",
|
|
"--discovery-port", String(discoveryPort),
|
|
"--discovery-group", discoveryGroup,
|
|
"--timeout-ms", "3000",
|
|
], cultLibRoot, {}, expectedPeers);
|
|
|
|
logInteropPhase("schema-v0", "python discovery probe");
|
|
await expectProbePeers("python-probe", pythonCommand, [
|
|
"-m", "cultnet_py.interop_peer",
|
|
"probe",
|
|
"--runtime-id", "python-prober",
|
|
"--discovery-port", String(discoveryPort),
|
|
"--discovery-group", discoveryGroup,
|
|
"--timeout-ms", "3000",
|
|
], cultcachePyRoot, { PYTHONPATH: cultcachePySrc }, expectedPeers);
|
|
});
|
|
|
|
test("CultNet TypeScript and Rust full interop peers exchange schema-v0 over RUDP", async (t) => {
|
|
logInteropPhase("ts-rust-full-rudp", "build peers");
|
|
await buildRustInteropPeer();
|
|
cleanInteropStores([
|
|
"rust-rudp-peer",
|
|
"rust-rudp-client-dial",
|
|
"ts-rust-rudp-peer",
|
|
"ts-rust-rudp-client-dial",
|
|
]);
|
|
|
|
const rustTcpPort = await getFreePort();
|
|
const rustRudpPort = await getFreePort();
|
|
const tsTcpPort = await getFreePort();
|
|
const tsRudpPort = await getFreePort();
|
|
const discoveryPort = await getFreePort();
|
|
const advertiseHost = findAdvertiseHost();
|
|
const servers: RunningServeProcess[] = [];
|
|
// Registered before anything is spawned. A peer whose readiness rejects
|
|
// would otherwise skip this and strand every peer already running.
|
|
t.after(async () => {
|
|
await Promise.all(servers.map(stopProcess));
|
|
});
|
|
|
|
servers.push(await spawnServeProcess("rust-rudp", {
|
|
command: rustBinaryPath,
|
|
args: [
|
|
"serve",
|
|
"--runtime-id", "rust-rudp-peer",
|
|
"--runtime-kind", "rust",
|
|
"--display-name", "Rust RUDP Peer",
|
|
"--agent-id", "rust-rudp-agent",
|
|
"--bind-host", "127.0.0.1",
|
|
"--advertise-host", advertiseHost,
|
|
"--tcp-port", String(rustTcpPort),
|
|
"--rudp-port", String(rustRudpPort),
|
|
"--discovery-port", String(discoveryPort),
|
|
"--discovery-group", discoveryGroup,
|
|
"--schema-path", interopSchemaPath,
|
|
],
|
|
cwd: cultnetRsRoot,
|
|
}));
|
|
await servers[servers.length - 1].ready;
|
|
|
|
servers.push(await spawnServeProcess("ts-rust-rudp", {
|
|
command: process.execPath,
|
|
args: [
|
|
tsPeerScript,
|
|
"serve",
|
|
"--runtime-id", "ts-rust-rudp-peer",
|
|
"--runtime-kind", "node",
|
|
"--display-name", "TypeScript Rust RUDP Peer",
|
|
"--agent-id", "ts-rust-rudp-agent",
|
|
"--bind-host", "127.0.0.1",
|
|
"--advertise-host", advertiseHost,
|
|
"--tcp-port", String(tsTcpPort),
|
|
"--rudp-port", String(tsRudpPort),
|
|
"--discovery-port", String(discoveryPort),
|
|
"--discovery-group", discoveryGroup,
|
|
"--schema-path", interopSchemaPath,
|
|
],
|
|
cwd: cultNetTsRoot,
|
|
}));
|
|
await servers[servers.length - 1].ready;
|
|
|
|
logInteropPhase("ts-rust-full-rudp", "typescript dials rust over rudp");
|
|
const tsRudpDial = await runJsonCommand("ts-rust-rudp-dial", process.execPath, [
|
|
tsPeerScript,
|
|
"dial",
|
|
"--runtime-id", "ts-rust-rudp-client",
|
|
"--runtime-kind", "node",
|
|
"--display-name", "TS Rust RUDP Dialer",
|
|
"--agent-id", "ts-rust-rudp-client-agent",
|
|
"--target-host", "127.0.0.1",
|
|
"--target-rudp-port", String(rustRudpPort),
|
|
"--schema-path", interopSchemaPath,
|
|
], cultNetTsRoot);
|
|
assert.equal(tsRudpDial.transport, "rudp");
|
|
assert.equal(tsRudpDial.remoteHello.runtimeId, "rust-rudp-peer");
|
|
assert.equal(tsRudpDial.hasInteropSchema, true);
|
|
assert.equal(tsRudpDial.retrievedNote.authorRuntimeId, "rust-rudp-peer");
|
|
assert.ok(tsRudpDial.mutatedNote.tags.includes("decorated:ts-rust-rudp-client"));
|
|
assert.equal(tsRudpDial.fireReceipt.accepted, true);
|
|
|
|
logInteropPhase("ts-rust-full-rudp", "rust dials typescript over rudp");
|
|
const rustRudpDial = await runJsonCommand("rust-rudp-dial", rustBinaryPath, [
|
|
"dial",
|
|
"--runtime-id", "rust-rudp-client",
|
|
"--runtime-kind", "rust",
|
|
"--display-name", "Rust RUDP Dialer",
|
|
"--agent-id", "rust-rudp-client-agent",
|
|
"--target-host", "127.0.0.1",
|
|
"--target-rudp-port", String(tsRudpPort),
|
|
"--schema-path", interopSchemaPath,
|
|
], cultnetRsRoot);
|
|
assert.equal(rustRudpDial.transport, "rudp");
|
|
assert.equal(rustRudpDial.remoteHello.runtimeId, "ts-rust-rudp-peer");
|
|
assert.equal(rustRudpDial.hasInteropSchema, true);
|
|
assert.equal(rustRudpDial.retrievedNote.authorRuntimeId, "ts-rust-rudp-peer");
|
|
assert.ok(rustRudpDial.mutatedNote.tags.includes("decorated:rust-rudp-client"));
|
|
assert.equal(rustRudpDial.fireReceipt.accepted, true);
|
|
});
|
|
|
|
test("CultNet TypeScript and Python exchange schema frames over the shared RUDP socket transport", async () => {
|
|
const pythonPeer = spawnPythonRudpPeer();
|
|
const clientSocket = await bindUdpSocket();
|
|
let client: CultNetRudpSocketTransportConnection | undefined;
|
|
|
|
try {
|
|
const pythonPort = await pythonPeer.ready;
|
|
client = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "ts-rudp-interop",
|
|
socket: clientSocket,
|
|
mode: "client",
|
|
remoteHost: "127.0.0.1",
|
|
remotePort: pythonPort,
|
|
connectionId: 0x55667788,
|
|
initialSequence: 1,
|
|
resendDelayMs: 25,
|
|
resendPollMs: 5,
|
|
});
|
|
|
|
const receivedFrame = once(client, "frame");
|
|
client.connect(Buffer.from("ts-join"));
|
|
await waitFor(() => client?.connected === true, "TypeScript RUDP client to complete Python handshake");
|
|
client.send("schema", Buffer.from("client-state"));
|
|
|
|
const [frame] = await withTimeout(receivedFrame, 2_000, "Python RUDP response frame");
|
|
assert.equal(frame.channelId, "schema");
|
|
assert.deepEqual(Buffer.from(frame.payload), Buffer.from("python-state"));
|
|
|
|
const [exitCode] = await withTimeout(once(pythonPeer.child, "exit"), 2_000, "Python RUDP peer exit");
|
|
assert.equal(exitCode, 0, pythonPeer.stderr.join(""));
|
|
} finally {
|
|
if (client) {
|
|
client.close();
|
|
} else {
|
|
clientSocket.close();
|
|
}
|
|
if (pythonPeer.child.exitCode === null && !pythonPeer.child.killed) {
|
|
pythonPeer.child.kill("SIGTERM");
|
|
await once(pythonPeer.child, "exit").catch(() => undefined);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("CultNet Python and TypeScript exchange schema frames when Python dials a TypeScript RUDP server", async () => {
|
|
const serverSocket = await bindUdpSocket();
|
|
const server = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "ts-rudp-server-interop",
|
|
socket: serverSocket,
|
|
mode: "server",
|
|
connectionId: 0x66554433,
|
|
initialSequence: 100,
|
|
resendDelayMs: 25,
|
|
resendPollMs: 5,
|
|
});
|
|
const pythonClient = spawnPythonRudpClient(udpSocketPort(serverSocket));
|
|
|
|
try {
|
|
const receivedFrame = once(server, "frame");
|
|
const [frame] = await withTimeout(receivedFrame, 5_000, "Python RUDP client frame");
|
|
assert.equal(frame.channelId, "schema");
|
|
assert.deepEqual(Buffer.from(frame.payload), Buffer.from("python-client-state"));
|
|
server.send("schema", Buffer.from("ts-server-state"));
|
|
|
|
const [exitCode] = await withTimeout(once(pythonClient.child, "exit"), 5_000, "Python RUDP client exit");
|
|
assert.equal(exitCode, 0, pythonClient.stderr.join(""));
|
|
} finally {
|
|
server.close();
|
|
closeUdpSocket(serverSocket);
|
|
if (pythonClient.child.exitCode === null && !pythonClient.child.killed) {
|
|
pythonClient.child.kill("SIGTERM");
|
|
await once(pythonClient.child, "exit").catch(() => undefined);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("CultNet TypeScript and Python exchange schema-v0 MessagePack messages over RUDP", async () => {
|
|
const pythonPeer = spawnPythonRudpMessagePeer();
|
|
const clientSocket = await bindUdpSocket();
|
|
let client: CultNetRudpSocketTransportConnection | undefined;
|
|
|
|
try {
|
|
const pythonPort = await pythonPeer.ready;
|
|
client = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "ts-python-rudp-message-interop",
|
|
socket: clientSocket,
|
|
mode: "client",
|
|
remoteHost: "127.0.0.1",
|
|
remotePort: pythonPort,
|
|
connectionId: 0x55667789,
|
|
initialSequence: 1,
|
|
resendDelayMs: 25,
|
|
resendPollMs: 5,
|
|
});
|
|
|
|
const receivedFrame = once(client, "frame");
|
|
client.connect(Buffer.from("ts-python-message-join"));
|
|
await waitFor(() => client?.connected === true, "TypeScript RUDP client to complete Python message handshake");
|
|
client.send("schema", Buffer.from(encode({
|
|
schemaVersion: "cultnet.schema_catalog_request.v0",
|
|
messageId: "ts-python-schema-message",
|
|
includeSchemaJson: false,
|
|
schemaIds: [],
|
|
kinds: ["wire_message"],
|
|
})));
|
|
|
|
const [frame] = await withTimeout(receivedFrame, 2_000, "Python RUDP schema-v0 response frame");
|
|
assert.equal(frame.channelId, "schema");
|
|
const message = decode(frame.payload) as {
|
|
schemaVersion?: string;
|
|
runtimeId?: string;
|
|
supportsSchemaCatalog?: boolean;
|
|
transportProfiles?: Array<{ transports?: Array<{ protocol?: string; reconnectPolicy?: unknown; channels?: Array<{ channelId?: string }> }> }>;
|
|
};
|
|
assert.equal(message.schemaVersion, "cultnet.hello.v0");
|
|
assert.equal(message.runtimeId, "python-rudp-message-interop");
|
|
assert.equal(message.supportsSchemaCatalog, true);
|
|
assert.equal(message.transportProfiles?.[0]?.transports?.[0]?.protocol, "rudp");
|
|
assertRudpReconnectPolicy("Python", message.transportProfiles?.[0]?.transports?.[0]?.reconnectPolicy);
|
|
assert.equal(message.transportProfiles?.[0]?.transports?.[0]?.channels?.[0]?.channelId, "schema");
|
|
|
|
const [exitCode] = await withTimeout(once(pythonPeer.child, "exit"), 2_000, "Python RUDP message peer exit");
|
|
assert.equal(exitCode, 0, pythonPeer.stderr.join(""));
|
|
} finally {
|
|
if (client) {
|
|
client.close();
|
|
} else {
|
|
clientSocket.close();
|
|
}
|
|
if (pythonPeer.child.exitCode === null && !pythonPeer.child.killed) {
|
|
pythonPeer.child.kill("SIGTERM");
|
|
await once(pythonPeer.child, "exit").catch(() => undefined);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("CultNet Python and TypeScript exchange schema-v0 MessagePack messages over RUDP when Python dials", async () => {
|
|
const serverSocket = await bindUdpSocket();
|
|
const server = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "ts-python-rudp-message-server",
|
|
socket: serverSocket,
|
|
mode: "server",
|
|
connectionId: 0x66554434,
|
|
initialSequence: 100,
|
|
resendDelayMs: 25,
|
|
resendPollMs: 5,
|
|
});
|
|
const pythonClient = spawnPythonRudpMessageClient(udpSocketPort(serverSocket));
|
|
|
|
try {
|
|
const receivedFrame = once(server, "frame");
|
|
const [frame] = await withTimeout(receivedFrame, 5_000, "Python RUDP schema-v0 client frame");
|
|
assert.equal(frame.channelId, "schema");
|
|
const message = decode(frame.payload) as {
|
|
schemaVersion?: string;
|
|
messageId?: string;
|
|
kinds?: string[];
|
|
};
|
|
assert.equal(message.schemaVersion, "cultnet.schema_catalog_request.v0");
|
|
assert.equal(message.messageId, "python-ts-schema-message");
|
|
assert.deepEqual(message.kinds, ["wire_message"]);
|
|
server.send("schema", Buffer.from(encode({
|
|
schemaVersion: "cultnet.hello.v0",
|
|
runtimeId: "ts-python-rudp-message-server",
|
|
runtimeKind: "typescript",
|
|
supportedDocumentTypes: [],
|
|
supportedMutationContracts: [],
|
|
supportedMessageVersions: ["cultnet.hello.v0", "cultnet.schema_catalog_request.v0"],
|
|
transportProfiles: [],
|
|
supportsSchemaCatalog: true,
|
|
})));
|
|
|
|
const [exitCode] = await withTimeout(once(pythonClient.child, "exit"), 5_000, "Python RUDP message client exit");
|
|
assert.equal(exitCode, 0, pythonClient.stderr.join(""));
|
|
} finally {
|
|
server.close();
|
|
closeUdpSocket(serverSocket);
|
|
if (pythonClient.child.exitCode === null && !pythonClient.child.killed) {
|
|
pythonClient.child.kill("SIGTERM");
|
|
await once(pythonClient.child, "exit").catch(() => undefined);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("CultNet TypeScript and Rust exchange schema frames over the shared RUDP socket transport", async () => {
|
|
await buildRustInteropPeer();
|
|
const rustPeer = spawnRustRudpServer();
|
|
const clientSocket = await bindUdpSocket();
|
|
let client: CultNetRudpSocketTransportConnection | undefined;
|
|
|
|
try {
|
|
const rustPort = await rustPeer.ready;
|
|
client = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "ts-rust-rudp-interop",
|
|
socket: clientSocket,
|
|
mode: "client",
|
|
remoteHost: "127.0.0.1",
|
|
remotePort: rustPort,
|
|
connectionId: 0x22446688,
|
|
initialSequence: 1,
|
|
resendDelayMs: 25,
|
|
resendPollMs: 5,
|
|
});
|
|
|
|
const receivedFrame = once(client, "frame");
|
|
client.connect(Buffer.from("ts-rust-join"));
|
|
await waitFor(() => client?.connected === true, "TypeScript RUDP client to complete Rust handshake");
|
|
client.send("schema", Buffer.from("ts-rust-client-state"));
|
|
|
|
const [frame] = await withTimeout(receivedFrame, 2_000, "Rust RUDP response frame");
|
|
assert.equal(frame.channelId, "schema");
|
|
assert.deepEqual(Buffer.from(frame.payload), Buffer.from("rust-server-state"));
|
|
|
|
const [exitCode] = await withTimeout(once(rustPeer.child, "exit"), 2_000, "Rust RUDP peer exit");
|
|
assert.equal(exitCode, 0, rustPeer.stderr.join(""));
|
|
} finally {
|
|
if (client) {
|
|
client.close();
|
|
} else {
|
|
clientSocket.close();
|
|
}
|
|
if (rustPeer.child.exitCode === null && !rustPeer.child.killed) {
|
|
rustPeer.child.kill("SIGTERM");
|
|
await once(rustPeer.child, "exit").catch(() => undefined);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("CultNet Rust and TypeScript exchange schema frames when Rust dials a TypeScript RUDP server", async () => {
|
|
await buildRustInteropPeer();
|
|
const serverSocket = await bindUdpSocket();
|
|
const server = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "ts-rust-rudp-server-interop",
|
|
socket: serverSocket,
|
|
mode: "server",
|
|
connectionId: 0x88664422,
|
|
initialSequence: 100,
|
|
resendDelayMs: 25,
|
|
resendPollMs: 5,
|
|
});
|
|
const rustClient = spawnRustRudpClient(udpSocketPort(serverSocket));
|
|
|
|
try {
|
|
const receivedFrame = once(server, "frame");
|
|
const [frame] = await withTimeout(receivedFrame, 2_000, "Rust RUDP client frame");
|
|
assert.equal(frame.channelId, "schema");
|
|
assert.deepEqual(Buffer.from(frame.payload), Buffer.from("rust-client-state"));
|
|
server.send("schema", Buffer.from("ts-rust-server-state"));
|
|
|
|
const [exitCode] = await withTimeout(once(rustClient.child, "exit"), 2_000, "Rust RUDP client exit");
|
|
assert.equal(exitCode, 0, rustClient.stderr.join(""));
|
|
} finally {
|
|
server.close();
|
|
closeUdpSocket(serverSocket);
|
|
if (rustClient.child.exitCode === null && !rustClient.child.killed) {
|
|
rustClient.child.kill("SIGTERM");
|
|
await once(rustClient.child, "exit").catch(() => undefined);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("CultNet TypeScript and Rust exchange schema-v0 MessagePack messages over RUDP", async () => {
|
|
await buildRustInteropPeer();
|
|
const rustPeer = spawnRustRudpMessageServer();
|
|
const clientSocket = await bindUdpSocket();
|
|
let client: CultNetRudpSocketTransportConnection | undefined;
|
|
|
|
try {
|
|
const rustPort = await rustPeer.ready;
|
|
client = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "ts-rust-rudp-message-interop",
|
|
socket: clientSocket,
|
|
mode: "client",
|
|
remoteHost: "127.0.0.1",
|
|
remotePort: rustPort,
|
|
connectionId: 0x22446689,
|
|
initialSequence: 1,
|
|
resendDelayMs: 25,
|
|
resendPollMs: 5,
|
|
});
|
|
|
|
const receivedFrame = once(client, "frame");
|
|
client.connect(Buffer.from("ts-rust-message-join"));
|
|
await waitFor(() => client?.connected === true, "TypeScript RUDP client to complete Rust message handshake");
|
|
client.send("schema", Buffer.from(encode({
|
|
schemaVersion: "cultnet.schema_catalog_request.v0",
|
|
messageId: "ts-rust-schema-message",
|
|
includeSchemaJson: false,
|
|
schemaIds: [],
|
|
kinds: ["wire_message"],
|
|
})));
|
|
|
|
const [frame] = await withTimeout(receivedFrame, 2_000, "Rust RUDP schema-v0 response frame");
|
|
assert.equal(frame.channelId, "schema");
|
|
const message = decode(frame.payload) as {
|
|
schemaVersion?: string;
|
|
runtimeId?: string;
|
|
supportsSchemaCatalog?: boolean;
|
|
transportProfiles?: Array<{ transports?: Array<{ protocol?: string; reconnectPolicy?: unknown; channels?: Array<{ channelId?: string }> }> }>;
|
|
};
|
|
assert.equal(message.schemaVersion, "cultnet.hello.v0");
|
|
assert.equal(message.runtimeId, "rust-rudp-message-interop");
|
|
assert.equal(message.supportsSchemaCatalog, true);
|
|
assert.equal(message.transportProfiles?.[0]?.transports?.[0]?.protocol, "rudp");
|
|
assertRudpReconnectPolicy("Rust", message.transportProfiles?.[0]?.transports?.[0]?.reconnectPolicy);
|
|
assert.equal(message.transportProfiles?.[0]?.transports?.[0]?.channels?.[0]?.channelId, "schema");
|
|
|
|
const [exitCode] = await withTimeout(once(rustPeer.child, "exit"), 2_000, "Rust RUDP message peer exit");
|
|
assert.equal(exitCode, 0, rustPeer.stderr.join(""));
|
|
} finally {
|
|
if (client) {
|
|
client.close();
|
|
} else {
|
|
clientSocket.close();
|
|
}
|
|
if (rustPeer.child.exitCode === null && !rustPeer.child.killed) {
|
|
rustPeer.child.kill("SIGTERM");
|
|
await once(rustPeer.child, "exit").catch(() => undefined);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("CultNet Rust and TypeScript exchange schema-v0 MessagePack messages over RUDP when Rust dials", async () => {
|
|
await buildRustInteropPeer();
|
|
const serverSocket = await bindUdpSocket();
|
|
const server = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "ts-rust-rudp-message-server",
|
|
socket: serverSocket,
|
|
mode: "server",
|
|
connectionId: 0x88664423,
|
|
initialSequence: 100,
|
|
resendDelayMs: 25,
|
|
resendPollMs: 5,
|
|
});
|
|
const rustClient = spawnRustRudpMessageClient(udpSocketPort(serverSocket));
|
|
|
|
try {
|
|
const receivedFrame = once(server, "frame");
|
|
const [frame] = await withTimeout(receivedFrame, 2_000, "Rust RUDP schema-v0 client frame");
|
|
assert.equal(frame.channelId, "schema");
|
|
const message = decode(frame.payload) as {
|
|
schemaVersion?: string;
|
|
messageId?: string;
|
|
kinds?: string[];
|
|
};
|
|
assert.equal(message.schemaVersion, "cultnet.schema_catalog_request.v0");
|
|
assert.equal(message.messageId, "rust-ts-schema-message");
|
|
assert.deepEqual(message.kinds, ["wire_message"]);
|
|
server.send("schema", Buffer.from(encode({
|
|
schemaVersion: "cultnet.hello.v0",
|
|
runtimeId: "ts-rust-rudp-message-server",
|
|
runtimeKind: "typescript",
|
|
supportedDocumentTypes: [],
|
|
supportedMutationContracts: [],
|
|
supportedMessageVersions: ["cultnet.hello.v0", "cultnet.schema_catalog_request.v0"],
|
|
transportProfiles: [],
|
|
supportsSchemaCatalog: true,
|
|
})));
|
|
|
|
const [exitCode] = await withTimeout(once(rustClient.child, "exit"), 2_000, "Rust RUDP message client exit");
|
|
assert.equal(exitCode, 0, rustClient.stderr.join(""));
|
|
} finally {
|
|
server.close();
|
|
closeUdpSocket(serverSocket);
|
|
if (rustClient.child.exitCode === null && !rustClient.child.killed) {
|
|
rustClient.child.kill("SIGTERM");
|
|
await once(rustClient.child, "exit").catch(() => undefined);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("CultNet TypeScript and C# exchange schema frames over the shared RUDP socket transport", async () => {
|
|
await buildCSharpInteropPeer();
|
|
const csharpPeer = spawnCSharpRudpServer();
|
|
const clientSocket = await bindUdpSocket();
|
|
let client: CultNetRudpSocketTransportConnection | undefined;
|
|
|
|
try {
|
|
const csharpPort = await csharpPeer.ready;
|
|
client = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "ts-csharp-rudp-interop",
|
|
socket: clientSocket,
|
|
mode: "client",
|
|
remoteHost: "127.0.0.1",
|
|
remotePort: csharpPort,
|
|
connectionId: 0x33557799,
|
|
initialSequence: 1,
|
|
resendDelayMs: 25,
|
|
resendPollMs: 5,
|
|
});
|
|
|
|
const receivedFrame = once(client, "frame");
|
|
client.connect(Buffer.from("ts-csharp-join"));
|
|
await waitFor(() => client?.connected === true, "TypeScript RUDP client to complete C# handshake");
|
|
client.send("schema", Buffer.from("ts-csharp-client-state"));
|
|
|
|
const [frame] = await withTimeout(receivedFrame, 2_000, "C# RUDP response frame");
|
|
assert.equal(frame.channelId, "schema");
|
|
assert.deepEqual(Buffer.from(frame.payload), Buffer.from("csharp-server-state"));
|
|
|
|
const [exitCode] = await withTimeout(once(csharpPeer.child, "exit"), 2_000, "C# RUDP peer exit");
|
|
assert.equal(exitCode, 0, csharpPeer.stderr.join(""));
|
|
} finally {
|
|
if (client) {
|
|
client.close();
|
|
} else {
|
|
clientSocket.close();
|
|
}
|
|
if (csharpPeer.child.exitCode === null && !csharpPeer.child.killed) {
|
|
csharpPeer.child.kill("SIGTERM");
|
|
await once(csharpPeer.child, "exit").catch(() => undefined);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("CultNet C# and TypeScript exchange schema frames when C# dials a TypeScript RUDP server", async () => {
|
|
await buildCSharpInteropPeer();
|
|
const serverSocket = await bindUdpSocket();
|
|
const server = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "ts-csharp-rudp-server-interop",
|
|
socket: serverSocket,
|
|
mode: "server",
|
|
connectionId: 0x99775533,
|
|
initialSequence: 100,
|
|
resendDelayMs: 25,
|
|
resendPollMs: 5,
|
|
});
|
|
const csharpClient = spawnCSharpRudpClient(udpSocketPort(serverSocket));
|
|
|
|
try {
|
|
const receivedFrame = once(server, "frame");
|
|
const [frame] = await withTimeout(receivedFrame, 2_000, "C# RUDP client frame");
|
|
assert.equal(frame.channelId, "schema");
|
|
assert.deepEqual(Buffer.from(frame.payload), Buffer.from("csharp-client-state"));
|
|
server.send("schema", Buffer.from("ts-csharp-server-state"));
|
|
|
|
const [exitCode] = await withTimeout(once(csharpClient.child, "exit"), 2_000, "C# RUDP client exit");
|
|
assert.equal(exitCode, 0, csharpClient.stderr.join(""));
|
|
} finally {
|
|
server.close();
|
|
closeUdpSocket(serverSocket);
|
|
if (csharpClient.child.exitCode === null && !csharpClient.child.killed) {
|
|
csharpClient.child.kill("SIGTERM");
|
|
await once(csharpClient.child, "exit").catch(() => undefined);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("CultNet TypeScript and C# exchange schema-v0 MessagePack messages over RUDP", async () => {
|
|
await buildCSharpInteropPeer();
|
|
const csharpPeer = spawnCSharpRudpMessageServer();
|
|
const clientSocket = await bindUdpSocket();
|
|
let client: CultNetRudpSocketTransportConnection | undefined;
|
|
|
|
try {
|
|
const csharpPort = await csharpPeer.ready;
|
|
client = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "ts-csharp-rudp-message-interop",
|
|
socket: clientSocket,
|
|
mode: "client",
|
|
remoteHost: "127.0.0.1",
|
|
remotePort: csharpPort,
|
|
connectionId: 0x3355779a,
|
|
initialSequence: 1,
|
|
resendDelayMs: 25,
|
|
resendPollMs: 5,
|
|
});
|
|
|
|
const receivedFrame = once(client, "frame");
|
|
client.connect(Buffer.from("ts-csharp-message-join"));
|
|
await waitFor(() => client?.connected === true, "TypeScript RUDP client to complete C# message handshake");
|
|
client.send("schema", Buffer.from(encode({
|
|
schemaVersion: "cultnet.schema_catalog_request.v0",
|
|
messageId: "ts-csharp-schema-message",
|
|
includeSchemaJson: false,
|
|
schemaIds: [],
|
|
kinds: ["wire_message"],
|
|
})));
|
|
|
|
const [frame] = await withTimeout(receivedFrame, 2_000, "C# RUDP schema-v0 response frame");
|
|
assert.equal(frame.channelId, "schema");
|
|
const message = decode(frame.payload) as {
|
|
schemaVersion?: string;
|
|
runtimeId?: string;
|
|
supportsSchemaCatalog?: boolean;
|
|
transportProfiles?: Array<{ transports?: Array<{ protocol?: string; reconnectPolicy?: unknown; channels?: Array<{ channelId?: string }> }> }>;
|
|
};
|
|
assert.equal(message.schemaVersion, "cultnet.hello.v0");
|
|
assert.equal(message.runtimeId, "csharp-rudp-message-interop");
|
|
assert.equal(message.supportsSchemaCatalog, true);
|
|
assert.equal(message.transportProfiles?.[0]?.transports?.[0]?.protocol, "rudp");
|
|
assertRudpReconnectPolicy("C#", message.transportProfiles?.[0]?.transports?.[0]?.reconnectPolicy);
|
|
assert.equal(message.transportProfiles?.[0]?.transports?.[0]?.channels?.[0]?.channelId, "schema");
|
|
|
|
const [exitCode] = await withTimeout(once(csharpPeer.child, "exit"), 2_000, "C# RUDP message peer exit");
|
|
assert.equal(exitCode, 0, csharpPeer.stderr.join(""));
|
|
} finally {
|
|
if (client) {
|
|
client.close();
|
|
} else {
|
|
clientSocket.close();
|
|
}
|
|
if (csharpPeer.child.exitCode === null && !csharpPeer.child.killed) {
|
|
csharpPeer.child.kill("SIGTERM");
|
|
await once(csharpPeer.child, "exit").catch(() => undefined);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("CultNet C# and TypeScript exchange schema-v0 MessagePack messages over RUDP when C# dials", async () => {
|
|
await buildCSharpInteropPeer();
|
|
const serverSocket = await bindUdpSocket();
|
|
const server = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "ts-csharp-rudp-message-server",
|
|
socket: serverSocket,
|
|
mode: "server",
|
|
connectionId: 0x99775534,
|
|
initialSequence: 100,
|
|
resendDelayMs: 25,
|
|
resendPollMs: 5,
|
|
});
|
|
const csharpClient = spawnCSharpRudpMessageClient(udpSocketPort(serverSocket));
|
|
|
|
try {
|
|
const receivedFrame = once(server, "frame");
|
|
const [frame] = await withTimeout(receivedFrame, 2_000, "C# RUDP schema-v0 client frame");
|
|
assert.equal(frame.channelId, "schema");
|
|
const message = decode(frame.payload) as {
|
|
schemaVersion?: string;
|
|
messageId?: string;
|
|
kinds?: string[];
|
|
};
|
|
assert.equal(message.schemaVersion, "cultnet.schema_catalog_request.v0");
|
|
assert.equal(message.messageId, "csharp-ts-schema-message");
|
|
assert.deepEqual(message.kinds, ["wire_message"]);
|
|
server.send("schema", Buffer.from(encode({
|
|
schemaVersion: "cultnet.hello.v0",
|
|
runtimeId: "ts-csharp-rudp-message-server",
|
|
runtimeKind: "typescript",
|
|
supportedDocumentTypes: [],
|
|
supportedMutationContracts: [],
|
|
supportedMessageVersions: ["cultnet.hello.v0", "cultnet.schema_catalog_request.v0"],
|
|
transportProfiles: [],
|
|
supportsSchemaCatalog: true,
|
|
})));
|
|
|
|
const [exitCode] = await withTimeout(once(csharpClient.child, "exit"), 2_000, "C# RUDP message client exit");
|
|
assert.equal(exitCode, 0, csharpClient.stderr.join(""));
|
|
} finally {
|
|
server.close();
|
|
closeUdpSocket(serverSocket);
|
|
if (csharpClient.child.exitCode === null && !csharpClient.child.killed) {
|
|
csharpClient.child.kill("SIGTERM");
|
|
await once(csharpClient.child, "exit").catch(() => undefined);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("CultNet TypeScript and Kotlin exchange schema frames over the shared RUDP socket transport", async () => {
|
|
await buildKotlinInteropPeer();
|
|
const kotlinPeer = spawnKotlinRudpServer();
|
|
const clientSocket = await bindUdpSocket();
|
|
let client: CultNetRudpSocketTransportConnection | undefined;
|
|
|
|
try {
|
|
const kotlinPort = await kotlinPeer.ready;
|
|
client = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "ts-kotlin-rudp-interop",
|
|
socket: clientSocket,
|
|
mode: "client",
|
|
remoteHost: "127.0.0.1",
|
|
remotePort: kotlinPort,
|
|
connectionId: 0x446688aa,
|
|
initialSequence: 1,
|
|
resendDelayMs: 25,
|
|
resendPollMs: 5,
|
|
});
|
|
|
|
const receivedFrame = once(client, "frame");
|
|
client.connect(Buffer.from("ts-kotlin-join"));
|
|
await waitFor(() => client?.connected === true, "TypeScript RUDP client to complete Kotlin handshake");
|
|
client.send("schema", Buffer.from("ts-kotlin-client-state"));
|
|
|
|
const [frame] = await withTimeout(receivedFrame, 2_000, "Kotlin RUDP response frame");
|
|
assert.equal(frame.channelId, "schema");
|
|
assert.deepEqual(Buffer.from(frame.payload), Buffer.from("kotlin-server-state"));
|
|
|
|
const [exitCode] = await withTimeout(once(kotlinPeer.child, "exit"), 2_000, "Kotlin RUDP peer exit");
|
|
assert.equal(exitCode, 0, kotlinPeer.stderr.join(""));
|
|
} finally {
|
|
if (client) {
|
|
client.close();
|
|
} else {
|
|
clientSocket.close();
|
|
}
|
|
if (kotlinPeer.child.exitCode === null && !kotlinPeer.child.killed) {
|
|
kotlinPeer.child.kill("SIGTERM");
|
|
await once(kotlinPeer.child, "exit").catch(() => undefined);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("CultNet Kotlin and TypeScript exchange schema frames when Kotlin dials a TypeScript RUDP server", async () => {
|
|
await buildKotlinInteropPeer();
|
|
const serverSocket = await bindUdpSocket();
|
|
const server = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "ts-kotlin-rudp-server-interop",
|
|
socket: serverSocket,
|
|
mode: "server",
|
|
connectionId: 0xaa886644,
|
|
initialSequence: 100,
|
|
resendDelayMs: 25,
|
|
resendPollMs: 5,
|
|
});
|
|
const kotlinClient = spawnKotlinRudpClient(udpSocketPort(serverSocket));
|
|
|
|
try {
|
|
const receivedFrame = once(server, "frame");
|
|
const [frame] = await withTimeout(receivedFrame, 2_000, "Kotlin RUDP client frame");
|
|
assert.equal(frame.channelId, "schema");
|
|
assert.deepEqual(Buffer.from(frame.payload), Buffer.from("kotlin-client-state"));
|
|
server.send("schema", Buffer.from("ts-kotlin-server-state"));
|
|
|
|
const [exitCode] = await withTimeout(once(kotlinClient.child, "exit"), 2_000, "Kotlin RUDP client exit");
|
|
assert.equal(exitCode, 0, kotlinClient.stderr.join(""));
|
|
} finally {
|
|
server.close();
|
|
closeUdpSocket(serverSocket);
|
|
if (kotlinClient.child.exitCode === null && !kotlinClient.child.killed) {
|
|
kotlinClient.child.kill("SIGTERM");
|
|
await once(kotlinClient.child, "exit").catch(() => undefined);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("CultNet TypeScript and Kotlin exchange schema-v0 MessagePack messages over RUDP", async () => {
|
|
await buildKotlinInteropPeer();
|
|
const kotlinPeer = spawnKotlinRudpMessageServer();
|
|
const clientSocket = await bindUdpSocket();
|
|
let client: CultNetRudpSocketTransportConnection | undefined;
|
|
|
|
try {
|
|
const kotlinPort = await kotlinPeer.ready;
|
|
client = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "ts-kotlin-rudp-message-interop",
|
|
socket: clientSocket,
|
|
mode: "client",
|
|
remoteHost: "127.0.0.1",
|
|
remotePort: kotlinPort,
|
|
connectionId: 0x446688ab,
|
|
initialSequence: 1,
|
|
resendDelayMs: 25,
|
|
resendPollMs: 5,
|
|
});
|
|
|
|
const receivedFrame = once(client, "frame");
|
|
client.connect(Buffer.from("ts-kotlin-message-join"));
|
|
await waitFor(() => client?.connected === true, "TypeScript RUDP client to complete Kotlin message handshake");
|
|
client.send("schema", Buffer.from(encode({
|
|
schemaVersion: "cultnet.schema_catalog_request.v0",
|
|
messageId: "ts-kotlin-schema-message",
|
|
includeSchemaJson: false,
|
|
schemaIds: [],
|
|
kinds: ["wire_message"],
|
|
})));
|
|
|
|
const [frame] = await withTimeout(receivedFrame, 2_000, "Kotlin RUDP schema-v0 response frame");
|
|
assert.equal(frame.channelId, "schema");
|
|
const message = decode(frame.payload) as {
|
|
schemaVersion?: string;
|
|
runtimeId?: string;
|
|
supportsSchemaCatalog?: boolean;
|
|
transportProfiles?: Array<{ transports?: Array<{ protocol?: string; reconnectPolicy?: unknown; channels?: Array<{ channelId?: string }> }> }>;
|
|
};
|
|
assert.equal(message.schemaVersion, "cultnet.hello.v0");
|
|
assert.equal(message.runtimeId, "kotlin-rudp-message-interop");
|
|
assert.equal(message.supportsSchemaCatalog, true);
|
|
assert.equal(message.transportProfiles?.[0]?.transports?.[0]?.protocol, "rudp");
|
|
assertRudpReconnectPolicy("Kotlin", message.transportProfiles?.[0]?.transports?.[0]?.reconnectPolicy);
|
|
assert.equal(message.transportProfiles?.[0]?.transports?.[0]?.channels?.[0]?.channelId, "schema");
|
|
|
|
const [exitCode] = await withTimeout(once(kotlinPeer.child, "exit"), 2_000, "Kotlin RUDP message peer exit");
|
|
assert.equal(exitCode, 0, kotlinPeer.stderr.join(""));
|
|
} finally {
|
|
if (client) {
|
|
client.close();
|
|
} else {
|
|
clientSocket.close();
|
|
}
|
|
if (kotlinPeer.child.exitCode === null && !kotlinPeer.child.killed) {
|
|
kotlinPeer.child.kill("SIGTERM");
|
|
await once(kotlinPeer.child, "exit").catch(() => undefined);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("CultNet Kotlin and TypeScript exchange schema-v0 MessagePack messages over RUDP when Kotlin dials", async () => {
|
|
await buildKotlinInteropPeer();
|
|
const serverSocket = await bindUdpSocket();
|
|
const server = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: "ts-kotlin-rudp-message-server",
|
|
socket: serverSocket,
|
|
mode: "server",
|
|
connectionId: 0xaa886645,
|
|
initialSequence: 100,
|
|
resendDelayMs: 25,
|
|
resendPollMs: 5,
|
|
});
|
|
const kotlinClient = spawnKotlinRudpMessageClient(udpSocketPort(serverSocket));
|
|
|
|
try {
|
|
const receivedFrame = once(server, "frame");
|
|
const [frame] = await withTimeout(receivedFrame, 2_000, "Kotlin RUDP schema-v0 client frame");
|
|
assert.equal(frame.channelId, "schema");
|
|
const message = decode(frame.payload) as {
|
|
schemaVersion?: string;
|
|
messageId?: string;
|
|
kinds?: string[];
|
|
};
|
|
assert.equal(message.schemaVersion, "cultnet.schema_catalog_request.v0");
|
|
assert.equal(message.messageId, "kotlin-ts-schema-message");
|
|
assert.deepEqual(message.kinds, ["wire_message"]);
|
|
server.send("schema", Buffer.from(encode({
|
|
schemaVersion: "cultnet.hello.v0",
|
|
runtimeId: "ts-kotlin-rudp-message-server",
|
|
runtimeKind: "typescript",
|
|
supportedDocumentTypes: [],
|
|
supportedMutationContracts: [],
|
|
supportedMessageVersions: ["cultnet.hello.v0", "cultnet.schema_catalog_request.v0"],
|
|
transportProfiles: [],
|
|
supportsSchemaCatalog: true,
|
|
})));
|
|
|
|
const [exitCode] = await withTimeout(once(kotlinClient.child, "exit"), 2_000, "Kotlin RUDP message client exit");
|
|
assert.equal(exitCode, 0, kotlinClient.stderr.join(""));
|
|
} finally {
|
|
server.close();
|
|
closeUdpSocket(serverSocket);
|
|
if (kotlinClient.child.exitCode === null && !kotlinClient.child.killed) {
|
|
kotlinClient.child.kill("SIGTERM");
|
|
await once(kotlinClient.child, "exit").catch(() => undefined);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("CultNet TypeScript and C# full interop peers exchange schema-v0 over RUDP", async (t) => {
|
|
logInteropPhase("ts-csharp-full-rudp", "build csharp peer");
|
|
await buildCSharpInteropPeer();
|
|
cleanInteropStores([
|
|
"ts-csharp-rudp-peer",
|
|
"ts-csharp-rudp-client-dial",
|
|
]);
|
|
|
|
const csharpTcpPort = await getFreePort();
|
|
const csharpRudpPort = await getFreePort();
|
|
const tsTcpPort = await getFreePort();
|
|
const tsRudpPort = await getFreePort();
|
|
const discoveryPort = await getFreePort();
|
|
const advertiseHost = findAdvertiseHost();
|
|
const servers: RunningServeProcess[] = [];
|
|
// Registered before anything is spawned. A peer whose readiness rejects
|
|
// would otherwise skip this and strand every peer already running.
|
|
t.after(async () => {
|
|
await Promise.all(servers.map(stopProcess));
|
|
});
|
|
|
|
servers.push(await spawnServeProcess("csharp-rudp", {
|
|
command: dotnetCommand,
|
|
args: [
|
|
csharpDllPath,
|
|
"serve",
|
|
"--runtime-id", "csharp-rudp-peer",
|
|
"--runtime-kind", "dotnet",
|
|
"--display-name", "CSharp RUDP Peer",
|
|
"--agent-id", "csharp-rudp-agent",
|
|
"--bind-host", "127.0.0.1",
|
|
"--advertise-host", advertiseHost,
|
|
"--tcp-port", String(csharpTcpPort),
|
|
"--rudp-port", String(csharpRudpPort),
|
|
"--discovery-port", String(discoveryPort),
|
|
"--discovery-group", discoveryGroup,
|
|
"--schema-path", interopSchemaPath,
|
|
],
|
|
cwd: cultLibRoot,
|
|
}));
|
|
await servers[servers.length - 1].ready;
|
|
|
|
servers.push(await spawnServeProcess("ts-csharp-rudp", {
|
|
command: process.execPath,
|
|
args: [
|
|
tsPeerScript,
|
|
"serve",
|
|
"--runtime-id", "ts-csharp-rudp-peer",
|
|
"--runtime-kind", "node",
|
|
"--display-name", "TypeScript CSharp RUDP Peer",
|
|
"--agent-id", "ts-csharp-rudp-agent",
|
|
"--bind-host", "127.0.0.1",
|
|
"--advertise-host", advertiseHost,
|
|
"--tcp-port", String(tsTcpPort),
|
|
"--rudp-port", String(tsRudpPort),
|
|
"--discovery-port", String(discoveryPort),
|
|
"--discovery-group", discoveryGroup,
|
|
"--schema-path", interopSchemaPath,
|
|
],
|
|
cwd: cultNetTsRoot,
|
|
}));
|
|
await servers[servers.length - 1].ready;
|
|
|
|
logInteropPhase("ts-csharp-full-rudp", "typescript dials csharp over rudp");
|
|
const tsRudpDial = await runJsonCommand("ts-csharp-rudp-dial", process.execPath, [
|
|
tsPeerScript,
|
|
"dial",
|
|
"--runtime-id", "ts-csharp-rudp-client",
|
|
"--runtime-kind", "node",
|
|
"--display-name", "TS CSharp RUDP Dialer",
|
|
"--agent-id", "ts-csharp-rudp-client-agent",
|
|
"--target-host", "127.0.0.1",
|
|
"--target-rudp-port", String(csharpRudpPort),
|
|
"--schema-path", interopSchemaPath,
|
|
], cultNetTsRoot);
|
|
assert.equal(tsRudpDial.transport, "rudp");
|
|
assert.equal(tsRudpDial.remoteHello.runtimeId, "csharp-rudp-peer");
|
|
assert.equal(tsRudpDial.hasInteropSchema, true);
|
|
assert.equal(tsRudpDial.retrievedNote.authorRuntimeId, "csharp-rudp-peer");
|
|
assert.ok(tsRudpDial.mutatedNote.tags.includes("decorated:ts-csharp-rudp-client"));
|
|
assert.equal(tsRudpDial.fireReceipt.accepted, true);
|
|
|
|
logInteropPhase("ts-csharp-full-rudp", "csharp dials typescript over rudp");
|
|
const csharpRudpDial = await runJsonCommand("csharp-rudp-dial", dotnetCommand, [
|
|
csharpDllPath,
|
|
"dial",
|
|
"--runtime-id", "csharp-rudp-client",
|
|
"--runtime-kind", "dotnet",
|
|
"--display-name", "CSharp RUDP Dialer",
|
|
"--agent-id", "csharp-rudp-client-agent",
|
|
"--target-host", "127.0.0.1",
|
|
"--target-rudp-port", String(tsRudpPort),
|
|
"--schema-path", interopSchemaPath,
|
|
], cultLibRoot);
|
|
assert.equal(csharpRudpDial.transport, "rudp");
|
|
assert.equal(csharpRudpDial.remoteHello.runtimeId, "ts-csharp-rudp-peer");
|
|
assert.equal(csharpRudpDial.hasInteropSchema, true);
|
|
assert.equal(csharpRudpDial.retrievedNote.authorRuntimeId, "ts-csharp-rudp-peer");
|
|
assert.ok(csharpRudpDial.mutatedNote.tags.includes("decorated:csharp-rudp-client"));
|
|
assert.equal(csharpRudpDial.fireReceipt.accepted, true);
|
|
});
|
|
|
|
test("CultNet TypeScript and Kotlin full interop peers exchange schema-v0 over RUDP", async (t) => {
|
|
logInteropPhase("ts-kotlin-full-rudp", "build kotlin peer");
|
|
await buildKotlinInteropPeer();
|
|
cleanInteropStores([
|
|
"ts-kotlin-rudp-peer",
|
|
"ts-kotlin-rudp-client-dial",
|
|
]);
|
|
|
|
const kotlinTcpPort = await getFreePort();
|
|
const kotlinRudpPort = await getFreePort();
|
|
const tsTcpPort = await getFreePort();
|
|
const tsRudpPort = await getFreePort();
|
|
const discoveryPort = await getFreePort();
|
|
const advertiseHost = findAdvertiseHost();
|
|
const servers: RunningServeProcess[] = [];
|
|
// Registered before anything is spawned. A peer whose readiness rejects
|
|
// would otherwise skip this and strand every peer already running.
|
|
t.after(async () => {
|
|
await Promise.all(servers.map(stopProcess));
|
|
});
|
|
|
|
servers.push(await spawnServeProcess("kotlin-rudp", {
|
|
command: kotlinJavaCommand,
|
|
args: [
|
|
"-cp", kotlinClasspath,
|
|
"org.gamecult.cultmesh.CultMeshKt",
|
|
"serve",
|
|
"--runtime-id", "kotlin-rudp-peer",
|
|
"--runtime-kind", "kotlin",
|
|
"--display-name", "Kotlin RUDP Peer",
|
|
"--agent-id", "kotlin-rudp-agent",
|
|
"--bind-host", "127.0.0.1",
|
|
"--advertise-host", advertiseHost,
|
|
"--tcp-port", String(kotlinTcpPort),
|
|
"--rudp-port", String(kotlinRudpPort),
|
|
"--discovery-port", String(discoveryPort),
|
|
"--discovery-group", discoveryGroup,
|
|
"--schema-path", interopSchemaPath,
|
|
],
|
|
cwd: cultLibRoot,
|
|
}));
|
|
await servers[servers.length - 1].ready;
|
|
|
|
servers.push(await spawnServeProcess("ts-kotlin-rudp", {
|
|
command: process.execPath,
|
|
args: [
|
|
tsPeerScript,
|
|
"serve",
|
|
"--runtime-id", "ts-kotlin-rudp-peer",
|
|
"--runtime-kind", "node",
|
|
"--display-name", "TypeScript Kotlin RUDP Peer",
|
|
"--agent-id", "ts-kotlin-rudp-agent",
|
|
"--bind-host", "127.0.0.1",
|
|
"--advertise-host", advertiseHost,
|
|
"--tcp-port", String(tsTcpPort),
|
|
"--rudp-port", String(tsRudpPort),
|
|
"--discovery-port", String(discoveryPort),
|
|
"--discovery-group", discoveryGroup,
|
|
"--schema-path", interopSchemaPath,
|
|
],
|
|
cwd: cultNetTsRoot,
|
|
}));
|
|
await servers[servers.length - 1].ready;
|
|
|
|
logInteropPhase("ts-kotlin-full-rudp", "typescript dials kotlin over rudp");
|
|
const tsRudpDial = await runJsonCommand("ts-kotlin-rudp-dial", process.execPath, [
|
|
tsPeerScript,
|
|
"dial",
|
|
"--runtime-id", "ts-kotlin-rudp-client",
|
|
"--runtime-kind", "node",
|
|
"--display-name", "TS Kotlin RUDP Dialer",
|
|
"--agent-id", "ts-kotlin-rudp-client-agent",
|
|
"--target-host", "127.0.0.1",
|
|
"--target-rudp-port", String(kotlinRudpPort),
|
|
"--schema-path", interopSchemaPath,
|
|
], cultNetTsRoot);
|
|
assert.equal(tsRudpDial.transport, "rudp");
|
|
assert.equal(tsRudpDial.remoteHello.runtimeId, "kotlin-rudp-peer");
|
|
assert.equal(tsRudpDial.hasInteropSchema, true);
|
|
assert.equal(tsRudpDial.retrievedNote.authorRuntimeId, "kotlin-rudp-peer");
|
|
assert.ok(tsRudpDial.mutatedNote.tags.includes("decorated:ts-kotlin-rudp-client"));
|
|
assert.equal(tsRudpDial.fireReceipt.accepted, true);
|
|
|
|
logInteropPhase("ts-kotlin-full-rudp", "kotlin dials typescript over rudp");
|
|
const kotlinRudpDial = await runJsonCommand("kotlin-rudp-dial", kotlinJavaCommand, [
|
|
"-cp", kotlinClasspath,
|
|
"org.gamecult.cultmesh.CultMeshKt",
|
|
"dial",
|
|
"--runtime-id", "kotlin-rudp-client",
|
|
"--runtime-kind", "kotlin",
|
|
"--display-name", "Kotlin RUDP Dialer",
|
|
"--agent-id", "kotlin-rudp-client-agent",
|
|
"--target-host", "127.0.0.1",
|
|
"--target-rudp-port", String(tsRudpPort),
|
|
"--schema-path", interopSchemaPath,
|
|
], cultLibRoot);
|
|
assert.equal(kotlinRudpDial.transport, "rudp");
|
|
assert.equal(kotlinRudpDial.remoteHello.runtimeId, "ts-kotlin-rudp-peer");
|
|
assert.equal(kotlinRudpDial.hasInteropSchema, true);
|
|
assert.equal(kotlinRudpDial.retrievedNote.authorRuntimeId, "ts-kotlin-rudp-peer");
|
|
assert.ok(kotlinRudpDial.mutatedNote.tags.includes("decorated:kotlin-rudp-client"));
|
|
assert.equal(kotlinRudpDial.fireReceipt.accepted, true);
|
|
});
|
|
|
|
test("CultNet RUDP reliable schema delivery survives one dropped client data packet across runtimes", async () => {
|
|
for (const peer of rudpServerInteropPeers()) {
|
|
await peer.build();
|
|
const serverPeer = peer.spawn();
|
|
const clientSocket = await bindUdpSocket();
|
|
const lossyBridge = await createRudpLossyBridge({
|
|
remoteHost: "127.0.0.1",
|
|
remotePort: await serverPeer.ready,
|
|
shouldDropClientPacket: (wire) => {
|
|
const packet = decodeRudpPacket(wire);
|
|
return packet.packetType === "data"
|
|
&& packet.channelId === "schema"
|
|
&& Buffer.from(packet.payload ?? []).equals(Buffer.from(peer.clientPayload));
|
|
},
|
|
});
|
|
let client: CultNetRudpSocketTransportConnection | undefined;
|
|
|
|
try {
|
|
client = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: `ts-lossy-${peer.name.replace(/\W/g, "").toLowerCase()}-rudp-interop`,
|
|
socket: clientSocket,
|
|
mode: "client",
|
|
remoteHost: "127.0.0.1",
|
|
remotePort: lossyBridge.port,
|
|
connectionId: peer.connectionId,
|
|
initialSequence: 1,
|
|
resendDelayMs: 25,
|
|
resendPollMs: 5,
|
|
});
|
|
|
|
const receivedFrame = once(client, "frame");
|
|
client.connect(Buffer.from(peer.joinPayload));
|
|
await waitFor(() => client?.connected === true, `TypeScript RUDP client to complete ${peer.name} handshake through lossy bridge`);
|
|
client.send("schema", Buffer.from(peer.clientPayload));
|
|
|
|
const [frame] = await withTimeout(receivedFrame, 2_000, `${peer.name} RUDP response frame after dropped data packet`);
|
|
assert.equal(frame.channelId, "schema");
|
|
assert.deepEqual(Buffer.from(frame.payload), Buffer.from(peer.expectedPayload));
|
|
assert.equal(lossyBridge.droppedClientPackets, 1, `${peer.name} bridge should drop exactly one client data packet`);
|
|
|
|
const [exitCode] = await withTimeout(once(serverPeer.child, "exit"), 2_000, `${peer.name} RUDP peer exit after lossy delivery`);
|
|
assert.equal(exitCode, 0, serverPeer.stderr.join(""));
|
|
} finally {
|
|
if (client) {
|
|
client.close();
|
|
} else {
|
|
clientSocket.close();
|
|
}
|
|
lossyBridge.close();
|
|
if (serverPeer.child.exitCode === null && !serverPeer.child.killed) {
|
|
serverPeer.child.kill("SIGTERM");
|
|
await once(serverPeer.child, "exit").catch(() => undefined);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test("CultNet RUDP reliable schema response survives one dropped server data packet across runtimes", async () => {
|
|
for (const peer of rudpServerInteropPeers()) {
|
|
await peer.build();
|
|
const serverPeer = peer.spawn();
|
|
const clientSocket = await bindUdpSocket();
|
|
const lossyBridge = await createRudpLossyBridge({
|
|
remoteHost: "127.0.0.1",
|
|
remotePort: await serverPeer.ready,
|
|
shouldDropServerPacket: (wire) => {
|
|
const packet = decodeRudpPacket(wire);
|
|
return packet.packetType === "data"
|
|
&& packet.channelId === "schema"
|
|
&& Buffer.from(packet.payload ?? []).equals(Buffer.from(peer.expectedPayload));
|
|
},
|
|
});
|
|
let client: CultNetRudpSocketTransportConnection | undefined;
|
|
|
|
try {
|
|
client = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: `ts-server-loss-${peer.name.replace(/\W/g, "").toLowerCase()}-rudp-interop`,
|
|
socket: clientSocket,
|
|
mode: "client",
|
|
remoteHost: "127.0.0.1",
|
|
remotePort: lossyBridge.port,
|
|
connectionId: peer.connectionId,
|
|
initialSequence: 1,
|
|
resendDelayMs: 25,
|
|
resendPollMs: 5,
|
|
});
|
|
|
|
const receivedFrame = once(client, "frame");
|
|
client.connect(Buffer.from(peer.joinPayload));
|
|
await waitFor(() => client?.connected === true, `TypeScript RUDP client to complete ${peer.name} handshake through server-loss bridge`);
|
|
client.send("schema", Buffer.from(peer.clientPayload));
|
|
|
|
const [frame] = await withTimeout(receivedFrame, 2_000, `${peer.name} RUDP response frame after dropped server data packet`);
|
|
assert.equal(frame.channelId, "schema");
|
|
assert.deepEqual(Buffer.from(frame.payload), Buffer.from(peer.expectedPayload));
|
|
assert.equal(lossyBridge.droppedServerPackets, 1, `${peer.name} bridge should drop exactly one server data packet`);
|
|
|
|
const [exitCode] = await withTimeout(once(serverPeer.child, "exit"), 2_000, `${peer.name} RUDP peer exit after server resend`);
|
|
assert.equal(exitCode, 0, serverPeer.stderr.join(""));
|
|
} finally {
|
|
if (client) {
|
|
client.close();
|
|
} else {
|
|
clientSocket.close();
|
|
}
|
|
lossyBridge.close();
|
|
if (serverPeer.child.exitCode === null && !serverPeer.child.killed) {
|
|
serverPeer.child.kill("SIGTERM");
|
|
await once(serverPeer.child, "exit").catch(() => undefined);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test("CultNet RUDP reliable schema delivery survives reordered client data packets across runtimes", async () => {
|
|
for (const peer of rudpServerInteropPeers()) {
|
|
await peer.build();
|
|
const serverPeer = peer.spawn();
|
|
const clientSocket = await bindUdpSocket();
|
|
const lossyBridge = await createRudpLossyBridge({
|
|
remoteHost: "127.0.0.1",
|
|
remotePort: await serverPeer.ready,
|
|
shouldDelayClientPacket: (wire) => {
|
|
const packet = decodeRudpPacket(wire);
|
|
return packet.packetType === "data"
|
|
&& packet.channelId === "schema"
|
|
&& Buffer.from(packet.payload ?? []).equals(Buffer.from(peer.clientPayload));
|
|
},
|
|
});
|
|
let client: CultNetRudpSocketTransportConnection | undefined;
|
|
|
|
try {
|
|
client = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: `ts-reordered-${peer.name.replace(/\W/g, "").toLowerCase()}-rudp-interop`,
|
|
socket: clientSocket,
|
|
mode: "client",
|
|
remoteHost: "127.0.0.1",
|
|
remotePort: lossyBridge.port,
|
|
connectionId: peer.connectionId,
|
|
initialSequence: 1,
|
|
resendDelayMs: 25,
|
|
resendPollMs: 5,
|
|
});
|
|
|
|
const receivedFrame = once(client, "frame");
|
|
client.connect(Buffer.from(peer.joinPayload));
|
|
await waitFor(() => client?.connected === true, `TypeScript RUDP client to complete ${peer.name} handshake through reorder bridge`);
|
|
client.send("schema", Buffer.from(peer.clientPayload));
|
|
client.send("schema", Buffer.from(`${peer.clientPayload}:second`));
|
|
|
|
const [frame] = await withTimeout(receivedFrame, 2_000, `${peer.name} RUDP response frame after reordered client data packets`);
|
|
assert.equal(frame.channelId, "schema");
|
|
assert.deepEqual(Buffer.from(frame.payload), Buffer.from(peer.expectedPayload));
|
|
assert.equal(lossyBridge.delayedClientPackets, 1, `${peer.name} bridge should delay exactly one client data packet`);
|
|
|
|
const [exitCode] = await withTimeout(once(serverPeer.child, "exit"), 2_000, `${peer.name} RUDP peer exit after reordered delivery`);
|
|
assert.equal(exitCode, 0, serverPeer.stderr.join(""));
|
|
} finally {
|
|
if (client) {
|
|
client.close();
|
|
} else {
|
|
clientSocket.close();
|
|
}
|
|
lossyBridge.close();
|
|
if (serverPeer.child.exitCode === null && !serverPeer.child.killed) {
|
|
serverPeer.child.kill("SIGTERM");
|
|
await once(serverPeer.child, "exit").catch(() => undefined);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test("CultNet RUDP reliable schema response survives reordered server data packets across runtimes", async () => {
|
|
for (const peer of rudpServerInteropPeers()) {
|
|
await peer.build();
|
|
const serverPeer = peer.spawn({
|
|
serverExtraPayload: `${peer.expectedPayload}:second`,
|
|
});
|
|
const clientSocket = await bindUdpSocket();
|
|
const lossyBridge = await createRudpLossyBridge({
|
|
remoteHost: "127.0.0.1",
|
|
remotePort: await serverPeer.ready,
|
|
shouldDelayServerPacket: (wire) => {
|
|
const packet = decodeRudpPacket(wire);
|
|
return packet.packetType === "data"
|
|
&& packet.channelId === "schema"
|
|
&& Buffer.from(packet.payload ?? []).equals(Buffer.from(peer.expectedPayload));
|
|
},
|
|
});
|
|
let client: CultNetRudpSocketTransportConnection | undefined;
|
|
|
|
try {
|
|
client = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: `ts-server-reordered-${peer.name.replace(/\W/g, "").toLowerCase()}-rudp-interop`,
|
|
socket: clientSocket,
|
|
mode: "client",
|
|
remoteHost: "127.0.0.1",
|
|
remotePort: lossyBridge.port,
|
|
connectionId: peer.connectionId,
|
|
initialSequence: 1,
|
|
resendDelayMs: 25,
|
|
resendPollMs: 5,
|
|
});
|
|
|
|
const receivedFrame = once(client, "frame");
|
|
client.connect(Buffer.from(peer.joinPayload));
|
|
await waitFor(() => client?.connected === true, `TypeScript RUDP client to complete ${peer.name} handshake through server reorder bridge`);
|
|
client.send("schema", Buffer.from(peer.clientPayload));
|
|
|
|
const [frame] = await withTimeout(receivedFrame, 2_000, `${peer.name} RUDP response frame after reordered server data packets`);
|
|
assert.equal(frame.channelId, "schema");
|
|
assert.deepEqual(Buffer.from(frame.payload), Buffer.from(peer.expectedPayload));
|
|
assert.equal(lossyBridge.delayedServerPackets, 1, `${peer.name} bridge should delay exactly one server data packet`);
|
|
|
|
const [exitCode] = await withTimeout(once(serverPeer.child, "exit"), 2_000, `${peer.name} RUDP peer exit after server reorder`);
|
|
assert.equal(exitCode, 0, serverPeer.stderr.join(""));
|
|
} finally {
|
|
if (client) {
|
|
client.close();
|
|
} else {
|
|
clientSocket.close();
|
|
}
|
|
lossyBridge.close();
|
|
if (serverPeer.child.exitCode === null && !serverPeer.child.killed) {
|
|
serverPeer.child.kill("SIGTERM");
|
|
await once(serverPeer.child, "exit").catch(() => undefined);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test("CultNet RUDP fragmented schema frames cross runtime boundaries", async () => {
|
|
for (const peer of rudpServerInteropPeers()) {
|
|
await peer.build();
|
|
const clientPayload = `${peer.clientPayload}:fragmented:${"x".repeat(96)}`;
|
|
const serverPayload = `${peer.expectedPayload}:fragmented:${"y".repeat(96)}`;
|
|
const serverPeer = peer.spawn({
|
|
clientPayload,
|
|
serverPayload,
|
|
maxFragmentBytes: 16,
|
|
});
|
|
const clientSocket = await bindUdpSocket();
|
|
let client: CultNetRudpSocketTransportConnection | undefined;
|
|
|
|
try {
|
|
client = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: `ts-fragmented-${peer.name.replace(/\W/g, "").toLowerCase()}-rudp-interop`,
|
|
socket: clientSocket,
|
|
mode: "client",
|
|
remoteHost: "127.0.0.1",
|
|
remotePort: await serverPeer.ready,
|
|
connectionId: peer.connectionId,
|
|
initialSequence: 1,
|
|
resendDelayMs: 25,
|
|
resendPollMs: 5,
|
|
maxFragmentBytes: 16,
|
|
});
|
|
|
|
const receivedFrame = once(client, "frame");
|
|
client.connect(Buffer.from(peer.joinPayload));
|
|
await waitFor(() => client?.connected === true, `TypeScript RUDP client to complete ${peer.name} fragmented handshake`);
|
|
client.send("schema", Buffer.from(clientPayload));
|
|
|
|
const [frame] = await withTimeout(receivedFrame, 2_000, `${peer.name} fragmented RUDP response frame`);
|
|
assert.equal(frame.channelId, "schema");
|
|
assert.deepEqual(Buffer.from(frame.payload), Buffer.from(serverPayload));
|
|
|
|
const [exitCode] = await withTimeout(once(serverPeer.child, "exit"), 2_000, `${peer.name} RUDP peer exit after fragmented exchange`);
|
|
assert.equal(exitCode, 0, serverPeer.stderr.join(""));
|
|
} finally {
|
|
if (client) {
|
|
client.close();
|
|
} else {
|
|
clientSocket.close();
|
|
}
|
|
if (serverPeer.child.exitCode === null && !serverPeer.child.killed) {
|
|
serverPeer.child.kill("SIGTERM");
|
|
await once(serverPeer.child, "exit").catch(() => undefined);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test("CultNet RUDP disconnect reasons cross runtime boundaries", async () => {
|
|
for (const peer of rudpServerInteropPeers()) {
|
|
await peer.build();
|
|
const disconnectReason = `${peer.name.toLowerCase()}-done`;
|
|
const serverPeer = peer.spawn({ disconnectReason });
|
|
const clientSocket = await bindUdpSocket();
|
|
let client: CultNetRudpSocketTransportConnection | undefined;
|
|
|
|
try {
|
|
client = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: `ts-disconnect-${peer.name.replace(/\W/g, "").toLowerCase()}-rudp-interop`,
|
|
socket: clientSocket,
|
|
mode: "client",
|
|
remoteHost: "127.0.0.1",
|
|
remotePort: await serverPeer.ready,
|
|
connectionId: peer.connectionId,
|
|
initialSequence: 1,
|
|
resendDelayMs: 25,
|
|
resendPollMs: 5,
|
|
});
|
|
|
|
const receivedFrame = once(client, "frame");
|
|
const disconnected = once(client, "disconnect");
|
|
client.connect(Buffer.from(peer.joinPayload));
|
|
await waitFor(() => client?.connected === true, `TypeScript RUDP client to complete ${peer.name} disconnect handshake`);
|
|
client.send("schema", Buffer.from(peer.clientPayload));
|
|
|
|
const [frame] = await withTimeout(receivedFrame, 2_000, `${peer.name} RUDP response frame before disconnect`);
|
|
assert.equal(frame.channelId, "schema");
|
|
assert.deepEqual(Buffer.from(frame.payload), Buffer.from(peer.expectedPayload));
|
|
|
|
const [disconnect] = await withTimeout(disconnected, 2_000, `${peer.name} RUDP disconnect reason`);
|
|
assert.deepEqual(Buffer.from(disconnect.reason), Buffer.from(disconnectReason));
|
|
assert.equal(client.connected, false);
|
|
|
|
const [exitCode] = await withTimeout(once(serverPeer.child, "exit"), 2_000, `${peer.name} RUDP peer exit after disconnect`);
|
|
assert.equal(exitCode, 0, serverPeer.stderr.join(""));
|
|
} finally {
|
|
if (client) {
|
|
client.close();
|
|
} else {
|
|
clientSocket.close();
|
|
}
|
|
if (serverPeer.child.exitCode === null && !serverPeer.child.killed) {
|
|
serverPeer.child.kill("SIGTERM");
|
|
await once(serverPeer.child, "exit").catch(() => undefined);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test("CultNet RUDP ping/pong keepalive crosses runtime boundaries", async () => {
|
|
for (const peer of rudpServerInteropPeers()) {
|
|
await peer.build();
|
|
const serverPeer = peer.spawn();
|
|
const clientSocket = await bindUdpSocket();
|
|
let client: CultNetRudpSocketTransportConnection | undefined;
|
|
|
|
try {
|
|
client = new CultNetRudpSocketTransportConnection({
|
|
runtimeId: `ts-ping-${peer.name.replace(/\W/g, "").toLowerCase()}-rudp-interop`,
|
|
socket: clientSocket,
|
|
mode: "client",
|
|
remoteHost: "127.0.0.1",
|
|
remotePort: await serverPeer.ready,
|
|
connectionId: peer.connectionId,
|
|
initialSequence: 1,
|
|
resendDelayMs: 25,
|
|
resendPollMs: 5,
|
|
});
|
|
|
|
const receivedPong = once(client, "pong");
|
|
const receivedFrame = once(client, "frame");
|
|
client.connect(Buffer.from(peer.joinPayload));
|
|
await waitFor(() => client?.connected === true, `TypeScript RUDP client to complete ${peer.name} ping handshake`);
|
|
|
|
const pingPayload = Buffer.from(`${peer.name.toLowerCase()}-ping`);
|
|
client.ping(pingPayload);
|
|
const [pong] = await withTimeout(receivedPong, 2_000, `${peer.name} RUDP pong`);
|
|
assert.deepEqual(Buffer.from(pong.payload), pingPayload);
|
|
|
|
assert.equal(client.checkTimeout(1_000), false);
|
|
client.send("schema", Buffer.from(peer.clientPayload));
|
|
|
|
const [frame] = await withTimeout(receivedFrame, 2_000, `${peer.name} RUDP response frame after ping`);
|
|
assert.equal(frame.channelId, "schema");
|
|
assert.deepEqual(Buffer.from(frame.payload), Buffer.from(peer.expectedPayload));
|
|
|
|
const [exitCode] = await withTimeout(once(serverPeer.child, "exit"), 2_000, `${peer.name} RUDP peer exit after ping`);
|
|
assert.equal(exitCode, 0, serverPeer.stderr.join(""));
|
|
} finally {
|
|
if (client) {
|
|
client.close();
|
|
} else {
|
|
clientSocket.close();
|
|
}
|
|
if (serverPeer.child.exitCode === null && !serverPeer.child.killed) {
|
|
serverPeer.child.kill("SIGTERM");
|
|
await once(serverPeer.child, "exit").catch(() => undefined);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
async function buildInteropPeers(): Promise<void> {
|
|
await buildRustInteropPeer();
|
|
await buildCSharpInteropPeer();
|
|
}
|
|
|
|
async function buildRustInteropPeer(): Promise<void> {
|
|
rustInteropPeerBuild ??= execFileAsync(cargoCommand, ["build", "--quiet", "--example", "cultnet_interop_peer"], {
|
|
cwd: cultnetRsRoot,
|
|
}).then(() => undefined);
|
|
await rustInteropPeerBuild;
|
|
}
|
|
|
|
async function buildCSharpInteropPeer(): Promise<void> {
|
|
csharpInteropPeerBuild ??= execFileAsync(dotnetCommand, ["build", csharpProjectPath, "-nologo"], {
|
|
cwd: cultLibRoot,
|
|
}).then(() => undefined);
|
|
await csharpInteropPeerBuild;
|
|
}
|
|
|
|
async function buildKotlinInteropPeer(): Promise<void> {
|
|
kotlinInteropPeerBuild ??= execFileAsync(
|
|
"powershell",
|
|
["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", resolve(cultmeshKotlinRoot, "build.ps1")],
|
|
{
|
|
cwd: cultLibRoot,
|
|
timeout: 180_000,
|
|
},
|
|
).then(() => undefined);
|
|
await kotlinInteropPeerBuild;
|
|
}
|
|
|
|
function cleanInteropStores(runtimeIds: readonly string[]): void {
|
|
for (const runtimeId of runtimeIds) {
|
|
rmSync(join(tmpdir(), `cultnet-rs-interop-${runtimeId}.msgpack`), { force: true });
|
|
rmSync(join(tmpdir(), `cultnet-ts-interop-${runtimeId}.msgpack`), { force: true });
|
|
rmSync(join(tmpdir(), `cultnet-py-interop-${runtimeId}.msgpack`), { force: true });
|
|
}
|
|
}
|
|
|
|
function logInteropPhase(lane: string, phase: string): void {
|
|
console.error(`[interop:${lane}] ${new Date().toISOString()} ${phase}`);
|
|
}
|
|
|
|
function findAncestor(start: string, marker: string): string | undefined {
|
|
let current = start;
|
|
while (true) {
|
|
if (existsSync(resolve(current, marker))) {
|
|
return current;
|
|
}
|
|
|
|
const parent = dirname(current);
|
|
if (parent === current) {
|
|
return undefined;
|
|
}
|
|
|
|
current = parent;
|
|
}
|
|
}
|
|
|
|
interface ServeCommand {
|
|
command: string;
|
|
args: string[];
|
|
cwd: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
}
|
|
|
|
interface RunningServeProcess {
|
|
name: string;
|
|
child: ChildProcessByStdio<null, Readable, Readable>;
|
|
ready: Promise<unknown>;
|
|
stderr: string[];
|
|
}
|
|
|
|
async function spawnServeProcess(name: string, command: ServeCommand): Promise<RunningServeProcess> {
|
|
const child = spawn(command.command, command.args, {
|
|
cwd: command.cwd,
|
|
env: { ...process.env, ...command.env },
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
const stderr: string[] = [];
|
|
let stdoutBuffer = "";
|
|
|
|
const ready = new Promise<unknown>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
reject(new Error(`${name} serve process did not become ready within ${serveReadyTimeoutMs}ms.\n${stderr.join("")}`));
|
|
}, serveReadyTimeoutMs);
|
|
|
|
const resolveReady = (value: unknown) => {
|
|
clearTimeout(timeout);
|
|
resolve(value);
|
|
};
|
|
|
|
const rejectReady = (error: Error) => {
|
|
clearTimeout(timeout);
|
|
reject(error);
|
|
};
|
|
|
|
child.stdout.setEncoding("utf8");
|
|
child.stdout.on("data", (chunk: string) => {
|
|
stdoutBuffer += chunk;
|
|
while (true) {
|
|
const newline = stdoutBuffer.indexOf("\n");
|
|
if (newline === -1) {
|
|
break;
|
|
}
|
|
|
|
const line = stdoutBuffer.slice(0, newline).trim();
|
|
stdoutBuffer = stdoutBuffer.slice(newline + 1);
|
|
if (!line) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(line) as { status?: string };
|
|
if (parsed.status === "ready") {
|
|
resolveReady(parsed);
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
rejectReady(new Error(`${name} emitted non-JSON stdout while starting: ${line}`));
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
|
|
child.stderr.setEncoding("utf8");
|
|
child.stderr.on("data", (chunk: string) => {
|
|
stderr.push(chunk);
|
|
});
|
|
|
|
child.once("exit", (code, signal) => {
|
|
rejectReady(new Error(`${name} serve process exited before becoming ready (code=${code}, signal=${signal}).\n${stderr.join("")}`));
|
|
});
|
|
child.once("error", rejectReady);
|
|
});
|
|
|
|
return { name, child, ready, stderr };
|
|
}
|
|
|
|
async function runJsonCommand(
|
|
name: string,
|
|
command: string,
|
|
args: string[],
|
|
cwd: string,
|
|
env: NodeJS.ProcessEnv = {},
|
|
): Promise<any> {
|
|
const { stdout, stderr } = await execFileAsync(command, args, {
|
|
cwd,
|
|
env: { ...process.env, ...env },
|
|
timeout: jsonCommandTimeoutMs,
|
|
});
|
|
const trimmed = stdout.trim();
|
|
if (!trimmed) {
|
|
throw new Error(`${name} produced no stdout.\n${stderr}`);
|
|
}
|
|
|
|
const lines = trimmed.split(/\r?\n/).filter(Boolean);
|
|
try {
|
|
return JSON.parse(lines.at(-1) as string);
|
|
} catch (error) {
|
|
throw new Error(`${name} did not end with JSON stdout.\nstdout:\n${stdout}\nstderr:\n${stderr}`);
|
|
}
|
|
}
|
|
|
|
interface RunningPythonRudpPeer {
|
|
child: ChildProcessByStdio<null, Readable, Readable>;
|
|
ready: Promise<number>;
|
|
stderr: string[];
|
|
}
|
|
|
|
function spawnPythonRudpPeer(options: RudpServerSpawnOptions = {}): RunningPythonRudpPeer {
|
|
const child = spawn(pythonCommand, ["-c", pythonRudpPeerScript], {
|
|
cwd: cultcachePyRoot,
|
|
env: {
|
|
...process.env,
|
|
PYTHONPATH: cultcachePySrc,
|
|
...(options.clientPayload ? { RUDP_CLIENT_PAYLOAD: options.clientPayload } : {}),
|
|
...(options.serverPayload ? { RUDP_SERVER_PAYLOAD: options.serverPayload } : {}),
|
|
...(options.serverExtraPayload ? { RUDP_SERVER_EXTRA_PAYLOAD: options.serverExtraPayload } : {}),
|
|
...(options.disconnectReason ? { RUDP_DISCONNECT_REASON: options.disconnectReason } : {}),
|
|
...(options.maxFragmentBytes ? { RUDP_MAX_FRAGMENT_BYTES: String(options.maxFragmentBytes) } : {}),
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
const stderr: string[] = [];
|
|
let stdoutBuffer = "";
|
|
|
|
const ready = new Promise<number>((resolveReady, rejectReady) => {
|
|
child.stdout.setEncoding("utf8");
|
|
child.stdout.on("data", (chunk: string) => {
|
|
stdoutBuffer += chunk;
|
|
while (true) {
|
|
const newline = stdoutBuffer.indexOf("\n");
|
|
if (newline === -1) {
|
|
break;
|
|
}
|
|
|
|
const line = stdoutBuffer.slice(0, newline).trim();
|
|
stdoutBuffer = stdoutBuffer.slice(newline + 1);
|
|
if (!line) {
|
|
continue;
|
|
}
|
|
|
|
const portMatch = /^PORT\s+(\d+)$/.exec(line);
|
|
if (portMatch) {
|
|
resolveReady(Number(portMatch[1]));
|
|
continue;
|
|
}
|
|
if (line !== "OK") {
|
|
rejectReady(new Error(`Python RUDP peer emitted unexpected stdout: ${line}`));
|
|
}
|
|
}
|
|
});
|
|
child.stderr.setEncoding("utf8");
|
|
child.stderr.on("data", (chunk: string) => {
|
|
stderr.push(chunk);
|
|
});
|
|
child.once("exit", (code, signal) => {
|
|
rejectReady(new Error(`Python RUDP peer exited before publishing a port (code=${code}, signal=${signal}).\n${stderr.join("")}`));
|
|
});
|
|
child.once("error", rejectReady);
|
|
});
|
|
|
|
return { child, ready, stderr };
|
|
}
|
|
|
|
function spawnPythonRudpMessagePeer(): RunningPythonRudpPeer {
|
|
const child = spawn(pythonCommand, ["-c", pythonRudpMessagePeerScript], {
|
|
cwd: cultcachePyRoot,
|
|
env: { ...process.env, PYTHONPATH: cultcachePySrc },
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
const stderr: string[] = [];
|
|
let stdoutBuffer = "";
|
|
|
|
const ready = new Promise<number>((resolveReady, rejectReady) => {
|
|
child.stdout.setEncoding("utf8");
|
|
child.stdout.on("data", (chunk: string) => {
|
|
stdoutBuffer += chunk;
|
|
while (true) {
|
|
const newline = stdoutBuffer.indexOf("\n");
|
|
if (newline === -1) {
|
|
break;
|
|
}
|
|
|
|
const line = stdoutBuffer.slice(0, newline).trim();
|
|
stdoutBuffer = stdoutBuffer.slice(newline + 1);
|
|
if (!line) {
|
|
continue;
|
|
}
|
|
|
|
const portMatch = /^PORT\s+(\d+)$/.exec(line);
|
|
if (portMatch) {
|
|
resolveReady(Number(portMatch[1]));
|
|
continue;
|
|
}
|
|
if (line !== "OK") {
|
|
rejectReady(new Error(`Python RUDP message peer emitted unexpected stdout: ${line}`));
|
|
}
|
|
}
|
|
});
|
|
child.stderr.setEncoding("utf8");
|
|
child.stderr.on("data", (chunk: string) => {
|
|
stderr.push(chunk);
|
|
});
|
|
child.once("exit", (code, signal) => {
|
|
rejectReady(new Error(`Python RUDP message peer exited before publishing a port (code=${code}, signal=${signal}).\n${stderr.join("")}`));
|
|
});
|
|
child.once("error", rejectReady);
|
|
});
|
|
|
|
return { child, ready, stderr };
|
|
}
|
|
|
|
function spawnRustRudpServer(options: RudpServerSpawnOptions = {}): RunningPythonRudpPeer {
|
|
const child = spawn(rustBinaryPath, [
|
|
"rudp-serve-once",
|
|
"--bind-host", "127.0.0.1",
|
|
...(options.clientPayload ? ["--client-payload", options.clientPayload] : []),
|
|
...(options.serverPayload ? ["--server-payload", options.serverPayload] : []),
|
|
...(options.serverExtraPayload ? ["--server-extra-payload", options.serverExtraPayload] : []),
|
|
...(options.disconnectReason ? ["--disconnect-reason", options.disconnectReason] : []),
|
|
...(options.maxFragmentBytes ? ["--max-fragment-bytes", String(options.maxFragmentBytes)] : []),
|
|
], {
|
|
cwd: cultnetRsRoot,
|
|
env: process.env,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
const stderr: string[] = [];
|
|
let stdoutBuffer = "";
|
|
|
|
const ready = new Promise<number>((resolveReady, rejectReady) => {
|
|
child.stdout.setEncoding("utf8");
|
|
child.stdout.on("data", (chunk: string) => {
|
|
stdoutBuffer += chunk;
|
|
while (true) {
|
|
const newline = stdoutBuffer.indexOf("\n");
|
|
if (newline === -1) {
|
|
break;
|
|
}
|
|
|
|
const line = stdoutBuffer.slice(0, newline).trim();
|
|
stdoutBuffer = stdoutBuffer.slice(newline + 1);
|
|
if (!line) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(line) as { status?: string; port?: number };
|
|
if (parsed.status === "ready" && typeof parsed.port === "number") {
|
|
resolveReady(parsed.port);
|
|
} else if (parsed.status !== "ok") {
|
|
rejectReady(new Error(`Rust RUDP peer emitted unexpected stdout: ${line}`));
|
|
}
|
|
} catch (error) {
|
|
rejectReady(new Error(`Rust RUDP peer emitted non-JSON stdout: ${line}`));
|
|
}
|
|
}
|
|
});
|
|
child.stderr.setEncoding("utf8");
|
|
child.stderr.on("data", (chunk: string) => {
|
|
stderr.push(chunk);
|
|
});
|
|
child.once("exit", (code, signal) => {
|
|
rejectReady(new Error(`Rust RUDP peer exited before publishing a port (code=${code}, signal=${signal}).\n${stderr.join("")}`));
|
|
});
|
|
child.once("error", rejectReady);
|
|
});
|
|
|
|
return { child, ready, stderr };
|
|
}
|
|
|
|
function spawnRustRudpMessageServer(): RunningPythonRudpPeer {
|
|
const child = spawn(rustBinaryPath, [
|
|
"rudp-serve-message-once",
|
|
"--bind-host", "127.0.0.1",
|
|
], {
|
|
cwd: cultnetRsRoot,
|
|
env: process.env,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
const stderr: string[] = [];
|
|
let stdoutBuffer = "";
|
|
|
|
const ready = new Promise<number>((resolveReady, rejectReady) => {
|
|
child.stdout.setEncoding("utf8");
|
|
child.stdout.on("data", (chunk: string) => {
|
|
stdoutBuffer += chunk;
|
|
while (true) {
|
|
const newline = stdoutBuffer.indexOf("\n");
|
|
if (newline === -1) {
|
|
break;
|
|
}
|
|
|
|
const line = stdoutBuffer.slice(0, newline).trim();
|
|
stdoutBuffer = stdoutBuffer.slice(newline + 1);
|
|
if (!line) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(line) as { status?: string; port?: number };
|
|
if (parsed.status === "ready" && typeof parsed.port === "number") {
|
|
resolveReady(parsed.port);
|
|
} else if (parsed.status !== "ok") {
|
|
rejectReady(new Error(`Rust RUDP message peer emitted unexpected stdout: ${line}`));
|
|
}
|
|
} catch (error) {
|
|
rejectReady(new Error(`Rust RUDP message peer emitted non-JSON stdout: ${line}`));
|
|
}
|
|
}
|
|
});
|
|
child.stderr.setEncoding("utf8");
|
|
child.stderr.on("data", (chunk: string) => {
|
|
stderr.push(chunk);
|
|
});
|
|
child.once("exit", (code, signal) => {
|
|
rejectReady(new Error(`Rust RUDP message peer exited before publishing a port (code=${code}, signal=${signal}).\n${stderr.join("")}`));
|
|
});
|
|
child.once("error", rejectReady);
|
|
});
|
|
|
|
return { child, ready, stderr };
|
|
}
|
|
|
|
function spawnCSharpRudpServer(options: RudpServerSpawnOptions = {}): RunningPythonRudpPeer {
|
|
const child = spawn(dotnetCommand, [
|
|
csharpDllPath,
|
|
"rudp-serve-once",
|
|
"--bind-host", "127.0.0.1",
|
|
...(options.clientPayload ? ["--client-payload", options.clientPayload] : []),
|
|
...(options.serverPayload ? ["--server-payload", options.serverPayload] : []),
|
|
...(options.serverExtraPayload ? ["--server-extra-payload", options.serverExtraPayload] : []),
|
|
...(options.disconnectReason ? ["--disconnect-reason", options.disconnectReason] : []),
|
|
...(options.maxFragmentBytes ? ["--max-fragment-bytes", String(options.maxFragmentBytes)] : []),
|
|
], {
|
|
cwd: cultLibRoot,
|
|
env: process.env,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
const stderr: string[] = [];
|
|
let stdoutBuffer = "";
|
|
|
|
const ready = new Promise<number>((resolveReady, rejectReady) => {
|
|
child.stdout.setEncoding("utf8");
|
|
child.stdout.on("data", (chunk: string) => {
|
|
stdoutBuffer += chunk;
|
|
while (true) {
|
|
const newline = stdoutBuffer.indexOf("\n");
|
|
if (newline === -1) {
|
|
break;
|
|
}
|
|
|
|
const line = stdoutBuffer.slice(0, newline).trim();
|
|
stdoutBuffer = stdoutBuffer.slice(newline + 1);
|
|
if (!line) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(line) as { status?: string; port?: number };
|
|
if (parsed.status === "ready" && typeof parsed.port === "number") {
|
|
resolveReady(parsed.port);
|
|
} else if (parsed.status !== "ok") {
|
|
rejectReady(new Error(`C# RUDP peer emitted unexpected stdout: ${line}`));
|
|
}
|
|
} catch (error) {
|
|
rejectReady(new Error(`C# RUDP peer emitted non-JSON stdout: ${line}`));
|
|
}
|
|
}
|
|
});
|
|
child.stderr.setEncoding("utf8");
|
|
child.stderr.on("data", (chunk: string) => {
|
|
stderr.push(chunk);
|
|
});
|
|
child.once("exit", (code, signal) => {
|
|
rejectReady(new Error(`C# RUDP peer exited before publishing a port (code=${code}, signal=${signal}).\n${stderr.join("")}`));
|
|
});
|
|
child.once("error", rejectReady);
|
|
});
|
|
|
|
return { child, ready, stderr };
|
|
}
|
|
|
|
function spawnCSharpRudpMessageServer(): RunningPythonRudpPeer {
|
|
const child = spawn(dotnetCommand, [
|
|
csharpDllPath,
|
|
"rudp-serve-message-once",
|
|
"--bind-host", "127.0.0.1",
|
|
], {
|
|
cwd: cultLibRoot,
|
|
env: process.env,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
const stderr: string[] = [];
|
|
let stdoutBuffer = "";
|
|
|
|
const ready = new Promise<number>((resolveReady, rejectReady) => {
|
|
child.stdout.setEncoding("utf8");
|
|
child.stdout.on("data", (chunk: string) => {
|
|
stdoutBuffer += chunk;
|
|
while (true) {
|
|
const newline = stdoutBuffer.indexOf("\n");
|
|
if (newline === -1) {
|
|
break;
|
|
}
|
|
|
|
const line = stdoutBuffer.slice(0, newline).trim();
|
|
stdoutBuffer = stdoutBuffer.slice(newline + 1);
|
|
if (!line) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(line) as { status?: string; port?: number };
|
|
if (parsed.status === "ready" && typeof parsed.port === "number") {
|
|
resolveReady(parsed.port);
|
|
} else if (parsed.status !== "ok") {
|
|
rejectReady(new Error(`C# RUDP message peer emitted unexpected stdout: ${line}`));
|
|
}
|
|
} catch (error) {
|
|
rejectReady(new Error(`C# RUDP message peer emitted non-JSON stdout: ${line}`));
|
|
}
|
|
}
|
|
});
|
|
child.stderr.setEncoding("utf8");
|
|
child.stderr.on("data", (chunk: string) => {
|
|
stderr.push(chunk);
|
|
});
|
|
child.once("exit", (code, signal) => {
|
|
rejectReady(new Error(`C# RUDP message peer exited before publishing a port (code=${code}, signal=${signal}).\n${stderr.join("")}`));
|
|
});
|
|
child.once("error", rejectReady);
|
|
});
|
|
|
|
return { child, ready, stderr };
|
|
}
|
|
|
|
function spawnKotlinRudpServer(options: RudpServerSpawnOptions = {}): RunningPythonRudpPeer {
|
|
const child = spawn(kotlinJavaCommand, [
|
|
"-cp", kotlinClasspath,
|
|
"org.gamecult.cultmesh.CultMeshKt",
|
|
"rudp-serve-once",
|
|
"--bind-host", "127.0.0.1",
|
|
...(options.clientPayload ? ["--client-payload", options.clientPayload] : []),
|
|
...(options.serverPayload ? ["--server-payload", options.serverPayload] : []),
|
|
...(options.serverExtraPayload ? ["--server-extra-payload", options.serverExtraPayload] : []),
|
|
...(options.disconnectReason ? ["--disconnect-reason", options.disconnectReason] : []),
|
|
...(options.maxFragmentBytes ? ["--max-fragment-bytes", String(options.maxFragmentBytes)] : []),
|
|
], {
|
|
cwd: cultLibRoot,
|
|
env: process.env,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
const stderr: string[] = [];
|
|
let stdoutBuffer = "";
|
|
|
|
const ready = new Promise<number>((resolveReady, rejectReady) => {
|
|
child.stdout.setEncoding("utf8");
|
|
child.stdout.on("data", (chunk: string) => {
|
|
stdoutBuffer += chunk;
|
|
while (true) {
|
|
const newline = stdoutBuffer.indexOf("\n");
|
|
if (newline === -1) {
|
|
break;
|
|
}
|
|
|
|
const line = stdoutBuffer.slice(0, newline).trim();
|
|
stdoutBuffer = stdoutBuffer.slice(newline + 1);
|
|
if (!line) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(line) as { status?: string; port?: number };
|
|
if (parsed.status === "ready" && typeof parsed.port === "number") {
|
|
resolveReady(parsed.port);
|
|
} else if (parsed.status !== "ok") {
|
|
rejectReady(new Error(`Kotlin RUDP peer emitted unexpected stdout: ${line}`));
|
|
}
|
|
} catch (error) {
|
|
rejectReady(new Error(`Kotlin RUDP peer emitted non-JSON stdout: ${line}`));
|
|
}
|
|
}
|
|
});
|
|
child.stderr.setEncoding("utf8");
|
|
child.stderr.on("data", (chunk: string) => {
|
|
stderr.push(chunk);
|
|
});
|
|
child.once("exit", (code, signal) => {
|
|
rejectReady(new Error(`Kotlin RUDP peer exited before publishing a port (code=${code}, signal=${signal}).\n${stderr.join("")}`));
|
|
});
|
|
child.once("error", rejectReady);
|
|
});
|
|
|
|
return { child, ready, stderr };
|
|
}
|
|
|
|
function spawnKotlinRudpMessageServer(): RunningPythonRudpPeer {
|
|
const child = spawn(kotlinJavaCommand, [
|
|
"-cp", kotlinClasspath,
|
|
"org.gamecult.cultmesh.CultMeshKt",
|
|
"rudp-serve-message-once",
|
|
"--bind-host", "127.0.0.1",
|
|
], {
|
|
cwd: cultLibRoot,
|
|
env: process.env,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
const stderr: string[] = [];
|
|
let stdoutBuffer = "";
|
|
|
|
const ready = new Promise<number>((resolveReady, rejectReady) => {
|
|
child.stdout.setEncoding("utf8");
|
|
child.stdout.on("data", (chunk: string) => {
|
|
stdoutBuffer += chunk;
|
|
while (true) {
|
|
const newline = stdoutBuffer.indexOf("\n");
|
|
if (newline === -1) {
|
|
break;
|
|
}
|
|
|
|
const line = stdoutBuffer.slice(0, newline).trim();
|
|
stdoutBuffer = stdoutBuffer.slice(newline + 1);
|
|
if (!line) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(line) as { status?: string; port?: number };
|
|
if (parsed.status === "ready" && typeof parsed.port === "number") {
|
|
resolveReady(parsed.port);
|
|
} else if (parsed.status !== "ok") {
|
|
rejectReady(new Error(`Kotlin RUDP message peer emitted unexpected stdout: ${line}`));
|
|
}
|
|
} catch (error) {
|
|
rejectReady(new Error(`Kotlin RUDP message peer emitted non-JSON stdout: ${line}`));
|
|
}
|
|
}
|
|
});
|
|
child.stderr.setEncoding("utf8");
|
|
child.stderr.on("data", (chunk: string) => {
|
|
stderr.push(chunk);
|
|
});
|
|
child.once("exit", (code, signal) => {
|
|
rejectReady(new Error(`Kotlin RUDP message peer exited before publishing a port (code=${code}, signal=${signal}).\n${stderr.join("")}`));
|
|
});
|
|
child.once("error", rejectReady);
|
|
});
|
|
|
|
return { child, ready, stderr };
|
|
}
|
|
|
|
function spawnPythonRudpClient(remotePort: number): Omit<RunningPythonRudpPeer, "ready"> {
|
|
const child = spawn(pythonCommand, ["-c", pythonRudpClientScript, String(remotePort)], {
|
|
cwd: cultcachePyRoot,
|
|
env: { ...process.env, PYTHONPATH: cultcachePySrc },
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
const stderr: string[] = [];
|
|
child.stderr.setEncoding("utf8");
|
|
child.stderr.on("data", (chunk: string) => {
|
|
stderr.push(chunk);
|
|
});
|
|
|
|
return { child, stderr };
|
|
}
|
|
|
|
function spawnPythonRudpMessageClient(remotePort: number): Omit<RunningPythonRudpPeer, "ready"> {
|
|
const child = spawn(pythonCommand, ["-c", pythonRudpMessageClientScript, String(remotePort)], {
|
|
cwd: cultcachePyRoot,
|
|
env: { ...process.env, PYTHONPATH: cultcachePySrc },
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
const stderr: string[] = [];
|
|
child.stderr.setEncoding("utf8");
|
|
child.stderr.on("data", (chunk: string) => {
|
|
stderr.push(chunk);
|
|
});
|
|
|
|
return { child, stderr };
|
|
}
|
|
|
|
function spawnKotlinRudpClient(remotePort: number): Omit<RunningPythonRudpPeer, "ready"> {
|
|
const child = spawn(kotlinJavaCommand, [
|
|
"-cp", kotlinClasspath,
|
|
"org.gamecult.cultmesh.CultMeshKt",
|
|
"rudp-dial-once",
|
|
"--target-host", "127.0.0.1",
|
|
"--target-port", String(remotePort),
|
|
], {
|
|
cwd: cultLibRoot,
|
|
env: process.env,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
const stderr: string[] = [];
|
|
child.stderr.setEncoding("utf8");
|
|
child.stderr.on("data", (chunk: string) => {
|
|
stderr.push(chunk);
|
|
});
|
|
|
|
return { child, stderr };
|
|
}
|
|
|
|
function spawnKotlinRudpMessageClient(remotePort: number): Omit<RunningPythonRudpPeer, "ready"> {
|
|
const child = spawn(kotlinJavaCommand, [
|
|
"-cp", kotlinClasspath,
|
|
"org.gamecult.cultmesh.CultMeshKt",
|
|
"rudp-dial-message-once",
|
|
"--target-host", "127.0.0.1",
|
|
"--target-port", String(remotePort),
|
|
], {
|
|
cwd: cultLibRoot,
|
|
env: process.env,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
const stderr: string[] = [];
|
|
child.stderr.setEncoding("utf8");
|
|
child.stderr.on("data", (chunk: string) => {
|
|
stderr.push(chunk);
|
|
});
|
|
|
|
return { child, stderr };
|
|
}
|
|
|
|
function spawnCSharpRudpClient(remotePort: number): Omit<RunningPythonRudpPeer, "ready"> {
|
|
const child = spawn(dotnetCommand, [
|
|
csharpDllPath,
|
|
"rudp-dial-once",
|
|
"--target-host", "127.0.0.1",
|
|
"--target-port", String(remotePort),
|
|
], {
|
|
cwd: cultLibRoot,
|
|
env: process.env,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
const stderr: string[] = [];
|
|
child.stderr.setEncoding("utf8");
|
|
child.stderr.on("data", (chunk: string) => {
|
|
stderr.push(chunk);
|
|
});
|
|
|
|
return { child, stderr };
|
|
}
|
|
|
|
function spawnCSharpRudpMessageClient(remotePort: number): Omit<RunningPythonRudpPeer, "ready"> {
|
|
const child = spawn(dotnetCommand, [
|
|
csharpDllPath,
|
|
"rudp-dial-message-once",
|
|
"--target-host", "127.0.0.1",
|
|
"--target-port", String(remotePort),
|
|
], {
|
|
cwd: cultLibRoot,
|
|
env: process.env,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
const stderr: string[] = [];
|
|
child.stderr.setEncoding("utf8");
|
|
child.stderr.on("data", (chunk: string) => {
|
|
stderr.push(chunk);
|
|
});
|
|
|
|
return { child, stderr };
|
|
}
|
|
|
|
function spawnRustRudpClient(remotePort: number): Omit<RunningPythonRudpPeer, "ready"> {
|
|
const child = spawn(rustBinaryPath, [
|
|
"rudp-dial-once",
|
|
"--target-host", "127.0.0.1",
|
|
"--target-port", String(remotePort),
|
|
], {
|
|
cwd: cultnetRsRoot,
|
|
env: process.env,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
const stderr: string[] = [];
|
|
child.stderr.setEncoding("utf8");
|
|
child.stderr.on("data", (chunk: string) => {
|
|
stderr.push(chunk);
|
|
});
|
|
|
|
return { child, stderr };
|
|
}
|
|
|
|
function spawnRustRudpMessageClient(remotePort: number): Omit<RunningPythonRudpPeer, "ready"> {
|
|
const child = spawn(rustBinaryPath, [
|
|
"rudp-dial-message-once",
|
|
"--target-host", "127.0.0.1",
|
|
"--target-port", String(remotePort),
|
|
], {
|
|
cwd: cultnetRsRoot,
|
|
env: process.env,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
const stderr: string[] = [];
|
|
child.stderr.setEncoding("utf8");
|
|
child.stderr.on("data", (chunk: string) => {
|
|
stderr.push(chunk);
|
|
});
|
|
|
|
return { child, stderr };
|
|
}
|
|
|
|
/** Closes a socket this file bound, tolerating a transport that adopted and
|
|
* already closed it. A socket left open holds the event loop forever, which is
|
|
* invisible while tests pass and fatal to the runner's exit. */
|
|
function closeUdpSocket(socket: Socket | undefined): void {
|
|
if (!socket) {
|
|
return;
|
|
}
|
|
try {
|
|
socket.close();
|
|
} catch {
|
|
// already closed
|
|
}
|
|
}
|
|
|
|
async function bindUdpSocket(): Promise<Socket> {
|
|
const socket = dgram.createSocket("udp4");
|
|
await new Promise<void>((resolveBind, rejectBind) => {
|
|
socket.once("error", rejectBind);
|
|
socket.bind(0, "127.0.0.1", () => {
|
|
socket.off("error", rejectBind);
|
|
resolveBind();
|
|
});
|
|
});
|
|
return socket;
|
|
}
|
|
|
|
interface RudpLossyBridge {
|
|
port: number;
|
|
readonly droppedClientPackets: number;
|
|
readonly droppedServerPackets: number;
|
|
readonly delayedClientPackets: number;
|
|
readonly delayedServerPackets: number;
|
|
close(): void;
|
|
}
|
|
|
|
async function createRudpLossyBridge(options: {
|
|
remoteHost: string;
|
|
remotePort: number;
|
|
shouldDropClientPacket?: (wire: Buffer) => boolean;
|
|
shouldDropServerPacket?: (wire: Buffer) => boolean;
|
|
shouldDelayClientPacket?: (wire: Buffer) => boolean;
|
|
shouldDelayServerPacket?: (wire: Buffer) => boolean;
|
|
}): Promise<RudpLossyBridge> {
|
|
const socket = dgram.createSocket("udp4");
|
|
const remote = { address: options.remoteHost, port: options.remotePort };
|
|
let client: { address: string; port: number } | undefined;
|
|
let droppedClientPackets = 0;
|
|
let droppedServerPackets = 0;
|
|
let delayedClientPackets = 0;
|
|
let delayedServerPackets = 0;
|
|
let heldClientWire: Buffer | undefined;
|
|
let heldServerWire: Buffer | undefined;
|
|
let heldClientTimer: NodeJS.Timeout | undefined;
|
|
let heldServerTimer: NodeJS.Timeout | undefined;
|
|
|
|
const flushHeldClient = () => {
|
|
if (!heldClientWire) {
|
|
return;
|
|
}
|
|
const wire = heldClientWire;
|
|
heldClientWire = undefined;
|
|
if (heldClientTimer) {
|
|
clearTimeout(heldClientTimer);
|
|
heldClientTimer = undefined;
|
|
}
|
|
socket.send(wire, remote.port, remote.address);
|
|
};
|
|
|
|
const flushHeldServer = () => {
|
|
if (!heldServerWire || !client) {
|
|
return;
|
|
}
|
|
const wire = heldServerWire;
|
|
heldServerWire = undefined;
|
|
if (heldServerTimer) {
|
|
clearTimeout(heldServerTimer);
|
|
heldServerTimer = undefined;
|
|
}
|
|
socket.send(wire, client.port, client.address);
|
|
};
|
|
|
|
socket.on("message", (wire, sender) => {
|
|
if (sender.address === remote.address && sender.port === remote.port) {
|
|
if (droppedServerPackets === 0 && options.shouldDropServerPacket?.(wire)) {
|
|
droppedServerPackets += 1;
|
|
return;
|
|
}
|
|
if (delayedServerPackets === 0 && options.shouldDelayServerPacket?.(wire)) {
|
|
delayedServerPackets += 1;
|
|
heldServerWire = Buffer.from(wire);
|
|
heldServerTimer = setTimeout(flushHeldServer, 25);
|
|
return;
|
|
}
|
|
if (client) {
|
|
socket.send(wire, client.port, client.address);
|
|
flushHeldServer();
|
|
}
|
|
return;
|
|
}
|
|
|
|
client ??= { address: sender.address, port: sender.port };
|
|
if (droppedClientPackets === 0 && options.shouldDropClientPacket?.(wire)) {
|
|
droppedClientPackets += 1;
|
|
return;
|
|
}
|
|
if (delayedClientPackets === 0 && options.shouldDelayClientPacket?.(wire)) {
|
|
delayedClientPackets += 1;
|
|
heldClientWire = Buffer.from(wire);
|
|
heldClientTimer = setTimeout(flushHeldClient, 25);
|
|
return;
|
|
}
|
|
|
|
socket.send(wire, remote.port, remote.address);
|
|
flushHeldClient();
|
|
});
|
|
|
|
await new Promise<void>((resolveBind, rejectBind) => {
|
|
socket.once("error", rejectBind);
|
|
socket.bind(0, "127.0.0.1", () => {
|
|
socket.off("error", rejectBind);
|
|
resolveBind();
|
|
});
|
|
});
|
|
|
|
return {
|
|
port: udpSocketPort(socket),
|
|
get droppedClientPackets() {
|
|
return droppedClientPackets;
|
|
},
|
|
get droppedServerPackets() {
|
|
return droppedServerPackets;
|
|
},
|
|
get delayedClientPackets() {
|
|
return delayedClientPackets;
|
|
},
|
|
get delayedServerPackets() {
|
|
return delayedServerPackets;
|
|
},
|
|
close() {
|
|
if (heldClientTimer) {
|
|
clearTimeout(heldClientTimer);
|
|
}
|
|
if (heldServerTimer) {
|
|
clearTimeout(heldServerTimer);
|
|
}
|
|
socket.close();
|
|
},
|
|
};
|
|
}
|
|
|
|
function udpSocketPort(socket: Socket): number {
|
|
const address = socket.address();
|
|
if (typeof address === "string") {
|
|
throw new Error(`Expected UDP socket address info, got ${address}.`);
|
|
}
|
|
return address.port;
|
|
}
|
|
|
|
async function waitFor(predicate: () => boolean, description: string, timeoutMs = 2_000): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (predicate()) {
|
|
return;
|
|
}
|
|
await delay(5);
|
|
}
|
|
throw new Error(`Timed out waiting for ${description}.`);
|
|
}
|
|
|
|
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, description: string): Promise<T> {
|
|
let timeout: NodeJS.Timeout | undefined;
|
|
try {
|
|
return await Promise.race([
|
|
promise,
|
|
new Promise<never>((_, reject) => {
|
|
timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${description}.`)), timeoutMs);
|
|
}),
|
|
]);
|
|
} finally {
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function expectProbePeers(
|
|
name: string,
|
|
command: string,
|
|
args: string[],
|
|
cwd: string,
|
|
env: NodeJS.ProcessEnv,
|
|
expectedPeers: string[],
|
|
): Promise<void> {
|
|
const found = new Set<string>();
|
|
const profiles = new Map<string, unknown>();
|
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
const probe = await runJsonCommand(`${name}-${attempt + 1}`, command, args, cwd, env);
|
|
for (const peer of probe.peers as Array<{ runtimeId: string; transportProfiles?: unknown }>) {
|
|
found.add(peer.runtimeId);
|
|
profiles.set(peer.runtimeId, peer.transportProfiles);
|
|
}
|
|
if (expectedPeers.every((peer) => found.has(peer))) {
|
|
assert.deepEqual([...found].sort(), expectedPeers);
|
|
for (const peerId of expectedPeers) {
|
|
assertTcpFramedTransportProfile(peerId, profiles.get(peerId));
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
assert.deepEqual([...found].sort(), expectedPeers);
|
|
}
|
|
|
|
function assertTcpFramedTransportProfile(peerId: string, value: unknown): void {
|
|
assert.ok(Array.isArray(value), `${peerId} did not advertise transport profiles`);
|
|
const firstProfile = value[0] as any;
|
|
assert.equal(firstProfile?.schemaVersion, "cultnet.transport_profile.v0");
|
|
assert.equal(firstProfile?.runtimeId, peerId);
|
|
const transport = firstProfile?.transports?.[0];
|
|
assert.equal(transport?.protocol, "tcp_framed");
|
|
assert.equal(transport?.channels?.[0]?.delivery, "reliable");
|
|
assert.equal(transport?.channels?.[0]?.ordering, "ordered");
|
|
}
|
|
|
|
function assertRudpReconnectPolicy(peerName: string, value: unknown): void {
|
|
assert.ok(value && typeof value === "object", `${peerName} did not advertise an RUDP reconnect policy`);
|
|
const policy = value as {
|
|
schemaVersion?: string;
|
|
policyId?: string;
|
|
baseDelayMs?: number;
|
|
maxDelayMs?: number;
|
|
maxJitterMs?: number;
|
|
};
|
|
assert.equal(policy.schemaVersion, "cultnet.reconnect_policy.v0");
|
|
assert.equal(policy.policyId, "default");
|
|
assert.equal(policy.baseDelayMs, 1_000);
|
|
assert.equal(policy.maxDelayMs, 30_000);
|
|
assert.equal(policy.maxJitterMs, 250);
|
|
}
|
|
|
|
async function requestCultMeshFromPython(port: number, request: Record<string, unknown>): Promise<any> {
|
|
const socket = connectTcp(port, "127.0.0.1");
|
|
const framer = new LengthPrefixedMessageFramer();
|
|
await once(socket, "connect");
|
|
|
|
return await new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
socket.destroy();
|
|
reject(new Error(`Timed out waiting for Python CultMesh response to ${request.schemaVersion}.`));
|
|
}, 5000);
|
|
|
|
socket.on("data", (chunk) => {
|
|
for (const frame of framer.push(chunk)) {
|
|
clearTimeout(timeout);
|
|
socket.end();
|
|
resolve(decode(frame));
|
|
}
|
|
});
|
|
socket.once("error", (error) => {
|
|
clearTimeout(timeout);
|
|
reject(error);
|
|
});
|
|
socket.write(encodeFrame(encode(request)));
|
|
});
|
|
}
|
|
|
|
async function runPythonCultMeshClient(port: number): Promise<any> {
|
|
const script = [
|
|
"import json",
|
|
"from cultmesh_py import CultMeshDiscoveryClient",
|
|
`client = CultMeshDiscoveryClient("127.0.0.1", ${port}, timeout_seconds=4.0)`,
|
|
"verses = client.fetch_verses(transport_version='cultmesh.v0')",
|
|
"peers = client.fetch_peers(verse_id='python-interop', roles=['read-replica'])",
|
|
"print(json.dumps({",
|
|
" 'verses': [{'verseId': verse.verse_id, 'transportVersion': verse.compatibility.transport_version} for verse in verses],",
|
|
" 'peers': [{'peerId': peer.peer_id, 'roles': list(peer.roles)} for peer in peers],",
|
|
"}))",
|
|
].join("\n");
|
|
const { stdout } = await execFileAsync(pythonCommand, ["-c", script], {
|
|
cwd: cultcachePyRoot,
|
|
env: {
|
|
...process.env,
|
|
PYTHONPATH: cultcachePySrc,
|
|
},
|
|
timeout: 8000,
|
|
});
|
|
return JSON.parse(stdout);
|
|
}
|
|
|
|
async function runPythonCultNetRawClient(port: number): Promise<any> {
|
|
const script = [
|
|
"import json",
|
|
"from cultnet_py import CultNetRawClient",
|
|
`client = CultNetRawClient("127.0.0.1", ${port}, timeout_seconds=4.0)`,
|
|
"catalog = client.fetch_schema_catalog(kinds=['wire_message'])",
|
|
`snapshot = client.fetch_snapshot(schema_ids=['${interopNoteSchemaId}'])`,
|
|
`shards = client.fetch_shard_catalog(schema_ids=['${interopNoteSchemaId}'])`,
|
|
"print(json.dumps({",
|
|
" 'wireSchemaCount': len(catalog.get('schemas', [])),",
|
|
" 'snapshotRecordKeys': [document.get('recordKey') for document in snapshot.get('documents', [])],",
|
|
" 'shards': [{'shardId': shard.get('shardId'), 'epoch': shard.get('epoch')} for shard in shards.get('shards', [])],",
|
|
"}))",
|
|
].join("\n");
|
|
const { stdout } = await execFileAsync(pythonCommand, ["-c", script], {
|
|
cwd: cultcachePyRoot,
|
|
env: {
|
|
...process.env,
|
|
PYTHONPATH: cultcachePySrc,
|
|
},
|
|
timeout: 8000,
|
|
});
|
|
return JSON.parse(stdout);
|
|
}
|
|
|
|
async function runPythonCultMeshNodeSync(port: number): Promise<any> {
|
|
const schemaPathJson = JSON.stringify(interopSchemaPath);
|
|
const script = [
|
|
"import json",
|
|
"from pathlib import Path",
|
|
"from cultcache_py import define_database_entry_type",
|
|
"from cultmesh_py import create_node",
|
|
"from cultnet_py import CultNetRawClient",
|
|
`schema_json = Path(${schemaPathJson}).read_text(encoding='utf-8')`,
|
|
"note_doc = define_database_entry_type(",
|
|
" 'cultnet.interop-note',",
|
|
" [('schemaVersion', 0), ('documentId', 1), ('authorRuntimeId', 2), ('title', 3), ('body', 4), ('tags', 5, [])],",
|
|
` schema_id='${interopNoteSchemaId}',`,
|
|
" schema_version='cultnet.interop_note.v0',",
|
|
" canonical_schema_json=schema_json,",
|
|
")",
|
|
"node = create_node(runtime_id='python-node-sync')",
|
|
"node.register_document(note_doc)",
|
|
`client = CultNetRawClient('127.0.0.1', ${port}, timeout_seconds=4.0)`,
|
|
`changes = node.sync_snapshot(client, schema_ids=['${interopNoteSchemaId}'], record_keys=['note:python-peer'])`,
|
|
"note = node.get_required(note_doc, 'note:python-peer')",
|
|
"print(json.dumps({",
|
|
" 'synced': [{'schemaId': change.schema_id, 'recordKey': change.record_key} for change in changes],",
|
|
" 'note': note,",
|
|
"}))",
|
|
].join("\n");
|
|
const { stdout } = await execFileAsync(pythonCommand, ["-c", script], {
|
|
cwd: cultcachePyRoot,
|
|
env: {
|
|
...process.env,
|
|
PYTHONPATH: cultcachePySrc,
|
|
},
|
|
timeout: 8000,
|
|
});
|
|
return JSON.parse(stdout);
|
|
}
|
|
|
|
async function runPythonCultMeshNodeEmit(port: number): Promise<any> {
|
|
const schemaPathJson = JSON.stringify(interopSchemaPath);
|
|
const script = [
|
|
"import json",
|
|
"import socket",
|
|
"import time",
|
|
"from pathlib import Path",
|
|
"from cultcache_py import define_database_entry_type",
|
|
"from cultmesh_py import create_node",
|
|
"from cultnet_py import CultNetRawClient, write_frame",
|
|
`schema_json = Path(${schemaPathJson}).read_text(encoding='utf-8')`,
|
|
"note_doc = define_database_entry_type(",
|
|
" 'cultnet.interop-note',",
|
|
" [('schemaVersion', 0), ('documentId', 1), ('authorRuntimeId', 2), ('title', 3), ('body', 4), ('tags', 5, [])],",
|
|
` schema_id='${interopNoteSchemaId}',`,
|
|
" schema_version='cultnet.interop_note.v0',",
|
|
" canonical_schema_json=schema_json,",
|
|
")",
|
|
"writer = create_node(runtime_id='python-node-emit')",
|
|
"writer.register_document(note_doc)",
|
|
"message = writer.put_raw_message(",
|
|
" note_doc,",
|
|
" 'note:python-node-emit',",
|
|
" {",
|
|
" 'schemaVersion': 'cultnet.interop_note.v0',",
|
|
" 'documentId': 'note:python-node-emit',",
|
|
" 'authorRuntimeId': 'python-node-emit',",
|
|
" 'title': 'Python node emitted raw put',",
|
|
" 'body': 'node-level Python emit reached the peer',",
|
|
" 'tags': ['python', 'node', 'emit'],",
|
|
" },",
|
|
" message_id='python-node-emit-put',",
|
|
" shard_id='interop',",
|
|
" shard_epoch=1,",
|
|
")",
|
|
`with socket.create_connection(('127.0.0.1', ${port}), timeout=4.0) as connection:`,
|
|
" stream = connection.makefile('rwb')",
|
|
" write_frame(stream, message.to_bytes())",
|
|
" stream.flush()",
|
|
"reader = create_node(runtime_id='python-node-emit-reader')",
|
|
"reader.register_document(note_doc)",
|
|
`client = CultNetRawClient('127.0.0.1', ${port}, timeout_seconds=4.0)`,
|
|
"changes = []",
|
|
"for _ in range(20):",
|
|
` changes = reader.sync_snapshot(client, schema_ids=['${interopNoteSchemaId}'], record_keys=['note:python-node-emit'])`,
|
|
" if changes:",
|
|
" break",
|
|
" time.sleep(0.1)",
|
|
"note = reader.get_required(note_doc, 'note:python-node-emit')",
|
|
"print(json.dumps({",
|
|
" 'synced': [{'schemaId': change.schema_id, 'recordKey': change.record_key} for change in changes],",
|
|
" 'note': note,",
|
|
"}))",
|
|
].join("\n");
|
|
const { stdout } = await execFileAsync(pythonCommand, ["-c", script], {
|
|
cwd: cultcachePyRoot,
|
|
env: {
|
|
...process.env,
|
|
PYTHONPATH: cultcachePySrc,
|
|
},
|
|
timeout: 8000,
|
|
});
|
|
return JSON.parse(stdout);
|
|
}
|
|
|
|
async function runPythonCultNetSubscription(port: number): Promise<any> {
|
|
const schemaPathJson = JSON.stringify(interopSchemaPath);
|
|
const script = [
|
|
"import json",
|
|
"from pathlib import Path",
|
|
"from cultcache_py import define_database_entry_type",
|
|
"from cultmesh_py import create_node",
|
|
"from cultnet_py import CultNetRawClient",
|
|
`schema_json = Path(${schemaPathJson}).read_text(encoding='utf-8')`,
|
|
"note_doc = define_database_entry_type(",
|
|
" 'cultnet.interop-note',",
|
|
" [('schemaVersion', 0), ('documentId', 1), ('authorRuntimeId', 2), ('title', 3), ('body', 4), ('tags', 5, [])],",
|
|
` schema_id='${interopNoteSchemaId}',`,
|
|
" schema_version='cultnet.interop_note.v0',",
|
|
" canonical_schema_json=schema_json,",
|
|
")",
|
|
"node = create_node(runtime_id='python-sub-client')",
|
|
"node.register_document(note_doc)",
|
|
"message = node.put_raw_message(",
|
|
" note_doc,",
|
|
" 'note:python-sub-client',",
|
|
" {",
|
|
" 'schemaVersion': 'cultnet.interop_note.v0',",
|
|
" 'documentId': 'note:python-sub-client',",
|
|
" 'authorRuntimeId': 'python-sub-client',",
|
|
" 'title': 'Python subscription client emitted raw put',",
|
|
" 'body': 'subscription client saw its own live change',",
|
|
" 'tags': ['python', 'subscription'],",
|
|
" },",
|
|
" message_id='python-sub-client-put',",
|
|
" shard_id='interop',",
|
|
" shard_epoch=1,",
|
|
")",
|
|
`client = CultNetRawClient('127.0.0.1', ${port}, timeout_seconds=4.0)`,
|
|
`with client.subscribe_database(subscription_id='python-public-sub', schema_ids=['${interopNoteSchemaId}'], record_keys=['note:python-sub-client'], include_snapshot=False) as subscription:`,
|
|
" subscription.send(message)",
|
|
" change = subscription.read_next()",
|
|
"print(json.dumps({",
|
|
" 'subscriptionId': change.get('subscriptionId'),",
|
|
" 'changeKind': change.get('changeKind'),",
|
|
" 'recordKey': change.get('document', {}).get('recordKey'),",
|
|
"}))",
|
|
].join("\n");
|
|
const { stdout } = await execFileAsync(pythonCommand, ["-c", script], {
|
|
cwd: cultcachePyRoot,
|
|
env: {
|
|
...process.env,
|
|
PYTHONPATH: cultcachePySrc,
|
|
},
|
|
timeout: 8000,
|
|
});
|
|
return JSON.parse(stdout);
|
|
}
|
|
|
|
async function runPythonCultMeshPredictionReconcile(port: number): Promise<any> {
|
|
const schemaPathJson = JSON.stringify(interopSchemaPath);
|
|
const script = [
|
|
"import json",
|
|
"import tempfile",
|
|
"from pathlib import Path",
|
|
"from cultcache_py import define_database_entry_type",
|
|
"from cultmesh_py import CultMesh, CultMeshGameSessionOptions",
|
|
"from cultnet_py import CultNetClientAuthorityScope, CultNetRawClient",
|
|
`schema_json = Path(${schemaPathJson}).read_text(encoding='utf-8')`,
|
|
"note_doc = define_database_entry_type(",
|
|
" 'cultnet.interop-note',",
|
|
" [('schemaVersion', 0), ('documentId', 1), ('authorRuntimeId', 2), ('title', 3), ('body', 4), ('tags', 5, [])],",
|
|
` schema_id='${interopNoteSchemaId}',`,
|
|
" schema_version='cultnet.interop_note.v0',",
|
|
" canonical_schema_json=schema_json,",
|
|
")",
|
|
"with tempfile.TemporaryDirectory() as tmp:",
|
|
" node = CultMesh.create_node(Path(tmp) / 'prediction.cc', runtime_id='python-predict-client')",
|
|
" node.register_document(note_doc)",
|
|
" session = CultMesh.create_game_session(",
|
|
" node,",
|
|
" CultMeshGameSessionOptions(client_authority_scopes=(",
|
|
` CultNetClientAuthorityScope('python-predict-client', schema_ids=('${interopNoteSchemaId}',), key_prefix='note:python-node-emit'),`,
|
|
" )),",
|
|
" )",
|
|
" session.predict(note_doc, 'note:python-node-emit', {",
|
|
" 'schemaVersion': 'cultnet.interop_note.v0',",
|
|
" 'documentId': 'note:python-node-emit',",
|
|
" 'authorRuntimeId': 'python-predict-client',",
|
|
" 'title': 'Predicted Python node emit',",
|
|
" 'body': 'local predicted body',",
|
|
" 'tags': ['python', 'prediction'],",
|
|
" })",
|
|
` client = CultNetRawClient('127.0.0.1', ${port}, timeout_seconds=4.0)`,
|
|
" changes = session.sync_shard_log(client, shard_id='interop', shard_epoch=1, after_sequence=0)",
|
|
" note = node.get_required(note_doc, 'note:python-node-emit')",
|
|
" print(json.dumps({",
|
|
" 'reconciledKeys': [change.record_key for change in changes if change.change_kind == 'reconciled'],",
|
|
" 'changes': [{'kind': change.change_kind, 'key': change.record_key} for change in changes],",
|
|
" 'note': note,",
|
|
" }))",
|
|
].join("\n");
|
|
const { stdout } = await execFileAsync(pythonCommand, ["-c", script], {
|
|
cwd: cultcachePyRoot,
|
|
env: {
|
|
...process.env,
|
|
PYTHONPATH: cultcachePySrc,
|
|
},
|
|
timeout: 8000,
|
|
});
|
|
return JSON.parse(stdout);
|
|
}
|
|
|
|
async function requestPythonDatabaseChange(port: number, subscribe: Record<string, unknown>, put: Record<string, unknown>): Promise<any> {
|
|
const socket = connectTcp(port, "127.0.0.1");
|
|
const framer = new LengthPrefixedMessageFramer();
|
|
await once(socket, "connect");
|
|
|
|
return await new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
socket.destroy();
|
|
reject(new Error("Timed out waiting for Python database change response."));
|
|
}, 5000);
|
|
|
|
socket.on("data", (chunk) => {
|
|
for (const frame of framer.push(chunk)) {
|
|
const decoded = decode(frame) as any;
|
|
if (decoded?.schemaVersion === "cultnet.database_change_raw.v0") {
|
|
clearTimeout(timeout);
|
|
socket.end();
|
|
resolve(decoded);
|
|
}
|
|
}
|
|
});
|
|
socket.once("error", (error) => {
|
|
clearTimeout(timeout);
|
|
reject(error);
|
|
});
|
|
socket.write(encodeFrame(encode(subscribe)));
|
|
socket.write(encodeFrame(encode(put)));
|
|
});
|
|
}
|
|
|
|
async function requestPythonDatabaseDelete(
|
|
port: number,
|
|
subscribe: Record<string, unknown>,
|
|
put: Record<string, unknown>,
|
|
remove: Record<string, unknown>,
|
|
logRequest: Record<string, unknown>,
|
|
): Promise<{ change: any; log: any }> {
|
|
const socket = connectTcp(port, "127.0.0.1");
|
|
const framer = new LengthPrefixedMessageFramer();
|
|
await once(socket, "connect");
|
|
|
|
return await new Promise((resolve, reject) => {
|
|
const result: { change?: any; log?: any } = {};
|
|
let sawAdded = false;
|
|
const timeout = setTimeout(() => {
|
|
socket.destroy();
|
|
reject(new Error("Timed out waiting for Python delete change/log responses."));
|
|
}, 5000);
|
|
|
|
socket.on("data", (chunk) => {
|
|
for (const frame of framer.push(chunk)) {
|
|
const decoded = decode(frame) as any;
|
|
if (decoded?.schemaVersion === "cultnet.database_change_raw.v0" && decoded.changeKind === "added") {
|
|
sawAdded = true;
|
|
socket.write(encodeFrame(encode(remove)));
|
|
socket.write(encodeFrame(encode(logRequest)));
|
|
} else if (decoded?.schemaVersion === "cultnet.database_change_raw.v0" && decoded.changeKind === "removed") {
|
|
result.change = decoded;
|
|
} else if (decoded?.schemaVersion === "cultnet.shard_log_response.v0") {
|
|
result.log = decoded;
|
|
}
|
|
if (sawAdded && result.change && result.log) {
|
|
clearTimeout(timeout);
|
|
socket.end();
|
|
resolve({ change: result.change, log: result.log });
|
|
}
|
|
}
|
|
});
|
|
socket.once("error", (error) => {
|
|
clearTimeout(timeout);
|
|
reject(error);
|
|
});
|
|
socket.write(encodeFrame(encode(subscribe)));
|
|
socket.write(encodeFrame(encode(put)));
|
|
});
|
|
}
|
|
|
|
async function requestPythonShardCatalogAndLog(
|
|
port: number,
|
|
catalogRequest: Record<string, unknown>,
|
|
put: Record<string, unknown>,
|
|
logRequest: Record<string, unknown>,
|
|
): Promise<{ catalog: any; log: any }> {
|
|
const socket = connectTcp(port, "127.0.0.1");
|
|
const framer = new LengthPrefixedMessageFramer();
|
|
await once(socket, "connect");
|
|
|
|
return await new Promise((resolve, reject) => {
|
|
const result: { catalog?: any; log?: any } = {};
|
|
const timeout = setTimeout(() => {
|
|
socket.destroy();
|
|
reject(new Error("Timed out waiting for Python shard catalog/log responses."));
|
|
}, 5000);
|
|
|
|
socket.on("data", (chunk) => {
|
|
for (const frame of framer.push(chunk)) {
|
|
const decoded = decode(frame) as any;
|
|
if (decoded?.schemaVersion === "cultnet.shard_catalog_response.v0") {
|
|
result.catalog = decoded;
|
|
socket.write(encodeFrame(encode(put)));
|
|
socket.write(encodeFrame(encode(logRequest)));
|
|
} else if (decoded?.schemaVersion === "cultnet.shard_log_response.v0") {
|
|
result.log = decoded;
|
|
}
|
|
if (result.catalog && result.log) {
|
|
clearTimeout(timeout);
|
|
socket.end();
|
|
resolve({ catalog: result.catalog, log: result.log });
|
|
}
|
|
}
|
|
});
|
|
socket.once("error", (error) => {
|
|
clearTimeout(timeout);
|
|
reject(error);
|
|
});
|
|
socket.write(encodeFrame(encode(catalogRequest)));
|
|
});
|
|
}
|
|
|
|
async function requestPythonSimulationCandidate(port: number, observation: Record<string, unknown>): Promise<any> {
|
|
const socket = connectTcp(port, "127.0.0.1");
|
|
const framer = new LengthPrefixedMessageFramer();
|
|
await once(socket, "connect");
|
|
|
|
return await new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
socket.destroy();
|
|
reject(new Error("Timed out waiting for Python simulation consensus candidate."));
|
|
}, 5000);
|
|
|
|
socket.on("data", (chunk) => {
|
|
for (const frame of framer.push(chunk)) {
|
|
const decoded = decode(frame) as any;
|
|
if (decoded?.schemaVersion === "cultnet.simulation_consensus_candidate.v0") {
|
|
clearTimeout(timeout);
|
|
socket.end();
|
|
resolve(decoded);
|
|
}
|
|
}
|
|
});
|
|
socket.once("error", (error) => {
|
|
clearTimeout(timeout);
|
|
reject(error);
|
|
});
|
|
socket.write(encodeFrame(encode(observation)));
|
|
});
|
|
}
|
|
|
|
function computeSimulationClaimHash(...parts: string[]): string {
|
|
return createHash("sha256").update(parts.join("\x1f"), "utf8").digest("hex");
|
|
}
|
|
|
|
function createSimulationFactKey(candidate: any): string {
|
|
const factId = createHash("sha256")
|
|
.update([
|
|
candidate.shardId,
|
|
String(candidate.shardEpoch),
|
|
String(candidate.frame),
|
|
candidate.subjectId,
|
|
candidate.claimKind,
|
|
].join("\x1f"), "utf8")
|
|
.digest("hex");
|
|
return `simulation:${factId}`;
|
|
}
|
|
|
|
async function requestPythonSnapshot(port: number, request: Record<string, unknown>): Promise<any> {
|
|
const socket = connectTcp(port, "127.0.0.1");
|
|
const framer = new LengthPrefixedMessageFramer();
|
|
await once(socket, "connect");
|
|
|
|
return await new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
socket.destroy();
|
|
reject(new Error("Timed out waiting for Python snapshot response."));
|
|
}, 5000);
|
|
|
|
socket.on("data", (chunk) => {
|
|
for (const frame of framer.push(chunk)) {
|
|
const decoded = decode(frame) as any;
|
|
if (decoded?.schemaVersion === "cultnet.snapshot_response_raw.v0") {
|
|
clearTimeout(timeout);
|
|
socket.end();
|
|
resolve(decoded);
|
|
}
|
|
}
|
|
});
|
|
socket.once("error", (error) => {
|
|
clearTimeout(timeout);
|
|
reject(error);
|
|
});
|
|
socket.write(encodeFrame(encode(request)));
|
|
});
|
|
}
|
|
|
|
async function putAndSnapshotPythonWitnessBundle(port: number, put: Record<string, unknown>): Promise<{ catalog: any; snapshot: any }> {
|
|
const socket = connectTcp(port, "127.0.0.1");
|
|
const framer = new LengthPrefixedMessageFramer();
|
|
await once(socket, "connect");
|
|
|
|
return await new Promise((resolve, reject) => {
|
|
const result: { catalog?: any; snapshot?: any } = {};
|
|
const timeout = setTimeout(() => {
|
|
socket.destroy();
|
|
reject(new Error("Timed out waiting for Python witness artifact bundle roundtrip."));
|
|
}, 5000);
|
|
|
|
socket.on("data", (chunk) => {
|
|
for (const frame of framer.push(chunk)) {
|
|
const decoded = decode(frame) as any;
|
|
if (decoded?.schemaVersion === "cultnet.schema_catalog_response.v0") {
|
|
result.catalog = decoded;
|
|
socket.write(encodeFrame(encode(put)));
|
|
socket.write(encodeFrame(encode({
|
|
schemaVersion: "cultnet.snapshot_request.v0",
|
|
messageId: "python-witness-snapshot",
|
|
schemaIds: [witnessArtifactBundleSchemaId],
|
|
recordKeys: ["bundle:ts-python-witness"],
|
|
})));
|
|
} else if (decoded?.schemaVersion === "cultnet.snapshot_response_raw.v0") {
|
|
result.snapshot = decoded;
|
|
}
|
|
if (result.catalog && result.snapshot) {
|
|
clearTimeout(timeout);
|
|
socket.end();
|
|
resolve({ catalog: result.catalog, snapshot: result.snapshot });
|
|
}
|
|
}
|
|
});
|
|
socket.once("error", (error) => {
|
|
clearTimeout(timeout);
|
|
reject(error);
|
|
});
|
|
socket.write(encodeFrame(encode({
|
|
schemaVersion: "cultnet.schema_catalog_request.v0",
|
|
messageId: "python-witness-catalog",
|
|
includeSchemaJson: true,
|
|
schemaIds: [witnessArtifactBundleSchemaId],
|
|
})));
|
|
});
|
|
}
|
|
|
|
async function stopProcess(processState: RunningServeProcess): Promise<void> {
|
|
if (processState.child.exitCode !== null) {
|
|
return;
|
|
}
|
|
|
|
if (process.platform === "win32") {
|
|
await execFileAsync("taskkill.exe", ["/PID", String(processState.child.pid), "/T", "/F"], {
|
|
timeout: processCleanupTimeoutMs,
|
|
})
|
|
.catch(() => undefined);
|
|
await Promise.race([
|
|
once(processState.child, "exit").then(() => true),
|
|
delay(processCleanupTimeoutMs).then(() => false),
|
|
]);
|
|
return;
|
|
}
|
|
|
|
const gracefulExit = once(processState.child, "exit").then(() => true);
|
|
processState.child.kill("SIGTERM");
|
|
const exited = await Promise.race([
|
|
gracefulExit,
|
|
delay(2_000).then(() => false),
|
|
]);
|
|
if (exited || processState.child.exitCode !== null) {
|
|
return;
|
|
}
|
|
|
|
const forcedExit = once(processState.child, "exit").then(() => true);
|
|
processState.child.kill("SIGKILL");
|
|
const killed = await Promise.race([
|
|
forcedExit,
|
|
delay(2_000).then(() => false),
|
|
]);
|
|
if (!killed && processState.child.exitCode === null) {
|
|
throw new Error(`${processState.name} serve process did not exit after SIGKILL.`);
|
|
}
|
|
}
|
|
|
|
async function getFreePort(): Promise<number> {
|
|
const server = createServer();
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("Failed to allocate an ephemeral port.");
|
|
}
|
|
|
|
const { port } = address;
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.close((error) => {
|
|
if (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
return port;
|
|
}
|
|
|
|
function findAdvertiseHost(): string {
|
|
const interfaces = networkInterfaces();
|
|
for (const entries of Object.values(interfaces)) {
|
|
for (const entry of entries ?? []) {
|
|
if (entry.family === "IPv4" && !entry.internal) {
|
|
return entry.address;
|
|
}
|
|
}
|
|
}
|
|
|
|
return "127.0.0.1";
|
|
}
|