Is a memory-leaked reference a valid reference?

see Rust Playground for a demo
We know that in rust memory leak is memory safe, and Rc can't resolve memory leak. So if we leak a const reference to a object b and then we just modify b, is this a defined or undefined behavior? I run the demo above with MIRIFLAGS="-Zmiri-ignore-leaks" cargo miri run, which exit without errors. Is this expected result or is it a bug?

to my understanding it's safe. as long the value is not dropped, the reference remains valid.

There is no unsafe code, so it must be sound unless there's a bug in the unsafe implementation of the types involved.

2 Likes

If the reference remains valid, then the modification should be a undefined behavior, right?

There’s a part of the compiler which I believe is called “drop check” which makes sure such things like the code you have here are sound.

In particular, Rust does support some interactions with dangling/invalid references; in particular destructors that don’t touch them (typically because they aren’t custom Drop impls, but the compiler-implemented dropping code that simply drops all fields recursively; as well as some specially marked Drop implementations in the standard library, e.g. for Box or Vec, but notably also Rc).

E.g. if you didn’t create the leaking cycle as in

fn main() {
    let mut b = B(true);
    let a = Rc::new(RefCell::new(A {
        self_ref: None,
        b: &b,
    }));
    //a.borrow_mut().self_ref = Some(Rc::clone(&a));
    //drop(a);
    b.0 = false;
    println!("{}", &b.0);
    // `a` implicitly dropped here x
}

Then, too, a would still be around at a point where the reference to b is already invalidated.


But in contrast, if you did an explicit call to drop(a), the compiler becomes unhappy

fn main() {
    let mut b = B(true);
    let a = Rc::new(RefCell::new(A {
        self_ref: None,
        b: &b,
    }));
    //a.borrow_mut().self_ref = Some(Rc::clone(&a));
    //drop(a);
    b.0 = false;
    println!("{}", &b.0);
    // `a` implicitly dropped here x
    drop(a);
}
error[E0506]: cannot assign to `b.0` because it is borrowed
  --> src/main.rs:22:5
   |
18 |         b: &b,
   |            -- `b.0` is borrowed here
...
22 |     b.0 = false;
   |     ^^^^^^^^^^^ `b.0` is assigned to here but it was already borrowed
...
25 |     drop(a);
   |          - borrow later used here

(which is not really related to any strong soundness reasons but more to how limited this support for dropping values with invalid references is)


And similarly, if you add a manual Drop implementation…

use std::{
    cell::RefCell,
    rc::Rc,
};

struct A<'a> {
    self_ref: Option<Rc<RefCell<A<'a>>>>,
    #[allow(dead_code)]
    b: &'a B,
}
impl Drop for A<'_> {
    fn drop(&mut self) {}
}

struct B(bool);

fn main() {
    let mut b = B(true);
    let a = Rc::new(RefCell::new(A {
        self_ref: None,
        b: &b,
    }));
    a.borrow_mut().self_ref = Some(Rc::clone(&a));
    drop(a);
    b.0 = false;
    println!("{}", &b.0);
}

wait… nope, this still compiles. It looks like there are no concerns, as from the compiler’s point of view, a is considered completely gone after the drop(a). For soundness it doesn’t matter if the contained A was actually dropped or leaked, as long as it became inaccessible.

If you however remove the drop(a)

use std::{
    cell::RefCell,
    rc::Rc,
};

struct A<'a> {
    self_ref: Option<Rc<RefCell<A<'a>>>>,
    #[allow(dead_code)]
    b: &'a B,
}
impl Drop for A<'_> {
    fn drop(&mut self) {}
}

struct B(bool);

fn main() {
    let mut b = B(true);
    let a = Rc::new(RefCell::new(A {
        self_ref: None,
        b: &b,
    }));
    a.borrow_mut().self_ref = Some(Rc::clone(&a));
    // drop(a);
    b.0 = false;
    println!("{}", &b.0);
}

then it’s unhappy again

error[E0506]: cannot assign to `b.0` because it is borrowed
  --> src/main.rs:25:5
   |
21 |         b: &b,
   |            -- `b.0` is borrowed here
...
25 |     b.0 = false;
   |     ^^^^^^^^^^^ `b.0` is assigned to here but it was already borrowed
26 |     println!("{}", &b.0);
27 | }
   | - borrow might be used here, when `a` is dropped and runs the `Drop` code for type `Rc`

because it needs to conservatively assume that the implicit drop of a at the end may drop the contained A<'_> value, calling the custom Drop impl which then could access the target of the b: &B reference field. (Feel free to test uncommenting the impl Drop to see the error go away again.)

8 Likes

The reference does not exist anymore as far as the compiler is concerned. It can't be accessed through any other entity after dropping a.

Might be an interesting question for T-OPSEM, though.

2 Likes

oh, this is a tricky situation. if the reference remains valid, but it's not accessible, does it count? I don't know. I would bet it's a dead reference as for as static analysis can tell.

For comparison, this is a more minimal example for how references are allowed to be invalid. The existence of a while b.0 = false is executed doesn’t result in UB; but any actual usage of a after that point may be UB. (AFAIR, it’s not entirely fully decided yet which exact forms of usage of an invalid reference are UB; i.e. if merely “touching” it by things like copying the reference is enough, of if only dereferencing it is UB. Dereferencing it would definitely be UB. Merely “touching” it is enough to get caught/prevented by the borrow-checker. So the question only matters once unsafe code is involved.)

struct B(bool);

fn main() {
    let mut b = B(true);
    let a = &b;
    b.0 = false;
    println!("{}", &b.0);
    // `a` technically still exists at this point,
    // but the reference it contains is already invalid
    // ever since `b.0 = false` mutated `b`.
}
5 Likes

References are allowed to remain "around" past their valid lifetime, as long as the compiler can prove they aren't used - you can, for instance, put a bunch of references in a Vec and hang on to the Vec indefinitely, even after the references become invalid, as long as you don't try to use it (or do anything with it other than let it go peacefully out of scope). See here.

What's happening in the OP is similar, in that a reference is allowed to exist past its valid lifetime, but any attempt to actually use that reference outside of its lifetime will fail.

If it's possible in purely safe code, it's obviously safe, and opsem just gets to describe why. The current working model is that reference validity only matters to the opsem when a reference is "used," and that empty drop glue (needs_drop() == false) is not a use.

Precisely defining what is a use is the interesting part, of course.

1 Like

Well, compiler bugs and incomplete/incorrect/unintended specs are possible – I'm sure OP would have not asked unless this were the implication.

I recently hit this situation. I defined a Rust object which owned a foreign object. The Rust object's drop handler destroyed the foreign object. The foreign object invoked callbacks on the Rust object, which forward them to library-user-provided callbacks. I had bounds on the user-provided callbacks to make sure the object didn't outlive the callbacks.

I noticed that if a user-provided callback had an Rc to the Rust object, the loop would cause everything to live longer than a reference captured by the user-provided callback. I strengthened the callback bounds to 'static to prevent this.

The annoyance of writing 'static callbacks led me to create https://crates.io/crates/closure_attr

Interesting crate. You might want to traverse the closure bodies in the visitor, too; I wouldn’t know why #[closure(…)] inside of the body of another closure shouldn’t be made to work (and I believe there may be practical situation where you want the cloning on multiple levels).

4 posts were merged into a different topic: Feedback on the new closure_attr utility/macro crate

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.