diff --git a/docs/installation.md b/docs/installation.md index 1f1cc286ee..a134a2a83d 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -495,6 +495,10 @@ Do not rename `index.cjs` to `index.mjs` or disable protection to make startup p ### Update the owning installation +Read-only metadata and version checks time out after 10 seconds. This does not +interrupt a package installation already in progress. If a probe cannot be +stopped, the updater reports a cleanup failure. + `rea update` installs the exact resolved release into the npm prefix or Bun global directory that owns the running package, then checks the new executable's version before reporting success. diff --git a/src/application/UpdateCommandRunner.ts b/src/application/UpdateCommandRunner.ts new file mode 100644 index 0000000000..bcaf7e77c0 --- /dev/null +++ b/src/application/UpdateCommandRunner.ts @@ -0,0 +1,224 @@ +import spawn from "cross-spawn"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { + ProviderProcessSupervisor, + type ProviderProcessSnapshot, +} from "../process/ProviderProcess.js"; +import { ProviderStartupDeadline } from "../process/ProviderDeadline.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { UpdateCommandProgress } from "./UpdateCommandProgress.js"; +import type { UpdateOutput } from "./Update.js"; +import { withoutPackageRunnerIdentity } from "./ClientRegistrationIdentity.js"; + +export const UPDATE_COMMAND_OUTPUT_BYTES = 4 * 1024 * 1024; +const UPDATE_PROBE_TIMEOUT_MS = 10_000; + +/** Own metadata subprocesses through cancellation, final collection and cleanup retry. */ +export class UpdateCommandRunner { + readonly #resources = new ArtifactResourceScope(); + readonly #controller = new AbortController(); + constructor(private readonly probeTimeoutMs = UPDATE_PROBE_TIMEOUT_MS) {} + + async runProbe( + command: readonly string[], + environment: Readonly, + output: UpdateOutput = "structured", + workingDirectory?: string, + ): Promise> { + if (this.#controller.signal.aborted) + return err("Update command runner is closed."); + const deadline = new ProviderStartupDeadline( + this.probeTimeoutMs, + this.#controller.signal, + ); + try { + return await this.#resources.run(() => + runUpdateProcess( + command, + environment, + output, + workingDirectory, + "complete", + this.#resources, + deadline, + ), + ); + } catch (cause: unknown) { + return err(cause instanceof Error ? cause.message : String(cause)); + } finally { + deadline.dispose(); + } + } + + async runInstall( + command: readonly string[], + environment: Readonly, + output: UpdateOutput, + workingDirectory?: string, + ): Promise> { + if (this.#controller.signal.aborted) + return err("Update command runner is closed."); + return this.#resources + .run(() => + runUpdateProcess( + command, + environment, + output, + workingDirectory, + "diagnostics", + this.#resources, + ), + ) + .catch((cause: unknown) => + err(cause instanceof Error ? cause.message : String(cause)), + ); + } + + close(): Promise { + this.#controller.abort(new Error("Update command runner closed.")); + return this.#resources.close(); + } +} + +const runUpdateProcess = async ( + command: readonly string[], + selectedEnvironment: Readonly, + output: UpdateOutput, + workingDirectory: string | undefined, + retention: "complete" | "diagnostics", + resources: ArtifactResourceScope, + probe?: ProviderStartupDeadline, +): Promise> => { + if (probe?.signal.aborted) + return err("Update metadata probe cancelled before launch."); + const [executable, ...args] = command; + if (executable === undefined) return err("No executable supplied."); + const environment = withoutPackageRunnerIdentity(selectedEnvironment); + const child = spawn(executable, args, { + stdio: ["ignore", "pipe", "pipe"], + env: environment, + windowsHide: true, + ...(workingDirectory === undefined ? {} : { cwd: workingDirectory }), + }); + let processFailure: string | undefined; + const supervisor = new ProviderProcessSupervisor( + { process: child, ownsProcessLifetime: true }, + { + maxDiagnosticBytes: UPDATE_COMMAND_OUTPUT_BYTES, + onDiagnostic: (event) => { + if (event.type === "error") processFailure ??= event.message; + }, + }, + ); + const progress = + output === "human" ? new UpdateCommandProgress(process.stderr) : undefined; + if (progress !== undefined) { + for (const stream of [child.stdout, child.stderr]) + if (stream !== null) progress.pipe(stream); + } + let retained = false; + try { + // Drain even an oversized reply. Verbosity must not interrupt a package install. + // Keep the existing launch/signal policy; this collector owns no process-tree authority. + const completed = await supervisor.waitForOutputClose( + undefined, + probe?.signal, + ); + let interrupted: string | undefined; + if (!completed) { + interrupted = + probe?.interruption === "timeout" + ? `Update metadata probe timed out after ${String(probe.timeoutMs)} ms.` + : "Update metadata probe cancelled."; + const stopped = await supervisor.stop(); + if (stopped.status === "incomplete") { + retained = true; + resources.retain({ + kind: "lease", + resource: executable, + lease: { + release: async () => { + const retried = await supervisor.stop(); + if (retried.status === "incomplete") + throw new Error(retried.reason); + }, + }, + }); + interrupted += ` Cleanup incomplete: ${stopped.reason}`; + } + } + const progressFailure = await progress?.close(); + const snapshot = supervisor.snapshot(); + if (processFailure !== undefined) + return err( + probeFailureDetail( + interrupted === undefined + ? processFailure + : `${processFailure} ${interrupted}`, + snapshot, + ), + ); + if (interrupted !== undefined) + return err(probeFailureDetail(interrupted, snapshot)); + if (snapshot.outputIncomplete === true) + return err("Update command output did not close completely."); + if (retention === "complete" && snapshot.diagnosticTruncated === true) + return err( + `Update command output exceeded the ${UPDATE_COMMAND_OUTPUT_BYTES}-byte complete-output retention budget; reply was not accepted.`, + ); + if (snapshot.exitCode !== 0 || snapshot.signal !== null) { + const detail = + snapshot.stderr.text.trim() || + snapshot.stdout.text.trim() || + (typeof snapshot.signal === "string" + ? `Process terminated by ${snapshot.signal}.` + : typeof snapshot.exitCode === "number" + ? `Process exited with code ${snapshot.exitCode}.` + : "Update command failed without a recorded exit status."); + return err( + snapshot.diagnosticTruncated + ? `${detail}\n[Update diagnostics truncated at ${UPDATE_COMMAND_OUTPUT_BYTES} bytes; later output omitted.]` + : detail, + ); + } + if ( + retention === "complete" && + (snapshot.stdout.invalidUtf8 || snapshot.stderr.invalidUtf8) + ) + return err( + "Update command returned invalid UTF-8; complete output is unavailable.", + ); + if (progressFailure !== undefined) + return err(`Update progress output failed: ${progressFailure.message}`); + if (probe?.interruption !== undefined) + return err( + probeFailureDetail( + probe.interruption === "timeout" + ? `Update metadata probe timed out after ${String(probe.timeoutMs)} ms.` + : "Update metadata probe cancelled.", + snapshot, + ), + ); + return ok(retention === "diagnostics" ? "" : snapshot.stdout.text.trim()); + } finally { + // Failed stop ownership stays on the runner for a later cleanup attempt. + if (!retained) supervisor.dispose(); + await progress?.close(); + } +}; + +const probeFailureDetail = ( + reason: string, + snapshot: ProviderProcessSnapshot, +): string => + [ + reason, + snapshot.stdout.text === "" ? "" : `stdout: ${snapshot.stdout.text}`, + snapshot.stderr.text === "" ? "" : `stderr: ${snapshot.stderr.text}`, + snapshot.diagnosticTruncated ? "[Update diagnostics truncated.]" : "", + snapshot.outputIncomplete + ? "[Update output did not close completely.]" + : "", + ] + .filter((part) => part !== "") + .join("\n"); diff --git a/src/application/UpdateRuntime.ts b/src/application/UpdateRuntime.ts index 9b54597ad7..bd317ff582 100644 --- a/src/application/UpdateRuntime.ts +++ b/src/application/UpdateRuntime.ts @@ -2,9 +2,6 @@ import { realpath, stat } from "node:fs/promises"; import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import spawn from "cross-spawn"; -import { ProviderProcessSupervisor } from "../process/ProviderProcess.js"; -import { UpdateCommandProgress } from "./UpdateCommandProgress.js"; import { lt, valid } from "semver"; import { PRODUCT_IDENTITY } from "../identity.js"; @@ -18,10 +15,9 @@ import { type ReleaseLookupFailure, type UpdateHost, type UpdateInstallation, - type UpdateOutput, } from "./Update.js"; import { planIntegrationMaintenance } from "./UpdateMaintenance.js"; -import { withoutPackageRunnerIdentity } from "./ClientRegistrationIdentity.js"; +import { UpdateCommandRunner } from "./UpdateCommandRunner.js"; /** npm filesystem queries used to identify the installation being updated. */ export interface NpmInstallationHost { @@ -36,110 +32,6 @@ export interface BunInstallationHost { isFile(path: string): Promise; } -/** Aggregate raw-byte retention policy for package-manager replies and diagnostics. */ -export const UPDATE_COMMAND_OUTPUT_BYTES = 4 * 1024 * 1024; - -/** Complete metadata queries share launch and collection with diagnostic-only installs. */ -export const runUpdateCommand = ( - command: readonly string[], - selectedEnvironment: Readonly, - output: UpdateOutput = "structured", - workingDirectory: string | undefined = undefined, -): Promise> => - runUpdateProcess( - command, - selectedEnvironment, - output, - workingDirectory, - "complete", - ); - -const runUpdateProcess = async ( - command: readonly string[], - selectedEnvironment: Readonly, - output: UpdateOutput, - workingDirectory: string | undefined, - retention: "complete" | "diagnostics", -): Promise> => { - const [executable, ...args] = command; - if (executable === undefined) return err("No executable supplied."); - const environment = withoutPackageRunnerIdentity(selectedEnvironment); - const child = spawn(executable, args, { - stdio: ["ignore", "pipe", "pipe"], - env: environment, - windowsHide: true, - ...(workingDirectory === undefined ? {} : { cwd: workingDirectory }), - }); - let processFailure: string | undefined; - const supervisor = new ProviderProcessSupervisor( - { process: child, ownsProcessLifetime: true }, - { - maxDiagnosticBytes: UPDATE_COMMAND_OUTPUT_BYTES, - onDiagnostic: (event) => { - if (event.type === "error") processFailure ??= event.message; - }, - }, - ); - const progress = - output === "human" ? new UpdateCommandProgress(process.stderr) : undefined; - if (progress !== undefined) { - for (const stream of [child.stdout, child.stderr]) - if (stream !== null) progress.pipe(stream); - } - try { - // Drain even an oversized reply. Verbosity must not interrupt a package install. - // Keep the existing launch/signal policy; this collector owns no process-tree authority. - await supervisor.waitForOutputClose(); - const progressFailure = await progress?.close(); - const snapshot = supervisor.snapshot(); - if (processFailure !== undefined) return err(processFailure); - if (snapshot.outputIncomplete === true) - return err("Update command output did not close completely."); - if (retention === "complete" && snapshot.diagnosticTruncated === true) - return err( - `Update command output exceeded the ${UPDATE_COMMAND_OUTPUT_BYTES}-byte complete-output retention budget; reply was not accepted.`, - ); - if (snapshot.exitCode !== 0 || snapshot.signal !== null) { - const detail = - snapshot.stderr.text.trim() || - snapshot.stdout.text.trim() || - (typeof snapshot.signal === "string" - ? `Process terminated by ${snapshot.signal}.` - : typeof snapshot.exitCode === "number" - ? `Process exited with code ${snapshot.exitCode}.` - : "Update command failed without a recorded exit status."); - return err( - snapshot.diagnosticTruncated - ? `${detail}\n[Update diagnostics truncated at ${UPDATE_COMMAND_OUTPUT_BYTES} bytes; later output omitted.]` - : detail, - ); - } - if ( - retention === "complete" && - (snapshot.stdout.invalidUtf8 || snapshot.stderr.invalidUtf8) - ) - return err( - "Update command returned invalid UTF-8; complete output is unavailable.", - ); - if (progressFailure !== undefined) - return err(`Update progress output failed: ${progressFailure.message}`); - return ok(retention === "diagnostics" ? "" : snapshot.stdout.text.trim()); - } finally { - // Reached only after physical close; no child or inherited pipe remains pending. - supervisor.dispose(); - await progress?.close(); - } -}; - -const requireCommandOutput = async ( - command: readonly string[], - environment: Readonly, -): Promise => { - const result = await runUpdateCommand(command, environment); - if (!result.ok) throw new Error(result.error); - return result.value; -}; - /** Resolve the global prefix only when it owns the running package. */ export const detectNpmInstallation = async ( packageRoot: string, @@ -343,6 +235,12 @@ export const systemUpdateHost = ( environment: Readonly = process.env, ): UpdateHost => { const resources = new ArtifactResourceScope(); + const commands = new UpdateCommandRunner(); + const requireOutput = async (command: readonly string[]): Promise => { + const result = await commands.runProbe(command, environment); + if (!result.ok) throw new Error(result.error); + return result.value; + }; return { installation: async () => (await detectBunInstallation( @@ -352,15 +250,13 @@ export const systemUpdateHost = ( )) ?? detectNpmInstallation(packageRoot, { canonicalPath: realpath, - globalRoot: () => - requireCommandOutput(["npm", "root", "--global"], environment), - globalPrefix: () => - requireCommandOutput(["npm", "prefix", "--global"], environment), + globalRoot: () => requireOutput(["npm", "root", "--global"]), + globalPrefix: () => requireOutput(["npm", "prefix", "--global"]), }), latestVersion: async (installation) => { const context = packageManagerContext(installation, environment); if (installation?.manager === "bun") { - const version = await runUpdateCommand( + const version = await commands.runProbe( ["bun", "--version"], context.environment, "structured", @@ -377,7 +273,7 @@ export const systemUpdateHost = ( }); } return npmReleaseLookup( - await runUpdateCommand( + await commands.runProbe( installation?.manager === "bun" ? BUN_LATEST_VERSION_COMMAND : npmLatestVersionCommand(installation), @@ -389,17 +285,16 @@ export const systemUpdateHost = ( }, installVersion: async (installation, version, output) => { const context = packageManagerContext(installation, environment); - const result = await runUpdateProcess( + const result = await commands.runInstall( updateInstallCommand(installation, version), context.environment, output, context.workingDirectory, - "diagnostics", ); return result.ok ? ok(undefined) : result; }, installedVersion: (installation) => - runUpdateCommand( + commands.runProbe( [ process.execPath, join(installation.packageRoot, "scripts", "rea.mjs"), @@ -412,16 +307,26 @@ export const systemUpdateHost = ( home ?? homeDirectoryFromEnvironment(environment, process.platform), join(installation.packageRoot, "scripts", "rea.mjs"), environment, - (command) => runUpdateCommand(command, environment), + (command) => commands.runProbe(command, environment), resources, ), close: async () => { - try { - await resources.close(); - return undefined; - } catch (cause: unknown) { - return `Update resource cleanup failed: ${cause instanceof Error ? cause.message : String(cause)}`; - } + const results = await Promise.allSettled([ + commands.close(), + resources.close(), + ]); + const failures = results.flatMap((result) => + result.status === "rejected" + ? [ + result.reason instanceof Error + ? result.reason.message + : String(result.reason), + ] + : [], + ); + return failures.length === 0 + ? undefined + : `Update resource cleanup failed: ${failures.join("; ")}`; }, }; }; diff --git a/src/setupEnvironment.test.ts b/src/setupEnvironment.test.ts index 93e9917dca..0f22cafabe 100644 --- a/src/setupEnvironment.test.ts +++ b/src/setupEnvironment.test.ts @@ -1,8 +1,9 @@ +import { UpdateCommandRunner } from "./application/UpdateCommandRunner.js"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { createDoctorHostFixture } from "./application/Doctor.fixture.js"; import * as macHopperInstaller from "./application/MacHopper.js"; @@ -11,7 +12,6 @@ import { discoverSetupState } from "./application/SetupPlan.js"; import { options } from "./application/Setup.fixture.js"; import { systemSetupHost } from "./application/SetupHost.js"; import { systemUninstallHost } from "./application/Uninstall.js"; -import { runUpdateCommand } from "./application/UpdateRuntime.js"; import { createSystemDoctorHost } from "./doctorRuntime.js"; import { PRODUCT_IDENTITY } from "./identity.js"; import { @@ -287,7 +287,7 @@ it.each([ it("passes only selected environment to updater subprocesses and drops npm invocation identity", async () => { vi.stubEnv("REA_UPDATE_MARKER", "ambient"); - const result = await runUpdateCommand( + const result = await updateCommands.runProbe( [ process.execPath, "-e", @@ -398,3 +398,11 @@ it("registers the selected Windows package runner after canonicalizing environme await host.close?.(); } }); + +let updateCommands: UpdateCommandRunner; +beforeEach(() => { + updateCommands = new UpdateCommandRunner(); +}); +afterEach(async () => { + await updateCommands.close(); +}); diff --git a/tests/process-global/updateMaintenance.test.ts b/tests/process-global/updateMaintenance.test.ts index f387c1f6c7..1cd69b385a 100644 --- a/tests/process-global/updateMaintenance.test.ts +++ b/tests/process-global/updateMaintenance.test.ts @@ -1,3 +1,7 @@ +import { + UpdateCommandRunner, + UPDATE_COMMAND_OUTPUT_BYTES, +} from "../../src/application/UpdateCommandRunner.js"; import { mkdir, readFile, rm, writeFile, access } from "node:fs/promises"; import { delimiter, dirname, join, relative, resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -11,11 +15,7 @@ import { existingMaintenanceScope, planIntegrationMaintenance, } from "../../src/application/UpdateMaintenance.js"; -import { - runUpdateCommand, - UPDATE_COMMAND_OUTPUT_BYTES, - systemUpdateHost, -} from "../../src/application/UpdateRuntime.js"; +import { systemUpdateHost } from "../../src/application/UpdateRuntime.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; import { cliTest } from "../support/cli/cliFixture.js"; @@ -528,7 +528,7 @@ describe("updated executable maintenance planning", () => { home, entryPoint, process.env, - (command) => runUpdateCommand(command, process.env), + (command) => updateCommands.runProbe(command, process.env), ); expect(result).toMatchObject({ status: "planned", @@ -586,7 +586,7 @@ describe("update subprocess boundaries", () => { }); it("captures output through stream closure and keeps arguments literal", async () => { const literal = 'space and $(echo bad); & | "quotes"'; - const result = await runUpdateCommand( + const result = await updateCommands.runProbe( [ process.execPath, "-e", @@ -600,7 +600,7 @@ describe("update subprocess boundaries", () => { it("reports actual stderr and launch failures", async () => { await expect( - runUpdateCommand( + updateCommands.runProbe( [ process.execPath, "-e", @@ -609,7 +609,7 @@ describe("update subprocess boundaries", () => { process.env, ), ).resolves.toEqual(err("EACCES /test/install")); - const missing = await runUpdateCommand( + const missing = await updateCommands.runProbe( [join(home, "missing-executable")], process.env, ); @@ -814,3 +814,11 @@ describe("Bun global installation update", () => { }, ); }); + +let updateCommands: UpdateCommandRunner; +beforeEach(() => { + updateCommands = new UpdateCommandRunner(); +}); +afterEach(async () => { + await updateCommands.close(); +}); diff --git a/tests/process-global/updateOutput.test.ts b/tests/process-global/updateOutput.test.ts index 9b004168a0..699e9ea5d9 100644 --- a/tests/process-global/updateOutput.test.ts +++ b/tests/process-global/updateOutput.test.ts @@ -1,11 +1,13 @@ +import { ProviderProcessSupervisor } from "../../src/process/ProviderProcess.js"; +import { systemUpdateHost } from "../../src/application/UpdateRuntime.js"; +import { + UpdateCommandRunner, + UPDATE_COMMAND_OUTPUT_BYTES, +} from "../../src/application/UpdateCommandRunner.js"; import { spawn, type ChildProcess } from "node:child_process"; import { setImmediate as nextTurn } from "node:timers/promises"; import { Writable } from "node:stream"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; -import { - runUpdateCommand, - UPDATE_COMMAND_OUTPUT_BYTES, -} from "../../src/application/UpdateRuntime.js"; const execution = vi.hoisted(() => ({ spawn: vi.fn() })); vi.mock("cross-spawn", () => ({ default: execution.spawn })); @@ -45,7 +47,7 @@ const command = (script: string) => [process.execPath, "-e", script]; it.each(["stdout", "stderr"])( "rejects incomplete %s metadata after draining the command", async (stream) => { - const result = await runUpdateCommand( + const result = await updateCommands.runProbe( command( `process.stdout.write('"6.5.0"');process.${stream}.write(' '.repeat(${UPDATE_COMMAND_OUTPUT_BYTES}))`, ), @@ -60,7 +62,7 @@ it.each(["stdout", "stderr"])( ); it("accepts exactly the combined byte budget and preserves split UTF-8", async () => { - const result = await runUpdateCommand( + const result = await updateCommands.runProbe( command( `process.stdout.write('x'.repeat(${UPDATE_COMMAND_OUTPUT_BYTES - 3}));const b=Buffer.from('界');process.stdout.write(b.subarray(0,1));setTimeout(()=>process.stdout.write(b.subarray(1)),10)`, ), @@ -75,7 +77,7 @@ it("accepts exactly the combined byte budget and preserves split UTF-8", async ( it("refuses malformed UTF-8 instead of interpreting replacement text", async () => { expect( - await runUpdateCommand( + await updateCommands.runProbe( command("process.stdout.write(Buffer.from([255]))"), process.env, ), @@ -87,19 +89,22 @@ it("refuses malformed UTF-8 instead of interpreting replacement text", async () it("waits for physical close after an early process error", async () => { let settled = false; - const pending = runUpdateCommand( - command("setTimeout(()=>process.stdout.write('late output'),50)"), - process.env, - ).then((result) => { - settled = true; - return result; - }); + const pending = updateCommands + .runProbe( + command("setTimeout(()=>process.stdout.write('late output'),50)"), + process.env, + ) + .then((result) => { + settled = true; + return result; + }); + await nextTurn(); child!.emit("error", new Error("controlled process error")); await Promise.resolve(); expect(settled).toBe(false); expect(await pending).toEqual({ ok: false, - error: "controlled process error", + error: expect.stringContaining("controlled process error"), }); expect(child?.exitCode).toBe(0); }); @@ -120,14 +125,17 @@ it("joins the final human progress write before reporting success", async () => destination as typeof process.stderr, ); let settled = false; - const pending = runUpdateCommand( - command("process.stdout.write('final progress')"), - process.env, - "human", - ).then((result) => { - settled = true; - return result; - }); + const pending = updateCommands + .runProbe( + command("process.stdout.write('final progress')"), + process.env, + "human", + ) + .then((result) => { + settled = true; + return result; + }); + await nextTurn(); const closed = new Promise((resolve) => { child!.once("close", resolve); }); @@ -143,3 +151,148 @@ it("joins the final human progress write before reporting success", async () => await nextTurn(); expect(destination.listenerCount("error")).toBe(0); }); + +let updateCommands: UpdateCommandRunner; +beforeEach(() => { + updateCommands = new UpdateCommandRunner(); +}); +afterEach(async () => { + await updateCommands.close(); +}); + +/** Observe readiness at launch, before the fixture can emit its first byte. */ +const nextChildReady = (script?: string): Promise => + new Promise((resolve) => { + execution.spawn.mockImplementationOnce( + ( + executable: string, + args: readonly string[], + options: Parameters[2], + ) => { + child = spawn( + script === undefined ? executable : process.execPath, + script === undefined ? [...args] : ["-e", script], + options, + ); + child.stdout!.once("data", () => resolve()); + return child; + }, + ); + }); +const hangingProbe = + "process.stdout.write('probe-ready');setInterval(()=>{},1000)"; + +it("stops and joins metadata discovery when the update host closes", async () => { + const ready = nextChildReady(hangingProbe); + const host = systemUpdateHost(process.cwd(), process.cwd(), process.env); + const probing = host.installation(); + await ready; + expect(child?.exitCode).toBeNull(); + expect(await host.close?.()).toBeUndefined(); + await probing; + expect(child?.signalCode).not.toBeNull(); +}); + +it("bounds a stalled metadata probe and keeps the runner usable", async () => { + await updateCommands.close(); + updateCommands = new UpdateCommandRunner(2000); + const ready = nextChildReady(); + const pending = updateCommands.runProbe(command(hangingProbe), process.env); + await ready; + const result = await pending; + expect(result).toMatchObject({ + ok: false, + error: expect.stringContaining("timed out"), + }); + if (!result.ok) expect(result.error).toContain("probe-ready"); + expect(child?.signalCode).not.toBeNull(); + expect( + await updateCommands.runProbe( + command("console.log('recovered')"), + process.env, + ), + ).toEqual({ ok: true, value: "recovered" }); +}); + +it("does not launch probes after close, including a queued launch", async () => { + const queued = updateCommands.runProbe(command(hangingProbe), process.env); + await updateCommands.close(); + expect(await queued).toMatchObject({ ok: false }); + expect( + await updateCommands.runProbe(command(hangingProbe), process.env), + ).toMatchObject({ ok: false }); + expect(execution.spawn).not.toHaveBeenCalled(); +}); + +it("retains incomplete probe cleanup until a later close succeeds", async () => { + const ready = nextChildReady(); + const pending = updateCommands.runProbe(command(hangingProbe), process.env); + await ready; + const stop = vi + .spyOn(ProviderProcessSupervisor.prototype, "stop") + .mockResolvedValue({ + status: "incomplete", + reason: "controlled stop refusal", + }); + try { + await expect(updateCommands.close()).rejects.toThrow( + /cleanup remains incomplete/u, + ); + expect(await pending).toMatchObject({ + ok: false, + error: expect.stringContaining("Cleanup incomplete"), + }); + expect(child?.signalCode).toBeNull(); + } finally { + stop.mockRestore(); + } + await expect(updateCommands.close()).resolves.toBeUndefined(); + expect(child?.signalCode).not.toBeNull(); +}); + +it("joins an install past the metadata deadline without cancelling it", async () => { + await updateCommands.close(); + updateCommands = new UpdateCommandRunner(30); + const ready = nextChildReady(); + const pending = updateCommands.runInstall( + command( + "process.stdout.write('install-ready');setTimeout(()=>console.log('done'),100)", + ), + process.env, + "structured", + ); + await ready; + await updateCommands.close(); + expect(await pending).toEqual({ ok: true, value: "" }); + expect(child?.exitCode).toBe(0); +}); + +it("preserves cancellation during final progress acknowledgement", async () => { + let acknowledge!: () => void; + let writing!: () => void; + const started = new Promise((resolve) => { + writing = resolve; + }); + const destination = new Writable({ + write(_chunk, _encoding, callback) { + acknowledge = callback; + writing(); + }, + }); + vi.spyOn(process, "stderr", "get").mockReturnValue( + destination as typeof process.stderr, + ); + const pending = updateCommands.runProbe( + command("process.stdout.write('completed metadata')"), + process.env, + "human", + ); + await started; + const closing = updateCommands.close(); + acknowledge(); + expect(await pending).toMatchObject({ + ok: false, + error: expect.stringContaining("cancelled"), + }); + await closing; +});