Files
reverse-engineering-anything/src/providers/AnalysisClientCreation.ts
T
morluto 4aefe2daec fix(errors): name rea doctor only for providers it can check (#10565)
* fix(errors): name rea doctor only for providers it can check

An unclassified ProviderAdapterError, an unreadable result and the final
presentation fallback all told the caller to run rea doctor, although doctor
checks only hopper, ghidra, ida, jadx, kuna and desktop_build. The doctor
scope list moves into the domain so presentation can consult it: covered
providers get `rea doctor --provider ID`, others are told plainly that the
failure is unclassified and where its reason is reported.

Producers whose actionable reason lived only in details.diagnostics now
author it: Ghidra's committed-profile conflicts share one owner that says to
reopen the target, closed analysis clients share one owner, and a jadx-reported
request failure no longer points at provider readiness. The deep-analysis
profile rejection names doctor only for doctor-checkable providers. Dead
presentation branches for tags handled earlier are removed.

* fix(errors): make provider recovery actionable

* test(android): remove brittle recovery wording check

* test: wait for observed child exit before cancellation

* test(errors): check that doctor is not offered instead of matching recovery prose
2026-11-15 18:09:40 +08:00

279 lines
9.3 KiB
TypeScript

import { analysisClientFailureForOperation } from "./AnalysisClientFailure.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type {
AnalysisClient,
RetainedResourceScope,
} from "../application/AnalysisProvider.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
cancelledBy,
carriedReason,
} from "../domain/analysisErrorCore.js";
import {
closedAnalysisClientError,
ProviderAdapterError,
} from "../domain/providerAdapterError.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { err, ok, type Result } from "../domain/result.js";
import { SharedStartupAttempt } from "../process/SharedStartupAttempt.js";
import { closeAnalysisClient } from "../application/AnalysisClientCleanup.js";
/** Own deferred client creation while callers wait independently and close joins it. */
export class AnalysisClientCreation {
readonly #inspectionResources = new ArtifactResourceScope();
readonly #inspectionOwner: RetainedResourceScope = {
retain: (owner, resource) =>
this.#inspectionResources.retain({
kind: "lease",
resource,
lease: AnalysisCleanupLease.forCloseOwner(owner, resource),
}),
run: (operation) => this.#inspectionResources.run(operation),
};
#attempt:
| SharedStartupAttempt<Result<AnalysisClient, AnalysisError>>
| undefined;
#client: AnalysisClient | undefined;
#failure: AnalysisError | undefined;
#closed = false;
#clientLease: AnalysisCleanupLease | undefined;
#closing: Promise<Result<null, AnalysisError>> | undefined;
#closeOptions: Parameters<AnalysisClient["close"]>[0];
constructor(
private readonly providerId: string,
private readonly create: (
signal: AbortSignal,
resourceOwner: RetainedResourceScope,
) => Promise<AnalysisClient>,
) {}
get ownedClient(): AnalysisClient | undefined {
return this.#client;
}
get readyClient(): AnalysisClient | undefined {
return this.#closed ||
this.#attempt !== undefined ||
this.#failure !== undefined
? undefined
: this.#client;
}
get reusable(): boolean {
return (
!this.#closed &&
this.#attempt === undefined &&
this.#failure === undefined &&
this.#clientLease?.released !== true
);
}
async acquire(
operation: string,
signal?: AbortSignal,
): Promise<Result<AnalysisClient, AnalysisError>> {
if (signal?.aborted) return err(cancelledBy(operation, signal));
if (this.#closed)
return err(closedAnalysisClientError(this.providerId, operation));
if (this.#failure?.cleanupIncomplete)
return err(
analysisClientFailureForOperation(
this.#failure,
this.providerId,
operation,
),
);
const ready = this.readyClient;
if (ready !== undefined && this.#clientLease?.released !== true)
return ok(ready);
if (this.#clientLease?.released === true) {
this.#client = undefined;
this.#clientLease = undefined;
}
let attempt = this.#attempt;
const draining = attempt?.signal.aborted === true;
if (attempt === undefined) {
this.#failure = undefined;
attempt = new SharedStartupAttempt((ownerSignal) =>
Promise.resolve().then(() => this.#createOwned(ownerSignal)),
);
const owned = attempt;
this.#attempt = owned;
void owned.promise.then((result) => {
if (this.#attempt !== owned) return;
if (!result.ok) this.#failure = result.error;
this.#attempt = undefined;
});
}
const waited = await attempt.wait(signal);
if (waited.status === "cancelled")
return err(cancelledBy(operation, carriedReason(waited.reason)));
if (
draining &&
!signal?.aborted &&
!waited.value.ok &&
waited.value.error instanceof AnalysisCancelledError &&
!waited.value.error.cleanupIncomplete &&
waited.value.error.cause === attempt.signal.reason
)
return this.acquire(operation, signal);
if (!waited.value.ok)
return err(
analysisClientFailureForOperation(
waited.value.error,
this.providerId,
operation,
),
);
if (signal?.aborted) return err(cancelledBy(operation, signal));
return waited.value;
}
async #createOwned(
signal: AbortSignal,
): Promise<Result<AnalysisClient, AnalysisError>> {
if (signal.aborted)
return err(cancelledBy("create_analysis_client", signal));
let primary: AnalysisError | undefined;
try {
this.#client = await this.create(signal, this.#inspectionOwner);
} catch (cause: unknown) {
primary =
cause instanceof AnalysisError
? cause
: new ProviderAdapterError(
this.providerId,
"create_analysis_client",
{ cause },
);
}
const inspections = await this.#cleanupInspectionResources(() =>
this.#inspectionResources.retryCleanup(),
);
if (signal.aborted && primary === undefined)
primary = cancelledBy("create_analysis_client", signal);
if (!inspections.ok)
primary =
primary === undefined
? inspections.error
: analysisErrorWithCleanupFailure(
primary,
inspections.error,
"create_analysis_client",
).bindCleanupDependencies([primary, inspections.error]);
if (primary !== undefined) {
const closed = await this.#releaseClient(this.#closeOptions);
return err(
closed.ok
? primary
: analysisErrorWithCleanupFailure(
primary,
closed.error,
"create_analysis_client",
).bindCleanupDependencies([primary, closed.error]),
);
}
if (this.#client === undefined)
throw new Error("Client creation completed without a client");
return ok(this.#client);
}
async #cleanupInspectionResources(
cleanup: () => Promise<void>,
): Promise<Result<null, AnalysisError>> {
try {
await cleanup();
return ok(null);
} catch (cause: unknown) {
const observation = ArtifactReaderFailure.cleanupObservation(
cause,
"client prerequisite resources",
);
const failure = new ProviderCleanupError(
this.providerId,
observation.resources,
{ reason: observation.reason },
{ cause, operation: "create_analysis_client" },
);
bindArtifactFileCleanup(cause, failure);
return err(failure);
}
}
close(
options?: Parameters<AnalysisClient["close"]>[0],
): Promise<Result<null, AnalysisError>> {
this.#closed = true;
this.#closeOptions = options;
this.#attempt?.close();
this.#closing ??= Promise.resolve()
.then(async () => {
await this.#attempt?.promise;
const inspections = await this.#cleanupInspectionResources(() =>
this.#inspectionResources.close(),
);
const inspectionFailure = inspections.ok
? undefined
: inspections.error;
const closed = await this.#releaseClient(options);
if (!closed.ok && inspectionFailure !== undefined)
return err(
analysisErrorWithCleanupFailure(
closed.error,
inspectionFailure,
"create_analysis_client",
).bindCleanupDependencies([closed.error, inspectionFailure]),
);
if (!closed.ok) return closed;
if (inspectionFailure !== undefined) return err(inspectionFailure);
return this.#failure?.cleanupIncomplete ? err(this.#failure) : ok(null);
})
.finally(() => {
this.#closing = undefined;
});
return this.#closing;
}
async #releaseClient(
options: Parameters<AnalysisClient["close"]>[0],
): Promise<Result<null, AnalysisError>> {
if (this.#client === undefined) return ok(null);
const client = this.#client;
this.#closeOptions = options;
this.#clientLease ??= new AnalysisCleanupLease(async () => {
const result = await closeAnalysisClient(
client,
this.providerId,
this.#closeOptions,
);
if (!result.ok) throw result.error;
});
try {
await this.#clientLease.release();
return ok(null);
} catch (cause: unknown) {
const failure = new ProviderCleanupError(
this.providerId,
cause instanceof AnalysisError && cause.cleanupResources.length > 0
? cause.cleanupResources
: ["provider-client"],
{
reason:
"Client creation retains its exact client until close confirms release",
...(cause instanceof AnalysisError
? { client_close_failure: projectAnalysisError(cause) }
: {}),
},
{ cause, operation: "create_analysis_client" },
);
failure.bindCleanupResourceOwner(this.#clientLease);
this.#clientLease.bindCleanupError(failure);
return err(failure);
}
}
}