Rust 2024 migration: cargo fix keeps your deadlock alive
By Nihar Ranjan Das · Thu Oct 15 2026 · 9 min read · 0 views
View as a Web StorySoftware#rust#rust 2024#edition migration#cargo fix#deadlock#drop order

Rust 2024 migration: cargo fix keeps your deadlock alive
What does cargo fix --edition actually change?
It makes your code compile on edition 2024 and keeps the old behaviour, so it fixes errors but not bugs. We ran it on Rust 1.99.0 against a small edition 2021 crate with six risky patterns. It rewrote five of them and warned about the sixth. It never touched the edition line.
Rust edition is a named bundle of language rules that you opt into per crate with the edition field in Cargo.toml. Edition 2024 arrived with Rust 1.85. Crates on different editions can depend on each other.
Our test setup was a single main.rs of about 50 lines, built with cargo +1.99.0 on an Apple Silicon Mac. Here is what cargo fix --edition --allow-dirty did to it:
| Pattern in edition 2021 | What cargo fix did |
|---|---|
if let Some(x) = *lock.read().unwrap() { } else { } |
Rewrote to match, same behaviour as 2021 |
if let Some(_) = Noisy("scrutinee").get() { } else { } |
Rewrote to match |
extern "C" { fn abs(x: i32) -> i32; } |
Changed to unsafe extern "C" |
#[no_mangle] |
Changed to #[unsafe(no_mangle)] |
std::env::set_var("FOO", "bar"); |
Wrapped in unsafe { } with a FIXME comment |
| A value with a destructor in a tail expression | Warned only: "relative drop order changing in Rust 2024" |

The tool then stopped. The file still said edition = "2021". The migration is two steps, and cargo fix does only the first. After it finishes, you edit Cargo.toml yourself and rebuild.
Why does the rewritten code still deadlock?
Because the match that cargo fix wrote keeps the old lifetime of the lock guard. Edition 2024 shortens that lifetime for if let, and the tool undoes the shortening to stay safe. The deadlock survives the migration unless you revert the rewrite.
Here is the code that hangs on edition 2021. A deadlock is a state where a thread waits forever for a lock that it already holds:
use std::sync::RwLock;
fn if_let_case(value: &RwLock<Option<bool>>) {
if let Some(x) = *value.read().unwrap() {
println!("value is {x}");
} else {
let mut v = value.write().unwrap();
*v = Some(true);
}
}
In edition 2021, the read lock from value.read().unwrap() lives until the end of the whole if let ... else statement. The else branch then asks for the write lock while the read lock is still held. We ran it with a 5-second timeout, and it exited with code 124, which means it hung.
Cargo fix rewrote the function like this:
match *value.read().unwrap() {
Some(x) => {
println!("value is {x}");
}
_ => {
let mut v = value.write().unwrap();
*v = Some(true);
}
}
We bumped the edition to 2024 and ran the rewritten file. It hung again, with the same exit code 124. A match scrutinee keeps its temporaries until the end of the statement in every edition, so the write lock request still waits on the read lock. The Edition Guide on if let scope says this directly: review each rewrite, and revert it if the 2024 behaviour is what you want.
Advertisement
We then restored the original if let and ran it on edition 2024. It finished with exit code 0. The read lock now drops before the else branch runs, so the write lock succeeds.
How do you find the rewrites to revert?
Search the diff for match blocks that cargo fix added. Each one has the shape match <expr> { Some(x) => { ... } _ => { ... } } with an unusual one-line layout. Run git diff after cargo fix --edition, then ask one question per hunk: does the else branch need the temporary to stay alive? If not, put the if let back.
What changes in drop order in Rust 2024?
Two things drop earlier. A temporary in the tail expression of a block now drops before the block's local variables, and a temporary in an if let condition now drops before the else branch. Neither change shows up as an error. Only the order of side effects moves.
We measured both with a type whose destructor prints a line. A destructor is the Drop code that Rust runs when a value goes out of scope. This is the order we saw, on the same code in each edition:
| Case | Edition 2021 | Edition 2024 |
|---|---|---|
| Tail expression: local and temporary | drop local, then drop tail temporary |
drop tail temporary, then drop local |
if let with an else branch |
else branch, then drop scrutinee |
drop scrutinee, then else branch |

