cultcache-ts: pin the default write path's sync and close order, and a directory at the lock path failing fast

The default StoreFileOps were never observed. One test now wraps fs/promises.open and each handle's
sync and close, and records the store's bytes at every call: the staged file is synced and closed
while the store still holds its old bytes, and on Unix the directory is synced and closed after the
store changed. A second test puts a directory at <store>.lock and requires the lock to fail within
3 s with the lock path named, under the default 30 s wait bound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-20 16:42:24 +02:00
commit 289d7f7d49

View file

@ -1,8 +1,10 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdirSync, readFileSync } from "node:fs";
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import fsp = require("node:fs/promises");
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { basename, join, resolve } from "node:path";
import test from "node:test";
import { promisify } from "node:util";
@ -84,6 +86,52 @@ test("a commit syncs the staged file before it replaces the store, and the direc
}
});
// The default write path, with nothing injected: every file handle it opens is observed through
// fs/promises.open (the module object, read at call time), recording what the handle did and what the
// store held at that moment. The replace is seen as the store's bytes changing, so the order holds on
// every platform without a seam in the replace itself.
test("the default write path syncs and closes the staged file before the store changes, and syncs the directory after (Unix)", async () => {
const directory = await scratch();
const realOpen = fsp.open;
try {
const store = join(directory, "store.cc");
await writeFile(store, "old");
const storeNow = () => readFileSync(store, "utf8");
const events: string[] = [];
(fsp as { open: typeof fsp.open }).open = (async (path: string, flags?: string) => {
const handle = await realOpen(path, flags as never);
const kind = basename(String(path)) === basename(directory) ? "dir" : String(path).includes(".tmp-") ? "staged" : "other";
const sync = handle.sync.bind(handle);
const close = handle.close.bind(handle);
handle.sync = () => {
events.push(`sync:${kind}:${storeNow()}`);
return sync();
};
handle.close = () => {
events.push(`close:${kind}:${storeNow()}`);
return close();
};
return handle;
}) as typeof fsp.open;
await commitStoreFile(store, new TextEncoder().encode("new"));
(fsp as { open: typeof fsp.open }).open = realOpen;
// The staged file is synced, then closed, while the store still holds its old bytes.
assert.deepEqual(events.filter((event) => event.includes(":staged:")), ["sync:staged:old", "close:staged:old"]);
if (process.platform === "win32") {
assert.deepEqual(events.filter((event) => event.includes(":dir:")), [], "Windows relies on the replace and opens no directory");
} else {
// The directory is synced and closed after the store changed.
assert.deepEqual(events.filter((event) => event.includes(":dir:")), ["sync:dir:new", "close:dir:new"]);
}
assert.equal(storeNow(), "new");
} finally {
(fsp as { open: typeof fsp.open }).open = realOpen;
await rm(directory, { recursive: true, force: true });
}
});
test("a commit that fails before the replace leaves the store byte for byte and no staged file behind", async () => {
const directory = await scratch();
try {
@ -383,6 +431,35 @@ test("the store lock gives up after its wait bound and names the lock path", asy
}
});
test("a directory at the lock path fails at once with the lock path named, not after the 30 s wait", async () => {
const directory = await scratch();
try {
const store = join(directory, "store.cc");
mkdirSync(`${store}.lock`);
const started = Date.now();
let ran = false;
// The default wait bound (30 s) is in force: only a failure that does not wait can beat the race.
const outcome = await Promise.race([
withStoreWriteLock(store, async () => {
ran = true;
}).then(
() => ({ error: undefined as Error | undefined }),
(error: Error) => ({ error }),
),
new Promise<"waiting">((resolveWait) => setTimeout(() => resolveWait("waiting"), 3000).unref()),
]);
assert.notEqual(outcome, "waiting", "the lock attempt was still waiting after 3 s");
const { error } = outcome as { error: Error | undefined };
assert.ok(error, "locking a directory must fail");
assert.ok(error.message.includes(`${store}.lock`), error.message);
assert.ok(!/timed out/.test(error.message), error.message);
assert.ok(Date.now() - started < 3000);
assert.equal(ran, false);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("the store lock is released when the operation throws", async () => {
const directory = await scratch();
try {