[2.x] feat: support passing JVM arguments via -- delimiter in run commands (#8868)

Summary
- Adds support for passing JVM arguments inline to `run`, `runMain`, `bgRun`, `bgRunMain`, and `fgRun`/`fgRunMain` using `--` as a delimiter
- Syntax: `run <jvmArgs> -- <appArgs>` (e.g., `run -Xmx2G -Dapp.mode=debug -- arg1 arg2`)
- Fully backward compatible — without `--`, all arguments are treated as app args as before
- When `fork` is `false`, a warning is logged that JVM arguments will be ignored
This commit is contained in:
Renzo 2026-03-02 20:49:10 -08:00 committed by GitHub
parent 09c4856409
commit b1db6ba44d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 143 additions and 23 deletions

View File

@ -1953,11 +1953,15 @@ object Defaults extends BuildCommon {
val parser =
loadForParser(discoveredMainClasses)((s, names) => runMainParser(s, names getOrElse Nil))
Def.inputTask {
val (mainClass, args) = parser.parsed
val (mainClass, allArgs) = parser.parsed
val (jvmArgs, appArgs) = RunUtil.splitArgs(allArgs)
val cp = classpath.value
val fo = (run / forkOptions).value
val log = streams.value.log
given FileConverter = fileConverter.value
scalaRun.value
.run(mainClass, cp.files, args, streams.value.log)
val (modifiedRun, _) = RunUtil.applyJvmArgs(scalaRun.value, jvmArgs, fo, log)
modifiedRun
.run(mainClass, cp.files, appArgs, log)
.get
}
}

View File

@ -12,9 +12,43 @@ import sbt.internal.worker.{ ClientJobParams, FilePath, JvmRunInfo, RunInfo }
import sbt.io.IO
import sbt.protocol.Serialization
import sbt.util.CacheImplicits.given
import sbt.util.Logger
import xsbti.FileConverter
object RunUtil:
/**
* Split arguments at the first `--` delimiter.
* Tokens before `--` are JVM args; tokens after are app args.
* If no `--` is present, all tokens are app args (backward compatible).
*/
private[sbt] def splitArgs(args: Seq[String]): (Seq[String], Seq[String]) =
val idx = args.indexOf("--")
if idx < 0 then (Nil, args)
else (args.take(idx), args.drop(idx + 1))
/**
* Apply CLI JVM args to the ScalaRun and ForkOptions.
* For ForkRun: creates new ForkRun with augmented runJVMOptions.
* For Run (non-fork): warns and returns unchanged.
*/
private[sbt] def applyJvmArgs(
scalaRun: ScalaRun,
jvmArgs: Seq[String],
fo: ForkOptions,
log: Logger,
): (ScalaRun, ForkOptions) =
if jvmArgs.isEmpty then (scalaRun, fo)
else
scalaRun match
case _: ForkRun =>
val newFo = fo.withRunJVMOptions(fo.runJVMOptions ++ jvmArgs)
(new ForkRun(newFo), newFo)
case _ =>
log.warn(
s"JVM options (${jvmArgs.mkString(" ")}) will be ignored, fork is set to false"
)
(scalaRun, fo)
private def setWindowTitle(title: String): Unit =
if System.console() != null && System.getenv("TERM") != null then
scala.Console.print(s"\u001b]0;$title\u0007")
@ -38,11 +72,14 @@ object RunUtil:
): Initialize[InputTask[Unit]] =
val parser = Def.spaceDelimited()
Def.inputTask {
val in = parser.parsed
val (jvmArgs, appArgs) = splitArgs(parser.parsed)
val mainClass = getMainClass(mainClassTask.value)
val cp = classpath.value
val fo = (run / forkOptions).value
val log = streams.value.log
given FileConverter = fileConverter.value
scalaRun.value.run(mainClass, cp.files, in, streams.value.log).get
val (modifiedRun, _) = applyJvmArgs(scalaRun.value, jvmArgs, fo, log)
modifiedRun.run(mainClass, cp.files, appArgs, log).get
}
def configTasks(c: ScopeAxis[ConfigKey]): Seq[Setting[?]] = Seq(
@ -136,9 +173,12 @@ object RunUtil:
val conv = fileConverter.value
given FileConverter = conv
val service = bgJobService.value
val (mainClass, args) = parser.parsed
val (mainClass, allArgs) = parser.parsed
val (jvmArgs, appArgs) = splitArgs(allArgs)
val hashClasspath = (bgRunMain / bgHashClasspath).value
val fo = (run / forkOptions).value
val log = streams.value.log
val (modifiedRun, modifiedFo) = applyJvmArgs(scalaRun.value, jvmArgs, fo, log)
val state = Keys.state.value
val windowTitle = mkWindowTitle("runMain", organization.value, name.value, version.value)
if clientRun.value && state.isNetworkCommand then
@ -149,7 +189,8 @@ object RunUtil:
workingDir,
conv,
)
val info = mkRunInfo(args.toVector, mainClass, cp, fo, conv, Some(windowTitle))
val info =
mkRunInfo(appArgs.toVector, mainClass, cp, modifiedFo, conv, Some(windowTitle))
val result = ClientJobParams(
runInfo = info
)
@ -168,15 +209,15 @@ object RunUtil:
hashClasspath,
conv,
)
scalaRun.value match
modifiedRun match
case r: Run =>
val loader = r.newLoader(cp.files)
(
Some(loader),
wrapper(() => r.runWithLoader(loader, cp.files, mainClass, args, logger).get)
wrapper(() => r.runWithLoader(loader, cp.files, mainClass, appArgs, logger).get)
)
case sr =>
(None, wrapper(() => sr.run(mainClass, cp.files, args, logger).get))
(None, wrapper(() => sr.run(mainClass, cp.files, appArgs, logger).get))
service.waitForTry(handle).get
()
}
@ -192,11 +233,13 @@ object RunUtil:
Def.inputTask {
val conv = fileConverter.value
given FileConverter = conv
val args = parser.parsed
val (jvmArgs, appArgs) = splitArgs(parser.parsed)
val service = bgJobService.value
val mainClass = getMainClass(mainClassTask.value)
val hashClasspath = (bgRun / bgHashClasspath).value
val fo = (run / forkOptions).value
val log = streams.value.log
val (modifiedRun, modifiedFo) = applyJvmArgs(scalaRun.value, jvmArgs, fo, log)
val state = Keys.state.value
val windowTitle = mkWindowTitle("run", organization.value, name.value, version.value)
if clientRun.value && state.isNetworkCommand then
@ -207,7 +250,7 @@ object RunUtil:
workingDir,
conv,
)
val info = mkRunInfo(args.toVector, mainClass, cp, fo, conv, Some(windowTitle))
val info = mkRunInfo(appArgs.toVector, mainClass, cp, modifiedFo, conv, Some(windowTitle))
val result = ClientJobParams(
runInfo = info
)
@ -226,15 +269,15 @@ object RunUtil:
hashClasspath,
conv
)
scalaRun.value match
modifiedRun match
case r: Run =>
val loader = r.newLoader(cp.files)
(
Some(loader),
wrapper(() => r.runWithLoader(loader, cp.files, mainClass, args, logger).get)
wrapper(() => r.runWithLoader(loader, cp.files, mainClass, appArgs, logger).get)
)
case sr =>
(None, wrapper(() => sr.run(mainClass, cp.files, args, logger).get))
(None, wrapper(() => sr.run(mainClass, cp.files, appArgs, logger).get))
service.waitForTry(handle).get
()
}
@ -250,8 +293,12 @@ object RunUtil:
)
Def.inputTask {
val service = bgJobService.value
val (mainClass, args) = parser.parsed
val (mainClass, allArgs) = parser.parsed
val (jvmArgs, appArgs) = splitArgs(allArgs)
val hashClasspath = (bgRunMain / bgHashClasspath).value
val fo = (run / forkOptions).value
val log = streams.value.log
val (modifiedRun, _) = applyJvmArgs(scalaRun.value, jvmArgs, fo, log)
val wrapper = termWrapper(canonicalInput.value, echoInput.value)
val converter = fileConverter.value
setWindowTitle(mkWindowTitle("bgRunMain", organization.value, name.value, version.value))
@ -268,15 +315,15 @@ object RunUtil:
)
else classpath.value
given FileConverter = fileConverter.value
scalaRun.value match
modifiedRun match
case r: Run =>
val loader = r.newLoader(cp.files)
(
Some(loader),
wrapper(() => r.runWithLoader(loader, cp.files, mainClass, args, logger).get)
wrapper(() => r.runWithLoader(loader, cp.files, mainClass, appArgs, logger).get)
)
case sr =>
(None, wrapper(() => sr.run(mainClass, cp.files, args, logger).get))
(None, wrapper(() => sr.run(mainClass, cp.files, appArgs, logger).get))
}
}
@ -289,10 +336,13 @@ object RunUtil:
): Initialize[InputTask[JobHandle]] =
val parser = Def.spaceDelimited()
Def.inputTask {
val args = parser.parsed
val (jvmArgs, appArgs) = splitArgs(parser.parsed)
val service = bgJobService.value
val mainClass = getMainClass(mainClassTask.value)
val hashClasspath = (bgRun / bgHashClasspath).value
val fo = (run / forkOptions).value
val log = streams.value.log
val (modifiedRun, _) = applyJvmArgs(scalaRun.value, jvmArgs, fo, log)
val wrapper = termWrapper(canonicalInput.value, echoInput.value)
val converter = fileConverter.value
setWindowTitle(mkWindowTitle("bgRun", organization.value, name.value, version.value))
@ -309,15 +359,15 @@ object RunUtil:
)
else classpath.value
given FileConverter = converter
scalaRun.value match
modifiedRun match
case r: Run =>
val loader = r.newLoader(cp.files)
(
Some(loader),
wrapper(() => r.runWithLoader(loader, cp.files, mainClass, args, logger).get)
wrapper(() => r.runWithLoader(loader, cp.files, mainClass, appArgs, logger).get)
)
case sr =>
(None, wrapper(() => sr.run(mainClass, cp.files, args, logger).get))
(None, wrapper(() => sr.run(mainClass, cp.files, appArgs, logger).get))
}
}
end RunUtil

