fix(update): unique temp path for concurrent self-updates

Two overlapping `omp update` runs shared the fixed `<binary>.new` temp
path. downloadVerifiedBinary unlinks the target before writing, so the
second run's unlink deleted the first run's still-downloading temp file;
the first kept writing to its open fd (size and digest still passed),
then chmod hit the missing path and aborted with ENOENT.

Give the temp path the same unique per-attempt suffix the backup path
already uses (pid, timestamp, and a new process-local counter that also
covers same-millisecond, same-process collisions). Generalize the stale
backup sweep to also reclaim orphaned `.new` temp files, age-gated by the
download window so a concurrent run's in-progress temp is never deleted.

Fixes #8434
This commit is contained in:
roboomp
2026-08-13 15:17:41 +00:00
parent 326d24bd40
commit 1d6d35bd24
3 changed files with 158 additions and 31 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed `omp update` aborting with `chmod ENOENT` on `<binary>.new` when two update runs overlapped: the download temp path is now unique per attempt (pid, timestamp, and a process-local counter), so concurrent runs no longer delete each other's temp file, and orphaned temp files are reclaimed alongside stale backups ([#8434](https://github.com/can1357/oh-my-pi/issues/8434)).
## [17.3.0] - 2026-08-13
### Breaking Changes
+57 -25
View File
@@ -987,8 +987,8 @@ async function unlinkIfExists(filePath: string): Promise<void> {
* running process image, so unlinking it fails with EPERM/EACCES until this
* process exits (issue #845). The replacement and verification already
* succeeded by the time we get here, so every error is swallowed; the leftover
* is reclaimed by {@link sweepStaleBackups} on the next update once it is no
* longer in use. Returns whether the file is gone.
* is reclaimed by {@link sweepStaleUpdateArtifacts} on the next update once it
* is no longer in use. Returns whether the file is gone.
*/
async function removeBackupBestEffort(filePath: string): Promise<boolean> {
try {
@@ -1000,16 +1000,21 @@ async function removeBackupBestEffort(filePath: string): Promise<boolean> {
}
/**
* Best-effort removal of binary-update backups left by earlier runs.
* Best-effort removal of binary-update leftovers from earlier runs.
*
* Each self-update moves the previous executable to `<binary>.<timestamp>.<pid>.bak`
* before swapping the new one in. On Windows that backup cannot be deleted
* while the updating process is alive, so it is left for a later run to reclaim
* once its owning process has exited. Also matches the legacy fixed
* `<binary>.bak` name produced before backups were timestamped, so users
* upgrading from a buggy release get the orphaned file cleaned up.
* Each self-update writes to `<binary>.<timestamp>.<pid>.new` and moves the
* previous executable to `<binary>.<timestamp>.<pid>.bak` before swapping the
* new one in. On Windows a backup cannot be deleted while the updating process
* is alive (it is the running process image), so it is left for a later run to
* reclaim once its owning process has exited. A `.new` temp file only survives
* a hard kill mid-download; it is reaped once older than the download window,
* which a live download cannot exceed without timing out and cleaning up after
* itself — so a concurrent run's in-progress temp is never deleted. Legacy
* fixed `<binary>.bak` / `<binary>.new` names (from before suffixes were made
* unique) are matched too, so users upgrading from a buggy release get the
* orphaned files cleaned up.
*/
export async function sweepStaleBackups(targetPath: string): Promise<void> {
export async function sweepStaleUpdateArtifacts(targetPath: string): Promise<void> {
const dir = path.dirname(targetPath);
const base = path.basename(targetPath);
let entries: string[];
@@ -1018,13 +1023,28 @@ export async function sweepStaleBackups(targetPath: string): Promise<void> {
} catch {
return;
}
const now = Date.now();
for (const entry of entries) {
if (!entry.startsWith(`${base}.`) || !entry.endsWith(".bak")) continue;
// Legacy "<base>.bak" → empty middle; new "<base>.<timestamp>.<pid>.bak"
// → dot-separated numeric run. Anything else is an unrelated *.bak file.
const middle = entry.slice(base.length + 1, entry.length - ".bak".length);
if (!entry.startsWith(`${base}.`)) continue;
const suffix = entry.endsWith(".bak") ? ".bak" : entry.endsWith(".new") ? ".new" : undefined;
if (!suffix) continue;
// Legacy "<base><suffix>" → empty middle; new "<base>.<timestamp>.<pid><suffix>"
// → dot-separated numeric run. Anything else is an unrelated file.
const middle = entry.slice(base.length + 1, entry.length - suffix.length);
if (middle.length > 0 && !/^\d+(\.\d+)*$/.test(middle)) continue;
await removeBackupBestEffort(path.join(dir, entry));
const full = path.join(dir, entry);
if (suffix === ".new") {
// A temp file may belong to a concurrent update still downloading, so
// only reap ones older than the download window.
let mtimeMs: number;
try {
mtimeMs = (await fs.promises.stat(full)).mtimeMs;
} catch {
continue;
}
if (now - mtimeMs < BINARY_DOWNLOAD_TIMEOUT_MS) continue;
}
await removeBackupBestEffort(full);
}
}
@@ -1334,6 +1354,11 @@ async function updateViaMise(expectedVersion: string, force: boolean): Promise<v
await printVerification(expectedVersion);
}
// Monotonic within this process so two updates started in the same millisecond
// (same pid, same `Date.now()`) still get distinct temp/backup paths. Kept
// numeric so the artifact sweep's `\d+(\.\d+)*` matcher still reclaims them.
let updateAttemptSeq = 0;
/**
* Download a release binary to a target path, replacing an existing file.
*/
@@ -1348,12 +1373,18 @@ export async function updateViaBinaryAt(
} = {},
): Promise<void> {
const binaryName = options.binaryName ?? getBinaryName();
const tempPath = `${targetPath}.new`;
// Unique per attempt: a stale backup from an earlier update may still be
// locked (it is the previous process image on Windows), and a fixed name
// would force the move-aside rename to overwrite it. pid + timestamp keeps
// two forced updates in the same millisecond from colliding.
const backupPath = `${targetPath}.${Date.now()}.${process.pid}.bak`;
// Unique per attempt so two overlapping `omp update` runs never share a temp
// or backup path. A fixed temp name (`<binary>.new`) let the second run's
// pre-download unlink delete the first run's still-downloading temp file; the
// first kept writing to its open fd (size + digest still passed), then chmod
// hit the missing path and the update aborted (issue #8434). The backup needs
// the same uniqueness: a stale backup from an earlier update may still be
// locked (the previous process image on Windows), so a fixed name would force
// the move-aside rename to overwrite it. pid, timestamp, and a process-local
// counter keep two updates started in the same millisecond from colliding.
const attempt = `${Date.now()}.${process.pid}.${updateAttemptSeq++}`;
const tempPath = `${targetPath}.${attempt}.new`;
const backupPath = `${targetPath}.${attempt}.bak`;
const asset = await getReleaseBinaryAsset(expectedVersion, binaryName, options.fetchImpl, options.githubToken);
console.log(chalk.dim(`Downloading ${binaryName}…`));
await downloadVerifiedBinary({
@@ -1374,7 +1405,7 @@ export async function updateViaBinaryAt(
verifyInstalledVersion: options.verifyInstalledVersion ?? verifyInstalledVersion,
});
// Reclaim backups from earlier updates whose owning process has since exited.
await sweepStaleBackups(targetPath);
await sweepStaleUpdateArtifacts(targetPath);
printVerifiedVersion(expectedVersion);
console.log(chalk.dim(`Restart ${APP_NAME} to use the new version`));
}
@@ -1421,7 +1452,8 @@ export async function updateViaShimTakeover(
const binaryName = options.binaryName ?? getBinaryName();
const launcherDir = path.dirname(shimPath);
const exePath = path.join(launcherDir, `${APP_NAME}.exe`);
const tempPath = `${exePath}.new`;
const attempt = `${Date.now()}.${process.pid}.${updateAttemptSeq++}`;
const tempPath = `${exePath}.${attempt}.new`;
const asset = await getReleaseBinaryAsset(expectedVersion, binaryName, options.fetchImpl, options.githubToken);
console.log(chalk.dim(`Downloading ${binaryName}…`));
await downloadVerifiedBinary({
@@ -1441,7 +1473,7 @@ export async function updateViaShimTakeover(
// renamed (held open without delete sharing) is rewritten in place as a
// forwarder to the exe — write and rename take different Windows locks,
// so one can succeed where the other fails.
const backupSuffix = `${Date.now()}.${process.pid}.bak`;
const backupSuffix = `${attempt}.bak`;
const retired: Array<{ launcher: string; backup: string }> = [];
const forwarded: Array<{ launcher: string; original: string }> = [];
const stuck: string[] = [];
@@ -1489,7 +1521,7 @@ export async function updateViaShimTakeover(
}
// Reclaim exe backups and retired-shim leftovers from earlier attempts.
for (const ext of [".exe", "", ".cmd", ".ps1", ".bat"]) {
await sweepStaleBackups(path.join(launcherDir, `${APP_NAME}${ext}`));
await sweepStaleUpdateArtifacts(path.join(launcherDir, `${APP_NAME}${ext}`));
}
for (const { launcher } of forwarded) {
console.log(chalk.dim(`Converted ${launcher} to a forwarder (it could not be removed).`));
+97 -6
View File
@@ -27,7 +27,7 @@ import {
resolveReleaseRename,
resolveUpdateMethodForTest,
shouldForceBinaryUpdate,
sweepStaleBackups,
sweepStaleUpdateArtifacts,
updateViaBinaryAt,
updateViaShimTakeover,
} from "@oh-my-pi/pi-coding-agent/cli/update-cli";
@@ -758,7 +758,8 @@ describe("update-cli release binary integrity", () => {
expect(metadataAuthorizations).toEqual(["Bearer test-token"]);
expect(await Bun.file(targetPath).text()).toBe(installed);
expect((await fs.stat(targetPath)).mode & 0o777).toBe(0o755);
expect(await Bun.file(`${targetPath}.new`).exists()).toBe(false);
const newResidue = (await fs.readdir(dir)).filter(name => name.endsWith(".new"));
expect(newResidue).toEqual([]);
} finally {
if (previousGitHubToken === undefined) delete Bun.env.GITHUB_TOKEN;
else Bun.env.GITHUB_TOKEN = previousGitHubToken;
@@ -869,26 +870,41 @@ describe("update-cli binary replacement on locked backups", () => {
});
});
describe("update-cli stale backup sweep", () => {
it("reclaims timestamped and legacy backups while leaving unrelated .bak files", async () => {
describe("update-cli stale update artifact sweep", () => {
it("reclaims timestamped and legacy backups and orphaned temps while sparing in-progress temps and unrelated files", async () => {
const dir = await makeTempDir();
const targetPath = path.join(dir, "omp.exe");
await Bun.write(targetPath, "current binary");
await Bun.write(`${targetPath}.bak`, "legacy backup");
await Bun.write(`${targetPath}.1700000000000.4242.bak`, "timestamped backup");
await Bun.write(`${targetPath}.1800000000000.99.bak`, "another backup");
// Must survive: foreign basename and a non-numeric middle segment.
// Orphaned temp files from a hard-killed download: reaped once older than
// the download window. Legacy fixed name and timestamped name both count.
const stale = new Date(Date.now() - 60 * 60 * 1000);
await Bun.write(`${targetPath}.new`, "legacy temp");
await fs.utimes(`${targetPath}.new`, stale, stale);
await Bun.write(`${targetPath}.1700000000000.4242.new`, "timestamped temp");
await fs.utimes(`${targetPath}.1700000000000.4242.new`, stale, stale);
// Must survive: a fresh temp still belongs to a concurrent, in-progress
// download (unique per attempt), plus foreign basenames and non-numeric
// middle segments.
await Bun.write(`${targetPath}.9999999999999.7.new`, "in-progress temp");
await Bun.write(path.join(dir, "notes.bak"), "keep me");
await Bun.write(`${targetPath}.config.bak`, "keep me too");
await Bun.write(`${targetPath}.config.new`, "keep me three");
await sweepStaleBackups(targetPath);
await sweepStaleUpdateArtifacts(targetPath);
expect(await Bun.file(targetPath).exists()).toBe(true);
expect(await Bun.file(`${targetPath}.bak`).exists()).toBe(false);
expect(await Bun.file(`${targetPath}.1700000000000.4242.bak`).exists()).toBe(false);
expect(await Bun.file(`${targetPath}.1800000000000.99.bak`).exists()).toBe(false);
expect(await Bun.file(`${targetPath}.new`).exists()).toBe(false);
expect(await Bun.file(`${targetPath}.1700000000000.4242.new`).exists()).toBe(false);
expect(await Bun.file(`${targetPath}.9999999999999.7.new`).exists()).toBe(true);
expect(await Bun.file(path.join(dir, "notes.bak")).exists()).toBe(true);
expect(await Bun.file(`${targetPath}.config.bak`).exists()).toBe(true);
expect(await Bun.file(`${targetPath}.config.new`).exists()).toBe(true);
});
});
@@ -1071,3 +1087,78 @@ describe("update-cli script-shim takeover", () => {
}
});
});
describe("update-cli concurrent binary updates", () => {
const version = "999.0.0";
const binaryName = "omp-linux-x64";
const url = `https://github.com/can1357/oh-my-pi/releases/download/v${version}/${binaryName}`;
const payload = Buffer.alloc(2048, 0x41);
const digest = `sha256:${createHash("sha256").update(payload).digest("hex")}`;
function metadata(): Response {
return Response.json({
tag_name: `v${version}`,
draft: false,
prerelease: false,
assets: [{ name: binaryName, state: "uploaded", size: payload.byteLength, digest, browser_download_url: url }],
});
}
// Regression for #8434: two overlapping `omp update` runs must not share a
// temp path. Run A downloads slowly and only finishes after run B has fully
// installed. With the old fixed `<binary>.new` temp name, B's pre-download
// unlink deleted A's temp file, so A's chmod failed with ENOENT even though
// its size + digest passed. Unique temp paths keep the two runs independent.
it("lets an overlapping slow run install after a fast run completes, instead of failing chmod with ENOENT", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const dir = await makeTempDir();
const targetPath = path.join(dir, "omp");
await Bun.write(targetPath, "old binary");
const aWroteFirstChunk = Promise.withResolvers<void>();
const letAFinish = Promise.withResolvers<void>();
const slowFetch = async (input: string | URL | Request): Promise<Response> => {
const requestUrl = String(input);
if (requestUrl.startsWith("https://api.github.com/")) return metadata();
if (requestUrl === url) {
return new Response(
new ReadableStream<Uint8Array>({
async start(controller) {
controller.enqueue(payload.subarray(0, 1024));
aWroteFirstChunk.resolve();
await letAFinish.promise;
controller.enqueue(payload.subarray(1024));
controller.close();
},
}),
);
}
throw new Error(`Unexpected request: ${requestUrl}`);
};
const fastFetch = async (input: string | URL | Request): Promise<Response> => {
const requestUrl = String(input);
if (requestUrl.startsWith("https://api.github.com/")) return metadata();
if (requestUrl === url) return new Response(payload);
throw new Error(`Unexpected request: ${requestUrl}`);
};
const verify = async () => ({ ok: true, actual: version, path: targetPath });
const runA = updateViaBinaryAt(targetPath, version, {
binaryName,
fetchImpl: slowFetch,
verifyInstalledVersion: verify,
});
await aWroteFirstChunk.promise;
await updateViaBinaryAt(targetPath, version, {
binaryName,
fetchImpl: fastFetch,
verifyInstalledVersion: verify,
});
letAFinish.resolve();
await runA;
expect(await Bun.file(targetPath).bytes()).toEqual(new Uint8Array(payload));
const residue = (await fs.readdir(dir)).filter(name => name.endsWith(".new"));
expect(residue).toEqual([]);
});
});