Hi @bgamari! I have finished another round of review and identified a few potential improvements.
Proposal structure. At the moment, the "Proposed Change Specification" section contains a lot of information in addition to the specification itself. It's mixed with exposition and implementation-specific code snippets. Instead, I expect to see a concise list (bulleted or numbered) of proposed changes, as seen from the end-user perspective. Undoubtedly, the rest of the prose is also valuable, so I ask you not to remove it but to distribute it across other sections of the proposal text.
Make
ExceptionContext
opaque. The proposal defineExceptionContext
as a wrapper around[SomeExceptionAnnotation]
, but should we expose this fact to our users? What if we decide to store additional information there? I believe it would be best not to export the data constructor to allow for future changes.Remove the
Semigroup
instance forExceptionContext
. The annotations are stored in a list, so(<>)
is O(n), and that's not good. Furthermore, I can't think of a use case for appending annotations from different exceptions, so there's a simple way to address this: do not defineSemigroup
orMonoid
instances.Semantics of
ExceptionContext
. The list ofSomeExceptionAnnotation
is ordered, so we ought to define how users can interpret this order. What does it mean for one annotation to come before another? Here's an idea: introduce an operation similar tocheckpoint
fromannotated-exceptions
:annotateIO :: ExceptionAnnotation a => a -> IO r -> IO rThe order of
SomeExceptionAnnotation
would reflect the nesting of calls toannotateIO
, reflecting the call stack in its own way (separately from the collected backtraces).Group backtraces in a record. If we say that a single element in
SomeExceptionAnnotation
corresponds to a call toannotateIO
, it is somewhat strange to add multiple annotations for a single event of collecting backtraces. After all, what would it mean for theHasCallStack
backtraces to come before theCostCentre
backtrace or vice versa? So introduce a record to store them together:data Backtraces = Backtraces { costCentreBacktrace :: Maybe (Ptr CostCentreStack), hasCallStackBacktrace :: Maybe GHC.Stack.CallStack, executionBacktrace :: Maybe [GHC.ExecutionStack.Location], ipeBacktrace :: Maybe [StackEntry] }(I refine this idea in the next suggestion)
Encode the
Backtraces
record with GADTs. The record typeBacktraces
that I suggest in the previous point introduces a slightly annoying form of code duplication. The proposal already has an enumeration of backtrace mechanisms:data BacktraceMechanism = CostCenterBacktraceMech | ExecutionStackBacktraceMech | IPEBacktraceMech | HasCallStackBacktraceMechAnd in the record, we have a field per mechanism, each wrapped in a
Maybe
. Fortunately, there is an encoding that removes this duplication. Let us indexBacktraceMechanism
by the representation type of the backtrace:data BacktraceMechanism a where CostCentreBacktrace :: BacktraceMechanism (Ptr CostCentreStack) HasCallStackBacktrace :: BacktraceMechanism GHC.Stack.CallStack ExecutionBacktrace :: BacktraceMechanism [GHC.ExecutionStack.Location] IPEBacktrace :: BacktraceMechanism [StackEntry]Now we can encode the set of enabled mechanisms as a function to
Bool
and the record of collected backtraces as a function toMaybe a
. Something along the lines of:type EnabledBacktraceMechanisms = forall a. BacktraceMechanism a -> Bool type Backtraces = forall a. BacktraceMechanism a -> Maybe aThis isn't as low-tech as a an enum and a record, and I realize that low-tech solutions are appealing in their own way, but in this particular case, I find that GADTs offer a more elegant encoding.
Hide the implicit parameter behind a synonym. We choose to pass around the exception context as an implicit parameter, but this should be hidden from the end user. This is the way it's done with
HasCallStack
, where documentation clearly states:NOTE: The implicit parameter
?callStack :: CallStack
is an implementation
detail and should not be considered part of the CallStack API, we may
decide to change the implementation in the future.Let's do the same and introduce a synonym for exception contexts:
type HasExceptionContext = (?exceptionContext :: ExceptionContext)Preserve backtraces on rethrowing. The way the proposal is currently written, when an exception is caught and rethrown, its old backtrace is dropped and a new one is constructed. This is very bad, because rethrowing happens all the time (e.g. in
bracket
)! But it is not hard to fix:catch
should provide the context to the handler as an implicit parameter, andthrow
should make use of it:catch :: Exception e => IO a -> (HasExceptionContext => e -> IO a) -> IO a throwIO :: (HasExceptionContext, Exception e) => e -> IO aWhat about uses of
throwIO
whereHasExceptionContext
has not been provided bycatch
? Easy: default to an empty context. It shouldn't be hard to add this special case to the solver, sinceHasCallStack
has already set a precedent.Get rid of
toExceptionWithContext
. The proposal introduces a new method toException
:toException :: Exception e => e -> SomeException -- old toExceptionWithContext :: Exception e => e -> ExceptionContext -> SomeException -- newBut if we are passing the context as a constraint, then we could simply add it to the original method:
toException :: (HasExceptionContext, Exception e) => e -> SomeExceptionThere is no need for two methods this way.
Improvements to
throwIONoBacktrace
. Currently the proposal definesNoBacktrace
variants ofthrow
andthrowIO
:throwNoBacktrace :: forall e a. (Exception e) => e -> a throwIONoBacktrace :: forall e a. (Exception e) => e -> aThe idea is to allow users to opt out of backtraces for non-exceptional control flow. But the problem is that this choice is not recorded anywhere in the exception, so when the exception is caught and rethrown, it will have unwanted backtraces added to it.
One solution is to add a
Bool
flag toExceptionContext
to record the choice of not collecting the backtraces, so that they are not collected when the exception is rethrown. In fact, we could avoid the duplication ofthrow
andthrowIO
this way:backtracesEnabled :: HasExceptionContext => Bool enableBacktraces, disableBacktraces :: (HasExceptionContext => r) -> (HasExceptionContext => r) throw :: (HasExceptionContext, Exception e) => e -> IO a throwIO :: (HasExceptionContext, Exception e) => e -> IO a
throw
andthrowIO
can check forbacktracesEnabled
before callingcollectBacktraces
. The users would write something along the lines of:disableBacktraces $ throwIO MyControlFlowExceptionAnd the choice to disable backtraces would be carried along the exception in its context.
Hopefully, I have not missed anything. In the process of writing this review, I took a stab at rewriting the "Proposed Change Specification" section in accordance with all of the suggestions above, mainly to convince myself that the combination of those suggestions forms a coherent design. You can find it here: https://gist.github.com/int-index/750c6c292eb8266729adc61a5812a581. If you agree with my comments, feel free to incorporate the updated specification (or parts of it) into the proposal.
Thank you!
—
Reply to this email directly, view it on GitHub, or unsubscribe.
You are receiving this because you are subscribed to this thread.