What is the real reason of the UB in move_pinned_ref?

The code is an example of Pin, it is clearly documented to be unsound, which I don't understand.

Well, my previous posts about UB always lead to because It is volatile the API contract or it is documented as UB, then the code is unsound.

This time, I admit it is unsound first, The question is what makes the API contract to be what we see today.

To my understanding, The API contract is a result, there must be something difficult for the compiler to make the code safe, only with this the protection of the contact, can codes always be sound.

My question is what are the difficult things, for this particular example.

use std::mem;
use std::pin::Pin;

fn move_pinned_ref<T>(mut a: T, mut b: T) {
    unsafe {
        let p: Pin<&mut T> = Pin::new_unchecked(&mut a);
        // This should mean the pointee `a` can never move again.
    }
    mem::swap(&mut a, &mut b); // Potential UB down the road ⚠️
    // The address of `a` changed to `b`'s stack slot, so `a` got moved even
    // though we have previously pinned it! We have violated the pinning API contract.
}

There's nothing about "the compiler" in this example.

By pinning, you promise to everyone, including arbitrary 3rd-party (safe or unsafe) code, that you won't move the value, but then you move it anyway. There's nothing "the compiler" can (or cannot) do about it. It's literally like saying "I promise I won't do X" and then doing exactly X.

For example, if you have a self-referential type that (tautologically) contains a reference to itself, then moving it will cause the self-reference to be invalidated: it will either point to some other, unexpected value, or to an uninitialized value. The latter is instant UB, and the former is bad enough too (it may also be UB in itself, depending on how strictly you interpret the memory model).

1 Like

There's no UB in this code directly (i.e. it does not violate any validity invariants), but Pin's raison d'ĂŞtre is to allow other unsafe code to rely on its contractual guarantees. By violating that contract, you've opened the door for that other code to do weird things via UB.

Rust is designed from the ground up for values to be relocatable, so that copying a value from one place in memory to another doesn't change the meaning of that value. As part of the effort to add async to the language, it became clear that automatically-generated futures needed to never be moved— Otherwise, it would be impossible to hold any local references across an await point.

Pin's contract was designed to retrofit this exception to the general rule onto the language without disrupting the assumptions of all the pre-existing Rust code that assumed it's always OK to move a value around in memory.

1 Like

In this example, there is no other user, there is only a pined value, created at an inner scope then expires.

After the pinned value expires, a swap happens. Then document days Potential UB down the road.

Why UB down the road? Because there ever was a pinned value? What makes the expired value to be the cause of unsound?

Drop.

For the Pin type Drop is allowed to be unsound if value was moved. Other functions which use Pin may depend on that property, too.

1 Like

I explained above how it can go wrong. If the type T is such a type, then you will have UB.

I believe that this example is technically sound despite violating Pin's contract, but only because there's no opportunity for the Pin to be observed by code that might rely on it.

In a less pedagogical setting where p is "released into the wild" somehow, even if only temporarily, even if only by calling one of T's methods that expects a pinned value, the swap becomes unsound.

T might contain a MaybeUninit inside it, for example, and use a static index to keep track of which instances have been initialized, keyed on the pinned address. By performing the swap, you've broken the index, potentially leading to reading uninitialized memory.

1 Like

For a T, if it expected always been pinned,the reasonable way is T::new returns a pin, which will ensure none chance for users of T to swap the value.

While, since in this example &mut T can be obtained, that means T::drop should not expected the swap will not happen.

Anyway, T::new and T::drop is totally under the control of T's author. What I mean is, how users of T will use T's API is unknown for the author, it is unwise for the drop implemention to require no swapping, if it indded require that, it is a semantic bug of T's Api, not the users, and the user is allowed to pin T for a while, then swap it.

Often, the lifecycle of types that rely on Pin happens looks something like this:

  1. new() will return an owned value that can be moved to an arbitrary place.
  2. Once it's done being passed around in memory to its final resting place, as determined by external code, it gets Pinnned.
  3. Some method gets called that requires a Pinned value; this changes the internal state to rely on the address, now that it is guaranteed to be stable.
  4. The drop() implementation relies on the fact that, if the change from (3) has happened, the object hasn't moved in memory since then.

