From 33da8a1298627edd25d8c19a4c5e89ec3463515b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 23 Sep 2026 19:10:52 +0200 Subject: [PATCH 1/6] cultmesh-ts: stop leaking koffi.alloc buffers in the QUIC native binding A Soul-verified probe found every koffi out-parameter allocation (runtime open, listener open, connection open, stream open, and both per-`nextEvent` buffers) was never freed. `nextEvent` runs in the pump's tight poll loop, so this leaked roughly 53 bytes per idle cycle even with no traffic, and grew unbounded under real frame volume (214 MB of RSS over 4M allocations on the pinned koffi 3.3.0 build). Allocate `nextEvent`'s out-event and out-required buffers once per runtime instance and reuse them for every call instead of once per call: the pump never has more than one `nextEvent` in flight, so decode-then-reuse is safe, and they are freed once in `release()` after the pump loop exits. Every other allocation (`open`, `listenerOpen`, `connectionOpen`, `streamOpen`) is synchronous and short-lived, so those are freed with `koffi.free` right after decode. Co-Authored-By: Claude Sonnet 5 --- .../cultmesh-ts/src/realtime-quic-native.ts | 98 +++++++++++++------ 1 file changed, 69 insertions(+), 29 deletions(-) diff --git a/packages/cultmesh-ts/src/realtime-quic-native.ts b/packages/cultmesh-ts/src/realtime-quic-native.ts index c3f13ca2..507c84a2 100644 --- a/packages/cultmesh-ts/src/realtime-quic-native.ts +++ b/packages/cultmesh-ts/src/realtime-quic-native.ts @@ -275,9 +275,21 @@ export class CultMeshQuicNativeRuntime { private closed = false; private pumpLoop: Promise; + /** + * `nextEvent`'s out-parameter buffers, allocated once and reused for every + * call instead of per call: the pump is single-threaded with at most one + * `nextEvent` in flight (`nextEventAsync` is only ever awaited, never + * fired concurrently), so decode-then-reuse is safe. Freed once in + * `release()` after the pump loop has exited. + */ + private readonly outEventBuffer: unknown; + private readonly outRequiredBuffer: unknown; + private constructor(handle: CultMeshQuicNativeHandle, lib: LoadedNativeLibrary) { this.handle = handle; this.lib = lib; + this.outEventBuffer = lib.koffi.alloc(lib.event, 1); + this.outRequiredBuffer = lib.koffi.alloc("int32_t", 1); this.pumpLoop = this.pump(); } @@ -287,9 +299,11 @@ export class CultMeshQuicNativeRuntime { const out = lib.koffi.alloc("void *", 1); const status = lib.runtimeOpenFn(null, out); if (status !== 0) { + lib.koffi.free(out); throw new Error(`cultmesh_quic_runtime_open failed with status ${status}.`); } const handle = toBigInt64(lib.koffi.decode(out, "void *")); + lib.koffi.free(out); CultMeshQuicNativeRuntime.shared = new CultMeshQuicNativeRuntime(handle, lib); } CultMeshQuicNativeRuntime.refCountValue += 1; @@ -315,6 +329,8 @@ export class CultMeshQuicNativeRuntime { CultMeshQuicNativeRuntime.shared = undefined; await this.pumpLoop; this.lib.runtimeCloseFn(this.handle); + this.lib.koffi.free(this.outEventBuffer); + this.lib.koffi.free(this.outRequiredBuffer); } onListenerEvent(listenerId: bigint, listener: NativeEventListener): void { @@ -349,23 +365,28 @@ export class CultMeshQuicNativeRuntime { ): { listenerId: bigint; boundPort: number } { const outListenerId = this.lib.koffi.alloc("uint64_t", 1); const outBoundPort = this.lib.koffi.alloc("uint16_t", 1); - const status = this.lib.listenerOpenFn( - this.handle, - host, - port, - pkcs12, - pkcs12.length, - password, - outListenerId, - outBoundPort, - ); - if (status !== 0) { - throw new Error(`cultmesh_quic_listener_open failed: ${readLastError(this.lib, this.handle)}`); + try { + const status = this.lib.listenerOpenFn( + this.handle, + host, + port, + pkcs12, + pkcs12.length, + password, + outListenerId, + outBoundPort, + ); + if (status !== 0) { + throw new Error(`cultmesh_quic_listener_open failed: ${readLastError(this.lib, this.handle)}`); + } + return { + listenerId: toBigInt64(this.lib.koffi.decode(outListenerId, "uint64_t")), + boundPort: this.lib.koffi.decode(outBoundPort, "uint16_t") as number, + }; + } finally { + this.lib.koffi.free(outListenerId); + this.lib.koffi.free(outBoundPort); } - return { - listenerId: toBigInt64(this.lib.koffi.decode(outListenerId, "uint64_t")), - boundPort: this.lib.koffi.decode(outBoundPort, "uint16_t") as number, - }; } listenerClose(listenerId: bigint): void { @@ -375,11 +396,15 @@ export class CultMeshQuicNativeRuntime { connectionOpen(host: string, port: number): bigint { const out = this.lib.koffi.alloc("uint64_t", 1); - const status = this.lib.connectionOpenFn(this.handle, host, port, out); - if (status !== 0) { - throw new Error(`cultmesh_quic_connection_open failed: ${readLastError(this.lib, this.handle)}`); + try { + const status = this.lib.connectionOpenFn(this.handle, host, port, out); + if (status !== 0) { + throw new Error(`cultmesh_quic_connection_open failed: ${readLastError(this.lib, this.handle)}`); + } + return toBigInt64(this.lib.koffi.decode(out, "uint64_t")); + } finally { + this.lib.koffi.free(out); } - return toBigInt64(this.lib.koffi.decode(out, "uint64_t")); } connectionCertificateComplete(connectionId: bigint, accept: boolean): void { @@ -397,11 +422,15 @@ export class CultMeshQuicNativeRuntime { streamOpen(connectionId: bigint, kind: number): bigint { const out = this.lib.koffi.alloc("uint64_t", 1); - const status = this.lib.streamOpenFn(this.handle, connectionId, kind, out); - if (status !== 0) { - throw new Error(`cultmesh_quic_stream_open failed: ${readLastError(this.lib, this.handle)}`); + try { + const status = this.lib.streamOpenFn(this.handle, connectionId, kind, out); + if (status !== 0) { + throw new Error(`cultmesh_quic_stream_open failed: ${readLastError(this.lib, this.handle)}`); + } + return toBigInt64(this.lib.koffi.decode(out, "uint64_t")); + } finally { + this.lib.koffi.free(out); } - return toBigInt64(this.lib.koffi.decode(out, "uint64_t")); } streamSendFrame(streamId: bigint, encodedFrame: Uint8Array, fin: boolean): void { @@ -484,10 +513,21 @@ export class CultMeshQuicNativeRuntime { payload: Uint8Array | null, payloadCapacity: number, ): Promise<{ status: number; event: NativeEventStruct | null; required: number }> { - const outEvent = this.lib.koffi.alloc(this.lib.event, 1); - const outRequired = this.lib.koffi.alloc("int32_t", 1); - const status = await this.lib.nextEventAsync(this.handle, timeoutMs, outEvent, payload, payloadCapacity, outRequired); - const decoded = status === 1 ? (this.lib.koffi.decode(outEvent, this.lib.event) as NativeEventStruct) : null; + // Reuses the buffers allocated once in the constructor instead of + // allocating fresh ones per call: the pump awaits each `nextEventAsync` + // before issuing the next one, so at most one call is ever in flight + // against them, and both are fully decoded here before the buffers can + // be reused by the pump's next call. + const status = await this.lib.nextEventAsync( + this.handle, + timeoutMs, + this.outEventBuffer, + payload, + payloadCapacity, + this.outRequiredBuffer, + ); + const decoded = + status === 1 ? (this.lib.koffi.decode(this.outEventBuffer, this.lib.event) as NativeEventStruct) : null; return { status, event: decoded && { @@ -497,7 +537,7 @@ export class CultMeshQuicNativeRuntime { stream_id: toBigInt64(decoded.stream_id), code: toBigInt64(decoded.code), }, - required: this.lib.koffi.decode(outRequired, "int32_t") as number, + required: this.lib.koffi.decode(this.outRequiredBuffer, "int32_t") as number, }; } From f2f261bc3f22e6fc0d6a4b3a726ad9f22b774451 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 23 Sep 2026 19:11:08 +0200 Subject: [PATCH 2/6] cultmesh-ts: infer LoadedNativeLibrary from buildLibrary's return instead of restating it The `LoadedNativeLibrary` interface just re-listed the sixteen fields of the object literal `loadBindings` already built, one liability the methods below never needed a second time. Split the library-building body into `buildLibrary()` and let `LoadedNativeLibrary` be `ReturnType`; `loadBindings` is now a thin cache wrapper around it. `nextEventAsync`'s signature stays explicit as `NextEventAsyncFn`, since `promisify` can't infer one from koffi's untyped `.async`. Co-Authored-By: Claude Sonnet 5 --- .../cultmesh-ts/src/realtime-quic-native.ts | 55 +++++++++---------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/packages/cultmesh-ts/src/realtime-quic-native.ts b/packages/cultmesh-ts/src/realtime-quic-native.ts index 507c84a2..bb9a5efb 100644 --- a/packages/cultmesh-ts/src/realtime-quic-native.ts +++ b/packages/cultmesh-ts/src/realtime-quic-native.ts @@ -88,35 +88,26 @@ interface NativeEventStruct { // eslint-disable-next-line @typescript-eslint/no-explicit-any type KoffiLib = any; +/** `nextEventAsync`'s signature, typed explicitly because `promisify` cannot + * infer one from koffi's untyped (`any`) `.async` function. */ +type NextEventAsyncFn = ( + runtime: CultMeshQuicNativeHandle, + timeoutMs: number, + outEvent: unknown, + payload: Uint8Array | null, + payloadCapacity: number, + outRequired: unknown, +) => Promise; + /** * The loaded native library: koffi itself, the declared struct, and the * twelve raw v2 exports. Untyped (koffi is `any`); the runtime class methods * below are the type surface for these calls, so wrapping them a second time - * in a typed interface would only restate what the methods already say. + * in a typed interface would only restate what the methods already say. The + * type is inferred from `buildLibrary`'s return value instead of restated. */ -interface LoadedNativeLibrary { - koffi: KoffiLib; - event: KoffiLib; - runtimeOpenFn: KoffiLib; - runtimeCloseFn: KoffiLib; - listenerOpenFn: KoffiLib; - listenerCloseFn: KoffiLib; - connectionOpenFn: KoffiLib; - connectionCertificateCompleteFn: KoffiLib; - connectionShutdownFn: KoffiLib; - streamOpenFn: KoffiLib; - streamSendFrameFn: KoffiLib; - streamShutdownFn: KoffiLib; - nextEventAsync: ( - runtime: CultMeshQuicNativeHandle, - timeoutMs: number, - outEvent: unknown, - payload: Uint8Array | null, - payloadCapacity: number, - outRequired: unknown, - ) => Promise; - lastErrorFn: KoffiLib; -} +// eslint-disable-next-line @typescript-eslint/no-use-before-define +type LoadedNativeLibrary = ReturnType; let libraryCache: LoadedNativeLibrary | undefined; @@ -140,10 +131,8 @@ function resolveNativeDir(): string { return join(__dirname, "..", "native", platformArchDir()); } -/** Lazily loads koffi, resolves the binary directory, and declares the ABI. */ -function loadBindings(): LoadedNativeLibrary { - if (libraryCache) return libraryCache; - +/** Loads koffi, resolves the binary directory, and declares the ABI. */ +function buildLibrary() { // Untyped on purpose: koffi's own type declarations are ESM-flavored and // fight a CommonJS `typeof import(...)` reference under Node16 resolution. // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-explicit-any @@ -222,9 +211,9 @@ function loadBindings(): LoadedNativeLibrary { // eslint-disable-next-line @typescript-eslint/no-var-requires const { promisify } = require("node:util") as typeof import("node:util"); - const nextEventAsync = promisify(nextEventFn.async) as LoadedNativeLibrary["nextEventAsync"]; + const nextEventAsync = promisify(nextEventFn.async) as NextEventAsyncFn; - libraryCache = { + return { koffi, event, runtimeOpenFn, @@ -240,6 +229,12 @@ function loadBindings(): LoadedNativeLibrary { nextEventAsync, lastErrorFn, }; +} + +/** Lazily builds and caches the loaded native library. */ +function loadBindings(): LoadedNativeLibrary { + if (libraryCache) return libraryCache; + libraryCache = buildLibrary(); return libraryCache; } From 9a7e20a8d26aff1ec06ef54bcc90e12f5042529f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 23 Sep 2026 19:11:41 +0200 Subject: [PATCH 3/6] cultmesh-ts: add a steady-state memory test for the nextEvent leak Calls the runtime's private `nextEvent` directly (0 ms timeout, no listener/connection open, so each round trip returns immediately with status 0) 200,000 times after a warm-up, then asserts RSS growth across the run stays under 5 MiB with `global.gc()` forced before and after. The old per-call `koffi.alloc` code allocates a ~64 B event struct plus a 4 B int32 every iteration and never frees either, so it clears this bound by roughly 2x; the fixed code, which allocates nothing per call, stays flat. Skips when no native bridge is built or when Node wasn't started with `--expose-gc`; `npm test` now passes `--expose-gc` so the check runs by default instead of always skipping. Co-Authored-By: Claude Sonnet 5 --- packages/cultmesh-ts/package.json | 2 +- .../test/realtime-quic-native.test.ts | 61 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/packages/cultmesh-ts/package.json b/packages/cultmesh-ts/package.json index d379f02e..9d60f1d7 100644 --- a/packages/cultmesh-ts/package.json +++ b/packages/cultmesh-ts/package.json @@ -18,7 +18,7 @@ "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json", "prepack": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json", "typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --pretty false", - "test": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js" + "test": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --expose-gc --test dist-test/test/*.test.js" }, "keywords": [ "cultmesh", diff --git a/packages/cultmesh-ts/test/realtime-quic-native.test.ts b/packages/cultmesh-ts/test/realtime-quic-native.test.ts index 2d597628..04aa0ebd 100644 --- a/packages/cultmesh-ts/test/realtime-quic-native.test.ts +++ b/packages/cultmesh-ts/test/realtime-quic-native.test.ts @@ -205,3 +205,64 @@ test("negative grep: no koffi callbacks, .async only on nextEvent", () => { } assert.doesNotMatch(source, /koffi\.register/, "no koffi callbacks"); }); + +test( + "memory: sustained nextEvent calls do not leak koffi-allocated out-parameter buffers", + { timeout: 60_000 }, + async (t) => { + if (!nativeBridgeAvailable()) { + t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); + return; + } + if (typeof global.gc !== "function") { + t.skip("run with --expose-gc to measure RSS growth deterministically."); + return; + } + + const runtime = await CultMeshQuicNativeRuntime.open(); + try { + // `nextEvent` is private: the pump loop is the only production caller, + // and it never has more than one call in flight at a time, which is + // exactly the property this test needs to call it directly in a tight + // loop instead of waiting on the pump's 250 ms idle poll. A 0 ms + // timeout with no listener/connection open returns immediately with + // status 0 (no event), so each iteration is a fast round trip through + // the real native ABI, not a synthetic stand-in. + const nextEvent = ( + runtime as unknown as { + nextEvent(timeoutMs: number, payload: Uint8Array | null, payloadCapacity: number): Promise; + } + ).nextEvent.bind(runtime); + + // Warm up: let koffi's own one-time bookkeeping (module init, JIT, + // thread pool spin-up) happen before the baseline measurement. + for (let i = 0; i < 2_000; i += 1) { + await nextEvent(0, null, 0); + } + global.gc(); + const before = process.memoryUsage().rss; + + // Sized so the old per-call `koffi.alloc` code (~64 B event struct + + // ~4 B int32, plus allocator bookkeeping, well over 53 B/call) clearly + // exceeds the bound below, while the fixed code, which allocates + // nothing per call, stays flat. + const ITERATIONS = 200_000; + for (let i = 0; i < ITERATIONS; i += 1) { + await nextEvent(0, null, 0); + } + global.gc(); + const after = process.memoryUsage().rss; + + const growthBytes = after - before; + const BOUND_BYTES = 5 * 1024 * 1024; + assert.ok( + growthBytes < BOUND_BYTES, + `RSS grew by ${growthBytes} bytes (${(growthBytes / 1024 / 1024).toFixed(2)} MiB) 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.", + ); + } finally { + await runtime.release(); + } + }, +); From 28b6d3a67b4e6fb98340e520abfd26807cc7a67f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 23 Sep 2026 19:17:55 +0200 Subject: [PATCH 4/6] cultmesh-ts: stop the NextEventAsyncFn doc comment from tripping the async-callback grep The `.async` literal in the doc comment for `NextEventAsyncFn` matched the "negative grep: no koffi callbacks, .async only on nextEvent" test's `/\.async\b/g` scan, and the 40 characters before it didn't mention `nextEvent`, so the test failed on prose, not on a real second async callsite. Reworded to describe koffi's async export without the literal `.async` token. Co-Authored-By: Claude Sonnet 5 --- packages/cultmesh-ts/src/realtime-quic-native.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cultmesh-ts/src/realtime-quic-native.ts b/packages/cultmesh-ts/src/realtime-quic-native.ts index bb9a5efb..ab0ec1f3 100644 --- a/packages/cultmesh-ts/src/realtime-quic-native.ts +++ b/packages/cultmesh-ts/src/realtime-quic-native.ts @@ -89,7 +89,7 @@ interface NativeEventStruct { type KoffiLib = any; /** `nextEventAsync`'s signature, typed explicitly because `promisify` cannot - * infer one from koffi's untyped (`any`) `.async` function. */ + * infer one from koffi's untyped (`any`) async export. */ type NextEventAsyncFn = ( runtime: CultMeshQuicNativeHandle, timeoutMs: number, From a534af791f5b4ed39a97c1728dfbfff07f7f1525 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 23 Sep 2026 19:18:03 +0200 Subject: [PATCH 5/6] cultmesh-ts: calibrate the memory test's RSS bound against measured numbers The 5 MiB bound was a guess and false-failed on the fixed code: 200k direct `nextEvent` calls carry their own ~8 MiB RSS noise floor from thread-pool marshaling and promise bookkeeping, even with zero `koffi.alloc` calls in the loop. Measured on Yggdrasil, same build and host, same 200k-call loop: fixed code (0 koffi.alloc calls in the loop): ~8.0 MiB RSS growth pre-fix code (400k koffi.alloc calls, unfreed): ~29.9 MiB RSS growth 18 MiB sits well above the fixed baseline and well below the leaking one, so the test stays robust to ordinary run-to-run noise while still catching the regression. Co-Authored-By: Claude Sonnet 5 --- .../cultmesh-ts/test/realtime-quic-native.test.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/cultmesh-ts/test/realtime-quic-native.test.ts b/packages/cultmesh-ts/test/realtime-quic-native.test.ts index 04aa0ebd..a55d9a40 100644 --- a/packages/cultmesh-ts/test/realtime-quic-native.test.ts +++ b/packages/cultmesh-ts/test/realtime-quic-native.test.ts @@ -242,10 +242,14 @@ test( global.gc(); const before = process.memoryUsage().rss; - // Sized so the old per-call `koffi.alloc` code (~64 B event struct + - // ~4 B int32, plus allocator bookkeeping, well over 53 B/call) clearly - // exceeds the bound below, while the fixed code, which allocates - // nothing per call, stays flat. + // Even with zero JS-side allocation per call, 200k round trips through + // the real native ABI (thread-pool marshaling, promise bookkeeping) + // carry their own non-zero RSS noise floor: measured at ~8 MiB for the + // fixed code (0 `koffi.alloc` calls in the loop) versus ~30 MiB for the + // old per-call `koffi.alloc` code (400k alloc calls, never freed), on + // the same build and host. The bound sits well above the fixed + // baseline and well below the leaking one, so it stays robust to + // ordinary run-to-run noise while still catching the regression. const ITERATIONS = 200_000; for (let i = 0; i < ITERATIONS; i += 1) { await nextEvent(0, null, 0); @@ -254,7 +258,7 @@ test( const after = process.memoryUsage().rss; const growthBytes = after - before; - const BOUND_BYTES = 5 * 1024 * 1024; + const BOUND_BYTES = 18 * 1024 * 1024; assert.ok( growthBytes < BOUND_BYTES, `RSS grew by ${growthBytes} bytes (${(growthBytes / 1024 / 1024).toFixed(2)} MiB) over ` + From fedba286c5307c8149bcec8eae4eaf284e817ce6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 23 Sep 2026 19:24:54 +0200 Subject: [PATCH 6/6] cultmesh-ts: isolate the memory probe in its own child process By the time the memory test ran in-process, ~150 earlier tests in the suite had already grown and partly freed the glibc heap, so a real per-call native leak could land in already-mapped free space and never show up as RSS growth in that process: confirmed by hand, the in-process version of this test kept passing even with `nextEvent` mutated back to allocating fresh koffi buffers every call, once it ran inside the full suite rather than alone. Spawn a dedicated `node --expose-gc` child process for the measurement instead. A fresh process has no such history, matching the ~8 MiB (fixed) vs ~30 MiB (pre-fix) split measured in isolation. Confirmed by hand again with the same mutation: the child-process version now fails reliably (~28.7 MiB growth) inside the full 150-test suite, not just standalone. Co-Authored-By: Claude Sonnet 5 --- .../test/realtime-quic-native.test.ts | 119 ++++++++++-------- 1 file changed, 68 insertions(+), 51 deletions(-) diff --git a/packages/cultmesh-ts/test/realtime-quic-native.test.ts b/packages/cultmesh-ts/test/realtime-quic-native.test.ts index a55d9a40..5598c27f 100644 --- a/packages/cultmesh-ts/test/realtime-quic-native.test.ts +++ b/packages/cultmesh-ts/test/realtime-quic-native.test.ts @@ -11,6 +11,7 @@ // `packages/cultmesh-ts/native/` 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"; @@ -206,67 +207,83 @@ test("negative grep: no koffi callbacks, .async only on 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 }, - async (t) => { + (t) => { if (!nativeBridgeAvailable()) { t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); return; } - if (typeof global.gc !== "function") { - t.skip("run with --expose-gc to measure RSS growth deterministically."); - return; - } - const runtime = await CultMeshQuicNativeRuntime.open(); - try { - // `nextEvent` is private: the pump loop is the only production caller, - // and it never has more than one call in flight at a time, which is - // exactly the property this test needs to call it directly in a tight - // loop instead of waiting on the pump's 250 ms idle poll. A 0 ms - // timeout with no listener/connection open returns immediately with - // status 0 (no event), so each iteration is a fast round trip through - // the real native ABI, not a synthetic stand-in. - const nextEvent = ( - runtime as unknown as { - nextEvent(timeoutMs: number, payload: Uint8Array | null, payloadCapacity: number): Promise; - } - ).nextEvent.bind(runtime); - - // Warm up: let koffi's own one-time bookkeeping (module init, JIT, - // thread pool spin-up) happen before the baseline measurement. - for (let i = 0; i < 2_000; i += 1) { - await nextEvent(0, null, 0); + // `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; } - global.gc(); - const before = process.memoryUsage().rss; - - // Even with zero JS-side allocation per call, 200k round trips through - // the real native ABI (thread-pool marshaling, promise bookkeeping) - // carry their own non-zero RSS noise floor: measured at ~8 MiB for the - // fixed code (0 `koffi.alloc` calls in the loop) versus ~30 MiB for the - // old per-call `koffi.alloc` code (400k alloc calls, never freed), on - // the same build and host. The bound sits well above the fixed - // baseline and well below the leaking one, so it stays robust to - // ordinary run-to-run noise while still catching the regression. - const ITERATIONS = 200_000; - for (let i = 0; i < ITERATIONS; i += 1) { - await nextEvent(0, null, 0); - } - global.gc(); - const after = process.memoryUsage().rss; - - const growthBytes = after - before; - const BOUND_BYTES = 18 * 1024 * 1024; - assert.ok( - growthBytes < BOUND_BYTES, - `RSS grew by ${growthBytes} bytes (${(growthBytes / 1024 / 1024).toFixed(2)} MiB) 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.", + assert.fail( + `memory probe child process failed (status ${String(result.status)}):\n${result.stderr ?? "(no stderr)"}`, ); - } finally { - await runtime.release(); } + + 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.", + ); }, );