Merge pull request #9181 from eed3si9n/bport/console-plugin-virtualpath

[2.0.x] bport: Use rootPaths to replace virtual paths in console and doc
This commit is contained in:
eugene yokota 2026-05-03 22:01:53 -04:00 committed by GitHub
commit ce34b6231d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 292 additions and 1 deletions

View File

@ -1109,9 +1109,16 @@ object Defaults extends BuildCommon {
},
compileIncSetup := Def.uncached(compileIncSetupTask.value),
console := Compiler.consoleTask.value,
// Strip pipelining flags before they reach the REPL (#8921).
console / scalacOptions := Def.uncached {
Compiler.toConsoleScalacOptions(scalacOptions.value)
},
console / forkOptions := Def.uncached(Compiler.consoleForkOptions.value),
collectAnalyses := Definition.collectAnalysesTask.map(_ => ()).value,
consoleQuick := consoleQuickTask.value,
consoleQuick / scalacOptions := Def.uncached {
Compiler.toConsoleScalacOptions(scalacOptions.value)
},
consoleQuick / forkOptions := Def.uncached((console / forkOptions).value),
discoveredMainClasses := compile
.map(discoverMainClasses)

View File

@ -344,6 +344,7 @@ object Compiler:
Def.task {
val s = Keys.streams.value
val conv = Keys.fileConverter.value
val rootPaths = Keys.rootPaths.value
val cside = (task / Keys.clientSide).value
val depsJars = (task / Keys.externalDependencyClasspath).value.toVector
.map(_.data)
@ -362,12 +363,14 @@ object Compiler:
workingDir,
conv,
)
val consoleScalacOptions =
resolveVirtualizedScalacOptions((task / Keys.scalacOptions).value, rootPaths)
val param = ConsoleInfo(
ArrayList(toolJars.asJava),
ArrayList(bridgeJars.toVector.map(vf => conv.toPath(vf).toUri()).asJava),
ArrayList(),
ArrayList(Attributed.data(cp).toVector.map(vf => conv.toPath(vf).toUri()).asJava),
ArrayList((task / Keys.scalacOptions).value.asJava),
ArrayList(consoleScalacOptions.asJava),
(task / Keys.initialCommands).value,
(task / Keys.cleanupCommands).value,
)
@ -432,4 +435,37 @@ object Compiler:
)
.withEnvVars(sys.env)
}
/**
* Strips `-Ypickle-java` and `-Ypickle-write <path>` from scalac options so they don't
* reach the REPL, where they cause file-lock errors on Windows and spurious
* `InterruptedException`s on all platforms (see #8921).
*/
private[sbt] def toConsoleScalacOptions(options: Seq[String]): Seq[String] =
options match
case "-Ypickle-write" +: (_ +: rest) => toConsoleScalacOptions(rest)
case "-Ypickle-write" +: _ => Seq.empty
case "-Ypickle-java" +: rest => toConsoleScalacOptions(rest)
case head +: rest => head +: toConsoleScalacOptions(rest)
case _ => Seq.empty
/**
* Converts mapped virtual file ids in compiler plugin options back to machine paths.
*
* Compiler plugin options are often encoded using `FileConverter.toVirtualFile` to keep
* paths portable in persisted settings (for example `-Xplugin:${CSR_CACHE}/...`). Before we
* launch tools that expect concrete filesystem paths (forked console, scaladoc), these ids
* must be resolved using the `rootPaths`.
*/
private[sbt] def resolveVirtualizedScalacOptions(
options: Seq[String],
rootPaths: Map[String, Path]
): Seq[String] =
def convertValue(value: String): String =
rootPaths.find((key, _) => value.startsWith(s"$${$key}/")) match
case Some((key, p)) => p.resolve(value.stripPrefix(s"$${$key}/")).toString()
case None => value
options.map(_.split(":").map(_.split(",").map(convertValue).mkString(",")).mkString(":"))
end Compiler

View File

@ -0,0 +1,208 @@
package sbt.internal
import hedgehog.*
import hedgehog.runner.*
import java.nio.file.Files
/**
* Tests for [[Compiler.toConsoleScalacOptions]] pipelining flags must be stripped before
* reaching the REPL (#8921).
*/
object CompilerConsoleOptsTest extends Properties:
override def tests: List[Test] = List(
// ── deterministic unit cases ──────────────────────────────────────────────
example("empty list stays empty", check(Seq.empty, Seq.empty)),
example(
"list with no pipelining flags is unchanged",
check(Seq("-deprecation", "-feature"), Seq("-deprecation", "-feature"))
),
example("-Ypickle-java alone is removed", check(Seq("-Ypickle-java"), Seq.empty)),
example(
"-Ypickle-java at the start is removed",
check(Seq("-Ypickle-java", "-deprecation"), Seq("-deprecation"))
),
example(
"-Ypickle-java at the end is removed",
check(Seq("-feature", "-Ypickle-java"), Seq("-feature"))
),
example(
"-Ypickle-java in the middle is removed",
check(Seq("-deprecation", "-Ypickle-java", "-feature"), Seq("-deprecation", "-feature"))
),
example(
"-Ypickle-write with its argument are both removed",
check(Seq("-Ypickle-write", "/tmp/early.jar"), Seq.empty)
),
example(
"-Ypickle-write at start removes flag and argument, keeps tail",
check(Seq("-Ypickle-write", "/tmp/early.jar", "-deprecation"), Seq("-deprecation"))
),
example(
"-Ypickle-write at end removes flag and argument",
check(Seq("-feature", "-Ypickle-write", "/tmp/early.jar"), Seq("-feature"))
),
example(
"-Ypickle-write in the middle removes flag and argument",
check(
Seq("-deprecation", "-Ypickle-write", "/tmp/early.jar", "-feature"),
Seq("-deprecation", "-feature")
)
),
example(
"-Ypickle-write without argument (trailing flag) is removed safely",
check(Seq("-deprecation", "-Ypickle-write"), Seq("-deprecation"))
),
example(
"both pipelining flags together are removed",
check(Seq("-Ypickle-java", "-Ypickle-write", "/tmp/early.jar"), Seq.empty)
),
example(
"both pipelining flags with surrounding options",
check(
Seq("-encoding", "utf8", "-Ypickle-java", "-Ypickle-write", "/tmp/early.jar", "-feature"),
Seq("-encoding", "utf8", "-feature")
)
),
example(
"multiple occurrences of -Ypickle-java are all removed",
check(Seq("-Ypickle-java", "-opt:l:inline", "-Ypickle-java"), Seq("-opt:l:inline"))
),
example(
"multiple -Ypickle-write occurrences are all removed with their args",
check(
Seq("-Ypickle-write", "/a.jar", "-deprecation", "-Ypickle-write", "/b.jar"),
Seq("-deprecation")
)
),
example(
"real-world pipelining option list is cleaned",
check(
Seq(
"-Ypickle-java",
"-Ypickle-write",
"/target/early/early.jar",
"-encoding",
"utf8",
"-deprecation",
"-feature"
),
Seq("-encoding", "utf8", "-deprecation", "-feature")
)
),
example(
"Vector input (as produced by sbt pipelining) is handled correctly",
check(
Vector(
"-Ypickle-java",
"-Ypickle-write",
"/target/out/early/subproject_3-0.1.0-SNAPSHOT.jar",
"-encoding",
"utf8"
),
Seq("-encoding", "utf8")
)
),
example(
"virtualized compiler plugin paths are resolved for tool invocation",
checkResolvedVirtualizedOptions
),
// ── property-based cases ──────────────────────────────────────────────────
property(
"result never contains -Ypickle-java or -Ypickle-write",
propNoPipeliningFlagsInResult
),
property(
"non-pipelining options are always preserved",
propNonPipeliningOptionsPreserved
),
property(
"idempotent: applying twice gives the same result as applying once",
propIdempotent
),
)
// ── helpers ───────────────────────────────────────────────────────────────
private def check(input: Seq[String], expected: Seq[String]): Result =
val got = Compiler.toConsoleScalacOptions(input)
Result
.assert(got == expected)
.log(s"input: $input")
.log(s"expected: $expected")
.log(s"got: $got")
private def checkResolvedVirtualizedOptions: Result =
val cacheRoot = Files.createTempDirectory("compiler-console-opts")
val rootPaths = Map("CSR_CACHE" -> cacheRoot)
val converter = _root_.sbt.internal.inc.MappedFileConverter(rootPaths, allowMachinePath = false)
val pluginJar = cacheRoot.resolve("plugins/acyclic.jar")
val pluginRef = converter.toVirtualFile(pluginJar).toString
val input = Seq(s"-Xplugin:$pluginRef", "-P:acyclic:force")
val expected = Seq(s"-Xplugin:${pluginJar.toString}", "-P:acyclic:force")
val got = Compiler.resolveVirtualizedScalacOptions(input, rootPaths)
Result
.assert(got == expected)
.log(s"input: $input")
.log(s"expected: $expected")
.log(s"got: $got")
private val pipeliningFlags = List("-Ypickle-java", "-Ypickle-write")
/** Generate an arbitrary scalac-option token (flag or path-like argument). */
private def genOption: Gen[String] =
Gen.frequency1(
7 -> Gen.element1(
"-deprecation",
"-feature",
"-encoding",
"utf8",
"-opt:l:inline",
"-Xfatal-warnings",
"-unchecked"
),
1 -> Gen.element1("-Ypickle-java"),
1 -> Gen.element1("-Ypickle-write"),
1 -> Gen.string(Gen.alphaNum, Range.linear(1, 30)).map("/" + _ + ".jar"),
)
private def genOptions: Gen[List[String]] =
Gen.list(genOption, Range.linear(0, 20))
def propNoPipeliningFlagsInResult: Property =
for options <- genOptions.forAll
yield
val result = Compiler.toConsoleScalacOptions(options)
Result
.assert(!result.contains("-Ypickle-java"))
.and(Result.assert(!result.contains("-Ypickle-write")))
.log(s"input: $options")
.log(s"result: $result")
def propNonPipeliningOptionsPreserved: Property =
for
options <- genOptions.forAll
// build a list that contains only non-pipelining flags
clean = options.filterNot(pipeliningFlags.contains)
yield
// inject the clean options as-is (no pipelining flags) and verify they survive
val result = Compiler.toConsoleScalacOptions(clean)
Result
.assert(result == clean)
.log(s"clean input: $clean")
.log(s"result: $result")
def propIdempotent: Property =
for options <- genOptions.forAll
yield
val once = Compiler.toConsoleScalacOptions(options)
val twice = Compiler.toConsoleScalacOptions(once)
Result
.assert(once == twice)
.log(s"input: $options")
.log(s"once: $once")
.log(s"twice: $twice")
end CompilerConsoleOptsTest

