Skip to content

test and fix: seed exception context does not survive being thrown - #115

Open
cdmren wants to merge 2 commits into
pb/annotate2from
cdm/annotate
Open

test and fix: seed exception context does not survive being thrown#115
cdmren wants to merge 2 commits into
pb/annotate2from
cdm/annotate

Conversation

@cdmren

@cdmren cdmren commented Aug 10, 2026

Copy link
Copy Markdown

Confirming what I suspected but didn't know for sure was a bug in #113. This stuff is subtle. When you throw as SomeException, the context gets lost. I don't totally understand why.

First commit adds a failing test, second fixes it.

AI Slop

First commit: specs to check the concerns raised on this PR about
logFailingSeed's catch-and-rethrow: does the context added by
addExceptionContext survive the throwIO call, and does logFailingSeed
discard context the caught exception already had?

seedPrefixesHUnitFailureSpec is a baseline showing the HUnitFailure
reason-prefixing still works. seedExceptionContextSpec and
seedExceptionContextNonHUnitFailureSpec assert the seed shows up via
someExceptionContext after runGraphulaT rethrows — both fail on that
first commit (expected: [2] but got: []).

The cause turned out to be two layers deep. throwIO's implementation calls
toException on its argument, and base's Exception SomeException
instance explicitly resets context to empty there ("This drops any attached
ExceptionContext"). Since addExceptionContext always returns a
SomeException, throwing that result wipes the context we just attached.
Building an ExceptionWithContext instead (whose toException sets
?exceptionContext directly rather than resetting it) avoids that — but
UnliftIO.Exception.throwIO, which logFailingSeed was using, is
liftIO . EUnsafe.throwIO . toSyncException: toSyncException converts
back to a plain SomeException before handing it to the real
Control.Exception.throwIO, re-triggering the same reset one level down.

Second commit: Graphula.ExceptionContext gains
throwWithGraphulaExceptionContext, which builds the ExceptionWithContext
and throws it via Control.Exception.throwIO directly (bypassing
UnliftIO.Exception's wrapper). All of the base-version-conditional logic,
and the only CPP in this change, lives in that one module; Graphula.hs
calls it unconditionally. All 6 specs pass now.

Adds a baseline spec confirming the HUnitFailure seed-prefix still
works, plus two specs asserting the GraphulaExceptionContext seed
annotation is present in someExceptionContext after runGraphulaT
rethrows. Both context specs currently fail: logFailingSeed's
`throwIO . addExceptionContext ctx . whenException f` pattern loses
all context (including one just added) because `throwIO`/`toException`
on a `SomeException` unconditionally resets its context to empty
before re-populating with an automatic backtrace annotation. This
happens regardless of whether the caught exception is an HUnitFailure
or already carried its own context.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cdmren
cdmren requested a review from pbrisbin August 10, 2026 21:39
@cdmren cdmren changed the title test: demonstrate seed exception context does not survive rethrow test and fix: seed exception context does not survive being thrown Aug 10, 2026
throwIO's toException resets a SomeException's context to empty before
re-raising (per its own Haddock, "This drops any attached
ExceptionContext"), and UnliftIO.Exception.throwIO makes this worse by
always routing through toSyncException, converting back to a
SomeException and re-triggering the same reset even if the caller built
a context-preserving value beforehand. Both of these unconditionally
discarded the seed annotation logFailingSeed tried to attach, and any
context the caught exception already had.

throwWithGraphulaExceptionContext fixes this: it pattern-matches the
caught SomeException open to recover the original concrete exception,
wraps *that* in ExceptionWithContext (whose own toException sets the
context directly instead of resetting it, and never needs to
"flatten" a nested SomeException since there isn't one), and throws it
via Control.Exception.throwIO directly, bypassing UnliftIO's wrapper.
All of the base-version-conditional logic, and the only CPP in this
change, lives in Graphula.ExceptionContext; Graphula.hs calls the new
function unconditionally.

Verified against both base-4.20.1.0 (ghc-9.10.3, via stack-lts24.yaml)
and base-4.21.2.0 (ghc-9.12.4, via cabal): all specs pass under both,
including generationFailureSpec, which briefly regressed under
base-4.20.1.0 in an earlier version of this fix that wrapped the
SomeException directly instead of unwrapping it first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cdmren
cdmren marked this pull request as ready for review August 10, 2026 21:55
@cdmren
cdmren requested a review from a team as a code owner August 10, 2026 21:55
Comment thread src/Graphula/ExceptionContext.hs
@pbrisbin

pbrisbin commented Aug 14, 2026

Copy link
Copy Markdown
Member

As far as I can tell, throwIO calls toException (in some form), which (according to its docs) must always build a SomeException with an empty context.

Piecing together the docs, it just seems really hard to get "rethrowing" right. I think the Right Way is to use catchNoPropagate, which gives you ExceptionWithContext e, then call rethrowIO on that (after modifying however we need).

Lastly, our end-users (and so our tests/docs) should be using tryWithContext or just catching @ExceptionWithContext to make the context available on handling.

catchNoPropagate and tryWithContext aren't available until base-4.21 though, so I'm not sure how close we can get to that within base-4.20.

@pbrisbin

Copy link
Copy Markdown
Member

Well, at least Well-Typed agrees this behavior is dumb.

The definition of toException is more problematic, however. Prior to 9.10, calling toException on SomeException was just an identity ... Now, however, the implementation must clear the existing context in order to satisfy the contract... I think this is simply wrong; at the very least, it is highly counter-intuitive, and it also does not match the original proposal; I don’t know why this was changed

https://well-typed.com/blog/2026/05/lay-annotation-land/#caution-instance-for-someexception-itself

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants