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.
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
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
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: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.
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 isAfter executing a
Call
, it's result is passed on tocont
until it's aDone
, in which case the program stops.The second way of sequencing is using
IO/bind
. It completely executes a chain ofCall
s and then passes the result to the next IO chain.What
IO/done_on_err
currently does is discard the continuations of aCall
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 theirCall
chain. The result is that a value of typeResult
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 asbind
but is meant for stopping earlyIt could for example be used in a
try
block of some sorts that usestry_bind
instead ofbind
, or we could individually write a "try ask" syntax likea? <- IO/action_that_can_fail
.Another approach would be using a function that wraps around the next action like
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
theread_file
example would become something like this (haven't tested this, probably something wrong), which is not legible: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 anError
variant that returns early. Then, instead of raw IO calls returningIO(Result(A, IOError(B))
, they'd simply returnIO(A, B)
whereA
is the type of the expected value andB
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 HVMI 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