fix(e2e): bound Parallels update logs

This commit is contained in:
Vincent Koc
2026-06-01 08:37:46 +02:00
parent ddbd595f2f
commit c11ff35841
4 changed files with 98 additions and 12 deletions

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env -S pnpm tsx
import { spawn } from "node:child_process";
import { readFileSync } from "node:fs";
import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
import { readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
@@ -64,6 +64,7 @@ interface Job {
interface UpdateJobContext {
append(chunk: string | Uint8Array): void;
logPath: string;
signal: AbortSignal;
}
interface NpmUpdateSummary {
@@ -575,19 +576,19 @@ class NpmUpdateSmoke {
startedAt,
};
job.promise = (async () => {
let log = "";
writeFileSync(logPath, "", "utf8");
const append = (chunk: string | Uint8Array): void => {
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
log += text;
appendFileSync(logPath, text, "utf8");
this.noteJobOutput(job, text);
};
return await runTimedUpdateJob({
append,
label,
run: () => fn({ append, logPath }),
run: ({ signal }) => fn({ append, logPath, signal }),
timeoutDescription: `${updateTimeoutSeconds}s plus cleanup backstop`,
timeoutMs: updateTimeoutSeconds * 1000 + updateCleanupBackstopMs,
writeLog: () => writeFile(logPath, log, "utf8"),
writeLog: async () => undefined,
});
})().finally(() => {
job.durationMs = Date.now() - job.startedAt;
@@ -898,6 +899,7 @@ class NpmUpdateSmoke {
return await new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: repoRoot,
detached: process.platform !== "win32",
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
@@ -906,16 +908,54 @@ class NpmUpdateSmoke {
child.stderr.on("data", (chunk: Buffer) => ctx.append(chunk));
let timedOut = false;
const timer = setTimeout(() => {
let killTimer: NodeJS.Timeout | undefined;
const signalChild = (signal: NodeJS.Signals): void => {
if (!child.pid) {
return;
}
try {
if (process.platform === "win32") {
child.kill(signal);
} else {
process.kill(-child.pid, signal);
}
} catch {
child.kill(signal);
}
};
const abort = (): void => {
if (timedOut) {
return;
}
timedOut = true;
child.kill("SIGTERM");
setTimeout(() => child.kill("SIGKILL"), 2_000).unref();
signalChild("SIGTERM");
killTimer = setTimeout(() => signalChild("SIGKILL"), 2_000);
killTimer.unref();
};
if (ctx.signal.aborted) {
abort();
} else {
ctx.signal.addEventListener("abort", abort, { once: true });
}
const timer = setTimeout(() => {
abort();
}, timeoutMs);
child.on("error", reject);
child.on("error", (error) => {
ctx.signal.removeEventListener("abort", abort);
if (killTimer) {
clearTimeout(killTimer);
}
reject(error);
});
child.on("close", (code, signal) => {
ctx.signal.removeEventListener("abort", abort);
clearTimeout(timer);
if (killTimer) {
clearTimeout(killTimer);
}
if (timedOut) {
signalChild("SIGKILL");
resolve(124);
return;
}

View File

@@ -1,7 +1,7 @@
interface TimedUpdateJobOptions {
append(this: void, chunk: string): void;
label: string;
run(this: void): Promise<void> | void;
run(this: void, context: { signal: AbortSignal }): Promise<void> | void;
timeoutDescription: string;
timeoutMs: number;
writeLog(this: void): Promise<void>;
@@ -16,20 +16,22 @@ export async function runTimedUpdateJob({
writeLog,
}: TimedUpdateJobOptions): Promise<number> {
let timedOut = false;
const controller = new AbortController();
const timeoutMessage = `${label} update timed out after ${timeoutDescription}`;
let timeout: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
timedOut = true;
append(`${timeoutMessage}\n`);
controller.abort(new Error(timeoutMessage));
reject(new Error(timeoutMessage));
}, timeoutMs);
});
try {
await Promise.race([Promise.resolve(run()), timeoutPromise]);
await Promise.race([Promise.resolve(run({ signal: controller.signal })), timeoutPromise]);
await writeLog();
return 0;
return timedOut ? 1 : 0;
} catch (error) {
if (!timedOut) {
append(`${error instanceof Error ? error.message : String(error)}\n`);

View File

@@ -65,6 +65,18 @@ describe("parallels npm update smoke", () => {
expect(script).toContain("Parallels NPM Update Smoke");
});
it("streams aggregate update logs instead of retaining them in memory", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");
const updateBlock = script.slice(
script.indexOf(" private spawnUpdate"),
script.indexOf(" private async runMacosUpdate"),
);
expect(updateBlock).toContain("appendFileSync(logPath, text");
expect(updateBlock).toContain("run: ({ signal }) => fn({ append, logPath, signal })");
expect(updateBlock).not.toContain("log += text");
});
it("runs Windows updates through a detached done-file runner", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");
const transports = readFileSync(GUEST_TRANSPORTS_PATH, "utf8");

View File

@@ -88,4 +88,36 @@ describe("Parallels update job timeout", () => {
expect(chunks).toEqual(["Windows update timed out after 1s\n"]);
expect(writeLog).toHaveBeenCalledTimes(1);
});
it("aborts the update body when the timeout fires", async () => {
vi.useFakeTimers();
const chunks: string[] = [];
const writeLog = vi.fn(async () => undefined);
let aborted = false;
const result = runTimedUpdateJob({
append: (chunk) => chunks.push(chunk),
label: "Linux",
run: ({ signal }) =>
new Promise<void>((resolve) => {
signal.addEventListener(
"abort",
() => {
aborted = true;
resolve();
},
{ once: true },
);
}),
timeoutDescription: "1s plus cleanup backstop",
timeoutMs: 1000,
writeLog,
});
await vi.advanceTimersByTimeAsync(1000);
await expect(result).resolves.toBe(1);
expect(aborted).toBe(true);
expect(chunks).toEqual(["Linux update timed out after 1s plus cleanup backstop\n"]);
expect(writeLog).toHaveBeenCalledTimes(1);
});
});