Skip to content

Groovy12281 investigation - #2821

Open
paulk-asert wants to merge 6 commits into
apache:masterfrom
paulk-asert:groovy12281-investigation
Open

Groovy12281 investigation#2821
paulk-asert wants to merge 6 commits into
apache:masterfrom
paulk-asert:groovy12281-investigation

Conversation

@paulk-asert

Copy link
Copy Markdown
Contributor

not for commit to master - just in case anyone wants to repeat the investigation

paulk-asert and others added 6 commits August 21, 2026 14:38
…ed, declined)

Routes platform-loader keys to the weak-key map and other keys to
ClassValue, selected via -Dgroovy.use.classvalue=hybrid. Measured
against pure ClassValue and pure map with a fresh-JVM-per-config
harness: the hybrid tracks the map, not ClassValue, on macro dispatch,
because dynamic code cannot avoid platform receivers (String, boxed
numbers, DGM) and those are exactly the keys the hybrid maps. Kept on
the investigation branch as the measured artifact; not proposed for
merge. Numbers and method are recorded in the GROOVY-12281 assessment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… guide

The escape hatch's trade-off was described as not measurable for most
applications. Measured (57 classic-dispatch JMH benchmarks): +4.8%
geomean with hot platform-receiver idioms up to ~1.4x, while compile
time is within 1%. The guide now states those numbers so integrators
can decide with their own workload in mind.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ion (opt-in)

Adds -Dgroovy.use.classvalue=soft: java.lang.ClassValue keeps the per-Class
fast path for all keys, but each association stores a bootstrap-loaded
SoftReference to the ClassInfo, cutting the only strong chain from immortal
platform classes to Groovy's loader (JDK-8136353 / GROOVY-12142).

Correctness pieces, addressing the review findings on the v1 assessment:
- Resurrection: a weak-key/weak-value side map is the identity authority;
  computeValue re-associates a still-reachable value instead of replacing it,
  so a fresh instance can only exist once no guard can observe the old one
  (kills the split-brain sequence; classic call-site version guards stay
  sound, so legacy groovy-callsite jars on the classpath are unaffected).
- Dirty roots: ClassInfos carrying non-reconstructible state (installed
  MetaClass, per-instance MetaClasses, registry-written MOP arrays) are
  strong-rooted Groovy-side; the MOP-write hook also enforces the
  registry-rooting invariant (non-empty DGM arrays => never collectible)
  by construction. ClassInfo.remove() unroots, keeping hard-detach
  undeploy semantics.
- Per-Class indy domain continuity: in soft mode SwitchPoint domains are
  adopted from a weak-Class-keyed map, so a successor ClassInfo shares its
  predecessor's domain and mutations deterministically retire guards that
  captured only the SwitchPoint (POJO direct dispatch); domain reclaim moves
  from ClassInfo-death to Class-death. Indy-only; default mode unchanged.

Tests: GroovyClassValueSoftTest (unit); ClassInfoSoftModeTest/Probe
(child JVM, deterministic forced clearing: resurrection identity+version,
DGM rooting, EMC and per-instance survival, true collection + fresh
dispatch, classic CallSiteArray soundness across clear + EMC change,
predecessor SwitchPoint retirement); ClassInfoSoftModeStressTest/Probe
(child JVM, real GC clearing: 128MB heap + SoftRefLRUPolicyMSPerMB=0,
concurrent indy + classic dispatch racing the collector through ~130 EMC
generations — 66M dispatches, 33/36 platform-receiver ClassInfos collected
and recreated mid-run, zero invariant violations). build-logic forwards
-Pgroovy.use.classvalue to test JVMs so whole suites can soak a non-default
mode; the full core suite passes under soft mode (16,800 tests).
…ed, measured

Supersedes v1 in place after review: withdraws the blanket "structurally
illegal" verdict (Failure 1 cannot occur for collectible ClassInfos — the
registry-rooting invariant; Failure 2 is real, demonstrated via the
$callSiteArray per-referent-clearing sequence, and prevented by
construction via resurrection). Records the reference-graph census
(including POGO self-pinning), the indy capture audit, the Option E
design, the consolidated option landscape for the adoption discussion
(three modes x static/dynamic x indy/classic, with guidance), and the
measurements:

- Acceptance (Groovy12281LoaderSpike, JDK 17/21/23): dropped child-loader
  Groovy is pinned forever under the default ClassValue, collected under
  soft mode once pressure clears soft references — the ticket's first
  demonstrated unpinning that keeps the ClassValue fast path.
