[GH-ISSUE #6093] [Bug] Go inspection drops completed artifact facts after descriptor finalization fails #3729

Closed
opened 2026-10-31 00:43:26 +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/6093

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

A completed stable-file read is absent from the error returned by Go inspection when descriptor finalization fails or receives cancellation. Through the real GoBinaryService and GoBinaryProvider, an injected close EIO returned cleanup_incomplete and cancellation during successful close returned cancelled; both had no partialObservation or projected details.partial_observation. The lower file boundary still retained the completed bytes and SHA-256. The normal-close control returned Go metadata for the same input.

Reproduction

Executed a focused production service/provider test with real file handles, an authored 4096-byte ELF fixture and controlled descriptor finalization. The fixture was never executed. Set REA_TEST_ROOT to an existing empty disposable directory. Save this as tests/process-global/goCloseOutcome.test.ts and run:

node node_modules/vitest/vitest.mjs run tests/process-global/goCloseOutcome.test.ts --maxWorkers 1

These assertions characterize the current loss; passing confirms it. The four cases below ran as part of one focused file with six independent configuration-refusal cases; all ten passed.

import { createHash } from "node:crypto";
import { writeFile, type FileHandle } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({ path: "", fail: false, onClose: undefined as (() => void) | undefined, handles: [] as FileHandle[] }));
vi.mock("node:fs/promises", async (importOriginal) => {
  const actual = await importOriginal<typeof import("node:fs/promises")>();
  return { ...actual, open: async (...args: Parameters<typeof actual.open>) => {
    const handle = await actual.open(...args);
    if (String(args[0]) !== state.path) return handle;
    state.handles.push(handle);
    const close = handle.close.bind(handle);
    handle.close = async () => { state.onClose?.(); if (state.fail) throw Object.assign(new Error("controlled close EIO"), { code: "EIO" }); await close(); };
    return handle;
  }};
});
import { readStableArtifact } from "../../src/artifacts/readStableArtifact.js";
import { GoBinaryProvider } from "../../src/go/GoBinaryProvider.js";
import { GoBinaryService } from "../../src/application/go/GoBinaryService.js";
import { createGoBinaryFixture } from "../fixtures/go/image.js";
import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js";
import { ArtifactFileCancelled, ArtifactFileCleanupFailure } from "../../src/artifacts/ArtifactFileOperation.js";
const root = process.env.REA_TEST_ROOT!;
for (const mode of ["control", "close-failure", "close-cancel"]) {
  it(`Go ${mode}`, async () => {
    state.path = join(root, "go-image"); state.handles = []; state.fail = mode === "close-failure";
    const controller = new AbortController(); const reason = new Error("cancel during close");
    state.onClose = mode === "close-cancel" ? () => controller.abort(reason) : undefined;
    const bytes = createGoBinaryFixture().bytes; await writeFile(state.path, bytes);
    const service = new GoBinaryService(new GoBinaryProvider());
    try {
      const result = await service.inspect({ path: state.path }, { signal: controller.signal });
      if (mode === "control") {
        expect(result.ok).toBe(true);
        if (!result.ok) throw result.error;
        expect(result.value.normalized_result).toMatchObject({ artifact: { sha256: createHash("sha256").update(bytes).digest("hex"), bytes: bytes.length }, build_info: { go_version: "go1.26.0" } });
      } else {
        expect(result.ok).toBe(false); if (result.ok) throw new Error("Expected failure");
        const primary = result.error.cleanupPrimary ?? result.error;
        const cause = primary.cause;
        const outcome = cause instanceof ArtifactFileCancelled ? cause.fileCancellation.outcome : cause instanceof ArtifactFileCleanupFailure ? cause.fileCleanupFailure.outcome : undefined;
        if (mode === "close-failure") {
          expect(result.error.cleanupIncomplete).toBe(true);
          expect(outcome).toMatchObject({ kind: "completed", value: { sha256: createHash("sha256").update(bytes).digest("hex"), bytes } });
          expect(state.handles[0]!.fd).toBeGreaterThanOrEqual(0);
        } else {
          expect(result.error._tag).toBe("AnalysisCancelledError"); expect(result.error.cause).toBe(reason); expect(result.error.cleanupIncomplete).toBe(false);
        }
        expect(result.error.partialObservation).toBeUndefined();
        expect(projectAnalysisError(result.error).details?.partial_observation).toBeUndefined();
        console.log(JSON.stringify({ mode, code: projectAnalysisError(result.error).code, cleanupIncomplete: result.error.cleanupIncomplete, completedOutcome: outcome?.kind, partialObservation: result.error.partialObservation ?? null }));
      }
    } finally {
      state.fail = false; state.onClose = undefined; await service.close();
      expect(state.handles.every(h => h.fd === -1)).toBe(true); state.path = "";
    }
  });
}
it("stable reader retains completed bytes and digest on close cancellation", async () => {
  state.path = join(root, "go-image"); state.fail = false; state.handles = [];
  const controller = new AbortController(); const reason = new Error("cancel during close");
  state.onClose = () => controller.abort(reason);
  const bytes = createGoBinaryFixture().bytes; await writeFile(state.path, bytes);
  try {
    await expect(readStableArtifact(state.path, 8192, controller.signal)).rejects.toMatchObject({
      fileCancellation: { reason, outcome: { kind: "completed", value: { bytes, sha256: createHash("sha256").update(bytes).digest("hex") } } },
    });
    expect(state.handles.every(h => h.fd === -1)).toBe(true);
  } finally { state.onClose = undefined; state.path = ""; }
});

Expected behavior

Keep a typed cancellation or cleanup error with a partial observation identifying the completed acquisition: accepted path, observed SHA-256 and byte length. Preserve the original cancellation reason and exact cleanup owner. A failed close must remain incomplete until its owner confirms release. Cancellation guidance requires retaining collected observations; descriptor lifecycle guidance requires preserving completed results through cleanup.

Impact and scope

P2: bounded loss of acquired artifact facts after finalization, rather than a successful metadata report. A stable readable file can be inspected again, as demonstrated by the normal-close control, but a later retry cannot establish the old identity if the file changes. Natural close-failure frequency and source disappearance were not measured. The ELF service path was executed; PE/thin Mach-O and public CLI/MCP reachability share this acquisition path by source inspection. No descriptor leak was observed: provider cleanup closed every captured handle.

REA version

rea-agents 6.7.0; source revision 5241597594.

Runtime and platform

Node.js v26.12.0; macOS 27.0.1 arm64; locked Vitest 5.0.3. Real file reads with injected close failure/cancellation; no Go compiler or external analysis provider required.

Target and artifact context

Repository-authored 4096-byte ELF Go fixture containing go1.26.0 build metadata. Inert bytes only; never launched or modified during reading.

Interface

Other

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

Observed service/provider output:

{"mode":"close-failure","code":"cleanup_incomplete","cleanupIncomplete":true,"completedOutcome":"completed","partialObservation":null}
{"mode":"close-cancel","code":"cancelled","cleanupIncomplete":false,"partialObservation":null}

The direct reader cancellation case verified fileCancellation.outcome.kind === "completed" with exact bytes and SHA-256. The service cancellation case preserved the exact supplied reason. All handles had fd === -1 after cleanup. Public CLI/MCP transport, natural filesystem close failures and summary delivery were not exercised.

Typed outcome or recovery expected

A typed error retaining acquisition Evidence as partialObservation, while preserving cancelled versus cleanup_incomplete, original cause and bound cleanup capability. Do not claim that metadata decoding has completed: it runs after the stable-read await.

Analysis or suspected cause

Go inspection decodes only after readStableArtifact returns. The Go failure mapper classifies cancellation and binds cleanup but never extracts the completed snapshot from the file outcome. The catch returns that error; the service forwards it. ArtifactFileCancelled deliberately retains the physical outcome. This is a consumer retention gap, rather than failure to keep the descriptor owner.

Additional context

Fetched upstream main d8a9673fc4; the relevant Go reader, owner and mapper lines are unchanged. #6030, #6047, #6052 and #6077 concern other descriptor consumers and require different fixes. #4266 / PR #4274 address shared hashing/ownership; this owner already retains its outcome. #4598 concerns precedence of an earlier physical failure; #4939 concerns CLI service-close cancellation forwarding; #4929 concerns already-aborted admission; #5333 concerns PE discovery. No introducing revision or broader occurrence rate established.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6093 ### 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 A completed stable-file read is absent from the error returned by Go inspection when descriptor finalization fails or receives cancellation. Through the real GoBinaryService and GoBinaryProvider, an injected close EIO returned `cleanup_incomplete` and cancellation during successful close returned `cancelled`; both had no `partialObservation` or projected `details.partial_observation`. The lower file boundary still retained the completed bytes and SHA-256. The normal-close control returned Go metadata for the same input. ### Reproduction Executed a focused production service/provider test with real file handles, an authored 4096-byte ELF fixture and controlled descriptor finalization. The fixture was never executed. Set `REA_TEST_ROOT` to an existing empty disposable directory. Save this as `tests/process-global/goCloseOutcome.test.ts` and run: ```sh node node_modules/vitest/vitest.mjs run tests/process-global/goCloseOutcome.test.ts --maxWorkers 1 ``` These assertions characterize the current loss; passing confirms it. The four cases below ran as part of one focused file with six independent configuration-refusal cases; all ten passed. ```ts import { createHash } from "node:crypto"; import { writeFile, type FileHandle } from "node:fs/promises"; import { join } from "node:path"; import { expect, it, vi } from "vitest"; const state = vi.hoisted(() => ({ path: "", fail: false, onClose: undefined as (() => void) | undefined, handles: [] as FileHandle[] })); vi.mock("node:fs/promises", async (importOriginal) => { const actual = await importOriginal<typeof import("node:fs/promises")>(); return { ...actual, open: async (...args: Parameters<typeof actual.open>) => { const handle = await actual.open(...args); if (String(args[0]) !== state.path) return handle; state.handles.push(handle); const close = handle.close.bind(handle); handle.close = async () => { state.onClose?.(); if (state.fail) throw Object.assign(new Error("controlled close EIO"), { code: "EIO" }); await close(); }; return handle; }}; }); import { readStableArtifact } from "../../src/artifacts/readStableArtifact.js"; import { GoBinaryProvider } from "../../src/go/GoBinaryProvider.js"; import { GoBinaryService } from "../../src/application/go/GoBinaryService.js"; import { createGoBinaryFixture } from "../fixtures/go/image.js"; import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js"; import { ArtifactFileCancelled, ArtifactFileCleanupFailure } from "../../src/artifacts/ArtifactFileOperation.js"; const root = process.env.REA_TEST_ROOT!; for (const mode of ["control", "close-failure", "close-cancel"]) { it(`Go ${mode}`, async () => { state.path = join(root, "go-image"); state.handles = []; state.fail = mode === "close-failure"; const controller = new AbortController(); const reason = new Error("cancel during close"); state.onClose = mode === "close-cancel" ? () => controller.abort(reason) : undefined; const bytes = createGoBinaryFixture().bytes; await writeFile(state.path, bytes); const service = new GoBinaryService(new GoBinaryProvider()); try { const result = await service.inspect({ path: state.path }, { signal: controller.signal }); if (mode === "control") { expect(result.ok).toBe(true); if (!result.ok) throw result.error; expect(result.value.normalized_result).toMatchObject({ artifact: { sha256: createHash("sha256").update(bytes).digest("hex"), bytes: bytes.length }, build_info: { go_version: "go1.26.0" } }); } else { expect(result.ok).toBe(false); if (result.ok) throw new Error("Expected failure"); const primary = result.error.cleanupPrimary ?? result.error; const cause = primary.cause; const outcome = cause instanceof ArtifactFileCancelled ? cause.fileCancellation.outcome : cause instanceof ArtifactFileCleanupFailure ? cause.fileCleanupFailure.outcome : undefined; if (mode === "close-failure") { expect(result.error.cleanupIncomplete).toBe(true); expect(outcome).toMatchObject({ kind: "completed", value: { sha256: createHash("sha256").update(bytes).digest("hex"), bytes } }); expect(state.handles[0]!.fd).toBeGreaterThanOrEqual(0); } else { expect(result.error._tag).toBe("AnalysisCancelledError"); expect(result.error.cause).toBe(reason); expect(result.error.cleanupIncomplete).toBe(false); } expect(result.error.partialObservation).toBeUndefined(); expect(projectAnalysisError(result.error).details?.partial_observation).toBeUndefined(); console.log(JSON.stringify({ mode, code: projectAnalysisError(result.error).code, cleanupIncomplete: result.error.cleanupIncomplete, completedOutcome: outcome?.kind, partialObservation: result.error.partialObservation ?? null })); } } finally { state.fail = false; state.onClose = undefined; await service.close(); expect(state.handles.every(h => h.fd === -1)).toBe(true); state.path = ""; } }); } it("stable reader retains completed bytes and digest on close cancellation", async () => { state.path = join(root, "go-image"); state.fail = false; state.handles = []; const controller = new AbortController(); const reason = new Error("cancel during close"); state.onClose = () => controller.abort(reason); const bytes = createGoBinaryFixture().bytes; await writeFile(state.path, bytes); try { await expect(readStableArtifact(state.path, 8192, controller.signal)).rejects.toMatchObject({ fileCancellation: { reason, outcome: { kind: "completed", value: { bytes, sha256: createHash("sha256").update(bytes).digest("hex") } } }, }); expect(state.handles.every(h => h.fd === -1)).toBe(true); } finally { state.onClose = undefined; state.path = ""; } }); ``` ### Expected behavior Keep a typed cancellation or cleanup error with a partial observation identifying the completed acquisition: accepted path, observed SHA-256 and byte length. Preserve the original cancellation reason and exact cleanup owner. A failed close must remain incomplete until its owner confirms release. [Cancellation guidance](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/tool-design.md#L101-L107) requires retaining collected observations; [descriptor lifecycle guidance](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/tool-design.md#L132-L140) requires preserving completed results through cleanup. ### Impact and scope P2: bounded loss of acquired artifact facts after finalization, rather than a successful metadata report. A stable readable file can be inspected again, as demonstrated by the normal-close control, but a later retry cannot establish the old identity if the file changes. Natural close-failure frequency and source disappearance were not measured. The ELF service path was executed; PE/thin Mach-O and public CLI/MCP reachability share this acquisition path by source inspection. No descriptor leak was observed: provider cleanup closed every captured handle. ### REA version rea-agents 6.7.0; source revision 52415975942a4b9c53a817915b313ccc404f280e. ### Runtime and platform Node.js v26.12.0; macOS 27.0.1 arm64; locked Vitest 5.0.3. Real file reads with injected close failure/cancellation; no Go compiler or external analysis provider required. ### Target and artifact context Repository-authored 4096-byte ELF Go fixture containing go1.26.0 build metadata. Inert bytes only; never launched or modified during reading. ### Interface Other ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence Observed service/provider output: ```json {"mode":"close-failure","code":"cleanup_incomplete","cleanupIncomplete":true,"completedOutcome":"completed","partialObservation":null} {"mode":"close-cancel","code":"cancelled","cleanupIncomplete":false,"partialObservation":null} ``` The direct reader cancellation case verified `fileCancellation.outcome.kind === "completed"` with exact bytes and SHA-256. The service cancellation case preserved the exact supplied reason. All handles had `fd === -1` after cleanup. Public CLI/MCP transport, natural filesystem close failures and summary delivery were not exercised. ### Typed outcome or recovery expected A typed error retaining acquisition Evidence as `partialObservation`, while preserving `cancelled` versus `cleanup_incomplete`, original cause and bound cleanup capability. Do not claim that metadata decoding has completed: it runs after the stable-read await. ### Analysis or suspected cause [Go inspection](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/go/GoBinaryProvider.ts#L231-L239) decodes only after `readStableArtifact` returns. [The Go failure mapper](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/go/GoBinaryProvider.ts#L135-L158) classifies cancellation and binds cleanup but never extracts the completed snapshot from the file outcome. [The catch](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/go/GoBinaryProvider.ts#L280-L283) returns that error; [the service](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/go/GoBinaryService.ts#L71-L72) forwards it. [ArtifactFileCancelled](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/artifacts/ArtifactFileOperation.ts#L129-L143) deliberately retains the physical outcome. This is a consumer retention gap, rather than failure to keep the descriptor owner. ### Additional context Fetched upstream main d8a9673fc44fde4bd30ff98c241886e6683580ca; the relevant Go reader, owner and mapper lines are unchanged. #6030, #6047, #6052 and #6077 concern other descriptor consumers and require different fixes. #4266 / PR #4274 address shared hashing/ownership; this owner already retains its outcome. #4598 concerns precedence of an earlier physical failure; #4939 concerns CLI service-close cancellation forwarding; #4929 concerns already-aborted admission; #5333 concerns PE discovery. No introducing revision or broader occurrence rate established.
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#3729
No description provided.