fix(lance-core): avoid aliasing &mut Runtime in global_cpu_runtime#7682
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
westonpace
left a comment
There was a problem hiding this comment.
Just some minor nits, and let's get rid of the test. The comment even explains that the old code would still have passed. We have basic happy path testing of spawn_cpu implicitly in plenty of other tests.
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| // Drives many `spawn_cpu` calls across real worker threads. Each task calls | ||
| // `global_cpu_runtime()` on its own thread, so before the fix (`&'static mut | ||
| // Runtime`) their transient `&mut` references to the one shared runtime could be | ||
| // live simultaneously — aliasing `&mut`, which violates `&mut` uniqueness. The | ||
| // fix returns a shared `&'static Runtime`, which many threads may hold at once. | ||
| // | ||
| // This is a functional/concurrency check, not a deterministic regression test: | ||
| // the aliasing was latent UB, so a plain `cargo test` passes on the old code too | ||
| // (the miscompilation it permits is not forced), and tokio's multi-thread runtime | ||
| // is not runnable under Miri. It guards against a future refactor breaking the | ||
| // concurrent path and documents the intended shared-reference contract. | ||
| #[tokio::test(flavor = "multi_thread", worker_threads = 4)] | ||
| async fn spawn_cpu_shared_runtime_is_concurrent_safe() { | ||
| let handles: Vec<_> = (0..64) | ||
| .map(|i| { | ||
| tokio::spawn(async move { | ||
| spawn_cpu(move || Ok::<_, std::convert::Infallible>(i * 2)) | ||
| .await | ||
| .unwrap() | ||
| }) | ||
| }) | ||
| .collect(); | ||
| let mut results = Vec::with_capacity(handles.len()); | ||
| for handle in handles { | ||
| results.push(handle.await.unwrap()); | ||
| } | ||
| results.sort_unstable(); | ||
| assert_eq!(results, (0..64).map(|i| i * 2).collect::<Vec<_>>()); | ||
| } | ||
| } |
There was a problem hiding this comment.
I don't think we need this test (I don't think it is actually testing anything is it?)
| // `Runtime` lives for the rest of the process — a `&'static` shared | ||
| // reference is valid. Multiple threads may hold this reference at once; | ||
| // that is sound because `Runtime` is `Sync` and every method used | ||
| // (`spawn_blocking`) takes `&self`. |
There was a problem hiding this comment.
| // `Runtime` lives for the rest of the process — a `&'static` shared | |
| // reference is valid. Multiple threads may hold this reference at once; | |
| // that is sound because `Runtime` is `Sync` and every method used | |
| // (`spawn_blocking`) takes `&self`. | |
| // `Runtime` lives for the rest of the process |
The first part of this comment is interesting (may not always be obvious to readers that static state is leaked on fork) but the second part seems to just be redefining the concept of a shared reference.
| // SAFETY: `new_ptr` was just obtained from `Box::into_raw`, so it is non-null, | ||
| // aligned, and points to a live `Runtime` that is never reclaimed. See the | ||
| // load branch above for why a shared `&'static` reference is the correct form. |
There was a problem hiding this comment.
| // SAFETY: `new_ptr` was just obtained from `Box::into_raw`, so it is non-null, | |
| // aligned, and points to a live `Runtime` that is never reclaimed. See the | |
| // load branch above for why a shared `&'static` reference is the correct form. | |
| // SAFETY: `new_ptr` was just obtained from `Box::into_raw`, so it is non-null, | |
| // aligned, and points to a live `Runtime` that is never reclaimed. |
global_cpu_runtime() returned &'static mut Runtime by dereferencing a process-global AtomicPtr<Runtime>. Its only caller, spawn_cpu, runs on many worker threads concurrently, so multiple &'static mut references to the same Runtime could be live at once — a violation of &mut uniqueness, which is undefined behavior on the abstract machine even though every Runtime method used takes &self and does not mutate. Return &'static Runtime and dereference with &*ptr instead. spawn_blocking takes &self, and Runtime is Sync, so many threads may share the reference soundly. The runtime is created via Box::into_raw and never reclaimed, so the &'static lifetime is genuine. Both unsafe derefs now carry SAFETY comments documenting these invariants.
b4179b3 to
4d2eef2
Compare
📝 WalkthroughWalkthroughThe function global_cpu_runtime() in rust/lance-core/src/utils/tokio.rs is changed to return a shared &'static Runtime instead of &'static mut Runtime. Both unsafe pointer dereferences within the function are updated accordingly, with added safety comments explaining pointer provenance and lifetime guarantees. ChangesRuntime accessor mutability change
Estimated code review effort: 2 (Simple) | ~10 minutes 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
|
Thanks for the review! @westonpace Addressed all three: dropped the test, and trimmed both SAFETY comments as suggested. Also rebased onto latest main. |
|
Thank you @westonpace |
What & why
lance-corekeeps a process-global CPU thread-pool runtime behindCPU_RUNTIME: AtomicPtr<Runtime>.global_cpu_runtime()dereferenced that raw pointer into&'static mut Runtime. Its only caller,spawn_cpu, runs concurrently on many worker threads, so multiple&'static mutreferences to the sameRuntimecould be live at once — a violation of&mutuniqueness, which is undefined behavior on the Rust abstract machine even though everyRuntimemethod actually used takes&selfand never mutates. It is latent UB the compiler is permitted to miscompile under noalias assumptions (Miri flags it), not an observed crash today.How & why it is correct
Return
&'static Runtimeand dereference with&*ptrinstead. Three facts make this sound:Runtime::spawn_blocking, which takes&self— the&mutwas never needed.Runtime: Sync, so many threads may hold&'static Runtimeconcurrently without UB, which is exactly the aliasing the old code got wrong.Box::into_rawand never reclaimed, so the&'staticlifetime is genuine (no use-after-free);atfork_tokio_childonly nulls the pointer, it does not free.Both
unsafederefs now carry// SAFETY:comments documenting these invariants.Compatibility
Fully backward compatible, zero behavior change.
global_cpu_runtimeis private, so the return-type change is internal-only;spawn_cpu's public signature is unchanged.&mut Runtimealready auto-reborrowed to&selfforspawn_blocking, so narrowing to&Runtimeis byte-for-byte identical at runtime — purely tightening a gratuitous, unsound&mutinto a shared&.Test plan
cargo test -p lance-corecargo clippy -p lance-core --tests -- -D warningscargo fmt --all -- --check