Skip to content

Commit 80ff26e

Browse files
authored
Cycle detection in moduleDeps and compileModuleDeps (#2790)
The problem: We currently don't detect cycles in transitive `moduleDeps` and `compileModuleDeps`. Cycles in these result in a `StackOverflowError`. To solve this, we first need to detect cycles. This is the newly added `ModuleUtils.recursive` method. Instead of `moduleDeps` we use the `moduleDepsChecked` in some places to ensure we checked for cycles before accessing module dependencies. Unfortunately, we can't easily report the detected cycle at evaluation time, as `moduleDeps` needs to be evaluated before we can create a task graph (via `T.traverse`), hence we need an alternative way to propagate the exception. The current approach throws an `BuildScriptException` which itself derives from `mill.api.MillException`. In `MillMain` as well as `MillClientMain`, our central entry points to Mill, we catch the `MillException` and report the error to the user. Pull request: #2790
1 parent f511656 commit 80ff26e

File tree

10 files changed

+168
-27
lines changed

10 files changed

+168
-27
lines changed

contrib/bloop/src/mill/contrib/bloop/BloopImpl.scala

+3-2
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ class BloopImpl(ev: () => Evaluator, wd: os.Path) extends ExternalModule { outer
201201
// //////////////////////////////////////////////////////////////////////////
202202

203203
val classpath = T.task {
204-
val depModules = (module.compileModuleDeps ++ module.recursiveModuleDeps).distinct
204+
val depModules = (module.compileModuleDepsChecked ++ module.recursiveModuleDeps).distinct
205205
// dep modules ++ ivy deps ++ unmanaged
206206
depModules.map(classes) ++
207207
module.resolvedIvyDeps().map(_.path) ++
@@ -408,7 +408,8 @@ class BloopImpl(ev: () => Evaluator, wd: os.Path) extends ExternalModule { outer
408408
sources = mSources,
409409
sourcesGlobs = None,
410410
sourceRoots = None,
411-
dependencies = (module.moduleDeps ++ module.compileModuleDeps).map(name).toList,
411+
dependencies =
412+
(module.moduleDepsChecked ++ module.compileModuleDepsChecked).map(name).toList,
412413
classpath = classpath().map(_.toNIO).toList,
413414
out = out(module).toNIO,
414415
classesDir = classes(module).toNIO,

main/api/src/mill/api/MillException.scala

+3
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,6 @@ package mill.api
55
* @param msg The error message, to be displayed to the user.
66
*/
77
class MillException(msg: String) extends Exception(msg)
8+
9+
class BuildScriptException(msg: String)
10+
extends MillException("Build script contains errors:\n" + msg)

main/define/src/mill/define/Task.scala

+1-1
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,7 @@ object Target extends Applicative.Applyer[Task, Task, Result, mill.api.Ctx] {
354354

355355
val taskIsPrivate = isPrivateTargetOption(c)
356356

357-
val lhs = Applicative.impl0[Task, T, mill.api.Ctx](c)(reify(Result.Success(t.splice)).tree)
357+
val lhs = Applicative.impl0[Task, T, mill.api.Ctx](c)(reify(Result.create(t.splice)).tree)
358358

359359
mill.moduledefs.Cacher.impl0[Target[T]](c)(
360360
reify(

scalalib/src/mill/scalalib/GenIdeaImpl.scala

+2-2
Original file line numberDiff line numberDiff line change
@@ -554,8 +554,8 @@ case class GenIdeaImpl(
554554
val libNames = Strict.Agg.from(sanizedDeps).iterator.toSeq
555555

556556
val depNames = Strict.Agg
557-
.from(mod.moduleDeps.map((_, None)) ++
558-
mod.compileModuleDeps.map((_, Some("PROVIDED"))))
557+
.from(mod.moduleDepsChecked.map((_, None)) ++
558+
mod.compileModuleDepsChecked.map((_, Some("PROVIDED"))))
559559
.filter(!_._1.skipIdea)
560560
.map { case (v, s) => ScopedOrd(moduleName(moduleLabels(v)), s) }
561561
.iterator

scalalib/src/mill/scalalib/JavaModule.scala

+47-12
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,14 @@ import coursier.parse.JavaOrScalaModule
99
import coursier.parse.ModuleParser
1010
import coursier.util.ModuleMatcher
1111
import mainargs.Flag
12-
import mill.api.Loose.Agg
13-
import mill.define.ModuleRef
12+
import mill.Agg
1413
import mill.api.{JarManifest, PathRef, Result, internal}
15-
import mill.util.Jvm
14+
import mill.define.{Command, ModuleRef, Segment, Task, TaskModule}
15+
import mill.scalalib.internal.ModuleUtils
1616
import mill.scalalib.api.CompilationResult
1717
import mill.scalalib.bsp.{BspBuildTarget, BspModule}
1818
import mill.scalalib.publish.Artifact
19+
import mill.util.Jvm
1920
import os.Path
2021

2122
/**
@@ -122,15 +123,49 @@ trait JavaModule
122123
*/
123124
def javacOptions: T[Seq[String]] = T { Seq.empty[String] }
124125

125-
/** The direct dependencies of this module */
126+
/**
127+
* The direct dependencies of this module.
128+
* @see [[moduleDepschecked]]
129+
*/
126130
def moduleDeps: Seq[JavaModule] = Seq.empty
127131

132+
/** Same as [[moduleDeps]] but checked to not contain cycles. */
133+
final def moduleDepsChecked: Seq[JavaModule] = {
134+
// trigger initialization to check for cycles
135+
recModuleDeps
136+
moduleDeps
137+
}
138+
139+
/** Should only be called from [[moduleDepsChecked]] */
140+
private lazy val recModuleDeps: Seq[JavaModule] =
141+
ModuleUtils.recursive[JavaModule](
142+
(millModuleSegments ++ Seq(Segment.Label("moduleDeps"))).render,
143+
this,
144+
_.moduleDeps
145+
)
146+
128147
/** The compile-only direct dependencies of this module. */
129148
def compileModuleDeps: Seq[JavaModule] = Seq.empty
130149

150+
/** Same as [[compileModuleDeps]] but checked to not contain cycles. */
151+
final def compileModuleDepsChecked: Seq[JavaModule] = {
152+
// trigger initialization to check for cycles
153+
recCompileModuleDeps
154+
compileModuleDeps
155+
}
156+
157+
/** Should only be called from [[compileModuleDeps]] */
158+
private lazy val recCompileModuleDeps: Seq[JavaModule] =
159+
ModuleUtils.recursive[JavaModule](
160+
(millModuleSegments ++ Seq(Segment.Label("compileModuleDeps"))).render,
161+
this,
162+
_.compileModuleDeps
163+
)
164+
131165
/** The direct and indirect dependencies of this module */
132166
def recursiveModuleDeps: Seq[JavaModule] = {
133-
moduleDeps.flatMap(_.transitiveModuleDeps).distinct
167+
// moduleDeps.flatMap(_.transitiveModuleDeps).distinct
168+
recModuleDeps
134169
}
135170

136171
/**
@@ -148,32 +183,32 @@ trait JavaModule
148183
* look at the direct `compileModuleDeps` when assembling this list
149184
*/
150185
def transitiveModuleCompileModuleDeps: Seq[JavaModule] = {
151-
(moduleDeps ++ compileModuleDeps).flatMap(_.transitiveModuleDeps).distinct
186+
(moduleDepsChecked ++ compileModuleDepsChecked).flatMap(_.transitiveModuleDeps).distinct
152187
}
153188

154189
/** The compile-only transitive ivy dependencies of this module and all it's upstream compile-only modules. */
155190
def transitiveCompileIvyDeps: T[Agg[BoundDep]] = T {
156191
// We never include compile-only dependencies transitively, but we must include normal transitive dependencies!
157192
compileIvyDeps().map(bindDependency()) ++
158-
T.traverse(compileModuleDeps)(_.transitiveIvyDeps)().flatten
193+
T.traverse(compileModuleDepsChecked)(_.transitiveIvyDeps)().flatten
159194
}
160195

161196
/**
162197
* Show the module dependencies.
163198
* @param recursive If `true` include all recursive module dependencies, else only show direct dependencies.
164199
*/
165200
def showModuleDeps(recursive: Boolean = false): Command[Unit] = T.command {
166-
val normalDeps = if (recursive) recursiveModuleDeps else moduleDeps
201+
val normalDeps = if (recursive) recursiveModuleDeps else moduleDepsChecked
167202
val compileDeps =
168-
if (recursive) compileModuleDeps.flatMap(_.transitiveModuleDeps).distinct
169-
else compileModuleDeps
203+
if (recursive) compileModuleDepsChecked.flatMap(_.transitiveModuleDeps).distinct
204+
else compileModuleDepsChecked
170205
val deps = (normalDeps ++ compileDeps).distinct
171206
val asString =
172207
s"${if (recursive) "Recursive module"
173208
else "Module"} dependencies of ${millModuleSegments.render}:\n\t${deps
174209
.map { dep =>
175210
dep.millModuleSegments.render ++
176-
(if (compileModuleDeps.contains(dep) || !normalDeps.contains(dep)) " (compile)"
211+
(if (compileModuleDepsChecked.contains(dep) || !normalDeps.contains(dep)) " (compile)"
177212
else "")
178213
}
179214
.mkString("\n\t")}"
@@ -193,7 +228,7 @@ trait JavaModule
193228
*/
194229
def transitiveIvyDeps: T[Agg[BoundDep]] = T {
195230
(ivyDeps() ++ mandatoryIvyDeps()).map(bindDependency()) ++
196-
T.traverse(moduleDeps)(_.transitiveIvyDeps)().flatten
231+
T.traverse(moduleDepsChecked)(_.transitiveIvyDeps)().flatten
197232
}
198233

199234
/**

scalalib/src/mill/scalalib/PublishModule.scala

+4-2
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,10 @@ trait PublishModule extends JavaModule { outer =>
5858
.filter(!ivyPomDeps.contains(_))
5959
.map(_.copy(scope = Scope.Provided))
6060

61-
val modulePomDeps = T.sequence(moduleDeps.map(_.publishSelfDependency))()
62-
val compileModulePomDeps = T.sequence(compileModuleDeps.collect {
61+
val modulePomDeps = T.sequence(moduleDepsChecked.collect {
62+
case m: PublishModule => m.publishSelfDependency
63+
})()
64+
val compileModulePomDeps = T.sequence(compileModuleDepsChecked.collect {
6365
case m: PublishModule => m.publishSelfDependency
6466
})()
6567

scalalib/src/mill/scalalib/internal/JavaModuleUtils.scala

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ object JavaModuleUtils {
1515
found
1616
else {
1717
val subMods = mod.millModuleDirectChildren ++ (mod match {
18-
case jm: JavaModule => jm.moduleDeps ++ jm.compileModuleDeps
18+
case jm: JavaModule => jm.moduleDepsChecked ++ jm.compileModuleDepsChecked
1919
case other => Seq.empty
2020
})
2121
subMods.foldLeft(found ++ Seq(mod)) { (all, mod) => loop(mod, all) }

scalalib/src/mill/scalalib/internal/ModuleUtils.scala

+37
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package mill.scalalib.internal
22

3+
import mill.api.{BuildScriptException, experimental}
34
import mill.define.{Module, Segments}
45

6+
import scala.annotation.tailrec
7+
58
@mill.api.internal
69
object ModuleUtils {
710

@@ -11,4 +14,38 @@ object ModuleUtils {
1114
def moduleDisplayName(module: Module): String = {
1215
(module.millModuleShared.value.getOrElse(Segments()) ++ module.millModuleSegments).render
1316
}
17+
18+
def recursive[T <: Module](name: String, start: T, deps: T => Seq[T]): Seq[T] = {
19+
20+
@tailrec def rec(
21+
seenModules: List[T],
22+
toAnalyze: List[(List[T], List[T])]
23+
): List[T] = {
24+
toAnalyze match {
25+
case Nil => seenModules
26+
case traces :: rest =>
27+
traces match {
28+
case (_, Nil) => rec(seenModules, rest)
29+
case (trace, cand :: remaining) =>
30+
if (trace.contains(cand)) {
31+
// cycle!
32+
val rendered =
33+
(cand :: (cand :: trace.takeWhile(_ != cand)).reverse).mkString(" -> ")
34+
val msg = s"${name}: cycle detected: ${rendered}"
35+
println(msg)
36+
throw new BuildScriptException(msg)
37+
}
38+
rec(
39+
seenModules ++ Seq(cand),
40+
toAnalyze = ((cand :: trace, deps(cand).toList)) :: (trace, remaining) :: rest
41+
)
42+
}
43+
}
44+
}
45+
46+
rec(
47+
seenModules = List(),
48+
toAnalyze = List((List(start), deps(start).toList))
49+
).reverse
50+
}
1451
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package mill.scalalib
2+
3+
import mill.api.BuildScriptException
4+
import mill.util.{TestEvaluator, TestUtil}
5+
import utest.framework.TestPath
6+
import utest.{TestSuite, Tests, compileError, intercept, test, assert}
7+
8+
object CycleTests extends TestSuite {
9+
10+
object CycleBase extends TestUtil.BaseModule {
11+
// See issue: https://github.com/com-lihaoyi/mill/issues/2341
12+
object a extends ScalaModule {
13+
override def moduleDeps = Seq(a)
14+
override def scalaVersion = sys.props.getOrElse("TEST_SCALA_VERSION", ???)
15+
}
16+
object b extends JavaModule {
17+
override def moduleDeps = Seq(c)
18+
object c extends JavaModule {
19+
override def moduleDeps = Seq(d)
20+
}
21+
object d extends JavaModule {
22+
override def moduleDeps = Seq(b)
23+
}
24+
}
25+
object e extends JavaModule {
26+
override def moduleDeps = Seq(b)
27+
}
28+
object f extends JavaModule {
29+
override def compileModuleDeps = Seq(f)
30+
}
31+
}
32+
33+
def workspaceTest[T](m: TestUtil.BaseModule)(t: TestEvaluator => T)(implicit tp: TestPath): T = {
34+
val eval = new TestEvaluator(m)
35+
os.remove.all(m.millSourcePath)
36+
os.remove.all(eval.outPath)
37+
os.makeDir.all(m.millSourcePath / os.up)
38+
t(eval)
39+
}
40+
41+
override def tests: Tests = Tests {
42+
test("moduleDeps") {
43+
test("self-reference") - workspaceTest(CycleBase) { eval =>
44+
val ex = intercept[BuildScriptException] {
45+
eval.apply(CycleBase.a.compile)
46+
}
47+
assert(ex.getMessage.contains("a.moduleDeps: cycle detected: a -> a"))
48+
}
49+
test("cycle-in-deps") - workspaceTest(CycleBase) { eval =>
50+
val ex = intercept[BuildScriptException] {
51+
eval.apply(CycleBase.e.compile)
52+
}
53+
assert(ex.getMessage.contains("e.moduleDeps: cycle detected: b -> b.c -> b.d -> b"))
54+
}
55+
}
56+
test("compileModuleDeps") {
57+
test("self-reference") - workspaceTest(CycleBase) { eval =>
58+
val ex = intercept[BuildScriptException] {
59+
eval.apply(CycleBase.f.compile)
60+
}
61+
assert(ex.getMessage.contains("f.compileModuleDeps: cycle detected: f -> f"))
62+
}
63+
}
64+
}
65+
}

scalalib/test/src/mill/scalalib/bsp/BspModuleTests.scala

+5-7
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ object BspModuleTests extends TestSuite {
3131
}
3232

3333
object InterDeps extends BspBase {
34-
val maxCrossCount = 25
34+
val maxCrossCount = 15
3535
val configs = 1.to(maxCrossCount)
3636
object Mod extends Cross[ModCross](configs)
3737
trait ModCross extends ScalaModule with Cross.Module[Int] {
@@ -105,7 +105,7 @@ object BspModuleTests extends TestSuite {
105105
}
106106
test("interdependencies are fast") {
107107
test("reference (no BSP)") {
108-
def runNoBsp(entry: Int, maxTime: Int) = workspaceTest(MultiBase) { eval =>
108+
def runNoBsp(entry: Int, maxTime: Int) = workspaceTest(InterDeps) { eval =>
109109
val start = System.currentTimeMillis()
110110
val Right((result, evalCount)) = eval.apply(
111111
InterDeps.Mod(entry).compileClasspath
@@ -116,11 +116,10 @@ object BspModuleTests extends TestSuite {
116116
}
117117
test("index 1 (no deps)") { runNoBsp(1, 5000) }
118118
test("index 10") { runNoBsp(10, 30000) }
119-
test("index 20") { runNoBsp(20, 30000) }
120-
test("index 25") { runNoBsp(25, 100000) }
119+
test("index 15") { runNoBsp(15, 30000) }
121120
}
122121
def run(entry: Int, maxTime: Int) = retry(3) {
123-
workspaceTest(MultiBase) { eval =>
122+
workspaceTest(InterDeps) { eval =>
124123
val start = System.currentTimeMillis()
125124
val Right((result, evalCount)) = eval.apply(
126125
InterDeps.Mod(entry).bspCompileClasspath
@@ -132,8 +131,7 @@ object BspModuleTests extends TestSuite {
132131
}
133132
test("index 1 (no deps)") { run(1, 500) }
134133
test("index 10") { run(10, 5000) }
135-
test("index 20") { run(20, 15000) }
136-
test("index 25") { run(25, 50000) }
134+
test("index 15") { run(15, 15000) }
137135
}
138136
}
139137
}

0 commit comments

Comments
 (0)