Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

IO/done_on_err doesn't work with IO/bind #698

Open
developedby opened this issue Aug 27, 2024 · 2 comments
Open

IO/done_on_err doesn't work with IO/bind #698

developedby opened this issue Aug 27, 2024 · 2 comments
Labels
bug Something isn't working

Comments

@developedby
Copy link
Member

developedby commented Aug 27, 2024

Reproducing the behavior

IO/done_on_err was added to the prelude with the intent of being a function that stops the current block of monadic IO actions, similar to how ? works in rust for example.

For example, this is how IO/FS/read_file uses it:

def IO/FS/read_file(path):
  with IO:
    fd <- IO/done_on_err(IO/FS/open(path, "r"))
    bytes <- IO/done_on_err(IO/FS/read_to_end(fd))
    * <- IO/done_on_err(IO/FS/close(fd))
    return wrap(bytes)

The idea is that if the result of any of those actions fails, we bail out early and return the error. This is meant as a simpler way of writing nested matches that in the future we could transform into a syntax sugar.

def IO/FS/read_file(path: String) -> IO(Result(List(u24), u24)):
  with IO:
    fd <- IO/FS/open(path, "r")
    match fd:
      case Result/Ok:
        fd = fd.val
        bytes <- IO/FS/read_to_end(fd)
        match bytes:
          case Result/Ok:
            bytes = bytes.val
            res <- IO/FS/close(fd)
            match res:
              case Result/Ok:
                return wrap(bytes)
              case Result/Err:
                return wrap(Result/Err(res.val))
          case Result/Err:
            return wrap(Result/Err(bytes.val))
      case Result/Err:
        return wrap(Result/Err(fd.val))

The problem is that with the way it's implemented (and using any similar strategy) it does not exit early from actions sequenced via an IO/bind.

There are two way of sequencing IO actions. One is by setting up a continuation inside the IO value itself.
The definition of the IO type is

type IO(T):
  Done { magic, expr }
  Call { magic, func, argm, cont }

After executing a Call, it's result is passed on to cont until it's a Done, in which case the program stops.

The second way of sequencing is using IO/bind. It completely executes a chain of Calls and then passes the result to the next IO chain.

What IO/done_on_err currently does is discard the continuations of a Call in case the returned value was an error. It does not stop the execution of the bind continuation because it can only act on the IO result it's wrapping.

In the read_file example above, done_on_error is simply not doing anything since all the values it wraps already have only a single action in their Call chain. The result is that a value of type Result is incorrectly passed to the next actions and everything will break due to type errors.

As a result, all the tests that should handle an IO failure are giving incorrect result and most of the IO functions are broken on failure.
Here are some alternatives to the done_on_error approach:


Using a try_bind function that does the same thing as bind but is meant for stopping early

IO/try_bind (IO/Done (Result/Ok x)) b = (undefer b x)
IO/try_bind (IO/Done (Result/Err x)) b = (IO/Done (Result/Err x))
IO/try_bind (IO/Call magic fn args cont) b = (IO/call magic fn args @x (IO/try_bind (cont x) b))

It could for example be used in a try block of some sorts that uses try_bind instead of bind, or we could individually write a "try ask" syntax like a? <- IO/action_that_can_fail.


Another approach would be using a function that wraps around the next action like

IO/and (a: Result(A, E), b: A -> IO(Result(B, E))) -> IO(Result(B, E))
IO/and (Result/Ok a) b = (undefer b a)
IO/and (Result/Err e) _ = (IO/wrap (Result/Err e))

Although this would work, it would be pretty bad to write. Potentially there could be some syntax sugar or better alternative function we could use, but with and the read_file example would become something like this (haven't tested this, probably something wrong), which is not legible:

def IO/FS/read_file(path: String) -> IO(Result(List(u24), u24)):
  with IO:
    fd <- IO/FS/open(path, "r")
    bytes_fd <- IO/and(fd, lambda fd: IO/map(IO/FS/read_to_end(fd), Result/map(@x (x, fd))))
    # would need a way to access the tuple fields in an expression
    res <- IO/and(bytes_fd, lambda bytes_fd: IO/map(IO/FS/close(bytes_fd.1), Result/map(@x (x, bytes_fd.0))))
    return IO/and(res, @res wrap(res.0))

I can't think of any good ways of doing a function like this in the imperative syntax.


There could be a way of composing two monads. I haven't looked into it, but we could create a monad that is the composite of IO with Result. Seems totally doable, but I don't know how to do this in a way that is not confusing for most users.


The final option would be to change the IO type to have an Error variant that returns early.
Then, instead of raw IO calls returning IO(Result(A, IOError(B)), they'd simply return IO(A, B) where A is the type of the expected value and B is the type of the error.
This option is similar to how haskell does it, for example. It would also require that we change the error type returned by HVM

I like the last two options because they don't require new syntax for making the IO error handling bearable for users, but I'm open to all ideas.

Although we technically could do the initial IO release with things how they are now, this issue may require very large changes to all the IO in the future, so it hsould probably go in the IO v0 milestone.

System Settings

Bend commit 8246a50

Additional context

No response

@developedby developedby added the bug Something isn't working label Aug 27, 2024
@developedby developedby added this to the Bend IO lib v0 milestone Aug 27, 2024
@developedby
Copy link
Member Author

The advantage of having an explicit "try" block is that it becomes easy to add error/exit handlers to do things like close files.

In an imperative syntax, to do that without something like try ... finally you'd need to declare a local function that does just the part that fails with the resource and them call it, catch the potential errors and release the resources.

Of course, a catch or a finally could be added as syntax to any of the alternative I proposed above.

@developedby
Copy link
Member Author

We could add a syntax for mapping the continuation of a bind. I think it could work both before and after executing the bind.

# Syntax-wise this is not great, but I'm trying to show the idea.
def IO/FS/read_file(path):
  with IO:
    fd <- IO/FS/open(path, "r") |> IO/done_on_err
    bytes <- IO/FS/read_to_end(fd) |> IO/done_on_err
    * <- IO/FS/close(fd) |> IO/done_on_err
    return wrap(bytes)

It's a function that receives the result of the action and the continuation of the bind and returns a new continuation that does something in the middle.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

No branches or pull requests

1 participant