Skip to content

Conversation

chescock
Copy link
Contributor

Objective

Make it possible to call deconstruct_moving_ptr with safe code. It currently needs to be unsafe because you can pass the same field name multiple times and cause mutable aliasing.

Fix some other small issues:

  • Allow the trailing comma to be omitted
  • Prevent passing a &mut MovingPtr or &MovingPtr, which would still call move_field but would forget the reference instead of the MovingPtr
  • Tighten safety requirements on move_maybe_uninit_field, since mutable aliasing is still UB for MaybeUninit

Solution

Have the deconstruct_moving_ptr macro emit code that borrows all of the provided fields at once from a &mut T. If the same field is provided more than once, this will fail with an error of "cannot borrow value as mutable more than once at a time".

That code will also fail to compile on a repr(packed) struct, since we can't borrow fields from that at all. So, drop support for those. That means move_field on an aligned pointer can return an aligned pointer, removing the need for .try_into().debug_checked_unwrap().

…t the fields don't overlap.

Return aligned pointers from `move_field`.
`deconstruct_moving_ptr` no longer works with `repr(packed)` structs, so the field of an aligned type will always be aligned.
@chescock chescock added A-ECS Entities, components, systems, and events C-Usability A targeted quality-of-life change that makes Bevy easier to use D-Unsafe Touches with unsafe code in some way S-Needs-Review Needs reviewer attention (from anyone!) to move forward D-Macros Code that generates Rust code labels Sep 12, 2025
// SAFETY: This closure is never called
let value = unsafe { ptr.assume_init_mut() };
// If a field is mentioned twice, this will cause mutable aliasing and fail to compile.
let _ = ($(&mut value.$field_index),*);
Copy link
Member

Choose a reason for hiding this comment

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

I'd write these into $field_alias and call core::hint::black_box on them instead to ensure that the compiler doesn't try to get smart and shorten their lifetimes.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm hoping the compile-fail test will be enough to catch the compiler shortening the lifetimes. :)

I think making a tuple like this requires that they are all alive together. How would I use black_box there? Is your idea to make all the &muts be local variables, and then consume them one-by-one? Even ordinary drop() should work for that, right?

Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
let _ = ($(&mut value.$field_index),*);
$(let $field_alias = &mut value.$field_index;)*;
$(core::hint::black_box($field_alias);)*

Alternatively, yeah, drop should work as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Suggested change
let _ = ($(&mut value.$field_index),*);
$(let $field_alias = &mut value.$field_index;)*;
$(core::hint::black_box($field_alias);)*

Alternatively, yeah, drop should work as well.

The trouble is that we aren't guaranteed to have an identifier here anymore, so we can't bind any locals.

If we want to check that the field list is exhaustive, then we need an escape hatch for users that want to drop some fields, which means we want to support 0: _, and we don't have a spare identifier in that case.

What if we pass the tuple itself to drop? Like

Suggested change
let _ = ($(&mut value.$field_index),*);
core::mem::drop(($(&mut value.$field_index),*));

Now the compiler clearly needs that tuple to exist in order to pass it to drop, which means the borrows in its fields must all be alive concurrently.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

error: call to `std::mem::drop` with a value that does not implement `Drop`. Dropping such a type only extends its contained lifetimes

Thanks, Clippy. core::hint::black_box it is!

@alice-i-cecile alice-i-cecile requested a review from hymm September 13, 2025 01:23
@james7132 james7132 added this to the 0.17 milestone Sep 13, 2025
@james7132
Copy link
Member

I was not aware of how much of the Bundle/Spawn* trait APIs were used in the greater ecosystem. I think we need to land this in the 0.17-rc process. I'd rather those cases not need to make otherwise unnecessary unsafe blocks.

Fix several soundness holes.
Ensure that field lists are exhaustive.
@chescock
Copy link
Contributor Author

Alright, I think I plugged the safety holes, but there may have been some scope creep. I can't tell whether the safety is still worth all this complexity.

Once we were using destructuring, I thought we might as well check that the list of fields was exhaustive. But SpawnRelatedBundle had a marker: PhantomData, and just adding it to the list caused an unused variable warning. Since we needed to specify the type anyway, I reworked the input to look more like a pattern, so we could write let SpawnRelatedBundle { list, marker: _ } = effect.

I had some trouble with tuples because you can't use the StructName { fields } syntax to destructure them. And we can't use an actual tuple pattern because there's no way to get the field_index in a macro_rules macro without passing them in. So I made separate rules for those that did the safety checks differently.

I also found another hole around autoderef, where a MovingPtr<&mut A> would still pass the destructuring check because you can destructure &mut A as an A. It turns out you can check for that with A { ..value }! But, of course, that doesn't work for tuples so they need something different still.

// SAFETY: This closure is never called
let value = unsafe { ptr.assume_init_mut() };
// If a field is mentioned twice, this will cause mutable aliasing and fail to compile.
let _ = ($(&mut value.$field_index),*);
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
let _ = ($(&mut value.$field_index),*);
$(let $field_alias = &mut value.$field_index;)*;
$(core::hint::black_box($field_alias);)*

Alternatively, yeah, drop should work as well.

