Skip to content

Cleanup observer system input: Trigger reborrowing - #25668

Open
ItsDoot wants to merge 4 commits into
bevyengine:mainfrom
ItsDoot:ecs/trigger-reborrow
Open

Cleanup observer system input: Trigger reborrowing#25668
ItsDoot wants to merge 4 commits into
bevyengine:mainfrom
ItsDoot:ecs/trigger-reborrow

Conversation

@ItsDoot

@ItsDoot ItsDoot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Objective

Simplify the safety story of On and Trigger, and reduce the number of lifetimes on On from two to one.

This is a cleanup pass in preparation to support observer target queries, On<'world, 'state, 'input, E: EventPattern, D: QueryData = (), F: QueryFilter = ()>.

Solution

Add and require support for Trigger reborrowing:

  • Removed the lifetime from Event::Trigger
    • That lets us add the E: Event<Trigger = Self> requirement directly to Trigger's trait, removing the unsafe requirement for it.
  • Added Trigger::State<'a>, which is the type that's passed to World::trigger and Commands::trigger
  • Added Trigger::View<'a>, which is the type passed into observer systems as a mutable view of the Trigger::State
    • This type currently only differs from State for PropagateEntityTrigger, in order to provide access to a propagate: &mut bool
  • Added Trigger::reborrow to convert from a &'a mut Trigger::State<'_> to a Trigger::View<'a> (owned)
  • Added EventTriggerState and EventTriggerView type aliases for ease of access (replacing EventPatternTrigger)

Testing

No new tests were added, suggestions welcome.

