Skip to content

fix(self-update): make replacement atomic and race-safe - #5077

Open
cachebag wants to merge 9 commits into
rust-lang:mainfrom
cachebag:demo/self-update-race-after
Open

cachebag wants to merge 9 commits into
rust-lang:mainfrom
cachebag:demo/self-update-race-after

Conversation

@cachebag

@cachebag cachebag commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Self-update currently stages every updater at the shared $CARGO_HOME/bin/rustup-init path. Any rustup proxy started while replacement is in progress can unlink that path during startup cleanup. On Unix, the replacement process then removes the installed rustup, fails to reopen its own deleted path, and leaves rustup missing. This appears to explain the failures reported in #1864, #4648, and #4777.

The first commit adds a deterministic test for that race. It fails on demo/self-update-race-before and the identical test passes with this branch.

This PR uses a single managed updater under $RUSTUP_HOME, guarded by an OS-released global self-update lock. It fully prepares and syncs a sibling binary before publishing it with rename on Unix or ReplaceFileW on Windows. Completed, failed, and abandoned artifacts are cleaned without racing an active update.

The core result of this fix is that now, a failure before publication will leave the existing rustup untouched.

Fixes #5076
Fixes #1864

@cachebag
cachebag force-pushed the demo/self-update-race-after branch 2 times, most recently from e266280 to a60db3a Compare September 13, 2026 21:53
@cachebag
cachebag marked this pull request as ready for review September 13, 2026 21:53
@cachebag
cachebag marked this pull request as draft September 13, 2026 21:53
@cachebag cachebag changed the title fix(self-update): make replacement atomic and race-safe [WIP] fix(self-update): make replacement atomic and race-safe Sep 13, 2026
@cachebag

Copy link
Copy Markdown
Contributor Author

@rami3l Re: #3937; this PR is intentionally limited to replacing rustup’s own executable and does not modify toolchain transactions, but it introduces a self-update-specific staging directory, lock, and cleanup lifecycle.

Does that overlap with the primitives you are developing for the process-safety goal, or do you think keeping self-update isolated here is reasonable?

Comment thread tests/suite/cli_self_upd.rs Outdated
Comment thread src/cli/self_update/stage.rs Outdated
Comment thread src/cli/self_update/stage.rs Outdated
Comment thread src/cli/self_update/stage.rs Outdated
Comment thread src/cli/self_update/stage.rs Outdated
Comment thread src/cli/self_update.rs Outdated
@rami3l

rami3l commented Sep 14, 2026

Copy link
Copy Markdown
Member

Does that overlap with the primitives you are developing for the process-safety goal, or do you think keeping self-update isolated here is reasonable?

@cachebag Actually I think it can be done quite separately from the transactional semantics.

As I added at the final minute comment when trying to merge the project goal, I specifically said that it would be only about concurrency problems of the Rust installations rather than those of the rustup installation (which this PR falls under): rust-lang/goals#731 (comment)

@cachebag
cachebag force-pushed the demo/self-update-race-after branch 3 times, most recently from 2cdfb67 to 3b4cc2b Compare September 14, 2026 14:36
@cachebag
cachebag requested a review from rami3l September 14, 2026 14:39
@cachebag cachebag changed the title [WIP] fix(self-update): make replacement atomic and race-safe fix(self-update): make replacement atomic and race-safe Sep 14, 2026
@cachebag
cachebag marked this pull request as ready for review September 14, 2026 14:39
@cachebag

cachebag commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

CI seems to have crapped out on us due to a GitHub issue. @rami3l does it let you re-run the failed jobs?

@rami3l

rami3l commented Sep 14, 2026

Copy link
Copy Markdown
Member

CI seems to crapped out on us due to a GitHub issue. @rami3l does it let you re-run the failed jobs?

@cachebag I can't even see the button. Fortunately my agent has figured out a way to bypass the GUI and rerun the thing via API.

@rami3l rami3l self-assigned this Sep 14, 2026

@djc djc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's an initial round of feedback.

IMO the last commit is still pretty messy, and looks like there could be more intermediate commits to clean things up and provide more structure.

View changes since this review

Comment thread src/cli/self_update/stage.rs Outdated
Comment thread src/cli/self_update/stage.rs Outdated
Comment thread src/cli/self_update/stage.rs Outdated
Comment thread src/cli/self_update/unix.rs Outdated
Comment thread src/cli/self_update/windows.rs Outdated
Comment thread src/cli/self_update/windows.rs Outdated
Comment thread src/cli/self_update.rs Outdated
Comment thread src/cli/self_update.rs Outdated
Comment thread src/cli/self_update.rs Outdated
Comment thread src/cli/self_update/stage.rs Outdated
@cachebag
cachebag force-pushed the demo/self-update-race-after branch 2 times, most recently from bc36468 to af1b039 Compare September 15, 2026 20:22
@rustbot