The tail-expression change is the quiet one. Cargo fix cannot preserve the old order, so it only warns. The Edition Guide on tail expressions explains the reason: no rewrite keeps the same meaning in every case. You must read each warning and decide.
Look for code where a destructor does real work. Examples include a guard that releases a lock, a logger that flushes a buffer, or a handle that commits a transaction. If the order of those side effects matters, a different order can change what is logged or released first.
A tail-expression error that disappears
One tail-expression case goes from an error to working code. This function fails on edition 2021 and compiles on edition 2024:
use std::cell::RefCell;
fn tail_len() -> usize {
let c = RefCell::new(String::from("abc"));
c.borrow().len()
}
On 2021, the compiler reports error[E0597]: c does not live long enough, because the borrow temporary outlived c. On 2024, the temporary drops first, and the function returns 3. If you worked around this in 2021 by writing let x = c.borrow().len(); x, you can delete the workaround.
Which hard errors does edition 2024 add?
Three kinds of code stop compiling until you add unsafe. The first two are mechanical, and cargo fix handles them. The third needs your attention.
We ran the unfixed 2021 file on edition 2024 and got these three errors:
error: extern blocks must be unsafeerror: unsafe attribute used without unsafeerror[E0133]: call to unsafe function set_var is unsafe and requires unsafe block
The set_var case matters most. Cargo fix wrapped the call and left a comment: // FIXME: Audit that the environment access only happens in single-threaded code. Do not delete that comment without checking. Changing the process environment while another thread reads it is undefined behaviour on some platforms. The wrapper tells the compiler that you accept the risk, and you must verify it is true.
What else does edition 2024 change?
Three more changes show up in the same migration, and cargo fix handles all of them. We tested each on Rust 1.99.0 with a tiny crate that compiles on edition 2021. They differ from the if let case in one useful way: the fix keeps the meaning of your code, so you can accept it without a long review.
| Change | Edition 2021 | Edition 2024 | What cargo fix did |
|---|---|---|---|
gen becomes a reserved keyword |
let gen = 5; compiles |
error: expected identifier, found reserved keyword gen |
Renamed to r#gen everywhere |
Raw pointer use inside an unsafe fn |
Compiles silently | warning[E0133]: dereference of raw pointer is unsafe and requires unsafe block |
Wrapped the body in unsafe { } |
impl Trait in a return type captures more lifetimes |
Compiles | error[E0505]: cannot move out of v because it is borrowed |
Added + use<> to the return type |
The third row is the one that surprises people. In edition 2021, a function that returns impl Iterator<Item = u8> hides the borrow of its argument. In edition 2024, the returned value is assumed to borrow every lifetime in scope, so the caller cannot drop the original vector while the iterator lives. Our test failed with the note "this call may capture more lifetimes than intended, because Rust 2024 has adjusted the impl Trait lifetime capture rules".
The + use<> suffix that cargo fix adds means "capture no lifetimes", which restores the 2021 behaviour. Keep it if the function really does not borrow its input. Remove it, and add the lifetime you need, if it does. Only the author of the function knows which is true, so read each one.
How do you migrate step by step?
Migrate one crate at a time, with a clean git tree, and review the diff before you change the edition. This order worked in our test, and it keeps each step small enough to review:
- Commit everything, so the migration is one reviewable diff.
- Run
cargo fix --edition --allow-dirty. The flag lets Cargo edit a tree that has uncommitted work, so skip it if your tree is clean. - Read
git diff. Revert anymatchthat was anif letwhere you want the 2024 lifetime. - Read every "relative drop order" warning and decide per case.
- Change
edition = "2021"toedition = "2024"inCargo.toml. - Run
cargo buildand fix the remaining errors, such as theunsafeones. - Run your tests, with extra attention to anything that uses locks or destructors.
Can you check without migrating?
Yes. Two lints show the same problems on edition 2021. Add this line to the top of your crate root, and build:
#![warn(if_let_rescope, tail_expr_drop_order)]
On our test crate, the build printed 0 warnings without the line. With it, we got two: "if let assigns a shorter lifetime since Edition 2024" and "relative drop order changing in Rust 2024". That is a cheap way to size the job before you commit to it. Run it on a large codebase on a Friday, and plan the migration on Monday.
Should you migrate to edition 2024 now?
Migrate now if you maintain a workspace, and wait if you ship a library for old compilers. Rust 1.99.0 added a reason: a workspace member can switch off an inherited dependency's default features only on edition 2024, and on edition 2021 Cargo ignores the setting with a warning. If you pin dependencies in a [workspace.dependencies] table, that is the strongest incentive so far.
The cost is the review time, not the compiler time. A crate with no locks and no meaningful destructors is a five-minute job. A crate with many guards deserves a careful read of each diff hunk, because a rewrite that looks harmless can keep a bug. Our test crate's rewritten match is the example: it compiled, it passed the tool, and it still hung.
The other changes that arrived with Rust 1.99.0, including the new CI default, are in our guide to Rust 1.99.0 breaking changes. If you also build on old 32-bit machines, read about the Rust 32-bit Windows host change before you pin a toolchain.
Advertisement
FAQ
Does cargo fix --edition change my Cargo.toml edition?
No. In our test on Rust 1.99.0, it rewrote the code and left `edition = "2021"` in place. You must change the field to `"2024"` yourself after you review the diff, and then rebuild to catch the remaining errors.
Why does my Rust code deadlock after migrating to edition 2024?
Probably because `cargo fix` rewrote an `if let` into a `match`, and a `match` keeps its temporaries, such as a lock guard, until the end of the statement. Revert the `match` to `if let` if you want the guard dropped before the `else` branch.
What is the if_let_rescope lint?
It is a lint in the `rust-2024-compatibility` group that flags `if let` statements whose temporaries would drop earlier in edition 2024. It is allow-by-default. Add `#![warn(if_let_rescope)]` to your crate root to see the places that change before you migrate.
What changed in drop order in Rust 2024?
Temporaries in a block's tail expression now drop before the block's local variables, and temporaries in an `if let` condition drop before the `else` branch. Both changes only reorder destructor side effects. Cargo fix warns about the tail case and cannot rewrite it.
Do I need unsafe for set_var in Rust 2024?
Yes. `std::env::set_var` is an unsafe function in edition 2024, and calling it without an `unsafe` block is error E0133. Cargo fix adds the block and a `FIXME` comment. Audit that no other thread reads the environment at the same time.
Comments
Loading…
Sign in to join the conversation.
Related posts

Rust 1.99 breaks one thing and quietly changes your CI
Rust 1.99.0 breaking changes, tested against 1.98.1: one new hard error, three new warnings, and a CI default that cuts the cache by 62%.
Thu Oct 15 2026 · 9 min read · 0 views

Rust is dropping 32-bit Windows hosts. Your i686 builds can stay
Rust 1.100 stops installing toolchains on 32-bit Windows, but i686 binaries keep building. Check if you are affected and cross-compile in three ways.
Thu Oct 15 2026 · 9 min read · 0 views

Does your GitHub Actions cache leak secrets? A Miri audit
Rust's Miri saved CI environment variables into target/, which GitHub Actions caches. Run this four-question audit and a cache scanner to check your repo.
Thu Oct 15 2026 · 8 min read · 0 views