Why dropping at the 2nd place fails to compile but ok at the 1st?

When I re-study the lifetime, I am confused by the following code,
To my understanding, dropping at 1 OR at 2 should both work, or both fails.

But, the compiling succeed if I drop it at 1, but fails at 2.
So, how to explain this?

pub fn fx<'a, T: 'a, U>(_: &mut &'a mut T) -> fn(&mut &'a mut U) {
    fn f<'b, T: 'b>(_: &mut &'b mut T) {}
    f::<U>
}

struct PrintOnDrop<'a>(&'a mut String);
impl<'a> Drop for PrintOnDrop<'a> {
    fn drop(&mut self) {}
}

fn main() {
    let mut m = "m".to_owned();
    let mut n = "n".to_owned();
    {
        let mut x1 = &mut m; // &'1 mut m
        let f = fx(&mut x1);

        let m1 = PrintOnDrop(x1);
        {
            let mut n1 = PrintOnDrop(&mut n);

            // 1. It is OK to compile
            drop(m1);

            f(&mut &mut n1);  // &mut &'1 mut n1

             // 2. but drop here, compile fails
            // drop(m1);  
        }
    }
}
1 Like

What the f** is going on h… I mean, let’s look at this.

Immediately, I see a Drop implementation on a type with a lifetime, so this may relate to drop check, which could explain why it’s confusing, since drop check and lifetimes can be a bit intricate.

On second look, the error message with the second place for the drop(m1) confirms, it’s related to drop check as the error message comes with the line

borrow might be used here, when `m1` is dropped and runs the `Drop` code for type `PrintOnDrop`

that mentions the dropping of m1.

Note that this does talk only about the implicit dropping of m1 at the end of its scope, not the explicit call to drop(m1). Thus it also appears if all explicit calls to drop(m1) are removed.

“What implicit drop?” you might ask, as the normal control flow of the function would always take you to the explicit dropping with drop(m1) that happens earlier. But not all control flow paths are normal; the exceptional control flow path is when something panics, and when something panics, say, at the end of the call to f, then the drop(m1) is skipped, and while unwinding, the normal implicit drops happen instead. Of course, in this kind of analysis whether f actually panics is irrelevant; the compiler’s borrow checker and drop check currently conservatively assumes that panics can happen essentially anywhere (even where there’s no code at all).


Anyways, now I’ll need to actually study the error that happens here in the first place, and your fx doesn’t have a particularly simple signature :sweat_smile:

Ah, I think it’s just a clever way of forcing x1 and &mut n1 to have the same lifetime. (For that matter, it’s a good point to call out that “lifetime”s are always about when a borrow ends, never when it begins, and thus x1 and the &mut n1 borrows are completely fine to have been created at different times, as long as they end at the same time.) Let’s find the problem in that.

The normal destructors run in inverse declaration order here, i.e. n1 is dropped first, then m1.

When n1 is dropped, the &mut n1 borrow must have ended, and thus x1 must become dead, too, as it was forced to have the same lifetime, i.e. end at the same time.

But then m1 is dropped, and its destructor has access to x1, which must thus be live. There’s out contradiction!

Why drop(m1) at postition 2 doesn’t help is something I’ve explained above: Possibility of panics is considered, and thus the compiler complains if the normal implicit drop order poses any possible problem.

What seems more interesting is to get behind how exactly drop(m1) at position 1 does help.

I guess, this is a bit mind-bending. After all, the call to f(&mut &mut n1) that forces these lifetimes to be identical still exists. On the other hand, if you do consider the possibility of the panic path that drops n1 first and then m1, with the drop(m1) in place, this path can only exist before the drop(m1) call, and thus before the call to f ever happened, which means at that point the &mut n1 reference hasn’t even been created yet! I don’t actually know the NLL borrow checker algorithm itself, but it does seem reasonable to me that when the &mut n1 wasn’t even created yet, it can simply consider n1 still not-borrowed, and x1 not yet forced to be constrained further in any way, yet.

In a sense, this interpretation does kind-of mean that … or at least feel like … the call to f(&mut &mut n1) sort-of does something to the lifetime of x1. Cutting it short to end before m1 is dropped. Which is a bit weird, since lifetimes are part of types and don’t change over time.

On the other hand, and I think this might be the more useful interpretation, maybe we shouldn’t think of f(&mut &mut n1) doing anything to x1 but just integrating the new reference (and it’s lifetime), the &mut n1 one, with the other preexisting borrows and their lifetimes, and this integration is done/constrained in a manner that it forces this new &mut n1 borrow to stay live (at least) all the way until x1 is last used in the destructor of m1. So, throughout the control flow of a program lifetimes don’t change, but new borrows with new lifetimes can be introduced, and this relates to the program’s control flow from this specific point in a meaningful manner. The meaningful difference that drop(m1) before f(&mut &mut n1); does it thus eliminating the control-flow path (the abovementioned panic case that’s always considered) at the place of the call to f(&mut &mut n1) in which m1 is dropped after n1.

Sorry for the longer “lifetimes philosophy” discussion, perhaps the hint on panicking paths is all that was needed. I certainly like to use code example such as yours here to further refine my own understanding of lifetimes in Rust, and I couldn’t help but write down my takeaways :slight_smile:

8 Likes

It seems you are right.

The reason is drop(m1) follows the type constructor directly, so there is no chance of panic before m1 getting dropped, putting something panic-able between the two will fail compiling.

pub fn fx<'a, T: 'a, U>(_: &mut &'a mut T) -> fn(&mut &'a mut U) {
    fn f<'b, T: 'b>(_: &mut &'b mut T) {}
    f::<U>
}

struct PrintOnDrop<'a>(&'a mut String);
impl<'a> Drop for PrintOnDrop<'a> {
    fn drop(&mut self) {}
}

fn main() {
    let mut m = "m".to_owned();
    let mut n = "n".to_owned();
    {
        let mut x1 = &mut m; // &'1 mut m
        let f = fx(&mut x1);

        let m1 = PrintOnDrop(x1);
        {
            let mut n1 = PrintOnDrop(&mut n);

            println!("ss");  ///-------------------- now fails the compiling

            drop(m1);

            // &mut &'1 mut n1
            f(&mut &mut n1);
        }
    }
}

f(&mut &mut n1); can do nothing more than introducing &mut n1 with life time '1.

Well, dropping m1 only means the liveness scope of m1 reaches it's end point, not x1.

Oh, wow. That doesn't quit match my explanation, or expectation - I find this somewhat surprising actually. Well it does prove the point to panics being relevant, but apparently the compiler isn't smart enough to not consider the &mut n1 reference relevant if the drop happens before the call to f, after all.


Even more surprisingly, doing something like 0+0 in between, which can only panic in debug mode, makes the code compile successfully with cargo run --release but not with cargo run. I'm not sure whether that's something that's supposed to happen.

The compiler works as I expected, I mentioned this above:

This seems consistent with point 8 of rust-blog/posts/common-rust-lifetime-misconceptions.md at master · pretzelhammer/rust-blog · GitHub

Specifically, it points out that the borrow checker isn't able to reason about unconditionally unreachable code behind an if false. Given that, I am not surprised that it also seems unable to reason about potentially unreachable code behind a panic.

Even more surprisingly, yet again, the failure to compile with some potentially-panicking code in-between goes away if it’s an async fn o.O

Rust Playground

putting something panic-able between the two will fail compiling.
I think this has to do with how the current borrow checker is flow-sensitive in some ways but not completely flow sensitive. See explanation here.

I think that the call to f will force n1 to live at least as long as x1's lifetime, regardless of control flow.
Then, whenever m1 is dropped before n1, this is okay, but if n1 could possibly be dropped before m1, the code miscompiles, even if f hasn't been called yet.

Maybe the function does not compile at all;
ShowASM in play shows it compiles to

playground::async_function:
	movb	$0, -1(%rsp)
	movb	-1(%rsp), %al
	retq

An async function compiles to a function returning a future. In this case, with the future capturing no parameters, and having no await points, the future itself is a single byte that marks the state (between initial state, finished state, and panicked/poisoned state). It seems, the async_function thus merely returns the future’s initial state, represented by a zero byte.

If you want to see the implementation of the future, it seems you’ll have to use it somehow. The cleanest way is probably to create a trait object from it, e.g.:

pub fn usage() -> Box<dyn std::future::Future<Output = ()>> {
    Box::new(async_function())
}

For me, this results in code that refers to the vtable for said future

