UB found in EntityWorldMut::replace_children in multi-threaded with pure safe code
- Dominant language
- Rust
- Stars
- 48.2k
- Forks
- 4.8k
- Avg merge
- 3d 16h
- Merged PRs (30d)
- 171
Description
## Bevy version and features
- Bevy commit: `e8b3598ff5e5ec40e8ba84edd5750a1c0e4d4e59`
- The issue requires the `multi_threaded` feature.
- I reproduced it with a standalone crate using the following local Bevy features:
```toml
[package]
name = "poc"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
bevy_app = { path = "../bevy/crates/bevy_app", default-features = false, features = ["std"] }
bevy_ecs = { path = "../bevy/crates/bevy_ecs", default-features = false, features = ["std"] }
bevy_tasks = { path = "../bevy/crates/bevy_tasks", default-features = false, features = ["async_executor", "multi_threaded"] }
bevy_transform = { path = "../bevy/crates/bevy_transform", default-features = false, features = ["std", "bevy-support", "bevy_reflect", "async_executor", "multi_threaded"] }
```
## \[Optional\] Relevant system information
If you cannot get Bevy to build or run on your machine, please include:
- the Rust version I'm using:
- cargo 1.97.1 (c980f4866 2026-06-30)
- rustc 1.97.1 (8bab26f4f 2026-07-14)
- OS: Windows 11 version `10.0.26200`
## What you did
I created a hierarchy where child has its own child, then used the safe replace_children API to replace an existing Children collection with 1024 copies of that same non-leaf child.
```rust
use bevy_app::App;
use bevy_ecs::prelude::*;
use bevy_tasks::{ComputeTaskPool, TaskPoolBuilder};
use bevy_transform::{
components::{GlobalTransform, Transform},
TransformPlugin,
};
const DUPLICATE_COUNT: usize = 1024;
fn main() {
// Use two workers so separate work batches can run concurrently.
ComputeTaskPool::get_or_init(|| TaskPoolBuilder::new().num_threads(2).build());
let mut app = App::new();
app.add_plugins(TransformPlugin);
let grandchild = app
.world_mut()
.spawn(Transform::from_xyz(0.0, 1.0, 0.0))
.id();
let child = app
.world_mut()
.spawn(Transform::from_xyz(1.0, 0.0, 0.0))
.id();
// Make `child` a non-leaf node.
app.world_mut().entity_mut(child).add_child(grandchild);
let root = app
.world_mut()
.spawn(Transform::from_xyz(10.0, 0.0, 0.0))
.id();
// This is required to enter replace_related's existing-collection branch.
app.world_mut().entity_mut(root).add_child(child);
// Safe API: creates 1024 duplicate Children entries.
app.world_mut()
.entity_mut(root)
.replace_children(&vec![child; DUPLICATE_COUNT]);
let children = app.world().get::(root).unwrap();
assert_eq!(children.len(), DUPLICATE_COUNT);
assert!(children.iter().all(|entity| entity == child));
app.update();
}
```
In my Windows PC, I run the above code by using the following command:
```bash
$env:MIRIFLAGS = "-Zmiri-preemption-rate=1 -Zmiri-seed=1"
cargo +nightly-x86_64-pc-windows-gnu miri run
```
## What went wrong
Miri reports UB:
```text
error: Undefined Behavior: Data race detected between (1) retag write on thread `main` and (2) retag write of type `bevy_transform::components::GlobalTransform` on thread `TaskPool (0)` at alloc427219
--> D:\projects\bevy\crates\bevy_ptr\src\lib.rs:1253:18
|
1253 | unsafe { &mut *self.get() }
| ^^^^^^^^^^^^^^^^ (2) just happened here
|
help: and (1) occurred earlier here
--> src\main.rs:48:5
|
48 | app.update();
| ^^^^^^^^^^^^
= help: retags occur on all (re)borrows and as well as when references are copied or moved
= help: retags permit optimizations that insert speculative reads or writes
= help: therefore from the perspective of data races, a retag has the same implications as a read or write
= help: this indicates a bug in the program: it performed an invalid operation, and caused Undefined Behavior
= help: see https://doc.rust-lang.org/nightly/reference/behavior-considered-undefined.html for further information
= note: this is on thread `TaskPool (0)`
note: the last function in that backtrace got called indirectly due to this code
--> src\main.rs:13:37
|
13 | ..._init(|| TaskPoolBuilder::new().num_threads(2).build());
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
error: aborting due to 1 previous error; 1 warning emitted
error: process didn't exit successfully: `C:\Users\14798\.rustup\toolchains\nightly-x86_64-pc-windows-gnu\bin\cargo-miri.exe runner target\miri\x86_64-pc-windows-gnu\debug\c15-miri-demo.exe` (exit code: 1)
```
I found that `replace_children` accepts duplicate entities when the parent already has a `Children` component.
The relevant path appears to be:
1. `EntityWorldMut::replace_children` calls `replace_related::`.
2. `replace_related` uses an `EntityHashSet` when updating `ChildOf`, but later copies the original input slice directly into `Children`:
In `EntityWorldMut::replace_related`:
```rust
collection.clear();
collection.extend_from_iter(related.iter().copied());
```
3. The multi-threaded transform system later treats Children as unique without validating it:
In system.rs `propagate_descendants_unchecked`:
```rust
UniqueEntitySlice::from_slice_unchecked(p_children)
```
4. Each duplicate non-leaf child is queued as a transform propagation task. The work queue batches tasks in groups of 512, so 1024 duplicates can be processed by separate workers.
5. Workers access the duplicate task entity using `nodes.get_unchecked(parent)`, obtaining mutable `GlobalTransform` access under the assumption that each task is a disjoint subtree.
## Additional information
The backtrace stack is so long that I think it is better not to paste here.
The issue is not limited to the direct `replace_children`. `replace_related_with_difference` documents that its input slices must not contain duplicates, but its duplicate validation is currently only enabled under **debug_assertions**. A release build can therefore potentially construct duplicate `Children` state through that path as well.
A possible fix would be to either:
1. deduplicate entities in safe APIs, preserving first-occurrence order; or
2. reject duplicates consistently in all build modes.
Contributor guide
Research direction
Start with EntityWorldMut::replace_children and replace_related, then inspect system.rs at propagate_descendants_unchecked and the duplicate handling in replace_related_with_difference. Run the supplied standalone reproduction under Miri with the multi_threaded feature. Done means duplicate relationship input is handled consistently and the reproduction no longer reports undefined behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- game-dev
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100