Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/0.5.0-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
11 changes: 10 additions & 1 deletion src/filesystem-lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
Expand Down
77 changes: 77 additions & 0 deletions tests/filesystem-locks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof fs.readdir>
) => {
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<typeof fs.readdir>
) => {
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) => {
Expand Down
33 changes: 31 additions & 2 deletions tests/generate.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 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);
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 ✓");
Expand Down
Loading