HigherOrderCO / Bend

A massively parallel, high-level programming language
https://higherorderco.com
Apache License 2.0
17.51k stars 428 forks source link

`IO/done_on_err` doesn't work with `IO/bind` #697

Closed developedby closed 3 months ago

developedby commented 3 months ago

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 for IO errors are incorrect and the IO function do not handle failure correctly. 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 haven't looked into how to do this in a way that is not totally 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

System Settings

Bend commit 8246a50cdb0fed36e47e566460acab92597960f1

Additional context

No response

developedby commented 3 months ago

github bugged out i think and made two issues (#698)