diff --git a/src/android/JadxProvider.ts b/src/android/JadxProvider.ts index 3865dca1ff..2de555964d 100644 --- a/src/android/JadxProvider.ts +++ b/src/android/JadxProvider.ts @@ -194,7 +194,10 @@ const hashAndroidSource = async ( interface PendingCleanup { session: JadxSession | undefined; root: JadxWorkspaceRoot | undefined; - readonly fileHandles: Map; + readonly fileHandles: Map< + OwnedFileHandle, + { readonly path: string; readonly error: AnalysisError } + >; readonly previousError: AnalysisError | undefined; } @@ -207,9 +210,10 @@ const cleanup = async ( previousError === undefined ? null : projectAnalysisError(previousError), }; const failures: { readonly resource: string; readonly cause: unknown }[] = []; - for (const [owner, path] of fileHandles) { + for (const [owner, { path, error }] of fileHandles) { try { await owner.close(); + error.resolveCleanup(); fileHandles.delete(owner); } catch (cause: unknown) { failures.push({ resource: path, cause }); @@ -522,9 +526,6 @@ export class JadxProvider implements AndroidAnalysisPort { this.#retained = retained; return outcome; } catch (cause) { - const fileHandles = new Map(); - if (cause instanceof OwnedRegularFileCleanupFailure) - fileHandles.set(cause.owner, cause.path); const previousError = executionError({ cause, request, @@ -532,6 +533,12 @@ export class JadxProvider implements AndroidAnalysisPort { signal: options?.signal, session, }); + const fileHandles: PendingCleanup["fileHandles"] = new Map(); + if (cause instanceof OwnedRegularFileCleanupFailure) + fileHandles.set(cause.owner, { + path: cause.path, + error: previousError, + }); const partialObservation = session?.partialObservation(); if (partialObservation !== undefined) previousError.retainPartialObservation(partialObservation); diff --git a/tests/boundary/process/jadxHashCleanup.test.ts b/tests/boundary/process/jadxHashCleanup.test.ts new file mode 100644 index 0000000000..2f4f062bb0 --- /dev/null +++ b/tests/boundary/process/jadxHashCleanup.test.ts @@ -0,0 +1,84 @@ +import { expect, it, vi } from "vitest"; + +import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js"; +import { AnalysisError } from "../../../src/domain/analysisErrorBase.js"; +import { + createJadxProtocolFixture, + verifyJadxFixtureCleanup, +} from "../../fixtures/android/jadx.js"; + +it("resolves descriptor uncertainty after an immediate owned hash close retry", async () => { + const { service, apk, provider, launches } = + await createJadxProtocolFixture(); + const cause = new Error("controlled hash close failure"); + const original = OwnedFileHandle.prototype.close; + const owners: OwnedFileHandle[] = []; + const close = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockImplementation(async function (this: OwnedFileHandle) { + owners.push(this); + if (owners.length === 1) throw cause; + await original.call(this); + }); + try { + const result = await service.execute("inspect_android_package", { + path: apk, + }); + if (result.ok) throw new Error("Expected the original close failure"); + expect(result.error.cleanupIncomplete).toBe(false); + expect(result.error.cause).toBeInstanceOf(AggregateError); + expect((result.error.cause as AggregateError).errors).toEqual( + expect.arrayContaining([expect.objectContaining({ cause })]), + ); + expect(owners).toHaveLength(2); + expect(owners[0]).toBe(owners[1]); + expect(owners[0]?.handle.fd).toBe(-1); + expect(launches).toHaveLength(0); + } finally { + close.mockRestore(); + await provider.close(); + await verifyJadxFixtureCleanup(launches); + } +}); + +it("keeps hash cleanup live until the exact retained descriptor closes", async () => { + const { service, apk, provider, launches } = + await createJadxProtocolFixture(); + const cause = new Error("controlled persistent hash close failure"); + const original = OwnedFileHandle.prototype.close; + const owners: OwnedFileHandle[] = []; + let allowClose = false; + const close = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockImplementation(async function (this: OwnedFileHandle) { + owners.push(this); + if (!allowClose) throw cause; + await original.call(this); + }); + try { + const result = await service.execute("inspect_android_package", { + path: apk, + }); + if (result.ok) throw new Error("Expected retained descriptor cleanup"); + expect(result.error.cleanupIncomplete).toBe(true); + const primary = result.error.cause; + if (!(primary instanceof AnalysisError)) + throw new Error("Missing original typed hash failure"); + const originalCause = primary.cause; + expect(primary.cleanupIncomplete).toBe(true); + expect(owners).toHaveLength(2); + expect(owners[0]).toBe(owners[1]); + expect(owners[0]?.handle.fd).toBeGreaterThanOrEqual(0); + allowClose = true; + await provider.close(); + expect(primary.cleanupIncomplete).toBe(false); + expect(primary.cause).toBe(originalCause); + expect(owners[0]?.handle.fd).toBe(-1); + expect(launches).toHaveLength(0); + } finally { + allowClose = true; + await provider.close(); + close.mockRestore(); + await verifyJadxFixtureCleanup(launches); + } +});