225 lines
7.7 KiB
TypeScript
225 lines
7.7 KiB
TypeScript
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");
|