Skip to content

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

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

cachebag wants to merge 10 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

@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 3 times, most recently from 80f6958 to 38048e7 Compare September 17, 2026 12:26
@rustbot

This comment has been minimized.

@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

Comment thread src/cli/self_update/unix.rs
Comment thread src/cli/self_update/unix.rs Outdated
Comment thread src/cli/self_update.rs
Comment thread src/cli/self_update/stage.rs Outdated
Comment thread src/cli/self_update/stage.rs Outdated
}

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().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I will withhold it until #5077 (comment) is settled, since that may reshape these methods anyway.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The lock only says whether an update is running right now. After the parent exits and before the replacer takes the lock, cleanup sees a free lock and an updater on disk and can’t tell the difference between being finished or queued to run. Deleting it in the second case is the bug.

The marker is the “done” signal. Without it the only safe option is age, and it doesn't seem conducive to then let every updated sit around for a day.

why do we have two of them?

I guess cleanup treats them the same. failed only helps the test fail fast and leaves a trace on Windows.

@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.

I think the first few commits are nice and clean, and we could probably merge them proactively via a separate commit?

Later commits seem to not be logically independent, so I'm not sure keeping them all separate really makes sense.

View changes since this review

Comment thread src/cli/self_update/stage.rs
Comment thread src/cli/self_update/stage.rs Outdated
Comment on lines +41 to +47
pub(super) fn prepare_updater(self, process: &Process) -> anyhow::Result<PreparedUpdater> {
let path = process
.cargo_home()?
.join(format!("bin/rustup-init{EXE_SUFFIX}"));
utils::ensure_file_removed("self-updater", &path)?;
Ok(PreparedUpdater { path, _lock: self })
}

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.

So I think this is effectively a type state thing where we're taking the SelfUpdateLock and wrapping the contained lock into a PreparedUpdater, right? I think it might be simpler to redefine SelfUpdateLock as a type with a generic type parameter somewhat like Mutex, with a Deref impl to replace PreparedUpdater::path().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

My hesitation with this is the payload is only ever a PathBuf, so it’d be one type parameter for one call site, and Deref to a path hides what’s being handed around.

Comment thread src/cli/self_update/stage.rs
Comment thread src/utils/mod.rs
/// Unlike [`remove_file`], a busy file is left alone instead of retried, since
/// callers use this for cleanup that another process may legitimately still be
/// using. Returns whether `path` is gone afterwards.
pub(crate) fn remove_file_best_effort(name: &str, path: &Path) -> bool {

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.

Unless/until this is used for other callers, suggest keeping it where it is used.

@cachebag cachebag Sep 19, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I agreed with what @ChrisDenton said here #5077 (comment).

wdyt?

@cachebag
cachebag force-pushed the demo/self-update-race-after branch from 38048e7 to 6bd5c62 Compare September 19, 2026 02:31
@cachebag

cachebag commented Sep 19, 2026

Copy link
Copy Markdown
Contributor Author

Later commits seem to not be logically independent, so I'm not sure keeping them all separate really makes sense.

@djc
I split these at @rami3l's request in #5077 (review). I do not mind either way; could either of you propose the preferred structure?

Re: a separate PR; I suppose that is fine...ab0d89a and 1ca3033 can be by themselves, though what should I do with fc00f5f? The whole point of that test is that it fails without the fix.

@cachebag
cachebag force-pushed the demo/self-update-race-after branch from 6bd5c62 to f5c76de Compare September 19, 2026 02:52
@rustbot

This comment has been minimized.

@cachebag
cachebag requested review from djc and rami3l September 19, 2026 02:52
@rami3l

rami3l commented Sep 21, 2026

Copy link
Copy Markdown
Member

what should I do with fc00f5f? The whole point of that test is that it fails without the fix.

@cachebag Sorry for the late reply!

As per our current contribution guidelines, each commit is supposed to pass the test suite on its own, so we encourage adding a test upfront demonstrating the current behavior (even the current behavior is an error being thrown) upfront, and when the fix arrives, the test case is updated accordingly.

@cachebag
cachebag force-pushed the demo/self-update-race-after branch from f5c76de to 1aba886 Compare September 21, 2026 19:27
@rustbot

rustbot commented Sep 21, 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 and others added 10 commits September 21, 2026 15:52
A process parked at a checkpoint could only be killed by the test
driver. Poll for the checkpoint marker instead of sleeping, and add
`ParkedChild::resume()`, which removes the marker and waits for the
process to finish. This lets a test race another command against a
paused operation and then observe how the paused one completes.
Park the replacer right before it installs the new binaries and run a
proxy in the meantime. The proxy's startup cleanup deletes
`$CARGO_HOME/bin/rustup-init`, which is the replacer's own executable,
so the replacer unlinks the installed rustup and then fails to copy
itself over it, leaving no rustup behind (rust-lang#5076, rust-lang#1864).

The test asserts this current behaviour so that it passes on its own;
the fix later in this series updates it to assert that the replacement
succeeds.
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.

The proxy cleanup race test now asserts that the replacement succeeds
and the installed rustup is updated.
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 1aba886 to ecfbdd4 Compare September 21, 2026 19:52
@cachebag

cachebag commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

@rami3l understood, thank you for the feedback that makes a lot of sense. this is how i've decided to split it out then:

  • c26231c is now a separate harness-only commit.
  • df59670 adds the test upfront asserting the current behavior (replacer fails, rustup is gone).
  • a6f8d54 flips the test to assert the replacement succeeds.

i ran the race test at each of those commits locally to confirm it passes at every step.

no rush of course: but i've left some threads open with questions/comments on your review(s).

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