diff --git a/.github/workflows/client-test.yml b/.github/workflows/client-test.yml index 2189aef13..23453c215 100644 --- a/.github/workflows/client-test.yml +++ b/.github/workflows/client-test.yml @@ -51,8 +51,9 @@ jobs: if: ${{ matrix.os == 'ubuntu-latest' }} shell: bash run: | - # test building sbtn on Linux + # test building sbtn and sbtw on Linux sbt "-Dsbt.io.virtual=false" nativeImage + sbt "-Dsbt.io.virtual=false" sbtwProj/nativeImage # smoke test native Image ./client/target/bin/sbtn --sbt-script=$(pwd)/sbt about ./client/target/bin/sbtn --sbt-script=$(pwd)/sbt shutdown @@ -69,8 +70,9 @@ jobs: if: ${{ matrix.os == 'macos-latest' }} shell: bash run: | - # test building sbtn on macOS + # test building sbtn and sbtw on macOS ./sbt "-Dsbt.io.virtual=false" nativeImage + ./sbt "-Dsbt.io.virtual=false" sbtwProj/nativeImage # test launcher script launcher-package/bin/coursier resolve sbt -Dsbt.build.version=$TEST_SBT_VER launcherPackageIntegrationTest/test @@ -84,19 +86,24 @@ jobs: max_attempts: 3 shell: bash command: | - # test building sbtn on Windows + # test building sbtn and sbtw on Windows sbt "-Dsbt.io.virtual=false" nativeImage + sbt "-Dsbt.io.virtual=false" sbtwProj/nativeImage - name: Client test (Windows) if: ${{ matrix.os == 'windows-latest' }} shell: bash run: | - # smoke test native Image + # smoke test native Image (sbtn) ./client/target/bin/sbtn --sbt-script=$(pwd)/launcher-package/src/universal/bin/sbt.bat about ./client/target/bin/sbtn --sbt-script=$(pwd)/launcher-package/src/universal/bin/sbt.bat shutdown + # verify sbtw native image was built (we do not smoke-test it: scopt uses lazy vals that misbehave under Graal native-image; sbtw is tested as JVM runner below) + test -f sbtw/target/bin/sbtw.exe || test -f sbtw/target/bin/sbtw # test launcher script echo build using JDK 17 test using JDK 17 and JDK 25 launcher-package/bin/coursier.bat resolve sbt -Dsbt.build.version=$TEST_SBT_VER launcherPackageIntegrationTest/test + # integration tests with sbtw as drop-in for sbt.bat (JVM runner) + sbt -Dsbt.build.version=$TEST_SBT_VER -Dsbt.test.useSbtw=true launcherPackageIntegrationTest/test cd launcher-package/citest ./test.bat test3/test3.bat diff --git a/build.sbt b/build.sbt index 8a3dbb2d4..2f9b00287 100644 --- a/build.sbt +++ b/build.sbt @@ -971,6 +971,38 @@ def scriptedTask(launch: Boolean): Def.Initialize[InputTask[Unit]] = Def.inputTa } lazy val publishLauncher = TaskKey[Unit]("publish-launcher") +val createSbtwBinDir = taskKey[Unit]("Create sbtw target/bin before native-image") + +lazy val sbtwProj = (project in file("sbtw")) + .enablePlugins(NativeImagePlugin) + .settings( + commonSettings, + name := "sbtw", + description := "Windows drop-in launcher for sbt (replaces sbt.bat)", + scalaVersion := "3.3.7", + crossPaths := false, + Compile / mainClass := Some("sbtw.Main"), + libraryDependencies += "com.github.scopt" %% "scopt" % "4.1.0", + nativeImageVersion := "23.0", + nativeImageJvm := "graalvm-java23", + createSbtwBinDir := { + val d = (target.value / "bin").toPath + if (!Files.exists(d)) Files.createDirectories(d) + }, + Compile / nativeImage := (Compile / nativeImage).dependsOn(createSbtwBinDir).value, + nativeImageOutput := { + val outputDir = (target.value / "bin").toPath + if (!Files.exists(outputDir)) Files.createDirectories(outputDir) + outputDir.resolve("sbtw").toFile + }, + nativeImageOptions ++= Seq( + "--no-fallback", + s"--initialize-at-run-time=sbtw", + "-H:+ReportExceptionStackTraces", + s"-H:Name=${(target.value / "bin" / "sbtw").getAbsolutePath}", + ), + Utils.noPublish, + ) def allProjects = Seq( @@ -990,6 +1022,7 @@ def allProjects = sbtProj, bundledLauncherProj, sbtClientProj, + sbtwProj, buildFileProj, utilCache, utilTracking, @@ -1347,6 +1380,7 @@ lazy val lmCoursierShadedPublishing = project lazy val launcherPackage = (project in file("launcher-package")) lazy val launcherPackageIntegrationTest = (project in (file("launcher-package") / "integration-test")) + .dependsOn(sbtwProj) .settings( name := "integration-test", scalaVersion := scala3, @@ -1359,6 +1393,14 @@ lazy val launcherPackageIntegrationTest = ), testFrameworks += TestFramework("hedgehog.sbt.Framework"), testFrameworks += TestFramework("verify.runner.Framework"), + Test / fork := true, + Test / javaOptions += { + val cp = (Test / fullClasspath).value + .map(_.data.getAbsolutePath) + .mkString(java.io.File.pathSeparator) + s"-Dsbt.test.classpath=$cp" + }, + Test / javaOptions += s"-Dsbt.test.integrationtest.basedir=${(baseDirectory).value.getAbsolutePath}", Test / test := { (Test / test) .dependsOn(launcherPackage / Universal / packageBin) diff --git a/launcher-package/integration-test/src/test/scala/BspConfigTest.scala b/launcher-package/integration-test/src/test/scala/BspConfigTest.scala index 1a825074c..70c179fdf 100644 --- a/launcher-package/integration-test/src/test/scala/BspConfigTest.scala +++ b/launcher-package/integration-test/src/test/scala/BspConfigTest.scala @@ -10,13 +10,13 @@ import verify.BasicTestSuite object BspConfigTest extends BasicTestSuite: lazy val isWindows: Boolean = sys.props("os.name").toLowerCase(Locale.ENGLISH).contains("windows") - lazy val sbtScript = - if (isWindows) new File("launcher-package/target/universal/stage/bin/sbt.bat") - else new File("launcher-package/target/universal/stage/bin/sbt") + lazy val sbtScript = IntegrationTestPaths.sbtScript(isWindows) + + private def launcherCmd = LauncherTestHelper.launcherCommand(sbtScript.getAbsolutePath) def sbtProcessInDir(dir: File)(args: String*) = Process( - Seq(sbtScript.getAbsolutePath) ++ args, + launcherCmd ++ args, dir, "JAVA_OPTS" -> "", "SBT_OPTS" -> "" diff --git a/launcher-package/integration-test/src/test/scala/ExtendedRunnerTest.scala b/launcher-package/integration-test/src/test/scala/ExtendedRunnerTest.scala index 14dec7316..2929df95f 100755 --- a/launcher-package/integration-test/src/test/scala/ExtendedRunnerTest.scala +++ b/launcher-package/integration-test/src/test/scala/ExtendedRunnerTest.scala @@ -12,21 +12,21 @@ object ExtendedRunnerTest extends BasicTestSuite: lazy val isWindows: Boolean = sys.props("os.name").toLowerCase(Locale.ENGLISH).contains("windows") lazy val isMac: Boolean = sys.props("os.name").toLowerCase(Locale.ENGLISH).contains("mac") - lazy val sbtScript = - if (isWindows) new File("launcher-package/target/universal/stage/bin/sbt.bat") - else new File("launcher-package/target/universal/stage/bin/sbt") + lazy val sbtScript = IntegrationTestPaths.sbtScript(isWindows) + + private def launcherCmd = LauncherTestHelper.launcherCommand(sbtScript.getAbsolutePath) def sbtProcess(args: String*) = sbtProcessWithOpts(args*)("", "") def sbtProcessWithOpts(args: String*)(javaOpts: String, sbtOpts: String) = Process( - Seq(sbtScript.getAbsolutePath) ++ args, - new File("launcher-package/citest"), + launcherCmd ++ args, + IntegrationTestPaths.citestDir("citest"), "JAVA_OPTS" -> javaOpts, "SBT_OPTS" -> sbtOpts ) def sbtProcessInDir(dir: File)(args: String*) = Process( - Seq(sbtScript.getAbsolutePath) ++ args, + launcherCmd ++ args, dir, "JAVA_OPTS" -> "", "SBT_OPTS" -> "" diff --git a/launcher-package/integration-test/src/test/scala/IntegrationTestPaths.scala b/launcher-package/integration-test/src/test/scala/IntegrationTestPaths.scala new file mode 100644 index 000000000..081cf8ec7 --- /dev/null +++ b/launcher-package/integration-test/src/test/scala/IntegrationTestPaths.scala @@ -0,0 +1,32 @@ +package example.test + +import java.io.File + +/** + * Resolves paths for launcher-package integration tests relative to the + * integration-test project base directory. The build injects + * sbt.test.integrationtest.basedir when Test/fork is true so paths are + * correct regardless of the forked JVM's working directory (e.g. on Windows). + */ +object IntegrationTestPaths { + private val baseDir: Option[File] = + sys.props.get("sbt.test.integrationtest.basedir").map(new File(_)) + + def sbtScript(isWindows: Boolean): File = + baseDir match { + case Some(b) => + val name = if (isWindows) "sbt.bat" else "sbt" + new File(b.getParentFile, s"target/universal/stage/bin/$name").getAbsoluteFile + case None => + val rel = if (isWindows) "../target/universal/stage/bin/sbt.bat" else "../target/universal/stage/bin/sbt" + new File(rel).getAbsoluteFile + } + + def citestDir(citestVariant: String = "citest"): File = + baseDir match { + case Some(b) => + new File(b.getParentFile, citestVariant).getAbsoluteFile + case None => + new File("..", citestVariant).getAbsoluteFile + } +} diff --git a/launcher-package/integration-test/src/test/scala/LauncherTestHelper.scala b/launcher-package/integration-test/src/test/scala/LauncherTestHelper.scala new file mode 100644 index 000000000..31da6d5d8 --- /dev/null +++ b/launcher-package/integration-test/src/test/scala/LauncherTestHelper.scala @@ -0,0 +1,23 @@ +package example.test + +import java.util.Locale + +/** + * Shared helper for launcher integration tests. When sbt.test.useSbtw=true on Windows, + * tests use sbtw (JVM) as the runner instead of sbt.bat, to validate sbtw as a drop-in. + */ +object LauncherTestHelper { + def isWindows: Boolean = + sys.props("os.name").toLowerCase(Locale.ENGLISH).contains("windows") + + def useSbtw: Boolean = + isWindows && sys.props.get("sbt.test.useSbtw").contains("true") + + /** Command prefix to run the launcher: either script path or java -cp sbtw.Main */ + def launcherCommand(scriptPath: String): Seq[String] = + if (useSbtw) { + val cp = sys.props.get("sbt.test.classpath").getOrElse(System.getProperty("java.class.path")) + Seq("java", "-cp", cp, "sbtw.Main") + } else + Seq(scriptPath) +} diff --git a/launcher-package/integration-test/src/test/scala/ShellScriptUtil.scala b/launcher-package/integration-test/src/test/scala/ShellScriptUtil.scala index 22de0f5a3..3d8f28f18 100644 --- a/launcher-package/integration-test/src/test/scala/ShellScriptUtil.scala +++ b/launcher-package/integration-test/src/test/scala/ShellScriptUtil.scala @@ -10,7 +10,7 @@ trait ShellScriptUtil extends BasicTestSuite { val isWindows: Boolean = sys.props("os.name").toLowerCase(java.util.Locale.ENGLISH).contains("windows") - protected val javaBinDir = new File("launcher-package/integration-test/bin").getAbsolutePath + protected val javaBinDir = new File("bin").getAbsolutePath protected def retry[A1](f: () => A1, maxAttempt: Int = 10): A1 = try { @@ -22,9 +22,7 @@ trait ShellScriptUtil extends BasicTestSuite { case e: Exception => throw e } - val sbtScript = - if (isWindows) new File("launcher-package/target/universal/stage/bin/sbt.bat") - else new File("launcher-package/target/universal/stage/bin/sbt") + val sbtScript = IntegrationTestPaths.sbtScript(isWindows) /** * testOutput is a helper function to create a test for shell script. @@ -47,7 +45,7 @@ trait ShellScriptUtil extends BasicTestSuite { else test(name) { val workingDirectory = Files.createTempDirectory("sbt-launcher-package-test").toFile - val citestDir = new File("launcher-package", citestVariant) + val citestDir = IntegrationTestPaths.citestDir(citestVariant) // Clean target directory if it exists to avoid copying temporary files that may be deleted during copy val targetDir = new File(citestDir, "target") if (targetDir.exists()) { @@ -159,9 +157,10 @@ trait ShellScriptUtil extends BasicTestSuite { else envVars("PATH") = javaBinDir + File.pathSeparator + path + val cmd = LauncherTestHelper.launcherCommand(testSbtScript.getAbsolutePath) ++ args val out = scala.sys.process .Process( - Seq(testSbtScript.getAbsolutePath) ++ args, + cmd, workingDirectory, envVars.toSeq* ) diff --git a/sbtw/build.sbt b/sbtw/build.sbt new file mode 100644 index 000000000..e0c90c6a3 --- /dev/null +++ b/sbtw/build.sbt @@ -0,0 +1 @@ +// sbtw project is defined in the root build.sbt (sbtwProj) diff --git a/sbtw/src/main/scala/sbtw/ArgParser.scala b/sbtw/src/main/scala/sbtw/ArgParser.scala new file mode 100644 index 000000000..719a5b9a4 --- /dev/null +++ b/sbtw/src/main/scala/sbtw/ArgParser.scala @@ -0,0 +1,56 @@ +package sbtw + +import scopt.OParser + +object ArgParser: + + def parse(args: Array[String]): Option[LauncherOptions] = + val b = OParser.builder[LauncherOptions] + val parser = + import b.* + OParser.sequence( + programName("sbtw"), + head("sbtw", "Windows launcher for sbt"), + opt[Unit]('h', "help").action((_, c) => c.copy(help = true)), + opt[Unit]('v', "verbose").action((_, c) => c.copy(verbose = true)), + opt[Unit]('d', "debug").action((_, c) => c.copy(debug = true)), + opt[Unit]('V', "version").action((_, c) => c.copy(version = true)), + opt[Unit]("numeric-version").action((_, c) => c.copy(numericVersion = true)), + opt[Unit]("script-version").action((_, c) => c.copy(scriptVersion = true)), + opt[Unit]("shutdownall").action((_, c) => c.copy(shutdownAll = true)), + opt[Unit]("allow-empty").action((_, c) => c.copy(allowEmpty = true)), + opt[Unit]("sbt-create").action((_, c) => c.copy(allowEmpty = true)), + opt[Unit]("client").action((_, c) => c.copy(client = true)), + opt[Unit]("server").action((_, c) => c.copy(server = true)), + opt[Unit]("jvm-client").action((_, c) => c.copy(jvmClient = true)), + opt[Unit]("no-server").action((_, c) => c.copy(noServer = true)), + opt[Unit]("no-colors").action((_, c) => c.copy(noColors = true)), + opt[Unit]("no-global").action((_, c) => c.copy(noGlobal = true)), + opt[Unit]("no-share").action((_, c) => c.copy(noShare = true)), + opt[Unit]("no-hide-jdk-warnings").action((_, c) => c.copy(noHideJdkWarnings = true)), + opt[Unit]("debug-inc").action((_, c) => c.copy(debugInc = true)), + opt[Unit]("timings").action((_, c) => c.copy(timings = true)), + opt[Unit]("traces").action((_, c) => c.copy(traces = true)), + opt[Unit]("batch").action((_, c) => c.copy(batch = true)), + opt[String]("sbt-dir").action((x, c) => c.copy(sbtDir = Some(x))), + opt[String]("sbt-boot").action((x, c) => c.copy(sbtBoot = Some(x))), + opt[String]("sbt-cache").action((x, c) => c.copy(sbtCache = Some(x))), + opt[String]("sbt-jar").action((x, c) => c.copy(sbtJar = Some(x))), + opt[String]("sbt-version").action((x, c) => c.copy(sbtVersion = Some(x))), + opt[String]("ivy").action((x, c) => c.copy(ivy = Some(x))), + opt[Int]("mem").action((x, c) => c.copy(mem = Some(x))), + opt[String]("supershell").action((x, c) => c.copy(supershell = Some(x))), + opt[String]("color").action((x, c) => c.copy(color = Some(x))), + opt[Int]("jvm-debug").action((x, c) => c.copy(jvmDebug = Some(x))), + opt[String]("java-home").action((x, c) => c.copy(javaHome = Some(x))), + arg[String]("") + .unbounded() + .optional() + .action((x, c) => c.copy(residual = c.residual :+ x)), + ) + OParser + .parse(parser, args, LauncherOptions()) + .map: opts => + val sbtNew = opts.residual.contains("new") || opts.residual.contains("init") + opts.copy(sbtNew = sbtNew) +end ArgParser diff --git a/sbtw/src/main/scala/sbtw/ConfigLoader.scala b/sbtw/src/main/scala/sbtw/ConfigLoader.scala new file mode 100644 index 000000000..f814ab064 --- /dev/null +++ b/sbtw/src/main/scala/sbtw/ConfigLoader.scala @@ -0,0 +1,61 @@ +package sbtw + +import java.io.File +import scala.io.Source +import scala.util.Using + +object ConfigLoader: + + def loadLines(file: File): Seq[String] = + if !file.isFile then Nil + else + try + Using.resource(Source.fromFile(file))(_.getLines().toList.flatMap: line => + val trimmed = line.trim + if trimmed.isEmpty || trimmed.startsWith("#") then Nil + else Seq(trimmed) + ) + catch { case _: Exception => Nil } + + def loadSbtOpts(cwd: File, sbtHome: File): Seq[String] = + val fromProject = new File(cwd, ".sbtopts") + val fromConfig = new File(sbtHome, "conf/sbtopts") + val fromEtc = new File("/etc/sbt/sbtopts") + val fromSbtConfig = new File(sbtHome, "conf/sbtconfig.txt") + val fromEnv = sys.env.get("SBT_OPTS").toSeq.flatMap(_.split("\\s+").filter(_.nonEmpty)) + val fromProjectLines = loadLines(fromProject).map(stripJ) + val fromConfigLines = loadLines(fromConfig) + val fromEtcLines = loadLines(fromEtc) + val fromSbtConfigLines = loadLines(fromSbtConfig) + (fromEtcLines ++ fromConfigLines ++ fromSbtConfigLines ++ fromEnv ++ fromProjectLines).toSeq + + def loadJvmOpts(cwd: File): Seq[String] = + val fromProject = new File(cwd, ".jvmopts") + val fromEnv = sys.env.get("JAVA_OPTS").toSeq.flatMap(_.split("\\s+").filter(_.nonEmpty)) + fromEnv ++ loadLines(fromProject) + + private def stripJ(s: String): String = if s.startsWith("-J") then s.substring(2).trim else s + + def defaultJavaOpts: Seq[String] = Seq("-Dfile.encoding=UTF-8") + def defaultSbtOpts: Seq[String] = Nil + + def sbtVersionFromBuildProperties(projectDir: File): Option[String] = + val f = new File(projectDir, "project/build.properties") + if !f.isFile then None + else + try + Using.resource(Source.fromFile(f)): src => + src + .getLines() + .map(_.trim) + .filterNot(_.startsWith("#")) + .find(line => line.startsWith("sbt.version") && line.contains("=")) + .flatMap: line => + val eq = line.indexOf('=') + if eq >= 0 then Some(line.substring(eq + 1).trim).filter(_.nonEmpty) + else None + catch { case _: Exception => None } + + def isSbtProjectDir(dir: File): Boolean = + new File(dir, "build.sbt").isFile || new File(dir, "project/build.properties").isFile +end ConfigLoader diff --git a/sbtw/src/main/scala/sbtw/LauncherOptions.scala b/sbtw/src/main/scala/sbtw/LauncherOptions.scala new file mode 100644 index 000000000..bc95e8f55 --- /dev/null +++ b/sbtw/src/main/scala/sbtw/LauncherOptions.scala @@ -0,0 +1,42 @@ +package sbtw + +case class LauncherOptions( + help: Boolean = false, + verbose: Boolean = false, + debug: Boolean = false, + version: Boolean = false, + numericVersion: Boolean = false, + scriptVersion: Boolean = false, + shutdownAll: Boolean = false, + allowEmpty: Boolean = false, + client: Boolean = false, + jvmClient: Boolean = false, + noServer: Boolean = false, + noColors: Boolean = false, + noGlobal: Boolean = false, + noShare: Boolean = false, + noHideJdkWarnings: Boolean = false, + debugInc: Boolean = false, + timings: Boolean = false, + traces: Boolean = false, + batch: Boolean = false, + sbtDir: Option[String] = None, + sbtBoot: Option[String] = None, + sbtCache: Option[String] = None, + sbtJar: Option[String] = None, + sbtVersion: Option[String] = None, + ivy: Option[String] = None, + mem: Option[Int] = None, + supershell: Option[String] = None, + color: Option[String] = None, + jvmDebug: Option[Int] = None, + javaHome: Option[String] = None, + server: Boolean = false, + residual: Seq[String] = Nil, + sbtNew: Boolean = false, +) + +object LauncherOptions: + val defaultMemMb = 1024 + val initSbtVersion = "_to_be_replaced" +end LauncherOptions diff --git a/sbtw/src/main/scala/sbtw/Main.scala b/sbtw/src/main/scala/sbtw/Main.scala new file mode 100644 index 000000000..52e2920b0 --- /dev/null +++ b/sbtw/src/main/scala/sbtw/Main.scala @@ -0,0 +1,164 @@ +package sbtw + +import java.io.File +import scala.sys.process.* + +object Main: + + def main(args: Array[String]): Unit = + val cwd = new File(sys.props("user.dir")) + val sbtHome = new File( + sys.env + .get("SBT_HOME") + .getOrElse: + sys.env.get("SBT_BIN_DIR").map(d => new File(d).getParent).getOrElse(cwd.getAbsolutePath) + ) + val sbtBinDir = new File(sbtHome, "bin") + + val fileSbtOpts = ConfigLoader.loadSbtOpts(cwd, sbtHome) + val fileArgs = fileSbtOpts.flatMap(_.split("\\s+").filter(_.nonEmpty)) + val allArgs = fileArgs ++ args + + ArgParser.parse(allArgs.toArray) match + case None => System.exit(1) + case Some(opts) => + val exitCode = run(cwd, sbtHome, sbtBinDir, opts) + System.exit(if exitCode == 0 then 0 else 1) + + private def run(cwd: File, sbtHome: File, sbtBinDir: File, opts: LauncherOptions): Int = + if opts.help then return printUsage() + if opts.version || opts.numericVersion || opts.scriptVersion then + return handleVersionCommands(cwd, sbtHome, sbtBinDir, opts) + if opts.shutdownAll then + val javaCmd = Runner.findJavaCmd(opts.javaHome) + return Runner.shutdownAll(javaCmd) + + if !opts.allowEmpty && !opts.sbtNew && !ConfigLoader.isSbtProjectDir(cwd) then + System.err.println( + "[error] Neither build.sbt nor a 'project' directory in the current directory: " + cwd + ) + System.err.println("[error] run 'sbt new', touch build.sbt, or run 'sbt --allow-empty'.") + return 1 + + val buildPropsVersion = ConfigLoader.sbtVersionFromBuildProperties(cwd) + val clientOpt = opts.client || sys.env.get("SBT_NATIVE_CLIENT").contains("true") + val useNativeClient = shouldRunNativeClient(opts.copy(client = clientOpt), buildPropsVersion) + + if useNativeClient then + val scriptPath = sbtBinDir.getAbsolutePath.replace("\\", "/") + "/sbt.bat" + return Runner.runNativeClient(sbtBinDir, scriptPath, opts) + + val javaCmd = Runner.findJavaCmd(opts.javaHome) + val javaVer = Runner.javaVersion(javaCmd) + if javaVer > 0 && javaVer < 8 then + System.err.println("[error] sbt requires at least JDK 8+, you have " + javaVer) + return 1 + + val sbtJar = opts.sbtJar + .filter(p => new File(p).isFile) + .getOrElse(new File(sbtBinDir, "sbt-launch.jar").getAbsolutePath) + if !new File(sbtJar).isFile then + System.err.println("[error] Launcher jar not found: " + sbtJar) + return 1 + + var javaOpts = ConfigLoader.loadJvmOpts(cwd) + if javaOpts.isEmpty then javaOpts = ConfigLoader.defaultJavaOpts + var sbtOpts = Runner.buildSbtOpts(opts) + + val (residualJava, bootArgs) = Runner.splitResidual(opts.residual) + javaOpts = javaOpts ++ residualJava + + val (finalJava, finalSbt) = if opts.mem.isDefined then + val evictedJava = Memory.evictMemoryOpts(javaOpts) + val evictedSbt = Memory.evictMemoryOpts(sbtOpts) + val memOpts = Memory.addMemory(opts.mem.get, javaVer) + (evictedJava ++ memOpts, evictedSbt) + else Memory.addDefaultMemory(javaOpts, sbtOpts, javaVer, LauncherOptions.defaultMemMb) + sbtOpts = finalSbt + + if !opts.noHideJdkWarnings && javaVer == 25 then + sbtOpts = sbtOpts ++ Seq( + "--sun-misc-unsafe-memory-access=allow", + "--enable-native-access=ALL-UNNAMED" + ) + val javaOptsWithDebug = opts.jvmDebug.fold(finalJava)(port => + finalJava :+ s"-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=$port" + ) + + Runner.runJvm(javaCmd, javaOptsWithDebug, sbtOpts, sbtJar, bootArgs, opts.verbose) + + private def shouldRunNativeClient( + opts: LauncherOptions, + buildPropsVersion: Option[String] + ): Boolean = + if opts.sbtNew then return false + if opts.jvmClient then return false + val version = buildPropsVersion.getOrElse(LauncherOptions.initSbtVersion) + val parts = version.split("[.-]").take(2).flatMap(s => scala.util.Try(s.toInt).toOption) + val (major, minor) = (parts.lift(0).getOrElse(0), parts.lift(1).getOrElse(0)) + if major >= 2 then !opts.server + else if major >= 1 && minor >= 4 then opts.client + else false + + private def handleVersionCommands( + cwd: File, + sbtHome: File, + sbtBinDir: File, + opts: LauncherOptions + ): Int = + if opts.scriptVersion then + println(LauncherOptions.initSbtVersion) + return 0 + val javaCmd = Runner.findJavaCmd(opts.javaHome) + val sbtJar = opts.sbtJar + .filter(p => new File(p).isFile) + .getOrElse(new File(sbtBinDir, "sbt-launch.jar").getAbsolutePath) + if !new File(sbtJar).isFile then + System.err.println("[error] Launcher jar not found for version check") + return 1 + if opts.numericVersion then + try + val out = Process(Seq(javaCmd, "-jar", sbtJar, "sbtVersion")).!! + println(out.linesIterator.toSeq.lastOption.map(_.trim).getOrElse("")) + return 0 + catch { case _: Exception => return 1 } + if opts.version then + if ConfigLoader.isSbtProjectDir(cwd) then + val out = + try Process(Seq(javaCmd, "-jar", sbtJar, "sbtVersion")).!! + catch { case _: Exception => "" } + val ver = out.linesIterator.toSeq.lastOption.map(_.trim).getOrElse("") + println("sbt version in this project: " + ver) + println("sbt runner version: " + LauncherOptions.initSbtVersion) + System.err.println("[info] sbt runner (sbtw) is a runner to run any declared version of sbt.") + System.err.println( + "[info] Actual version of sbt is declared using project\\build.properties for each build." + ) + return 0 + 0 + + private def printUsage(): Int = + println(""" + |Usage: sbtw [options] + | + | -h | --help print this message + | -v | --verbose this runner is chattier + | -V | --version print sbt version information + | --numeric-version print the numeric sbt version + | --script-version print the version of sbt script + | shutdownall shutdown all running sbt processes + | -d | --debug set sbt log level to debug + | --allow-empty start sbt even if current directory contains no sbt project + | --client run native client (sbt 1.4+) + | --server run JVM launcher (disable native client, sbt 2.x) + | --jvm-client run JVM client + | --mem set memory options (default: 1024) + | --sbt-version use the specified version of sbt + | --sbt-jar use the specified jar as the sbt launcher + | --java-home alternate JAVA_HOME + | -Dkey=val pass -Dkey=val to the JVM + | -X pass -X to the JVM (e.g. -Xmx1G) + | -J-X pass -X to the JVM (-J is stripped) + |""".stripMargin) + 0 +end Main diff --git a/sbtw/src/main/scala/sbtw/Memory.scala b/sbtw/src/main/scala/sbtw/Memory.scala new file mode 100644 index 000000000..c8b0dde3f --- /dev/null +++ b/sbtw/src/main/scala/sbtw/Memory.scala @@ -0,0 +1,56 @@ +package sbtw + +object Memory: + + private val memoryOptPrefixes = Set( + "-Xmx", + "-Xms", + "-Xss", + "-XX:MaxPermSize", + "-XX:MaxMetaspaceSize", + "-XX:ReservedCodeCacheSize", + "-XX:+UseCGroupMemoryLimitForHeap", + "-XX:MaxRAM", + "-XX:InitialRAMPercentage", + "-XX:MaxRAMPercentage", + "-XX:MinRAMPercentage" + ) + + def hasMemoryOpts(opts: Seq[String]): Boolean = + opts.exists(o => memoryOptPrefixes.exists(p => o.startsWith(p))) + + def evictMemoryOpts(opts: Seq[String]): Seq[String] = + opts.filter(o => !memoryOptPrefixes.exists(p => o.startsWith(p))) + + def addMemory(memMb: Int, javaVersion: Int): Seq[String] = + var codecache = memMb / 8 + if codecache > 512 then codecache = 512 + if codecache < 128 then codecache = 128 + val classMetadataSize = codecache * 2 + val base = Seq( + s"-Xms${memMb}m", + s"-Xmx${memMb}m", + "-Xss4M", + s"-XX:ReservedCodeCacheSize=${codecache}m" + ) + if javaVersion < 8 then base :+ s"-XX:MaxPermSize=${classMetadataSize}m" + else base + + def addDefaultMemory( + javaOpts: Seq[String], + sbtOpts: Seq[String], + javaVersion: Int, + defaultMemMb: Int + ): (Seq[String], Seq[String]) = + val fromJava = hasMemoryOpts(javaOpts) + val fromTool = + sys.env.get("JAVA_TOOL_OPTIONS").exists(s => hasMemoryOpts(s.split("\\s+").toSeq)) + val fromJdk = sys.env.get("JDK_JAVA_OPTIONS").exists(s => hasMemoryOpts(s.split("\\s+").toSeq)) + val fromSbt = hasMemoryOpts(sbtOpts) + if fromJava || fromTool || fromJdk || fromSbt then (javaOpts, sbtOpts) + else + val evictedJava = evictMemoryOpts(javaOpts) + val evictedSbt = evictMemoryOpts(sbtOpts) + val memOpts = addMemory(defaultMemMb, javaVersion) + (evictedJava ++ memOpts, evictedSbt) +end Memory diff --git a/sbtw/src/main/scala/sbtw/Runner.scala b/sbtw/src/main/scala/sbtw/Runner.scala new file mode 100644 index 000000000..06bc6d7e9 --- /dev/null +++ b/sbtw/src/main/scala/sbtw/Runner.scala @@ -0,0 +1,141 @@ +package sbtw + +import java.io.File +import java.lang.ProcessBuilder as JProcessBuilder +import scala.sys.process.* + +object Runner: + + def findJavaCmd(javaHome: Option[String]): String = + val cmd = javaHome match + case Some(h) => + val exe = new File(h, "bin/java.exe") + if exe.isFile then exe.getAbsolutePath + else + sys.env + .get("JAVACMD") + .orElse( + sys.env.get("JAVA_HOME").map(h0 => new File(h0, "bin/java.exe").getAbsolutePath) + ) + .getOrElse("java") + case None => + sys.env + .get("JAVACMD") + .orElse(sys.env.get("JAVA_HOME").map(h => new File(h, "bin/java.exe").getAbsolutePath)) + .getOrElse("java") + cmd.replace("\"", "") + + def javaVersion(javaCmd: String): Int = + try + val pb = Process(Seq(javaCmd, "-Xms32M", "-Xmx32M", "-version")) + val out = pb.!! + val line = out.linesIterator.find(_.contains("version")).getOrElse("") + val quoted = line.split("\"").lift(1).getOrElse("") + val parts = quoted.replaceFirst("^1\\.", "").split("[.-_]") + val major = parts.headOption.flatMap(s => scala.util.Try(s.toInt).toOption).getOrElse(0) + if quoted.startsWith("1.") && parts.nonEmpty then + scala.util.Try(parts(0).toInt).toOption.getOrElse(major) + else major + catch { case _: Exception => 0 } + + def buildSbtOpts(opts: LauncherOptions): Seq[String] = + var s: Seq[String] = Nil + if opts.debug then s = s :+ "-debug" + if opts.debugInc then s = s :+ "-Dxsbt.inc.debug=true" + if opts.noColors then s = s :+ "-Dsbt.log.noformat=true" + if opts.noGlobal then s = s :+ "-Dsbt.global.base=project/.sbtboot" + if opts.noShare then + s = s ++ Seq( + "-Dsbt.global.base=project/.sbtboot", + "-Dsbt.boot.directory=project/.boot", + "-Dsbt.ivy.home=project/.ivy" + ) + opts.supershell.foreach(v => s = s :+ s"-Dsbt.supershell=$v") + opts.sbtVersion.foreach(v => s = s :+ s"-Dsbt.version=$v") + opts.sbtDir.foreach(v => s = s :+ s"-Dsbt.global.base=$v") + opts.sbtBoot.foreach(v => s = s :+ s"-Dsbt.boot.directory=$v") + opts.sbtCache.foreach(v => s = s :+ s"-Dsbt.global.localcache=$v") + opts.ivy.foreach(v => s = s :+ s"-Dsbt.ivy.home=$v") + opts.color.foreach(v => s = s :+ s"-Dsbt.color=$v") + if opts.timings then + s = s ++ Seq("-Dsbt.task.timings=true", "-Dsbt.task.timings.on.shutdown=true") + if opts.traces then s = s :+ "-Dsbt.traces=true" + if opts.noServer then s = s ++ Seq("-Dsbt.io.virtual=false", "-Dsbt.server.autostart=false") + if opts.jvmClient then s = s :+ "--client" + s + + def runNativeClient(sbtBinDir: File, scriptPath: String, opts: LauncherOptions): Int = + val sbtn = new File(sbtBinDir, "sbtn-x86_64-pc-win32.exe") + if !sbtn.isFile then + System.err.println("[error] sbtn-x86_64-pc-win32.exe not found in " + sbtBinDir) + return 1 + val args = Seq("--sbt-script=" + scriptPath.replace(" ", "%20")) ++ + (if opts.verbose then Seq("-v") else Nil) ++ + opts.residual + val cmd = sbtn.getAbsolutePath +: args + if opts.verbose then + System.err.println("# running native client") + cmd.foreach(a => System.err.println(a)) + val proc = Process(cmd, None, "SBT_SCRIPT" -> scriptPath) + proc.! + + def runJvm( + javaCmd: String, + javaOpts: Seq[String], + sbtOpts: Seq[String], + sbtJar: String, + bootArgs: Seq[String], + verbose: Boolean + ): Int = + val toolOpts = + sys.env.get("JAVA_TOOL_OPTIONS").toSeq.flatMap(_.split("\\s+").filter(_.nonEmpty)) + val jdkOpts = sys.env.get("JDK_JAVA_OPTIONS").toSeq.flatMap(_.split("\\s+").filter(_.nonEmpty)) + val fullJavaOpts = javaOpts ++ sbtOpts ++ toolOpts ++ jdkOpts + val cmd = Seq(javaCmd) ++ fullJavaOpts ++ Seq("-cp", sbtJar, "xsbt.boot.Boot") ++ bootArgs + if verbose then + System.err.println("# Executing command line:") + cmd.foreach(a => System.err.println(if a.contains(" ") then s""""$a"""" else a)) + val jpb = new JProcessBuilder(cmd*) + jpb.inheritIO() + val p = jpb.start() + try + p.waitFor() + p.exitValue() + finally if p.isAlive then p.destroy() + + def shutdownAll(javaCmd: String): Int = + try + val jpsOut = Process(Seq("jps", "-lv")).!! + val pids = jpsOut.linesIterator + .filter(_.contains("xsbt.boot.Boot")) + .flatMap: line => + val pidStr = line.trim.takeWhile(_.isDigit) + if pidStr.nonEmpty then scala.util.Try(pidStr.toLong).toOption else None + .toList + pids.foreach: pid => + try Process(Seq("taskkill", "/F", "/PID", pid.toString)).! + catch { case _: Exception => } + System.err.println(s"shutdown ${pids.size} sbt processes") + 0 + catch { case _: Exception => 1 } + + def splitResidual(residual: Seq[String]): (Seq[String], Seq[String]) = + var javaOpts: Seq[String] = Nil + var bootArgs: Seq[String] = Nil + var i = 0 + while i < residual.size do + val a = residual(i) + if a.startsWith("-J") then javaOpts = javaOpts :+ a.drop(2) + else if a.startsWith("-X") then javaOpts = javaOpts :+ a + else if a.startsWith("-D") && a.contains("=") then bootArgs = bootArgs :+ a + else if a.startsWith("-D") && i + 1 < residual.size then + bootArgs = bootArgs :+ s"$a=${residual(i + 1)}" + i += 1 + else if a.startsWith("-XX") && a.contains("=") then bootArgs = bootArgs :+ a + else if a.startsWith("-XX") && i + 1 < residual.size then + bootArgs = bootArgs :+ s"$a=${residual(i + 1)}" + i += 1 + else bootArgs = bootArgs :+ a + i += 1 + (javaOpts, bootArgs) +end Runner