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:
parent
7b7de5b071
commit
8cf89a7e56
3 changed files with 104 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue