[2.x] feat: ClassLoaderStrategy.Raw (#9161)

**Problem**
In sbt 2.x, forking still creates layered classloader in the worker process,
which doesn't work for some tests.

**Solution**
This provides an escape hatch to emulate the sbt 1.x semantics of
using the system classpath for testing.
This commit is contained in:
eugene yokota 2026-04-30 12:35:30 -04:00 committed by Eugene Yokota
parent 06e53a0ae4
commit 532edd1716
11 changed files with 69 additions and 50 deletions

View File

@ -37,6 +37,10 @@ import sbt.internal.WorkerConnection
private[sbt] object ForkTests:
val r = Random()
/**
* virtualClasspath can be controlled by setting
* Test / classLoaderLayeringStrategy to ClassLoaderLayeringStrategy.Raw.
*/
def apply(
runners: Map[TestFramework, Runner],
opts: ProcessedOptions,
@ -46,6 +50,7 @@ private[sbt] object ForkTests:
fork: ForkOptions,
log: Logger,
parallelism: Option[Int],
virtualClasspath: Boolean,
tags: (Tag, Int)*
): Task[TestOutput] = {
import std.TaskExtra.*
@ -57,44 +62,22 @@ private[sbt] object ForkTests:
if opts.tests.isEmpty then
constant(TestOutput(TestResult.Passed, Map.empty[String, SuiteResult], Iterable.empty))
else
mainTestTask(runners, opts, classpath, converter, fork, log, config.parallel, parallelism)
.tagw(
config.tags*
)
mainTestTask(
runners = runners,
opts = opts,
classpath = classpath,
converter = converter,
fork = fork,
log = log,
parallel = config.parallel,
parallelism = parallelism,
virtualClasspath = virtualClasspath,
).tagw(config.tags*)
main.tagw(tags*).dependsOn(all(opts.setup)*) flatMap { results =>
all(opts.cleanup).join.map(_ => results)
}
}
def apply(
runners: Map[TestFramework, Runner],
tests: Vector[TestDefinition],
config: Execution,
classpath: Seq[HashedVirtualFileRef],
converter: FileConverter,
fork: ForkOptions,
log: Logger,
parallelism: Option[Int],
tags: (Tag, Int)*
): Task[TestOutput] = {
val opts = processOptions(config, tests, log)
apply(runners, opts, config, classpath, converter, fork, log, parallelism, tags*)
}
def apply(
runners: Map[TestFramework, Runner],
tests: Vector[TestDefinition],
config: Execution,
classpath: Seq[HashedVirtualFileRef],
converter: FileConverter,
fork: ForkOptions,
log: Logger,
parallelism: Option[Int],
tag: Tag
): Task[TestOutput] = {
apply(runners, tests, config, classpath, converter, fork, log, parallelism, tag -> 1)
}
private def mainTestTask(
runners: Map[TestFramework, Runner],
opts: ProcessedOptions,
@ -103,7 +86,8 @@ private[sbt] object ForkTests:
fork: ForkOptions,
log: Logger,
parallel: Boolean,
parallelism: Option[Int]
parallelism: Option[Int],
virtualClasspath: Boolean,
): Task[TestOutput] =
std.TaskExtra.task {
val testListeners = opts.testListeners.flatMap:
@ -131,8 +115,6 @@ private[sbt] object ForkTests:
ArrayList(mainRunner.remoteArgs().toList.asJava)
)
val g = WorkerMain.mkGson()
// virtualize classloading by using ClassLoader
val useClassLoader = true
val cpList = ArrayList[FilePath](
(classpath
.map: vf =>
@ -146,7 +128,7 @@ private[sbt] object ForkTests:
true, /* jvm */
RunInfo.JvmRunInfo(
ArrayList(),
if useClassLoader then cpList else ArrayList(),
if virtualClasspath then cpList else ArrayList(),
"",
false /*connectInput*/,
),
@ -160,7 +142,7 @@ private[sbt] object ForkTests:
testListeners.foreach(_.doInit())
val result =
val ct = WorkerConnection.Tcp
val w = WorkerExchange.startWorker(fork, if useClassLoader then Nil else cpFiles, ct)
val w = WorkerExchange.startWorker(fork, if virtualClasspath then Nil else cpFiles, ct)
val wl = React(randomId, log, opts.testListeners, resultsAcc, w.process)
try
WorkerExchange.registerListener(wl)

View File

@ -77,6 +77,11 @@ object ClassLoaderLayeringStrategy {
*/
case object Flat extends ClassLoaderLayeringStrategy
/**
* Use the system loader. This applises only for forked tests.
*/
case object Raw extends ClassLoaderLayeringStrategy
/**
* Add a layer for the scala library class loader.
*/

View File

@ -1573,6 +1573,7 @@ object Defaults extends BuildCommon {
opts,
s.log,
forkedParallelism,
strategy != ClassLoaderLayeringStrategy.Raw,
(Tags.ForkedTestGroup, 1) +: group.tags*
)
case Tests.InProcess =>

View File

@ -159,7 +159,8 @@ private[sbt] object ClassLoaders {
): ClassLoader = {
val cpFiles = fullCP.map(_._1)
strategy match {
case Flat => new FlatLoader(cpFiles.urls, interfaceLoader, tmp, close, allowZombies, logger)
case Flat | Raw =>
new FlatLoader(cpFiles.urls, interfaceLoader, tmp, close, allowZombies, logger)
case _ =>
val layerDependencies = strategy match {
case _: AllLibraryJars => true

View File

@ -0,0 +1,7 @@
val munit = "org.scalameta" %% "munit" % "1.0.4"
scalaVersion := "3.8.2"
libraryDependencies += munit % Test
Test / fork := true
Test / classLoaderLayeringStrategy := ClassLoaderLayeringStrategy.Raw

View File

@ -0,0 +1,8 @@
package testpkg
import munit.*
class ATest extends FunSuite:
test("sum"):
assert(1 + 1 == 2)
end ATest

View File

@ -0,0 +1,8 @@
package testpkg
import munit.*
class ATest extends FunSuite:
test("sum"):
assert(1 + 1 == 3)
end ATest

View File

@ -0,0 +1,4 @@
-> testFull
$ copy-file changes/Good.scala src/test/scala/ATest.scala
> testFull

View File

@ -1,9 +1,7 @@
ThisBuild / scalaVersion := "2.12.21"
val munit = "org.scalameta" %% "munit" % "1.0.4"
scalaVersion := "3.8.2"
lazy val munit = "org.scalameta" %% "munit" % "0.7.22"
lazy val root = (project in file("."))
lazy val root = rootProject
.settings(
Compile / scalacOptions += "-Yrangepos",
libraryDependencies += munit % Test
)

View File

@ -1,8 +1,8 @@
package testpkg
import munit._
import munit.*
class ClueSuite extends FunSuite {
class ClueSuite extends FunSuite:
val x = 42
val y = 32
checkPrint(
@ -31,10 +31,10 @@ class ClueSuite extends FunSuite {
options: TestOptions,
clues: Clues,
expected: String
)(implicit loc: Location): Unit = {
)(using loc: Location): Unit = {
test(options) {
val obtained = munitPrint(clues)
assertNoDiff(obtained, expected)
}
}
}
end ClueSuite

View File

@ -188,8 +188,13 @@ public final class WorkerMain {
if (info.jvm) {
RunInfo.JvmRunInfo jvmRunInfo = info.jvmRunInfo;
ClassLoader parent = new ForkTestMain().getClass().getClassLoader();
try (URLClassLoader cl = createClassLoader(jvmRunInfo, parent)) {
ForkTestMain.main(id, info, this.jsonOut, cl);
// empty virtual classpath means raw mode
if (jvmRunInfo.classpath.isEmpty()) {
ForkTestMain.main(id, info, this.jsonOut, parent);
} else {
try (URLClassLoader cl = createClassLoader(jvmRunInfo, parent)) {
ForkTestMain.main(id, info, this.jsonOut, cl);
}
}
} else {
throw new RuntimeException("only jvm is supported");