Relation between unleakable/undroppable types and borrow erasure in signatures

Hello, most interest for unleakable and undroppable types is for borrowing guards, that are preventing use of some resource until guard is dropped/consumed, not to actually "run some code", either in Drop impl or consumer of linear type.

Example: spawn_unchecked can become safe function if it would join in Drop of JoinHanlde.

My point is that only functions of like T -> () can be a threat, when T is not in the output of the signature.

Here is extension of classic code for safe forget:

fn forget<T>(val: T) {
    use std::cell::RefCell;
    use std::rc::Rc;

    struct Foo<T>(T, RefCell<Option<Rc<Foo<T>>>>);

    let x = Rc::new(Foo(val, RefCell::new(None)));
    *x.1.borrow_mut() = Some(x.clone());
}

struct Foo<'a>(&'a mut i32);

impl Drop for Foo<'_> {
    fn drop(&mut self) {
        unreachable!()
    }
}

fn main() {
    let mut value = 32;

    forget(Foo(&mut value));

    println!("{}", value);
}

My observation is that problem is not only in Rc, but in a signarute of forget itself - it takes T by value and "removes" the borrow. It expresses that after function with signature T -> () any borrows is removed.

If function has T -> T like signature, then borrow checker will extend lifetime further, preventing any unsound usage. As T is the same type, then lifetimes are also the same. If constructors of T are private, then library author gains more control, as described later.

In the following example we are actually forgetting T, but this is sound, because borrow checker will stop us from unsound activity (accessing value in that case, or some protected data that are worked with in other thread/c code):

fn sound_forget<T>(val: T) -> T {
    forget(val);
    std::process::abort()
}

fn forget<T>(val: T) {
    use std::cell::RefCell;
    use std::rc::Rc;

    struct Foo<T>(T, RefCell<Option<Rc<Foo<T>>>>);

    let x = Rc::new(Foo(val, RefCell::new(None)));
    *x.1.borrow_mut() = Some(x.clone());
}

struct Foo<'a>(&'a mut i32);

impl Drop for Foo<'_> {
    fn drop(&mut self) {
        unreachable!()
    }
}

fn main() {
    let mut value = 32;

    let foo = sound_forget(Foo(&mut value));

    println!("{}", value);

    forget(foo);
}

So, maybe we can make some progress by disallowing use of unleakable types in a signatures that "remove" them, except drop itself, for example implicit one? This is not a full solution of course, but it can help in creating sound signatures...

The only thing that breaks it, as far as I can see, is unwinding - one can have a function with signature T -> T, panic, then unwind to the point where no T is present and violate soundness. But it is probably possible for compiler to support in only in unwind = abort mode - a lot of people do not need unwinding anyway.

What do you think about it?

I don’t think this is true at all. Here’s a contrived example:

fn my_forget(input: JoinHandle) -> ThreadId {
  let result = input.thread().id();
  mem::forget(input);
  result
}

And if you say that still uses a call to a T -> () function, well, remember that the whole argument about forgetting being sound is that you can do it without calling forget, via Box::leak or Arc cycles or… Something will get dropped, but I’m not sure you can tell the difference between that and any other function that drops something locally (especially in the Arc case, it’s completely normal to drop at least some of the Arcs in a built-up object graph once you are done building it).

I probably can't understand your reply. Your my_forget is exactly the kind of function I propose to forbit calling, as it is T -> ()-like: e.g. JoinHandle is on the left, but not on the right.

Oh, so not the empty tuple specifically? But any consuming function has that signature, like join. Heck, mem::drop has the same signature as mem::forget.

Yeah, I updated the comment at some point

except drop itself, for example implicit one

(NOT A CONTRIBUTION)

You're not wrong, but I don't see how this could be a path toward anything useful.

The reason it's sound to forget a type if you later produce a new one is that the only way to produce an arbitrary type (and definitely the only way to produce any unleakable type we care about) from () is to diverge. This means any "forget" interface like you describe is morally equivalent to not leaking the type, it could have just been:

fn sound_forget<T>(val: T) -> T {
     process::abort();
     val
}

Or even:

fn sound_forget<T>(val: T) -> T {
     loop {}
     val
}

Since there's no way to write a sound_forget that forgets and doesn't diverge, sound_forget is not really forgetting: forgetting is continuing past the end of the scope of this value without running its destructor; sound_forget will never do that.

This all makes sense because unleakable types are trying to enforce a safety guarantee (something bad won't happen as a result of not running the destructor), and if the function diverges the safety guarantee is trivially upheld (nothing will happen, so something bad can't happen).

In contrast, undroppable types would extend this to also enforcing a liveness guarantee (something good will happen in that a specific clean up function will be called). But since Rust functions can diverge (as functions in any Turing complete language can), the liveness guarantees that undroppable types provide can only be defined modulo divergence: something good will happen unless the function diverges. Any designer of an API which tries to use undroppable types to enforce a liveness guarantee needs to know this.

EDIT: Probably a more accurate way to frame this is that both unleakable and undroppable types are enforcing a certain liveness guarantee (some clean up function will run; for unleakable types it may be the destructor) but that guarantee is always made modulo divergence. The APIs that benefit from unleakable types achieve a safety guarantee based on that imperfect liveness guarantee, because diverging does not violate it, only not diverging and not running the clean up code will violate the guarantee.

2 Likes

As far as I can see it, that idea gives a guarantee regarding unleackable types (modulo diverging): if we have one, the only way some data it is borrowing will stop being borrowed is by running Drop implementation - as no function would be able to consume it, except implicit drop. If there will be something like ?Leak, standard functions (including consuming ones, that are affected in that case) will anyway not be able to accept such types, so it will be backwards compatible. I agree that there isn't much you can do with such type, but it is at least something, and a lot of my personal use cases (embedded development, where I want to borrow resource on the stack instead of allocating it) can be done soundly, even if Drop will be abort (async stuff for example).