Domain errors with HasCallStack

Here’s my latest article, about how to use HasCallStack for error messages in embedded domain specific languages.

8 Likes

This feels unnatural.

The constant function should have a “position” argument, and for this particular case the position is getCallstack. Exceptions are not needed here at all (though I could go as far as to say they’re not needed ever when dealing with expected errors, but that’s just my view on things).

Also I would expect the size check on Data to be performed in a separate stage instead of being mushed together with all other future errors.

Sure, all those suggestions are fine. This is just a small sketch to present the idea of even carrying around CallStacks at all in EDSLs.

Quite a nice use case for cheap error locations! Works well for shallow embeddings.

Note that using error to throw an exception is not a particularly safe thing to do, because GHC’s optimisations are free to mask it with another imprecise error or a loop.

The latter version where you do throw should fare much better, because anything thrown through raiseIO# is considered a precise exception and will inhibit strictness analysis, for example.

What’s more, I think I would prefer it if errors where part of Assembly, i.e.

data Error = ...

data Assembly a
  = MkAssembly (forall es. Stream Constant es -> Stream Error es -> Eff es a)

and then you yield errors in constant or elsewhere. This has the advantage that you do not always need to capture the call stack, which might be expensive (if only in terms of memory residency).

Furthermore, I think it’s likely that one would want to differentiate between fatal errors (which halt the assembly pipeline immediately) and resumable errors (such as your “Wrong size constant”). Resumable errors can be yielded, but fatal errors would need to throw.

2 Likes

Yeah, agreed that imprecise exceptions are not the correct approach. To clarify for those less familiar with the area, “not particularly safe” here doesn’t mean unsafe like unsafeCoerce or unsafePerformIO, but it does mean it can be uncertain which exception is raised within pure code, for example error "foo" + error "bar" is not guaranteed to throw one or the other.

2 Likes

I’ve adopted this technique in the latest version of my cauldron dependency injection library.

One problem with the previous version was that, because wiring was checked at runtime, I didn’t have the source locations for wiring errors like I would have if wiring were stately checked by the compiler. It would print that a dependency required by the Bar constructor was missing, but not point to the source location of Bar.

Now, after storing the CallStacks, I can give nicer errors like

This constructor for a value of type G:
        CallStack (from HasCallStack):
          val, called at app/Main.hs:201:26 in cauldron-0.4.0.0-inplace-cauldron-example-wiring:Main
is missing the following dependencies:
- E

I’ve also learned how the function withFrozenCallStack might be useful: for the purpose of showing error locations, you want to store the CallStack only up to when it “enters” the library code, but not accumulate frames for internal calls within your library, which are irrelevant to the user. So in these cases it’s better to freeze the call stack. For example, if I remove a withFrozenCallStack in one of the functions and trigger an error, I get:

This constructor for a value of type G:                                                                                                                                            CallStack (from HasCallStack):
          val', called at lib/Cauldron.hs:1040:9 in cauldron-0.6.0.0-inplace:Cauldron
          val, called at app/Main.hs:204:18 in cauldron-0.6.0.0-inplace-cauldron-example-wiring:Main
is missing the following dependencies:
- Foo

where the line

          val', called at lib/Cauldron.hs:1040:9 in cauldron-0.6.0.0-inplace:Cauldron

is an internal call within the library and therefore noise.

2 Likes