mirror of https://github.com/sbt/sbt.git
[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:
parent
972cc30ce1
commit
f5261d9e08
|
|
@ -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
|
||||||
|
|
@ -344,6 +344,22 @@ private[internal] object SbtUpdateReport {
|
||||||
classLoaders: Seq[ClassLoader],
|
classLoaders: Seq[ClassLoader],
|
||||||
): UpdateReport = {
|
): 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 configReports = resolutions.map { (config, subRes) =>
|
||||||
val reports = moduleReports(
|
val reports = moduleReports(
|
||||||
thisModule,
|
thisModule,
|
||||||
|
|
@ -379,7 +395,9 @@ private[internal] object SbtUpdateReport {
|
||||||
}
|
}
|
||||||
|
|
||||||
def conflicts: Seq[coursier.graph.Conflict] =
|
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
|
catch case e: Throwable if missingOk => Nil
|
||||||
val evicted = for {
|
val evicted = for {
|
||||||
c <- conflicts
|
c <- conflicts
|
||||||
|
|
|
||||||
|
|
@ -296,6 +296,19 @@ final class ResolutionSpec extends AnyPropSpec with Matchers {
|
||||||
assert(resolution.isRight)
|
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") {
|
property("resolve licenses from parent poms") {
|
||||||
val dependencies =
|
val dependencies =
|
||||||
Vector(("org.apache.commons" % "commons-compress" % "1.26.2"))
|
Vector(("org.apache.commons" % "commons-compress" % "1.26.2"))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue