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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`EPERM`/`EBUSY`/`EACCES` rename errors. Concurrent `askr skills install`
runs without `--force` now admit exactly one install into an empty target
([#153](https://github.com/askrjs/askr-cli/issues/153)).
- `askr ssg --incremental` copies the live output folder into its staging
folder under the directory lock, so concurrent incremental builds into one
folder can no longer stage from a half-swapped tree or race the final swap.
The lock is held only for the copy, not for site generation
([#155](https://github.com/askrjs/askr-cli/issues/155)).

## [0.4.0] - 2026-09-28

Expand Down
20 changes: 4 additions & 16 deletions src/bin/ssg.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
#!/usr/bin/env node

import { constants, existsSync } from "node:fs";
import { existsSync } from "node:fs";
import * as fs from "node:fs/promises";
import { dirname, resolve } from "node:path";
import * as path from "node:path";
import { pathToFileURL } from "node:url";
import { register } from "tsx/esm/api";
import { isDirectExecution } from "./is-direct-execution";
import { generateSitemap, removeGeneratedSitemap, type SitemapConfig } from "../ssg/sitemap";
import { createSiblingStage, publishStagedDirectory } from "../directory-swap";
import { copyTargetIntoStage, createSiblingStage, publishStagedDirectory } from "../directory-swap";
import { inspectSsgDocuments } from "../ssg/documents";
import {
removeSsgOutputReport,
Expand Down Expand Up @@ -109,15 +109,6 @@ const defaultDeps: Required<Pick<SsgDeps, "cwd" | "existsSync" | "now">> = {
existsSync,
};

async function pathExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}

async function importProjectConfig(filePath: string): Promise<{
module: unknown;
unregister: () => Promise<void>;
Expand Down Expand Up @@ -352,11 +343,8 @@ export async function runSsgCli(
const routeSource = { registry: config.registry };

cliStagingDir = await createSiblingStage(resolvedOutputDir, "askr-ssg");
if (parsed.incremental && !parsed.forceFull && (await pathExists(resolvedOutputDir))) {
await fs.cp(resolvedOutputDir, cliStagingDir, {
recursive: true,
mode: constants.COPYFILE_FICLONE,
});
if (parsed.incremental && !parsed.forceFull) {
await copyTargetIntoStage(resolvedOutputDir, cliStagingDir);
}
const generationOutputDir = cliStagingDir;

Expand Down
19 changes: 19 additions & 0 deletions src/directory-swap.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import { constants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";

Expand Down Expand Up @@ -79,6 +80,24 @@ export async function createSiblingStage(target: string, label: string): Promise
return fs.mkdtemp(path.join(parent, `.${path.basename(resolved)}.${label}-`));
}

/**
* Copies the live `target` into `stage` while holding the target lock, so the
* copy can never observe a folder another build is half-way through swapping.
* The lock covers only the copy, not site generation. Resolves to `false` when
* the target does not exist and nothing was copied.
*/
export async function copyTargetIntoStage(target: string, stage: string): Promise<boolean> {
return withDirectoryTargetLock(target, async () => {
const resolvedTarget = path.resolve(target);
if (!(await exists(resolvedTarget))) return false;
await fs.cp(resolvedTarget, stage, {
recursive: true,
mode: constants.COPYFILE_FICLONE,
});
return true;
});
}

const TRANSIENT_RENAME_CODES = new Set(["EPERM", "EBUSY", "EACCES"]);
const RENAME_RETRY_DELAYS_MS = [10, 20, 40, 80, 160, 320, 640];

Expand Down
53 changes: 52 additions & 1 deletion tests/directory-swap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createSiblingStage, publishStagedDirectory } from "../src/directory-swap";
import {
copyTargetIntoStage,
createSiblingStage,
publishStagedDirectory,
withDirectoryTargetLock,
} from "../src/directory-swap";

const roots: string[] = [];

Expand Down Expand Up @@ -85,4 +90,50 @@ describe("directory publication", () => {
await expect(fs.readFile(path.join(target, "complete.txt"), "utf8")).resolves.toBe("new");
await expect(fs.access(lock)).rejects.toMatchObject({ code: "ENOENT" });
});

it("should not stage a copy while another build is swapping the target", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "askr-directory-copy-race-"));
roots.push(root);
const target = path.join(root, "target");
await fs.mkdir(target);
await fs.writeFile(path.join(target, "old.txt"), "old");
const stage = await createSiblingStage(target, "copy");

let release!: () => void;
const swapping = new Promise<void>((resolve) => (release = resolve));
let swapStarted!: () => void;
const started = new Promise<void>((resolve) => (swapStarted = resolve));
// A concurrent build holds the lock and has the target half-swapped.
const swap = withDirectoryTargetLock(target, async () => {
await fs.rm(target, { recursive: true, force: true });
swapStarted();
await swapping;
await fs.mkdir(target);
await fs.writeFile(path.join(target, "new.txt"), "new");
});
await started;

let copied = false;
const copy = copyTargetIntoStage(target, stage).then((result) => {
copied = true;
return result;
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect(copied).toBe(false);

release();
await swap;
expect(await copy).toBe(true);
expect(await fs.readdir(stage)).toEqual(["new.txt"]);
});

it("should report that nothing was staged when the target does not exist", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "askr-directory-copy-missing-"));
roots.push(root);
const target = path.join(root, "target");
const stage = await createSiblingStage(target, "copy");

expect(await copyTargetIntoStage(target, stage)).toBe(false);
expect(await fs.readdir(stage)).toEqual([]);
});
});
Loading