[Idea] `#[fallible_drop]` attribute for let statements

Robustness of error handling and explicitness are two (of many) qualities of Rust for which I love the language. And I would like Rust to allow explicit "fallible drop".

Let's say you handle a resource that can return an error on closing, such a resource as a file or a stream of data from a remote connection that must be closed. If you want to do something in case of such a failure, e.g. notify a user, it would be amazing to be able to handle such errors.

One solution to this problem would be #[must_consume] attribute. However, if you handle multiple resources, it gets complicated to manage.

Thanks to the reply of Alice Ryhl aka "alice" in Fallible Drop Method, I learned that...

...things such as File just ignore failures to close the file. It's not clear that there is much you can do about the failure, and triggering a panic in the destructor is prone to panics during panics, which immediately abort the process.

The solution that I see fit for this is introducing another rust-supported trait, std::ops::FallibleDrop, that will be pretty much like std::ops::Drop but offer fallible_drop with a slightly different signature:

pub trait FallibleDrop<E: std::error::Error> {
    // Required method
    fn fallible_drop(&mut self) -> Result<(), E>;
}

In order to make a possibility of error on drop explicit, we can introduce #[fallible_drop] attribute for let statements.

fn example() -> Result<(), ConnectionErrorOnDrop> {
  // Or #[fallible_drop(err_ty=ConnectionErrorOnDrop)]
  #[fallible_drop]
  let data_stream: DataStream = DataStream::connect()?;
  // ...
  // DataStream::fallible_drop()?;
}

If there are plans to stabilize std::ops::Try trait, we can replace (F|f)allible prefix with (T|t)ry and it'll work just as fine.

As for defaults, I don't know. Both FallibleDrop and Drop have good reasons to be defaults. But we can specify it for a struct with a separate attribute:

#[default_drop(Drop)]
struct File {
//...
}

Additional thoughts

It should also affect the expression on the right-hand side.

Before desugaring:

#[fallible_drop]
let v = vec![DataStream::connect(HOST1)?,DataStream::connect(HOST2)?,DataStream::connect(HOST3?)];

After desugaring:

#[fallible_drop]
let v = {
  let mut v = Vec::with_capacity(3);
  v.push(DataStream::connect(HOST1)?);
  // If the second connection fails, the first element in v has to be dropped too.
  v.push(DataStream::connect(HOST2)?);
  v.push(DataStream::connect(HOST3)?);
}

It should also probably be taken into consideration that proper support could be needed from 3rd party crates, notably tokio:

Before desugaring:

#[tokio::main]
async fn main() -> Result<(), Error> {
    #[fallible_drop]
    let future = my_async_fn();
    // future actually is taken by owned value and its type changes to Pin<...>
    pin!(future);

    (&mut future).await;
}

(I don't know what it desugars to)

And as a workaround, it could make sense to allow for #[fallible_drop] on function/method definitions.

1 Like

cc @yoshuawuyts, who probably has feelings about effects here, since one would also want #[async_drop] (like how .Net has IAsyncDisposable Interface (System) | Microsoft Learn)

I'm also reminded of Add the Close trait by czipperz · Pull Request #2677 · rust-lang/rfcs · GitHub, which wanted to give a more manual way to do something like this.

1 Like

I’m not really the person to comment on this, but “what happens during unwinding” is a question that should be addressed in any proposal for changes to Drop.

Thanks for the ping! Yeah in the Async WG we've got a pretty good idea of how we can make async Drop a reality. And it's a pretty good question: if we can make a version of Drop work which has an async effect, perhaps we could also make a version of Drop work which carries a try effect. I haven't fully explored the design space yet, so I can't say for sure - but at the very least I think it's that might hold promise, and is worth exploring.

One interesting problem which any design will need to grapple with: What should happen if you're returning an error from a function, and one of the destructors also returns an error?

fn example() -> io::Result<()> {
    let mut file = File::open("example.md")?; // 1. Pretend `File` implements fallible drop
    do_something()?;                          // 2. This may return an error.
                                              // 3. `File` is dropped here and may return an error
}

The issue here is that when 2. returns an error, File will be dropped in 3. which may also return an error. If both happen at the same time, then we now have instances of io::Error we may want to return. But the signature as written only supports returning a single error.

I think this requires us to either start discarding errors (which I think defeats the point of this), or introducing some form of standard "aggregate error" in the return type which would enable us to return multiple errors. If we then also consider the existence of Error::source which supports "error chaining", I think we're effectively looking at some sort of "error tree"?

5 Likes

It's a really good point. Maybe we can require the Result::Err variant hold E that would implement something like std::iter::Extend but also allowing for an array. Something like fill::Fill mentioned in Fallible alternative to Extend.

I'd like to point out to a discussion here (originated by myself :disguised_face: ) where a hypothetical TryDrop trait was considered:

trait TryDrop {
    type Result;
    fn try_drop(self) -> Self::Result;
}

The idea was using it for the cases when the outcome of the Drop would be interesting or important. It would be invoked manually and the caller would get the Result then to see if there is anything to report, a need to run a recovery action, or whatever else. The Err result might even return self back if there is a reasonable recovery action… and the caller could then decide if there is anything to do, or just can that returned self pass to the usual, silent Drop.

I suppose it is not useful for any async Drop ideas, let alone if you have some ideas how to cope with that in a more general effect system. But perhaps it might be worth alone, maybe a building block for something more advanced, which could be available sooner.

Regarding the aggregated Error::source, I think you are right. Just sharing my 2 cents: Java has try-with-resources blocks:

try (var resource = openMyFile()) {
   // Do stuff
} // Here resource is closed

Here, resource must implement the AutoCloseable interface and its close method is invoked always at the end of the block. If there is an error, an exception is thrown. And now the interesting part: an exception may be raised in the try block and should be propagated, but closing the resource throws an exception too – this is the situation that you mention. Or you have multiple resources and some of them fails to close. In any case, these secondary exceptions are attached as so called suppressed exceptions, i.e., not direct causes, but something that happened when cleaning up… so the information about failed clean-up is not lost.

Hence, Java implements actually a sort of error tree as you mentioned, we have a precedence. Thus I agree that Error could support something similar and should not be limited to a single "source".

Maybe… there might be an idiom using with TryDrop how to collect and combine the results of that explicit clean-up into such an aggregated Error.

1 Like

To provide an additional motivating use case from the stdlib we can refer to the docs for std::io::BufWriter:

It is critical to call flush before BufWriter<W> is dropped. Though dropping will attempt to flush the contents of the buffer, any errors that happen in the process of dropping will be ignored. Calling flush ensures that the buffer is empty and thus dropping will not even attempt file operations.

Rust means never having to close a socket, but apparently it still requires us to flush buffers by hand. Having a fallible version of Drop could be a way to address that.

3 Likes

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.