diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index 266b37e5f..1b567de2c 100644 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -1087,9 +1087,16 @@ object Defaults extends BuildCommon with DefExtra { }, 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) diff --git a/main/src/main/scala/sbt/internal/Compiler.scala b/main/src/main/scala/sbt/internal/Compiler.scala index bb77d7505..57e9c1604 100644 --- a/main/src/main/scala/sbt/internal/Compiler.scala +++ b/main/src/main/scala/sbt/internal/Compiler.scala @@ -558,4 +558,18 @@ object Compiler: ) .withEnvVars(sys.env) } + + /** + * Strips `-Ypickle-java` and `-Ypickle-write ` 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 + end Compiler diff --git a/main/src/test/scala/sbt/internal/CompilerConsoleOptsTest.scala b/main/src/test/scala/sbt/internal/CompilerConsoleOptsTest.scala new file mode 100644 index 000000000..310aad604 --- /dev/null +++ b/main/src/test/scala/sbt/internal/CompilerConsoleOptsTest.scala @@ -0,0 +1,188 @@ +package sbt.internal + +import hedgehog.* +import hedgehog.runner.* + +/** + * 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") + ) + ), + + // ── 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 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 diff --git a/sbt-app/src/sbt-test/project/usePipelining-console/build.sbt b/sbt-app/src/sbt-test/project/usePipelining-console/build.sbt new file mode 100644 index 000000000..0baa7f9ff --- /dev/null +++ b/sbt-app/src/sbt-test/project/usePipelining-console/build.sbt @@ -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") + } + ) diff --git a/sbt-app/src/sbt-test/project/usePipelining-console/modules/subproject/src/main/scala/Subproject.scala b/sbt-app/src/sbt-test/project/usePipelining-console/modules/subproject/src/main/scala/Subproject.scala new file mode 100644 index 000000000..224754681 --- /dev/null +++ b/sbt-app/src/sbt-test/project/usePipelining-console/modules/subproject/src/main/scala/Subproject.scala @@ -0,0 +1,3 @@ +object Subproject { + val value: List[Int] = Nil +} diff --git a/sbt-app/src/sbt-test/project/usePipelining-console/test b/sbt-app/src/sbt-test/project/usePipelining-console/test new file mode 100644 index 000000000..fedb3f6c0 --- /dev/null +++ b/sbt-app/src/sbt-test/project/usePipelining-console/test @@ -0,0 +1,5 @@ +# Verify pipelining flags are stripped from console/scalacOptions (#8921). + +> compile +> subproject/checkConsoleScalacOptions +> checkConsoleScalacOptions