From d9a406f9ff2b1d3f935b665591e03dc7eb0f6b4e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 23 Sep 2026 16:59:48 +0200 Subject: [PATCH] Fix hygiene: connect() rejection tests no longer leak a resolved transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found running the M1 mutation (bypasses the certificate-accept check) on Yggdrasil under cgroup-limited CPU: three tests correctly failed their assertion (`assert.rejects(connector.connect(...))` / `manager.connect(...)` observed a resolve instead of a reject), but realtime-quic-consumer.test.js then hung instead of exiting — "Promise resolution is still pending but the event loop has already resolved". Each of those assertions discarded the connect() call's return value on the assumption it would always reject, so the transport that unexpectedly connected under the mutant was never captured, never disposed: a live native connection, an active onConnectionEvent registration and an outstanding runtime reference kept the process alive after node:test had already recorded the failures. This is the exact scenario the batch names by history ("Soul saw M1 and M3 fail their assertion and then hang the file"). Added `assertRejectsAndDisposes()`, a drop-in replacement for `assert.rejects()` that disposes whatever the promise resolves to before failing the assertion, and used it at all eight call sites in this file that assert a connect() call rejects (two direct connector.connect() calls, six through the session manager). Verified on Yggdrasil: M1 re-run now fails the same three assertions and the file exits on its own. Co-Authored-By: Claude Sonnet 5 --- .../test/realtime-quic-consumer.test.ts | 42 +++++++++++++++---- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/packages/cultmesh-ts/test/realtime-quic-consumer.test.ts b/packages/cultmesh-ts/test/realtime-quic-consumer.test.ts index db684e1d..b3adc341 100644 --- a/packages/cultmesh-ts/test/realtime-quic-consumer.test.ts +++ b/packages/cultmesh-ts/test/realtime-quic-consumer.test.ts @@ -108,6 +108,32 @@ class RawQuicListener { } } +/** + * `assert.rejects`, but safe against a mutant or a real regression that + * makes the promise resolve instead: a transport that unexpectedly connects + * is disposed before the assertion fails, rather than left live with an open + * native connection and an outstanding runtime reference. A bare + * `assert.rejects(connector.connect(...))` hung the whole file exactly this + * way under M1 (the certificate-accept mutation): three tests failed their + * assertion correctly, but each left a connected, undisposed transport + * behind, and Node's test runner refuses to exit while any promise or + * native handle is still outstanding. + */ +async function assertRejectsAndDisposes( + promise: Promise<{ dispose(): void }>, + match?: RegExp, +): Promise { + let resolved: { dispose(): void } | undefined; + try { + resolved = await promise; + } catch (error) { + if (match) assert.match(error instanceof Error ? error.message : String(error), match); + return; + } + resolved.dispose(); + assert.fail("expected the promise to reject, but it resolved (the resolved value has been disposed)"); +} + function testFrame(overrides: Partial = {}): CultMeshRealtimeFrame { return { channelId: "aetheria.entities", @@ -162,7 +188,7 @@ test("connect refuses a wrong advertised pin", async (t) => { generation: "gen-1", }; const connector = new CultMeshQuicRealtimeConnector(); - await assert.rejects(connector.connect(candidate, target), /certificate|rejected/i); + await assertRejectsAndDisposes(connector.connect(candidate, target), /certificate|rejected/i); } finally { await listener.close(); } @@ -180,7 +206,7 @@ test("connect refuses a missing certificate pin when no validator is supplied", generation: "gen-1", }; const connector = new CultMeshQuicRealtimeConnector(); - await assert.rejects(connector.connect(candidate, target), /certificate|rejected/i); + await assertRejectsAndDisposes(connector.connect(candidate, target), /certificate|rejected/i); } finally { await listener.close(); } @@ -373,7 +399,7 @@ test("trust negative (a): an expired certificate is refused", async () => { trust, connectors: [unreachableConnector], }); - await assert.rejects(manager.connect(target), /valid|expired/i); + await assertRejectsAndDisposes(manager.connect(target), /valid|expired/i); }); test("trust negative (b): an unsigned remote route is refused", async () => { @@ -391,7 +417,7 @@ test("trust negative (b): an unsigned remote route is refused", async () => { trust, connectors: [unreachableConnector], }); - await assert.rejects(manager.connect(target), /Odin-signed/i); + await assertRejectsAndDisposes(manager.connect(target), /Odin-signed/i); }); test("trust negative (c): a route signed by an untrusted Odin root is refused", async () => { @@ -412,7 +438,7 @@ test("trust negative (c): a route signed by an untrusted Odin root is refused", trust, connectors: [unreachableConnector], }); - await assert.rejects(manager.connect(target), /not trusted/i); + await assertRejectsAndDisposes(manager.connect(target), /not trusted/i); }); test("trust negative (d): a route that verifies but pins the wrong certificate is refused at the transport", async (t) => { @@ -435,7 +461,7 @@ test("trust negative (d): a route that verifies but pins the wrong certificate i trust, connectors: [new CultMeshQuicRealtimeConnector()], }); - await assert.rejects(manager.connect(target), /certificate|rejected/i); + await assertRejectsAndDisposes(manager.connect(target), /certificate|rejected/i); } finally { await listener.close(); } @@ -482,7 +508,7 @@ test("trust negative (e): a transport that fails isVerifiedFor is disposed and r connectors: [staleConnector], }); - await assert.rejects(manager.connect(target), /did not prove|No realtime state path/i); + await assertRejectsAndDisposes(manager.connect(target), /did not prove|No realtime state path/i); assert.equal(disposed, true, "the session manager must dispose a transport that fails isVerifiedFor"); }); @@ -558,7 +584,7 @@ test("trust negative (e2): a real transport whose generation no longer matches t connectors: [replayConnector], }); - await assert.rejects(manager.connect(target), /did not prove|No realtime state path/i); + await assertRejectsAndDisposes(manager.connect(target), /did not prove|No realtime state path/i); assert.ok(connectedTransport, "a real transport must have connected"); // Real cleanup ran: receiveFrame on the disposed transport rejects. await assert.rejects(connectedTransport!.receiveFrame(), /disposed/i);