fix: harden qa lab docker launcher startup

This commit is contained in:
Peter Steinberger
2026-04-06 18:00:25 +01:00
parent 8f2ff2497a
commit bb29c8696a
2 changed files with 116 additions and 12 deletions

View File

@@ -1,4 +1,5 @@
import { mkdtemp, rm } from "node:fs/promises";
import { createServer } from "node:net";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
@@ -82,4 +83,42 @@ describe("runQaDockerUp", () => {
await rm(outputDir, { recursive: true, force: true });
}
});
it("falls back to free host ports when defaults are already occupied", async () => {
const gatewayServer = createServer();
const labServer = createServer();
const outputDir = await mkdtemp(path.join(os.tmpdir(), "qa-docker-up-"));
await new Promise<void>((resolve) => gatewayServer.listen(18789, "127.0.0.1", () => resolve()));
await new Promise<void>((resolve) => labServer.listen(43124, "127.0.0.1", () => resolve()));
try {
const result = await runQaDockerUp(
{
repoRoot: "/repo/openclaw",
outputDir,
skipUiBuild: true,
usePrebuiltImage: true,
},
{
async runCommand() {
return { stdout: "", stderr: "" };
},
fetchImpl: vi.fn(async () => ({ ok: true })),
sleepImpl: vi.fn(async () => {}),
},
);
expect(result.gatewayUrl).not.toBe("http://127.0.0.1:18789/");
expect(result.qaLabUrl).not.toBe("http://127.0.0.1:43124");
} finally {
await new Promise<void>((resolve, reject) =>
gatewayServer.close((error) => (error ? reject(error) : resolve())),
);
await new Promise<void>((resolve, reject) =>
labServer.close((error) => (error ? reject(error) : resolve())),
);
await rm(outputDir, { recursive: true, force: true });
}
});
});

View File

@@ -1,4 +1,5 @@
import { execFile } from "node:child_process";
import { createServer } from "node:net";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { writeQaDockerHarnessFiles } from "./docker-harness.js";
@@ -31,22 +32,83 @@ function describeError(error: unknown) {
return JSON.stringify(error);
}
async function execCommand(command: string, args: string[], cwd: string) {
return await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
execFile(command, args, { cwd, encoding: "utf8" }, (error, stdout, stderr) => {
if (error) {
reject(
new Error(
stderr.trim() || stdout.trim() || `Command failed: ${[command, ...args].join(" ")}`,
),
);
async function isPortFree(port: number) {
return await new Promise<boolean>((resolve) => {
const server = createServer();
server.once("error", () => resolve(false));
server.listen(port, () => {
server.close(() => resolve(true));
});
});
}
async function findFreePort() {
return await new Promise<number>((resolve, reject) => {
const server = createServer();
server.once("error", reject);
server.listen(0, () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close();
reject(new Error("failed to find free port"));
return;
}
resolve({ stdout, stderr });
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve(address.port);
});
});
});
}
async function resolveHostPort(preferredPort: number, pinned: boolean) {
if (pinned || (await isPortFree(preferredPort))) {
return preferredPort;
}
return await findFreePort();
}
function trimCommandOutput(output: string) {
const trimmed = output.trim();
if (!trimmed) {
return "";
}
const lines = trimmed.split("\n");
return lines.length <= 120 ? trimmed : lines.slice(-120).join("\n");
}
async function execCommand(command: string, args: string[], cwd: string) {
return await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
execFile(
command,
args,
{ cwd, encoding: "utf8", maxBuffer: 10 * 1024 * 1024 },
(error, stdout, stderr) => {
if (error) {
const renderedStdout = trimCommandOutput(stdout);
const renderedStderr = trimCommandOutput(stderr);
reject(
new Error(
[
`Command failed: ${[command, ...args].join(" ")}`,
renderedStderr ? `stderr:\n${renderedStderr}` : "",
renderedStdout ? `stdout:\n${renderedStdout}` : "",
]
.filter(Boolean)
.join("\n\n"),
),
);
return;
}
resolve({ stdout, stderr });
},
);
});
}
async function waitForHealth(
url: string,
deps: {
@@ -98,8 +160,11 @@ export async function runQaDockerUp(
): Promise<QaDockerUpResult> {
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
const outputDir = path.resolve(params.outputDir ?? DEFAULT_QA_DOCKER_DIR);
const gatewayPort = params.gatewayPort ?? 18789;
const qaLabPort = params.qaLabPort ?? 43124;
const gatewayPort = await resolveHostPort(
params.gatewayPort ?? 18789,
params.gatewayPort != null,
);
const qaLabPort = await resolveHostPort(params.qaLabPort ?? 43124, params.qaLabPort != null);
const runCommand = deps?.runCommand ?? execCommand;
const fetchImpl =
deps?.fetchImpl ??