Set 'static lifetime for local variables of diverging functions?

Diverging functions can still panic-unwind.

9 Likes

This could theoretically be done soundly in a macro by shadowing and putting a unwind/drop-is-abort bomb on the stack.

1 Like

Could this be accomplished with an unsafe transmute to extend the lifetime? I’m not sure if that’s instant UB or not.

The current (somewhat proposed) model of stacked borrows has no concept of lifetimes, only usage timestamps. If you obey the usage rules it’s sound under that model even if you lie to the type system so it can’t protect you. Cc @RalfJung

(Disclaimer: this is by memory and not an authoritative source of what is or isn’t UB)

Thanks for the quick answers! I normally work in panic=abort environments, so didn’t think of unwinding.

1 Like

Interesting idea! How could the macro check whether the enclosing function is diverging? Or do you mean to wrap the entire function in a macro?

Yes! I am also facing some similar confusion (read something about 192.168.10.1). Should we wrap the whole function itno a macro or not?

I think the macro idea was to encapsulate it safely, creating something like:

let name_hidden_by_macro_hygene = AbortOnDrop;

I have made the following PoC:

It grants an “arbitrary” shared reference through a closure that must diverge

  • since a diverging closure cannot use ! (it’s the never type instead of a diverging notation), I have used an empty enum for the same effect;

  • the lifetime is caller-chosen, hence the term “arbitrary”, but cannot of course “outlive the type”; i.e., for all types T of the local, the lifetime parameter 'a mut uphold that T : 'a (else &'a T doesn’t make sense);

  • an abort-on-drop bomb is used to prevent exploitation through stack unwinding; I have set up a scenario that would use-after-free otherwise (you may go an comment the let guard = ... line to see that for yourselves).

// #![feature(never_type)]
enum Diverging {}

use ::std::*;

struct AbortOnDrop;

impl Drop for AbortOnDrop { fn drop(&mut self) {
    // Triggered the abort bomb!
    process::abort();
}}

trait WithDiverging<'a> : Sized + 'a {
    fn with_diverging (
        self,
        f: impl FnOnce(&'a Self) -> Diverging,
    ) -> !
    {
        #![allow(unused_variables)]
        unsafe {
            let guard = AbortOnDrop;
            let diverged = f(mem::transmute(&self));
            match diverged {
                // !
            }
            // kabooms here if stack unwinds
            // (*before* self is dropped)
        }
    }
}

impl<'a, T : Sized + 'a> WithDiverging<'a> for T {}


fn main ()
{
    let _ = panic::catch_unwind(|| {
        // Our local
        let s = String::from("hi");
        s.with_diverging(|at_s: &'static String| {
            // can transmute to &'static since this closure diverges ...
            assert_eq!(at_s, "hi");
            thread::spawn(move || {
                // &'static is given to another thread
                // that constantly reads it
                loop {
                    thread::sleep(time::Duration::from_millis(100));
                    dbg!(at_s);
                }
            });
            thread::sleep(time::Duration::from_secs(1));
            // ... and thanks to the abortbomb guard
            panic!("Attempt at being evil");
        })
    });
    // sleep to give time to the other thread to use at_s if unwind
    thread::sleep(time::Duration::from_secs(3));
}

EDIT: the code can be changed into granting a unique reference, since it already takes ownership of T:

trait WithDiverging<'a> : Sized + 'a {
    fn with_diverging (
        self,
        f: impl FnOnce(&'a mut Self) -> Diverging,
    ) -> !

EDIT2: s/Fn/FnOnce/g

6 Likes

This was discussed as part of the NLL design. IIRC the core design input was that literally just loop{} essentially never happens and rust doesn’t have a panic-free effect, so nearly anything inside the loop would make it uncertainly-forever. And along with a desire to not have the borrowing rules differ for different panic implementations, that meant the decision was to just never allow this.

If you have a function you know really is forever, you can always just Box::leak something to get a &'static mut.

6 Likes

So, is this sound?

pub struct NeverDrop<T>(T);

impl<T> NeverDrop<T> {
    pub fn new(value: T) -> Self {
        NeverDrop(value)
    }
    
    pub fn get(&self) -> &'static T {
        unsafe {
            std::mem::transmute::<&T, &'static T>(&self.0)
        }
    }

    pub fn get_mut(&mut self) -> &'static mut T {
        unsafe {
            std::mem::transmute::<&mut T, &'static mut T>(&mut self.0)
        }
    }

}

impl<T> Drop for NeverDrop<T> {
    fn drop(&mut self) {
        eprintln!("attempted to drop NeverDrop");
        std::process::abort();
    }
}

Playground

No, the NeverDrop value could be moved around, invalidating references. Or if you made it hold a borrowed reference in the first place, it could be leaked with mem::forget to avoid the guard.

Also, that get_mut allows mutable aliasing, because the disconnected lifetime doesn’t hold a borrow on the value anymore.

3 Likes

Since we’d need T : 'static to call .get(), how would that work?

It seems to me that with some pinning (and without .get_mut()!) that API might become sound, although it is very easy to miss an important detail.

Obviously if we are pinning, we may very possibly end up using Box, at which point Box::leak is a thousand times better. But I love these hypothetical challenges :slight_smile:

I was trying to predict an alternate like struct NeverDrop<'a, T>(&'a T). This would solve the problem of the value moving since it’s borrowed here. But since get's lifetime doesn’t hold a borrow (same problem as get_mut), this guard can just be leaked or forgotten.

I’m not well versed in pinning, but yes, once you involve the heap, you might as well just Box::leak.

1 Like

You can Pin things on the stack as well.

Re: @dhm’s WithDiverging:

I agree this should be sound, I cannot see a problem with this.

However, we have to be a bit careful – not because of Stacked Borrows, but because if we generalize this idea to "replace some lifetime by 'static", we can make currently sound patterns of lifetime usage unsound. Namely, exploiting generative lifetimes relies on programs not being able to change invariant lifetimes to anything else, but if we argue that in a diverging function, we can have a lifetime 'a outlive 'static (and vice versa, because it’s 'static) then we can break some libraries.

3 Likes

Interesting!

Just a note: I did choose the most cautious approach for my API: taking ownership of a value of type T (which we could #[inline(always)] to hint at avoiding the copy), and then lending a &'a mut T for any 'a where T : 'a. I’m pretty sure this is not only currently sound, but sound even with regards to other patterns. Please correct me if I am wrong!

The elephant in the room here, to which @RalfJung’s post was directed (I think), is if instead of

trait WithDiverging<'a> : Sized + 'a {
    fn with_diverging (
        self,
        f: impl FnOnce(&'a mut Self) -> Diverging,
    ) -> !

we had

trait WithDiverging<'a> : 'a {
    fn with_diverging (
        &'_ mut self,
        f: impl FnOnce(&'a mut Self) -> Diverging,
    ) -> ! // this function **never** returns (AbortOnDrop guard)

it does seem like it should be sound, by agree that it may break other subtle stuff.

EDIT: s/Fn/FnOnce/g

Side question – is there a reason you chose Fn instead of FnOnce?

1 Like

My overlooking that point :sweat_smile::laughing::laughing:

=> s/Fn/FnOnce/g

1 Like

I agree, this should be sound. However, building a model that can actually verify these patterns is something we are currently looking into; once we have a model that is able to do so we’ll check your approach and some less careful ones to see how much we can make work.

We currently think that your most conservative version should be no issue to prove correct.

That is the less cautious version indeed. It still seems sound but it is much more challenging, from what we can see so far in our attempts to build models for this – some of our preliminary models likely can not prove correctness of this.

And then there is a version (that we cannot write in current Rust) that is definitely incompatible with generative lifetimes:

    fn with_diverging <T: lft -> type>(
        x: T<'_>,
        f: impl FnOnce(T<'a>) -> Diverging,
    ) -> ! // this function **never** returns (AbortOnDrop guard)

where T is a type constructor; this basically allows replacing a lifetime appearing anywhere in a type by 'static.

1 Like