Skip to content

GROOVY-12488: SC: call the constructor of a constructor reference directly - #3022

Open
codeconsole wants to merge 15 commits into
apache:masterfrom
codeconsole:ctor-ref-static-compile
Open

codeconsole wants to merge 15 commits into
apache:masterfrom
codeconsole:ctor-ref-static-compile

Conversation

@codeconsole

@codeconsole codeconsole commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Under @CompileStatic, a constructor reference such as Made::new is implemented by a synthetic method ctorRef$<method>$<n> on the enclosing class, which the LambdaMetafactory call site targets. That method was not statically compiled:

private static final Made ctorRef$ctorRef$0();
   0: ldc           class Made
   2: invokedynamic init:(Ljava/lang/Class;)Ljava/lang/Object;
   7: invokedynamic cast:(Ljava/lang/Object;)LMade;
  12: areturn

So every call selected the constructor through the meta-class at run time. In a GraalVM native image the constructor also needed reflection metadata, although the code is statically compiled; Grails replaced X::new with () -> new X() for this reason. Other method references (String::length, Math::abs) already link their target directly.

The cause is in StaticTypesMethodReferenceExpressionWriter.addSyntheticMethodForConstructorReference. It built ctorX(type, args) without a target constructor and set STATIC_COMPILE_NODE to false, as its TODO described. StaticInvocationWriter.writeInvokeConstructor emits a direct call only when the constructor call carries DIRECT_METHOD_CALL_TARGET.

Constructor selection

The writer now selects the constructor among the constructors visible from the enclosing class, matched against the functional interface's parameter types with StaticTypeCheckingSupport.chooseBestMethod, as the type checker selects the constructor of new X(args). Inside a closure or lambda, visibility is checked for the enclosing class, not the generated class, as the type checker does, so a private constructor of a nested class stays visible from a closure in the outer class.

When exactly one constructor matches and the current class can call it, the writer sets it as DIRECT_METHOD_CALL_TARGET and compiles the synthetic method statically. The result is NEW/INVOKESPECIAL, with the existing writer handling boxing, unboxing, widening and variadic arrays. Private constructors of nestmates are called directly, and so are protected and package-private constructors of a class of the same package, unless that class comes from another class loader (see below).

Run-time selection is kept in these cases:

  • when no single constructor accepts the parameter types, e.g. Function<Object,Foo> where Foo only has Foo(String) (GROOVY-10971);
  • for an enum, which Java code cannot instantiate;
  • for a protected constructor from another package, which only super(...) can call;
  • for a protected or package-private constructor of a class from another class loader, when a GroovyClassLoader defines the compilation in memory: a class of an earlier GroovyShell parse, or a precompiled class used from a script. It is in another run-time package even with the same package name, so a direct call would fail with IllegalAccessError. GroovyClassLoader marks such compilations, and AsmClassGenerator.isInSameRuntimePackage uses the mark. Code compiled to class files (groovyc, Gradle, Maven, joint compilation) calls such a constructor directly, as javac does.

In a native image those cases still need reflection metadata for the constructors. A constructor that Groovy generates for default arguments is selected like a declared one: with D(String a, String b = 'b'), Function<String, D> f = D::new calls D(String) directly. Function<Map, A> f = A::new, where A has only a no-argument constructor, does a map-style construction like new A(map) with a Map variable (GROOVY-12454).

The synthetic method of an array constructor reference such as String[]::new is compiled as statically compiled code too. Its bytecode does not change: it already created the array with NEWARRAY, ANEWARRAY or MULTIANEWARRAY, and unboxed the size as a statically compiled new String[n] does.

Behavior differences

The constructor is now chosen at compile time by the static compiler's rules, as for new X(args) in the same class and as in Java, rather than from the run-time argument types:

  • Function<CharSequence,X> f = X::new calls X(CharSequence) even when it is given a String;
  • with IL(Object) and IL(long), Function<Integer,IL> calls IL(Object) (before: IL(long));
  • with IL2(int) and IL2(Integer), IntFunction<IL2> calls IL2(int) (before: IL2(Integer));
  • with VO(Object...), Function<Object,VO> given an Object[3] passes it as one element (before: three);
  • with PI(int), Function<Integer,PI> given null throws NullPointerException (before: GroovyRuntimeException).

A constructor the enclosing class cannot call no longer wins over one it can: with private Foo(String) and public Foo(Object), Function<String,Foo> from another class calls Foo(Object), as new Foo(s) does. Before, the meta-class called the private constructor.

A constructor added to the meta-class, for example with an ExpandoMetaClass constructor override, is no longer used by a statically compiled constructor reference, just as it is not used by a statically compiled new.

