Skip to content

Headless app (WgpuSettings { backends: None }) panics on despawn of any render-synced entity: PendingSyncEntity does not exist #24927

Description

@rewin123

Bevy version and features

  • Bevy 0.19.0 (crates.io).
  • Default features (plus debug, only so the panic prints the resource's
    full type name — the bug reproduces with default features too).

[Optional] Relevant system information

  • cargo 1.96.0 (30a34c682 2026-05-25)
  • macOS 15.5 (Sequoia), Apple M4.
  • No GPU / adapter is involved: the app is configured with
    WgpuSettings { backends: None, .. }, so no wgpu adapter is requested and no
    render world is ever created. The crash is purely in the main world and is
    platform-independent.

What you did

Ran a minimal headless Bevy app: DefaultPlugins with no window, no winit
event loop, and no wgpu backend (backends: None), driven by
ScheduleRunnerPlugin. A startup system spawns a single Camera3d entity; an
update system despawns it a few frames later.

cargo run
src/main.rs (full, single file, no assets)
//! Repro: despawning a render-synced entity (e.g. `Camera3d`) in a headless
//! app (`WgpuSettings { backends: None }`) panics with
//! «Requested resource bevy_render::sync_world::PendingSyncEntity does not exist».
//!
//! Cause: `SyncComponentPlugin::<C>` registers an `on_remove` component hook
//! unconditionally, and that hook does `world.resource_mut::<PendingSyncEntity>()`.
//! The resource is only inserted by `SyncWorldPlugin`, which is only added when
//! the render world is created — and with `backends: None` it never is.
//!
//! Run: `cargo run`. Expected: the app panics at frame 3, right at the despawn.

use std::time::Duration;

use bevy::{
    app::ScheduleRunnerPlugin,
    prelude::*,
    render::{
        settings::{WgpuSettings, RenderCreation},
        RenderPlugin,
    },
    window::ExitCondition,
    winit::WinitPlugin,
};

fn main() {
    App::new()
        .add_plugins(
            DefaultPlugins
                // No window / no winit event loop: pure headless.
                .set(WindowPlugin {
                    primary_window: None,
                    primary_cursor_options: None,
                    exit_condition: ExitCondition::DontExit,
                    close_when_requested: false,
                })
                // The crux: request no wgpu backend, so no render world is
                // ever created.
                .set(RenderPlugin {
                    render_creation: RenderCreation::from(WgpuSettings {
                        backends: None,
                        ..default()
                    }),
                    ..default()
                })
                .disable::<WinitPlugin>(),
        )
        // Drive the schedule ourselves since there is no winit loop.
        .add_plugins(ScheduleRunnerPlugin::run_loop(Duration::ZERO))
        .add_systems(Startup, spawn_camera)
        .add_systems(Update, despawn_on_frame_3)
        .run();
}

fn spawn_camera(mut commands: Commands) {
    let entity = commands.spawn(Camera3d::default()).id();
    info!("spawned camera entity {entity}");
}

fn despawn_on_frame_3(
    mut commands: Commands,
    q: Query<Entity, With<Camera3d>>,
    mut frame: Local<u32>,
) {
    *frame += 1;
    info!("frame {}", *frame);
    if *frame == 3 {
        for entity in &q {
            info!("about to despawn camera {entity} ...");
            commands.entity(entity).despawn();
            info!("... despawn command queued (panic expected as it applies)");
        }
    }
}

Cargo.toml:

[dependencies]
bevy = { version = "0.19", features = ["debug"] }

What went wrong

Expected: one of two consistent behaviors:

  1. Fail fast at App build time. Arguably RenderPlugin with
    backends: None while render-consuming plugins (camera/mesh/light) are
    present is a configuration the engine does not want to support — then
    building the App should panic/error right away with a clear message,
    not succeed and blow up minutes later.
  2. Degrade gracefully. The render world is never created, so the sync
    machinery has nothing to do: despawning cameras, meshes, lights, etc.
    should simply work.

Either is fine; what should not happen is the current middle ground — the app
builds and runs normally, then panics at an arbitrary later point, the first
time any render-synced entity is despawned.

