diff --git a/src/application/binary/AnalysisProviderRegistry.test.ts b/src/application/binary/AnalysisProviderRegistry.test.ts index 10c440daa0..ef4b44394e 100644 --- a/src/application/binary/AnalysisProviderRegistry.test.ts +++ b/src/application/binary/AnalysisProviderRegistry.test.ts @@ -1132,6 +1132,34 @@ it("joins cancelled readiness finalization and shutdown before admitting another } }); +it.each(["alpha", "beta"])( + "retains unknown owned-resource cleanup when %s has no completion signal", + async (unknownOwner) => { + const unknown = new ProviderAdapterError(unknownOwner, "close_binary", { + userMessage: "cleanup completion unknown", + }); + const tracked = new ProviderCleanupError("rea", ["owned-reader"], {}); + let released = false; + const registry = new AnalysisProviderRegistry( + ["alpha", "beta"].map((id) => ({ + ...candidate(id).provider, + closeOwnedResources: async () => { + if (!released) return err(id === unknownOwner ? unknown : tracked); + if (id !== unknownOwner) tracked.resolveCleanup(); + return ok(null); + }, + })), + ); + const closed = await registry.closeOwnedResources(); + if (closed.ok) throw new Error("Expected incomplete registry close"); + expect(closed.error.cleanupIncomplete).toBe(true); + released = true; + expect(await registry.closeOwnedResources()).toEqual(ok(null)); + expect(tracked.cleanupIncomplete).toBe(false); + expect(closed.error.cleanupIncomplete).toBe(true); + }, +); + it("joins every candidate and retains physical failure with exact mixed cleanup owners", async () => { const alpha = candidate("alpha").provider; const beta = candidate("beta").provider; diff --git a/src/application/binary/AnalysisProviderRegistry.ts b/src/application/binary/AnalysisProviderRegistry.ts index e07ce2820e..0a61b66605 100644 --- a/src/application/binary/AnalysisProviderRegistry.ts +++ b/src/application/binary/AnalysisProviderRegistry.ts @@ -252,16 +252,15 @@ export class AnalysisProviderRegistry { const distinct = [...new Set(failures)]; if (distinct.length === 0) return ok(null); if (distinct.length === 1) return err(distinct[0]!); - return err( - new ProviderCleanupError( - "rea", - [...new Set(distinct.flatMap((failure) => failure.cleanupResources))], - { reason: distinct.map((failure) => failure.message).join("; ") }, - { cause: new AggregateError(distinct) }, - ).bindCleanupDependencies( - distinct.filter((failure) => failure.cleanupIncomplete), - ), + const failure = new ProviderCleanupError( + "rea", + [...new Set(distinct.flatMap((failure) => failure.cleanupResources))], + { reason: distinct.map((failure) => failure.message).join("; ") }, + { cause: new AggregateError(distinct) }, ); + if (distinct.every((failure) => failure.cleanupIncomplete)) + failure.bindCleanupDependencies(distinct); + return err(failure); } /** List the unique deep-operation declarations without probing candidates. */ diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index 6186a9afe7..6627353b5a 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -913,7 +913,8 @@ export class BinarySession next, "close_binary", ); - resourceFailure.bindCleanupDependencies([previous, next]); + if (previous.cleanupIncomplete && next.cleanupIncomplete) + resourceFailure.bindCleanupDependencies([previous, next]); } if (!closed.ok && resourceFailure !== undefined) { const combined = analysisErrorWithCleanupFailure( @@ -921,7 +922,8 @@ export class BinarySession resourceFailure, "close_binary", ); - combined.bindCleanupDependencies([closed.error, resourceFailure]); + if (closed.error.cleanupIncomplete && resourceFailure.cleanupIncomplete) + combined.bindCleanupDependencies([closed.error, resourceFailure]); return err(combined); } if (!closed.ok) return closed; diff --git a/src/application/binary/BinarySessionBundleResources.test.ts b/src/application/binary/BinarySessionBundleResources.test.ts index b7f3552ecd..9ea8d52e2d 100644 --- a/src/application/binary/BinarySessionBundleResources.test.ts +++ b/src/application/binary/BinarySessionBundleResources.test.ts @@ -982,6 +982,64 @@ it.each([0, 1])( }, ); +it.each([ + ["binary", "client"], + ["binary", "files"], + ["bundle", "client"], + ["bundle", "files"], +] as const)( + "keeps unknown %s close completion when the %s owner has no cleanup signal", + async (targetKind, unknownOwner) => { + const tracked = new ProviderCleanupError(identity.id, ["owned-reader"], {}); + const unknown = new ProviderAdapterError(identity.id, "close_binary", { + userMessage: "cleanup completion unknown", + }); + let released = false; + let failCleanup = false; + const closeOwner = async (owner: "client" | "files") => { + if (!failCleanup) return ok(null); + if (!released) return err(owner === unknownOwner ? unknown : tracked); + if (owner !== unknownOwner) tracked.resolveCleanup(); + return ok(null); + }; + const client: AnalysisClient = { + execute: async () => ok(createAnalysisExecution(null, binaryIdentity)), + close: () => closeOwner("client"), + }; + // Exercise the public session close composition with independently owned + // file cleanup; the file owner's failure shape is the regression boundary. + class SessionWithFileCleanup extends BinarySession { + override closeFileResources() { + return closeOwner("files"); + } + } + const session = new SessionWithFileCleanup( + createTestProviderRouter(() => client), + new InvestigationRecords(), + { createClient: () => ({ client, capabilities: [] }) }, + ); + if (targetKind === "binary") { + expect(await session.open(process.execPath)).toMatchObject({ ok: true }); + } else { + const rootPath = await mkdtemp(join(tmpdir(), "rea-resource-close-")); + temporaryDirectories.push(rootPath); + await writeFile(join(rootPath, "Info.plist"), ""); + expect(await session.openBundleResources(rootPath)).toMatchObject({ + ok: true, + }); + } + failCleanup = true; + const closed = await session.close(); + if (closed.ok) throw new Error("Expected incomplete close"); + expect(closed.error.cleanupIncomplete).toBe(true); + released = true; + expect(await session.close()).toMatchObject({ ok: true }); + expect(tracked.cleanupIncomplete).toBe(false); + expect(closed.error.cleanupIncomplete).toBe(true); + expect(await session.status()).toMatchObject({ cleanup_pending: false }); + }, +); + it.each(["health", "cancellation"] as const)( "updates early resource-open %s cleanup after its candidate retries", async (failureStage) => {