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.
This commit is contained in:
morluto
2026-10-16 09:06:19 +08:00
parent 3ed925e547
commit 6e74704f93
2 changed files with 96 additions and 5 deletions
+12 -5
View File
@@ -194,7 +194,10 @@ const hashAndroidSource = async (
interface PendingCleanup {
session: JadxSession | undefined;
root: JadxWorkspaceRoot | undefined;
readonly fileHandles: Map<OwnedFileHandle, string>;
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<OwnedFileHandle, string>();
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);
@@ -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);
}
});