fix(artifacts): retain original I/O failure evidence

This commit is contained in:
morluto
2026-10-24 12:21:44 +08:00
parent 915e56e3e5
commit 1d896e03b7
3 changed files with 52 additions and 13 deletions
+4
View File
@@ -256,6 +256,10 @@ File-error wrappers retain the format owner's specific diagnostic and recovery
text. Strict JSON file input, for example, keeps the byte-order-mark explanation
and UTF-8 recovery through CLI/MCP projection rather than replacing it with
generic invalid-file advice. Consumers do not classify parser message text.
When an artifact boundary falls back to an I/O error, retain the original cause
on the typed error so provider callers can inspect its filesystem evidence.
Keep public error fields under the canonical projector's ownership; retaining
a cause does not require exposing it in transport output.
Update canonical contracts, output schemas, examples, generated artifacts, and
relevant docs together. Verify converted advertised schemas and success,
+3 -1
View File
@@ -37,5 +37,7 @@ export const translateArtifactFailure = (
}
// Keep typed Evidence and other analysis failures instead of reducing them to I/O.
if (cause instanceof AnalysisError) return cause;
return new ArtifactOperationError(operation, "io");
return new ArtifactOperationError(operation, "io", undefined, undefined, {
cause,
});
};
+45 -12
View File
@@ -125,6 +125,23 @@ it("preserves a dyld read failure when the same read observes cancellation", asy
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
});
it("retains the original dyld I/O cause through provider translation", async () => {
const { client } = await fixture();
const cause = Object.assign(new Error("controlled dyld EIO"), {
code: "EIO",
path: fault.path,
});
fault.readFailure = cause;
const result = await client.execute("trace_dylib_resolution", {
roots: ["Main"],
});
if (result.ok) throw new Error("Expected failed dyld read");
expect(result.error).toMatchObject({ reason: "io" });
expect(result.error.cause).toBe(cause);
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
await expect(client.close()).resolves.toMatchObject({ ok: true });
});
it("observes cancellation during the final dyld digest close", async () => {
const { trace } = await fixture();
fault.abortClose = 2;
@@ -382,15 +399,31 @@ it("waits for an in-flight final dyld close before completing provider close", a
}
});
it("keeps post-admission pathname disappearance as an integrity failure through the provider", async () => {
const { client } = await fixture();
fault.onRead = async () => {
fault.onRead = undefined;
await rename(fault.path, `${fault.path}.old`);
};
const result = await client.execute("trace_dylib_resolution", {
roots: ["Main"],
});
expect(result).toMatchObject({ ok: false, error: { reason: "integrity" } });
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
});
it.each(["image", "digest"] as const)(
"retains the path and filesystem cause when the pathname disappears during the dyld %s read",
async (stage) => {
const { client } = await fixture();
fault.onRead = async () => {
if (fault.handles.length !== (stage === "image" ? 1 : 2)) return;
fault.onRead = undefined;
await rename(fault.path, `${fault.path}.old`);
};
const result = await client.execute("trace_dylib_resolution", {
roots: ["Main"],
});
expect(result).toMatchObject({
ok: false,
error: {
reason: "integrity",
detail: expect.stringContaining(fault.path),
cause: {
reason: "integrity",
cause: { code: "ENOENT", path: fault.path },
},
},
});
expect(fault.handles).toHaveLength(stage === "image" ? 1 : 2);
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
await expect(client.close()).resolves.toMatchObject({ ok: true });
},
);