Equivalent of C++ `std::uncaught_exceptions` for `Result`?

Thinking about how a less common C++ idiom should translate to Rust. Its common to write "cleanup" code in destructors in both C++ and Rust, but sometimes you have work that should only be done if it's time to cleanup "for the right reason."

For example, say you have a TempFile struct, where TempFile::new() creates a temporary file you can use for say a unit test, and it has a drop handler that deletes the file. However, when the test fails, you actually don't want to clean up the file -- you want to keep it so the developer can look at it and try to figure out why the test failed.

So if drop is running because your #[test] function returned Result::Err, you want to keep the file. If it's running because your function returned Result::Ok, you don't want to keep the file.

In C++, in a code base where you consistently use exceptions for error handling, you can use std::uncaught_exception to detect if you are unwinding due to an exception being thrown:

struct TempFile {
    ~TempFile() {
        if(!std::uncaught_exception()) {
            this->delete_file();
        }
    }
};

This gives you the best of both worlds:

  • By defining a destructor you can't forget to call delete_file when using TempFile
  • The file is only deleted on success

AFAICT there is no way in a drop handler to distinguish if it's due to success or failure. I'm also not sure if it's possible to detect unwinding is happening due to panic. So I think I would have to force users to write:

let foo = TempFile::new();
test_work_that_may_fail(foo)?;
foo.success();

Where they have to remember to call success or the cleanup won't happen. Is there a better way?

There are other examples of this pattern -- e.g. you want to create a file called "foo.pending" and then only once you've fully written it successfully rename it to "foo". This prevents other processes from accidentally trying to work on an incomplete file, either because the file is still being written or because the process writing it crashed and it shouldn't be used (you want the next stage of your pipeline to fail fast complaining the file isn't present, instead of silently appearing to work on empty/truncated data),

Footnote: I'm glossing over a detail here -- you would use std::uncaught_exceptions (note the s) in real code in order to properly handle the case of a destructor running in response to an exception that itself has a try/catch inside of it. But then you have to save the starting value in the constructor and the snippet is longer/more-complicated. But the idea is the same.

I'm not familiar with the C++ feature, but it sounds like you may be looking for std::thread::panicking().

1 Like

That would cover the panicking case, but not the case where drop is called due to returning Result::Err. Also it looks like Rust may have copied the same C++ design mistake: panicking should return usize instead of bool, because catch_unwind could be used inside a destructor that is running in response to a panic, and you only want to suppress deleting the file if you are an extra unwind deep when drop runs compared to when the TempFile was constructed. C++ started with a bool as well with std::uncaught_exception and then later introduced std::uncaught_exceptions (plural).

Well at least for you testing example you should be able to use

#[cfg(test)]

or it's negation to achieve something similar.

I believe you can't detect arbitrary code being run and arbitrary values being constructed from independent code.

I guess the idiomatic solution for checking error types would be either to create an all-encompassing error enum, or to achieve the same effect using dyn Error and downcasting.

I'm not sure what you mean, how does having a more encompassing error enum help you know if you're running drop due to an error from inside drop?

Hmm, I didn't catch that requirement. However, since drop is infallible, I believe there is no way you can actually communicate an error outward from Drop other than via panicking.

I guess you can get some inspiration from implementations of database connections:

  • store a "success" flag, defaulted to false, in your Drop type, wherever this is relevant
  • have the user explicitly mark your instance as successful in a consuming method (this is the equivalent of Transaction::commit())
  • if the drop impl sees that the flag is still set to false, then the destructor must have been called due to unsuccessful early exit.
1 Like