[ 
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)

Reply via email to