From b24ecddbd6327a261945d06001a365abf76d3054 Mon Sep 17 00:00:00 2001 From: bitloi <89318445+bitloi@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:52:14 -0300 Subject: [PATCH] [2.x] fix: Skip Conflict when dependency relocations form a cycle (#8919) **Problem** Coursier graph.Conflict -> DependencyTree relocation can loop forever when Maven/Gradle relocation metadata forms a cycle (sbt-site 1.4.1). **Solution** Detect relocation cycles with the same step logic as Coursier, skip Conflict for affected configs, log once per update. Generated-by: Claude --- .../internal/RelocationCycleDetector.scala | 70 +++++++++++++++++++ .../lmcoursier/internal/SbtUpdateReport.scala | 20 +++++- .../scala/lmcoursier/ResolutionSpec.scala | 13 ++++ .../RelocationCycleDetectorSpec.scala | 10 +++ 4 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 lm-coursier/src/main/scala/lmcoursier/internal/RelocationCycleDetector.scala create mode 100644 lm-coursier/src/test/scala/lmcoursier/internal/RelocationCycleDetectorSpec.scala diff --git a/lm-coursier/src/main/scala/lmcoursier/internal/RelocationCycleDetector.scala b/lm-coursier/src/main/scala/lmcoursier/internal/RelocationCycleDetector.scala new file mode 100644 index 000000000..ec3569ff3 --- /dev/null +++ b/lm-coursier/src/main/scala/lmcoursier/internal/RelocationCycleDetector.scala @@ -0,0 +1,70 @@ +package lmcoursier.internal + +import coursier.core.* +import coursier.core.Resolution as CoreResolution +import coursier.{ Dependency, Resolution } + +import scala.annotation.tailrec + +/** + * Detects cyclic Maven / Gradle relocation chains that make + * `coursier.graph.DependencyTree` loop forever (see sbt#8917, coursier#3578). + * + * Mirrors one step of `coursier.graph.DependencyTree.Node.relocation` so we + * only skip `Conflict` when Coursier would spin on the same graph. + */ +private[internal] object RelocationCycleDetector: + + type Mvc = CoreResolution.ModuleVersionConstraint + + private def oneRelocationStep(resolution: Resolution, dep: Dependency): Option[Mvc] = + resolution.reconciledVersions + .get(dep.module) + .flatMap: recon => + val dep0 = + if dep.versionConstraint == recon then dep + else dep.withVersionConstraint(recon) + resolution.projectCache0 + .get(dep0.moduleVersionConstraint) + .flatMap: + case (_, proj) => + val mavenRelocatedOpt = + if proj.relocated && proj.dependencies0.lengthCompare(1) == 0 then + Some(proj.dependencies0.head._2) + else None + def gradleRelocated: Option[Dependency] = + dep0.variantSelector match + case attr: VariantSelector.AttributesBased => + if proj.variants.isEmpty then None + else + proj.variantFor(attr) match + case Left(_) => None + case Right(variant) => proj.isRelocatedVariant(variant) + case _: VariantSelector.ConfigurationBased => None + mavenRelocatedOpt + .orElse(gradleRelocated) + .map: relocatedTo => + val relocatedTo0 = + if relocatedTo.variantSelector.isEmpty then + relocatedTo.withVariantSelector(dep0.variantSelector) + else relocatedTo + relocatedTo0.moduleVersionConstraint + + /** When true, `coursier.graph.Conflict(resolution)` can run indefinitely. */ + def hasRelocationCycle(resolution: Resolution): Boolean = + resolution.isDone && resolution.conflicts.isEmpty && resolution.errors0.isEmpty && { + val keys = resolution.projectCache0.keySet + keys.exists: start => + @tailrec + def walk(visited: Set[Mvc], cur: Option[Mvc]): Boolean = + cur match + case None => false + case Some(mvc) => + if visited.contains(mvc) then true + else + val dep = Dependency(module = mvc._1, version = mvc._2) + walk(visited + mvc, oneRelocationStep(resolution, dep)) + walk(Set.empty, Some(start)) + } + +end RelocationCycleDetector diff --git a/lm-coursier/src/main/scala/lmcoursier/internal/SbtUpdateReport.scala b/lm-coursier/src/main/scala/lmcoursier/internal/SbtUpdateReport.scala index 85663875b..a4555b89c 100644 --- a/lm-coursier/src/main/scala/lmcoursier/internal/SbtUpdateReport.scala +++ b/lm-coursier/src/main/scala/lmcoursier/internal/SbtUpdateReport.scala @@ -361,6 +361,22 @@ private[internal] object SbtUpdateReport { classLoaders: Seq[ClassLoader], ): UpdateReport = { + val skipConflictDetail: Map[Configuration, Boolean] = + resolutions.map { case (cfg, subRes) => + cfg -> RelocationCycleDetector.hasRelocationCycle(subRes) + }.toMap + val configsWithRelocationCycle = + skipConflictDetail.collect { case (cfg, true) => cfg.value }.toSeq.sorted + if (configsWithRelocationCycle.nonEmpty) { + log.warn( + "Skipping dependency conflict detail for configuration(s) " + + configsWithRelocationCycle.mkString(", ") + + ": cyclic Maven or Gradle relocations in the resolved graph. " + + "Resolution succeeded; eviction detail may be incomplete. " + + "See https://github.com/sbt/sbt/issues/8917" + ) + } + val configReports = resolutions.map { (config, subRes) => val reports = moduleReports( thisModule, @@ -396,7 +412,9 @@ private[internal] object SbtUpdateReport { } def conflicts: Seq[coursier.graph.Conflict] = - try coursier.graph.Conflict(subRes) + try + if (skipConflictDetail.getOrElse(config, false)) Nil + else coursier.graph.Conflict(subRes) catch case e: Throwable if missingOk => Nil val evicted = for { c <- conflicts diff --git a/lm-coursier/src/test/scala/lmcoursier/ResolutionSpec.scala b/lm-coursier/src/test/scala/lmcoursier/ResolutionSpec.scala index e33a33be9..e940a4208 100644 --- a/lm-coursier/src/test/scala/lmcoursier/ResolutionSpec.scala +++ b/lm-coursier/src/test/scala/lmcoursier/ResolutionSpec.scala @@ -296,6 +296,19 @@ final class ResolutionSpec extends AnyPropSpec with Matchers { assert(resolution.isRight) } + property("resolve sbt-site 1.4.1 (sbt #8917)") { + val pluginAttributes = Map("scalaVersion" -> "2.12", "sbtVersion" -> "1.0") + val dependencies = + Vector(("com.typesafe.sbt" % "sbt-site" % "1.4.1").withExtraAttributes(pluginAttributes)) + val coursierModule = module(lmEngine, stubModule, dependencies, Some("2.12.4")) + val resolution = + lmEngine.update(coursierModule, UpdateConfiguration(), UnresolvedWarningConfiguration(), log) + assert(resolution.isRight) + val compileConfig = + resolution.toOption.get.configurations.find(_.configuration == Compile.toConfigRef).get + compileConfig.modules.map(_.module.name) should not be empty + } + property("resolve licenses from parent poms") { val dependencies = Vector(("org.apache.commons" % "commons-compress" % "1.26.2")) diff --git a/lm-coursier/src/test/scala/lmcoursier/internal/RelocationCycleDetectorSpec.scala b/lm-coursier/src/test/scala/lmcoursier/internal/RelocationCycleDetectorSpec.scala new file mode 100644 index 000000000..eb132dd17 --- /dev/null +++ b/lm-coursier/src/test/scala/lmcoursier/internal/RelocationCycleDetectorSpec.scala @@ -0,0 +1,10 @@ +package lmcoursier.internal + +import coursier.Resolution +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class RelocationCycleDetectorSpec extends AnyFunSuite with Matchers: + + test("incomplete resolution is not treated as having a relocation cycle"): + RelocationCycleDetector.hasRelocationCycle(Resolution()) shouldBe false