Exploration: #[must_destructure] "not easily dropped" types: Async cleanup

I was thinking about the idea of "must-destructure" types (a offshoot of "undroppable" types) after reading boat's Asynchronous clean-up post.

I like the idea that rustc can tell me "you can't just implicitly drop this type here". It nicely eliminates the possibility of accidental not calling custom cleanup fn in some branch of code.

I suggested it over on reddit but didn't get a clear reason on why it was stupid, so perhaps you guys could help provide that :slight_smile:.

Example: Async cleanup

async fn example() {
    let guard = must_destructure_example::start();

    guard.do_something(1).await;
    guard.do_something(2).await;

    // compiler will err without the next line
    // as AsyncGuard must be destructured
    guard.cleanup(123).await;
}

mod must_destructure_example {
    pub fn start() -> AsyncGuard {
        // start something
        AsyncGuard {}
    }

    #[must_destructure] // <-- new thing
    pub struct AsyncGuard {
        // ...
    }

    impl AsyncGuard {
        /// Do something.
        pub async fn do_something(&self, arg: u8) {
            // do some operation
        }

        /// Cleanup using the given `arg`.
        pub async fn cleanup(self, arg: u8) {
            // destructure and do cleanup asynchronously
        }

        /// Fallback sync cleanup.
        fn sync_cleanup(&mut self) {}
    }

    impl Drop for AsyncGuard {
        fn drop(&mut self) {
            self.sync_cleanup();
        }
    }
}

Note: Without the line guard.cleanup(123).await; rustc will fail: "must destructure `guard`" and will suggest using the available self methods, e.g. AsyncGuard::cleanup.

Limitation: Can't move into generic code

You couldn't move such types into generic code, as that code is allowed to just drop them and that would make them quite easily accidentally dropped. So rustc would have to forbid this.

This is a big limitation. But if it is possible to implement this rule the types still seem quite useful.

It is conceivable that generics could allow must-destructure types as opt-in somehow too, though such an ability would not be an initial requirement.

Otherwise can be moved

Like existing drop-guards, but unlike try-finally or defer, these types can be moved (just not into generic code). E.g. the AsyncGuard is moved in the example. This makes them more flexible as they would be used a similar way to sync drop-guards and could be composed into other types.

Leaking

As with sync drop-guards these types could benefit from better guarantees that they shouldn't leak (aka unforgettable types). However, I think they would be useful regardless as sync drop-guards already are.

Drop handles edge cases: Panicking & cancelling

The difficulty then are the edge cases: panicking & cancelling (perhaps there are others too?). As you can move types out of a runtime, can cancel any future, can panic etc, it seems a necessity to cover these with a sync fallback.

Why not use Drop to define this sync fallback? Then rustc will do its best to avoid implicitly dropping, but then actually will use Drop to handle panics, cancels and perhaps other such edge cases.

Because of this rustc should probably require must-destructure types implement Drop.

In this way any must-destructure type can define a sync-drop behaviour as a fallback. But rustc can help a lot in ensuring that something like an async cleanup fn is called in almost all regular usage.

This is also why I don't call these types "undroppable".

Composition

Must-destructure types may be used as fields of parent structs. It makes sense that rustc should then also require that parents are also must-destructure types.

"Linear" types is the keyword you want to search for. The usual post to link here is Gankra's The Pain Of Real Linear Types in Rust. The shortest version of it is that "can't move into generics" is a ginormous limitation; you can't Box it, you can't have a Vec of it, you can't Optionally have it, etc. Maybe in the world of async it's not that functionally different to a (stack) pinned value that can only be manipulated by (pinned) reference,

Additionally worth mentioning here that you could still write drop(guard.cleanup(123)) without awaiting it to skip doing the async cleanup, although that's reasonably unlikely to happen accidentally. It might also skip the sync cleanup, depending on how exactly the destructuring is done.

Also, if you're going to impl Drop for the type, additional new rules will be needed around destructuring, since impl Drop currently prohibits destructuring the type.

3 Likes