Skip to content

Automatic cleanup of one-shot systems#24114

Open
ItsDoot wants to merge 5 commits intobevyengine:mainfrom
ItsDoot:ecs/oneshothandle
Open

Automatic cleanup of one-shot systems#24114
ItsDoot wants to merge 5 commits intobevyengine:mainfrom
ItsDoot:ecs/oneshothandle

Conversation

@ItsDoot
Copy link
Copy Markdown
Contributor

@ItsDoot ItsDoot commented May 4, 2026

Objective

#24087 introduces scene templating for SystemIds, however it can result in a memory leak if a scene is re-constructed multiple times:

#24087 (comment)

This was proposed basically 1:1 in #24026 (this was later changed though). The issue is that it's unclear who owns these systems, that is who is responsible for unregistering them once they are no longer needed. Given that recreating the template will spawn the system again this basically becomes a memory leak.

#24087 (comment)

Hm, are you sure this is the case even tho in build_template it only registers the system the first time its called, switching over to storing the SystemId after the first call?

If you recreate the template (e.g. you call my_scene() again) then you will create a new instance of the system. And since the system is not scoped to the scene once the scene is despawned the system entity will be leaked.

Essentially, we need a way to connect the lifetime of the registered system to the lifetime of the scene.

Solution

Introducing: SystemHandles.

pub enum SystemHandle<I: SystemInput = (), O = ()> {
    /// A strong handle keeps the system entity alive as long as the handle
    /// (and any clones of it) exist.
    Strong(Arc<StrongSystemHandle>),
    /// A weak handle does not keep the system entity alive. If a weak handle is
    /// returned by a registration function, the system entity must be
    /// manually despawned.
    Weak(SystemId<I, O>),
}

pub struct StrongSystemHandle {
    entity: Entity,
    #[cfg(feature = "std")]
    drop_sender: crossbeam_channel::Sender<Entity>,
}

Similar to bevy_asset::Handles,SystemHandle's custom Drop implementation enqueues the registered system entity into a message channel. The system despawn_unused_registered_systems pulls from the other end of this message channel and despawns the registered system entities.

World::register_system family of functions return strong handles, while Commands::register_system returns weak handles (due to the limitation of world access).

Testing

  • Added a test to ensure that despawn_unused_registered_systems does its job
  • Added a test to ensure that the default app will automatically call despawn_unused_registered_systems

Future work

  • SystemId scene templating #24087 will use this PR as a base
  • Might be worth adding a NoCleanup marker component that's attachable to registered system entities, and filter out entities with this component inside despawn_unused_registered_systems
  • Probably worth adding a way for no-std folks to get automatic cleanup as well, since currently its gated on std.

@ItsDoot ItsDoot added A-ECS Entities, components, systems, and events C-Performance A change motivated by improving speed, memory usage or compile times D-Modest A "normal" level of difficulty; suitable for simple features or challenging fixes S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels May 4, 2026
@github-project-automation github-project-automation Bot moved this to Needs SME Triage in ECS May 4, 2026
@ItsDoot ItsDoot added the M-Migration-Guide A breaking change to Bevy's public API that needs to be noted in a migration guide label May 4, 2026
@ItsDoot ItsDoot added S-Waiting-on-Author The author needs to make changes or address concerns before this can be merged and removed S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels May 4, 2026
@ItsDoot ItsDoot added S-Needs-Review Needs reviewer attention (from anyone!) to move forward and removed S-Waiting-on-Author The author needs to make changes or address concerns before this can be merged labels May 4, 2026
@alice-i-cecile
Copy link
Copy Markdown
Member

Strongly in favor of this core idea!

/// The cached `SystemId` as an `Entity`.
pub entity: Entity,
pub struct CachedSystemHandle<S> {
strong: Arc<StrongSystemHandle>,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why use this for cached systems? The Arcs are extra overhead, and most use cases of cached systems will never want to despawn them.

IntoSystem::into_system(system),
)))
.id();
SystemHandle::Weak(SystemId::from_entity(entity))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

World::register_system and Commands::register_system returning the same type but having different behavior will be very surprising! Users might get used to the cleanup from the World version, then use Commands somewhere and introduce a leak.

