From d713dfb73e26888a462a265e9140600aa534803b Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Fri, 9 Oct 2026 23:57:16 -0400 Subject: [PATCH 1/2] test: expose contended publication rejection reasons --- tests/generate.test.ts | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/tests/generate.test.ts b/tests/generate.test.ts index 6a0c4cf..ef248f7 100644 --- a/tests/generate.test.ts +++ b/tests/generate.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, readdir, symlink, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { pathToFileURL } from "node:url"; @@ -218,11 +218,40 @@ describe("askr generate", () => { writeGenerated(output, first, false), writeGenerated(output, second, false), ]); - expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(2); + expect(results).toEqual([ + { status: "fulfilled", value: undefined }, + { status: "fulfilled", value: undefined }, + ]); const schema = await readFile(join(output, "schemas.ts"), "utf8"); expect([first["schemas.ts"], second["schemas.ts"]]).toContain(schema); expect((await readdir(output)).sort()).toEqual(Object.keys(first).sort()); }); + it("should serialize repeated contended publication without rejecting a completed writer", async () => { + const root = await mkdtemp(join(tmpdir(), "askr-repeated-concurrent-generate-")); + const output = join(root, "generated"); + const files = generateFiles(document); + try { + for (let batch = 0; batch < 50; batch += 1) { + const schemas = Array.from( + { length: 4 }, + (_, index) => `${files["schemas.ts"]}// batch ${batch}, writer ${index}\n`, + ); + const results = await Promise.allSettled( + schemas.map((schema) => + writeGenerated(output, { ...files, "schemas.ts": schema }, false), + ), + ); + // Keep rejection reasons visible in hosted output instead of reporting + // only the number of completed writers. + expect(results).toEqual(schemas.map(() => ({ status: "fulfilled", value: undefined }))); + expect(schemas).toContain(await readFile(join(output, "schemas.ts"), "utf8")); + expect((await readdir(output)).sort()).toEqual(Object.keys(files).sort()); + expect(await readdir(root)).toEqual(["generated"]); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }, 60_000); it("should generate safely in a path containing spaces and Unicode", async () => { const root = await mkdtemp(join(tmpdir(), "askr hostile 路径 ")); const output = join(root, "generated client ✓"); From ca9265a6330291d67bbc63d66b1938fc0070ea5c Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Sat, 10 Oct 2026 00:05:39 -0400 Subject: [PATCH 2/2] fix: retry Windows lock scans during owner handoff --- CHANGELOG.md | 4 ++ docs/0.5.0-api.md | 2 + src/filesystem-lock.ts | 11 ++++- tests/filesystem-locks.test.ts | 77 ++++++++++++++++++++++++++++++++++ tests/generate.test.ts | 2 +- 5 files changed, 94 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 654c10f..3c25fbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Retry a denied lock-directory scan during Windows publication handoff within + the existing wait bound. Retain the native error when a denial persists and + preserve the owner. Avoid rejecting contenders during normal lock handoff. + - Prepare file and directory locks with unique owner records before publishing them. Recover only the observed dead owner so delayed stale-lock recovery cannot delete a successor's lock and allow overlapping updates. Preserve diff --git a/docs/0.5.0-api.md b/docs/0.5.0-api.md index 3ff9f8c..de48e01 100644 --- a/docs/0.5.0-api.md +++ b/docs/0.5.0-api.md @@ -54,6 +54,8 @@ File transactions and directory publication now share one lock protocol: prepare The executed matrix covers two simultaneous stale-owner observations for file and directory operations, exact stale-write rejection, unrelated lock contents, junctions, fresh malformed-record timeout, aged legacy recovery, partial owner writes, failed preparation cleanup and an exclusive preparation collision. It also verifies successor ownership during release, denied process probes and filesystem errors during acquisition. The original six minimal cases failed before the fix. Separate child-process cases terminate after preparing an owner but before publishing the lock: data is unchanged, no incomplete live lock is exposed, retry succeeds and the abandoned private preparation remains available for manual cleanup. Existing file/directory termination and recovery cases also run with the shared protocol. These results qualify the tested process checkpoints and cooperating current CLI commands, not power-loss durability or concurrent use of older lock protocols. +On Windows, scanning a lock directory while its previous owner removes it can return `EPERM`. The acquisition loop retries that specific scan failure within its existing ten-second bound and retains the native cause if it persists; other filesystem errors still propagate. A repeated four-writer generator probe reproduced the native failure before the fix. Deterministic file/directory cases verify transient recovery, permanent-denial timeout, exact bytes and owner preservation. + ## Release boundaries The normal packed consumer checks both retained declaration names, nine rejected old imports and private paths under strict TypeScript 6 and 7, then runs the installed CLI help/version commands. No production dependency, package version or peer range changes in this cut. Full candidate graph, generated website API and maintainer review still gate release. CLI hardening issue #159 stays open until its remaining qualification is complete. diff --git a/src/filesystem-lock.ts b/src/filesystem-lock.ts index 5b1f730..7e6530c 100644 --- a/src/filesystem-lock.ts +++ b/src/filesystem-lock.ts @@ -128,7 +128,16 @@ export async function acquireFilesystemLock( const code = error instanceof Error && "code" in error ? String(error.code) : ""; if (!RENAME_CONTENTION_CODES.has(code)) throw error; lastError = error; - if (await removeOrphanedLock(lock)) continue; + try { + if (await removeOrphanedLock(lock)) continue; + } catch (inspectionError) { + // Windows can reject a scan while another owner removes this lock. + // Retry within the same deadline; a permanent denial retains its cause. + if (!isNodeError(inspectionError, "EPERM") || inspectionError.syscall !== "scandir") { + throw inspectionError; + } + lastError = inspectionError; + } await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS)); } } diff --git a/tests/filesystem-locks.test.ts b/tests/filesystem-locks.test.ts index 3d7e6e7..8be491f 100644 --- a/tests/filesystem-locks.test.ts +++ b/tests/filesystem-locks.test.ts @@ -45,6 +45,83 @@ afterEach(async () => { }); describe("filesystem lock ownership", () => { + it.each(["directory", "file"])( + "retries a transient Windows-style scandir denial while the %s lock is handed off", + async (kind) => { + const { root, target, lock } = await fixture(kind); + await fs.mkdir(lock); + await fs.writeFile(path.join(lock, "owner.json"), '{"pid":2147483647}'); + const readdir = fs.readdir.bind(fs); + const denied = Object.assign(new Error("injected pending-deletion directory scan"), { + code: "EPERM", + syscall: "scandir", + path: lock, + }); + let failed = false; + vi.spyOn(fs, "readdir").mockImplementation((async ( + ...args: Parameters + ) => { + if (String(args[0]) === lock && !failed) { + failed = true; + throw denied; + } + return readdir(...args); + }) as typeof fs.readdir); + let entered = 0; + await expect( + operate(kind, target, async () => { + entered += 1; + }), + ).resolves.toBeUndefined(); + expect(failed).toBe(true); + expect(entered).toBe(1); + if (kind === "file") expect(await fs.readFile(target, "utf8")).toBe("new"); + expect(await fs.readdir(root)).toEqual(kind === "directory" ? [] : ["manifest.json"]); + }, + ); + + it.each(["directory", "file"])( + "bounds a permanent %s lock scan denial and retains the native cause without deleting its owner", + async (kind) => { + const { root, target, lock } = await fixture(kind); + await fs.mkdir(lock); + const owner = path.join(lock, "owner.json"); + await fs.writeFile(owner, '{"pid":2147483647}'); + const denied = Object.assign(new Error("injected permanent directory scan denial"), { + code: "EPERM", + syscall: "scandir", + path: lock, + }); + let now = Date.now(); + vi.spyOn(Date, "now").mockImplementation(() => now); + const readdir = fs.readdir.bind(fs); + vi.spyOn(fs, "readdir").mockImplementation((async ( + ...args: Parameters + ) => { + if (String(args[0]) === lock) { + now += 10_001; + throw denied; + } + return readdir(...args); + }) as typeof fs.readdir); + await expect( + operate(kind, target, async () => { + throw new Error("must not enter"); + }), + ).rejects.toMatchObject({ + message: expect.stringContaining("Timed out waiting"), + cause: denied, + }); + expect(await fs.readFile(owner, "utf8")).toBe('{"pid":2147483647}'); + if (kind === "file") expect(await fs.readFile(target, "utf8")).toBe("old"); + expect((await fs.readdir(root)).sort()).toEqual( + kind === "directory" + ? [path.basename(lock)] + : [path.basename(lock), "manifest.json"].sort(), + ); + }, + ); + it.each(["directory", "file"])( "does not remove a successor's %s owner during release", async (kind) => { diff --git a/tests/generate.test.ts b/tests/generate.test.ts index ef248f7..4fec2ec 100644 --- a/tests/generate.test.ts +++ b/tests/generate.test.ts @@ -226,7 +226,7 @@ describe("askr generate", () => { expect([first["schemas.ts"], second["schemas.ts"]]).toContain(schema); expect((await readdir(output)).sort()).toEqual(Object.keys(first).sort()); }); - it("should serialize repeated contended publication without rejecting a completed writer", async () => { + it("should serialize repeated publication without rejecting contenders during handoff", async () => { const root = await mkdtemp(join(tmpdir(), "askr-repeated-concurrent-generate-")); const output = join(root, "generated"); const files = generateFiles(document);