Extend the Cut 5 interop lane: a late C# native connector gets the TS retained frame

Cut C's headline cross-process proof. The TS `serve` peer already tore its
listener down the instant it finished broadcasting, so nothing could dial it
late; it gains `--linger-ms` to hold the listener open afterward instead. The
interop harness starts that provider, runs a throwaway `dial` peer that
receives every frame and disconnects (the provider requires a first peer
before it broadcasts at all, so this is also what proves broadcasting
finished), then only after that dials with the C# native connector and
asserts the frame it gets is the retained one, by payload, not just
non-empty. New `NativeConnectorReceivesTheRetainedFrameWhenJoiningLate` in
GameCult.Mesh.Quic.Native.Tests carries the assertion; the existing
`NativeConnectorReceivesFromExternalProvider` is untouched. Windows-only,
matching the native connector under test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-23 18:32:39 +02:00
commit 8cf89a7e56
3 changed files with 104 additions and 1 deletions

View file

@ -13,7 +13,10 @@
// `WaitForFirstConnectionAsync`, since a broadcast to nobody would otherwise
// silently no-op), then broadcasts `--frames` frames at `--delivery` with
// `--interval-ms` between them, watching stdin for EOF as its own shutdown
// signal like the C# peer's `WatchStdinCloseAsync`.
// signal like the C# peer's `WatchStdinCloseAsync`. `--linger-ms` (default 0)
// keeps the listener open that long after broadcasting finishes, so the
// harness can dial a peer late and exercise Cut C's retained-state seed
// instead of the provider always tearing down the moment it is done sending.
//
// Neither mode imports `cultmesh-ts` itself (the harness drives processes,
// and the dependency direction is `cultmesh-ts` -> `cultnet-ts`, never the
@ -88,6 +91,7 @@ async function serveAsync(args: Map<string, string>): Promise<void> {
}
const delivery = deliveryArg as CultMeshRealtimeDelivery;
const intervalMs = optionalIntArg(args, "interval-ms", 50);
const lingerMs = optionalIntArg(args, "linger-ms", 0);
const port = optionalIntArg(args, "port", 0);
const provider = await CultMeshQuicRealtimeProvider.listen({
@ -129,6 +133,7 @@ async function serveAsync(args: Map<string, string>): Promise<void> {
writeLog("quic-realtime-serve", { sent: sequence, hex: Buffer.from(encodeRealtimeFrame(frame)).toString("hex") });
if (intervalMs > 0) await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
if (lingerMs > 0 && !stopped) await new Promise((resolve) => setTimeout(resolve, lingerMs));
} finally {
provider.dispose();
// Mirrors the C# peer returning from `QuicRealtimeServeAsync`: once

View file

@ -506,6 +506,74 @@ test("CultMesh QUIC realtime: TypeScript provider and C# native connector (the U
assert.doesNotMatch(stdout, /Skipped:\s*1/, `the env var should have made the test run, not skip.\n${stdout}`);
});
// Cut C headline: a late-joining C# native connector receives the TS
// provider's retained frame. The TS provider's own trigger peer (a `dial`
// subprocess, needed only because the provider waits for a first connection
// before it broadcasts anything) receives every frame and disconnects before
// the native connector dials, so nothing here is a live retransmission: only
// Cut C's retained seed can deliver a frame to the late C# connector.
test("CultMesh QUIC realtime: a late-joining C# native connector receives the retained frame from a TypeScript provider", async (t) => {
if (process.platform !== "win32") {
t.skip("The native connector is Windows-only; this lane runs on Windows.");
return;
}
if (!process.env.CULTMESH_QUIC_NATIVE_DIR) {
t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge.");
return;
}
logInteropPhase("quic-provider-retention", "build the typescript quic peer");
await buildCultMeshTsQuicPeer();
const servers: RunningServeProcess[] = [];
t.after(async () => {
for (const server of servers) {
server.child.stdin?.end();
server.child.kill();
}
await delay(processCleanupTimeoutMs);
});
logInteropPhase("quic-provider-retention", "start the typescript QUIC provider");
servers.push(await spawnServeProcess("ts-quic-provider-retention", {
command: process.execPath,
args: [
cultMeshQuicPeerScript,
"serve",
"--frames", "3",
"--delivery", "latest-only",
"--interval-ms", "20",
"--linger-ms", "60000",
],
cwd: cultMeshTsRoot,
env: { CULTMESH_QUIC_NATIVE_DIR: process.env.CULTMESH_QUIC_NATIVE_DIR ?? "" },
stdin: "pipe",
}));
const ready = (await servers[servers.length - 1].ready) as { status: string; endpoint: string };
assert.equal(ready.status, "ready");
logInteropPhase("quic-provider-retention", "trigger peer receives every frame, then disconnects");
const trigger = await runJsonCommand("ts-quic-dial-trigger", process.execPath, [
cultMeshQuicPeerScript,
"dial",
"--endpoint", ready.endpoint,
"--expect", "3",
], cultMeshTsRoot, { CULTMESH_QUIC_NATIVE_DIR: process.env.CULTMESH_QUIC_NATIVE_DIR ?? "" });
assert.equal(trigger.frames.length, 3);
logInteropPhase("quic-provider-retention", "dotnet test: the native connector dials late, after the trigger peer is gone");
const { stdout } = await runDotnetTestFilter(
quicNativeTestsProject,
"NativeConnectorReceivesTheRetainedFrameWhenJoiningLate",
{
CULTMESH_NATIVE_EXTERNAL_ENDPOINT: ready.endpoint,
CULTMESH_NATIVE_EXTERNAL_EXPECTED_PAYLOAD: "frame-3",
},
);
assert.match(stdout, /Test Run Successful\./);
assert.match(stdout, /Passed:\s*1/, `expected exactly one passed test.\n${stdout}`);
assert.doesNotMatch(stdout, /Skipped:\s*1/, `the env vars should have made the test run, not skip.\n${stdout}`);
});
test("CultMesh QUIC realtime: TypeScript provider and C# managed connector, with golden bytes", async (t) => {
if (!process.env.CULTMESH_QUIC_NATIVE_DIR) {
t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge.");

View file

@ -35,6 +35,36 @@ public sealed class CultMeshNativeQuicRealtimeTransportTests
frame.Payload.Should().NotBeEmpty();
}
/// <summary>
/// Cut C's headline cross-process proof: the harness lets the external
/// TypeScript provider finish broadcasting and disconnect its trigger peer
/// <i>before</i> this test dials, so nothing is retransmitted live. If a
/// frame arrives at all here, it can only be the provider's retained seed;
/// this test also checks the retained frame's own payload, so a
/// regression that seeds a stale generation (not just no seed) is caught
/// too.
/// </summary>
[Test]
public async Task NativeConnectorReceivesTheRetainedFrameWhenJoiningLate()
{
var endpoint = Environment.GetEnvironmentVariable("CULTMESH_NATIVE_EXTERNAL_ENDPOINT");
if (string.IsNullOrWhiteSpace(endpoint))
Assert.Ignore("Set CULTMESH_NATIVE_EXTERNAL_ENDPOINT to exercise the late-join retention lane.");
var expectedPayload = Environment.GetEnvironmentVariable("CULTMESH_NATIVE_EXTERNAL_EXPECTED_PAYLOAD");
if (string.IsNullOrWhiteSpace(expectedPayload))
Assert.Ignore("Set CULTMESH_NATIVE_EXTERNAL_EXPECTED_PAYLOAD to the retained frame's expected payload text.");
var connector = new CultMeshNativeQuicRealtimeTransportConnector();
using var client = await connector.ConnectAsync(
new CultMeshTransportCandidate(endpoint!),
new CultMeshSessionTarget("aetheria", "aetheria.local"));
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var frame = await client.ReceiveAsync(timeout.Token);
frame.Delivery.Should().Be(CultMeshRealtimeDelivery.LatestOnly);
System.Text.Encoding.UTF8.GetString(frame.Payload.ToArray()).Should().Be(expectedPayload);
}
[Test]
public async Task NativeConnectorAuthenticatesAndReceivesLatestOnlyFrame()
{