mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(kuna): bind cached export reads to verified file state
This commit is contained in:
1 parent
b654ac8b13
commit
bf876efbaa
12 files changed
+321
-67
No files matched your search
@@ -125,7 +125,7 @@ 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.
|
||||
|
||||
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. 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.
|
||||
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
|
||||
|
||||
|
||||
@@ -177,9 +177,24 @@ const isUnopenableFileTypeError = (cause: unknown): boolean =>
|
||||
typeof cause.errno === "number" &&
|
||||
UNOPENABLE_FILE_TYPE_ERRNOS.has(cause.errno);
|
||||
|
||||
/** Stable content identity; access times are deliberately excluded. */
|
||||
export type RegularFileState = Pick<
|
||||
Stats,
|
||||
"dev" | "ino" | "size" | "mode" | "mtimeMs" | "ctimeMs"
|
||||
>;
|
||||
|
||||
export const regularFileState = (stats: Stats): RegularFileState => ({
|
||||
dev: stats.dev,
|
||||
ino: stats.ino,
|
||||
size: stats.size,
|
||||
mode: stats.mode,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
ctimeMs: stats.ctimeMs,
|
||||
});
|
||||
|
||||
/** Compare one regular file's identity and mutable metadata across a read. */
|
||||
export const sameRegularFileState = (
|
||||
expected: Stats | BigIntStats,
|
||||
expected: RegularFileState | BigIntStats,
|
||||
observed: Stats | BigIntStats,
|
||||
): boolean =>
|
||||
observed.isFile() &&
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
RegularFileOperationCancelled,
|
||||
assertRegularFileActive,
|
||||
sameRegularFileState,
|
||||
regularFileState,
|
||||
type RegularFileState,
|
||||
} from "./RegularFile.js";
|
||||
import type { OwnedFileHandle } from "./OwnedFileHandle.js";
|
||||
|
||||
@@ -20,6 +22,7 @@ export interface StableFileHashOptions {
|
||||
}
|
||||
|
||||
export interface StableFileObservation {
|
||||
readonly state: RegularFileState;
|
||||
readonly sha256: string;
|
||||
readonly bytes: number;
|
||||
/** The first two bytes, read from the same descriptor and stream as the hash. */
|
||||
@@ -131,7 +134,9 @@ async function readStableFile(
|
||||
throw new RegularFileChangedError(path);
|
||||
assertRegularFileActive(options.signal);
|
||||
const fingerprint = { sha256: hash.digest("hex"), bytes: position };
|
||||
return includePrefix ? { ...fingerprint, prefix } : fingerprint;
|
||||
return includePrefix
|
||||
? { ...fingerprint, prefix, state: regularFileState(initial) }
|
||||
: fingerprint;
|
||||
},
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type { RegularFileState } from "../filesystem/RegularFile.js";
|
||||
import {
|
||||
assertKunaProjectState,
|
||||
type KunaProjectFileStates,
|
||||
} from "./KunaProjectState.js";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import {
|
||||
mkdir,
|
||||
@@ -26,7 +31,8 @@ import {
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
hashStableFile,
|
||||
observeStableFile,
|
||||
type StableFileObservation,
|
||||
stableFileHashCause,
|
||||
} from "../filesystem/StableFileHash.js";
|
||||
import {
|
||||
@@ -121,6 +127,7 @@ export type KunaProjectManifest = z.infer<typeof manifestSchema>;
|
||||
|
||||
/** One verified, published export. */
|
||||
export interface KunaProject {
|
||||
readonly fileStates: KunaProjectFileStates;
|
||||
readonly key: string;
|
||||
/** Directory holding the export's `.c`, `.h`, `.asm`, README and inventory. */
|
||||
readonly directory: string;
|
||||
@@ -362,7 +369,18 @@ export class KunaProjectCache {
|
||||
}
|
||||
const verified = this.#verified.get(key);
|
||||
if (verified !== undefined) {
|
||||
// Another process may have evicted it since this one verified it.
|
||||
try {
|
||||
await assertKunaProjectState(
|
||||
verified.directory,
|
||||
verified.manifest.files,
|
||||
verified.fileStates,
|
||||
signal,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (!isDamaged(cause)) throw cause;
|
||||
await this.#discard(key);
|
||||
return undefined;
|
||||
}
|
||||
if (await this.#touch(key)) return verified;
|
||||
this.#verified.delete(key);
|
||||
return undefined;
|
||||
@@ -383,11 +401,17 @@ export class KunaProjectCache {
|
||||
return undefined;
|
||||
}
|
||||
const directory = join(entry, "project");
|
||||
for (const file of Object.values(manifest.files)) {
|
||||
let observed: { readonly sha256: string; readonly bytes: number };
|
||||
const fileStates: Partial<
|
||||
Record<keyof KunaProjectFileStates, RegularFileState>
|
||||
> = {};
|
||||
for (const role of Object.keys(
|
||||
manifest.files,
|
||||
) as (keyof KunaProjectFileStates)[]) {
|
||||
const file = manifest.files[role];
|
||||
let observed: StableFileObservation;
|
||||
try {
|
||||
const path = join(directory, file.path);
|
||||
observed = await hashStableFile(path, {
|
||||
observed = await observeStableFile(path, {
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
retainFailedClose: (owner) =>
|
||||
this.#retainHashFile(owner, key, null, path),
|
||||
@@ -405,6 +429,7 @@ export class KunaProjectCache {
|
||||
await this.#discard(key);
|
||||
return undefined;
|
||||
}
|
||||
fileStates[role] = observed.state;
|
||||
if (observed.sha256 !== file.sha256 || observed.bytes !== file.bytes) {
|
||||
await this.#discard(key);
|
||||
return undefined;
|
||||
@@ -414,13 +439,23 @@ export class KunaProjectCache {
|
||||
directory,
|
||||
manifest.files,
|
||||
manifest.summary,
|
||||
fileStates as KunaProjectFileStates,
|
||||
signal,
|
||||
);
|
||||
if (!parsed.ok) {
|
||||
).catch((cause: unknown) => {
|
||||
if (!isDamaged(cause)) throw cause;
|
||||
return undefined;
|
||||
});
|
||||
if (parsed === undefined || !parsed.ok) {
|
||||
await this.#discard(key);
|
||||
return undefined;
|
||||
}
|
||||
const project = { key, directory, manifest, ...parsed.value };
|
||||
const project = {
|
||||
key,
|
||||
directory,
|
||||
manifest,
|
||||
fileStates: fileStates as KunaProjectFileStates,
|
||||
...parsed.value,
|
||||
};
|
||||
if (!(await this.#touch(key))) return undefined;
|
||||
this.#verified.set(key, project);
|
||||
return project;
|
||||
@@ -948,6 +983,7 @@ export class KunaProjectCache {
|
||||
key,
|
||||
directory: join(destination, "project"),
|
||||
manifest,
|
||||
fileStates: exported.fileStates,
|
||||
functions: exported.functions,
|
||||
inventory: exported.inventory,
|
||||
};
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import type { RegularFileState } from "../filesystem/RegularFile.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
assertKunaProjectState,
|
||||
type KunaProjectFileStates,
|
||||
} from "./KunaProjectState.js";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
@@ -12,6 +18,7 @@ import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
hashStableFile,
|
||||
observeStableFile,
|
||||
StableFileHashFailure,
|
||||
} from "../filesystem/StableFileHash.js";
|
||||
import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
@@ -64,6 +71,7 @@ export interface KunaExportFiles {
|
||||
|
||||
/** One validated export: its files, Kuna's counts and the facts parsed from them. */
|
||||
export interface KunaExport {
|
||||
readonly fileStates: KunaProjectFileStates;
|
||||
readonly files: KunaExportFiles;
|
||||
readonly summary: KunaExportSummary;
|
||||
/** Decompiled function blocks in `.c` order. */
|
||||
@@ -288,6 +296,9 @@ export const exportKunaProject = async (
|
||||
inventory: INVENTORY_FILE,
|
||||
} as const;
|
||||
const files: Partial<Record<keyof KunaExportFiles, KunaExportFile>> = {};
|
||||
const fileStates: Partial<
|
||||
Record<keyof KunaProjectFileStates, RegularFileState>
|
||||
> = {};
|
||||
for (const [role, name] of Object.entries(names) as [
|
||||
keyof KunaExportFiles,
|
||||
string,
|
||||
@@ -299,16 +310,25 @@ export const exportKunaProject = async (
|
||||
});
|
||||
if (status === undefined || !status.isFile())
|
||||
return unusable(`Kuna's export has no regular ${name}.`);
|
||||
const observed = await observeStableFile(path, {
|
||||
signal,
|
||||
retainFailedClose: (owner) => request.retainFailedFileClose(owner, path),
|
||||
});
|
||||
files[role] = {
|
||||
path: name,
|
||||
...(await hashStableFile(path, {
|
||||
signal,
|
||||
retainFailedClose: (owner) =>
|
||||
request.retainFailedFileClose(owner, path),
|
||||
})),
|
||||
sha256: observed.sha256,
|
||||
bytes: observed.bytes,
|
||||
};
|
||||
fileStates[role] = observed.state;
|
||||
}
|
||||
const complete = files as KunaExportFiles;
|
||||
const states = fileStates as KunaProjectFileStates;
|
||||
if (
|
||||
complete.inventory.sha256 !==
|
||||
createHash("sha256").update(listed.snapshot.stdout.text).digest("hex")
|
||||
)
|
||||
return unusable("the retained inventory changed before export sealing");
|
||||
await assertKunaProjectState(request.output, complete, states, signal);
|
||||
const functions = await readKunaExportSource(
|
||||
request.output,
|
||||
complete.c,
|
||||
@@ -316,6 +336,7 @@ export const exportKunaProject = async (
|
||||
signal,
|
||||
);
|
||||
if (!functions.ok) return unusable(functions.error);
|
||||
await assertKunaProjectState(request.output, complete, states, signal);
|
||||
const identity = await verifyKunaExecutable(
|
||||
installation,
|
||||
signal,
|
||||
@@ -328,6 +349,7 @@ export const exportKunaProject = async (
|
||||
kind: "done",
|
||||
result: ok({
|
||||
files: complete,
|
||||
fileStates: states,
|
||||
summary,
|
||||
functions: functions.value,
|
||||
inventory: inventory.value,
|
||||
@@ -355,6 +377,7 @@ export const readKunaExport = async (
|
||||
directory: string,
|
||||
files: KunaExportFiles,
|
||||
summary: KunaExportSummary,
|
||||
fileStates: KunaProjectFileStates,
|
||||
signal?: AbortSignal,
|
||||
): Promise<
|
||||
Result<
|
||||
@@ -365,6 +388,7 @@ export const readKunaExport = async (
|
||||
string
|
||||
>
|
||||
> => {
|
||||
await assertKunaProjectState(directory, files, fileStates, signal);
|
||||
const functions = await readKunaExportSource(
|
||||
directory,
|
||||
files.c,
|
||||
@@ -379,5 +403,6 @@ export const readKunaExport = async (
|
||||
}),
|
||||
);
|
||||
if (!inventory.ok) return inventory;
|
||||
await assertKunaProjectState(directory, files, fileStates, signal);
|
||||
return ok({ functions: functions.value, inventory: inventory.value });
|
||||
};
|
||||
@@ -1,3 +1,9 @@
|
||||
import type { RegularFileState } from "../filesystem/RegularFile.js";
|
||||
import {
|
||||
RegularFileChangedError,
|
||||
sameRegularFileState,
|
||||
} from "../filesystem/RegularFile.js";
|
||||
import { assertKunaFileState } from "./KunaProjectState.js";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { open } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
@@ -67,6 +73,7 @@ export interface KunaSpanOwnership {
|
||||
const readSpan = async (
|
||||
operation: string,
|
||||
path: string,
|
||||
expected: RegularFileState,
|
||||
offset: number,
|
||||
length: number,
|
||||
ownership: KunaSpanOwnership,
|
||||
@@ -101,10 +108,15 @@ const readSpan = async (
|
||||
const owner = new OwnedFileHandle(opened.value);
|
||||
let read: Result<string, unknown>;
|
||||
try {
|
||||
if (!sameRegularFileState(expected, await owner.handle.stat()))
|
||||
throw new RegularFileChangedError(path);
|
||||
const buffer = Buffer.alloc(length);
|
||||
const { bytesRead } = await owner.handle.read(buffer, 0, length, offset);
|
||||
if (bytesRead !== length)
|
||||
throw new Error(`${path} is shorter than its recorded span`);
|
||||
if (!sameRegularFileState(expected, await owner.handle.stat()))
|
||||
throw new RegularFileChangedError(path);
|
||||
await assertKunaFileState(path, expected);
|
||||
read = ok(buffer.toString("utf8"));
|
||||
} catch (cause: unknown) {
|
||||
read = err(cause);
|
||||
@@ -136,12 +148,15 @@ const readSpan = async (
|
||||
: await ownership.finishSpan(finish);
|
||||
return ok(value);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof OwnedRegularFileOperationFailure)
|
||||
if (cause instanceof OwnedRegularFileOperationFailure) {
|
||||
if (cause.operationCause instanceof RegularFileChangedError)
|
||||
throw cause.operationCause;
|
||||
return err(
|
||||
new ProviderAdapterError(KUNA_PROVIDER_ID, operation, {
|
||||
cause: cause.operationCause,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (cause instanceof RegularFileCleanupFailure)
|
||||
return err(
|
||||
spanCloseFailure(
|
||||
@@ -268,6 +283,11 @@ export class KunaProjectReader {
|
||||
operation: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<KunaProjectText, AnalysisError>> {
|
||||
await assertKunaFileState(
|
||||
join(this.project.directory, this.project.manifest.files.c.path),
|
||||
this.project.fileStates.c,
|
||||
signal ?? this.lifetime.signal,
|
||||
);
|
||||
this.#blocks ??= new Map(
|
||||
this.project.functions.map((block) => [block.address, block]),
|
||||
);
|
||||
@@ -296,6 +316,7 @@ export class KunaProjectReader {
|
||||
const text = await readSpan(
|
||||
operation,
|
||||
join(this.project.directory, this.project.manifest.files.c.path),
|
||||
this.project.fileStates.c,
|
||||
block.cOffset,
|
||||
block.cLength,
|
||||
this.lifetime,
|
||||
@@ -329,6 +350,7 @@ export class KunaProjectReader {
|
||||
const text = await readSpan(
|
||||
operation,
|
||||
join(this.project.directory, this.project.manifest.files.asm.path),
|
||||
this.project.fileStates.asm,
|
||||
span.offset,
|
||||
span.length,
|
||||
this.lifetime,
|
||||
@@ -362,13 +384,29 @@ export class KunaProjectReader {
|
||||
operation: string,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<Result<KunaAssemblyIndex, AnalysisError>> {
|
||||
const path = join(
|
||||
this.project.directory,
|
||||
this.project.manifest.files.asm.path,
|
||||
);
|
||||
await assertKunaFileState(
|
||||
path,
|
||||
this.project.fileStates.asm,
|
||||
signal ?? this.lifetime.signal,
|
||||
);
|
||||
this.#assembly ??= this.lifetime.own(
|
||||
scanKunaAssembly(
|
||||
createReadStream(
|
||||
join(this.project.directory, this.project.manifest.files.asm.path),
|
||||
{ signal: this.lifetime.signal },
|
||||
),
|
||||
),
|
||||
).then(async (index) => {
|
||||
await assertKunaFileState(
|
||||
path,
|
||||
this.project.fileStates.asm,
|
||||
this.lifetime.signal,
|
||||
);
|
||||
return index;
|
||||
}),
|
||||
);
|
||||
const index = await waitForAbortable(this.#assembly, signal);
|
||||
if (index === ABORTED)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
assertRegularFileActive,
|
||||
RegularFileChangedError,
|
||||
sameRegularFileState,
|
||||
type RegularFileState,
|
||||
} from "../filesystem/RegularFile.js";
|
||||
import type { KunaExportFiles } from "./KunaProjectExport.js";
|
||||
|
||||
/** Verified local states accompany digests in memory, never in the persisted manifest. */
|
||||
export type KunaProjectFileStates = Readonly<
|
||||
Record<keyof KunaExportFiles, RegularFileState>
|
||||
>;
|
||||
|
||||
export const assertKunaFileState = async (
|
||||
path: string,
|
||||
expected: RegularFileState,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> => {
|
||||
assertRegularFileActive(signal);
|
||||
if (!sameRegularFileState(expected, await stat(path)))
|
||||
throw new RegularFileChangedError(path);
|
||||
assertRegularFileActive(signal);
|
||||
};
|
||||
|
||||
export const assertKunaProjectState = async (
|
||||
directory: string,
|
||||
files: KunaExportFiles,
|
||||
states: KunaProjectFileStates,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> => {
|
||||
for (const role of Object.keys(files) as (keyof KunaExportFiles)[])
|
||||
await assertKunaFileState(
|
||||
join(directory, files[role].path),
|
||||
states[role],
|
||||
signal,
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js";
|
||||
import {
|
||||
AnalysisCleanupLease,
|
||||
AnalysisCleanupLeaseGroup,
|
||||
@@ -162,11 +163,13 @@ const waitFor = async <T>(
|
||||
: result;
|
||||
};
|
||||
|
||||
/** A file the adopted export named is gone, e.g. evicted by another REA process. */
|
||||
const isMissing = (cause: unknown): boolean =>
|
||||
/** An adopted export file disappeared or changed since its digest was verified. */
|
||||
const isUnavailableProject = (cause: unknown): boolean =>
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
(cause.code === "ENOENT" || cause.code === "ENOTDIR");
|
||||
(cause.code === "ENOENT" ||
|
||||
cause.code === "ENOTDIR" ||
|
||||
cause.code === "ESTALE");
|
||||
|
||||
/** Read through a call so a check before an await does not narrow later ones. */
|
||||
const cancelled = (signal: AbortSignal | undefined): boolean =>
|
||||
@@ -480,8 +483,14 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (cancelled(options.signal))
|
||||
return err(new AnalysisCancelledError(operation, { cause }));
|
||||
if (cancelled(options.signal)) {
|
||||
const cancellation = regularFileCancellation(cause);
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, {
|
||||
cause: cancellation === undefined ? cause : cancellation.reason,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return err(failure(operation, cause));
|
||||
}
|
||||
}
|
||||
@@ -1030,7 +1039,7 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
source: { kind: "project-export", project: reader.project },
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
if (!isMissing(cause) || recovered) throw cause;
|
||||
if (!isUnavailableProject(cause) || recovered) throw cause;
|
||||
recovered = true;
|
||||
this.#invalidateProject(reader);
|
||||
await this.#startProject(installation);
|
||||
@@ -1592,7 +1601,7 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
try {
|
||||
return await read(reader.value);
|
||||
} catch (cause: unknown) {
|
||||
if (!isMissing(cause) || attempt !== 0) throw cause;
|
||||
if (!isUnavailableProject(cause) || attempt !== 0) throw cause;
|
||||
this.#invalidateProject(reader.value);
|
||||
await this.#startProject(installation);
|
||||
}
|
||||
|
||||
@@ -357,9 +357,9 @@ it("resolves a hash failure delivered after its retained owner was released", as
|
||||
const warmCache = newCache(installation);
|
||||
const failureRetained = deferred<void>();
|
||||
const deliverFailure = deferred<void>();
|
||||
const hashFile = stableFileHash.hashStableFile;
|
||||
const hashFile = stableFileHash.observeStableFile;
|
||||
const hash = vi
|
||||
.spyOn(stableFileHash, "hashStableFile")
|
||||
.spyOn(stableFileHash, "observeStableFile")
|
||||
.mockImplementation(async (...args) => {
|
||||
try {
|
||||
return await hashFile(...args);
|
||||
@@ -1145,3 +1145,23 @@ it.each(["missing", "corrupt"])(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("invalidates a warm export when same-length content changes", async () => {
|
||||
const { cache, installation, target } = await fixture(
|
||||
"rea-kuna-warm-integrity-",
|
||||
);
|
||||
try {
|
||||
const built = await cache.build({
|
||||
installation,
|
||||
...(await target("program")),
|
||||
});
|
||||
if (!built.ok) throw built.error;
|
||||
const file = join(built.value.directory, built.value.manifest.files.c.path);
|
||||
const original = await readFile(file, "utf8");
|
||||
await writeFile(file, original.replace("return 0", "return 9"));
|
||||
expect(await cache.lookup(built.value.key)).toBeUndefined();
|
||||
await expect(readFile(file)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally {
|
||||
expect(await cache.close()).toMatchObject({ ok: true });
|
||||
}
|
||||
});
|
||||
@@ -8,11 +8,9 @@ import { run, session } from "./kunaSessionFixture.js";
|
||||
|
||||
const reading = vi.hoisted(() => ({
|
||||
asm: "",
|
||||
c: "",
|
||||
held: false,
|
||||
gate: undefined as Promise<void> | undefined,
|
||||
missing: 0,
|
||||
onMissing: undefined as (() => void) | undefined,
|
||||
onSpanClosed: undefined as (() => void) | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
@@ -20,44 +18,39 @@ vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
open: async (...args: Parameters<typeof actual.open>) => {
|
||||
try {
|
||||
const handle = await actual.open(...args);
|
||||
if (String(args[0]) !== reading.asm || reading.held) return handle;
|
||||
return new Proxy(handle, {
|
||||
get(target, key) {
|
||||
if (key === "read")
|
||||
return async (...readArgs: unknown[]) => {
|
||||
const result: unknown = await Reflect.apply(
|
||||
target.read,
|
||||
target,
|
||||
readArgs,
|
||||
);
|
||||
reading.held = true;
|
||||
await reading.gate;
|
||||
return result;
|
||||
};
|
||||
const value: unknown = Reflect.get(target, key, target);
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
if (String(args[0]) === reading.c) {
|
||||
reading.missing += 1;
|
||||
if (reading.missing === 2) reading.onMissing?.();
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
const handle = await actual.open(...args);
|
||||
if (String(args[0]) !== reading.asm || reading.held) return handle;
|
||||
return new Proxy(handle, {
|
||||
get(target, key) {
|
||||
if (key === "close")
|
||||
return async () => {
|
||||
await target.close();
|
||||
reading.onSpanClosed?.();
|
||||
};
|
||||
if (key === "read")
|
||||
return async (...readArgs: unknown[]) => {
|
||||
const result: unknown = await Reflect.apply(
|
||||
target.read,
|
||||
target,
|
||||
readArgs,
|
||||
);
|
||||
reading.held = true;
|
||||
await reading.gate;
|
||||
return result;
|
||||
};
|
||||
const value: unknown = Reflect.get(target, key, target);
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
reading.asm = "";
|
||||
reading.c = "";
|
||||
reading.held = false;
|
||||
reading.gate = undefined;
|
||||
reading.missing = 0;
|
||||
reading.onMissing = undefined;
|
||||
reading.onSpanClosed = undefined;
|
||||
});
|
||||
|
||||
const barrier = () => {
|
||||
@@ -73,7 +66,7 @@ test.skipIf(process.platform === "win32")(
|
||||
async () => {
|
||||
const assembly = barrier();
|
||||
const rebuild = barrier();
|
||||
const missing = barrier();
|
||||
const closed = barrier();
|
||||
let exports = 0;
|
||||
const { cacheRoot, open } = await session(
|
||||
"rea-kuna-reader-eviction-",
|
||||
@@ -92,9 +85,8 @@ test.skipIf(process.platform === "win32")(
|
||||
const [key] = await readdir(join(cacheRoot, "projects"));
|
||||
const project = join(cacheRoot, "projects", key ?? "");
|
||||
reading.asm = join(project, "project", "program.asm");
|
||||
reading.c = join(project, "project", "program.c");
|
||||
reading.gate = assembly.promise;
|
||||
reading.onMissing = missing.release;
|
||||
reading.onSpanClosed = closed.release;
|
||||
dossier = run(client, "analyze_function", { procedure: "main" });
|
||||
await expect.poll(() => reading.held).toBe(true);
|
||||
await rename(project, join(cacheRoot, "trash", "evicted-elsewhere"));
|
||||
@@ -107,9 +99,9 @@ test.skipIf(process.platform === "win32")(
|
||||
});
|
||||
await expect.poll(() => exports).toBe(2);
|
||||
assembly.release();
|
||||
// The fixed reader attempts the missing C before retrying the entire
|
||||
// report. The old reader instead completes with a single-function source.
|
||||
await Promise.race([dossier, missing.promise]);
|
||||
// Release the rebuild after the evicted span relinquishes its descriptor.
|
||||
// Detection may happen through descriptor state or the missing path.
|
||||
await Promise.race([dossier, closed.promise]);
|
||||
rebuild.release();
|
||||
const result = await dossier;
|
||||
expect(result.rawResult).toMatchObject({
|
||||
|
||||
@@ -977,3 +977,69 @@ it("leaves no private copy behind when workspace setup fails", async () => {
|
||||
else process.env.TMPDIR = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["c", "overwrite"],
|
||||
["c", "replace"],
|
||||
["asm", "overwrite"],
|
||||
["asm", "replace"],
|
||||
] as const)(
|
||||
"rebuilds a changed %s export (%s) before producing dossier Evidence",
|
||||
async (extension, mutation) => {
|
||||
const { open, provider, kuna } = await session(
|
||||
"rea-kuna-export-integrity-",
|
||||
);
|
||||
const client = open();
|
||||
try {
|
||||
const first = await run(client, "analyze_function", {
|
||||
procedure: "main",
|
||||
});
|
||||
const source = (
|
||||
first.rawResult as {
|
||||
source: {
|
||||
directory: string;
|
||||
files: { file: string; file_sha256: string }[];
|
||||
};
|
||||
}
|
||||
).source;
|
||||
const selected = source.files.find(({ file }) =>
|
||||
file.endsWith(`.${extension}`),
|
||||
);
|
||||
if (selected === undefined) throw new Error("Missing exported facet");
|
||||
const path = join(source.directory, selected.file);
|
||||
const original = await readFile(path, "utf8");
|
||||
const edited =
|
||||
extension === "c"
|
||||
? original.replace("return 0", "return 9")
|
||||
: original.replace("RET", "NOP");
|
||||
expect(edited).not.toBe(original);
|
||||
expect(Buffer.byteLength(edited)).toBe(Buffer.byteLength(original));
|
||||
if (mutation === "replace") {
|
||||
await writeFile(`${path}.replacement`, edited);
|
||||
await rename(`${path}.replacement`, path);
|
||||
} else await writeFile(path, edited);
|
||||
const recovered = await run(client, "analyze_function", {
|
||||
procedure: "main",
|
||||
});
|
||||
const recoveredSource = (recovered.rawResult as { source: typeof source })
|
||||
.source;
|
||||
for (const file of recoveredSource.files) {
|
||||
const bytes = await readFile(
|
||||
join(recoveredSource.directory, file.file),
|
||||
);
|
||||
expect(createHash("sha256").update(bytes).digest("hex")).toBe(
|
||||
file.file_sha256,
|
||||
);
|
||||
}
|
||||
expect(recovered.result).toEqual(first.result);
|
||||
expect(
|
||||
(await fakeKunaCalls(kuna)).filter(
|
||||
({ argv }) => argv[0] === "decompile-project",
|
||||
),
|
||||
).toHaveLength(2);
|
||||
} finally {
|
||||
expect(await client.close()).toMatchObject({ ok: true });
|
||||
expect(await provider.closeOwnedResources()).toMatchObject({ ok: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -26,6 +26,7 @@ const fault = vi.hoisted(() => ({
|
||||
hold: undefined as Promise<void> | undefined,
|
||||
releaseHold: (): void => undefined,
|
||||
afterRead: (): void => undefined,
|
||||
afterStat: (): void => undefined,
|
||||
readRejection: undefined as { readonly cause: unknown } | undefined,
|
||||
handles: [] as FileHandle[],
|
||||
cause: Object.assign(new Error("controlled close refusal"), { code: "EIO" }),
|
||||
@@ -35,6 +36,11 @@ vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
||||
return {
|
||||
...actual,
|
||||
stat: async (...args: Parameters<typeof actual.stat>) => {
|
||||
const state = await actual.stat(...args);
|
||||
if (String(args[0]) === fault.span && fault.reads > 0) fault.afterStat();
|
||||
return state;
|
||||
},
|
||||
open: async (...args: Parameters<typeof actual.open>) => {
|
||||
const handle = await actual.open(...args);
|
||||
if (String(args[0]) !== fault.span) return handle;
|
||||
@@ -81,6 +87,7 @@ afterEach(() => {
|
||||
fault.hold = undefined;
|
||||
fault.releaseHold = () => undefined;
|
||||
fault.afterRead = () => undefined;
|
||||
fault.afterStat = () => undefined;
|
||||
fault.readRejection = undefined;
|
||||
fault.handles = [];
|
||||
});
|
||||
@@ -397,7 +404,7 @@ it("does not open a queued span after its caller aborts", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["before close", "during close"] as const)(
|
||||
it.each(["before close", "during verification", "during close"] as const)(
|
||||
"keeps the caller abort reason when span close succeeds (%s)",
|
||||
async (when) => {
|
||||
const reason = { requested: "span-close" };
|
||||
@@ -417,6 +424,8 @@ it.each(["before close", "during close"] as const)(
|
||||
);
|
||||
const abort = new AbortController();
|
||||
if (when === "before close") fault.afterRead = () => abort.abort(reason);
|
||||
else if (when === "during verification")
|
||||
fault.afterStat = () => abort.abort(reason);
|
||||
else {
|
||||
fault.hold = new Promise<void>((resolve) => {
|
||||
fault.releaseHold = resolve;
|
||||
|
||||
Reference in new issue
Block a user