Skip to content

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

Description

@yilin0518

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:
[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.

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::<Children>(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:

$env:MIRIFLAGS = "-Zmiri-preemption-rate=1 -Zmiri-seed=1"
cargo +nightly-x86_64-pc-windows-gnu miri run

What went wrong

Miri reports UB:

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::<ChildOf>.

  2. replace_related uses an EntityHashSet when updating ChildOf, but later copies the original input slice directly into Children:

In EntityWorldMut::replace_related:

collection.clear();
collection.extend_from_iter(related.iter().copied());
  1. The multi-threaded transform system later treats Children as unique without validating it:

In system.rs propagate_descendants_unchecked:

UniqueEntitySlice::from_slice_unchecked(p_children)
  1. 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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    A-ECSEntities, components, systems, and eventsC-BugAn unexpected or incorrect behaviorD-UnsafeTouches with unsafe code in some wayP-UnsoundA bug that results in undefined compiler behaviorS-Ready-For-ImplementationThis issue is ready for an implementation PR. Go for it!X-UncontroversialThis work is generally agreed upon

    Type

    No type

    Projects

    Status
    Needs SME Triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions