mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(update): bound and own metadata probe lifetimes
This commit is contained in:
1 parent
bf876efbaa
commit
7404847f64
6 files changed
+462
-160
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
@@ -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("; ")}`;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
Reference in new issue
Block a user