fix(hopper): own profile reads and retain completed coordinate evidence

This commit is contained in:
morluto committed 2026-10-26 22:42:48 +08:00
1 parent 5b983516ab
commit 921142efbc
5 files changed
+770 -225

No files matched your search

@@ -1,12 +1,18 @@
/** Native mapping facts retained when original-file coordinates cannot be verified. */
/** Native mapping facts retained through source verification and finalization. */
export interface FileOffsetPartialObservation {
readonly operation: "address_to_file_offset";
readonly address: string;
readonly provider_file_offset: number;
readonly provider_source_path: string | null;
readonly provider_image_header_hex: string | null;
readonly original_file_offset: {
readonly available: false;
readonly reason: string;
};
readonly original_file_offset:
| {
readonly available: false;
readonly reason: string;
}
| {
readonly available: true;
readonly file_offset: number;
readonly source_sha256: string;
};
}
+276 -66
View File
@@ -1,22 +1,18 @@
import { machoTargetSelectionSchema } from "../domain/apple/machoTargetSelection.js";
import { createFunctionCoordinateContext } from "../domain/native/functionCoordinateContext.js";
import { createHash } from "node:crypto";
import { PassThrough } from "node:stream";
import { writeFile } from "node:fs/promises";
import { open, writeFile } from "node:fs/promises";
import { execFileSync } from "node:child_process";
import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js";
import { join } from "node:path";
import { afterEach, expect, it, vi } from "vitest";
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
createReadStream: vi.fn(actual.createReadStream),
};
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return { ...actual, open: vi.fn(actual.open) };
});
import { createReadStream } from "node:fs";
import { createInspectionResources } from "../../tests/fixtures/inspectionResources.js";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import {
@@ -28,6 +24,14 @@ let inspectionResources = createInspectionResources();
afterEach(async () => {
await inspectionResources.close();
inspectionResources = createInspectionResources();
vi.mocked(open).mockReset();
vi.mocked(open).mockImplementation(
(
await vi.importActual<typeof import("node:fs/promises")>(
"node:fs/promises",
)
).open,
);
});
it("routes physical universal Mach-O containers through Hopper's FAT loader", () => {
@@ -214,51 +218,30 @@ it("commits a thin header selection only for derived Hopper loader arguments", a
);
});
it("keeps a read failure primary when cancellation arrives with the stream error", async () => {
const path = "/fixture/arm64.macho";
const target = {
path,
sha256: "a".repeat(64),
kind: "executable" as const,
format: "mach-o" as const,
architecture: "arm64" as const,
availableArchitectures: ["arm64" as const],
};
it("keeps a read failure primary when cancellation arrives during descriptor cleanup", async () => {
const directory = await createTestTempDirectory("rea-hopper-profile-read-");
const path = join(directory, "arm64.macho");
await writeFile(path, Buffer.from([0xfe, 0xed, 0xfa, 0xcf]));
const controller = new AbortController();
const reason = { request: "cancel-after-read-error" };
const physicalFailure = new Error("fixture read failure");
const stream = new PassThrough();
vi.mocked(createReadStream).mockReturnValueOnce(
stream as unknown as ReturnType<typeof createReadStream>,
);
stream.once("error", () => controller.abort(reason));
const resolving = resolveHopperAnalysisProfile(target, {
launcherPath: "/fixture/hopper",
loaderArgsOverride: [],
provider: { id: "hopper", name: "Hopper", version: null },
resourceScope: inspectionResources.owner,
signal: controller.signal,
const actualFs =
await vi.importActual<typeof import("node:fs/promises")>(
"node:fs/promises",
);
vi.mocked(open).mockImplementationOnce(async (...args) => {
const handle = await actualFs.open(...args);
vi.spyOn(handle, "read").mockRejectedValueOnce(physicalFailure);
const close = handle.close.bind(handle);
vi.spyOn(handle, "close").mockImplementation(async () => {
controller.abort(reason);
await close();
});
return handle;
});
stream.destroy(physicalFailure);
const result = await resolving;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("A failed header read was accepted");
expect(result.error._tag).toBe("BinaryTargetError");
expect(result.error.cause).toBe(physicalFailure);
});
it("retains the exact caller reason when the owned header stream is aborted", async () => {
const controller = new AbortController();
const reason = { request: "cancel-header-read" };
const stream = new PassThrough();
vi.mocked(createReadStream).mockReturnValueOnce(
stream as unknown as ReturnType<typeof createReadStream>,
);
const resolving = resolveHopperAnalysisProfile(
const result = await resolveHopperAnalysisProfile(
{
path: "/fixture/arm64.macho",
path,
sha256: "a".repeat(64),
kind: "executable",
format: "mach-o",
@@ -266,20 +249,60 @@ it("retains the exact caller reason when the owned header stream is aborted", as
availableArchitectures: ["arm64"],
},
{
launcherPath: "/fixture/hopper",
launcherPath: path,
loaderArgsOverride: [],
provider: { id: "hopper", name: "Hopper", version: null },
resourceScope: inspectionResources.owner,
signal: controller.signal,
},
);
controller.abort(reason);
expect(result).toMatchObject({
ok: false,
error: { _tag: "BinaryTargetError", cause: physicalFailure },
});
});
const result = await resolving;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("An aborted header read was accepted");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
it("retains the exact caller reason when cancellation arrives during the owned header read", async () => {
const directory = await createTestTempDirectory("rea-hopper-profile-abort-");
const path = join(directory, "arm64.macho");
await writeFile(path, Buffer.from([0xfe, 0xed, 0xfa, 0xcf]));
const controller = new AbortController();
const reason = { request: "cancel-header-read" };
const actualFs =
await vi.importActual<typeof import("node:fs/promises")>(
"node:fs/promises",
);
vi.mocked(open).mockImplementationOnce(async (...args) => {
const handle = await actualFs.open(...args);
const read = handle.read.bind(handle);
vi.spyOn(handle, "read").mockImplementationOnce(async (...readArgs) => {
const result = await read(...readArgs);
controller.abort(reason);
return result;
});
return handle;
});
const result = await resolveHopperAnalysisProfile(
{
path,
sha256: "a".repeat(64),
kind: "executable",
format: "mach-o",
architecture: "arm64",
availableArchitectures: ["arm64"],
},
{
launcherPath: path,
loaderArgsOverride: [],
provider: { id: "hopper", name: "Hopper", version: null },
resourceScope: inspectionResources.owner,
signal: controller.signal,
},
);
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError", cause: reason },
});
});
it("keeps failed Mach-O preflight cleanup attached to the supplied profile owner", async () => {
@@ -295,10 +318,6 @@ it("keeps failed Mach-O preflight cleanup attached to the supplied profile owner
await vi.importActual<typeof import("node:fs/promises")>(
"node:fs/promises",
);
const actualNodeFs =
await vi.importActual<typeof import("node:fs")>("node:fs");
vi.mocked(createReadStream).mockReset();
vi.mocked(createReadStream).mockImplementation(actualNodeFs.createReadStream);
const closeFailure = new Error("profile preflight close failure");
let allowClose = false;
const imageFileSystem = {
@@ -354,10 +373,7 @@ it("preserves launcher fingerprint filesystem failures instead of an absent prof
new Error("EACCES: fixture launcher read denied"),
{ code: "EACCES" },
);
const stream = new PassThrough();
vi.mocked(createReadStream).mockReturnValueOnce(
stream as unknown as ReturnType<typeof createReadStream>,
);
vi.mocked(open).mockRejectedValueOnce(failure);
const resolving = resolveHopperAnalysisProfile(
{
path: "/fixture/input",
@@ -374,7 +390,6 @@ it("preserves launcher fingerprint filesystem failures instead of an absent prof
resourceScope: inspectionResources.owner,
},
);
stream.destroy(failure);
const result = await resolving;
expect(result).toMatchObject({
ok: false,
@@ -385,3 +400,198 @@ it("preserves launcher fingerprint filesystem failures instead of an absent prof
},
});
});
it.skipIf(process.platform === "win32").each(["header", "launcher"] as const)(
"rejects a real FIFO at the %s profile boundary without a writer",
async (kind) => {
const directory = await createTestTempDirectory("rea-hopper-profile-fifo-");
const path = join(directory, "hopper.fifo");
execFileSync("mkfifo", [path]);
const result = await resolveHopperAnalysisProfile(
{
path,
sha256: "a".repeat(64),
kind: "executable",
format: kind === "header" ? "mach-o" : "elf",
architecture: "x86_64",
availableArchitectures: ["x86_64"],
},
{
launcherPath: path,
loaderArgsOverride: [],
provider: { id: "hopper", name: "Hopper", version: null },
resourceScope: inspectionResources.owner,
},
);
expect(result).toMatchObject({
ok: false,
error: {
_tag: kind === "header" ? "BinaryTargetError" : "ProviderAdapterError",
cause: { name: "NonRegularFileReadError", path },
},
});
},
);
it.each([
["header", false],
["header", true],
["launcher", false],
["launcher", true],
] as const)(
"retains the %s descriptor through failed close, late cancellation and retry (read failed: %s)",
async (kind, readFails) => {
const directory = await createTestTempDirectory(
"rea-hopper-profile-close-",
);
const path = join(directory, "selected-file");
await writeFile(path, Buffer.from([0xfe, 0xed, 0xfa, 0xcf]));
const actualFs =
await vi.importActual<typeof import("node:fs/promises")>(
"node:fs/promises",
);
const controller = new AbortController();
const closeFailure = new Error("profile descriptor close failed");
const physicalFailure = new Error("profile descriptor read failed");
const reason = { request: "cancel-during-final-close" };
let allowClose = false;
let closeAttempts = 0;
vi.mocked(open).mockImplementationOnce(async (...args) => {
const handle = await actualFs.open(...args);
if (readFails)
vi.spyOn(handle, "read").mockRejectedValueOnce(physicalFailure);
const close = handle.close.bind(handle);
vi.spyOn(handle, "close").mockImplementation(async () => {
closeAttempts += 1;
if (!allowClose) {
controller.abort(reason);
throw closeFailure;
}
await close();
});
return handle;
});
const target = {
path,
sha256: "a".repeat(64),
kind: "executable" as const,
format: kind === "header" ? ("mach-o" as const) : ("elf" as const),
architecture: "x86_64" as const,
availableArchitectures: ["x86_64" as const],
};
const options = {
launcherPath: path,
loaderArgsOverride: ["--fixture"],
provider: { id: "hopper", name: "Hopper", version: null },
resourceScope: inspectionResources.owner,
};
const first = await resolveHopperAnalysisProfile(target, {
...options,
signal: controller.signal,
});
expect(first.ok).toBe(false);
if (first.ok) throw new Error("Failed close was accepted");
expect(first.error.cleanupIncomplete).toBe(true);
expect(first.error.cleanupResources).toEqual([path]);
expect(first.error.cause).toBe(readFails ? physicalFailure : reason);
const cleanup = first.error.secondaryFailures[0];
expect(cleanup?.cause).toBeInstanceOf(RegularFileCleanupFailure);
expect(cleanup?.cause).toMatchObject({
path,
cleanupCause: closeFailure,
outcome: { kind: readFails ? "failed" : "completed" },
});
if (!readFails)
expect(first.error.diagnostics).toMatchObject({
completed_profile_read:
kind === "header"
? 0xfeedfacf
: {
sha256: createHash("sha256")
.update(Buffer.from([0xfe, 0xed, 0xfa, 0xcf]))
.digest("hex"),
bytes: 4,
},
});
expect(first.error._tag).toBe(
readFails
? kind === "header"
? "BinaryTargetError"
: "ProviderAdapterError"
: "AnalysisCancelledError",
);
const retry = await resolveHopperAnalysisProfile(target, options);
expect(retry.ok).toBe(false);
if (retry.ok) throw new Error("Failed cleanup retry was accepted");
expect(retry.error.cleanupIncomplete).toBe(true);
expect(retry.error.cleanupResources).toEqual([path]);
expect(open).toHaveBeenCalledTimes(1);
expect(closeAttempts).toBe(2);
allowClose = true;
await inspectionResources.close();
expect(closeAttempts).toBe(3);
expect(first.error.cleanupIncomplete).toBe(false);
expect(retry.error.cleanupIncomplete).toBe(false);
},
);
it.each(["header", "launcher"] as const)(
"retains completed %s facts when cancellation arrives during successful final close",
async (kind) => {
const directory = await createTestTempDirectory(
"rea-hopper-profile-late-abort-",
);
const path = join(directory, "selected-file");
const bytes = Buffer.from([0xfe, 0xed, 0xfa, 0xcf]);
await writeFile(path, bytes);
const controller = new AbortController();
const reason = { request: "cancel-on-final-close" };
const actualFs =
await vi.importActual<typeof import("node:fs/promises")>(
"node:fs/promises",
);
vi.mocked(open).mockImplementationOnce(async (...args) => {
const handle = await actualFs.open(...args);
const close = handle.close.bind(handle);
vi.spyOn(handle, "close").mockImplementation(async () => {
await close();
controller.abort(reason);
});
return handle;
});
const result = await resolveHopperAnalysisProfile(
{
path,
sha256: "a".repeat(64),
kind: "executable",
format: kind === "header" ? "mach-o" : "elf",
architecture: "x86_64",
availableArchitectures: ["x86_64"],
},
{
launcherPath: path,
loaderArgsOverride: ["--fixture"],
provider: { id: "hopper", name: "Hopper", version: null },
resourceScope: inspectionResources.owner,
signal: controller.signal,
},
);
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCancelledError",
cause: reason,
cleanupIncomplete: false,
diagnostics: {
completed_profile_read:
kind === "header"
? 0xfeedfacf
: {
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: 4,
},
},
},
});
},
);
+174 -100
View File
@@ -1,5 +1,19 @@
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js";
import { ArtifactResourceRetryFailure } from "../artifacts/ArtifactResourceScope.js";
import {
hashStableFile,
stableFileHashCause,
type StableFileHashOptions,
} from "../filesystem/StableFileHash.js";
import {
RegularFileCleanupFailure,
RegularFileCancelled,
regularFileCancellation,
withOwnedRegularFile,
} from "../filesystem/OwnedRegularFile.js";
import { assertRegularFileActive } from "../filesystem/RegularFile.js";
import type {
AnalysisProfileResolution,
@@ -22,6 +36,8 @@ import {
} from "../domain/analysisErrorCore.js";
import { BinaryTargetError } from "../domain/configurationErrors.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import {
machoTargetSelectionSchema,
type MachoTargetSelection,
@@ -54,13 +70,19 @@ export const resolveHopperAnalysisProfile = async (
resolveHopperAnalysisProfileInScope(target, options),
);
} catch (cause: unknown) {
return err(
cause instanceof AnalysisError
? cause
: new ProviderAdapterError("hopper", "resolve_analysis_profile", {
cause,
}),
if (cause instanceof AnalysisError) return err(cause);
const failure = new ProviderAdapterError(
"hopper",
"resolve_analysis_profile",
{
cause,
...(cause instanceof ArtifactResourceRetryFailure
? { cleanup: cause.cleanup }
: {}),
},
);
bindArtifactFileCleanup(cause, failure);
return err(failure);
}
};
@@ -72,7 +94,11 @@ const resolveHopperAnalysisProfileInScope = async (
return ok({ profile: null });
let machoContainer: "thin" | "fat32" | "fat64" | undefined;
if (target.kind === "executable" && isMachOFormat(target.format)) {
const header = await readMachoMagic(target.path, options.signal);
const header = await readMachoMagic(
target.path,
options.resourceScope,
options.signal,
);
if (!header.ok) return header;
machoContainer = [0xcafebabf, 0xbfbafeca].includes(header.value)
? "fat64"
@@ -127,7 +153,11 @@ const resolveHopperAnalysisProfileInScope = async (
options.loaderArgsOverride.length === 0
? derived.value
: [...options.loaderArgsOverride];
const launcherDigest = await sha256File(options.launcherPath, options.signal);
const launcherDigest = await sha256File(
options.launcherPath,
options.resourceScope,
options.signal,
);
if (!launcherDigest.ok) return launcherDigest;
const provider = {
id: options.provider.id,
@@ -269,102 +299,146 @@ const hopperArchitectureFlag = (architecture: HeaderArchitecture): string => {
}
};
const readMachoMagic = async (
const readMachoMagic = (
path: string,
resources: RetainedResourceScope,
signal?: AbortSignal,
): Promise<Result<number, AnalysisError>> => {
if (signalIsAborted(signal))
return err(
new AnalysisCancelledError("open_binary", { cause: signal?.reason }),
);
const stream = createReadStream(path, { start: 0, end: 3 });
const abortError = new Error("Caller cancelled Mach-O header read");
const onAbort = (): void => {
stream.destroy(abortError);
};
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted) onAbort();
try {
const chunks: Buffer[] = [];
for await (const chunk of stream) chunks.push(Buffer.from(chunk));
if (signalIsAborted(signal))
return err(
new AnalysisCancelledError("open_binary", { cause: signal?.reason }),
);
const bytes = Buffer.concat(chunks);
return bytes.length === 4
? ok(bytes.readUInt32BE(0))
: err(
new BinaryTargetError(
path,
"Mach-O header became truncated before Hopper loader selection",
),
);
} catch (cause: unknown) {
return cause === abortError
? err(
new AnalysisCancelledError("open_binary", { cause: signal?.reason }),
)
: err(
new BinaryTargetError(
path,
`Cannot read the Mach-O container header: ${cause instanceof Error ? cause.message : String(cause)}`,
{ cause },
),
);
} finally {
signal?.removeEventListener("abort", onAbort);
}
};
): Promise<Result<number, AnalysisError>> =>
readHopperProfileFile(
{ path, kind: "header" },
resources,
signal,
(options) =>
withOwnedRegularFile(path, options, async ({ handle }) => {
const bytes = Buffer.alloc(4);
let length = 0;
while (length < bytes.length) {
assertRegularFileActive(signal);
const { bytesRead } = await handle.read(
bytes,
length,
bytes.length - length,
length,
);
if (bytesRead === 0)
throw new BinaryTargetError(
path,
"Mach-O header became truncated before Hopper loader selection",
);
length += bytesRead;
}
return bytes.readUInt32BE(0);
}),
);
const sha256File = async (
const sha256File = (
path: string,
resources: RetainedResourceScope,
signal: AbortSignal | undefined,
): Promise<Result<string, AnalysisError>> => {
if (signalIsAborted(signal))
return err(
new AnalysisCancelledError("open_binary", { cause: signal?.reason }),
);
const stream = createReadStream(path);
const abortError = new Error("Caller cancelled launcher fingerprint read");
const onAbort = (): void => {
stream.destroy(abortError);
};
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted) onAbort();
): Promise<Result<string, AnalysisError>> =>
readHopperProfileFile(
{ path, kind: "launcher" },
resources,
signal,
async (options) => (await hashStableFile(path, options)).sha256,
);
/** Both profile reads retain the exact descriptor through final close and retry. */
const readHopperProfileFile = async <Value>(
source: { readonly path: string; readonly kind: "header" | "launcher" },
resources: RetainedResourceScope,
signal: AbortSignal | undefined,
read: (options: StableFileHashOptions) => Promise<Value>,
): Promise<Result<Value, AnalysisError>> => {
const { path, kind } = source;
let closeLease: AnalysisCleanupLease | undefined;
try {
const hash = createHash("sha256");
for await (const chunk of stream) hash.update(chunk);
return signalIsAborted(signal)
? err(
new AnalysisCancelledError("open_binary", { cause: signal?.reason }),
)
: ok(hash.digest("hex"));
} catch (cause: unknown) {
return cause === abortError
? err(
new AnalysisCancelledError("open_binary", { cause: signal?.reason }),
)
: err(
new ProviderAdapterError("hopper", "resolve_analysis_profile", {
cause,
userMessage: `Cannot fingerprint the selected Hopper launcher ${path}: ${cause instanceof Error ? cause.message : String(cause)}`,
remediationAction:
"Restore read access to the selected Hopper launcher, then retry provider selection.",
diagnostics: {
launcher_path: path,
system_code:
cause instanceof Error &&
typeof Reflect.get(cause, "code") === "string"
? (Reflect.get(cause, "code") as string)
: null,
return ok(
await read({
...(signal === undefined ? {} : { signal }),
retainFailedClose: (owner) => {
closeLease = AnalysisCleanupLease.forCloseOwner(owner, path);
resources.retain(
{
close: () => closeLease!.release(),
get cleanupResources() {
return closeLease!.cleanupResources;
},
},
}),
);
} finally {
signal?.removeEventListener("abort", onAbort);
path,
);
},
}),
);
} catch (cause: unknown) {
const physical = stableFileHashCause(
cause instanceof RegularFileCleanupFailure
? cause.outcome.kind === "failed"
? cause.outcome.cause
: cause.cleanupCause
: cause,
);
const cleanup =
cause instanceof RegularFileCleanupFailure
? {
reason:
cause.cleanupCause instanceof Error
? cause.cleanupCause.message
: String(cause.cleanupCause),
resources: [path],
}
: undefined;
const cancellation = regularFileCancellation(cause);
const completed =
(cause instanceof RegularFileCleanupFailure ||
cause instanceof RegularFileCancelled) &&
cause.outcome.kind === "completed"
? { completed_profile_read: jsonValueSchema.parse(cause.outcome.value) }
: {};
const diagnostics = { profile_file: { kind, path }, ...completed };
const failure =
cancellation !== undefined
? new AnalysisCancelledError("open_binary", {
cause: cancellation.reason,
cleanup,
diagnostics,
})
: physical instanceof AnalysisError
? physical
: kind === "header"
? new BinaryTargetError(
path,
`Cannot read the Mach-O container header: ${physical instanceof Error ? physical.message : String(physical)}`,
{ cause: physical, cleanup },
)
: new ProviderAdapterError("hopper", "resolve_analysis_profile", {
cause: physical,
cleanup,
userMessage: `Cannot fingerprint the selected Hopper launcher ${path}: ${physical instanceof Error ? physical.message : String(physical)}`,
remediationAction:
"Select a readable regular Hopper launcher, then retry provider selection.",
diagnostics: {
launcher_path: path,
...diagnostics,
system_code:
physical instanceof Error &&
typeof Reflect.get(physical, "code") === "string"
? (Reflect.get(physical, "code") as string)
: null,
},
});
if (cause instanceof RegularFileCleanupFailure) {
const cleanupFailure = new ProviderCleanupError(
"hopper",
[path],
diagnostics,
{ cause, operation: "resolve_analysis_profile" },
);
closeLease?.bindCleanupError(cleanupFailure);
failure
.retainSecondaryFailure(cleanupFailure)
.bindCleanupDependencies([cleanupFailure]);
}
return err(failure);
}
};
const signalIsAborted = (signal: AbortSignal | undefined): boolean =>
signal?.aborted === true;
+74 -48
View File
@@ -16,6 +16,7 @@ import { BinaryTargetError } from "../domain/configurationErrors.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { RegularFileOperationCancelled } from "../filesystem/RegularFile.js";
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
import {
RegularFileCancelled,
RegularFileCleanupFailure,
@@ -99,7 +100,6 @@ export async function mapHopperFileOffset(
observation,
partial,
unavailable,
signal,
publish,
withCleanup: (primary, marker) =>
analysisErrorWithCleanupFailure(
@@ -125,9 +125,25 @@ export async function mapHopperFileOffset(
return err(
artifactChanged(target.path, captured.error, partial(captured.error)),
);
return err(cancelled(partial, cause.reason));
const verified = coordinatesForSnapshot(verification, captured);
if (!verified.result.ok)
return err(
retainSnapshot(
verified.result.error,
captured.value,
verification.observation,
),
);
return err(
retainSnapshot(
cancelled(partial, cause.reason),
captured.value,
verification.observation,
verified.fileOffset,
),
);
}
return err(sourceAccessFailure(target.path, cause, partial, signal));
return err(sourceAccessFailure(target.path, cause, partial));
}
}
@@ -185,18 +201,9 @@ const sourceAccessFailure = (
path: string,
cause: unknown,
partial: (reason: string) => FileOffsetPartialObservation,
signal: AbortSignal | undefined,
): AnalysisError => {
if (
signal?.aborted === true ||
cause instanceof RegularFileOperationCancelled
)
return cancelled(
partial,
cause instanceof RegularFileOperationCancelled
? cause.reason
: signal?.reason,
);
if (cause instanceof RegularFileOperationCancelled)
return cancelled(partial, cause.reason);
const reason = `Cannot verify original source bytes: ${cause instanceof Error ? cause.message : String(cause)}`;
const code =
typeof cause === "object" && cause !== null && "code" in cause
@@ -233,7 +240,6 @@ interface OffsetVerification {
readonly observation: Observation;
readonly partial: (reason: string) => FileOffsetPartialObservation;
readonly unavailable: (reason: string) => Result<JsonValue, AnalysisError>;
readonly signal: AbortSignal | undefined;
readonly withCleanup: (
primary: AnalysisError,
marker: ProviderCleanupError,
@@ -245,17 +251,12 @@ const cleanupAfterSnapshot = (
verification: OffsetVerification,
failure: RegularFileCleanupFailure<unknown>,
): Result<JsonValue, AnalysisError> => {
const { target, partial, signal, withCleanup, publish } = verification;
const { target, partial, withCleanup, publish } = verification;
const marker = descriptorCleanup(failure);
if (failure.outcome.kind === "failed")
return err(
withCleanup(
sourceAccessFailure(
target.path,
failure.outcome.cause,
partial,
signal,
),
sourceAccessFailure(target.path, failure.outcome.cause, partial),
marker,
),
);
@@ -268,35 +269,63 @@ const cleanupAfterSnapshot = (
marker,
),
);
if (signal?.aborted === true || failure.completionCancellation !== undefined)
const verified = coordinatesForSnapshot(verification, captured);
if (!verified.result.ok)
return err(
withCleanup(
cancelled(
partial,
failure.completionCancellation?.reason ?? signal?.reason,
retainSnapshot(
verified.result.error,
captured.value,
verification.observation,
),
marker,
),
);
const verified = coordinatesForSnapshot(verification, captured);
if (!verified.result.ok)
return err(withCleanup(verified.result.error, marker));
const published = publish(marker);
published.retainPartialObservation(
partial(
"Source descriptor close failed after the admitted bytes were read",
),
if (failure.completionCancellation !== undefined)
return err(
withCleanup(
retainSnapshot(
cancelled(partial, failure.completionCancellation.reason),
captured.value,
verification.observation,
verified.fileOffset,
),
marker,
),
);
const published = retainSnapshot(
publish(marker),
captured.value,
verification.observation,
verified.fileOffset,
);
published.retainDiagnostics({
source_sha256: captured.value.sha256,
...(verified.fileOffset === undefined
? {}
: { file_offset: verified.fileOffset }),
source_identity: "verified",
});
return err(published);
};
const retainSnapshot = <Failure extends AnalysisError>(
failure: Failure,
snapshot: SourceSnapshot,
observation: Observation,
fileOffset?: number,
): Failure => {
if (fileOffset !== undefined)
failure.partialObservation = {
operation: OPERATION,
...observation,
original_file_offset: {
available: true,
file_offset: fileOffset,
source_sha256: snapshot.sha256,
},
};
return failure.retainDiagnostics({
source_sha256: snapshot.sha256,
...(fileOffset === undefined
? {}
: { file_offset: fileOffset, source_identity: "verified" }),
});
};
const coordinatesForSnapshot = (
verification: OffsetVerification,
captured: Result<SourceSnapshot, string>,
@@ -304,7 +333,7 @@ const coordinatesForSnapshot = (
readonly result: Result<JsonValue, AnalysisError>;
readonly fileOffset?: number;
} => {
const { target, observation, partial, unavailable, signal } = verification;
const { target, observation, partial, unavailable } = verification;
if (!captured.ok)
return {
result: err(
@@ -312,8 +341,6 @@ const coordinatesForSnapshot = (
),
};
const snapshot = captured.value;
if (signal?.aborted === true)
return { result: err(cancelled(partial, signal.reason)) };
if (snapshot.sha256 !== target.sha256) {
const reason = `Source ${target.path} differs from the admitted artifact: expected SHA-256 ${target.sha256}, observed ${snapshot.sha256}. Reopen the changed source for a new analysis identity.`;
return {
@@ -384,10 +411,9 @@ async function readSourceSnapshot(file: FileHandle, signal?: AbortSignal) {
);
}
};
for await (const chunk of file.createReadStream({
start: 0,
autoClose: false,
...(signal === undefined ? {} : { signal }),
for await (const chunk of readFileHandleChunks(file, {
signal,
cancellationFailure: (reason) => new RegularFileOperationCancelled(reason),
})) {
const bytes = Buffer.from(chunk);
hash.update(bytes);
@@ -12,8 +12,13 @@ import { join } from "node:path";
import { expect, it, vi } from "vitest";
import { createAnalysisProfile } from "../../../../src/domain/analysisProfile.js";
import { AnalysisArtifactChangedError } from "../../../../src/domain/analysisErrorCore.js";
import {
AnalysisAccessDeniedError,
AnalysisArtifactChangedError,
AnalysisCancelledError,
} from "../../../../src/domain/analysisErrorCore.js";
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
import { BinaryTargetError } from "../../../../src/domain/configurationErrors.js";
import { ProviderCleanupError } from "../../../../src/domain/providerCleanupError.js";
import { parseConfig } from "../../../../src/config/parseConfig.js";
import { silentLogger } from "../../../../src/logger.js";
@@ -27,6 +32,9 @@ const state = vi.hoisted(() => ({
code: "EIO",
handles: [] as Array<{ handle: FileHandle; close: () => Promise<void> }>,
ended: 0,
statFailure: undefined as Error | undefined,
abortOnClose: undefined as AbortController | undefined,
afterRead: undefined as (() => void) | undefined,
}));
vi.mock("node:fs/promises", async (importOriginal) => {
@@ -37,13 +45,22 @@ vi.mock("node:fs/promises", async (importOriginal) => {
const handle = await actual.open(...args);
if (String(args[0]) !== state.path) return handle;
const close = handle.close.bind(handle);
const stat = handle.stat.bind(handle);
handle.stat = ((...options: Parameters<typeof handle.stat>) => {
if (state.statFailure !== undefined)
return Promise.reject(state.statFailure);
return stat(...options);
}) as typeof handle.stat;
state.handles.push({ handle, close });
const stream = handle.createReadStream.bind(handle);
handle.createReadStream = (options) =>
stream(options).on("end", () => {
state.ended += 1;
});
const read = handle.read.bind(handle);
handle.read = (async (...options: Parameters<typeof handle.read>) => {
const result = await read(...options);
state.afterRead?.();
if (result.bytesRead === 0) state.ended += 1;
return result;
}) as typeof handle.read;
handle.close = async () => {
state.abortOnClose?.abort("caller cancelled during source close");
if (state.fail)
throw Object.assign(
new Error("controlled descriptor close failure"),
@@ -122,6 +139,14 @@ it("preserves file-offset reads and retries the exact descriptor after close fai
file_offset: 8,
source_identity: "verified",
});
expect(failed.error.partialObservation).toMatchObject({
...observation,
original_file_offset: {
available: true,
file_offset: 8,
source_sha256: createHash("sha256").update(bytes).digest("hex"),
},
});
expect(projectAnalysisError(failed.error).code).toBe("cleanup_incomplete");
const owned = state.handles.at(-1);
if (owned === undefined) throw new Error("missing descriptor");
@@ -285,3 +310,207 @@ it("clears a combined Hopper close only after the descriptor and session release
await rm(root, { recursive: true, force: true });
}
});
it.each([false, true])(
"retains a source metadata failure when cancellation arrives during close (close failure: %s)",
async (closeFails) => {
const root = await mkdtemp(
join(tmpdir(), "rea-hopper-file-offset-primary-"),
);
const bytes = Buffer.alloc(64);
state.path = join(root, "sample");
await writeFile(state.path, bytes);
const client = await clientFor(state.path, bytes);
const call = vi
.spyOn(HopperClient.prototype, "callTool")
.mockResolvedValue(ok(observation));
const controller = new AbortController();
const primary = Object.assign(
new Error("controlled source metadata denial"),
{ code: "EACCES" },
);
state.statFailure = primary;
state.abortOnClose = controller;
state.fail = closeFails;
try {
const result = await client.execute(
"address_to_file_offset",
{ address: "0x1000" },
{ signal: controller.signal },
);
if (result.ok) throw new Error("expected source denial");
expect(controller.signal.aborted).toBe(true);
const failure = closeFails ? result.error.cleanupPrimary : result.error;
expect(failure).toBeInstanceOf(AnalysisAccessDeniedError);
expect(failure?.cause).toBe(primary);
expect(failure?.partialObservation).toMatchObject(observation);
expect(result.error.cleanupIncomplete).toBe(closeFails);
const owned = state.handles.at(-1);
if (owned === undefined) throw new Error("missing source descriptor");
expect(owned.handle.fd >= 0).toBe(closeFails);
state.fail = false;
expect((await client.close()).ok).toBe(true);
expect(owned.handle.fd).toBe(-1);
expect(result.error.cleanupIncomplete).toBe(false);
} finally {
state.fail = false;
state.statFailure = undefined;
state.abortOnClose = undefined;
call.mockRestore();
await client.close();
await rm(root, { recursive: true, force: true });
}
},
);
it.each([false, true])(
"retains verified source coordinates when cancellation arrives during close (close failure: %s)",
async (closeFails) => {
const root = await mkdtemp(
join(tmpdir(), "rea-hopper-file-offset-cancel-"),
);
const bytes = Buffer.alloc(64);
state.path = join(root, "sample");
await writeFile(state.path, bytes);
const sha256 = createHash("sha256").update(bytes).digest("hex");
const client = await clientFor(state.path, bytes);
const call = vi
.spyOn(HopperClient.prototype, "callTool")
.mockResolvedValue(ok(observation));
const controller = new AbortController();
state.abortOnClose = controller;
state.fail = closeFails;
try {
const result = await client.execute(
"address_to_file_offset",
{ address: "0x1000" },
{ signal: controller.signal },
);
if (result.ok) throw new Error("expected cancellation");
const cancellation = closeFails
? result.error.cleanupPrimary
: result.error;
expect(cancellation).toBeInstanceOf(AnalysisCancelledError);
expect(cancellation?.cause).toBe(controller.signal.reason);
const partialObservation = {
...observation,
original_file_offset: {
available: true,
file_offset: 8,
source_sha256: sha256,
},
};
expect(cancellation?.partialObservation).toMatchObject(
partialObservation,
);
expect(projectAnalysisError(result.error)).toMatchObject({
details: { partial_observation: partialObservation },
});
expect(cancellation?.diagnostics).toMatchObject({
source_sha256: sha256,
file_offset: 8,
source_identity: "verified",
});
expect(result.error.cleanupIncomplete).toBe(closeFails);
const owned = state.handles.at(-1);
if (owned === undefined) throw new Error("missing source descriptor");
expect(owned.handle.fd >= 0).toBe(closeFails);
state.fail = false;
expect((await client.close()).ok).toBe(true);
expect(owned.handle.fd).toBe(-1);
expect(result.error.cleanupIncomplete).toBe(false);
} finally {
state.fail = false;
state.abortOnClose = undefined;
call.mockRestore();
await client.close();
await rm(root, { recursive: true, force: true });
}
},
);
it.each([false, true])(
"uses the descriptor read outcome when cancellation and an I/O result coincide (read failure: %s)",
async (readFails) => {
const root = await mkdtemp(join(tmpdir(), "rea-hopper-file-offset-read-"));
const bytes = Buffer.alloc(64);
state.path = join(root, "sample");
await writeFile(state.path, bytes);
const client = await clientFor(state.path, bytes);
const call = vi
.spyOn(HopperClient.prototype, "callTool")
.mockResolvedValue(ok(observation));
const controller = new AbortController();
const reason = new Error("caller cancelled during source read");
const primary = Object.assign(
new Error("controlled descriptor read failure"),
{ code: "EIO" },
);
let readObserved = false;
state.afterRead = () => {
readObserved = true;
controller.abort(reason);
if (readFails) throw primary;
};
try {
const result = await client.execute(
"address_to_file_offset",
{ address: "0x1000" },
{ signal: controller.signal },
);
if (result.ok)
throw new Error("expected interrupted source verification");
expect(readObserved).toBe(true);
expect(result.error).toBeInstanceOf(
readFails ? BinaryTargetError : AnalysisCancelledError,
);
expect(result.error.cause).toBe(readFails ? primary : reason);
expect(result.error.partialObservation).toMatchObject(observation);
expect(result.error.cleanupIncomplete).toBe(false);
expect(state.handles.at(-1)?.handle.fd).toBe(-1);
} finally {
state.afterRead = undefined;
call.mockRestore();
await client.close();
await rm(root, { recursive: true, force: true });
}
},
);
it("retains a changed source digest without publishing verified coordinates after late cancellation", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-hopper-file-offset-changed-"));
const bytes = Buffer.alloc(64);
state.path = join(root, "sample");
await writeFile(state.path, bytes);
const client = await clientFor(state.path, bytes);
const changed = Buffer.alloc(64, 1);
await writeFile(state.path, changed);
const call = vi
.spyOn(HopperClient.prototype, "callTool")
.mockResolvedValue(ok(observation));
const controller = new AbortController();
state.abortOnClose = controller;
try {
const result = await client.execute(
"address_to_file_offset",
{ address: "0x1000" },
{ signal: controller.signal },
);
if (result.ok) throw new Error("expected changed source identity");
expect(controller.signal.aborted).toBe(true);
expect(result.error).toBeInstanceOf(AnalysisArtifactChangedError);
expect(result.error.partialObservation).toMatchObject({
...observation,
original_file_offset: { available: false },
});
expect(result.error.diagnostics).toEqual({
source_sha256: createHash("sha256").update(changed).digest("hex"),
});
expect(state.handles.at(-1)?.handle.fd).toBe(-1);
} finally {
state.abortOnClose = undefined;
call.mockRestore();
await client.close();
await rm(root, { recursive: true, force: true });
}
});