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

This comment has been minimized.

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

@djc

djc commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

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

I don't really disagree with anything @rami3l wrote there, but I think in the execution "logically independent commits" can be up to interpretation. Here are some guidelines that I like to keep in mind: specifically when you're mainly adding code, until you've actually achieved a desirable outcome the intermediate outcomes (that don't achieve a desirable outcome yet) aren't as valuable, because the state of the code really only makes sense when you achieve the desirable outcome. For adding code, splitting up the additions into multiple code often doesn't really make it more reviewable (which is very different from when you're adding code and changing existing code in the same commit).

So in that light do you think the current commit history could be simplified somewhat? @rami3l thoughts?

@rustbot

This comment has been minimized.

@rami3l

rami3l commented Sep 22, 2026

Copy link
Copy Markdown
Member

@djc I agree with you that if one single change is adding a large chunk of stuff then there is generally no use in splitting it into multiple commits.

However, my previous point was just that since the previous version of that change (fix(self-update): publish the rustup binary atomically) when I was making the commit was proposing alternatives to quite a few existing APIs in a non-mechanical way, I needed to do a lot of back-and-forth jumps to make sure that the code stayed equivalent before and after the change.

@cachebag The new split looks pretty good to me, so I'd consider that concern to have been addressed. Nice work on that matter!

Frankly speaking, I think the current state of this PR looks pretty polished already (probably modulo some minor concerns). I'd love to do another round once you have done with the rebase and I'm looking forward to merging it soon :)

@cachebag
cachebag force-pushed the demo/self-update-race-after branch from ecfbdd4 to 0d0a8de Compare September 22, 2026 11:11
@rustbot

This comment has been minimized.

@rustbot

This comment has been minimized.

cachebag and others added 5 commits September 23, 2026 09:11
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.
@cachebag
cachebag force-pushed the demo/self-update-race-after branch from 0d0a8de to adafa09 Compare September 23, 2026 13:29
@rustbot

rustbot commented Sep 23, 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.

…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 adafa09 to 83ae15f Compare September 23, 2026 13:34

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

LGTM (modulo the existing threads and some new nits), thanks a whole lot!

View changes since this review

Comment on lines +1258 to +1277
fn wait_for_completed_update(rustup_home: &Path) {
let stage = rustup_home.join("self-update");
retry(Fibonacci::from_millis(1).map(jitter).take(23), || {
if stage.join("complete").is_file() {
Ok(())
} else if stage.join("failed").is_file() {
Err("self-update failed")
} else {
Err("self-update has not completed")
}
})
.unwrap();
}

fn managed_updater(rustup_home: &Path) -> PathBuf {
rustup_home
.join("self-update")
.join(format!("rustup-init{EXE_SUFFIX}"))
}

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: mod self_update is public so I guess you can reuse some constants/helpers from over there.

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),
);
}
}

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.

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.

Please correct me if I have misread anything, I am just trying to understand the full story. In the following snippet that you have written:

    let result = process.cargo_home().and_then(...);
    stage::mark_result(result.is_ok(), process);
    result?;

If the process crashes in let result = ..., then the cleanup never happens, because the proxy still thinks that the updater is queued to run, so not deleting the replacer in this case is (at this point) still a bug, but this bug is mitigated by cleaning it up when it becomes stale in a later commit, because a self update can't possibly take that long. Is that correct?

Comment on lines +671 to +676
.and_then(|cargo_home| {
self_update_lock.install_bins(&cargo_home.join("bin"), super::force_hard_links(process))
})
.and_then(|()| {
update_uninstall_registry_display_version(env!("CARGO_PKG_VERSION"), 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: Maybe we can do both steps in one callback?

This branch has not been deployed

No deployments
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