fix(session): track retained bundle cleanup dependencies

This commit is contained in:
morluto committed 2026-10-27 07:40:40 +08:00
1 parent e80fb638be
commit 6a9911ea77
4 files changed
+145 -12

No files matched your search

+8 -2
View File
@@ -939,14 +939,20 @@ export class BinarySession
);
if (!closed.ok) {
retained.push(session);
const previous = failure;
failure =
failure === undefined
previous === undefined
? closed.error
: analysisErrorWithCleanupFailure(
failure,
previous,
closed.error,
"close_binary",
);
if (
previous?.cleanupIncomplete === true &&
closed.error.cleanupIncomplete
)
failure.bindCleanupDependencies([previous, closed.error]);
}
}
this.#pendingBundleResourceCleanup = retained;
@@ -16,12 +16,12 @@ import { SessionProviderRouter } from "./SessionProviderRouter.js";
import { bundleResourceRootSchema } from "../../domain/apple/bundleResourceRoot.js";
import { AnalysisSnapshotMismatchError } from "../../domain/evidenceErrors.js";
import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import { err } from "../../domain/result.js";
import { err, ok } from "../../domain/result.js";
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
import { createEvidence } from "../../domain/evidence.js";
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
import { ok } from "../../domain/result.js";
import { createTestProviderRouter } from "../../../tests/fixtures/binarySession.js";
const identity: ProviderIdentity = {
@@ -797,3 +797,63 @@ it("cancels a binary switch during resource retirement and retains null-subject
expect(await session.close()).toMatchObject({ ok: true });
expect(resourceCloseAttempts).toBe(2);
});
it("tracks independent pending resource owners after a failed session replacement", async () => {
const rootPath = await mkdtemp(
join(tmpdir(), "rea-resource-pending-owners-"),
);
temporaryDirectories.push(rootPath);
await writeFile(join(rootPath, "Info.plist"), "<plist><dict/></plist>");
const failures = [
new ProviderCleanupError(identity.id, ["previous-reader"], {}),
new ProviderCleanupError(identity.id, ["candidate-reader"], {}),
];
const released = [false, false];
let created = 0;
const session = new BinarySession(
SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []),
new InvestigationRecords(),
{
createClient: () => {
const index = created++;
const failure = failures[index];
if (failure === undefined)
throw new Error("Unexpected resource client");
return {
capabilities: [],
client: {
execute: async () => ok(createAnalysisExecution(null, identity)),
close: async () => {
if (!released[index]) return err(failure);
failure.resolveCleanup();
return ok(null);
},
},
};
},
},
);
expect(await session.openBundleResources(rootPath)).toMatchObject({
ok: true,
});
expect(await session.openBundleResources(rootPath)).toMatchObject({
ok: false,
});
const first = await session.close();
if (first.ok)
throw new Error("Expected both resource owners to remain pending");
expect(new Set(first.error.cleanupResources)).toEqual(
new Set(["previous-reader", "candidate-reader"]),
);
released[0] = true;
expect(await session.close()).toMatchObject({ ok: false });
expect(first.error.cleanupIncomplete).toBe(true);
expect(first.error.cleanupResources).toEqual(["candidate-reader"]);
released[1] = true;
expect(await session.close()).toMatchObject({ ok: true });
expect(first.error.cleanupIncomplete).toBe(false);
expect(first.error.cleanupResources).toEqual([]);
expect(await session.status()).toMatchObject({ cleanup_pending: false });
});
@@ -6,8 +6,9 @@ import {
type AnalysisClient,
type ProviderIdentity,
} from "../AnalysisProvider.js";
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import { err } from "../../domain/result.js";
import { err, ok } from "../../domain/result.js";
const identity: ProviderIdentity = {
id: "bundle-resource-close-fixture",
@@ -104,3 +105,67 @@ it("reports the first mixed child failure before awaiting the other owner and re
expect(artifactCloseAttempts).toBe(2);
expect(nativeCloseAttempts).toBe(2);
});
it("updates retained cleanup facts as the two child owners release independently", async () => {
const artifactFailure = new ProviderCleanupError(
identity.id,
["artifact-reader"],
{},
);
const nativeFailure = new ProviderCleanupError(
identity.id,
["native-reader"],
{},
);
let releaseArtifact = false;
let releaseNative = false;
const client = (
failure: ProviderCleanupError,
released: () => boolean,
): AnalysisClient => ({
execute: async () => ok(createAnalysisExecution(null, identity)),
close: async () => {
if (!released()) return err(failure);
failure.resolveCleanup();
return ok(null);
},
});
const artifact = client(artifactFailure, () => releaseArtifact);
const native = client(nativeFailure, () => releaseNative);
const combined = combineBundleResourceClients(artifact, native);
const first = await combined.close();
if (first.ok) throw new Error("Expected both child owners to remain open");
expect(new Set(first.error.cleanupResources)).toEqual(
new Set(["artifact-reader", "native-reader"]),
);
releaseArtifact = true;
expect(await artifact.close()).toMatchObject({ ok: true });
expect(first.error.cleanupIncomplete).toBe(true);
expect(first.error.cleanupResources).toEqual(["native-reader"]);
releaseNative = true;
expect(await combined.close()).toMatchObject({ ok: true });
expect(first.error.cleanupIncomplete).toBe(false);
expect(first.error.cleanupResources).toEqual([]);
expect(new Set(first.error.cleanup?.resources)).toEqual(
new Set(["artifact-reader", "native-reader"]),
);
});
it("keeps cleanup unknown when a child close failure has no cleanup lifecycle", async () => {
const owned = new ProviderCleanupError(identity.id, ["artifact-reader"], {});
const client = (
failure: AnalysisInputError | ProviderCleanupError,
): AnalysisClient => ({
execute: async () => ok(createAnalysisExecution(null, identity)),
close: async () => err(failure),
});
const closed = await combineBundleResourceClients(
client(owned),
client(new AnalysisInputError("close_binary")),
).close();
if (closed.ok) throw new Error("Expected child close failures");
owned.resolveCleanup();
expect(closed.error.cleanupIncomplete).toBe(true);
});
@@ -70,14 +70,16 @@ export const combineBundleResourceClients = (
closeAnalysisClient(artifactClient, "bundle-resources", options),
closeAnalysisClient(nativeClient, "bundle-resources", options),
]);
if (!artifact.ok && !native.ok)
return err(
analysisErrorWithCleanupFailure(
artifact.error,
native.error,
"close_binary",
),
if (!artifact.ok && !native.ok) {
const failure = analysisErrorWithCleanupFailure(
artifact.error,
native.error,
"close_binary",
);
if (artifact.error.cleanupIncomplete && native.error.cleanupIncomplete)
failure.bindCleanupDependencies([artifact.error, native.error]);
return err(failure);
}
if (!artifact.ok) return artifact;
if (!native.ok) return native;
return ok(null);