Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Redesign for exceptions #285

Closed
alt-romes opened this issue Sep 20, 2024 · 51 comments
Closed

Redesign for exceptions #285

alt-romes opened this issue Sep 20, 2024 · 51 comments
Labels
approved Approved by CLC vote

Comments

@alt-romes
Copy link

alt-romes commented Sep 20, 2024

CLC Proposal for Exceptions Redesign

Part 1

Recently, #231 and #261, two proposals regarding exceptions and backtraces, have been accepted and implemented.
These proposals display new information on thrown exceptions: type, module, and package of the exception that was thrown.

However, I believe the formatting/layout of this new information, together with the changes added to the header of the exception message, cause the exception message as a whole to be confusing, unhelpful, and noisy -- undoing the benefits of adding this information.

Example

main = error "this list should never be empty"

Compiled with GHC head (with all implemented proposals), the program will output the following:

Main: Exception:

this list should never be empty
CallStack (from HasCallStack):
  error, called at Main.hs:1:8 in main:Main

Package: ghc-internal
Module: GHC.Internal.Exception
Type: ErrorCall

HasCallStack backtrace:
  collectBacktraces, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:92:13 in ghc-internal:GHC.Internal.Exception
  toExceptionWithBacktrace, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:128:3 in ghc-internal:GHC.Internal.Exception

Diagnosis

I think this message is problematic

  • It's hard to relate any of the lines Package: ghc-internal, Module: GHC.Internal.Exception, or Type: ErrorCall to the program that we wrote. What does it mean?!
    • Unlike the example in the accepted proposals BlockedIndefinitelyOnMVar, ErrorCall is a generic name of a much more common exception type which to the user likely reads as something internal leaking -- it's hard to connect it to the type of the exception that is being thrown.
    • This information is given entire paragraphs below the line saying Exception -- it's hard to link these two bits. It relates more easily with the HasCallStack backtrace below which at least also refers ghc-internal.
    • Using three separate lines wastes screen space in a message that is ideally compact, but, perhaps more importantly, it makes the three lines seem unrelated
      • The whitespace around every paragraph only seems needed to compensate for that to try and group them.
    • Spelling out Package, Module, and Type is also redundant since this information is only for developers who would just as well understand the triple <package>:<module>.<type>.
  • The leading Main: Exception: is also confusingly short when compared with how much whitespace and other blocks of text there are -- it's just hanging up there instead of relating to the exception type!

This confusion is exacerbated when exceptions are re-thrown according to #202 (example below)

Proposal

Considering this, I believe the solution is straightforward and both makes the message prettier and responds to my critiques above. Here are the key points:

  • The header of the message should relate the exception that was thrown, and its type, all in the same line. The message can be inline or in the next line.
    • Using the compact full representation of the type: <package>:<module>.<type>
    • Connect this type to it being the type of the exception that was thrown (not just some random internal type -- recall, internal exceptions type are seldom known to users, like ErrorCall, and would be perceived as such)
    • Being more compact and relating the error type to its message is what other languages do
      • Java:
        Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "<local1>" is null
           at GFG.main(J.java:13)
        
      • Python
        Traceback (most recent call last):
          File "/private/var/folders/tv/35hlch6s3y15hfvndc71l6d40000gn/T/tmp.tUIx5JL667/x.py", line 4, in <module>
            f(None)
          File "/private/var/folders/tv/35hlch6s3y15hfvndc71l6d40000gn/T/tmp.tUIx5JL667/x.py", line 2, in f
            x[0]
            ~^^^
        TypeError: 'NoneType' object is not subscriptable
        

So here's a suggestion for what the message should instead look like:

T24807: Uncaught exception ghc-internal:GHC.Internal.Exception.ErrorCall:

this list should never be empty
CallStack (from HasCallStack):
  error, called at T24807.hs:1:8 in main:Main

HasCallStack backtrace:
  collectBacktraces, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:92:13 in ghc-internal:GHC.Internal.Exception
  toExceptionWithBacktrace, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:127:5 in ghc-internal:GHC.Internal.Exception

Suggestion applied to example from the original proposal

With the original proposal:
λ. ./Main
Main: Exception:

thread blocked indefinitely in an MVar operation

Package: ghc-internal
Module: GHC.Internal.IO.Exception
Type: BlockedIndefinitelyOnMVar

HasCallStack backtrace:
  collectBacktraces, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:92:13 in ghc-internal:GHC.Internal.Exception
  toExceptionWithBacktrace, called at libraries/ghc-internal/src/GHC/Internal/IO.hs:260:11 in ghc-internal:GHC.Internal.IO
  throwIO, called at Main.hs:33:16 in main:Main
With this proposal:
λ. ./Main
Main: Uncaught exception ghc-internal:GHC.Internal.IO.Exception.BlockedIndefinitelyOnMVar:

thread blocked indefinitely in an MVar operation

HasCallStack backtrace:
  collectBacktraces, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:92:13 in ghc-internal:GHC.Internal.Exception
  toExceptionWithBacktrace, called at libraries/ghc-internal/src/GHC/Internal/IO.hs:260:11 in ghc-internal:GHC.Internal.IO
  throwIO, called at Main.hs:33:16 in main:Main

Interaction with rethrown exceptions

When re-thrown exceptions are thrown into the mix it's worse because these large blocks appear for every "while handling":
cgrun025: Exception:

hello, error
CallStack (from HasCallStack):
  error, called at cgrun025.hs:25:75 in main:Main

Package: ghc-internal
Module: GHC.Internal.Exception
Type: ErrorCall

While handling __WURBLE__: getEnv: does not exist (no environment variable)

  Package: ghc-internal
  Module: GHC.Internal.IO.Exception
  Type: IOException
  
  HasCallStack backtrace:
    collectBacktraces, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:92:13 in ghc-internal:GHC.Internal.Exception
    toExceptionWithBacktrace, called at libraries/ghc-internal/src/GHC/Internal/IO.hs:284:11 in ghc-internal:GHC.Internal.IO
    throwIO, called at libraries/ghc-internal/src/GHC/Internal/IO/Exception.hs:315:19 in ghc-internal:GHC.Internal.IO.Exception
    ioException, called at libraries/ghc-internal/src/GHC/Internal/System/Environment.hs:192:26 in ghc-internal:GHC.Internal.System.Environment

HasCallStack backtrace:
  collectBacktraces, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:92:13 in ghc-internal:GHC.Internal.Exception
  toExceptionWithBacktrace, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:128:3 in ghc-internal:GHC.Internal.Exception

After the proposal, the re-thrown exception example would become:
cgrun025: Uncaught exception ghc-internal:GHC.Internal.Exception.ErrorCall:

hello, error
CallStack (from HasCallStack):
  error, called at cgrun025.hs:25:75 in main:Main

While handling ghc-internal:GHC.Internal.IO.Exception.IOException:

    __WURBLE__: getEnv: does not exist (no environment variable)

    HasCallStack backtrace:
      collectBacktraces, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:92:13 in ghc-internal:GHC.Internal.Exception
      toExceptionWithBacktrace, called at libraries/ghc-internal/src/GHC/Internal/IO.hs:284:11 in ghc-internal:GHC.Internal.IO
      throwIO, called at libraries/ghc-internal/src/GHC/Internal/IO/Exception.hs:315:19 in ghc-internal:GHC.Internal.IO.Exception
      ioException, called at libraries/ghc-internal/src/GHC/Internal/System/Environment.hs:192:26 in ghc-internal:GHC.Internal.System.Environment

HasCallStack backtrace:
  collectBacktraces, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:92:13 in ghc-internal:GHC.Internal.Exception
  toExceptionWithBacktrace, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:127:5 in ghc-internal:GHC.Internal.Exception
  error, called at cgrun025.hs:25:75 in main:Main

Impact

The originally accepted proposal is yet unreleased. It will become available in 9.12 unless this proposal is accepted in the meantime.

Testsuites logging the stderr of haskell programs could suffer from changes in the exception messages, even if minor, so it would be ideal, if agreed upon, to get this change in before 9.12 is released.

Implementation

Done in https://gitlab.haskell.org/ghc/ghc/-/merge_requests/13280.
Note the differences in the testsuite accepted tests. Net reduction in 600 lines in exceptions printing.

Part 2

Secondly, I propose the implementation of displayException for SomeException to be:

displayException (SomeException e) = displayException e

This provides transparency of SomeException when working with the Exception class methods.
Currently, displayException of SomeException will additionally:

  • Display the type of the exception
  • Display the backtrace context of the exception

This information should be moved to a separate method, displayExceptionWithInfo, which is used by default by uncaughtExceptionHandler -- the handler responsible for printing to the user uncaught exceptions.

Updating the default handler and the instance guarantees that exceptions by default still get printed with the type and backtrace, as they currently do. However, developers who want to provide user-facing exceptions can override the default exception handler to opt-out of this information irrelevant to the user.

With catching/re-throwing exceptions, we just have to make sure that the instance of Exception WhileHandling uses displayExceptionWithInfo to print callstacks and type information of WhileHandling exceptions.

The examples listed in Part 1 don't change according to Part 2 by default.

Part 3

Since #164 was implemented, we display by default the backtrace of all exceptions. However, the ErrorCall exception type used in the implementation of error and undefined keeps a callstack manually. This results in a buggy output where we get a redundant callstack (seen in the first example of Part 1).

I propose that ErrorCall stops propagating a callstack manually and completely delegate the callstack handling to the backtrace mechanism.

Applied to the first example resulting from Part 1:

T24807: Uncaught exception ghc-internal:GHC.Internal.Exception.ErrorCall:

this list should never be empty
CallStack (from HasCallStack):
  error, called at T24807.hs:1:8 in main:Main

HasCallStack backtrace:
  collectBacktraces, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:92:13 in ghc-internal:GHC.Internal.Exception
  toExceptionWithBacktrace, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:127:5 in ghc-internal:GHC.Internal.Exception
  error, called at T24807.hs:1:8 in main:Main

With Part 3, we'd instead get:

T24807: Uncaught exception ghc-internal:GHC.Internal.Exception.ErrorCall:

this list should never be empty

HasCallStack backtrace:
  collectBacktraces, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:92:13 in ghc-internal:GHC.Internal.Exception
  toExceptionWithBacktrace, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:127:5 in ghc-internal:GHC.Internal.Exception
  error, called at T24807.hs:1:8 in main:Main

GHC-issue: 25283
Implemetation: I've already got a commit which does this, but essentially we drop the ErrorCall call pattern synonym and make ErrorCall the only constructor of ErrorCall.

Part 4

Currently, the callstacks include unnecessary internal details of functions called in the implementation of error, undefined, throwIO, ioException, ioError.

I propose we freeze the call stack at these functions. The example above would now stop the callstack at error, hiding the unnecessary traces of toExceptionWithBacktrace and collectBacktraces.

T24807: Uncaught exception ghc-internal:GHC.Internal.Exception.ErrorCall:

this list should never be empty

HasCallStack backtrace:
  error, called at T24807.hs:1:8 in main:Main

Part 5

Since I'm already changing this code, let's finally remove errorWithStackTrace, which has been deprecated since GHC 8.0 (2015), i.e. for 9 years.

This is not uncontentious, so I'm dropping this suggestion.

@alt-romes alt-romes changed the title Formatting exceptions better Redesign for displaying exceptions Sep 20, 2024
@alt-romes
Copy link
Author

I'm going to ping those involved in the original message design to initiate discussion: @tomjaguarpaw @tbidne @adamgundry @parsonsmatt

