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
+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).`));