diff --git a/.agents/skills/reverse-engineer-anything/references/android-applications.md b/.agents/skills/reverse-engineer-anything/references/android-applications.md index 912a42f5b0..07e8c4ce93 100644 --- a/.agents/skills/reverse-engineer-anything/references/android-applications.md +++ b/.agents/skills/reverse-engineer-anything/references/android-applications.md @@ -25,8 +25,9 @@ stdio. A full JDK, including `jdk.compiler`, is required on every host; Windows `JAVA_HOME` selects `bin\java.exe`. Consult the Android guide for actual host verification coverage. Calls use one worker and provider deadlines; optional `REA_JADX_HEAP_MIB` selects the JVM heap and `REA_JADX_OPERATION_TIMEOUT_MS` -the per-operation deadline (default 120000 ms; the MCP client's tool timeout -must also allow it). The APK is never launched, +the per-operation deadline (integer milliseconds from 1 to 2147483647, default +120000; a change applies on the next process start; the MCP client's tool +timeout must also allow it). The APK is never launched, and no emulator is required. Manifest entries and decompiled text are static observations, not execution or diff --git a/docs/adb-device-analysis.md b/docs/adb-device-analysis.md index 035810baa1..299638fe05 100644 --- a/docs/adb-device-analysis.md +++ b/docs/adb-device-analysis.md @@ -186,9 +186,11 @@ and during shutdown. The app/activity lifecycle tools use `am start -W` and `am force-stop`. - Per-invocation budgets: client/devices 10-20 s, shell reads 20-60 s with 64 KiB-8 MiB capture caps (logcat 4 MiB, screen 32 MiB), transfers and - bugreports 600 s. Output capture overflow reports `resource_constraint`; - deadline expiration, including the client-version probe, reports - `provider_timeout`. Command cancellation preserves the caller's exact reason; + bugreports 600 s. Output capture overflow reports `resource_constraint` + and names the byte allowance. Deadline expiration, including the + client-version probe, reports `provider_timeout` and names the effective + milliseconds. This release has no runtime override for those deadlines or + capture caps. Command cancellation preserves the caller's exact reason; a later abort does not replace an already completed command failure. - Pull and capture outputs are additive: the destination must not already exist. Re-running a completed pull refuses the existing directory instead diff --git a/docs/android-analysis.md b/docs/android-analysis.md index 50de2ad6c0..daa588ab72 100644 --- a/docs/android-analysis.md +++ b/docs/android-analysis.md @@ -335,9 +335,12 @@ export REA_JADX_OPERATION_TIMEOUT_MS=600000 ``` The value must be a decimal millisecond count from 1 to 2147483647; anything -else is refused before Java starts. The same deadline bounds each upstream -call, a timeout reports the selected value, and a passing JADX configuration -check in `rea doctor` reports the effective `operation_timeout_ms`. Explicit +else is refused before Java starts. REA reads it from the environment captured +at process start. A change applies on the next process start; an already +running server keeps the deadline it started with. The same deadline bounds +each upstream call. A timeout names `REA_JADX_OPERATION_TIMEOUT_MS`, the +effective milliseconds, and that range. A passing JADX configuration check in +`rea doctor` reports the effective `operation_timeout_ms`. Explicit cancellation still ends the operation immediately, and partial observations and owned-process cleanup are unchanged. Raising the deadline does not extend the bridge's 30-second startup handshake or the upstream **90-second** diff --git a/docs/apktool-resource-analysis.md b/docs/apktool-resource-analysis.md index a2feff2169..87972ec0c9 100644 --- a/docs/apktool-resource-analysis.md +++ b/docs/apktool-resource-analysis.md @@ -65,6 +65,7 @@ rea decode-android-resources /targets/Example.apk --locale de rea decode-android-resources /targets/Example.apk --locales de --locales pt-BR rea decode-android-resources /targets/Example.apk --locales de --locales pt-BR --string-tables-only rea decode-android-resources /targets/Example.apk --no-strings +rea decode-android-resources /targets/Example.apk --decode-timeout-ms 900000 ``` Equivalent MCP methods are `inspect_apktool_client` and @@ -133,9 +134,14 @@ still suppresses all string projection, including in this mode. ## Resource and lifecycle limits -- The decode runs with a 600 s deadline and a 4 MiB output capture budget; - a stopped decode reports `provider_timeout` and exceeded budgets report - `resource_constraint`. +- The decode wall clock is `decode_timeout_ms` (CLI `--decode-timeout-ms`). + Omitted means 600000 ms. The integer range is 1 to 2147483647. A change + applies on the next decode. It does not raise the 4194304-byte (4 MiB) + output capture budget. That capture budget has no runtime override. A + stopped decode reports `provider_timeout` and names the effective + milliseconds; an exceeded capture budget reports `resource_constraint`. +- The version probe waits 20000 ms and retains 65536 bytes. This release has + no runtime override for that probe. `decode_timeout_ms` does not raise it. - Manifest reads are bounded to 512 KiB before decoding UTF-8. Strings XML reads are bounded to 8 MiB; larger documents are skipped with partial coverage and an explicit limit, rather than reported as missing. diff --git a/scripts/lib/test-projects.mjs b/scripts/lib/test-projects.mjs index 9d73487cee..724fb18464 100644 --- a/scripts/lib/test-projects.mjs +++ b/scripts/lib/test-projects.mjs @@ -17,7 +17,7 @@ export const SOURCE_TEST_PROJECTS = [ group: "source", include: [ "src/*.test.ts", - "src/{apktool,jeb,adb,artifacts,browser,dotnet,electron,evm,firmware,flutter,pcap,ghidra,go,gpu,hopper,ida,inspector,javascript,native,process,reference,rust,server,sqlite,wasm,windows}/**/*.test.ts", + "src/{android,apktool,jeb,adb,artifacts,browser,dotnet,electron,evm,firmware,flutter,pcap,ghidra,go,gpu,hopper,ida,inspector,javascript,native,process,reference,rust,server,sqlite,wasm,windows}/**/*.test.ts", ], }, { diff --git a/src/adb/AdbCommand.test.ts b/src/adb/AdbCommand.test.ts index 33123950a9..c9628dccb5 100644 --- a/src/adb/AdbCommand.test.ts +++ b/src/adb/AdbCommand.test.ts @@ -217,9 +217,44 @@ it.each([true, false])( ? "live output may change between requests" : "Repeating the unchanged request fails the same way.", ); + expect(projected.remediation.action).toContain("1024 bytes"); + expect(projected.remediation.action).toContain("no runtime override"); }, ); +it("names the effective adb deadline and says it has no runtime override", async () => { + vi.mocked(execFileBytes).mockRejectedValueOnce( + Object.assign(new Error("deadline"), { + code: "ETIMEDOUT", + stdout: "", + stderr: "", + signal: null, + killed: true, + }), + ); + const result = await runAdbCapture( + "pull_adb_file", + { device: "device" }, + "/host/adb", + ["pull", "/remote"], + { timeoutMs: 600_000, maxOutputBytes: 1024 }, + undefined, + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected an adb deadline"); + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ + code: "provider_timeout", + details: { timeout_ms: 600_000 }, + }); + expect(projected.message).toContain("600000 ms"); + expect(projected.message).toContain("no runtime override"); + expect(projected.remediation.action).toContain("no runtime override"); + expect(projected.remediation.action).not.toContain( + "REA_JADX_OPERATION_TIMEOUT_MS", + ); +}); + it("keeps a physical failure even when the later abort reason is that same error", async () => { const controller = new AbortController(); const failure = new Error("physical command failure"); diff --git a/src/adb/AdbCommand.ts b/src/adb/AdbCommand.ts index b1859e385c..c3c9f74275 100644 --- a/src/adb/AdbCommand.ts +++ b/src/adb/AdbCommand.ts @@ -138,23 +138,25 @@ export const runAdbCapture = async ( return err( new AnalysisProtocolError(`adb failed for ${operation}`, { cause }), ); - if (failure.outputTruncated) + if (failure.outputTruncated) { + const name = `The adb output capture allowance of ${String(limits.maxOutputBytes)} bytes (this release has no runtime override)`; + const allowance = + limits.volatileOutput === true + ? { + fixedAllowance: { name, kind: "live" as const }, + retryable: true as const, + } + : fixedAllowanceOptions(name, "capture"); return err( new AnalysisResourceConstraintError( operation, "memory", `adb output exceeded the ${String(limits.maxOutputBytes)}-byte capture budget`, { max_output_bytes: limits.maxOutputBytes }, - { - cause, - ...fixedAllowanceOptions( - "The adb output capture allowance", - limits.volatileOutput === true ? "live" : "capture", - ), - ...(limits.volatileOutput === true ? { retryable: true } : {}), - }, + { cause, ...allowance }, ), ); + } const stderr = failure.stderr.trim(); const excerpt = stderr.slice(0, STDERR_EXCERPT_CHARACTERS); // Device-state words inside a reported file name are not device verdicts. @@ -212,7 +214,11 @@ export const runAdbCapture = async ( } if (failure.killed) return err( - new AnalysisTimeoutError(operation, limits.timeoutMs, { cause }), + new AnalysisTimeoutError(operation, limits.timeoutMs, { + cause, + userMessage: `adb ${operation} reached its ${String(limits.timeoutMs)} ms deadline (deadlineExpired). This release has no runtime override.`, + remediationAction: `This ${String(limits.timeoutMs)} ms adb deadline has no runtime override. Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`, + }), ); return err( new AdbProtocolError( diff --git a/src/adb/AdbConfiguration.ts b/src/adb/AdbConfiguration.ts index 1de39f9c09..4872bfe531 100644 --- a/src/adb/AdbConfiguration.ts +++ b/src/adb/AdbConfiguration.ts @@ -141,7 +141,7 @@ export const inspectAdbClient = async ( ? {} : { capturedOutput: adbCommandCapturedOutput(failure) }), }; - if (failure?.outputTruncated === true) + if (failure?.outputTruncated === true) { throw new AnalysisResourceConstraintError( "inspect_adb_client", "memory", @@ -150,17 +150,18 @@ export const inspectAdbClient = async ( { ...options, ...fixedAllowanceOptions( - "The adb version-output capture allowance", + `The adb version-output capture allowance of ${String(VERSION_MAX_OUTPUT_BYTES)} bytes (this release has no runtime override)`, "capture", ), }, ); + } if (failure?.code === "ETIMEDOUT") - throw new AnalysisTimeoutError( - "inspect_adb_client", - VERSION_TIMEOUT_MS, - options, - ); + throw new AnalysisTimeoutError("inspect_adb_client", VERSION_TIMEOUT_MS, { + ...options, + userMessage: `adb version probe reached its ${String(VERSION_TIMEOUT_MS)} ms deadline (deadlineExpired). This release has no runtime override.`, + remediationAction: `This ${String(VERSION_TIMEOUT_MS)} ms adb version probe has no runtime override. Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`, + }); if (failure?.code === "ENOENT") throw new AdbConfigurationFailure( "binary_missing", diff --git a/src/adb/AdbVersionProbe.test.ts b/src/adb/AdbVersionProbe.test.ts new file mode 100644 index 0000000000..ef1048bcda --- /dev/null +++ b/src/adb/AdbVersionProbe.test.ts @@ -0,0 +1,62 @@ +import { expect, it, vi } from "vitest"; + +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { AnalysisTimeoutError } from "../domain/analysisErrorCore.js"; +import { execFileOutput } from "../process/ExecFileOutput.js"; +import { inspectAdbClient } from "./AdbConfiguration.js"; + +vi.mock("../process/ExecFileOutput.js", async (importOriginal) => ({ + ...(await importOriginal()), + execFileOutput: vi.fn(), +})); + +const selection = { + binary: "/host/adb", + pathSource: "environment", +} as const; + +it("names the adb version-probe deadline and says it has no runtime override", async () => { + vi.mocked(execFileOutput).mockRejectedValueOnce( + Object.assign(new Error("deadline"), { + code: "ETIMEDOUT", + stdout: "", + stderr: "", + signal: null, + killed: true, + }), + ); + const failure = await inspectAdbClient(selection).catch( + (cause: unknown) => cause, + ); + expect(failure).toBeInstanceOf(AnalysisTimeoutError); + const projected = projectAnalysisError(failure as AnalysisTimeoutError); + expect(projected).toMatchObject({ + code: "provider_timeout", + details: { timeout_ms: 10_000 }, + }); + expect(projected.message).toContain("10000 ms"); + expect(projected.message).toContain("no runtime override"); + expect(projected.remediation.action).toContain("no runtime override"); +}); + +it("names the adb version-output cap and says it has no runtime override", async () => { + vi.mocked(execFileOutput).mockRejectedValueOnce( + Object.assign(new Error("too much"), { + code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", + stdout: "", + stderr: "", + signal: null, + killed: false, + outputTruncated: true, + }), + ); + const failure = await inspectAdbClient(selection).catch( + (cause: unknown) => cause, + ); + const projected = projectAnalysisError( + failure as Parameters[0], + ); + expect(projected.message).toContain("65536-byte"); + expect(projected.remediation.action).toContain("65536 bytes"); + expect(projected.remediation.action).toContain("no runtime override"); +}); diff --git a/src/android/JadxConfiguration.ts b/src/android/JadxConfiguration.ts index ddeeb2cb6c..ae93b732b1 100644 --- a/src/android/JadxConfiguration.ts +++ b/src/android/JadxConfiguration.ts @@ -33,6 +33,7 @@ import { ZipArtifactReader } from "../artifacts/ZipArtifactReader.js"; import type { DoctorProviderCheck } from "../application/Doctor.js"; import { DEFAULT_JADX_OPERATION_TIMEOUT_MS, + jadxOperationTimeoutGuidance, JADX_JAR_CONFIGURATION_REMEDIATION, JADX_JAVA_PROBE_REMEDIATION, JADX_JAVA_RUNTIME_REMEDIATION, @@ -483,6 +484,7 @@ const archiveCancellation = ( ? new AnalysisTimeoutError(operation, options.operationDeadline.timeoutMs, { cause: reason, diagnostics, + ...jadxOperationTimeoutGuidance(options.operationDeadline.timeoutMs), }) : new AnalysisCancelledError(operation, { cause: reason, diagnostics }); }; diff --git a/src/android/JadxFailure.ts b/src/android/JadxFailure.ts index 6fc1c4aa45..2e934e15c3 100644 --- a/src/android/JadxFailure.ts +++ b/src/android/JadxFailure.ts @@ -1,3 +1,4 @@ +import { jadxOperationTimeoutGuidance } from "./JadxRelease.js"; import { JadxInterruption } from "./JadxInterruption.js"; import { z } from "zod"; import { AnalysisError } from "../domain/analysisErrorBase.js"; @@ -108,6 +109,7 @@ const observeJadxFailure = ( : timeout.aborted && cancellation.reason === timeout.reason ? new AnalysisTimeoutError(request.operation, timeoutMs, { cause: cancellation.reason, + ...jadxOperationTimeoutGuidance(timeoutMs), }) : new AnalysisCancelledError(request.operation, { cause: cancellation.reason, @@ -185,11 +187,10 @@ const presentDiagnosticFailure = ( if (failure.kind === "cancelled") return new AnalysisCancelledError(failure.operation, options); if (failure.kind === "timeout") - return new AnalysisTimeoutError( - failure.operation, - failure.timeoutMs, - options, - ); + return new AnalysisTimeoutError(failure.operation, failure.timeoutMs, { + ...options, + ...jadxOperationTimeoutGuidance(failure.timeoutMs), + }); if (failure.kind === "output") return new AnalysisOutputError(failure.operation, failure.reason, options); if (failure.kind === "startup") { diff --git a/src/android/JadxJavaProbe.ts b/src/android/JadxJavaProbe.ts index 7ebf769219..e37d0594bb 100644 --- a/src/android/JadxJavaProbe.ts +++ b/src/android/JadxJavaProbe.ts @@ -22,6 +22,7 @@ import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js"; import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js"; import { retainOwnedCommandCleanup } from "../providers/OwnedCommandCleanup.js"; import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js"; +import { jadxOperationTimeoutGuidance } from "./JadxRelease.js"; const JAVA_MODULE_LISTING_TIMEOUT_MS = 10_000; const JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES = 1024 * 1024; @@ -184,7 +185,12 @@ export const inspectJadxJava = async ( ? new AnalysisTimeoutError( operation, options.operationDeadline.timeoutMs, - errorOptions, + { + ...errorOptions, + ...jadxOperationTimeoutGuidance( + options.operationDeadline.timeoutMs, + ), + }, ) : new AnalysisCancelledError(operation, errorOptions); } else if ( @@ -269,9 +275,9 @@ const commandProbeRefusal = ( : "runtime_unavailable", kind: systemCode === "ENOENT" ? "java_runtime" : "java_probe", message: limited - ? `The selected Java launcher ${java} exceeded the ${JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES / (1024 * 1024)} MiB output budget while listing modules. REA retained partial output but could not establish compiler or version availability. Inspect the selected launcher for verbose or unbounded output; java --list-modules should return a concise module listing.` + ? `The selected Java launcher ${java} exceeded the ${String(JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES)}-byte module-listing output budget. This release has no runtime override. REA retained partial output but could not establish compiler or version availability. Inspect the selected launcher for verbose or unbounded output; java --list-modules should return a concise module listing. REA_JADX_OPERATION_TIMEOUT_MS does not raise this probe.` : timedOut - ? `The selected Java launcher ${java} did not finish the module-listing probe within ${timeoutMs / 1000} seconds. Inspect its startup responsiveness and verify it with java --list-modules.` + ? `The selected Java launcher ${java} did not finish the module-listing probe within ${String(timeoutMs)} ms. This release has no runtime override. Inspect its startup responsiveness and verify it with java --list-modules. REA_JADX_OPERATION_TIMEOUT_MS does not raise this probe.` : `Cannot inspect selected Java runtime ${java}: ${systemCode === "ENOENT" ? "spawn java ENOENT" : cause instanceof Error ? cause.message : cause === undefined ? `exit ${String(output?.exit_code ?? "unknown")}${output?.signal == null ? "" : ` (${output.signal})`}` : String(cause)}. Review the selected launcher and retained diagnostics.`, diagnostics: { java_executable: java, diff --git a/src/android/JadxOperationTimeout.test.ts b/src/android/JadxOperationTimeout.test.ts new file mode 100644 index 0000000000..704c92f050 --- /dev/null +++ b/src/android/JadxOperationTimeout.test.ts @@ -0,0 +1,35 @@ +import { expect, it } from "vitest"; + +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { captureJadxFailure } from "./JadxFailure.js"; +import { jadxOperationTimeoutGuidance } from "./JadxRelease.js"; + +it("names REA_JADX_OPERATION_TIMEOUT_MS when the operation deadline elapses", () => { + const timeout = AbortSignal.abort(new Error("deadline")); + const present = captureJadxFailure({ + cause: timeout.reason, + request: { + operation: "inspect_android_package", + input: { path: "/targets/app.apk" }, + }, + timeout, + timeoutMs: 1500, + signal: undefined, + session: undefined, + }); + const projected = projectAnalysisError(present()); + const guidance = jadxOperationTimeoutGuidance(1500); + expect(projected.message).toBe(guidance.userMessage); + expect(projected.remediation.action).toBe(guidance.remediationAction); + expect(projected.message).toContain("1500 ms"); + expect(projected.remediation.action).toContain( + "REA_JADX_OPERATION_TIMEOUT_MS", + ); + expect(projected.remediation.action).toContain("1 to 2147483647"); + expect(projected.remediation.action).toContain("120000"); + expect(projected.remediation.action).toContain("next process start"); + expect(projected).toMatchObject({ + code: "provider_timeout", + details: { timeout_ms: 1500 }, + }); +}); diff --git a/src/android/JadxRelease.ts b/src/android/JadxRelease.ts index e408057af3..7b238443a6 100644 --- a/src/android/JadxRelease.ts +++ b/src/android/JadxRelease.ts @@ -29,6 +29,14 @@ export const DEFAULT_JADX_OPERATION_TIMEOUT_MS = 120_000; /** Remediation when the JADX operation deadline override is malformed. */ export const JADX_OPERATION_TIMEOUT_REMEDIATION = `Set REA_JADX_OPERATION_TIMEOUT_MS to a decimal millisecond count from 1 to 2147483647, or unset it to keep the ${String(DEFAULT_JADX_OPERATION_TIMEOUT_MS)} ms default.`; +/** Name the launch-environment deadline when a JADX operation reaches it. */ +export const jadxOperationTimeoutGuidance = ( + timeoutMs: number, +): { readonly userMessage: string; readonly remediationAction: string } => ({ + userMessage: `JADX reached its configured operation deadline of ${String(timeoutMs)} ms (deadlineExpired).`, + remediationAction: `Set REA_JADX_OPERATION_TIMEOUT_MS in REA's launch environment to an integer from 1 to 2147483647 milliseconds (omitted means ${String(DEFAULT_JADX_OPERATION_TIMEOUT_MS)} ms). A change applies on the next process start. The effective deadline for this attempt was ${String(timeoutMs)} ms. It does not raise the bridge's 30000 ms startup handshake. Raising only the MCP client deadline does not change this budget. Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`, +}); + /** Remediation when the host cannot run the Android JADX subprocess. */ export const JADX_UNSUPPORTED_HOST_REMEDIATION = "Use Linux, macOS, or Windows x64 with its bundled native controls for Android JADX analysis."; diff --git a/src/apktool/ApktoolConfiguration.ts b/src/apktool/ApktoolConfiguration.ts index 97010f9b06..30df654db7 100644 --- a/src/apktool/ApktoolConfiguration.ts +++ b/src/apktool/ApktoolConfiguration.ts @@ -37,6 +37,14 @@ export const APKTOOL_COMMAND_ENV = "REA_APKTOOL_COMMAND"; const VERSION_TIMEOUT_MS = 20_000; const VERSION_MAX_OUTPUT_BYTES = 64 * 1024; +const apktoolVersionProbeTimeoutGuidance = (): { + readonly userMessage: string; + readonly remediationAction: string; +} => ({ + userMessage: `apktool version probe reached its ${String(VERSION_TIMEOUT_MS)} ms deadline (deadlineExpired). This release has no runtime override.`, + remediationAction: `This ${String(VERSION_TIMEOUT_MS)} ms apktool version probe has no runtime override. decode_timeout_ms does not raise it. Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`, +}); + /** How the apktool launcher was selected for this provider instance. */ export interface ApktoolCommandSelection { readonly command: string; @@ -102,6 +110,7 @@ export const resolveApktoolCommand = async ( }) : new AnalysisTimeoutError("inspect_apktool_client", VERSION_TIMEOUT_MS, { diagnostics: { phase: "windows_path_lookup" }, + ...apktoolVersionProbeTimeoutGuidance(), }); try { for (const entry of directories) { @@ -501,7 +510,11 @@ const versionProbeFailure = ( return new AnalysisTimeoutError( "inspect_apktool_client", VERSION_TIMEOUT_MS, - { cause, ...(output === undefined ? {} : { capturedOutput: output }) }, + { + cause, + ...(output === undefined ? {} : { capturedOutput: output }), + ...apktoolVersionProbeTimeoutGuidance(), + }, ); if ( cause instanceof Error && @@ -519,9 +532,13 @@ const versionProbeFailure = ( { cause }, ); const snapshot = cause instanceof OwnedCommandFailure ? cause.snapshot : null; + const outputLimited = + cause instanceof OwnedCommandFailure && cause.reason === "output-limit"; return new ApktoolConfigurationFailure( "version_unresolved", - "The selected apktool launcher did not report a usable version", + outputLimited + ? `apktool version output exceeded the ${String(VERSION_MAX_OUTPUT_BYTES)}-byte capture budget. This release has no runtime override. decode_timeout_ms does not raise it.` + : "The selected apktool launcher did not report a usable version", { selected_command: selection.command, ...(observed === undefined diff --git a/src/apktool/ApktoolDecodeTimeout.test.ts b/src/apktool/ApktoolDecodeTimeout.test.ts new file mode 100644 index 0000000000..3246d9d8d5 --- /dev/null +++ b/src/apktool/ApktoolDecodeTimeout.test.ts @@ -0,0 +1,153 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, it, vi } from "vitest"; + +import { ApktoolResourceAnalysisService } from "../application/apktool/ApktoolResourceAnalysisService.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { + DEFAULT_APKTOOL_DECODE_TIMEOUT_MS, + MAX_APKTOOL_DECODE_TIMEOUT_MS, +} from "../domain/apktool/apktoolResourceAnalysis.js"; +import { + OwnedCommandFailure, + runOwnedCommand, +} from "../process/OwnedCommand.js"; +import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js"; +import { ApktoolProvider } from "./ApktoolProvider.js"; + +vi.mock("../process/OwnedCommand.js", async (importOriginal) => ({ + ...(await importOriginal()), + runOwnedCommand: vi.fn(), +})); + +const YML = [ + "!!brut.androlib.meta.MetaInfo", + "sdkInfo:", + " minSdkVersion: '24'", + " targetSdkVersion: '34'", + "versionInfo:", + " versionCode: '7'", + " versionName: 1.2.3", + "", +].join("\n"); +const MANIFEST = [ + '', + '', + "", +].join("\n"); + +const decodeTimeouts: number[] = []; +const outputBudgets: number[] = []; + +afterEach(() => { + decodeTimeouts.length = 0; + outputBudgets.length = 0; + vi.mocked(runOwnedCommand).mockReset(); +}); + +const completed = (text: string): ProviderProcessSnapshot => + ({ + stdout: { text }, + stderr: { text: "" }, + }) as ProviderProcessSnapshot; + +const installLauncher = (): void => { + vi.mocked(runOwnedCommand).mockImplementation(async (spawn, limits) => { + if (spawn.arguments[0] === "--version") { + const output = completed("2.9.3\n"); + limits.observeCompleted?.(output); + return output; + } + decodeTimeouts.push(limits.timeoutMs); + outputBudgets.push(limits.diagnosticBytes); + // Projects the deadline sentence only. A real launcher expiry and its + // process-tree cleanup are covered by apktoolDecodeDeadline.test.ts. + if ( + limits.timeoutMs === 200 || + limits.timeoutMs === MAX_APKTOOL_DECODE_TIMEOUT_MS + ) + throw new OwnedCommandFailure("timeout", "Command deadline elapsed."); + const outputIndex = spawn.arguments.indexOf("-o"); + const workspace = spawn.arguments[outputIndex + 1]; + if (workspace === undefined) throw new Error("decode output was not named"); + await mkdir(workspace, { recursive: true }); + await writeFile(join(workspace, "apktool.yml"), YML); + await writeFile(join(workspace, "AndroidManifest.xml"), MANIFEST); + return completed("I: Using Apktool on stub\n"); + }); +}; + +const decode = async ( + provider: ApktoolProvider, + path: string, + decodeTimeoutMs?: number, +) => + new ApktoolResourceAnalysisService(provider).execute( + "decode_android_resources", + { + path, + include_strings: false, + ...(decodeTimeoutMs === undefined + ? {} + : { decode_timeout_ms: decodeTimeoutMs }), + }, + ); + +it("applies the omitted 600000 ms decode deadline and a selected value on the next decode", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-apktool-deadline-")); + const path = join(root, "probe.apk"); + await writeFile(path, "apk-bytes"); + installLauncher(); + const provider = new ApktoolProvider({ + environment: { REA_APKTOOL_COMMAND: "/opt/apktool/apktool" }, + }); + try { + const omitted = await decode(provider, path); + expect(omitted.ok).toBe(true); + if (!omitted.ok) return; + expect(omitted.value.parameters).not.toHaveProperty("decode_timeout_ms"); + expect(decodeTimeouts).toEqual([DEFAULT_APKTOOL_DECODE_TIMEOUT_MS]); + expect(outputBudgets).toEqual([4 * 1024 * 1024]); + + const timedOut = await decode(provider, path, 200); + expect(timedOut.ok).toBe(false); + if (timedOut.ok) throw new Error("Expected a decode deadline"); + const projected = projectAnalysisError(timedOut.error); + expect(projected).toMatchObject({ + code: "provider_timeout", + details: { timeout_ms: 200 }, + }); + expect(projected.message).toContain("200 ms"); + expect(projected.remediation.action).toContain("decode_timeout_ms"); + expect(projected.remediation.action).toContain( + String(MAX_APKTOOL_DECODE_TIMEOUT_MS), + ); + expect(projected.remediation.action).toContain("600000"); + expect(projected.remediation.action).toContain("next decode"); + expect(projected.remediation.action).toContain("no runtime override"); + expect(decodeTimeouts).toEqual([DEFAULT_APKTOOL_DECODE_TIMEOUT_MS, 200]); + + const selected = await decode(provider, path, 5_000); + expect(selected.ok).toBe(true); + if (!selected.ok) return; + expect(selected.value.parameters).toMatchObject({ + decode_timeout_ms: 5_000, + }); + expect(decodeTimeouts).toEqual([ + DEFAULT_APKTOOL_DECODE_TIMEOUT_MS, + 200, + 5_000, + ]); + + const maximum = await decode(provider, path, MAX_APKTOOL_DECODE_TIMEOUT_MS); + expect(maximum.ok).toBe(false); + if (maximum.ok) throw new Error("Expected the maximum decode deadline"); + expect(projectAnalysisError(maximum.error).remediation.action).toContain( + "do not increase it further", + ); + } finally { + await provider.close(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/src/apktool/ApktoolProvider.ts b/src/apktool/ApktoolProvider.ts index 97a3cef52e..e4533f11fa 100644 --- a/src/apktool/ApktoolProvider.ts +++ b/src/apktool/ApktoolProvider.ts @@ -32,6 +32,8 @@ import { } from "../application/AnalysisProvider.js"; import { apktoolResultSchemas, + DEFAULT_APKTOOL_DECODE_TIMEOUT_MS, + MAX_APKTOOL_DECODE_TIMEOUT_MS, type ApktoolRequest, type ApktoolDecodeResult, } from "../domain/apktool/apktoolResourceAnalysis.js"; @@ -77,12 +79,22 @@ export const APKTOOL_PROVIDER_IDENTITY = { version: null, } as const; -const DECODE_TIMEOUT_MS = 600_000; const DECODE_MAX_OUTPUT_BYTES = 4 * 1024 * 1024; const MANIFEST_MAX_BYTES = 512 * 1024; const MAX_DECODED_ENTRIES_VISITED = 100_000; const STDERR_EXCERPT_BYTES = 512; +const apktoolDecodeTimeoutGuidance = ( + timeoutMs: number, +): { readonly userMessage: string; readonly remediationAction: string } => ({ + userMessage: `apktool decode reached its ${String(timeoutMs)} ms wall-clock deadline (deadlineExpired).`, + remediationAction: `${ + timeoutMs < MAX_APKTOOL_DECODE_TIMEOUT_MS + ? `Increase decode_timeout_ms above ${String(timeoutMs)} (integer milliseconds from 1 to ${String(MAX_APKTOOL_DECODE_TIMEOUT_MS)}; omitted means ${String(DEFAULT_APKTOOL_DECODE_TIMEOUT_MS)}), or use rea decode-android-resources --decode-timeout-ms on the CLI.` + : `decode_timeout_ms is already at the maximum ${String(MAX_APKTOOL_DECODE_TIMEOUT_MS)} ms; do not increase it further.` + } A change applies on the next decode. It does not raise the ${String(DECODE_MAX_OUTPUT_BYTES)}-byte output capture allowance or the version probe; both have no runtime override. Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`, +}); + const throwIfCancelled = ( operation: string, signal: AbortSignal | undefined, @@ -185,8 +197,9 @@ const apktoolCommandFailure = ( return new AnalysisTimeoutError(operation, limits.timeoutMs, { cause, ...(capturedOutput === undefined ? {} : { capturedOutput }), + ...apktoolDecodeTimeoutGuidance(limits.timeoutMs), }); - if (cause.reason === "output-limit") + if (cause.reason === "output-limit") { return new AnalysisResourceConstraintError( operation, "memory", @@ -196,11 +209,12 @@ const apktoolCommandFailure = ( cause, ...(capturedOutput === undefined ? {} : { capturedOutput }), ...fixedAllowanceOptions( - "The apktool output capture allowance", + `The apktool output capture allowance of ${String(limits.maxOutputBytes)} bytes (this release has no runtime override; decode_timeout_ms does not raise it)`, "capture", ), }, ); + } // A physical launcher/capture failure does not establish target refusal. // Only a fully collected ordinary nonzero decode exit carries that meaning. if ( @@ -564,7 +578,8 @@ export class ApktoolProvider { snapshot, ], { - timeoutMs: DECODE_TIMEOUT_MS, + timeoutMs: + input.decode_timeout_ms ?? DEFAULT_APKTOOL_DECODE_TIMEOUT_MS, maxOutputBytes: DECODE_MAX_OUTPUT_BYTES, }, signal, diff --git a/src/apktool/ApktoolVersionProbe.test.ts b/src/apktool/ApktoolVersionProbe.test.ts index de61f2d5b8..9ad637590b 100644 --- a/src/apktool/ApktoolVersionProbe.test.ts +++ b/src/apktool/ApktoolVersionProbe.test.ts @@ -72,11 +72,16 @@ it("reports an expired version probe as a typed timeout", async () => { platform: "linux", }).catch((cause: unknown) => cause); expect(failure).toBeInstanceOf(AnalysisTimeoutError); - expect(projectAnalysisError(failure as AnalysisTimeoutError)).toMatchObject({ + const projected = projectAnalysisError(failure as AnalysisTimeoutError); + expect(projected).toMatchObject({ code: "provider_timeout", category: "timeout", details: { timeout_ms: 20_000 }, }); + expect(projected.message).toContain("20000 ms"); + expect(projected.message).toContain("no runtime override"); + expect(projected.remediation.action).toContain("decode_timeout_ms"); + expect(projected.remediation.action).toContain("no runtime override"); }); it("retains an unresolved probe cleanup owner and reports the cleanup", async () => { diff --git a/src/cli/apktoolCommands.ts b/src/cli/apktoolCommands.ts index 43b65f552f..26eb0c885b 100644 --- a/src/cli/apktoolCommands.ts +++ b/src/cli/apktoolCommands.ts @@ -61,6 +61,8 @@ export const registerApktoolCommands = ( apktoolInputSchemas.decode_android_resources.shape.locales.describe( "Repeat to project multiple localized string tables, e.g. --locales de --locales pt-BR", ), + decodeTimeoutMs: + apktoolInputSchemas.decode_android_resources.shape.decode_timeout_ms, strings: z .boolean() .default( @@ -81,6 +83,9 @@ export const registerApktoolCommands = ( : { string_tables_only: options["string-tables-only"] }), ...(options.locale === undefined ? {} : { locale: options.locale }), ...(options.locales === undefined ? {} : { locales: options.locales }), + ...(options.decodeTimeoutMs === undefined + ? {} + : { decode_timeout_ms: options.decodeTimeoutMs }), }), }); }; diff --git a/src/contracts/apktool/apktoolToolContracts.ts b/src/contracts/apktool/apktoolToolContracts.ts index 89af3ed92c..0848ca4ae8 100644 --- a/src/contracts/apktool/apktoolToolContracts.ts +++ b/src/contracts/apktool/apktoolToolContracts.ts @@ -22,7 +22,7 @@ export const APKTOOL_TOOL_CONTRACTS = [ name: "decode_android_resources", ...toolContractMetadata("decode_android_resources"), description: - "Decode one local APK's resources with `apktool d --no-src` in a provider-owned workspace that is removed after projection: the readable AndroidManifest.xml, apktool.yml version and SDK facts, the app's string resources (default or selected locales, bounded to 2000 entries per table), selectable string locales, and the decoded file count. The target APK is digest with SHA-256 before decoding; smali is skipped because Java-source recovery belongs to the JADX family.", + "Decode one local APK's resources with `apktool d --no-src` in a provider-owned workspace that is removed after projection: the readable AndroidManifest.xml, apktool.yml version and SDK facts, the app's string resources (default or selected locales, bounded to 2000 entries per table), selectable string locales, and the decoded file count. The target APK is digest with SHA-256 before decoding; smali is skipped because Java-source recovery belongs to the JADX family. Optional decode_timeout_ms is the decode wall clock in milliseconds (default 600000, range 1..2147483647); omitted means 600000 and the value applies on the next decode. It does not raise the fixed output capture allowance or the version probe.", kind: "apktool-provider", inputSchema: apktoolInputSchemas.decode_android_resources, outputSchema: evidenceResultOf( diff --git a/src/domain/apktool/apktoolResourceAnalysis.test.ts b/src/domain/apktool/apktoolResourceAnalysis.test.ts index 4a0fb2e089..7cad51191f 100644 --- a/src/domain/apktool/apktoolResourceAnalysis.test.ts +++ b/src/domain/apktool/apktoolResourceAnalysis.test.ts @@ -2,7 +2,10 @@ import { expect, it } from "vitest"; import { androidLocaleDirectorySuffix, androidLocaleQualifierSchema, + apktoolInputSchemas, classifyAndroidStringsTable, + DEFAULT_APKTOOL_DECODE_TIMEOUT_MS, + MAX_APKTOOL_DECODE_TIMEOUT_MS, } from "./apktoolResourceAnalysis.js"; it.each([ @@ -81,3 +84,34 @@ it.each([ }); }, ); + +const decodeInput = apktoolInputSchemas.decode_android_resources; + +it("keeps 600000 ms as the omitted apktool decode deadline", () => { + const parsed = decodeInput.parse({ path: "/targets/app.apk" }); + expect(parsed).not.toHaveProperty("decode_timeout_ms"); + expect(DEFAULT_APKTOOL_DECODE_TIMEOUT_MS).toBe(600_000); + expect(decodeInput.shape.decode_timeout_ms.description).toContain("600000"); + expect(decodeInput.shape.decode_timeout_ms.description).toContain( + "next decode", + ); +}); + +it.each([1, 600_000, MAX_APKTOOL_DECODE_TIMEOUT_MS])( + "accepts decode_timeout_ms %s", + (decode_timeout_ms) => { + expect( + decodeInput.parse({ path: "/targets/app.apk", decode_timeout_ms }), + ).toMatchObject({ decode_timeout_ms }); + }, +); + +it.each([0, -1, 1.5, MAX_APKTOOL_DECODE_TIMEOUT_MS + 1])( + "rejects decode_timeout_ms %s", + (decode_timeout_ms) => { + expect( + decodeInput.safeParse({ path: "/targets/app.apk", decode_timeout_ms }) + .success, + ).toBe(false); + }, +); diff --git a/src/domain/apktool/apktoolResourceAnalysis.ts b/src/domain/apktool/apktoolResourceAnalysis.ts index 59e8624f0a..b1e4604896 100644 --- a/src/domain/apktool/apktoolResourceAnalysis.ts +++ b/src/domain/apktool/apktoolResourceAnalysis.ts @@ -6,6 +6,10 @@ export const MAX_STRINGS = 2_000; export const MAX_SELECTED_LOCALES = 20; export const MAX_AGGREGATE_STRINGS = 10_000; export const AGGREGATE_STRINGS_XML_MAX_BYTES = 32 * 1024 * 1024; +/** Decode wall clock used when decode_timeout_ms is omitted. */ +export const DEFAULT_APKTOOL_DECODE_TIMEOUT_MS = 600_000; +/** Largest delay supported without overflowing Node timers. */ +export const MAX_APKTOOL_DECODE_TIMEOUT_MS = 2_147_483_647; const ANDROID_NON_LOCALE_QUALIFIERS = new Set([ "car", "desk", @@ -91,6 +95,15 @@ export const apktoolInputSchemas = { .describe( `Project multiple localized string tables from res/values-/strings.xml alongside the default table (bounded to ${String(MAX_SELECTED_LOCALES)} locales)`, ), + decode_timeout_ms: z + .number() + .int() + .min(1) + .max(MAX_APKTOOL_DECODE_TIMEOUT_MS) + .exactOptional() + .describe( + `Decode wall-clock budget in milliseconds (default ${String(DEFAULT_APKTOOL_DECODE_TIMEOUT_MS)}), from apktool d start through process exit. Omitted means ${String(DEFAULT_APKTOOL_DECODE_TIMEOUT_MS)}. Applies on the next decode. Does not raise the fixed output capture allowance or the version probe.`, + ), }), } as const; diff --git a/tests/boundary/android/jadxConfiguration.test.ts b/tests/boundary/android/jadxConfiguration.test.ts index a7619730dd..ba8af4eca2 100644 --- a/tests/boundary/android/jadxConfiguration.test.ts +++ b/tests/boundary/android/jadxConfiguration.test.ts @@ -249,7 +249,8 @@ it("preserves exact Java probe output and marks max-buffer capture as truncated" ); expect(failure).toBeInstanceOf(AnalysisCapabilityUnavailableError); if (!(failure instanceof AnalysisCapabilityUnavailableError)) return; - expect(failure.reason).toContain("exceeded the 1 MiB output budget"); + expect(failure.reason).toContain("1048576-byte"); + expect(failure.reason).toContain("no runtime override"); expect(failure.reason).not.toContain("Select a full JDK"); expect(failure.capturedOutput).toMatchObject({ stdout: expect.stringMatching(/^x/u), @@ -359,7 +360,7 @@ it("reports a killed Java probe as unresolved instead of missing runtime", async expect(availability).toMatchObject({ status: "unavailable", code: "version_unresolved", - reason: expect.stringContaining("within 10 seconds"), + reason: expect.stringContaining("10000 ms"), diagnostics: { java_executable: java, failure: "probe_terminated", @@ -367,6 +368,7 @@ it("reports a killed Java probe as unresolved instead of missing runtime", async deadline_ms: 10_000, }, }); + expect(availability.reason).toContain("no runtime override"); expect(availability.reason).not.toContain("Select a full JDK"); }); diff --git a/tests/boundary/cli/apktoolBooleanOptions.test.ts b/tests/boundary/cli/apktoolBooleanOptions.test.ts index 9da4353112..311d863a8c 100644 --- a/tests/boundary/cli/apktoolBooleanOptions.test.ts +++ b/tests/boundary/cli/apktoolBooleanOptions.test.ts @@ -35,6 +35,40 @@ it("keeps string projection enabled when the flag is omitted", async () => { ]); }); +it("passes decode_timeout_ms only when the caller selects it", async () => { + const omitted = await invokeApktoolCli([ + "decode-android-resources", + "/targets/app.apk", + "--json", + ]); + expect(omitted.exitCode).toBe(0); + expect(omitted.requests[0]?.input).not.toHaveProperty("decode_timeout_ms"); + + const selected = await invokeApktoolCli([ + "decode-android-resources", + "/targets/app.apk", + "--decode-timeout-ms", + "900000", + "--json", + ]); + expect(selected.exitCode).toBe(0); + expect(selected.requests[0]?.input).toMatchObject({ + decode_timeout_ms: 900_000, + }); + + for (const value of ["0", "2147483648"]) { + const rejected = await invokeApktoolCli([ + "decode-android-resources", + "/targets/app.apk", + "--decode-timeout-ms", + value, + "--json", + ]); + expect(rejected.exitCode).not.toBe(0); + expect(rejected.requests).toEqual([]); + } +}); + it("documents the supported negative flag in command help", async () => { const result = await invokeApktoolCli(["decode-android-resources", "--help"]); diff --git a/tests/boundary/providers/apktoolDecodeDeadline.test.ts b/tests/boundary/providers/apktoolDecodeDeadline.test.ts new file mode 100644 index 0000000000..ef54e67341 --- /dev/null +++ b/tests/boundary/providers/apktoolDecodeDeadline.test.ts @@ -0,0 +1,143 @@ +import { execFile } from "node:child_process"; +import { chmod, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { performance } from "node:perf_hooks"; +import { setTimeout as delay } from "node:timers/promises"; +import { promisify } from "node:util"; +import { expect, it } from "vitest"; + +import { ApktoolProvider } from "../../../src/apktool/ApktoolProvider.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +/** + * Only an exited process counts as stopped: it is gone, a zombie waiting to + * be reaped, or absent from `ps`. A failed inspection is not proof of exit. + */ +const alive = async (pid: number): Promise => { + try { + process.kill(pid, 0); + } catch (cause: unknown) { + if ((cause as NodeJS.ErrnoException).code === "ESRCH") return false; + return true; + } + return promisify(execFile)("ps", ["-o", "stat=", "-p", `${pid}`]).then( + ({ stdout }) => { + const state = stdout.trim(); + return state !== "" && !state.startsWith("Z"); + }, + // `ps` exits 1 when no process matches; any other failure is unknown. + (cause: unknown) => (cause as { code?: unknown }).code !== 1, + ); +}; + +const waitFor = async (check: () => Promise, ms: number) => { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + if (await check()) return true; + await delay(25); + } + return false; +}; + +const readPids = async (path: string): Promise => + readFile(path, "utf8") + .then((text) => JSON.parse(text) as number[]) + .catch(() => []); + +const DECODE_TIMEOUT_MS = 2_500; +const CHILD_SCRIPT = + "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);"; + +// Windows Job Object cleanup is a separate owned-command lane. This observes +// the POSIX process group the apktool decode launcher actually uses. +it.skipIf(process.platform === "win32")( + "stops a hanging launcher and its pipe-holding child when the decode deadline elapses", + async () => { + const root = await createTestTempDirectory("rea-apktool-decode-deadline-"); + const pidsPath = join(root, "pids.json"); + const launcher = join(root, "apktool"); + await writeFile( + launcher, + [ + `#!${process.execPath}`, + 'const { spawn } = require("node:child_process");', + 'const { writeFileSync } = require("node:fs");', + 'if (process.argv[2] === "--version") { process.stdout.write("2.9.3\\n"); process.exit(0); }', + 'if (process.argv[2] !== "d") { process.stderr.write("unexpected " + process.argv.slice(2).join(" ")); process.exit(1); }', + `const child = spawn(process.execPath, ["-e", ${JSON.stringify(CHILD_SCRIPT)}], { stdio: "inherit" });`, + "if (child.pid === undefined) process.exit(1);", + `writeFileSync(${JSON.stringify(pidsPath)}, JSON.stringify([process.pid, child.pid]));`, + 'process.on("SIGTERM", () => {});', + "setInterval(() => {}, 1000);", + "", + ].join("\n"), + ); + await chmod(launcher, 0o755); + const apk = join(root, "probe.apk"); + await writeFile(apk, "apk-bytes"); + const provider = new ApktoolProvider({ + environment: { REA_APKTOOL_COMMAND: launcher }, + }); + const started = performance.now(); + const pending = provider.execute({ + operation: "decode_android_resources", + input: { + path: apk, + include_strings: false, + decode_timeout_ms: DECODE_TIMEOUT_MS, + }, + }); + let launched: readonly number[] = []; + try { + expect( + await waitFor(async () => { + const pids = await readPids(pidsPath); + if (pids.length !== 2) return false; + launched = pids; + return (await Promise.all(pids.map(alive))).every(Boolean); + }, 8_000), + ).toBe(true); + const outcome = await Promise.race([ + pending, + delay(15_000).then(() => "still pending" as const), + ]); + if (outcome === "still pending") + throw new Error("apktool decode deadline did not settle"); + const elapsed = performance.now() - started; + expect(outcome.ok).toBe(false); + if (outcome.ok) return; + expect(outcome.error.cleanupIncomplete).toBe(false); + const projected = projectAnalysisError(outcome.error); + expect(projected).toMatchObject({ + code: "provider_timeout", + details: { timeout_ms: DECODE_TIMEOUT_MS }, + }); + expect(projected.message).toContain(`${String(DECODE_TIMEOUT_MS)} ms`); + expect(projected.remediation.action).toContain("decode_timeout_ms"); + expect(projected.remediation.action).toContain("next decode"); + // Settlement includes the owned-command deadline, so the timer elapsed. + expect(elapsed).toBeGreaterThanOrEqual(DECODE_TIMEOUT_MS - 50); + expect(elapsed).toBeLessThan(15_000); + expect( + await waitFor( + async () => + (await Promise.all(launched.map(alive))).every((live) => !live), + 5_000, + ), + ).toBe(true); + } finally { + const closing = provider.close(); + await Promise.race([pending, delay(10_000)]).catch(() => undefined); + for (const pid of await readPids(pidsPath)) + if (await alive(pid)) + try { + process.kill(pid, "SIGKILL"); + } catch { + // Already exited between the check and the signal. + } + await Promise.race([closing, delay(10_000)]).catch(() => undefined); + } + }, + 30_000, +);