From 442f536d59c4475ede18f3f88bf8d9ff801e70b2 Mon Sep 17 00:00:00 2001 From: Altay Date: Sat, 15 Aug 2026 12:12:05 +0300 Subject: [PATCH 1/3] fix: clean up packed smoke processes --- package.json | 2 +- scripts/process-lifecycle.mts | 139 +++++++++ scripts/process-lifecycle.test.mts | 225 ++++++++++++++ scripts/smoke-packed-install.mts | 453 +++++++++++++++++------------ vite.config.ts | 2 +- 5 files changed, 637 insertions(+), 184 deletions(-) create mode 100644 scripts/process-lifecycle.mts create mode 100644 scripts/process-lifecycle.test.mts diff --git a/package.json b/package.json index 6677d92..fe5ba1a 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "coverage": "vp test run --coverage", "dev": "vp pack --watch", "prepack": "vp pack", - "smoke:pack": "node ./scripts/smoke-packed-install.mts", + "smoke:pack": "node --test ./scripts/process-lifecycle.test.mts && node ./scripts/smoke-packed-install.mts", "test": "vp test", "prepublishOnly": "npm run build", "verify:sea": "node ./scripts/verify-sea.mjs", diff --git a/scripts/process-lifecycle.mts b/scripts/process-lifecycle.mts new file mode 100644 index 0000000..c8a4c57 --- /dev/null +++ b/scripts/process-lifecycle.mts @@ -0,0 +1,139 @@ +import type { ChildProcess } from "node:child_process"; +import process from "node:process"; + +type TerminationOptions = { + readonly forceTimeoutMs?: number; + readonly gracefulTimeoutMs?: number; +}; + +type LifecycleOutcome = { + readonly cleanupErrors: ReadonlyArray; + readonly interruptedBy: NodeJS.Signals | undefined; + readonly primaryError: unknown; +}; + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const hasExited = (child: ChildProcess) => child.exitCode !== null || child.signalCode !== null; + +const waitForExit = (child: ChildProcess, timeoutMs: number): Promise => { + if (hasExited(child)) return Promise.resolve(true); + + return new Promise((resolve) => { + const finish = (exited: boolean) => { + clearTimeout(timer); + child.off("exit", onExit); + resolve(exited); + }; + const onExit = () => finish(true); + const timer = setTimeout(() => finish(false), timeoutMs); + child.once("exit", onExit); + }); +}; + +export const ownedProcessExists = (child: ChildProcess) => { + if (child.pid === undefined) return false; + if (process.platform === "win32") return !hasExited(child); + + try { + process.kill(-child.pid, 0); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ESRCH") return false; + if (code === "EPERM") return true; + throw error; + } +}; + +const waitForOwnedProcessExit = async (child: ChildProcess, timeoutMs: number) => { + const deadline = Date.now() + timeoutMs; + while (ownedProcessExists(child)) { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) return false; + await wait(Math.min(25, remainingMs)); + } + + if (hasExited(child)) return true; + return waitForExit(child, Math.max(0, deadline - Date.now())); +}; + +const signalOwnedProcess = (child: ChildProcess, signal: NodeJS.Signals) => { + if (child.pid === undefined) return; + + try { + if (process.platform === "win32") { + if (!hasExited(child)) child.kill(signal); + } else { + process.kill(-child.pid, signal); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } +}; + +export const terminateOwnedProcess = async ( + child: ChildProcess, + options: TerminationOptions = {}, +) => { + const gracefulTimeoutMs = options.gracefulTimeoutMs ?? 2_000; + const forceTimeoutMs = options.forceTimeoutMs ?? 2_000; + + if (!ownedProcessExists(child)) { + if (!hasExited(child) && !(await waitForExit(child, gracefulTimeoutMs))) { + throw new Error(`Owned process ${child.pid ?? "unknown"} did not terminate.`); + } + return; + } + + signalOwnedProcess(child, "SIGTERM"); + if (await waitForOwnedProcessExit(child, gracefulTimeoutMs)) return; + + signalOwnedProcess(child, "SIGKILL"); + if (!(await waitForOwnedProcessExit(child, forceTimeoutMs))) { + throw new Error(`Owned process ${child.pid ?? "unknown"} did not terminate.`); + } +}; + +export const createInterruptionController = () => { + const abortController = new AbortController(); + let interruptedBy: NodeJS.Signals | undefined; + let rejectInterruption: (error: Error) => void = () => undefined; + const interruption = new Promise((_resolve, reject) => { + rejectInterruption = reject; + }); + const handlers = new Map void>(); + + for (const signal of ["SIGINT", "SIGTERM"] as const) { + const handler = () => { + if (interruptedBy !== undefined) return; + interruptedBy = signal; + abortController.abort(); + rejectInterruption(new Error(`Interrupted by ${signal}.`)); + }; + handlers.set(signal, handler); + process.on(signal, handler); + } + + return { + dispose: () => { + for (const [signal, handler] of handlers) process.off(signal, handler); + }, + interruptedBy: () => interruptedBy, + interruption, + signal: abortController.signal, + } as const; +}; + +export const resolveLifecycleOutcome = ({ + cleanupErrors, + interruptedBy, + primaryError, +}: LifecycleOutcome) => { + if (interruptedBy !== undefined) return interruptedBy === "SIGINT" ? 130 : 143; + if (primaryError !== undefined) throw primaryError; + if (cleanupErrors.length > 0) { + throw new AggregateError(cleanupErrors, "Owned process cleanup failed."); + } + return undefined; +}; diff --git a/scripts/process-lifecycle.test.mts b/scripts/process-lifecycle.test.mts new file mode 100644 index 0000000..662067a --- /dev/null +++ b/scripts/process-lifecycle.test.mts @@ -0,0 +1,225 @@ +import assert from "node:assert/strict"; +import { execFile as execFileCallback, spawn, type ChildProcess } from "node:child_process"; +import { once } from "node:events"; +import { fileURLToPath } from "node:url"; +import process from "node:process"; +import test from "node:test"; +import { promisify } from "node:util"; +import { + createInterruptionController, + ownedProcessExists, + resolveLifecycleOutcome, + terminateOwnedProcess, +} from "./process-lifecycle.mts"; + +const execFile = promisify(execFileCallback); + +const fixtureSource = ` +process.on("SIGTERM", () => process.exit(0)); +process.stdout.write("ready\\n"); +setInterval(() => undefined, 1_000); +`; + +const stubbornGroupSource = ` +const { spawn } = require("node:child_process"); +const descendant = spawn( + process.execPath, + ["-e", 'process.on("SIGTERM", () => undefined); process.stdout.write("ready\\\\n"); setInterval(() => undefined, 1_000);'], + { stdio: ["ignore", "pipe", "ignore"] }, +); +descendant.stdout.once("data", () => process.stdout.write(String(descendant.pid) + "\\n")); +process.on("SIGTERM", () => process.exit(0)); +setInterval(() => undefined, 1_000); +`; + +const processExists = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; + throw error; + } +}; + +const readLine = (child: ChildProcess) => + new Promise((resolve, reject) => { + let stdout = ""; + child.once("error", reject); + child.once("exit", (code, signal) => { + reject(new Error(`Child exited before readiness with code ${code} and signal ${signal}.`)); + }); + child.stdout?.on("data", (chunk) => { + stdout += String(chunk); + const newline = stdout.indexOf("\n"); + if (newline >= 0) resolve(stdout.slice(0, newline)); + }); + }); + +const runSignalFixture = async () => { + const interruptionController = createInterruptionController(); + const child = spawn(process.execPath, ["-e", fixtureSource], { + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "inherit"], + }); + let primaryError: unknown; + const cleanupErrors: unknown[] = []; + + try { + await readLine(child); + process.stdout.write(`ready ${child.pid ?? "unknown"}\n`); + await interruptionController.interruption; + } catch (error) { + primaryError = error; + } finally { + try { + await terminateOwnedProcess(child); + } catch (error) { + cleanupErrors.push(error); + } + interruptionController.dispose(); + } + + const exitCode = resolveLifecycleOutcome({ + cleanupErrors, + interruptedBy: interruptionController.interruptedBy(), + primaryError, + }); + if (exitCode !== undefined) process.exitCode = exitCode; +}; + +const runCommandInterruptionFixture = async () => { + const interruptionController = createInterruptionController(); + let primaryError: unknown; + + try { + const runningCommand = execFile( + process.execPath, + ["-e", "setTimeout(() => process.exit(7), 1_000)"], + { signal: interruptionController.signal }, + ); + process.stdout.write("ready\n"); + await Promise.race([runningCommand, interruptionController.interruption]); + } catch (error) { + primaryError = error; + } finally { + interruptionController.dispose(); + } + + const exitCode = resolveLifecycleOutcome({ + cleanupErrors: [], + interruptedBy: interruptionController.interruptedBy(), + primaryError, + }); + if (exitCode !== undefined) process.exitCode = exitCode; +}; + +const interruptFixture = async (signal: "SIGINT" | "SIGTERM", expectedExitCode: number) => { + const fixture = spawn(process.execPath, [fileURLToPath(import.meta.url), "--fixture"], { + stdio: ["ignore", "pipe", "pipe"], + }); + const ready = await readLine(fixture); + const match = /^ready (\d+)$/.exec(ready); + assert.ok(match, `Expected fixture readiness with a child PID, received ${ready}.`); + const childPid = Number(match[1]); + + fixture.kill(signal); + const [exitCode, exitSignal] = (await once(fixture, "exit")) as [number | null, string | null]; + + assert.equal(exitSignal, null); + assert.equal(exitCode, expectedExitCode); + assert.equal(processExists(childPid), false, "owned child survived fixture exit"); +}; + +if (process.argv[2] === "--fixture") { + await runSignalFixture(); +} else if (process.argv[2] === "--command-fixture") { + await runCommandInterruptionFixture(); +} else { + test( + "SIGINT reaps the owned child before returning exit 130", + { skip: process.platform === "win32" }, + () => interruptFixture("SIGINT", 130), + ); + + test( + "SIGTERM reaps the owned child before returning exit 143", + { skip: process.platform === "win32" }, + () => interruptFixture("SIGTERM", 143), + ); + + test( + "termination escalates until the owned process group is absent", + { skip: process.platform === "win32" }, + async () => { + const leader = spawn(process.execPath, ["-e", stubbornGroupSource], { + detached: true, + stdio: ["ignore", "pipe", "ignore"], + }); + const descendantPid = Number(await readLine(leader)); + assert.ok(Number.isInteger(descendantPid) && descendantPid > 0); + + await terminateOwnedProcess(leader, { forceTimeoutMs: 2_000, gracefulTimeoutMs: 100 }); + + assert.equal(ownedProcessExists(leader), false); + assert.equal(processExists(descendantPid), false, "owned descendant survived cleanup return"); + }, + ); + + test("primary failures are not masked by cleanup failures", () => { + const primaryError = new Error("primary failure"); + assert.throws( + () => + resolveLifecycleOutcome({ + cleanupErrors: [new Error("cleanup failure")], + interruptedBy: undefined, + primaryError, + }), + (error) => error === primaryError, + ); + }); + + test("interruption exit status is not masked by cleanup failures", () => { + assert.equal( + resolveLifecycleOutcome({ + cleanupErrors: [new Error("cleanup failure")], + interruptedBy: "SIGTERM", + primaryError: new Error("interrupted"), + }), + 143, + ); + }); + + test("cleanup-only failures remain visible", () => { + assert.throws( + () => + resolveLifecycleOutcome({ + cleanupErrors: [new Error("cleanup failure")], + interruptedBy: undefined, + primaryError: undefined, + }), + AggregateError, + ); + }); + + test( + "a signal during a child command keeps its interruption exit status", + { skip: process.platform === "win32" }, + async () => { + const fixture = spawn( + process.execPath, + [fileURLToPath(import.meta.url), "--command-fixture"], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + assert.equal(await readLine(fixture), "ready"); + assert.equal(fixture.kill("SIGTERM"), true); + + const [exitCode, exitSignal] = (await once(fixture, "exit")) as [ + number | null, + string | null, + ]; + assert.equal(exitSignal, null); + assert.equal(exitCode, 143); + }, + ); +} diff --git a/scripts/smoke-packed-install.mts b/scripts/smoke-packed-install.mts index ef85119..b002a8f 100644 --- a/scripts/smoke-packed-install.mts +++ b/scripts/smoke-packed-install.mts @@ -1,7 +1,14 @@ -import { execFileSync, spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { execFile as execFileCallback, spawn, type ChildProcess } from "node:child_process"; import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import process from "node:process"; +import { promisify } from "node:util"; +import { + createInterruptionController, + resolveLifecycleOutcome, + terminateOwnedProcess, +} from "./process-lifecycle.mts"; type AuthStatus = { readonly apiBaseUrl: string; @@ -40,9 +47,11 @@ type NpmPackageInventoryEntry = { const root = process.cwd(); const artifactsDir = join(root, ".artifacts"); -const installDir = mkdtempSync(join(tmpdir(), "putio-cli-install-")); -const configPath = join(installDir, "putio-config.json"); +let installDir: string; +let configPath: string; +let commandAbortSignal: AbortSignal; const commandTimeoutMs = 120_000; +const execFile = promisify(execFileCallback); const mockApiSource = ` import { createServer } from "node:http"; @@ -73,32 +82,36 @@ server.listen(0, "127.0.0.1", () => { process.on("SIGTERM", () => server.close(() => process.exit(0))); `; -const run = (command: string, args: ReadonlyArray, options: object = {}) => - execFileSync(command, args, { - cwd: root, - encoding: "utf8", - stdio: "pipe", - timeout: commandTimeoutMs, - ...options, - }); +const run = async (command: string, args: ReadonlyArray, options: object = {}) => + ( + await execFile(command, [...args], { + ...options, + cwd: root, + encoding: "utf8", + signal: commandAbortSignal, + timeout: commandTimeoutMs, + }) + ).stdout; -const runPutioJson = ( +const runPutioJson = async ( binaryPath: string, args: ReadonlyArray, env: Record = {}, -): A => +): Promise => JSON.parse( - execFileSync(binaryPath, args, { - cwd: installDir, - encoding: "utf8", - env: { - ...process.env, - ...env, - PUTIO_CLI_CONFIG_PATH: configPath, - }, - stdio: "pipe", - timeout: commandTimeoutMs, - }), + ( + await execFile(binaryPath, [...args], { + cwd: installDir, + encoding: "utf8", + env: { + ...process.env, + ...env, + PUTIO_CLI_CONFIG_PATH: configPath, + }, + signal: commandAbortSignal, + timeout: commandTimeoutMs, + }) + ).stdout, ) as A; const assert = (condition: boolean, message: string) => { @@ -107,15 +120,17 @@ const assert = (condition: boolean, message: string) => { } }; -const startMockApi = () => - new Promise<{ readonly baseUrl: string; readonly child: ChildProcess }>((resolve, reject) => { - const child = spawn(process.execPath, ["--input-type=module", "--eval", mockApiSource], { - stdio: ["ignore", "pipe", "pipe"], - }); +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const startMockApi = () => { + const child = spawn(process.execPath, ["--input-type=module", "--eval", mockApiSource], { + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"], + }); + const ready = new Promise((resolve, reject) => { let stderr = ""; let stdout = ""; const timer = setTimeout(() => { - child.kill(); reject(new Error(`Timed out starting the packed-install API server. ${stderr}`.trim())); }, 10_000); @@ -140,15 +155,17 @@ const startMockApi = () => clearTimeout(timer); if (!Number.isInteger(port) || port <= 0) { - child.kill(); reject(new Error(`Expected the API server to report a valid port. ${stderr}`.trim())); return; } - resolve({ baseUrl: `http://127.0.0.1:${port}`, child }); + resolve(`http://127.0.0.1:${port}`); }); }); + return { child, ready } as const; +}; + const readFailureMessage = (value: unknown) => { if ( typeof value !== "object" || @@ -165,32 +182,40 @@ const readFailureMessage = (value: unknown) => { return value.error.message; }; -const runPutioFailure = ( +const runPutioFailure = async ( binaryPath: string, args: ReadonlyArray, configFile: string, env: Record = {}, ) => { - const result = spawnSync(binaryPath, args, { - cwd: installDir, - encoding: "utf8", - env: { - ...process.env, - ...env, - PUTIO_CLI_CONFIG_PATH: configFile, - }, - stdio: "pipe", - timeout: commandTimeoutMs, - }); - - assert(result.error === undefined, `Expected the CLI process to start: ${result.error?.message}`); - assert(result.status === 1, `Expected CLI failure exit code 1, received ${result.status}.`); - - const output = result.stdout.trim().length > 0 ? result.stdout : result.stderr; - return readFailureMessage(JSON.parse(output)); + try { + await execFile(binaryPath, [...args], { + cwd: installDir, + encoding: "utf8", + env: { + ...process.env, + ...env, + PUTIO_CLI_CONFIG_PATH: configFile, + }, + signal: commandAbortSignal, + timeout: commandTimeoutMs, + }); + throw new Error("Expected the CLI command to fail."); + } catch (error) { + const failure = error as Error & { + readonly code?: number | string; + readonly stderr?: string; + readonly stdout?: string; + }; + assert(failure.code === 1, `Expected CLI failure exit code 1, received ${failure.code}.`); + + const stdout = failure.stdout ?? ""; + const output = stdout.trim().length > 0 ? stdout : (failure.stderr ?? ""); + return readFailureMessage(JSON.parse(output)); + } }; -const smokeAuthProfiles = (binaryPath: string) => { +const smokeAuthProfiles = async (binaryPath: string) => { writeFileSync( configPath, `${JSON.stringify( @@ -212,7 +237,7 @@ const smokeAuthProfiles = (binaryPath: string) => { )}\n`, ); - const defaultList = runPutioJson(binaryPath, [ + const defaultList = await runPutioJson(binaryPath, [ "auth", "profiles", "list", @@ -228,7 +253,7 @@ const smokeAuthProfiles = (binaryPath: string) => { "Expected `devs-fe-auto` not to be current before selection.", ); - const defaultStatus = runPutioJson(binaryPath, [ + const defaultStatus = await runPutioJson(binaryPath, [ "auth", "status", "--output", @@ -238,9 +263,13 @@ const smokeAuthProfiles = (binaryPath: string) => { assert(defaultStatus.profile === "human", "Expected default status to use `human`."); assert(defaultStatus.source === "profile", "Expected default status source to be `profile`."); - const envStatus = runPutioJson(binaryPath, ["auth", "status", "--output", "json"], { - PUTIO_CLI_PROFILE: "devs-fe-auto", - }); + const envStatus = await runPutioJson( + binaryPath, + ["auth", "status", "--output", "json"], + { + PUTIO_CLI_PROFILE: "devs-fe-auto", + }, + ); assert(envStatus.authenticated, "Expected env-selected profile status to be authenticated."); assert(envStatus.profile === "devs-fe-auto", "Expected env selection to use `devs-fe-auto`."); assert( @@ -248,7 +277,7 @@ const smokeAuthProfiles = (binaryPath: string) => { "Expected env-selected profile to use its profile-specific API base URL.", ); - const useResult = runPutioJson<{ readonly profile: string }>(binaryPath, [ + const useResult = await runPutioJson<{ readonly profile: string }>(binaryPath, [ "auth", "profiles", "use", @@ -258,7 +287,7 @@ const smokeAuthProfiles = (binaryPath: string) => { ]); assert(useResult.profile === "devs-fe-auto", "Expected `profiles use` to select dev profile."); - const selectedList = runPutioJson(binaryPath, [ + const selectedList = await runPutioJson(binaryPath, [ "auth", "profiles", "list", @@ -274,7 +303,7 @@ const smokeAuthProfiles = (binaryPath: string) => { "Expected dev profile to be current after `profiles use`.", ); - const logoutResult = runPutioJson(binaryPath, [ + const logoutResult = await runPutioJson(binaryPath, [ "auth", "logout", "--profile", @@ -285,7 +314,7 @@ const smokeAuthProfiles = (binaryPath: string) => { assert(logoutResult.cleared, "Expected profile logout to report a cleared token."); assert(logoutResult.profile === "devs-fe-auto", "Expected logout to report selected profile."); - const devAfterLogout = runPutioJson(binaryPath, [ + const devAfterLogout = await runPutioJson(binaryPath, [ "auth", "status", "--profile", @@ -295,7 +324,7 @@ const smokeAuthProfiles = (binaryPath: string) => { ]); assert(!devAfterLogout.authenticated, "Expected dev profile to be unauthenticated after logout."); - const humanAfterDevLogout = runPutioJson(binaryPath, [ + const humanAfterDevLogout = await runPutioJson(binaryPath, [ "auth", "status", "--profile", @@ -308,7 +337,7 @@ const smokeAuthProfiles = (binaryPath: string) => { "Expected human profile to remain authenticated after dev logout.", ); - const removeResult = runPutioJson(binaryPath, [ + const removeResult = await runPutioJson(binaryPath, [ "auth", "profiles", "remove", @@ -318,7 +347,7 @@ const smokeAuthProfiles = (binaryPath: string) => { ]); assert(removeResult.removed, "Expected `profiles remove human` to report removal."); - const finalList = runPutioJson(binaryPath, [ + const finalList = await runPutioJson(binaryPath, [ "auth", "profiles", "list", @@ -335,126 +364,186 @@ const smokeAuthProfiles = (binaryPath: string) => { ); }; -let mockApiProcess: ChildProcess | undefined; - -try { - rmSync(artifactsDir, { force: true, recursive: true }); - run("pnpm", ["pack", "--pack-destination", artifactsDir]); - - const tarball = readdirSync(artifactsDir).find((file) => file.endsWith(".tgz")); - - if (!tarball) { - throw new Error("Expected `pnpm pack` to produce a tarball."); - } - - execFileSync( - "npm", - ["install", "--no-package-lock", "--no-save", resolve(artifactsDir, tarball)], - { - cwd: installDir, - encoding: "utf8", - env: { - ...process.env, - npm_config_cache: join(installDir, "npm-cache"), +const main = async () => { + let mockApiProcess: ChildProcess | undefined; + let didCreateInstallDir = false; + const interruptionController = createInterruptionController(); + commandAbortSignal = interruptionController.signal; + + const throwIfInterrupted = async () => { + await wait(0); + const signal = interruptionController.interruptedBy(); + if (signal !== undefined) { + throw new Error(`Packed-install smoke interrupted by ${signal}.`); + } + }; + + const runSmoke = async () => { + rmSync(artifactsDir, { force: true, recursive: true }); + await run("pnpm", ["pack", "--pack-destination", artifactsDir]); + await throwIfInterrupted(); + + const tarball = readdirSync(artifactsDir).find((file) => file.endsWith(".tgz")); + + if (!tarball) { + throw new Error("Expected `pnpm pack` to produce a tarball."); + } + + await execFile( + "npm", + ["install", "--no-package-lock", "--no-save", resolve(artifactsDir, tarball)], + { + cwd: installDir, + encoding: "utf8", + env: { + ...process.env, + npm_config_cache: join(installDir, "npm-cache"), + }, + signal: commandAbortSignal, + timeout: commandTimeoutMs, }, - stdio: "pipe", - timeout: commandTimeoutMs, - }, - ); - - const binaryPath = join(installDir, "node_modules", ".bin", "putio"); - const versionOutput = execFileSync(binaryPath, ["version"], { - cwd: installDir, - encoding: "utf8", - stdio: "pipe", - timeout: commandTimeoutMs, - }); - - JSON.parse(versionOutput); - - const describeOutput = execFileSync(binaryPath, ["describe"], { - cwd: installDir, - encoding: "utf8", - stdio: "pipe", - timeout: commandTimeoutMs, - }); - - JSON.parse(describeOutput); - smokeAuthProfiles(binaryPath); - - const effectInventory = JSON.parse( - execFileSync("npm", ["query", '[name="effect"]', "--json"], { - cwd: installDir, - encoding: "utf8", - stdio: "pipe", - timeout: commandTimeoutMs, - }), - ) as ReadonlyArray; - const effectVersions = effectInventory.flatMap((entry) => - entry.version === undefined ? [] : [entry.version], - ); - assert( - effectVersions.length === 1 && effectVersions[0] === "4.0.0-rc.109", - `Expected the package to install one Effect 4.0.0-rc.109 runtime, received ${effectVersions.join(", ")}.`, - ); - - const mockApi = await startMockApi(); - mockApiProcess = mockApi.child; - const transfers = runPutioJson( - binaryPath, - ["transfers", "list", "--output", "json"], - { - PUTIO_CLI_API_BASE_URL: mockApi.baseUrl, - PUTIO_CLI_TOKEN: "packed-smoke-token", - }, - ); - assert(transfers.transfers.length === 0, "Expected the SDK-backed transfer list to be empty."); - assert(transfers.cursor === null, "Expected the SDK-backed transfer list cursor to be null."); - assert(transfers.total === 0, "Expected the SDK-backed transfer list total to be zero."); + ); + await throwIfInterrupted(); + + const binaryPath = join(installDir, "node_modules", ".bin", "putio"); + const versionOutput = ( + await execFile(binaryPath, ["version"], { + cwd: installDir, + encoding: "utf8", + signal: commandAbortSignal, + timeout: commandTimeoutMs, + }) + ).stdout; + + JSON.parse(versionOutput); + + const describeOutput = ( + await execFile(binaryPath, ["describe"], { + cwd: installDir, + encoding: "utf8", + signal: commandAbortSignal, + timeout: commandTimeoutMs, + }) + ).stdout; + + JSON.parse(describeOutput); + await smokeAuthProfiles(binaryPath); + + const effectInventory = JSON.parse( + ( + await execFile("npm", ["query", '[name="effect"]', "--json"], { + cwd: installDir, + encoding: "utf8", + signal: commandAbortSignal, + timeout: commandTimeoutMs, + }) + ).stdout, + ) as ReadonlyArray; + const effectVersions = effectInventory.flatMap((entry) => + entry.version === undefined ? [] : [entry.version], + ); + assert( + effectVersions.length === 1 && effectVersions[0] === "4.0.0-rc.109", + `Expected the package to install one Effect 4.0.0-rc.109 runtime, received ${effectVersions.join(", ")}.`, + ); + await throwIfInterrupted(); + + const mockApi = startMockApi(); + mockApiProcess = mockApi.child; + const mockApiBaseUrl = await mockApi.ready; + const transfers = await runPutioJson( + binaryPath, + ["transfers", "list", "--output", "json"], + { + PUTIO_CLI_API_BASE_URL: mockApiBaseUrl, + PUTIO_CLI_TOKEN: "packed-smoke-token", + }, + ); + assert(transfers.transfers.length === 0, "Expected the SDK-backed transfer list to be empty."); + assert(transfers.cursor === null, "Expected the SDK-backed transfer list cursor to be null."); + assert(transfers.total === 0, "Expected the SDK-backed transfer list total to be zero."); + await throwIfInterrupted(); + + const missingAuthMessage = await runPutioFailure( + binaryPath, + ["whoami", "--fields", "auth", "--output", "json"], + join(installDir, "missing-config.json"), + ); + assert( + missingAuthMessage.includes("Set PUTIO_CLI_TOKEN or run `putio auth login`."), + "Expected missing authentication to include an actionable recovery step.", + ); + + const invalidConfigMessage = await runPutioFailure( + binaryPath, + ["auth", "status", "--output", "json"], + join(installDir, "invalid-config.json"), + { PUTIO_CLI_API_BASE_URL: "not-a-url" }, + ); + assert( + invalidConfigMessage.includes("Expected a valid absolute URL"), + "Expected invalid configuration to identify the malformed URL.", + ); + + writeFileSync( + join(artifactsDir, "smoke-packed-install.json"), + `${JSON.stringify( + { + proofs: [ + "packaged-install", + "version", + "describe", + "single-effect-runtime", + "authenticated-sdk-request", + "auth-profile-round-trip", + "missing-auth-failure", + "invalid-config-failure", + ], + status: "passed", + tarball, + }, + null, + 2, + )}\n`, + ); + }; + + let primaryError: unknown; + const cleanupErrors: unknown[] = []; + try { + installDir = mkdtempSync(join(tmpdir(), "putio-cli-install-")); + didCreateInstallDir = true; + configPath = join(installDir, "putio-config.json"); + await Promise.race([runSmoke(), interruptionController.interruption]); + } catch (error) { + primaryError = error; + } finally { + if (mockApiProcess !== undefined) { + try { + await terminateOwnedProcess(mockApiProcess); + } catch (error) { + cleanupErrors.push(error); + } + } + if (didCreateInstallDir) { + try { + rmSync(installDir, { force: true, recursive: true }); + } catch (error) { + cleanupErrors.push(error); + } + } + interruptionController.dispose(); + } - const missingAuthMessage = runPutioFailure( - binaryPath, - ["whoami", "--fields", "auth", "--output", "json"], - join(installDir, "missing-config.json"), - ); - assert( - missingAuthMessage.includes("Set PUTIO_CLI_TOKEN or run `putio auth login`."), - "Expected missing authentication to include an actionable recovery step.", - ); + const interruptedBy = interruptionController.interruptedBy(); + if ((interruptedBy !== undefined || primaryError !== undefined) && cleanupErrors.length > 0) { + for (const error of cleanupErrors) { + console.error(`Packed-install smoke cleanup failed: ${String(error)}`); + } + } - const invalidConfigMessage = runPutioFailure( - binaryPath, - ["auth", "status", "--output", "json"], - join(installDir, "invalid-config.json"), - { PUTIO_CLI_API_BASE_URL: "not-a-url" }, - ); - assert( - invalidConfigMessage.includes("Expected a valid absolute URL"), - "Expected invalid configuration to identify the malformed URL.", - ); + const exitCode = resolveLifecycleOutcome({ cleanupErrors, interruptedBy, primaryError }); + if (exitCode !== undefined) process.exitCode = exitCode; +}; - writeFileSync( - join(artifactsDir, "smoke-packed-install.json"), - `${JSON.stringify( - { - proofs: [ - "packaged-install", - "version", - "describe", - "single-effect-runtime", - "authenticated-sdk-request", - "auth-profile-round-trip", - "missing-auth-failure", - "invalid-config-failure", - ], - status: "passed", - tarball, - }, - null, - 2, - )}\n`, - ); -} finally { - mockApiProcess?.kill(); - rmSync(installDir, { force: true, recursive: true }); -} +await main(); diff --git a/vite.config.ts b/vite.config.ts index 0922d30..8ae5cd2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -43,7 +43,7 @@ export default defineConfig({ "*.{js,ts,tsx,mjs,cjs,mts,cts}": "vp check --fix", }, test: { - exclude: ["node_modules/**", "scripts/**/*.test.ts"], + exclude: ["node_modules/**", "scripts/**/*.test.*"], coverage: { ...coverageConfig, exclude: [ From d1aaa28b4ce5f0c48915954e19544477de152b7b Mon Sep 17 00:00:00 2001 From: Altay Date: Sat, 15 Aug 2026 14:35:28 +0300 Subject: [PATCH 2/3] Revert "fix: clean up packed smoke processes" This reverts commit 442f536d59c4475ede18f3f88bf8d9ff801e70b2. --- package.json | 2 +- scripts/process-lifecycle.mts | 139 --------- scripts/process-lifecycle.test.mts | 225 -------------- scripts/smoke-packed-install.mts | 453 ++++++++++++----------------- vite.config.ts | 2 +- 5 files changed, 184 insertions(+), 637 deletions(-) delete mode 100644 scripts/process-lifecycle.mts delete mode 100644 scripts/process-lifecycle.test.mts diff --git a/package.json b/package.json index fe5ba1a..6677d92 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "coverage": "vp test run --coverage", "dev": "vp pack --watch", "prepack": "vp pack", - "smoke:pack": "node --test ./scripts/process-lifecycle.test.mts && node ./scripts/smoke-packed-install.mts", + "smoke:pack": "node ./scripts/smoke-packed-install.mts", "test": "vp test", "prepublishOnly": "npm run build", "verify:sea": "node ./scripts/verify-sea.mjs", diff --git a/scripts/process-lifecycle.mts b/scripts/process-lifecycle.mts deleted file mode 100644 index c8a4c57..0000000 --- a/scripts/process-lifecycle.mts +++ /dev/null @@ -1,139 +0,0 @@ -import type { ChildProcess } from "node:child_process"; -import process from "node:process"; - -type TerminationOptions = { - readonly forceTimeoutMs?: number; - readonly gracefulTimeoutMs?: number; -}; - -type LifecycleOutcome = { - readonly cleanupErrors: ReadonlyArray; - readonly interruptedBy: NodeJS.Signals | undefined; - readonly primaryError: unknown; -}; - -const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -const hasExited = (child: ChildProcess) => child.exitCode !== null || child.signalCode !== null; - -const waitForExit = (child: ChildProcess, timeoutMs: number): Promise => { - if (hasExited(child)) return Promise.resolve(true); - - return new Promise((resolve) => { - const finish = (exited: boolean) => { - clearTimeout(timer); - child.off("exit", onExit); - resolve(exited); - }; - const onExit = () => finish(true); - const timer = setTimeout(() => finish(false), timeoutMs); - child.once("exit", onExit); - }); -}; - -export const ownedProcessExists = (child: ChildProcess) => { - if (child.pid === undefined) return false; - if (process.platform === "win32") return !hasExited(child); - - try { - process.kill(-child.pid, 0); - return true; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ESRCH") return false; - if (code === "EPERM") return true; - throw error; - } -}; - -const waitForOwnedProcessExit = async (child: ChildProcess, timeoutMs: number) => { - const deadline = Date.now() + timeoutMs; - while (ownedProcessExists(child)) { - const remainingMs = deadline - Date.now(); - if (remainingMs <= 0) return false; - await wait(Math.min(25, remainingMs)); - } - - if (hasExited(child)) return true; - return waitForExit(child, Math.max(0, deadline - Date.now())); -}; - -const signalOwnedProcess = (child: ChildProcess, signal: NodeJS.Signals) => { - if (child.pid === undefined) return; - - try { - if (process.platform === "win32") { - if (!hasExited(child)) child.kill(signal); - } else { - process.kill(-child.pid, signal); - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; - } -}; - -export const terminateOwnedProcess = async ( - child: ChildProcess, - options: TerminationOptions = {}, -) => { - const gracefulTimeoutMs = options.gracefulTimeoutMs ?? 2_000; - const forceTimeoutMs = options.forceTimeoutMs ?? 2_000; - - if (!ownedProcessExists(child)) { - if (!hasExited(child) && !(await waitForExit(child, gracefulTimeoutMs))) { - throw new Error(`Owned process ${child.pid ?? "unknown"} did not terminate.`); - } - return; - } - - signalOwnedProcess(child, "SIGTERM"); - if (await waitForOwnedProcessExit(child, gracefulTimeoutMs)) return; - - signalOwnedProcess(child, "SIGKILL"); - if (!(await waitForOwnedProcessExit(child, forceTimeoutMs))) { - throw new Error(`Owned process ${child.pid ?? "unknown"} did not terminate.`); - } -}; - -export const createInterruptionController = () => { - const abortController = new AbortController(); - let interruptedBy: NodeJS.Signals | undefined; - let rejectInterruption: (error: Error) => void = () => undefined; - const interruption = new Promise((_resolve, reject) => { - rejectInterruption = reject; - }); - const handlers = new Map void>(); - - for (const signal of ["SIGINT", "SIGTERM"] as const) { - const handler = () => { - if (interruptedBy !== undefined) return; - interruptedBy = signal; - abortController.abort(); - rejectInterruption(new Error(`Interrupted by ${signal}.`)); - }; - handlers.set(signal, handler); - process.on(signal, handler); - } - - return { - dispose: () => { - for (const [signal, handler] of handlers) process.off(signal, handler); - }, - interruptedBy: () => interruptedBy, - interruption, - signal: abortController.signal, - } as const; -}; - -export const resolveLifecycleOutcome = ({ - cleanupErrors, - interruptedBy, - primaryError, -}: LifecycleOutcome) => { - if (interruptedBy !== undefined) return interruptedBy === "SIGINT" ? 130 : 143; - if (primaryError !== undefined) throw primaryError; - if (cleanupErrors.length > 0) { - throw new AggregateError(cleanupErrors, "Owned process cleanup failed."); - } - return undefined; -}; diff --git a/scripts/process-lifecycle.test.mts b/scripts/process-lifecycle.test.mts deleted file mode 100644 index 662067a..0000000 --- a/scripts/process-lifecycle.test.mts +++ /dev/null @@ -1,225 +0,0 @@ -import assert from "node:assert/strict"; -import { execFile as execFileCallback, spawn, type ChildProcess } from "node:child_process"; -import { once } from "node:events"; -import { fileURLToPath } from "node:url"; -import process from "node:process"; -import test from "node:test"; -import { promisify } from "node:util"; -import { - createInterruptionController, - ownedProcessExists, - resolveLifecycleOutcome, - terminateOwnedProcess, -} from "./process-lifecycle.mts"; - -const execFile = promisify(execFileCallback); - -const fixtureSource = ` -process.on("SIGTERM", () => process.exit(0)); -process.stdout.write("ready\\n"); -setInterval(() => undefined, 1_000); -`; - -const stubbornGroupSource = ` -const { spawn } = require("node:child_process"); -const descendant = spawn( - process.execPath, - ["-e", 'process.on("SIGTERM", () => undefined); process.stdout.write("ready\\\\n"); setInterval(() => undefined, 1_000);'], - { stdio: ["ignore", "pipe", "ignore"] }, -); -descendant.stdout.once("data", () => process.stdout.write(String(descendant.pid) + "\\n")); -process.on("SIGTERM", () => process.exit(0)); -setInterval(() => undefined, 1_000); -`; - -const processExists = (pid: number) => { - try { - process.kill(pid, 0); - return true; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; - throw error; - } -}; - -const readLine = (child: ChildProcess) => - new Promise((resolve, reject) => { - let stdout = ""; - child.once("error", reject); - child.once("exit", (code, signal) => { - reject(new Error(`Child exited before readiness with code ${code} and signal ${signal}.`)); - }); - child.stdout?.on("data", (chunk) => { - stdout += String(chunk); - const newline = stdout.indexOf("\n"); - if (newline >= 0) resolve(stdout.slice(0, newline)); - }); - }); - -const runSignalFixture = async () => { - const interruptionController = createInterruptionController(); - const child = spawn(process.execPath, ["-e", fixtureSource], { - detached: process.platform !== "win32", - stdio: ["ignore", "pipe", "inherit"], - }); - let primaryError: unknown; - const cleanupErrors: unknown[] = []; - - try { - await readLine(child); - process.stdout.write(`ready ${child.pid ?? "unknown"}\n`); - await interruptionController.interruption; - } catch (error) { - primaryError = error; - } finally { - try { - await terminateOwnedProcess(child); - } catch (error) { - cleanupErrors.push(error); - } - interruptionController.dispose(); - } - - const exitCode = resolveLifecycleOutcome({ - cleanupErrors, - interruptedBy: interruptionController.interruptedBy(), - primaryError, - }); - if (exitCode !== undefined) process.exitCode = exitCode; -}; - -const runCommandInterruptionFixture = async () => { - const interruptionController = createInterruptionController(); - let primaryError: unknown; - - try { - const runningCommand = execFile( - process.execPath, - ["-e", "setTimeout(() => process.exit(7), 1_000)"], - { signal: interruptionController.signal }, - ); - process.stdout.write("ready\n"); - await Promise.race([runningCommand, interruptionController.interruption]); - } catch (error) { - primaryError = error; - } finally { - interruptionController.dispose(); - } - - const exitCode = resolveLifecycleOutcome({ - cleanupErrors: [], - interruptedBy: interruptionController.interruptedBy(), - primaryError, - }); - if (exitCode !== undefined) process.exitCode = exitCode; -}; - -const interruptFixture = async (signal: "SIGINT" | "SIGTERM", expectedExitCode: number) => { - const fixture = spawn(process.execPath, [fileURLToPath(import.meta.url), "--fixture"], { - stdio: ["ignore", "pipe", "pipe"], - }); - const ready = await readLine(fixture); - const match = /^ready (\d+)$/.exec(ready); - assert.ok(match, `Expected fixture readiness with a child PID, received ${ready}.`); - const childPid = Number(match[1]); - - fixture.kill(signal); - const [exitCode, exitSignal] = (await once(fixture, "exit")) as [number | null, string | null]; - - assert.equal(exitSignal, null); - assert.equal(exitCode, expectedExitCode); - assert.equal(processExists(childPid), false, "owned child survived fixture exit"); -}; - -if (process.argv[2] === "--fixture") { - await runSignalFixture(); -} else if (process.argv[2] === "--command-fixture") { - await runCommandInterruptionFixture(); -} else { - test( - "SIGINT reaps the owned child before returning exit 130", - { skip: process.platform === "win32" }, - () => interruptFixture("SIGINT", 130), - ); - - test( - "SIGTERM reaps the owned child before returning exit 143", - { skip: process.platform === "win32" }, - () => interruptFixture("SIGTERM", 143), - ); - - test( - "termination escalates until the owned process group is absent", - { skip: process.platform === "win32" }, - async () => { - const leader = spawn(process.execPath, ["-e", stubbornGroupSource], { - detached: true, - stdio: ["ignore", "pipe", "ignore"], - }); - const descendantPid = Number(await readLine(leader)); - assert.ok(Number.isInteger(descendantPid) && descendantPid > 0); - - await terminateOwnedProcess(leader, { forceTimeoutMs: 2_000, gracefulTimeoutMs: 100 }); - - assert.equal(ownedProcessExists(leader), false); - assert.equal(processExists(descendantPid), false, "owned descendant survived cleanup return"); - }, - ); - - test("primary failures are not masked by cleanup failures", () => { - const primaryError = new Error("primary failure"); - assert.throws( - () => - resolveLifecycleOutcome({ - cleanupErrors: [new Error("cleanup failure")], - interruptedBy: undefined, - primaryError, - }), - (error) => error === primaryError, - ); - }); - - test("interruption exit status is not masked by cleanup failures", () => { - assert.equal( - resolveLifecycleOutcome({ - cleanupErrors: [new Error("cleanup failure")], - interruptedBy: "SIGTERM", - primaryError: new Error("interrupted"), - }), - 143, - ); - }); - - test("cleanup-only failures remain visible", () => { - assert.throws( - () => - resolveLifecycleOutcome({ - cleanupErrors: [new Error("cleanup failure")], - interruptedBy: undefined, - primaryError: undefined, - }), - AggregateError, - ); - }); - - test( - "a signal during a child command keeps its interruption exit status", - { skip: process.platform === "win32" }, - async () => { - const fixture = spawn( - process.execPath, - [fileURLToPath(import.meta.url), "--command-fixture"], - { stdio: ["ignore", "pipe", "pipe"] }, - ); - assert.equal(await readLine(fixture), "ready"); - assert.equal(fixture.kill("SIGTERM"), true); - - const [exitCode, exitSignal] = (await once(fixture, "exit")) as [ - number | null, - string | null, - ]; - assert.equal(exitSignal, null); - assert.equal(exitCode, 143); - }, - ); -} diff --git a/scripts/smoke-packed-install.mts b/scripts/smoke-packed-install.mts index b002a8f..ef85119 100644 --- a/scripts/smoke-packed-install.mts +++ b/scripts/smoke-packed-install.mts @@ -1,14 +1,7 @@ -import { execFile as execFileCallback, spawn, type ChildProcess } from "node:child_process"; +import { execFileSync, spawn, spawnSync, type ChildProcess } from "node:child_process"; import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import process from "node:process"; -import { promisify } from "node:util"; -import { - createInterruptionController, - resolveLifecycleOutcome, - terminateOwnedProcess, -} from "./process-lifecycle.mts"; type AuthStatus = { readonly apiBaseUrl: string; @@ -47,11 +40,9 @@ type NpmPackageInventoryEntry = { const root = process.cwd(); const artifactsDir = join(root, ".artifacts"); -let installDir: string; -let configPath: string; -let commandAbortSignal: AbortSignal; +const installDir = mkdtempSync(join(tmpdir(), "putio-cli-install-")); +const configPath = join(installDir, "putio-config.json"); const commandTimeoutMs = 120_000; -const execFile = promisify(execFileCallback); const mockApiSource = ` import { createServer } from "node:http"; @@ -82,36 +73,32 @@ server.listen(0, "127.0.0.1", () => { process.on("SIGTERM", () => server.close(() => process.exit(0))); `; -const run = async (command: string, args: ReadonlyArray, options: object = {}) => - ( - await execFile(command, [...args], { - ...options, - cwd: root, - encoding: "utf8", - signal: commandAbortSignal, - timeout: commandTimeoutMs, - }) - ).stdout; +const run = (command: string, args: ReadonlyArray, options: object = {}) => + execFileSync(command, args, { + cwd: root, + encoding: "utf8", + stdio: "pipe", + timeout: commandTimeoutMs, + ...options, + }); -const runPutioJson = async ( +const runPutioJson = ( binaryPath: string, args: ReadonlyArray, env: Record = {}, -): Promise => +): A => JSON.parse( - ( - await execFile(binaryPath, [...args], { - cwd: installDir, - encoding: "utf8", - env: { - ...process.env, - ...env, - PUTIO_CLI_CONFIG_PATH: configPath, - }, - signal: commandAbortSignal, - timeout: commandTimeoutMs, - }) - ).stdout, + execFileSync(binaryPath, args, { + cwd: installDir, + encoding: "utf8", + env: { + ...process.env, + ...env, + PUTIO_CLI_CONFIG_PATH: configPath, + }, + stdio: "pipe", + timeout: commandTimeoutMs, + }), ) as A; const assert = (condition: boolean, message: string) => { @@ -120,17 +107,15 @@ const assert = (condition: boolean, message: string) => { } }; -const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -const startMockApi = () => { - const child = spawn(process.execPath, ["--input-type=module", "--eval", mockApiSource], { - detached: process.platform !== "win32", - stdio: ["ignore", "pipe", "pipe"], - }); - const ready = new Promise((resolve, reject) => { +const startMockApi = () => + new Promise<{ readonly baseUrl: string; readonly child: ChildProcess }>((resolve, reject) => { + const child = spawn(process.execPath, ["--input-type=module", "--eval", mockApiSource], { + stdio: ["ignore", "pipe", "pipe"], + }); let stderr = ""; let stdout = ""; const timer = setTimeout(() => { + child.kill(); reject(new Error(`Timed out starting the packed-install API server. ${stderr}`.trim())); }, 10_000); @@ -155,17 +140,15 @@ const startMockApi = () => { clearTimeout(timer); if (!Number.isInteger(port) || port <= 0) { + child.kill(); reject(new Error(`Expected the API server to report a valid port. ${stderr}`.trim())); return; } - resolve(`http://127.0.0.1:${port}`); + resolve({ baseUrl: `http://127.0.0.1:${port}`, child }); }); }); - return { child, ready } as const; -}; - const readFailureMessage = (value: unknown) => { if ( typeof value !== "object" || @@ -182,40 +165,32 @@ const readFailureMessage = (value: unknown) => { return value.error.message; }; -const runPutioFailure = async ( +const runPutioFailure = ( binaryPath: string, args: ReadonlyArray, configFile: string, env: Record = {}, ) => { - try { - await execFile(binaryPath, [...args], { - cwd: installDir, - encoding: "utf8", - env: { - ...process.env, - ...env, - PUTIO_CLI_CONFIG_PATH: configFile, - }, - signal: commandAbortSignal, - timeout: commandTimeoutMs, - }); - throw new Error("Expected the CLI command to fail."); - } catch (error) { - const failure = error as Error & { - readonly code?: number | string; - readonly stderr?: string; - readonly stdout?: string; - }; - assert(failure.code === 1, `Expected CLI failure exit code 1, received ${failure.code}.`); - - const stdout = failure.stdout ?? ""; - const output = stdout.trim().length > 0 ? stdout : (failure.stderr ?? ""); - return readFailureMessage(JSON.parse(output)); - } + const result = spawnSync(binaryPath, args, { + cwd: installDir, + encoding: "utf8", + env: { + ...process.env, + ...env, + PUTIO_CLI_CONFIG_PATH: configFile, + }, + stdio: "pipe", + timeout: commandTimeoutMs, + }); + + assert(result.error === undefined, `Expected the CLI process to start: ${result.error?.message}`); + assert(result.status === 1, `Expected CLI failure exit code 1, received ${result.status}.`); + + const output = result.stdout.trim().length > 0 ? result.stdout : result.stderr; + return readFailureMessage(JSON.parse(output)); }; -const smokeAuthProfiles = async (binaryPath: string) => { +const smokeAuthProfiles = (binaryPath: string) => { writeFileSync( configPath, `${JSON.stringify( @@ -237,7 +212,7 @@ const smokeAuthProfiles = async (binaryPath: string) => { )}\n`, ); - const defaultList = await runPutioJson(binaryPath, [ + const defaultList = runPutioJson(binaryPath, [ "auth", "profiles", "list", @@ -253,7 +228,7 @@ const smokeAuthProfiles = async (binaryPath: string) => { "Expected `devs-fe-auto` not to be current before selection.", ); - const defaultStatus = await runPutioJson(binaryPath, [ + const defaultStatus = runPutioJson(binaryPath, [ "auth", "status", "--output", @@ -263,13 +238,9 @@ const smokeAuthProfiles = async (binaryPath: string) => { assert(defaultStatus.profile === "human", "Expected default status to use `human`."); assert(defaultStatus.source === "profile", "Expected default status source to be `profile`."); - const envStatus = await runPutioJson( - binaryPath, - ["auth", "status", "--output", "json"], - { - PUTIO_CLI_PROFILE: "devs-fe-auto", - }, - ); + const envStatus = runPutioJson(binaryPath, ["auth", "status", "--output", "json"], { + PUTIO_CLI_PROFILE: "devs-fe-auto", + }); assert(envStatus.authenticated, "Expected env-selected profile status to be authenticated."); assert(envStatus.profile === "devs-fe-auto", "Expected env selection to use `devs-fe-auto`."); assert( @@ -277,7 +248,7 @@ const smokeAuthProfiles = async (binaryPath: string) => { "Expected env-selected profile to use its profile-specific API base URL.", ); - const useResult = await runPutioJson<{ readonly profile: string }>(binaryPath, [ + const useResult = runPutioJson<{ readonly profile: string }>(binaryPath, [ "auth", "profiles", "use", @@ -287,7 +258,7 @@ const smokeAuthProfiles = async (binaryPath: string) => { ]); assert(useResult.profile === "devs-fe-auto", "Expected `profiles use` to select dev profile."); - const selectedList = await runPutioJson(binaryPath, [ + const selectedList = runPutioJson(binaryPath, [ "auth", "profiles", "list", @@ -303,7 +274,7 @@ const smokeAuthProfiles = async (binaryPath: string) => { "Expected dev profile to be current after `profiles use`.", ); - const logoutResult = await runPutioJson(binaryPath, [ + const logoutResult = runPutioJson(binaryPath, [ "auth", "logout", "--profile", @@ -314,7 +285,7 @@ const smokeAuthProfiles = async (binaryPath: string) => { assert(logoutResult.cleared, "Expected profile logout to report a cleared token."); assert(logoutResult.profile === "devs-fe-auto", "Expected logout to report selected profile."); - const devAfterLogout = await runPutioJson(binaryPath, [ + const devAfterLogout = runPutioJson(binaryPath, [ "auth", "status", "--profile", @@ -324,7 +295,7 @@ const smokeAuthProfiles = async (binaryPath: string) => { ]); assert(!devAfterLogout.authenticated, "Expected dev profile to be unauthenticated after logout."); - const humanAfterDevLogout = await runPutioJson(binaryPath, [ + const humanAfterDevLogout = runPutioJson(binaryPath, [ "auth", "status", "--profile", @@ -337,7 +308,7 @@ const smokeAuthProfiles = async (binaryPath: string) => { "Expected human profile to remain authenticated after dev logout.", ); - const removeResult = await runPutioJson(binaryPath, [ + const removeResult = runPutioJson(binaryPath, [ "auth", "profiles", "remove", @@ -347,7 +318,7 @@ const smokeAuthProfiles = async (binaryPath: string) => { ]); assert(removeResult.removed, "Expected `profiles remove human` to report removal."); - const finalList = await runPutioJson(binaryPath, [ + const finalList = runPutioJson(binaryPath, [ "auth", "profiles", "list", @@ -364,186 +335,126 @@ const smokeAuthProfiles = async (binaryPath: string) => { ); }; -const main = async () => { - let mockApiProcess: ChildProcess | undefined; - let didCreateInstallDir = false; - const interruptionController = createInterruptionController(); - commandAbortSignal = interruptionController.signal; - - const throwIfInterrupted = async () => { - await wait(0); - const signal = interruptionController.interruptedBy(); - if (signal !== undefined) { - throw new Error(`Packed-install smoke interrupted by ${signal}.`); - } - }; - - const runSmoke = async () => { - rmSync(artifactsDir, { force: true, recursive: true }); - await run("pnpm", ["pack", "--pack-destination", artifactsDir]); - await throwIfInterrupted(); - - const tarball = readdirSync(artifactsDir).find((file) => file.endsWith(".tgz")); - - if (!tarball) { - throw new Error("Expected `pnpm pack` to produce a tarball."); - } - - await execFile( - "npm", - ["install", "--no-package-lock", "--no-save", resolve(artifactsDir, tarball)], - { - cwd: installDir, - encoding: "utf8", - env: { - ...process.env, - npm_config_cache: join(installDir, "npm-cache"), - }, - signal: commandAbortSignal, - timeout: commandTimeoutMs, - }, - ); - await throwIfInterrupted(); - - const binaryPath = join(installDir, "node_modules", ".bin", "putio"); - const versionOutput = ( - await execFile(binaryPath, ["version"], { - cwd: installDir, - encoding: "utf8", - signal: commandAbortSignal, - timeout: commandTimeoutMs, - }) - ).stdout; - - JSON.parse(versionOutput); - - const describeOutput = ( - await execFile(binaryPath, ["describe"], { - cwd: installDir, - encoding: "utf8", - signal: commandAbortSignal, - timeout: commandTimeoutMs, - }) - ).stdout; - - JSON.parse(describeOutput); - await smokeAuthProfiles(binaryPath); - - const effectInventory = JSON.parse( - ( - await execFile("npm", ["query", '[name="effect"]', "--json"], { - cwd: installDir, - encoding: "utf8", - signal: commandAbortSignal, - timeout: commandTimeoutMs, - }) - ).stdout, - ) as ReadonlyArray; - const effectVersions = effectInventory.flatMap((entry) => - entry.version === undefined ? [] : [entry.version], - ); - assert( - effectVersions.length === 1 && effectVersions[0] === "4.0.0-rc.109", - `Expected the package to install one Effect 4.0.0-rc.109 runtime, received ${effectVersions.join(", ")}.`, - ); - await throwIfInterrupted(); - - const mockApi = startMockApi(); - mockApiProcess = mockApi.child; - const mockApiBaseUrl = await mockApi.ready; - const transfers = await runPutioJson( - binaryPath, - ["transfers", "list", "--output", "json"], - { - PUTIO_CLI_API_BASE_URL: mockApiBaseUrl, - PUTIO_CLI_TOKEN: "packed-smoke-token", - }, - ); - assert(transfers.transfers.length === 0, "Expected the SDK-backed transfer list to be empty."); - assert(transfers.cursor === null, "Expected the SDK-backed transfer list cursor to be null."); - assert(transfers.total === 0, "Expected the SDK-backed transfer list total to be zero."); - await throwIfInterrupted(); - - const missingAuthMessage = await runPutioFailure( - binaryPath, - ["whoami", "--fields", "auth", "--output", "json"], - join(installDir, "missing-config.json"), - ); - assert( - missingAuthMessage.includes("Set PUTIO_CLI_TOKEN or run `putio auth login`."), - "Expected missing authentication to include an actionable recovery step.", - ); - - const invalidConfigMessage = await runPutioFailure( - binaryPath, - ["auth", "status", "--output", "json"], - join(installDir, "invalid-config.json"), - { PUTIO_CLI_API_BASE_URL: "not-a-url" }, - ); - assert( - invalidConfigMessage.includes("Expected a valid absolute URL"), - "Expected invalid configuration to identify the malformed URL.", - ); - - writeFileSync( - join(artifactsDir, "smoke-packed-install.json"), - `${JSON.stringify( - { - proofs: [ - "packaged-install", - "version", - "describe", - "single-effect-runtime", - "authenticated-sdk-request", - "auth-profile-round-trip", - "missing-auth-failure", - "invalid-config-failure", - ], - status: "passed", - tarball, - }, - null, - 2, - )}\n`, - ); - }; - - let primaryError: unknown; - const cleanupErrors: unknown[] = []; - try { - installDir = mkdtempSync(join(tmpdir(), "putio-cli-install-")); - didCreateInstallDir = true; - configPath = join(installDir, "putio-config.json"); - await Promise.race([runSmoke(), interruptionController.interruption]); - } catch (error) { - primaryError = error; - } finally { - if (mockApiProcess !== undefined) { - try { - await terminateOwnedProcess(mockApiProcess); - } catch (error) { - cleanupErrors.push(error); - } - } - if (didCreateInstallDir) { - try { - rmSync(installDir, { force: true, recursive: true }); - } catch (error) { - cleanupErrors.push(error); - } - } - interruptionController.dispose(); - } +let mockApiProcess: ChildProcess | undefined; + +try { + rmSync(artifactsDir, { force: true, recursive: true }); + run("pnpm", ["pack", "--pack-destination", artifactsDir]); - const interruptedBy = interruptionController.interruptedBy(); - if ((interruptedBy !== undefined || primaryError !== undefined) && cleanupErrors.length > 0) { - for (const error of cleanupErrors) { - console.error(`Packed-install smoke cleanup failed: ${String(error)}`); - } + const tarball = readdirSync(artifactsDir).find((file) => file.endsWith(".tgz")); + + if (!tarball) { + throw new Error("Expected `pnpm pack` to produce a tarball."); } - const exitCode = resolveLifecycleOutcome({ cleanupErrors, interruptedBy, primaryError }); - if (exitCode !== undefined) process.exitCode = exitCode; -}; + execFileSync( + "npm", + ["install", "--no-package-lock", "--no-save", resolve(artifactsDir, tarball)], + { + cwd: installDir, + encoding: "utf8", + env: { + ...process.env, + npm_config_cache: join(installDir, "npm-cache"), + }, + stdio: "pipe", + timeout: commandTimeoutMs, + }, + ); + + const binaryPath = join(installDir, "node_modules", ".bin", "putio"); + const versionOutput = execFileSync(binaryPath, ["version"], { + cwd: installDir, + encoding: "utf8", + stdio: "pipe", + timeout: commandTimeoutMs, + }); + + JSON.parse(versionOutput); + + const describeOutput = execFileSync(binaryPath, ["describe"], { + cwd: installDir, + encoding: "utf8", + stdio: "pipe", + timeout: commandTimeoutMs, + }); + + JSON.parse(describeOutput); + smokeAuthProfiles(binaryPath); + + const effectInventory = JSON.parse( + execFileSync("npm", ["query", '[name="effect"]', "--json"], { + cwd: installDir, + encoding: "utf8", + stdio: "pipe", + timeout: commandTimeoutMs, + }), + ) as ReadonlyArray; + const effectVersions = effectInventory.flatMap((entry) => + entry.version === undefined ? [] : [entry.version], + ); + assert( + effectVersions.length === 1 && effectVersions[0] === "4.0.0-rc.109", + `Expected the package to install one Effect 4.0.0-rc.109 runtime, received ${effectVersions.join(", ")}.`, + ); + + const mockApi = await startMockApi(); + mockApiProcess = mockApi.child; + const transfers = runPutioJson( + binaryPath, + ["transfers", "list", "--output", "json"], + { + PUTIO_CLI_API_BASE_URL: mockApi.baseUrl, + PUTIO_CLI_TOKEN: "packed-smoke-token", + }, + ); + assert(transfers.transfers.length === 0, "Expected the SDK-backed transfer list to be empty."); + assert(transfers.cursor === null, "Expected the SDK-backed transfer list cursor to be null."); + assert(transfers.total === 0, "Expected the SDK-backed transfer list total to be zero."); + + const missingAuthMessage = runPutioFailure( + binaryPath, + ["whoami", "--fields", "auth", "--output", "json"], + join(installDir, "missing-config.json"), + ); + assert( + missingAuthMessage.includes("Set PUTIO_CLI_TOKEN or run `putio auth login`."), + "Expected missing authentication to include an actionable recovery step.", + ); -await main(); + const invalidConfigMessage = runPutioFailure( + binaryPath, + ["auth", "status", "--output", "json"], + join(installDir, "invalid-config.json"), + { PUTIO_CLI_API_BASE_URL: "not-a-url" }, + ); + assert( + invalidConfigMessage.includes("Expected a valid absolute URL"), + "Expected invalid configuration to identify the malformed URL.", + ); + + writeFileSync( + join(artifactsDir, "smoke-packed-install.json"), + `${JSON.stringify( + { + proofs: [ + "packaged-install", + "version", + "describe", + "single-effect-runtime", + "authenticated-sdk-request", + "auth-profile-round-trip", + "missing-auth-failure", + "invalid-config-failure", + ], + status: "passed", + tarball, + }, + null, + 2, + )}\n`, + ); +} finally { + mockApiProcess?.kill(); + rmSync(installDir, { force: true, recursive: true }); +} diff --git a/vite.config.ts b/vite.config.ts index 8ae5cd2..0922d30 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -43,7 +43,7 @@ export default defineConfig({ "*.{js,ts,tsx,mjs,cjs,mts,cts}": "vp check --fix", }, test: { - exclude: ["node_modules/**", "scripts/**/*.test.*"], + exclude: ["node_modules/**", "scripts/**/*.test.ts"], coverage: { ...coverageConfig, exclude: [ From 9115c7e68c3153ea88504c3f5932340306c0163f Mon Sep 17 00:00:00 2001 From: Altay Date: Sat, 15 Aug 2026 14:44:33 +0300 Subject: [PATCH 3/3] fix: clean up the packed smoke server --- scripts/smoke-packed-install.mts | 117 +++++++++++++++++++++++++------ 1 file changed, 97 insertions(+), 20 deletions(-) diff --git a/scripts/smoke-packed-install.mts b/scripts/smoke-packed-install.mts index ef85119..116e883 100644 --- a/scripts/smoke-packed-install.mts +++ b/scripts/smoke-packed-install.mts @@ -1,7 +1,16 @@ -import { execFileSync, spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { + execFile as execFileCallback, + execFileSync, + spawn, + spawnSync, + type ChildProcess, +} from "node:child_process"; +import { once } from "node:events"; import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import process from "node:process"; +import { promisify } from "node:util"; type AuthStatus = { readonly apiBaseUrl: string; @@ -40,9 +49,10 @@ type NpmPackageInventoryEntry = { const root = process.cwd(); const artifactsDir = join(root, ".artifacts"); -const installDir = mkdtempSync(join(tmpdir(), "putio-cli-install-")); -const configPath = join(installDir, "putio-config.json"); +let installDir: string; +let configPath: string; const commandTimeoutMs = 120_000; +const execFile = promisify(execFileCallback); const mockApiSource = ` import { createServer } from "node:http"; @@ -107,11 +117,11 @@ const assert = (condition: boolean, message: string) => { } }; -const startMockApi = () => - new Promise<{ readonly baseUrl: string; readonly child: ChildProcess }>((resolve, reject) => { - const child = spawn(process.execPath, ["--input-type=module", "--eval", mockApiSource], { - stdio: ["ignore", "pipe", "pipe"], - }); +const startMockApi = () => { + const child = spawn(process.execPath, ["--input-type=module", "--eval", mockApiSource], { + stdio: ["ignore", "pipe", "pipe"], + }); + const ready = new Promise((resolve, reject) => { let stderr = ""; let stdout = ""; const timer = setTimeout(() => { @@ -145,10 +155,31 @@ const startMockApi = () => return; } - resolve({ baseUrl: `http://127.0.0.1:${port}`, child }); + resolve(`http://127.0.0.1:${port}`); }); }); + return { child, ready } as const; +}; + +const waitForExit = async (child: ChildProcess, timeoutMs: number) => { + if (child.exitCode !== null || child.signalCode !== null) return true; + return Promise.race([ + once(child, "exit").then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), timeoutMs)), + ]); +}; + +const stopMockApi = async (child: ChildProcess) => { + if (child.exitCode !== null || child.signalCode !== null) return; + child.kill("SIGTERM"); + if (await waitForExit(child, 2_000)) return; + child.kill("SIGKILL"); + if (!(await waitForExit(child, 2_000))) { + throw new Error(`Packed-install API server ${child.pid ?? "unknown"} did not stop.`); + } +}; + const readFailureMessage = (value: unknown) => { if ( typeof value !== "object" || @@ -336,8 +367,26 @@ const smokeAuthProfiles = (binaryPath: string) => { }; let mockApiProcess: ChildProcess | undefined; +let didCreateInstallDir = false; +let interruptedBy: NodeJS.Signals | undefined; +const commandController = new AbortController(); +const interruptHandlers = new Map void>(); + +for (const signal of ["SIGINT", "SIGTERM"] as const) { + const handler = () => { + interruptedBy ??= signal; + commandController.abort(); + }; + interruptHandlers.set(signal, handler); + process.on(signal, handler); +} +let primaryError: unknown; +let cleanupError: unknown; try { + installDir = mkdtempSync(join(tmpdir(), "putio-cli-install-")); + didCreateInstallDir = true; + configPath = join(installDir, "putio-config.json"); rmSync(artifactsDir, { force: true, recursive: true }); run("pnpm", ["pack", "--pack-destination", artifactsDir]); @@ -398,16 +447,25 @@ try { `Expected the package to install one Effect 4.0.0-rc.109 runtime, received ${effectVersions.join(", ")}.`, ); - const mockApi = await startMockApi(); + const mockApi = startMockApi(); mockApiProcess = mockApi.child; - const transfers = runPutioJson( - binaryPath, - ["transfers", "list", "--output", "json"], - { - PUTIO_CLI_API_BASE_URL: mockApi.baseUrl, - PUTIO_CLI_TOKEN: "packed-smoke-token", - }, - ); + const mockApiBaseUrl = await mockApi.ready; + const transfers = JSON.parse( + ( + await execFile(binaryPath, ["transfers", "list", "--output", "json"], { + cwd: installDir, + encoding: "utf8", + env: { + ...process.env, + PUTIO_CLI_API_BASE_URL: mockApiBaseUrl, + PUTIO_CLI_CONFIG_PATH: configPath, + PUTIO_CLI_TOKEN: "packed-smoke-token", + }, + signal: commandController.signal, + timeout: commandTimeoutMs, + }) + ).stdout, + ) as TransfersList; assert(transfers.transfers.length === 0, "Expected the SDK-backed transfer list to be empty."); assert(transfers.cursor === null, "Expected the SDK-backed transfer list cursor to be null."); assert(transfers.total === 0, "Expected the SDK-backed transfer list total to be zero."); @@ -454,7 +512,26 @@ try { 2, )}\n`, ); +} catch (error) { + primaryError = error; } finally { - mockApiProcess?.kill(); - rmSync(installDir, { force: true, recursive: true }); + if (mockApiProcess !== undefined) { + try { + await stopMockApi(mockApiProcess); + } catch (error) { + cleanupError = error; + } + } + if (didCreateInstallDir) rmSync(installDir, { force: true, recursive: true }); + for (const [signal, handler] of interruptHandlers) process.off(signal, handler); +} + +if (interruptedBy !== undefined) { + if (cleanupError !== undefined) console.error(`Packed-install cleanup failed: ${cleanupError}`); + process.exitCode = interruptedBy === "SIGINT" ? 130 : 143; +} else if (primaryError !== undefined) { + if (cleanupError !== undefined) console.error(`Packed-install cleanup failed: ${cleanupError}`); + throw primaryError; +} else if (cleanupError !== undefined) { + throw cleanupError; }