Actual: the app panics the moment the despawn command is applied:

Why this scenario matters. Headless is usefull for CI testing of game, which far faster without render. And wgpu setting without backend looks like normal way to run headless (if there are no panic in RenderPlugin creation)

thread 'main' panicked at
.../bevy_render-0.19.0/src/sync_component.rs:55:41:
Requested resource bevy_render::sync_world::PendingSyncEntity does not exist in the `World`.
                Did you forget to add it using `app.insert_resource` / `app.init_resource`?
                Resources are also implicitly added via `app.add_message`,
                and can be added by plugins.
Encountered a panic when applying buffers for system `bevy_headless_sync_repro::despawn_on_frame_3`!

(Without the debug feature the message is the same panic at the same location,
just with <Enable the debug feature to see the name> instead of the resolved
PendingSyncEntity type name.)

Additional information

Root cause. SyncComponentPlugin::<C>::build() registers an on_remove
component hook unconditionally, and that hook unconditionally reads the
PendingSyncEntity resource:

bevy_render-0.19.0/src/sync_component.rs (build):

impl<C: SyncComponent<F>, F: Send + Sync + 'static> Plugin for SyncComponentPlugin<C, F> {
    fn build(&self, app: &mut App) {
        app.register_required_components::<C, SyncToRenderWorld>();

        app.world_mut()
            .register_component_hooks::<C>()
            .on_remove(|mut world, context| {
                let mut pending = world.resource_mut::<PendingSyncEntity>(); // <-- panics
                pending.push(EntityRecord::ComponentRemoved(
                    context.entity,
                    |mut entity| { entity.remove::<C::Target>(); },
                ));
            });
    }
}

But PendingSyncEntity is only inserted by SyncWorldPlugin
(bevy_render-0.19.0/src/sync_world.rs: app.init_resource::<PendingSyncEntity>()),
and SyncWorldPlugin is only added through ExtractPlugin, which
RenderPlugin::build() adds only when a rendering backend is available
(bevy_render-0.19.0/src/lib.rs):

if insert_future_resources(&self.render_creation, app.world_mut()) {
    // We only create the render world and set up extraction if we
    // have a rendering backend available.
    app.add_plugins(ExtractPlugin { .. });
};

app.add_plugins((
    WindowRenderPlugin,
    CameraPlugin,   // <-- adds SyncComponentPlugin::<Camera> → registers the on_remove hook
    ...
));

So with backends: None, insert_future_resources returns false, no render
world / SyncWorldPlugin / PendingSyncEntity — yet CameraPlugin (and the
mesh/light plugins) still add SyncComponentPlugin, which still registers the
on_remove hook. Despawning any entity carrying a synced component
(Camera3d, Mesh3d, DirectionalLight/PointLight/SpotLight, …) fires the
hook, which reads a resource that does not exist → panic.

This means any backends: None headless app crashes on the first despawn of a
camera / mesh / light
. (For the record, Bevy's own CI is not affected — it
runs examples against real software GPUs, mesa/dx12 — but backends: None is a
documented WgpuSettings option used by downstream headless simulation/test
setups.) In a real app it surfaces easily, e.g. via DespawnOnExit tearing
down a scene that contains a camera.

Suggested fix. The hook has nothing to do when there is no render world, so
it should degrade gracefully instead of asserting the resource's presence — use
get_resource_mut with an early return:

.on_remove(|mut world, context| {
    // No render world → nothing to sync.
    let Some(mut pending) = world.get_resource_mut::<PendingSyncEntity>() else {
        return;
    };
    pending.push(EntityRecord::ComponentRemoved(
        context.entity,
        |mut entity| { entity.remove::<C::Target>(); },
    ));
});

(Alternatively, only register the hook when the render world / SyncWorldPlugin
is present.)

Workaround. Patch bevy_render locally ([patch.crates-io]) to make the
on_remove hook use get_resource_mut with an early return as above; this fully
avoids the crash with no behavioral change for apps that do have a render world.

Metadata

Metadata

Assignees

No one assigned

    Labels

    C-BugAn unexpected or incorrect behaviorS-Needs-TriageThis issue needs to be labelled

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions