diff --git a/test/cli/challenge-integration.test.ts b/test/cli/challenge-integration.test.ts index 1bb9f9c..1fcb686 100644 --- a/test/cli/challenge-integration.test.ts +++ b/test/cli/challenge-integration.test.ts @@ -9,7 +9,7 @@ import { type ManagedChildProcess, stopPkcDaemon, waitForCondition, - startPkcDaemon, + startPkcDaemonWithDynamicPorts, waitForKuboReady } from "../helpers/daemon-helpers.js"; @@ -17,13 +17,10 @@ dns.setDefaultResultOrder("ipv4first"); type PKCInstance = Awaited>; -// --- Port allocation (unique to this test file) --- -const RPC_PORT = 59138; -const KUBO_API_PORT = 50039; -const GATEWAY_PORT = 6493; -const rpcWsUrl = `ws://localhost:${RPC_PORT}`; -const kuboApiUrl = `http://0.0.0.0:${KUBO_API_PORT}/api/v0`; -const gatewayUrl = `http://0.0.0.0:${GATEWAY_PORT}`; +// Ports/URLs are allocated dynamically per run and assigned in beforeAll (issue #87). +let RPC_PORT: number; +let KUBO_API_PORT: number; +let rpcWsUrl: string; // --- Helpers specific to this test file --- @@ -167,11 +164,12 @@ describe("challenge integration tests", { timeout: 600_000 }, () => { expect(installResult.exitCode).toBe(0); expect(installResult.stdout).toContain("added test-challenge@1.0.0 in"); - // Start daemon — it handles kubo, RPC, and webui internally - daemonProcess = await startPkcDaemon( - ["--pkcOptions.dataPath", dataPath, "--pkcRpcUrl", rpcWsUrl], - { KUBO_RPC_URL: kuboApiUrl, IPFS_GATEWAY_URL: gatewayUrl } - ); + // Start daemon — it handles kubo, RPC, and webui internally. Dynamic ports + retry guard + // against the macOS ephemeral-range bind race (issue #87); the seeded dataPath is reused + // across retries (preInitKuboWithEphemeralSwarm is idempotent). + const daemon = await startPkcDaemonWithDynamicPorts((e) => ["--pkcOptions.dataPath", dataPath, "--pkcRpcUrl", e.rpcWsUrl]); + daemonProcess = daemon.daemonProcess; + ({ rpcPort: RPC_PORT, kuboPort: KUBO_API_PORT, rpcWsUrl } = daemon); // Wait for kubo API to be fully ready (it can lag behind the "Communities in data path" message) const kuboReady = await waitForKuboReady(`http://localhost:${KUBO_API_PORT}/api/v0`, 30000); diff --git a/test/cli/command-completion-time.test.ts b/test/cli/command-completion-time.test.ts index 1d75623..bc702d6 100644 --- a/test/cli/command-completion-time.test.ts +++ b/test/cli/command-completion-time.test.ts @@ -8,20 +8,18 @@ import WebSocket from "ws"; import { type ManagedChildProcess, stopPkcDaemon, - startPkcDaemon, + startPkcDaemonWithDynamicPorts, waitForCondition, waitForWebSocketOpen, waitForPortFree } from "../helpers/daemon-helpers.js"; dns.setDefaultResultOrder("ipv4first"); -// --- Port allocation (unique to this test file) --- -const RPC_PORT = 9538; -const KUBO_API_PORT = 50209; -const GATEWAY_PORT = 6703; -const rpcWsUrl = `ws://localhost:${RPC_PORT}`; -const kuboApiUrl = `http://0.0.0.0:${KUBO_API_PORT}/api/v0`; -const gatewayUrl = `http://0.0.0.0:${GATEWAY_PORT}`; +// Ports/URLs are allocated dynamically per run and assigned in beforeAll (issue #87). +let RPC_PORT: number; +let KUBO_API_PORT: number; +let GATEWAY_PORT: number; +let rpcWsUrl: string; // Generic subprocess runner with timeout const runBitsocialCommand = ( @@ -89,10 +87,9 @@ describe("CLI commands complete within 10s (real pkc instance)", () => { logDir = path.join(stateHome, "bitsocial"); await fsPromise.mkdir(logDir, { recursive: true }); - daemonProcess = await startPkcDaemon( - ["--logPath", logDir, "--pkcRpcUrl", rpcWsUrl], - { KUBO_RPC_URL: kuboApiUrl, IPFS_GATEWAY_URL: gatewayUrl } - ); + const daemon = await startPkcDaemonWithDynamicPorts((e) => ["--logPath", logDir, "--pkcRpcUrl", e.rpcWsUrl]); + daemonProcess = daemon.daemonProcess; + ({ rpcPort: RPC_PORT, kuboPort: KUBO_API_PORT, gatewayPort: GATEWAY_PORT, rpcWsUrl } = daemon); // Wait for log file to appear await waitForCondition(async () => { diff --git a/test/cli/daemon-kubo-restart-race.test.ts b/test/cli/daemon-kubo-restart-race.test.ts index 8aee4b1..25bf491 100644 --- a/test/cli/daemon-kubo-restart-race.test.ts +++ b/test/cli/daemon-kubo-restart-race.test.ts @@ -22,26 +22,19 @@ import { type ManagedChildProcess, stopPkcDaemon, waitForCondition, - startPkcDaemon, + startPkcDaemonWithDynamicPorts, + withKuboBindRetry, + isAddressInUseError, ensureKuboNodeStopped } from "../helpers/daemon-helpers.js"; import { preInitKuboWithEphemeralSwarm } from "../helpers/kubo-helpers.js"; import { startKuboNode } from "../../dist/ipfs/startIpfs.js"; dns.setDefaultResultOrder("ipv4first"); // to be able to resolve localhost -// --- Port allocations unique to this file (avoid conflicts with other test files and external processes) --- -const RACE_RPC_URL = `ws://localhost:9548`; -const RACE_KUBO_URL = `http://0.0.0.0:50299/api/v0`; -const RACE_GATEWAY_URL = `http://0.0.0.0:6753`; -const RACE_KUBO_API_URL = `http://localhost:50299/api/v0`; - -const SETTLE_KUBO_API_URL = new URL(`http://127.0.0.1:50399/api/v0`); -const SETTLE_GATEWAY_URL = new URL(`http://127.0.0.1:6853`); - -const WEDGE_RPC_URL = `ws://localhost:9648`; -const WEDGE_KUBO_URL = new URL(`http://0.0.0.0:50499/api/v0`); -const WEDGE_GATEWAY_URL = new URL(`http://0.0.0.0:6953`); -const WEDGE_KUBO_API_URL = `http://localhost:50499/api/v0`; +// Ports are allocated dynamically per test (issue #87): the hardcoded API ports this file used to +// pin fell inside macOS's ephemeral port range, so under fileParallelism the kernel could hand one +// of them to another test file's outbound socket and kubo's bind would intermittently fail with +// "address already in use". Each test now grabs fresh free ports and retries on the bind race. const killProcessGroup = (pid: number, signal: NodeJS.Signals) => { if (process.platform !== "win32") { @@ -64,30 +57,28 @@ describe("daemon kubo restart race (issue #70)", () => { { timeout: 120000 }, async () => { let daemonProcess: ManagedChildProcess | undefined; + let kuboApiUrl: string | undefined; try { - await ensureKuboNodeStopped(RACE_KUBO_API_URL); - daemonProcess = await startPkcDaemon( - ["--pkcOptions.dataPath", randomDirectory(), "--pkcRpcUrl", RACE_RPC_URL], - { - KUBO_RPC_URL: RACE_KUBO_URL, - IPFS_GATEWAY_URL: RACE_GATEWAY_URL, - // Hold every keepKuboUp entry for 7s between its guard and its pendingKuboStart - // assignment — guarantees a 5s watchdog tick lands inside the window, so the - // restart after the kubo shutdown below is entered twice concurrently. - PKC_CLI_TEST_KEEPKUBOUP_PORTCHECK_DELAY_MS: "7000" - } + const daemon = await startPkcDaemonWithDynamicPorts( + (e) => ["--pkcOptions.dataPath", randomDirectory(), "--pkcRpcUrl", e.rpcWsUrl], + // Hold every keepKuboUp entry for 7s between its guard and its pendingKuboStart + // assignment — guarantees a 5s watchdog tick lands inside the window, so the + // restart after the kubo shutdown below is entered twice concurrently. + () => ({ PKC_CLI_TEST_KEEPKUBOUP_PORTCHECK_DELAY_MS: "7000" }) ); + daemonProcess = daemon.daemonProcess; + kuboApiUrl = daemon.kuboApiUrl; expect(typeof daemonProcess.pid).toBe("number"); // Kill kubo out from under the daemon to trigger the restart cycle - const shutdownRes = await fetch(`${RACE_KUBO_API_URL}/shutdown`, { method: "POST" }); + const shutdownRes = await fetch(`${kuboApiUrl}/shutdown`, { method: "POST" }); expect(shutdownRes.status).toBe(200); // Restart is delayed ~7s by the hook; wait generously const kuboRestarted = await waitForCondition( async () => { try { - const res = await fetch(`${RACE_KUBO_API_URL}/bitswap/stat`, { method: "POST" }); + const res = await fetch(`${kuboApiUrl}/bitswap/stat`, { method: "POST" }); return res.ok; } catch { return false; @@ -117,7 +108,7 @@ describe("daemon kubo restart race (issue #70)", () => { const kuboStoppedAfterKill = await waitForCondition( async () => { try { - const res = await fetch(`${RACE_KUBO_API_URL}/bitswap/stat`, { method: "POST" }); + const res = await fetch(`${kuboApiUrl}/bitswap/stat`, { method: "POST" }); return !res.ok; } catch { return true; @@ -129,7 +120,7 @@ describe("daemon kubo restart race (issue #70)", () => { expect(kuboStoppedAfterKill).toBe(true); } finally { if (daemonProcess) await stopPkcDaemon(daemonProcess); - await ensureKuboNodeStopped(RACE_KUBO_API_URL); + if (kuboApiUrl) await ensureKuboNodeStopped(kuboApiUrl); } } ); @@ -144,41 +135,72 @@ describe("daemon shutdown with a wedged kubo startup (issue #70, PR #71 review)" // never settles within any reasonable horizon (kubo wedged before "Daemon is ready" // from the daemon's point of view): hold the ready acknowledgement for 10 minutes. const dataPath = randomDirectory(); - await preInitKuboWithEphemeralSwarm(path.join(dataPath, ".bitsocial-cli.ipfs"), WEDGE_KUBO_URL, WEDGE_GATEWAY_URL); let daemonProcess: ChildProcess | undefined; + let kuboApiUrl: string | undefined; try { - await ensureKuboNodeStopped(WEDGE_KUBO_API_URL); - daemonProcess = spawn( - "node", - ["./bin/run", "daemon", "--logPath", randomDirectory(), "--pkcOptions.dataPath", dataPath, "--pkcRpcUrl", WEDGE_RPC_URL], - { - stdio: ["pipe", "pipe", "pipe"], - env: { - ...process.env, - KUBO_RPC_URL: WEDGE_KUBO_URL.toString(), - IPFS_GATEWAY_URL: WEDGE_GATEWAY_URL.toString(), - PKC_CLI_TEST_IPFS_READY_DELAY_MS: "600000" - } - } - ); - expect(typeof daemonProcess.pid).toBe("number"); + // startPkcDaemon can't drive this case (the wedged daemon never prints its ready + // banner), so spawn manually under withKuboBindRetry: on a lost bind race the daemon + // exits with "address already in use" in its output, which we surface to trigger a + // retry on fresh ports. preInitKuboWithEphemeralSwarm is idempotent across retries. + const wedged = await withKuboBindRetry( + async (e) => { + await preInitKuboWithEphemeralSwarm( + path.join(dataPath, ".bitsocial-cli.ipfs"), + new URL(e.kuboRpcUrl), + new URL(e.gatewayUrl) + ); + const proc = spawn( + "node", + ["./bin/run", "daemon", "--logPath", randomDirectory(), "--pkcOptions.dataPath", dataPath, "--pkcRpcUrl", e.rpcWsUrl], + { + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + KUBO_RPC_URL: e.kuboRpcUrl, + IPFS_GATEWAY_URL: e.gatewayUrl, + PKC_CLI_TEST_IPFS_READY_DELAY_MS: "600000" + } + } + ); + let output = ""; + proc.stdout?.on("data", (d) => (output += d.toString())); + proc.stderr?.on("data", (d) => (output += d.toString())); - // Wait until kubo is spawned and serving (daemon is now blocked inside the held - // startKuboNode promise; kuboProcess is tracked via onSpawn, pendingKuboStart pending) - const kuboUp = await waitForCondition( - async () => { + // Resolve once kubo serves its API; otherwise throw so withKuboBindRetry can + // decide: an "address already in use" message means retry, anything else is real. + // On any failure kill THIS attempt's process group (never the port's listener, + // which on a same-suite race could be another test's healthy daemon). try { - const res = await fetch(`${WEDGE_KUBO_API_URL}/version`, { method: "POST" }); - return res.ok; - } catch { - return false; + const outcome = await new Promise<"up">((resolve, reject) => { + const deadline = Date.now() + 60000; + const tick = async () => { + if (proc.exitCode !== null || proc.signalCode !== null) + return reject(new Error(`daemon exited before kubo came up:\n${output}`)); + if (isAddressInUseError(output)) + return reject(new Error(`kubo lost the bind race: address already in use\n${output}`)); + try { + const res = await fetch(`${e.kuboApiUrl}/version`, { method: "POST" }); + if (res.ok) return resolve("up"); + } catch { + /* not up yet */ + } + if (Date.now() > deadline) return reject(new Error(`timed out waiting for kubo API:\n${output}`)); + setTimeout(tick, 500); + }; + void tick(); + }); + expect(outcome).toBe("up"); + return proc; + } catch (error) { + if (proc.pid) killProcessGroup(proc.pid, "SIGKILL"); + throw error; } - }, - 60000, - 500 + } ); - expect(kuboUp).toBe(true); + daemonProcess = wedged.result; + kuboApiUrl = wedged.endpoints.kuboApiUrl; + expect(typeof daemonProcess.pid).toBe("number"); const killed = daemonProcess.kill(); expect(killed).toBe(true); @@ -193,7 +215,7 @@ describe("daemon shutdown with a wedged kubo startup (issue #70, PR #71 review)" const kuboStoppedAfterKill = await waitForCondition( async () => { try { - const res = await fetch(`${WEDGE_KUBO_API_URL}/version`, { method: "POST" }); + const res = await fetch(`${kuboApiUrl}/version`, { method: "POST" }); return !res.ok; } catch { return true; @@ -206,18 +228,13 @@ describe("daemon shutdown with a wedged kubo startup (issue #70, PR #71 review)" } finally { if (daemonProcess?.pid && daemonProcess.exitCode === null && daemonProcess.signalCode === null) killProcessGroup(daemonProcess.pid, "SIGKILL"); - await ensureKuboNodeStopped(WEDGE_KUBO_API_URL); + if (kuboApiUrl) await ensureKuboNodeStopped(kuboApiUrl); } } ); }); describe("daemon shutdown with a late signal-exit registrant (issue #70)", () => { - const SIGEXIT_RPC_URL = `ws://localhost:9748`; - const SIGEXIT_KUBO_URL = `http://0.0.0.0:50599/api/v0`; - const SIGEXIT_GATEWAY_URL = `http://0.0.0.0:7053`; - const SIGEXIT_KUBO_API_URL = `http://localhost:50599/api/v0`; - it.skipIf(process.platform === "win32")( "kubo is killed on SIGTERM even when a signal-exit handler registered after the exit hook", { timeout: 120000 }, @@ -229,22 +246,22 @@ describe("daemon shutdown with a late signal-exit registrant (issue #70)", () => // family left and re-raises the signal, killing the daemon while the async kubo // cleanup is still parked — the restarted kubo outlives the daemon. let daemonProcess: ManagedChildProcess | undefined; + let kuboApiUrl: string | undefined; try { - await ensureKuboNodeStopped(SIGEXIT_KUBO_API_URL); - daemonProcess = await startPkcDaemon( - ["--pkcOptions.dataPath", randomDirectory(), "--pkcRpcUrl", SIGEXIT_RPC_URL], - { - KUBO_RPC_URL: SIGEXIT_KUBO_URL, - IPFS_GATEWAY_URL: SIGEXIT_GATEWAY_URL, + const daemon = await startPkcDaemonWithDynamicPorts( + (e) => ["--pkcOptions.dataPath", randomDirectory(), "--pkcRpcUrl", e.rpcWsUrl], + () => ({ PKC_CLI_TEST_SIMULATE_LATE_SIGNAL_EXIT: "1", // Hold the restarted kubo's start promise so SIGTERM lands mid-start, // like the CI failure (SIGTERM 0.4s after the restarted kubo's API came up) PKC_CLI_TEST_IPFS_READY_DELAY_MS: "5000" - } + }) ); + daemonProcess = daemon.daemonProcess; + kuboApiUrl = daemon.kuboApiUrl; expect(typeof daemonProcess.pid).toBe("number"); - const shutdownRes = await fetch(`${SIGEXIT_KUBO_API_URL}/shutdown`, { method: "POST" }); + const shutdownRes = await fetch(`${kuboApiUrl}/shutdown`, { method: "POST" }); expect(shutdownRes.status).toBe(200); // This is a setup precondition, not the assertion under test: after /shutdown the @@ -255,7 +272,7 @@ describe("daemon shutdown with a late signal-exit registrant (issue #70)", () => const kuboRestarted = await waitForCondition( async () => { try { - const res = await fetch(`${SIGEXIT_KUBO_API_URL}/bitswap/stat`, { method: "POST" }); + const res = await fetch(`${kuboApiUrl}/bitswap/stat`, { method: "POST" }); return res.ok; } catch { return false; @@ -277,7 +294,7 @@ describe("daemon shutdown with a late signal-exit registrant (issue #70)", () => const kuboStoppedAfterKill = await waitForCondition( async () => { try { - const res = await fetch(`${SIGEXIT_KUBO_API_URL}/bitswap/stat`, { method: "POST" }); + const res = await fetch(`${kuboApiUrl}/bitswap/stat`, { method: "POST" }); return !res.ok; } catch { return true; @@ -289,7 +306,7 @@ describe("daemon shutdown with a late signal-exit registrant (issue #70)", () => expect(kuboStoppedAfterKill).toBe(true); } finally { if (daemonProcess) await stopPkcDaemon(daemonProcess); - await ensureKuboNodeStopped(SIGEXIT_KUBO_API_URL); + if (kuboApiUrl) await ensureKuboNodeStopped(kuboApiUrl); } } ); @@ -297,11 +314,13 @@ describe("daemon shutdown with a late signal-exit registrant (issue #70)", () => describe("startKuboNode settles on failure (issue #70)", () => { let firstKubo: ChildProcessWithoutNullStreams | undefined; + let settleKuboApiUrl: string | undefined; afterEach(async () => { if (firstKubo?.pid) killProcessGroup(firstKubo.pid, "SIGKILL"); firstKubo = undefined; - await ensureKuboNodeStopped(SETTLE_KUBO_API_URL.toString().replace(/\/$/, "")); + if (settleKuboApiUrl) await ensureKuboNodeStopped(settleKuboApiUrl); + settleKuboApiUrl = undefined; }); it.skipIf(process.platform === "win32")( @@ -309,17 +328,24 @@ describe("startKuboNode settles on failure (issue #70)", () => { { timeout: 90000 }, async () => { const dataPath = randomDirectory(); - // Pre-init the repo with an ephemeral swarm port so parallel test kubos don't collide on 4001 - await preInitKuboWithEphemeralSwarm(path.join(dataPath, ".bitsocial-cli.ipfs"), SETTLE_KUBO_API_URL, SETTLE_GATEWAY_URL); - // First start: brings up a real kubo daemon that holds the repo lock - firstKubo = await startKuboNode(SETTLE_KUBO_API_URL, SETTLE_GATEWAY_URL, dataPath); + // First start: bring up a real kubo daemon that holds the repo lock, retrying on fresh + // ports if it loses the bind race (issue #87). preInit (idempotent) seeds an ephemeral + // swarm port so parallel test kubos don't collide on 4001. + const first = await withKuboBindRetry(async (e) => { + const apiUrl = new URL(e.kuboRpcUrl); + const gatewayUrl = new URL(e.gatewayUrl); + await preInitKuboWithEphemeralSwarm(path.join(dataPath, ".bitsocial-cli.ipfs"), apiUrl, gatewayUrl); + return startKuboNode(apiUrl, gatewayUrl, dataPath); + }); + firstKubo = first.result; + settleKuboApiUrl = first.endpoints.kuboApiUrl; expect(typeof firstKubo.pid).toBe("number"); - // Second start on the same repo: `ipfs init` fails with "ipfs daemon is running". - // The returned promise must settle (reject) — with the bug it never settles and the - // error escapes as an unhandledRejection instead. - const secondStart = startKuboNode(SETTLE_KUBO_API_URL, SETTLE_GATEWAY_URL, dataPath); + // Second start on the same repo: it must settle (reject) — `ipfs init` bails because the + // config exists and the running daemon holds the repo lock. With the bug it never settles + // and the error escapes as an unhandledRejection instead. + const secondStart = startKuboNode(new URL(first.endpoints.kuboRpcUrl), new URL(first.endpoints.gatewayUrl), dataPath); const outcome = await Promise.race([ secondStart.then( () => "resolved", diff --git a/test/cli/daemon.test.ts b/test/cli/daemon.test.ts index 4d578d7..add31f9 100644 --- a/test/cli/daemon.test.ts +++ b/test/cli/daemon.test.ts @@ -1,7 +1,7 @@ // This file is to test root commands like `bitsocial daemon` or `bitsocial get`, whereas commands like `bitsocial community start` are considered nested import { ChildProcess, spawn } from "child_process"; import net from "net"; -import { describe, it, beforeAll, afterAll, afterEach, expect } from "vitest"; +import { describe, it, beforeAll, beforeEach, afterAll, afterEach, expect } from "vitest"; import { directory as randomDirectory } from "tempy"; import WebSocket from "ws"; import { path as kuboExePathFunc } from "kubo"; @@ -14,20 +14,22 @@ import { stopPkcDaemon, waitForCondition, startPkcDaemon, + startPkcDaemonWithDynamicPorts, + allocateFreePort, + allocateKuboEndpoints, ensureKuboNodeStopped, waitForWebSocketOpen, waitForKuboReady, waitForPortFree } from "../helpers/daemon-helpers.js"; +import { preInitKuboWithEphemeralSwarm } from "../helpers/kubo-helpers.js"; dns.setDefaultResultOrder("ipv4first"); // to be able to resolve localhost -// --- Port allocations unique to this file (avoid conflicts with other test files and external processes) --- -const DAEMON_RPC_PORT = 9338; -const DAEMON_KUBO_PORT = 50079; -const DAEMON_GATEWAY_PORT = 6533; -const DAEMON_RPC_URL = `ws://localhost:${DAEMON_RPC_PORT}`; -const DAEMON_KUBO_URL = `http://0.0.0.0:${DAEMON_KUBO_PORT}/api/v0`; -const DAEMON_GATEWAY_URL = `http://0.0.0.0:${DAEMON_GATEWAY_PORT}`; +// Ports are allocated dynamically per test (issue #87): the API ports this file used to pin fell in +// macOS's ephemeral range, so under fileParallelism another test file's outbound socket could grab +// one and kubo's bind would intermittently fail. Happy-path daemons retry on the bind race; the +// negative blocks below allocate fresh free ports but must NOT retry (they assert failure / adoption +// on a specific port), so they keep using startPkcDaemon / runPkcDaemonExpectFailure directly. const testConnectionToPkcRpc = async (rpcServerPort: number | string) => { const rpcClient = new WebSocket(`ws://localhost:${rpcServerPort}`); @@ -53,7 +55,22 @@ const startPkcDaemonCapturingStderr = (args: string[], env?: Record arg.startsWith("--pkcOptions.dataPath")); const hasCustomLogPath = args.some((arg) => arg === "--logPath"); const logPathArgs = hasCustomLogPath ? [] : ["--logPath", randomDirectory()]; - const daemonArgs = hasCustomDataPath ? args : ["--pkcOptions.dataPath", randomDirectory(), ...args]; + const dataPath = hasCustomDataPath + ? (args[args.findIndex((a) => a.startsWith("--pkcOptions.dataPath")) + 1] as string) + : randomDirectory(); + const daemonArgs = hasCustomDataPath ? args : ["--pkcOptions.dataPath", dataPath, ...args]; + + // Pre-init kubo with an ephemeral swarm port (like startPkcDaemon) so this daemon doesn't + // bind swarm 4001 — otherwise a kubo lingering from a previous test (on Windows, where the + // daemon's kill doesn't take kubo with it) collides on 4001 with the next daemon (issue #87). + if (env?.KUBO_RPC_URL && env?.IPFS_GATEWAY_URL) { + try { + await preInitKuboWithEphemeralSwarm(path.join(dataPath, ".bitsocial-cli.ipfs"), new URL(env.KUBO_RPC_URL), new URL(env.IPFS_GATEWAY_URL)); + } catch (error) { + return reject(error); + } + } + const daemonProcess = spawn("node", ["./bin/run", "daemon", ...logPathArgs, ...daemonArgs], { stdio: ["pipe", "pipe", "pipe"], env: env ? { ...process.env, ...env } : undefined @@ -79,6 +96,15 @@ const startPkcDaemonCapturingStderr = (args: string[], env?: Record { const output = data.toString(); daemonProcess.capturedStdout += output; + // Capture the kubo RPC URL so stopPkcDaemon can /shutdown kubo afterwards (matches startPkcDaemon). + const kuboConfigMatch = output.match(/kuboRpcClientsOptions:\s*\[\s*'([^']+)'/); + if (!daemonProcess.kuboRpcUrl && kuboConfigMatch?.[1]) { + try { + daemonProcess.kuboRpcUrl = new URL(kuboConfigMatch[1]); + } catch { + /* ignore parse errors */ + } + } if (output.match("Communities in data path")) { daemonProcess.stdout!.off("data", onStdoutData); daemonProcess.off("exit", onExit); @@ -187,26 +213,27 @@ const runPkcDaemonExpectFailure = (args: string[], envOverrides?: Record { let daemonProcess: ManagedChildProcess; - const kuboApiUrl = `http://localhost:${DAEMON_KUBO_PORT}/api/v0`; + let kuboApiUrl: string; + let rpcWsUrl: string; + let rpcPort: number; beforeAll(async () => { - await ensureKuboNodeStopped(DAEMON_KUBO_URL); - - daemonProcess = await startPkcDaemon( - ["--pkcOptions.dataPath", randomDirectory(), "--pkcRpcUrl", DAEMON_RPC_URL], - { KUBO_RPC_URL: DAEMON_KUBO_URL, IPFS_GATEWAY_URL: DAEMON_GATEWAY_URL } - ); + const daemon = await startPkcDaemonWithDynamicPorts((e) => ["--pkcOptions.dataPath", randomDirectory(), "--pkcRpcUrl", e.rpcWsUrl]); + daemonProcess = daemon.daemonProcess; + kuboApiUrl = daemon.kuboApiUrl; + rpcWsUrl = daemon.rpcWsUrl; + rpcPort = daemon.rpcPort; expect(typeof daemonProcess.pid).toBe("number"); expect(daemonProcess.killed).toBe(false); }); afterAll(async () => { await stopPkcDaemon(daemonProcess); - await waitForPortFree(DAEMON_RPC_PORT, "localhost", 10000); + await waitForPortFree(rpcPort, "localhost", 10000); }); it(`PKC RPC server is started`, async () => { - const rpcClient = new WebSocket(DAEMON_RPC_URL); + const rpcClient = new WebSocket(rpcWsUrl); await waitForWebSocketOpen(rpcClient); expect(rpcClient.readyState).toBe(1); // 1 = connected rpcClient.close(); @@ -270,7 +297,7 @@ describe("bitsocial daemon (kubo daemon is started by bitsocial-cli)", async () await stopPkcDaemon(daemonProcess); // Wait for RPC to become unreachable - const rpcClient = new WebSocket(DAEMON_RPC_URL); + const rpcClient = new WebSocket(rpcWsUrl); const connected = await new Promise((resolve) => { const timer = setTimeout(() => resolve(false), 5000); rpcClient.once("open", () => { @@ -292,13 +319,24 @@ describe("bitsocial daemon (kubo daemon is started by bitsocial-cli)", async () }); describe("bitsocial daemon port availability validation", () => { - // Use unique ports for port validation tests - const validationRpcPort = 9388; - const validationKuboPort = 50089; - const validationGatewayPort = 6543; - const validationRpcUrl = `ws://localhost:${validationRpcPort}`; - const validationKuboUrl = `http://0.0.0.0:${validationKuboPort}/api/v0`; - const validationGatewayUrl = `http://0.0.0.0:${validationGatewayPort}`; + // Freshly allocated free ports per test (issue #87). These tests assert the daemon FAILS when a + // configured port is occupied, so they must point the daemon at exactly these ports with no retry. + let validationRpcPort: number; + let validationKuboPort: number; + let validationGatewayPort: number; + let validationRpcUrl: string; + let validationKuboUrl: string; + let validationGatewayUrl: string; + + beforeEach(async () => { + const e = await allocateKuboEndpoints(); + validationRpcPort = e.rpcPort; + validationKuboPort = e.kuboPort; + validationGatewayPort = e.gatewayPort; + validationRpcUrl = e.rpcWsUrl; + validationKuboUrl = e.kuboRpcUrl; + validationGatewayUrl = e.gatewayUrl; + }); const occupiedServers: net.Server[] = []; const cleanupServers = async () => { @@ -380,11 +418,6 @@ describe("bitsocial daemon port availability validation", () => { }); describe("bitsocial daemon kubo restart cleanup", async () => { - const cleanupRpcUrl = `ws://localhost:9348`; - const cleanupKuboUrl = `http://0.0.0.0:50099/api/v0`; - const cleanupGatewayUrl = `http://0.0.0.0:6553`; - const cleanupKuboApiUrl = `http://localhost:50099/api/v0`; - // On Windows, process.kill() calls TerminateProcess() which instantly kills the daemon // without running exit hooks (asyncExitHook/process.on("exit")), so the daemon has no // opportunity to clean up kubo. On Unix, SIGTERM is caught by the exit hook which runs @@ -396,12 +429,18 @@ describe("bitsocial daemon kubo restart cleanup", async () => { // Explicit logPath so the daemon's DEBUG log files can be dumped if the test fails (issue #70) const cleanupLogDir = randomDirectory(); let daemonProcess: ManagedChildProcess | undefined; + let cleanupKuboApiUrl: string | undefined; try { - await ensureKuboNodeStopped(cleanupKuboApiUrl); - daemonProcess = await startPkcDaemon( - ["--logPath", cleanupLogDir, "--pkcOptions.dataPath", randomDirectory(), "--pkcRpcUrl", cleanupRpcUrl], - { KUBO_RPC_URL: cleanupKuboUrl, IPFS_GATEWAY_URL: cleanupGatewayUrl } - ); + const daemon = await startPkcDaemonWithDynamicPorts((e) => [ + "--logPath", + cleanupLogDir, + "--pkcOptions.dataPath", + randomDirectory(), + "--pkcRpcUrl", + e.rpcWsUrl + ]); + daemonProcess = daemon.daemonProcess; + cleanupKuboApiUrl = daemon.kuboApiUrl; expect(typeof daemonProcess.pid).toBe("number"); // Diagnostics for the flaky-on-CI failures (issue #70/#77): the daemon's DEBUG output is @@ -464,19 +503,26 @@ describe("bitsocial daemon kubo restart cleanup", async () => { if (daemonProcess) await stopPkcDaemon(daemonProcess); if (previousDelay === undefined) delete process.env["PKC_CLI_TEST_IPFS_READY_DELAY_MS"]; else process.env["PKC_CLI_TEST_IPFS_READY_DELAY_MS"] = previousDelay; - await ensureKuboNodeStopped(cleanupKuboApiUrl); + if (cleanupKuboApiUrl) await ensureKuboNodeStopped(cleanupKuboApiUrl); } }); }); describe(`bitsocial daemon (kubo daemon is started by another process on the same port that bitsocial-cli is using)`, async () => { let kuboDaemonProcess: ChildProcess | undefined; - const extKuboPort = 50139; - const extKuboRpcUrl = new URL(`http://127.0.0.1:${extKuboPort}/api/v0`); - const extRpcUrl = `ws://localhost:9358`; - const extGatewayUrl = `http://0.0.0.0:6593`; + // Freshly allocated free ports (issue #87). The external kubo and the bitsocial daemon must agree + // on extKuboPort, so it's fixed for the suite (no retry) but allocated dynamically to dodge the + // macOS ephemeral-range collision the old hardcoded 50139 was prone to. + let extKuboPort: number; + let extKuboRpcUrl: URL; + let extRpcUrl: string; + let extGatewayUrl: string; beforeAll(async () => { + extKuboPort = await allocateFreePort(); + extKuboRpcUrl = new URL(`http://127.0.0.1:${extKuboPort}/api/v0`); + extRpcUrl = `ws://localhost:${await allocateFreePort()}`; + extGatewayUrl = `http://0.0.0.0:${await allocateFreePort()}`; await ensureKuboNodeStopped(extKuboRpcUrl.toString()); kuboDaemonProcess = await startKuboDaemon(extKuboPort); const res = await fetch(`http://localhost:${extKuboPort}/api/v0/bitswap/stat`, { method: "POST" }); @@ -604,23 +650,17 @@ describe(`bitsocial daemon (kubo daemon is started by another process on the sam }); describe("bitsocial daemon survives transient port occupation after its own kubo exits", () => { - const exitRpcPort = 9378; - const exitKuboPort = 50109; - const exitGatewayPort = 6563; - const exitRpcUrl = `ws://localhost:${exitRpcPort}`; - const exitKuboUrl = `http://0.0.0.0:${exitKuboPort}/api/v0`; - const exitKuboApiUrl = `http://localhost:${exitKuboPort}/api/v0`; - const exitGatewayUrl = `http://0.0.0.0:${exitGatewayPort}`; - it("daemon does not crash when kubo port is occupied right after kubo exits", { timeout: 90000 }, async () => { - await ensureKuboNodeStopped(exitKuboApiUrl); - let pkcDaemonProcess: ManagedChildProcess | undefined; + let exitKuboPort: number | undefined; + let exitKuboApiUrl: string | undefined; + let exitRpcUrl: string | undefined; try { - pkcDaemonProcess = await startPkcDaemon( - ["--pkcOptions.dataPath", randomDirectory(), "--pkcRpcUrl", exitRpcUrl], - { KUBO_RPC_URL: exitKuboUrl, IPFS_GATEWAY_URL: exitGatewayUrl } - ); + const daemon = await startPkcDaemonWithDynamicPorts((e) => ["--pkcOptions.dataPath", randomDirectory(), "--pkcRpcUrl", e.rpcWsUrl]); + pkcDaemonProcess = daemon.daemonProcess; + exitKuboPort = daemon.kuboPort; + exitKuboApiUrl = daemon.kuboApiUrl; + exitRpcUrl = daemon.rpcWsUrl; // Verify kubo is healthy const kuboReady = await waitForKuboReady(exitKuboApiUrl, 45000); @@ -671,21 +711,18 @@ describe("bitsocial daemon survives transient port occupation after its own kubo expect(kuboRestarted).toBe(true); } finally { await stopPkcDaemon(pkcDaemonProcess); - await ensureKuboNodeStopped(exitKuboApiUrl); + if (exitKuboApiUrl) await ensureKuboNodeStopped(exitKuboApiUrl); } }); }); describe(`bitsocial daemon --pkcRpcUrl`, async () => { it(`A bitsocial daemon should be change where to listen URL`, async () => { - const rpcUrl = new URL("ws://localhost:9148"); let firstRpcProcess: ManagedChildProcess | undefined; try { - firstRpcProcess = await startPkcDaemon( - ["--pkcRpcUrl", rpcUrl.toString()], - { KUBO_RPC_URL: "http://0.0.0.0:50159/api/v0", IPFS_GATEWAY_URL: "http://0.0.0.0:6613" } - ); - await testConnectionToPkcRpc(rpcUrl.port); + const daemon = await startPkcDaemonWithDynamicPorts((e) => ["--pkcRpcUrl", e.rpcWsUrl]); + firstRpcProcess = daemon.daemonProcess; + await testConnectionToPkcRpc(daemon.rpcPort); } finally { await stopPkcDaemon(firstRpcProcess); } @@ -695,13 +732,13 @@ describe(`bitsocial daemon --pkcRpcUrl`, async () => { describe(`bitsocial daemon PKC_RPC_AUTH_KEY env var`, async () => { it(`daemon uses PKC_RPC_AUTH_KEY when set`, async () => { const customAuthKey = "my-test-auth-key-1234567890"; - const rpcUrl = new URL("ws://localhost:9158"); let daemonProcess: ManagedChildProcess | undefined; try { - daemonProcess = await startPkcDaemon( - ["--pkcOptions.dataPath", randomDirectory(), "--pkcRpcUrl", rpcUrl.toString()], - { PKC_RPC_AUTH_KEY: customAuthKey, KUBO_RPC_URL: "http://0.0.0.0:50169/api/v0", IPFS_GATEWAY_URL: "http://0.0.0.0:6623" } + const daemon = await startPkcDaemonWithDynamicPorts( + (e) => ["--pkcOptions.dataPath", randomDirectory(), "--pkcRpcUrl", e.rpcWsUrl], + () => ({ PKC_RPC_AUTH_KEY: customAuthKey }) ); + daemonProcess = daemon.daemonProcess; expect(daemonProcess.capturedStdout).toContain(customAuthKey); } finally { await stopPkcDaemon(daemonProcess); @@ -711,16 +748,12 @@ describe(`bitsocial daemon PKC_RPC_AUTH_KEY env var`, async () => { describe(`bitsocial daemon KUBO_RPC_URL env var`, async () => { it(`daemon uses KUBO_RPC_URL env var to configure kubo bind address`, async () => { - const rpcUrl = new URL("ws://localhost:9168"); - const testKuboPort = 50179; let daemonProcess: ManagedChildProcess | undefined; try { - daemonProcess = await startPkcDaemon( - ["--pkcOptions.dataPath", randomDirectory(), "--pkcRpcUrl", rpcUrl.toString()], - { KUBO_RPC_URL: `http://0.0.0.0:${testKuboPort}/api/v0`, IPFS_GATEWAY_URL: "http://0.0.0.0:6633" } - ); - // Kubo should be reachable on the configured port - const res = await fetch(`http://localhost:${testKuboPort}/api/v0/bitswap/stat`, { method: "POST" }); + const daemon = await startPkcDaemonWithDynamicPorts((e) => ["--pkcOptions.dataPath", randomDirectory(), "--pkcRpcUrl", e.rpcWsUrl]); + daemonProcess = daemon.daemonProcess; + // Kubo should be reachable on the port configured via the injected KUBO_RPC_URL env var + const res = await fetch(`${daemon.kuboApiUrl}/bitswap/stat`, { method: "POST" }); expect(res.status).toBe(200); } finally { await stopPkcDaemon(daemonProcess); @@ -730,13 +763,12 @@ describe(`bitsocial daemon KUBO_RPC_URL env var`, async () => { describe(`bitsocial daemon webui`, async () => { let daemonProcess: ManagedChildProcess; - const rpcUrl = new URL("ws://localhost:9178"); + let rpcPort: number; beforeAll(async () => { - daemonProcess = await startPkcDaemon( - ["--pkcOptions.dataPath", randomDirectory(), "--pkcRpcUrl", rpcUrl.toString()], - { KUBO_RPC_URL: "http://0.0.0.0:50189/api/v0", IPFS_GATEWAY_URL: "http://0.0.0.0:6643" } - ); + const daemon = await startPkcDaemonWithDynamicPorts((e) => ["--pkcOptions.dataPath", randomDirectory(), "--pkcRpcUrl", e.rpcWsUrl]); + daemonProcess = daemon.daemonProcess; + rpcPort = daemon.rpcPort; }); afterAll(async () => { @@ -744,7 +776,7 @@ describe(`bitsocial daemon webui`, async () => { }); it(`5chan webui does not contain the root hash redirect script`, async () => { - const res = await fetch(`http://localhost:${rpcUrl.port}/5chan`); + const res = await fetch(`http://localhost:${rpcPort}/5chan`); expect(res.status).toBe(200); const html = await res.text(); expect(html).not.toMatch( @@ -753,7 +785,7 @@ describe(`bitsocial daemon webui`, async () => { }); it(`POST /api/challenges/reload returns 200 for local connections`, async () => { - const res = await fetch(`http://localhost:${rpcUrl.port}/api/challenges/reload`, { method: "POST" }); + const res = await fetch(`http://localhost:${rpcPort}/api/challenges/reload`, { method: "POST" }); expect(res.status).toBe(200); const body = (await res.json()) as { ok: boolean; challenges: string[] }; expect(body.ok).toBe(true); @@ -762,21 +794,13 @@ describe(`bitsocial daemon webui`, async () => { }); describe("bitsocial daemon kills kubo on its own shutdown (no backup /shutdown call)", async () => { - const rpcUrl = new URL("ws://localhost:9188"); - const kuboApiUrl = "http://127.0.0.1:50029/api/v0"; - const gatewayUrl = "http://127.0.0.1:6483"; - - beforeAll(async () => { - await ensureKuboNodeStopped(kuboApiUrl); - }); - it.skipIf(process.platform === "win32")("daemon's own cleanup kills kubo after SIGTERM", { timeout: 60000 }, async () => { let daemonProcess: ManagedChildProcess | undefined; + let kuboApiUrl: string | undefined; try { - daemonProcess = await startPkcDaemon( - ["--pkcOptions.dataPath", randomDirectory(), "--pkcRpcUrl", rpcUrl.toString()], - { KUBO_RPC_URL: kuboApiUrl, IPFS_GATEWAY_URL: gatewayUrl } - ); + const daemon = await startPkcDaemonWithDynamicPorts((e) => ["--pkcOptions.dataPath", randomDirectory(), "--pkcRpcUrl", e.rpcWsUrl]); + daemonProcess = daemon.daemonProcess; + kuboApiUrl = daemon.kuboApiUrl; // Verify kubo is running const kuboRes = await fetch(`${kuboApiUrl}/bitswap/stat`, { method: "POST" }); @@ -797,17 +821,17 @@ describe("bitsocial daemon kills kubo on its own shutdown (no backup /shutdown c expect(kuboStopped).toBe(true); } finally { await killChildProcess(daemonProcess); - await ensureKuboNodeStopped(kuboApiUrl); + if (kuboApiUrl) await ensureKuboNodeStopped(kuboApiUrl); } }); it.skipIf(process.platform === "win32")("daemon's own cleanup kills kubo after double SIGTERM (impatient user)", { timeout: 60000 }, async () => { let daemonProcess: ManagedChildProcess | undefined; + let kuboApiUrl: string | undefined; try { - daemonProcess = await startPkcDaemon( - ["--pkcOptions.dataPath", randomDirectory(), "--pkcRpcUrl", rpcUrl.toString()], - { KUBO_RPC_URL: kuboApiUrl, IPFS_GATEWAY_URL: gatewayUrl } - ); + const daemon = await startPkcDaemonWithDynamicPorts((e) => ["--pkcOptions.dataPath", randomDirectory(), "--pkcRpcUrl", e.rpcWsUrl]); + daemonProcess = daemon.daemonProcess; + kuboApiUrl = daemon.kuboApiUrl; // Verify kubo is running const kuboRes = await fetch(`${kuboApiUrl}/bitswap/stat`, { method: "POST" }); @@ -836,30 +860,20 @@ describe("bitsocial daemon kills kubo on its own shutdown (no backup /shutdown c expect(kuboStopped).toBe(true); } finally { await killChildProcess(daemonProcess); - await ensureKuboNodeStopped(kuboApiUrl); + if (kuboApiUrl) await ensureKuboNodeStopped(kuboApiUrl); } }); }); describe("bitsocial daemon DEBUG env var", () => { - const testKuboApiUrl = "http://127.0.0.1:50119/api/v0"; - const testGatewayUrl = "http://127.0.0.1:6573"; - - const cleanupKubo = async () => { - await ensureKuboNodeStopped(testKuboApiUrl); - }; - - beforeAll(cleanupKubo); - afterEach(cleanupKubo); - it("DEBUG=* does not leak debug output to stderr", { timeout: 60000 }, async () => { - const rpcUrl = new URL("ws://localhost:9198"); const logPath = randomDirectory(); + const e = await allocateKuboEndpoints(); let daemonProcess: ManagedChildProcess | undefined; try { daemonProcess = await startPkcDaemonCapturingStderr( - ["--pkcOptions.dataPath", randomDirectory(), "--pkcRpcUrl", rpcUrl.toString(), "--logPath", logPath], - { DEBUG: "*", KUBO_RPC_URL: testKuboApiUrl, IPFS_GATEWAY_URL: testGatewayUrl } + ["--pkcOptions.dataPath", randomDirectory(), "--pkcRpcUrl", e.rpcWsUrl, "--logPath", logPath], + { DEBUG: "*", KUBO_RPC_URL: e.kuboRpcUrl, IPFS_GATEWAY_URL: e.gatewayUrl } ); // stderr should not contain debug-format output (lines ending with +Nms) @@ -880,12 +894,12 @@ describe("bitsocial daemon DEBUG env var", () => { }); it("daemon without DEBUG shows tip messages in stdout", { timeout: 60000 }, async () => { - const rpcUrl = new URL("ws://localhost:9208"); + const e = await allocateKuboEndpoints(); let daemonProcess: ManagedChildProcess | undefined; try { daemonProcess = await startPkcDaemonCapturingStderr( - ["--pkcOptions.dataPath", randomDirectory(), "--pkcRpcUrl", rpcUrl.toString()], - { KUBO_RPC_URL: testKuboApiUrl, IPFS_GATEWAY_URL: testGatewayUrl } + ["--pkcOptions.dataPath", randomDirectory(), "--pkcRpcUrl", e.rpcWsUrl], + { KUBO_RPC_URL: e.kuboRpcUrl, IPFS_GATEWAY_URL: e.gatewayUrl } ); // stderr should not contain debug-format output diff --git a/test/cli/edit-null-removal.e2e.test.ts b/test/cli/edit-null-removal.e2e.test.ts index 2681917..3cc27d3 100644 --- a/test/cli/edit-null-removal.e2e.test.ts +++ b/test/cli/edit-null-removal.e2e.test.ts @@ -7,20 +7,18 @@ import WebSocket from "ws"; import { type ManagedChildProcess, stopPkcDaemon, - startPkcDaemon, + startPkcDaemonWithDynamicPorts, waitForCondition, waitForWebSocketOpen, waitForPortFree } from "../helpers/daemon-helpers.js"; dns.setDefaultResultOrder("ipv4first"); -// --- Port allocation (unique to this test file) --- -const RPC_PORT = 9638; -const KUBO_API_PORT = 50309; -const GATEWAY_PORT = 6803; -const rpcWsUrl = `ws://localhost:${RPC_PORT}`; -const kuboApiUrl = `http://0.0.0.0:${KUBO_API_PORT}/api/v0`; -const gatewayUrl = `http://0.0.0.0:${GATEWAY_PORT}`; +// Ports/URLs are allocated dynamically per run and assigned in beforeAll (issue #87). +let RPC_PORT: number; +let KUBO_API_PORT: number; +let GATEWAY_PORT: number; +let rpcWsUrl: string; const runBitsocialCommand = ( args: string[], @@ -67,10 +65,9 @@ describe("community edit null removal (real pkc instance)", () => { let communityAddress: string; beforeAll(async () => { - daemonProcess = await startPkcDaemon( - ["--pkcRpcUrl", rpcWsUrl], - { KUBO_RPC_URL: kuboApiUrl, IPFS_GATEWAY_URL: gatewayUrl } - ); + const daemon = await startPkcDaemonWithDynamicPorts((e) => ["--pkcRpcUrl", e.rpcWsUrl]); + daemonProcess = daemon.daemonProcess; + ({ rpcPort: RPC_PORT, kuboPort: KUBO_API_PORT, gatewayPort: GATEWAY_PORT, rpcWsUrl } = daemon); await waitForCondition(async () => { try { diff --git a/test/cli/logs.test.ts b/test/cli/logs.test.ts index ae45a45..dc3088b 100644 --- a/test/cli/logs.test.ts +++ b/test/cli/logs.test.ts @@ -7,18 +7,13 @@ import dns from "node:dns"; import { type ManagedChildProcess, stopPkcDaemon, - startPkcDaemon, + startPkcDaemonWithDynamicPorts, waitForCondition } from "../helpers/daemon-helpers.js"; dns.setDefaultResultOrder("ipv4first"); -// --- Port allocation (unique to this test file) --- -const RPC_PORT = 9438; -const KUBO_API_PORT = 50129; -const GATEWAY_PORT = 6583; -const rpcWsUrl = `ws://localhost:${RPC_PORT}`; -const kuboApiUrl = `http://0.0.0.0:${KUBO_API_PORT}/api/v0`; -const gatewayUrl = `http://0.0.0.0:${GATEWAY_PORT}`; +// Ports/URLs are allocated dynamically per run and assigned in beforeAll (issue #87). +let rpcWsUrl: string; const createLogDir = async () => { const logDir = randomDirectory(); @@ -497,10 +492,9 @@ describe("bitsocial logs (live daemon tests)", async () => { beforeAll(async () => { ({ logDir } = await createLogDir()); - daemonProcess = await startPkcDaemon( - ["--logPath", logDir, "--pkcRpcUrl", rpcWsUrl], - { KUBO_RPC_URL: kuboApiUrl, IPFS_GATEWAY_URL: gatewayUrl } - ); + const daemon = await startPkcDaemonWithDynamicPorts((e) => ["--logPath", logDir, "--pkcRpcUrl", e.rpcWsUrl]); + daemonProcess = daemon.daemonProcess; + rpcWsUrl = daemon.rpcWsUrl; // Wait for log file to be written await waitForCondition(async () => { const files = await fsPromise.readdir(logDir); diff --git a/test/cli/mintpass-integration.test.ts b/test/cli/mintpass-integration.test.ts index 969b4f1..89d96da 100644 --- a/test/cli/mintpass-integration.test.ts +++ b/test/cli/mintpass-integration.test.ts @@ -7,7 +7,7 @@ import { type ManagedChildProcess, stopPkcDaemon, waitForCondition, - startPkcDaemon, + startPkcDaemonWithDynamicPorts, waitForKuboReady } from "../helpers/daemon-helpers.js"; @@ -15,13 +15,10 @@ dns.setDefaultResultOrder("ipv4first"); type PKCInstance = Awaited>; -// --- Port allocation (unique to this test file) --- -const RPC_PORT = 59238; -const KUBO_API_PORT = 50049; -const GATEWAY_PORT = 6503; -const rpcWsUrl = `ws://localhost:${RPC_PORT}`; -const kuboApiUrl = `http://0.0.0.0:${KUBO_API_PORT}/api/v0`; -const gatewayUrl = `http://0.0.0.0:${GATEWAY_PORT}`; +// Ports/URLs are allocated dynamically per run and assigned in beforeAll (issue #87). +let RPC_PORT: number; +let KUBO_API_PORT: number; +let rpcWsUrl: string; // --- Helpers specific to this test file --- @@ -129,11 +126,12 @@ describe.skipIf(process.platform === "win32")("@bitsocial/mintpass-challenge int expect(installResult.exitCode).toBe(0); expect(installResult.stdout).toContain("added @bitsocial/mintpass-challenge"); - // Start daemon — it handles kubo, RPC, and webui internally - daemonProcess = await startPkcDaemon(["--pkcOptions.dataPath", dataPath, "--pkcRpcUrl", rpcWsUrl], { - KUBO_RPC_URL: kuboApiUrl, - IPFS_GATEWAY_URL: gatewayUrl - }); + // Start daemon — it handles kubo, RPC, and webui internally. Dynamic ports + retry guard + // against the macOS ephemeral-range bind race (issue #87); the seeded dataPath is reused + // across retries (preInitKuboWithEphemeralSwarm is idempotent). + const daemon = await startPkcDaemonWithDynamicPorts((e) => ["--pkcOptions.dataPath", dataPath, "--pkcRpcUrl", e.rpcWsUrl]); + daemonProcess = daemon.daemonProcess; + ({ rpcPort: RPC_PORT, kuboPort: KUBO_API_PORT, rpcWsUrl } = daemon); // Wait for kubo API to be fully ready (it can lag behind the "Communities in data path" message) const kuboReady = await waitForKuboReady(`http://localhost:${KUBO_API_PORT}/api/v0`, 30000); diff --git a/test/cli/update-install-restart-race.test.ts b/test/cli/update-install-restart-race.test.ts index 64c5c63..bfffa0e 100644 --- a/test/cli/update-install-restart-race.test.ts +++ b/test/cli/update-install-restart-race.test.ts @@ -33,20 +33,15 @@ import fs from "fs/promises"; import path from "path"; import { stopPkcDaemon, - startPkcDaemon, + startPkcDaemonWithDynamicPorts, ensureKuboNodeStopped, waitForKuboReady, type ManagedChildProcess } from "../helpers/daemon-helpers.js"; -// Ports unique to this file (avoid collisions with other test files and external processes). -const RPC_PORT = 9468; -const KUBO_PORT = 50121; -const GATEWAY_PORT = 6581; -const RPC_URL = `ws://localhost:${RPC_PORT}`; -const KUBO_URL = `http://0.0.0.0:${KUBO_PORT}/api/v0`; -const KUBO_API_URL = `http://localhost:${KUBO_PORT}/api/v0`; -const GATEWAY_URL = `http://0.0.0.0:${GATEWAY_PORT}`; +// Ports are allocated dynamically per test (issue #87): the kubo API port this file used to pin +// (50121) fell in macOS's ephemeral range, so under fileParallelism it could be grabbed by another +// test file's outbound socket and the daemon's kubo bind would intermittently fail. const CLI_VERSION = JSON.parse(readFileSync(path.join(process.cwd(), "package.json"), "utf-8")).version as string; @@ -72,6 +67,7 @@ const runUpdateInstall = (env: Record): Promise<{ exitCode: numb describe("bitsocial update install restart race (issue #70)", async () => { let daemonA: ManagedChildProcess | undefined; let restartedPidFile: string | undefined; + let kuboApiUrl: string | undefined; afterEach(async () => { if (daemonA) await stopPkcDaemon(daemonA); @@ -91,7 +87,8 @@ describe("bitsocial update install restart race (issue #70)", async () => { } } restartedPidFile = undefined; - await ensureKuboNodeStopped(KUBO_API_URL); + if (kuboApiUrl) await ensureKuboNodeStopped(kuboApiUrl); + kuboApiUrl = undefined; }); it.skipIf(process.platform === "win32")( @@ -138,29 +135,36 @@ describe("bitsocial update install restart race (issue #70)", async () => { ); await fs.chmod(shim, 0o755); - const sharedEnv = { + const isolatedEnv = { HOME: isolatedHome, - XDG_DATA_HOME: path.join(isolatedHome, ".local", "share"), - KUBO_RPC_URL: KUBO_URL, - IPFS_GATEWAY_URL: GATEWAY_URL + XDG_DATA_HOME: path.join(isolatedHome, ".local", "share") }; - await ensureKuboNodeStopped(KUBO_API_URL); - // Start daemon A — a real daemon with a real kubo, writing its state file into the - // isolated home so update install discovers only this daemon. - daemonA = await startPkcDaemon(["--pkcRpcUrl", RPC_URL], { - ...sharedEnv, - PKC_CLI_TEST_KUBO_SHUTDOWN_DELAY_MS: String(KUBO_SHUTDOWN_DELAY_MS) - }); + // isolated home so update install discovers only this daemon. Dynamic ports + retry guard + // the macOS ephemeral-range bind race (issue #87); the update install restart below reuses + // the same KUBO_RPC_URL so the shim's port check observes the right kubo API port. + const daemon = await startPkcDaemonWithDynamicPorts( + (e) => ["--pkcRpcUrl", e.rpcWsUrl], + (e) => ({ + ...isolatedEnv, + KUBO_RPC_URL: e.kuboRpcUrl, + IPFS_GATEWAY_URL: e.gatewayUrl, + PKC_CLI_TEST_KUBO_SHUTDOWN_DELAY_MS: String(KUBO_SHUTDOWN_DELAY_MS) + }) + ); + daemonA = daemon.daemonProcess; + kuboApiUrl = daemon.kuboApiUrl; expect(typeof daemonA.pid).toBe("number"); - expect(await waitForKuboReady(KUBO_API_URL, 45000)).toBe(true); + expect(await waitForKuboReady(kuboApiUrl, 45000)).toBe(true); // Run the real `bitsocial update install `: same version => skips npm, // but runs the full stop + _restartDaemons path. The shim (first on PATH) is what it // spawns for the restart. const result = await runUpdateInstall({ - ...sharedEnv, + ...isolatedEnv, + KUBO_RPC_URL: daemon.kuboRpcUrl, + IPFS_GATEWAY_URL: daemon.gatewayUrl, PATH: `${tmpBin}:${process.env.PATH}`, PKC_CLI_TEST_RESTART_MARKER: markerFile }); diff --git a/test/helpers/daemon-helpers.ts b/test/helpers/daemon-helpers.ts index 0ec4e63..bcae43c 100644 --- a/test/helpers/daemon-helpers.ts +++ b/test/helpers/daemon-helpers.ts @@ -183,3 +183,122 @@ export const waitForPortFree = async (port: number, host = "localhost", timeoutM timeoutMs ); }; + +// --- Dynamic port allocation (collision-proof test daemons; see issue #87) --------------------- + +// Bind to :0 and hand back the kernel-assigned free port. There's an unavoidable TOCTOU window +// between closing this probe socket and kubo binding the port, so any caller that then starts kubo +// must pair this with retry-on-"address already in use" (see startPkcDaemonWithDynamicPorts). +export const allocateFreePort = (host = "127.0.0.1"): Promise => + new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, host, () => { + const address = server.address(); + if (address && typeof address === "object") { + const { port } = address; + server.close((closeError) => (closeError ? reject(closeError) : resolve(port))); + } else { + server.close(() => reject(new Error("Failed to allocate a free port"))); + } + }); + }); + +export interface KuboEndpoints { + rpcPort: number; + kuboPort: number; + gatewayPort: number; + rpcWsUrl: string; // ws://localhost: (PKC RPC, --pkcRpcUrl) + kuboRpcUrl: string; // http://0.0.0.0:/api/v0 (KUBO_RPC_URL env / the addr kubo binds) + kuboApiUrl: string; // http://localhost:/api/v0 (client fetches, waitForKuboReady, ensureKuboNodeStopped) + gatewayUrl: string; // http://0.0.0.0: (IPFS_GATEWAY_URL env) +} + +// Allocate a fresh, currently-free set of RPC / kubo-API / gateway ports for one test daemon. +// Probe each port on the interface it will actually be bound on: the PKC RPC server listens on +// localhost, but kubo binds its API and gateway on 0.0.0.0 (wildcard). A port can be free on +// loopback yet unavailable for a wildcard bind, so probing the wrong interface would hand back a +// port that immediately collides on kubo startup. +export const allocateKuboEndpoints = async (): Promise => { + const [rpcPort, kuboPort, gatewayPort] = await Promise.all([ + allocateFreePort("127.0.0.1"), + allocateFreePort("0.0.0.0"), + allocateFreePort("0.0.0.0") + ]); + return { + rpcPort, + kuboPort, + gatewayPort, + rpcWsUrl: `ws://localhost:${rpcPort}`, + kuboRpcUrl: `http://0.0.0.0:${kuboPort}/api/v0`, + kuboApiUrl: `http://localhost:${kuboPort}/api/v0`, + gatewayUrl: `http://0.0.0.0:${gatewayPort}` + }; +}; + +// startPkcDaemon rejects with either a string (subprocess exit, carrying captured stdout/stderr) +// or an Error; the bind-race signature ("...address already in use") can surface in either. +export const isAddressInUseError = (reason: unknown): boolean => { + const message = typeof reason === "string" ? reason : reason instanceof Error ? reason.message : String(reason); + return /address already in use|EADDRINUSE/i.test(message); +}; + +export type DynamicDaemonResult = KuboEndpoints & { daemonProcess: ManagedChildProcess }; + +// Start a bitsocial daemon on freshly allocated, currently-free ports, retrying with a brand-new +// set if kubo loses the TOCTOU bind race (issue #87). buildArgs/buildEnv receive the allocated +// endpoints so callers can thread --pkcRpcUrl / a seeded dataPath / extra env through; KUBO_RPC_URL +// and IPFS_GATEWAY_URL are injected automatically (buildEnv may override them). Returns the live +// daemon plus the endpoints that actually won, so the test addresses the daemon via those URLs. +// +// Retries reuse whatever dataPath the caller bakes into buildArgs — preInitKuboWithEphemeralSwarm +// is idempotent, so a seeded dataPath survives a retry while picking up the new ports. +export const startPkcDaemonWithDynamicPorts = async ( + buildArgs: (endpoints: KuboEndpoints) => string[], + buildEnv?: (endpoints: KuboEndpoints) => Record, + { retries = 4 }: { retries?: number } = {} +): Promise => { + let lastError: unknown; + for (let attempt = 1; attempt <= retries; attempt++) { + const endpoints = await allocateKuboEndpoints(); + const env = { KUBO_RPC_URL: endpoints.kuboRpcUrl, IPFS_GATEWAY_URL: endpoints.gatewayUrl, ...(buildEnv?.(endpoints) ?? {}) }; + try { + const daemonProcess = await startPkcDaemon(buildArgs(endpoints), env); + return { ...endpoints, daemonProcess }; + } catch (reason) { + lastError = reason; + if (!isAddressInUseError(reason) || attempt === retries) throw reason; + // Nothing of ours lingers to clean up: when kubo loses the bind race it never binds and + // startPkcDaemon's subprocess has already exited. We must NOT ensureKuboNodeStopped the + // losing port here — in a same-suite race the listener on it is another test's healthy + // daemon, and shutting that down would reintroduce cross-test flakes. Just retry with a + // fresh endpoint set. + } + } + throw lastError; +}; + +// Run an arbitrary kubo-starting operation on freshly allocated free ports, retrying with a new +// set if it rejects with an "address already in use" bind race (issue #87). For starts that +// startPkcDaemonWithDynamicPorts can't express — a direct startKuboNode() call, or a manual +// `node ./bin/run daemon` spawn whose daemon stays wedged and never prints its ready banner. +// `start` must reject with a message containing "address already in use" for a lost bind to be +// retried; `cleanup` runs after a failed attempt (e.g. kill a half-spawned process) before retry. +export const withKuboBindRetry = async ( + start: (endpoints: KuboEndpoints) => Promise, + { retries = 4, cleanup }: { retries?: number; cleanup?: (endpoints: KuboEndpoints) => Promise | void } = {} +): Promise<{ result: T; endpoints: KuboEndpoints }> => { + let lastError: unknown; + for (let attempt = 1; attempt <= retries; attempt++) { + const endpoints = await allocateKuboEndpoints(); + try { + const result = await start(endpoints); + return { result, endpoints }; + } catch (reason) { + lastError = reason; + await cleanup?.(endpoints); + if (!isAddressInUseError(reason) || attempt === retries) throw reason; + } + } + throw lastError; +}; diff --git a/test/helpers/dynamic-ports.test.ts b/test/helpers/dynamic-ports.test.ts new file mode 100644 index 0000000..8bf0415 --- /dev/null +++ b/test/helpers/dynamic-ports.test.ts @@ -0,0 +1,96 @@ +// Regression tests for issue #87: the dynamic-port + bind-race-retry machinery that makes the +// daemon/kubo test suite collision-proof. Hardcoded kubo API ports fell inside macOS's ephemeral +// range, so under fileParallelism the kernel could hand one to another test file's outbound socket +// and kubo's bind would intermittently fail with "address already in use". These tests cover the +// allocator and the retry helper directly (no daemon spawn) so they're fast and deterministic. +import { describe, it, expect } from "vitest"; +import net from "net"; +import { allocateFreePort, allocateKuboEndpoints, isAddressInUseError, withKuboBindRetry, type KuboEndpoints } from "./daemon-helpers.js"; + +const isBindable = (port: number, host = "127.0.0.1") => + new Promise((resolve) => { + const server = net.createServer(); + server.once("error", () => resolve(false)); + server.listen(port, host, () => server.close(() => resolve(true))); + }); + +describe("dynamic port allocation helpers (issue #87)", () => { + it("allocateFreePort returns a currently-bindable port", async () => { + const port = await allocateFreePort(); + expect(port).toBeGreaterThan(0); + expect(await isBindable(port)).toBe(true); + }); + + it("allocateKuboEndpoints returns three distinct ports and well-formed URLs", async () => { + const e = await allocateKuboEndpoints(); + expect(new Set([e.rpcPort, e.kuboPort, e.gatewayPort]).size).toBe(3); + expect(e.rpcWsUrl).toBe(`ws://localhost:${e.rpcPort}`); + expect(e.kuboRpcUrl).toBe(`http://0.0.0.0:${e.kuboPort}/api/v0`); + expect(e.kuboApiUrl).toBe(`http://localhost:${e.kuboPort}/api/v0`); + expect(e.gatewayUrl).toBe(`http://0.0.0.0:${e.gatewayPort}`); + }); + + it("isAddressInUseError recognises the bind-race signatures (string and Error)", () => { + expect(isAddressInUseError("listen tcp4 0.0.0.0:50599: bind: address already in use")).toBe(true); + expect(isAddressInUseError(new Error("EADDRINUSE: address already in use 0.0.0.0:50599"))).toBe(true); + expect(isAddressInUseError("some unrelated failure")).toBe(false); + }); + + it("withKuboBindRetry retries a bind race with fresh endpoints, then succeeds", async () => { + const seen: KuboEndpoints[] = []; + let attempts = 0; + const { result, endpoints } = await withKuboBindRetry(async (e) => { + attempts++; + seen.push(e); + if (attempts < 3) throw new Error(`listen tcp4 0.0.0.0:${e.kuboPort}: bind: address already in use`); + return "started"; + }); + expect(attempts).toBe(3); + expect(result).toBe("started"); + // Every attempt got a freshly allocated set — that's what dodges a recurring collision. + expect(new Set(seen.map((s) => s.kuboPort)).size).toBe(3); + expect(endpoints).toBe(seen[2]); + }); + + it("withKuboBindRetry does NOT retry a non-bind error and rethrows immediately", async () => { + let attempts = 0; + await expect( + withKuboBindRetry(async () => { + attempts++; + throw new Error("kubo repo is corrupt"); + }) + ).rejects.toThrow("kubo repo is corrupt"); + expect(attempts).toBe(1); + }); + + it("withKuboBindRetry gives up after the retry budget and throws the last bind error", async () => { + let attempts = 0; + await expect( + withKuboBindRetry( + async (e) => { + attempts++; + throw new Error(`bind: address already in use (port ${e.kuboPort})`); + }, + { retries: 2 } + ) + ).rejects.toThrow(/address already in use/); + expect(attempts).toBe(2); + }); + + it("withKuboBindRetry runs cleanup after each failed attempt", async () => { + let cleanups = 0; + const { result } = await withKuboBindRetry( + async (e) => { + if (cleanups < 1) throw new Error(`bind: address already in use ${e.kuboPort}`); + return "ok"; + }, + { + cleanup: () => { + cleanups++; + } + } + ); + expect(result).toBe("ok"); + expect(cleanups).toBe(1); + }); +}); diff --git a/test/helpers/kubo-helpers.ts b/test/helpers/kubo-helpers.ts index 93cffab..858401c 100644 --- a/test/helpers/kubo-helpers.ts +++ b/test/helpers/kubo-helpers.ts @@ -22,15 +22,29 @@ const EPHEMERAL_SWARM_ADDRESSES = [ // then overrides Swarm to ephemeral addresses. When the bitsocial daemon later // runs `ipfs init` against this dir it'll bail with "configuration file already // exists", skip mergeCliDefaultsIntoIpfsConfig, and spawn kubo with our Swarm. +// +// Idempotent: if a config already exists (e.g. a retry reusing a seeded dataPath +// after a port-bind race), skip `ipfs init`/profile apply but re-apply the API, +// Gateway and Swarm addresses so the freshly allocated ports take effect. This lets +// startPkcDaemonWithDynamicPorts retry with new ports without `ipfs init` throwing +// "ipfs configuration file already exists!". export const preInitKuboWithEphemeralSwarm = async (ipfsDataPath: string, apiUrl: URL, gatewayUrl: URL) => { await fs.mkdir(ipfsDataPath, { recursive: true }); const kuboBinaryPath = await resolveKuboBinary(); const env = { ...process.env, IPFS_PATH: ipfsDataPath }; + const configPath = path.join(ipfsDataPath, "config"); - await execFileAsync(kuboBinaryPath, ["init"], { env }); - await execFileAsync(kuboBinaryPath, ["config", "profile", "apply", "server"], { env }); + const configExists = await fs + .access(configPath) + .then(() => true) + .catch(() => false); - const configPath = path.join(ipfsDataPath, "config"); + if (!configExists) { + await execFileAsync(kuboBinaryPath, ["init"], { env }); + await execFileAsync(kuboBinaryPath, ["config", "profile", "apply", "server"], { env }); + } + + // Always (re-)apply API/Gateway addresses for the requested ports, then pin Swarm ephemeral. await mergeCliDefaultsIntoIpfsConfig(() => {}, configPath, apiUrl, gatewayUrl); const config = JSON.parse(await fs.readFile(configPath, "utf-8"));