These are listed in a Groovy 7 entry of COMPATIBILITY.md, and the method reference section of the operators guide says how a constructor reference selects its constructor and which cases still need reflection metadata in a native image.

Interfaces

The synthetic method was private, static and final. The JVM rejects ACC_FINAL on any method of an interface (JVMS 4.6), so a method reference that needs a synthetic method in a static or default method of a @CompileStatic interface failed with ClassFormatError (illegal modifiers 0x101A) when the interface was loaded. That covered every helper method a method reference adds: Foo::new and String[]::new, variadic and static extension method references such as Arrays::asList as a Supplier<List> or Thread::startDaemon, and a method of Class such as String::getName. The method is no longer final in an interface; it is private, so it cannot be overridden anyway. This failure also happens on master.

Tests

The tests are in MethodReferenceTest, marked // GROOVY-12488:

  • bytecode tests for the no-argument, overloaded, variadic, generic and private-nestmate cases, a private nested-class constructor referenced from a closure and a lambda, the visible-constructor selection, same-package protected and package-private constructors (in the same compilation, of an earlier parse, and of class files), constructors generated for default arguments, an enum, a protected constructor of another package referenced from an anonymous subclass, array factories, and the run-time cases; the existing test that pinned the dynamic init/cast sequence is updated;
  • behavior tests for the same cases, plus primitive and widening arguments, non-static inner classes, default-argument constructors, the static overload rules, the meta-class override, a Map parameter, constructor and method references in static and default methods of an interface (each kind of helper method, checked private and not final), array constructor references for each size type the type checker accepts, and default-argument constructors of a class declared later or compiled before.

