test(lifecycle): target owned finalization boundaries

This commit is contained in:
morluto
2026-10-24 07:43:56 +08:00
parent d442ac752e
commit fcd5192743
4 changed files with 114 additions and 62 deletions
@@ -795,7 +795,10 @@ it
const output = { stdout: "", stderr: "upstream warning", truncated: false };
expect(projectAnalysisError(result.error)).toMatchObject({
category:
scenario === "late-cancellation" ? "cancelled" : "execution_failure",
scenario === "late-cancellation" ||
scenario === "cancelled-cleanup-failure"
? "cancelled"
: "execution_failure",
details:
scenario === "late-cancellation"
? { captured_output: output }
@@ -23,7 +23,7 @@ import {
deliverAnalysisEvidence,
} from "../../../src/application/EvidenceBundleCommands.js";
import { EvidenceLedger } from "../../../src/application/investigation/EvidenceLedger.js";
import { RegularFileCleanupFailure } from "../../../src/filesystem/OwnedRegularFile.js";
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js";
import { OwnedStagedOutput } from "../../../src/filesystem/OwnedStagedOutput.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
@@ -210,27 +210,38 @@ it("preserves the failed-close owner when import is also cancelled", async () =>
const cancellationReason = new JsonInputPhysicalReadFailure(
new Error("opaque cancellation reason"),
);
let owner: OwnedFileHandle | undefined;
const resources = new ArtifactResourceScope();
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementationOnce(async () => {
.mockImplementation(async function (this: OwnedFileHandle) {
owner = this;
controller.abort(cancellationReason);
throw cleanupCause;
});
let failure: unknown;
try {
try {
await readImmutableEvidenceBundle(path, controller.signal);
} catch (cause: unknown) {
failure = cause;
}
expect(failure).toBeInstanceOf(RegularFileCleanupFailure);
if (!(failure instanceof RegularFileCleanupFailure))
throw new Error("Expected the retained file handle owner");
expect(failure.cleanupCause).toBe(cleanupCause);
const result = await readImmutableEvidenceBundle(
path,
controller.signal,
resources,
);
if (result.ok) throw new Error("Expected failed finalization");
expect(result.error).toMatchObject({
_tag: "EvidenceFileError",
cleanupIncomplete: true,
cleanupPrimary: {
_tag: "AnalysisCancelledError",
cause: cancellationReason,
},
});
expect(owner?.handle.fd).toBeGreaterThanOrEqual(0);
close.mockRestore();
await resources.close();
expect(owner?.handle.fd).toBe(-1);
expect(result.error.cleanupIncomplete).toBe(false);
} finally {
close.mockRestore();
if (failure instanceof RegularFileCleanupFailure)
await failure.owner.close();
await resources.close();
}
expect(await readEvidenceBundle(path)).toMatchObject({ ok: true });
});
+67 -43
View File
@@ -10,6 +10,7 @@ import { androidResultSchemas } from "../../../src/domain/android/androidAnalysi
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { snapshotAndroidEngine } from "../../../src/android/AndroidTargetSnapshot.js";
import { hashStableFile } from "../../../src/filesystem/StableFileHash.js";
import * as stableHash from "../../../src/filesystem/StableFileHash.js";
import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js";
import { RegularFileCleanupFailure } from "../../../src/filesystem/OwnedRegularFile.js";
import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
@@ -23,24 +24,56 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const it = test.skipIf(process.platform === "win32");
it("retains a failed input hash descriptor until provider shutdown retries it", async () => {
const { service, apk, provider, launches } = await setup();
const closeCause = new Error("fixture source hash close unavailable");
const owners: OwnedFileHandle[] = [];
// Select the provider's source hash after resolver and engine admission complete.
const failInputHashCloses = (path: string, failures: number) => {
const originalHash = stableHash.hashStableFile;
const originalClose = OwnedFileHandle.prototype.close;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementationOnce(async function (this: OwnedFileHandle) {
owners.push(this);
throw closeCause;
})
.mockImplementationOnce(async function (this: OwnedFileHandle) {
owners.push(this);
throw closeCause;
let hashingInput = false;
const retained = new Set<OwnedFileHandle>();
const owners: OwnedFileHandle[] = [];
const close = vi.fn(async (owner: OwnedFileHandle) => {
owners.push(owner);
if (owners.length <= failures)
throw new Error("fixture input hash close unavailable");
return originalClose.call(owner);
});
const hash = vi
.spyOn(stableHash, "hashStableFile")
.mockImplementation(async (selected, options) => {
hashingInput = selected === path;
try {
return await originalHash(selected, options);
} finally {
hashingInput = false;
}
});
const closing = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementation(async function (this: OwnedFileHandle) {
if (!hashingInput && !retained.has(this)) return originalClose.call(this);
retained.add(this);
return close(this);
});
return {
close,
owners,
restore: () => {
closing.mockRestore();
hash.mockRestore();
},
};
};
it("retains a failed input hash descriptor until provider shutdown retries it", async () => {
const { apk, provider, launches } = await setup();
const target = await parseBinaryTarget(apk);
if (!target.ok) throw target.error;
const fault = failInputHashCloses(target.value.path, 2);
const { close, owners } = fault;
try {
const result = await service.execute("inspect_android_package", {
path: apk,
const result = await provider.execute(target.value, {
operation: "inspect_android_package",
input: { path: apk },
});
expect(result).toMatchObject({
ok: false,
@@ -57,25 +90,22 @@ it("retains a failed input hash descriptor until provider shutdown retries it",
expect(owners[0]!.handle.fd).toBe(-1);
expect(launches).toHaveLength(0);
} finally {
close.mockRestore();
fault.restore();
await provider.close();
await verifyCleanup(launches);
}
});
it("clears hash cleanup uncertainty when the provider immediately retries the same handle", async () => {
const { service, apk, provider, launches } = await setup();
const closeCause = new Error("fixture source hash close unavailable");
const originalClose = OwnedFileHandle.prototype.close;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockRejectedValueOnce(closeCause)
.mockImplementation(async function (this: OwnedFileHandle) {
return originalClose.call(this);
});
const { apk, provider, launches } = await setup();
const target = await parseBinaryTarget(apk);
if (!target.ok) throw target.error;
const fault = failInputHashCloses(target.value.path, 1);
const { close } = fault;
try {
const result = await service.execute("inspect_android_package", {
path: apk,
const result = await provider.execute(target.value, {
operation: "inspect_android_package",
input: { path: apk },
});
expect(result).toMatchObject({ ok: false });
if (result.ok) throw new Error("Expected the hash close failure");
@@ -84,7 +114,7 @@ it("clears hash cleanup uncertainty when the provider immediately retries the sa
expect(result.error.cause).toBeInstanceOf(AggregateError);
expect(launches).toHaveLength(0);
} finally {
close.mockRestore();
fault.restore();
await provider.close();
await verifyCleanup(launches);
}
@@ -100,22 +130,16 @@ it("drains hash descriptors even when session shutdown fails and retains its wor
const workspace = launches[0]?.cwd;
if (workspace === undefined)
throw new Error("Expected an acquired workspace");
const originalClose = OwnedFileHandle.prototype.close;
let fileCloseCalls = 0;
const fileClose = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementation(async function (this: OwnedFileHandle) {
fileCloseCalls += 1;
if (fileCloseCalls === 1)
throw new Error("fixture input hash close failure");
return originalClose.call(this);
});
const target = await parseBinaryTarget(apk);
if (!target.ok) throw target.error;
const fault = failInputHashCloses(target.value.path, 1);
const sessionClose = vi
.spyOn(JadxSession.prototype, "close")
.mockRejectedValueOnce(new Error("fixture session cleanup failure"));
try {
const outcome = await service.execute("inspect_android_package", {
path: apk,
const outcome = await provider.execute(target.value, {
operation: "inspect_android_package",
input: { path: apk },
});
expect(outcome).toMatchObject({
ok: false,
@@ -125,17 +149,17 @@ it("drains hash descriptors even when session shutdown fails and retains its wor
},
});
if (outcome.ok) throw new Error("Expected retained session cleanup");
expect(fileCloseCalls).toBe(2);
expect(fault.close).toHaveBeenCalledTimes(2);
await expect(access(workspace)).resolves.toBeUndefined();
sessionClose.mockRestore();
fileClose.mockRestore();
fault.restore();
await provider.close();
expect(outcome.error.cleanupIncomplete).toBe(false);
await verifyCleanup(launches);
} finally {
sessionClose.mockRestore();
fileClose.mockRestore();
fault.restore();
await provider.close();
await verifyCleanup(launches);
}
@@ -156,7 +156,7 @@ it.skipIf(process.platform === "win32").each(["reader", "import"] as const)(
);
it.skipIf(process.platform === "win32").each(["contents", "root"] as const)(
"retains a changed %s as a graph limitation after its directory handle closes",
"retains observed entries when %s changes after its directory handle closes",
async (changed) => {
const parent = await createTestTempDirectory("rea-reference-root-race-");
const root = join(parent, "observed");
@@ -192,14 +192,28 @@ it.skipIf(process.platform === "win32").each(["contents", "root"] as const)(
"already observed source",
);
await expect(readFile(source)).rejects.toMatchObject({ code: "ENOENT" });
if (changed === "root") {
expect(imported).toMatchObject({
ok: false,
error: {
code: "io",
message: expect.stringContaining(
"Directory identity could not be verified: ENOENT",
),
partial: {
entries: [{ status: "read", kind: "file", path: "source.txt" }],
bytesRead: Buffer.byteLength("already observed source"),
},
},
});
return;
}
expect(imported.ok, JSON.stringify(imported)).toBe(true);
if (!imported.ok) return;
expect(imported.value.inventory_state).toBe("partial");
expect(imported.value.limitations).toContainEqual(
expect.stringContaining(
changed === "contents"
? "Reference root enumeration is incomplete: Reference root changed"
: "Reference root enumeration is incomplete: Directory identity could not be verified: ENOENT",
"Reference root enumeration is incomplete: Reference root changed",
),
);
expect(imported.value.entries).toEqual([