@tomjaguarpaw
Copy link
Member

That seems fine by me.

@tbidne
Copy link

tbidne commented Sep 24, 2024

The rethrown example is compelling. Agreed that this is an improvement. To keep discussion centralized, I'll respond to @bgamari's questions on the original MR here:

Exception type data added to SomeException's displayException, rather than the handler.

it is not clear to me why this implementation approach was taken. This doesn't appear to be consistent with what was proposed in the CLC proposal and means that users have no way of opting out of this output. Perhaps you can shed some light on the reasoning here?

  1. That decision was based on the discussion in the 2nd issue. The (light) consensus was that changing the format from:

    <msg>
    
    <backtraces>
    
    <type>
    

    to

    <msg>
    
    <type>
    
    <backtraces>
    

    improved clarity, and doing so required moving the type info to the instance, since that is where msg and backtraces were handled.

    This PR changes it to

    <type>
    
    <msg>
    
    <backtraces>
    

    so AFAICT it would be possible now to move the <type> to the handler instead of the instance. But perhaps this is less important now since the message is far more compact. Moreover, given that we can have multiple exceptions in play with WhileHandling, I think it makes sense to leave it on the instance since that would ensure each exception receives the type info.

  2. As far as "opting out", that would of course be possible with a global mechanism like setBacktraceMechanismState, but it's more complexity that no one asked for at the time, and hopefully it's less important with the proposed concise output.

@adamgundry
Copy link
Member

Thanks for taking a careful look at the quality of exception messages @alt-romes, I think this is very valuable.

On reflection, it does seem to me problematic for displayException to show anything other than the user-friendly message, because it is the normal mechanism for developers to use when rendering exceptions for end users, and they may do so in contexts where backtraces/types are not desired. So I think displayException for SomeException should give only the displayException of the underlying exception, but we could have another function that displays the full glorious details, expose that function to users and call it in the default handler. I regret not picking up on this in the previous discussion.

@alt-romes alt-romes changed the title Redesign for displaying exceptions Redesign for exceptions Sep 24, 2024
@alt-romes
Copy link
Author

I'm currently making an effort to get exceptions as a whole in a better state before the 9.12 release.

As I've come to realize when implementing WhileHandling/re-throwing of exceptions, there are a few other minor bugs and improvements hanging around that I believe we should act on swiftly, but which do require GHC proposals.

@adamgundry suggested I try to keep the discussion within a single CLC proposal for all the incremental improvements to the exceptions. Accordingly, I've updated this proposal with additional part 2 to 5.

Thank you for commenting on the proposal for better exception messages already. The part 1 of the exception (which you originally read) is kept unchanged. I would appreciate it if you could comment on the new parts of the proposal for improving the exceptions as a whole.

I've implemented these suggestions in this MR

@tomjaguarpaw
Copy link
Member

I don't use exceptions myself1 so I'll defer to exception users regarding what they need. However, I am surprised about this:

[displayException] is the normal mechanism for developers to use when rendering exceptions for end users

I always understood than an uncaught exception was considered hard abort, if not programmer error: something has gone unexpectedly wrong. In such cases one typically wants as much information as possible dumped to the screen to help with debugging or bug reporting. That seems to me approximately what displayException is currently providing, and I thought that was good. But am I now to understand that displayException is used for formatting error messages intended to be read by end users? If so, should it really be? Why not instead catch the exception and format a nice message of your own choosing?

Footnotes

  1. At least not in the standard throw/throwIO and then catch in IO (or not) – instead I use well-scoped exceptions, i.e. Either/MTL/Bluefin etc.

@parsonsmatt
Copy link

If so, should it really be? Why not instead catch the exception and format a nice message of your own choosing?

The dynamic nature of SomeException means that you can do this, if you explicitly list out the types you expect to catch. And if you missed one, you need a different message.

foo `catches`
  [ Handler (\KnownException -> print0)
  , Handler (\(OtherKnownException a) -> print1 a)
  , Handler (\(SomeException unknown) -> putStrLn $ displayException unknown)
   ]

In that fallback case, all we "know" is (forall e. Exception e => e), which means we only have the methods on Exception and Show to do anything useful with it.

@alt-romes
Copy link
Author

alt-romes commented Sep 24, 2024

You implement displayException as part of instance Exception that describes how to print an exception. Then, when an exception is not caught, it will be handled by uncaughtExceptionHandler (which can be set with setUncaughtExceptionHandler).

By default, the uncaught excp. handler prints out as much information as possible, as you said. At the moment, this includes the type of the exception and the callstack.

However, if you have business-logic exceptions being thrown in your program, you may want to override the exception handler to print out the exception cleanly to the user in a way custom to your program.

The problem is the handler receives the exception wrapped in SomeException. You'd want to have displayException (SomeException e) = displayException e so that your definition of displayException is the one printed out.

But at the moment it's not. displayException @SomeException will also display type and backtrace.

The proposal is to define displayExceptionWithInfo in terms of displayException, but which wraps it with the type and callstack. Then, the default handler uses displayExceptionWithInfo which will print all the details. However, you can override the handler and call displayException -- opting out of the extra details

But am I now to understand that displayException is used for formatting error messages intended to be read by end users? If so, should it really be? Why not instead catch the exception and format a nice message of your own choosing?

You could have a top-level handler that tries to catch all exceptions relevant to the user and manually prints them differently. But I also think it is clean to have proper exceptions relevant to the user simply being surfaced to the top level and handled by the exception handler.

@tomjaguarpaw
Copy link
Member

By default, the uncaught excp. handler prints out as much information as possible, as you said. At the moment, this includes the type of the exception and the callstack.

Right, so far I understand.

However, if you have business-logic exceptions being thrown in your program, you may want to override the exception handler to print out the exception cleanly to the user in a way custom to your program.

