fix(ci): clean gateway watch temp home

This commit is contained in:
Vincent Koc
2026-06-01 14:09:58 +02:00
parent 8fe5e83462
commit 5117f457bb
2 changed files with 144 additions and 89 deletions
+97 -89
View File
@@ -404,7 +404,7 @@ async function allocateLoopbackPort() {
const { port } = address;
server.close((closeErr) => {
if (closeErr) {
reject(closeErr);
reject(closeErr instanceof Error ? closeErr : new Error(String(closeErr)));
return;
}
resolve(port);
@@ -484,100 +484,108 @@ function parseTimingFile(timeFilePath) {
};
}
async function runTimedWatch(options, outputDir) {
export async function runTimedWatch(options, outputDir, deps = {}) {
const allocatePort = deps.allocateLoopbackPort ?? allocateLoopbackPort;
const parseTiming = deps.parseTimingFile ?? parseTimingFile;
const readCpuMs = deps.readProcessTreeCpuMs ?? readProcessTreeCpuMs;
const sleepMs = deps.sleep ?? sleep;
const spawnCommand = deps.spawn ?? spawn;
const stopChild = deps.stopTimedWatchChild ?? stopTimedWatchChild;
const waitReady = deps.waitForGatewayReady ?? waitForGatewayReady;
const pidFilePath = path.join(outputDir, "watch.pid");
const timeFilePath = path.join(outputDir, "watch.time.log");
const isolatedHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-gateway-watch-"));
fs.writeFileSync(path.join(outputDir, "watch.home.txt"), `${isolatedHomeDir}\n`, "utf8");
const stdoutPath = path.join(outputDir, "watch.stdout.log");
const stderrPath = path.join(outputDir, "watch.stderr.log");
for (const stalePath of [pidFilePath, timeFilePath, stdoutPath, stderrPath]) {
removePathIfExists(stalePath);
}
const port = await allocateLoopbackPort();
fs.writeFileSync(path.join(outputDir, "watch.port.txt"), `${String(port)}\n`, "utf8");
const { command, args, env } = buildTimedWatchCommand(
pidFilePath,
timeFilePath,
isolatedHomeDir,
port,
);
const child = spawn(command, args, {
cwd: process.cwd(),
env: { ...process.env, ...env },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let stdoutTruncated = false;
let stderrTruncated = false;
let buildDetection = { buffer: "", triggered: false, reason: null };
child.stdout?.on("data", (chunk) => {
const next = appendBoundedWatchLog(stdout, chunk);
stdout = next.text;
stdoutTruncated ||= next.truncated;
buildDetection = updateWatchBuildDetection(buildDetection, chunk);
});
child.stderr?.on("data", (chunk) => {
const next = appendBoundedWatchLog(stderr, chunk);
stderr = next.text;
stderrTruncated ||= next.truncated;
buildDetection = updateWatchBuildDetection(buildDetection, chunk);
});
let spawnError = null;
const spawnErrorExit = new Promise((resolve) => {
child.once("error", (error) => {
spawnError = error;
resolve({ code: null, signal: null, error: error.message });
});
});
let watchPid = null;
for (let attempt = 0; attempt < 50; attempt += 1) {
if (fs.existsSync(pidFilePath)) {
watchPid = Number(fs.readFileSync(pidFilePath, "utf8").trim());
break;
try {
const stdoutPath = path.join(outputDir, "watch.stdout.log");
const stderrPath = path.join(outputDir, "watch.stderr.log");
for (const stalePath of [pidFilePath, timeFilePath, stdoutPath, stderrPath]) {
removePathIfExists(stalePath);
}
await sleep(100);
const port = await allocatePort();
fs.writeFileSync(path.join(outputDir, "watch.port.txt"), `${String(port)}\n`, "utf8");
const { command, args, env } = buildTimedWatchCommand(
pidFilePath,
timeFilePath,
isolatedHomeDir,
port,
);
const child = spawnCommand(command, args, {
cwd: process.cwd(),
env: { ...process.env, ...env },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let stdoutTruncated = false;
let stderrTruncated = false;
let buildDetection = { buffer: "", triggered: false, reason: null };
child.stdout?.on("data", (chunk) => {
const next = appendBoundedWatchLog(stdout, chunk);
stdout = next.text;
stdoutTruncated ||= next.truncated;
buildDetection = updateWatchBuildDetection(buildDetection, chunk);
});
child.stderr?.on("data", (chunk) => {
const next = appendBoundedWatchLog(stderr, chunk);
stderr = next.text;
stderrTruncated ||= next.truncated;
buildDetection = updateWatchBuildDetection(buildDetection, chunk);
});
let spawnError = null;
const spawnErrorExit = new Promise((resolve) => {
child.once("error", (error) => {
spawnError = error;
resolve({ code: null, signal: null, error: error.message });
});
});
let watchPid = null;
for (let attempt = 0; attempt < 50; attempt += 1) {
if (fs.existsSync(pidFilePath)) {
watchPid = Number(fs.readFileSync(pidFilePath, "utf8").trim());
break;
}
await sleepMs(100);
}
const readyBeforeWindow = await waitReady(() => `${stdout}\n${stderr}`, options.readyTimeoutMs);
if (readyBeforeWindow && options.readySettleMs > 0) {
await sleepMs(options.readySettleMs);
}
const idleCpuStartMs = watchPid ? readCpuMs(watchPid) : null;
await sleepMs(options.windowMs);
const idleCpuEndMs = watchPid ? readCpuMs(watchPid) : null;
const exit = await Promise.race([stopChild(child, watchPid, options), spawnErrorExit]);
fs.writeFileSync(stdoutPath, formatCapturedWatchLog(stdout, stdoutTruncated), "utf8");
fs.writeFileSync(stderrPath, formatCapturedWatchLog(stderr, stderrTruncated), "utf8");
const timingFileMissing = !fs.existsSync(timeFilePath);
const timing = timingFileMissing
? { userSeconds: Number.NaN, sysSeconds: Number.NaN, elapsedSeconds: Number.NaN }
: parseTiming(timeFilePath);
return {
exit,
spawnError: spawnError ? spawnError.message : null,
timingFileMissing,
timing,
readyBeforeWindow,
idleCpuMs:
idleCpuStartMs == null || idleCpuEndMs == null
? null
: Math.max(0, idleCpuEndMs - idleCpuStartMs),
stdoutPath,
stderrPath,
timeFilePath,
watchTriggeredBuild: buildDetection.triggered,
watchBuildReason: buildDetection.reason,
};
} finally {
fs.rmSync(isolatedHomeDir, { force: true, recursive: true });
}
const readyBeforeWindow = await waitForGatewayReady(
() => `${stdout}\n${stderr}`,
options.readyTimeoutMs,
);
if (readyBeforeWindow && options.readySettleMs > 0) {
await sleep(options.readySettleMs);
}
const idleCpuStartMs = watchPid ? readProcessTreeCpuMs(watchPid) : null;
await sleep(options.windowMs);
const idleCpuEndMs = watchPid ? readProcessTreeCpuMs(watchPid) : null;
const exit = await Promise.race([stopTimedWatchChild(child, watchPid, options), spawnErrorExit]);
fs.writeFileSync(stdoutPath, formatCapturedWatchLog(stdout, stdoutTruncated), "utf8");
fs.writeFileSync(stderrPath, formatCapturedWatchLog(stderr, stderrTruncated), "utf8");
const timingFileMissing = !fs.existsSync(timeFilePath);
const timing = timingFileMissing
? { userSeconds: Number.NaN, sysSeconds: Number.NaN, elapsedSeconds: Number.NaN }
: parseTimingFile(timeFilePath);
return {
exit,
spawnError: spawnError ? spawnError.message : null,
timingFileMissing,
timing,
readyBeforeWindow,
idleCpuMs:
idleCpuStartMs == null || idleCpuEndMs == null
? null
: Math.max(0, idleCpuEndMs - idleCpuStartMs),
stdoutPath,
stderrPath,
timeFilePath,
watchTriggeredBuild: buildDetection.triggered,
watchBuildReason: buildDetection.reason,
};
}
export async function stopTimedWatchChild(child, watchPid, options, deps = {}) {
@@ -7,6 +7,7 @@ import {
appendBoundedWatchLog,
hasGatewayReadyLog,
parseArgs,
runTimedWatch,
shouldRefreshBuildStampForRestoredArtifacts,
stopTimedWatchChild,
updateWatchBuildDetection,
@@ -132,4 +133,50 @@ describe("check-gateway-watch-regression", () => {
expect(child.stderr.destroy).toHaveBeenCalledOnce();
expect(child.unref).toHaveBeenCalledOnce();
});
it("removes the isolated watch home after spawn failures", async () => {
const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-gateway-watch-output-"));
const child = new EventEmitter() as EventEmitter & {
stderr: EventEmitter;
stdout: EventEmitter;
};
child.stderr = new EventEmitter();
child.stdout = new EventEmitter();
try {
const result = await runTimedWatch(
{
readySettleMs: 0,
readyTimeoutMs: 0,
sigkillGraceMs: 1,
windowMs: 0,
},
outputDir,
{
allocateLoopbackPort: async () => 19042,
sleep: async () => undefined,
spawn: () => {
process.nextTick(() => {
child.emit("error", new Error("spawn failed"));
});
return child;
},
stopTimedWatchChild: () =>
new Promise<never>(() => {
// Intentionally never resolves; the injected spawn error wins the race.
}),
waitForGatewayReady: async () => false,
},
);
const isolatedHomeDir = fs
.readFileSync(path.join(outputDir, "watch.home.txt"), "utf8")
.trim();
expect(result.spawnError).toBe("spawn failed");
expect(fs.existsSync(isolatedHomeDir)).toBe(false);
expect(fs.existsSync(path.join(outputDir, "watch.home.txt"))).toBe(true);
} finally {
fs.rmSync(outputDir, { recursive: true, force: true });
}
});
});