So there is no compiler magic here. If a programmer believes no bad things will happen, and if he is right, then he can freely swap the value even there is a pinned value?

For a T, if it expected always been pinned

And how would you guarantee that move_pinned_ref would never be called with any other type?

Anyway, T::new and T::drop is totally under the control of T's author.

Absolutely. But there's nothing that T's author can do to prevent it's use with move_pinned_ref .

I think your misunderstanding is not about how Pin works, but about what sound code is.

Sound code is not code that can be used correctly if you pick nice T type! No!

It's the other way around: sound code have to accept anything including T types which are explicitly designed not to be used with Pin.

And that's what that exactly talks about: that code is unsound, but, sure enough, with appropriate T (e.g. if T is i32) it can be correct.

That's why there are word “potential UB” in the comment, not “guaranteed UB”.

What do you mean by “allowed to pin”? Rules if the Pin pretty explicitly forbid that. If you call Pin::new_unchecked then it's your responsibility to ensure that swap wouldn't happen after that. That's precisely what documentation say you should never allow.

Yet it's perfectly acceptable to implement it in way where it doesn't work correctly if Pin containing that reference ever existed — and then value was moved.

That's the whole point of Pin, it's raison d'ĂŞtre! If you you are creating Pin for the !Unpin type then you have to guarantee that nothing would ever be moved after that moment! Not author of T or author of Pin, but the guy who is calling Pin::new_unchecked is the one who have to do the work.

P.S. “Wise” and “unwise”, “makes sense” and “makes no sense”… you have to forget these words if you want to reason about how language works. Because compiler doesn't have a common sense and if it may do something “unwise” then you can be pretty sure it would do something “unwise”. That's why all you reasoning must be 100% water-tight, that's the only way to produce code which you can trust.

3 Likes

If type is Unpin (e.g. i32) then you can do swap without any problems. But move_pinned_ref , as it is written, have no right to exist because it have to work with any type T, including “bad” types made by “unwise” people.

The point of the question is wether there are compiler magics. As I say, if the programmer has thought it carefully, and if he is right (for some concrete type), then can the code works as expected if he swap the value even there are pinned pointer of it alive.

For example, he swap the value in a critical section, and swap it back before leave the section to allow the pinned value to be accessed in the same critical section

The actual example in the documentation

  • does not swap back,
  • can be called with arbitrary T types, and
  • is not an unsafe fn.

If you make it swap back, then yes, it is sound. If you add a trait bound or concrete type to restrict it only to Ts that don't manipulate their self-references in Drop, then it is sound. If you make it an unsafe fn then the caller is responsible for using it correctly.

But with none of those changes, it's an unsound function. Unsound doesn't mean it will cause UB; it means that if you pass certain arguments (or call it at the wrong time, or other such circumstantial factors) it's possible to cause UB. A sound function never causes UB no matter what arguments you pass.

5 Likes

There is no compiler magic. Pinning is exclusively a safety invariant, and it only leads to language UB if you call into code that relies on the guarantee.

3 Likes

I think this example from the documentation could be explained further. Other people in the thread have written the same thing. You can construct a Pin<Box<T>> safely for any sized T and given a mut ref to it, get a Pin<&mut T> out. Now let's say that T has some Pin<&mut Self> method, foo, that does something unsafe, e.g. store a self-reference within the instance. With the box, you're fine. Until it's dropped you can never get a &mut T out and so you can't move the object leaving a dangling reference. However if you constructed the Pin<&mut T> directly as in the example you could get a &mut T later and do unsafe things.

The "potential UB down the road" would be if you added a call to foo while you still had the Pin. The example is showing you how to peel a banana and lay the peel on the floor, just not how to step on it.

The Drop implementation on this hypothetical T has to act as if its argument was pinned. But when you implement T you get to implement Drop so that it is safe. You can't keep a user from moving out of a mut ref.

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.