fix(session): report retained file and candidate cleanup

Report cleanup from the owning snapshot and provider-candidate resource scopes without retrying or starting work. Keep status accurate before a client exists and through cleanup retry.

Refs #4277, #4274
This commit is contained in:
morluto committed 2026-10-16 07:25:20 +08:00
1 parent 93c620c3e7
commit 839410b663
10 files changed
+110 -9

No files matched your search

+8 -7
View File
@@ -404,13 +404,14 @@ at that bounded check. These are historical snapshots, not live process
inventories, and do not claim that no short-lived descendant existed.
`binary_session.cleanup_pending` reports whether the session retains a provider
client whose cleanup has not succeeded. It can be true with `open: false`;
target retirement does not establish resource release. Health, activity, and
lineage remain available under that client's original `analysis_run.run_id`
where the provider supplies them. Successful cleanup clears the retained run;
a replacement client receives a fresh run ID. Follow the cleanup error's
reported resources and recovery action before reopening; a missing health
observation does not establish an idle provider.
client, session file resource, or provider-candidate resource whose cleanup has
not succeeded. It can be true with `open: false`; target retirement does not
establish resource release. Health, activity, and lineage remain available
under a retained client's original `analysis_run.run_id` where the provider
supplies them. Successful client cleanup clears the retained run; a replacement
client receives a fresh run ID. Follow the cleanup error's reported resources
and recovery action before reopening; a missing health observation does not
establish an idle provider.
## Progress and cancellation
+2
View File
@@ -290,6 +290,8 @@ export interface AnalysisProviderCandidate extends AnalysisProvider {
inspectAvailability(): ProviderAvailability;
inspectReadinessSnapshot?(): AnalysisProviderReadinessSnapshot;
inspectTargetSupport(target: BinaryTarget): ProviderTargetSupport;
/** Report retained candidate-owned cleanup without starting work or retrying it. */
hasPendingOwnedResources?(): boolean;
/** Release candidate-owned resources that may exist without a client. */
closeOwnedResources?(): Promise<Result<null, AnalysisError>>;
}
@@ -136,6 +136,13 @@ export class AnalysisProviderRegistry {
);
}
/** Report retained candidate resources without retrying cleanup. */
hasPendingOwnedResources(): boolean {
return this.#providers.some(
(provider) => provider.hasPendingOwnedResources?.() === true,
);
}
/** Close candidate-owned resources even when no target client was opened. */
async closeOwnedResources(): Promise<Result<null, AnalysisError>> {
const failures: AnalysisError[] = [];
+4 -1
View File
@@ -594,7 +594,10 @@ export class BinarySession
runtimeLineageSnapshots: client?.runtimeLineageSnapshots?.() ?? [],
requestActivitySnapshots: client?.requestActivitySnapshots?.() ?? [],
providerOperationHealth: client?.operationHealthSnapshot?.() ?? null,
cleanupPending: this.#pendingCleanup !== undefined,
cleanupPending:
this.#pendingCleanup !== undefined ||
this.hasPendingWriteResources ||
this.#providerRouter.hasPendingOwnedResources(),
});
}
@@ -63,6 +63,11 @@ export abstract class BinarySessionRecords {
this.#records = records;
}
/** Snapshot staging retained after a failed resource cleanup attempt. */
protected get hasPendingWriteResources(): boolean {
return this.#writeResources.hasPending;
}
/** Observe changes to the mutable current analysis snapshot resource. */
onAnalysisSnapshotChanged(listener: () => void | Promise<void>): () => void {
this.#snapshotListeners.add(listener);
@@ -75,6 +75,11 @@ export class SessionProviderRouter {
return this.registry.closeOwnedResources();
}
/** Report retained candidate cleanup without performing cleanup work. */
hasPendingOwnedResources(): boolean {
return this.registry.hasPendingOwnedResources();
}
/** Resolve target routes before any provider client is created. */
async resolve(
target: BinaryTarget,
+9
View File
@@ -248,6 +248,15 @@ export class KunaProjectCache {
this.#launcher = options.launcher;
}
/** Report cleanup owners retained after failed process or file cleanup. */
get hasPendingCleanup(): boolean {
return (
this.#retainedProcesses.length > 0 ||
this.#retainedFiles.size > 0 ||
this.#retainedHashHandles.size > 0
);
}
/** Keep an entry from eviction while this process serves it. */
pin(key: string): () => void {
this.#pins.set(key, (this.#pins.get(key) ?? 0) + 1);
+8
View File
@@ -298,6 +298,14 @@ export class KunaProvider implements AnalysisProviderCandidate {
);
}
/** Report retained file cleanup without probing readiness or retrying owners. */
hasPendingOwnedResources(): boolean {
return (
this.#readinessFileOwners.size > 0 ||
this.#cache?.hasPendingCleanup === true
);
}
async #closeReadinessOwners() {
const failures: { readonly path: string; readonly cause: unknown }[] = [];
for (const [owner, path] of this.#readinessFileOwners) {
@@ -32,6 +32,8 @@ import { readJsonObjectFile } from "../../../src/application/JsonInputFile.js";
import { RegularFileChangedError } from "../../../src/filesystem/RegularFile.js";
import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js";
import { RegularFileCleanupFailure } from "../../../src/application/RegularFileRead.js";
import { OwnedStagedOutput } from "../../../src/filesystem/OwnedStagedOutput.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
const snapshotFixture = (): AnalysisSnapshot => {
const target = snapshotTarget(TARGET);
@@ -76,6 +78,50 @@ const snapshotFixture = (): AnalysisSnapshot => {
};
describe("analysis snapshots: persistence", () => {
it("reports retained snapshot staging without an active provider and clears it after retry", async () => {
const directory = await createTestTempDirectory(
"rea-snapshot-cleanup-status-",
);
const path = join(directory, "analysis.json");
const session = createTestBinarySession(() => {
throw new Error("Snapshot persistence must not create a provider client");
});
const close = vi
.spyOn(OwnedStagedOutput.prototype, "close")
.mockRejectedValue(new Error("Controlled staging cleanup failure"));
onTestFinished(async () => {
close.mockRestore();
const closed = await session.close();
if (!closed.ok) throw closed.error;
});
const written = await session.writeAnalysisSnapshot(
snapshotFixture(),
path,
false,
);
expect(written).toMatchObject({
ok: false,
error: { cleanupIncomplete: true, published: { path } },
});
expect(
parseAnalysisSnapshot(JSON.parse(await readFile(path, "utf8"))),
).toEqual(snapshotFixture());
expect(session.status()).toMatchObject({
open: false,
cleanup_pending: true,
});
expect(await session.close()).toMatchObject({
ok: false,
error: { cleanupIncomplete: true },
});
expect(session.status()).toMatchObject({ cleanup_pending: true });
close.mockRestore();
expect(await session.close()).toEqual({ ok: true, value: null });
expect(session.status()).toMatchObject({ cleanup_pending: false });
});
it("writes canonical private v2 JSON and rejects changed query identity", async () => {
const directory = await createTestTempDirectory("rea-snapshot-");
const path = join(directory, "analysis.json");
@@ -126,10 +126,25 @@ it.skipIf(process.platform === "win32")(
cleanupResources: [fixture.kuna],
});
expect(injected.closeCalls).toBe(2);
expect(binary.status()).toMatchObject({
open: false,
cleanup_pending: true,
});
expect(await binary.close()).toMatchObject({
ok: false,
error: {
cleanupIncomplete: true,
cleanupResources: [fixture.kuna],
},
});
expect(injected.closeCalls).toBe(3);
expect(binary.status()).toMatchObject({ cleanup_pending: true });
injected.releaseCloseFailures = true;
expect(await binary.close()).toEqual({ ok: true, value: null });
expect(injected.closeCalls).toBe(3);
expect(injected.closeCalls).toBe(4);
expect(binary.status()).toMatchObject({ cleanup_pending: false });
expect((await fakeKunaCalls(fixture.kuna)).map(({ argv }) => argv)).toEqual(
[["--version"], ["--version"]],
);