Skip to content

GROOVY-12763, GROOVY-12764: async constructs rethrow checked exceptions as themselves; no self-suppression - #3298

Merged
paulk-asert merged 3 commits into
apache:masterfrom
paulk-asert:groovy12763
Nov 15, 2026
Merged

paulk-asert merged 3 commits into
apache:masterfrom
paulk-asert:groovy12763

Conversation

@paulk-asert

@paulk-asert paulk-asert commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

Two async fixes for 6.0.1, one commit each.

GROOVY-12763: async constructs rethrow a checked exception as itself

await rethrew a checked exception unchanged, but five other paths wrapped it:

  • a scope child failure (DefaultAsyncScope.close()) and a defer action (AsyncSupport.executeDeferScope) in RuntimeException
  • a withScope body (including the timeout variant) and a generator body in UndeclaredThrowableException, added when the closure is converted to Function / Consumer
  • a Flow.Publisher error consumed with for await (FlowPublisherAdapter) in RuntimeException

All five now rethrow the original exception, as await does (GROOVY-9381's exception-transparency principle; GROOVY-12459 for for await). Only the UndeclaredThrowableException layer that closure conversion adds around a checked exception is removed; other wrappers the code throws are kept. Suppressed exceptions from scope close are kept.

  • AsyncSupport.rethrow is now public and returns RuntimeException so call sites can write throw rethrow(t); new AsyncSupport.unwrapUndeclared
  • @apiNote on AsyncScope and close() for Java callers, which can now receive a checked exception these methods don't declare
  • COMPATIBILITY.md entry for 6.0.1
  • testScopeCloseWithCheckedExceptionWrapped, which asserted the old wrapping, becomes testScopeCloseRethrowsCheckedExceptionUnwrapped

Known limitation: a checked exception the code itself wrapped in UndeclaredThrowableException and threw from a withScope or generator body is also unwrapped. Removing that needs entry points that avoid closure conversion (a Closure overload of withScope, a generator body type declaring throws Throwable), left for a later release.

GROOVY-12764: no "Self-suppression not permitted" when one exception instance is reported twice

Two children (for example, two that await the same failed task) or two defer actions failing with the same instance made close() / executeDeferScope throw IllegalArgumentException and lose the real failure. They now skip addSuppressed for the same instance, as withScope already does.

Tests

New tests in AsyncAwaitTest fail before the fix and pass after: each of the five paths, the two self-suppression cases, the paths under @CompileStatic, and wrapper preservation (a wrapper thrown by a child task or defer action is removed exactly as await removes it; one from a Flow.Publisher is kept). Locally: full ./gradlew test (the one failure was a race in a new Flow test, since fixed), then AsyncAwaitTest, groovy.concurrent.* and org.apache.groovy.runtime.async.* green.

Both commits are intended for backporting to GROOVY_6_0_X.

@codecov-commenter

codecov-commenter commented Nov 14, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.92308% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.4908%. Comparing base (0b455da) to head (3068e71).
⚠️ Report is 31 commits behind head on master.

Files with missing lines Patch % Lines
.../org/apache/groovy/runtime/async/AsyncSupport.java 72.7273% 2 Missing and 4 partials ⚠️
...apache/groovy/runtime/async/DefaultAsyncScope.java 90.6250% 1 Missing and 2 partials ⚠️
...org/apache/groovy/runtime/async/GroovyPromise.java 25.0000% 3 Missing ⚠️
src/main/java/groovy/concurrent/AsyncScope.java 66.6667% 1 Missing ⚠️
...che/groovy/runtime/async/FlowPublisherAdapter.java 0.0000% 1 Missing ⚠️
...g/apache/groovy/runtime/async/GeneratorBridge.java 0.0000% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##               master      #3298        +/-   ##
==================================================
+ Coverage     74.4894%   74.4908%   +0.0014%     
- Complexity      44906      44913         +7     
==================================================
  Files            1703       1703                
  Lines          152180     152212        +32     
  Branches        29166      29162         -4     
==================================================
+ Hits           113358     113384        +26     
- Misses          28408      28413         +5     
- Partials        10414      10415         +1     
Files with missing lines Coverage Δ
.../apache/groovy/runtime/async/AwaitCombinators.java 93.8272% <100.0000%> (ø)
src/main/java/groovy/concurrent/AsyncScope.java 81.8182% <66.6667%> (+7.9051%) ⬆️
...che/groovy/runtime/async/FlowPublisherAdapter.java 69.2982% <0.0000%> (+1.1948%) ⬆️
...g/apache/groovy/runtime/async/GeneratorBridge.java 85.7143% <0.0000%> (+2.9557%) ⬆️
...apache/groovy/runtime/async/DefaultAsyncScope.java 86.9863% <90.6250%> (-2.3267%) ⬇️
...org/apache/groovy/runtime/async/GroovyPromise.java 82.0513% <25.0000%> (-6.5202%) ⬇️
.../org/apache/groovy/runtime/async/AsyncSupport.java 78.2123% <72.7273%> (+1.1937%) ⬆️

... and 371 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@daniellansun daniellansun left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @paulk-asert for tackling both GROOVY-12763 and GROOVY-12764 so cleanly! Unifying all five async constructs around checked-exception transparency in a single atomic 6.0.1 change—paired with explicit @apiNote guidance for groovy-concurrent-java callers and the COMPATIBILITY.md entry—is the right architectural call. Given how fresh 6.0.0 is and that async/await and groovy.concurrent are marked incubating, landing all five paths together avoids leaving users in a split state where withScope bodies are transparent while child tasks inside the very same scope stay wrapped.

Left inline notes across the diff; summarizing the main points by priority below:

  1. Pre-merge fix — DefaultAsyncScope.withScopeTimeout self-suppression & timeout cause handling (DefaultAsyncScope.java): Unlike AsyncScope.withScope (which closes scope manually with if (closeError != bodyError)), withScopeTimeout still uses try (DefaultAsyncScope scope = ...) where javac emits an unguarded primary.addSuppressed(closeError) (JDK-8317229). When a timed body and child task fail with (or await) the same checked exception instance, withScopeTimeout now throws IllegalArgumentException: Self-suppression not permitted. In addition, calling te.initCause(e) before unwrapUndeclared(e) leaves UndeclaredThrowableException as the timeout cause in dynamic Groovy, catch (Exception e) misses direct checked Throwable subclasses under @CompileStatic, and a late timer interrupt firing just as body.apply(scope) returns leaves the caller thread's interrupt flag set. Aligning withScopeTimeout with AsyncScope.withScope's manual close structure resolves all of these cleanly.
  2. Pre-merge fix — Preserving getSuppressed() when stripping wrappers (AsyncSupport.java): Both unwrap and unwrapUndeclared currently return t.getCause() directly, silently dropping any suppressed exceptions attached to the outer wrapper (for instance, subsequent defer or try-with-resources cleanup failures attached to an ExecutionException or UndeclaredThrowableException prior to unwrapping). Transferring wrapper.getSuppressed() (with suppressed != unwrapped guarding) preserves cleanup diagnostics with zero allocation on the common path.
  3. Design & edge-case notes (non-blocking / follow-up):
    • Dynamic proxy UndeclaredThrowableException in unwrapUndeclared: In practice, UndeclaredThrowableException(checked) frequently originates from JDK java.lang.reflect.Proxy frameworks (Spring AOP, MyBatis, RPC clients) rather than manual instantiation; since it is unchecked, neither dynamic ConvertedClosure nor @CompileStatic adds a second wrapper, so unwrapUndeclared strips the proxy's wrapper. Deferring entry-point signature work from 6.0.1 makes sense, and an internal ThrowingConsumer overload on AsyncSupport.asyncGenerator could remove SAM coercion for generators cleanly later.
    • Wrapper & fan-out subtleties: executeDeferScope itself keeps firstError as-is (ExecutionException is stripped in await async by await, whereas in async generators GeneratorBridge preserves it via unwrapUndeclared), and a small identity-deduplicating helper for addSuppressed in DefaultAsyncScope.close() and executeDeferScope would avoid duplicate suppressed entries when multiple fan-out tasks/defers rethrow a shared secondary errB != firstError.
    • Cross-thread observability & interrupt state (future consideration): Rethrowing a worker thread's checked InterruptedException onto an awaiting thread leaves Thread.currentThread().isInterrupted() == false, and bare sneaky-rethrow across worker/virtual threads omits caller-side join frames from the stack trace—worth tracking as a separate follow-up alongside Groovy 7 await wrapper narrowing.
  4. Tests & 6.0.1 backport: Expanding @CompileStatic coverage in AsyncAwaitTest to include for await (Flow.Publisher) and timed withScope, plus regression cases for timed self-suppression and wrapper-suppressed transfer, would lock down all paths. Since reversing testScopeCloseWithCheckedExceptionWrapped alters tested 6.0.0 behavior in a patch release, a brief heads-up on dev@ prior to backporting to GROOVY_6_0_X may also be worthwhile if not already sent.

Comment thread COMPATIBILITY.md Resolved
…not permitted" when one exception instance is reported twice

Record a later failure as suppressed only when it is not the first
failure itself and not already recorded, so a shared instance is
neither suppressed into itself nor listed twice.

withScope with a timeout now closes its scope by hand, as withScope
does, because try-with-resources suppressed a failure shared by the
body and a child into itself. The timer hand-off is also tightened so
a timer firing as the body finishes cannot leave the caller interrupted.
…ons instead of rethrowing them as await does

Scope close, withScope, defer, generators and Flow publishers now rethrow
a checked exception as itself, as await does. The UndeclaredThrowable
layer that closure coercion adds around a checked exception is removed;
other wrappers are kept. A timed withScope uses the unwrapped exception
as the cause of its TimeoutException.

Exceptions suppressed by a wrapper that is removed move to its cause,
since without the old RuntimeException layer they would otherwise be
lost.

@daniellansun daniellansun left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @paulk-asert — the updates in d667d75 and 62b1bd6 address every point from the initial review thoroughly and cleanly!

In particular, the 4-state TIMER_PENDING / TIMER_STOPPED / TIMER_FIRING / TIMER_FIRED handshake in DefaultAsyncScope.stopTimer nicely closes even the narrow scheduler window where scope.cancelAll() wakes the body thread just before bodyThread.interrupt() runs, and addSuppressedOnce + transferSuppressed (synchronizing on primary to match Throwable's own monitor) handles concurrent await unwrapping cleanly without lock-order hazards. The expanded @apiNote / COMPATIBILITY.md notes and the new @CompileStatic, shared-suppression, and timer tests in AsyncAwaitTest look great as well.

Only two tiny, strictly non-blocking observations (no need to hold up merging):

  1. In AsyncSupport.addSuppressedOnce, checking if (secondary == primary || secondary == primary.getCause()) return; could avoid attaching closeError into TimeoutException.getSuppressed() in the rare race where a timed scope expires right as both body and child fail with the same instance err (te.initCause(err) + te.addSuppressed(err) causes Throwable.printStackTrace()'s dejaVu set to print Caused by: [CIRCULAR REFERENCE: ...]).
  2. In testTimedScopeDoesNotLeaveCallerInterrupted, spinning while (!Thread.currentThread().isInterrupted() && System.nanoTime() < end) { Thread.onSpinWait() } (with e.g. a 5s ceiling instead of a fixed 300ms busy loop) would finish in ~20ms as soon as the timer interrupts bodyThread while guaranteeing the TIMER_FIRED branch is exercised even on a heavily stalled CI runner.

+1 LGTM — ready for master and GROOVY_6_0_X!

…sed exceptions are combined

addSuppressedOnce also skips an exception that, without its JDK wrapper
layers, is the primary itself or the primary's cause. A timed scope that
times out as its body and a child fail with one instance no longer lists
the TimeoutException's cause among its suppressed exceptions, and moving
suppressed exceptions no longer attaches a wrapper to the failure it wraps.

Awaitable.first records each failure unwrapped and once. A pending
GroovyPromise reads a failure through join(), as a completed one does,
because CompletableFuture.get() drops a CompletionException's suppressed
exceptions.

The timed-scope interrupt test now waits for the interrupt rather than
for a fixed time, so it always exercises the fired timer.
@paulk-asert

Copy link
Copy Markdown
Contributor Author

Thanks Daniel! Both nits are in 3068e71, plus one more from the same family: Awaitable.first with sources failing on one shared instance put a CompletionException wrapping that instance into its own suppressed list once suppressed exceptions started moving to the cause. addSuppressedOnce now compares after peeling JDK wrappers (without moving anything), against both the primary and its cause, which covers your timeout case too. firstAsync stores failures unwrapped, and a pending GroovyPromise.get() now reads a failure via join() like the completed fast path, since CompletableFuture.get() drops a CompletionException's suppressed exceptions (that made the outcome timing-dependent). The interrupt test uses your spin-until-interrupted form and asserts the interrupt was actually seen. I'll merge once CI is green.

@testlens-app

testlens-app Bot commented Nov 15, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 3068e71
▶️ Tests: 131953 executed
⚪️ Checks: 27/27 completed


Learn more about TestLens at testlens.app/docs.

@paulk-asert
paulk-asert merged commit d642cc3 into apache:master Nov 15, 2026
28 checks passed
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.

3 participants