diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index 730a3e562..2e2d8afbf 100644 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -1407,6 +1407,7 @@ object Defaults extends BuildCommon with DefExtra { ) ) } + def forkOptionsTask: Initialize[Task[ForkOptions]] = Def.task { val canUseArgumentsFile = sys.props @@ -1425,6 +1426,10 @@ object Defaults extends BuildCommon with DefExtra { ) } + /** Fork options for run-like tasks: the forked process inherits sbt's working directory. */ + private[sbt] def runForkOptionsTask: Initialize[Task[ForkOptions]] = + Def.task(forkOptionsTask.value.withWorkingDirectory(None)) + def testExecutionTask(task: Scoped): Initialize[Task[Tests.Execution]] = Def.task { new Tests.Execution( @@ -2604,7 +2609,7 @@ object Defaults extends BuildCommon with DefExtra { private lazy val newRunnerSettings: Seq[Setting[?]] = Seq( runner := Def.uncached(ClassLoaders.runner.value), - forkOptions := Def.uncached(forkOptionsTask.value) + forkOptions := Def.uncached(runForkOptionsTask.value) ) lazy val baseTasks: Seq[Setting[?]] = projectTasks ++ packageBase @@ -4974,7 +4979,7 @@ trait BuildExtra extends BuildCommon with DefExtra { } } }.evaluated - ) ++ inTask(scoped)((config / forkOptions) := Def.uncached(forkOptionsTask.value)) + ) ++ inTask(scoped)((config / forkOptions) := Def.uncached(runForkOptionsTask.value)) } // public API @@ -4996,7 +5001,7 @@ trait BuildExtra extends BuildCommon with DefExtra { r.run(mainClass, cp.files, arguments, s.log).get } }.value - ) ++ inTask(scoped)((config / forkOptions) := Def.uncached(forkOptionsTask.value)) + ) ++ inTask(scoped)((config / forkOptions) := Def.uncached(runForkOptionsTask.value)) def initScoped[T](sk: ScopedKey[?], i: Initialize[T]): Initialize[T] = initScope(fillTaskAxis(sk.scope, sk.key), i) diff --git a/main/src/main/scala/sbt/internal/server/BuildServerProtocol.scala b/main/src/main/scala/sbt/internal/server/BuildServerProtocol.scala index 24d43b197..54c489d84 100644 --- a/main/src/main/scala/sbt/internal/server/BuildServerProtocol.scala +++ b/main/src/main/scala/sbt/internal/server/BuildServerProtocol.scala @@ -343,7 +343,7 @@ object BuildServerProtocol { JavacOptionsItem(target, javacOptions, classpath, classDirectory.toURI) }, bspBuildTargetJVMRunEnvironment := bspInputTask { (_, filter) => - val items = bspBuildTargetJvmEnvironmentItem.result.all(filter).value + val items = (run / bspBuildTargetJvmEnvironmentItem).result.all(filter).value val successfulItems = anyOrThrow(items) val result = JvmRunEnvironmentResult(successfulItems.toVector, None) state.value.respondEvent(result) @@ -354,7 +354,8 @@ object BuildServerProtocol { val result = JvmTestEnvironmentResult(successfulItems.toVector, None) state.value.respondEvent(result) }.evaluated, - bspBuildTargetJvmEnvironmentItem := jvmEnvironmentItem().value, + bspBuildTargetJvmEnvironmentItem := jvmEnvironmentItem(forkOptions).value, + run / bspBuildTargetJvmEnvironmentItem := jvmEnvironmentItem(run / forkOptions).value, bspInternalDependencyConfigurations := internalDependencyConfigurationsSetting.value, bspScalaTestClassesItem := scalaTestClassesTask.value, bspScalaMainClassesItem := scalaMainClassesTask.value, @@ -793,7 +794,12 @@ object BuildServerProtocol { Def.task(taskImpl(workspace, filter)) } - private def jvmEnvironmentItem(): Initialize[Task[JvmEnvironmentItem]] = Def.task { + private def bspEnvironmentVariables(opts: ForkOptions): Vector[String] = + opts.envVars.map { (k, v) => s"$k=$v" }.toVector + + private def jvmEnvironmentItem( + forkOptions: Initialize[Task[ForkOptions]] + ): Initialize[Task[JvmEnvironmentItem]] = Def.task { val target = Keys.bspTargetIdentifier.value val converter = fileConverter.value val classpath = Keys.fullClasspath.value @@ -801,16 +807,17 @@ object BuildServerProtocol { .map(converter.toPath) .map(_.toFile.toURI) .toVector - val jvmOptions = Keys.javaOptions.value.toVector - val baseDir = Keys.baseDirectory.value.getAbsolutePath - val env = envVars.value + val opts = forkOptions.value + val workingDir = opts.workingDirectory + .getOrElse(new File(sys.props("user.dir"))) + .getAbsolutePath JvmEnvironmentItem( target, classpath, - jvmOptions, - baseDir, - env + opts.runJVMOptions, + workingDir, + opts.envVars ) } @@ -947,7 +954,8 @@ object BuildServerProtocol { val json = jsonParser.parsed val runParams = json.flatMap(Converter.fromJson[RunParams]).get val defaultClass = Keys.mainClass.value - val defaultJvmOptions = Keys.javaOptions.value + val defaultOpts = (run / forkOptions).value + val defaultEnv = bspEnvironmentVariables(defaultOpts) val mainClass = runParams.dataKind match { case Some("scala-main-class") => @@ -959,9 +967,7 @@ object BuildServerProtocol { e.getMessage ) case Success(value) => - value.withEnvironmentVariables( - envVars.value.map { (k, v) => s"$k=$v" }.toVector ++ value.environmentVariables - ) + value.withEnvironmentVariables(defaultEnv ++ value.environmentVariables) } case Some(dataKind) => @@ -979,8 +985,8 @@ object BuildServerProtocol { ) ), runParams.arguments, - defaultJvmOptions.toVector, - envVars.value.map { (k, v) => s"$k=$v" }.toVector + defaultOpts.runJVMOptions, + defaultEnv ) } runMainClassTask(mainClass, runParams.originId) @@ -1038,21 +1044,18 @@ object BuildServerProtocol { val state = Keys.state.value val logger = Keys.streams.value.log val classpath = Attributed.data(fullClasspath.value) - val forkOpts = ForkOptions( - javaHome = javaHome.value, - outputStrategy = outputStrategy.value, - // bootJars is empty by default because only jars on the user's classpath should be on the boot classpath - bootJars = Vector(), - workingDirectory = Some(baseDirectory.value), - runJVMOptions = mainClass.jvmOptions, - connectInput = connectInput.value, - envVars = mainClass.environmentVariables - .flatMap(_.split("=", 2).toList match { - case key :: value :: Nil => Some(key -> value) - case _ => None - }) - .toMap - ) + // connectInput is disabled so non-interactive BSP output is captured as log messages + val forkOpts = (run / forkOptions).value + .withConnectInput(false) + .withRunJVMOptions(mainClass.jvmOptions) + .withEnvVars( + mainClass.environmentVariables + .flatMap(_.split("=", 2).toList match { + case key :: value :: Nil => Some(key -> value) + case _ => None + }) + .toMap + ) val runner = new ForkRun(forkOpts) val converter = fileConverter.value val cp = classpath.map(converter.toPath) @@ -1122,13 +1125,14 @@ object BuildServerProtocol { } private def scalaMainClassesTask: Initialize[Task[ScalaMainClassesItem]] = Def.task { - val jvmOptions = Keys.javaOptions.value.toVector + val opts = (run / forkOptions).value + val env = bspEnvironmentVariables(opts) val mainClasses = Keys.discoveredMainClasses.value.map( ScalaMainClass( _, Vector(), - jvmOptions, - envVars.value.map { (k, v) => s"$k=$v" }.toVector + opts.runJVMOptions, + env ) ) ScalaMainClassesItem( diff --git a/notes/2.0.0/fork-working-directory.md b/notes/2.0.0/fork-working-directory.md new file mode 100644 index 000000000..b02151264 --- /dev/null +++ b/notes/2.0.0/fork-working-directory.md @@ -0,0 +1,25 @@ +### Forked run starts in sbt's working directory + +Previously, forked `run` set the forked JVM's working directory to the project's +`baseDirectory`, while non-forked `run` executed in the directory sbt itself was +started from. In a multi-project build, toggling `fork` silently changed the +directory that relative paths resolved against. + +sbt 2.x makes forked `run` (and forked `console`) inherit sbt's own working +directory by default, consistent with non-forked execution and with `sbtn` +expectations. Forked `test` is unchanged and keeps the project's `baseDirectory` +as its working directory. The working directory of any forked process can be +configured via `forkOptions`: + +```scala +Compile / run / forkOptions := Def.uncached( + (Compile / run / forkOptions).value.withWorkingDirectory(Some(baseDirectory.value)) +) +``` + +The BSP `buildTarget/jvmRunEnvironment` response reports the same working +directory that `run` uses. + +This addresses [#1032][i1032] for `run`. + +[i1032]: https://github.com/sbt/sbt/issues/1032 diff --git a/notes/2.0.0/migration.md b/notes/2.0.0/migration.md index 18dd31853..7d5d9956f 100644 --- a/notes/2.0.0/migration.md +++ b/notes/2.0.0/migration.md @@ -1,5 +1,17 @@ +## Forked run working directory + +Forked `run` no longer runs in the project's `baseDirectory`; it inherits sbt's +working directory, matching non-forked behavior. Forked `test` is unchanged. To +restore the sbt 1.x behavior: + +```scala +Compile / run / forkOptions := Def.uncached( + (Compile / run / forkOptions).value.withWorkingDirectory(Some(baseDirectory.value)) +) +``` + ## files extension on Classpath ```scala diff --git a/sbt-app/src/sbt-test/run/fork/test b/sbt-app/src/sbt-test/run/fork/test index 773af0776..60760439f 100644 --- a/sbt-app/src/sbt-test/run/fork/test +++ b/sbt-app/src/sbt-test/run/fork/test @@ -1,18 +1,29 @@ -> run fork +# non-forked run executes in sbt's working directory +> run $ exists flag $ delete flag -$ mkdir forked +# forked run inherits sbt's working directory by default, +# even when run / baseDirectory points elsewhere (#1032) > set fork := true > set run / baseDirectory := baseDirectory(_ / "forked").value +> run +$ exists flag +$ absent forked/flag +$ delete flag -> run forked +# run / forkOptions configures the forked working directory +> session clear +> set fork := true +> set Compile / run / forkOptions := Def.uncached((Compile / run / forkOptions).value.withWorkingDirectory(Some(baseDirectory.value / "forked"))) +$ mkdir forked +> run $ exists forked/flag $ absent flag $ delete forked/flag > set envVars += ("flag.name" -> "env.flag") -> run forked +> run $ exists forked/env.flag $ absent flag $ absent forked/flag diff --git a/sbt-app/src/sbt-test/tests/fork-working-directory/build.sbt b/sbt-app/src/sbt-test/tests/fork-working-directory/build.sbt new file mode 100644 index 000000000..dbf929bb1 --- /dev/null +++ b/sbt-app/src/sbt-test/tests/fork-working-directory/build.sbt @@ -0,0 +1,11 @@ +val scalatest = "org.scalatest" %% "scalatest" % "3.2.19" + +ThisBuild / scalaVersion := "3.8.4" + +lazy val root = (project in file(".")) + +lazy val sub = project + .settings( + Test / fork := true, + libraryDependencies += scalatest % Test, + ) diff --git a/sbt-app/src/sbt-test/tests/fork-working-directory/changes/forkdir.sbt b/sbt-app/src/sbt-test/tests/fork-working-directory/changes/forkdir.sbt new file mode 100644 index 000000000..5790d7a56 --- /dev/null +++ b/sbt-app/src/sbt-test/tests/fork-working-directory/changes/forkdir.sbt @@ -0,0 +1,3 @@ +Test / forkOptions := Def.uncached( + (Test / forkOptions).value.withWorkingDirectory(Some((ThisBuild / baseDirectory).value)) +) diff --git a/sbt-app/src/sbt-test/tests/fork-working-directory/sub/src/test/scala/CwdSpec.scala b/sbt-app/src/sbt-test/tests/fork-working-directory/sub/src/test/scala/CwdSpec.scala new file mode 100644 index 000000000..79887d224 --- /dev/null +++ b/sbt-app/src/sbt-test/tests/fork-working-directory/sub/src/test/scala/CwdSpec.scala @@ -0,0 +1,8 @@ +import org.scalatest.funsuite.AnyFunSuite + +class CwdSpec extends AnyFunSuite { + test("create marker in the forked working directory") { + val marker = new java.io.File("cwd-marker").getAbsoluteFile + assert(marker.createNewFile() || marker.exists()) + } +} diff --git a/sbt-app/src/sbt-test/tests/fork-working-directory/test b/sbt-app/src/sbt-test/tests/fork-working-directory/test new file mode 100644 index 000000000..f96568e94 --- /dev/null +++ b/sbt-app/src/sbt-test/tests/fork-working-directory/test @@ -0,0 +1,12 @@ +# a forked test's working directory remains the project's baseDirectory +> sub/testFull +$ exists sub/cwd-marker +$ absent cwd-marker +$ delete sub/cwd-marker + +# Test / forkOptions configures the forked working directory +$ copy-file changes/forkdir.sbt sub/forkdir.sbt +> reload +> sub/testFull +$ exists cwd-marker +$ absent sub/cwd-marker diff --git a/server-test/src/server-test/buildserver/build.sbt b/server-test/src/server-test/buildserver/build.sbt index 62465c8e5..02608cd43 100644 --- a/server-test/src/server-test/buildserver/build.sbt +++ b/server-test/src/server-test/buildserver/build.sbt @@ -9,6 +9,9 @@ lazy val runAndTest = project.in(file("run-and-test")) libraryDependencies += "org.scalatest" %% "scalatest" % "3.0.8" % "test", Compile / javaOptions := Vector("Xmx256M"), Compile / envVars := Map("KEY" -> "VALUE"), + Compile / run / forkOptions := Def.uncached( + (Compile / run / forkOptions).value.withWorkingDirectory(Some(baseDirectory.value)) + ), Test / javaOptions := Vector("Xmx512M"), Test / envVars := Map("KEY_TEST" -> "VALUE_TEST"), diff --git a/server-test/src/test/scala/testpkg/BuildServerTest.scala b/server-test/src/test/scala/testpkg/BuildServerTest.scala index 679f83f0a..d36aae8f7 100644 --- a/server-test/src/test/scala/testpkg/BuildServerTest.scala +++ b/server-test/src/test/scala/testpkg/BuildServerTest.scala @@ -472,8 +472,12 @@ class BuildServerTest extends AbstractServerTest { val id = sendRequest("buildTarget/scalaMainClasses", ScalaMainClassesParams(targets, None)) val res = svr.session.waitForResultInResponseMsg[ScalaMainClassesResult](30.seconds, id).get - val classes = res.items.flatMap(_.classes.map(_.`class`)) - assert(classes.contains("main.Main")) + val mainClasses = res.items.flatMap(_.classes) + assert(mainClasses.map(_.`class`).contains("main.Main")) + // JVM options and environment are derived from run / forkOptions + val main = mainClasses.find(_.`class` == "main.Main").get + assert(main.jvmOptions.contains("Xmx256M")) + assert(main.environmentVariables.contains("KEY=VALUE")) } test("buildTarget/run") { @@ -498,18 +502,23 @@ class BuildServerTest extends AbstractServerTest { test("buildTarget/jvmRunEnvironment") { val buildTarget = buildTargetUri("runAndTest", "Compile") - val targets = Vector(BuildTargetIdentifier(buildTarget)) + val utilTarget = buildTargetUri("util", "Compile") + val targets = Vector(buildTarget, utilTarget).map(BuildTargetIdentifier.apply) val id = sendRequest("buildTarget/jvmRunEnvironment", JvmRunEnvironmentParams(targets, None)) val res = svr.session.waitForResultInResponseMsg[JvmRunEnvironmentResult](10.seconds, id).get - val item = res.items.head + val item = res.items.find(_.target.uri == buildTarget).get assert( item.classpath.exists(_.toString.contains("jsoniter-scala-core_2.13-2.13.11.jar")), "classpath should contain compile dependency" ) assert(item.jvmOptions.contains("Xmx256M")) assert(item.environmentVariables == Map("KEY" -> "VALUE")) - assert(item.workingDirectory.contains("/buildserver/run-and-test")) + // run / forkOptions is honored for the run environment + assert(item.workingDirectory.endsWith("/run-and-test")) + // by default, forked run inherits sbt's working directory + val utilItem = res.items.find(_.target.uri == utilTarget).get + assert(utilItem.workingDirectory.endsWith("/buildserver")) } test("buildTarget/jvmTestEnvironment") { @@ -529,6 +538,8 @@ class BuildServerTest extends AbstractServerTest { ) assert(item.jvmOptions.contains("Xmx512M")) assert(item.environmentVariables == Map("KEY_TEST" -> "VALUE_TEST")) + // forked tests keep the project's baseDirectory as their working directory + assert(item.workingDirectory.endsWith("/run-and-test")) } test("buildTarget/scalaTestClasses") {