diff --git a/package.json b/package.json index fd1a319..ffc5cc7 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "generate:server-json": "tsx scripts/sync-release-metadata.ts", "publish:mcp-registry": "tsx scripts/publish-mcp-registry.ts", "lint": "eslint .", - "test": "node --test --test-concurrency=1 dist/test/coordinator-socket-error.integration.test.js dist/test/identity-store.test.js dist/test/identity-cert.test.js dist/test/broadcast-window.integration.test.js dist/test/identity-restart.integration.test.js dist/test/mesh-e2e.integration.test.js dist/test/ws-broadcast-window.integration.test.js dist/test/tls-transport.integration.test.js dist/test/peer-id-verification.integration.test.js dist/test/state-sync-convergence.test.js dist/test/downtime-replay.test.js dist/test/downtime-replay.integration.test.js dist/test/filestore.test.js", + "test": "node --test --test-concurrency=1 dist/test/coordinator-socket-error.integration.test.js dist/test/identity-store.test.js dist/test/identity-cert.test.js dist/test/broadcast-window.integration.test.js dist/test/identity-restart.integration.test.js dist/test/mesh-e2e.integration.test.js dist/test/ws-broadcast-window.integration.test.js dist/test/tls-transport.integration.test.js dist/test/peer-id-verification.integration.test.js dist/test/become-coordinator-actual-port.integration.test.js dist/test/state-sync-convergence.test.js dist/test/downtime-replay.test.js dist/test/downtime-replay.integration.test.js dist/test/filestore.test.js", "test:visibility": "node --test dist/test/visibility.integration.test.js", "test:delivery": "node dist/test/delivery-receipt.runner.js", "test:federation": "node dist/test/federation.integration.test.js", diff --git a/src/core/tcp-transport.ts b/src/core/tcp-transport.ts index 602ec01..d833d3e 100644 --- a/src/core/tcp-transport.ts +++ b/src/core/tcp-transport.ts @@ -314,12 +314,15 @@ export class TcpTransport implements MeshTransport { }); server.listen(port, host, () => { + const addr = server.address(); + const actualPort = + typeof addr === "object" && addr !== null ? addr.port : port; this._isCoordinator = true; this.coordinatorListeners.set(id, { server, policy: "full", host, - port, + port: actualPort, isDefault: true, }); this.defaultListenerId = id; diff --git a/src/core/tls-transport.ts b/src/core/tls-transport.ts index 3822bd2..42388c8 100644 --- a/src/core/tls-transport.ts +++ b/src/core/tls-transport.ts @@ -405,12 +405,15 @@ export class TlsTransport { }); server.listen(port, host, () => { + const addr = server.address(); + const actualPort = + typeof addr === "object" && addr !== null ? addr.port : port; this._isCoordinator = true; this.coordinatorListeners.set(id, { server, policy: "full", host, - port, + port: actualPort, isDefault: true, }); this.defaultListenerId = id; diff --git a/src/test/become-coordinator-actual-port.integration.test.ts b/src/test/become-coordinator-actual-port.integration.test.ts new file mode 100644 index 0000000..734524c --- /dev/null +++ b/src/test/become-coordinator-actual-port.integration.test.ts @@ -0,0 +1,122 @@ +/** + * becomeCoordinator actual-port regression test (#42) — becomeCoordinator(host, 0) must report the OS-assigned port it actually bound, not the literal 0 it was called with, on every transport whose listener bookkeeping goes through listListeners(). + * + * Run: node dist/test/become-coordinator-actual-port.integration.test.js [test-name] With no argument, every scenario runs in order. + */ + +import * as net from "node:net"; +import * as tls from "node:tls"; +import * as assert from "node:assert/strict"; +import { TlsTransport } from "../core/tls-transport.js"; +import { TcpTransport } from "../core/tcp-transport.js"; +import { generateIdentity } from "../core/identity.js"; +import type { TransportEvents } from "../core/transport.js"; + +function noopEvents(): TransportEvents { + return { + onMessage: () => undefined, + onPeerConnected: () => undefined, + onPeerDisconnected: () => undefined, + onIntroduction: () => undefined, + onConnectionRequest: () => undefined, + onPeerList: () => undefined, + onPeerJoined: () => undefined, + onBecomeCoordinator: () => undefined, + }; +} + +async function testTlsReportsActualPort(): Promise { + const identity = generateIdentity(); + const transport = new TlsTransport(noopEvents(), identity); + + await transport.becomeCoordinator("127.0.0.1", 0); + const [listener] = transport.listListeners(); + assert.ok(listener); + assert.notStrictEqual(listener.port, 0); + + await new Promise((resolve, reject) => { + const socket = tls.connect( + { host: "127.0.0.1", port: listener.port, rejectUnauthorized: false }, + () => { + socket.destroy(); + resolve(); + }, + ); + socket.once("error", reject); + }); + console.log( + ` ✓ TlsTransport reported and bound the same port (${listener.port})`, + ); + + await transport.shutdown(); +} + +async function testTcpReportsActualPort(): Promise { + const transport = new TcpTransport(noopEvents()); + + await transport.becomeCoordinator("127.0.0.1", 0); + const [listener] = transport.listListeners(); + assert.ok(listener); + assert.notStrictEqual(listener.port, 0); + + await new Promise((resolve, reject) => { + const socket = net.connect( + { host: "127.0.0.1", port: listener.port }, + () => { + socket.destroy(); + resolve(); + }, + ); + socket.once("error", reject); + }); + console.log( + ` ✓ TcpTransport reported and bound the same port (${listener.port})`, + ); + + await transport.shutdown(); +} + +// --------------------------------------------------------------------------- +// Runner +// --------------------------------------------------------------------------- + +const testName = process.argv[2]; + +const tests: Record Promise> = { + "tls-reports-actual-port": testTlsReportsActualPort, + "tcp-reports-actual-port": testTcpReportsActualPort, +}; + +const selected = + testName === undefined + ? Object.entries(tests) + : Object.entries(tests).filter(([name]) => name === testName); +if (selected.length === 0) { + console.error(`Unknown test: ${testName}`); + console.error(`Available: ${Object.keys(tests).join(", ")}`); + process.exit(1); +} + +async function run(): Promise { + for (const [name, fn] of selected) { + console.log(`Running ${name}:`); + await fn(); + } + + const maxWait = 2000; + const start = Date.now(); + while ( + (( + process as unknown as { _getActiveHandles?: () => unknown[] } + )._getActiveHandles?.()?.length ?? 0) > 0 && + Date.now() - start < maxWait + ) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + process.exit(0); +} + +run().catch((err: unknown) => { + console.error(`FAIL [${testName ?? "all"}]:`, err); + process.exit(1); +});