- Cost (Groovy12281PerfSpike + compiler harness + classic JMH sweep):
  +0.6ns on a raw getClassInfo lookup, macro dispatch and compilation at
  parity, 0 of 37 JMH classic benchmarks significant (geomean +1.7%;
  Option D failed the same rule with 12); focused re-measurement puts
  classic polymorphic miss traffic at a pooled +3.7%, flagged for the
  idiom-suite sweep before any default-flip discussion.

v1's measured declines of Options B (hybrid) and D (map default) stand.
Recommendation: opt-in mode now; default question deferred to team review.
association; expose value reclaimability as a store capability

Jochen's reverse memory-pressure question on PR apache#2820 found a real leak:
the global strong root set extended EMC-dirty script-created classes
(and their loaders) to the runtime's lifetime, where the default mode
releases them with their class (probe: 0/20 loaders collected vs 20/20).
The root set's justifying comment was true for immortal platform keys
but false for collectible ones.

- GroovyClassValue gains valuesReclaimable() plus default-no-op
  pin/unpin: callers never consult the mode; they ask the store for its
  capabilities.
- GroovyClassValueSoft stores a per-Class slot (bootstrap
  AtomicReference) holding either a bootstrap SoftReference
  (reclaimable) or the value itself (pinned). The strong hold lives
  inside the association — an ephemeron — so a pinned ClassInfo lives
  exactly as long as its class: an immortal platform key retains it, a
  dropped script class releases it together with its loader. pin()
  retries across a concurrent remove-and-recompute; unpin() is a CAS so
  a foreign value cannot downgrade the slot.
- ClassInfo drops nonReclaimableRoots and the isSoftMode() coupling;
  updateReclaimability() pins/unpins unconditionally (no-ops outside
  soft mode) and remove() needs no store-specific unroot: the pin
  travels with the association.
- GroovyClassValueFactory stores the parsed mode as booleans; the mode
  string never leaves the factory.
- IndyInvalidation / SwitchPointInvalidator document their layering
  (mechanism vs policy, with ClassInfo the only other supported
  consumer) and are annotated @internal.

Tests: GroovyClassValueSoftTest adds the capability, pin-until-unpin,
remove-releases-pin and foreign-unpin-noop cases; ClassInfoSoftModeProbe
gains the reverse scenario (an EMC-dirty script class dies with its
loader under memory pressure) and asserts pinned slots directly — the
stronger claim, since a pinned slot has no clearable reference. The
reverse scenario is @CompileStatic and nulls its loop locals on purpose:
indy call-site guards in the long-lived probe class and stale frame
slots retain script classes in every mode — receiver-side effects
distinct from the association lifetime under test. The classic call-site
scenario moves to a clean POJO receiver, since a pinned receiver like
String no longer has a clearable reference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds §12 (the reverse-scenario leak, its diagnosis and the ephemeron
fix; responses to the six inline comments) and updates
§2/§6/§7/§8/§9/§10 for the pinned-slot design. Also records the
pre-existing reverse leak of groovy.use.classvalue=false on current
master (a weak-key/strong-value map is not an ephemeron), to be filed
separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@testlens-app

testlens-app Bot commented Aug 21, 2026

Copy link
Copy Markdown

🚨 TestLens detected 1 failed test 🚨

Here is what you can do:

  1. Inspect the test failures carefully.
  2. If you are convinced that some of the tests are flaky, you can mute them below.
  3. Finally, trigger a rerun by checking the rerun checkbox.

Test Summary

Build and test / lts (25, ubuntu-latest) > :test

Test Runs Flakiness
ClassInfoSoftModeStressTest > softModeSurvivesRealGcClearingUnderConcurrency() 20% 🔴

🏷️ Commit: 4d28d2a
▶️ Tests: 111607 executed
⚪️ Checks: 23/23 completed

Test Failures

ClassInfoSoftModeStressTest > softModeSurvivesRealGcClearingUnderConcurrency() (:test in Build and test / lts (25, ubuntu-latest))
org.opentest4j.AssertionFailedError: stress probe failed: dispatches=3447961 generations=108 javaInfosCollected=33/36
ERROR: mutated-0: IllegalStateException: mutated-0 observed gen 25 after already seeing 26
 ==> expected: <0> but was: <1>
	at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:628)
	at org.codehaus.groovy.reflection.ClassInfoSoftModeStressTest.softModeSurvivesRealGcClearingUnderConcurrency(ClassInfoSoftModeStressTest.groovy:53)
expected actual
0 1

Rerun Controls

Select tests to mute in this pull request:

  • ClassInfoSoftModeStressTest > softModeSurvivesRealGcClearingUnderConcurrency()

Reuse successful test results:

  • ♻️ Only rerun the tests that failed or were muted before

Click the checkbox to trigger a rerun:

  • Rerun jobs

Learn more about TestLens at testlens.app/docs.

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.

1 participant