fix(native): share command supervision and retain failure output

This commit is contained in:
morluto
2026-10-09 05:08:24 +08:00
parent 4a45c14742
commit f2e9c491ea
8 changed files with 604 additions and 98 deletions
+6
View File
@@ -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.
+4
View File
@@ -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
View File
@@ -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")));
+70 -3
View File
@@ -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 =>
+19 -4
View File
@@ -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",
+22 -2
View File
@@ -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(
+57 -1
View File
@@ -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", () => {