Files
rea/tests/boundary/filesystem/stableFileHashOwnership.test.ts
T
morluto e65c3955f9 fix(filesystem): preserve digest failures through cleanup cancellation
Record cancellation at file checkpoints and before owner handoff. Keep physical hash failures, exact reasons, completed facts and failed-close owners distinct through artifact and provider projections and cleanup retries.

Refs #4598. Validated with 566 passing affected tests and 22 platform skips; final Kuna cleanup reason rerun passed 43 tests. No Android device or real JADX/Kuna execution claimed.
2026-10-17 05:47:19 +08:00

605 lines
22 KiB
TypeScript

import { createHash } from "node:crypto";
import { open as openFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js";
import * as regularFile from "../../../src/filesystem/RegularFile.js";
import { RegularFileAdmissionFailure } from "../../../src/filesystem/RegularFile.js";
import { hashStableFile } from "../../../src/filesystem/StableFileHash.js";
import { LinuxHopperPublisher } from "../../../src/application/LinuxHopperPublisher.js";
import {
RegularFileCancelled,
RegularFileCleanupFailure,
} from "../../../src/filesystem/OwnedRegularFile.js";
import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
import type { VerifiedLinuxHopperPackage } from "../../../src/application/LinuxHopperPublisher.js";
it("adopts and retries the descriptor when hashing succeeded but close failed", async () => {
const root = await createTestTempDirectory("rea-stable-hash-close-");
const path = join(root, "input.bin");
await writeFile(path, "owned bytes");
const closeCause = new Error("injected descriptor close failure");
const originalClose = OwnedFileHandle.prototype.close;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementationOnce(async () => {
throw closeCause;
});
const retained: OwnedFileHandle[] = [];
try {
let failure: unknown;
try {
await hashStableFile(path, {
retainFailedClose: (owner) => retained.push(owner),
});
} catch (cause: unknown) {
failure = cause;
}
expect(failure).toBeInstanceOf(RegularFileCleanupFailure);
expect(retained).toHaveLength(1);
if (!(failure instanceof RegularFileCleanupFailure))
throw new Error("Expected a typed hash cleanup failure");
expect(failure.outcome).toMatchObject({
kind: "completed",
value: { bytes: 11 },
});
expect(failure.cleanupCause).toBe(closeCause);
expect(failure.retention).toEqual({ kind: "callback_returned" });
expect(failure.owner).toBe(retained[0]);
expect(retained[0]!.handle.fd).toBeGreaterThanOrEqual(0);
close.mockRestore();
await retained[0]!.close();
expect(retained[0]!.handle.fd).toBe(-1);
} finally {
close.mockRestore();
if (retained[0] !== undefined && retained[0]!.handle.fd >= 0)
await originalClose.call(retained[0]);
}
});
it("preserves a read cancellation cause when close also fails", async () => {
const root = await createTestTempDirectory("rea-stable-hash-primary-");
const path = join(root, "input.bin");
await writeFile(path, "owned bytes");
const primary = new Error("injected cancellation after read");
const closeCause = new Error("injected close failure after cancellation");
const controller = new AbortController();
const nativeOpen = regularFile.openRegularFile;
const open = vi
.spyOn(regularFile, "openRegularFile")
.mockImplementation(async (selectedPath, options) => {
const handle = await nativeOpen(selectedPath, options);
vi.spyOn(handle, "stat").mockImplementationOnce(async () => {
controller.abort(primary);
throw primary;
});
return handle;
});
const originalClose = OwnedFileHandle.prototype.close;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementationOnce(async () => {
throw closeCause;
});
const retained: OwnedFileHandle[] = [];
try {
let failure: unknown;
try {
await hashStableFile(path, {
signal: controller.signal,
retainFailedClose: (owner) => retained.push(owner),
});
} catch (cause: unknown) {
failure = cause;
}
expect(failure).toBeInstanceOf(RegularFileCleanupFailure);
if (!(failure instanceof RegularFileCleanupFailure))
throw new Error("Expected a typed hash cleanup failure");
expect(failure.outcome).toEqual({ kind: "failed", cause: primary });
expect(failure.cleanupCause).toBe(closeCause);
expect(failure.cause).toBeInstanceOf(AggregateError);
expect((failure.cause as AggregateError).errors).toEqual([
primary,
closeCause,
]);
expect(retained).toEqual([failure.owner]);
close.mockRestore();
await failure.owner.close();
expect(failure.owner.handle.fd).toBe(-1);
} finally {
close.mockRestore();
open.mockRestore();
if (retained[0] !== undefined && retained[0]!.handle.fd >= 0)
await originalClose.call(retained[0]);
}
});
it("closes a normally hashed file without adopting an owner", async () => {
const root = await createTestTempDirectory("rea-stable-hash-control-");
const path = join(root, "input.bin");
await writeFile(path, "owned bytes");
const retainFailedClose = vi.fn();
await expect(
hashStableFile(path, { retainFailedClose }),
).resolves.toMatchObject({ bytes: 11 });
expect(retainFailedClose).not.toHaveBeenCalled();
});
it("retains a completed digest when cancellation follows successful close", async () => {
const root = await createTestTempDirectory("rea-stable-hash-cancel-");
const path = join(root, "input.bin");
const bytes = Buffer.from("owned bytes");
await writeFile(path, bytes);
const controller = new AbortController();
const reason = new Error("caller cancelled during close");
const retainFailedClose = vi.fn();
const nativeClose = OwnedFileHandle.prototype.close;
let observed: OwnedFileHandle | undefined;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementationOnce(async function (this: OwnedFileHandle) {
observed = this;
await nativeClose.call(this);
controller.abort(reason);
});
try {
const failure: unknown = await hashStableFile(path, {
signal: controller.signal,
retainFailedClose,
}).catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(RegularFileCancelled);
if (!(failure instanceof RegularFileCancelled))
throw new Error(
"Expected completed file observations under cancellation",
);
expect(failure.path).toBe(path);
expect(failure.reason).toBe(reason);
expect(failure.owner).toBe(observed);
expect(failure.outcome).toEqual({
kind: "completed",
value: {
bytes: bytes.length,
sha256: createHash("sha256").update(bytes).digest("hex"),
},
});
expect(observed?.handle.fd).toBe(-1);
expect(retainFailedClose).not.toHaveBeenCalled();
} finally {
close.mockRestore();
await observed?.close();
}
});
it("adopts a descriptor from an admission failure and preserves both causes", async () => {
const root = await createTestTempDirectory("rea-stable-hash-admission-");
const path = join(root, "input.bin");
await writeFile(path, "owned bytes");
const owner = new OwnedFileHandle(await openFile(path, "r"));
const primary = new Error("injected file admission failure");
const cleanupCause = new Error("injected admission descriptor close failure");
const open = vi
.spyOn(regularFile, "openRegularFile")
.mockRejectedValueOnce(
new RegularFileAdmissionFailure(path, primary, cleanupCause, owner),
);
const retained: OwnedFileHandle[] = [];
try {
let failure: unknown;
try {
await hashStableFile(path, {
retainFailedClose: (retainedOwner) => retained.push(retainedOwner),
});
} catch (cause: unknown) {
failure = cause;
}
expect(failure).toBeInstanceOf(RegularFileCleanupFailure);
if (!(failure instanceof RegularFileCleanupFailure))
throw new Error("Expected a typed admission cleanup failure");
expect(failure.outcome).toEqual({ kind: "failed", cause: primary });
expect(failure.cleanupCause).toBe(cleanupCause);
expect(failure.retention).toEqual({ kind: "callback_returned" });
expect((failure.cause as AggregateError).errors).toEqual([
primary,
cleanupCause,
]);
expect(retained).toEqual([owner]);
} finally {
open.mockRestore();
await owner.close();
}
});
it("distinguishes a thrown undefined primary failure from a clean hash", async () => {
const root = await createTestTempDirectory("rea-stable-hash-undefined-");
const path = join(root, "input.bin");
await writeFile(path, "owned bytes");
const openFileAtBoundary = regularFile.openRegularFile;
const open = vi
.spyOn(regularFile, "openRegularFile")
.mockImplementation(async (selectedPath, options) => {
const handle = await openFileAtBoundary(selectedPath, options);
const originalStat = handle.stat.bind(handle);
let calls = 0;
vi.spyOn(handle, "stat").mockImplementation(async () => {
calls += 1;
if (calls === 2) throw undefined;
return originalStat();
});
return handle;
});
const closeCause = new Error("injected close failure after undefined");
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockRejectedValueOnce(closeCause);
const retained: OwnedFileHandle[] = [];
try {
let failure: unknown;
try {
await hashStableFile(path, {
retainFailedClose: (owner) => retained.push(owner),
});
} catch (cause: unknown) {
failure = cause;
}
expect(failure).toBeInstanceOf(RegularFileCleanupFailure);
if (!(failure instanceof RegularFileCleanupFailure))
throw new Error("Expected a typed hash cleanup failure");
expect(failure.outcome).toEqual({ kind: "failed", cause: undefined });
expect(failure.cleanupCause).toBe(closeCause);
expect((failure.cause as AggregateError).errors).toEqual([
undefined,
closeCause,
]);
expect(retained).toEqual([failure.owner]);
} finally {
close.mockRestore();
open.mockRestore();
if (retained[0] !== undefined) await retained[0].close();
}
});
it("retries a publisher-owned launcher hash close before returning its digest", async () => {
const root = await createTestTempDirectory("rea-publisher-hash-owner-");
const path = join(root, "launcher");
await writeFile(path, "fixture launcher bytes");
const publisher = new LinuxHopperPublisher({}, async () => undefined);
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockRejectedValueOnce(new Error("fixture launcher close failure"));
try {
await expect(publisher.hashLauncher(path)).resolves.toMatch(
/^[a-f0-9]{64}$/u,
);
expect(close).toHaveBeenCalledTimes(2);
close.mockRestore();
await publisher.close();
} finally {
close.mockRestore();
await publisher.close();
}
});
it("retries pending cleanup before acquiring a publisher temporary root", async () => {
const root = await createTestTempDirectory("rea-publisher-admission-");
const path = join(root, "launcher");
const launcherBytes = "fixture launcher bytes";
await writeFile(path, launcherBytes);
const publisher = new LinuxHopperPublisher(
{ ...process.env, XDG_CACHE_HOME: join(root, "cache") },
async () => undefined,
);
const originalClose = OwnedFileHandle.prototype.close;
const closeOwners: OwnedFileHandle[] = [];
let allowClose = false;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementation(async function (this: OwnedFileHandle) {
closeOwners.push(this);
if (!allowClose) throw new Error("fixture close remains unavailable");
return originalClose.call(this);
});
const createRoot = vi.spyOn(PrivateRuntimeRoot, "create");
try {
await expect(publisher.hashLauncher(path)).rejects.toMatchObject({
reason: "cleanup_failed",
});
const retainedOwner = closeOwners[0];
if (retainedOwner === undefined)
throw new Error("Expected a retained launcher descriptor");
const attemptsBeforeAdmission = closeOwners.length;
await expect(
publisher.retainVerifiedArchive({} as VerifiedLinuxHopperPackage, path),
).resolves.toMatchObject({
ok: false,
error: {
reason: "cleanup_failed",
cause: { cleanup: { resources: [path] } },
},
});
expect(createRoot).not.toHaveBeenCalled();
expect(closeOwners.length).toBeGreaterThan(attemptsBeforeAdmission);
expect(closeOwners.every((owner) => owner === retainedOwner)).toBe(true);
const attemptsBeforeRecovery = closeOwners.length;
allowClose = true;
await expect(publisher.hashLauncher(path)).resolves.toBe(
createHash("sha256").update(launcherBytes).digest("hex"),
);
const recoveryOwners = closeOwners.slice(attemptsBeforeRecovery);
expect(recoveryOwners[0]).toBe(retainedOwner);
expect(recoveryOwners.some((owner) => owner !== retainedOwner)).toBe(true);
expect(closeOwners.every((owner) => owner.handle.fd === -1)).toBe(true);
expect(createRoot).not.toHaveBeenCalled();
const attemptsAfterRecovery = closeOwners.length;
await publisher.close();
expect(closeOwners).toHaveLength(attemptsAfterRecovery);
} finally {
close.mockRestore();
createRoot.mockRestore();
await publisher.close();
}
});
it.each([
{ stage: "close", reason: null },
{ stage: "close", reason: "caller stopped" },
{ stage: "close", reason: new Error("caller stopped") },
{ stage: "retention", reason: "after the failed close" },
] as const)(
"records completion cancellation at $stage before owner handoff",
async ({ stage, reason }) => {
const root = await createTestTempDirectory("rea-hash-close-provenance-");
const path = join(root, "input.bin");
await writeFile(path, "owned bytes");
const controller = new AbortController();
const nativeClose = OwnedFileHandle.prototype.close;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementationOnce(async () => {
if (stage === "close") controller.abort(reason);
throw new Error("injected failed close");
});
let retained: OwnedFileHandle | undefined;
try {
const failure: unknown = await hashStableFile(path, {
signal: controller.signal,
retainFailedClose: (owner) => {
retained = owner;
if (stage === "retention") controller.abort(reason);
},
}).catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(RegularFileCleanupFailure);
expect(failure).toMatchObject({
outcome: { kind: "completed", value: { bytes: 11 } },
});
if (stage === "close") {
expect(failure).toMatchObject({ completionCancellation: { reason } });
expect(
Reflect.get(failure as object, "completionCancellation").reason,
).toBe(reason);
} else
expect(failure).not.toHaveProperty(
"completionCancellation",
expect.anything(),
);
} finally {
close.mockRestore();
if (retained !== undefined) await nativeClose.call(retained);
}
},
);
it("tags a physical hash failure without inferring cancellation from a later signal", async () => {
const root = await createTestTempDirectory("rea-hash-physical-provenance-");
const path = join(root, "input.bin");
await writeFile(path, "owned bytes");
const controller = new AbortController();
const primary = Object.assign(new Error("injected digest read failure"), {
code: "EIO",
});
const nativeOpen = regularFile.openRegularFile;
const open = vi
.spyOn(regularFile, "openRegularFile")
.mockImplementation(async (selected, options) => {
const handle = await nativeOpen(selected, options);
vi.spyOn(handle, "read").mockRejectedValueOnce(primary);
return handle;
});
const nativeClose = OwnedFileHandle.prototype.close;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementationOnce(async function (this: OwnedFileHandle) {
await nativeClose.call(this);
controller.abort(primary);
});
try {
const failure: unknown = await hashStableFile(path, {
signal: controller.signal,
retainFailedClose: () => {
throw new Error("Close succeeded");
},
}).catch((cause: unknown) => cause);
expect(failure).toMatchObject({
_tag: "StableFileHashFailure",
cause: primary,
});
if (!(failure instanceof Error))
throw new Error("Expected producer-owned physical failure");
expect(failure.cause).toBe(primary);
} finally {
open.mockRestore();
close.mockRestore();
}
});
it.each([false, true])(
"preserves premature EOF with same-read cancellation and failed close=%s",
async (failClose) => {
const root = await createTestTempDirectory("rea-hash-eof-cancel-");
const path = join(root, "input.bin");
await writeFile(path, "admitted extent");
const controller = new AbortController();
const nativeOpen = regularFile.openRegularFile;
let retained: OwnedFileHandle | undefined;
const open = vi
.spyOn(regularFile, "openRegularFile")
.mockImplementation(async (selected, options) => {
const handle = await nativeOpen(selected, options);
vi.spyOn(handle, "read").mockImplementationOnce(async () => {
controller.abort("cancelled at EOF");
return { bytesRead: 0, buffer: Buffer.alloc(0) };
});
return handle;
});
const nativeClose = OwnedFileHandle.prototype.close;
const close = vi.spyOn(OwnedFileHandle.prototype, "close");
if (failClose) close.mockRejectedValueOnce(new Error("close unavailable"));
try {
const failure: unknown = await hashStableFile(path, {
signal: controller.signal,
retainFailedClose: (owner) => {
retained = owner;
},
}).catch((cause: unknown) => cause);
const physical =
failure instanceof RegularFileCleanupFailure
? failure.outcome.kind === "failed"
? failure.outcome.cause
: undefined
: failure instanceof Error
? failure.cause
: undefined;
expect(physical).toBeInstanceOf(regularFile.RegularFileChangedError);
if (failClose) {
expect(failure).toBeInstanceOf(RegularFileCleanupFailure);
expect(retained?.handle.fd).toBeGreaterThanOrEqual(0);
} else expect(failure).toMatchObject({ _tag: "StableFileHashFailure" });
} finally {
open.mockRestore();
close.mockRestore();
if (retained !== undefined) await nativeClose.call(retained);
}
},
);
it.each(
[null, "caller stopped", new Error("caller stopped")].flatMap((reason) =>
[false, true].map((failClose) => ({ reason, failClose })),
),
)(
"retains the exact read checkpoint reason with failed close=$failClose ($reason)",
async ({ reason, failClose }) => {
const root = await createTestTempDirectory("rea-hash-read-checkpoint-");
const path = join(root, "input.bin");
await writeFile(path, "admitted bytes");
const controller = new AbortController();
const nativeOpen = regularFile.openRegularFile;
let retained: OwnedFileHandle | undefined;
let handleSeen: Awaited<ReturnType<typeof nativeOpen>> | undefined;
const open = vi
.spyOn(regularFile, "openRegularFile")
.mockImplementation(async (selected, options) => {
const handle = await nativeOpen(selected, options);
handleSeen = handle;
const read = handle.read.bind(handle);
vi.spyOn(handle, "read").mockImplementationOnce(async (...args) => {
const result = await read(...args);
controller.abort(reason);
return result;
});
return handle;
});
const nativeClose = OwnedFileHandle.prototype.close;
const close = vi.spyOn(OwnedFileHandle.prototype, "close");
if (failClose) close.mockRejectedValueOnce(new Error("close unavailable"));
try {
const failure: unknown = await hashStableFile(path, {
signal: controller.signal,
retainFailedClose: (owner) => {
retained = owner;
},
}).catch((cause: unknown) => cause);
const primary =
failure instanceof RegularFileCleanupFailure &&
failure.outcome.kind === "failed"
? failure.outcome.cause
: failure;
expect(primary).toBeInstanceOf(regularFile.RegularFileOperationCancelled);
expect(
(primary as regularFile.RegularFileOperationCancelled).reason,
).toBe(reason);
expect(handleSeen?.fd).toBe(failClose ? retained?.handle.fd : -1);
} finally {
open.mockRestore();
close.mockRestore();
if (retained !== undefined) await nativeClose.call(retained);
}
},
);
it.each(["physical", "completed"] as const)(
"preserves the publisher's %s hash outcome after cancellation and a successful close retry",
async (outcome) => {
const root = await createTestTempDirectory(
"rea-publisher-hash-retry-cancel-",
);
const path = join(root, "launcher");
await writeFile(path, "launcher bytes");
const controller = new AbortController();
const primary = new Error("injected publisher read failure");
const reason = null;
const nativeOpen = regularFile.openRegularFile;
const open = vi
.spyOn(regularFile, "openRegularFile")
.mockImplementation(async (selected, options) => {
const handle = await nativeOpen(selected, options);
if (outcome === "physical")
vi.spyOn(handle, "read").mockRejectedValueOnce(primary);
return handle;
});
const nativeClose = OwnedFileHandle.prototype.close;
let ownerSeen: OwnedFileHandle | undefined;
let attempts = 0;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementation(async function (this: OwnedFileHandle) {
ownerSeen = this;
attempts++;
if (attempts === 1) {
controller.abort(reason);
throw new Error("first close unavailable");
}
await nativeClose.call(this);
});
const publisher = new LinuxHopperPublisher(
{ XDG_CACHE_HOME: join(root, "cache") },
async () => undefined,
);
try {
const result = await publisher.inspectLauncher(path, controller.signal);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Injected hash outcome passed");
expect(result.error.reason).toBe(
outcome === "physical" ? "verification_unavailable" : "cancelled",
);
if (outcome === "physical")
expect(result.error.cause).toMatchObject({
_tag: "StableFileHashFailure",
cause: primary,
});
else expect(result.error.cause).toBe(reason);
expect(attempts).toBe(2);
expect(ownerSeen?.handle.fd).toBe(-1);
} finally {
open.mockRestore();
close.mockRestore();
await publisher.close();
if (ownerSeen !== undefined && ownerSeen.handle.fd >= 0)
await nativeClose.call(ownerSeen);
}
},
);