Comment on lines 1382 to 1404
({ let tuple { $($field_index:tt: $pattern:pat),* $(,)? } = $ptr:expr ;}) => {
// Specify the type to make sure the `mem::forget` doesn't forget a mere `&mut MovingPtr`
let mut ptr: $crate::MovingPtr<_, _> = $ptr;
let _ = || {
let value = &mut *ptr;
// Ensure that each field index exists and is mentioned only once
// Ensure that the struct is not `repr(packed)` and that we may take references to fields
let _ = ($(&mut value.$field_index),*);
// Ensure that `ptr` is a tuple and not something that derefs to it
// Ensure that the number of patterns matches the number of fields
fn unreachable<T>(_index: usize) -> T {
unreachable!()
}
*value = ($(unreachable($field_index)),*);
};
// SAFETY:
// - `f` does a raw pointer offset, which always returns a non-null pointer to a field inside `T`
// - The struct is not `repr(packed)`, since otherwise the block of code above would fail compilation
// - `mem::forget` is called on `self` immediately after these calls
// - Each field is distinct, since otherwise the block of code above would fail compilation
$(let $pattern = unsafe { ptr.move_field(|f| &raw mut (*f).$field_index) };)*
core::mem::forget(ptr);
};
Copy link
Contributor

Choose a reason for hiding this comment

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

This seems to still be vulnerable to the deref issue since there's nothing checking that MovingPtr points to a tuple nor that $field_index is actually a tuple index.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This seems to still be vulnerable to the deref issue since there's nothing checking that MovingPtr points to a tuple nor that $field_index is actually a tuple index.

The assignment should check that. It's a little hard to see through the macro syntax, but it's doing *value = (unreachable(0), unreachable(1));, which is only valid if value is a &mut (_, _) of the right arity.

It turns out that even destructuring wouldn't prevent it, since &mut T can be destructured just like a T, but assignment does require the type to match.

The compile-fail tests for MovingPtr<Box<(usize, usize)>> and MovingPtr<&mut (usize, usize)> should be enough to verify that the check works, right? Or is there another case I'm missing?

...Although I just noticed that I put the , as a separator instead of after every element, which won't quite work for 1-tuples. I'll fix that now, and add some 1-tuple tests.

Comment on lines 1405 to 1428
({ let MaybeUninit::<tuple> { $($field_index:tt: $pattern:pat),* $(,)? } = $ptr:expr ;}) => {
// Specify the type to make sure the `mem::forget` doesn't forget a mere `&mut MovingPtr`
let mut ptr: $crate::MovingPtr<core::mem::MaybeUninit<_>, _> = $ptr;
let _ = || {
// SAFETY: This closure is never called
let value = unsafe { ptr.assume_init_mut() };
// Ensure that each field index exists and is mentioned only once
// Ensure that the struct is not `repr(packed)` and that we may take references to fields
let _ = ($(&mut value.$field_index),*);
// Ensure that `ptr` is a tuple and not something that derefs to it
// Ensure that the number of patterns matches the number of fields
fn unreachable<T>(_index: usize) -> T {
unreachable!()
}
*value = ($(unreachable($field_index)),*);
};
// SAFETY:
// - `f` does a raw pointer offset, which always returns a non-null pointer to a field inside `T`
// - The struct is not `repr(packed)`, since otherwise the block of code above would fail compilation
// - `mem::forget` is called on `self` immediately after these calls
// - Each field is distinct, since otherwise the block of code above would fail compilation
$(let $pattern = unsafe { ptr.move_maybe_uninit_field(|f| &raw mut (*f).$field_index) };)*
core::mem::forget(ptr);
};
Copy link
Contributor

Choose a reason for hiding this comment

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

This too.

@alice-i-cecile alice-i-cecile added S-Ready-For-Final-Review This PR has been approved by the community. It's ready for a maintainer to consider merging it and removed S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels Sep 21, 2025
@alice-i-cecile alice-i-cecile added this pull request to the merge queue Sep 21, 2025
Merged via the queue into bevyengine:main with commit af32dd2 Sep 21, 2025
35 checks passed
mockersf added a commit that referenced this pull request Sep 21, 2025
# Objective

Make it possible to call `deconstruct_moving_ptr` with safe code. It
currently needs to be `unsafe` because you can pass the same field name
multiple times and cause mutable aliasing.

Fix some other small issues:
* Allow the trailing comma to be omitted
* Prevent passing a `&mut MovingPtr` or `&MovingPtr`, which would still
call `move_field` but would forget the reference instead of the
`MovingPtr`
* Tighten safety requirements on `move_maybe_uninit_field`, since
mutable aliasing is still UB for `MaybeUninit`

## Solution

Have the `deconstruct_moving_ptr` macro emit code that borrows all of
the provided fields at once from a `&mut T`. If the same field is
provided more than once, this will fail with an error of "cannot borrow
value as mutable more than once at a time".

That code will *also* fail to compile on a `repr(packed)` struct, since
we can't borrow fields from that at all. So, drop support for those.
That means `move_field` on an aligned pointer can return an aligned
pointer, removing the need for `.try_into().debug_checked_unwrap()`.

---------

Co-authored-by: François Mockers <[email protected]>
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-Usability A targeted quality-of-life change that makes Bevy easier to use D-Macros Code that generates Rust code D-Unsafe Touches with unsafe code in some way S-Ready-For-Final-Review This PR has been approved by the community. It's ready for a maintainer to consider merging it
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants