From 9d620f3b1f111db220b0f16b3cbafdab762d2073 Mon Sep 17 00:00:00 2001 From: kenji yoshida <6b656e6a69@gmail.com> Date: Thu, 16 Apr 2026 11:29:19 +0900 Subject: [PATCH 1/3] [2.x] refactor: Use Files.writeString instead of Files.write (#9093) --- lm-coursier/src/main/scala/lmcoursier/internal/LockFile.scala | 2 +- .../aar-packaging/src/main/scala/Main.scala | 2 +- .../exclude-dependencies2/src/main/scala/Main.scala | 2 +- .../exclude-dependencies3/src/main/scala/Main.scala | 2 +- .../src/main/scala/Main.scala | 2 +- .../baz/src/main/scala/Main.scala | 2 +- .../inter-project/b/src/main/scala/Main.scala | 2 +- .../dependency-management/profiles/src/main/scala/Main.scala | 2 +- .../url-no-head/src/main/scala/Main.scala | 2 +- .../src/sbt-test/lm-coursier/credentials-from-file/build.sbt | 2 +- .../lm-coursier/from-no-head/src/main/scala/Main.scala | 2 +- .../lm-coursier/from-wrong-url/src/main/scala/Main.scala | 2 +- .../src/sbt-test/lm-coursier/from/src/main/scala/Main.scala | 2 +- .../inter-project-resolvers/b/src/main/scala/Main.scala | 2 +- .../inter-project-scala-tool/src/main/scala/Main.scala | 2 +- .../lm-coursier/maven-compatible/src/main/scala/Main.scala | 2 +- .../maven-plugin-classpath-type/src/main/scala/Main.scala | 2 +- .../sbt-test/lm-coursier/scala-jars/src/main/scala/Main.scala | 2 +- .../scala-sources-javadoc-jars/src/main/scala/Main.scala | 2 +- .../src/sbt-test/lm-coursier/simple/src/main/scala/Main.scala | 2 +- .../sbt-test/lm-coursier/zookeeper/src/main/scala/Main.scala | 2 +- .../plugins/sbt-native-packager/src/main/scala/Main.scala | 2 +- .../src/main/scala/sbt/internal/librarymanagement/IvyXml.scala | 3 +-- 23 files changed, 23 insertions(+), 24 deletions(-) diff --git a/lm-coursier/src/main/scala/lmcoursier/internal/LockFile.scala b/lm-coursier/src/main/scala/lmcoursier/internal/LockFile.scala index fdbda1d2a..c352a2219 100644 --- a/lm-coursier/src/main/scala/lmcoursier/internal/LockFile.scala +++ b/lm-coursier/src/main/scala/lmcoursier/internal/LockFile.scala @@ -31,7 +31,7 @@ object LockFile { val json = Converter.toJson(data).get val content = PrettyPrinter(json) lockFile.getParentFile.mkdirs() - Files.write(lockFile.toPath, content.getBytes(StandardCharsets.UTF_8)) + Files.writeString(lockFile.toPath, content) } match { case Success(_) => Right(()) case Failure(ex) => Left(s"Failed to write lock file: ${ex.getMessage}") diff --git a/sbt-app/src/sbt-test/dependency-management/aar-packaging/src/main/scala/Main.scala b/sbt-app/src/sbt-test/dependency-management/aar-packaging/src/main/scala/Main.scala index f99bd4330..5e1ecbfaf 100644 --- a/sbt-app/src/sbt-test/dependency-management/aar-packaging/src/main/scala/Main.scala +++ b/sbt-app/src/sbt-test/dependency-management/aar-packaging/src/main/scala/Main.scala @@ -3,6 +3,6 @@ import java.nio.file.Files object Main { def main(args: Array[String]): Unit = { - Files.write(new File("output").toPath, "OK".getBytes("UTF-8")) + Files.writeString(new File("output").toPath, "OK") } } diff --git a/sbt-app/src/sbt-test/dependency-management/exclude-dependencies2/src/main/scala/Main.scala b/sbt-app/src/sbt-test/dependency-management/exclude-dependencies2/src/main/scala/Main.scala index 430ca7239..b3cc99033 100644 --- a/sbt-app/src/sbt-test/dependency-management/exclude-dependencies2/src/main/scala/Main.scala +++ b/sbt-app/src/sbt-test/dependency-management/exclude-dependencies2/src/main/scala/Main.scala @@ -29,6 +29,6 @@ object Main { "Expected not to find classes from argonaut" ) - Files.write(new File("output").toPath, "OK".getBytes("UTF-8")) + Files.writeString(new File("output").toPath, "OK") } } diff --git a/sbt-app/src/sbt-test/dependency-management/exclude-dependencies3/src/main/scala/Main.scala b/sbt-app/src/sbt-test/dependency-management/exclude-dependencies3/src/main/scala/Main.scala index 8e747d78b..579e0687a 100644 --- a/sbt-app/src/sbt-test/dependency-management/exclude-dependencies3/src/main/scala/Main.scala +++ b/sbt-app/src/sbt-test/dependency-management/exclude-dependencies3/src/main/scala/Main.scala @@ -34,6 +34,6 @@ object Main { "Expected not to find class from cats-mtl" ) - Files.write(new File("output").toPath, "OK".getBytes("UTF-8")) + Files.writeString(new File("output").toPath, "OK") } } diff --git a/sbt-app/src/sbt-test/dependency-management/hadoop-yarn-server-resourcemanager/src/main/scala/Main.scala b/sbt-app/src/sbt-test/dependency-management/hadoop-yarn-server-resourcemanager/src/main/scala/Main.scala index fe852fa1b..86189435f 100644 --- a/sbt-app/src/sbt-test/dependency-management/hadoop-yarn-server-resourcemanager/src/main/scala/Main.scala +++ b/sbt-app/src/sbt-test/dependency-management/hadoop-yarn-server-resourcemanager/src/main/scala/Main.scala @@ -5,6 +5,6 @@ import org.apache.zookeeper.ZooKeeper object Main { def main(args: Array[String]): Unit = { - Files.write(new File("output").toPath, classOf[ZooKeeper].getSimpleName.getBytes("UTF-8")) + Files.writeString(new File("output").toPath, classOf[ZooKeeper].getSimpleName) } } diff --git a/sbt-app/src/sbt-test/dependency-management/i4847-inter-project-variant-scala/baz/src/main/scala/Main.scala b/sbt-app/src/sbt-test/dependency-management/i4847-inter-project-variant-scala/baz/src/main/scala/Main.scala index b89b1e043..96bc17e43 100644 --- a/sbt-app/src/sbt-test/dependency-management/i4847-inter-project-variant-scala/baz/src/main/scala/Main.scala +++ b/sbt-app/src/sbt-test/dependency-management/i4847-inter-project-variant-scala/baz/src/main/scala/Main.scala @@ -4,6 +4,6 @@ import java.nio.file.Paths object Main { def main(args: Array[String]): Unit = { val msg = Bar.value - Files.write(Paths.get("baz/output"), msg.getBytes("UTF-8")) + Files.writeString(Paths.get("baz/output"), msg) } } diff --git a/sbt-app/src/sbt-test/dependency-management/inter-project/b/src/main/scala/Main.scala b/sbt-app/src/sbt-test/dependency-management/inter-project/b/src/main/scala/Main.scala index 863dcdea3..6feb0830a 100644 --- a/sbt-app/src/sbt-test/dependency-management/inter-project/b/src/main/scala/Main.scala +++ b/sbt-app/src/sbt-test/dependency-management/inter-project/b/src/main/scala/Main.scala @@ -10,6 +10,6 @@ object Main { def main(args: Array[String]): Unit = { val msg = CC(2, A.msg).asJson.spaces2 - Files.write(new File("output").toPath, msg.getBytes("UTF-8")) + Files.writeString(new File("output").toPath, msg) } } diff --git a/sbt-app/src/sbt-test/dependency-management/profiles/src/main/scala/Main.scala b/sbt-app/src/sbt-test/dependency-management/profiles/src/main/scala/Main.scala index 9b533b23a..a560cd2ca 100644 --- a/sbt-app/src/sbt-test/dependency-management/profiles/src/main/scala/Main.scala +++ b/sbt-app/src/sbt-test/dependency-management/profiles/src/main/scala/Main.scala @@ -16,6 +16,6 @@ object Main { assert(hadoopVersion == "2.6.0") - Files.write(new File("output").toPath, "OK".getBytes("UTF-8")) + Files.writeString(new File("output").toPath, "OK") } } diff --git a/sbt-app/src/sbt-test/dependency-management/url-no-head/src/main/scala/Main.scala b/sbt-app/src/sbt-test/dependency-management/url-no-head/src/main/scala/Main.scala index b23bbf7cb..99ef841c8 100644 --- a/sbt-app/src/sbt-test/dependency-management/url-no-head/src/main/scala/Main.scala +++ b/sbt-app/src/sbt-test/dependency-management/url-no-head/src/main/scala/Main.scala @@ -8,6 +8,6 @@ object Main { // assert(Thread.currentThread.getContextClassLoader.getResource("org/nlogo/nvm/Task.class") != null) // Thread.currentThread.getContextClassLoader.getResource("org/nlogo/nvm/Task.class") def main(args: Array[String]): Unit = { - Files.write(new File("output").toPath, "OK".getBytes("UTF-8")) + Files.writeString(new File("output").toPath, "OK") } } diff --git a/sbt-app/src/sbt-test/lm-coursier/credentials-from-file/build.sbt b/sbt-app/src/sbt-test/lm-coursier/credentials-from-file/build.sbt index 6f2b08bde..b5c5439cf 100644 --- a/sbt-app/src/sbt-test/lm-coursier/credentials-from-file/build.sbt +++ b/sbt-app/src/sbt-test/lm-coursier/credentials-from-file/build.sbt @@ -13,7 +13,7 @@ csrExtraCredentials += { |foo.https-only=false """.stripMargin val dest = (ThisBuild / baseDirectory).value / "project" / "target" / "cred" - Files.write(dest.toPath, content.getBytes("UTF-8")) + Files.writeString(dest.toPath, content) lmcoursier.credentials.FileCredentials(dest.toString) } diff --git a/sbt-app/src/sbt-test/lm-coursier/from-no-head/src/main/scala/Main.scala b/sbt-app/src/sbt-test/lm-coursier/from-no-head/src/main/scala/Main.scala index b23bbf7cb..99ef841c8 100644 --- a/sbt-app/src/sbt-test/lm-coursier/from-no-head/src/main/scala/Main.scala +++ b/sbt-app/src/sbt-test/lm-coursier/from-no-head/src/main/scala/Main.scala @@ -8,6 +8,6 @@ object Main { // assert(Thread.currentThread.getContextClassLoader.getResource("org/nlogo/nvm/Task.class") != null) // Thread.currentThread.getContextClassLoader.getResource("org/nlogo/nvm/Task.class") def main(args: Array[String]): Unit = { - Files.write(new File("output").toPath, "OK".getBytes("UTF-8")) + Files.writeString(new File("output").toPath, "OK") } } diff --git a/sbt-app/src/sbt-test/lm-coursier/from-wrong-url/src/main/scala/Main.scala b/sbt-app/src/sbt-test/lm-coursier/from-wrong-url/src/main/scala/Main.scala index 8bec661cd..820834887 100644 --- a/sbt-app/src/sbt-test/lm-coursier/from-wrong-url/src/main/scala/Main.scala +++ b/sbt-app/src/sbt-test/lm-coursier/from-wrong-url/src/main/scala/Main.scala @@ -11,6 +11,6 @@ object Main { val l = Generic[CC].to(cc) val msg = l.head - Files.write(new File("output").toPath, msg.getBytes("UTF-8")) + Files.writeString(new File("output").toPath, msg) } } diff --git a/sbt-app/src/sbt-test/lm-coursier/from/src/main/scala/Main.scala b/sbt-app/src/sbt-test/lm-coursier/from/src/main/scala/Main.scala index 8bec661cd..820834887 100644 --- a/sbt-app/src/sbt-test/lm-coursier/from/src/main/scala/Main.scala +++ b/sbt-app/src/sbt-test/lm-coursier/from/src/main/scala/Main.scala @@ -11,6 +11,6 @@ object Main { val l = Generic[CC].to(cc) val msg = l.head - Files.write(new File("output").toPath, msg.getBytes("UTF-8")) + Files.writeString(new File("output").toPath, msg) } } diff --git a/sbt-app/src/sbt-test/lm-coursier/inter-project-resolvers/b/src/main/scala/Main.scala b/sbt-app/src/sbt-test/lm-coursier/inter-project-resolvers/b/src/main/scala/Main.scala index 7021af0d1..f3fc49d40 100644 --- a/sbt-app/src/sbt-test/lm-coursier/inter-project-resolvers/b/src/main/scala/Main.scala +++ b/sbt-app/src/sbt-test/lm-coursier/inter-project-resolvers/b/src/main/scala/Main.scala @@ -6,6 +6,6 @@ object Main { // TODO Use some jvm-repr stuff as a test def main(args: Array[String]): Unit = { - Files.write(new File("output").toPath, A.default.msg.getBytes("UTF-8")) + Files.writeString(new File("output").toPath, A.default.msg) } } diff --git a/sbt-app/src/sbt-test/lm-coursier/inter-project-scala-tool/src/main/scala/Main.scala b/sbt-app/src/sbt-test/lm-coursier/inter-project-scala-tool/src/main/scala/Main.scala index 6053db976..37a6c3a7d 100644 --- a/sbt-app/src/sbt-test/lm-coursier/inter-project-scala-tool/src/main/scala/Main.scala +++ b/sbt-app/src/sbt-test/lm-coursier/inter-project-scala-tool/src/main/scala/Main.scala @@ -10,6 +10,6 @@ import java.nio.file.Files */ object Main { def main(args: Array[String]): Unit = { - Files.write(new File("output").toPath, "OK".getBytes("UTF-8")) + Files.writeString(new File("output").toPath, "OK") } } diff --git a/sbt-app/src/sbt-test/lm-coursier/maven-compatible/src/main/scala/Main.scala b/sbt-app/src/sbt-test/lm-coursier/maven-compatible/src/main/scala/Main.scala index 8a0ee6b72..fd89f906f 100644 --- a/sbt-app/src/sbt-test/lm-coursier/maven-compatible/src/main/scala/Main.scala +++ b/sbt-app/src/sbt-test/lm-coursier/maven-compatible/src/main/scala/Main.scala @@ -6,6 +6,6 @@ object Main { // TODO Use some jvm-repr stuff def main(args: Array[String]): Unit = { - Files.write(new File("output").toPath, "OK".getBytes("UTF-8")) + Files.writeString(new File("output").toPath, "OK") } } diff --git a/sbt-app/src/sbt-test/lm-coursier/maven-plugin-classpath-type/src/main/scala/Main.scala b/sbt-app/src/sbt-test/lm-coursier/maven-plugin-classpath-type/src/main/scala/Main.scala index f99bd4330..5e1ecbfaf 100644 --- a/sbt-app/src/sbt-test/lm-coursier/maven-plugin-classpath-type/src/main/scala/Main.scala +++ b/sbt-app/src/sbt-test/lm-coursier/maven-plugin-classpath-type/src/main/scala/Main.scala @@ -3,6 +3,6 @@ import java.nio.file.Files object Main { def main(args: Array[String]): Unit = { - Files.write(new File("output").toPath, "OK".getBytes("UTF-8")) + Files.writeString(new File("output").toPath, "OK") } } diff --git a/sbt-app/src/sbt-test/lm-coursier/scala-jars/src/main/scala/Main.scala b/sbt-app/src/sbt-test/lm-coursier/scala-jars/src/main/scala/Main.scala index b20c27d95..47a75ccd7 100644 --- a/sbt-app/src/sbt-test/lm-coursier/scala-jars/src/main/scala/Main.scala +++ b/sbt-app/src/sbt-test/lm-coursier/scala-jars/src/main/scala/Main.scala @@ -63,6 +63,6 @@ object Main { notFromCoursierCache("scala-library") assert(props.lengthCompare(1) == 0, s"Found several library.properties files in classpath: $props") - Files.write(new File("output").toPath, "OK".getBytes("UTF-8")) + Files.writeString(new File("output").toPath, "OK") } } diff --git a/sbt-app/src/sbt-test/lm-coursier/scala-sources-javadoc-jars/src/main/scala/Main.scala b/sbt-app/src/sbt-test/lm-coursier/scala-sources-javadoc-jars/src/main/scala/Main.scala index f99bd4330..5e1ecbfaf 100644 --- a/sbt-app/src/sbt-test/lm-coursier/scala-sources-javadoc-jars/src/main/scala/Main.scala +++ b/sbt-app/src/sbt-test/lm-coursier/scala-sources-javadoc-jars/src/main/scala/Main.scala @@ -3,6 +3,6 @@ import java.nio.file.Files object Main { def main(args: Array[String]): Unit = { - Files.write(new File("output").toPath, "OK".getBytes("UTF-8")) + Files.writeString(new File("output").toPath, "OK") } } diff --git a/sbt-app/src/sbt-test/lm-coursier/simple/src/main/scala/Main.scala b/sbt-app/src/sbt-test/lm-coursier/simple/src/main/scala/Main.scala index f99bd4330..5e1ecbfaf 100644 --- a/sbt-app/src/sbt-test/lm-coursier/simple/src/main/scala/Main.scala +++ b/sbt-app/src/sbt-test/lm-coursier/simple/src/main/scala/Main.scala @@ -3,6 +3,6 @@ import java.nio.file.Files object Main { def main(args: Array[String]): Unit = { - Files.write(new File("output").toPath, "OK".getBytes("UTF-8")) + Files.writeString(new File("output").toPath, "OK") } } diff --git a/sbt-app/src/sbt-test/lm-coursier/zookeeper/src/main/scala/Main.scala b/sbt-app/src/sbt-test/lm-coursier/zookeeper/src/main/scala/Main.scala index fe852fa1b..86189435f 100644 --- a/sbt-app/src/sbt-test/lm-coursier/zookeeper/src/main/scala/Main.scala +++ b/sbt-app/src/sbt-test/lm-coursier/zookeeper/src/main/scala/Main.scala @@ -5,6 +5,6 @@ import org.apache.zookeeper.ZooKeeper object Main { def main(args: Array[String]): Unit = { - Files.write(new File("output").toPath, classOf[ZooKeeper].getSimpleName.getBytes("UTF-8")) + Files.writeString(new File("output").toPath, classOf[ZooKeeper].getSimpleName) } } diff --git a/sbt-app/src/sbt-test/plugins/sbt-native-packager/src/main/scala/Main.scala b/sbt-app/src/sbt-test/plugins/sbt-native-packager/src/main/scala/Main.scala index 1080f4ba4..e56c9aa40 100644 --- a/sbt-app/src/sbt-test/plugins/sbt-native-packager/src/main/scala/Main.scala +++ b/sbt-app/src/sbt-test/plugins/sbt-native-packager/src/main/scala/Main.scala @@ -2,4 +2,4 @@ import java.io.File import java.nio.file.Files @main def hello() = - Files.write(new File("output").toPath, "OK".getBytes("UTF-8")) + Files.writeString(new File("output").toPath, "OK") diff --git a/sbt-ivy/src/main/scala/sbt/internal/librarymanagement/IvyXml.scala b/sbt-ivy/src/main/scala/sbt/internal/librarymanagement/IvyXml.scala index 6a8709743..d1817ae34 100644 --- a/sbt-ivy/src/main/scala/sbt/internal/librarymanagement/IvyXml.scala +++ b/sbt-ivy/src/main/scala/sbt/internal/librarymanagement/IvyXml.scala @@ -10,7 +10,6 @@ package sbt package internal package librarymanagement -import java.nio.charset.StandardCharsets.UTF_8 import java.nio.file.Files import lmcoursier.definitions.{ Configuration, Project } @@ -66,7 +65,7 @@ object IvyXml { val content0 = rawContent(currentProject, shadedConfigOpt, bomForcedDeps) cacheIvyFile.getParentFile.mkdirs() log.debug(s"writing Ivy file $cacheIvyFile") - Files.write(cacheIvyFile.toPath, content0.getBytes(UTF_8)) + Files.writeString(cacheIvyFile.toPath, content0) // Just writing an empty file here... Are these only used? cacheIvyPropertiesFile.getParentFile.mkdirs() From ef90ca0540f82c2f933a82851361682a41bed8df Mon Sep 17 00:00:00 2001 From: Clayton <118192227+claytonlin1110@users.noreply.github.com> Date: Wed, 15 Apr 2026 23:58:06 -0500 Subject: [PATCH 2/3] [2.x] feat: Forward test events to listeners as they are emitted (#9087) Test report listeners now receive testEvent callbacks as the underlying test framework emits events, instead of only after a whole group finishes. startGroup / endGroup timing is aligned with that streaming model for both in-process and forked test runs. No public API changes - only listener callback timing and the internal worker <-> sbt JSON-RPC protocol. --- .../src/main/scala/sbt/ForkTests.scala | 28 ++++-- .../scala/sbt/internal/WorkerExchange.scala | 2 + .../tests/stream-test-events/build.sbt | 80 +++++++++++++++++ .../scala/custom/StreamingFramework.scala | 68 +++++++++++++++ .../scala/custom/StreamingFramework.scala | 68 +++++++++++++++ .../sbt-test/tests/stream-test-events/test | 6 ++ .../src/main/scala/sbt/TestFramework.scala | 10 ++- .../sbt/internal/worker1/ForkTestMain.java | 85 +++++++++++++------ 8 files changed, 313 insertions(+), 34 deletions(-) create mode 100644 sbt-app/src/sbt-test/tests/stream-test-events/build.sbt create mode 100644 sbt-app/src/sbt-test/tests/stream-test-events/forked/src/test/scala/custom/StreamingFramework.scala create mode 100644 sbt-app/src/sbt-test/tests/stream-test-events/inproc/src/test/scala/custom/StreamingFramework.scala create mode 100644 sbt-app/src/sbt-test/tests/stream-test-events/test diff --git a/main-actions/src/main/scala/sbt/ForkTests.scala b/main-actions/src/main/scala/sbt/ForkTests.scala index 08590ed08..06180a28c 100755 --- a/main-actions/src/main/scala/sbt/ForkTests.scala +++ b/main-actions/src/main/scala/sbt/ForkTests.scala @@ -194,6 +194,9 @@ private class React( ) extends WorkerResponseListener: val g = WorkerMain.mkGson() val promise: Promise[Int] = Promise() + + /** Events per test group, accumulated for [[SuiteResult]] (listeners get each event immediately). */ + private val progressEvents = mutable.Map.empty[String, mutable.ArrayBuffer[testing.Event]] override def apply(line: String): Unit = try val o = JsonParser.parseString(line).getAsJsonObject() @@ -235,15 +238,30 @@ private class React( case ForkTags.Debug => log.debug(info.message) case _ => () else () - case "testEvents" => + case "startTestGroup" => + val params = o.getAsJsonObject("params") + val info = + g.fromJson[ForkTestMain.ForkGroupStart](params, classOf[ForkTestMain.ForkGroupStart]) + if info.id == id then + progressEvents(info.group) = mutable.ArrayBuffer.empty + listeners.foreach(_.startGroup(info.group)) + else () + case "testProgress" => val params = o.getAsJsonObject("params") val info = g.fromJson[ForkTestMain.ForkEventsInfo](params, classOf[ForkTestMain.ForkEventsInfo]) if info.id == id then - val events = info.events.asScala.toSeq - listeners.foreach(_.startGroup(info.group)) - val event = TestEvent(events) - listeners.foreach(_.testEvent(event)) + val buf = progressEvents.getOrElseUpdate(info.group, mutable.ArrayBuffer.empty) + for e <- info.events.asScala do + buf += e + listeners.foreach(_.testEvent(TestEvent(Seq(e)))) + else () + case "endTestGroup" => + val params = o.getAsJsonObject("params") + val info = + g.fromJson[ForkTestMain.ForkGroupEnd](params, classOf[ForkTestMain.ForkGroupEnd]) + if info.id == id then + val events = progressEvents.remove(info.group).getOrElse(mutable.ArrayBuffer.empty).toSeq val suiteResult = SuiteResult(events) results += info.group -> suiteResult listeners.foreach(_.endGroup(info.group, suiteResult.result)) diff --git a/main-actions/src/main/scala/sbt/internal/WorkerExchange.scala b/main-actions/src/main/scala/sbt/internal/WorkerExchange.scala index 6e91c6636..979bf8aae 100644 --- a/main-actions/src/main/scala/sbt/internal/WorkerExchange.scala +++ b/main-actions/src/main/scala/sbt/internal/WorkerExchange.scala @@ -50,6 +50,8 @@ object WorkerExchange: val scanner = Scanner(socket.getInputStream(), "UTF-8") while scanner.hasNextLine() do notifyListeners(scanner.nextLine()) }) + accepter.setName("sbt-fork-test-response-reader") + accepter.setPriority(Thread.NORM_PRIORITY + 1) accepter.start() Some(serverSocket) case _ => None diff --git a/sbt-app/src/sbt-test/tests/stream-test-events/build.sbt b/sbt-app/src/sbt-test/tests/stream-test-events/build.sbt new file mode 100644 index 000000000..e3f20da9d --- /dev/null +++ b/sbt-app/src/sbt-test/tests/stream-test-events/build.sbt @@ -0,0 +1,80 @@ +import java.util.concurrent.atomic.AtomicLong +import sbt.protocol.testing.TestResult + +val startAtNs = collection.concurrent.TrieMap.empty[String, AtomicLong] +val eventCallbackCount = collection.concurrent.TrieMap.empty[String, AtomicLong] +val maxDetailSize = collection.concurrent.TrieMap.empty[String, AtomicLong] +val endSeen = collection.concurrent.TrieMap.empty[String, AtomicLong] + +def counter(map: collection.concurrent.TrieMap[String, AtomicLong], key: String): AtomicLong = + map.getOrElseUpdate(key, new AtomicLong(0L)) + +def resetCounters(key: String): Unit = { + counter(startAtNs, key).set(0L) + counter(eventCallbackCount, key).set(0L) + counter(maxDetailSize, key).set(0L) + counter(endSeen, key).set(0L) +} + +def streamingListener(label: String): TestReportListener = new TestReportListener { + def startGroup(name: String): Unit = + counter(startAtNs, label).compareAndSet(0L, System.nanoTime()) + + def testEvent(event: TestEvent): Unit = { + counter(eventCallbackCount, label).incrementAndGet() + val detailSize = event.detail.size.toLong + val maxSeen = counter(maxDetailSize, label) + var done = false + while (!done) { + val current = maxSeen.get() + if (detailSize <= current) done = true + else done = maxSeen.compareAndSet(current, detailSize) + } + } + + def endGroup(name: String, t: Throwable): Unit = + counter(endSeen, label).set(1L) + + def endGroup(name: String, result: TestResult): Unit = + counter(endSeen, label).set(1L) +} + +lazy val resetListener = taskKey[Unit]("Reset listener state for timing checks") +lazy val checkStreaming = taskKey[Unit]("Assert test events are received before endGroup") + +ThisBuild / scalaVersion := "2.12.21" + +def commonSettings(label: String): Seq[Def.Setting[_]] = + Seq( + libraryDependencies += "org.scala-sbt" % "test-interface" % "1.0" % Test, + Test / testFrameworks := Seq(new TestFramework("custom.StreamingFramework")), + Test / parallelExecution := false, + testListeners += streamingListener(label), + resetListener := resetCounters(label), + checkStreaming := { + val startNs = counter(startAtNs, label).get() + val callbacks = counter(eventCallbackCount, label).get() + val largestDetail = counter(maxDetailSize, label).get() + val endWasSeen = counter(endSeen, label).get() + if (startNs == 0L) sys.error("startGroup was never called") + if (endWasSeen == 0L) sys.error("endGroup was never called") + if (callbacks < 2L) + sys.error("Expected at least two testEvent callbacks, saw " + callbacks) + if (largestDetail > 1L) + sys.error( + "Expected streamed test events with detail size 1, largest detail size was " + largestDetail + ) + } + ) + +lazy val inproc = (project in file("inproc")) + .settings(commonSettings("inproc"): _*) + .settings(Test / fork := false) + +lazy val forked = (project in file("forked")) + .settings(commonSettings("forked"): _*) + .settings(Test / fork := true) + +lazy val root = (project in file(".")) + .aggregate(inproc, forked) + .settings(publish / skip := true) diff --git a/sbt-app/src/sbt-test/tests/stream-test-events/forked/src/test/scala/custom/StreamingFramework.scala b/sbt-app/src/sbt-test/tests/stream-test-events/forked/src/test/scala/custom/StreamingFramework.scala new file mode 100644 index 000000000..2564d9544 --- /dev/null +++ b/sbt-app/src/sbt-test/tests/stream-test-events/forked/src/test/scala/custom/StreamingFramework.scala @@ -0,0 +1,68 @@ +package custom + +import sbt.testing._ + +trait StreamTest + +final class SampleTest extends StreamTest + +final class StreamingFramework extends Framework { + def name(): String = "StreamingFramework" + + def fingerprints(): Array[Fingerprint] = + Array( + new SubclassFingerprint { + def isModule(): Boolean = false + def superclassName(): String = "custom.StreamTest" + def requireNoArgConstructor(): Boolean = true + } + ) + + def runner( + args: Array[String], + remoteArgs: Array[String], + testClassLoader: ClassLoader + ): Runner = + new StreamingRunner +} + +final class StreamingRunner extends Runner { + def tasks(taskDefs: Array[TaskDef]): Array[Task] = + taskDefs.map(new StreamingTask(_)) + + def done(): String = "" + + def args(): Array[String] = Array.empty + + def remoteArgs(): Array[String] = Array.empty + + def receiveMessage(msg: String): Option[String] = None + + def serializeTask(task: Task, serializer: TaskDef => String): String = + serializer(task.taskDef()) + + def deserializeTask(task: String, deserializer: String => TaskDef): Task = + new StreamingTask(deserializer(task)) +} + +final class StreamingTask(td: TaskDef) extends Task { + def taskDef(): TaskDef = td + + def tags(): Array[String] = Array.empty + + def execute(handler: EventHandler, loggers: Array[Logger]): Array[Task] = { + handler.handle(new StreamingEvent(td, "first")) + Thread.sleep(1200L) + handler.handle(new StreamingEvent(td, "second")) + Array.empty + } +} + +final class StreamingEvent(td: TaskDef, testName: String) extends Event { + def fullyQualifiedName(): String = td.fullyQualifiedName() + def fingerprint(): Fingerprint = td.fingerprint() + def selector(): Selector = new TestSelector(testName) + def status(): Status = Status.Success + def throwable(): OptionalThrowable = new OptionalThrowable() + def duration(): Long = 0L +} \ No newline at end of file diff --git a/sbt-app/src/sbt-test/tests/stream-test-events/inproc/src/test/scala/custom/StreamingFramework.scala b/sbt-app/src/sbt-test/tests/stream-test-events/inproc/src/test/scala/custom/StreamingFramework.scala new file mode 100644 index 000000000..2564d9544 --- /dev/null +++ b/sbt-app/src/sbt-test/tests/stream-test-events/inproc/src/test/scala/custom/StreamingFramework.scala @@ -0,0 +1,68 @@ +package custom + +import sbt.testing._ + +trait StreamTest + +final class SampleTest extends StreamTest + +final class StreamingFramework extends Framework { + def name(): String = "StreamingFramework" + + def fingerprints(): Array[Fingerprint] = + Array( + new SubclassFingerprint { + def isModule(): Boolean = false + def superclassName(): String = "custom.StreamTest" + def requireNoArgConstructor(): Boolean = true + } + ) + + def runner( + args: Array[String], + remoteArgs: Array[String], + testClassLoader: ClassLoader + ): Runner = + new StreamingRunner +} + +final class StreamingRunner extends Runner { + def tasks(taskDefs: Array[TaskDef]): Array[Task] = + taskDefs.map(new StreamingTask(_)) + + def done(): String = "" + + def args(): Array[String] = Array.empty + + def remoteArgs(): Array[String] = Array.empty + + def receiveMessage(msg: String): Option[String] = None + + def serializeTask(task: Task, serializer: TaskDef => String): String = + serializer(task.taskDef()) + + def deserializeTask(task: String, deserializer: String => TaskDef): Task = + new StreamingTask(deserializer(task)) +} + +final class StreamingTask(td: TaskDef) extends Task { + def taskDef(): TaskDef = td + + def tags(): Array[String] = Array.empty + + def execute(handler: EventHandler, loggers: Array[Logger]): Array[Task] = { + handler.handle(new StreamingEvent(td, "first")) + Thread.sleep(1200L) + handler.handle(new StreamingEvent(td, "second")) + Array.empty + } +} + +final class StreamingEvent(td: TaskDef, testName: String) extends Event { + def fullyQualifiedName(): String = td.fullyQualifiedName() + def fingerprint(): Fingerprint = td.fingerprint() + def selector(): Selector = new TestSelector(testName) + def status(): Status = Status.Success + def throwable(): OptionalThrowable = new OptionalThrowable() + def duration(): Long = 0L +} \ No newline at end of file diff --git a/sbt-app/src/sbt-test/tests/stream-test-events/test b/sbt-app/src/sbt-test/tests/stream-test-events/test new file mode 100644 index 000000000..f655ede5f --- /dev/null +++ b/sbt-app/src/sbt-test/tests/stream-test-events/test @@ -0,0 +1,6 @@ +> inproc/resetListener +> inproc/test +> inproc/checkStreaming +> forked/resetListener +> forked/test +> forked/checkStreaming diff --git a/testing/src/main/scala/sbt/TestFramework.scala b/testing/src/main/scala/sbt/TestFramework.scala index 9d208cfe2..883b3f96c 100644 --- a/testing/src/main/scala/sbt/TestFramework.scala +++ b/testing/src/main/scala/sbt/TestFramework.scala @@ -135,7 +135,12 @@ private[sbt] final class TestRunner( // Thread-safe collection so AsyncFunSuite (and other async frameworks) can call // handle() from multiple threads without corrupting results (fixes #5245). val results = new CopyOnWriteArrayList[Event] - val handler = new EventHandler { def handle(e: Event): Unit = { results.add(e) } } + val handler = new EventHandler { + def handle(e: Event): Unit = { + results.add(e) + safeListenersCall(_.testEvent(TestEvent(Seq(e)))) + } + } val loggers: Vector[ContentLogger] = listeners.flatMap(_.contentLogger(testDefinition)) def errorEvents(e: Throwable): Array[sbt.testing.Task] = { val taskDef = testTask.taskDef @@ -148,6 +153,7 @@ private[sbt] final class TestRunner( val duration = -1L } results.add(event) + safeListenersCall(_.testEvent(TestEvent(Seq(event)))) Array.empty } val nestedTasks = @@ -160,8 +166,6 @@ private[sbt] final class TestRunner( loggers.foreach(_.flush()) } val resultsList = results.asScala.toList - val event = TestEvent(resultsList) - safeListenersCall(_.testEvent(event)) (SuiteResult(resultsList), nestedTasks.toSeq) } diff --git a/worker/src/main/java/sbt/internal/worker1/ForkTestMain.java b/worker/src/main/java/sbt/internal/worker1/ForkTestMain.java index ce18a426f..27f4e9ca1 100644 --- a/worker/src/main/java/sbt/internal/worker1/ForkTestMain.java +++ b/worker/src/main/java/sbt/internal/worker1/ForkTestMain.java @@ -12,7 +12,7 @@ import java.io.PrintStream; import java.io.Serializable; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collection; +import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; import java.util.concurrent.*; @@ -140,6 +140,26 @@ public class ForkTestMain { } } + public static class ForkGroupStart implements Serializable { + public long id; + public String group; + + public ForkGroupStart(long id, String group) { + this.id = id; + this.group = group; + } + } + + public static class ForkGroupEnd implements Serializable { + public long id; + public String group; + + public ForkGroupEnd(long id, String group) { + this.id = id; + this.group = group; + } + } + // ----------------------------------------------------------------------------- public static final class ForkError extends Exception { @@ -296,16 +316,38 @@ public class ForkTestMain { }; } - private void writeEvents(final TaskDef taskDef, final ForkEvent[] events) { + private void writeGroupStart(final TaskDef taskDef) { + ForkGroupStart info = new ForkGroupStart(this.id, taskDef.fullyQualifiedName()); + String params = this.gson.toJson(info, ForkGroupStart.class); + String notification = + String.format( + "{ \"jsonrpc\": \"2.0\", \"method\": \"startTestGroup\", \"params\": %s, \"re\": %d }", + params, this.id); + this.originalOut.println(notification); + this.originalOut.flush(); + } + + private void writeTestProgress(final TaskDef taskDef, final ForkEvent event) { ForkEventsInfo info = new ForkEventsInfo( this.id, taskDef.fullyQualifiedName(), - new ArrayList(Arrays.asList(events))); + new ArrayList(Collections.singletonList(event))); String params = this.gson.toJson(info, ForkEventsInfo.class); String notification = String.format( - "{ \"jsonrpc\": \"2.0\", \"method\": \"testEvents\", \"params\": %s, \"re\": %d }", + "{ \"jsonrpc\": \"2.0\", \"method\": \"testProgress\", \"params\": %s, \"re\": %d }", + params, this.id); + this.originalOut.println(notification); + this.originalOut.flush(); + } + + private void writeGroupEnd(final TaskDef taskDef) { + ForkGroupEnd info = new ForkGroupEnd(this.id, taskDef.fullyQualifiedName()); + String params = this.gson.toJson(info, ForkGroupEnd.class); + String notification = + String.format( + "{ \"jsonrpc\": \"2.0\", \"method\": \"endTestGroup\", \"params\": %s, \"re\": %d }", params, this.id); this.originalOut.println(notification); this.originalOut.flush(); @@ -414,41 +456,32 @@ public class ForkTestMain { final ExecutorService executor, final Task task, final Logger[] loggers) { return executor.submit( () -> { - ForkEvent[] events; Task[] nestedTasks; final TaskDef taskDef = task.taskDef(); + writeGroupStart(taskDef); try { - final Collection eventList = new ConcurrentLinkedDeque<>(); final EventHandler handler = new EventHandler() { public void handle(final Event e) { - eventList.add(new ForkEvent(e)); + writeTestProgress(taskDef, new ForkEvent(e)); } }; logDebug(" Running " + taskDef); nestedTasks = task.execute(handler, loggers); - if (nestedTasks.length > 0 || eventList.size() > 0) - logDebug( - " Produced " - + nestedTasks.length - + " nested tasks and " - + eventList.size() - + " events."); - events = eventList.toArray(new ForkEvent[eventList.size()]); + logDebug(" Produced " + nestedTasks.length + " nested tasks (events streamed)."); } catch (final Throwable t) { nestedTasks = new Task[0]; - events = - new ForkEvent[] { - testError( - taskDef, - "Uncaught exception when running " - + taskDef.fullyQualifiedName() - + ": " - + t.toString(), - t) - }; + writeTestProgress( + taskDef, + testError( + taskDef, + "Uncaught exception when running " + + taskDef.fullyQualifiedName() + + ": " + + t.toString(), + t)); } - writeEvents(taskDef, events); + writeGroupEnd(taskDef); return nestedTasks; }); } From 81b81ce6b0ef697e141134a66dfcab58ec9c2c8c Mon Sep 17 00:00:00 2001 From: Dream <42954461+eureka0928@users.noreply.github.com> Date: Thu, 16 Apr 2026 01:37:24 -0400 Subject: [PATCH 3/3] [2.x] fix: Fix consoleProject for Scala 3 (#9073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Problem** consoleProject does not work with Scala 3 because the compiler bridge does not implement REPL binding injection (scala/scala3#5069). The bindings currentState, extracted, and cpHelpers are never injected into the REPL session, causing Not found errors. **Solution** Work around the missing binding support by storing the three runtime objects in a static holder (ConsoleProjectBindings) before launching the REPL, and generating val definitions via initialCommands that read from the holder. The original bindings are still passed to Console to preserve Scala 2 backward compatibility. Pass -Xrepl-interrupt-instrumentation:local to the REPL when the consoleProject scala instance is 3.8 or newer. In local mode the instrumented loader skips re-defining classpath classes and falls through to standard parent-first delegation, so REPL code sees the same singleton sbt.* and scala.* classes as the surrounding sbt process — while still keeping interrupt support for REPL-defined code, preserving Ctrl+C on long-running expressions like (Compile / compile).eval. --------- Co-authored-by: Claude Opus 4.6 (1M context) --- main/src/main/scala/sbt/Defaults.scala | 55 ++++++-- .../main/scala/sbt/internal/Compiler.scala | 69 ++++++++++ .../scala/sbt/internal/ConsoleProject.scala | 126 ++++++++++++++++-- .../sbt/internal/ConsoleProjectBindings.scala | 45 +++++++ .../project/consoleProject/{pending => test} | 0 .../project/scala3-console-project/build.sbt | 23 +++- .../project/scala3-console-project/pending | 1 - .../project/scala3-console-project/test | 2 + 8 files changed, 295 insertions(+), 26 deletions(-) create mode 100644 main/src/main/scala/sbt/internal/ConsoleProjectBindings.scala rename sbt-app/src/sbt-test/project/consoleProject/{pending => test} (100%) delete mode 100644 sbt-app/src/sbt-test/project/scala3-console-project/pending create mode 100644 sbt-app/src/sbt-test/project/scala3-console-project/test diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index 90b9a5655..7ec2d417b 100644 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -790,6 +790,28 @@ object Defaults extends BuildCommon with DefExtra { ScalaArtifacts.Organization, appConfiguration.value.provider.scalaProvider.version ), + // consoleProject compiles the *build definition*, which targets sbt's own + // Scala version (the launcher's scalaProvider.version), not the project's. + // The default `scalaCompilerBridgeBin` resolves via the `update` report, + // which uses the project's scalaVersion; resolve the pre-built Scala 3 + // bridge directly for sbt's Scala version instead. See sbt/sbt#7722. + consoleProject / scalaCompilerBridgeBin := Def.uncached { + val sv = appConfiguration.value.provider.scalaProvider.version + val st = state.value + val g = BuildPaths.getGlobalBase(st) + val zincDir = BuildPaths.getZincDirectory(st, g) + val conv = fileConverter.value + Compiler + .scala3ConsoleProjectBridgeJar( + scalaVersion = sv, + scalaOrg = ScalaArtifacts.Organization, + dr = (LocalRootProject / dependencyResolution).value, + retrieveDir = zincDir, + log = streams.value.log, + ) + .toVector + .map(jar => conv.toVirtualFile(jar.toPath()): HashedVirtualFileRef) + }, classpathOptions := ClasspathOptionsUtil.noboot(scalaVersion.value), console / classpathOptions := ClasspathOptionsUtil.replNoboot(scalaVersion.value), ) @@ -811,22 +833,35 @@ object Defaults extends BuildCommon with DefExtra { ), consoleProject := ConsoleProject.consoleProjectTask.value, consoleProject / scalaInstance := { - val topLoader = classOf[org.jline.terminal.Terminal].getClassLoader + // Use the classloader that has `ConsoleProjectBindings` as the top so + // that code generated by `initialCommands` in the REPL sees the same + // class instance that sbt has set fields on. See sbt/sbt#7722. + val topLoader = classOf[ConsoleProjectBindings.type].getClassLoader val scalaProvider = appConfiguration.value.provider.scalaProvider - val allJars = scalaProvider.jars - val libraryJars = allJars.filter { jar => + val launcherJars = scalaProvider.jars + val libraryJars = launcherJars.filter { jar => jar.getName == "scala-library.jar" || jar.getName.startsWith("scala3-library_3") } - val compilerJar = allJars.filter { jar => - jar.getName == "scala-compiler.jar" || jar.getName.startsWith("scala3-compiler_3") - } - ScalaInstance(scalaProvider.version, scalaProvider.launcher) + // Scala 3.8+ extracted ReplDriver from scala3-compiler_3 into scala3-repl_3, + // which the sbt launcher does not ship. Resolve it explicitly so that + // consoleProject can start the Scala 3 REPL. See sbt/sbt#7722. + val st = state.value + val g = BuildPaths.getGlobalBase(st) + val zincDir = BuildPaths.getZincDirectory(st, g) + val replToolJars = Compiler.scala3ReplToolJars( + scalaVersion = scalaProvider.version, + scalaOrg = ScalaArtifacts.Organization, + dr = (LocalRootProject / dependencyResolution).value, + retrieveDir = zincDir, + log = streams.value.log, + ) + val allJars = (launcherJars.toSeq ++ replToolJars).distinct Compiler.makeScalaInstance( scalaProvider.version, libraryJars, - allJars.toSeq, - Seq.empty, - state.value, + allJars, + replToolJars, + st, topLoader, ) }, diff --git a/main/src/main/scala/sbt/internal/Compiler.scala b/main/src/main/scala/sbt/internal/Compiler.scala index 57e9c1604..879a4bf44 100644 --- a/main/src/main/scala/sbt/internal/Compiler.scala +++ b/main/src/main/scala/sbt/internal/Compiler.scala @@ -27,6 +27,8 @@ import sbt.librarymanagement.{ Configuration, Configurations, ConfigurationReport, + CrossVersion, + DependencyResolution, ModuleID, ScalaArtifacts, SemanticSelector, @@ -110,6 +112,73 @@ object Compiler: case _ => ScalaInstance(sv, scalaProvider) } + /** + * Resolves extra REPL tool jars required for Scala 3.8+. + * + * In Scala 3.8+, `dotty.tools.repl.ReplDriver` was extracted from + * `scala3-compiler_3` into a new artifact `scala3-repl_3`. The sbt launcher's + * Scala provider only includes `scala3-compiler_3`, so we need to resolve + * `scala3-repl_3` explicitly for `consoleProject` to work. + * + * Returns an empty sequence for Scala versions < 3.8. + * + * @see https://github.com/sbt/sbt/issues/7722 + * @see https://github.com/scala/scala3/pull/24243 + */ + private[sbt] def scala3ReplToolJars( + scalaVersion: String, + scalaOrg: String, + dr: DependencyResolution, + retrieveDir: File, + log: Logger + ): Seq[File] = + if !ScalaArtifacts.isScala3_8Plus(scalaVersion) then Nil + else + // Scala 3 artifacts use the `_3` suffix, not `_3.8`. Bake the suffix into + // the artifact name and disable cross-version resolution, because the + // caller's scalaModuleInfo may be None or reflect a different project + // Scala version than the one we actually need. + val replModule = ModuleID(scalaOrg, s"${ScalaArtifacts.Scala3ReplID}_3", scalaVersion) + .withCrossVersion(CrossVersion.disabled) + dr.retrieve(replModule, scalaModuleInfo = None, retrieveDir, log) match + case Right(resolved) => resolved.toSeq + case Left(unresolved) => + log.warn( + s"Could not resolve $replModule for consoleProject; REPL may fail to start: ${unresolved.resolveException.getMessage}" + ) + Nil + + /** + * Resolves the pre-built Scala 3 compiler bridge jar for `consoleProject`. + * + * The default `scalaCompilerBridgeBin` uses the project's Scala version, but + * `consoleProject` compiles the build definition with sbt's own Scala + * version. Resolve the pre-built bridge directly for that version to keep + * the bridge consistent with `consoleProject / scalaInstance`. + * + * The bridge is a Java-compiled jar (cross-version disabled). + * + * @see https://github.com/sbt/sbt/issues/7722 + */ + private[sbt] def scala3ConsoleProjectBridgeJar( + scalaVersion: String, + scalaOrg: String, + dr: DependencyResolution, + retrieveDir: File, + log: Logger + ): Option[File] = + if !ScalaArtifacts.isScala3(scalaVersion) then None + else + val bridgeModule = ModuleID(scalaOrg, "scala3-sbt-bridge", scalaVersion) + .withCrossVersion(CrossVersion.disabled) + dr.retrieve(bridgeModule, scalaModuleInfo = None, retrieveDir, log) match + case Right(resolved) => resolved.find(_.getName.startsWith("scala3-sbt-bridge")) + case Left(unresolved) => + log.warn( + s"Could not resolve $bridgeModule for consoleProject: ${unresolved.resolveException.getMessage}" + ) + None + def scalaInstanceConfigFromHome(dir: File): Def.Initialize[Task[ScalaInstanceConfig]] = Def.task { val dummy = ScalaInstance(dir)(Keys.state.value.classLoaderCache.apply) diff --git a/main/src/main/scala/sbt/internal/ConsoleProject.scala b/main/src/main/scala/sbt/internal/ConsoleProject.scala index 35fd97906..08a0f588a 100644 --- a/main/src/main/scala/sbt/internal/ConsoleProject.scala +++ b/main/src/main/scala/sbt/internal/ConsoleProject.scala @@ -53,7 +53,9 @@ object ConsoleProject: ): Unit = { val extracted = Project.extract(state) val cpImports = new Imports(extracted, state) - // Bindings are blocked by https://github.com/scala/scala3/issues/5069 + // Bindings are ignored by Scala 3 bridge: https://github.com/scala/scala3/issues/5069 + // Workaround: vals are injected via initialCommands from ConsoleProjectBindings holder. + // bindings are still passed to Console for Scala 2 backward compatibility. val bindings = ("currentState" -> state) :: ("extracted" -> extracted) :: ("cpHelpers" -> cpImports) :: Nil val unit = extracted.currentUnit @@ -86,22 +88,120 @@ object ConsoleProject: classLoaderCache = state.get(BasicKeys.classLoaderCache), log = log ) - val imports = BuildUtil.getImports(unit.unit) ++ BuildUtil.importAll(bindings.map(_._1)) - val importString = imports.mkString("", ";\n", ";\n\n") - val initCommands = importString + extra - val loader = ClasspathUtil.makeLoader(unit.classpath, si, tempDir) + ConsoleProjectBindings.set(state, extracted, cpImports) + val baseImports = BuildUtil.getImports(unit.unit) + val bindingDefs = Seq( + "val currentState = _root_.sbt.internal.ConsoleProjectBindings.state", + "val extracted = _root_.sbt.internal.ConsoleProjectBindings.extracted", + "val cpHelpers = _root_.sbt.internal.ConsoleProjectBindings.cpHelpers", + ) + val bindingImports = BuildUtil.importAll(bindings.map(_._1)) + val allLines = baseImports ++ bindingDefs ++ bindingImports + val initCommands = allLines.mkString("", ";\n", ";\n\n") + extra + // Two things are required so the REPL resolves `sbt.*` (e.g. + // `sbt.TaskKey`, `sbt.Keys`, `sbt.State`, `sbt.internal.ConsoleProjectBindings`) + // and `scala.*` (e.g. `scala.Function2`) via the *same* class objects + // that sbt itself uses. See sbt/sbt#7722. + // + // 1. Remove sbt's own module jars from the runtime URL classloader, + // so that `sbt.*` references resolve via parent delegation back + // to sbt's `MetaBuildLoader` rather than being defined a second + // time by the REPL's URL classloader (which would break the + // `ConsoleProjectBindings` singleton's static state and + // trigger `LinkageError: loader constraint violation` when REPL + // code touches a method whose signature mentions a duplicated + // type — e.g. `sbt.TaskKey.zipWith(_, scala.Function2)`). + // + // 2. On Scala 3.8+ switch the REPL's bytecode interrupt + // instrumentation to `local` mode. The default (`true`) for + // `dotty.tools.repl.AbstractFileClassLoader` (added in 3.8) + // reads every class's bytes from the parent loader via + // `getResourceAsStream` and `defineClass`-es them a *second* + // time inside the REPL loader, producing duplicate `Class` + // objects for every `sbt.*` and `scala.*` class. `local` skips + // that re-definition and falls through to standard parent-first + // delegation (so the REPL sees the same singleton classes as + // the surrounding sbt process) while still keeping interrupt + // support for REPL-defined code — preserving Ctrl+C for long- + // running expressions like `(Compile / compile).eval`. The flag + // does not exist on Scala 3.7 and earlier (which use the older + // AFClassLoader without instrumentation), so we only pass it + // when the consoleProject scala instance is 3.8+ to avoid a + // "bad option" warning. + // + // The full classpath is still passed to `Console` below so the REPL's + // compile-time classpath is unchanged. + val runtimeClasspath = unit.classpath.filterNot(isSbtModuleJar) + val loader = ClasspathUtil.makeLoader(runtimeClasspath, si, tempDir) + val replOptions = + if needsInterruptInstrumentationOptOut(si.version) then + "-Xrepl-interrupt-instrumentation:local" +: options + else options val terminal = Terminal.get // TODO - Hook up dsl classpath correctly... - (new Console(compiler))( - unit.classpath.map(_.toFile), - options, - initCommands, - cleanupCommands, - terminal - )(Some(loader), bindings).get - () + try + (new Console(compiler))( + unit.classpath.map(_.toFile), + replOptions, + initCommands, + cleanupCommands, + terminal + )(Some(loader), bindings).get + () + finally ConsoleProjectBindings.clear() } + /** + * `dotty.tools.repl.AbstractFileClassLoader`'s bytecode interrupt + * instrumentation was added in Scala 3.8 and is enabled by default — + * see Scala 3 PR scala/scala3#22720. The setting that disables it is + * also a 3.8+ addition (see `Xrepl-interrupt-instrumentation` in + * `dotty.tools.dotc.config.ScalaSettings`). For 3.7 and earlier the + * REPL classloader doesn't re-define classes locally, so the flag is + * unnecessary and would only produce a "bad option" warning. + */ + private def needsInterruptInstrumentationOptOut(scalaVersion: String): Boolean = + scalaVersion match + case s"3.$rest" => + rest.takeWhile(_.isDigit).toIntOption.exists(_ >= 8) + case _ => false + + /** + * Returns true when a `Path` refers to a jar published by + * `org.scala-sbt`. These jars ship sbt's own classes (e.g. `sbt.State`, + * `sbt.TaskKey`, `sbt.Keys`) that are already reachable via the parent + * class loader used by `consoleProject`. They must be excluded from the + * REPL's runtime classloader so that `sbt.*` references resolve via + * parent delegation and reach sbt's singleton copies — rather than + * being defined fresh by the URL classloader from `unit.classpath`, + * which would trigger a `LinkageError: loader constraint violation` + * whenever those classes are used from the REPL. See sbt/sbt#7722. + * + * Detection is done via `META-INF/MANIFEST.MF`'s `Implementation-Vendor-Id` + * attribute, which all sbt module jars set to `org.scala-sbt`. This is + * more robust than checking for specific class entries, because it + * uniformly catches every sbt module (main, main-settings, command, io, + * util-*, etc.) without enumerating them. + */ + 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 + val entry = zf.getEntry("META-INF/MANIFEST.MF") + if entry eq null then false + else + val is = zf.getInputStream(entry) + try + val manifest = new java.util.jar.Manifest(is) + val attrs = manifest.getMainAttributes + attrs != null && "org.scala-sbt" == attrs.getValue("Implementation-Vendor-Id") + finally is.close() + 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.* diff --git a/main/src/main/scala/sbt/internal/ConsoleProjectBindings.scala b/main/src/main/scala/sbt/internal/ConsoleProjectBindings.scala new file mode 100644 index 000000000..1e4e69fba --- /dev/null +++ b/main/src/main/scala/sbt/internal/ConsoleProjectBindings.scala @@ -0,0 +1,45 @@ +/* + * sbt + * Copyright 2023, Scala center + * Copyright 2011 - 2022, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt +package internal + +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 sbt generates `val` + * definitions in `initialCommands` that read from this holder. + * + * 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 + @volatile private var _extracted: Extracted = uninitialized + @volatile private var _cpHelpers: ConsoleProject.Imports = uninitialized + + def set(state: State, extracted: Extracted, cpHelpers: ConsoleProject.Imports): Unit = + _state = state + _extracted = extracted + _cpHelpers = cpHelpers + + def clear(): Unit = + _state = null + _extracted = null + _cpHelpers = null + + def state: State = _state + def extracted: Extracted = _extracted + def cpHelpers: ConsoleProject.Imports = _cpHelpers +end ConsoleProjectBindings diff --git a/sbt-app/src/sbt-test/project/consoleProject/pending b/sbt-app/src/sbt-test/project/consoleProject/test similarity index 100% rename from sbt-app/src/sbt-test/project/consoleProject/pending rename to sbt-app/src/sbt-test/project/consoleProject/test diff --git a/sbt-app/src/sbt-test/project/scala3-console-project/build.sbt b/sbt-app/src/sbt-test/project/scala3-console-project/build.sbt index 208b8c684..4ce54ef9f 100644 --- a/sbt-app/src/sbt-test/project/scala3-console-project/build.sbt +++ b/sbt-app/src/sbt-test/project/scala3-console-project/build.sbt @@ -1,3 +1,22 @@ -ThisBuild / scalaVersion := "3.0.0-M2" +ThisBuild / scalaVersion := "3.8.3" -lazy val root = project.in(file(".")) +lazy val markerFile = settingKey[java.io.File]("marker file written by consoleProject REPL when bindings resolve") + +lazy val root = project.in(file(".")).settings( + markerFile := target.value / "console-bindings-ok", + Global / initialCommands := { + val path = markerFile.value.getAbsolutePath.replace("\\", "\\\\") + // Exercise the exact code paths that hit `LinkageError: loader + // constraint violation` in earlier iterations of sbt/sbt#7722: + // 1. Resolving `sbt.Keys.compile` loads `sbt.TaskKey` through the + // REPL's classloader chain (first regression, pre-#9073). + // 2. Calling `TaskKey.zipWith(_, Function2)` tripped on + // `scala.Function2` being defined twice (second regression, + // reported on PR #9073 after the initial fix). + // If either resolution fails, the marker file is never written. + s"""val _compileKey = _root_.sbt.Keys.compile + |val _zipped = _compileKey.zipWith(_compileKey)((_, _) => 0) + |_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 + "/" + _compileKey.key.label + "/" + _zipped.getClass.getName) + |""".stripMargin + }, +) diff --git a/sbt-app/src/sbt-test/project/scala3-console-project/pending b/sbt-app/src/sbt-test/project/scala3-console-project/pending deleted file mode 100644 index b80db65f1..000000000 --- a/sbt-app/src/sbt-test/project/scala3-console-project/pending +++ /dev/null @@ -1 +0,0 @@ -> consoleProject diff --git a/sbt-app/src/sbt-test/project/scala3-console-project/test b/sbt-app/src/sbt-test/project/scala3-console-project/test new file mode 100644 index 000000000..02e890c91 --- /dev/null +++ b/sbt-app/src/sbt-test/project/scala3-console-project/test @@ -0,0 +1,2 @@ +> consoleProject +$ exists target/out/**/console-bindings-ok