mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
fix(native): share command supervision and retain failure output
This commit is contained in:
@@ -12,6 +12,12 @@ input file and include the observed lipo slice offset for universal binaries.
|
||||
If that offset is unavailable, segment evidence locations are omitted with an
|
||||
explicit limitation; architecture inventory locations remain available.
|
||||
|
||||
Native Xcode command execution uses shared process supervision with a 60-second
|
||||
deadline and a 64 MiB aggregate stdout/stderr budget. Timeout, cancellation,
|
||||
stream failure, and output exhaustion retain the captured output, exit details,
|
||||
and cleanup outcome in the error result. Truncated output is never parsed as a
|
||||
complete observation.
|
||||
|
||||
- `trace_dylib_resolution` / `rea trace-dylib-resolution <app-or-mach-o>`
|
||||
parses Mach-O load commands in TypeScript and follows dyld's path expansion
|
||||
for every executable in an app bundle, or for one Mach-O within its directory.
|
||||
|
||||
@@ -37,6 +37,10 @@ export interface AnalysisCapturedOutput {
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
readonly truncated: boolean;
|
||||
readonly stdout_bytes?: number;
|
||||
readonly stderr_bytes?: number;
|
||||
readonly exit_code?: number | null;
|
||||
readonly signal?: string | null;
|
||||
}
|
||||
|
||||
/** Cleanup uncertainty attached to the original typed analysis failure. */
|
||||
|
||||
+170
-78
@@ -1,9 +1,18 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
OwnedCommandFailure,
|
||||
runOwnedCommand,
|
||||
} from "../process/OwnedCommand.js";
|
||||
import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js";
|
||||
|
||||
/** Maximum wall-clock duration for one native command. */
|
||||
export const NATIVE_COMMAND_TIMEOUT_MS = 60_000;
|
||||
/** Retain complete command output up to a bounded aggregate size. */
|
||||
export const NATIVE_COMMAND_OUTPUT_BUDGET_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
export interface NativeCommandCapture {
|
||||
readonly tool: string;
|
||||
@@ -20,17 +29,38 @@ export interface NativeCommandCapture {
|
||||
readonly signal: string | null;
|
||||
}
|
||||
|
||||
/** Native command failure with retained partial output and cleanup status. */
|
||||
export class NativeCommandFailure extends Error {
|
||||
constructor(
|
||||
readonly tool: string,
|
||||
readonly reason: "unavailable" | "cancelled" | "nonzero-exit" | "io",
|
||||
readonly reason:
|
||||
| "unavailable"
|
||||
| "cancelled"
|
||||
| "timeout"
|
||||
| "output-limit"
|
||||
| "nonzero-exit"
|
||||
| "io",
|
||||
readonly exitCode: number | null = null,
|
||||
options?: ErrorOptions,
|
||||
readonly capture: NativeCommandFailureCapture | null = null,
|
||||
readonly cleanupFailure: string | null = null,
|
||||
readonly cleanupResources: readonly string[] = [],
|
||||
) {
|
||||
super(`Native command ${tool} failed: ${reason}`, options);
|
||||
}
|
||||
}
|
||||
|
||||
/** Partial output and lifecycle details retained when a native command fails. */
|
||||
export interface NativeCommandFailureCapture {
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
readonly stdoutBytes: number;
|
||||
readonly stderrBytes: number;
|
||||
readonly exitCode: number | null;
|
||||
readonly signal: string | null;
|
||||
readonly truncated: boolean;
|
||||
}
|
||||
|
||||
/** Cancellation and exit handling for one allowlisted native command. */
|
||||
export interface NativeCommandOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
@@ -136,20 +166,66 @@ export const resolveXcrunTool = async (
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
if (!found.ok) {
|
||||
if (found.error.reason === "cancelled")
|
||||
return err(new NativeCommandFailure(tool, "cancelled"));
|
||||
return err(new NativeCommandFailure(tool, "unavailable"));
|
||||
return err(
|
||||
new NativeCommandFailure(
|
||||
tool,
|
||||
found.error.reason === "nonzero-exit" ||
|
||||
found.error.reason === "unavailable"
|
||||
? "unavailable"
|
||||
: found.error.reason,
|
||||
found.error.exitCode,
|
||||
{ cause: found.error },
|
||||
found.error.capture,
|
||||
found.error.cleanupFailure,
|
||||
found.error.cleanupResources,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (found.value.exitCode !== 0)
|
||||
return err(new NativeCommandFailure(tool, "unavailable"));
|
||||
return err(
|
||||
new NativeCommandFailure(
|
||||
tool,
|
||||
"unavailable",
|
||||
found.value.exitCode,
|
||||
undefined,
|
||||
failureCapture(found.value, false),
|
||||
),
|
||||
);
|
||||
const candidate = found.value.stdout.trim();
|
||||
if (!candidate.startsWith("/"))
|
||||
return err(new NativeCommandFailure(tool, "unavailable"));
|
||||
return err(
|
||||
new NativeCommandFailure(
|
||||
tool,
|
||||
"unavailable",
|
||||
found.value.exitCode,
|
||||
undefined,
|
||||
failureCapture(found.value, false),
|
||||
),
|
||||
);
|
||||
try {
|
||||
const path = await realpath(candidate);
|
||||
return ok({ path, sha256: await hashFile(path) });
|
||||
return ok({ path, sha256: await hashFile(path, signal) });
|
||||
} catch (cause: unknown) {
|
||||
return err(new NativeCommandFailure(tool, "io", null, { cause }));
|
||||
const capture = failureCapture(found.value, false);
|
||||
if (signal?.aborted === true)
|
||||
return err(
|
||||
new NativeCommandFailure(
|
||||
tool,
|
||||
"cancelled",
|
||||
capture.exitCode,
|
||||
{ cause },
|
||||
capture,
|
||||
),
|
||||
);
|
||||
return err(
|
||||
new NativeCommandFailure(
|
||||
tool,
|
||||
"io",
|
||||
capture.exitCode,
|
||||
{ cause },
|
||||
capture,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -163,7 +239,20 @@ type ProcessCapture = Omit<
|
||||
| "arguments"
|
||||
>;
|
||||
|
||||
const captureProcess = (
|
||||
const failureCapture = (
|
||||
capture: ProcessCapture,
|
||||
truncated: boolean,
|
||||
): NativeCommandFailureCapture => ({
|
||||
stdout: capture.stdout,
|
||||
stderr: capture.stderr,
|
||||
stdoutBytes: capture.stdoutBytes,
|
||||
stderrBytes: capture.stderrBytes,
|
||||
exitCode: capture.exitCode,
|
||||
signal: capture.signal,
|
||||
truncated,
|
||||
});
|
||||
|
||||
const captureProcess = async (
|
||||
executable: string,
|
||||
arguments_: readonly string[],
|
||||
tool: string,
|
||||
@@ -171,80 +260,83 @@ const captureProcess = (
|
||||
readonly signal?: AbortSignal;
|
||||
readonly acceptNonZero?: boolean;
|
||||
},
|
||||
): Promise<Result<ProcessCapture, NativeCommandFailure>> =>
|
||||
new Promise((resolve) => {
|
||||
if (options.signal?.aborted === true) {
|
||||
resolve(err(new NativeCommandFailure(tool, "cancelled")));
|
||||
return;
|
||||
}
|
||||
const runToken = randomUUID();
|
||||
const child = spawn(executable, [...arguments_], {
|
||||
shell: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: {
|
||||
PATH: "/usr/bin:/bin:/usr/sbin:/sbin",
|
||||
LC_ALL: "C",
|
||||
LANG: "C",
|
||||
REA_NATIVE_RUN_TOKEN: runToken,
|
||||
): Promise<Result<ProcessCapture, NativeCommandFailure>> => {
|
||||
const runId = `rea-native-command-${randomUUID()}`;
|
||||
try {
|
||||
const snapshot = await runOwnedCommand(
|
||||
{
|
||||
command: executable,
|
||||
arguments: [...arguments_],
|
||||
runId,
|
||||
env: {
|
||||
PATH: "/usr/bin:/bin:/usr/sbin:/sbin",
|
||||
LC_ALL: "C",
|
||||
LANG: "C",
|
||||
},
|
||||
hostEnvironment: {},
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
},
|
||||
{
|
||||
timeoutMs: NATIVE_COMMAND_TIMEOUT_MS,
|
||||
diagnosticBytes: NATIVE_COMMAND_OUTPUT_BUDGET_BYTES,
|
||||
acceptNonZeroExit: options.acceptNonZero === true,
|
||||
},
|
||||
});
|
||||
const stdout: Buffer[] = [];
|
||||
const stderr: Buffer[] = [];
|
||||
let stdoutBytes = 0;
|
||||
let stderrBytes = 0;
|
||||
let settled = false;
|
||||
let reason: NativeCommandFailure["reason"] | undefined;
|
||||
const finish = (
|
||||
result: Result<ProcessCapture, NativeCommandFailure>,
|
||||
): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
options.signal?.removeEventListener("abort", onAbort);
|
||||
resolve(result);
|
||||
};
|
||||
const stop = (failure: NativeCommandFailure["reason"]): void => {
|
||||
reason ??= failure;
|
||||
child.kill("SIGKILL");
|
||||
};
|
||||
const onAbort = (): void => stop("cancelled");
|
||||
options.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
stdoutBytes += chunk.length;
|
||||
stdout.push(chunk);
|
||||
});
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
stderrBytes += chunk.length;
|
||||
stderr.push(chunk);
|
||||
});
|
||||
child.once("error", (cause: unknown) =>
|
||||
finish(err(new NativeCommandFailure(tool, "io", null, { cause }))),
|
||||
);
|
||||
child.once("close", (code, signal) => {
|
||||
if (reason !== undefined) {
|
||||
finish(err(new NativeCommandFailure(tool, reason, code)));
|
||||
return;
|
||||
}
|
||||
if (signal !== null || (code !== 0 && options.acceptNonZero !== true)) {
|
||||
finish(err(new NativeCommandFailure(tool, "nonzero-exit", code)));
|
||||
return;
|
||||
}
|
||||
finish(
|
||||
ok({
|
||||
stdout: Buffer.concat(stdout).toString("utf8"),
|
||||
stderr: Buffer.concat(stderr).toString("utf8"),
|
||||
stdoutBytes,
|
||||
stderrBytes,
|
||||
exitCode: code,
|
||||
signal,
|
||||
}),
|
||||
return ok(processCaptureFromSnapshot(snapshot));
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof OwnedCommandFailure) {
|
||||
const capture =
|
||||
cause.snapshot === null
|
||||
? null
|
||||
: failureCapture(
|
||||
processCaptureFromSnapshot(cause.snapshot),
|
||||
cause.snapshot.diagnosticTruncated === true,
|
||||
);
|
||||
return err(
|
||||
new NativeCommandFailure(
|
||||
tool,
|
||||
cause.reason === "process"
|
||||
? processFailureReason(capture)
|
||||
: cause.reason,
|
||||
capture?.exitCode ?? null,
|
||||
{ cause },
|
||||
capture,
|
||||
cause.cleanupFailure,
|
||||
cause.resources,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
return err(new NativeCommandFailure(tool, "io", null, { cause }));
|
||||
}
|
||||
};
|
||||
|
||||
const hashFile = (path: string): Promise<string> =>
|
||||
const processFailureReason = (
|
||||
capture: NativeCommandFailureCapture | null,
|
||||
): "io" | "nonzero-exit" =>
|
||||
capture !== null &&
|
||||
(capture.signal !== null ||
|
||||
(capture.exitCode !== null && capture.exitCode !== 0))
|
||||
? "nonzero-exit"
|
||||
: "io";
|
||||
|
||||
const processCaptureFromSnapshot = (
|
||||
snapshot: ProviderProcessSnapshot,
|
||||
): ProcessCapture => ({
|
||||
stdout: snapshot.stdout.text,
|
||||
stderr: snapshot.stderr.text,
|
||||
stdoutBytes: snapshot.stdout.bytes,
|
||||
stderrBytes: snapshot.stderr.bytes,
|
||||
exitCode: snapshot.exitCode ?? null,
|
||||
signal: snapshot.signal ?? null,
|
||||
});
|
||||
|
||||
const hashFile = (path: string, signal?: AbortSignal): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const hash = createHash("sha256");
|
||||
const stream = createReadStream(path);
|
||||
const stream = createReadStream(
|
||||
path,
|
||||
signal === undefined ? undefined : { signal },
|
||||
);
|
||||
stream.on("data", (chunk) => hash.update(chunk));
|
||||
stream.once("error", reject);
|
||||
stream.once("end", () => resolve(hash.digest("hex")));
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
AnalysisResourceConstraintError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { BinaryTargetError } from "../domain/configurationErrors.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
@@ -49,6 +51,8 @@ import {
|
||||
} from "../domain/native/nativeInspection.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
NATIVE_COMMAND_TIMEOUT_MS,
|
||||
NATIVE_COMMAND_OUTPUT_BUDGET_BYTES,
|
||||
NativeCommandFailure,
|
||||
XcrunCommandRunner,
|
||||
type NativeCommandCapture,
|
||||
@@ -641,15 +645,64 @@ const translateCommandFailure = (
|
||||
operation: NativeToolName,
|
||||
failure: NativeCommandFailure,
|
||||
): AnalysisError => {
|
||||
const capturedOutput =
|
||||
failure.capture === null
|
||||
? undefined
|
||||
: {
|
||||
stdout: failure.capture.stdout,
|
||||
stderr: failure.capture.stderr,
|
||||
truncated: failure.capture.truncated,
|
||||
stdout_bytes: failure.capture.stdoutBytes,
|
||||
stderr_bytes: failure.capture.stderrBytes,
|
||||
exit_code: failure.capture.exitCode,
|
||||
signal: failure.capture.signal,
|
||||
};
|
||||
const cleanup =
|
||||
failure.cleanupFailure === null
|
||||
? undefined
|
||||
: {
|
||||
reason: failure.cleanupFailure,
|
||||
resources: [...failure.cleanupResources],
|
||||
};
|
||||
const errorOptions = {
|
||||
...(capturedOutput === undefined ? {} : { capturedOutput }),
|
||||
...(cleanup === undefined ? {} : { cleanup }),
|
||||
};
|
||||
if (failure.reason === "unavailable")
|
||||
return new AnalysisCapabilityUnavailableError(
|
||||
IDENTITY.id,
|
||||
operation,
|
||||
`${failure.tool} is unavailable through xcrun.`,
|
||||
errorOptions,
|
||||
);
|
||||
if (failure.reason === "cancelled")
|
||||
return new AnalysisCancelledError(operation);
|
||||
return new ProviderAdapterError(IDENTITY.id, operation, { cause: failure });
|
||||
return new AnalysisCancelledError(operation, errorOptions);
|
||||
if (failure.reason === "timeout")
|
||||
return new AnalysisTimeoutError(
|
||||
operation,
|
||||
NATIVE_COMMAND_TIMEOUT_MS,
|
||||
errorOptions,
|
||||
);
|
||||
if (failure.reason === "output-limit")
|
||||
return new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
"memory",
|
||||
`Native command output exceeded ${String(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES)} bytes; complete output is required for analysis.`,
|
||||
{ max_output_bytes: NATIVE_COMMAND_OUTPUT_BUDGET_BYTES },
|
||||
errorOptions,
|
||||
);
|
||||
return new ProviderAdapterError(IDENTITY.id, operation, {
|
||||
cause: failure,
|
||||
...errorOptions,
|
||||
diagnostics: {
|
||||
tool: failure.tool,
|
||||
reason: failure.reason,
|
||||
exit_code: failure.capture?.exitCode ?? failure.exitCode,
|
||||
signal: failure.capture?.signal ?? null,
|
||||
stdout_bytes: failure.capture?.stdoutBytes ?? 0,
|
||||
stderr_bytes: failure.capture?.stderrBytes ?? 0,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const translateCodeSignExitFailure = (
|
||||
@@ -657,7 +710,21 @@ const translateCodeSignExitFailure = (
|
||||
): AnalysisError =>
|
||||
translateCommandFailure(
|
||||
"inspect_signature",
|
||||
new NativeCommandFailure("codesign", "nonzero-exit", capture.exitCode),
|
||||
new NativeCommandFailure(
|
||||
"codesign",
|
||||
"nonzero-exit",
|
||||
capture.exitCode,
|
||||
undefined,
|
||||
{
|
||||
stdout: capture.stdout,
|
||||
stderr: capture.stderr,
|
||||
stdoutBytes: capture.stdoutBytes,
|
||||
stderrBytes: capture.stderrBytes,
|
||||
exitCode: capture.exitCode,
|
||||
signal: capture.signal,
|
||||
truncated: false,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const commandOutput = (capture: NativeCommandCapture): string =>
|
||||
|
||||
@@ -30,7 +30,12 @@ export class OwnedCommandFailure extends Error {
|
||||
/** Execute a single owned process with independent cleanup and bounded diagnostic retention. */
|
||||
export const runOwnedCommand = async (
|
||||
spawn: OwnedProviderProcessSpawnOptions,
|
||||
limits: { readonly timeoutMs: number; readonly diagnosticBytes: number },
|
||||
limits: {
|
||||
readonly timeoutMs: number;
|
||||
readonly diagnosticBytes: number;
|
||||
/** Accept an ordinary nonzero exit after output closes without other process errors. */
|
||||
readonly acceptNonZeroExit?: boolean;
|
||||
},
|
||||
options: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly launcher?: (
|
||||
@@ -60,7 +65,11 @@ export const runOwnedCommand = async (
|
||||
|
||||
const collectOwnedCommand = async (
|
||||
spawn: OwnedProviderProcessSpawnOptions,
|
||||
limits: { readonly diagnosticBytes: number },
|
||||
limits: {
|
||||
readonly diagnosticBytes: number;
|
||||
/** Accept an ordinary nonzero exit after output closes without other process errors. */
|
||||
readonly acceptNonZeroExit?: boolean;
|
||||
},
|
||||
launcher: (
|
||||
input: OwnedProviderProcessSpawnOptions,
|
||||
) => Promise<SpawnedOwnedProviderProcess>,
|
||||
@@ -143,10 +152,16 @@ const collectOwnedCommand = async (
|
||||
"Command diagnostic output exceeded its complete-output budget.",
|
||||
snapshot,
|
||||
);
|
||||
const acceptedNonzeroExit =
|
||||
limits.acceptNonZeroExit === true &&
|
||||
snapshot.exitCode !== null &&
|
||||
snapshot.exitCode !== undefined &&
|
||||
snapshot.exitCode !== 0 &&
|
||||
snapshot.signal === null;
|
||||
if (
|
||||
processFailure !== undefined ||
|
||||
snapshot.exitCode !== 0 ||
|
||||
snapshot.signal !== null
|
||||
((snapshot.exitCode !== 0 || snapshot.signal !== null) &&
|
||||
!acceptedNonzeroExit)
|
||||
)
|
||||
throw new OwnedCommandFailure(
|
||||
"process",
|
||||
|
||||
@@ -223,6 +223,12 @@ export class ProviderProcessSupervisor {
|
||||
readonly #onStderr = (chunk: Buffer | string): void => {
|
||||
this.#capture("stderr", this.#stderr, chunk);
|
||||
};
|
||||
readonly #onStdoutError = (cause: Error): void => {
|
||||
this.#recordStreamError("stdout", cause);
|
||||
};
|
||||
readonly #onStderrError = (cause: Error): void => {
|
||||
this.#recordStreamError("stderr", cause);
|
||||
};
|
||||
readonly #onExit = (
|
||||
code: number | null,
|
||||
signal: NodeJS.Signals | null,
|
||||
@@ -315,7 +321,9 @@ export class ProviderProcessSupervisor {
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
this.launch.process.stdout?.off("data", this.#onStdout);
|
||||
this.launch.process.stdout?.off("error", this.#onStdoutError);
|
||||
this.launch.process.stderr?.off("data", this.#onStderr);
|
||||
this.launch.process.stderr?.off("error", this.#onStderrError);
|
||||
this.launch.process.off("exit", this.#onExit);
|
||||
this.launch.process.off("close", this.#onClose);
|
||||
this.launch.process.off("error", this.#onError);
|
||||
@@ -404,8 +412,20 @@ export class ProviderProcessSupervisor {
|
||||
}
|
||||
|
||||
#attach(stream: Readable | null, name: "stdout" | "stderr"): void {
|
||||
if (name === "stdout") stream?.on("data", this.#onStdout);
|
||||
else stream?.on("data", this.#onStderr);
|
||||
if (name === "stdout") {
|
||||
stream?.on("data", this.#onStdout);
|
||||
stream?.on("error", this.#onStdoutError);
|
||||
} else {
|
||||
stream?.on("data", this.#onStderr);
|
||||
stream?.on("error", this.#onStderrError);
|
||||
}
|
||||
}
|
||||
|
||||
#recordStreamError(stream: "stdout" | "stderr", cause: Error): void {
|
||||
this.#options.onDiagnostic?.({
|
||||
type: "error",
|
||||
message: `${stream} stream failed: ${cause.message}`,
|
||||
});
|
||||
}
|
||||
|
||||
#capture(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { beforeAll, expect, it } from "vitest";
|
||||
import { beforeAll, expect, it, vi } from "vitest";
|
||||
import {
|
||||
OwnedCommandFailure,
|
||||
runOwnedCommand,
|
||||
@@ -93,6 +95,60 @@ it("collects a real owned command and verifies its exit before returning diagnos
|
||||
});
|
||||
});
|
||||
|
||||
it("fails an accepted nonzero command when an output stream errors and cleans it up", async () => {
|
||||
const stdout = new PassThrough();
|
||||
const stderr = new PassThrough();
|
||||
class FakeProcess extends EventEmitter {
|
||||
readonly stdout = stdout;
|
||||
readonly stderr = stderr;
|
||||
exitCode: number | null = null;
|
||||
signalCode: NodeJS.Signals | null = null;
|
||||
readonly pid = 41234;
|
||||
kill(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const child = new FakeProcess();
|
||||
const cleanup = vi.fn(async () => ({
|
||||
cleaned: true as const,
|
||||
signaled: false,
|
||||
}));
|
||||
const request = command("unused fake launcher command");
|
||||
|
||||
const failure = runOwnedCommand(
|
||||
request,
|
||||
{ timeoutMs: 2_000, diagnosticBytes: 1024, acceptNonZeroExit: true },
|
||||
{
|
||||
launcher: async () => {
|
||||
setImmediate(() => {
|
||||
stdout.write("partial output");
|
||||
stdout.destroy(new Error("fixture read failure"));
|
||||
stderr.end();
|
||||
child.exitCode = 7;
|
||||
child.emit("exit", 7, null);
|
||||
child.emit("close", 7, null);
|
||||
});
|
||||
return {
|
||||
process: child,
|
||||
ownership: {
|
||||
runId: request.runId,
|
||||
leaderPid: child.pid,
|
||||
processGroupId: child.pid,
|
||||
},
|
||||
cleanup,
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await expect(failure).rejects.toMatchObject({
|
||||
reason: "process",
|
||||
message: "stdout stream failed: fixture read failure",
|
||||
snapshot: { stdout: { text: "partial output" }, exitCode: 7 },
|
||||
});
|
||||
expect(cleanup).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("cancels a real acquired process and independently releases it", async () => {
|
||||
const controller = new AbortController();
|
||||
let pid: number | undefined;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js";
|
||||
import {
|
||||
NATIVE_COMMAND_OUTPUT_BUDGET_BYTES,
|
||||
NativeCommandFailure,
|
||||
XcrunCommandRunner,
|
||||
type NativeCommandRunner,
|
||||
@@ -14,6 +15,8 @@ import {
|
||||
import { err, ok } from "../../../../src/domain/result.js";
|
||||
import { parseLipoArchitectures } from "../../../../src/native/parsers/lipo.js";
|
||||
import { parseCodeSignature } from "../../../../src/native/parsers/codesign.js";
|
||||
import { prepareProcessOwnershipInspection } from "../../../../src/process/ProcessOwnershipObservation.js";
|
||||
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
|
||||
|
||||
import {
|
||||
NativeFixtureRunner as FixtureRunner,
|
||||
@@ -22,6 +25,8 @@ import {
|
||||
nativeMachoTargetForFile,
|
||||
} from "../../../fixtures/nativeCommands.js";
|
||||
|
||||
beforeAll(() => prepareProcessOwnershipInspection());
|
||||
|
||||
let directory: string | undefined;
|
||||
afterEach(async () => {
|
||||
if (directory !== undefined)
|
||||
@@ -29,7 +34,7 @@ afterEach(async () => {
|
||||
directory = undefined;
|
||||
});
|
||||
|
||||
describe("native macOS provider discovery and inspection", () => {
|
||||
describe("native macOS provider discovery", () => {
|
||||
it.each(["ordinary.fixture", " spaced.fixture ", " leading", "trailing "])(
|
||||
"preserves the exact signing identifier %j reported by codesign",
|
||||
(identifier) => {
|
||||
@@ -87,6 +92,48 @@ describe("native macOS provider discovery and inspection", () => {
|
||||
expect(resolutions).toBe(2);
|
||||
});
|
||||
|
||||
it.each(["timeout", "output-limit"] as const)(
|
||||
"preserves %s resolution failures with their captured output and cleanup status",
|
||||
async (reason) => {
|
||||
const failure = new NativeCommandFailure(
|
||||
"file",
|
||||
reason,
|
||||
9,
|
||||
undefined,
|
||||
{
|
||||
stdout: "partial xcrun output",
|
||||
stderr: "partial xcrun diagnostic",
|
||||
stdoutBytes: 20,
|
||||
stderrBytes: 25,
|
||||
exitCode: 9,
|
||||
signal: null,
|
||||
truncated: reason === "output-limit",
|
||||
},
|
||||
"ownership cleanup was not confirmed",
|
||||
["process-group:1234"],
|
||||
);
|
||||
const runner = new XcrunCommandRunner(() =>
|
||||
Promise.resolve(err(failure)),
|
||||
);
|
||||
|
||||
const result = await runner.run("file", [], {});
|
||||
|
||||
expect(result).toEqual(err(failure));
|
||||
if (result.ok) throw new Error("Resolution failure unexpectedly passed");
|
||||
expect(result.error).toMatchObject({
|
||||
reason,
|
||||
capture: {
|
||||
stdout: "partial xcrun output",
|
||||
truncated: reason === "output-limit",
|
||||
},
|
||||
cleanupFailure: "ownership cleanup was not confirmed",
|
||||
cleanupResources: ["process-group:1234"],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("native command output collection", () => {
|
||||
it("retains the complete native command output", async () => {
|
||||
const runner = new XcrunCommandRunner(() =>
|
||||
Promise.resolve(ok({ path: process.execPath, sha256: "a".repeat(64) })),
|
||||
@@ -101,23 +148,222 @@ describe("native macOS provider discovery and inspection", () => {
|
||||
expect(captured.ok && captured.value.stdout).toBe(output);
|
||||
});
|
||||
|
||||
it("cancels and reaps a running native command without an operation timeout", async () => {
|
||||
it("retains both output streams and exit status when a command fails", async () => {
|
||||
const runner = new XcrunCommandRunner(() =>
|
||||
Promise.resolve(ok({ path: process.execPath, sha256: "a".repeat(64) })),
|
||||
);
|
||||
const result = await runner.run(
|
||||
"file",
|
||||
[
|
||||
"-e",
|
||||
'process.stdout.write("partial stdout"); process.stderr.write("partial stderr"); process.exitCode = 7',
|
||||
],
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error).toMatchObject({
|
||||
reason: "nonzero-exit",
|
||||
exitCode: 7,
|
||||
capture: {
|
||||
stdout: "partial stdout",
|
||||
stderr: "partial stderr",
|
||||
stdoutBytes: 14,
|
||||
stderrBytes: 14,
|
||||
exitCode: 7,
|
||||
signal: null,
|
||||
truncated: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("retains accepted nonzero output as a complete command capture", async () => {
|
||||
const runner = new XcrunCommandRunner(() =>
|
||||
Promise.resolve(ok({ path: process.execPath, sha256: "a".repeat(64) })),
|
||||
);
|
||||
const result = await runner.run(
|
||||
"file",
|
||||
[
|
||||
"-e",
|
||||
'process.stdout.write("unsigned observation"); process.exitCode = 1',
|
||||
],
|
||||
{ acceptNonZero: true },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
stdout: "unsigned observation",
|
||||
stdoutBytes: 20,
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("native command failure capture and limits", () => {
|
||||
it("projects captured diagnostics from a failed native provider command", async () => {
|
||||
const runner = new XcrunCommandRunner(() =>
|
||||
Promise.resolve(ok({ path: process.execPath, sha256: "a".repeat(64) })),
|
||||
);
|
||||
const result = await new NativeMacOSProvider(runner, "darwin")
|
||||
.createClient(machoTarget("/private/fixture"))
|
||||
.execute("demangle_swift", { symbols: ["fixture-symbol"] });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error._tag).toBe("ProviderAdapterError");
|
||||
expect(result.error.capturedOutput?.stderr).toContain("Cannot find module");
|
||||
expect(result.error.capturedOutput?.stderr_bytes).toBeGreaterThan(0);
|
||||
expect(result.error.capturedOutput?.exit_code).not.toBe(0);
|
||||
expect(projectAnalysisError(result.error).details).toMatchObject({
|
||||
captured_output: {
|
||||
stderr: expect.stringContaining("Cannot find module"),
|
||||
stderr_bytes: expect.any(Number),
|
||||
exit_code: expect.any(Number),
|
||||
},
|
||||
diagnostics: {
|
||||
reason: "nonzero-exit",
|
||||
stderr_bytes: expect.any(Number),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("retains output written before cancellation and reaps the stalled command", async () => {
|
||||
const runner = new XcrunCommandRunner(() =>
|
||||
Promise.resolve(ok({ path: process.execPath, sha256: "a".repeat(64) })),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const pending = runner.run("file", ["-e", "setTimeout(() => {}, 10000)"], {
|
||||
signal: controller.signal,
|
||||
});
|
||||
setTimeout(() => controller.abort(), 30);
|
||||
const pending = runner.run(
|
||||
"file",
|
||||
[
|
||||
"-e",
|
||||
'process.stdout.write("started"); process.stderr.write("warning"); setInterval(() => {}, 1000)',
|
||||
],
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
setTimeout(() => controller.abort(), 100);
|
||||
|
||||
const result = await pending;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { reason: "cancelled" },
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error).toMatchObject({
|
||||
reason: "cancelled",
|
||||
capture: {
|
||||
stdout: "started",
|
||||
stderr: "warning",
|
||||
stdoutBytes: 7,
|
||||
stderrBytes: 7,
|
||||
truncated: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects output beyond the complete native-command budget", async () => {
|
||||
const runner = new XcrunCommandRunner(() =>
|
||||
Promise.resolve(ok({ path: process.execPath, sha256: "a".repeat(64) })),
|
||||
);
|
||||
const megabytesOverBudget =
|
||||
Math.ceil(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES / (1024 * 1024)) + 1;
|
||||
const result = await runner.run(
|
||||
"file",
|
||||
[
|
||||
"-e",
|
||||
`async function main() { const chunk = "x".repeat(1024 * 1024); for (let i = 0; i < ${String(megabytesOverBudget)}; i += 1) await new Promise((resolve) => process.stdout.write(chunk, resolve)); } void main()`,
|
||||
],
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.reason).toBe("output-limit");
|
||||
expect(result.error.capture?.truncated).toBe(true);
|
||||
expect(
|
||||
(result.error.capture?.stdoutBytes ?? 0) +
|
||||
(result.error.capture?.stderrBytes ?? 0),
|
||||
).toBeLessThanOrEqual(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES);
|
||||
});
|
||||
});
|
||||
|
||||
describe("native command failure projection", () => {
|
||||
it("projects native cancellation output and incomplete cleanup to callers", async () => {
|
||||
const capture = {
|
||||
stdout: "partial output",
|
||||
stderr: "partial diagnostic",
|
||||
stdoutBytes: 14,
|
||||
stderrBytes: 19,
|
||||
exitCode: null,
|
||||
signal: "SIGKILL",
|
||||
truncated: false,
|
||||
};
|
||||
const runner: NativeCommandRunner = {
|
||||
async run(tool) {
|
||||
return err(
|
||||
new NativeCommandFailure(
|
||||
tool,
|
||||
"cancelled",
|
||||
null,
|
||||
undefined,
|
||||
capture,
|
||||
"process group cleanup was not confirmed",
|
||||
["process-group:1234"],
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
const result = await new NativeMacOSProvider(runner, "darwin")
|
||||
.createClient(machoTarget("/private/fixture"))
|
||||
.execute("list_architectures", {});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.capturedOutput).toMatchObject({
|
||||
stdout: "partial output",
|
||||
stderr: "partial diagnostic",
|
||||
stdout_bytes: 14,
|
||||
stderr_bytes: 19,
|
||||
signal: "SIGKILL",
|
||||
truncated: false,
|
||||
});
|
||||
expect(result.error.cleanup).toEqual({
|
||||
reason: "process group cleanup was not confirmed",
|
||||
resources: ["process-group:1234"],
|
||||
});
|
||||
expect(projectAnalysisError(result.error).details).toMatchObject({
|
||||
captured_output: {
|
||||
stdout_bytes: 14,
|
||||
stderr_bytes: 19,
|
||||
signal: "SIGKILL",
|
||||
},
|
||||
cleanup: "incomplete",
|
||||
resources: ["process-group:1234"],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["timeout", "AnalysisTimeoutError", "provider_timeout"],
|
||||
["output-limit", "AnalysisResourceConstraintError", "resource_constraint"],
|
||||
] as const)(
|
||||
"projects native command %s as a typed resource failure",
|
||||
async (reason, tag, code) => {
|
||||
const runner: NativeCommandRunner = {
|
||||
async run(tool) {
|
||||
return err(new NativeCommandFailure(tool, reason));
|
||||
},
|
||||
};
|
||||
const result = await new NativeMacOSProvider(runner, "darwin")
|
||||
.createClient(machoTarget("/private/fixture"))
|
||||
.execute("list_architectures", {});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error._tag).toBe(tag);
|
||||
expect(projectAnalysisError(result.error).code).toBe(code);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("native macOS provider inspection", () => {
|
||||
|
||||
Reference in New Issue
Block a user