bevyengine / bevyengine/bevy

UB found in EntityWorldMut::replace_children in multi-threaded with pure safe code

Open
#25,349 4 comments 0 reactions 0 assignees View on GitHub
A-ECS C-Bug D-Unsafe I-Unsound S-Ready-For-Implementation X-Uncontroversial
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.