Files
rea/src/kuna/KunaProjectExport.ts
T
Kaoru0822andClaude Opus 5.5 9be493880c fix(kuna): name the setting that owns a timed-out Kuna run (#4343)
* fix(kuna): name the setting that owns a timed-out Kuna run

A Kuna run past its deadline surfaced the generic "Analysis took too
long. Try a smaller request" message. The request is already one
function or one whole-program scan, and the same run takes as long
again, so the advice was wrong and REA_KUNA_TIMEOUT_MS or
REA_KUNA_PROJECT_TIMEOUT_MS was never mentioned.

Each Kuna command now carries a KunaDeadline: the configured
milliseconds and the setting that owns them, from KunaConfiguration.
A timeout reports, for example, "kuna decompile did not finish within
REA_KUNA_TIMEOUT_MS (120000 ms)." Its remediation says to raise that
setting and restart REA, and for a single-function decompile that the
session's project export can still answer. AnalysisTimeoutError now
accepts a producer message and remediation, which projection uses and
per-waiter scan errors keep.

Refs #1957

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(kuna): scope the provider_timeout statement to direct Kuna runs

A timed-out project export is reported through the export failure as
capability_unavailable, not provider_timeout.

Refs #1957

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kuna): verify native xrefs under the project deadline

The provider's xrefs scan runs under REA_KUNA_PROJECT_TIMEOUT_MS, but the
native oracle in verify:kuna aborted at REA_KUNA_TIMEOUT_MS. A scan that
fit the project deadline but not the function deadline failed
verification although the provider answered it. Use the same deadline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:38:16 -07:00

384 lines
13 KiB
TypeScript

import { createReadStream } from "node:fs";
import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisArtifactChangedError,
AnalysisCancelledError,
} from "../domain/analysisErrorCore.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { err, ok, type Result } from "../domain/result.js";
import {
hashStableFile,
StableFileHashFailure,
} from "../filesystem/StableFileHash.js";
import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { prepareProcessOwnershipInspection } from "../process/ProcessOwnershipObservation.js";
import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
import {
copyAndHash,
KUNA_INVENTORY_OUTPUT_BYTES,
kunaFileCleanupError,
runKunaCommand,
type KunaProcessLauncher,
} from "./KunaCommand.js";
import { kunaProjectDeadline } from "./KunaConfiguration.js";
import type { KunaInstallation } from "./KunaInstallation.js";
import { KUNA_DIAGNOSTIC_BYTES } from "./KunaLimits.js";
import {
parseKunaExportSummary,
parseKunaInventory,
scanKunaProjectSource,
type KunaExportSummary,
type KunaInventoryFunction,
type KunaProjectFunction,
} from "./KunaProjectFiles.js";
import { KUNA_PROVIDER_ID } from "./KunaRelease.js";
import {
RegularFileCleanupFailure,
regularFileCancellation,
} from "../filesystem/OwnedRegularFile.js";
/** Operation name every export and cache failure reports. */
export const KUNA_EXPORT_OPERATION = "kuna:decompile-project";
const INVENTORY_FILE = "functions.json";
/** Identity of one file an export consists of. */
export interface KunaExportFile {
readonly path: string;
readonly bytes: number;
readonly sha256: string;
}
/** The files of one export, by role. */
export interface KunaExportFiles {
readonly c: KunaExportFile;
readonly h: KunaExportFile;
readonly asm: KunaExportFile;
readonly readme: KunaExportFile;
readonly inventory: KunaExportFile;
}
/** One validated export: its files, Kuna's counts and the facts parsed from them. */
export interface KunaExport {
readonly files: KunaExportFiles;
readonly summary: KunaExportSummary;
/** Decompiled function blocks in `.c` order. */
readonly functions: readonly KunaProjectFunction[];
/** Every callable entry Kuna found, including imports it does not decompile. */
readonly inventory: readonly KunaInventoryFunction[];
}
/** The step an export is in; Kuna itself reports no progress until it finishes. */
export interface KunaExportStatus {
readonly phase: "inventory" | "decompiling" | "sealing";
/**
* Every callable entry the export's inventory found, once known. Listings
* use it while the export still decompiles, so no second inventory runs.
*/
readonly inventory: readonly KunaInventoryFunction[] | null;
}
/**
* How an export ended. `retained` means an owned Kuna process could not be
* proven stopped, so it may still write into the workspace, which therefore
* stays with the caller's cleanup owner.
*/
export type KunaExportOutcome =
| {
readonly kind: "done";
readonly result: Result<KunaExport, AnalysisError>;
readonly fileCleanupFailure?: RegularFileCleanupFailure;
}
| {
readonly kind: "retained";
readonly error: ProviderCleanupError;
readonly owner: ProviderProcessSupervisor | undefined;
};
/** One export of a target into a private workspace. */
export interface KunaExportRequest {
readonly installation: KunaInstallation;
/** Caller-selected target path; Kuna decompiles a verified copy of it. */
readonly targetPath: string;
readonly targetSha256: string;
/** Private directory for the copy, Kuna's temporary files and its output. */
readonly workspace: string;
/** Directory, inside the workspace, that receives the export's files. */
readonly output: string;
readonly signal: AbortSignal;
readonly retainFailedFileClose: (
owner: OwnedFileHandle,
resource: string,
) => void;
readonly onStatus: (status: KunaExportStatus) => void;
readonly launcher?: KunaProcessLauncher | undefined;
}
const message = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);
const isMissing = (cause: unknown): boolean =>
cause instanceof Error &&
"code" in cause &&
(cause.code === "ENOENT" || cause.code === "ENOTDIR");
/** Map an unexpected failure to a typed one, preferring cancellation. */
export const kunaExportFailure = (
cause: unknown,
signal: AbortSignal | undefined,
): AnalysisError => {
if (cause instanceof RegularFileCleanupFailure)
return kunaFileCleanupError(cause, KUNA_EXPORT_OPERATION);
const cancellation = regularFileCancellation(cause);
if (cancellation !== undefined)
return new AnalysisCancelledError(KUNA_EXPORT_OPERATION, {
cause: cancellation.reason,
});
if (cause instanceof AnalysisError) return cause;
if (!(cause instanceof StableFileHashFailure) && signal?.aborted === true)
return new AnalysisCancelledError(KUNA_EXPORT_OPERATION, { cause });
return new ProviderAdapterError(KUNA_PROVIDER_ID, KUNA_EXPORT_OPERATION, {
cause,
userMessage: `Kuna project cache failed: ${message(cause)}`,
});
};
const unusable = (reason: string): KunaExportOutcome => ({
kind: "done",
result: err(
new ProviderAdapterError(KUNA_PROVIDER_ID, KUNA_EXPORT_OPERATION, {
userMessage: `Kuna's project export is not usable: ${reason}`,
}),
),
});
/** Verify the selected installation at each execution and publication boundary. */
const verifyKunaExecutable = async (
installation: KunaInstallation,
signal: AbortSignal,
retainFailedFileClose: KunaExportRequest["retainFailedFileClose"],
): Promise<Result<null, ProviderAdapterError>> => {
const executable = await hashStableFile(installation.executable, {
signal,
retainFailedClose: (owner) =>
retainFailedFileClose(owner, installation.executable),
});
if (executable.sha256 === installation.sha256) return ok(null);
return err(
new ProviderAdapterError(KUNA_PROVIDER_ID, KUNA_EXPORT_OPERATION, {
userMessage: `${installation.executable} changed after readiness inspection; reopen the target so REA probes the current Kuna executable.`,
diagnostics: {
expected_sha256: installation.sha256,
observed_sha256: executable.sha256,
},
}),
);
};
/**
* Inventory and export one target with Kuna, then validate the result.
*
* Kuna runs on a copy hashed while copying, so the export always describes
* the digest the caller selected. Both Kuna runs share one deadline.
*/
export const exportKunaProject = async (
request: KunaExportRequest,
): Promise<KunaExportOutcome> => {
const { installation, signal } = request;
const { configuration } = installation;
const deadline = Date.now() + configuration.projectTimeoutMs;
const input = join(request.workspace, "input", basename(request.targetPath));
await mkdir(join(request.workspace, "input"), { mode: 0o700 });
await mkdir(request.output, { recursive: true, mode: 0o700 });
const copied = await copyAndHash(request.targetPath, input, signal);
if (copied !== request.targetSha256)
return {
kind: "done",
result: err(
new AnalysisArtifactChangedError(
KUNA_EXPORT_OPERATION,
request.targetPath,
"the target no longer matches the digest selected for analysis",
),
),
};
await prepareProcessOwnershipInspection(signal);
const run = async (arguments_: readonly string[], outputBytes: number) => {
const outcome = await runKunaCommand({
installation,
arguments: arguments_,
workspace: request.workspace,
operation: KUNA_EXPORT_OPERATION,
timeoutMs: deadline - Date.now(),
deadline: kunaProjectDeadline(configuration),
outputBytes,
signal,
launcher: request.launcher,
retainFailedFileClose: request.retainFailedFileClose,
});
if (outcome.kind === "ok")
return { ok: true as const, snapshot: outcome.snapshot };
return {
ok: false as const,
outcome:
outcome.kind === "retained"
? ({
kind: "retained",
error: outcome.error,
owner: outcome.owner,
} as const)
: ({
kind: "done",
result: err(outcome.error),
...(outcome.fileCleanupFailure === undefined
? {}
: { fileCleanupFailure: outcome.fileCleanupFailure }),
} as const),
};
};
request.onStatus({ phase: "inventory", inventory: null });
const listed = await run(
["functions", input, "--json"],
KUNA_INVENTORY_OUTPUT_BYTES,
);
if (!listed.ok) return listed.outcome;
const inventory = parseKunaInventory(listed.snapshot.stdout.text);
if (!inventory.ok) return unusable(inventory.error);
if (listed.snapshot.stdout.invalidUtf8 === true)
return unusable("the function inventory is not valid UTF-8");
await writeFile(
join(request.output, INVENTORY_FILE),
listed.snapshot.stdout.text,
{ mode: 0o600 },
);
request.onStatus({ phase: "decompiling", inventory: inventory.value });
const exported = await run(
[
"decompile-project",
input,
"-o",
request.output,
"--jobs",
String(configuration.jobs),
],
// Kuna prints one summary line per export on stdout.
KUNA_DIAGNOSTIC_BYTES,
);
if (!exported.ok) return exported.outcome;
request.onStatus({ phase: "sealing", inventory: inventory.value });
const summary = parseKunaExportSummary(exported.snapshot.stdout.text);
if (summary === undefined)
return unusable(
"Kuna did not print its `functions: N ok, M failed` summary.",
);
const stem = basename(request.targetPath);
const names = {
c: `${stem}.c`,
h: `${stem}.h`,
asm: `${stem}.asm`,
readme: "README.md",
inventory: INVENTORY_FILE,
} as const;
const files: Partial<Record<keyof KunaExportFiles, KunaExportFile>> = {};
for (const [role, name] of Object.entries(names) as [
keyof KunaExportFiles,
string,
][]) {
const path = join(request.output, name);
const status = await lstat(path).catch((cause: unknown) => {
if (isMissing(cause)) return undefined;
throw cause;
});
if (status === undefined || !status.isFile())
return unusable(`Kuna's export has no regular ${name}.`);
files[role] = {
path: name,
...(await hashStableFile(path, {
signal,
retainFailedClose: (owner) =>
request.retainFailedFileClose(owner, path),
})),
};
}
const complete = files as KunaExportFiles;
const functions = await readKunaExportSource(
request.output,
complete.c,
summary,
signal,
);
if (!functions.ok) return unusable(functions.error);
const identity = await verifyKunaExecutable(
installation,
signal,
request.retainFailedFileClose,
);
if (!identity.ok) return { kind: "done", result: identity };
// `functions.json` holds exactly the text this inventory was parsed from, so
// publishing reuses it rather than holding a second parsed copy.
return {
kind: "done",
result: ok({
files: complete,
summary,
functions: functions.value,
inventory: inventory.value,
}),
};
};
/** Split an export's `.c` into its function blocks without buffering the file. */
const readKunaExportSource = (
directory: string,
file: KunaExportFile,
summary: KunaExportSummary,
signal: AbortSignal | undefined,
): Promise<Result<readonly KunaProjectFunction[], string>> =>
scanKunaProjectSource(
createReadStream(
join(directory, file.path),
signal === undefined ? {} : { signal },
),
summary,
);
/** Parse a published export's function blocks and inventory from its own files. */
export const readKunaExport = async (
directory: string,
files: KunaExportFiles,
summary: KunaExportSummary,
signal?: AbortSignal,
): Promise<
Result<
{
readonly functions: readonly KunaProjectFunction[];
readonly inventory: readonly KunaInventoryFunction[];
},
string
>
> => {
const functions = await readKunaExportSource(
directory,
files.c,
summary,
signal,
);
if (!functions.ok) return functions;
const inventory = parseKunaInventory(
await readFile(join(directory, files.inventory.path), {
encoding: "utf8",
...(signal === undefined ? {} : { signal }),
}),
);
if (!inventory.ok) return inventory;
return ok({ functions: functions.value, inventory: inventory.value });
};