mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
ff78165978
Use the shared command supervisor for installation probes and retain exact process cleanup owners through doctor, profile resolution, and session health. Preserve exit status, bounded captured output, cancellation reasons, and live cleanup facts. Block subsequent inspection until retained cleanup is retried. Generalize the existing inspection resource scope and migrate its consumers, including verifier scripts; remove the verifier's duplicate raw version probe. Validation: 116 distinct focused tests passed, including built CLI doctor with inherited output, cancellation/finalization, cleanup retry, and fake-provider verifier workflows. Build, check:fast, and test discovery passed. No real Kuna distribution or analysis target was executed. Synthetic Darwin inspection timing: first-call median 707ms versus 5.79ms; same-process repeat 37.9ms versus 3.08ms. First use includes ownership-helper preparation. Family CPU and actual peak family memory remain unmeasured. Fixes #5557
1656 lines
57 KiB
TypeScript
1656 lines
57 KiB
TypeScript
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
|
|
import { z } from "zod";
|
|
|
|
import {
|
|
createAnalysisExecution,
|
|
type AnalysisClient,
|
|
type AnalysisExecution,
|
|
type AnalysisOperation,
|
|
type ExecutionOptions,
|
|
type RetainedResourceScope,
|
|
} from "../application/AnalysisProvider.js";
|
|
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
|
import {
|
|
AnalysisCancelledError,
|
|
AnalysisCapabilityUnavailableError,
|
|
AnalysisInputError,
|
|
AnalysisResourceConstraintError,
|
|
AnalysisTimeoutError,
|
|
} from "../domain/analysisErrorCore.js";
|
|
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
|
|
import {
|
|
analysisSearchPredicate,
|
|
analysisSearchQuerySchema,
|
|
} from "../domain/analysisSearch.js";
|
|
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
|
import type { EvidenceLocation } from "../domain/evidence.js";
|
|
import { combineFunctionRelationshipCoverage } from "../domain/functionCallCoverage.js";
|
|
import { functionDossierSchema } from "../domain/hopperValues.js";
|
|
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
|
|
import type { JsonValue } from "../domain/jsonValue.js";
|
|
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
|
import type { RelationshipCoverage } from "../domain/relationshipCoverage.js";
|
|
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
|
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
|
import { err, ok, type Result } from "../domain/result.js";
|
|
import { ABORTED, waitForAbortable } from "../process/AbortablePromise.js";
|
|
import { SharedStartupAttempt } from "../process/SharedStartupAttempt.js";
|
|
import {
|
|
KUNA_INVENTORY_OUTPUT_BYTES,
|
|
kunaFileCleanupError,
|
|
kunaRetainedResourceCleanupError,
|
|
type KunaProcessLauncher,
|
|
} from "./KunaCommand.js";
|
|
import { kunaFunctionDeadline } from "./KunaConfiguration.js";
|
|
import {
|
|
createKunaResourceScope,
|
|
inspectKunaInstallation,
|
|
kunaInstallationCancellation,
|
|
type KunaInstallation,
|
|
} from "./KunaInstallation.js";
|
|
import {
|
|
kunaProjectKey,
|
|
type KunaProject,
|
|
type KunaProjectCache,
|
|
} from "./KunaProjectCache.js";
|
|
import type { KunaExportStatus } from "./KunaProjectExport.js";
|
|
import {
|
|
parseKunaInventory,
|
|
type KunaInventoryFunction,
|
|
} from "./KunaProjectFiles.js";
|
|
import {
|
|
isKunaOperation,
|
|
KUNA_RELATIONSHIP_LIMITATION,
|
|
KUNA_SINGLE_FUNCTION_LIMITATION,
|
|
kunaLimitations,
|
|
type KunaOperation,
|
|
} from "./KunaProviderCapabilities.js";
|
|
import { KUNA_PROVIDER_ID } from "./KunaRelease.js";
|
|
import { KunaTargetCommands } from "./KunaTargetCommands.js";
|
|
import { KunaReferenceQueries } from "./KunaReferenceQueries.js";
|
|
import { KunaQueryWork } from "./KunaQueryWork.js";
|
|
import { KunaStringQueries } from "./KunaStringQueries.js";
|
|
import { normalizeKunaAddress } from "./KunaAddress.js";
|
|
import { KUNA_FUNCTION_TEXT_BYTES } from "./KunaLimits.js";
|
|
import type { KunaRunRelease, KunaRunSlots } from "./KunaRunSlots.js";
|
|
import {
|
|
KunaProjectReader,
|
|
type KunaProjectFileSpan,
|
|
} from "./KunaProjectReader.js";
|
|
import { kunaUnavailable } from "./KunaUnavailable.js";
|
|
import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js";
|
|
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
|
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
|
import { kunaQueryFailureForCaller } from "./KunaQueryErrors.js";
|
|
|
|
/** How often a request waiting on the project export reports that it still waits. */
|
|
const WAIT_PROGRESS_INTERVAL_MS = 10_000;
|
|
|
|
/** Where one answer's text came from, recorded in its raw result. */
|
|
type AnswerSource =
|
|
| { readonly kind: "project-export"; readonly project: KunaProject }
|
|
| { readonly kind: "single-function" };
|
|
|
|
interface InventoryIndex {
|
|
readonly inventory: readonly KunaInventoryFunction[];
|
|
readonly byAddress: ReadonlyMap<string, KunaInventoryFunction>;
|
|
readonly byName: ReadonlyMap<string, readonly KunaInventoryFunction[]>;
|
|
}
|
|
|
|
/** A queued single-function request found the export finished first. */
|
|
const EXPORT_READY = Symbol("kuna-export-ready");
|
|
|
|
type SingleFunctionOutcome =
|
|
| Result<string, AnalysisError>
|
|
| typeof EXPORT_READY;
|
|
|
|
/** One single-function run that every request for its address shares. */
|
|
interface SingleFunctionRun {
|
|
readonly outcome: Promise<SingleFunctionOutcome>;
|
|
/** Stops the run once every waiter has left. */
|
|
readonly controller: AbortController;
|
|
waiters: number;
|
|
}
|
|
|
|
/**
|
|
* Completed single-function texts a session keeps while it has no export
|
|
* reader. Each repeat would otherwise analyze the whole image again; after a
|
|
* failed export that holds for the rest of the session.
|
|
*/
|
|
const SINGLE_FUNCTION_TEXT_CACHE_BYTES = 16 * 1024 * 1024;
|
|
/** Charged per kept text beside its UTF-8 bytes. */
|
|
const SINGLE_FUNCTION_TEXT_ENTRY_BYTES = 256;
|
|
|
|
interface SingleFunctionText {
|
|
readonly text: string;
|
|
readonly bytes: number;
|
|
}
|
|
|
|
interface Answer {
|
|
readonly result: JsonValue;
|
|
readonly source: AnswerSource | null;
|
|
readonly files?: readonly KunaProjectFileSpan[];
|
|
readonly locations: readonly EvidenceLocation[];
|
|
readonly limitations: readonly string[];
|
|
readonly referenceSource?: JsonValue;
|
|
readonly stringSource?: JsonValue;
|
|
readonly relationshipCoverage?: RelationshipCoverage;
|
|
}
|
|
|
|
/**
|
|
* Wait for work this session owns, stopping only this caller's wait when its
|
|
* signal aborts; the work itself continues for other callers.
|
|
*/
|
|
const waitFor = async <T>(
|
|
work: Promise<Result<T, AnalysisError>>,
|
|
signal: AbortSignal | undefined,
|
|
operation: string,
|
|
): Promise<Result<T, AnalysisError>> => {
|
|
const result = await waitForAbortable(work, signal);
|
|
return result === ABORTED
|
|
? err(new AnalysisCancelledError(operation, { cause: signal?.reason }))
|
|
: result;
|
|
};
|
|
|
|
/** A file the adopted export named is gone, e.g. evicted by another REA process. */
|
|
const isMissing = (cause: unknown): boolean =>
|
|
cause instanceof Error &&
|
|
"code" in cause &&
|
|
(cause.code === "ENOENT" || cause.code === "ENOTDIR");
|
|
|
|
/** Read through a call so a check before an await does not narrow later ones. */
|
|
const cancelled = (signal: AbortSignal | undefined): boolean =>
|
|
signal?.aborted === true;
|
|
|
|
const message = (cause: unknown): string =>
|
|
cause instanceof Error ? cause.message : String(cause);
|
|
|
|
const failure = (operation: string, cause: unknown): AnalysisError =>
|
|
cause instanceof AnalysisError
|
|
? cause
|
|
: new ProviderAdapterError(KUNA_PROVIDER_ID, operation, {
|
|
cause,
|
|
userMessage: `Kuna ${operation} failed: ${message(cause)}`,
|
|
});
|
|
|
|
/**
|
|
* How a request that needs the export reports the export's failure. A session
|
|
* never rebuilds a failed export, so repeating the request cannot succeed; the
|
|
* error says what to change before reopening the target. Kuna settings are read
|
|
* when REA starts, so changing one also needs a restart. Cancellation and
|
|
* unproven cleanup keep their own errors, which callers must still observe.
|
|
*/
|
|
const exportUnavailable = (
|
|
operation: string,
|
|
failure: AnalysisError,
|
|
): AnalysisError => {
|
|
if (failure instanceof AnalysisCancelledError || failure.cleanupIncomplete)
|
|
return failure;
|
|
const reason = failure.userMessage ?? failure.message;
|
|
const exported = projectAnalysisError(failure);
|
|
const restart =
|
|
"REA reads Kuna settings when it starts, so restart REA, then reopen the target.";
|
|
const next =
|
|
failure instanceof AnalysisTimeoutError
|
|
? `To retry, set REA_KUNA_PROJECT_TIMEOUT_MS above ${String(failure.timeoutMs)} ms or raise REA_KUNA_JOBS in the environment that starts REA. ${restart}`
|
|
: failure instanceof AnalysisResourceConstraintError &&
|
|
failure.remediationAction !== undefined
|
|
? // A fixed buffer (`memory`) is no setting, so a restart cannot help.
|
|
`${failure.remediationAction.replace(/\.?$/u, ".")}${failure.resource === "memory" ? "" : ` ${restart}`}`
|
|
: "To retry, resolve the reported failure, then reopen the target.";
|
|
return new AnalysisCapabilityUnavailableError(
|
|
KUNA_PROVIDER_ID,
|
|
operation,
|
|
reason,
|
|
{
|
|
cause: failure,
|
|
// Callers see only the top-level projection, so the export failure's
|
|
// own projected code and details (its limits, target path, captured
|
|
// output and diagnostics) travel whole rather than field by field.
|
|
diagnostics: {
|
|
export_error: failure._tag,
|
|
export_code: exported.code,
|
|
...(exported.details === undefined
|
|
? {}
|
|
: { export_details: exported.details }),
|
|
},
|
|
userMessage: `Kuna's project export failed, so ${operation} is unavailable in this session: ${reason.replace(/\.?$/u, ".")} ${next}`,
|
|
},
|
|
);
|
|
};
|
|
|
|
/** Kuna names a procedure by its inventory entry or by an exact entry address. */
|
|
const procedureSelectorSchema = z.string().min(1);
|
|
|
|
/**
|
|
* One Kuna analysis session for one target.
|
|
*
|
|
* Reads come from the verified project export in REA's cache. Until that
|
|
* export exists, listings use the inventory the export takes first and
|
|
* pseudocode a single-function `kuna decompile`, while the export builds in
|
|
* the background; assembly waits for it. Kuna always runs on a private copy
|
|
* whose digest matches the selected target.
|
|
*/
|
|
export class KunaSessionClient implements AnalysisClient {
|
|
readonly #target: BinaryTarget;
|
|
readonly #profile: AnalysisProfileCommitment | undefined;
|
|
readonly #cache: KunaProjectCache;
|
|
readonly #environment: Readonly<NodeJS.ProcessEnv>;
|
|
readonly #runSlots: KunaRunSlots;
|
|
readonly #launcher: KunaProcessLauncher | undefined;
|
|
readonly #resourceScope = new ArtifactResourceScope();
|
|
/** Single-function runs in flight, by entry address. */
|
|
readonly #singleFunctionRuns = new Map<string, SingleFunctionRun>();
|
|
/** Completed single-function texts by address, least recently used first. */
|
|
readonly #singleFunctionTexts = new Map<string, SingleFunctionText>();
|
|
#singleFunctionTextBytes = 0;
|
|
/** Owns background work: aborting it cancels this session's export interest. */
|
|
readonly #session = new AbortController();
|
|
/** Requests and shared readers retain their resources until their work settles. */
|
|
readonly #active = new Set<Promise<unknown>>();
|
|
readonly #commands: KunaTargetCommands;
|
|
readonly #queryWork: KunaQueryWork;
|
|
readonly #references: KunaReferenceQueries;
|
|
readonly #strings: KunaStringQueries;
|
|
#installation:
|
|
| SharedStartupAttempt<Result<KunaInstallation, AnalysisError>>
|
|
| undefined;
|
|
#inventory:
|
|
| Promise<Result<readonly KunaInventoryFunction[], AnalysisError>>
|
|
| undefined;
|
|
#project: Promise<Result<KunaProject, AnalysisError>> | undefined;
|
|
/** The cache lookup that decides #project, shared by concurrent first requests. */
|
|
#starting: Promise<void> | undefined;
|
|
/** The building export's inventory; undefined when it settles without one. */
|
|
#exportInventory:
|
|
| Promise<readonly KunaInventoryFunction[] | undefined>
|
|
| undefined;
|
|
#reader: KunaProjectReader | undefined;
|
|
#inventoryIndex: InventoryIndex | undefined;
|
|
#status: KunaExportStatus | undefined;
|
|
#key: string | undefined;
|
|
#exportFailure: string | undefined;
|
|
#releasePin: (() => void) | undefined;
|
|
/** Set once close starts; new requests are refused from then on. */
|
|
#closing = false;
|
|
/** Set only when close has released every owned resource. */
|
|
#closed = false;
|
|
|
|
constructor(
|
|
target: BinaryTarget,
|
|
profile: AnalysisProfileCommitment | undefined,
|
|
dependencies: {
|
|
readonly cache: KunaProjectCache;
|
|
readonly runSlots: KunaRunSlots;
|
|
readonly environment: Readonly<NodeJS.ProcessEnv>;
|
|
readonly launcher?: KunaProcessLauncher;
|
|
},
|
|
) {
|
|
this.#target = target;
|
|
this.#profile = profile;
|
|
this.#cache = dependencies.cache;
|
|
this.#runSlots = dependencies.runSlots;
|
|
this.#environment = dependencies.environment;
|
|
this.#launcher = dependencies.launcher;
|
|
this.#commands = new KunaTargetCommands(target, {
|
|
signal: this.#session.signal,
|
|
...(dependencies.launcher === undefined
|
|
? {}
|
|
: { launcher: dependencies.launcher }),
|
|
});
|
|
this.#queryWork = new KunaQueryWork(this.#commands, {
|
|
signal: this.#session.signal,
|
|
own: (work) => this.#own(work),
|
|
runSlots: dependencies.runSlots,
|
|
});
|
|
this.#references = new KunaReferenceQueries(this.#queryWork);
|
|
this.#strings = new KunaStringQueries(this.#queryWork);
|
|
}
|
|
|
|
async execute(
|
|
operation: AnalysisOperation,
|
|
parameters: Readonly<Record<string, JsonValue>>,
|
|
options: ExecutionOptions = {},
|
|
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
|
if (this.#closing)
|
|
return err(kunaUnavailable(operation, "This Kuna session is closed."));
|
|
return this.#own(
|
|
this.#execute(operation, parameters, {
|
|
...options,
|
|
signal:
|
|
options.signal === undefined
|
|
? this.#session.signal
|
|
: AbortSignal.any([this.#session.signal, options.signal]),
|
|
}),
|
|
);
|
|
}
|
|
|
|
#own<T>(work: Promise<T>): Promise<T> {
|
|
this.#active.add(work);
|
|
const release = () => this.#active.delete(work);
|
|
void work.then(release, release);
|
|
return work;
|
|
}
|
|
|
|
async #execute(
|
|
operation: AnalysisOperation,
|
|
parameters: Readonly<Record<string, JsonValue>>,
|
|
options: ExecutionOptions,
|
|
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
|
if (cancelled(options.signal))
|
|
return err(
|
|
new AnalysisCancelledError(operation, {
|
|
cause: options.signal?.reason,
|
|
}),
|
|
);
|
|
if (operation !== "health" && !isKunaOperation(operation))
|
|
return err(
|
|
kunaUnavailable(
|
|
operation,
|
|
"Kuna answers procedure and string queries, pseudocode, assembly and references; this operation needs another provider.",
|
|
),
|
|
);
|
|
const document = parameters.document;
|
|
if (document !== undefined && document !== this.#target.path)
|
|
return err(
|
|
new AnalysisInputError(
|
|
operation,
|
|
{
|
|
userMessage:
|
|
"A Kuna session analyzes one program; omit document or pass the selected target path.",
|
|
},
|
|
[
|
|
{
|
|
path: ["document"],
|
|
reason: "invalid_value",
|
|
expected: this.#target.path,
|
|
},
|
|
],
|
|
),
|
|
);
|
|
const installation = await this.#ensureInstallation(
|
|
operation,
|
|
options.signal,
|
|
);
|
|
if (!installation.ok) return installation;
|
|
try {
|
|
if (operation === "health")
|
|
return ok(
|
|
this.#execution([], installation.value, {
|
|
result: {
|
|
ready: true,
|
|
kuna: {
|
|
version: installation.value.version,
|
|
verified: installation.value.verified,
|
|
sha256: installation.value.sha256,
|
|
},
|
|
},
|
|
source: null,
|
|
locations: [],
|
|
limitations: [],
|
|
}),
|
|
);
|
|
if (
|
|
operation === "procedure_pseudo_code" ||
|
|
operation === "procedure_assembly" ||
|
|
operation === "analyze_function"
|
|
)
|
|
await this.#startProject(installation.value);
|
|
const answer = await this.#answer(
|
|
operation,
|
|
parameters,
|
|
installation.value,
|
|
options,
|
|
);
|
|
if (!answer.ok)
|
|
return err(kunaQueryFailureForCaller(answer.error, operation));
|
|
if (cancelled(options.signal))
|
|
return err(
|
|
new AnalysisCancelledError(operation, {
|
|
cause: options.signal?.reason,
|
|
}),
|
|
);
|
|
return ok(
|
|
this.#execution(
|
|
kunaLimitations(operation),
|
|
installation.value,
|
|
answer.value,
|
|
),
|
|
);
|
|
} catch (cause: unknown) {
|
|
if (cancelled(options.signal))
|
|
return err(new AnalysisCancelledError(operation, { cause }));
|
|
return err(failure(operation, cause));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Release everything this session owns. A failed close keeps reporting its
|
|
* failure, and each later call retries the cleanup that remains.
|
|
*/
|
|
async close(): Promise<Result<null, AnalysisError>> {
|
|
if (this.#closed) return ok(null);
|
|
if (!this.#closing) {
|
|
this.#closing = true;
|
|
this.#session.abort();
|
|
}
|
|
// Cancellation starts process shutdown; its completion proves whether it
|
|
// is safe to remove private copies and exposes any retained cleanup owner.
|
|
// An admitted request may create a shared reader after shutdown begins.
|
|
while (this.#active.size > 0) await Promise.allSettled(this.#active);
|
|
this.#references.clear();
|
|
this.#strings.clear();
|
|
this.#dropSingleFunctionTexts();
|
|
await this.#project;
|
|
await this.#inventory;
|
|
const failures: ProviderCleanupError[] = [];
|
|
// Kuna runs in its own process group; an export no other session wants
|
|
// must stop before this session reports itself closed.
|
|
if (this.#key !== undefined) {
|
|
const settled = await this.#cache.settled(this.#key);
|
|
if (!settled.ok) failures.push(settled.error);
|
|
}
|
|
const commands = await this.#commands.close();
|
|
if (!commands.ok) failures.push(commands.error);
|
|
try {
|
|
await this.#resourceScope.close();
|
|
} catch (cause: unknown) {
|
|
const observation = ArtifactReaderFailure.cleanupObservation(
|
|
cause,
|
|
"Kuna provider file descriptor",
|
|
);
|
|
failures.push(
|
|
new ProviderCleanupError(
|
|
KUNA_PROVIDER_ID,
|
|
observation.resources,
|
|
{ reason: observation.reason },
|
|
{ cause, operation: "close_binary" },
|
|
),
|
|
);
|
|
}
|
|
if (failures.length > 0)
|
|
return err(
|
|
new ProviderCleanupError(
|
|
KUNA_PROVIDER_ID,
|
|
failures.flatMap((failed) => failed.cleanupResources),
|
|
{
|
|
reason: failures
|
|
.map((failed) =>
|
|
String(failed.diagnostics?.["reason"] ?? failed.message),
|
|
)
|
|
.join("; "),
|
|
},
|
|
{
|
|
cause: new AggregateError(
|
|
failures,
|
|
"Kuna session cleanup remains incomplete",
|
|
),
|
|
operation: "close_binary",
|
|
},
|
|
).bindCleanupDependencies(failures),
|
|
);
|
|
this.#releasePin?.();
|
|
this.#releasePin = undefined;
|
|
this.#closed = true;
|
|
return ok(null);
|
|
}
|
|
|
|
#execution(
|
|
operationLimitations: readonly string[],
|
|
installation: KunaInstallation,
|
|
answer: Answer,
|
|
): AnalysisExecution {
|
|
const source = answer.source;
|
|
const files =
|
|
source?.kind !== "project-export"
|
|
? []
|
|
: (answer.files ?? []).map((file) => ({
|
|
facet: file.facet,
|
|
file: file.path,
|
|
file_sha256:
|
|
Object.values(source.project.manifest.files).find(
|
|
({ path }) => path === file.path,
|
|
)?.sha256 ?? null,
|
|
byte_offset: file.byteOffset,
|
|
byte_length: file.byteLength,
|
|
}));
|
|
// Preserve the direct text source shape; combined answers retain every
|
|
// component once, including its producing file digest and byte range.
|
|
const recordedFiles =
|
|
files.length === 1 ? files[0] : files.length === 0 ? {} : { files };
|
|
return createAnalysisExecution(
|
|
answer.result,
|
|
{
|
|
id: KUNA_PROVIDER_ID,
|
|
name: "Kuna",
|
|
version: installation.version,
|
|
},
|
|
{
|
|
// Kuna's identity is in the analysis profile; the raw result records
|
|
// only where file-backed text came from.
|
|
...(source === null &&
|
|
answer.referenceSource === undefined &&
|
|
answer.stringSource === undefined
|
|
? {}
|
|
: {
|
|
rawResult: {
|
|
...(source === null
|
|
? {}
|
|
: {
|
|
source:
|
|
source.kind === "single-function"
|
|
? { kind: "single-function" }
|
|
: {
|
|
kind: "project-export",
|
|
key: source.project.key,
|
|
directory: source.project.directory,
|
|
...recordedFiles,
|
|
},
|
|
project_export: this.#exportState(),
|
|
}),
|
|
...(answer.referenceSource === undefined
|
|
? {}
|
|
: { references: answer.referenceSource }),
|
|
...(answer.stringSource === undefined
|
|
? {}
|
|
: { strings: answer.stringSource }),
|
|
},
|
|
}),
|
|
limitations: [...operationLimitations, ...answer.limitations],
|
|
...(answer.relationshipCoverage === undefined
|
|
? {}
|
|
: { relationshipCoverage: answer.relationshipCoverage }),
|
|
locations: [
|
|
{ kind: "artifact-path", path: this.#target.path },
|
|
...answer.locations,
|
|
],
|
|
...(this.#profile === undefined
|
|
? {}
|
|
: { analysisProfile: this.#profile }),
|
|
},
|
|
);
|
|
}
|
|
|
|
/** Where the background export stands, for callers deciding whether to wait. */
|
|
#exportState(): JsonValue {
|
|
if (this.#reader !== undefined)
|
|
return { state: "ready", key: this.#reader.project.key };
|
|
if (this.#project === undefined) return { state: "not-started" };
|
|
if (this.#exportFailure !== undefined)
|
|
return { state: "failed", reason: this.#exportFailure };
|
|
return {
|
|
state: "building",
|
|
phase: this.#status?.phase ?? null,
|
|
inventory_functions: this.#status?.inventory?.length ?? null,
|
|
};
|
|
}
|
|
|
|
async #answer(
|
|
operation: KunaOperation,
|
|
parameters: Readonly<Record<string, JsonValue>>,
|
|
installation: KunaInstallation,
|
|
options: ExecutionOptions,
|
|
): Promise<Result<Answer, AnalysisError>> {
|
|
if (operation === "list_documents")
|
|
return ok({
|
|
result: [this.#target.path],
|
|
source: null,
|
|
locations: [],
|
|
limitations: [],
|
|
});
|
|
if (
|
|
operation === "xrefs" ||
|
|
operation === "procedure_callers" ||
|
|
operation === "procedure_callees"
|
|
)
|
|
return this.#referenceAnswer(
|
|
operation,
|
|
parameters,
|
|
installation,
|
|
options,
|
|
);
|
|
if (operation === "list_strings" || operation === "search_strings")
|
|
return this.#stringAnswer(operation, parameters, installation, options);
|
|
if (operation === "list_procedures" || operation === "search_procedures")
|
|
return this.#procedureList(operation, parameters, installation, options);
|
|
const inventory = await this.#ensureInventory(installation, options);
|
|
if (!inventory.ok) return inventory;
|
|
if (operation === "address_name")
|
|
return this.#addressName(
|
|
parameters,
|
|
inventory.value,
|
|
installation,
|
|
options,
|
|
);
|
|
const entry = this.#resolve(
|
|
operation,
|
|
parameters.procedure,
|
|
inventory.value,
|
|
);
|
|
if (!entry.ok) {
|
|
const selectedAddress =
|
|
operation === "procedure_address"
|
|
? normalizeKunaAddress(parameters.procedure)
|
|
: undefined;
|
|
if (selectedAddress === undefined) return entry;
|
|
const graph = await this.#references.functionAt(
|
|
installation,
|
|
selectedAddress,
|
|
operation,
|
|
options.signal,
|
|
);
|
|
if (!graph.ok) return graph;
|
|
if (graph.value === undefined) return entry;
|
|
return ok({
|
|
result: graph.value.address,
|
|
source: null,
|
|
locations: [{ kind: "address", address: selectedAddress }],
|
|
limitations: [],
|
|
referenceSource: graph.value.source,
|
|
});
|
|
}
|
|
const locations = [
|
|
{ kind: "address", address: entry.value.address } as const,
|
|
];
|
|
switch (operation) {
|
|
case "procedure_address":
|
|
return ok({
|
|
result: entry.value.address,
|
|
source: null,
|
|
locations,
|
|
limitations: [],
|
|
});
|
|
case "procedure_pseudo_code": {
|
|
const pseudocode = await this.#pseudocode(
|
|
operation,
|
|
entry.value,
|
|
installation,
|
|
options,
|
|
);
|
|
if (!pseudocode.ok) return pseudocode;
|
|
return ok({ ...pseudocode.value, locations });
|
|
}
|
|
case "procedure_assembly": {
|
|
const assembly = await this.#assemblyText(
|
|
operation,
|
|
entry.value,
|
|
installation,
|
|
options,
|
|
);
|
|
if (!assembly.ok) return assembly;
|
|
return ok({ ...assembly.value, locations });
|
|
}
|
|
case "analyze_function":
|
|
return this.#withProject(operation, installation, options, (reader) =>
|
|
this.#dossier(
|
|
operation,
|
|
entry.value,
|
|
reader,
|
|
installation,
|
|
options.signal,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
async #stringAnswer(
|
|
operation: "list_strings" | "search_strings",
|
|
parameters: Readonly<Record<string, JsonValue>>,
|
|
installation: KunaInstallation,
|
|
options: ExecutionOptions,
|
|
): Promise<Result<Answer, AnalysisError>> {
|
|
const answer = await this.#own(
|
|
this.#strings.execute(
|
|
operation,
|
|
parameters,
|
|
installation,
|
|
AbortSignal.any([
|
|
this.#session.signal,
|
|
...(options.signal === undefined ? [] : [options.signal]),
|
|
]),
|
|
),
|
|
);
|
|
return answer.ok
|
|
? ok({
|
|
result: answer.value.result,
|
|
source: null,
|
|
stringSource: answer.value.source,
|
|
locations: [],
|
|
limitations: [],
|
|
})
|
|
: answer;
|
|
}
|
|
|
|
async #procedureList(
|
|
operation: "list_procedures" | "search_procedures",
|
|
parameters: Readonly<Record<string, JsonValue>>,
|
|
installation: KunaInstallation,
|
|
options: ExecutionOptions,
|
|
): Promise<Result<Answer, AnalysisError>> {
|
|
const query =
|
|
operation === "search_procedures"
|
|
? analysisSearchQuerySchema.safeParse(parameters)
|
|
: undefined;
|
|
if (query !== undefined && !query.success)
|
|
return err(
|
|
analysisInputErrorFromIssues(operation, query.error.issues, parameters),
|
|
);
|
|
const inventory = await this.#ensureInventory(installation, options);
|
|
if (!inventory.ok) return inventory;
|
|
let selected = inventory.value;
|
|
if (query?.success === true) {
|
|
if (query.data.mode === "regex") {
|
|
const matches = await this.#queryWork.match({
|
|
operation,
|
|
pattern: query.data.pattern,
|
|
caseSensitive: query.data.case_sensitive,
|
|
values: () => inventory.value.map(({ name }) => name),
|
|
signal: options.signal,
|
|
});
|
|
if (!matches.ok) return matches;
|
|
selected = matches.value.map((index) => inventory.value[index]!);
|
|
} else {
|
|
const matches = analysisSearchPredicate(query.data);
|
|
selected = inventory.value.filter(({ name }) => matches(name));
|
|
}
|
|
}
|
|
return ok({
|
|
result: selected.map(({ address, name }) => ({ address, value: name })),
|
|
source: null,
|
|
locations: [],
|
|
limitations: [],
|
|
});
|
|
}
|
|
|
|
async #referenceAnswer(
|
|
operation: "xrefs" | "procedure_callers" | "procedure_callees",
|
|
parameters: Readonly<Record<string, JsonValue>>,
|
|
installation: KunaInstallation,
|
|
options: ExecutionOptions,
|
|
): Promise<Result<Answer, AnalysisError>> {
|
|
const field = operation === "xrefs" ? "address" : "procedure";
|
|
const selected = await this.#referenceAddress(
|
|
operation,
|
|
parameters[field],
|
|
field,
|
|
installation,
|
|
options,
|
|
);
|
|
if (!selected.ok) return selected;
|
|
const address = selected.value;
|
|
const locations = [{ kind: "address" as const, address }];
|
|
if (operation === "xrefs") {
|
|
const references = await this.#references.incoming(
|
|
installation,
|
|
address,
|
|
options.signal,
|
|
);
|
|
if (!references.ok) return references;
|
|
return ok({
|
|
result: [...references.value.addresses],
|
|
source: null,
|
|
locations,
|
|
limitations: [],
|
|
referenceSource: {
|
|
kind: "kuna-xrefs",
|
|
direction: "to",
|
|
target: address,
|
|
sha256: references.value.sha256,
|
|
bytes: references.value.bytes,
|
|
examined_rows: references.value.rows,
|
|
excluded_alias_rows: references.value.excludedAliasRows,
|
|
},
|
|
});
|
|
}
|
|
const relationships = await this.#references.relationships(
|
|
installation,
|
|
address,
|
|
operation,
|
|
options.signal,
|
|
);
|
|
if (!relationships.ok) return relationships;
|
|
const entries =
|
|
operation === "procedure_callers"
|
|
? relationships.value.callers
|
|
: relationships.value.callees;
|
|
return ok({
|
|
result: entries.map(({ address }) => address),
|
|
source: null,
|
|
locations,
|
|
referenceSource: relationships.value.source,
|
|
limitations: [],
|
|
relationshipCoverage:
|
|
operation === "procedure_callers"
|
|
? relationships.value.callersCoverage
|
|
: relationships.value.calleesCoverage,
|
|
});
|
|
}
|
|
|
|
/** Exact addresses are validated by the producing graph, which also owns
|
|
* synthetic import nodes absent from the quick function inventory. */
|
|
async #referenceAddress(
|
|
operation: string,
|
|
raw: unknown,
|
|
field: "address" | "procedure",
|
|
installation: KunaInstallation,
|
|
options: ExecutionOptions,
|
|
): Promise<Result<string, AnalysisError>> {
|
|
if (typeof raw !== "string" || raw.length === 0)
|
|
return err(
|
|
new AnalysisInputError(
|
|
operation,
|
|
{ userMessage: `Kuna requires ${field}; it has no cursor.` },
|
|
[{ path: [field], reason: "missing_argument" }],
|
|
),
|
|
);
|
|
const address = normalizeKunaAddress(raw);
|
|
if (address !== undefined) return ok(address);
|
|
if (/^0x[0-9a-f]+$/iu.test(raw))
|
|
return err(
|
|
new AnalysisInputError(
|
|
operation,
|
|
{ userMessage: "The address exceeds Kuna's u64 address space." },
|
|
[{ path: [field], reason: "invalid_value" }],
|
|
),
|
|
);
|
|
const inventory = await this.#ensureInventory(installation, options);
|
|
if (!inventory.ok) return inventory;
|
|
const entry = this.#resolve(operation, raw, inventory.value, field);
|
|
return entry.ok ? ok(entry.value.address) : entry;
|
|
}
|
|
|
|
/** Resolve an exact inventory name, alias or entry address. */
|
|
#resolve(
|
|
operation: string,
|
|
raw: unknown,
|
|
inventory: readonly KunaInventoryFunction[],
|
|
field: "procedure" | "address" = "procedure",
|
|
): Result<KunaInventoryFunction, AnalysisError> {
|
|
const parsed = procedureSelectorSchema.safeParse(raw);
|
|
if (!parsed.success)
|
|
return err(
|
|
new AnalysisInputError(
|
|
operation,
|
|
{ userMessage: `${field} is required.` },
|
|
[{ path: [field], reason: "missing_argument" }],
|
|
),
|
|
);
|
|
const selector = parsed.data;
|
|
const address = normalizeKunaAddress(selector);
|
|
if (address !== undefined) {
|
|
const entry = this.#index(inventory).byAddress.get(address);
|
|
return entry === undefined
|
|
? err(
|
|
new AnalysisInputError(operation, {
|
|
userMessage: `${address} is not a procedure entry Kuna found; list_procedures shows the entries it found.`,
|
|
}),
|
|
)
|
|
: ok(entry);
|
|
}
|
|
const matches = this.#index(inventory).byName.get(selector) ?? [];
|
|
if (matches.length === 1 && matches[0] !== undefined) return ok(matches[0]);
|
|
return err(
|
|
new AnalysisInputError(operation, {
|
|
userMessage:
|
|
matches.length === 0
|
|
? `Kuna found no procedure named ${selector}; search_procedures finds similar names.`
|
|
: `${selector} names ${String(matches.length)} Kuna entries (${matches.map(({ address }) => address).join(", ")}); select one by address.`,
|
|
}),
|
|
);
|
|
}
|
|
|
|
async #addressName(
|
|
parameters: Readonly<Record<string, JsonValue>>,
|
|
inventory: readonly KunaInventoryFunction[],
|
|
installation: KunaInstallation,
|
|
options: ExecutionOptions,
|
|
): Promise<Result<Answer, AnalysisError>> {
|
|
const raw = parameters.address;
|
|
const address = normalizeKunaAddress(raw);
|
|
if (address === undefined)
|
|
return err(
|
|
new AnalysisInputError(
|
|
"address_name",
|
|
{
|
|
userMessage:
|
|
"Kuna has no cursor; pass an explicit 0x-prefixed address.",
|
|
},
|
|
[{ path: ["address"], reason: "invalid_format" }],
|
|
),
|
|
);
|
|
const locations = [{ kind: "address", address } as const];
|
|
const entry = this.#index(inventory).byAddress.get(address);
|
|
if (entry !== undefined)
|
|
return ok({
|
|
result: entry.name,
|
|
source: null,
|
|
locations,
|
|
limitations: [],
|
|
});
|
|
await this.#startProject(installation);
|
|
return this.#withProject(
|
|
"address_name",
|
|
installation,
|
|
options,
|
|
async (reader) => {
|
|
const name = await reader.dataName(
|
|
address,
|
|
"address_name",
|
|
options.signal,
|
|
);
|
|
if (!name.ok) return name;
|
|
return ok({
|
|
result: name.value,
|
|
source: { kind: "project-export", project: reader.project },
|
|
locations,
|
|
limitations: [
|
|
"Kuna names function entries and the data its export references; null means Kuna assigned no name at this address, not that no symbol exists.",
|
|
],
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
async #pseudocode(
|
|
operation: string,
|
|
entry: KunaInventoryFunction,
|
|
installation: KunaInstallation,
|
|
options: ExecutionOptions,
|
|
): Promise<Result<Omit<Answer, "locations">, AnalysisError>> {
|
|
for (let recovered = false; ;) {
|
|
const reader = this.#reader;
|
|
if (reader !== undefined) {
|
|
try {
|
|
const text = await reader.pseudocode(entry.address, operation);
|
|
if (!text.ok) return text;
|
|
return ok({
|
|
...text.value,
|
|
source: { kind: "project-export", project: reader.project },
|
|
});
|
|
} catch (cause: unknown) {
|
|
if (!isMissing(cause) || recovered) throw cause;
|
|
recovered = true;
|
|
this.#invalidateProject(reader);
|
|
await this.#startProject(installation);
|
|
continue;
|
|
}
|
|
}
|
|
const outcome = await this.#joinSingleFunction(
|
|
operation,
|
|
entry.address,
|
|
installation,
|
|
options.signal,
|
|
);
|
|
if (outcome === EXPORT_READY) continue;
|
|
if (!outcome.ok) return outcome;
|
|
const exportFailure = await this.#settledExportFailure();
|
|
return ok({
|
|
result: outcome.value,
|
|
source: { kind: "single-function" },
|
|
limitations: [
|
|
KUNA_SINGLE_FUNCTION_LIMITATION,
|
|
...(exportFailure === undefined
|
|
? []
|
|
: [
|
|
`The project export failed, so later requests also use single-function runs: ${exportFailure}`,
|
|
]),
|
|
],
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wait for this address's shared single-function run, starting it when none
|
|
* is in flight. A caller's cancellation stops only its own wait while others
|
|
* still wait; the last waiter stops the run and receives how it ended,
|
|
* including cleanup it could not prove. A completed text answers repeats
|
|
* until the export can.
|
|
*/
|
|
#joinSingleFunction(
|
|
operation: string,
|
|
address: string,
|
|
installation: KunaInstallation,
|
|
signal: AbortSignal | undefined,
|
|
): Promise<SingleFunctionOutcome> {
|
|
if (cancelled(signal))
|
|
return Promise.resolve(
|
|
err(new AnalysisCancelledError(operation, { cause: signal?.reason })),
|
|
);
|
|
const kept = this.#singleFunctionTexts.get(address);
|
|
if (kept !== undefined) {
|
|
this.#singleFunctionTexts.delete(address);
|
|
this.#singleFunctionTexts.set(address, kept);
|
|
return Promise.resolve(ok(kept.text));
|
|
}
|
|
let run = this.#singleFunctionRuns.get(address);
|
|
if (run === undefined) {
|
|
const controller = new AbortController();
|
|
const created: SingleFunctionRun = {
|
|
controller,
|
|
waiters: 0,
|
|
outcome: this.#own(
|
|
this.#singleFunction(
|
|
operation,
|
|
address,
|
|
installation,
|
|
AbortSignal.any([this.#session.signal, controller.signal]),
|
|
).catch((cause: unknown) => err(failure(operation, cause))),
|
|
),
|
|
};
|
|
this.#singleFunctionRuns.set(address, created);
|
|
void created.outcome.then((outcome) => {
|
|
// A run whose waiters all left was already replaced or removed.
|
|
if (this.#singleFunctionRuns.get(address) !== created) return;
|
|
this.#singleFunctionRuns.delete(address);
|
|
if (outcome !== EXPORT_READY && outcome.ok)
|
|
this.#keepSingleFunctionText(address, outcome.value);
|
|
});
|
|
run = created;
|
|
}
|
|
const shared = run;
|
|
shared.waiters += 1;
|
|
return new Promise((resolve) => {
|
|
let waiting = true;
|
|
const leave = () => {
|
|
waiting = false;
|
|
shared.waiters -= 1;
|
|
};
|
|
const onAbort = () => {
|
|
if (!waiting) return;
|
|
leave();
|
|
if (shared.waiters > 0) {
|
|
resolve(
|
|
err(
|
|
new AnalysisCancelledError(operation, { cause: signal?.reason }),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
// A run whose waiters all left winds down; a new request starts afresh.
|
|
if (this.#singleFunctionRuns.get(address) === shared)
|
|
this.#singleFunctionRuns.delete(address);
|
|
shared.controller.abort(signal?.reason);
|
|
};
|
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
void shared.outcome.then((outcome) => {
|
|
signal?.removeEventListener("abort", onAbort);
|
|
if (waiting) leave();
|
|
resolve(outcome);
|
|
});
|
|
});
|
|
}
|
|
|
|
/** Keep a completed text within budget, dropping the least recently used. */
|
|
#keepSingleFunctionText(address: string, text: string): void {
|
|
// The export's text supersedes it; a closing session keeps nothing.
|
|
if (this.#reader !== undefined || this.#closing) return;
|
|
const bytes = Buffer.byteLength(text) + SINGLE_FUNCTION_TEXT_ENTRY_BYTES;
|
|
if (bytes > SINGLE_FUNCTION_TEXT_CACHE_BYTES) return;
|
|
this.#singleFunctionTexts.set(address, { text, bytes });
|
|
this.#singleFunctionTextBytes += bytes;
|
|
while (this.#singleFunctionTextBytes > SINGLE_FUNCTION_TEXT_CACHE_BYTES) {
|
|
const oldest = this.#singleFunctionTexts.entries().next().value;
|
|
if (oldest === undefined) break;
|
|
this.#singleFunctionTexts.delete(oldest[0]);
|
|
this.#singleFunctionTextBytes -= oldest[1].bytes;
|
|
}
|
|
}
|
|
|
|
#dropSingleFunctionTexts(): void {
|
|
this.#singleFunctionTexts.clear();
|
|
this.#singleFunctionTextBytes = 0;
|
|
}
|
|
|
|
/** One `kuna decompile --addr` run, admitted by the provider's run slots. */
|
|
async #singleFunction(
|
|
operation: string,
|
|
address: string,
|
|
installation: KunaInstallation,
|
|
signal: AbortSignal,
|
|
): Promise<SingleFunctionOutcome> {
|
|
const prepared = await this.#commands.prepare();
|
|
if (!prepared.ok) return prepared;
|
|
const admitted = await this.#admitSingleFunction(signal);
|
|
if (admitted === EXPORT_READY) return admitted;
|
|
if (admitted === undefined)
|
|
return err(
|
|
new AnalysisCancelledError(operation, { cause: signal.reason }),
|
|
);
|
|
const run = await this.#commands.run({
|
|
installation,
|
|
arguments: (input) => ["decompile", input, address, "--addr"],
|
|
operation,
|
|
deadline: kunaFunctionDeadline(installation.configuration),
|
|
outputBytes: KUNA_FUNCTION_TEXT_BYTES,
|
|
signal,
|
|
releaseRun: admitted,
|
|
});
|
|
return run.ok ? ok(run.value.stdout.text.replace(/\n+$/u, "")) : run;
|
|
}
|
|
|
|
/**
|
|
* A run slot, or EXPORT_READY when the export this session builds finishes
|
|
* first; the export then answers without another Kuna process.
|
|
*/
|
|
async #admitSingleFunction(
|
|
signal: AbortSignal,
|
|
): Promise<KunaRunRelease | undefined | typeof EXPORT_READY> {
|
|
const yielded = new AbortController();
|
|
const slot = this.#runSlots.acquire(
|
|
AbortSignal.any([signal, yielded.signal]),
|
|
);
|
|
const project = this.#project;
|
|
const exported =
|
|
project === undefined
|
|
? undefined
|
|
: project.then((built) =>
|
|
built.ok && this.#reader !== undefined
|
|
? EXPORT_READY
|
|
: new Promise<never>(() => undefined),
|
|
);
|
|
const admitted = await (exported === undefined
|
|
? slot
|
|
: Promise.race([slot, exported]));
|
|
if (admitted !== EXPORT_READY) return admitted;
|
|
yielded.abort();
|
|
// A slot granted in the same turn is returned rather than leaked.
|
|
void slot.then((release) => release?.());
|
|
return EXPORT_READY;
|
|
}
|
|
|
|
async #assemblyText(
|
|
operation: string,
|
|
entry: KunaInventoryFunction,
|
|
installation: KunaInstallation,
|
|
options: ExecutionOptions,
|
|
): Promise<Result<Omit<Answer, "locations">, AnalysisError>> {
|
|
return this.#withProject(
|
|
operation,
|
|
installation,
|
|
options,
|
|
async (reader) => {
|
|
const text = await reader.assembly(
|
|
entry.address,
|
|
operation,
|
|
options.signal,
|
|
);
|
|
return text.ok
|
|
? ok({
|
|
...text.value,
|
|
source: { kind: "project-export", project: reader.project },
|
|
})
|
|
: text;
|
|
},
|
|
);
|
|
}
|
|
|
|
async #dossier(
|
|
operation: string,
|
|
entry: KunaInventoryFunction,
|
|
reader: KunaProjectReader,
|
|
installation: KunaInstallation,
|
|
signal: AbortSignal | undefined,
|
|
): Promise<Result<Answer, AnalysisError>> {
|
|
const assembly = await reader.assembly(entry.address, operation, signal);
|
|
if (!assembly.ok) return assembly;
|
|
const pseudocode = await reader.pseudocode(entry.address, operation);
|
|
if (!pseudocode.ok) return pseudocode;
|
|
const text =
|
|
typeof pseudocode.value.result === "string"
|
|
? pseudocode.value.result
|
|
: null;
|
|
const unavailable = (reason: string) =>
|
|
({ available: false, reason }) as const;
|
|
const relationships = await this.#references.relationships(
|
|
installation,
|
|
entry.address,
|
|
operation,
|
|
signal,
|
|
);
|
|
if (
|
|
!relationships.ok &&
|
|
(relationships.error.cleanupIncomplete || signal?.aborted === true)
|
|
)
|
|
return relationships;
|
|
const callAvailability = relationships.ok
|
|
? { available: true as const }
|
|
: unavailable(
|
|
relationships.error.userMessage ?? relationships.error.message,
|
|
);
|
|
const dossier = functionDossierSchema.parse({
|
|
procedure: {
|
|
address: entry.address,
|
|
name: entry.name,
|
|
classification: null,
|
|
body: unavailable(
|
|
"Kuna reports an inventory extent that is an upper bound, not observed body ranges.",
|
|
),
|
|
signature: null,
|
|
locals: [],
|
|
},
|
|
pseudocode: text ?? "",
|
|
assembly:
|
|
typeof assembly.value.result === "string"
|
|
? assembly.value.result.split("\n")
|
|
: [],
|
|
comments: [],
|
|
callers: relationships.ok ? relationships.value.callers : [],
|
|
callees: relationships.ok ? relationships.value.callees : [],
|
|
incoming_references: [],
|
|
outgoing_references: [],
|
|
referenced_strings: [],
|
|
referenced_names: [],
|
|
basic_blocks: [],
|
|
native_api: null,
|
|
native_value_flow: null,
|
|
facet_availability: {
|
|
pseudocode:
|
|
text === null
|
|
? unavailable(
|
|
pseudocode.value.limitations.at(-1) ??
|
|
"Kuna did not supply pseudocode.",
|
|
)
|
|
: { available: true },
|
|
assembly: { available: true },
|
|
comments: unavailable("Kuna exports no analyst comments."),
|
|
callers: callAvailability,
|
|
callees: callAvailability,
|
|
references: unavailable(
|
|
"This provider does not report typed references.",
|
|
),
|
|
strings_names: unavailable(
|
|
"This provider does not report function-referenced strings and names.",
|
|
),
|
|
cfg: unavailable("Kuna exports no basic blocks."),
|
|
},
|
|
limitations: [
|
|
...kunaLimitations("analyze_function"),
|
|
...pseudocode.value.limitations,
|
|
KUNA_RELATIONSHIP_LIMITATION,
|
|
...(relationships.ok
|
|
? []
|
|
: [relationships.error.userMessage ?? relationships.error.message]),
|
|
],
|
|
});
|
|
return ok({
|
|
result: dossier,
|
|
source: { kind: "project-export", project: reader.project },
|
|
files: [
|
|
...(pseudocode.value.files ?? []),
|
|
...(assembly.value.files ?? []),
|
|
],
|
|
locations: [{ kind: "address", address: entry.address }],
|
|
limitations: [
|
|
...pseudocode.value.limitations,
|
|
KUNA_RELATIONSHIP_LIMITATION,
|
|
...(relationships.ok
|
|
? []
|
|
: [relationships.error.userMessage ?? relationships.error.message]),
|
|
],
|
|
referenceSource: relationships.ok
|
|
? relationships.value.source
|
|
: {
|
|
available: false,
|
|
reason:
|
|
relationships.error.userMessage ?? relationships.error.message,
|
|
},
|
|
relationshipCoverage: relationships.ok
|
|
? combineFunctionRelationshipCoverage(
|
|
relationships.value.callersCoverage,
|
|
relationships.value.calleesCoverage,
|
|
)
|
|
: {
|
|
status: "unknown",
|
|
reason:
|
|
relationships.error.userMessage ?? relationships.error.message,
|
|
limited_directions: ["callers", "callees"],
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Address and name lookups over one inventory, built once. */
|
|
#index(inventory: readonly KunaInventoryFunction[]): InventoryIndex {
|
|
if (this.#inventoryIndex?.inventory === inventory)
|
|
return this.#inventoryIndex;
|
|
const byAddress = new Map<string, KunaInventoryFunction>();
|
|
const byName = new Map<string, KunaInventoryFunction[]>();
|
|
for (const entry of inventory) {
|
|
byAddress.set(entry.address, entry);
|
|
for (const name of new Set([entry.name, ...entry.aliases])) {
|
|
const named = byName.get(name);
|
|
if (named === undefined) byName.set(name, [entry]);
|
|
else named.push(entry);
|
|
}
|
|
}
|
|
this.#inventoryIndex = { inventory, byAddress, byName };
|
|
return this.#inventoryIndex;
|
|
}
|
|
|
|
/** One installation producer; only the final departing caller stops it. */
|
|
async #ensureInstallation(
|
|
operation: string,
|
|
signal: AbortSignal | undefined,
|
|
): Promise<Result<KunaInstallation, AnalysisError>> {
|
|
let attempt = this.#installation;
|
|
if (attempt === undefined) {
|
|
attempt = new SharedStartupAttempt(
|
|
(ownerSignal) => this.#own(this.#inspectInstallation(ownerSignal)),
|
|
this.#session.signal,
|
|
);
|
|
const owned = attempt;
|
|
this.#installation = owned;
|
|
void owned.promise.then((result) => {
|
|
// A proven cancellation has no reusable installation result. Its
|
|
// complete cleanup precedes replacement; physical failures stay cached.
|
|
if (
|
|
this.#installation === owned &&
|
|
owned.signal.aborted &&
|
|
(result.ok ||
|
|
(result.error instanceof AnalysisCancelledError &&
|
|
!result.error.cleanupIncomplete))
|
|
)
|
|
this.#installation = undefined;
|
|
});
|
|
}
|
|
const waited = await attempt.wait(signal);
|
|
if (waited.status === "cancelled")
|
|
return err(
|
|
new AnalysisCancelledError(operation, { cause: signal?.reason }),
|
|
);
|
|
if (!waited.value.ok) {
|
|
// A fresh caller can arrive while the previous final waiter joins stop.
|
|
// Retry only proven cancellation, never an authored physical failure.
|
|
if (
|
|
!signal?.aborted &&
|
|
attempt.signal.aborted &&
|
|
waited.value.error instanceof AnalysisCancelledError &&
|
|
!waited.value.error.cleanupIncomplete
|
|
)
|
|
return this.#ensureInstallation(operation, signal);
|
|
return err(kunaQueryFailureForCaller(waited.value.error, operation));
|
|
}
|
|
if (signal?.aborted)
|
|
return err(
|
|
new AnalysisCancelledError(operation, { cause: signal.reason }),
|
|
);
|
|
return waited.value;
|
|
}
|
|
|
|
async #inspectInstallation(
|
|
signal: AbortSignal,
|
|
): Promise<Result<KunaInstallation, AnalysisError>> {
|
|
const parentResourceScope: RetainedResourceScope = {
|
|
retain: (owner, resource) =>
|
|
this.#resourceScope.retain({
|
|
kind: "lease",
|
|
resource,
|
|
lease: AnalysisCleanupLease.forCloseOwner(owner, resource),
|
|
}),
|
|
run: <T>(operation: () => Promise<T>) =>
|
|
this.#resourceScope.run(operation),
|
|
};
|
|
const kunaResources = createKunaResourceScope(parentResourceScope);
|
|
try {
|
|
const inspected = await inspectKunaInstallation(this.#environment, {
|
|
resourceScope: kunaResources.owner,
|
|
signal,
|
|
...(this.#launcher === undefined
|
|
? {}
|
|
: { processLauncher: this.#launcher }),
|
|
});
|
|
if (!inspected.ok) {
|
|
const error = kunaUnavailable("health", inspected.error.reason, {
|
|
diagnostics: inspected.error.diagnostics,
|
|
...(!kunaResources.hasPending
|
|
? {}
|
|
: {
|
|
cleanup: {
|
|
reason: "Kuna version-probe process cleanup is unconfirmed.",
|
|
resources: kunaResources.cleanupResources ?? [],
|
|
},
|
|
}),
|
|
});
|
|
kunaResources.bindRetainedFailure(error);
|
|
return err(error);
|
|
}
|
|
const committed = this.#profile?.parameters["kuna_sha256"];
|
|
if (committed !== undefined && committed !== inspected.value.sha256)
|
|
return err(
|
|
new ProviderAdapterError(KUNA_PROVIDER_ID, "health", {
|
|
userMessage:
|
|
"The Kuna executable changed after this target was opened; reopen the target to analyze it with the current executable.",
|
|
diagnostics: {
|
|
committed_sha256: committed,
|
|
observed_sha256: inspected.value.sha256,
|
|
},
|
|
}),
|
|
);
|
|
return ok(inspected.value);
|
|
} catch (cause: unknown) {
|
|
const retainedCleanup = kunaRetainedResourceCleanupError(cause, "health");
|
|
if (retainedCleanup !== undefined) return err(retainedCleanup);
|
|
const cancellation = kunaInstallationCancellation(cause);
|
|
if (
|
|
!(cause instanceof RegularFileCleanupFailure) &&
|
|
cancellation !== undefined
|
|
) {
|
|
const error = new AnalysisCancelledError("health", {
|
|
cause: cancellation.reason,
|
|
diagnostics: cancellation.diagnostics,
|
|
...(!kunaResources.hasPending
|
|
? {}
|
|
: {
|
|
cleanup: {
|
|
reason: "Kuna version-probe process cleanup is unconfirmed.",
|
|
resources: kunaResources.cleanupResources ?? [],
|
|
},
|
|
}),
|
|
});
|
|
kunaResources.bindRetainedFailure(error);
|
|
return err(error);
|
|
}
|
|
if (!(cause instanceof RegularFileCleanupFailure))
|
|
return err(failure("health", cause));
|
|
const error = kunaFileCleanupError(cause, "health");
|
|
kunaResources.bindFailure(cause, error);
|
|
return err(error);
|
|
}
|
|
}
|
|
|
|
/** Reuse a verified export, or start building one in the background. */
|
|
#startProject(installation: KunaInstallation): Promise<void> {
|
|
if (this.#project !== undefined) return Promise.resolve();
|
|
// Concurrent first requests share one lookup, which verifies every
|
|
// export file's digest on a fresh server.
|
|
this.#starting ??= this.#lookupOrBuild(installation).finally(() => {
|
|
this.#starting = undefined;
|
|
});
|
|
return this.#starting;
|
|
}
|
|
|
|
async #lookupOrBuild(installation: KunaInstallation): Promise<void> {
|
|
const key = kunaProjectKey({
|
|
targetSha256: this.#target.sha256,
|
|
kunaSha256: installation.sha256,
|
|
kunaVersion: installation.version,
|
|
jobs: installation.configuration.jobs,
|
|
});
|
|
this.#key = key;
|
|
const cached = await this.#cache.lookup(key, this.#session.signal);
|
|
if (cached !== undefined) {
|
|
this.#adopt(cached);
|
|
this.#project = Promise.resolve(ok(cached));
|
|
return;
|
|
}
|
|
let listed!: (
|
|
inventory: readonly KunaInventoryFunction[] | undefined,
|
|
) => void;
|
|
this.#exportInventory = new Promise((resolve) => {
|
|
listed = resolve;
|
|
});
|
|
this.#project = this.#cache
|
|
.build({
|
|
installation,
|
|
targetPath: this.#target.path,
|
|
targetSha256: this.#target.sha256,
|
|
signal: this.#session.signal,
|
|
onStatus: (status) => {
|
|
this.#status = status;
|
|
if (status.inventory !== null) listed(status.inventory);
|
|
},
|
|
})
|
|
.then((result) => {
|
|
listed(result.ok ? result.value.inventory : undefined);
|
|
if (result.ok && !this.#closing) this.#adopt(result.value);
|
|
else if (!result.ok && !this.#closing)
|
|
this.#exportFailure =
|
|
result.error.userMessage ?? result.error.message;
|
|
return result;
|
|
});
|
|
}
|
|
|
|
/** Retry the complete read when its immutable project disappears, never just one facet. */
|
|
async #withProject<T>(
|
|
operation: string,
|
|
installation: KunaInstallation,
|
|
options: ExecutionOptions,
|
|
read: (reader: KunaProjectReader) => Promise<Result<T, AnalysisError>>,
|
|
): Promise<Result<T, AnalysisError>> {
|
|
for (let attempt = 0; ; attempt += 1) {
|
|
const reader = await this.#awaitProject(operation, options);
|
|
if (!reader.ok) return reader;
|
|
try {
|
|
return await read(reader.value);
|
|
} catch (cause: unknown) {
|
|
if (!isMissing(cause) || attempt !== 0) throw cause;
|
|
this.#invalidateProject(reader.value);
|
|
await this.#startProject(installation);
|
|
}
|
|
}
|
|
}
|
|
|
|
#invalidateProject(reader: KunaProjectReader): void {
|
|
// Another request may already have replaced this reader and its build.
|
|
if (this.#reader !== reader) return;
|
|
this.#releasePin?.();
|
|
this.#releasePin = undefined;
|
|
this.#reader = undefined;
|
|
this.#project = undefined;
|
|
this.#status = undefined;
|
|
this.#exportFailure = undefined;
|
|
}
|
|
|
|
#adopt(project: KunaProject): void {
|
|
this.#reader = this.#readerFor(project);
|
|
this.#dropSingleFunctionTexts();
|
|
this.#releasePin ??= this.#cache.pin(project.key);
|
|
}
|
|
|
|
#readerFor(project: KunaProject): KunaProjectReader {
|
|
return this.#reader?.project === project
|
|
? this.#reader
|
|
: new KunaProjectReader(project, {
|
|
signal: this.#session.signal,
|
|
own: (work) => this.#own(work),
|
|
});
|
|
}
|
|
|
|
/** The export's failure once it has settled; undefined while it builds or after success. */
|
|
async #settledExportFailure(): Promise<string | undefined> {
|
|
const settled = await Promise.race([
|
|
this.#project,
|
|
Promise.resolve(undefined),
|
|
]);
|
|
if (settled === undefined || settled.ok) return undefined;
|
|
return settled.error.userMessage ?? settled.error.message;
|
|
}
|
|
|
|
/** Wait for the export, reporting progress, without cancelling it for other callers. */
|
|
async #awaitProject(
|
|
operation: string,
|
|
options: ExecutionOptions,
|
|
): Promise<Result<KunaProjectReader, AnalysisError>> {
|
|
if (this.#reader !== undefined) return ok(this.#reader);
|
|
const project = this.#project;
|
|
if (project === undefined)
|
|
return err(
|
|
new ProviderAdapterError(KUNA_PROVIDER_ID, operation, {
|
|
userMessage: "The Kuna project export was never started.",
|
|
}),
|
|
);
|
|
const started = Date.now();
|
|
const progress = options.progress;
|
|
// Report waiting only; Kuna exposes no measure of completed export work.
|
|
const timer = setInterval(() => {
|
|
void progress
|
|
?.report({
|
|
phase: "kuna_export",
|
|
completed: 0,
|
|
total: null,
|
|
message: `Waiting for Kuna's project export (${this.#status?.phase ?? "starting"}, ${String(Math.floor((Date.now() - started) / 1000))}s elapsed); it serves this request and every later one`,
|
|
})
|
|
.catch(() => undefined);
|
|
}, WAIT_PROGRESS_INTERVAL_MS);
|
|
try {
|
|
const built = await waitFor(project, options.signal, operation);
|
|
if (!built.ok) return err(exportUnavailable(operation, built.error));
|
|
return ok(this.#readerFor(built.value));
|
|
} finally {
|
|
clearInterval(timer);
|
|
}
|
|
}
|
|
|
|
/** The published or building export's inventory, otherwise one `kuna functions` run. */
|
|
async #ensureInventory(
|
|
installation: KunaInstallation,
|
|
options: ExecutionOptions,
|
|
): Promise<Result<readonly KunaInventoryFunction[], AnalysisError>> {
|
|
if (this.#reader !== undefined) return ok(this.#reader.project.inventory);
|
|
const exported = this.#status?.inventory;
|
|
if (exported !== undefined && exported !== null) return ok(exported);
|
|
if (this.#inventory === undefined) {
|
|
// The session owns the shared run; each caller only stops waiting.
|
|
const run = this.#listInventory(installation);
|
|
this.#inventory = run;
|
|
void run.then((listed) => {
|
|
// A failed listing is retried by the next request.
|
|
if (!listed.ok && this.#inventory === run) this.#inventory = undefined;
|
|
});
|
|
}
|
|
return waitFor(this.#inventory, options.signal, "list_procedures");
|
|
}
|
|
|
|
/**
|
|
* The export lists every callable entry before it decompiles, so listings
|
|
* wait for that inventory. Only an export that ends without one, or none at
|
|
* all, leads to a separate `kuna functions` run.
|
|
*/
|
|
async #listInventory(
|
|
installation: KunaInstallation,
|
|
): Promise<Result<readonly KunaInventoryFunction[], AnalysisError>> {
|
|
// A concurrent text request may still be choosing between the cache and a
|
|
// build; its outcome decides whether an inventory already exists.
|
|
await this.#starting?.catch(() => undefined);
|
|
if (this.#reader !== undefined) return ok(this.#reader.project.inventory);
|
|
const exported = await this.#exportInventory;
|
|
if (exported !== undefined) return ok(exported);
|
|
const run = await this.#commands.run({
|
|
installation,
|
|
arguments: (input) => ["functions", input, "--json"],
|
|
operation: "list_procedures",
|
|
deadline: kunaFunctionDeadline(installation.configuration),
|
|
outputBytes: KUNA_INVENTORY_OUTPUT_BYTES,
|
|
signal: this.#session.signal,
|
|
});
|
|
if (!run.ok) return run;
|
|
const inventory = parseKunaInventory(run.value.stdout.text);
|
|
return inventory.ok
|
|
? ok(inventory.value)
|
|
: err(
|
|
new ProviderAdapterError(KUNA_PROVIDER_ID, "list_procedures", {
|
|
userMessage: `Kuna's function inventory is not usable: ${inventory.error}`,
|
|
}),
|
|
);
|
|
}
|
|
}
|