I also expect we'll have cases that won't need automatic cleanup, and it would be nice to avoid the overhead of Arcs in those cases.

Could we expose separate methods on World to register automatically-cleaned and manually-cleaned systems, with different names and returning different types? Then the method on Commands could correspond directly to a method on World.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think switching Commands::register_system back to returning SystemId instead of SystemHandle would be the simplest fix here; users will still be able to wrap SystemIds into SystemHandles themselves, but do so explicitly. Thoughts?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think switching Commands::register_system back to returning SystemId instead of SystemHandle would be the simplest fix here; users will still be able to wrap SystemIds into SystemHandles themselves, but do so explicitly. Thoughts?

That might help, although those are both impl Into<SystemId> and could be passed directly to run_system without changes.

I do also think we'll want to expose a method on World that returns a manually-cleaned system, rather than having to tell users that world.commands().register_system(s); world.flush(); is the simplest way to do it.

So I would still vote for giving them different names, even if we don't offer the manually-cleaned version on World. But I also have no authority here and am happy to be overridden :).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, and I hope I don't sound negative on the overall idea. Automatic cleanup seems like a powerful pattern! I just think it should be decoupled from register_system a bit, since register_system is useful without automatic cleanup. (And automatic cleanup will be useful for other kinds of entities!)

pub enum SystemHandle<I: SystemInput = (), O = ()> {
/// A strong handle keeps the system entity alive as long as the handle
/// (and any clones of it) exist.
Strong(Arc<StrongSystemHandle>),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see how we got here, but looking at the whole thing together: Why even store the system as an entity in the World? As long as we're allocating Arcs, why not just use Arc<Mutex<dyn System>>? That gets cleaned up automatically without needing a channel or anything.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there's some apprehension to requiring locking, as noted in #24072. That PR works as-is however in case we want to explore that option instead.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there's some apprehension to requiring locking, as noted in #24072. That PR works as-is however in case we want to explore that option instead.

Oh, I just meant that client code could use Arc themselves instead of using registered systems. Once you have an Arc that you can share, there's no need to store anything in the World!

... Hmm, I bet we could get rid of the locking there if we needed. The reason we don't have to lock today is that the system is owned by the World, so the &mut World gives us access to the system. But we could simulate that with a SyncUnsafeCell that has a safety requirement that it only be accessed by code with &mut World for the right world! We'd still need synchronization to set the WorldId, but that would only be on the first run.

Something like:

struct SystemArcInner<S: ?Sized> {
    world_id: OnceLock<WorldId>,
    running: SyncUnsafeCell<bool>,
    system: SyncUnsafeCell<S>,
}

impl<S: System> SystemArcInner {
    fn run(&self, world: &mut World) {
        // This takes locks the first time it's run, but is an ordinary load afterwards
        let world_id = self.world_id.get_or_init(|| {
            // This is safe because `Once` ensures only one thread runs an initializer
            unsafe { &mut *self.system.get() }.initialize(world);
            world.id()
        });
        assert_eq!(world.id(), world_id);

        // Claim the `running` lock
        // This is safe because we only access `running` when holding `&mut World` for *this* `World`
        let running = unsafe { &mut *self.running.get() };
        assert!(!*running);
        *running = true;

        // This is safe because we have the `running` lock
        let system = unsafe { &mut *self.system.get() };
        system.run(world);

        // Release the `running` lock
        // Note that this needs a new reference to `running` in case of reentrancy!
        // TODO: This should be in a `Drop` impl so it runs on unwind
        unsafe { *self.running.get() = false };
    }
}

@cart cart closed this May 5, 2026
@github-project-automation github-project-automation Bot moved this from Needs SME Triage to Done in ECS May 5, 2026
@cart cart reopened this May 5, 2026
@github-project-automation github-project-automation Bot moved this from Done to Needs SME Triage in ECS May 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-ECS Entities, components, systems, and events C-Performance A change motivated by improving speed, memory usage or compile times D-Modest A "normal" level of difficulty; suitable for simple features or challenging fixes M-Migration-Guide A breaking change to Bevy's public API that needs to be noted in a migration guide S-Needs-Review Needs reviewer attention (from anyone!) to move forward

Projects

Status: Needs SME Triage

Development

Successfully merging this pull request may close these issues.

4 participants