-
-
Notifications
You must be signed in to change notification settings - Fork 438
scalaDocGenerated fails with classloader conflict when mill-contrib-docker is a build dependency in Mill 1.1.3 #6923
Description
Disclaimer
Note
Investigation and root cause analysis was done via ai agent. I have marked the sections below accordingly.
Summary
Note
Reviewed and edited by human
After upgrading from Mill 1.1.2 to 1.1.3, all scalaDocGenerated tasks fail for Scala 3 modules. The root cause is that mill-contrib-docker gained a new dependency on jib-core:0.27.2 in 1.1.3, which transitively pulls Jackson JARs onto the Mill runner classloader. This creates a classloader conflict with Scaladoc's own Jackson dependency, causing a ServiceConfigurationError.
Reproduction
Note
Reviewed, reproduced and edited by human
Prerequisites
- A Scala 3 project using Mill 1.1.3
mill-contrib-dockerdeclared as a build dependency
Minimal build.mill
//| mill-version: 1.1.3
//| mill-jvm-version: temurin:25
//| mvnDeps: ["com.lihaoyi::mill-contrib-docker:$MILL_VERSION"]
package build
import mill._
import mill.scalalib._
object `package` extends ScalaModule:
def scalaVersion = "3.8.2"Steps
./mill scalaDocGeneratedExpected
Scaladoc generation completes successfully (as it does when setting mill-version to 1.1.2 or when removing the docker contrib dependency).
Actual
The task fails. The visible output from scaladoc is misleading:
<...>/out/scalaDocGenerated.dest/javadoc' does not exist or is not a directory or .jar file
Running with --debug reveals the actual exception:
java.util.ServiceConfigurationError: com.fasterxml.jackson.databind.Module:
com.fasterxml.jackson.datatype.jsr310.JavaTimeModule not a subtype
at java.base/java.util.ServiceLoader.fail(ServiceLoader.java:559)
...
at com.fasterxml.jackson.databind.ObjectMapper.findAndRegisterModules(ObjectMapper.java:1165)
at dotty.tools.scaladoc.site.BlogParser$.readYml(BlogParser.scala:19)
at dotty.tools.scaladoc.site.StaticSiteLoader.loadBlog(StaticSiteLoader.scala:118)
at dotty.tools.scaladoc.site.StaticSiteLoader.loadBasedOnFileSystem(StaticSiteLoader.scala:111)
...
at dotty.tools.scaladoc.Scaladoc$.run(Scaladoc.scala:241)
at dotty.tools.scaladoc.Main.run(Main.scala:8)
Root Cause Analysis
Note
Largely unedited and only skimmed from hereon to the end. Root cause analysis seems plausible to me but goes into
more depth than I am willing to review and understand fully. I have kept it because it might aid maintainers in
understanding the root cause.
The dependency change
In Mill 1.1.2, mill-contrib-docker had no dependency on jib-core:
<!-- mill-contrib-docker_3-1.1.2.pom -->
<dependencies>
<dependency><groupId>org.scala-lang</groupId><artifactId>scala-library</artifactId></dependency>
<dependency><groupId>com.lihaoyi</groupId><artifactId>mill-moduledefs_3</artifactId></dependency>
<dependency><groupId>com.lihaoyi</groupId><artifactId>unroll-annotation_3</artifactId></dependency>
<dependency><groupId>com.lihaoyi</groupId><artifactId>mill-libs-javalib_3</artifactId><scope>provided</scope></dependency>
</dependencies>In Mill 1.1.3, jib-core was added:
<!-- mill-contrib-docker_3-1.1.3.pom -->
<dependencies>
<dependency><groupId>com.google.cloud.tools</groupId><artifactId>jib-core</artifactId><version>0.27.2</version></dependency>
<!-- ... same as before ... -->
</dependencies>jib-core:0.27.2 transitively depends on jackson-databind:2.15.2 and jackson-datatype-jsr310:2.15.2.
The classloader conflict
-
Because
mill-contrib-dockeris a build dependency, its entire transitive closure — including Jackson 2.15.2 — is placed on the Mill runner classloader (therunClasspath). -
When
ZincWorker.scaladocJarruns Scaladoc for Scala 3, it sets the thread's context classloader to the Mill worker classloader (source):mill.api.daemon.ClassLoader.withContextClassLoader(this.getClass.getClassLoader) { val scaladocClass = compilers.scalac().scalaInstance().loader().loadClass("dotty.tools.scaladoc.Main") // ... }
-
Scaladoc has its own classloader (via
compilers.scalac().scalaInstance().loader()) which contains its own Jackson JARs (e.g.jackson-databind:2.15.1from the scaladoc dependency tree). -
During Scaladoc rendering,
BlogParser.readYmlcreates a JacksonObjectMapperand callsfindAndRegisterModules(). This uses Java'sServiceLoader, which loads service providers from the context classloader (the Mill runner classloader — Jackson 2.15.2). However,ObjectMapperandcom.fasterxml.jackson.databind.Moduleare loaded from the scaladoc classloader (Jackson 2.15.1). -
Because the
Moduleclass is loaded by two different classloaders, Java considers them incompatible types:JavaTimeModule(from the Mill runner classloader) is "not a subtype" ofModule(from the scaladoc classloader) —ServiceConfigurationError.
Why the error message is misleading
The Retry(count=2, failWithFirstError=true) wrapper in ZincWorker.scaladocJar catches the ServiceConfigurationError and retries. On the first attempt, Scaladoc deletes the output directory as part of its initialization — but then crashes before recreating it. On retry, Scaladoc sees the missing output directory and prints "does not exist or is not a directory". The failWithFirstError=true flag means the original ServiceConfigurationError is the actual thrown exception, but only the scaladoc stdout/stderr from the retry is visible to the user.
This error-masking issue is separately tracked in #6362.
Why it worked in Mill 1.1.2
In 1.1.2, mill-contrib-docker did not depend on jib-core, so no Jackson JARs were present on the Mill runner classloader. ServiceLoader.findAndRegisterModules() found nothing from the context classloader, and all Jackson classes were loaded exclusively from the scaladoc classloader — single classloader, no conflict.
Possible Fixes
-
Isolate
jib-corein a worker classloader: Similar to howZincWorkeris loaded in a separate classloader viainternalWorkerClassLoader,mill-contrib-dockercould loadjib-coreand its transitive dependencies (including Jackson) in an isolated classloader rather than on the main runner classpath. This would preventjib-core's Jackson from being visible to other modules. -
Don't set the context classloader to the Mill worker classloader in
scaladocJar: Instead ofwithContextClassLoader(this.getClass.getClassLoader), usewithContextClassLoader(compilers.scalac().scalaInstance().loader())— the scaladoc classloader itself. The existing comment in the code acknowledges this is a workaround for DottyDoc's Jackson usage, but using the scaladoc classloader as the context classloader would ensureServiceLoaderfinds Jackson classes from the same classloader asObjectMapper. -
Shade Jackson in
jib-core(upstream fix): This would prevent Jackson from leaking out ofjib-core's dependency tree entirely.
Environment
- Mill: 1.1.3 (works on 1.1.2)
- Scala: 3.8.2 (likely affects all Scala 3 versions)
- JVM: Temurin 25
- OS: Linux