From 2c23cd4927185ffedd07cab4ebecb782dbf54a5a Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:27:30 +0000 Subject: [PATCH] test(cli): let test daemons allocate their listening ports (#8058) Reserve-and-release port selection races other listeners during daemon startup. Bind port zero and use the existing daemon instance endpoint for readiness and callers. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../42-test-daemon-ephemeral-port.test.ts | 16 +++++++ packages/cli/tests/helpers/test-daemon.ts | 46 +++++++++---------- 2 files changed, 38 insertions(+), 24 deletions(-) create mode 100644 packages/cli/tests/42-test-daemon-ephemeral-port.test.ts diff --git a/packages/cli/tests/42-test-daemon-ephemeral-port.test.ts b/packages/cli/tests/42-test-daemon-ephemeral-port.test.ts new file mode 100644 index 0000000000..f559c275bc --- /dev/null +++ b/packages/cli/tests/42-test-daemon-ephemeral-port.test.ts @@ -0,0 +1,16 @@ +#!/usr/bin/env npx tsx + +import assert from "node:assert/strict"; +import { startTestDaemon, runPaseoCli } from "./helpers/test-daemon.ts"; + +// Port zero leaves allocation to the listening daemon, with no reserve/release gap. +const daemon = await startTestDaemon({ port: 0 }); +try { + assert(daemon.port > 0, "the helper returns the port the daemon actually bound"); + assert.equal(daemon.wsUrl, `ws://127.0.0.1:${daemon.port}`); + const result = await runPaseoCli(daemon, ["agent", "ls", "--host", daemon.wsUrl, "--json"]); + assert.equal(result.exitCode, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), []); +} finally { + await daemon.stop(); +} diff --git a/packages/cli/tests/helpers/test-daemon.ts b/packages/cli/tests/helpers/test-daemon.ts index 5b5513d1a7..cfc9a87fb1 100644 --- a/packages/cli/tests/helpers/test-daemon.ts +++ b/packages/cli/tests/helpers/test-daemon.ts @@ -18,7 +18,7 @@ import { tmpdir } from "os"; import { join } from "path"; import { fileURLToPath } from "url"; import { ChildProcess, spawn } from "child_process"; -import { getAvailablePort } from "./network.ts"; +import { readDaemonInstance } from "@getpaseo/server/daemon-control"; export interface TestDaemonContext { /** Available local port for test daemon (never 6767) */ @@ -195,17 +195,20 @@ async function probeDaemonReady( } async function waitForDaemonReady( - port: number, paseoHome: string, timeout = 30000, env?: NodeJS.ProcessEnv, -): Promise { +): Promise { const deadline = Date.now() + timeout; - async function poll(): Promise { - if (await probeDaemonReady(port, paseoHome, env)) return; + async function poll(): Promise { + const instance = await readDaemonInstance(paseoHome); + if (instance?.listen) { + const port = Number(new URL(`http://${instance.listen}`).port); + if (await probeDaemonReady(port, paseoHome, env)) return port; + } if (Date.now() >= deadline) { - throw new Error(`Daemon failed to become ready on port ${port} within ${timeout}ms`); + throw new Error(`Daemon failed to publish a ready endpoint within ${timeout}ms`); } await sleep(100); return poll(); @@ -231,15 +234,14 @@ export async function startTestDaemon(options?: { timeout?: number; env?: NodeJS.ProcessEnv; }): Promise { - const port = options?.port ?? (await getAvailablePort()); + // Let the listening process allocate its port, without a reserve/release race. + const requestedPort = options?.port ?? 0; const { paseoHome, workDir } = options?.paseoHome && options?.workDir ? { paseoHome: options.paseoHome, workDir: options.workDir } : await createTempDirs(); const timeout = options?.timeout ?? 30000; - const wsUrl = `ws://${TEST_DAEMON_HOST}:${port}`; - // Find the CLI entry point - use the source file directly with tsx const cliDir = join(import.meta.dirname, "..", ".."); const cliSrcPath = join(cliDir, "src", "index.ts"); @@ -252,7 +254,7 @@ export async function startTestDaemon(options?: { ), ...TEST_DAEMON_ENV_DEFAULTS, PASEO_HOME: paseoHome, - PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`, + PASEO_LISTEN: `${TEST_DAEMON_HOST}:${requestedPort}`, // Force no TTY to prevent QR code output CI: "true", ...options?.env, @@ -312,20 +314,18 @@ export async function startTestDaemon(options?: { } }); - const ctx: TestDaemonContext = { - port, - wsUrl, - paseoHome, - workDir, - process: daemonProcess, - isReady: false, - stop: cleanup, - }; - // Wait for daemon to be ready try { - await waitForDaemonReady(port, paseoHome, timeout, options?.env); - ctx.isReady = true; + const port = await waitForDaemonReady(paseoHome, timeout, options?.env); + return { + port, + wsUrl: `ws://${TEST_DAEMON_HOST}:${port}`, + paseoHome, + workDir, + process: daemonProcess, + isReady: true, + stop: cleanup, + }; } catch (err) { // Daemon failed to start - clean up and rethrow await cleanup(); @@ -335,8 +335,6 @@ export async function startTestDaemon(options?: { { cause: err }, ); } - - return ctx; } /**