CultLib/packages/cultmesh-ts/test/realtime-quic-native.test.ts
Meta Vens 58a6fc5c06 Merge the koffi allocation-leak fix into the retention line
This brings fix batch 3c (per-runtime out-buffers, freed on the last
release, plus a steady-state RSS test in its own child process) onto
the branch carrying fix batches 3a and 3b and retention.

The merge was automatic. Checked by hand: release() frees the reused
buffers only after the reference count reaches zero and the pump has
exited. retain() (from 3a) is called only while the provider already
holds a reference, so it cannot resurrect a runtime that is closing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:06:29 +02:00

388 lines
16 KiB
TypeScript

// The Cut 3 FFI smoke, now run against `CultMeshQuicNativeRuntime` instead of
// raw koffi calls: open a runtime, open a listener with a real credential,
// connect a real loopback connection, exchange one frame's worth of raw
// bytes, and close everything down. This proves the binding's declared ABI
// matches the shipped bridge; frame semantics (delivery, generation) are
// `realtime-quic-consumer.test.ts`'s job.
//
// Skips instead of failing when no native bridge is available: set
// CULTMESH_QUIC_NATIVE_DIR to a directory `scripts/build-quic-native.ps1` or
// `scripts/build-quic-native.sh` built, or run from the committed
// `packages/cultmesh-ts/native/<platform>` tree once a later cut lands it.
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
import {
CultMeshQuicNativeRuntime,
CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION,
CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED,
CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED,
CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN,
CULTMESH_QUIC_EVENT_STREAM_STARTED,
CULTMESH_QUIC_EVENT_STREAM_FRAME,
CULTMESH_QUIC_EVENT_STREAM_SEND_COMPLETE,
CULTMESH_QUIC_STREAM_RELIABLE,
} from "../src/realtime-quic-native";
import { nativeBridgeAvailable } from "./support/native-bridge";
// __dirname at runtime is dist-test/test; fixtures are binary and are never
// compiled/copied there, so they are read from their source location, two
// levels up.
const FIXTURE_P12 = join(__dirname, "..", "..", "test", "fixtures", "quic-test.p12");
async function waitFor<T>(collect: (resolve: (value: T) => void) => void, timeoutMs = 5_000): Promise<T> {
return await new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("timed out waiting for a native QUIC event")), timeoutMs);
collect((value) => {
clearTimeout(timer);
resolve(value);
});
});
}
test("CultMeshQuicNativeRuntime opens and closes with no listener or connection", async (t) => {
if (!nativeBridgeAvailable()) {
t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge.");
return;
}
const runtime = await CultMeshQuicNativeRuntime.open();
await runtime.release();
});
test("CultMeshQuicNativeRuntime opens and closes a listener with a real PKCS12 credential", async (t) => {
if (!nativeBridgeAvailable()) {
t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge.");
return;
}
const runtime = await CultMeshQuicNativeRuntime.open();
try {
const pkcs12 = readFileSync(FIXTURE_P12);
const { listenerId, boundPort } = runtime.listenerOpen("127.0.0.1", 0, pkcs12, "");
assert.ok(boundPort > 0);
runtime.listenerClose(listenerId);
} finally {
await runtime.release();
}
});
test("a full loopback connection delivers one raw frame end to end", async (t) => {
if (!nativeBridgeAvailable()) {
t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge.");
return;
}
const runtime = await CultMeshQuicNativeRuntime.open();
try {
const pkcs12 = readFileSync(FIXTURE_P12);
const { listenerId, boundPort } = runtime.listenerOpen("127.0.0.1", 0, pkcs12, "");
const accepted = waitFor<bigint>((resolve) => {
runtime.onListenerEvent(listenerId, (event) => {
if (event.type === CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION) resolve(event.connectionId);
});
});
const clientConnectionId = runtime.connectionOpen("127.0.0.1", boundPort);
const clientConnected = waitFor<void>((resolve) => {
runtime.onConnectionEvent(clientConnectionId, (event) => {
if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED) {
runtime.connectionCertificateComplete(clientConnectionId, true);
return;
}
if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED) resolve();
});
});
const serverConnectionId = await accepted;
const serverFrame = waitFor<Uint8Array>((resolve) => {
runtime.onConnectionEvent(serverConnectionId, (event) => {
if (event.type === CULTMESH_QUIC_EVENT_STREAM_STARTED) {
assert.equal(event.streamKind, CULTMESH_QUIC_STREAM_RELIABLE);
return;
}
if (event.type === CULTMESH_QUIC_EVENT_STREAM_FRAME) resolve(event.payload);
});
});
await clientConnected;
const streamId = runtime.streamOpen(clientConnectionId, CULTMESH_QUIC_STREAM_RELIABLE);
const sendComplete = waitFor<void>((resolve) => {
runtime.onConnectionEvent(clientConnectionId, (event) => {
if (event.type === CULTMESH_QUIC_EVENT_STREAM_SEND_COMPLETE) resolve();
});
});
runtime.streamSendFrame(streamId, new Uint8Array([1, 2, 3, 4]), true);
await sendComplete;
const payload = await serverFrame;
assert.deepEqual(Array.from(payload), [1, 2, 3, 4]);
runtime.connectionShutdown(clientConnectionId, 0n);
runtime.connectionShutdown(serverConnectionId, 0n);
runtime.listenerClose(listenerId);
} finally {
await runtime.release();
}
});
test("F7: a 64-bit event code round-trips exactly through the struct decode", async (t) => {
if (!nativeBridgeAvailable()) {
t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge.");
return;
}
const runtime = await CultMeshQuicNativeRuntime.open();
try {
const pkcs12 = readFileSync(FIXTURE_P12);
const { listenerId, boundPort } = runtime.listenerOpen("127.0.0.1", 0, pkcs12, "");
// `code` (a `uint64_t`, the same field `CULTMESH_QUIC_SEND_CANCELED` is
// compared against on the stream-send path) is exercised here through
// CONNECTION_SHUTDOWN instead, whose code is an application-chosen value
// this test fully controls: two values above 2^53 (where koffi's decode
// stops being a plain safe-integer `number`) prove the whole
// encode-native-decode-coerce path is exact, deterministically, with no
// dependency on MsQuic's own send-completion timing.
for (const code of [(1n << 53n) + 1n, (1n << 62n) - 1n]) {
const accepted = waitFor<bigint>((resolve) => {
runtime.onListenerEvent(listenerId, (event) => {
if (event.type === CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION) resolve(event.connectionId);
});
});
const clientConnectionId = runtime.connectionOpen("127.0.0.1", boundPort);
const clientConnected = waitFor<void>((resolve) => {
runtime.onConnectionEvent(clientConnectionId, (event) => {
if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED) {
runtime.connectionCertificateComplete(clientConnectionId, true);
return;
}
if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED) resolve();
});
});
const serverConnectionId = await accepted;
await clientConnected;
const seenOnClient = waitFor<bigint>((resolve) => {
runtime.onConnectionEvent(clientConnectionId, (event) => {
if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN) resolve(event.code);
});
});
runtime.connectionShutdown(serverConnectionId, code);
const seen = await seenOnClient;
assert.equal(typeof seen, "bigint", "a decoded event code must always be bigint, never number");
assert.equal(seen, code, `shutdown code ${code} must round-trip exactly through the struct decode`);
}
runtime.listenerClose(listenerId);
} finally {
await runtime.release();
}
});
test(
"P10b: a throwing onFault handler is isolated — the pump survives and other connections' handlers still run",
{ timeout: 10_000 },
async (t) => {
if (!nativeBridgeAvailable()) {
t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge.");
return;
}
const runtime = await CultMeshQuicNativeRuntime.open();
try {
const pkcs12 = readFileSync(FIXTURE_P12);
const { listenerId, boundPort } = runtime.listenerOpen("127.0.0.1", 0, pkcs12, "");
// Connection B: a normal, fully connected loopback pair, established
// before A ever faults — so proving B still works afterward exercises
// an already-running connection's handler, not a second accept cycle
// racing whatever state A's fault left behind.
const acceptedB = waitFor<bigint>((resolve) => {
runtime.onListenerEvent(listenerId, (event) => {
if (event.type === CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION) resolve(event.connectionId);
});
});
const clientB = runtime.connectionOpen("127.0.0.1", boundPort);
const clientBConnected = waitFor<void>((resolve) => {
runtime.onConnectionEvent(clientB, (event) => {
if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED) {
runtime.connectionCertificateComplete(clientB, true);
return;
}
if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED) resolve();
});
});
const serverBId = await acceptedB;
const serverBFrame = waitFor<Uint8Array>((resolve) => {
runtime.onConnectionEvent(serverBId, (event) => {
if (event.type === CULTMESH_QUIC_EVENT_STREAM_FRAME) resolve(event.payload);
});
});
await clientBConnected;
// Connection A, opened only after B is fully connected: both its
// listener and its onFault handler throw on the first event delivered
// to it. Without `faultConnection`'s own try/catch isolating a
// throwing `onFault` (P10b's target), that throw would escape the
// pump's dispatch loop and take every other connection — including
// the already-live B — down with it, instead of staying scoped to A.
//
// The server-side connection listener is registered synchronously,
// inside the LISTENER_NEW_CONNECTION callback itself, rather than
// after an `await`: the native bridge can dispatch a connection's next
// event (CERTIFICATE_RECEIVED, or CONNECTED) before a later `await`'s
// continuation ever runs, and an event with no listener registered for
// its connection id is silently dropped (`dispatch`'s own
// `if (!entry) return`) — the same async-registration gap fix 1 closed
// for the provider's own accept path.
const serverAFaulted = waitFor<void>((resolve) => {
runtime.onListenerEvent(listenerId, (event) => {
if (event.type !== CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION) return;
runtime.onConnectionEvent(
event.connectionId,
() => {
throw new Error("P10b: connection A's listener always throws.");
},
() => {
resolve();
throw new Error("P10b: connection A's onFault also throws.");
},
);
});
});
const clientA = runtime.connectionOpen("127.0.0.1", boundPort);
runtime.onConnectionEvent(clientA, (event) => {
if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED) {
runtime.connectionCertificateComplete(clientA, true);
}
});
await serverAFaulted;
// Prove B's handler is still live after A's onFault threw: send a
// frame on B and confirm the server side still receives it.
const streamId = runtime.streamOpen(clientB, CULTMESH_QUIC_STREAM_RELIABLE);
runtime.streamSendFrame(streamId, new Uint8Array([9, 9, 9]), true);
const payload = await serverBFrame;
assert.deepEqual(
Array.from(payload),
[9, 9, 9],
"connection B must still deliver frames after A's onFault threw",
);
runtime.connectionShutdown(clientA, 0n);
runtime.connectionShutdown(clientB, 0n);
runtime.connectionShutdown(serverBId, 0n);
runtime.listenerClose(listenerId);
} finally {
await runtime.release();
}
},
);
test("fix 2: release() refuses a call with no outstanding reference", async (t) => {
if (!nativeBridgeAvailable()) {
t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge.");
return;
}
const runtime = await CultMeshQuicNativeRuntime.open();
await runtime.release();
await assert.rejects(runtime.release(), /no outstanding reference/i);
});
test("negative grep: no koffi callbacks, .async only on nextEvent", () => {
// __dirname at runtime is dist-test/test; the source lives two levels up,
// in src/, since only .js output is mirrored under dist-test.
const source = readFileSync(join(__dirname, "..", "..", "src", "realtime-quic-native.ts"), "utf8");
// `.async` is referenced (not necessarily called with a literal "(" at the
// reference site: it is handed to `promisify`, which calls it) only for
// `nextEvent`, the ABI's single blocking export.
const asyncUses = [...source.matchAll(/\.async\b/g)];
assert.ok(asyncUses.length > 0, "expected at least one .async use for nextEvent");
for (const match of asyncUses) {
const before = source.slice(Math.max(0, match.index! - 40), match.index!);
assert.match(before, /nextEvent/i, "the only .async reference must be nextEvent");
}
assert.doesNotMatch(source, /koffi\.register/, "no koffi callbacks");
});
// The measurement below runs in its own `node --expose-gc` child process
// instead of in this test's own process: by the time this test would run,
// ~150 earlier tests in the suite have already grown and partly freed the
// glibc heap, so a real per-call native leak can land in already-mapped free
// space and never show up as RSS growth in *this* process (confirmed by hand:
// the in-process version of this test passed even with `nextEvent` mutated
// back to allocating fresh koffi buffers every call). A freshly spawned
// process has no such history, matching the ~8 MiB (fixed) vs ~30 MiB
// (pre-fix) split measured in isolation.
const MEMORY_PROBE_SCRIPT = `
const { CultMeshQuicNativeRuntime } = require(process.argv[1]);
(async () => {
const runtime = await CultMeshQuicNativeRuntime.open();
const nextEvent = runtime.nextEvent.bind(runtime);
try {
for (let i = 0; i < 2000; i += 1) await nextEvent(0, null, 0);
global.gc();
const before = process.memoryUsage().rss;
const ITERATIONS = 200000;
for (let i = 0; i < ITERATIONS; i += 1) await nextEvent(0, null, 0);
global.gc();
const after = process.memoryUsage().rss;
process.stdout.write(JSON.stringify({ before, after, growthBytes: after - before, ITERATIONS }));
} finally {
await runtime.release();
}
})().catch((error) => {
console.error(error);
process.exit(1);
});
`;
test(
"memory: sustained nextEvent calls do not leak koffi-allocated out-parameter buffers",
{ timeout: 60_000 },
(t) => {
if (!nativeBridgeAvailable()) {
t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge.");
return;
}
// `nextEvent` is private; the compiled module path is resolved the same
// way this file's own `import` resolves it, and handed to the child by
// argv so the child needs no path logic of its own.
const modulePath = require.resolve("../src/realtime-quic-native");
const result = spawnSync(process.execPath, ["--expose-gc", "-e", MEMORY_PROBE_SCRIPT, modulePath], {
encoding: "utf8",
env: process.env,
});
if (result.status !== 0 || typeof result.stdout !== "string" || result.stdout.trim().length === 0) {
if (/--expose-gc/.test(result.stderr ?? "")) {
t.skip("this Node build does not support --expose-gc.");
return;
}
assert.fail(
`memory probe child process failed (status ${String(result.status)}):\n${result.stderr ?? "(no stderr)"}`,
);
}
const { before, after, growthBytes, ITERATIONS } = JSON.parse(result.stdout.trim()) as {
before: number;
after: number;
growthBytes: number;
ITERATIONS: number;
};
// Measured on Yggdrasil, same build and host, in a dedicated process:
// fixed code (0 `koffi.alloc` calls in the loop): ~8.0 MiB RSS growth.
// pre-fix code (400k `koffi.alloc` calls, never freed): ~29.9 MiB.
// 18 MiB sits well above the fixed baseline and well below the leaking
// one, so the bound stays robust to ordinary run-to-run noise while
// still catching the regression.
const BOUND_BYTES = 18 * 1024 * 1024;
assert.ok(
growthBytes < BOUND_BYTES,
`RSS grew by ${growthBytes} bytes (${(growthBytes / 1024 / 1024).toFixed(2)} MiB, from ${before} to ${after}) ` +
`over ${ITERATIONS} nextEvent calls; expected it to stay under ${BOUND_BYTES / 1024 / 1024} MiB once ` +
"out-parameter buffers are reused instead of allocated per call.",
);
},
);