From d89ab2c014cf58d682a2a8936abbe15afb9df903 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 16 Jun 2026 04:19:29 +0200 Subject: [PATCH] fix(e2e): wait for Parallels update cleanup --- scripts/e2e/parallels/update-job-timeout.ts | 36 +++++++- .../parallels-update-job-timeout.test.ts | 82 ++++++++++++++++++- 2 files changed, 113 insertions(+), 5 deletions(-) diff --git a/scripts/e2e/parallels/update-job-timeout.ts b/scripts/e2e/parallels/update-job-timeout.ts index 418215db5e73..809f686c5774 100644 --- a/scripts/e2e/parallels/update-job-timeout.ts +++ b/scripts/e2e/parallels/update-job-timeout.ts @@ -1,5 +1,6 @@ // Update Job Timeout script supports OpenClaw repository automation. interface TimedUpdateJobOptions { + abortSettleMs?: number; append(this: void, chunk: string): void; label: string; run(this: void, context: { signal: AbortSignal }): Promise | void; @@ -9,6 +10,7 @@ interface TimedUpdateJobOptions { } export async function runTimedUpdateJob({ + abortSettleMs = 2_500, append, label, run, @@ -20,19 +22,35 @@ export async function runTimedUpdateJob({ const controller = new AbortController(); const timeoutMessage = `${label} update timed out after ${timeoutDescription}`; let timeout: NodeJS.Timeout | undefined; - const timeoutPromise = new Promise((_, reject) => { + const runOutcome = Promise.resolve() + .then(() => run({ signal: controller.signal })) + .then( + () => ({ status: "pass" as const }), + (error: unknown) => ({ error, status: "fail" as const }), + ); + const timeoutPromise = new Promise<"timeout">((resolve) => { timeout = setTimeout(() => { timedOut = true; append(`${timeoutMessage}\n`); controller.abort(new Error(timeoutMessage)); - reject(new Error(timeoutMessage)); + resolve("timeout"); }, timeoutMs); }); try { - await Promise.race([Promise.resolve(run({ signal: controller.signal })), timeoutPromise]); + const outcome = await Promise.race([runOutcome, timeoutPromise]); + if (outcome === "timeout") { + await waitForAbortSettle(runOutcome, abortSettleMs); + await writeLog(); + return 1; + } + if (outcome.status === "fail") { + append(`${outcome.error instanceof Error ? outcome.error.message : String(outcome.error)}\n`); + await writeLog(); + return 1; + } await writeLog(); - return timedOut ? 1 : 0; + return 0; } catch (error) { if (!timedOut) { append(`${error instanceof Error ? error.message : String(error)}\n`); @@ -45,3 +63,13 @@ export async function runTimedUpdateJob({ } } } + +async function waitForAbortSettle(runOutcome: Promise, ms: number): Promise { + return await new Promise((resolve) => { + const timeout = setTimeout(resolve, ms); + void runOutcome.then((outcome) => { + clearTimeout(timeout); + resolve(outcome); + }); + }); +} diff --git a/test/scripts/parallels-update-job-timeout.test.ts b/test/scripts/parallels-update-job-timeout.test.ts index 45fed453e715..af3bbe21188a 100644 --- a/test/scripts/parallels-update-job-timeout.test.ts +++ b/test/scripts/parallels-update-job-timeout.test.ts @@ -1,4 +1,7 @@ // Parallels Update Job Timeout tests cover parallels update job timeout script behavior. +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; import { runTimedUpdateJob } from "../../scripts/e2e/parallels/update-job-timeout.ts"; @@ -76,6 +79,7 @@ describe("Parallels update job timeout", () => { const writeLog = vi.fn(async () => undefined); const result = runTimedUpdateJob({ + abortSettleMs: 1, append: (chunk) => chunks.push(chunk), label: "Windows", run: () => new Promise(() => {}), @@ -84,7 +88,7 @@ describe("Parallels update job timeout", () => { writeLog, }); - await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(1001); await expect(result).resolves.toBe(1); expect(chunks).toEqual(["Windows update timed out after 1s\n"]); expect(writeLog).toHaveBeenCalledTimes(1); @@ -121,4 +125,80 @@ describe("Parallels update job timeout", () => { expect(chunks).toEqual(["Linux update timed out after 1s plus cleanup backstop\n"]); expect(writeLog).toHaveBeenCalledTimes(1); }); + + it("waits for abort-aware cleanup before writing the job log", async () => { + vi.useFakeTimers(); + const events: string[] = []; + + const result = runTimedUpdateJob({ + abortSettleMs: 250, + append: (chunk) => events.push(chunk.trim()), + label: "macOS", + run: ({ signal }) => + new Promise((resolve) => { + signal.addEventListener( + "abort", + () => { + events.push("abort"); + setTimeout(() => { + events.push("cleanup"); + resolve(); + }, 25); + }, + { once: true }, + ); + }), + timeoutDescription: "1s plus cleanup backstop", + timeoutMs: 1000, + writeLog: async () => { + events.push("writeLog"); + }, + }); + + await vi.advanceTimersByTimeAsync(1025); + await expect(result).resolves.toBe(1); + expect(events).toEqual([ + "macOS update timed out after 1s plus cleanup backstop", + "abort", + "cleanup", + "writeLog", + ]); + }); + + it("keeps the process alive long enough to write logs for hung runners", () => { + const moduleUrl = pathToFileURL( + path.resolve("scripts/e2e/parallels/update-job-timeout.ts"), + ).href; + const probe = ` +import { runTimedUpdateJob } from ${JSON.stringify(moduleUrl)}; +const events = []; +const result = await runTimedUpdateJob({ + abortSettleMs: 25, + append: (chunk) => events.push(chunk.trim()), + label: "Linux", + run: () => new Promise(() => {}), + timeoutDescription: "10ms", + timeoutMs: 10, + writeLog: async () => events.push("writeLog"), +}); +console.log(JSON.stringify({ events, result })); +`; + + const child = spawnSync( + process.execPath, + ["--import", "tsx", "--input-type=module", "--eval", probe], + { + cwd: process.cwd(), + encoding: "utf8", + timeout: 5_000, + }, + ); + + expect(child.stderr).toBe(""); + expect(child.status).toBe(0); + expect(JSON.parse(child.stdout)).toEqual({ + events: ["Linux update timed out after 10ms", "writeLog"], + result: 1, + }); + }); });