From f2e9c491ea00bf79d5665102d95a3339b63cfe4f Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 9 Oct 2026 05:08:24 +0800 Subject: [PATCH] fix(native): share command supervision and retain failure output --- docs/native-investigation.md | 6 + src/domain/analysisErrorBase.ts | 4 + src/native/CommandRunner.ts | 248 +++++++++++----- src/native/NativeMacOSProvider.ts | 73 ++++- src/process/OwnedCommand.ts | 23 +- src/process/ProviderProcess.ts | 24 +- tests/boundary/process/ownedCommand.test.ts | 58 +++- .../providers/native/nativeProvider.test.ts | 266 +++++++++++++++++- 8 files changed, 604 insertions(+), 98 deletions(-) diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 6255d5e4e1..e385ef9e56 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -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 ` 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. diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index a6a0f7b255..31ae207dbf 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -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. */ diff --git a/src/native/CommandRunner.ts b/src/native/CommandRunner.ts index 962535fa8c..37794d1582 100644 --- a/src/native/CommandRunner.ts +++ b/src/native/CommandRunner.ts @@ -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> => - 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> => { + 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, - ): 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 => +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 => 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"))); diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index d480667905..d011509d6d 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -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 => diff --git a/src/process/OwnedCommand.ts b/src/process/OwnedCommand.ts index 1fa054c709..b5de06b644 100644 --- a/src/process/OwnedCommand.ts +++ b/src/process/OwnedCommand.ts @@ -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, @@ -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", diff --git a/src/process/ProviderProcess.ts b/src/process/ProviderProcess.ts index 896ae47e6f..5bedea76e7 100644 --- a/src/process/ProviderProcess.ts +++ b/src/process/ProviderProcess.ts @@ -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( diff --git a/tests/boundary/process/ownedCommand.test.ts b/tests/boundary/process/ownedCommand.test.ts index e647263315..05ccdb81cd 100644 --- a/tests/boundary/process/ownedCommand.test.ts +++ b/tests/boundary/process/ownedCommand.test.ts @@ -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; diff --git a/tests/boundary/providers/native/nativeProvider.test.ts b/tests/boundary/providers/native/nativeProvider.test.ts index e0dca4d21d..91b6d18d72 100644 --- a/tests/boundary/providers/native/nativeProvider.test.ts +++ b/tests/boundary/providers/native/nativeProvider.test.ts @@ -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", () => {