The groovy, gls, bugs, org.codehaus.groovy.classgen and org.codehaus.groovy.transform packages pass (13,420 tests), apart from bugs.Groovy12363, which also fails on master when it runs after bugs.Groovy12142 in the same JVM (GROOVY-12580, #3096, fixes it). The Checkstyle and CodeNarc gates pass.

Backport

The javap above is from Groovy 6.0.0, so 6.0.x has the same behavior. A backport to GROOVY_6_0_X would let Grails 9, which builds with Groovy 6, use constructor references in native images. As it changes which constructor some statically compiled constructor references call, it would go to 6.0.1 after a heads-up on the dev list.

JIRA: GROOVY-12488

@codeconsole codeconsole changed the title SC: call the constructor of a constructor reference directly GROOVY-12488: SC: call the constructor of a constructor reference directly Oct 24, 2026
@codeconsole
codeconsole force-pushed the ctor-ref-static-compile branch from 2a2825e to d5395be Compare October 24, 2026 21:59
@codecov-commenter

codecov-commenter commented Oct 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.23529% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.6818%. Comparing base (5b077f9) to head (73b3cc8).
⚠️ Report is 4 commits behind head on master.

Files with missing lines Patch % Lines
...rg/codehaus/groovy/classgen/AsmClassGenerator.java 66.6667% 0 Missing and 2 partials ⚠️
...sc/StaticTypesMethodReferenceExpressionWriter.java 89.4737% 0 Missing and 2 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##               master      #3022        +/-   ##
==================================================
+ Coverage     73.6711%   73.6818%   +0.0107%     
- Complexity      42584      42609        +25     
==================================================
  Files            1678       1678                
  Lines          146907     146944        +37     
  Branches        27790      27800        +10     
==================================================
+ Hits           108228     108271        +43     
+ Misses          28528      28520         -8     
- Partials        10151      10153         +2     
Files with missing lines Coverage Δ
src/main/java/groovy/lang/GroovyClassLoader.java 73.7500% <100.0000%> (+0.2652%) ⬆️
...groovy/classgen/asm/sc/StaticInvocationWriter.java 75.7112% <100.0000%> (+0.2687%) ⬆️
...rg/codehaus/groovy/classgen/AsmClassGenerator.java 85.5466% <66.6667%> (-0.0835%) ⬇️
...sc/StaticTypesMethodReferenceExpressionWriter.java 89.0080% <89.4737%> (-0.0676%) ⬇️

... and 18 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@daniellansun daniellansun left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for closing the long-standing TODO in StaticTypesMethodReferenceExpressionWriter.addSyntheticMethodForConstructorReference. Setting StaticTypesMarker.DIRECT_METHOD_CALL_TARGET on the synthetic ctorX and flipping STATIC_COMPILE_NODE to true when a single visible, directly callable constructor matches the SAM parameter types is the natural SC counterpart to an ordinary statically compiled new. Visibility is filtered with filterMethodsByVisibility(…, controller.getThisType()) (enclosing class, including from closures/lambdas), while bytecode accessibility uses controller.getClassNode() via isDirectlyAccessible — the right split. The new MethodReferenceTest cases (overloads, varargs, generics, nestmates, package/protected across compilation units, EMC constructor intercept, interface static/default methods) plus bytecode sequences for INVOKESPECIAL vs INVOKEDYNAMIC init are thorough.

I am leaving a COMMENT (not an approval): the approach is sound and well tested, but a few SC/accessibility edges and one incomplete path deserve attention before this lands.

Observations / concerns

  1. isDirectlyAccessible for protected/package-private requires declaringClass.isPrimaryClassNode() — correct, but easy to misread.
    Declining direct INVOKESPECIAL when the constructor’s class comes from another compilation unit / class loader (same package name, different runtime package) matches the JVM and is locked by testProtectedAndPackagePrivateConstructorsOfClassCompiledBeforeAreSelectedAtRuntime / testFunctionCN23. Please add a one-sentence comment that isPrimaryClassNode() here is a same-compilation-unit proxy for “same class loader”, not a general “source vs binary” style check — future readers may otherwise “fix” it to !isResolved() or similar and reintroduce IllegalAccessError.

  2. Protected constructors of a superclass in another package correctly stay dynamic — confirm no SC false sense of security.
    testFunctionCN17 / testProtectedConstructorOfAnotherPackageIsSelectedAtRuntime show runtime selection for p.A::new from a subclass and for ObjectInputStream::new. That is right: a static factory emitting NEW+INVOKESPECIAL is not a legal use of a protected ctor the way super(…) is. Worth a javadoc line on isDirectlyAccessible stating explicitly that subclass privilege does not grant direct constructor-reference emission. Without that, someone may add accessingClass.isDerivedFrom(declaringClass) by analogy with AsmClassGenerator.isMemberDirectlyAccessible and break verification / access control.

  3. Array constructor references remain STATIC_COMPILE_NODE = false.
    The returnType.isArray() branch still builds an ArrayExpression and never sets a direct target, so array ::new stays on the old non-SC synthetic path. Pre-existing limitation, but this PR’s TODO removal makes the asymmetry louder. Either:

    • briefly note in a comment why arrays are out of scope for GROOVY-12488, or
    • file a follow-up to mark the array factory synthetic method STATIC_COMPILE_NODE = true when the component type / dims are known (the bytecode tests already distinguish array factories).
      Not a blocker for this PR’s class::new goal.
  4. Ambiguity / conversion edges fall back to runtime — good — but default-parameter bridges need a careful look.
    chooseBestMethod + ctors.size() == 1 means overloads that STC would reject as ambiguous, or SAM types that need MetaClass conversion (Function<Object, Foo> with only Foo(String) — GROOVY-10971 / testFunctionCN16), correctly keep STATIC_COMPILE_NODE = false. Default-parameter constructors (testFunctionCN15) rely on Groovy’s generated overloads matching SAM arity. Please add a case where the only declared ctor has defaults and the SAM arity matches a generated bridge that is synthetic — confirm isDirectlyAccessible / emission still works (synthetic constructors are usually callable; if chooseBestMethod returns the bridge, SC must be allowed to invoke it). If synthetic bridges are ever filtered elsewhere, this path could silently degrade to runtime.

  5. Interface host: dropping ACC_FINAL on the synthetic helper is necessary and slightly orthogonal.
    addSyntheticMethod now uses ACC_PRIVATE | ACC_STATIC | (interface ? 0 : ACC_FINAL) because the JVM rejects private+final interface methods. testMethodReferencesInStaticAndDefaultMethodsOfInterface covers ctor and method refs. Good catch. Please ensure method-reference helpers for non-constructor refs on interfaces get the same flag treatment if they share addSyntheticMethod (they do — so this fixes a latent VerifyError for any interface-hosted method reference helper, not only ::new). A one-line comment pointing at the JVMS rule would help.

  6. EMC constructor intercept is bypassed — intentional SC semantics, should be stated on the writer.
    testFunctionCN24 shows Foo::new under SC ignores an EMC constructor while new Foo(s) from dynamic code does not. That matches other SC direct calls, but constructor references are a popular dynamic-looking API. A short javadoc on findConstructorReferenceTarget (“when a direct target is chosen, MetaClass constructor intercepts are not honoured”) would set expectations next to the code, not only in the test.

  7. filterMethodsByVisibility + private nestmate from closure.
    Visibility uses getThisType() (outer class); accessibility uses getClassNode() (closure/lambda class) with nestmate checks. testPrivateConstructorOfNestedClassIsCalledDirectlyFromClosureOrLambda locks this. One softer case: a constructor reference returned from a closure and invoked later still embeds the helper in the closure class — already covered. No change requested; calling out so reviewers see the split is deliberate.

Test gaps I would still like to see

  • Comment or follow-up for array ::new static compile (item 3).
  • Default-parameter / synthetic bridge ctor chosen by arity (item 4).
  • Optional: enum E { ; E(String s){} } / E::new as Function<String,E> — findConstructorReferenceTarget returns null for enums; a bytecode assert of INVOKEDYNAMIC init would document that enums never take the direct path (even when a ctor looks writable).
  • Optional: abstract class with protected ctor + anonymous SC subclass exposing ::new — expect runtime path.

Non-blocking nits

  • Import of chooseBestMethod / samePackageName keeps the writer aligned with STC rather than reimplementing overload selection — appreciated.
  • compileStaticMethodBytecodeAgainstLibrary is a useful helper; consider whether it belongs on AbstractBytecodeTestCase for reuse by other SC tests.
  • Updating the older nestmate bytecode test from indy-init to NEW/INVOKESPECIAL is the right golden-bytecode adjustment.

Stance

Directionally I am in favour: this is the SC constructor-reference story we have wanted since the TODO was written, with careful accessibility rules and strong tests. The main asks before approval are documenting why protected subclass privilege must not be added to isDirectlyAccessible, clarifying the primary-class-node / class-loader proxy, and acknowledging the array-::new leftover. I am not approving yet; happy to re-review once those are addressed.

@codeconsole

codeconsole commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor Author

Pushed 35346fd; its last two commits fix CI failures (a new test that counted JaCoCo's $jacocoInit under coverage, and a LogTest race). Several points refer to code that changed after f35e725; replies describe the current head, in the order of the review:

  1. Since 0b89f6c the check is AsmClassGenerator.isInSameRuntimePackage, and only compilations a GroovyClassLoader defines in memory treat another compilation's class as another run-time package; class files, incremental and joint compilation keep direct access. Its Javadoc now says that isPrimaryClassNode() stands for "defined by the class loader of the accessing class", not "compiled from source" (932d9e6).
  2. Documented on StaticInvocationWriter.isDirectlyAccessible (932d9e6): there is no subclass rule, since only super(...) and anonymous classes may call a protected constructor of another package. new of one is now a type checking error (fe92a5f), and ::new keeps run-time selection. a358ba1 adds the anonymous-subclass case (testFunctionCN27, testProtectedConstructorOfAnotherPackageIsSelectedAtRuntimeFromAnonymousSubclass).
  3. Done in this PR, 4169695: the synthetic method of an array constructor reference is compiled as statically compiled code. Its bytecode does not change: it already used NEWARRAY/ANEWARRAY/MULTIANEWARRAY with the size unboxed as a statically compiled new String[s] does. testArrayConstructorReferenceCreatesArrayLikeStaticallyCompiledNewArray pins the bytecode, testArrayConstructorReferencesWithEachSizeType the behavior.
  4. The constructors generated for default arguments are not synthetic: Verifier.addConstructor copies the declared constructor's modifiers (javap: flags: (0x0001) ACC_PUBLIC). Every class of the compilation is verified before any is generated, so the writer always sees them. a358ba1 pins direct calls for a class declared after the reference, all-default and package-private constructors, and a class of an earlier parse (testConstructorReferenceCallsConstructorGeneratedForDefaultArgumentsDirectly, testConstructorReferenceCallsConstructorGeneratedForDefaultArgumentsOfClassCompiledBefore, testFunctionCN26). The first fails if generated constructors are filtered out.
  5. All helper kinds share addSyntheticMethod; the comment now cites JVMS 4.6 (932d9e6). f85f7e2 tests the array, static-extension (Thread::startDaemon), Class method (String::getName) and other-class-loader (M::spm, M::pm) helpers in static and default interface methods; with ACC_FINAL put back they fail with ClassFormatError (0x101A).
  6. Added to the Javadoc of findConstructorReferenceTarget (932d9e6); testFunctionCN24 pins it.
  7. No change; the split is pinned by testPrivateConstructorOfNestedClassIsCalledDirectlyFromClosureOrLambda, which reads the helpers from the closure and lambda classes.

Test gaps:

  • Array ::new: item 3.
  • Default-parameter constructor chosen by arity: item 4.
  • Enum: testEnumConstructorReferenceSelectsConstructorAtRuntime (a358ba1). E::new as Function<String,E> does not type-check (the constructor is rewritten to take name and ordinal), so the test uses an interface matching (String, int, String) inside the enum, where the private constructor would be callable: the reference stays INVOKEDYNAMIC init, and the test fails without the enum guard.
  • Abstract class: a reference to its constructor is a type checking error, also in an anonymous subclass ("Cannot instantiate the type p.A", GROOVY-11385), so there is no run-time path. For a concrete class with a protected constructor of another package, the anonymous subclass's p.B::new is selected at run time (testFunctionCN27).

Nits:

  • Kept.
  • Moved to AbstractBytecodeTestCase as compileAgainstLibrary, extractMethodSequence and selectMethod (51893ae); StaticCompileRuntimePackageTest uses them too.
  • No change.

16 of the 35 new MethodReferenceTest tests and the updated test fail on master, and 21 of the 30 StaticCompileRuntimePackageTest tests.

@paulk-asert

Copy link
Copy Markdown
Contributor

Thanks Scott, this is a thorough piece of work and the core change is right: under static compilation, X::new now selects its constructor the way new X(args) and every other method reference already do. I read the full compiler and runtime diff. Three things before merge, then a suggestion on how to carve it up.

1. Documentation. The behaviour changes in the description (overload chosen by the SAM's static types, Object[] to varargs as one element, NPE for null to a primitive, visible-constructor selection, the EMC constructor override bypass) are method-resolution changes, which COMPATIBILITY.md classes as breaking. They are the right semantics for @CompileStatic, but they need a "Groovy 7" entry there, plus a sentence in the constructor-reference section of core-operators.adoc. Same for the new compile error on new of a protected constructor from another package in a subclass: dynamic Groovy allows that, so it is a real Groovy/Java difference that SC now closes. Worth a line for native-image users too: the fallback cases (enum, protected constructor of another package, ambiguous overload, anything a GroovyClassLoader defines at run time) still need reflection metadata.

2. Scope. The constructor-reference fix needs the run-time-package guard for constructors, since without it a direct call would regress a GroovyShell script referencing a package-private constructor from an earlier parse. Everything beyond that is fixing pre-existing bugs, some unrelated to constructor references. I'd like those under their own JIRAs even if the commits stay here, and the BroadcastChannel, test de-flaking and LogTest changes moved to a separate PR. Suggested grouping:

A. Constructor references, Groovy 7 and the 6.0.1 backport Grails wants:
d5395be, 9f9124f, 8fba262, f60c6e4 (the ACC_FINAL fix is JVMS 4.6 and hits every helper method in a @CompileStatic interface, so it belongs here), f35e725, 4169695, a358ba1, f85f7e2, 50a8e6e, 51893ae, the constructor slice of 0b89f6c (GroovyClassLoader marker, isInSameRuntimePackage, StaticInvocationWriter.isDirectlyAccessible and its use in the method-reference writer), and the constructor slice of 932d9e6. c854974 is superseded by GROOVY-12454 from master: its production change is gone from the net diff and only testFunctionCN25 remains, so keep the test and drop the rest.

B. The same run-time-package rule applied to new, methods, fields and method references, so 7.0 doesn't ship a rule that applies only to constructors. One new JIRA:
19f8427, c4b5728, 41c24c6, 1865f19, 3bf8589, the remainder of 0b89f6c and 932d9e6, a4ef7fb, plus the protected-member diagnostics fe92a5f and 8c338c3 and the Selector change 94895f3 (all three turn VerifyError or GroovyBugError into a compile error or the meta-class path, so no working code is affected).

C. Extras, each its own JIRA and PR:
0e55d32 (package-private classes of another class loader: this widens inferred types in StaticTypesTypeChooser and follows through the call-site writer, for-each, spread and method-pointer paths, which is the broadest codegen change in the PR), b4b1b63 (MetaClassImpl super dispatch; this affects all dynamic code in any split-package setup, not just scripts), a270edd (BroadcastChannel publisher after close; a shipped 6.0.0 API, and it conflicts on GROOVY_6_0_X), bce20df and 99ad290 (sleep-to-await test changes), 35346fd (LogTest). The merge commit d64a720 goes away on squash.

3. Backport. Group A is contained and well tested; I'd take it to 6.0.1 after a heads-up on the dev list, since it changes overload selection in a point release. Groups B and C should soak on master first.

Two smaller notes: the protected-constructor error is raised from StaticInvocationWriter during class generation rather than by the type checker, so @TypeChecked code is silent and works dynamically; tolerable, but worth a comment. And GroovyClassLoader marks every parseClass compilation as defined in memory regardless of the collector, so a subclass that defines classes into the loader itself will take the meta-class path where direct access would work; not a failure, but a sentence in the javadoc would help the next reader.

@daniellansun daniellansun left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the substantial follow-up on tip 35346fd, and for the detailed reply to my earlier COMMENT on f35e725. I also read Paul’s notes on documentation and scope.

Prior technical asks — largely closed

  1. Same-runtime-package proxy. AsmClassGenerator.isInSameRuntimePackage with the clarified “defined by the class loader of the accessing class” Javadoc is much clearer than reading isPrimaryClassNode() alone. Restricting the meta-class fallback to in-memory GroovyClassLoader compilations (class files / incremental / joint keep direct access) is the right boundary.

  2. No subclass privilege for protected ctors. Documented on isDirectlyAccessible; new of a protected ctor from another package is now a compile error under SC; ::new keeps runtime selection; anonymous-subclass cases added. Please do not later add isDerivedFrom by analogy with member access.

  3. Array ::new. Marking the synthetic method STATIC_COMPILE_NODE = true (bytecode already NEWARRAY / ANEWARRAY / MULTIANEWARRAY) removes the asymmetry. Good.

  4. Default-argument bridges. Confirmed non-synthetic (Verifier.addConstructor copies modifiers) and pinned for late-declared / precompiled / package-private shapes.

  5. Interface helpers / ACC_FINAL. Shared addSyntheticMethod + JVMS 4.6 comment, with ClassFormatError regression coverage for array, extension, Class method, and other-loader helpers, closes the latent VerifyError for every interface-hosted helper — not only ::new.

  6. EMC bypass. Javadoc on findConstructorReferenceTarget + testFunctionCN24 set expectations.

  7. Enum / abstract / anonymous protected cases filled as discussed.

Blocking / process before I would approve

  1. Merge conflicts. Tip is mergeable_state: dirty against current master. Please rebase / resolve before any approval can mean anything operationally.

  2. Scope carve-up (agreeing with Paul). The core X::new static compile is coherent and well tested. The PR also carries runtime-package generalisation for methods/fields/package-private classes, BroadcastChannel publisher-after-close, sleep→await de-flaking, and LogTest GC races. Those are valuable, but they are separate risk and backport stories. Paul’s A / B / C split (ctor-ref + necessary ctor runtime-package guard for 6.0.1; broader runtime-package + diagnostics for master soak; BroadcastChannel / LogTest / de-flakes as their own JIRAs) is the right way to land this without blocking Grails on unrelated surface area.

  3. Documentation. Overload selection by SAM static types, Object[]→varargs-as-one-element, NPE for null→primitive, visible-ctor selection, and EMC constructor bypass are COMPATIBILITY.md-class changes under SC. A Groovy 7 entry there, a sentence in core-operators for constructor references, and a native-image note that fallback cases still need reflection metadata should land with group A.

Smaller residual

  • Protected-ctor error raised from StaticInvocationWriter (classgen) means @TypeChecked stays silent and works dynamically — tolerable; a code comment stating that is enough for now.
  • Paul’s note that marking every parseClass compilation as in-memory can push subclass loaders onto the meta-class path even when direct access would work — not a failure, but a Javadoc sentence would help the next reader.

Stance

I am leaving a COMMENT, not an approval: tip conflicts, and the PR still mixes a backport-worthy ctor-ref fix with broader runtime-package and unrelated cleanups. Once conflicts are cleared and group A is separable (with COMPATIBILITY / docs), I would be glad to re-review that slice toward approval. Thank you for the depth of the work — the technical answers to the earlier review are solid.

…ectly

Under static compilation, a constructor reference such as Made::new is
implemented by a synthetic method of the enclosing class, which the
LambdaMetafactory call site targets. That method was generated with
STATIC_COMPILE_NODE set to false, because its constructor call had no
target constructor. So it created the object through the meta-class, an
"init" invokedynamic call site followed by a "cast" call site, and the
constructor was selected at run time on every call. In a GraalVM native
image, the constructor also had to be registered for reflection although
the code was statically compiled.

StaticTypesMethodReferenceExpressionWriter now selects the constructor
when it builds the synthetic method: chooseBestMethod matches the
declared constructors of the type against the parameter types of the
functional interface, as the arguments of a constructor call are
matched. When a single constructor matches and the current class can
call it, it becomes the DIRECT_METHOD_CALL_TARGET of the constructor
call and the synthetic method is statically compiled, so
StaticInvocationWriter emits NEW and INVOKESPECIAL, with any boxing,
unboxing, widening or variadic array the arguments need. As for other
method references, the constructor is then chosen by the parameter
types of the functional interface, not by the run-time types of the
arguments.

The constructor is still selected at run time when no single
constructor accepts the parameter types, for example Foo::new as a
Function<Object,Foo> when Foo only declares Foo(String), and when the
constructor is protected and declared in another package, which only a
super(...) call can use. Array constructor references are unchanged:
they already create the array directly.
… the visible ones

The constructor of a statically compiled constructor reference was chosen
from all the constructors the type declares. When the best match was a
constructor the enclosing class cannot use, such as private Foo(String)
next to a public Foo(Object), the reference fell back to run-time
selection and the meta-class called the inaccessible constructor, while
new Foo(s) in the same class calls Foo(Object).

The constructors are now filtered by visibility before chooseBestMethod,
as the type checker does for a constructor call, so the reference calls
Foo(Object) directly, as new Foo(s) and Java do. Visibility is checked
for the enclosing class of a closure or lambda, not for the generated
class, so a private constructor of a nested class stays visible from a
closure or lambda of the outer class, as it is for the type checker.
…y only within the compilation unit

A constructor reference called a protected or package-private constructor
of the same package directly. The JVM checks the run-time package, which
is the package name and the class loader, so the call failed with
IllegalAccessError when the class came from another class loader with the
same package name: a class of an earlier GroovyShell parse, which
GroovyClassLoader defines with another InnerLoader, or a precompiled class
used from a script.

Such a constructor is now called directly only when its class is compiled
in the same compilation unit, which defines all its classes with one class
loader. Otherwise the constructor is selected at run time, as before the
constructor reference was compiled statically.
…final in an interface

The synthetic method that implements a constructor reference, or adapts a
variadic or extension method reference, is private, static and final. The
JVM rejects a final private method in an interface, so a method reference
that needs one, such as Foo::new or Arrays::asList as a Supplier<List>, in
a static or default method of a statically compiled interface failed with
ClassFormatError when the interface was loaded. The method is no longer
final in an interface; it is private, so it cannot be overridden anyway.
…he meta-class

A statically compiled constructor reference now calls the constructor
directly, as new Foo(s) does under static compilation, so a constructor
added to the meta-class, for example with ExpandoMetaClass, is no longer
used by it. Pin this with a test, and mark the tests added for
GROOVY-12488 with the issue key.
…cked code does

new A(map), where map is a Map variable rather than a map literal and A
declares only a no-argument constructor, is a map-style construction:
Groovy calls the no-argument constructor and sets a property for each
entry of the map. The type checker accepts it: as the property names are
only known at run time, it does not select a constructor, and type-checked
code constructs the object through the meta-class. Under static
compilation nothing compiled such a call, and compilation stopped with the
internal error "Target constructor for constructor call expression hasn't
been set". A constructor reference such as Function<Map, A> f = A::new
stopped with the same error, because the type checker checks it as
new A(map).

The type checker now marks such a call for dynamic resolution, and the
static compiler creates the object through the meta-class, as
type-checked and dynamic code do. A map literal is still compiled to the
no-argument constructor and a setter call for each entry, and a
constructor reference to such a class still selects the constructor at
run time.

(Test only: GROOVY-12454 on master already makes static compilation
construct such an object through the meta-class, so only the test of the
constructor reference, testFunctionCN25, is kept.)
…ugh the meta-class only for classes defined in memory

The previous commits accessed a protected or package-private member of a
class with the same package name through the meta-class whenever that
class was compiled by another compilation unit. That also hit class files:
a Java class of a joint compilation, a main class used by a test, a class
left over from an incremental compilation. Such classes are loaded by one
class loader at run time, as javac assumes, so the direct access worked;
the meta-class path was slower, followed meta-class semantics, needed
reflection metadata in a native image, and made an incremental build give
other bytecode than a full one.

The run-time package of such a class only differs when the classes are
defined in memory by a GroovyClassLoader, which defines each compilation
(a GroovyShell script, parseClass, JSR-223) with an InnerLoader of its
own. GroovyClassLoader now records that on the compilation unit, and
AsmClassGenerator.isInSameRuntimePackage treats a class of another
compilation unit as another run-time package only then. Constructors,
map-style construction, methods, getters and setters, method references,
fields, dynamic super and this.@ access and super$ methods all use this
check. Compiling to class files keeps direct access, as before these
changes and as javac does.

(Constructor part: the GroovyClassLoader marker,
AsmClassGenerator.isInSameRuntimePackage and
StaticInvocationWriter.isDirectlyAccessible, which constructor references
use. The same rule for new, methods, fields, dynamic super, this.@ and
super$ methods follows separately.)
…structor reference

The synthetic method of an array constructor reference such as String[]::new
was the one constructor-reference method still marked as not statically
compiled. It is now compiled by the static writers, like the method of a
class constructor reference that has a target constructor.

Its bytecode does not change: the array was already created with NEWARRAY,
ANEWARRAY or MULTIANEWARRAY, and the size unboxed the way a statically
compiled new T[n] does (Integer.intValue, or DefaultTypeTransformation.intUnbox
for a Short or Byte). The new tests pin that bytecode, compared with
new String[s], and the behavior for each size type the type checker accepts
(int, short, byte and their wrappers), multi-dimensional and primitive
arrays, closures and lambdas, a negative size and null.
…rules of constructor references

- isInSameRuntimePackage: isPrimaryClassNode() stands for "defined by the
  class loader of the accessing class", not for "compiled from source".
- StaticInvocationWriter.isDirectlyAccessible: unlike
  isMemberDirectlyAccessible it has no subclass rule, since the verifier
  rejects NEW and INVOKESPECIAL of a protected constructor of another
  package in a subclass too.
- findConstructorReferenceTarget: a selected constructor is called
  directly, so a meta-class constructor override is not used, as for a
  statically compiled new. The guard comment now says why enums are left
  to the meta-class: the type checker accepts an enum constructor reference
  whose arguments match the generated constructor, but Java code cannot
  instantiate an enum.
- addSyntheticMethod: the ACC_FINAL rule of JVMS 4.6 applies to every helper
  method of a method reference in an interface.

(Constructor part: the notes on statically compiled new follow
separately.)
…nd abstract-class constructors

Default arguments: the constructors that Verifier generates for default
arguments keep the modifiers of the declared constructor and are not
synthetic, and every class of a compilation is verified before any class is
generated, so a constructor reference whose functional interface matches a
generated constructor calls it directly, also when the class is declared
after the reference, when the constructor is package-private, when every
parameter has a default, and for a public constructor of a class compiled
by an earlier parse. A package-private one of such a class is still
selected at run time.

Enums: the type checker accepts E::new inside the enum when the functional
interface matches the constructor Groovy generates (name, ordinal, ...); the
reference stays on the run-time path, while javac rejects it outright.

Abstract classes: A::new is a type checking error also inside an anonymous
subclass. A protected constructor of another package referenced from an
anonymous subclass is selected at run time and works.
… interface

All helper methods that a method reference adds to the current class are
created by addSyntheticMethod, which leaves out ACC_FINAL in an interface.
The interface tests covered the constructor-reference and variadic
helpers; they now also cover the array constructor reference, the static
extension method helper (Thread::startDaemon), the Class method helper
(String::getName) and the helper that calls a package-private method of a
class of another class loader (M::spm, M::pm), in static and default
methods, and check that every helper is private and not final. With
ACC_FINAL put back, both tests fail with ClassFormatError (illegal
modifiers 0x101A).

(Constructor-reference part: the case of a method of another run-time
package follows separately.)
…AbstractBytecodeTestCase

MethodReferenceTest and StaticCompileRuntimePackageTest each had a copy of
the helper that compiles a script against the classes of an earlier parse
of the same GroovyClassLoader, and of the code that keeps only the
instructions of one method. AbstractBytecodeTestCase now provides them as
compileAgainstLibrary, extractMethodSequence and selectMethod, and both
tests use them. Test code only; the tests are unchanged.

(Here MethodReferenceTest only; StaticCompileRuntimePackageTest, which
comes separately, uses the same helpers.)
…ce helpers

testMethodReferencesInStaticAndDefaultMethodsOfInterface listed every
synthetic method of the interface. With coverage on, JaCoCo adds its own
synthetic $jacocoInit, so the CI coverage job failed. The test now skips
names starting with '$'.
…nstructor references

COMPATIBILITY.md lists the changes for Groovy 7: the overload is chosen
by the static parameter types of the functional interface, an Object[]
is passed to a variadic constructor as one element, null for a primitive
parameter throws NullPointerException, a constructor the enclosing class
cannot call no longer wins over one it can, and a meta-class constructor
is not used. The method reference section of the operators guide says how
the constructor of a constructor reference is selected, when it is still
selected at run time, and that those cases need reflection metadata in a
native image.
…er subclass

GroovyClassLoader marks every parseClass compilation as defined in memory,
whatever its class collector does. A subclass whose collector defines the
classes with the GroovyClassLoader itself therefore takes the meta-class
path for non-public members of classes of its other compilations, where a
direct access would also work. The javadoc of the mark now says so.
@codeconsole

Copy link
Copy Markdown
Contributor Author

@paulk-asert @daniellansun I've split this as Paul suggested. This PR is now group A, rebuilt on current master, so the conflicts are gone:

This branch passes the groovy, gls, bugs, classgen and transform packages (13,420 tests); only bugs.Groovy12363 fails, as it does on master, and #3096 fixes that.

@testlens-app

testlens-app Bot commented Oct 30, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 73b3cc8
▶️ Tests: 125186 executed
⚪️ Checks: 27/27 completed


Learn more about TestLens at testlens.app/docs.

This branch has not been deployed

No deployments
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.

4 participants