Merge PR #8435: fix(update): unique temp path for concurrent self-updates (@roboomp)

This commit is contained in:
can1357
2026-08-13 17:32:50 +02:00
3 changed files with 155 additions and 31 deletions
+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([]);
});
});