Paul King created GROOVY-12160:
----------------------------------

             Summary: @AnnotationCollector emits collected annotation members 
in hash order when the collector is precompiled
                 Key: GROOVY-12160
                 URL: https://issues.apache.org/jira/browse/GROOVY-12160
             Project: Groovy
          Issue Type: Improvement
            Reporter: Paul King
            Assignee: Paul King


h2. Description

When a collector annotation is precompiled, {{AnnotationCollectorTransform}} 
reads its preset members back from the generated {{CollectorHelper}} and copies 
them onto the annotations the collector expands to. That copy goes through an 
unordered map:

{code:java}
// AnnotationCollectorTransform.java:316-337 (makeListOfAnnotations)
for (Object[] inner : data) {
    Class<?> anno = (Class<?>) inner[0];
    AnnotationNode toAdd = new AnnotationNode(ClassHelper.make(anno));
    ret.add(toAdd);

    Map<String,Object> member = (Map<String, Object>) inner[1];
    if (member.isEmpty()) {
        continue;
    }
    Map<String, Expression> generated = new HashMap<>(member.size());
    for (Map.Entry<String, Object> entry : member.entrySet()) {
        generated.put(entry.getKey(), makeExpression(entry.getValue()));
    }
    copyMembers(generated, toAdd);       // iterates `generated` -> 
toAdd.addMember(...)
}
{code}

{{copyMembers}} ({{:274}}) iterates that map into {{AnnotationNode.addMember}}, 
and those members are written to the class file's annotation attribute in that 
order. So *the members of the expanded annotation are emitted in the hash order 
of their names*.

Declaration order is intact everywhere else in the chain and is lost only here: 
{{serialize(AnnotationNode)}} ({{:184}}) builds the helper's map by iterating 
{{AnnotationNode.getMembers()}}, which is a {{LinkedHashMap}}, so the order the 
members were declared on the collector survives into the precompiled 
{{CollectorHelper}}.

h3. Reachability

Only when the collector is *precompiled* — i.e. resolved from the classpath, so 
the members are read reflectively from its {{CollectorHelper}} via 
{{getTargetListFromClass}} ({{:280-290}}). A collector declared in the same 
compilation unit takes the AST-based path, which preserves order. The 
precompiled case is the normal one for any collector shipped in a library, 
including Groovy's own {{@Immutable}} and {{@Canonical}}.

h3. Demonstration

Compilation unit 1 (the collector, presetting five members):
{code:java}
import groovy.transform.AnnotationCollector
import java.lang.annotation.*

@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE)
@interface Marker {
    String alpha() default ''
    String beta() default ''
    String gamma() default ''
    String delta() default ''
    String epsilon() default ''
}

@Marker(alpha='a', beta='b', gamma='c', delta='d', epsilon='e')
@AnnotationCollector
@interface Coll {}
{code}

Compilation unit 2, compiled against the first:
{code:java}
@Coll
class Holder {}
{code}

Reading the emitted {{Holder.class}} with an ASM {{ClassReader}}, the members 
of the {{@Marker}} annotation appear as:

{noformat}
delta, epsilon, beta, gamma, alpha
{noformat}

rather than the declared order {{alpha, beta, gamma, delta, epsilon}}.

h3. Impact

The keys are {{String}}s and {{String.hashCode}} is stable, so for a given key 
set the iteration order is reproducible across JVM runs — this is not by itself 
a live reproducible-builds failure. It still matters because the emitted order 
is arbitrary rather than meaningful, it lands in the 
{{RuntimeVisibleAnnotations}} attribute of the class file, and within a 
colliding bucket {{HashMap}} chain order follows insertion order. Of the 
related ordering issues below, this one has the most ordinary trigger: any 
classpath collector with more than one preset member.

h3. Proposed fix

Use an insertion-ordered map:

{code:java}
Map<String, Expression> generated = new LinkedHashMap<>(member.size());
{code}

The collected members are then emitted in the order they were declared on the 
collector. The member *set* and their values are unchanged — only the order.

h3. Test

{{src/test/groovy/org/codehaus/groovy/transform/AnnotationCollectorMemberOrderTest.groovy}}
 compiles the annotation and collector into a temp directory as one compilation 
unit, applies the collector from a second unit with that directory on the 
classpath (so the precompiled path is exercised), then reads the emitted bytes 
with an ASM {{ClassReader}} and asserts the annotation's members appear in 
declaration order. It fails on the current code with {{['delta', 'epsilon', 
'beta', 'gamma', 'alpha']}} and passes with the fix.

h3. Related

GROOVY-12149 (nondeterministic reflection order flows into generated bytecode). 
Same class of defect: {{Verifier.addCovariantMethods}} (covariant bridge 
methods emitted in hash order), 
{{AutoImplementASTTransformation.getAllCorrectedMethodsMap}} 
({{@AutoImplement}} methods emitted in hash order), {{AsmClassGenerator}} 
{{referencedClasses}} ({{$class$}} synthetic fields and accessors), and 
GROOVY-12156 ({{chooseBestMethod}} candidates in identity-hash order).



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

Reply via email to