[GH-ISSUE #5976] [Bug] inspect_asset_catalog reports assetutil deadlines as artifact I/O failures #3645

Closed
opened 2026-10-31 00:42:40 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @coygeek on GitHub (Oct 21, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/5976

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Evidence, artifacts, or reconstruction

Observed behavior

When the asset catalog subprocess reaches its deadline, inspect_asset_catalog loses timeout provenance and presents a non-retryable artifact I/O failure: artifact_operation_failed, category execution_failure, reason io, and “Check file access and try again.” The result has no timeout_ms or timeout recovery hint. A real controlled subprocess deadline reproduced this mapping through the production artifact provider. The failure is not reported as truncation on the normal Node timeout path.

Reproduction

Run the following focused test from a macOS source checkout with locked dependencies installed. Save it under src/artifacts/apple/assetCatalogFailureClassification.test.ts, then run:

node node_modules/vitest/vitest.mjs run src/artifacts/apple/assetCatalogFailureClassification.test.ts --maxWorkers=1

This test was executed with the same content except for the temporary fixture directory location. It exercises the production artifact provider and canonical error projection. The provider boundary is simulated: no native catalog validity, real assetutil output failure, or CLI/MCP transport result is asserted. The deadline case uses a real Node child and real execFileOutput timeout handling, shortening only the deadline to 100 ms after checking the production options specify 60,000 ms and no stopSignal. The other cases supply successful subprocess stdout. All five cases passed, including the valid [] control.

import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, it, expect, vi } from "vitest";
import { ArtifactProvider } from "../ArtifactProvider.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { execFileOutput } from "../../process/ExecFileOutput.js";

vi.mock("../../process/ExecFileOutput.js", async (importOriginal) => {
  const actual = await importOriginal<typeof import("../../process/ExecFileOutput.js")>();
  return { ...actual, execFileOutput: vi.fn(actual.execFileOutput) };
});
const actual = await vi.importActual<typeof import("../../process/ExecFileOutput.js")>("../../process/ExecFileOutput.js");
const scratch = tmpdir();
async function run() {
  const bundle = await mkdtemp(join(scratch, "fixture-"));
  await mkdir(join(bundle, "Contents", "Resources"), { recursive: true });
  await writeFile(join(bundle, "Contents", "Resources", "Assets.car"), "inert fixture");
  const client = new ArtifactProvider({}, "darwin").createBundleResourceClient({
    callerPath: bundle, canonicalPath: bundle,
    infoPlistPath: join(bundle, "Contents", "Info.plist"),
    layout: "contents", executableSha256: null,
  });
  try { return await client.execute("inspect_asset_catalog", {}); }
  finally { await client.close(); await rm(bundle, { recursive: true, force: true }); }
}
describe("assetutil failure classification", () => {
  it("observes the real non-stoppable subprocess timeout shape", async () => {
    let childCode: unknown = "not observed";
    vi.mocked(execFileOutput).mockImplementationOnce(async (command, args, options) => {
      expect(command).toBe("/usr/bin/assetutil");
      expect(args[0]).toBe("--info");
      expect(options?.timeout).toBe(60_000);
      expect(options?.stopSignal).toBeUndefined();
      try {
        return await actual.execFileOutput(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { ...options, timeout: 100 });
      } catch (cause) {
        childCode = Reflect.get(cause as object, "code");
        throw cause;
      }
    });
    const result = await run();
    expect(result.ok).toBe(false);
    expect(childCode).toBeNull();
    if (result.ok) throw new Error("expected failure");
    const projected = projectAnalysisError(result.error);
    console.log("actual-timeout", JSON.stringify(projected));
    expect(projected).toMatchObject({code: "artifact_operation_failed", category: "execution_failure", retryable: false, details: { reason: "io", detail: "assetutil failed (null)" }});
  });
  it.each(["not json", "{}", "[null]"])("classifies supplied output %s", async (stdout) => {
    vi.mocked(execFileOutput).mockResolvedValueOnce({ stdout, stderr: "", stdoutBytes: Buffer.byteLength(stdout), stderrBytes: 0 });
    const result = await run();
    expect(result.ok).toBe(false);
    if (result.ok) throw new Error("expected failure");
    const projected = projectAnalysisError(result.error);
    console.log("supplied-output", JSON.stringify({stdout, projected}));
    expect(projected).toMatchObject({code: "artifact_operation_failed", category: "execution_failure", retryable: false, details: {reason: "format"}});
  });
  it("accepts supplied valid empty output", async () => {
    vi.mocked(execFileOutput).mockResolvedValueOnce({stdout: "[]", stderr: "", stdoutBytes: 2, stderrBytes: 0});
    const result = await run();
    expect(result.ok).toBe(true);
  });
});

Expected behavior

Preserve an owned provider deadline as a timeout, including the operation and deadline, rather than diagnosing artifact file access. The error recovery contract describes provider_timeout as REA’s server/provider deadline. AnalysisTimeoutError and its canonical code mapping already express this condition. This does not imply a user-adjustable timeout setting.

Impact and scope

P2: affected deadline failures carry the wrong machine-readable classification and actionable recovery advice. Callers following retryable/category/recovery cannot recognize this as a provider deadline. The failure is bounded to the command-error path and leaves the inspected bytes unchanged. The controlled process timed out once and produced this result once. Real assetutil timeout frequency is unknown. Inspecting the underlying cause can reveal timeout-related child termination, but no public REA workaround was demonstrated.

REA version

rea-agents 6.7.0; source revision 52415975942a4b9c53a817915b313ccc404f280e. Latest npm version checked: 6.7.0. Fetched origin/main at 25e1a24a3ed3671a0148757555f18d16c29d936a leaves the producing paths unchanged; that newer revision was compared as source, not executed.

Runtime and platform

macOS 27.0.1 (26A434), arm64; Node.js v26.12.0; locked Vitest 5.0.3. No deep-analysis engine was involved.

Target and artifact context

Synthetic macOS-layout resource directory with one regular Contents/Resources/Assets.car holding inert fixture bytes. The app resources are inspected as data; no target code runs. Successful utility output is supplied at the subprocess boundary; this does not establish native catalog validity.

Interface

Other

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

Observed projection for the real controlled deadline:

{"code":"artifact_operation_failed","category":"execution_failure","message":"Artifact could not be read or written. Check file access and try again.","retryable":false,"remediation":{"action":"Artifact could not be read or written. Check file access and try again."},"details":{"operation":"inspect_asset_catalog","reason":"io","detail":"assetutil failed (null)"}}

The timed-out child returned code: null. The test checks the assetutil call is configured with timeout: 60000 and no stopSignal, then forwards those options to the controlled child with a 100 ms deadline. It does not fabricate an ETIMEDOUT error.

Typed outcome or recovery expected

A timeout-classified analysis error, normally AnalysisTimeoutError("inspect_asset_catalog", 60000), preserving the actual deadline in details.timeout_ms and canonical timeout recovery. Retain size/output-limit classification for genuine buffer exhaustion. Do not advise replacing the artifact or changing file permissions for a deadline.

Analysis or suspected cause

Source-derived: runAssetUtil requests a native Node timeout, while execFileOutput handling creates its explicit ETIMEDOUT only when stopSignal timeout handling runs; that handling is skipped without stopSignal. Native Node reports the demonstrated termination with code null, which falls through to artifact I/O. Even an ETIMEDOUT error would currently be mapped to limit, another incorrect timeout classification. Artifact failure translation also does not pass through AnalysisTimeoutError, so preserving the typed outcome requires considering that boundary.

Additional context

The runtime result comes from the production artifact-provider boundary, not a public CLI/MCP invocation. The CLI registration dispatches this operation through the same provider; that reachability is source-derived. No real-app prevalence, known-good revision, or software fix was established. Related #5698 covers a nonzero assetutil exit for damaged catalog bytes; #5111 concerns empty-inventory pagination; #5887 concerns skipped-catalog join coverage; #5635 and #2176 concern resource-key recognition; #3914/merged PR #4094 concern filename selection; PR #3285 concerns bundle-walk concurrency. Those fixes have different restoration signals.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/5976 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Evidence, artifacts, or reconstruction ### Observed behavior When the asset catalog subprocess reaches its deadline, `inspect_asset_catalog` loses timeout provenance and presents a non-retryable artifact I/O failure: `artifact_operation_failed`, category `execution_failure`, reason `io`, and “Check file access and try again.” The result has no `timeout_ms` or timeout recovery hint. A real controlled subprocess deadline reproduced this mapping through the production artifact provider. The failure is not reported as truncation on the normal Node timeout path. ### Reproduction Run the following focused test from a macOS source checkout with locked dependencies installed. Save it under `src/artifacts/apple/assetCatalogFailureClassification.test.ts`, then run: ```sh node node_modules/vitest/vitest.mjs run src/artifacts/apple/assetCatalogFailureClassification.test.ts --maxWorkers=1 ``` This test was executed with the same content except for the temporary fixture directory location. It exercises the production artifact provider and canonical error projection. The provider boundary is simulated: no native catalog validity, real assetutil output failure, or CLI/MCP transport result is asserted. The deadline case uses a real Node child and real execFileOutput timeout handling, shortening only the deadline to 100 ms after checking the production options specify 60,000 ms and no stopSignal. The other cases supply successful subprocess stdout. All five cases passed, including the valid `[]` control. ```ts import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { describe, it, expect, vi } from "vitest"; import { ArtifactProvider } from "../ArtifactProvider.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { execFileOutput } from "../../process/ExecFileOutput.js"; vi.mock("../../process/ExecFileOutput.js", async (importOriginal) => { const actual = await importOriginal<typeof import("../../process/ExecFileOutput.js")>(); return { ...actual, execFileOutput: vi.fn(actual.execFileOutput) }; }); const actual = await vi.importActual<typeof import("../../process/ExecFileOutput.js")>("../../process/ExecFileOutput.js"); const scratch = tmpdir(); async function run() { const bundle = await mkdtemp(join(scratch, "fixture-")); await mkdir(join(bundle, "Contents", "Resources"), { recursive: true }); await writeFile(join(bundle, "Contents", "Resources", "Assets.car"), "inert fixture"); const client = new ArtifactProvider({}, "darwin").createBundleResourceClient({ callerPath: bundle, canonicalPath: bundle, infoPlistPath: join(bundle, "Contents", "Info.plist"), layout: "contents", executableSha256: null, }); try { return await client.execute("inspect_asset_catalog", {}); } finally { await client.close(); await rm(bundle, { recursive: true, force: true }); } } describe("assetutil failure classification", () => { it("observes the real non-stoppable subprocess timeout shape", async () => { let childCode: unknown = "not observed"; vi.mocked(execFileOutput).mockImplementationOnce(async (command, args, options) => { expect(command).toBe("/usr/bin/assetutil"); expect(args[0]).toBe("--info"); expect(options?.timeout).toBe(60_000); expect(options?.stopSignal).toBeUndefined(); try { return await actual.execFileOutput(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { ...options, timeout: 100 }); } catch (cause) { childCode = Reflect.get(cause as object, "code"); throw cause; } }); const result = await run(); expect(result.ok).toBe(false); expect(childCode).toBeNull(); if (result.ok) throw new Error("expected failure"); const projected = projectAnalysisError(result.error); console.log("actual-timeout", JSON.stringify(projected)); expect(projected).toMatchObject({code: "artifact_operation_failed", category: "execution_failure", retryable: false, details: { reason: "io", detail: "assetutil failed (null)" }}); }); it.each(["not json", "{}", "[null]"])("classifies supplied output %s", async (stdout) => { vi.mocked(execFileOutput).mockResolvedValueOnce({ stdout, stderr: "", stdoutBytes: Buffer.byteLength(stdout), stderrBytes: 0 }); const result = await run(); expect(result.ok).toBe(false); if (result.ok) throw new Error("expected failure"); const projected = projectAnalysisError(result.error); console.log("supplied-output", JSON.stringify({stdout, projected})); expect(projected).toMatchObject({code: "artifact_operation_failed", category: "execution_failure", retryable: false, details: {reason: "format"}}); }); it("accepts supplied valid empty output", async () => { vi.mocked(execFileOutput).mockResolvedValueOnce({stdout: "[]", stderr: "", stdoutBytes: 2, stderrBytes: 0}); const result = await run(); expect(result.ok).toBe(true); }); }); ``` ### Expected behavior Preserve an owned provider deadline as a timeout, including the operation and deadline, rather than diagnosing artifact file access. [The error recovery contract](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/mcp-contracts.md#L243-L250) describes `provider_timeout` as REA’s server/provider deadline. [AnalysisTimeoutError](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/domain/analysisErrorCore.ts#L164-L187) and its [canonical code mapping](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/domain/analysisErrorProjection.ts#L244-L254) already express this condition. This does not imply a user-adjustable timeout setting. ### Impact and scope P2: affected deadline failures carry the wrong machine-readable classification and actionable recovery advice. Callers following retryable/category/recovery cannot recognize this as a provider deadline. The failure is bounded to the command-error path and leaves the inspected bytes unchanged. The controlled process timed out once and produced this result once. Real assetutil timeout frequency is unknown. Inspecting the underlying cause can reveal timeout-related child termination, but no public REA workaround was demonstrated. ### REA version `rea-agents` 6.7.0; source revision `52415975942a4b9c53a817915b313ccc404f280e`. Latest npm version checked: 6.7.0. Fetched `origin/main` at `25e1a24a3ed3671a0148757555f18d16c29d936a` leaves the producing paths unchanged; that newer revision was compared as source, not executed. ### Runtime and platform macOS 27.0.1 (26A434), arm64; Node.js v26.12.0; locked Vitest 5.0.3. No deep-analysis engine was involved. ### Target and artifact context Synthetic macOS-layout resource directory with one regular `Contents/Resources/Assets.car` holding inert fixture bytes. The app resources are inspected as data; no target code runs. Successful utility output is supplied at the subprocess boundary; this does not establish native catalog validity. ### Interface Other ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence Observed projection for the real controlled deadline: ```json {"code":"artifact_operation_failed","category":"execution_failure","message":"Artifact could not be read or written. Check file access and try again.","retryable":false,"remediation":{"action":"Artifact could not be read or written. Check file access and try again."},"details":{"operation":"inspect_asset_catalog","reason":"io","detail":"assetutil failed (null)"}} ``` The timed-out child returned `code: null`. The test checks the assetutil call is configured with `timeout: 60000` and no `stopSignal`, then forwards those options to the controlled child with a 100 ms deadline. It does not fabricate an ETIMEDOUT error. ### Typed outcome or recovery expected A timeout-classified analysis error, normally `AnalysisTimeoutError("inspect_asset_catalog", 60000)`, preserving the actual deadline in `details.timeout_ms` and canonical timeout recovery. Retain size/output-limit classification for genuine buffer exhaustion. Do not advise replacing the artifact or changing file permissions for a deadline. ### Analysis or suspected cause Source-derived: [runAssetUtil](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/artifacts/apple/AppleAssetCatalogAnalysis.ts#L260-L292) requests a native Node timeout, while [execFileOutput handling](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/process/ExecFileOutput.ts#L146-L174) creates its explicit ETIMEDOUT only when stopSignal timeout handling runs; [that handling is skipped without stopSignal](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/process/ExecFileOutput.ts#L186-L201). Native Node reports the demonstrated termination with code null, which falls through to artifact I/O. Even an ETIMEDOUT error would currently be mapped to `limit`, another incorrect timeout classification. [Artifact failure translation](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/artifacts/ArtifactOperationFailure.ts#L16-L49) also does not pass through AnalysisTimeoutError, so preserving the typed outcome requires considering that boundary. ### Additional context The runtime result comes from the production artifact-provider boundary, not a public CLI/MCP invocation. The CLI registration dispatches this operation through the same provider; that reachability is source-derived. No real-app prevalence, known-good revision, or software fix was established. Related #5698 covers a nonzero assetutil exit for damaged catalog bytes; #5111 concerns empty-inventory pagination; #5887 concerns skipped-catalog join coverage; #5635 and #2176 concern resource-key recognition; #3914/merged PR #4094 concern filename selection; PR #3285 concerns bundle-walk concurrency. Those fixes have different restoration signals.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#3645
No description provided.