Merge PR #8435: fix(update): unique temp path for concurrent self-updates (@roboomp)
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user