diff --git a/packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts b/packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts index 241514a6..aadd1265 100644 --- a/packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts +++ b/packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts @@ -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): Promise { } 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): Promise { 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 diff --git a/packages/cultnet-ts/test/interop/cultnet-interop.test.ts b/packages/cultnet-ts/test/interop/cultnet-interop.test.ts index c90a7e0c..756ef97d 100644 --- a/packages/cultnet-ts/test/interop/cultnet-interop.test.ts +++ b/packages/cultnet-ts/test/interop/cultnet-interop.test.ts @@ -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."); diff --git a/tests/GameCult.Mesh.Quic.Native.Tests/CultMeshNativeQuicRealtimeTransportTests.cs b/tests/GameCult.Mesh.Quic.Native.Tests/CultMeshNativeQuicRealtimeTransportTests.cs index e56b7c2e..4ab27191 100644 --- a/tests/GameCult.Mesh.Quic.Native.Tests/CultMeshNativeQuicRealtimeTransportTests.cs +++ b/tests/GameCult.Mesh.Quic.Native.Tests/CultMeshNativeQuicRealtimeTransportTests.cs @@ -35,6 +35,36 @@ public sealed class CultMeshNativeQuicRealtimeTransportTests frame.Payload.Should().NotBeEmpty(); } + /// + /// Cut C's headline cross-process proof: the harness lets the external + /// TypeScript provider finish broadcasting and disconnect its trigger peer + /// before 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. + /// + [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() {