[2.x] fix: Make consoleProject bindings non-null in Scala 3

**Problem**
After #9073, `consoleProject` starts a Scala 3 REPL successfully, but
`currentState`, `extracted`, and `cpHelpers` show as `null` in the
REPL. Reviewer eed3si9n reported this on the PR.

Root cause: the Scala 3 REPL's `dotty.tools.repl.AbstractFileClassLoader`
ends up defining a fresh copy of `sbt.internal.ConsoleProjectBindings`
(and the other sbt classes referenced by the bindings) from the sbt
module jars on `unit.classpath`, instead of delegating to sbt's own
class loader via parent-first delegation. The two `Class` objects are
distinct, so setting static fields on sbt's copy has no effect on the
one the REPL compiles against. A strongly typed cast would additionally
hit `LinkageError: loader constraint violation` on `sbt.State`.

**Solution**
Strip every jar shipping sbt core code from the runtime class loader
used by the `consoleProject` REPL. The jars are detected by content —
any jar that contains `sbt/State.class`, `sbt/Extracted.class`,
`sbt/internal/ConsoleProjectBindings$.class`, or `sbt/internal/Load$.class`
is considered an sbt module jar and filtered out.

The full classpath is still passed to `Console` below, so the REPL's
compile-time classpath (from the `-classpath` flag) is unchanged and
the DSL types typecheck as before. Only the runtime URLClassLoader is
affected, forcing `sbt.*` class references inside the REPL to resolve
via parent delegation and reach sbt's own singleton copies.

**Test**
Update `project/scala3-console-project` to write a marker file from the
REPL's `initialCommands` via
`Files.writeString(path, currentState.toString.length.toString + ...)`.
If any binding is null, the `toString` throws an NPE, the REPL swallows
it, the marker is never written, and `$ exists` fails. The test now
verifies the REPL actually sees non-null bindings, not just that
`consoleProject` starts successfully.
This commit is contained in:
eureka928 2026-04-13 13:44:25 +02:00
parent 1285562c76
commit 385c5c7578
No known key found for this signature in database
GPG Key ID: B0C6F02BD1A12D8E
4 changed files with 61 additions and 13 deletions

View File

@ -98,7 +98,19 @@ object ConsoleProject:
val bindingImports = BuildUtil.importAll(bindings.map(_._1))
val allLines = baseImports ++ bindingDefs ++ bindingImports
val initCommands = allLines.mkString("", ";\n", ";\n\n") + extra
val loader = ClasspathUtil.makeLoader(unit.classpath, si, tempDir)
// Remove sbt's own module jars from the runtime loader's URL list so
// that `sbt.*` classes (including `sbt.State`, `sbt.Extracted` and
// `sbt.internal.ConsoleProjectBindings`) can only be resolved via
// parent delegation, reaching sbt's own class loader. Without this,
// the Scala 3 REPL's `AbstractFileClassLoader` would define a fresh
// copy of each sbt class from `unit.classpath`, and any attempt to
// use the bindings would trigger
// `LinkageError: loader constraint violation` two different JVM
// `Class` objects for the same `sbt.State`. The full classpath is
// still passed to `Console` below so the REPL's compile-time
// classpath is unchanged. See sbt/sbt#7722.
val runtimeClasspath = unit.classpath.filterNot(isSbtModuleJar)
val loader = ClasspathUtil.makeLoader(runtimeClasspath, si, tempDir)
val terminal = Terminal.get
// TODO - Hook up dsl classpath correctly...
try
@ -113,6 +125,38 @@ object ConsoleProject:
finally ConsoleProjectBindings.clear()
}
/**
* Classes that identify an sbt module jar if any of these entries is
* present, the jar ships sbt core code that must be excluded from the
* consoleProject runtime classloader. See `isSbtModuleJar`.
*/
private val SbtModuleMarkerClasses: Seq[String] = Seq(
"sbt/State.class",
"sbt/Extracted.class",
"sbt/internal/ConsoleProjectBindings$.class",
"sbt/internal/Load$.class",
)
/**
* Returns true when a `Path` refers to a jar that ships sbt core code
* (anything containing `sbt.State`, `sbt.Extracted`, etc.). These jars
* must be excluded from the `consoleProject` REPL runtime classloader
* so that `sbt.*` references resolve via parent delegation and reach
* sbt's own singleton copies rather than being defined fresh by the
* Scala 3 REPL's `AbstractFileClassLoader`, which would break the
* static-field bindings and trigger a `LinkageError: loader
* constraint violation` when the bindings are used. See sbt/sbt#7722.
*/
private def isSbtModuleJar(p: java.nio.file.Path): Boolean =
val name = p.getFileName.toString
if !name.endsWith(".jar") || !java.nio.file.Files.isRegularFile(p) then false
else
try
val zf = new java.util.zip.ZipFile(p.toFile)
try SbtModuleMarkerClasses.exists(zf.getEntry(_) ne null)
finally zf.close()
catch case _: java.io.IOException => false
/** Conveniences for consoleProject that shouldn't normally be used for builds. */
final class Imports private[sbt] (extracted: Extracted, state: State) {
import extracted.*

View File

@ -15,12 +15,14 @@ import scala.compiletime.uninitialized
* Static holder for consoleProject bindings.
*
* Scala 3 compiler bridge does not implement REPL binding injection
* (https://github.com/scala/scala3/issues/5069), so we store the runtime
* objects here and generate `val` definitions in `initialCommands` that
* read from this holder.
* (https://github.com/scala/scala3/issues/5069), so sbt generates `val`
* definitions in `initialCommands` that read from this holder.
*
* Must NOT be `private[sbt]` because the Scala 3 REPL compiles
* `initialCommands` outside the `sbt` package.
* The holder has to be resolved to the same JVM `Class` object from both
* sbt itself and the Scala 3 REPL's `AbstractFileClassLoader`. sbt's
* own sbt module jars are removed from the REPL runtime classloader in
* `ConsoleProject.apply`, so that all `sbt.*` references in the REPL go
* through the parent chain and reach the sbt singleton.
*/
object ConsoleProjectBindings:
@volatile private var _state: State = uninitialized

View File

@ -1,11 +1,12 @@
ThisBuild / scalaVersion := "3.7.4"
lazy val markerFile = settingKey[java.io.File]("marker file written by consoleProject REPL when bindings resolve")
lazy val root = project.in(file(".")).settings(
// Verify that consoleProject bindings are accessible in the Scala 3 REPL.
// These assertions run as initialCommands; if any binding is null, the REPL fails.
consoleProject / initialCommands :=
"""assert(currentState != null, "currentState binding missing")
|assert(extracted != null, "extracted binding missing")
|assert(cpHelpers != null, "cpHelpers binding missing")
|""".stripMargin,
markerFile := target.value / "console-bindings-ok",
Global / initialCommands := {
val path = markerFile.value.getAbsolutePath.replace("\\", "\\\\")
s"""_root_.java.nio.file.Files.writeString(_root_.java.nio.file.Paths.get("$path"), currentState.toString.length.toString + "/" + extracted.toString.length.toString + "/" + cpHelpers.toString.length.toString)
|""".stripMargin
},
)

View File

@ -1 +1,2 @@
> consoleProject
$ exists target/out/**/console-bindings-ok