I found the issue; it's a Gradle bug.  I fed the information to AI and
had it reproduce it.  Below is basically a bug report.  I'll be fixing
in the code line & then opening a gradle ticket eventually:

# Forked Groovy compiler daemon reuse leaks `jvmArgumentProviders`
system properties between compile tasks

## Summary

When multiple `GroovyCompile` tasks run with `options.fork = true`,
Gradle reuses a single
forked compiler daemon (JVM) across tasks. JVM system properties contributed via
`options.forkOptions.jvmArgumentProviders` (a
`CommandLineArgumentProvider`) are applied
**once, at daemon start**, and are **not** part of the key Gradle uses
to decide whether a
daemon can be reused for another task.

The result: a compiler daemon first started for task **A** (which sets
`-Dfoo=A` via a
`jvmArgumentProvider`) is later reused to run task **B** (which sets a
*different* value, or
none). Inside task B's compilation, `System.getProperty("foo")` still
returns **A's** value.
Any AST transformation / compiler plugin that reads a system property
therefore sees a value
belonging to a *different* project.

This is a correctness issue: a task's requested fork JVM arguments are
silently ignored when a
daemon is reused, and one task's process-global state bleeds into another.

## Environment

- Gradle: **9.6.0**
- JDK: Liberica **21.0.7** (also reproduced on 21.x generally)
- Groovy: 4.0.x (`groovy` plugin, `GroovyCompile`)
- OS: reproduced on macOS (local) and Ubuntu (GitHub Actions CI)
- Relevant setting: `org.gradle.parallel=true`; also reproduces with
`--max-workers=1`

## Expected behavior

For two `GroovyCompile` tasks whose forked JVMs are configured with
*different* JVM arguments
(whether via `forkOptions.jvmArgs` **or**
`forkOptions.jvmArgumentProviders`), one of the
following should hold:

1. The daemon started for task A is **not** reused for task B (the
differing JVM args make the
   daemons incompatible), **or**
2. The provider-contributed arguments are re-applied per task.

In other words, `jvmArgumentProviders` should participate in
compiler-daemon reuse decisions
the same way `jvmArgs` do, so a task never runs in a daemon configured
with another task's
JVM arguments.

## Actual behavior

`jvmArgumentProviders` are applied to the daemon JVM at startup but
are **excluded from the
daemon-reuse key**. A daemon is reused across tasks with different
provider-contributed system
properties, and the daemon retains the system properties of whichever
task first started it.
Subsequent tasks observe the wrong (stale) system-property values at
compile time.

There is an inconsistency between the two ways of setting fork JVM args:
- `forkOptions.jvmArgs = ['-Dfoo=A']` — affects daemon identity
(partitions daemons).
- `forkOptions.jvmArgumentProviders.add { ['-Dfoo=A'] }` — applied to
the JVM, but does **not**
  affect daemon identity (daemons are shared across differing values).

## Real-world impact (how we found it)

We hit this in the Apache Grails build (a ~200-module Gradle project).
Grails contributes a
`-Dbase.dir=<projectDir>` system property to the Groovy compiler fork via a
`CommandLineArgumentProvider` (so it is configuration-cache safe). A
compile-time global AST
transformation reads `System.getProperty("base.dir")` to locate the
module being compiled and
merge that module's checked-in `META-INF/grails.factories` into the
generated output.

Because the compiler daemon is reused, modules that did **not** set
`base.dir` (or set a
different one) read the `base.dir` of an unrelated module and merged
**that** module's
`grails.factories` into their own output — producing published
artifacts that contain
service-registration entries pointing at classes from a different
module (and not present in
the jar). This is non-deterministic: which module "wins" depends on
daemon scheduling, so the
same source produced different artifacts on different machines/CI
runs, breaking reproducible
builds.

### Direct evidence

We instrumented the transform to log the compiler-daemon PID, the
compilation target
directory, and `System.getProperty("base.dir")` for every compile, then ran
`./gradlew assemble --max-workers=1 --rerun-tasks --no-build-cache` (with
`org.gradle.parallel=true`). Representative output (one line per
compiled module):

```
pid=68685  compiled=grails-cache            saw base.dir=grails-cache
   <- daemon started here
pid=68685  compiled=grails-converters       saw base.dir=grails-cache
   <- REUSED, stale value
pid=68685  compiled=grails-rest-transforms  saw base.dir=grails-cache
   <- REUSED, stale value
pid=68813  compiled=grails-plugin           saw base.dir=grails-plugin
   <- daemon started here
pid=68813  compiled=grails-interceptors     saw base.dir=grails-plugin
   <- REUSED, stale value
pid=68571  compiled=grails-controllers      saw base.dir=null
   <- daemon started with no base.dir
pid=68571  compiled=grails-domain-class     saw base.dir=null
   <- REUSED (also fine)
```

A single daemon PID compiles several modules and reports a **fixed**
`base.dir` — the value
belonging to whichever module first started that daemon — regardless
of what the currently
compiling module configured.

## Minimal reproduction

A standalone project reproducing the leak without any of the Grails
machinery. Three pieces:
a global AST transform that records the system property it observes at
compile time, and two
modules whose compiler forks request *different* values via
`jvmArgumentProviders`.

### `settings.gradle`
```groovy
rootProject.name = 'daemon-fork-arg-leak'
include 'transform', 'moduleA', 'moduleB'
```

### `transform/build.gradle`
```groovy
plugins { id 'groovy' }
repositories { mavenCentral() }
dependencies { implementation "org.apache.groovy:groovy:4.0.28" }
```

### `transform/src/main/groovy/repro/ProbeTransform.groovy`
```groovy
package repro

import org.codehaus.groovy.ast.ASTNode
import org.codehaus.groovy.control.CompilePhase
import org.codehaus.groovy.control.SourceUnit
import org.codehaus.groovy.transform.ASTTransformation
import org.codehaus.groovy.transform.GroovyASTTransformation

@GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION)
class ProbeTransform implements ASTTransformation {
    @Override
    void visit(ASTNode[] nodes, SourceUnit source) {
        def log = new File(System.getProperty('repro.log'))
        log.append("pid=${ProcessHandle.current().pid()} " +
                   "compiling=${source.name} " +
                   "repro.base=${System.getProperty('repro.base')}\n")
    }
}
```

### 
`transform/src/main/resources/META-INF/services/org.codehaus.groovy.transform.ASTTransformation`
```
repro.ProbeTransform
```

### `moduleA/build.gradle`
```groovy
plugins { id 'groovy' }
repositories { mavenCentral() }
dependencies {
    implementation "org.apache.groovy:groovy:4.0.28"
    implementation project(':transform')   // puts the global
transform on the compile classpath
}
def reproLog = 
rootProject.layout.buildDirectory.file('probe.log').get().asFile.absolutePath
tasks.withType(GroovyCompile).configureEach {
    options.fork = true
    // A sets repro.base=A via a CommandLineArgumentProvider
    options.forkOptions.jvmArgumentProviders.add({ ['-Drepro.base=A',
"-Drepro.log=${reproLog}".toString()] } as
CommandLineArgumentProvider)
}
```

### `moduleB/build.gradle`
```groovy
plugins { id 'groovy' }
repositories { mavenCentral() }
dependencies {
    implementation "org.apache.groovy:groovy:4.0.28"
    implementation project(':transform')
}
def reproLog = 
rootProject.layout.buildDirectory.file('probe.log').get().asFile.absolutePath
tasks.withType(GroovyCompile).configureEach {
    options.fork = true
    // B sets repro.base=B (a DIFFERENT value) via a CommandLineArgumentProvider
    options.forkOptions.jvmArgumentProviders.add({ ['-Drepro.base=B',
"-Drepro.log=${reproLog}".toString()] } as
CommandLineArgumentProvider)
}
```

### One source file per module
```groovy
// moduleA/src/main/groovy/a/A.groovy
package a
class A {}
```
```groovy
// moduleB/src/main/groovy/b/B.groovy
package b
class B {}
```

### Run
```bash
./gradlew :moduleA:compileGroovy :moduleB:compileGroovy
--max-workers=1 --rerun-tasks
cat build/probe.log
```

### Expected `build/probe.log`
```
pid=<X> compiling=.../A.groovy repro.base=A
pid=<Y> compiling=.../B.groovy repro.base=B
```

### Actual `build/probe.log` (bug)
```
pid=<X> compiling=.../A.groovy repro.base=A
pid=<X> compiling=.../B.groovy repro.base=A   <-- same daemon PID, B
sees A's value
```

> Note: to make the daemon reuse deterministic, run the two compile tasks in a 
> single
> invocation. Whether B observes `A` or `B` depends only on which task started 
> the shared
> daemon; the point is that B runs in a daemon configured with A's JVM 
> arguments.

## Suggested fix / discussion

The core inconsistency is that `forkOptions.jvmArgs` participate in
compiler-daemon reuse
identity but `forkOptions.jvmArgumentProviders` do not, even though
both end up on the forked
JVM's command line. Resolving the providers and including their output in the
`DaemonForkOptions` fingerprint (used for reuse) would make daemon
reuse correct: a daemon
would only be reused for a task whose fully-resolved fork JVM arguments match.

If that is intended behavior (providers deliberately excluded from
daemon identity), it would
help to document it prominently, because it makes
`jvmArgumentProviders` unsafe for passing any
per-task system property when compiler daemons are shared — a subtle,
data-dependent
correctness trap.

## Workarounds we are evaluating (for reference)

- Passing per-task data as static `forkOptions.jvmArgs` instead of via
a provider (partitions
  daemons, but is not configuration-cache friendly with absolute
paths, and disables daemon
  reuse across modules).
- Not relying on a process-global system property to identify the
module being compiled;
  deriving it from the per-compilation
`CompilerConfiguration.targetDirectory` instead.

On Tue, Jun 30, 2026 at 7:49 PM James Daugherty <[email protected]> wrote:
>
> I can't seem to reproduce this issue locally.  I've opened 
> https://github.com/apache/grails-core/pull/15799 as a guard for this issue so 
> we can fix once we know what's causing it.  If I can get 2 reviewers, I can 
> reroll the release.
>
> On 2026/06/30 22:02:04 James Daugherty wrote:
> > Just an update: I've located 2 issues with the build after fixing the 
> > licensing issue.
> >
> > 1. there are build reproducibility issues that appear related to the Gradle 
> > 9 upgrade.
> > 2. the micronaut island logic was recompiling everything under java 25, 
> > which meant the binaries from the script were v25 while the binaries in 
> > github action build were v21.
> >
> > I've fixed #2, and am researching #1.  I'll follow through once I figure 
> > out a solution.
> >
> > -James
> >
> > On 2026/06/30 16:09:13 James Daugherty wrote:
> > > FYI: GitHub is having issues running the abort action.  I'm seeing a 500 
> > > error.  I intend to restage this later today or tomorrow as a result.
> > >
> > > On 2026/06/30 14:54:32 James Daugherty wrote:
> > > > The initial vote for 8.0.0-M2
> > > > (https://lists.apache.org/thread/ffqocxwqo7bkmlnjpgq5nntobmbcnbrw) is
> > > > cancelled due to a licensing issue with the build.  I'll reroll this
> > > > today and try to get a new vote sent this afternoon.
> > > >
> > > > -James
> > > >
> > >
> >

Reply via email to