View File

@ -16,6 +16,13 @@ object RunUtilTest extends Properties:
override def tests: List[Test] = List(
property("mkWindowTitle formats title correctly", testMkWindowTitle),
property("mkWindowTitle handles empty strings", testMkWindowTitleEmpty),
property("splitArgs with no -- treats all as app args", testSplitArgsNoDash),
property("splitArgs splits at first --", testSplitArgsWithDash),
property("splitArgs with only -- gives empty jvm and app args", testSplitArgsOnlyDash),
property("splitArgs with -- at start gives empty jvm args", testSplitArgsDashAtStart),
property("splitArgs with -- at end gives empty app args", testSplitArgsDashAtEnd),
property("splitArgs with multiple -- splits at first only", testSplitArgsMultipleDash),
property("splitArgs with empty input", testSplitArgsEmpty),
)
def testMkWindowTitle: Property =
@ -34,4 +41,48 @@ object RunUtilTest extends Properties:
yield
val result = RunUtil.mkWindowTitle("run", "", "", "")
Result.assert(result == "sbt run: % % ")
def testSplitArgsNoDash: Property =
for _ <- Gen.constant(()).forAll
yield
val (jvm, app) = RunUtil.splitArgs(Seq("arg1", "arg2"))
Result.assert(jvm.isEmpty).and(Result.assert(app == Seq("arg1", "arg2")))
def testSplitArgsWithDash: Property =
for _ <- Gen.constant(()).forAll
yield
val (jvm, app) = RunUtil.splitArgs(Seq("-Xmx2G", "-ea", "--", "arg1", "arg2"))
Result.assert(jvm == Seq("-Xmx2G", "-ea")).and(Result.assert(app == Seq("arg1", "arg2")))
def testSplitArgsOnlyDash: Property =
for _ <- Gen.constant(()).forAll
yield
val (jvm, app) = RunUtil.splitArgs(Seq("--"))
Result.assert(jvm.isEmpty).and(Result.assert(app.isEmpty))
def testSplitArgsDashAtStart: Property =
for _ <- Gen.constant(()).forAll
yield
val (jvm, app) = RunUtil.splitArgs(Seq("--", "arg1"))
Result.assert(jvm.isEmpty).and(Result.assert(app == Seq("arg1")))
def testSplitArgsDashAtEnd: Property =
for _ <- Gen.constant(()).forAll
yield
val (jvm, app) = RunUtil.splitArgs(Seq("-Xmx2G", "--"))
Result.assert(jvm == Seq("-Xmx2G")).and(Result.assert(app.isEmpty))
def testSplitArgsMultipleDash: Property =
for _ <- Gen.constant(()).forAll
yield
val (jvm, app) = RunUtil.splitArgs(Seq("-Xmx2G", "--", "arg1", "--", "arg2"))
Result
.assert(jvm == Seq("-Xmx2G"))
.and(Result.assert(app == Seq("arg1", "--", "arg2")))
def testSplitArgsEmpty: Property =
for _ <- Gen.constant(()).forAll
yield
val (jvm, app) = RunUtil.splitArgs(Seq.empty)
Result.assert(jvm.isEmpty).and(Result.assert(app.isEmpty))
end RunUtilTest

View File

@ -0,0 +1,2 @@
scalaVersion := "3.6.4"
run / fork := true

View File

@ -0,0 +1,8 @@
object Main {
def main(args: Array[String]): Unit = {
val prop = System.getProperty("test.prop")
assert(prop == "hello", s"Expected system property test.prop=hello but got '$prop'")
assert(args.toList == List("arg1", "arg2"), s"Expected args [arg1, arg2] but got ${args.toList}")
println("OK")
}
}

View File

@ -0,0 +1,5 @@
# Verify that JVM args can be passed via the -- delimiter
> run -Dtest.prop=hello -- arg1 arg2
# Verify runMain also supports JVM args via --
> runMain Main -Dtest.prop=hello -- arg1 arg2