diff --git a/docs/native-investigation.md b/docs/native-investigation.md index ba59d727e2..48679e3c3b 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -743,6 +743,12 @@ caller-selected entries, records them and continues: - Objective-C methods by selector, optionally limited to one class and to instance or class methods. +LLDB discovery uses a separate 60000 ms `xcrun` deadline with no runtime +override. Discovery timeouts report `provider_timeout` and retain timeout +recovery, captured diagnostics and cleanup state. Output-budget and I/O failures +retain their own error categories; only actual discovery unavailability reports +missing LLDB. `duration_ms` controls the later target observation window. + ```sh rea observe-native-calls /Applications/Example.app '{"breakpoints":[{"kind":"objc-method","class_name":"NSURLSession","selector":"dataTaskWithRequest:completionHandler:"},{"kind":"function","name":"open","module":"libsystem_kernel.dylib"}],"duration_ms":5000,"backtrace_frames":4}' --json ``` diff --git a/src/native/LldbCallTracer.ts b/src/native/LldbCallTracer.ts index 6f88f624fe..39bffae3d8 100644 --- a/src/native/LldbCallTracer.ts +++ b/src/native/LldbCallTracer.ts @@ -1,3 +1,5 @@ +import { translateNativeCommandFailure } from "./NativeCommandAnalysisError.js"; +import { DEFAULT_NATIVE_COMMAND_TIMEOUT_MS } from "../domain/native/nativeDeadlines.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { randomUUID } from "node:crypto"; import { writeFile } from "node:fs/promises"; @@ -515,39 +517,19 @@ export class LldbCallTracer implements NativeCallTracer { const preparation = failure.preparationFailure; if (preparation !== undefined) return withResolverMarkers(lldbPreparationFailure(preparation), markers); - const cleanup = - markers.length === 0 - ? undefined - : { - reason: markers - .map((marker) => - typeof marker.diagnostics?.reason === "string" - ? marker.diagnostics.reason - : marker.message, - ) - .join("; "), - resources: [ - ...new Set(markers.flatMap((marker) => marker.cleanupResources)), - ], - }; - const cleanupContext = - cleanup === undefined ? {} : { cause: failure, cleanup }; - const error = - failure.reason === "cancelled" - ? new AnalysisCancelledError(OPERATION, { - ...(cleanup === undefined ? {} : { cleanup }), - ...(failure.cancellation === undefined - ? cleanup === undefined - ? {} - : { cause: failure } - : { cause: failure.cancellation.reason }), - }) - : new AnalysisCapabilityUnavailableError( - PROVIDER, - OPERATION, - LLDB_REQUIREMENT, - { ...cleanupContext, userMessage: LLDB_REQUIREMENT }, - ); + const error = translateNativeCommandFailure( + OPERATION, + failure, + DEFAULT_NATIVE_COMMAND_TIMEOUT_MS, + { + unavailableMessage: LLDB_REQUIREMENT, + timeoutRecovery: { + userMessage: `LLDB discovery reached REA's ${String(DEFAULT_NATIVE_COMMAND_TIMEOUT_MS)}ms xcrun deadline. This is not the client deadline.`, + remediationAction: + "Inspect the retained discovery output and cleanup state, then resolve the xcrun/toolchain condition before retrying. This LLDB discovery deadline has no runtime override; duration_ms controls the later target observation window.", + }, + }, + ); return markers.length === 0 ? error : error.bindCleanupDependencies(markers); diff --git a/src/native/NativeCommandAnalysisError.ts b/src/native/NativeCommandAnalysisError.ts new file mode 100644 index 0000000000..7a3b03c727 --- /dev/null +++ b/src/native/NativeCommandAnalysisError.ts @@ -0,0 +1,212 @@ +import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, + AnalysisResourceConstraintError, + AnalysisTimeoutError, +} from "../domain/analysisErrorCore.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { NATIVE_MACOS_PROVIDER_IDENTITY as IDENTITY } from "./NativeMacOSProviderMetadata.js"; +import { + DEFAULT_NATIVE_COMMAND_TIMEOUT_MS, + fixedNativeAllowance, + nativeDeadlineRecovery, +} from "../domain/native/nativeDeadlines.js"; +import { + NATIVE_COMMAND_OUTPUT_BUDGET_BYTES, + NATIVE_FAILURE_DIAGNOSTIC_BYTES, + type NativeCommandFailure, +} from "./CommandRunner.js"; + +/** Operation-owned text for a command deadline or unavailable requirement. */ +export interface NativeCommandFailurePresentation { + readonly unavailableMessage?: string; + readonly timeoutRecovery?: { + readonly userMessage: string; + readonly remediationAction: string; + }; +} + +/** Translate the command owner's facts once, preserving capture and cleanup authority. */ +export const translateNativeCommandFailure = ( + operation: string, + failure: NativeCommandFailure, + timeoutMs = DEFAULT_NATIVE_COMMAND_TIMEOUT_MS, + presentation: NativeCommandFailurePresentation = {}, +): AnalysisError => { + const cleanupOwners = [ + ...failure.cleanupFailures, + ...(failure.cleanupOwner === undefined + ? [] + : [ + { + owner: failure.cleanupOwner, + marker: failure.cleanupMarker, + reason: + failure.cleanupFailure ?? + "native command cleanup remains incomplete", + resources: failure.cleanupResources, + }, + ]), + ]; + const markers = cleanupOwners.flatMap(({ marker }) => + marker === undefined ? [] : [marker], + ); + const bindCleanup = (error: AnalysisError): AnalysisError => + cleanupOwners.length === 0 || + cleanupOwners.some(({ marker }) => marker === undefined) + ? error + : error.bindCleanupDependencies([...new Set(markers)]); + const preparation = failure.preparationFailure; + if (preparation !== undefined) { + const marker = preparation.cleanupFailure; + const compiler = preparation.compilerOutcome; + const options = { + cause: failure, + ...(preparation.capturedOutput === undefined + ? {} + : { capturedOutput: preparation.capturedOutput }), + ...(marker === undefined + ? {} + : { + cleanup: { + reason: marker.message, + resources: marker.cleanupResources, + }, + }), + diagnostics: { + phase: "process-preparation", + tool: failure.tool, + reason: failure.reason, + detail: preparation.message, + ...(compiler === undefined + ? {} + : { + compiler_outcome: { + outcome: compiler.outcome, + code: compiler.code, + signal: compiler.signal, + ...(compiler.timeoutMs === undefined + ? {} + : { timeout_ms: compiler.timeoutMs }), + }, + }), + }, + }; + const error = + preparation.cancellation === undefined + ? new ProviderAdapterError(IDENTITY.id, operation, options) + : failure.reason === "timeout" + ? new AnalysisTimeoutError(operation, timeoutMs, { + ...options, + cause: preparation.cancellation.reason, + ...(presentation.timeoutRecovery ?? + nativeDeadlineRecovery({ + timeoutMs, + setting: "native_command_timeout_ms", + cliFlag: "--native-command-timeout-ms", + defaultMs: DEFAULT_NATIVE_COMMAND_TIMEOUT_MS, + phase: "macOS native command deadline", + unchanged: `The ${String(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES)}-byte command output allowance and the ${String(NATIVE_FAILURE_DIAGNOSTIC_BYTES)}-byte failure-diagnostic excerpt have no runtime override.`, + })), + }) + : new AnalysisCancelledError(operation, { + ...options, + cause: preparation.cancellation.reason, + }); + return bindCleanup( + marker === undefined ? error : error.bindCleanupDependencies([marker]), + ); + } + 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 = { + cause: failure, + ...(capturedOutput === undefined ? {} : { capturedOutput }), + ...(cleanup === undefined ? {} : { cleanup }), + }; + if (failure.reason === "unavailable") { + const reason = + presentation.unavailableMessage ?? + `${failure.tool} is unavailable through xcrun.`; + return bindCleanup( + new AnalysisCapabilityUnavailableError(IDENTITY.id, operation, reason, { + ...errorOptions, + userMessage: reason, + }), + ); + } + if (failure.reason === "cancelled") + return bindCleanup( + new AnalysisCancelledError(operation, { + ...errorOptions, + ...(failure.cancellation === undefined + ? {} + : { cause: failure.cancellation.reason }), + }), + ); + if (failure.reason === "timeout") + return bindCleanup( + new AnalysisTimeoutError(operation, timeoutMs, { + ...errorOptions, + ...(presentation.timeoutRecovery ?? + nativeDeadlineRecovery({ + timeoutMs, + setting: "native_command_timeout_ms", + cliFlag: "--native-command-timeout-ms", + defaultMs: DEFAULT_NATIVE_COMMAND_TIMEOUT_MS, + phase: "macOS native command deadline", + unchanged: `The ${String(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES)}-byte command output allowance and the ${String(NATIVE_FAILURE_DIAGNOSTIC_BYTES)}-byte failure-diagnostic excerpt have no runtime override.`, + })), + }), + ); + if (failure.reason === "output-limit") + return bindCleanup( + new AnalysisResourceConstraintError( + operation, + "memory", + `Native command output exceeded ${String(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES)} bytes. Complete output is required for analysis. This release has no runtime override. native_command_timeout_ms does not raise this allowance.`, + { max_output_bytes: NATIVE_COMMAND_OUTPUT_BUDGET_BYTES }, + { + ...errorOptions, + ...fixedNativeAllowance( + "The native command output capture allowance", + "capture", + `${String(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES)} bytes`, + ), + }, + ), + ); + return bindCleanup( + 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, + ...(failure.detail === null ? {} : { detail: failure.detail }), + }, + }), + ); +}; diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index ff314f53c4..b03d2f4033 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -1,3 +1,4 @@ +import { translateNativeCommandFailure } from "./NativeCommandAnalysisError.js"; import { plistSourceFormat, isBinaryPlistDescription, @@ -57,16 +58,12 @@ import { AnalysisCapabilityUnavailableError, AnalysisInputError, AnalysisOutputError, - AnalysisResourceConstraintError, AnalysisUnsupportedTargetError, - AnalysisTimeoutError, } from "../domain/analysisErrorCore.js"; import { DEFAULT_NATIVE_COMMAND_TIMEOUT_MS, DEFAULT_NATIVE_UI_DEADLINE_MS, MAX_NATIVE_TIMER_MS, - fixedNativeAllowance, - nativeDeadlineRecovery, readNativeDeadline, } from "../domain/native/nativeDeadlines.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; @@ -99,8 +96,6 @@ import { } from "../domain/native/nativeInspection.js"; import { err, ok, type Result } from "../domain/result.js"; import { - NATIVE_COMMAND_OUTPUT_BUDGET_BYTES, - NATIVE_FAILURE_DIAGNOSTIC_BYTES, NativeCommandFailure, XcrunCommandRunner, failureCapture, @@ -1221,7 +1216,7 @@ class NativeMacOSClient implements AnalysisClient { !options.acceptNonZeroIf(captured.value) ) return err( - translateCommandFailure( + translateNativeCommandFailure( operation, new NativeCommandFailure( tool, @@ -1247,7 +1242,11 @@ class NativeMacOSClient implements AnalysisClient { return captured; } return err( - translateCommandFailure(operation, captured.error, options.timeoutMs), + translateNativeCommandFailure( + operation, + captured.error, + options.timeoutMs, + ), ); } } @@ -1320,187 +1319,10 @@ const isNativeOperation = ( ): operation is NativeToolName => NATIVE_TOOL_CONTRACTS.some(({ name }) => name === operation); -const translateCommandFailure = ( - operation: NativeToolName, - failure: NativeCommandFailure, - timeoutMs = DEFAULT_NATIVE_COMMAND_TIMEOUT_MS, -): AnalysisError => { - const cleanupOwners = [ - ...failure.cleanupFailures, - ...(failure.cleanupOwner === undefined - ? [] - : [ - { - owner: failure.cleanupOwner, - marker: failure.cleanupMarker, - reason: - failure.cleanupFailure ?? - "native command cleanup remains incomplete", - resources: failure.cleanupResources, - }, - ]), - ]; - const markers = cleanupOwners.flatMap(({ marker }) => - marker === undefined ? [] : [marker], - ); - const bindCleanup = (error: AnalysisError): AnalysisError => - cleanupOwners.length === 0 || - cleanupOwners.some(({ marker }) => marker === undefined) - ? error - : error.bindCleanupDependencies([...new Set(markers)]); - const preparation = failure.preparationFailure; - if (preparation !== undefined) { - const marker = preparation.cleanupFailure; - const compiler = preparation.compilerOutcome; - const options = { - cause: failure, - ...(preparation.capturedOutput === undefined - ? {} - : { capturedOutput: preparation.capturedOutput }), - ...(marker === undefined - ? {} - : { - cleanup: { - reason: marker.message, - resources: marker.cleanupResources, - }, - }), - diagnostics: { - phase: "process-preparation", - tool: failure.tool, - reason: failure.reason, - detail: preparation.message, - ...(compiler === undefined - ? {} - : { - compiler_outcome: { - outcome: compiler.outcome, - code: compiler.code, - signal: compiler.signal, - ...(compiler.timeoutMs === undefined - ? {} - : { timeout_ms: compiler.timeoutMs }), - }, - }), - }, - }; - const error = - preparation.cancellation === undefined - ? new ProviderAdapterError(IDENTITY.id, operation, options) - : failure.reason === "timeout" - ? new AnalysisTimeoutError(operation, timeoutMs, { - ...options, - cause: preparation.cancellation.reason, - ...nativeDeadlineRecovery({ - timeoutMs, - setting: "native_command_timeout_ms", - cliFlag: "--native-command-timeout-ms", - defaultMs: DEFAULT_NATIVE_COMMAND_TIMEOUT_MS, - phase: "macOS native command deadline", - unchanged: `The ${String(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES)}-byte command output allowance and the ${String(NATIVE_FAILURE_DIAGNOSTIC_BYTES)}-byte failure-diagnostic excerpt have no runtime override.`, - }), - }) - : new AnalysisCancelledError(operation, { - ...options, - cause: preparation.cancellation.reason, - }); - return bindCleanup( - marker === undefined ? error : error.bindCleanupDependencies([marker]), - ); - } - 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") { - const reason = `${failure.tool} is unavailable through xcrun.`; - return bindCleanup( - new AnalysisCapabilityUnavailableError(IDENTITY.id, operation, reason, { - ...errorOptions, - userMessage: reason, - }), - ); - } - if (failure.reason === "cancelled") - return bindCleanup( - new AnalysisCancelledError(operation, { - ...errorOptions, - ...(failure.cancellation === undefined - ? {} - : { cause: failure.cancellation.reason }), - }), - ); - if (failure.reason === "timeout") - return bindCleanup( - new AnalysisTimeoutError(operation, timeoutMs, { - ...errorOptions, - ...nativeDeadlineRecovery({ - timeoutMs, - setting: "native_command_timeout_ms", - cliFlag: "--native-command-timeout-ms", - defaultMs: DEFAULT_NATIVE_COMMAND_TIMEOUT_MS, - phase: "macOS native command deadline", - unchanged: `The ${String(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES)}-byte command output allowance and the ${String(NATIVE_FAILURE_DIAGNOSTIC_BYTES)}-byte failure-diagnostic excerpt have no runtime override.`, - }), - }), - ); - if (failure.reason === "output-limit") - return bindCleanup( - new AnalysisResourceConstraintError( - operation, - "memory", - `Native command output exceeded ${String(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES)} bytes. Complete output is required for analysis. This release has no runtime override. native_command_timeout_ms does not raise this allowance.`, - { max_output_bytes: NATIVE_COMMAND_OUTPUT_BUDGET_BYTES }, - { - ...errorOptions, - ...fixedNativeAllowance( - "The native command output capture allowance", - "capture", - `${String(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES)} bytes`, - ), - }, - ), - ); - return bindCleanup( - 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, - ...(failure.detail === null ? {} : { detail: failure.detail }), - }, - }), - ); -}; - const translateCodeSignExitFailure = ( capture: NativeCommandCapture, ): AnalysisError => - translateCommandFailure( + translateNativeCommandFailure( "inspect_signature", new NativeCommandFailure( "codesign", diff --git a/tests/boundary/providers/native/lldbDiscovery.test.ts b/tests/boundary/providers/native/lldbDiscovery.test.ts new file mode 100644 index 0000000000..e524425941 --- /dev/null +++ b/tests/boundary/providers/native/lldbDiscovery.test.ts @@ -0,0 +1,117 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; + +import { DEFAULT_NATIVE_COMMAND_TIMEOUT_MS } from "../../../../src/domain/native/nativeDeadlines.js"; +import { thinMach } from "../../../../src/domain/binaryTarget.fixture.js"; +import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; +import { err } from "../../../../src/domain/result.js"; +import { LldbCallTracer } from "../../../../src/native/LldbCallTracer.js"; +import { NativeCommandFailure } from "../../../../src/native/CommandRunner.js"; +import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js"; +import { + NativeFixtureRunner, + nativeMachoTargetForFile, +} from "../../../fixtures/nativeCommands.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; + +for (const [reason, code] of [ + ["timeout", "provider_timeout"], + ["output-limit", "resource_constraint"], + ["io", "execution_failure"], + ["unavailable", "capability_unavailable"], + ["cancelled", "cancelled"], +] as const) + it(`preserves LLDB discovery ${reason} through the native provider client`, async () => { + const directory = await createTestTempDirectory("rea-lldb-discovery-"); + const target = join(directory, "inert-header.macho"); + await writeFile(target, thinMach(0xfeedfacf, 0x0100000c)); + const controller = new AbortController(); + const abortReason = { requested: "cancel discovery" }; + const failure = new NativeCommandFailure( + "lldb", + reason, + null, + { + cause: new Error("Fixture xcrun failure"), + ...(reason === "cancelled" + ? { cancellation: { reason: abortReason } } + : {}), + }, + { + stdout: "fixture discovery output", + stderr: "fixture discovery diagnostic", + stdoutBytes: 24, + stderrBytes: 28, + exitCode: null, + signal: null, + truncated: false, + }, + ); + const launch = vi.fn(async () => { + throw new Error("Discovery failure cannot launch LLDB"); + }); + const tracer = new LldbCallTracer({}, launch, async (_tool, signal) => { + expect(signal).toBe(controller.signal); + if (reason === "cancelled") controller.abort(abortReason); + return err(failure); + }); + const client = new NativeMacOSProvider( + {}, + new NativeFixtureRunner(), + "darwin", + () => tracer, + ).createClient(await nativeMachoTargetForFile(target)); + try { + const result = await client.execute( + "observe_native_calls", + { + breakpoints: [{ kind: "function", name: "main" }], + }, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected discovery failure"); + expect(launch).not.toHaveBeenCalled(); + expect(result.error.cause).toBe( + reason === "cancelled" ? abortReason : failure, + ); + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ + code, + details: { + captured_output: { + stdout: failure.capture?.stdout, + stderr: failure.capture?.stderr, + }, + }, + }); + if (reason === "timeout") { + expect(projected).toMatchObject({ + retryable: true, + recovery: { + kind: "inspect_timeout_state", + operation: "observe_native_calls", + timeout_ms: DEFAULT_NATIVE_COMMAND_TIMEOUT_MS, + }, + remediation: { + action: expect.stringContaining( + "discovery deadline has no runtime override", + ), + }, + }); + expect(projected.remediation.action).not.toContain( + "Increase native_command_timeout_ms", + ); + } else if (reason === "unavailable") + expect(projected.message).toBe( + "LLDB from Xcode or the Command Line Tools is not available", + ); + else + expect(projected.message).not.toBe( + "LLDB from Xcode or the Command Line Tools is not available", + ); + } finally { + expect((await client.close()).ok).toBe(true); + } + }); diff --git a/tests/boundary/providers/native/lldbPrelaunchCleanup.test.ts b/tests/boundary/providers/native/lldbPrelaunchCleanup.test.ts index aef269c75c..2f3731d845 100644 --- a/tests/boundary/providers/native/lldbPrelaunchCleanup.test.ts +++ b/tests/boundary/providers/native/lldbPrelaunchCleanup.test.ts @@ -147,7 +147,7 @@ it("retries the tracer-owned resolver supervisor before another trace", async () const result = await tracer.trace(request); expect(result.ok).toBe(false); if (result.ok) return; - expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError"); + expect(result.error._tag).toBe("ProviderAdapterError"); expect(result.error.cleanupIncomplete).toBe(true); expect(result.error.cleanupResources).toEqual(["owned-resolver-process"]); expect(projectAnalysisError(result.error).code).toBe("cleanup_incomplete");