That doesn't work: any guard / drop glue can be disabled by mem::forget
ting the handle (as a comparison point, most of the problems here with FFI are very similar to those of spawning threads and "joining" to unregister them; and as you can see, most thread spawning APIs require 'static
).
The way to ensure an eventual / unavoidable cleanup (within some region 'lt
) is to use
The scoped API (callback) pattern
-
Before anything, some helper(s) (e.g., to debug stuff):
pub struct Deathrattle(pub &'static str);
impl Drop for Deathrattle {
fn drop (self: &'_ mut Self)
{
println!("Dropping `{}`", self.0);
}
}
Let's start with a callback whose body kind of defines a scope:
// Naïve approach
fn run<R> (f: impl FnOnce() -> R)
-> R
{
let ret = f();
println!("(Right before) end of scope");
ret
}
fn main ()
{
let _: i32 = {
let value = Deathrattle("value");
run(|| {
let _captured = &value;
println!("Body");
42
})
};
}
which prints:
Body
(Right before) end of scope
Dropping `value`
Now, this code has an important issue as well: what if f()
panics? Then we are technically "returning an unwind", with a short-circuiting behavior which leads to the "(Right before) end of scope"
"cleanup" to be skipped!:
- Playground which prints
"Body"
and then Dropping …
, with no end of scope
whatsover.
This is because of have written code after something, rather than relying on drop glue. Indeed, drop glue is the usual mechanism to avoid the footgun not to run cleanup glue on short-circuiting returns, such as panic!
s or ?
:
// Naïve approach
fn run<R> (f: impl FnOnce() -> R)
-> R
{
+ let _guard = ::scopeguard::guard((), /* on drop: */ |()| {
+ println!("(Right before) end of scope")
+ });
let ret = f();
- println!("(Right before) end of scope");
ret
}
- (
::scopeguard
is the go-to crate for ad-hoc drop glue instances).
With this, we do get:
thread 'main' panicked at 'Body', src/main.rs:27:13
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
(Right before) end of scope
Dropping `value`
- The interesting bit is that I've started this post warning against relying too much on drop glue, and yet here I am using drop glue! The difference lies in who owns the drop-glue-imbued handle: since an owner, but very definition, is the one who
will may drop the value, we can trust the drop glue provided we, the API / the callee are the ones owning the drop-glue-imbued handle (hence the callback style), which is not the case with more straight-forward APIs which return guards rather than taking callbacks.
I've personally written a crate to make this pattern more readable, since this is, after-all, related to unwind safety (it's as basic / simple as just a builder-pattern wrapper around a scopeguard
, but the increased readability is quite nice imho):
fn run<R> (f: impl FnOnce() -> R)
-> R
{
::unwind_safe::with_state(())
.try_eval(|_| f())
.finally(|_| {
println!("(Right before) end of scope");
})
}
So, now that we have something resembling a scope, let's try to get a lifetime parameter with which to work: a 'scope
so that anything that is : 'scope
may not dangle before the unregistration / cleanup. Given that property, let's call it 'beyond_cleanup
instead:
- fn run<R> (f: impl FnOnce() -> R)
+ fn run<'beyond_cleanup, R> (f: impl FnOnce() -> R)
-> R
And that's it! No need to bound anything here (not even f: impl … + 'beyond_cleanup
!). Indeed:
-
<'beyond_cleanup>
is a(n external) generic function parameter. From the looks of it, it is unbounded. This means that it could be used to represent any region of code …
provided that region span beyond the end of run
! Indeed, that's a kind of implicit / unknown aspect of lifetime parameters "in scope": they must represent a lifetime that spans beyond the end of a function's body the moment they are in scope of that function (≠ for<'any>
lifetimes, which appear in HRTB and are thus no longer in scope within a function's body).
So there is an effective lower bound on 'beyond_cleanup
, so that if, later on, we write T : 'beyond_cleanup
, we'd effectively be lower-bounding T
by this lifetime parameter which is free but itself lower-bounded to outlive / span beyond our end of scope
cleanup.
-
f
is consumed within the call to run
, so by the very construction / design, shan't be alive beyond the end of the scope. So we could very well add a + 'beyond_cleanup
bound on f
(and many real-life scoped APIs do, for the sake of "documentation" / good measure, even if it's technically not needed).
Now, there isn't much to use 'beyond_cleanup
with, however, with the current design: if we wanted to perform that .spawn()
API taking some Machine<'beyond_cleanup>
handle, as you had, we'll need to integrate that Machine
in there. For the sake of generality, I'll call it ScopeHandle
:
fn run<'beyond_cleanup, R> (
f: impl FnOnce(&ScopeHandle<'beyond_cleanup>) -> R,
) -> R
{
let scope: ScopeHandle<'beyond_cleanup> = ScopeHandle {
_beyond_cleanup_lifetime: PhantomInvariant,
};
::unwind_safe::with_state(scope)
.try_eval(|scope: &'_ ScopeHandle<'beyond_cleanup>| {
impl<'beyond_cleanup> ScopeHandle<'beyond_cleanup> {
// `f` cannot dangle / contain references that dangle
// before `'beyond_cleanup` ends
fn spawn(&self, f: impl Fn() + 'beyond_cleanup)
{
Box::leak(Box::new(f)); // etc.
}
}
f(scope)
})
.finally(|scope| { // cleanup!
drop(scope);
println!("Cleanup!");
}) // <- and `'beyond_cleanup` must necessarily span beyond this.
}
// where
struct ScopeHandle<'beyond_cleanup> {
_beyond_cleanup_lifetime: PhantomInvariant<'beyond_drop>,
}
There is an importance nuance, here: that 'beyond_cleanup
lifetime is used as a lower-bound for the area of owned-usability of f
(the point of all this design). It's important that such lower-bound not be allowed to shrink. The official phrasing for this property is that we don't want type ScopeHandle<'beyond_cleanup>
to be covariant in 'beyond_cleanup
. It should, at best, be contravariant (lower bound is allowed to grow), and, in practice, invariant (neither cov. nor contra.) is just saner: let's not allow it to do something we don't need it to be allowed to do (even if it would be harmless in this instance). Hence the PhantomInvariant
This takes care of having a 'beyond_cleanup
lifetime lower bound for stuff such as closures or other items that may be used concurrently all the way up that region.
But now let's imagine we also want to use that ScopeHandle
to generate new instances which are, themselves, lifetime infected with a lifetime parameter that isn't allowed to span beyond the start of the cleanup process, that is, a lifetime that, at most, spans 'until_start_of_cleanup
. Well, when we think about this, the very borrow over the ScopeHandle
itself is a very nice candidate / representation of such a lifetime! I had just kept it elided to alleviate the signature, but the full signature would be:
fn run<'beyond_cleanup, R, F> (
f: F,
) -> R
where
for<'until_cleanup> // implicit `'beyond_cleanup : 'until_cleanup` bound
F : FnOnce(&'until_cleanup ScopeHandle<'beyond_cleanup>) -> R,
As you can see, "a wild HRTB appeared", which shouldn't be surprising, given how I had mentioned that (classic) lifetime parameters necessarily spanned beyond the end of a function's body, and how here we wanted the very opposite with that 'until_cleanup
lifetime.
The fact it is higher-order means that for a caller's closure to be eligible to become / to be usable as f
, it needs to be "agnostic" in the lifetime of the borrow of the ScopeHandle
; it needs to ignore / disregard it. Or to be precise, any attempt not to do so is doomed to fail, since the higher-orderness of the signature there "threatens" the actual 'until_cleanup
to be arbitrarily / infinitely small / short (although beyond f
's body itself, of course): such a lifetime won't be able to escape its scope.
And, as I mentioned initially, this is exactly what ::crossbeam
's scoped-thread spawning (auto-joined) API is like:
fn scope<'env, F, R> (
f: F,
) -> thread::Result<R>
where
F : FnOnce(&Scope<'env>) -> R,
'env
is 'beyond_join
, and thus represents the lifetime bound for the .spawn()
-ed closure's 'env
ironment.
Note that once we have an "owned thing" we construct in our scope
API, we can try to embed all the cleanup within its drop glue, simplifying the implementation:
Final signature
fn scope_api<'beyond_scope, R, F> (
f: F,
) -> R
where
for<'scope>
F : FnOnce(&'scope Scope<'beyond_scope>) -> R
,
{
let scope_handle: Scope<'beyond_scope> = …;
impl Drop for Scope<'_> { fn drop(&mut self) {
/* cleanup */
}}
f(&scope_handle)
}