From 6e74704f931e994dfa6eef6736dd97a4e2fc2671 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:06:19 +0800 Subject: [PATCH] fix(android): resolve hash cleanup through its owner Bind the typed hash failure to its retained descriptor and resolve cleanup uncertainty only when that exact owner closes. Preserve the original failure through immediate retirement and later shutdown recovery. Controlled real-file regressions cover immediate and persistent close faults; no real JADX engine was launched. Refs #4266, #4274. --- src/android/JadxProvider.ts | 17 ++-- .../boundary/process/jadxHashCleanup.test.ts | 84 +++++++++++++++++++ 2 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 tests/boundary/process/jadxHashCleanup.test.ts 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); + } +});