rea/src/application/RuntimeExecutableDiagnostic...

319 lines
9.1 KiB
TypeScript

import { execFile } from "node:child_process";
import { constants } from "node:fs";
import { access, realpath } from "node:fs/promises";
import { delimiter, join } from "node:path";
const TOOL_NAMES = ["node", "npm", "npx"] as const;
const MAX_OUTPUT_BYTES = 65_536;
const MAX_DIAGNOSTIC_BYTES = 4_096;
const PROBE_CONCURRENCY = 4;
type RuntimeToolName = (typeof TOOL_NAMES)[number];
type RuntimeProbeFailureCode =
| "runtime_dynamic_library_missing"
| "runtime_invalid_version_output"
| "runtime_timeout"
| "runtime_signal"
| "runtime_spawn_failed"
| "runtime_nonzero_exit";
/** Stable, bounded classification of one failed executable probe. */
interface RuntimeProbeFailure {
readonly code: RuntimeProbeFailureCode;
readonly exit_code: number | null;
readonly signal: NodeJS.Signals | null;
readonly dependency: string | null;
readonly stderr: string;
}
interface RuntimeExecutableIdentity {
readonly tool: RuntimeToolName;
readonly lexical_path: string;
readonly canonical_path: string;
readonly path_index: number | null;
readonly selection: "rea-launcher" | "path-primary" | "path-shadowed";
}
/** One lexical PATH candidate and its canonical executable probe result. */
type RuntimeExecutableDiagnostic = RuntimeExecutableIdentity &
(
| {
readonly version: string;
readonly healthy: true;
readonly failure: null;
}
| {
readonly version: null;
readonly healthy: false;
readonly failure: RuntimeProbeFailure;
}
);
/** Runtime identities observed under one exact effective environment. */
export interface RuntimeExecutableInventory {
readonly launcher_node: string;
readonly candidates: readonly RuntimeExecutableDiagnostic[];
}
/** Host inputs that determine executable discovery and shebang resolution. */
export interface RuntimeExecutableInventoryOptions {
readonly platform: NodeJS.Platform;
readonly path: string;
readonly launcherNode: string;
readonly pathExtensions?: readonly string[];
readonly timeoutMs?: number;
}
/** Inventory and bounded-probe Node toolchain candidates under one exact PATH. */
export const inspectRuntimeExecutables = async (
options: RuntimeExecutableInventoryOptions,
): Promise<RuntimeExecutableInventory> => {
const pathEntries = unique(options.path.split(delimiter).filter(Boolean));
const candidates: Array<{
readonly tool: RuntimeToolName;
readonly path: string;
readonly pathIndex: number | null;
readonly selection: RuntimeExecutableDiagnostic["selection"];
}> = [
{
tool: "node",
path: options.launcherNode,
pathIndex: null,
selection: "rea-launcher",
},
];
for (const tool of TOOL_NAMES) {
const discovered = await discoverToolCandidates(
tool,
pathEntries,
options.platform,
options.pathExtensions,
);
for (const [index, candidate] of discovered.entries())
candidates.push({
tool,
path: candidate.path,
pathIndex: candidate.pathIndex,
selection: index === 0 ? "path-primary" : "path-shadowed",
});
}
const seen = new Set<string>();
const uniqueCandidates: typeof candidates = [];
for (const candidate of candidates) {
const key = `${candidate.tool}\0${candidate.path}`;
if (seen.has(key)) continue;
seen.add(key);
uniqueCandidates.push(candidate);
}
const diagnostics = await mapConcurrentBounded(
uniqueCandidates,
PROBE_CONCURRENCY,
(candidate) =>
probeCandidate(candidate, options.timeoutMs ?? 5_000, options.path),
);
return {
launcher_node: await canonicalPath(options.launcherNode),
candidates: diagnostics,
};
};
const discoverToolCandidates = async (
tool: RuntimeToolName,
pathEntries: readonly string[],
platform: NodeJS.Platform,
configuredExtensions: readonly string[] | undefined,
): Promise<
readonly { readonly path: string; readonly pathIndex: number }[]
> => {
const extensions =
platform === "win32"
? (configuredExtensions ?? [".COM", ".EXE", ".BAT", ".CMD"])
: [""];
const candidates: Array<{
readonly path: string;
readonly pathIndex: number;
}> = [];
for (const [pathIndex, directory] of pathEntries.entries()) {
for (const extension of extensions) {
const path = join(directory, `${tool}${extension}`);
if (await isExecutable(path, platform)) {
candidates.push({ path, pathIndex });
break;
}
}
}
return candidates;
};
const probeCandidate = async (
candidate: {
readonly tool: RuntimeToolName;
readonly path: string;
readonly pathIndex: number | null;
readonly selection: RuntimeExecutableDiagnostic["selection"];
},
timeoutMs: number,
effectivePath: string,
): Promise<RuntimeExecutableDiagnostic> => {
const canonical = await canonicalPath(candidate.path);
const result = await executeVersion(candidate.path, timeoutMs, effectivePath);
const identity = {
tool: candidate.tool,
lexical_path: candidate.path,
canonical_path: canonical,
path_index: candidate.pathIndex,
selection: candidate.selection,
};
return result.ok
? { ...identity, version: result.version, healthy: true, failure: null }
: {
...identity,
version: null,
healthy: false,
failure: result.failure,
};
};
type VersionResult =
| { readonly ok: true; readonly version: string }
| { readonly ok: false; readonly failure: RuntimeProbeFailure };
const executeVersion = (
path: string,
timeoutMs: number,
effectivePath: string,
): Promise<VersionResult> =>
new Promise((resolve) => {
const child = execFile(
path,
["--version"],
{
encoding: "utf8",
timeout: timeoutMs,
maxBuffer: MAX_OUTPUT_BYTES,
windowsHide: true,
env: environmentWithPath(effectivePath),
},
(cause: Error | null, stdout: string, stderr: string) => {
if (cause === null) {
const version =
firstNonemptyLine(stdout) ?? firstNonemptyLine(stderr);
if (version !== undefined) resolve({ ok: true, version });
else
resolve({
ok: false,
failure: failure("runtime_invalid_version_output", child, stderr),
});
return;
}
resolve({
ok: false,
failure: classifyProbeFailure(cause, child, stderr),
});
},
);
});
const classifyProbeFailure = (
cause: Error,
child: ReturnType<typeof execFile>,
stderr: string,
): RuntimeProbeFailure => {
const dependency = missingDynamicLibrary(stderr);
if (dependency !== null)
return failure(
"runtime_dynamic_library_missing",
child,
stderr,
dependency,
);
if ("killed" in cause && cause.killed === true)
return failure("runtime_timeout", child, stderr);
if (child.signalCode !== null)
return failure("runtime_signal", child, stderr);
if ("code" in cause && typeof cause.code === "string")
return failure("runtime_spawn_failed", child, stderr);
return failure("runtime_nonzero_exit", child, stderr);
};
const failure = (
code: RuntimeProbeFailureCode,
child: ReturnType<typeof execFile>,
stderr: string,
dependency: string | null = null,
): RuntimeProbeFailure => ({
code,
exit_code: child.exitCode,
signal: child.signalCode,
dependency,
stderr: bounded(stderr.trim()),
});
const missingDynamicLibrary = (stderr: string): string | null =>
/(?:dyld(?:\[[^\]]+\])?: )?Library not loaded:\s*([^\r\n]+)/u
.exec(stderr)?.[1]
?.trim() ?? null;
const isExecutable = async (
path: string,
platform: NodeJS.Platform,
): Promise<boolean> => {
try {
await access(path, platform === "win32" ? constants.F_OK : constants.X_OK);
return true;
} catch {
return false;
}
};
const canonicalPath = async (path: string): Promise<string> => {
try {
return await realpath(path);
} catch {
return path;
}
};
const firstNonemptyLine = (value: string): string | undefined =>
value
.split(/\r?\n/u)
.find((line) => line.trim().length > 0)
?.trim();
const bounded = (value: string): string =>
value.length <= MAX_DIAGNOSTIC_BYTES
? value
: value.slice(-MAX_DIAGNOSTIC_BYTES);
const unique = (values: readonly string[]): string[] => [...new Set(values)];
const environmentWithPath = (path: string): NodeJS.ProcessEnv => {
const environment: NodeJS.ProcessEnv = {};
for (const [name, value] of Object.entries(process.env))
if (name.toUpperCase() !== "PATH") environment[name] = value;
environment.PATH = path;
return environment;
};
const mapConcurrentBounded = async <Input, Output>(
inputs: readonly Input[],
concurrency: number,
operation: (input: Input) => Promise<Output>,
): Promise<Output[]> => {
const outputs: Output[] = [];
let nextIndex = 0;
const workers = Array.from(
{ length: Math.min(concurrency, inputs.length) },
async () => {
while (nextIndex < inputs.length) {
const index = nextIndex;
nextIndex += 1;
const input = inputs[index];
if (input !== undefined) outputs[index] = await operation(input);
}
},
);
await Promise.all(workers);
return outputs;
};