This comment has been minimized.

@cachebag
cachebag requested review from djc and rami3l September 15, 2026 20:25
Comment thread src/cli/self_update/windows.rs
@cachebag
cachebag force-pushed the demo/self-update-race-after branch from af1b039 to c7ebab5 Compare September 15, 2026 21:53
@rustbot

This comment has been minimized.

@cachebag
cachebag force-pushed the demo/self-update-race-after branch from c7ebab5 to c1d76d8 Compare September 15, 2026 21:59

@ChrisDenton ChrisDenton left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll want to look at this more tomorrow when I'm more rested but looks good to me so far.

View changes since this review

Comment thread src/cli/self_update/stage.rs Outdated
Comment thread src/cli/self_update/stage.rs Outdated
Comment thread src/cli/self_update/stage.rs
Comment thread src/cli/self_update/stage.rs Outdated
Comment thread src/cli/self_update/stage.rs Outdated
Comment thread src/cli/self_update/unix.rs Outdated
Comment thread src/cli/self_update/windows.rs
@cachebag
cachebag force-pushed the demo/self-update-race-after branch 2 times, most recently from dcef181 to 1dcce29 Compare September 16, 2026 17:38
@rustbot

This comment has been minimized.

@cachebag cachebag closed this Sep 16, 2026
@cachebag
cachebag deleted the demo/self-update-race-after branch September 16, 2026 17:39
@cachebag
cachebag restored the demo/self-update-race-after branch September 16, 2026 17:40
@cachebag cachebag reopened this Sep 16, 2026
Comment thread src/cli/self_update/stage.rs Outdated
Comment thread src/cli/self_update/windows.rs

@rami3l rami3l left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In terms of the end result this looks good. However as @djc has mentioned in #5077 (review), I have the feeling that the two main commits here have been way too juicy to review properly.

Also, this is introducing an API surface that is way too wide without a clear reason. For example, helpers like is_finished() have introduced no clear benefits and has slowed down the review process since the reviewer has to find the function and do the jump.

My perspective as the reviewer regarding atomic commits is that every should be as dumb as possible: the less juicy the meat of the feature actually is (and the fewer jumps I have to make when reading the diff file), the better.

Most commits in your PR should contain mostly tasteless changes with a clear single focus: renaming, extraction, inlining actions etc., that sandwich the actual feature changes, and when a PR contains too many such changes upfront, you should consider splitting some of them into one preparation PR or more.

A more concrete example here: WRT the staged updater, the cleanup of the stale file can be completely separated from the main feature, so probably you can split the change into two.

I totally understand that you might feel uncomfortable "inventing" intermediate states of your history, but as with every paper or code publication there is, some polishing to the "how we got there" is required for easier digestion.

View changes since this review

@cachebag
cachebag force-pushed the demo/self-update-race-after branch 2 times, most recently from 7bb25fd to 80f6958 Compare September 17, 2026 12:26
cachebag and others added 9 commits September 17, 2026 08:26
Import sibling items through `super` in the Windows module and move
`DEFAULT_UPDATE_ROOT` below its users, as the coding standards prefer.
No functional change.
Two concurrent `rustup self update` invocations shared one updater path
and could overwrite each other's download or replacement (rust-lang#1864).

`prepare_update` now takes a global self-update lock before downloading
and hands it to `run_update` inside a `PreparedUpdater`, which releases
it only once the replacer has been spawned. The replacer takes the same
lock before replacing rustup, so `install_bins` becomes a method on the
lock and can only run while it is held. The lock file lives under
`$RUSTUP_HOME/self-update/` and is released by the OS when the owning
process exits, so a crash can never leave it held.
…come

