test and fix: seed exception context does not survive being thrown - #115
test and fix: seed exception context does not survive being thrown#115cdmren wants to merge 2 commits into
Conversation
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>
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>
|
As far as I can tell, Piecing together the docs, it just seems really hard to get "rethrowing" right. I think the Right Way is to use Lastly, our end-users (and so our tests/docs) should be using
|
|
Well, at least Well-Typed agrees this behavior is dumb.
https://well-typed.com/blog/2026/05/lay-annotation-land/#caution-instance-for-someexception-itself |
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 byaddExceptionContextsurvive thethrowIOcall, and doeslogFailingSeeddiscard context the caught exception already had?
seedPrefixesHUnitFailureSpecis a baseline showing theHUnitFailurereason-prefixing still works.
seedExceptionContextSpecandseedExceptionContextNonHUnitFailureSpecassert the seed shows up viasomeExceptionContextafterrunGraphulaTrethrows — both fail on thatfirst commit (
expected: [2] but got: []).The cause turned out to be two layers deep.
throwIO's implementation callstoExceptionon its argument, andbase'sException SomeExceptioninstance explicitly resets context to empty there ("This drops any attached
ExceptionContext"). Since
addExceptionContextalways returns aSomeException, throwing that result wipes the context we just attached.Building an
ExceptionWithContextinstead (whosetoExceptionsets?exceptionContextdirectly rather than resetting it) avoids that — butUnliftIO.Exception.throwIO, whichlogFailingSeedwas using, isliftIO . EUnsafe.throwIO . toSyncException:toSyncExceptionconvertsback to a plain
SomeExceptionbefore handing it to the realControl.Exception.throwIO, re-triggering the same reset one level down.Second commit:
Graphula.ExceptionContextgainsthrowWithGraphulaExceptionContext, which builds theExceptionWithContextand throws it via
Control.Exception.throwIOdirectly (bypassingUnliftIO.Exception's wrapper). All of thebase-version-conditional logic,and the only CPP in this change, lives in that one module;
Graphula.hscalls it unconditionally. All 6 specs pass now.