From fcd5192743a7c1824732929fd3a191ece295ec2a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:43:56 +0800 Subject: [PATCH] test(lifecycle): target owned finalization boundaries --- .../evm/evmoleInterfaceProvider.test.ts | 5 +- .../filesystem/evidenceBundleFiles.test.ts | 39 ++++--- .../boundary/process/jadxIntegration.test.ts | 110 +++++++++++------- .../referenceSourceSymlinkRace.test.ts | 22 +++- 4 files changed, 114 insertions(+), 62 deletions(-) diff --git a/tests/boundary/evm/evmoleInterfaceProvider.test.ts b/tests/boundary/evm/evmoleInterfaceProvider.test.ts index 52327be449..f1b012a058 100644 --- a/tests/boundary/evm/evmoleInterfaceProvider.test.ts +++ b/tests/boundary/evm/evmoleInterfaceProvider.test.ts @@ -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 } diff --git a/tests/boundary/filesystem/evidenceBundleFiles.test.ts b/tests/boundary/filesystem/evidenceBundleFiles.test.ts index c43d64aad1..90dd75fd80 100644 --- a/tests/boundary/filesystem/evidenceBundleFiles.test.ts +++ b/tests/boundary/filesystem/evidenceBundleFiles.test.ts @@ -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 }); }); diff --git a/tests/boundary/process/jadxIntegration.test.ts b/tests/boundary/process/jadxIntegration.test.ts index 3e452f8679..a1e8a8cf10 100644 --- a/tests/boundary/process/jadxIntegration.test.ts +++ b/tests/boundary/process/jadxIntegration.test.ts @@ -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(); + 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); } diff --git a/tests/process-global/referenceSourceSymlinkRace.test.ts b/tests/process-global/referenceSourceSymlinkRace.test.ts index 6229ab873d..7dd1be27fd 100644 --- a/tests/process-global/referenceSourceSymlinkRace.test.ts +++ b/tests/process-global/referenceSourceSymlinkRace.test.ts @@ -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([