Skip to content

scalaDocGenerated fails with classloader conflict when mill-contrib-docker is a build dependency in Mill 1.1.3 #6923

@maxstreese

Description

@maxstreese

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-docker declared 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 scalaDocGenerated

Expected

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

  1. Because mill-contrib-docker is a build dependency, its entire transitive closure — including Jackson 2.15.2 — is placed on the Mill runner classloader (the runClasspath).

  2. When ZincWorker.scaladocJar runs 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")
      // ...
    }
  3. Scaladoc has its own classloader (via compilers.scalac().scalaInstance().loader()) which contains its own Jackson JARs (e.g. jackson-databind:2.15.1 from the scaladoc dependency tree).

  4. During Scaladoc rendering, BlogParser.readYml creates a Jackson ObjectMapper and calls findAndRegisterModules(). This uses Java's ServiceLoader, which loads service providers from the context classloader (the Mill runner classloader — Jackson 2.15.2). However, ObjectMapper and com.fasterxml.jackson.databind.Module are loaded from the scaladoc classloader (Jackson 2.15.1).

  5. Because the Module class is loaded by two different classloaders, Java considers them incompatible types: JavaTimeModule (from the Mill runner classloader) is "not a subtype" of Module (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

  1. Isolate jib-core in a worker classloader: Similar to how ZincWorker is loaded in a separate classloader via internalWorkerClassLoader, mill-contrib-docker could load jib-core and its transitive dependencies (including Jackson) in an isolated classloader rather than on the main runner classpath. This would prevent jib-core's Jackson from being visible to other modules.

  2. Don't set the context classloader to the Mill worker classloader in scaladocJar: Instead of withContextClassLoader(this.getClass.getClassLoader), use withContextClassLoader(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 ensure ServiceLoader finds Jackson classes from the same classloader as ObjectMapper.

  3. Shade Jackson in jib-core (upstream fix): This would prevent Jackson from leaking out of jib-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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions