fix: Use sbt script in BSP config instead of hardcoded Java path (#8920)

**Problem**

sbt bspConfig writes the absolute path of the current Java binary into .bsp/sbt.json. When the user switches Java versions (via sdkman, cs java, etc.) or removes that JDK, the IDE fails to start the sbt BSP server because the hardcoded path is stale or gone.

**Solution**

When an sbt launcher script is available (via `sbt.script` system property or PATH lookup), generate:

"argv": ["/path/to/sbt", "bsp"]
This commit is contained in:
BitToby 2026-03-20 02:57:53 +02:00 committed by Eugene Yokota
parent bf6bd68a96
commit ce638e7a0f
8 changed files with 156 additions and 114 deletions

View File

@ -1,84 +1,90 @@
package example.test
import scala.sys.process.*
import java.io.File
import java.util.Locale
import sbt.io.IO
import verify.BasicTestSuite
// Test for issues #7792/#7794: BSP config generation and argv execution
object BspConfigTest extends BasicTestSuite:
lazy val isWindows: Boolean =
sys.props("os.name").toLowerCase(Locale.ENGLISH).contains("windows")
lazy val sbtScript = IntegrationTestPaths.sbtScript(isWindows)
private def launcherCmd = LauncherTestHelper.launcherCommand(sbtScript.getAbsolutePath)
def sbtProcessInDir(dir: File)(args: String*) =
Process(
launcherCmd ++ args,
dir,
"JAVA_OPTS" -> "",
"SBT_OPTS" -> ""
)
test("sbt bspConfig") {
import ujson.*
IO.withTemporaryDirectory { tmp =>
// Create minimal build.sbt for the test project
IO.write(new File(tmp, "build.sbt"), """name := "test-bsp-config"""")
// Run bspConfig to generate .bsp/sbt.json
val configResult = sbtProcessInDir(tmp)("bspConfig", "--batch").!
assert(configResult == 0, s"bspConfig command failed with exit code $configResult")
// Verify .bsp/sbt.json exists
val bspFile = new File(tmp, ".bsp/sbt.json")
assert(bspFile.exists, ".bsp/sbt.json should exist after running bspConfig")
// Parse and verify JSON content
val content = IO.read(bspFile)
val json = ujson.read(content)
// Extract argv array from JSON
val argvValue = json.obj.get("argv")
assert(argvValue.isDefined, "argv field not found in sbt.json")
val argv = argvValue.get.arr.map(_.str).toVector
// Verify argv structure
assert(argv.nonEmpty, "argv should not be empty")
assert(argv.head.contains("java"), s"argv should start with java command, got: ${argv.head}")
assert(argv.contains("-bsp"), s"argv should contain -bsp flag, got: $argv")
// Test execution of the generated argv
// Run the BSP command with a very short timeout to verify it starts correctly
// We just need to verify the command doesn't fail immediately on startup
if (!isWindows) {
// On Unix, we can test the argv execution
// Create a process and check if it starts (will timeout waiting for BSP input)
val process = Process(argv.toSeq, tmp)
val processBuilder = process.run(ProcessLogger(_ => (), _ => ()))
// Give it a moment to fail if it's going to fail immediately
Thread.sleep(500)
// If still running, it means the BSP server started successfully
val isAlive = processBuilder.isAlive()
processBuilder.destroy()
// The process should either still be alive (waiting for BSP messages)
// or have exited with code 0 (graceful)
if (!isAlive) {
val exitCode = processBuilder.exitValue()
assert(
exitCode == 0 || exitCode == 143, // 143 = SIGTERM from destroy()
s"BSP process failed with exit code $exitCode"
)
}
}
}
()
}
end BspConfigTest
package example.test
import scala.sys.process.*
import java.io.File
import java.util.Locale
import sbt.io.IO
import verify.BasicTestSuite
// Test for issues #7792/#7794: BSP config generation and argv execution
object BspConfigTest extends BasicTestSuite:
lazy val isWindows: Boolean =
sys.props("os.name").toLowerCase(Locale.ENGLISH).contains("windows")
lazy val sbtScript = IntegrationTestPaths.sbtScript(isWindows)
private def launcherCmd = LauncherTestHelper.launcherCommand(sbtScript.getAbsolutePath)
def sbtProcessInDir(dir: File)(args: String*) =
Process(
launcherCmd ++ args,
dir,
"JAVA_OPTS" -> "",
"SBT_OPTS" -> ""
)
test("sbt bspConfig") {
import ujson.*
IO.withTemporaryDirectory { tmp =>
// Create minimal build.sbt for the test project
IO.write(new File(tmp, "build.sbt"), """name := "test-bsp-config"""")
// Run bspConfig to generate .bsp/sbt.json
val configResult = sbtProcessInDir(tmp)("bspConfig", "--batch").!
assert(configResult == 0, s"bspConfig command failed with exit code $configResult")
// Verify .bsp/sbt.json exists
val bspFile = new File(tmp, ".bsp/sbt.json")
assert(bspFile.exists, ".bsp/sbt.json should exist after running bspConfig")
// Parse and verify JSON content
val content = IO.read(bspFile)
val json = ujson.read(content)
// Extract argv array from JSON
val argvValue = json.obj.get("argv")
assert(argvValue.isDefined, "argv field not found in sbt.json")
val argv = argvValue.get.arr.map(_.str).toVector
// Verify argv structure
assert(argv.nonEmpty, "argv should not be empty")
// When sbt script is available, argv uses the sbt script with "bsp" command.
// When not, argv falls back to direct java invocation with "-bsp" flag.
val usesSbtScript = argv.last == "bsp" && !argv.head.contains("java")
val usesJavaDirect = argv.head.contains("java") && argv.contains("-bsp")
assert(
usesSbtScript || usesJavaDirect,
s"argv should either use sbt script with 'bsp' command or java with '-bsp' flag, got: $argv"
)
// Test execution of the generated argv
// Run the BSP command with a very short timeout to verify it starts correctly
// We just need to verify the command doesn't fail immediately on startup
if (!isWindows) {
// On Unix, we can test the argv execution
// Create a process and check if it starts (will timeout waiting for BSP input)
val process = Process(argv.toSeq, tmp)
val processBuilder = process.run(ProcessLogger(_ => (), _ => ()))
// Give it a moment to fail if it's going to fail immediately
Thread.sleep(500)
// If still running, it means the BSP server started successfully
val isAlive = processBuilder.isAlive()
processBuilder.destroy()
// The process should either still be alive (waiting for BSP messages)
// or have exited with code 0 (graceful)
if (!isAlive) {
val exitCode = processBuilder.exitValue()
assert(
exitCode == 0 || exitCode == 143, // 143 = SIGTERM from destroy()
s"BSP process failed with exit code $exitCode"
)
}
}
}
()
}
end BspConfigTest

View File

@ -442,6 +442,11 @@ if "%~0" == "shutdownall" (
goto args_loop
)
if "%~0" == "bsp" (
set sbt_args_client=0
goto args_loop
)
if "%~0" == "--script-version" (
set sbt_args_print_sbt_script_version=1
goto args_loop

View File

@ -1300,7 +1300,13 @@ object NetworkClient {
case "--sbt-launch-jar" if i + 1 < sanitized.length =>
i += 1
launchJar = Option(sanitized(i).replace("%20", " "))
case "-bsp" | "--bsp" => bsp = true
case "-bsp" | "--bsp" | "bsp" => bsp = true
case "-no-server" | "--no-server" =>
System.setProperty("sbt.server.autostart", "false")
case a if a.startsWith("--autostart=") =>
System.setProperty("sbt.server.autostart", a.stripPrefix("--autostart="))
case a if a.startsWith("-autostart=") =>
System.setProperty("sbt.server.autostart", a.stripPrefix("-autostart="))
case a if launcherValueFlags.contains(a) =>
if (i + 1 < sanitized.length) i += 1
case a if launcherNoValueFlags.contains(a) => ()

View File

@ -75,7 +75,7 @@ private[sbt] object xMain:
.map(_.trim)
.filterNot(_ == DashDashServer)
val isClient: String => Boolean = cmd => (cmd == JavaClient) || (cmd == DashDashClient)
val isBsp: String => Boolean = cmd => (cmd == "-bsp") || (cmd == "--bsp")
val isBsp: String => Boolean = cmd => (cmd == "-bsp") || (cmd == "--bsp") || (cmd == "bsp")
val isNew: String => Boolean = cmd => (cmd == "new") || (cmd == "init")
lazy val isServer = !userCommands.exists(c => isBsp(c) || isClient(c))
// keep this lazy to prevent project directory created prematurely

View File

@ -26,42 +26,48 @@ object BuildServerConnection {
private[sbt] def writeConnectionFile(sbtVersion: String, baseDir: File): Unit = {
val bspConnectionFile = new File(baseDir, ".bsp/sbt.json")
val javaHome = Util.javaHome
val classPath = System.getProperty("java.class.path")
val sbtScript = Option(System.getProperty("sbt.script"))
.map(_.replace("%20", " "))
.filter(_.nonEmpty)
.orElse(sbtScriptInPath)
.map(script => s"-Dsbt.script=$script")
val sbtOptsArgs = parseSbtOpts(sys.env.get("SBT_OPTS"))
val sbtLaunchJar = classPath
.split(File.pathSeparator)
.find(jar => SbtLaunchJar.findFirstIn(jar).nonEmpty)
.map(_.replace(" ", "%20"))
.map(jar => s"--sbt-launch-jar=$jar")
val argv =
Vector(
s"$javaHome/bin/java",
"-Xms100m",
"-Xmx100m",
) ++
sbtOptsArgs ++
Vector(
"-classpath",
classPath,
) ++
sbtScript ++
Vector("xsbt.boot.Boot", "-bsp") ++
(if (sbtScript.isEmpty) sbtLaunchJar else None)
val argv = sbtScript match
case Some(script) =>
Vector(script, "bsp")
case None =>
buildFallbackArgv
val details = BspConnectionDetails(name, sbtVersion, bspVersion, languages, argv)
val json = Converter.toJson(details).get
IO.write(bspConnectionFile, CompactPrinter(json), append = false)
}
private def sbtScriptInPath: Option[String] = {
private[sbt] def buildFallbackArgv: Vector[String] = {
val javaHome = Util.javaHome
val classPath = System.getProperty("java.class.path")
val sbtOptsArgs = parseSbtOpts(sys.env.get("SBT_OPTS"))
val sbtLaunchJar = classPath
.split(File.pathSeparator)
.find(jar => SbtLaunchJar.findFirstIn(jar).nonEmpty)
.map(_.replace(" ", "%20"))
.map(jar => s"--sbt-launch-jar=$jar")
Vector(
s"$javaHome/bin/java",
"-Xms100m",
"-Xmx100m",
) ++
sbtOptsArgs ++
Vector(
"-classpath",
classPath,
) ++
Vector("xsbt.boot.Boot", "-bsp") ++
sbtLaunchJar
}
private[sbt] def sbtScriptInPath: Option[String] = {
val fileName = if (Properties.isWin) "sbt.bat" else "sbt"
val envPath = sys.env.collectFirst {
case (k, v) if k.toUpperCase() == "PATH" => v
@ -72,7 +78,7 @@ object BuildServerConnection {
allPaths
.map(_.resolve(fileName))
.find(file => Files.exists(file) && Files.isExecutable(file))
.map(_.toString.replace(" ", "%20"))
.map(_.toString)
}
private[sbt] def parseSbtOpts(sbtOpts: Option[String]): Vector[String] =

View File

@ -45,3 +45,18 @@ object BuildServerConnectionSpec extends BasicTestSuite:
test("parseSbtOpts should handle whitespace-separated options"):
val result = BuildServerConnection.parseSbtOpts(Some(" -Dfoo=bar -Xmx1g "))
assert(result == Vector("-Dfoo=bar", "-Xmx1g"))
test("sbtScriptInPath should return None when sbt is not in PATH"):
val result = BuildServerConnection.sbtScriptInPath
result match
case Some(path) => assert(path.nonEmpty)
case None => assert(true)
test("buildFallbackArgv should include java path and -bsp flag"):
val argv = BuildServerConnection.buildFallbackArgv
assert(argv.head.contains("java"), s"argv should start with java, got: ${argv.head}")
assert(argv.contains("-bsp"), s"argv should contain -bsp, got: $argv")
assert(argv.contains("-Xms100m"), s"argv should contain -Xms100m, got: $argv")
assert(argv.contains("-Xmx100m"), s"argv should contain -Xmx100m, got: $argv")
assert(argv.contains("-classpath"), s"argv should contain -classpath, got: $argv")
assert(argv.contains("xsbt.boot.Boot"), s"argv should contain xsbt.boot.Boot, got: $argv")

1
sbt
View File

@ -770,6 +770,7 @@ process_args () {
-Dsbt.color=never|-Dsbt.log.noformat=true) addJava "$1" && use_colors=0 && shift ;;
"-D*"|-D*) addJava "$1" && shift ;;
-J*) addJava "${1:2}" && shift ;;
bsp) use_sbtn=0 && addResidual "-bsp" && shift ;;
*) addResidual "$1" && shift ;;
esac
done

View File

@ -51,8 +51,11 @@ object Main:
else System.err.println("[error] sbt requires at least JDK 8+, you have " + javaVer)
return 1
val bspMode = opts.residual.exists(a => a == "bsp" || a == "-bsp" || a == "--bsp")
val clientOpt = opts.client || sys.env.get("SBT_NATIVE_CLIENT").contains("true")
val useNativeClient = shouldRunNativeClient(opts.copy(client = clientOpt), buildPropsVersion)
val useNativeClient =
if bspMode then false
else shouldRunNativeClient(opts.copy(client = clientOpt), buildPropsVersion)
if useNativeClient then
val scriptPath = sbtBinDir.getAbsolutePath.replace("\\", "/") + "/sbt.bat"