Skip to content

GROOVY-12223: Introduce hidden class support - #2755

Open
daniellansun wants to merge 2 commits into
masterfrom
GROOVY-12223
Open

GROOVY-12223: Introduce hidden class support#2755
daniellansun wants to merge 2 commits into
masterfrom
GROOVY-12223

Conversation

@daniellansun

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot 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.

⚠️ Performance Alert ⚠️

Possible performance regression was detected for benchmark.
Benchmark result of this commit is worse than the previous benchmark result exceeding threshold 1.50.

Benchmark suite Current: d64841c Previous: 2f85f42 Ratio
org.apache.groovy.bench.AryBench.java ( {"n":"100"} ) 0.020284455721950537 ms/op 0.011410769075782604 ms/op 1.78
org.apache.groovy.bench.CalibrationBench.memoryPointerChase 994.2828699711587 us/op 573.8179240262663 us/op 1.73

This comment was automatically generated by workflow using github-action-benchmark.

@codecov-commenter

codecov-commenter commented Aug 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 79.14439% with 39 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.0253%. Comparing base (b67fc73) to head (d64841c).
⚠️ Report is 9 commits behind head on master.

Files with missing lines Patch % Lines
...org/codehaus/groovy/runtime/ProxyClassDefiner.java 71.4286% 6 Missing and 14 partials ⚠️
...haus/groovy/runtime/metaclass/ReflectorLoader.java 57.1429% 9 Missing and 3 partials ⚠️
...ava/org/apache/groovy/util/HiddenClassDefiner.java 89.1304% 4 Missing and 1 partial ⚠️
...roovy/reflection/ClassLoaderForClassArtifacts.java 89.4737% 0 Missing and 2 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##               master      #2755        +/-   ##
==================================================
+ Coverage     69.9742%   70.0253%   +0.0510%     
- Complexity      35502      35607       +105     
==================================================
  Files            1557       1559         +2     
  Lines          131667     131821       +154     
  Branches        24166      24207        +41     
==================================================
+ Hits            92133      92308       +175     
+ Misses          31193      31162        -31     
- Partials         8341       8351        +10     
Files with missing lines Coverage Δ
...codehaus/groovy/runtime/ProxyGeneratorAdapter.java 90.4863% <100.0000%> (+0.6375%) ⬆️
...roovy/reflection/ClassLoaderForClassArtifacts.java 85.7143% <89.4737%> (+10.7143%) ⬆️
...ava/org/apache/groovy/util/HiddenClassDefiner.java 89.1304% <89.1304%> (ø)
...haus/groovy/runtime/metaclass/ReflectorLoader.java 61.2245% <57.1429%> (+61.2245%) ⬆️
...org/codehaus/groovy/runtime/ProxyClassDefiner.java 71.4286% <71.4286%> (ø)

... and 16 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.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

JMH summary — classic (commit e5fdd46)

Speedup vs trailing 90-day baseline on gh-pages. Higher = faster.
1.00 = in line with history. Per-benchmark ratio, geomean within group.
Time-per-op units inverted so direction is consistent. The calibrated
column divides out this runner's speed vs the baseline hardware, as
measured by Groovy-independent pure-Java ruler benchmarks.

Group Speedup Calibrated n
bench 0.940 × 0.995 × 99
core 1.024 × 1.054 × 83
grails 0.994 × 0.979 × 80

Runner calibration (this run vs baseline hardware): bench 0.95× (26 rulers) · core-ag 0.98× (3 rulers) · core-hz 0.96× (3 rulers) · grails-ad 1.10× (3 rulers) · grails-ez 0.95× (3 rulers)

Baseline: dev/bench/jmh/<part>/classic/data.js on gh-pages, trailing 90 days. Daily dashboard · Per-suite raw data

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

JMH summary — indy (commit e5fdd46)

Speedup vs trailing 90-day baseline on gh-pages. Higher = faster.
1.00 = in line with history. Per-benchmark ratio, geomean within group.
Time-per-op units inverted so direction is consistent. The calibrated
column divides out this runner's speed vs the baseline hardware, as
measured by Groovy-independent pure-Java ruler benchmarks.

Group Speedup Calibrated n
bench 0.984 × 1.005 × 99
core 2.995 × 2.874 × 83
grails 4.198 × 4.172 × 80

Runner calibration (this run vs baseline hardware): bench 0.98× (26 rulers) · core-ag 1.12× (3 rulers) · core-hz 0.95× (3 rulers) · grails-ad 0.91× (3 rulers) · grails-ez 1.10× (3 rulers)

Baseline: dev/bench/jmh/<part>/indy/data.js on gh-pages, trailing 90 days. Daily dashboard · Per-suite raw data

@blackdrag blackdrag 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.

  • HiddenClassDefiner contains method used only in tests
  • ProxyGeneratorAdapter and ReflectorLoader seems to duplicate work already done in HiddenClassDefiner, while still using HiddenClassDefiner

But the big point for me is MethodHandles.lookup(). It has the javadoc:

Returns a lookup object with full capabilities to emulate all supported bytecode behaviors of the caller. These capabilities include full privilege access to the caller. Factory methods on the lookup object can create direct method handles for any member that the caller has access to via bytecodes, including protected and private fields and methods. This lookup object is created by the original lookup class and has the ORIGINAL bit set. This lookup object is a capability which may be delegated to trusted agents. Do not store it in place where untrusted code can access it.

This method is caller sensitive, which means that it may return different values to different callers. In cases where MethodHandles.lookup is called from a context where there is no caller frame on the stack (e.g. when called directly from a JNI attached thread), IllegalCallerException is thrown. To obtain a lookup object in such a context, use an auxiliary class that will implicitly be identified as the caller, or use publicLookup() to obtain a low-privileged lookup instead.

So if I create a Lookup for HiddenClassDefinerTest in HiddenClassDefinerTest I will get full access. But if I create that in the same class for java.lang.String, I will not get full access. In practice this usually means that unless the class creates the lookup (for example during invokedynamic) it is useless for many things. So if you need private module access, then MethodHandles.lookup() is most likely not the way to go.
Which means I don`t think that the intend of HiddenClassDefiner.privateLookupIn and usuability go hand in hand here.

Copilot AI 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.

Pull request overview

Note

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Introduces JEP 371 hidden-class support across Groovy’s dynamic class generators to reduce class-space pollution and improve unloading behavior, with comprehensive new tests validating hidden vs fallback paths.

Changes:

  • Added HiddenClassDefiner utility to define hidden nestmates with package alignment and a kill-switch property.
  • Updated ReflectorLoader, ClassLoaderForClassArtifacts, and ProxyGeneratorAdapter to prefer hidden-class definition with transparent fallback.
  • Added/expanded tests covering hidden-class behavior and name-generation rules.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/main/java/org/apache/groovy/util/HiddenClassDefiner.java New central API for defining hidden classes (nestmate/strong), with package alignment + soft/strict entry points.
src/main/java/org/codehaus/groovy/runtime/metaclass/ReflectorLoader.java Prefer defining generated Reflectors as hidden nestmates of Reflector, with fallback to defineClass.
src/main/java/org/codehaus/groovy/reflection/ClassLoaderForClassArtifacts.java Prefer defining per-class artifacts as hidden nestmates of the target class; adds name-generation/docs refactor.
src/main/java/org/codehaus/groovy/runtime/ProxyGeneratorAdapter.java Prefer hidden-class proxies when safe; adds host-candidate selection and isProxyHidden() API.
src/test/groovy/org/apache/groovy/util/HiddenClassDefinerTest.groovy New unit tests for hidden-class definition, package alignment, and constructors.
src/test/groovy/org/codehaus/groovy/runtime/metaclass/ReflectorLoaderTest.groovy New tests covering hidden-class path for ReflectorLoader.
src/test/groovy/org/codehaus/groovy/reflection/ClassLoaderForClassArtifactsTest.groovy New tests validating hidden artifact definitions, constructor helper, and naming constraints.
src/test/groovy/groovy/util/ProxyGeneratorAdapterTest.groovy Adds hidden-class specific proxy tests and fixes Map.Entry reference.

Comment on lines +106 to +116
public Constructor defineClassAndGetConstructor(
final String name,
final byte[] bytes,
final Class<?>... parameterTypes) {
try {
final Class<?> cls = define(name, bytes);
return cls.getDeclaredConstructor(parameterTypes);
} catch (NoSuchMethodException e) {
return null;
}
}
Comment on lines +80 to +92
public Class define(final String name, final byte[] bytes) {
final Class<?> host = klazz.get();
if (host != null) {
final Class<?> hidden = HiddenClassDefiner.tryDefineNestmate(host, bytes, false);
if (hidden != null) {
return hidden;
}
}

// Fallback: visible class with the target's protection domain
final Class<?> cls = defineClass(
name, bytes, 0, bytes.length,
host != null ? host.getProtectionDomain() : null);
Comment on lines +88 to +95
void testCreateClassNameUniquenessAndJavaPrefix() {
def loader = new ClassLoaderForClassArtifacts(Host)
String first = loader.createClassName('m')
String second = loader.createClassName('m')
assertTrue(first.contains(Host.name))
assertTrue(first != second || second.endsWith('$0') || second.contains('$'))

def javaLoader = new ClassLoaderForClassArtifacts(String)
Comment on lines +339 to +343
Class<?> proxyCls = adapter.proxy(map).getClass()
assertTrue(proxyCls.isHidden(), 'Proxy class must report isHidden() == true')
assertThrows(ClassNotFoundException) {
Class.forName(proxyCls.getName())
}
Comment on lines +267 to +277
@SuppressWarnings("unchecked")
public static <T> Constructor<T> findConstructor(
final Class<T> type,
final Class<?>... parameterTypes) {
try {
return type.getDeclaredConstructor(parameterTypes);
} catch (NoSuchMethodException e) {
throw new IllegalStateException(
"Class " + type.getName() + " is missing the expected constructor", e);
}
}
Comment on lines +103 to +106
* @param name the fully qualified binary name of the Reflector class
* @param bytecode the bytecode of the Reflector class
* @param domain the protection domain for the class
* @param domain the protection domain for the fallback visible-class
* definition; not used when the hidden-class path succeeds
@daniellansun

Copy link
Copy Markdown
Contributor Author

@blackdrag Thanks for the careful review — those points were well taken. Here is how the current design addresses them.

Test-only API on HiddenClassDefiner

Agreed. The extra helpers that existed only for tests (findConstructor, strict multi-option define* overloads, etc.) have been removed. Production surface is now essentially:

  • isEnabled() / the disable property
  • tryDefineNestmate(Lookup, bytes, initialize) — preferred
  • tryDefineNestmate(Class, bytes, initialize) — best-effort for foreign hosts only

Duplication with ProxyGeneratorAdapter / ReflectorLoader

Also agreed that define policy should not be reimplemented at each call site. Class definition (package alignment, NESTMATE + weak lifecycle, soft-fail → null) lives only in HiddenClassDefiner.

  • ReflectorLoader / ClassLoaderForClassArtifacts are thin: one tryDefineNestmate + visible defineClass fallback.
  • Proxy-specific policy (when a proxy may be hidden, which host to try, MockFor-style “must stay nameable” aggregates) was pulled out of ProxyGeneratorAdapter into package-private ProxyClassDefiner, so the adapter stays focused on bytecode generation and no longer reimplements the define path.

MethodHandles.lookup() and private module access

Your reading of the javadoc matches how we now structure the API.

A Lookup only carries full privilege for the class that actually called MethodHandles.lookup(). Capturing a lookup inside HiddenClassDefiner (or any other utility) cannot magically grant full access into arbitrary hosts such as java.lang.String. Relying on that for private/module access would be the wrong model.

So the intended split is:

  1. Preferred: tryDefineNestmate(Lookup, …) where the nest-host class owns the lookup, e.g.
    private static final Lookup LOOKUP = MethodHandles.lookup();
    on ReflectorLoader / ProxyGeneratorAdapter. That is the correct caller-sensitive pattern.

  2. Best-effort only: tryDefineNestmate(Class, …) for foreign hosts (user classes we do not control). It uses privateLookupIn from HiddenClassDefiner and is documented to succeed only when the host package is accessible to Groovy’s module (typical for unnamed application classes; not a general substitute for a host-created Lookup). Callers must always handle null and fall back to ClassLoader.defineClass.

We no longer expose a public HiddenClassDefiner.privateLookupIn that would suggest “full access for any class from a utility.” That intent and usability are no longer conflated.

Happy to adjust naming or docs further if anything still feels misleading.

final Lookup lookup,
final byte[] bytes,
final boolean initialize) {
if (HIDDEN_CLASSES_DISABLED || lookup == null || bytes == null) {

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.

Did I see it wrong or is this effectively called only with MethodHandles.lookup()? Does it really matter if it is one time HiddenClassDefiner and another time ReflectorLoader?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You are not wrong that every production Lookup we pass in is obtained via MethodHandles.lookup() — we do not use another factory for that path.

What still matters is which class executes that call.

Lookup.lookupClass() becomes the nest host and fixes the hidden class’s defining loader, run-time package, and protection domain. So:

  • ReflectorLoader’s LOOKUP → nestmates of ReflectorLoader (its package / loader)
  • ProxyGeneratorAdapter’s LOOKUP → nestmates of ProxyGeneratorAdapter
  • foreign hosts via tryDefineNestmate(Class, …) → nestmates of that user class (privateLookupIn)

Those are not interchangeable. Sharing only the define policy (package alignment, NESTMATE+weak, soft-fail) in HiddenClassDefiner does not make the lookups equivalent.

HiddenClassDefiner’s own LOOKUP is used only as the caller argument to privateLookupIn for the foreign-host overload — never as the nest host for those generators.

So: same API (lookup()), different ownership — and yes, that ownership still matters.

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.

My Point is that this code is always called from within the Groovy Runtime. Which means MethodHandles.lookup() will always return the same. It will be for the module the runtime is in and allow access to public methods in that module as well as accessible methods in other classes. And that later case is the potential problem, since if the runtime has no access to a different module the ProxyAdapter will not have that too. You can do that MethodHandles.lookup() anywhere in the Groovy runtime to get the same result. It also means the private lookup is restricted. What does it mean for nest mates? Since we need private access privileges for the target host class I have high doubts that this works all as intended in a module system. Just try to create a nest mate for String for example.

Looking at the usage I see ReflectorLoader. I found only a test, no actual code. Which actually means this class should be deprecated/removed.
ProxyGeneratorAdapter is used by InlinedASTCustomizerFactory and ProxyGenerator. InlinedASTCustomizerFactory is probably fine, but for ProxyGenerator: what if the interface is not accessible by the runtime? And I mean groovy can inspect the class, the methods are public? Map for example is no problem. It is in a different and restricted module, but Map itself is public API. The scenario I always keep in mind for such cases is that of 2 modules. Module A in Java, Module B in Groovy and Module C, the Groovy runtime. And B uses A. B has to give C access, but why should A give C access? And then all kinds of things may start failing. So I try to improve the code base step-by-step in a way that can handle such cases for the time when Groovy becomes a real module and has to handle Groovy programs in real modules.

Now you probably say something like that this is why we have the fallback. And you are right about that. But assume we are in the modules case, how useful is the nestmate variant then in the end? What would it cover outside of module B and C? And is that enough for us? If you think it is good enough feel free to resolve this conversation.

@paulk-asert

Copy link
Copy Markdown
Contributor

I haven't done a proper review yet, but as part of some other work, I assessed whether the PR impacts potential GraalVM support if we try harder to support that in the future. It came back with below, I'm not sure we want to do what is says yet - but just wanted to capture it somewhere for now:

Two native-image observations from exercising this branch alongside the packed-closure work (GROOVY-12227). Both are small; the first is a genuine easy win.

1. The soft-fail contract doesn't hold under native image

tryDefineNestmate catches IllegalAccessException | SecurityException | LinkageError (plus IllegalArgumentException and IndexOutOfBoundsException), and the class javadoc documents the intent as returning null on the expected failure modes "so call sites fall back to ClassLoader#defineClass with one null check".

GraalVM signals "this runtime cannot define classes" with com.oracle.svm.core.jdk.UnsupportedFeatureError, which extends java.lang.Error directly:

$ javap com/oracle/svm/core/jdk/UnsupportedFeatureError.class   # from lib/svm/builder/svm.jar, GraalVM CE 25.2.4
public class com.oracle.svm.core.jdk.UnsupportedFeatureError extends java.lang.Error {

It is not a LinkageError, so it escapes the catch and propagates out of the try* method — in precisely the environment where the fallback matters most.

A blanket catch (Throwable) would contradict the javadoc's "unexpected failures ... are not swallowed as a blanket RuntimeException", so something targeted is probably wanted, e.g.:

} catch (Error e) {
    // GraalVM native image: runtime class definition is unsupported. Name-checked
    // to avoid a build-time dependency on org.graalvm.
    if ("com.oracle.svm.core.jdk.UnsupportedFeatureError".equals(e.getClass().getName())) {
        return null;
    }
    throw e;
}

2. The kill switch is baked in at build time

public static final boolean HIDDEN_CLASSES_DISABLED =
        SystemUtil.getBooleanSafe(PROPERTY_DISABLE, false);

static final, documented as "evaluated once at class-init so hot paths pay no property-lookup cost". Under native image this class is very likely initialized at build time, so the value captured is the build JVM's, and -Dgroovy.hidden.classes.disable=true at run time silently does nothing — the one escape hatch a native user would reach for.

This is the same trap I hit in GROOVY-12227: I ended up evaluating the equivalent check per link rather than caching it in a static, because a build-time-initialized class bakes in the wrong answer (the image-code property reports buildtime there, not runtime).

Possibly one fix for both

If isEnabled() did a per-call check that also returned false when
"runtime".equals(System.getProperty("org.graalvm.nativeimage.imagecode")), then the native path would never attempt the definition at all, the kill switch would work at run time, and (1) becomes belt-and-braces rather than load-bearing. If the hot-path cost of the property read is the concern, a non-final holder initialised on first use rather than at class-init keeps both properties.

Caveat on incidence

I have not observed (1) fire in practice — the coercion I tested ([run: { ... }] as Runnable) goes through java.lang.reflect.Proxy and works natively on both master and this branch, so it does not reach HiddenClassDefiner. This is from reading the code plus confirming the class hierarchy, not from a reproduced failure. Worth a targeted test if you think the proxy/reflector paths are reachable in a native image.

@daniellansun

Copy link
Copy Markdown
Contributor Author

@paulk-asert Thanks for capturing this — both points are real and we’ve addressed them in the current code.

1. Soft-fail under native image

Agreed that UnsupportedFeatureError extends Error directly and was not covered by LinkageError.

tryDefineNestmate now soft-fails that case by class name (com.oracle.svm.core.jdk.UnsupportedFeatureError) with no GraalVM compile dependency, and rethrows other Errors. The shared path is softFailOrRethrow(Error).

2. Kill switch baked in at build time

Agreed. The old static final HIDDEN_CLASSES_DISABLED snapshot was unsafe if the class is initialized at native-image build time.

We removed that field. isEnabled() is evaluated on each call (class definition is not a hot path):

  • returns false when org.graalvm.nativeimage.imagecode=runtime (skip define entirely);
  • otherwise honours -Dgroovy.hidden.classes.disable=true at run time.

So native users get a working escape hatch, and the soft-fail on UnsupportedFeatureError is belt-and-braces if something still attempts define.

Happy to adjust further if you hit a concrete native path that still reaches HiddenClassDefiner unexpectedly.

@daniellansun
daniellansun requested a review from blackdrag August 4, 2026 17:04
@sonarqubecloud

sonarqubecloud Bot commented Aug 4, 2026

Copy link
Copy Markdown

@testlens-app

testlens-app Bot commented Aug 4, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: d64841c
▶️ Tests: 0 executed
⚪️ Checks: 31/31 completed


Learn more about TestLens at testlens.app.

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.

5 participants