But now I don't understand. Why override the exception handler? Why not catch the exception, and then do whatever you like with it? I feel I must be missing something that's very clear to everyone else.

You could have a top-level handler that tries to catch all exceptions relevant to the user and manually prints them differently. But I also think it is clean to have proper exceptions relevant to the user simply being surfaced to the top level and handled by the exception handler.

That doesn't seem clean to me at all. Since I don't use exceptions like this I may be missing something that seems obvious to others, but letting unhandled exceptions get to the default, top-level, handler, and expecting them to be printed nicely for users, doesn't seem, to me, like something we should be supporting.

@alt-romes
Copy link
Author

alt-romes commented Sep 24, 2024

That doesn't seem clean to me at all. Since I don't use exceptions like this I may be missing something that seems obvious to others, but letting unhandled exceptions get to the default, top-level, handler, and expecting them to be printed nicely for users, doesn't seem, to me, like something we should be supporting.

(Even if there is disagreement on the merits of this style of programming, the proposed solution enables strictly more styles while keeping everything the same for any user who doesn't touch the default uncaught exception handler).

I think SomeException should ideally be "transparent", since it's just an existential wrapper over an Exception. And with the proposal we do indeed get:

displayException (SomeException e) = displayException e

While keeping exactly the same all of the extra information when exceptions are uncaught and reported.

@adamgundry
Copy link
Member

But now I don't understand. Why override the exception handler? Why not catch the exception, and then do whatever you like with it?

One answer: the exception may be thrown in library code I don't control, on a thread forked by the library. There's no way AFAIK to add an exception handler in such a case. Obviously this is a bad/buggy library, but such libraries exist! Thus the application programmer needs to be able to specify a top-level exception handler to report the exception using whatever logging mechanism is appropriate for their application.

For (a not entirely hypothetical) example, I'm writing a concurrent web server with a multitude of request-handling and background worker threads. I can add a top-level handler so that if any of these threads dies with an exception, the exception gets serialised to JSON and sent to a cloud logging service. With @alt-romes's proposal, in the logging code I can just call displayException to get the human-friendly description to populate one field of the log object (plus populate other fields as needed e.g. with types/backtraces). Whereas with the status quo, I have to be careful to call displayException not at type SomeException but only on the inner exception object (otherwise I'll end up accidentally logging the backtraces in the "description" field).

@tomjaguarpaw
Copy link
Member

@alt-romes

(Even if there is disagreement on the merits of this style of programming, the proposed solution enables strictly more styles while keeping everything the same for any user who doesn't touch the default uncaught exception handler).

That sounds great! But I don't understand. This proposal suggests a change to the behaviour of the default uncaught exception handler, doesn't it?

I think SomeException should ideally be "transparent", since it's just an existential wrapper over an Exception. And with the proposal we do indeed get:

displayException (SomeException e) = displayException e

This seems pretty reasonable to me, because the thing that is thrown an caught is always a SomeException, right? And then we only check what instance of Exception it is by casting it with fromException. So it seems to me to never make sense to show the top-level SomeException wrapper. But I'm yet again confused: why was the wrapper ever shown? Is this a long-standing issue?

(To be honest I'm beginning to think that it would be easier to deal with this proposal in separate parts. Some parts, like this one, seem like they would be quite quick to resolve.)

@adamgundry

the exception may be thrown in library code I don't control, on a thread forked by the library. There's no way AFAIK to add an exception handler in such a case

I don't follow. Could you elaborate? Maybe with an example?

@alt-romes
Copy link
Author

alt-romes commented Sep 25, 2024

The change to the behavior of the default handler together with the change to displayException @SomeException keeps the program output on an uncaught exception exactly the same.

It's about moving the part adding the call stack to the handler from the instance, achieving all our goals without changing the default output.

@tomjaguarpaw
Copy link
Member

why was the wrapper ever shown?

Ah, looks like the wrapper was never shown, but the difference is that recently we added more stuff to the displayException of SomeException.

https://gitlab.haskell.org/ghc/ghc/-/commit/ff158fcd84f080a3450e09320819ef1d950a2c2d?page=2#03cfee2d5ff1e2f3ab92a8cb79be769da8656d93

Is the idea that should "more stuff" shouldn't be in the displayException of SomeException, but should rather be added by a handler? If so I think things are becoming clearer for me. Could you confirm?

The change to the behavior of the default handler together with the change to displayException @SomeException keeps the program output on an uncaught exception exactly the same.

Great, I must have missed that. I think that's worth calling out prominently and explicitly in the proposal.

@alt-romes
Copy link
Author

Is the idea that should "more stuff" shouldn't be in the displayException of SomeException, but should rather be added by a handler? If so I think things are becoming clearer for me. Could you confirm?

Yes, that's exactly right.

@michaelpj
Copy link

This looks good to me. I only have one small comment on Part 1 and the interaction with rethrown exceptions. It would be nice if the WhileHandling annotations were the last thing printed. As it is, you can see in your example that you have a substantial block for the WhileHandling exception, and then you have to "pop" your reading context back out to the top-level exception to read the backtraces. That is, I think it would be nice if we could get the following formatting for you example:

cgrun025: Uncaught exception ghc-internal:GHC.Internal.Exception.ErrorCall:

hello, error
CallStack (from HasCallStack):
  error, called at cgrun025.hs:25:75 in main:Main

HasCallStack backtrace:
  collectBacktraces, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:92:13 in ghc-internal:GHC.Internal.Exception
  toExceptionWithBacktrace, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:127:5 in ghc-internal:GHC.Internal.Exception
  error, called at cgrun025.hs:25:75 in main:Main

While handling ghc-internal:GHC.Internal.IO.Exception.IOException:

    __WURBLE__: getEnv: does not exist (no environment variable)

    HasCallStack backtrace:
      collectBacktraces, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:92:13 in ghc-internal:GHC.Internal.Exception
      toExceptionWithBacktrace, called at libraries/ghc-internal/src/GHC/Internal/IO.hs:284:11 in ghc-internal:GHC.Internal.IO
      throwIO, called at libraries/ghc-internal/src/GHC/Internal/IO/Exception.hs:315:19 in ghc-internal:GHC.Internal.IO.Exception
      ioException, called at libraries/ghc-internal/src/GHC/Internal/System/Environment.hs:192:26 in ghc-internal:GHC.Internal.System.Environment

@tomjaguarpaw
Copy link
Member

Is the idea that should "more stuff" shouldn't be in the displayException of SomeException, but should rather be added by a handler? If so I think things are becoming clearer for me. Could you confirm?

Yes, that's exactly right.

OK, great. I see this is actually spelled out in the following sentence:

Updating the default handler and the instance guarantees that exceptions by default still get printed with the type and backtrace, as they currently do.

but I think it would be worth calling out more explicitly that this change doesn't change the behaviour of the default handler.

I propose that ErrorCall stops propagating a callstack manually

Currently,

data ErrorCall = ErrorCallWithLocation String String

(see https://www.stackage.org/haddock/lts-22.35/base-4.18.2.1/Control-Exception.html#t:ErrorCall) Are you suggesting changing this constructor? Perhaps changing it back to just ErrorCall String? (There is a already a pattern synonym ErrorCall.)

Since I'm already changing this code, let's finally remove errorWithStackTrace, which has been deprecated since GHC 8.0 (2015), i.e. for 9 years.

I'm against this. errorWithStackTrace does not require any ongoing support or maintenance. There is a chance (albeit probably tiny) that removing it could break someone's code and make them hate Haskell, and I don't see why we should take that risk. I appreciate the desire to "tidy up", so as a compromise I would accept the following, which at the point of use tells the user exactly what they have to do to fix their code:

errorWithStackTrace ::
  Unsatisfiable
    (Text "'errorWithStackTrace' no longer exists. Use 'error' instead.") =>
  String ->
  a
errorWithStackTrace = unsatisfiable
test28.hs:12:7: error: [GHC-22250]
    • 'errorWithStackTrace' no longer exists. Use 'error' instead.
    • In the expression: errorWithStackTrace "error message"
      In an equation for ‘foo’: foo = errorWithStackTrace "error message"
   |
12 | foo = errorWithStackTrace "error message"
   |       ^^^^^^^^^^^^^^^^^^^

@tomjaguarpaw
Copy link
Member

(And to be clear, I'm +1 on the rest of the proposal.)

@alt-romes
Copy link
Author

alt-romes commented Sep 26, 2024

Currently,

data ErrorCall = ErrorCallWithLocation String String

(see https://www.stackage.org/haddock/lts-22.35/base-4.18.2.1/Control-Exception.html#t:ErrorCall) Are you suggesting changing this constructor? Perhaps changing it back to just ErrorCall String? (There is an already a pattern synonym ErrorCall.)

That's right. The commit is https://gitlab.haskell.org/ghc/ghc/-/merge_requests/13301/diffs?commit_id=c0a2cb447c3fec71edc6653fe7f26619b751ce5f.

We're deleting the ErrorCall pattern synonym, deleting the ErrorCallWithLocation constructor, and instead making ErrorCall :: String -> ErrorCall the only constructor of ErrorCall.

This means that nothing changes for those already using the pattern synonym.

Since I'm already changing this code, let's finally remove errorWithStackTrace, which has been deprecated since GHC 8.0 (2015), i.e. for 9 years.

I'm against this. errorWithStackTrace does not require any ongoing support or maintenance. There is a chance (albeit probably tiny) that removing it could break someone's code and make them hate Haskell, and I don't see why we should take that risk. I appreciate the desire to "tidy up", so as a compromise I would accept the following, which at the point of use tells the user exactly what they have to do to fix their code:

In that case I've amended the proposal to not touch errorWithStackTrace. I had no strong motivation to do this other than clean up. FWIW, the 9-year-old deprecation message already refers error as the supposed substitute:

-- | Like the function 'error', but appends a stack trace to the error
-- message if one is available.
--
-- @since base-4.7.0.0
{-# DEPRECATED errorWithStackTrace "'error' appends the call stack now" #-}
  -- DEPRECATED in 8.0.1
errorWithStackTrace :: String -> a
errorWithStackTrace x = unsafeDupablePerformIO $ throwIO (ErrorCall x)

@alt-romes
Copy link
Author

alt-romes commented Sep 26, 2024

@michaelpj I think your suggestion would look better, but it's not clear to me how to achieve this with the exception annotations.

However, for the WhileHandling display I intend to use vertical bars | to better distinguish nested levels:

ghc: Uncaught exception ghc-9.11-inplace:GHC.Utils.Panic.GhcException:

default output name would overwrite the input file; must specify -o explicitly
Usage: For basic information, try the `--help' option.

While handling ghc-9.11-inplace:GHC.Utils.Panic.GhcException:
  |
  | default output name would overwrite the input file; must specify -o explicitly
  | Usage: For basic information, try the `--help' option.
  |
  | While handling ghc-9.11-inplace:GHC.Utils.Panic.GhcException:
  |   |
  |   | default output name would overwrite the input file; must specify -o explicitly
  |   | Usage: For basic information, try the `--help' option.
  |   |
  |   | While handling ghc-9.11-inplace:GHC.Utils.Panic.GhcException:
  |   |   |
  |   |   | default output name would overwrite the input file; must specify -o explicitly
  |   |   | Usage: For basic information, try the `--help' option.
  |   |   |
  |   |   | HasCallStack backtrace:
  |   |   |   collectBacktraces, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:91:13 in ghc-internal:GHC.Internal.Exception
  |   |   |   toExceptionWithBacktrace, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:83:32 in ghc-internal:GHC.Internal.Exception
  |   |   |   throw, called at compiler/GHC/Utils/Panic.hs:180:21 in ghc-9.11-inplace:GHC.Utils.Panic
  |   |
  |   | HasCallStack backtrace:
  |   |   bracket_, called at libraries/semaphore-compat/src/System/Semaphore.hs:320:23 in semaphore-compat-1.0.0-inplace:System.Semaphore
  |
  | HasCallStack backtrace:
  |   throwIO, called at libraries/exceptions/src/Control/Monad/Catch.hs:371:12 in exceptions-0.10.7-inplace:Control.Monad.Catch
  |   throwM, called at libraries/exceptions/src/Control/Monad/Catch.hs:860:84 in exceptions-0.10.7-inplace:Control.Monad.Catch
  |   onException, called at compiler/GHC/Driver/Make.hs:2986:23 in ghc-9.11-inplace:GHC.Driver.Make

HasCallStack backtrace:
  bracket, called at compiler/GHC/Driver/Make.hs:2953:3 in ghc-9.11-inplace:GHC.Driver.Make

This makes it more-OK that the call stack appears at the bottom only.
Also, the Haskell call stack is read from bottom-to-top, so that's also aligned with the outermost exception showing at the bottom.

@alt-romes
Copy link
Author

To add a datapoint, today we got a user bug report about the duplicate call stacks part of this proposal (Part 3): https://gitlab.haskell.org/ghc/ghc/-/issues/25311

@ulysses4ever
Copy link
Contributor

Hey @alt-romes . Following your invitation to comment on the proposal, I have one suggestion regarding presentation. In particular, the example in the top post has the following formatting on the "Proposal" part:

this list should never be empty
CallStack (from HasCallStack):
  error, called at T24807.hs:1:8 in main:Main

I think the message and the call stack are two different paragraphs in the text, semantically. Therefore, I'd expect them to be separated by an empty line:

this list should never be empty

CallStack (from HasCallStack):
  error, called at T24807.hs:1:8 in main:Main

Bonus points would gain an extra indentation for the message because morally it's a quote. The reason I think of it as a quote is because I read the whole output as authored by the RTS, whereas the error message was written by the programmer.

@alt-romes
Copy link
Author

I think the message and the call stack are two different paragraphs in the text, semantically. Therefore, I'd expect them to be separated by an empty line:

this list should never be empty

CallStack (from HasCallStack):
  error, called at T24807.hs:1:8 in main:Main

the two lines being together is determined by the instance of Exception for ErrorCall, where its constructor ErrorCallWithLocation holds the error and the callstack. However, we're getting rid of the callstack from ErrorCall in favour of the exception context backtrace altogether as proposed in Part 3, since they were duplicate.

Therefore, there's no line to add since the ErrorCall callstack will just be gone.

As for the exception context callstack, there's already a line between it and the user message (example from part 3):

T24807: Uncaught exception ghc-internal:GHC.Internal.Exception.ErrorCall:

this list should never be empty

HasCallStack backtrace:
  collectBacktraces, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:92:13 in ghc-internal:GHC.Internal.Exception
  toExceptionWithBacktrace, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:127:5 in ghc-internal:GHC.Internal.Exception
  error, called at T24807.hs:1:8 in main:Main

Thanks!

@9999years
Copy link

Therefore, there's no line to add since the ErrorCall callstack will just be gone.

Sounds like we should add a blank line there when there's no ErrorCall callstack, then?

@alt-romes
Copy link
Author

Sounds like we should add a blank line there when there's no ErrorCall callstack, then?

After removing the ErrorCall callstack in favour of the exception-propagation callstack, we have:

T24807: Uncaught exception ghc-internal:GHC.Internal.Exception.ErrorCall:

this list should never be empty

HasCallStack backtrace:
  collectBacktraces, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:92:13 in ghc-internal:GHC.Internal.Exception
  toExceptionWithBacktrace, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:127:5 in ghc-internal:GHC.Internal.Exception
  error, called at T24807.hs:1:8 in main:Main

which eventually becomes

T24807: Uncaught exception ghc-internal:GHC.Internal.Exception.ErrorCall:

this list should never be empty

HasCallStack backtrace:
  error, called at T24807.hs:1:8 in main:Main

by freezing the callstack of error (part 4).
As far as I can tell, there shouldn't be additional newlines in this message

@ulysses4ever
Copy link
Contributor

@alt-romes any chance "this list should never be empty" can be indented two or four spaces so that it looks like a quote?

@alt-romes
Copy link
Author

It could. What do you suggest would happen to the "WhileHandling" messages? In any case, the behavior for whileHandling could differ from the top level handler message.

Of course, if this is not unambiguous I won't, since I don't intend to add any friction to this proposal.

@Bodigrim
Copy link
Collaborator

Bodigrim commented Oct 6, 2024

I'm personally sympathetic with Part 1. However, in my role I must make a point of order.

There was time and place to raise concerns about #261. The CLC proposal was open for 3+ months. The corresponding MR was available for review as well. We cannot bikeshed this ad infinitum every time someone disagrees on aesthetic. Is there a material change validating re-evaluation? What would prevent someone to raise another proposal to change formatting in a week or two? How long shall we keep this proposal open to make sure that all interested parties had voiced their opinions?

@alt-romes
Copy link
Author

alt-romes commented Oct 6, 2024

We cannot bikeshed this ad infinitum every time someone disagrees on aesthetic. Is there a material change validating re-evaluation?

These changes are motivated by a holistic assessment of what exceptions as a whole have become in GHC, in light of all the recent proposals that have changed them. Unlike other MRs, this is not a standalone step in a direction, but rather a correction to exceptions as they stand whole. This could only have been done now, after the incremental ideas made their way in.

What would prevent someone to raise another proposal to change formatting in a week or two?

I don't think this is just about changing formatting (see above), but in any case, if they did, we would:

  • Analyse the merits to the format change
  • Against the drawbacks: changing it a third time, but this time it would be after a released version of the compiler with the message, which may break testsuites and confuse users (unlike this proposal, which should amend the exceptions messages before they are released with the current, previously unreleased, formatting).

How long shall we keep this proposal open to make sure that all interested parties had voiced their opinions?

I intend the implementation to be 100% finished next week, or the week after in the worst case scenario. 100% here means full CI pipeline finished, since the code implementation is essentially complete. The proposal is ideally voted on at that time.

@simonmichael
Copy link

Some related feedback from me in #GHC chat:

With ghc 9.10, readFile "nosuchfile" is printing a (useless) stack trace with the error, when compiled. Is it a known bug ? Is there an easy way to prevent it ?

I call it a bug because it should be possible (easy, default, as in past ghc versions) to keep stack traces out of user-visible output. And I (rudely) called it useless because it doesn't look like it'll ever provide useful information, though I could be wrong. Here's how it looked:

HasCallStack backtrace:
  collectBacktraces, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:92:13 in ghc-internal:GHC.Internal.Exception
  toExceptionWithBacktrace, called at libraries/ghc-internal/src/GHC/Internal/IO.hs:260:11 in ghc-internal:GHC.Internal.IO
  throwIO, called at libraries/ghc-internal/src/GHC/Internal/IO/Exception.hs:315:19 in ghc-internal:GHC.Internal.IO.Exception
  ioException, called at libraries/ghc-internal/src/GHC/Internal/IO/Exception.hs:319:20 in ghc-internal:GHC.Internal.IO.Exception

@simonmichael
Copy link

To be clear, when developing user-facing applications we want brief errors without stack traces by default, and I don't think we should have to set up a special exception handler to achieve that.

@tbidne
Copy link

tbidne commented Oct 13, 2024

Have you tried the following? I didn't see it in the chat:

main = do
  -- HasCallStackBacktrace is the only backtrace on by default
  setBacktraceMechanismState HasCallStackBacktrace False
  ...

haddocks.

IMO a bit easier than messing with the handler.

@phadej
Copy link

phadej commented Oct 13, 2024

To be clear, when developing user-facing applications we want brief errors without stack traces by default, and I don't think we should have to set up a special exception handler to achieve that.

That's the problem of picking the color for the bike shed. Everyone has different opinions. Your opinion is fair, but also saying that application should give enough information so when a user can copy paste an error and report a bug, the information is there.

The proper solution(s) is the ones which allow changing the behavior to suit different needs; then the default can be chosen based on about anything, gut feeling, perception of elegance etc.

AFAICT, moving stuff between instance Exception SomeException and the defaultHandler doesn't preveng developers for doing stuff differently. In fact, defaultHandler *doesn't need to use the instance Exception SomeException at all. So the discussion can be split into two independent parts:

  • What instance Exception SomeException should be
  • What the default exception handler should be

These are not coupled code parts.

I could also argue that default exception handler design should not alone affect instance Exeption SomeException.

@thielema
Copy link

thielema commented Oct 13, 2024 via email

@simonmichael
Copy link

simonmichael commented Oct 15, 2024

Hello ghc friends!

Have you tried ... setBacktraceMechanismState

I have not (I wasn't aware of it). Thank you - I confirm that it works and is relatively easy if you know about it and if you add an import of Control.Exception.Backtrace, but only when building with base 4.20+ / ghc 9.10+ (which will mean CPP most likely, since base-compat lags and adds complexity.)

That's the problem of picking the color for the bike shed.

I agree that options are nice and this is about the choice of default behaviour. I think you didn't say which default you'd prefer phadej (or I missed it).

Unhandled exceptions are programming errors and programming errors should be reported in a developer-readable way.

I'm not sure this is a programming error. At least, it used not to be. It sounds like you'd be saying that from ghc 9.10 on, it is a programming error to not explicitly override ghc's default error output.

It's hard for one person to see the whole picture, and I have only skimmed this long thread, but I think the viewpoint of ordinary developers building end user software with GHC, might be underrepresented here. Allow me to expand a little, and excuse the verbosity. I feel this is mainly about

  1. Good defaults. This kind of IO error is a very common result of "user error", and very often seen by users. In my current humble opinion, showing a detailed backtrace (even an accurate/useful one) is the wrong default, since it adds a new burden to haskell programmers trying to make end-user software, for insufficient reason.

  2. Stability. This kind of IO error has produced brief end-user-appropriate output, for probably all of Haskell's ~30 year history. This change of default output in 9.10 was not, and I think could not be, announced loudly enough or motivated/sold enough, to avoid surprising and annoying GHC users for at least the next 5 years.

@tbidne
Copy link

tbidne commented Oct 15, 2024

For me, there are at least two major reasons why (some form of) backtraces on is the right default.

  1. Most modern programming languages have them on. In fact, the idea of disabling stacktraces at all seems quite exotic, at least in my experience. The usual way to "silence" stacktraces is catching the exception and printing out a custom message. The setUncaughtExceptionHandler function is really just convenience for something you do manually in other languages i.e. try/catch.

  2. Considering the two possible defaults, which negative scenario is worse?

    a. Stacktraces on: Users see noisy error messages.

    b. Stacktraces off: Developers do not have stacktraces for debugging.

    For me, b is much worse. Yes, a is not ideal in all scenarios. But I think every user has, at some point, encountered noisy error messages, or worse. It's annoying, but not a major disaster.

    On the other hand, a developer receiving no more info than Prelude.head: empty list often is a disaster. I've seen long-standing bugs go unsolved due to messages like that. Cthulhu help you if the bug isn't in your code but in one of your many dependencies.

    I realize I am biased, but even as a user, I'd rather see unnecessarily noisy messages than have the developers crippled, as the latter results in worse software!

I could be wrong, but I don't think exceptions were absent from Haskell because of a conscious choice. It was because the implementation was really hard. Simon Marlow gave a talk in 2012 about the difficulties, describing it as a "very old problem, I've been struggling with for many, many years."

This fits my impression, that the direction of travel has always been to add more information to errors by default when possible (see: HasCallStack, error adding the stacktrace to the message). It's just been slow going because of the difficulties.


As you point out, it is clearly a good idea to give users friendly error messages, even if they are not actionable. That means making it easy for developers to get the behavior they want, and communicating these methods effectively.

It seems setBacktraceMechanismState may fit the bill here. Communication is never easy, though for what it's worth, the proposal that changed this was highly discussed and open for 3 years before being merged. There have been also been quite a few blog posts / videos made (see this excellent one by the principle author, Ben Gamari).

@tomjaguarpaw
Copy link
Member

Unhandled exceptions are programming errors and programming errors
should be reported in a developer-readable way.

I'm not sure this is a programming error. At least, it used not to be.

My line of reasoning would be: if an exception can be thrown that the developer is not aware of, then that is a programming error. Therefore, if there is no programming error then the developer is aware of all exceptions that can be thrown, and thus they should catch all of them, reporting them to the user in an appropriate way, rather than defer to the default exception handler.

Now, in practice I'm lazy and I often do actually let the default exception handler print exceptions. But I consider it "my fault" if I don't like it when the default exception handler changes to show more information.

@simonmichael
Copy link

@frasertweedale and the Haskell Security team, any thoughts on showing stack traces by default ?

@thielema
Copy link

thielema commented Oct 15, 2024 via email

@frasertweedale
Copy link

and the Haskell Security team, any thoughts on showing stack traces by default ?

We'll discuss it at this week's meeting and follow up soon!

@alt-romes
Copy link
Author

alt-romes commented Oct 17, 2024

The implementation is ready at https://gitlab.haskell.org/ghc/ghc/-/merge_requests/13301#note_590084
The failing CI is due to the array and stm submodules whose testsuite needs to be updated. I've already put up PRs for these libraries updating the testsuite.

Given the implementation is ready, we should wait for the last comments and then proceed to a vote (which looks like waiting @frasertweedale's meeting).

Regarding the behaviour of the default uncaught exception handler, I do not wish to change this proposal further.

I believe the alternative suggestions are too contentious to be part of this proposal, regardless of their merits. This proposal liberates the SomeException instance from types and backtraces by moving them to the default exception handler, but keeps the final output of uncaught exceptions exactly the same (since the default exception handler already used the SomeException instance).

@frasertweedale
Copy link

SRT discussed at our meeting yesterday: https://github.com/haskell/security-advisories/blob/main/meeting-notes/2024-10-16.md#stack-traces-proposal

SRT has no objection to this proposal. We note that many languages and frameworks display full stack traces in a variety of situations. We also note that in most cases the program structure revealed by the call stack is not sensitive.

We have some small uncertainties about when stack traces would be displayed by default (but it should be clear to people familiar with this proposal). If this proposal results in situations where stack trace will be shown where previously it was not, this should be clearly communicated via release notes, users guide and other documentation. We have no problem with the behaviour per se - only want to avoid unpleasant surprises for users.

@alt-romes
Copy link
Author

@Bodigrim, I would like to trigger a vote.

@tomjaguarpaw
Copy link
Member

It is unfortunate that the IO type does not show the exceptions that can be thrown. Suggestions were made for new monad and transformers that add exceptions explicitly in types, however we would still need an exceptionless I/O monad. 'main' should be of this type. Then you cannot accidentally miss to handle exceptions.

This is a problem that IO-based effect systems like Bluefin1 and effectful can resolve. If you make your "top-level" a forall es. Eff es (), then it's guaranteed to be exceptionless. Still, you will need to run IO operations within it and handle all their exceptions, so it shifts the problem elsewhere, but hopefully to somewhere more manageable.

Footnotes

  1. Full disclosure: I'm the Bluefin author

@Bodigrim
Copy link
Collaborator

Bodigrim commented Nov 1, 2024

I'm having hard time to grasp the proposal in its entirety, but it's high time to vote. Dear CLC members, last chance to offer comments / suggestions / objections before we open the vote early next week.

@Bodigrim
Copy link
Collaborator

Bodigrim commented Nov 8, 2024

Dear CLC members, let's vote on the proposal to redesign certain aspects of exception display and handling as detailed in the top post. There are no breaking changes, the proposal is mostly about displaying stack traces and exception provenance in a more user-friendly manner.

Please cast your vote separately on each of 4 parts.

@tomjaguarpaw @hasufell @parsonsmatt @angerman @velveteer @mixphix


+1 from me. I re-read the proposal attentively and I agree with the arguments.

@Bodigrim Bodigrim added the vote-in-progress The CLC vote is in progress label Nov 8, 2024
@mixphix
Copy link
Collaborator

mixphix commented Nov 8, 2024

+1 to all four parts from me, especially part 4!

@tomjaguarpaw
Copy link
Member

+1


Thanks, this is a very helpful, detailed and well-motivated proposal.

@velveteer
Copy link
Contributor

+1

@Bodigrim
Copy link
Collaborator

Bodigrim commented Nov 9, 2024

Thanks all, that's enough votes to approve.

@Bodigrim Bodigrim added approved Approved by CLC vote and removed vote-in-progress The CLC vote is in progress labels Nov 9, 2024
@alt-romes
Copy link
Author

alt-romes commented Nov 18, 2024

Thank you all for the discussion and voting.

The patch implementing these changes has landed now: !13301.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
approved Approved by CLC vote
Projects
None yet
Development

No branches or pull requests