fix(update): bound and own metadata probe lifetimes

This commit is contained in:
morluto committed 2026-10-26 14:49:10 +08:00
1 parent bf876efbaa
commit 7404847f64
6 files changed
+462 -160

No files matched your search

+4
View File
@@ -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.
+224
View File
@@ -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<NodeJS.ProcessEnv>,
output: UpdateOutput = "structured",
workingDirectory?: string,
): Promise<Result<string, string>> {
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<NodeJS.ProcessEnv>,
output: UpdateOutput,
workingDirectory?: string,
): Promise<Result<string, string>> {
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<void> {
this.#controller.abort(new Error("Update command runner closed."));
return this.#resources.close();
}
}
const runUpdateProcess = async (
command: readonly string[],
selectedEnvironment: Readonly<NodeJS.ProcessEnv>,
output: UpdateOutput,
workingDirectory: string | undefined,
retention: "complete" | "diagnostics",
resources: ArtifactResourceScope,
probe?: ProviderStartupDeadline,
): Promise<Result<string, string>> => {
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");
+30 -125
View File
@@ -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<boolean>;
}
/** 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<NodeJS.ProcessEnv>,
output: UpdateOutput = "structured",
workingDirectory: string | undefined = undefined,
): Promise<Result<string, string>> =>
runUpdateProcess(
command,
selectedEnvironment,
output,
workingDirectory,
"complete",
);
const runUpdateProcess = async (
command: readonly string[],
selectedEnvironment: Readonly<NodeJS.ProcessEnv>,
output: UpdateOutput,
workingDirectory: string | undefined,
retention: "complete" | "diagnostics",
): Promise<Result<string, string>> => {
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<NodeJS.ProcessEnv>,
): Promise<string> => {
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<NodeJS.ProcessEnv> = process.env,
): UpdateHost => {
const resources = new ArtifactResourceScope();
const commands = new UpdateCommandRunner();
const requireOutput = async (command: readonly string[]): Promise<string> => {
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("; ")}`;
},
};
};
+11 -3
View File
@@ -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();
});
+17 -9
View File
@@ -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();
});
+176 -23
View File
@@ -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<void>((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<void> =>
new Promise((resolve) => {
execution.spawn.mockImplementationOnce(
(
executable: string,
args: readonly string[],
options: Parameters<typeof spawn>[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<void>((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;
});