Right now, exceptions in Aff use the underlying Error type of Eff, which is represented by native JS exceptions. There's quite a bit of machinery under the hood for propagating these exceptions in an async manner for very little utility. In my use of Aff, I've found true exceptions to be rare, and because they are largely opaque on the PureScript-side (which I think is a good thing) there's not much to do by catching them. Since the machinery is already there, we should think about generalizing exceptions in Aff, rather than fixing it to Error. That is:
data Aff e a
data Fiber e a
instance monadThrowAff :: MonadThrow e (Aff e)
instance monadErrorAff :: MonadError e (Aff e)
instance bifunctorAff :: Bifunctor Aff
How is this different than just using something like ExceptT? One, it is more efficient. Aff already has to propagate Eithers for results and error, so we already bake in ExceptT handling under the hood. Since we are already paying the cost of it, and not getting that much utility out of the current formulation, we might as well take advantage of it. Otherwise, it's largely a convenience. I don't think there's anything that can't be expressed by using an additional ExceptT layer.
What does this mean for the implementation? For that, I should clarify the three channels we use to propagate async state:
- The result channel (part of
step in the source code). This is just the normal happy path of Aff evaluation.
- The error channel (
fail in the source code). This is used to propagate recoverable exceptions like with throwError.
- The interrupt channel (
interrupt in the source code). This is an irrecoverable panic, which is currently only used with killFiber.
I'll also clarify the cases where we implicitly catch exceptions during the source of evaluation:
liftEff. All lifted Effs always have exceptions caught regardless of whether it carries an exception effect.
makeAff. We only catch the Eff which initiates the async effect.
The Error type is currently used on both the error channel and the interrupt channel. There isn't any part of the implementation that assumes the Error type for the error channel, so the implementation would largely remain as it is. The question is what do we do about catching exceptions? We'll still catch them, but the only channel available to us (for arbitrary Errors) in this case would be the interrupt channel. This implies that an uncaught exception within a lifted Eff would result in a panic of the current Fiber.
Note that you can still propagate Error on the error channel, since it is parametric, but Aff would not implicitly catch and propagate anything on the error channel. A user would need to explicitly use catchException in liftEff or makeAff. This does not limit any existing workflows.
For forked Fibers which have panicked, any observers (through joinFiber) would necessarily need to panic as well. That is, panics are infectious. Since killFiber users the interrupt/panic channel, killing a Fiber would result in its observers also panicking.
What about forked Fibers which have no observers (that is, nobody is waiting for it's result with joinFiber)? Currently, if a Fiber propagates an error or an interrupt, and no one is there to observe it, we rethrow it in a fresh, global stack using setTimeout. This ensures that exceptions are still observable by things like window.onerror or whatever node uses. However, if we have arbitrary user defined exceptions, we can't rethrow what's on the error channel since it isn't necessarily a instance of Error (though we can still rethrow interrupts). There are a couple of possibilities:
We'd likely need to do both, unless we want to require that all Aff contexts have a Supervisor, which would make sense.
/cc @jdegoes @hdgarrood @chexxor
Right now, exceptions in Aff use the underlying
Errortype ofEff, which is represented by native JS exceptions. There's quite a bit of machinery under the hood for propagating these exceptions in an async manner for very little utility. In my use of Aff, I've found true exceptions to be rare, and because they are largely opaque on the PureScript-side (which I think is a good thing) there's not much to do by catching them. Since the machinery is already there, we should think about generalizing exceptions in Aff, rather than fixing it toError. That is:How is this different than just using something like
ExceptT? One, it is more efficient. Aff already has to propagateEithers for results and error, so we already bake inExceptThandling under the hood. Since we are already paying the cost of it, and not getting that much utility out of the current formulation, we might as well take advantage of it. Otherwise, it's largely a convenience. I don't think there's anything that can't be expressed by using an additionalExceptTlayer.What does this mean for the implementation? For that, I should clarify the three channels we use to propagate async state:
stepin the source code). This is just the normal happy path of Aff evaluation.failin the source code). This is used to propagate recoverable exceptions like withthrowError.interruptin the source code). This is an irrecoverable panic, which is currently only used withkillFiber.I'll also clarify the cases where we implicitly catch exceptions during the source of evaluation:
liftEff. All lifted Effs always have exceptions caught regardless of whether it carries anexceptioneffect.makeAff. We only catch theEffwhich initiates the async effect.The
Errortype is currently used on both the error channel and the interrupt channel. There isn't any part of the implementation that assumes theErrortype for the error channel, so the implementation would largely remain as it is. The question is what do we do about catching exceptions? We'll still catch them, but the only channel available to us (for arbitraryErrors) in this case would be the interrupt channel. This implies that an uncaught exception within a lifted Eff would result in a panic of the currentFiber.Note that you can still propagate
Erroron the error channel, since it is parametric, but Aff would not implicitly catch and propagate anything on the error channel. A user would need to explicitly usecatchExceptioninliftEfformakeAff. This does not limit any existing workflows.For forked Fibers which have panicked, any observers (through
joinFiber) would necessarily need to panic as well. That is, panics are infectious. SincekillFiberusers the interrupt/panic channel, killing a Fiber would result in its observers also panicking.What about forked Fibers which have no observers (that is, nobody is waiting for it's result with
joinFiber)? Currently, if a Fiber propagates an error or an interrupt, and no one is there to observe it, we rethrow it in a fresh, global stack usingsetTimeout. This ensures that exceptions are still observable by things likewindow.onerroror whatever node uses. However, if we have arbitrary user defined exceptions, we can't rethrow what's on the error channel since it isn't necessarily a instance ofError(though we can still rethrow interrupts). There are a couple of possibilities:console.error.SupervisorAPI to observe uncaught exceptions (Extended supervisor API #132)We'd likely need to do both, unless we want to require that all Aff contexts have a
Supervisor, which would make sense./cc @jdegoes @hdgarrood @chexxor