Merging errors during cleanup failures

Hello. Thanks for your work on the Mojo language.

Right now, Mojo does not merge exceptions by default when multiple exceptions are raised, especially during cleanup failures. May it change in a future version of Mojo?

Python is designed to merge exceptions by default when multiple exceptions are raised. For example, in each of the 3 below codes, the error message contains the lines:

  • Exception: regular error
  • During handling of the above exception, another exception occurred:
  • Exception: cleanup failure

First code:

try:
    raise Exception("regular error")
finally:
    raise Exception("cleanup failure")

Second code:

class Context:
    def __enter__(self):
        return self
    def __exit__(self, exc_type, exc_value, traceback):
        raise Exception("cleanup failure")

with Context():
    raise Exception("regular error")

Third code:

class AsyncContext:
    async def __aenter__(self):
        return self
    async def __aexit__(self, exc_type, exc_value, traceback):
        raise Exception("cleanup failure")

async def main():
    async with AsyncContext():
        raise Exception("regular error")

import asyncio
asyncio.run(main())

In Mojo, syntactic sugar over merging exceptions with finally seems challenging because the user can create its own error types which may not support merging with other errors. But merging errors is easier with context managers.

In Errors, error handling, and context managers, I read that Mojo allows to write several __exit__() methods. When there is an error as an argument, __exit__() can return True, return False or raise a new error. Raising a new error gives the potential to merge errors if a cleanup failure occurs.

But I saw that FileHandle does not have an __exit__ method and that FileHandle.__deinit__ discards the potential error raised by self.close(). The current design is similar to C++ and Rust which silent errors in destructors in their standard libraries.

This FileHandle design decision seems to come from the current Mojo Error structure which does not seem to be designed for multiple errors, although we can tinker with the error message to include multiple suberror messages. But why is the current Error structure like this?

In Mojo vision, I read that Any deviation from Python requires a strong, mission-driven justification. Also, in the current Mojo design, the presence of @explicit_destroy suggests that Mojo’s resource cleanup aims to be better than C++ and Rust.

What is the rational about the current error handling design? May it change in a future version of Mojo?

Remark: This message focuses on the fallible cleanups, but merging multiple errors has other use cases, for example:

  • spawning several parallel fallible tasks, waiting for their completion and collect all errors and
  • parsing multiple parts from an input and collect all parsing errors.

About the syntactic sugar over merging exceptions with finally, I just edited my message to add a hint (traits to allow errors to be mergeable) but I don’t have a specific design in mind. Context managers are easier.

I just deleted the sentence about the trait to allow errors to be mergeable, because I just read Customizable Type Merging in Mojo and discovered __merge_with__.

I see __merge_with__ is defined in Span, StringSpan and Pointer.

So __merge_with__ coud also be used to define error merging when an exception is raised in finally.

But this would require to choose what to do if __merge_with__ is not defined (discarding an exception? compilation error?)

In the future, we may have a way to perform explicit conversions between error types. For now, if you raise different types we have to treat them as different and thus a violation of the specified API.

Trying to “figure it out” would explode compile times, because every function call that raises would need to figure out every single error that could be raised through it.

Most likely, a more ergonomic solution will come with sum types (Rust-style enums) in the future. For now, we don’t want to do something quick to make things a little nicer then be stuck with that for 20 years.

I just realized __merge_with__ does not fit to merge errors because it does not merge 2 values: it just finds a commun upper type. I wrote my previous message too quickly. My bad.

Maybe trying to make finally looking more like Python does not worth it.

But I still think allowing to merge 2 values of type Error into a single value of type Error which aggregates the error messages seems worthy.

Then, any context manager defining def __exit__(self, error: Error) raises -> Bool would be allowed to merge a cleanup failure of type Error with the error parameter and raise the new merged error.

For example, FileHandle as a context manager would not have to discard an exception raised by self.close().

Merging multiple errors has other use cases, for example:

  • spawning several parallel fallible tasks, waiting for their completion and collect all errors and
  • parsing multiple parts from an input and collect all parsing errors.

Rust does this through the Questionmark operator which performs conversion through the From trait if applicable. Try blocks will have no conversion though, because of inference issues.

So the compiler could insert (implicit) constructors, but try catch does a relatively good job if you really need to bridge between error types.
As long Error is just a String with a Backtrace and not a wide pointer, I don’t really see the use case for this. So mojo would need some sort of Errorable trait.

Error sets would also be a higher level goal, but my guess is, mojo would have to decide between implicit error conversions and error sets to avoid an exponential algorithm.

Right now, we don’t actually stop you from raising an integer if you want to (for example if you wanted to raise the errno value from C FFI). By convention, anything you raise should be Movable & Writable, but we may clamp down on that over time. We have that default type so we have something to hang stack traces off of when people raise strings, but you can completely ignore it if you want to.

Error sets will likely come for free as part of sum types.

I am not sure if they will be “free”, but they would theoretically be possible. They would have weird ordering semantics, so the type system would have to figure this out first and then they would stil be backed by some weird intrinsic like Variant.

It’s a good thing you can raise strings, but interoperability completely falls apart. Just saying, I am sure you all know that.

I think the main motivation for an Error trait is that you can do runtime reflection on the type id and optional error chaining like Go and Rust. Writable as a bound for error would mostly be a policy decision. Otherwise Error would just be a marker trait like Swift. Though I guess it also has something to do with ergonomics. For example the main function handler could use the Writable impl. It also depends on, whether dynamic trait reflection will be possible, which would require some decision about runtime metadata.

The interesting question is, whether raising types would have to conform to an Error trait. Implicit constructors for raising types would not require this, since they are just constructors the compiler inserts implicitly. I am not really sure how useful this would be as most errors would be strings, but I guess custom errors would still be common. In Rust this is mainly used to convert to Box<dyn Error>. But I mean this is orthogonal. If people at some point complain, that they have to write too many try except blocks, mojo could add this.

Mojo doesn’t currently have an equivalent, hence the temporary stand-in. I suspect that in the future we’ll move to that or an equivalent for bare raises.

Mojo error handling in general has some ergonomics issues that show up when you try to rigorously handle errors, so I suspect we’ll end up with something like var r: Result[...] = try raising_func(...). This is especially true since raises and async/await have some “fun” interactions if you want to handle them literally (aka an individual poll may raise).

I am not convinced we need an language feature for that.
Swift does this with 2 clean methods:

Result.get: def(Result[T, Err]) raises Err -> T
Result.init: def(def() raises Err -> T) -> Result[T, Err]