From a805b32d1d92de4878440bf2c0e8f0b5e635291b Mon Sep 17 00:00:00 2001 From: eugene yokota Date: Fri, 25 Sep 2026 23:38:55 -0400 Subject: [PATCH 1/6] [2.0.x] fix: Fixes .m2/settings.xml overriding sbtResolvers (#9828) **Problem** Since Coursier 2.1.25 .m2/settings.xml takes over even when sbtResolvers exists. **Solution** Turn off .m2/settings.xml unless explicitly enabled. --- .../lmcoursier/internal/ResolutionRun.scala | 36 ++++++++++++- .../internal/ResolutionRunSpec.scala | 50 +++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/lm-coursier/src/main/scala/lmcoursier/internal/ResolutionRun.scala b/lm-coursier/src/main/scala/lmcoursier/internal/ResolutionRun.scala index 04859ed5f..f6ed25f13 100644 --- a/lm-coursier/src/main/scala/lmcoursier/internal/ResolutionRun.scala +++ b/lm-coursier/src/main/scala/lmcoursier/internal/ResolutionRun.scala @@ -1,6 +1,7 @@ package lmcoursier.internal -import coursier.{ Resolution, Resolve } +import coursier.{ CoursierEnv, Resolution, Resolve } +import coursier.cache.CacheEnv import coursier.cache.internal.ThreadUtil import coursier.cache.loggers.{ FallbackRefreshDisplay, ProgressBarRefreshDisplay, RefreshLogger } import coursier.core.* @@ -8,8 +9,9 @@ import coursier.error.ResolutionError import coursier.error.ResolutionError.CantDownloadModule import coursier.ivy.IvyRepository import coursier.maven.MavenRepositoryLike +import coursier.params.Mirror import coursier.params.rule.RuleResolution -import coursier.util.Task +import coursier.util.{ EnvValues, Task } import sbt.util.Logger import scala.annotation.nowarn @@ -19,6 +21,35 @@ import scala.collection.mutable // private[coursier] object ResolutionRun { + /** + * Mirrors from the coursier configuration, plus the ones of Maven's settings.xml + * only when COURSIER_MAVEN_SETTINGS (or coursier.maven-settings) is set explicitly, + * since a settings.xml mirror of `*` would otherwise shadow the resolvers of the build. + * See https://github.com/sbt/sbt/issues/9821 + */ + lazy val defaultMirrors: Seq[Mirror] = + CoursierEnv.defaultMirrors( + CoursierEnv.mirrors.read(), + CoursierEnv.mirrorsExtra.read(), + CoursierEnv.scalaCliConfig.read(), + CacheEnv.configDir.read() + ) ++ + mavenSettingsMirrors( + CoursierEnv.mavenSettings.read(), + CoursierEnv.mavenHome.read(), + CoursierEnv.mavenHomeFallback.read() + ) + + private[lmcoursier] def mavenSettingsMirrors( + mavenSettings: EnvValues, + mavenHome: EnvValues, + mavenHomeFallback: EnvValues + ): Seq[Mirror] = + val explicit = mavenSettings.env.orElse(mavenSettings.prop).exists(_.trim.nonEmpty) + if explicit then + CoursierEnv.defaultMavenSettingsMirrors(mavenSettings, mavenHome, mavenHomeFallback) + else Nil + private def resolution( params: ResolutionParams, verbosityLevel: Int, @@ -103,6 +134,7 @@ object ResolutionRun { }, boms = params.boms, repositories = repositories, + mirrors = defaultMirrors, resolutionParams = params.params .addForceVersion( (if (isSandboxConfig) Nil diff --git a/lm-coursier/src/test/scala/lmcoursier/internal/ResolutionRunSpec.scala b/lm-coursier/src/test/scala/lmcoursier/internal/ResolutionRunSpec.scala index a3ad3f4e0..64d4b9f03 100644 --- a/lm-coursier/src/test/scala/lmcoursier/internal/ResolutionRunSpec.scala +++ b/lm-coursier/src/test/scala/lmcoursier/internal/ResolutionRunSpec.scala @@ -1,11 +1,16 @@ package lmcoursier.internal +import coursier.CoursierEnv import coursier.core.{ Module, ModuleName, Organization, Resolution } import coursier.error.ResolutionError.CantDownloadModule +import coursier.util.EnvValues import coursier.version.VersionConstraint +import sbt.io.IO import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers +import java.io.File + class ResolutionRunSpec extends AnyFunSuite with Matchers: private def cantDownload(errors: String*): CantDownloadModule = @@ -37,3 +42,48 @@ class ResolutionRunSpec extends AnyFunSuite with Matchers: "Server returned HTTP response code: 404 for URL: https://repo.example.com/org/mod/1.0/mod-1.0.pom" ) ResolutionRun.isTransientResolutionError(err) shouldBe false + + private val unset = EnvValues(None, None) + + private def withMirrorOfAll(f: File => Unit): Unit = + IO.withTemporaryDirectory: dir => + IO.write( + new File(dir, "settings.xml"), + """ + | + | + | internal + | https://nexus.example.com/repository/maven-public + | * + | + | + | + |""".stripMargin + ) + f(dir) + + test("settings.xml mirrors are ignored unless the Maven settings are set explicitly"): + withMirrorOfAll: dir => + val mavenHome = EnvValues(Some(dir.getAbsolutePath), None) + assert(CoursierEnv.defaultMavenSettingsMirrors(unset, mavenHome, unset).nonEmpty) + assert(ResolutionRun.mavenSettingsMirrors(unset, mavenHome, unset).isEmpty) + + test("settings.xml mirrors are read when the Maven settings are set explicitly"): + withMirrorOfAll: dir => + val settings = new File(dir, "settings.xml").getAbsolutePath + assert( + ResolutionRun.mavenSettingsMirrors(EnvValues(Some(settings), None), unset, unset).size == 1 + ) + assert( + ResolutionRun.mavenSettingsMirrors(EnvValues(None, Some(settings)), unset, unset).size == 1 + ) + val mavenHome = EnvValues(Some(dir.getAbsolutePath), None) + assert( + ResolutionRun + .mavenSettingsMirrors(EnvValues(Some("true"), None), mavenHome, unset) + .size == 1 + ) + assert( + ResolutionRun.mavenSettingsMirrors(EnvValues(Some("false"), None), mavenHome, unset).isEmpty + ) +end ResolutionRunSpec From 091ec3b794b462bafc190a89115a1b138c19e990 Mon Sep 17 00:00:00 2001 From: Albert Meltzer <7529386+kitbellew@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:48:23 -0700 Subject: [PATCH 2/6] [2.0.x] fix: Keep accepting after a client fails **Problem** The accept loop did not catch an exception from onIncomingSocket, so its thread ended. Nothing closed the server socket, so the path stayed bound: the server took no further client, and the process kept running. The socket of the client that failed stayed open too. **Solution** Log that client, close its socket, and take the next one. An exception from accept itself keeps the handling it had, so a socket that is really broken still ends the loop. The callback now takes a holder rather than the socket, and calls AtomicCloseable.release to keep it. The loop closes whatever the callback left behind. Co-authored-by: Claude Opus 5 (1M context) --- .../main/scala/sbt/internal/util/Util.scala | 4 +- .../scala/sbt/internal/AtomicCloseable.scala | 47 ++++++++++++ .../scala/sbt/internal/server/Server.scala | 11 ++- .../sbt/internal/AtomicCloseableSpec.scala | 72 +++++++++++++++++++ .../scala/sbt/internal/CommandExchange.scala | 5 +- 5 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 main-command/src/main/scala/sbt/internal/AtomicCloseable.scala create mode 100644 main-command/src/test/scala/sbt/internal/AtomicCloseableSpec.scala diff --git a/internal/util-core/src/main/scala/sbt/internal/util/Util.scala b/internal/util-core/src/main/scala/sbt/internal/util/Util.scala index 12a897222..602af1ad7 100644 --- a/internal/util-core/src/main/scala/sbt/internal/util/Util.scala +++ b/internal/util-core/src/main/scala/sbt/internal/util/Util.scala @@ -13,7 +13,7 @@ import java.util.Locale import scala.collection.concurrent.TrieMap import scala.reflect.Selectable.reflectiveSelectable -import scala.util.Properties +import scala.util.{ Properties, Try } object Util: def makeList[T](size: Int, value: T): List[T] = List.fill(size)(value) @@ -50,6 +50,8 @@ object Util: () } + def ignoreTry[A](f: => A): Unit = ignoreResult(Try(f)) + lazy val isMac: Boolean = System.getProperty("os.name").toLowerCase(Locale.ENGLISH).contains("mac") diff --git a/main-command/src/main/scala/sbt/internal/AtomicCloseable.scala b/main-command/src/main/scala/sbt/internal/AtomicCloseable.scala new file mode 100644 index 000000000..f4dc92fa3 --- /dev/null +++ b/main-command/src/main/scala/sbt/internal/AtomicCloseable.scala @@ -0,0 +1,47 @@ +/* + * 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 java.util.concurrent.atomic.AtomicReference + +import sbt.internal.util.Util + +/** Holds a closeable that a caller replaces, and closes the one it replaces. */ +private[sbt] class AtomicCloseable[A >: Null <: AutoCloseable](val ref: AtomicReference[A]) + extends AnyVal: + def get: A = ref.get + def set(c: A): Unit = AtomicCloseable.close(ref.getAndSet(c)) + def close(): Unit = AtomicCloseable.close(AtomicCloseable.release(ref)) + + /** Keeps the value another caller put here, and closes the one this caller built. */ + def setIfEmpty(ctor: => A): A = + var obj = ref.get + if obj eq null then + val value = ctor + require(value ne null, "AtomicCloseable.setIfEmpty: `ctor` must not return null") + while obj eq null do obj = if ref.compareAndSet(null, value) then value else ref.get + if obj ne value then AtomicCloseable.close(value) + obj +end AtomicCloseable + +private[sbt] object AtomicCloseable: + def apply[A >: Null <: AutoCloseable](): AtomicCloseable[A] = + new AtomicCloseable(new AtomicReference[A]) + + def apply[A >: Null <: AutoCloseable](c: A): AtomicCloseable[A] = + new AtomicCloseable(new AtomicReference[A](c)) + + def close(obj: AutoCloseable): Unit = + if obj ne null then Util.ignoreTry(obj.close()) + + def release[A >: Null <: AutoCloseable](ref: AtomicReference[A]): A = + ref.getAndSet(null) + +end AtomicCloseable diff --git a/main-command/src/main/scala/sbt/internal/server/Server.scala b/main-command/src/main/scala/sbt/internal/server/Server.scala index e71d6e643..692399aa8 100644 --- a/main-command/src/main/scala/sbt/internal/server/Server.scala +++ b/main-command/src/main/scala/sbt/internal/server/Server.scala @@ -46,7 +46,7 @@ private[sbt] object Server { def start( connection: ServerConnection, - onIncomingSocket: (Socket, ServerInstance) => Unit, + onIncomingSocket: (AtomicReference[Socket], ServerInstance) => Unit, log: Logger ): ServerInstance = new ServerInstance { self => @@ -109,14 +109,19 @@ private[sbt] object Server { running.set(true) p.success(()) while (running.get()) { + val clientSocket = AtomicCloseable[Socket]() try { - val socket = serverSocket.accept() - onIncomingSocket(socket, self) + clientSocket.set(serverSocket.accept()) + onIncomingSocket(clientSocket.ref, self) } catch { + case scala.util.control.NonFatal(e) if clientSocket.get ne null => + log.error(s"sbt server failed to serve a client: $e") + log.trace(e) case e: IOException if Option(e.getMessage).exists(_.contains("connect")) => case _: SocketTimeoutException => // its ok case _: SocketException if !running.get => // the server is shutting down } + clientSocket.close() } serverSocketHolder.get match { case null => diff --git a/main-command/src/test/scala/sbt/internal/AtomicCloseableSpec.scala b/main-command/src/test/scala/sbt/internal/AtomicCloseableSpec.scala new file mode 100644 index 000000000..d26f1096f --- /dev/null +++ b/main-command/src/test/scala/sbt/internal/AtomicCloseableSpec.scala @@ -0,0 +1,72 @@ +/* + * 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 verify.BasicTestSuite + +object AtomicCloseableSpec extends BasicTestSuite: + class Probe extends AutoCloseable: + var closed: Boolean = false + override def close(): Unit = closed = true + end Probe + + test("a holder built around a value"): + val probe = new Probe + val holder = AtomicCloseable(probe) + assert(holder.get == probe) + assert(!probe.closed) + holder.close() + assert(probe.closed) + + test("a value that replaces another closes it"): + val holder = AtomicCloseable[Probe]() + val first, second = new Probe + holder.set(first) + holder.set(second) + assert(first.closed) + assert(!second.closed) + assert(holder.get == second) + + test("closing empties the holder"): + val holder = AtomicCloseable[Probe]() + val probe = new Probe + holder.set(probe) + holder.close() + assert(probe.closed) + assert(holder.get == null) + + test("closing an empty holder does nothing"): + AtomicCloseable[Probe]().close() + + test("a holder that has a value keeps it"): + val holder = AtomicCloseable[Probe]() + val first = new Probe + holder.set(first) + val second = new Probe + assert(holder.setIfEmpty(second) == first) + assert(!first.closed) + assert(!second.closed) + + test("a holder that has no value takes the one it is given"): + val holder = AtomicCloseable[Probe]() + val probe = new Probe + assert(holder.setIfEmpty(probe) == probe) + assert(holder.get == probe) + assert(!probe.closed) + + test("a value that loses a race is closed"): + val holder = AtomicCloseable[Probe]() + val winner, loser = new Probe + // the holder fills while this caller builds its own value + val result = holder.setIfEmpty { holder.set(winner); loser } + assert(result == winner) + assert(loser.closed) + assert(!winner.closed) +end AtomicCloseableSpec diff --git a/main/src/main/scala/sbt/internal/CommandExchange.scala b/main/src/main/scala/sbt/internal/CommandExchange.scala index 889c358ca..9ee9b799c 100644 --- a/main/src/main/scala/sbt/internal/CommandExchange.scala +++ b/main/src/main/scala/sbt/internal/CommandExchange.scala @@ -205,19 +205,20 @@ private[sbt] final class CommandExchange { lazy val enableBsp = s.get(bspEnabled).getOrElse(true) lazy val portfile = s.baseDir / "project" / "target" / "active.json" - def onIncomingSocket(socket: Socket, instance: ServerInstance): Unit = { + def onIncomingSocket(socket: AtomicReference[Socket], instance: ServerInstance): Unit = { val name = newNetworkName Terminal.consoleLog(s"new client connected: $name") val channel = new NetworkChannel( name, - socket, + socket.get, auth, instance, handlers, mkAskUser(name), ) subscribe(channel) + AtomicCloseable.release(socket) // i took over } if (server.isEmpty && firstInstance.get) { val h = Hash.halfHashString(IO.toURI(portfile).toString) From f1d9bae56e5ef0c9dc7f42daf7c79db91b87c007 Mon Sep 17 00:00:00 2001 From: Eugene Yokota Date: Sat, 3 Oct 2026 21:17:36 -0700 Subject: [PATCH 3/6] [2.0.x] fix: Fix task output lost by the thin client under CPU load **Problem** Under CPU load, the thin client sometimes loses task output. The channel started reading before it was subscribed, so an early sbt/attach was dropped and the command ran on an unattached channel. **Solution** Start the channel threads after subscribing it. Fixes #9834 Generated-by: Claude Code Co-Authored-By: Claude Opus 5.5 --- .../scala/sbt/internal/CommandExchange.scala | 1 + .../sbt/internal/server/NetworkChannel.scala | 6 ++- .../internal/server/NetworkChannelSpec.scala | 46 ++++++++++++++++--- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/main/src/main/scala/sbt/internal/CommandExchange.scala b/main/src/main/scala/sbt/internal/CommandExchange.scala index 9ee9b799c..36fd5339e 100644 --- a/main/src/main/scala/sbt/internal/CommandExchange.scala +++ b/main/src/main/scala/sbt/internal/CommandExchange.scala @@ -218,6 +218,7 @@ private[sbt] final class CommandExchange { mkAskUser(name), ) subscribe(channel) + channel.start() AtomicCloseable.release(socket) // i took over } if (server.isEmpty && firstInstance.get) { diff --git a/main/src/main/scala/sbt/internal/server/NetworkChannel.scala b/main/src/main/scala/sbt/internal/server/NetworkChannel.scala index 05809670b..341848eeb 100644 --- a/main/src/main/scala/sbt/internal/server/NetworkChannel.scala +++ b/main/src/main/scala/sbt/internal/server/NetworkChannel.scala @@ -965,8 +965,10 @@ final class NetworkChannel( private[sbt] def isAttached: Boolean = attached.get private[sbt] def isInitialized: Boolean = initialized - thread.start() - writeThread.start() + private[sbt] def start(): Unit = + thread.start() + writeThread.start() + } object NetworkChannel { diff --git a/main/src/test/scala/sbt/internal/server/NetworkChannelSpec.scala b/main/src/test/scala/sbt/internal/server/NetworkChannelSpec.scala index 83a043243..7443d40b8 100644 --- a/main/src/test/scala/sbt/internal/server/NetworkChannelSpec.scala +++ b/main/src/test/scala/sbt/internal/server/NetworkChannelSpec.scala @@ -13,7 +13,10 @@ import java.net.{ InetAddress, ServerSocket, Socket } import sbt.{ State, StandardMain } import sbt.internal.util.{ AttributeMap, ConsoleOut, GlobalLogging, MainAppender, Terminal, Util } import sbt.internal.util.Terminal.TerminalImpl +import sbt.internal.protocol.JsonRpcRequestMessage import sbt.protocol.Serialization +import sjsonnew.support.scalajson.unsafe.Parser +import scala.concurrent.duration.* import scala.jdk.CollectionConverters.* import scala.util.Using import verify.BasicTestSuite @@ -78,6 +81,31 @@ object NetworkChannelSpec extends BasicTestSuite: val outcome = whileInterrupted(StandardMain.exchange.removeChannel(channel)) assertSucceededAndStillInterrupted(outcome) + test("an attach sent before the channel is subscribed still attaches the client"): + withServerState: + withChannelThreadsJoined: + withLoopbackConnection: (client, connection) => + client.getOutputStream.write(attachRequest) + client.getOutputStream.flush() + Using.resource(newChannel(connection, Seq(VirtualTerminal.handler))): channel => + Thread.sleep(acceptThreadDelay.toMillis) + StandardMain.exchange.subscribe(channel) + channel.start() + assert(eventually(channel.isAttached), "the attach request was dropped") + + private val acceptThreadDelay = 200.millis + + private def attachRequest: Array[Byte] = + val params = Parser.parseUnsafe("""{"interactive": false}""") + Serialization.serializeRequestMessage( + JsonRpcRequestMessage("2.0", "attach-id", Serialization.attach, Some(params)) + ) + + private def eventually(condition: => Boolean): Boolean = + val deadline = 5.seconds.fromNow + while !condition && deadline.hasTimeLeft() do Thread.sleep(10) + condition + private type Outcome = (thrown: Option[Exception], stillInterrupted: Boolean) private given Using.Releasable[NetworkChannel] = _.shutdown(false) @@ -104,7 +132,7 @@ object NetworkChannelSpec extends BasicTestSuite: private def withAttachedClient[A](test: NetworkChannel => A): A = withServerState: withChannelThreadsJoined: - withLoopbackConnection: connection => + withLoopbackConnection: (_, connection) => Using.resource(attachedChannel(connection))(test) private def withChannelThreadsJoined[A](f: => A): A = @@ -120,26 +148,30 @@ object NetworkChannelSpec extends BasicTestSuite: thread.getName.startsWith("sbt-networkchannel-") || thread.getName.startsWith(s"sbt-$channelName-") - private def attachedChannel(connection: Socket): NetworkChannel = - val channel = new NetworkChannel( + private def newChannel(connection: Socket, handlers: Seq[ServerHandler]): NetworkChannel = + new NetworkChannel( name = channelName, connection = connection, auth = Set.empty, instance = null, - handlers = Nil, + handlers = handlers, mkUIThreadImpl = (_, _) => null, ) + + private def attachedChannel(connection: Socket): NetworkChannel = + val channel = newChannel(connection, Nil) + channel.start() val attachRequestId = "attached-id" channel.setInteractive(attachRequestId, value = false) channel - private def withLoopbackConnection[A](f: Socket => A): A = + private def withLoopbackConnection[A](f: (Socket, Socket) => A): A = val loopback = InetAddress.getLoopbackAddress val anyFreePort = 0 val backlog = 1 Using.resource(new ServerSocket(anyFreePort, backlog, loopback)): server => - Using.resource(new Socket(loopback, server.getLocalPort)): _ => - Using.resource(server.accept())(f) + Using.resource(new Socket(loopback, server.getLocalPort)): client => + Using.resource(server.accept())(f(client, _)) private def withServerState[A](f: => A): A = val previous = StandardMain.exchange.withState(Option(_)) From 8be080a4466182cbcc38e1dd320b0fe4a95deaab Mon Sep 17 00:00:00 2001 From: eugene yokota Date: Sat, 3 Oct 2026 22:05:40 -0400 Subject: [PATCH 4/6] [2.0.x] fix: Use Path.userHome (#9866) **Problem** user.home can return "?" **Solution** This centralize to Path.userHome, which works around the JDK bug. --- .../sbt/internal/librarymanagement/LMSysProp.scala | 13 +++++++++++-- .../lmcoursier/internal/ResolutionParams.scala | 3 ++- .../src/main/scala/sbt/coursierint/LMCoursier.scala | 2 +- main/src/main/scala/sbt/internal/SysProp.scala | 2 +- project/Dependencies.scala | 2 +- .../sbt/internal/inc/ZincComponentCompiler.scala | 9 ++------- 6 files changed, 18 insertions(+), 13 deletions(-) diff --git a/lm-core/src/main/scala/sbt/internal/librarymanagement/LMSysProp.scala b/lm-core/src/main/scala/sbt/internal/librarymanagement/LMSysProp.scala index 209e0cd11..6eb5e4b96 100644 --- a/lm-core/src/main/scala/sbt/internal/librarymanagement/LMSysProp.scala +++ b/lm-core/src/main/scala/sbt/internal/librarymanagement/LMSysProp.scala @@ -9,10 +9,11 @@ package sbt package internal package librarymanagement +import java.io.File import java.util.Locale import scala.util.control.NonFatal -object LMSysProp { +object LMSysProp: def booleanOpt(name: String): Option[Boolean] = sys.props.get(name).flatMap { x => x.toLowerCase(Locale.ENGLISH) match { @@ -63,4 +64,12 @@ object LMSysProp { lazy val maxPublishAttempts: Int = java.lang.Integer.getInteger("sbt.repository.publish.attempts", 3) -} + /** + * Ivy home directory: `sbt.ivy.home`, then `ivy.home`, else `user.home/.ivy2`. + */ + lazy val defaultIvyHome: File = + def prop(name: String): Option[String] = sys.props.get(name).map(_.trim).filter(_.nonEmpty) + prop("sbt.ivy.home").orElse(prop("ivy.home")) match + case Some(home) => File(home) + case None => File(sbt.io.Path.userHome, ".ivy2") +end LMSysProp diff --git a/lm-coursier/src/main/scala/lmcoursier/internal/ResolutionParams.scala b/lm-coursier/src/main/scala/lmcoursier/internal/ResolutionParams.scala index 1c91acdd4..2ee909b0f 100644 --- a/lm-coursier/src/main/scala/lmcoursier/internal/ResolutionParams.scala +++ b/lm-coursier/src/main/scala/lmcoursier/internal/ResolutionParams.scala @@ -8,6 +8,7 @@ import coursier.core.* import coursier.params.rule.Strict import lmcoursier.FallbackDependency import lmcoursier.definitions.ToCoursier +import sbt.internal.librarymanagement.LMSysProp import coursier.util.Task import scala.collection.mutable @@ -114,7 +115,7 @@ object ResolutionParams { val ivyHome = sys.props .get("ivy.home") .orElse(ivyHomeOpt.map(_.getAbsoluteFile.toURI.getPath)) - .getOrElse(new File(sys.props("user.home")).toURI.getPath + ".ivy2") + .getOrElse(LMSysProp.defaultIvyHome.getAbsoluteFile.toURI.getPath) val sbtIvyHome = sys.props.getOrElse( "sbt.ivy.home", diff --git a/main/src/main/scala/sbt/coursierint/LMCoursier.scala b/main/src/main/scala/sbt/coursierint/LMCoursier.scala index 70233ac7e..faa3c2664 100644 --- a/main/src/main/scala/sbt/coursierint/LMCoursier.scala +++ b/main/src/main/scala/sbt/coursierint/LMCoursier.scala @@ -61,7 +61,7 @@ object LMCoursier { sys.env .get("LOCALAPPDATA") .map(absoluteFile) - .getOrElse(absoluteFile(sys.props("user.home")) / "AppData" / "Local") + .getOrElse(sbt.io.Path.userHome.getAbsoluteFile / "AppData" / "Local") base / "Coursier" / "Cache" / "v1" } sys.props diff --git a/main/src/main/scala/sbt/internal/SysProp.scala b/main/src/main/scala/sbt/internal/SysProp.scala index 41369c245..ae90de146 100644 --- a/main/src/main/scala/sbt/internal/SysProp.scala +++ b/main/src/main/scala/sbt/internal/SysProp.scala @@ -202,7 +202,7 @@ object SysProp: def secondaryIdleTimeoutSec: Long = long("sbt.server.secondaryIdleTimeout", 600L) private def file(value: String): File = new File(value) - private def home: File = file(sys.props("user.home")) + private def home: File = sbt.io.Path.userHome /** * Default directory for global sbt config (plugins, settings). Respects XDG Base Directory diff --git a/project/Dependencies.scala b/project/Dependencies.scala index de79d1712..5e3108724 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -12,7 +12,7 @@ object Dependencies { sys.env.get("BUILD_VERSION") orElse sys.props.get("sbt.build.version") // sbt modules - val ioVersion = nightlyVersion.getOrElse("1.13.4") + val ioVersion = nightlyVersion.getOrElse("1.13.5") val zincVersion = nightlyVersion.getOrElse("2.0.4") private val sbtIO = "org.scala-sbt" %% "io" % ioVersion diff --git a/zinc-lm-integration/src/main/scala/sbt/internal/inc/ZincComponentCompiler.scala b/zinc-lm-integration/src/main/scala/sbt/internal/inc/ZincComponentCompiler.scala index 9ea5d12e8..26d83f852 100644 --- a/zinc-lm-integration/src/main/scala/sbt/internal/inc/ZincComponentCompiler.scala +++ b/zinc-lm-integration/src/main/scala/sbt/internal/inc/ZincComponentCompiler.scala @@ -11,7 +11,7 @@ package internal package inc import sbt.internal.inc.classpath.ClasspathUtil -import sbt.internal.librarymanagement.JsonUtil +import sbt.internal.librarymanagement.{ JsonUtil, LMSysProp } import sbt.internal.util.{ BufferedLogger, FullLogger } import sbt.io.IO import sbt.librarymanagement.* @@ -171,12 +171,7 @@ private[sbt] object ZincComponentCompiler { new ZincCompilerBridgeProvider(None, manager, dependencyResolution, scalaJarsTarget) private final val LocalIvy = - (sys.props.get("sbt.ivy.home") match { - case Some(home) => - if (home.endsWith("/")) home - else home + "/" - case _ => s"$${user.home}/.ivy2/" - }) + "local/" + Resolver.localBasePattern + Resolver.resolvePattern(LMSysProp.defaultIvyHome.toString, "local/" + Resolver.localBasePattern) final val LocalResolver: Resolver = { val toUse = Vector(LocalIvy) From cfba74f6ee0590cd791049fe2da655bd72a3e356 Mon Sep 17 00:00:00 2001 From: Eugene Date: Sun, 4 Oct 2026 05:12:50 +0200 Subject: [PATCH 5/6] [2.0.x] fix: Read all forked test output before reporting the fork's exit (#9860) **Problem** When a forked test JVM exits, WorkerProxy.watch calls notifyExit right away, and on exit code 0 that completes the run. If the response reader has not consumed the rest of the stream yet, the remaining events, logs and the final response are dropped, and the build passes with failing tests unreported. Slow listeners, or several forks at once, make this likely. **Solution** Track when the response stream reaches end of input (the socket reader for Tcp/Ipc, stdout for Stdio), and have the watch thread wait for it, with a bound, before calling notifyExit. This is what sbt 1.x did by joining the acceptor thread. The new scripted test uses a listener that sleeps per event, and fails without this change. Fixes #9859 Generated-by: Claude Opus 5.5 Co-authored-by: Eugene <25910599+eugkhp@users.noreply.github.com> --- .../scala/sbt/internal/WorkerExchange.scala | 26 ++++++++++++++++--- .../tests/fork-slow-listener/build.sbt | 11 ++++++++ .../src/test/scala/ManyTests.scala | 2 ++ .../sbt-test/tests/fork-slow-listener/test | 1 + 4 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 sbt-app/src/sbt-test/tests/fork-slow-listener/build.sbt create mode 100644 sbt-app/src/sbt-test/tests/fork-slow-listener/src/test/scala/ManyTests.scala create mode 100644 sbt-app/src/sbt-test/tests/fork-slow-listener/test diff --git a/main-actions/src/main/scala/sbt/internal/WorkerExchange.scala b/main-actions/src/main/scala/sbt/internal/WorkerExchange.scala index fe22ca67c..eae02d416 100644 --- a/main-actions/src/main/scala/sbt/internal/WorkerExchange.scala +++ b/main-actions/src/main/scala/sbt/internal/WorkerExchange.scala @@ -24,8 +24,9 @@ import scala.sys.process.{ BasicIO, Process, ProcessIO } import scala.collection.mutable import scala.collection.concurrent.TrieMap import scala.collection.mutable.ListBuffer -import scala.concurrent.{ Await, Promise } +import scala.concurrent.{ Await, Future, Promise } import scala.concurrent.duration.* +import scala.util.Try import scala.util.control.NonFatal object WorkerExchange: @@ -83,11 +84,13 @@ object WorkerExchange: IO.classLocationPath(classOf[Gson]).toFile, ) val inputRef = Promise[OutputStream]() + val responsesRead = Promise[Unit]() def runAccepter(out: OutputStream, in: InputStream): Unit = inputRef.success(out) val scanner = Scanner(in, "UTF-8") while scanner.hasNextLine() do notifyListeners(scanner.nextLine()) - val (connArgs, closer): (Seq[String], Option[AutoCloseable]) = connectionType match + responsesRead.trySuccess(()) + val (connArgs, closer) = connectionType match case WorkerConnection.Tcp => val serverSocket = Retry(ServerSocket(0, 1, loopback)) val accepter = Thread(() => { @@ -130,20 +133,24 @@ object WorkerExchange: val onStdoutLine: String => Unit = connectionType match case WorkerConnection.Stdio => notifyListeners case _ => (line) => scala.Console.out.println(line) + def readStdout(stdout: InputStream): Unit = + BasicIO.processFully(onStdoutLine)(stdout) + if connectionType == WorkerConnection.Stdio then responsesRead.trySuccess(()) val processIo = ProcessIO( in = (input) => (connectionType match case WorkerConnection.Stdio => inputRef.success(input) case _ => () ), - out = BasicIO.processFully(onStdoutLine), + out = readStdout, err = BasicIO.processFully((line) => scala.Console.err.println(line)), ) val forkWithIo = fo.withOutputStrategy(OutputStrategy.CustomInputOutput(processIo)) val p = Fork.java.fork(forkWithIo, options) val forkTimeout = fo.connectionTimeout.getOrElse(30.seconds) val input = Await.result(inputRef.future, forkTimeout) - WorkerProxy(input, p, options, closer) + WorkerProxy(input, p, options, closer, responsesRead.future) + end startWorker /** Generates a fresh path suitable for binding a `WorkerConnection.Ipc` socket. */ def newIpcSocketPath(): NioPath = @@ -178,6 +185,7 @@ class WorkerProxy( val process: Process, val options: Seq[String], closer: Option[AutoCloseable], + responsesRead: Future[Unit], ) extends AutoCloseable: lazy val inputStream = PrintStream(input) def close(): Unit = @@ -194,11 +202,21 @@ class WorkerProxy( val watch = Thread(() => { while process.isAlive() do Thread.sleep(100) + Try(Await.ready(responsesRead, WorkerProxy.responseDrainTimeout)) WorkerExchange.listeners.foreach(_.notifyExit(process)) }) watch.start() end WorkerProxy +object WorkerProxy: + /** + * How long to keep reading a worker's responses after it exits, before reporting the exit. + * The reader reaches end of stream as soon as it has consumed everything the worker wrote; + * the bound only matters for a worker that died before connecting. + */ + private val responseDrainTimeout = 10.seconds +end WorkerProxy + abstract class WorkerResponseListener extends Function1[String, Unit]: def notifyExit(p: Process): Unit diff --git a/sbt-app/src/sbt-test/tests/fork-slow-listener/build.sbt b/sbt-app/src/sbt-test/tests/fork-slow-listener/build.sbt new file mode 100644 index 000000000..9f889b515 --- /dev/null +++ b/sbt-app/src/sbt-test/tests/fork-slow-listener/build.sbt @@ -0,0 +1,11 @@ +scalaVersion := "3.9.0" +Test / fork := true +libraryDependencies += "org.scalameta" %% "munit" % "1.0.4" % Test + +Test / testListeners += new TestsListener: + def doInit(): Unit = () + def startGroup(name: String): Unit = () + def testEvent(event: TestEvent): Unit = Thread.sleep(50) + def endGroup(name: String, t: Throwable): Unit = () + def endGroup(name: String, result: TestResult): Unit = () + def doComplete(finalResult: TestResult): Unit = () diff --git a/sbt-app/src/sbt-test/tests/fork-slow-listener/src/test/scala/ManyTests.scala b/sbt-app/src/sbt-test/tests/fork-slow-listener/src/test/scala/ManyTests.scala new file mode 100644 index 000000000..9f1ae0f39 --- /dev/null +++ b/sbt-app/src/sbt-test/tests/fork-slow-listener/src/test/scala/ManyTests.scala @@ -0,0 +1,2 @@ +class ManyTests extends munit.FunSuite: + (1 to 40).foreach(i => test(s"test $i")(assert(i < 40))) diff --git a/sbt-app/src/sbt-test/tests/fork-slow-listener/test b/sbt-app/src/sbt-test/tests/fork-slow-listener/test new file mode 100644 index 000000000..5a9f22365 --- /dev/null +++ b/sbt-app/src/sbt-test/tests/fork-slow-listener/test @@ -0,0 +1 @@ +-> test From 8e2d40c940d2a8df60a4efb65daa4e74d6e14dc1 Mon Sep 17 00:00:00 2001 From: Eugene Yokota Date: Mon, 5 Oct 2026 14:17:15 -0400 Subject: [PATCH 6/6] [2.0.x] Mima --- build.sbt | 1 + 1 file changed, 1 insertion(+) diff --git a/build.sbt b/build.sbt index 3e7aa0ad3..542ad398c 100644 --- a/build.sbt +++ b/build.sbt @@ -620,6 +620,7 @@ lazy val actionsProj = (project in file("main-actions")) // an internal (sbt.internal) type not meant for external consumption. exclude[DirectMissingMethodProblem]("sbt.internal.WorkerConnection.valueOf"), exclude[DirectMissingMethodProblem]("sbt.internal.WorkerConnection.values"), + exclude[DirectMissingMethodProblem]("sbt.internal.*"), ), ) .dependsOn(lmCore)