fix: make version bump recovery atomic

This commit is contained in:
Peter Li 2026-07-16 18:17:35 +08:00
parent 7a3267ab44
commit f08c4fc881
2 changed files with 688 additions and 31 deletions

View file

@ -1,7 +1,17 @@
import { randomUUID } from "node:crypto";
import { realpathSync } from "node:fs";
import { open, readFile, rename, rm } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import {
mkdir,
open,
readFile,
readdir,
rename,
rm,
rmdir,
stat,
} from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";
import { performance } from "node:perf_hooks";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath } from "node:url";
@ -11,6 +21,8 @@ const modulePath = fileURLToPath(import.meta.url);
const lockName = ".aera-version-bump.lock";
const lockTimeoutMs = 30_000;
const lockRetryMs = 10;
const malformedLockStaleMs = 1_000;
const retryLockCode = "AERA_LOCK_RETRY";
function throwValidationErrors(errors, context = "") {
if (errors.length) {
@ -87,34 +99,257 @@ async function stageFile(path, contents, id) {
},
};
} catch (error) {
await temporaryFile?.close().catch(() => {});
if (ownsTemporaryPath) await rm(temporaryPath, { force: true }).catch(() => {});
const cleanupFailures = [];
try {
await temporaryFile?.close();
} catch (cleanupFailure) {
cleanupFailures.push({ action: "close", error: cleanupFailure });
}
if (ownsTemporaryPath) {
try {
await rm(temporaryPath, { force: true });
ownsTemporaryPath = false;
} catch (cleanupFailure) {
cleanupFailures.push({ action: "remove", error: cleanupFailure });
}
}
if (cleanupFailures.length) {
throw stageCleanupError(
error,
temporaryPath,
cleanupFailures,
ownsTemporaryPath,
);
}
throw error;
}
}
async function inspectLockOwner(ownerPath) {
try {
const [contents, metadata] = await Promise.all([
readFile(ownerPath, "utf8"),
stat(ownerPath),
]);
let owner;
try {
owner = JSON.parse(contents);
} catch {
owner = null;
}
const validOwner =
owner &&
typeof owner.token === "string" &&
owner.token.length > 0 &&
Number.isSafeInteger(owner.pid) &&
owner.pid > 0 &&
typeof owner.createdAt === "number";
return {
createdAt: validOwner ? owner.createdAt : null,
identity: validOwner
? `token:${owner.token}`
: `invalid:${metadata.dev}:${metadata.ino}:${metadata.mtimeMs}:${contents}`,
mtimeMs: metadata.mtimeMs,
pid: validOwner ? owner.pid : null,
token: validOwner ? owner.token : null,
};
} catch (error) {
if (error?.code === "ENOENT") return null;
throw error;
}
}
async function inspectLock(lockPath) {
try {
const [entries, metadata] = await Promise.all([
readdir(lockPath, { withFileTypes: true }),
stat(lockPath),
]);
if (entries.length === 0) {
return {
mtimeMs: metadata.mtimeMs,
owner: null,
ownerPath: null,
removable: true,
};
}
if (
entries.length === 1 &&
entries[0].isFile() &&
entries[0].name.endsWith(".json")
) {
const ownerPath = join(lockPath, entries[0].name);
const owner = await inspectLockOwner(ownerPath);
return {
mtimeMs: owner?.mtimeMs ?? metadata.mtimeMs,
owner,
ownerPath,
removable: true,
};
}
return {
mtimeMs: metadata.mtimeMs,
owner: null,
ownerPath: null,
removable: false,
};
} catch (error) {
if (error?.code === "ENOENT") return null;
throw error;
}
}
function processIsRunning(pid) {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error?.code !== "ESRCH";
}
}
function lockIsStale(lock) {
if (!lock.removable) return false;
if (lock.owner?.pid !== null && lock.owner?.pid !== undefined) {
return !processIsRunning(lock.owner.pid);
}
return Date.now() - lock.mtimeMs >= malformedLockStaleMs;
}
async function removeLockDirectory(lockPath) {
try {
await rmdir(lockPath);
return true;
} catch (error) {
if (
error?.code === "ENOENT" ||
error?.code === "ENOTEMPTY" ||
error?.code === "EEXIST"
) {
return false;
}
throw error;
}
}
async function releaseDirectoryLock(lockPath, ownerPath, token) {
const owner = await inspectLockOwner(ownerPath);
if (owner?.token !== token) return;
try {
await rm(ownerPath);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
await removeLockDirectory(lockPath);
}
async function removeStaleLock(lockPath, observed) {
if (observed.ownerPath) {
const currentOwner = await inspectLockOwner(observed.ownerPath);
if (currentOwner?.identity !== observed.owner?.identity) return false;
try {
await rm(observed.ownerPath);
} catch (error) {
if (error?.code === "ENOENT") return false;
throw error;
}
}
return removeLockDirectory(lockPath);
}
async function createDirectoryLock(lockPath) {
const token = randomUUID();
const ownerPath = join(lockPath, `${token}.json`);
const preparedOwnerPath = join(
dirname(lockPath),
`.aera-version-bump-owner-${token}.tmp`,
);
let lockFile;
try {
lockFile = await open(preparedOwnerPath, "wx");
await lockFile.writeFile(
`${JSON.stringify({ token, pid: process.pid, createdAt: Date.now() })}\n`,
);
await lockFile.close();
} catch (error) {
await lockFile?.close().catch(() => {});
await rm(preparedOwnerPath, { force: true }).catch(() => {});
throw error;
}
try {
await mkdir(lockPath);
} catch (error) {
try {
await rm(preparedOwnerPath);
} catch (cleanupFailure) {
throw stageCleanupError(
error,
preparedOwnerPath,
[{ action: "remove", error: cleanupFailure }],
true,
);
}
throw error;
}
let publicationError;
try {
await rename(preparedOwnerPath, ownerPath);
const published = await inspectLock(lockPath);
if (
published?.owner?.token !== token ||
published.ownerPath !== ownerPath
) {
publicationError = new Error("version lock owner publication lost its gate");
}
} catch (error) {
publicationError = error;
}
if (publicationError) {
await rm(preparedOwnerPath, { force: true }).catch(() => {});
await rm(ownerPath, { force: true }).catch(() => {});
await removeLockDirectory(lockPath).catch(() => {});
const retryError = new Error(
`could not publish version lock owner ${ownerPath}: ${errorMessage(publicationError)}`,
);
retryError.code = retryLockCode;
throw retryError;
}
return {
async release() {
await releaseDirectoryLock(lockPath, ownerPath, token);
},
};
}
async function acquireDirectoryLock(directory) {
const lockPath = join(directory, lockName);
const deadline = Date.now() + lockTimeoutMs;
const deadline = performance.now() + lockTimeoutMs;
while (true) {
try {
const lockFile = await open(lockPath, "wx");
let ownsLock = true;
return {
async release() {
await lockFile.close();
if (ownsLock) {
await rm(lockPath, { force: true });
ownsLock = false;
}
},
};
return await createDirectoryLock(lockPath);
} catch (error) {
if (error?.code === retryLockCode) {
if (performance.now() >= deadline) throw error;
await delay(lockRetryMs);
continue;
}
if (error?.code !== "EEXIST") {
throw new Error(`could not acquire version lock ${lockPath}: ${error.message}`);
}
if (Date.now() >= deadline) {
const observed = await inspectLock(lockPath);
if (observed && lockIsStale(observed)) {
await removeStaleLock(lockPath, observed);
continue;
}
if (performance.now() >= deadline) {
throw new Error(`timed out waiting for version lock ${lockPath}`);
}
await delay(lockRetryMs);
@ -126,6 +361,17 @@ function errorMessage(error) {
return error instanceof Error ? error.message : String(error);
}
function stageCleanupError(originalError, path, failures, retained) {
const details = failures
.map(({ action, error }) => `${action} ${path}: ${errorMessage(error)}`)
.join("; ");
const retainedDetails = retained ? `; temporary retained at ${path}` : "";
return new AggregateError(
[originalError, ...failures.map(({ error }) => error)],
`${errorMessage(originalError)}; cleanup failed: ${details}${retainedDetails}`,
);
}
function rollbackError(originalError, failures) {
const details = failures
.map(({ action, error, backup, destination }) => {
@ -167,6 +413,36 @@ function cleanupError(originalError, failures) {
);
}
async function cleanupBackups(staged) {
const results = await Promise.all(
staged
.filter((file) => file.backupOwned)
.map(async (file) => {
try {
await rm(file.backup);
file.backupOwned = false;
return null;
} catch (error) {
return { backup: file.backup, error };
}
}),
);
return results.filter(Boolean);
}
function backupCleanupError(failures) {
const details = failures
.map(
({ backup, error }) =>
`${backup}: ${errorMessage(error)}; backup retained at ${backup}`,
)
.join("; ");
return new AggregateError(
failures.map(({ error }) => error),
`backup cleanup failed: ${details}`,
);
}
async function replaceTogether(replacements) {
const transactionId = randomUUID();
const staged = [];
@ -239,19 +515,20 @@ async function replaceTogether(replacements) {
}
if (replacementError) throw replacementError;
for (const file of staged) {
await rm(file.backup);
file.backupOwned = false;
const backupCleanupFailures = await cleanupBackups(staged);
if (backupCleanupFailures.length) {
throw backupCleanupError(backupCleanupFailures);
}
}
export async function syncVersion(directory = process.cwd()) {
const lock = await acquireDirectoryLock(directory);
const absoluteDirectory = resolve(directory);
const lock = await acquireDirectoryLock(absoluteDirectory);
let operationError;
try {
const packagePath = join(directory, "package.json");
const manifestPath = join(directory, "manifest.json");
const versionsPath = join(directory, "versions.json");
const packagePath = join(absoluteDirectory, "package.json");
const manifestPath = join(absoluteDirectory, "manifest.json");
const versionsPath = join(absoluteDirectory, "versions.json");
const [packageJson, manifest, versions] = await Promise.all([
readJson(packagePath),
readJson(manifestPath),

View file

@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
@ -9,11 +10,12 @@ import {
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { basename, dirname, join } from "node:path";
import test from "node:test";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath, pathToFileURL } from "node:url";
import { bumpMetadata } from "../scripts/version-bump.mjs";
import { bumpMetadata, syncVersion } from "../scripts/version-bump.mjs";
const manifest = {
name: "Aera",
@ -44,13 +46,23 @@ function createFixture(t, packageVersion) {
return directory;
}
function runCli(cwd) {
return spawnSync(process.execPath, [cliPath], { cwd, encoding: "utf8" });
function runCli(cwd, options = {}) {
return spawnSync(process.execPath, [cliPath], {
cwd,
encoding: "utf8",
...options,
});
}
function runCliAsync(cwd) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [cliPath], { cwd });
function startCli(cwd, { env, preloadPath } = {}) {
const args = preloadPath
? ["--import", pathToFileURL(preloadPath).href, cliPath]
: [cliPath];
const child = spawn(process.execPath, args, {
cwd,
env: { ...process.env, ...env },
});
const result = new Promise((resolve, reject) => {
let stdout = "";
let stderr = "";
@ -67,6 +79,63 @@ function runCliAsync(cwd) {
resolve({ status, signal, stdout, stderr });
});
});
return { child, result };
}
function runCliAsync(cwd) {
return startCli(cwd).result;
}
async function waitForFile(path) {
const deadline = Date.now() + 2_000;
while (!existsSync(path)) {
if (Date.now() >= deadline) throw new Error(`timed out waiting for ${path}`);
await delay(5);
}
}
function readLockMetadata(lockPath) {
const ownerNames = readdirSync(lockPath).filter((name) => name.endsWith(".json"));
assert.equal(ownerNames.length, 1, "lock should have exactly one owner record");
return JSON.parse(readFileSync(join(lockPath, ownerNames[0]), "utf8"));
}
function startPausedCli(directory, name) {
const preloadPath = join(directory, `${name}-pause.mjs`);
const readyPath = join(directory, `${name}-ready`);
const continuePath = join(directory, `${name}-continue`);
writeFileSync(
preloadPath,
`import fs, { existsSync, writeFileSync } from "node:fs";
import { syncBuiltinESMExports } from "node:module";
import { basename } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
const realReadFile = fs.promises.readFile;
let paused = false;
fs.promises.readFile = async (path, ...args) => {
if (!paused && basename(String(path)) === "package.json") {
paused = true;
writeFileSync(process.env.AERA_LOCK_READY, "ready\\n");
while (!existsSync(process.env.AERA_LOCK_CONTINUE)) await delay(5);
}
return realReadFile(path, ...args);
};
syncBuiltinESMExports();
`,
);
return {
...startCli(directory, {
env: {
AERA_LOCK_CONTINUE: continuePath,
AERA_LOCK_READY: readyPath,
},
preloadPath,
}),
continuePath,
preloadPath,
readyPath,
};
}
test("bumpMetadata updates the manifest and appends the versions mapping", () => {
@ -151,6 +220,317 @@ test("version bump CLI reports invalid metadata without modifying files", (t) =>
assert.equal(readFileSync(versionsPath, "utf8"), originalVersions);
});
test("syncVersion fixes a relative directory before its first await", async (t) => {
const directory = createFixture(t, "1.0.0");
const otherDirectory = mkdtempSync(join(tmpdir(), "aera-version-bump-cwd-"));
const originalCwd = process.cwd();
t.after(() => rmSync(otherDirectory, { recursive: true, force: true }));
process.chdir(dirname(directory));
const synchronization = syncVersion(basename(directory));
process.chdir(otherDirectory);
try {
await synchronization;
} finally {
process.chdir(originalCwd);
}
assert.equal(
JSON.parse(readFileSync(join(directory, "manifest.json"), "utf8")).version,
"1.0.0",
);
});
test("version bump CLI takes over a stale lock after its owner is killed", async (t) => {
const directory = createFixture(t, "1.0.0");
const lockPath = join(directory, ".aera-version-bump.lock");
const paused = startPausedCli(directory, "killed-owner");
await waitForFile(paused.readyPath);
const metadata = readLockMetadata(lockPath);
paused.child.kill("SIGKILL");
await paused.result;
rmSync(paused.preloadPath, { force: true });
rmSync(paused.readyPath, { force: true });
const recovered = await Promise.all(
Array.from({ length: 12 }, () => runCliAsync(directory)),
);
for (const result of recovered) assert.equal(result.status, 0, result.stderr);
assert.equal(metadata.pid, paused.child.pid);
assert.equal(typeof metadata.token, "string");
assert.equal(typeof metadata.createdAt, "number");
assert.equal(existsSync(lockPath), false);
});
test("a previous lock owner does not delete a replacement lock", async (t) => {
const directory = createFixture(t, "1.0.0");
const lockPath = join(directory, ".aera-version-bump.lock");
const paused = startPausedCli(directory, "replaced-owner");
await waitForFile(paused.readyPath);
const replacement = {
token: "replacement-owner-token",
pid: process.pid,
createdAt: Date.now(),
};
rmSync(lockPath, { recursive: true });
mkdirSync(lockPath);
writeFileSync(
join(lockPath, `${replacement.token}.json`),
`${JSON.stringify(replacement)}\n`,
{ flag: "wx" },
);
writeFileSync(paused.continuePath, "continue\n");
const result = await paused.result;
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(readLockMetadata(lockPath), replacement);
});
test("releasing an owned lock never vacates the fixed lock path early", async (t) => {
const directory = createFixture(t, "1.0.0");
const lockPath = join(directory, ".aera-version-bump.lock");
const preloadPath = join(directory, "pause-lock-release.mjs");
const readyPath = join(directory, "release-ready");
const continuePath = join(directory, "release-continue");
writeFileSync(
preloadPath,
`import fs, { existsSync, writeFileSync } from "node:fs";
import { syncBuiltinESMExports } from "node:module";
import { basename, dirname } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
const realRm = fs.promises.rm;
let paused = false;
fs.promises.rm = async (path, options) => {
const name = basename(String(path));
const parent = basename(dirname(String(path)));
const releasingLock = name.endsWith(".moved") ||
(parent === ".aera-version-bump.lock" && name.endsWith(".json"));
if (!paused && releasingLock) {
paused = true;
writeFileSync(process.env.AERA_RELEASE_READY, "ready\\n");
while (!existsSync(process.env.AERA_RELEASE_CONTINUE)) await delay(5);
}
return realRm(path, options);
};
syncBuiltinESMExports();
`,
);
const running = startCli(directory, {
env: {
AERA_RELEASE_CONTINUE: continuePath,
AERA_RELEASE_READY: readyPath,
},
preloadPath,
});
await waitForFile(readyPath);
const fixedLockStillExists = existsSync(lockPath);
writeFileSync(continuePath, "continue\n");
const result = await running.result;
assert.equal(fixedLockStillExists, true);
assert.equal(result.status, 0, result.stderr);
assert.equal(existsSync(lockPath), false);
});
test("a slow owner publication cannot bypass a contender's recovery gate", async (t) => {
const directory = createFixture(t, "1.0.0");
const preloadPath = join(directory, "slow-owner-publication.mjs");
const slowReadyPath = join(directory, "slow-owner-ready");
const slowContinuePath = join(directory, "slow-owner-continue");
const slowEnteredPath = join(directory, "slow-owner-entered");
writeFileSync(
preloadPath,
`import fs, { existsSync, writeFileSync } from "node:fs";
import { syncBuiltinESMExports } from "node:module";
import { basename, dirname } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
const realOpen = fs.promises.open;
const realReadFile = fs.promises.readFile;
let ownerWritePaused = false;
fs.promises.open = async (path, ...args) => {
const handle = await realOpen(path, ...args);
const name = basename(String(path));
const parent = basename(dirname(String(path)));
const ownerRecord =
(parent === ".aera-version-bump.lock" && name.endsWith(".json")) ||
name.startsWith(".aera-version-bump-owner-");
if (!ownerWritePaused && ownerRecord) {
ownerWritePaused = true;
const realWriteFile = handle.writeFile.bind(handle);
handle.writeFile = async (...writeArgs) => {
writeFileSync(process.env.AERA_SLOW_READY, "ready\\n");
while (!existsSync(process.env.AERA_SLOW_CONTINUE)) await delay(5);
return realWriteFile(...writeArgs);
};
}
return handle;
};
fs.promises.readFile = async (path, ...args) => {
if (basename(String(path)) === "package.json") {
writeFileSync(process.env.AERA_SLOW_ENTERED, "entered\\n");
}
return realReadFile(path, ...args);
};
syncBuiltinESMExports();
`,
);
const slowOwner = startCli(directory, {
env: {
AERA_SLOW_CONTINUE: slowContinuePath,
AERA_SLOW_ENTERED: slowEnteredPath,
AERA_SLOW_READY: slowReadyPath,
},
preloadPath,
});
await waitForFile(slowReadyPath);
await delay(1_200);
const contender = startPausedCli(directory, "publication-contender");
await waitForFile(contender.readyPath);
writeFileSync(slowContinuePath, "continue\n");
await delay(200);
const slowOwnerEnteredWhileContenderHeld = existsSync(slowEnteredPath);
writeFileSync(contender.continuePath, "continue\n");
const contenderResult = await contender.result;
await waitForFile(slowEnteredPath);
const slowOwnerResult = await slowOwner.result;
assert.equal(slowOwnerEnteredWhileContenderHeld, false);
assert.equal(contenderResult.status, 0, contenderResult.stderr);
assert.equal(slowOwnerResult.status, 0, slowOwnerResult.stderr);
assert.equal(
readdirSync(directory).some((name) =>
name.startsWith(".aera-version-bump-owner-"),
),
false,
);
});
for (const failurePoint of ["write", "close"]) {
test(`version bump CLI reports retained staged temporary after ${failurePoint} failure`, (t) => {
const directory = createFixture(t, "1.0.0");
const preloadPath = join(directory, `fail-stage-${failurePoint}.mjs`);
writeFileSync(
preloadPath,
`import fs from "node:fs";
import { syncBuiltinESMExports } from "node:module";
import { basename } from "node:path";
const realOpen = fs.promises.open;
const realRm = fs.promises.rm;
fs.promises.open = async (path, ...args) => {
const handle = await realOpen(path, ...args);
const name = basename(String(path));
if (name.startsWith(".manifest.json.aera-") && name.endsWith(".tmp")) {
if (process.env.AERA_STAGE_FAILURE === "write") {
handle.writeFile = async () => {
throw new Error("injected temporary write failure");
};
handle.close = async () => {
throw new Error("injected temporary cleanup close failure");
};
} else {
const realClose = handle.close.bind(handle);
let closeFailed = false;
handle.close = async () => {
if (!closeFailed) {
closeFailed = true;
throw new Error("injected temporary close failure");
}
return realClose();
};
}
}
return handle;
};
fs.promises.rm = async (path, options) => {
const name = basename(String(path));
if (name.startsWith(".manifest.json.aera-") && name.endsWith(".tmp")) {
throw new Error("injected staged temporary removal failure");
}
return realRm(path, options);
};
syncBuiltinESMExports();
`,
);
const result = spawnSync(
process.execPath,
["--import", pathToFileURL(preloadPath).href, cliPath],
{
cwd: directory,
encoding: "utf8",
env: { ...process.env, AERA_STAGE_FAILURE: failurePoint },
},
);
const temporaryName = readdirSync(directory).find((name) =>
/^\.manifest\.json\.aera-.+\.tmp$/.test(name),
);
assert.equal(result.status, 1);
assert.match(result.stderr, new RegExp(`temporary ${failurePoint} failure`));
if (failurePoint === "write") {
assert.match(result.stderr, /temporary cleanup close failure/);
}
assert.match(result.stderr, /staged temporary removal failure/);
assert.ok(temporaryName, "failed cleanup should retain the staged temporary");
assert.match(result.stderr, new RegExp(temporaryName.replaceAll(".", "\\.")));
});
}
test("version bump CLI reports every retained backup after cleanup failures", (t) => {
const directory = createFixture(t, "1.0.0");
const preloadPath = join(directory, "fail-backup-cleanup.mjs");
writeFileSync(
preloadPath,
`import fs from "node:fs";
import { syncBuiltinESMExports } from "node:module";
import { basename } from "node:path";
const realRm = fs.promises.rm;
fs.promises.rm = async (path, options) => {
const name = basename(String(path));
if (name.startsWith(".manifest.json.aera-") && name.endsWith(".bak")) {
throw new Error("injected manifest backup cleanup failure");
}
if (name.startsWith(".versions.json.aera-") && name.endsWith(".bak")) {
throw new Error("injected versions backup cleanup failure");
}
return realRm(path, options);
};
syncBuiltinESMExports();
`,
);
const result = spawnSync(
process.execPath,
["--import", pathToFileURL(preloadPath).href, cliPath],
{ cwd: directory, encoding: "utf8" },
);
const backupNames = readdirSync(directory)
.filter((name) => /\.(?:manifest|versions)\.json\.aera-.+\.bak$/.test(name))
.sort();
assert.equal(result.status, 1);
assert.match(result.stderr, /injected manifest backup cleanup failure/);
assert.match(result.stderr, /injected versions backup cleanup failure/);
assert.equal(backupNames.length, 2);
for (const backupName of backupNames) {
assert.match(result.stderr, new RegExp(backupName.replaceAll(".", "\\.")));
}
assert.equal(
JSON.parse(readFileSync(join(directory, "manifest.json"), "utf8")).version,
"1.0.0",
);
});
test("version bump CLI reports rollback failures and preserves their backups", (t) => {
const directory = createFixture(t, "1.0.0");
const preloadPath = join(directory, "fail-renames.mjs");