View File

@ -0,0 +1,32 @@
// Regression test for #8921: pipelining flags must not leak into console/scalacOptions.
ThisBuild / usePipelining := true
ThisBuild / scalaVersion := "3.8.1"
val checkConsoleScalacOptions = taskKey[Unit](
"Fails if console/scalacOptions still contains pipelining flags (-Ypickle-java / -Ypickle-write)"
)
lazy val subproject = project
.in(file("modules/subproject"))
.settings(
checkConsoleScalacOptions := {
val opts = (Compile / console / scalacOptions).value
val bad = opts.filter(o => o == "-Ypickle-java" || o == "-Ypickle-write")
if (bad.nonEmpty)
sys.error(s"pipelining flags must not reach the REPL, found: $bad")
}
)
lazy val root = project
.in(file("."))
.dependsOn(subproject)
.aggregate(subproject)
.settings(
checkConsoleScalacOptions := {
val opts = (Compile / console / scalacOptions).value
val bad = opts.filter(o => o == "-Ypickle-java" || o == "-Ypickle-write")
if (bad.nonEmpty)
sys.error(s"pipelining flags must not reach the REPL, found: $bad")
}
)

View File

@ -0,0 +1,3 @@
object Subproject {
val value: List[Int] = Nil
}

View File

@ -0,0 +1,5 @@
# Verify pipelining flags are stripped from console/scalacOptions (#8921).
> compile
> subproject/checkConsoleScalacOptions
> checkConsoleScalacOptions