Skip to content

Commit 2b09680

Browse files
authored
Detect macro dependencies that are missing from the classloader (#20139)
So the situation is basically that `DFiant` and `html.scala` projects do not work "out of the box" with pipelining, and will need to tune their builds if they want some pipelining. However, the compiler reports an error that is not helpful to the user, so in this PR we report a better one. Previously, it was assumed that a missing class (that is valid in current run) during macro evaluation was due to the symbol being defined in the same project. If this condition is met, then compilation is suspended. This assumption breaks when the symbol comes from the classpath, but without a corresponding class file, leading a situation where the same file is always suspended, until it is the only one left, leading to the "cyclic macro dependencies" error. In this case we should assume that the class file will never become available because class path entries are supposed to be immutable. Therefore we should not suspend in this case. This commit therefore detects this situation. Instead of suspending the unit, the compiler aborts the macro expansion, reporting an error that the user will have to deal with - likely by changing the build definition/ In the end, users will see a more actionable error. Note that sbt already automatically disables pipelining on projects that define macros, but this is not useful if the macro itself depends on upstream projects that do not define macros. This is probably a hard problem to detect automatically - so this is good compromise. We also fix `-Xprint-suspension`, which appeared to swallow a lot of diagnostic information. Also make `-Yno-suspended-units` behave better. fixes #20119
2 parents 9d990fb + ab91dfe commit 2b09680

File tree

22 files changed

+227
-37
lines changed

22 files changed

+227
-37
lines changed

compiler/src/dotty/tools/dotc/CompilationUnit.scala

+8-11
Original file line numberDiff line numberDiff line change
@@ -90,22 +90,19 @@ class CompilationUnit protected (val source: SourceFile, val info: CompilationUn
9090
/** Suspends the compilation unit by thowing a SuspendException
9191
* and recording the suspended compilation unit
9292
*/
93-
def suspend()(using Context): Nothing =
93+
def suspend(hint: => String)(using Context): Nothing =
9494
assert(isSuspendable)
9595
// Clear references to symbols that may become stale. No need to call
9696
// `depRecorder.sendToZinc()` since all compilation phases will be rerun
9797
// when this unit is unsuspended.
9898
depRecorder.clear()
9999
if !suspended then
100-
if ctx.settings.YnoSuspendedUnits.value then
101-
report.error(i"Compilation unit suspended $this (-Yno-suspended-units is set)")
102-
else
103-
if (ctx.settings.XprintSuspension.value)
104-
report.echo(i"suspended: $this")
105-
suspended = true
106-
ctx.run.nn.suspendedUnits += this
107-
if ctx.phase == Phases.inliningPhase then
108-
suspendedAtInliningPhase = true
100+
suspended = true
101+
ctx.run.nn.suspendedUnits += this
102+
if ctx.settings.XprintSuspension.value then
103+
ctx.run.nn.suspendedHints += (this -> hint)
104+
if ctx.phase == Phases.inliningPhase then
105+
suspendedAtInliningPhase = true
109106
throw CompilationUnit.SuspendException()
110107

111108
private var myAssignmentSpans: Map[Int, List[Span]] | Null = null
@@ -123,7 +120,7 @@ class CompilationUnit protected (val source: SourceFile, val info: CompilationUn
123120

124121
override def isJava: Boolean = false
125122

126-
override def suspend()(using Context): Nothing =
123+
override def suspend(hint: => String)(using Context): Nothing =
127124
throw CompilationUnit.SuspendException()
128125

129126
override def assignmentSpans(using Context): Map[Int, List[Span]] = Map.empty

compiler/src/dotty/tools/dotc/Driver.scala

+3
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,10 @@ class Driver {
5252
if !ctx.reporter.errorsReported && run.suspendedUnits.nonEmpty then
5353
val suspendedUnits = run.suspendedUnits.toList
5454
if (ctx.settings.XprintSuspension.value)
55+
val suspendedHints = run.suspendedHints.toList
5556
report.echo(i"compiling suspended $suspendedUnits%, %")
57+
for (unit, hint) <- suspendedHints do
58+
report.echo(s" $unit: $hint")
5659
val run1 = compiler.newRun
5760
run1.compileSuspendedUnits(suspendedUnits)
5861
finish(compiler, run1)(using MacroClassLoader.init(ctx.fresh))

compiler/src/dotty/tools/dotc/Run.scala

+1
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ class Run(comp: Compiler, ictx: Context) extends ImplicitRunInfo with Constraint
130130
myUnits = us
131131

132132
var suspendedUnits: mutable.ListBuffer[CompilationUnit] = mutable.ListBuffer()
133+
var suspendedHints: mutable.Map[CompilationUnit, String] = mutable.HashMap()
133134

134135
def checkSuspendedUnits(newUnits: List[CompilationUnit])(using Context): Unit =
135136
if newUnits.isEmpty && suspendedUnits.nonEmpty && !ctx.reporter.errorsReported then

compiler/src/dotty/tools/dotc/core/Symbols.scala

+4
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,10 @@ object Symbols extends SymUtils {
165165
final def isDefinedInSource(using Context): Boolean =
166166
span.exists && isValidInCurrentRun && associatedFileMatches(!_.isScalaBinary)
167167

168+
/** Is this symbol valid in the current run, but comes from the classpath? */
169+
final def isDefinedInBinary(using Context): Boolean =
170+
isValidInCurrentRun && associatedFileMatches(_.isScalaBinary)
171+
168172
/** Is symbol valid in current run? */
169173
final def isValidInCurrentRun(using Context): Boolean =
170174
(lastDenot.validFor.runId == ctx.runId || stillValid(lastDenot)) &&

compiler/src/dotty/tools/dotc/inlines/Inliner.scala

+12-4
Original file line numberDiff line numberDiff line change
@@ -1058,22 +1058,30 @@ class Inliner(val call: tpd.Tree)(using Context):
10581058
}
10591059
}
10601060

1061-
private def expandMacro(body: Tree, splicePos: SrcPos)(using Context) = {
1061+
private def expandMacro(body: Tree, splicePos: SrcPos)(using Context): Tree = {
10621062
assert(level == 0)
10631063
val inlinedFrom = enclosingInlineds.last
10641064
val dependencies = macroDependencies(body)(using spliceContext)
10651065
val suspendable = ctx.compilationUnit.isSuspendable
1066+
val printSuspensions = ctx.settings.XprintSuspension.value
10661067
if dependencies.nonEmpty && !ctx.reporter.errorsReported then
1068+
val hints: mutable.ListBuffer[String] | Null =
1069+
if printSuspensions then mutable.ListBuffer.empty[String] else null
10671070
for sym <- dependencies do
10681071
if ctx.compilationUnit.source.file == sym.associatedFile then
10691072
report.error(em"Cannot call macro $sym defined in the same source file", call.srcPos)
10701073
else if ctx.settings.YnoSuspendedUnits.value then
10711074
val addendum = ", suspension prevented by -Yno-suspended-units"
10721075
report.error(em"Cannot call macro $sym defined in the same compilation run$addendum", call.srcPos)
1073-
if (suspendable && ctx.settings.XprintSuspension.value)
1074-
report.echo(i"suspension triggered by macro call to ${sym.showLocated} in ${sym.associatedFile}", call.srcPos)
1076+
if suspendable && printSuspensions then
1077+
hints.nn += i"suspension triggered by macro call to ${sym.showLocated} in ${sym.associatedFile}"
10751078
if suspendable then
1076-
ctx.compilationUnit.suspend() // this throws a SuspendException
1079+
if ctx.settings.YnoSuspendedUnits.value then
1080+
return ref(defn.Predef_undefined)
1081+
.withType(ErrorType(em"could not expand macro, suspended units are disabled by -Yno-suspended-units"))
1082+
.withSpan(splicePos.span)
1083+
else
1084+
ctx.compilationUnit.suspend(hints.nn.toList.mkString(", ")) // this throws a SuspendException
10771085

10781086
val evaluatedSplice = inContext(quoted.MacroExpansion.context(inlinedFrom)) {
10791087
Splicer.splice(body, splicePos, inlinedFrom.srcPos, MacroClassLoader.fromContext)

compiler/src/dotty/tools/dotc/quoted/Interpreter.scala

+40-19
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,8 @@ class Interpreter(pos: SrcPos, classLoader0: ClassLoader)(using Context):
166166
val inst =
167167
try loadModule(moduleClass)
168168
catch
169-
case MissingClassDefinedInCurrentRun(sym) =>
170-
suspendOnMissing(sym, pos)
169+
case MissingClassValidInCurrentRun(sym, origin) =>
170+
suspendOnMissing(sym, origin, pos)
171171
val clazz = inst.getClass
172172
val name = fn.name.asTermName
173173
val method = getMethod(clazz, name, paramsSig(fn))
@@ -213,8 +213,8 @@ class Interpreter(pos: SrcPos, classLoader0: ClassLoader)(using Context):
213213
private def loadClass(name: String): Class[?] =
214214
try classLoader.loadClass(name)
215215
catch
216-
case MissingClassDefinedInCurrentRun(sym) =>
217-
suspendOnMissing(sym, pos)
216+
case MissingClassValidInCurrentRun(sym, origin) =>
217+
suspendOnMissing(sym, origin, pos)
218218

219219

220220
private def getMethod(clazz: Class[?], name: Name, paramClasses: List[Class[?]]): JLRMethod =
@@ -223,8 +223,8 @@ class Interpreter(pos: SrcPos, classLoader0: ClassLoader)(using Context):
223223
case _: NoSuchMethodException =>
224224
val msg = em"Could not find method ${clazz.getCanonicalName}.$name with parameters ($paramClasses%, %)"
225225
throw new StopInterpretation(msg, pos)
226-
case MissingClassDefinedInCurrentRun(sym) =>
227-
suspendOnMissing(sym, pos)
226+
case MissingClassValidInCurrentRun(sym, origin) =>
227+
suspendOnMissing(sym, origin, pos)
228228
}
229229

230230
private def stopIfRuntimeException[T](thunk: => T, method: JLRMethod): T =
@@ -242,8 +242,8 @@ class Interpreter(pos: SrcPos, classLoader0: ClassLoader)(using Context):
242242
ex.getTargetException match {
243243
case ex: scala.quoted.runtime.StopMacroExpansion =>
244244
throw ex
245-
case MissingClassDefinedInCurrentRun(sym) =>
246-
suspendOnMissing(sym, pos)
245+
case MissingClassValidInCurrentRun(sym, origin) =>
246+
suspendOnMissing(sym, origin, pos)
247247
case targetException =>
248248
val sw = new StringWriter()
249249
sw.write("Exception occurred while executing macro expansion.\n")
@@ -348,8 +348,11 @@ object Interpreter:
348348
}
349349
end Call
350350

351-
object MissingClassDefinedInCurrentRun {
352-
def unapply(targetException: Throwable)(using Context): Option[Symbol] = {
351+
enum ClassOrigin:
352+
case Classpath, Source
353+
354+
object MissingClassValidInCurrentRun {
355+
def unapply(targetException: Throwable)(using Context): Option[(Symbol, ClassOrigin)] = {
353356
if !ctx.compilationUnit.isSuspendable then None
354357
else targetException match
355358
case _: NoClassDefFoundError | _: ClassNotFoundException =>
@@ -358,16 +361,34 @@ object Interpreter:
358361
else
359362
val className = message.replace('/', '.')
360363
val sym =
361-
if className.endsWith(str.MODULE_SUFFIX) then staticRef(className.toTermName).symbol.moduleClass
362-
else staticRef(className.toTypeName).symbol
363-
// If the symbol does not a a position we assume that it came from the current run and it has an error
364-
if sym.isDefinedInCurrentRun || (sym.exists && !sym.srcPos.span.exists) then Some(sym)
365-
else None
364+
if className.endsWith(str.MODULE_SUFFIX) then
365+
staticRef(className.stripSuffix(str.MODULE_SUFFIX).toTermName).symbol.moduleClass
366+
else
367+
staticRef(className.toTypeName).symbol
368+
if sym.isDefinedInBinary then
369+
// i.e. the associated file is `.tasty`, if the macro classloader is not able to find the class,
370+
// possibly it indicates that it comes from a pipeline-compiled dependency.
371+
Some((sym, ClassOrigin.Classpath))
372+
else if sym.isDefinedInCurrentRun || (sym.exists && !sym.srcPos.span.exists) then
373+
// If the symbol does not a a position we assume that it came from the current run and it has an error
374+
Some((sym, ClassOrigin.Source))
375+
else
376+
None
366377
case _ => None
367378
}
368379
}
369380

370-
def suspendOnMissing(sym: Symbol, pos: SrcPos)(using Context): Nothing =
371-
if ctx.settings.XprintSuspension.value then
372-
report.echo(i"suspension triggered by a dependency on $sym", pos)
373-
ctx.compilationUnit.suspend() // this throws a SuspendException
381+
def suspendOnMissing(sym: Symbol, origin: ClassOrigin, pos: SrcPos)(using Context): Nothing =
382+
if origin == ClassOrigin.Classpath then
383+
throw StopInterpretation(
384+
em"""Macro code depends on ${sym.showLocated} found on the classpath, but could not be loaded while evaluating the macro.
385+
| This is likely because class files could not be found in the classpath entry for the symbol.
386+
|
387+
| A possible cause is if the origin of this symbol was built with pipelined compilation;
388+
| in which case, this problem may go away by disabling pipelining for that origin.
389+
|
390+
| $sym is defined in file ${sym.associatedFile}""", pos)
391+
else if ctx.settings.YnoSuspendedUnits.value then
392+
throw StopInterpretation(em"suspension triggered by a dependency on missing ${sym.showLocated} not allowed with -Yno-suspended-units", pos)
393+
else
394+
ctx.compilationUnit.suspend(i"suspension triggered by a dependency on missing ${sym.showLocated}") // this throws a SuspendException

compiler/src/dotty/tools/dotc/transform/MacroAnnotations.scala

+2-2
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,8 @@ class MacroAnnotations(phase: IdentityDenotTransformer):
107107
if !ctx.reporter.hasErrors then
108108
report.error("Macro expansion was aborted by the macro without any errors reported. Macros should issue errors to end-users when aborting a macro expansion with StopMacroExpansion.", annot.tree)
109109
List(tree)
110-
case Interpreter.MissingClassDefinedInCurrentRun(sym) =>
111-
Interpreter.suspendOnMissing(sym, annot.tree)
110+
case Interpreter.MissingClassValidInCurrentRun(sym, origin) =>
111+
Interpreter.suspendOnMissing(sym, origin, annot.tree)
112112
case NonFatal(ex) =>
113113
val stack0 = ex.getStackTrace.takeWhile(_.getClassName != "dotty.tools.dotc.transform.MacroAnnotations")
114114
val stack = stack0.take(1 + stack0.lastIndexWhere(_.getMethodName == "transform"))

compiler/src/dotty/tools/dotc/typer/Namer.scala

+1-1
Original file line numberDiff line numberDiff line change
@@ -1678,7 +1678,7 @@ class Namer { typer: Typer =>
16781678

16791679
final override def complete(denot: SymDenotation)(using Context): Unit =
16801680
denot.resetFlag(Touched) // allow one more completion
1681-
ctx.compilationUnit.suspend()
1681+
ctx.compilationUnit.suspend(i"reset $denot")
16821682
}
16831683

16841684
/** Typecheck `tree` during completion using `typed`, and remember result in TypedAhead map */
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
ThisBuild / usePipelining := true
2+
3+
// m defines a macro depending on b.B, it also tries to use the macro in the same project,
4+
// which will succeed even though B.class is not available when running the macro,
5+
// because compilation can suspend until B is available.
6+
lazy val m = project.in(file("m"))
7+
.settings(
8+
scalacOptions += "-Ycheck:all",
9+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package a
2+
3+
class A(val i: Int)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package b
2+
3+
import a.A
4+
import scala.quoted.*
5+
6+
object B {
7+
8+
transparent inline def transparentPower(x: Double, inline n: Int): Double =
9+
${ powerCode('x, 'n) }
10+
11+
def powerCode(x: Expr[Double], n: Expr[Int])(using Quotes): Expr[Double] = {
12+
// this macro will cause a suspension in compilation of C.scala, because it calls
13+
// transparentPower. This will try to invoke the macro but fail because A.class
14+
// is not yet available until the run for A.scala completes.
15+
16+
// see sbt-test/pipelining/pipelining-scala-macro-splice/m/src/main/scala/b/B.scala
17+
// for a corresponding implementation that uses a class from an upstream project
18+
// instead, and fails because pipelining is turned on for the upstream project.
19+
def impl(x: Double, n: A): Double =
20+
if (n.i == 0) 1.0
21+
else if (n.i % 2 == 1) x * impl(x, A(n.i - 1))
22+
else impl(x * x, A(n.i / 2))
23+
24+
Expr(impl(x.valueOrError, A(n.valueOrError)))
25+
}
26+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package c
2+
3+
import b.B
4+
5+
object C {
6+
@main def run = {
7+
assert(B.transparentPower(2.0, 2) == 4.0)
8+
assert(B.transparentPower(2.0, 3) == 8.0)
9+
assert(B.transparentPower(2.0, 4) == 16.0)
10+
}
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import sbt._
2+
import Keys._
3+
4+
object DottyInjectedPlugin extends AutoPlugin {
5+
override def requires = plugins.JvmPlugin
6+
override def trigger = allRequirements
7+
8+
override val projectSettings = Seq(
9+
scalaVersion := sys.props("plugin.scalaVersion"),
10+
)
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# shows that it is ok to depend on a class, defined in the same project,
2+
# in a macro implementation. Compilation will suspend at typer.
3+
> m/run
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package a
2+
3+
class A(val i: Int)

sbt-test/pipelining/pipelining-scala-macro-splice/a_alt/.keep

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
ThisBuild / usePipelining := true
2+
3+
lazy val a = project.in(file("a"))
4+
.settings(
5+
scalacOptions += "-Ycheck:all",
6+
)
7+
8+
// same as a, but does not use pipelining
9+
lazy val a_alt = project.in(file("a_alt"))
10+
.settings(
11+
Compile / sources := (a / Compile / sources).value,
12+
Compile / exportPipelining := false,
13+
)
14+
15+
16+
// m defines a macro depending on a, it also tries to use the macro in the same project,
17+
// which will fail because A.class is not available when running the macro,
18+
// because the dependency on a is pipelined.
19+
lazy val m = project.in(file("m"))
20+
.dependsOn(a)
21+
.settings(
22+
scalacOptions += "-Ycheck:all",
23+
)
24+
25+
// same as m, but depends on a_alt, so it will compile
26+
// because A.class will be available when running the macro.
27+
lazy val m_alt = project.in(file("m_alt"))
28+
.dependsOn(a_alt)
29+
.settings(
30+
Compile / sources := (m / Compile / sources).value,
31+
scalacOptions += "-Ycheck:all",
32+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package b
2+
3+
import a.A
4+
import scala.quoted.*
5+
6+
object B {
7+
8+
transparent inline def transparentPower(x: Double, inline n: Int): Double =
9+
${ powerCode('x, 'n) }
10+
11+
def powerCode(x: Expr[Double], n: Expr[Int])(using Quotes): Expr[Double] = {
12+
// this macro is invoked during compilation of C.scala. When project a is pipelined
13+
// This will fail because A.class will never be available, because the classpath entry
14+
// is the early-output jar. The compiler detects this and aborts macro expansion with an error.
15+
16+
// see sbt-test/pipelining/pipelining-scala-macro-splice-ok/m/src/main/scala/b/B.scala
17+
// for a corresponding implementation that uses a class from the same project
18+
// instead, but succeeds because it can suspend compilation until classes become available.
19+
def impl(x: Double, n: A): Double =
20+
if (n.i == 0) 1.0
21+
else if (n.i % 2 == 1) x * impl(x, A(n.i - 1))
22+
else impl(x * x, A(n.i / 2))
23+
24+
Expr(impl(x.valueOrError, A(n.valueOrError)))
25+
}
26+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package c
2+
3+
import b.B
4+
5+
object C {
6+
@main def run = {
7+
assert(B.transparentPower(2.0, 2) == 4.0)
8+
assert(B.transparentPower(2.0, 3) == 8.0)
9+
assert(B.transparentPower(2.0, 4) == 16.0)
10+
}
11+
}

sbt-test/pipelining/pipelining-scala-macro-splice/m_alt/.keep

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import sbt._
2+
import Keys._
3+
4+
object DottyInjectedPlugin extends AutoPlugin {
5+
override def requires = plugins.JvmPlugin
6+
override def trigger = allRequirements
7+
8+
override val projectSettings = Seq(
9+
scalaVersion := sys.props("plugin.scalaVersion"),
10+
)
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# as described in build.sbt, this will fail to compile.
2+
# m defines a macro, depending on a.A, defined in upstream project a
3+
# however because m also tries to run the macro in the same project,
4+
# a/A.class is not available yet, so a reflection error will occur.
5+
# This is caught by the compiler and presents a pretty diagnostic to the user,
6+
# suggesting to disable pipelining in the project defining A.
7+
-> m/compile
8+
# This will run, simulating a user following the suggestion to
9+
# disable pipelining in project a.
10+
> m_alt/run

0 commit comments

Comments
 (0)