diff --git a/src/native/NativeMachoInspection.test.ts b/src/native/NativeMachoInspection.test.ts index 4ed533c2e8..6fe24eb903 100644 --- a/src/native/NativeMachoInspection.test.ts +++ b/src/native/NativeMachoInspection.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { + AnalysisCancelledError, + AnalysisOutputError, +} from "../domain/analysisErrorCore.js"; import { err, ok } from "../domain/result.js"; import type { NativeCommandCapture } from "./CommandRunner.js"; import { inspectNativeMacho } from "./NativeMachoInspection.js"; @@ -186,3 +189,64 @@ describe("Mach-O UUID reconciliation", () => { ); }); }); + +describe("Mach-O slice command scheduling", () => { + const SLICE_TOOLS = ["otool", "nm", "dyld_info", "dyld_info", "dwarfdump"]; + + /** Finish later-launched slice commands first and record overlap. */ + const reorderingRunner = (failing: ReadonlySet = new Set()) => { + const fixtures = new NativeFixtureRunner(); + let inFlight = 0; + let maxInFlight = 0; + let launched = 0; + const run = async (tool: string, args: readonly string[]) => { + if (tool === "file" || tool === "lipo") return fixtures.run(tool, args); + const order = launched++; + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + for (let turn = 0; turn < 12 - order * 2; turn += 1) + await new Promise((resolve) => setImmediate(resolve)); + inFlight -= 1; + if (failing.has(tool)) + return err(new AnalysisOutputError("inspect_macho", `${tool} failed`)); + return fixtures.run(tool, args); + }; + return { run, maxInFlight: () => maxInFlight }; + }; + + const inspectWith = (run: ReturnType["run"]) => + inspectNativeMacho({ + target: nativeMachoTarget("/fixture/app"), + run, + invocation: (capture) => ({ + tool: capture.tool, + command: [capture.executable, ...capture.arguments], + tool_version: capture.toolVersion, + version_reason: capture.versionReason, + executable_sha256: capture.executableSha256, + exit: { code: capture.exitCode, signal: capture.signal }, + stdout_bytes: capture.stdoutBytes, + stderr_bytes: capture.stderrBytes, + }), + }); + + it("runs independent slice commands together and keeps provenance in declared order", async () => { + const runner = reorderingRunner(); + const result = await inspectWith(runner.run); + if (!result.ok) throw result.error; + expect(runner.maxInFlight()).toBe(SLICE_TOOLS.length + 1); + expect(result.value.result).toMatchObject({ + provenance: ["file", "lipo", ...SLICE_TOOLS, "vtool"].map((tool) => ({ + tool, + })), + }); + }); + + it("reports the first failing slice command in declared order", async () => { + const runner = reorderingRunner(new Set(["nm", "dwarfdump"])); + const result = await inspectWith(runner.run); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toMatchObject({ reason: "nm failed" }); + }); +}); diff --git a/src/native/NativeMachoInspection.ts b/src/native/NativeMachoInspection.ts index 2ec6cd4a93..24ccb2514d 100644 --- a/src/native/NativeMachoInspection.ts +++ b/src/native/NativeMachoInspection.ts @@ -79,8 +79,11 @@ export const inspectNativeMacho = async ( ); const selectedArchitecture = selection.status === "selected" ? selection.architecture : null; - for (const [tool, prefix] of REQUIRED_SLICE_COMMANDS) { - const captured = await context.run( + const runSliceCommand = ([tool, prefix]: readonly [ + string, + readonly string[], + ]) => + context.run( tool, [ ...prefix, @@ -89,20 +92,18 @@ export const inspectNativeMacho = async ( ], context.signal, ); + // Slice commands only read the selected artifact, so launch them together. + // Every launch settles before return; declared order fixes provenance and + // which failure is reported. + const [required, optional] = await Promise.all([ + Promise.all(REQUIRED_SLICE_COMMANDS.map(runSliceCommand)), + runSliceCommand(OPTIONAL_VTOOL_COMMAND), + ]); + for (const captured of required) { if (!captured.ok) return err(captured.error); captures.push(captured.value); } const limitations: string[] = []; - const [tool, prefix] = OPTIONAL_VTOOL_COMMAND; - const optional = await context.run( - tool, - [ - ...prefix, - ...selectedArchitectureArguments(tool, selectedArchitecture), - context.target.path, - ], - context.signal, - ); if (optional.ok) captures.push(optional.value); else if (optional.error._tag === "AnalysisCapabilityUnavailableError") limitations.push(VTOOL_UNAVAILABLE_LIMITATION);