[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
This commit is contained in:
bitloi 2026-03-19 17:52:14 -03:00 committed by GitHub
parent 14606c593d
commit b24ecddbd6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 112 additions and 1 deletions

View File

@ -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

View File

@ -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

View File

@ -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"))

View File

@ -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