Paul King created GROOVY-12156:
----------------------------------

             Summary: chooseBestMethod returns candidates in identity-hash 
order, making static-compilation output path-dependent
                 Key: GROOVY-12156
                 URL: https://issues.apache.org/jira/browse/GROOVY-12156
             Project: Groovy
          Issue Type: Improvement
          Components: Static compilation
            Reporter: Paul King
            Assignee: Paul King


### Description

`StaticTypeCheckingSupport.chooseBestMethod` accumulates its result in a 
`HashSet` and returns it as a list:

```java
// StaticTypeCheckingSupport.java:1066
Set<MethodNode> bestMethods = new HashSet<>(); // choose best method(s) for 
each possible receiver
for (ClassNode rcvr : duckType ? ((UnionTypeClassNode) receiver).getDelegates() 
: new ClassNode[]{receiver}) {
    ...
    bestMethods.addAll(view);
}
return new LinkedList<>(bestMethods); // assumes caller wants remove to be 
inexpensive
```

`MethodNode` overrides neither `equals()` nor `hashCode()` (unlike `ClassNode`, 
whose `hashCode()` is text-based and stable). The `HashSet` therefore buckets 
by **identity hash code**, and the returned candidate order is whatever 
identity hashing produces.

#### Why this is not benign

HotSpot's default identity hash (`-XX:hashCode=5`) is a **thread-local xorshift 
PRNG**. The value an object receives depends on how many identity hashes that 
thread has already handed out, and each thread has its own state. So the order 
is not random per run — it is *path-dependent*:

```
main thread, 0 prior hashes : [m5, m3, m1, m2, m4]
main thread, 3 prior hashes : [m5, m2, m4, m3, m1]
main thread, 7 prior hashes : [m2, m5, m4, m1, m3]
worker thread               : [m1, m2, m3, m5, m4]
worker thread               : [m5, m3, m1, m4, m2]
```

(5 objects with no `hashCode()` override, added to a `HashSet`, iterated.) A 
fresh single-threaded JVM repeating identical work reproduces the same order — 
but the candidate order changes when the compile runs on a different thread or 
after a different amount of prior work. In practice that means a **long-lived, 
reused Gradle daemon**, **parallel compile workers**, or simply **a different 
set of classes compiled earlier in the same JVM** can each shift the order. 
Reproducible builds require identical output from identical sources regardless 
of that state. The nondeterministic reflection order in GROOVY-12149 also 
perturbs the sequence of `hashCode()` calls, so the two defects amplify each 
other.

#### Where the order reaches generated code

Most callers reduce to a single candidate and raise an ambiguity error 
otherwise (`StaticTypeCheckingVisitor:4397`, `:3581`, `findMethodOrFail:5694`) 
— those are safe, since an ambiguity error emits no bytecode. But three callers 
accept a multi-candidate list **without error**:

1. **Union-type receiver (GROOVY-8965), `StaticTypeCheckingVisitor:4385`** — 
the case that *deliberately* fills the set with one best method per union 
delegate, and so is a non-error path by construction:
   ```java
   if (mn.size() > 1 && obj instanceof UnionTypeClassNode) {
       ClassNode returnType = mn.stream().map(MethodNode::getReturnType)
                                
.reduce(WideningCategories::lowestUpperBound).get();
       call.putNodeMetaData(DYNAMIC_RESOLUTION, returnType);
   ```
   `Stream.reduce` folds in list order, and `lowestUpperBound` is not reliably 
order-insensitive (folding interface types can build differing synthetic LUB 
nodes). The resulting inferred type drives casts and return types in emitted 
bytecode.

2. **Method pointers / method references (`::`), 
`StaticTypeCheckingVisitor:3236-3268`** — on more than one candidate it calls 
`extension.handleAmbiguousMethods`, whose default implementation 
(`TypeCheckingExtension:190`) returns the list unchanged, then applies the same 
order-sensitive `lowestUpperBound` fold to type the closure.

3. **Macro dispatch, `MacroCallTransformingVisitor:106-119`** — 
`findMacroMethods` returns `chooseBestMethod(...)` directly and the caller 
loops over the candidates until one succeeds, so **the first candidate wins**. 
When two macro methods both apply, identity-hash order decides which one 
expands the call — a *different generated AST*, not merely a different byte 
layout.

Ambiguity **error messages** also list candidates in this order, so diagnostics 
are unstable too.

### Proposed fix

Use an insertion-ordered set:

```java
Set<MethodNode> bestMethods = new LinkedHashSet<>();
```

`MethodNode` has identity `equals`/`hashCode`, so this is a 
semantics-preserving drop-in — deduplication behaviour is unchanged. It yields 
a *meaningful* order rather than an arbitrary one: candidates follow the 
`duckType` loop, i.e. the union's declared delegate order. 
`StaticTypeCheckingVisitor:671` already uses `LinkedHashSet<MethodNode>` for 
`PV_METHODS_ACCESS`, so this matches existing practice.

### Suggested test

A `@CompileStatic` union-type receiver (per GROOVY-8965) whose delegates 
declare the same method with different return types, asserting the inferred 
`DYNAMIC_RESOLUTION` type; and a macro case with two applicable macro methods, 
asserting which expansion wins. To exercise the path-dependence, run the 
compile on a worker thread and/or after a varying number of identity-hash 
allocations — with the `HashSet` the selection shifts, with `LinkedHashSet` it 
does not.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to