playground::usage:
	pushq	%rax
	movq	__rust_no_alloc_shim_is_unstable@GOTPCREL(%rip), %rax
	movzbl	(%rax), %eax
	movl	$1, %edi
	movl	$1, %esi
	callq	*__rust_alloc@GOTPCREL(%rip)
	testq	%rax, %rax
	je	.LBB6_1
	movb	$0, (%rax)
	leaq	.L__unnamed_4(%rip), %rdx
	popq	%rcx
	retq

and the vtable

.L__unnamed_4:
	.quad	core::ptr::drop_in_place<playground::async_function::{{closure}}>
	.asciz	"\001\000\000\000\000\000\000\000\001\000\000\000\000\000\000"
	.quad	playground::async_function::{{closure}}

marks the relevant implementation playground::async_function::{{closure}} where the actual compiled function body / state machine lives

playground::async_function::{{closure}}:
	pushq	%r15
	pushq	%r14
	pushq	%r12
	pushq	%rbx
	subq	$56, %rsp
	movzbl	(%rdi), %eax
	testl	%eax, %eax
	jne	.LBB5_1
	movq	%rdi, %rbx
	movq	__rust_no_alloc_shim_is_unstable@GOTPCREL(%rip), %r15
	movzbl	(%r15), %eax
	movl	$1, %edi
	movl	$1, %esi
	callq	*__rust_alloc@GOTPCREL(%rip)
	testq	%rax, %rax
	je	.LBB5_4
	movq	%rax, %r14
	movb	$109, (%rax)
	movzbl	(%r15), %eax
	movl	$1, %edi
	movl	$1, %esi
	callq	*__rust_alloc@GOTPCREL(%rip)
	testq	%rax, %rax
	je	.LBB5_8
	movq	%rax, %r15
	movb	$110, (%rax)
	leaq	.L__unnamed_1(%rip), %rax
	movq	%rax, 8(%rsp)
	movq	$1, 16(%rsp)
	leaq	.L__unnamed_2(%rip), %rax
	movq	%rax, 24(%rsp)
	xorps	%xmm0, %xmm0
	movups	%xmm0, 32(%rsp)
	leaq	8(%rsp), %rdi
	callq	*std::io::stdio::_print@GOTPCREL(%rip)
	movq	__rust_dealloc@GOTPCREL(%rip), %r12
	movl	$1, %esi
	movl	$1, %edx
	movq	%r15, %rdi
	callq	*%r12
	movl	$1, %esi
	movl	$1, %edx
	movq	%r14, %rdi
	callq	*%r12
	movb	$1, (%rbx)
	xorl	%eax, %eax
	addq	$56, %rsp
	popq	%rbx
	popq	%r12
	popq	%r14
	popq	%r15
	retq

.LBB5_1:
	cmpl	$1, %eax
	jne	.LBB5_15
	leaq	str.1(%rip), %rdi
	leaq	.L__unnamed_3(%rip), %rdx
	movl	$35, %esi
	callq	*core::panicking::panic@GOTPCREL(%rip)
	ud2

.LBB5_15:
	leaq	str.2(%rip), %rdi
	leaq	.L__unnamed_3(%rip), %rdx
	movl	$34, %esi
	callq	*core::panicking::panic@GOTPCREL(%rip)
	ud2

.LBB5_4:
	movl	$1, %edi
	movl	$1, %esi
	callq	*alloc::alloc::handle_alloc_error@GOTPCREL(%rip)
	jmp	.LBB5_5

.LBB5_8:
	movl	$1, %edi
	movl	$1, %esi
	callq	*alloc::alloc::handle_alloc_error@GOTPCREL(%rip)

.LBB5_5:
	ud2
	movq	%rax, %r12
	movl	$1, %esi
	movq	%r15, %rdi
	callq	core::ptr::drop_in_place<alloc::string::String>
	jmp	.LBB5_10
	movq	%rax, %r12

.LBB5_10:
	movl	$1, %esi
	movq	%r14, %rdi
	callq	core::ptr::drop_in_place<alloc::string::String>
	movb	$2, (%rbx)
	movq	%r12, %rdi
	callq	_Unwind_Resume@PLT
	ud2
	movq	%rax, %r12
	movb	$2, (%rbx)
	movq	%r12, %rdi
	callq	_Unwind_Resume@PLT
	ud2

It does seem a potential compiler problem, I am not sure, though.

Replacing the code into a sync fn (and calling it in async fn) makes the compiling failure appears.

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.