[
https://issues.apache.org/jira/browse/GROOVY-12307?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
]
Paul King updated GROOVY-12307:
-------------------------------
Description:
The GROOVY-12281 stress test intermittently fails with a per-thread
monotonicity violation:
{noformat}
ClassInfoSoftModeStressTest > softModeSurvivesRealGcClearingUnderConcurrency()
FAILED
stress probe failed: dispatches=3721845 generations=114
javaInfosCollected=33/36
ERROR: mutated-1: IllegalStateException: mutated-1 observed gen 13 after
already seeing 14
{noformat}
i.e. a dispatcher thread observed an older ExpandoMetaClass generation *after*
already observing a newer one. The stress environment (soft ClassValue mode
plus GC churn) only raises the relink frequency; the underlying race is
independent of soft mode.
h3. Root cause
Indy call-site linking reads MOP state and installs its guard in the wrong
order:
# the fallback resolves the MetaClass and selects the target method;
# only afterwards ({{Selector.setGuards}} ->
{{IndyInterface.applyMopSwitchPoints}}) does it fetch the *current*
class-domain SwitchPoint to guard that target.
A MetaClass change landing between (1) and (2) retires the old SwitchPoint and
rotates in a fresh one — which is exactly the token the racing relink then
fetches. The stale selection is published under a live guard, clobbering any
newer target already installed at the shared call site. The site serves the
pre-change selection until an unrelated future invalidation; if the race lands
on the last change, the staleness is permanent (a MetaClass mutation is
silently lost).
The same time-of-check/time-of-use hole exists in the cold reflective tier
({{ColdReflectiveMethodHandleWrapper.tryBuild}} captures {{classValidity}}
after selection) and in the GEP-15 compound-assign resolver
({{IndyCompoundAssign.resolve}} fetches the SwitchPoint after
{{respondsTo}}/selection).
h3. Fix
Capture the class-domain SwitchPoint *before* selection first reads MOP state,
and install the guard with that captured token: a change racing the selection
then invalidates the token, so the guard routes to the fallback and re-links
instead of publishing the stale selection.
Two complications:
* *Born-dead tokens:* lazy MetaClass creation during resolution bumps the class
generation, invalidating a token captured before the first-ever read (an
always-fallback guard then recurses to StackOverflowError). The capture
therefore retries: capture token, resolve the MetaClass, re-capture if the
token died during resolution — the next pass finds the cached instance and
converges.
* *Pathological re-creation:* some resolutions re-create the MetaClass on every
lookup (e.g. ClosureMetaClass under {{ExpandoMetaClass.enableGlobally()}},
which {{isValidWeakMetaClass}} always rejects), so no pass can end with a live
token and an unbounded retry livelocks. The retry is bounded (3 passes); on
exhaustion the selection degrades to the existing uncacheable path — no guards,
fresh selection per call — which is correct and cannot livelock or recurse.
A deterministic regression test in {{IndyScopedSwitchPointTest}} encodes the
contract (a token captured before a mid-selection mutation must route to the
fallback); the stress test remains the probabilistic end-to-end check.
The classic (non-indy) {{CallSiteArray}} path has not been audited for an
analogous window and may warrant a follow-up.
> Indy: MetaClass change during call-site linking can be lost
> -----------------------------------------------------------
>
> Key: GROOVY-12307
> URL: https://issues.apache.org/jira/browse/GROOVY-12307
> Project: Groovy
> Issue Type: Bug
> Reporter: Paul King
> Assignee: Paul King
> Priority: Major
>
> The GROOVY-12281 stress test intermittently fails with a per-thread
> monotonicity violation:
> {noformat}
> ClassInfoSoftModeStressTest >
> softModeSurvivesRealGcClearingUnderConcurrency() FAILED
> stress probe failed: dispatches=3721845 generations=114
> javaInfosCollected=33/36
> ERROR: mutated-1: IllegalStateException: mutated-1 observed gen 13 after
> already seeing 14
> {noformat}
> i.e. a dispatcher thread observed an older ExpandoMetaClass generation
> *after* already observing a newer one. The stress environment (soft
> ClassValue mode plus GC churn) only raises the relink frequency; the
> underlying race is independent of soft mode.
> h3. Root cause
> Indy call-site linking reads MOP state and installs its guard in the wrong
> order:
> # the fallback resolves the MetaClass and selects the target method;
> # only afterwards ({{Selector.setGuards}} ->
> {{IndyInterface.applyMopSwitchPoints}}) does it fetch the *current*
> class-domain SwitchPoint to guard that target.
> A MetaClass change landing between (1) and (2) retires the old SwitchPoint
> and rotates in a fresh one — which is exactly the token the racing relink
> then fetches. The stale selection is published under a live guard, clobbering
> any newer target already installed at the shared call site. The site serves
> the pre-change selection until an unrelated future invalidation; if the race
> lands on the last change, the staleness is permanent (a MetaClass mutation is
> silently lost).
> The same time-of-check/time-of-use hole exists in the cold reflective tier
> ({{ColdReflectiveMethodHandleWrapper.tryBuild}} captures {{classValidity}}
> after selection) and in the GEP-15 compound-assign resolver
> ({{IndyCompoundAssign.resolve}} fetches the SwitchPoint after
> {{respondsTo}}/selection).
> h3. Fix
> Capture the class-domain SwitchPoint *before* selection first reads MOP
> state, and install the guard with that captured token: a change racing the
> selection then invalidates the token, so the guard routes to the fallback and
> re-links instead of publishing the stale selection.
> Two complications:
> * *Born-dead tokens:* lazy MetaClass creation during resolution bumps the
> class generation, invalidating a token captured before the first-ever read
> (an always-fallback guard then recurses to StackOverflowError). The capture
> therefore retries: capture token, resolve the MetaClass, re-capture if the
> token died during resolution — the next pass finds the cached instance and
> converges.
> * *Pathological re-creation:* some resolutions re-create the MetaClass on
> every lookup (e.g. ClosureMetaClass under
> {{ExpandoMetaClass.enableGlobally()}}, which {{isValidWeakMetaClass}} always
> rejects), so no pass can end with a live token and an unbounded retry
> livelocks. The retry is bounded (3 passes); on exhaustion the selection
> degrades to the existing uncacheable path — no guards, fresh selection per
> call — which is correct and cannot livelock or recurse.
> A deterministic regression test in {{IndyScopedSwitchPointTest}} encodes the
> contract (a token captured before a mid-selection mutation must route to the
> fallback); the stress test remains the probabilistic end-to-end check.
> The classic (non-indy) {{CallSiteArray}} path has not been audited for an
> analogous window and may warrant a follow-up.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)