Every rustup or proxy invocation deleted `$CARGO_HOME/bin/rustup-init`
during startup cleanup. A proxy starting while the updater was still
replacing rustup could therefore delete the updater out from under it
(rust-lang#5076).

The updater now lives at `$RUSTUP_HOME/self-update/rustup-init`, and
the replacer records a `complete` or `failed` marker next to it once it
is done. Startup cleanup removes the managed updater only when such a
marker exists and the self-update lock is free, so an update in
progress is never touched. The legacy path is still cleaned as before.
An updater whose replacer never ran, or crashed before recording an
outcome, has no marker and was left behind forever. A legacy
`$CARGO_HOME/bin/rustup-init` may still belong to an older rustup that
is running it, so deleting it on sight is the very race being fixed.

Both are now removed only after they have gone untouched for a day.
Replacement used to unlink the installed rustup and then copy the
updater over the freed path. Any failure in between, such as the
updater having been deleted meanwhile, left `$CARGO_HOME/bin` without
a rustup at all.

The new binary is now copied to a `.rustup-pending-*` sibling, synced
to disk, and then renamed over the installed rustup. `std::fs::rename`
replaces an existing destination in one step on every platform, so a
failure before publication leaves the existing rustup untouched.
A crash between staging and publishing leaves a `.rustup-pending-*`
file in `$CARGO_HOME/bin`. Startup cleanup now removes such files once
they have gone untouched for a day, the same threshold used for
abandoned updaters.
After spawning the replacer, the parent ran the updater a second time
with `--version` and wrote the result to the uninstall registry entry.
The registry could therefore claim a version that was never installed
if the replacer went on to fail.

The replacer is the new rustup and knows its own version, so it now
updates `DisplayVersion` right after installing the binaries, under the
same self-update lock. The test waits for the completion marker because
the registry is now written after `rustup self update` has returned.
@cachebag
cachebag force-pushed the demo/self-update-race-after branch from 80f6958 to 38048e7 Compare September 17, 2026 12:26
@rustbot

rustbot commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

This PR was rebased onto a different main commit. Here's a range-diff highlighting what actually changed.

Rebasing is a normal part of keeping PRs up to date, so no action is needed—this note is just to help reviewers.

@cachebag

Copy link
Copy Markdown
Contributor Author

I totally understand that you might feel uncomfortable "inventing" intermediate states of your history, but as with every paper or code publication there is, some polishing to the "how we got there" is required for easier digestion.

@rami3l @djc
This is reasonable. And I appreciate the feedback.

What do you think of the new structure? Let me know if there are any changes you'd want me to make.

@rami3l rami3l left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cachebag Thanks for the update! I think it is indeed looking a bit better now.

I really want to get this right before merging, so I'd like to invite the rest of @rust-lang/rustup to review as well just in case I've missed anything and/or they have some opinions regarding this patch.

View changes since this review

}

/// Tell the upgrader to replace the rustup bins, then delete
/// itself.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I think this docstring still applies?

Comment on lines +126 to +129
let status = prepared_updater.spawn_replacer()?.wait().context(format!(
"unable to wait for updater ({})",
setup_path.display()
))?;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Pre-existing, but possibly we can use .with_context() instead of .context() to make the evaluation a bit lazier?

Comment thread src/cli/self_update.rs
// Get download path
let download_url = utils::parse_url(&url)?;
let prepared_updater = self_update_lock.prepare_updater(dl_cfg.process)?;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Adding let setup_path = prepared_updater.path(); here could save some space in the diff.

}
}

pub(super) fn mark_result(process: &Process, succeeded: bool) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: According to our style guide, you should place the most interesting param upfront, so succeeded in mark_result() first, and then process.

pub(super) fn cleanup(process: &Process) -> anyhow::Result<()> {
if let Some(lock) = SelfUpdateLock::try_acquire(process)? {
let updater = lock.updater_path();
// A finished update has recorded its outcome; an abandoned one never will.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this claim a bit inaccurate?

If I have understood it correctly, it looks like "having markers" implies that an update has finished, but not having markers may or may not indicate a completed update, because the updater could have crashed exactly when trying to write the markers?

}

impl SelfUpdateLock {
pub(super) fn acquire(process: &Process) -> anyhow::Result<Self> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Following the naming convention, this series of methods are better named .lock() and .try_lock().

Comment on lines +132 to +143
let markers = [Marker::Complete, Marker::Failed];
let finished = markers
.iter()
.any(|marker| marker.path(&lock.directory).is_file());
if finished && utils::remove_file_best_effort("self-updater", &updater) {
for marker in markers {
utils::remove_file_best_effort(
"self-update status marker",
&marker.path(&lock.directory),
);
}
}

@rami3l rami3l Sep 18, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Would you mind elaborating why the marker is required in addition to the lock and, if it is indeed needed, why do we have two of them?

The rationale here is not very clear to me at least through your docstrings and/or commit messages.

View changes since the review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Concurrent proxy startup can delete the active self-updater and remove rustup Self update could interfere with itself

5 participants