fix(kuna): bind cached export reads to verified file state

This commit is contained in:
morluto committed 2026-10-26 14:49:10 +08:00
1 parent b654ac8b13
commit bf876efbaa
12 files changed
+321 -67

No files matched your search

+1 -1
View File
@@ -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
+16 -1
View File
@@ -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() &&
+6 -1
View File
@@ -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) {
+44 -8
View File
@@ -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,
};
+30 -5
View File
@@ -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 });
};
+40 -2
View File
@@ -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)
+39
View File
@@ -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,
);
};
+16 -7
View File
@@ -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;