Unsafe code review: Mutable scoped thread locals

I'm looking into possible strategies to fix some of the soundness holes in macroquad, which come from its use of static mut variables. In order to minimize the amount of code reorganization, I came up with this primitive to reflect the kind patterns that macroquad uses.

It's similar in idea to scoped-tls, but is designed for &mut references. Aliasing is prevented by removing the pointer from the static Cell while a borrow is active and returning it afterwards. If the wrong value is present when replacing a borrow or ending a loan, the thread will panic.

Is this implementation sound (or can it be made so), or is there some fundamental problem that I'm missing?

(cc @parasyte, who filed the macroquad issue and @alexcrichton, who wrote scoped-tls)


Edit: I'm also not happy with the name, and would be open to suggestions


use std::cell::Cell;
use std::thread::LocalKey;
use std::ptr::{NonNull, null_mut};
use std::ops::{Deref,DerefMut};

/// A thread-local static location which provieds &mut references to deep
/// stack frames.
pub struct StackTunnel<T:'static>(&'static LocalKey<Cell<*mut T>>);


// Template for definition macro (w/ payload Vec<u32>) -------------------
pub static VEC_TUNNEL:StackTunnel<Vec<u32>> = unsafe {
    thread_local! {
        static SLOT: Cell<*mut Vec<u32>> = Cell::new(null_mut());
    }
    crate::StackTunnel::new(&SLOT)
};
// End template ----------------------------------------------



impl<T:'static> StackTunnel<T> {
    /// Safety: All access to `slot` must go through this instance, and it must be initialized to null
    pub const unsafe fn new(slot: &'static LocalKey<Cell<*mut T>>)->Self {
        StackTunnel(slot)
    }
    
    /// Leak val and make it available until the end of the program.
    /// Will panic if there is an outstanding loan, but not necessarily immediately.
    pub fn give_leak(&self, val: Box<T>) {
        self.0.with(|cell| {
            assert!(cell.get().is_null());
            cell.set(Box::leak(val));
        });
    }
    
    /// Provide a mutable reference to T to functions down the stack somewhere
    pub fn loan<R>(&'static self, val: &mut T, f: impl FnOnce()->R)->R {
        let next = val as *mut T;
        let prev = self.0.with(|cell| cell.replace(next));
        
        struct PanicGuard<T:'static> { slot: &'static StackTunnel<T>, next: *mut T, prev: *mut T }
        impl<T:'static> Drop for PanicGuard<T> {
            fn drop(&mut self) {
                // Replace previous value, and panic if we saw something unexpected
                assert_eq!(self.next, self.slot.0.with(|cell| cell.replace(self.prev)));
            }
        }
        
        let _guard = PanicGuard { slot: self, next, prev };
        
        f()
    }
    
    /// Borrow the mutable reference that was loaned in a higher stack frame.
    /// Will panic if no such reference is available.
    pub fn borrow_mut(&'static self)->StackTunnelGuard<T> {
        let value = self.0.with(|cell| cell.replace(null_mut()));
        StackTunnelGuard {
            slot: self,
            value: NonNull::new(value).expect("No reference available")
        }
    }
}

/// Temporary access to &mut T; will return the borrow on drop.
pub struct StackTunnelGuard<T:'static> {
    slot: &'static StackTunnel<T>,
    value: NonNull<T>,
}

impl<T:'static> Deref for StackTunnelGuard<T> {
    type Target = T;
    fn deref(&self)->&T {
        unsafe { &*self.value.as_ptr() }
    }
}

impl<T:'static> DerefMut for StackTunnelGuard<T> {
    fn deref_mut(&mut self)->&mut T {
        unsafe { &mut *self.value.as_ptr() }
    }
}

impl<T:'static> Drop for StackTunnelGuard<T> {
    fn drop(&mut self) {
        let p = self.slot.0.with(|cell| cell.replace(self.value.as_ptr()));
        assert!(p.is_null());
    }
}

The panic in loan is not enough. You have to abort the program (or sleep forever) in that case because that failure means that someone still holds a StackTunnelGuard with a pointer that's about to become invalid.

Replacing borrow_mut with a closure-based method would let you eliminate the possibility of this.

Other than that, I'd like to note that Tokio does something similar for its context thread local: src/runtime/context/scoped.rs

2 Likes

Panics can be caught.

fn main() {
    let mut v = vec![1, 2, 3];
    let mut r = &vec![];
    let _ = catch_unwind(AssertUnwindSafe(|| {
        VEC_TUNNEL.loan(&mut v, || {
            r = &**Box::leak(Box::new(VEC_TUNNEL.borrow_mut()));
        })
    }));
    dbg!(r);
    drop(v);
    dbg!(r); // UB :-)
}

Edit: Aww… @alice already noticed the same thing in the mean-time :grin:

3 Likes

Unfortunately, I suspect that's a non-starter: macroquad currently exposes a &'static mut Context to user code, so switching to a callback-based API might be too big of an ask— Replacing that with a guard object is also a breaking change, but it probably just means adding some &muts to the user code.

Aborting on an error probably isn't the choice I would make in isolation, but it might be the right tradeoff here.

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.