@ItsDoot
ItsDoot requested review from chescock and hymm September 3, 2026 08:00
@ItsDoot ItsDoot added A-ECS Entities, components, systems, and events C-Code-Quality A section of code that is hard to understand or change M-Migration-Guide A breaking change to Bevy's public API that needs to be noted in a migration guide D-Complex Quite challenging from either a design or technical perspective. Ask for help! X-Contentious There are nontrivial implications that should be thought through D-Unsafe Touches with unsafe code in some way S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels Sep 3, 2026
@github-project-automation github-project-automation Bot moved this to Needs SME Triage in ECS Sep 3, 2026
@ItsDoot
ItsDoot force-pushed the ecs/trigger-reborrow branch from 4664ee0 to 16018f0 Compare September 3, 2026 08:20
Comment thread crates/bevy_ecs/src/event/trigger.rs Outdated
pub unsafe trait Trigger<E: Event> {
/// For any `'long: 'short`, `State<'long>` must have the same layout as
/// `State<'short>` and must be valid to reinterpret as `State<'short>` for
/// the duration of the shorter borrow.

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 can't review the PR right now, but I want to note that reinterpreting State<'long> as State<'short> also means that it cannot expose a way to get &mut Something<'short> (which includes public fields of type Something<'short>

I cannot check what this PR does, but that was the original issue and reason why On/Trigger had this lifetime hack.

(Also sorry for commenting only on this line, Github on mobile is terrible)

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 can't review the PR right now, but I want to note that reinterpreting State<'long> as State<'short> also means that it cannot expose a way to get &mut Something<'short> (which includes public fields of type Something<'short>

I cannot check what this PR does, but that was the original issue and reason why On/Trigger had this lifetime hack.

I think the fn reborrow() method having the extra lifetime parameter in Self::State<'_> is what addresses this issue. Reviewing your comments at #20731 (comment), you said:

Here however we're casting it to a &mut E::Trigger<'w> for some arbitrary lifetime 'w. This is fine only as long as nothing about the lifetime 'w that 's different from 'a is ever exposed to safe code, effectively forcing the safe code to be universally quantifed over a set of lifetimes that must include the original 'a.

I think that extra lifetime parameter gives us that universal quantification. The clever part is that the parameter is on a trait method on Trigger now, so it doesn't leak into On!

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've been looking at the PR a bit more and I think it should be good (don't quote me on that yet!) but I think the motivation is not quite right.

I think what makes it safe is that On has only one lifetime thanks to reborrowing, and the trigger ref also has only one lifetime thanks to reborrowing.

I don't see a reason for requiring the State to be transmutable though. The reborrowing is done by reborrow, there seems to be no transmute going on.

// - The implementation abides by the other safety constraints defined in [`Trigger`]
unsafe impl<E: AnimationEvent + for<'a> Event<Trigger<'a> = AnimationEventTrigger>> Trigger<E>
// SAFETY: `AnimationEventTrigger` has no lifetimes.
unsafe impl<E: AnimationEvent + Event<Trigger = AnimationEventTrigger>> Trigger<E>

@cart cart Sep 4, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

While this does simplify the lifetimes in the top level API, it also complicates the "type story" for triggers generally. I don't fully understand the "trigger target queries" stuff. Do you have a proposal link and/or a practical example of the API you're aiming for?

Comment thread crates/bevy_ecs/src/event/trigger.rs Outdated
Comment on lines +28 to +30
/// For any `'long: 'short`, `State<'long>` must have the same layout as
/// `State<'short>` and must be valid to reinterpret as `State<'short>` for
/// the duration of the shorter borrow.

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 you get this for free from parametricity, and don't need this to be a safety requirement. So Trigger can be a safe trait!

The only place the lifetime in the transmuted State<'_> is used is to call fn reborrow(). That's generic in the lifetime, and lifetime parameters are erased at runtime, so there is no way for it it depend on the particular lifetime. That is, you can declare that you are transmuting to the original 'long lifetime, since fn reborrow() has no way to tell it apart from any other lifetime.

The trouble with the old code was that On<...> as SystemInput had type Param<'i> = On<'i, 'i, E>, which wound up forcing that lifetime ('t) to equal a bunch of others ('w). Those other lifetimes should have been shorter, so code that relied on them being equal could be unsound. But this PR only uses the lifetime once, as a parameter to reborrow, so external code can't make any incorrect assumptions about it.

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.

Unsafeness removed!

Comment thread crates/bevy_ecs/src/event/trigger.rs Outdated
T: Traversal<E>,
{
type State<'input> = PropagateEntityTrigger<AUTO_PROPAGATE, E, T>;
type View<'input> = PropagateEntityTriggerItem<'input, AUTO_PROPAGATE, E, T>;

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.

This could alternately have been &'input mut PropagateEntityTrigger<AUTO_PROPAGATE, E, T> instead of a new type, right? That would have been slightly less breaking, since wouldn't need for the extra * when using trigger.propagate... although breaking the ability to modify original_event_target is probably for the best :).

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.

Yep, replaced!

///
/// This trait can be derived.
pub trait AnimationEvent: Clone + for<'a> Event<Trigger<'a> = AnimationEventTrigger> {}
pub trait AnimationEvent: Clone + Event<Trigger = AnimationEventTrigger> {}

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 like all the places where we got to get rid of for<'a>!

Comment thread crates/bevy_ecs/src/observer/mod.rs Outdated
pub fn trigger<'a, E: Event<Trigger<'a>: Default>>(&mut self, mut event: E) {
pub fn trigger<E: Event>(&mut self, mut event: E)
where
for<'a> EventTriggerState<'a, E>: Default,

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 don't love the places where we had to add a for<'a>, though.

... or, wait, could this be

Suggested change
for<'a> EventTriggerState<'a, E>: Default,
EventTriggerState<'static, E>: Default,

here and elsewhere? The weaker constraint is still enough to call default(), and it avoids the for<'a>.

(I don't think it's even any less general than the old API. Event: 'static, so there aren't any lifetimes in E, which means any lifetime in an impl is either 'static or a free lifetime that can be substituted with it.)

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.

Yep that worked across the board pretty much!

Comment thread crates/bevy_ecs/src/observer/system_param.rs Outdated
Comment thread crates/bevy_ecs/src/event/trigger.rs Outdated
pub unsafe trait Trigger<E: Event> {
/// For any `'long: 'short`, `State<'long>` must have the same layout as
/// `State<'short>` and must be valid to reinterpret as `State<'short>` for
/// the duration of the shorter borrow.

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 can't review the PR right now, but I want to note that reinterpreting State<'long> as State<'short> also means that it cannot expose a way to get &mut Something<'short> (which includes public fields of type Something<'short>

I cannot check what this PR does, but that was the original issue and reason why On/Trigger had this lifetime hack.

I think the fn reborrow() method having the extra lifetime parameter in Self::State<'_> is what addresses this issue. Reviewing your comments at #20731 (comment), you said:

Here however we're casting it to a &mut E::Trigger<'w> for some arbitrary lifetime 'w. This is fine only as long as nothing about the lifetime 'w that 's different from 'a is ever exposed to safe code, effectively forcing the safe code to be universally quantifed over a set of lifetimes that must include the original 'a.

I think that extra lifetime parameter gives us that universal quantification. The clever part is that the parameter is on a trait method on Trigger now, so it doesn't leak into On!

@ItsDoot

ItsDoot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Updated based on feedback; I think it's much clearer now.

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-Code-Quality A section of code that is hard to understand or change D-Complex Quite challenging from either a design or technical perspective. Ask for help! D-Unsafe Touches with unsafe code in some way 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 X-Contentious There are nontrivial implications that should be thought through

Projects

Status: Needs SME Triage

Development

Successfully merging this pull request may close these issues.

4 participants