From 68f915cf0c4a85c6abbca38584c62b4035cbde67 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Thu, 14 May 2026 10:57:40 +0200 Subject: [PATCH] Don't check for updates on custom repositories for `scala-lang.org` dependencies; add timeout for other cases --- .../tests/ActionableDiagnosticTests.scala | 282 ++++++++++++++++++ .../ActionableDependencyHandler.scala | 70 ++++- .../scala/build/options/BuildOptions.scala | 16 +- 3 files changed, 360 insertions(+), 8 deletions(-) diff --git a/modules/build/src/test/scala/scala/build/tests/ActionableDiagnosticTests.scala b/modules/build/src/test/scala/scala/build/tests/ActionableDiagnosticTests.scala index 9e4913399a..fafe5345be 100644 --- a/modules/build/src/test/scala/scala/build/tests/ActionableDiagnosticTests.scala +++ b/modules/build/src/test/scala/scala/build/tests/ActionableDiagnosticTests.scala @@ -1,14 +1,23 @@ package scala.build.tests import com.eed3si9n.expecty.Expecty.expect +import com.sun.net.httpserver.HttpServer +import coursier.cache.FileCache +import coursier.util.Task import coursier.version.Version +import java.net.InetSocketAddress +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.{ConcurrentLinkedQueue, Executors} + import scala.build.Ops.* import scala.build.Position.File import scala.build.actionable.ActionableDiagnostic.* import scala.build.actionable.ActionablePreprocessor import scala.build.options.{BuildOptions, InternalOptions, SuppressWarningOptions} import scala.build.{BuildThreads, Directories, LocalRepo} +import scala.jdk.CollectionConverters.* class ActionableDiagnosticTests extends TestUtil.ScalaCliBuildSuite { @@ -23,6 +32,57 @@ class ActionableDiagnosticTests extends TestUtil.ScalaCliBuildSuite { def path2url(p: os.Path): String = p.toIO.toURI.toURL.toString + /** Minimal HTTP Maven repo: records every request path, then optional delay on + * `maven-metadata.xml` when `delayWhen()` is true, then serves a body from `responses` or 404. + */ + def withRecordingMavenRepo( + responses: Map[String, Array[Byte]], + delayOnMetadataMs: Long = 0, + delayWhen: () => Boolean = () => false + )(body: (String, ConcurrentLinkedQueue[String]) => Unit): Unit = + val recorded = new ConcurrentLinkedQueue[String]() + val server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0) + server.setExecutor(Executors.newCachedThreadPool()) + server.createContext( + "/", + ex => { + val path = ex.getRequestURI.getPath + recorded.offer(path) + if delayOnMetadataMs > 0 && delayWhen() && path.endsWith("maven-metadata.xml") then + Thread.sleep(delayOnMetadataMs) + responses.get(path) match + case Some(bytes) => + ex.getResponseHeaders.set("Content-Type", "application/xml") + ex.sendResponseHeaders(200, bytes.length) + ex.getResponseBody.write(bytes) + ex.getResponseBody.close() + case None => + ex.sendResponseHeaders(404, -1) + ex.close() + } + ) + server.start() + try + val base = s"http://127.0.0.1:${server.getAddress.getPort}/" + body(base, recorded) + finally server.stop(0) + + def clearRecordedPaths(q: ConcurrentLinkedQueue[String]): Unit = + while q.poll() != null do () + + /** `Build.build` populates Coursier's disk cache with `maven-metadata.xml`. A later + * `cache.versions` during actionable checks often reuses it and never hits HTTP again, so + * recording-server assertions cannot tell pre-fix from post-fix. Use an empty cache directory + * for passes where we assert on network traffic. + */ + def buildOptionsWithEmptyCoursierCache(opts: BuildOptions): BuildOptions = + val dir = os.temp.dir(prefix = "scala-cli-actionable-diagnostic-coursier-") + opts.copy(internal = + opts.internal.copy( + cache = Some(FileCache[Task]().withLocation(dir.toString)) + ) + ) + test("using outdated os-lib") { val dependencyOsLib = "com.lihaoyi::os-lib:0.7.8" val testInputs = TestInputs( @@ -263,4 +323,226 @@ class ActionableDiagnosticTests extends TestUtil.ScalaCliBuildSuite { expect(testLibDiagnosticOpt.isEmpty) } } + + test("actionable outdated check for toolkit skips user repository metadata") { + val meta = + """ + | + | org.scala-lang + | toolkit_3 + | + | 99.0.0 + | 99.0.0 + | + | 0.3.0 + | 99.0.0 + | + | + | + |""".stripMargin.getBytes("UTF-8") + val responses = Map("/org/scala-lang/toolkit_3/maven-metadata.xml" -> meta) + withRecordingMavenRepo(responses)((repoUrl, recorded) => + val testInputs = TestInputs( + os.rel / "Foo.scala" -> + """//> using toolkit 0.3.0 + | + |object Hello extends App { + | println("Hello") + |} + |""".stripMargin + ) + val withRepo = baseOptions.copy( + classPathOptions = + baseOptions.classPathOptions.copy(extraRepositories = Seq(repoUrl)) + ) + testInputs.withBuild(withRepo, buildThreads, None, actionableDiagnostics = true) { + (_, _, maybeBuild) => + val build = maybeBuild.orThrow + clearRecordedPaths(recorded) + ActionablePreprocessor + .generateActionableDiagnostics(buildOptionsWithEmptyCoursierCache(build.options)) + .orThrow + val paths = recorded.asScala.toSeq + expect(!paths.exists(_.contains("toolkit_3/maven-metadata.xml"))) + } + ) + } + + test("actionable outdated check for org.scala-lang skips user repository metadata") { + val u = UUID.randomUUID().toString.replace("-", "") + val art = s"scala_cli_fake_$u" + val meta = + s""" + | + | org.scala-lang + | ${art}_3 + | + | 99.0.0 + | 99.0.0 + | + | 0.1.0 + | 99.0.0 + | + | + | + |""".stripMargin.getBytes("UTF-8") + val pom = + s""" + | + | org.scala-lang + | ${art}_3 + | 0.1.0 + |""".stripMargin.getBytes("UTF-8") + val responses = Map( + s"/org/scala-lang/${art}_3/maven-metadata.xml" -> meta, + s"/org/scala-lang/${art}_3/0.1.0/${art}_3-0.1.0.pom" -> pom + ) + withRecordingMavenRepo(responses)((repoUrl, recorded) => + val testInputs = TestInputs( + os.rel / "Foo.scala" -> + s"""//> using dep org.scala-lang::$art:0.1.0 + | + |object Hello extends App { + | println("Hello") + |} + |""".stripMargin + ) + val withRepo = baseOptions.copy( + classPathOptions = + baseOptions.classPathOptions.copy(extraRepositories = Seq(repoUrl)) + ) + testInputs.withBuild(withRepo, buildThreads, None, actionableDiagnostics = true) { + (_, _, maybeBuild) => + val build = maybeBuild.orThrow + clearRecordedPaths(recorded) + ActionablePreprocessor + .generateActionableDiagnostics(buildOptionsWithEmptyCoursierCache(build.options)) + .orThrow + val paths = recorded.asScala.toSeq + expect(!paths.exists(p => p.contains(s"${art}_3/") && p.contains("maven-metadata.xml"))) + } + ) + } + + test("actionable outdated check still consults user repository for other organizations") { + val u = UUID.randomUUID().toString.replace("-", "") + val art = s"scala_cli_fake_$u" + val meta = + s""" + | + | test-org + | ${art}_3 + | + | 99.0.0 + | 99.0.0 + | + | 0.1.0 + | 99.0.0 + | + | + | + |""".stripMargin.getBytes("UTF-8") + val pom = + s""" + | + | test-org + | ${art}_3 + | 0.1.0 + |""".stripMargin.getBytes("UTF-8") + val responses = Map( + s"/test-org/${art}_3/maven-metadata.xml" -> meta, + s"/test-org/${art}_3/0.1.0/${art}_3-0.1.0.pom" -> pom + ) + withRecordingMavenRepo(responses)((repoUrl, recorded) => + val testInputs = TestInputs( + os.rel / "Foo.scala" -> + s"""//> using dep test-org::$art:0.1.0 + | + |object Hello extends App { + | println("Hello") + |} + |""".stripMargin + ) + val withRepo = baseOptions.copy( + classPathOptions = + baseOptions.classPathOptions.copy(extraRepositories = Seq(repoUrl)) + ) + testInputs.withBuild(withRepo, buildThreads, None, actionableDiagnostics = true) { + (_, _, maybeBuild) => + val build = maybeBuild.orThrow + val paths = recorded.asScala.toSeq + expect(paths.exists(p => p.contains(s"${art}_3/") && p.contains("maven-metadata.xml"))) + val updateDiagnostics = + ActionablePreprocessor.generateActionableDiagnostics(build.options).orThrow + val dOpt = updateDiagnostics.collectFirst { + case diagnostic: ActionableDependencyUpdateDiagnostic => diagnostic + } + expect(dOpt.nonEmpty) + expect(dOpt.get.newVersion == "99.0.0") + } + ) + } + + test("actionable outdated check times out slow user repository") { + val u = UUID.randomUUID().toString.replace("-", "") + val art = s"scala_cli_fake_$u" + val meta = + s""" + | + | test-org + | ${art}_3 + | + | 0.2.0 + | 0.2.0 + | + | 0.1.0 + | 0.2.0 + | + | + | + |""".stripMargin.getBytes("UTF-8") + val pom = + s""" + | + | test-org + | ${art}_3 + | 0.1.0 + |""".stripMargin.getBytes("UTF-8") + val responses = Map( + s"/test-org/${art}_3/maven-metadata.xml" -> meta, + s"/test-org/${art}_3/0.1.0/${art}_3-0.1.0.pom" -> pom + ) + val slowAfterClear = new AtomicBoolean(false) + withRecordingMavenRepo( + responses, + delayOnMetadataMs = 30_000L, + delayWhen = () => slowAfterClear.get() + )((repoUrl, recorded) => + val testInputs = TestInputs( + os.rel / "Foo.scala" -> + s"""//> using dep test-org::$art:0.1.0 + | + |object Hello extends App { + | println("Hello") + |} + |""".stripMargin + ) + val withRepo = baseOptions.copy( + classPathOptions = + baseOptions.classPathOptions.copy(extraRepositories = Seq(repoUrl)) + ) + testInputs.withBuild(withRepo, buildThreads, None, actionableDiagnostics = true) { + (_, _, maybeBuild) => + val build = maybeBuild.orThrow + clearRecordedPaths(recorded) + slowAfterClear.set(true) + val t0 = System.nanoTime() + ActionablePreprocessor + .generateActionableDiagnostics(buildOptionsWithEmptyCoursierCache(build.options)) + .orThrow + val elapsedMs = (System.nanoTime() - t0) / 1_000_000 + expect(elapsedMs < 15_000) + } + ) + } } diff --git a/modules/options/src/main/scala/scala/build/actionable/ActionableDependencyHandler.scala b/modules/options/src/main/scala/scala/build/actionable/ActionableDependencyHandler.scala index fd538d882a..6db1c79a9c 100644 --- a/modules/options/src/main/scala/scala/build/actionable/ActionableDependencyHandler.scala +++ b/modules/options/src/main/scala/scala/build/actionable/ActionableDependencyHandler.scala @@ -1,6 +1,6 @@ package scala.build.actionable import coursier.cache.FileCache -import coursier.core.Repository +import coursier.core.{Repository, Versions as CoreVersions} import coursier.util.Task import coursier.version.{Latest, Version} import dependency.* @@ -13,6 +13,9 @@ import scala.build.internal.Util.* import scala.build.options.BuildOptions import scala.build.options.ScalaVersionUtil.versions import scala.build.{Logger, Positioned} +import scala.concurrent.duration.{DurationInt, FiniteDuration} +import scala.concurrent.{Await, ExecutionContext, Future, TimeoutException} +import scala.util.control.NonFatal case object ActionableDependencyHandler extends ActionableHandler[ActionableDependencyUpdateDiagnostic] { @@ -68,6 +71,20 @@ case object ActionableDependencyHandler /** Versions like 'latest.*': 'latest.release', 'latest.integration', 'latest.stable' */ private def isLatestSyntaxVersion(version: String): Boolean = Latest(version).nonEmpty + + /** Artifacts under this organization are only published to public Maven Central (not to user + * `//> using repository` hosts). Listing versions against private repos can hang or add large + * latency. + */ + private def isScalaLangOrganization(dep: AnyDependency): Boolean = + dep.module.organization == Constants.toolkitOrganization + + private val perRepoVersionsTimeout: FiniteDuration = 5.seconds + + private def mergeCoreVersions(parts: Seq[CoreVersions]): CoreVersions = + val mergedAvailable = parts.flatMap(_.available0).distinctBy(_.asString).toList + CoreVersions.empty.withAvailable0(mergedAvailable) + private def findLatestVersion( buildOptions: BuildOptions, setting: Positioned[AnyDependency], @@ -77,13 +94,54 @@ case object ActionableDependencyHandler val scalaParams: Option[ScalaParameters] = value(buildOptions.scalaParams) val cache: FileCache[Task] = buildOptions.finalCache val csModule: coursier.core.Module = value(dependency.toCs(scalaParams)).module - val repositories: Seq[Repository] = value(buildOptions.finalRepositories) + val includeUserExtraRepositories = !isScalaLangOrganization(dependency) + val repositories: Seq[Repository] = + value(buildOptions.finalRepositories(includeUserExtraRepositories)) + + given ExecutionContext = ExecutionContext.global + + val perRepoParts = try + Await.result( + Future.sequence(repositories.map { repo => + val label = repo.toString.take(200) + Future { + val listing = + Future(cache.versions(csModule, Seq(repo)).versions)(using ExecutionContext.global) + try Await.result(listing, perRepoVersionsTimeout) + catch { + case _: TimeoutException => + loggerOpt.foreach(_.debug( + s"Timeout listing versions for ${dependency.render} from repository $label (after $perRepoVersionsTimeout)" + )) + CoreVersions.empty + case NonFatal(e) => + loggerOpt.foreach(_.debug( + s"Failed listing versions for ${dependency.render} from repository $label: ${e.getMessage}" + )) + CoreVersions.empty + } + }(using ExecutionContext.global) + }), + perRepoVersionsTimeout + 3.seconds + ) + catch { + case _: TimeoutException => + loggerOpt.foreach(_.debug( + s"Timeout traversing repositories for ${dependency.render} (after ${perRepoVersionsTimeout + + 3.seconds})" + )) + Seq.empty[CoreVersions] + case NonFatal(e) => + loggerOpt.foreach(_.debug( + s"Failed traversing repositories for ${dependency.render}: ${e.getMessage}" + )) + Seq.empty[CoreVersions] + } - val latestVersionOpt = cache.versions(csModule, repositories) - .versions - .latest(Latest.Stable) + val mergedVersions = mergeCoreVersions(perRepoParts) + val latestVersionOpt = mergedVersions.latest(Latest.Stable) - if (latestVersionOpt.isEmpty) + if latestVersionOpt.isEmpty then loggerOpt.foreach(_.diagnostic( s"No latest version found for ${dependency.render}", Severity.Warning, diff --git a/modules/options/src/main/scala/scala/build/options/BuildOptions.scala b/modules/options/src/main/scala/scala/build/options/BuildOptions.scala index aa96da1d41..404de4a25b 100644 --- a/modules/options/src/main/scala/scala/build/options/BuildOptions.scala +++ b/modules/options/src/main/scala/scala/build/options/BuildOptions.scala @@ -261,7 +261,16 @@ final case class BuildOptions( private val scala2NightlyRepo = Seq(coursier.Repositories.scalaIntegration.root) private val scala3NightlyRepo = Seq(RepositoryUtils.scala3NightlyRepository.root) - def finalRepositories: Either[BuildException, Seq[Repository]] = either { + def finalRepositories: Either[BuildException, Seq[Repository]] = + finalRepositories(includeUserExtraRepositories = true) + + /** @param includeUserExtraRepositories + * when false, repositories from `//> using repository` are omitted (nightly, internal local, + * and snapshot repositories are kept). + */ + def finalRepositories( + includeUserExtraRepositories: Boolean + ): Either[BuildException, Seq[Repository]] = either { val maybeSv = scalaOptions.scalaVersion .map(_.asString) .orElse(scalaOptions.defaultScalaVersion) @@ -277,7 +286,10 @@ final case class BuildOptions( RepositoryUtils.scala3NightlyRepository ) else Nil - val extraRepositories = classPathOptions.extraRepositories.filterNot(_ == "snapshots") + val userExtraRepositories = classPathOptions.extraRepositories.filterNot(_ == "snapshots") + val extraRepositories = + if includeUserExtraRepositories then userExtraRepositories + else Nil val repositories = nightlyRepos ++ extraRepositories ++