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
14606c593d
commit
b24ecddbd6
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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