fix(providers): preserve launch selections and refresh obsolete Kuna exports

This commit is contained in:
morluto committed 2026-10-26 22:22:27 +08:00
1 parent eb06e263cb
commit 77468d5043
12 files changed
+411 -29

No files matched your search

+4
View File
@@ -125,6 +125,10 @@ REA writes Kuna's exports only inside `REA_KUNA_CACHE_DIR`:
- `trash/` holds retired builds and evicted exports awaiting removal.
- `home/` is Kuna's HOME.
The cache key includes the selected launcher and resolved executable paths.
Older exports without that identity are rebuilt automatically and retired through
normal cache cleanup; no manual migration is needed.
Builds publish by one rename, so a reader sees a complete export or none. If later staging retirement fails, the typed cleanup error retains the completed export's key, directory and manifest in `published_export`. The session adopts that verified project; later queries reuse its immutable files while `project_export.cleanup_incomplete` reports the retained owner's live state. `close_binary` retries only the remaining cleanup, and a successful retry resolves prior cleanup errors. A failed export followed by failed staging cleanup retains both failures; it does not invent a publication receipt. A session keeps the export it uses from eviction only within its own REA process. If another REA process sharing the cache evicts it, the session rebuilds it; meanwhile pseudocode falls back to single-function runs. A damaged or edited export is removed and rebuilt. REA retains the file state verified with each digest, checks it before warm-cache reuse, and checks open descriptors before and after reading text. Assembly indexes are checked against the same state; a changed file makes the whole report retry rather than mixing facets from different exports. Staging moves to `trash/` for removal only after REA proves its Kuna process stopped. Staging of a process it cannot prove stopped is never swept; REA keeps it owned, retries the stop on `close_binary`, and reports it until the stop is proven. Failed file removal also stays bound to its export and is retried by `close_binary`; closing does not report success while that cleanup remains. `rea doctor --provider kuna` reports the cache directory without creating it.
## Verify with a real Kuna
@@ -3,6 +3,8 @@ import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
const SELECTED_REGISTRATION_SETTINGS = [
"GHIDRA_INSTALL_DIR",
"GHIDRA_HEADLESS_MAXMEM",
"GHIDRA_MAXMEM",
"JAVA_HOME",
"HOPPER_LAUNCHER_PATH",
"REA_ILSPY_CMD_PATH",
+24 -14
View File
@@ -20,6 +20,7 @@ import {
runOwnedCommand,
} from "../process/OwnedCommand.js";
import { hashStableFile } from "../filesystem/StableFileHash.js";
import { assertArtifactCommandPath } from "../process/ArtifactCommandPath.js";
import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import {
RegularFileCleanupFailure,
@@ -166,9 +167,15 @@ const verifiedExecutables = new Map<
const verifyExecutable = async (
installation: KunaInstallation,
signal: AbortSignal,
operation: string,
retainFailedFileClose: KunaCommandRequest["retainFailedFileClose"],
): Promise<string | undefined> => {
const status = await stat(installation.executable, { bigint: true });
const invocationPath = installation.configuration.command;
await assertArtifactCommandPath(
{ invocationPath, resolvedPath: installation.executable },
operation,
);
const status = await stat(invocationPath, { bigint: true });
const identity = [
status.dev,
status.ino,
@@ -176,17 +183,17 @@ const verifyExecutable = async (
status.mtimeNs,
status.ctimeNs,
].join(":");
const known = verifiedExecutables.get(installation.executable);
const known = verifiedExecutables.get(invocationPath);
let sha256 = known?.identity === identity ? known.sha256 : undefined;
if (sha256 === undefined) {
sha256 = (
await hashStableFile(installation.executable, {
await hashStableFile(invocationPath, {
signal,
retainFailedClose: (owner) =>
retainFailedFileClose(owner, installation.executable),
retainFailedFileClose(owner, invocationPath),
})
).sha256;
verifiedExecutables.set(installation.executable, { identity, sha256 });
verifiedExecutables.set(invocationPath, { identity, sha256 });
}
return sha256 === installation.sha256 ? undefined : sha256;
};
@@ -200,6 +207,7 @@ export const runKunaCommand = async (
const changed = await verifyExecutable(
installation,
request.signal,
request.operation,
request.retainFailedFileClose,
);
if (changed !== undefined)
@@ -225,20 +233,22 @@ export const runKunaCommand = async (
return {
kind: "failed",
error:
cancellation !== undefined
? new AnalysisCancelledError(request.operation, {
cause: cancellation.reason,
})
: new ProviderAdapterError(KUNA_PROVIDER_ID, request.operation, {
cause,
userMessage: `Cannot verify ${installation.executable}: ${message(cause)}`,
}),
cause instanceof AnalysisError
? cause
: cancellation !== undefined
? new AnalysisCancelledError(request.operation, {
cause: cancellation.reason,
})
: new ProviderAdapterError(KUNA_PROVIDER_ID, request.operation, {
cause,
userMessage: `Cannot verify ${installation.executable}: ${message(cause)}`,
}),
};
}
try {
const snapshot = await runOwnedCommand(
{
command: installation.executable,
command: installation.configuration.command,
arguments: request.arguments,
runId: `rea-kuna-${randomUUID()}`,
// A script's process reports its interpreter, not the configured path.
+14 -5
View File
@@ -32,6 +32,7 @@ import {
runOwnedCommand,
} from "../process/OwnedCommand.js";
import { retainOwnedCommandCleanup } from "../providers/OwnedCommandCleanup.js";
import { assertArtifactCommandPath } from "../process/ArtifactCommandPath.js";
import type {
OwnedProviderProcessSpawnOptions,
ProviderProcessSnapshot,
@@ -426,11 +427,15 @@ export const inspectKunaInstallation = async (
phase: "fingerprint",
};
try {
await assertArtifactCommandPath(
{ invocationPath: command, resolvedPath: executable },
"kuna:installation",
);
beforeProbeFingerprint = await options.resourceScope.run(() =>
hashStableFile(executable, {
hashStableFile(command, {
...(options.signal === undefined ? {} : { signal: options.signal }),
retainFailedClose: (owner) =>
options.resourceScope.retain(owner, executable),
options.resourceScope.retain(owner, command),
}),
);
} catch (cause: unknown) {
@@ -456,7 +461,7 @@ export const inspectKunaInstallation = async (
snapshot = await options.resourceScope.run(() =>
runOwnedCommand(
{
command: executable,
command,
arguments: ["--version"],
runId: `rea-kuna-version-${randomUUID()}`,
// The configured command may be an interpreter-driven shim.
@@ -572,10 +577,14 @@ export const inspectKunaInstallation = async (
try {
({ fingerprint, script } = await options.resourceScope.run(async () => {
observed.phase = "identity";
const observation = await observeStableFile(executable, {
await assertArtifactCommandPath(
{ invocationPath: command, resolvedPath: executable },
"kuna:installation",
);
const observation = await observeStableFile(command, {
...(options.signal === undefined ? {} : { signal: options.signal }),
retainFailedClose: (owner) =>
options.resourceScope.retain(owner, executable),
options.resourceScope.retain(owner, command),
});
const fingerprint = {
sha256: observation.sha256,
+11 -1
View File
@@ -59,7 +59,7 @@ import type {
import { KUNA_PROVIDER_ID } from "./KunaRelease.js";
/** Bumped whenever the entry layout or manifest meaning changes. */
const CACHE_LAYOUT = 1;
const CACHE_LAYOUT = 2;
/** A manifest names five files and their digests; anything larger is not ours. */
const MANIFEST_MAX_BYTES = 64 * 1024;
@@ -72,6 +72,8 @@ const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
export interface KunaProjectKeyInputs {
readonly targetSha256: string;
readonly kunaSha256: string;
readonly kunaCommand: string;
readonly kunaExecutable: string;
readonly kunaVersion: string;
/**
* `--jobs 1` is Kuna's reference answer; more jobs decompile without
@@ -88,6 +90,8 @@ export const kunaProjectKey = (inputs: KunaProjectKeyInputs): string =>
layout: CACHE_LAYOUT,
target_sha256: inputs.targetSha256,
kuna_sha256: inputs.kunaSha256,
kuna_command: inputs.kunaCommand,
kuna_executable: inputs.kunaExecutable,
kuna_version: inputs.kunaVersion,
export: { command: "decompile-project", jobs: inputs.jobs },
}) ?? "",
@@ -106,6 +110,8 @@ const manifestSchema = z.strictObject({
inputs: z.strictObject({
target_sha256: z.string().regex(SHA256_PATTERN),
kuna_sha256: z.string().regex(SHA256_PATTERN),
kuna_command: z.string(),
kuna_executable: z.string(),
kuna_version: z.string().min(1),
jobs: z.int().positive(),
}),
@@ -475,6 +481,8 @@ export class KunaProjectCache {
const key = kunaProjectKey({
targetSha256: request.targetSha256,
kunaSha256: request.installation.sha256,
kunaCommand: request.installation.configuration.command,
kunaExecutable: request.installation.executable,
kunaVersion: request.installation.version,
jobs: request.installation.configuration.jobs,
});
@@ -945,6 +953,8 @@ export class KunaProjectCache {
inputs: {
target_sha256: request.targetSha256,
kuna_sha256: request.installation.sha256,
kuna_command: request.installation.configuration.command,
kuna_executable: request.installation.executable,
kuna_version: request.installation.version,
jobs: request.installation.configuration.jobs,
},
+8 -3
View File
@@ -23,6 +23,7 @@ import {
} from "../filesystem/StableFileHash.js";
import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { prepareProcessOwnershipInspection } from "../process/ProcessOwnershipObservation.js";
import { assertArtifactCommandPath } from "../process/ArtifactCommandPath.js";
import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
import {
copyAndHash,
@@ -170,10 +171,14 @@ const verifyKunaExecutable = async (
signal: AbortSignal,
retainFailedFileClose: KunaExportRequest["retainFailedFileClose"],
): Promise<Result<null, ProviderAdapterError>> => {
const executable = await hashStableFile(installation.executable, {
const invocationPath = installation.configuration.command;
await assertArtifactCommandPath(
{ invocationPath, resolvedPath: installation.executable },
KUNA_EXPORT_OPERATION,
);
const executable = await hashStableFile(invocationPath, {
signal,
retainFailedClose: (owner) =>
retainFailedFileClose(owner, installation.executable),
retainFailedClose: (owner) => retainFailedFileClose(owner, invocationPath),
});
if (executable.sha256 === installation.sha256) return ok(null);
return err(
+2
View File
@@ -268,6 +268,8 @@ export class KunaProvider implements AnalysisProviderCandidate {
{
kuna_version: value.version,
kuna_sha256: value.sha256,
kuna_command: value.configuration.command,
kuna_executable: value.executable,
verified_release: value.verified,
jobs: value.configuration.jobs,
export: "decompile-project",
+28 -4
View File
@@ -1,4 +1,7 @@
import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js";
import {
regularFileCancellation,
RegularFileCleanupFailure,
} from "../filesystem/OwnedRegularFile.js";
import {
AnalysisCleanupLease,
AnalysisCleanupLeaseGroup,
@@ -85,7 +88,6 @@ import {
type KunaProjectFileSpan,
} from "./KunaProjectReader.js";
import { kunaUnavailable } from "./KunaUnavailable.js";
import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js";
import {
ArtifactResourceRetryFailure,
ArtifactResourceScope,
@@ -1495,14 +1497,34 @@ export class KunaSessionClient implements AnalysisClient {
return err(error);
}
const committed = this.#profile?.parameters["kuna_sha256"];
if (committed !== undefined && committed !== inspected.value.sha256)
const committedCommand = this.#profile?.parameters["kuna_command"];
const committedExecutable = this.#profile?.parameters["kuna_executable"];
const identityChanged = [
[committed, inspected.value.sha256],
[committedCommand, inspected.value.configuration.command],
[committedExecutable, inspected.value.executable],
].some(
([expected, observed]) =>
expected !== undefined && expected !== observed,
);
if (identityChanged)
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,
...(committed === undefined
? {}
: { committed_sha256: committed }),
observed_sha256: inspected.value.sha256,
...(committedCommand === undefined
? {}
: { committed_command: committedCommand }),
observed_command: inspected.value.configuration.command,
...(committedExecutable === undefined
? {}
: { committed_executable: committedExecutable }),
observed_executable: inspected.value.executable,
},
}),
);
@@ -1540,6 +1562,8 @@ export class KunaSessionClient implements AnalysisClient {
const key = kunaProjectKey({
targetSha256: this.#target.sha256,
kunaSha256: installation.sha256,
kunaCommand: installation.configuration.command,
kunaExecutable: installation.executable,
kunaVersion: installation.version,
jobs: installation.configuration.jobs,
});
@@ -27,6 +27,8 @@ cliTest(
const selected = {
HOPPER_LAUNCHER_PATH: join(home, "missing-hopper"),
GHIDRA_INSTALL_DIR: join(home, "offline-ghidra"),
GHIDRA_HEADLESS_MAXMEM: "512M",
GHIDRA_MAXMEM: "3G",
JAVA_HOME: join(home, "offline-jdk"),
REA_IDA_MCP_CONFIG: join(home, "offline-ida.json"),
REA_JADX_MCP_JAR: join(home, "offline-jadx.jar"),
@@ -106,6 +108,11 @@ cliTest(
// A later setup must update a selection rather than retaining the first value.
selected.REA_OBJDUMP_COMMAND = join(home, "replacement-objdump");
environment.REA_OBJDUMP_COMMAND = selected.REA_OBJDUMP_COMMAND;
// Empty headless heap explicitly selects the lower-priority caller value.
selected.GHIDRA_HEADLESS_MAXMEM = "";
selected.GHIDRA_MAXMEM = "768M";
environment.GHIDRA_HEADLESS_MAXMEM = selected.GHIDRA_HEADLESS_MAXMEM;
environment.GHIDRA_MAXMEM = selected.GHIDRA_MAXMEM;
const updated = await run();
expect(updated.exitCode, updated.stdout).toBe(0);
const updatedConfiguration = await readFile(config, "utf8");
@@ -1,5 +1,6 @@
import { createHash } from "node:crypto";
import {
access,
appendFile,
chmod,
mkdir,
@@ -13,6 +14,7 @@ import {
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import canonicalize from "canonicalize";
import { expect, it as test, vi } from "vitest";
@@ -85,6 +87,8 @@ const keyOf = (installation: KunaInstallation, target: Target) =>
kunaProjectKey({
targetSha256: target.targetSha256,
kunaSha256: installation.sha256,
kunaCommand: installation.configuration.command,
kunaExecutable: installation.executable,
kunaVersion: installation.version,
jobs: installation.configuration.jobs,
});
@@ -114,6 +118,85 @@ const deferred = <T>() => {
return { promise, resolve, reject };
};
it("regenerates an obsolete export once and retires it without touching unrelated cache resources", async () => {
const { kuna, installation, cacheRoot, cache, target } = await fixture(
"rea-kuna-cache-format-transition-",
);
const input = await target("program");
const initial = await cache.build({ installation, ...input });
if (!initial.ok) throw initial.error;
expect((await cache.close()).ok).toBe(true);
// Seed the previously shipped manifest and content-key format using a real
// authored export; omitted launcher identity must never be invented or reused.
const legacyKey = createHash("sha256")
.update(
canonicalize({
layout: 1,
target_sha256: input.targetSha256,
kuna_sha256: installation.sha256,
kuna_version: installation.version,
export: {
command: "decompile-project",
jobs: installation.configuration.jobs,
},
}) ?? "",
)
.digest("hex");
const legacyEntry = join(cacheRoot, "projects", legacyKey);
await rename(join(cacheRoot, "projects", initial.value.key), legacyEntry);
await writeFile(
join(legacyEntry, "manifest.json"),
JSON.stringify({
...initial.value.manifest,
layout: 1,
key: legacyKey,
inputs: {
target_sha256: input.targetSha256,
kuna_sha256: installation.sha256,
kuna_version: installation.version,
jobs: installation.configuration.jobs,
},
}),
);
const unknown = join(cacheRoot, "projects", "unrelated-directory");
const unresolved = join(cacheRoot, "staging", "build-unresolved");
await mkdir(unknown);
await mkdir(unresolved);
await writeFile(join(unknown, "sentinel"), "preserve");
await writeFile(join(unresolved, "sentinel"), "preserve");
const migrated = newCache(installation);
try {
const built = await migrated.build({ installation, ...input });
if (!built.ok) throw built.error;
await expect(access(legacyEntry)).rejects.toMatchObject({ code: "ENOENT" });
expect(built.value.key).not.toBe(legacyKey);
expect(built.value.manifest).toMatchObject({
layout: 2,
inputs: {
kuna_command: installation.configuration.command,
kuna_executable: installation.executable,
},
});
expect(await callsOf(kuna, "decompile-project")).toHaveLength(2);
const fresh = newCache(installation);
try {
const reused = await fresh.build({ installation, ...input });
if (!reused.ok) throw reused.error;
expect(reused.value.manifest).toEqual(built.value.manifest);
expect(await callsOf(kuna, "decompile-project")).toHaveLength(2);
} finally {
expect((await fresh.close()).ok).toBe(true);
}
expect(await readFile(join(unknown, "sentinel"), "utf8")).toBe("preserve");
expect(await readFile(join(unresolved, "sentinel"), "utf8")).toBe(
"preserve",
);
expect(await entries(cacheRoot, "trash")).toEqual([]);
} finally {
expect((await migrated.close()).ok).toBe(true);
}
});
const causesOf = (cause: unknown): unknown[] => {
const pending = [cause];
const seen = new Set<unknown>();
@@ -152,7 +235,7 @@ it("publishes a verified export and serves later requests from the cache", async
value: {
key: keyOf(installation, input),
manifest: {
layout: 1,
layout: 2,
inputs: {
target_sha256: input.targetSha256,
kuna_sha256: installation.sha256,
@@ -181,13 +181,14 @@ it("waits for an aborted pre-session hash before retrying its late close owner",
});
it("commits the observed Kuna release and digest to the analysis profile", async () => {
const { profile } = await session("rea-kuna-profile-");
const { profile, kuna } = await session("rea-kuna-profile-");
expect(profile).toMatchObject({
provider: { id: "kuna", name: "Kuna", version: "1.833" },
parameters: {
kuna_version: "1.833",
kuna_sha256: expect.stringMatching(/^[0-9a-f]{64}$/u),
kuna_command: kuna,
verified_release: true,
jobs: 1,
cache_policy: "live",
@@ -0,0 +1,225 @@
import { createHash } from "node:crypto";
import {
access,
mkdir,
readFile,
symlink,
unlink,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { expect, it as test } from "vitest";
import type { RetainedResourceScope } from "../../../../src/application/AnalysisProvider.js";
import { runKunaCommand } from "../../../../src/kuna/KunaCommand.js";
import type { KunaInstallation } from "../../../../src/kuna/KunaInstallation.js";
import { KunaProjectCache } from "../../../../src/kuna/KunaProjectCache.js";
import { KunaProvider } from "../../../../src/kuna/KunaProvider.js";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
import { writeFakeKuna } from "./fakeKuna.js";
import {
inspectKunaInstallationOwned as inspectKunaInstallation,
withKunaResourceScope,
} from "./kunaResourceScope.js";
const it = test.skipIf(process.platform === "win32");
const shellArgument = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
const wrapperSource = `#!/bin/sh\nexec ${shellArgument(process.execPath)} "$(/usr/bin/dirname "$0")/producer.cjs" "$@"\n`;
const selectWrapper = async (
root: string,
name: string,
implementation: string,
) => {
const directory = join(root, name);
await mkdir(directory);
const command = join(directory, "kuna");
await symlink(implementation, command);
return { directory, command };
};
const runInstallation = (
installation: KunaInstallation,
workspace: string,
resources: RetainedResourceScope,
) =>
runKunaCommand({
installation,
arguments: ["authored-observation"],
workspace,
operation: "kuna:selection-fixture",
timeoutMs: 5_000,
deadline: { setting: "REA_KUNA_TIMEOUT_MS", ms: 5_000 },
outputBytes: 4096,
signal: new AbortController().signal,
retainFailedFileClose: (owner, path) => resources.retain(owner, path),
});
it("uses the selected Kuna launcher and its sibling producer for readiness and commands", async () => {
const root = await createTestTempDirectory("rea-kuna-command-selection-");
const implementation = join(root, "implementation");
await writeFile(implementation, wrapperSource, { mode: 0o755 });
const selected = await selectWrapper(root, "selected", implementation);
await writeFile(
join(selected.directory, "producer.cjs"),
`if (process.argv[2] === '--version') console.log('kuna 1.833');
else console.log(JSON.stringify({producer: __filename, arguments: process.argv.slice(2)}));\n`,
);
const installed = await inspectKunaInstallation({
REA_KUNA_COMMAND: selected.command,
REA_KUNA_CACHE_DIR: join(root, "cache"),
});
if (!installed.ok) throw new Error(installed.error.reason);
expect(installed.value).toMatchObject({
executable: implementation,
configuration: { command: selected.command },
sha256: createHash("sha256").update(wrapperSource).digest("hex"),
});
const outcome = await withKunaResourceScope((resources) =>
runInstallation(installed.value, root, resources),
);
if (outcome.kind !== "ok") throw outcome.error;
expect(JSON.parse(outcome.snapshot.stdout.text)).toEqual({
producer: join(selected.directory, "producer.cjs"),
arguments: ["authored-observation"],
});
});
it.each(["different bytes", "identical bytes"] as const)(
"refuses a selected launcher retargeted after discovery to %s before executing it",
async (mode) => {
const root = await createTestTempDirectory("rea-kuna-command-retarget-");
const implementation = join(root, "implementation");
await writeFile(implementation, wrapperSource, { mode: 0o755 });
const selected = await selectWrapper(root, "selected", implementation);
const marker = join(root, "ran-after-retarget");
await writeFile(
join(selected.directory, "producer.cjs"),
`if (process.argv[2] === '--version') console.log('kuna 1.833');
else require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'ran');\n`,
);
const installed = await inspectKunaInstallation({
REA_KUNA_COMMAND: selected.command,
REA_KUNA_CACHE_DIR: join(root, "cache"),
});
if (!installed.ok) throw new Error(installed.error.reason);
const replacement = join(root, "replacement");
await writeFile(
replacement,
mode === "identical bytes"
? wrapperSource
: `${wrapperSource}# replacement\n`,
{ mode: 0o755 },
);
await unlink(selected.command);
await symlink(replacement, selected.command);
const outcome = await withKunaResourceScope((resources) =>
runInstallation(installed.value, root, resources),
);
expect(outcome).toMatchObject({
kind: "failed",
error: { _tag: "AnalysisArtifactChangedError", path: selected.command },
});
await expect(access(marker)).rejects.toMatchObject({ code: "ENOENT" });
},
);
it("refuses an identical-byte launcher retargeted between profile creation and session acquisition", async () => {
const root = await createTestTempDirectory("rea-kuna-profile-retarget-");
const implementation = join(root, "implementation");
await writeFile(implementation, wrapperSource, { mode: 0o755 });
const selected = await selectWrapper(root, "selected", implementation);
await writeFile(
join(selected.directory, "producer.cjs"),
"console.log('kuna 1.833');\n",
);
const provider = new KunaProvider({
REA_KUNA_COMMAND: selected.command,
REA_KUNA_CACHE_DIR: join(root, "cache"),
});
const target = {
path: join(root, "target"),
sha256: createHash("sha256").update("inert input").digest("hex"),
kind: "executable" as const,
format: "elf" as const,
architecture: "x86_64" as const,
availableArchitectures: ["x86_64" as const],
};
await writeFile(target.path, "inert input");
const resolved = await withKunaResourceScope((resourceScope) =>
provider.resolveAnalysisProfile(target, { resourceScope }),
);
if (!resolved.ok || resolved.value.profile === null)
throw new Error("Expected an admitted Kuna profile");
const replacement = join(root, "replacement");
await writeFile(replacement, wrapperSource, { mode: 0o755 });
await unlink(selected.command);
await symlink(replacement, selected.command);
const client = provider.createClient(target, resolved.value.profile);
try {
expect(await client.execute("health", {})).toMatchObject({
ok: false,
error: {
_tag: "ProviderAdapterError",
diagnostics: {
committed_executable: implementation,
observed_executable: replacement,
},
},
});
} finally {
expect((await client.close()).ok).toBe(true);
}
});
it("keeps shared-cache exports separate for equal wrapper bytes selected beside different producers", async () => {
const root = await createTestTempDirectory("rea-kuna-command-cache-");
const implementation = join(root, "implementation");
await writeFile(implementation, wrapperSource, { mode: 0o755 });
const cacheRoot = join(root, "cache");
const targetPath = join(root, "target");
await writeFile(targetPath, "inert fixture input");
const targetSha256 = createHash("sha256")
.update(await readFile(targetPath))
.digest("hex");
const installations: KunaInstallation[] = [];
for (const name of ["alpha", "beta"]) {
const selected = await selectWrapper(root, name, implementation);
const producerRoot = join(root, `producer-${name}`);
await mkdir(producerRoot);
const producer = await writeFakeKuna(producerRoot, {
functions: [{ name, addr: "0x1000" }],
});
await symlink(producer, join(selected.directory, "producer.cjs"));
const installed = await inspectKunaInstallation({
REA_KUNA_COMMAND: selected.command,
REA_KUNA_CACHE_DIR: cacheRoot,
});
if (!installed.ok) throw new Error(installed.error.reason);
installations.push(installed.value);
}
expect(installations[0]?.sha256).toBe(installations[1]?.sha256);
const cache = new KunaProjectCache(cacheRoot, { maxBytes: 64 * 1024 ** 2 });
try {
const keys: string[] = [];
for (const [index, installation] of installations.entries()) {
const built = await cache.build({
installation,
targetPath,
targetSha256,
});
if (!built.ok) throw built.error;
keys.push(built.value.key);
expect(built.value.functions[0]?.name).toBe(
index === 0 ? "alpha" : "beta",
);
expect(built.value.manifest.inputs).toMatchObject({
kuna_command: installation.configuration.command,
kuna_executable: installation.executable,
});
}
expect(keys[0]).not.toBe(keys[1]);
} finally {
expect((await cache.close()).ok).toBe(true);
}
});