From 289d7f7d49feb29fa654a78fcf50e867d1df5731 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 20 Oct 2026 16:42:24 +0200 Subject: [PATCH] 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 .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 --- packages/cultcache-ts/test/store-file.test.ts | 79 ++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/packages/cultcache-ts/test/store-file.test.ts b/packages/cultcache-ts/test/store-file.test.ts index 249df44b..e98309f1 100644 --- a/packages/cultcache-ts/test/store-file.test.ts +++ b/packages/cultcache-ts/test/store-file.test.ts @@ -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 {