Merge pull request #9169 from eed3si9n/bport/i9100

[2.0.x] bport: Run related fixes
This commit is contained in:
eugene yokota 2026-05-02 22:27:46 -04:00 committed by GitHub
commit 7088ad9884
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 337 additions and 54 deletions

View File

@ -318,6 +318,7 @@ class NetworkClient(
token = tkn,
skipAnalysis = Some(skipAnalysis),
canWork = Some(true),
subscribeToAll = Some(false),
)
val initCommand = InitCommand(
token = tkn, // duplicated with opts for compatibility

View File

@ -1976,11 +1976,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

@ -366,12 +366,11 @@ private[sbt] final class CommandExchange {
}
// This is an interface to directly notify events.
private[sbt] def notifyEvent[A: JsonFormat](method: String, params: A): Unit = {
channels.foreach {
case c: NetworkChannel => tryTo(_.notifyEvent(method, params))(c)
case _ =>
}
}
private[sbt] def notifyEvent[A: JsonFormat](method: String, params: A): Unit =
channels.foreach:
case c: NetworkChannel if c.subscribeToAll || isChannelOwner(c) =>
tryTo(_.notifyEvent(method, params))(c)
case _ =>
private def tryTo(f: NetworkChannel => Unit)(
channel: NetworkChannel
@ -418,12 +417,23 @@ private[sbt] final class CommandExchange {
}
def unprompt(event: ConsoleUnpromptEvent): Unit = channels.foreach(_.unprompt(event))
def logMessage(event: LogEvent): Unit = {
channels.foreach {
case c: NetworkChannel => tryTo(_.notifyEvent(event))(c)
case _ =>
}
}
def logMessage(event: LogEvent): Unit =
channels.foreach:
case c: NetworkChannel if c.subscribeToAll || isChannelOwner(c) =>
tryTo(_.notifyEvent(event))(c)
case _ =>
// Route a log event to a specific channel, independent of currentExec.
// Used for background job output so messages reach the originating client
// even after the spawning task has completed and currentExec has been cleared.
private[sbt] def logMessage(channelName: String, event: LogEvent): Unit =
channels.foreach:
case c: NetworkChannel if c.subscribeToAll || c.name == channelName =>
tryTo(_.notifyEvent(event))(c)
case _ =>
private def isChannelOwner(c: NetworkChannel): Boolean =
currentExec.exists(_.source.exists(_.channelName == c.name))
def notifyStatus(event: ExecStatusEvent): Unit = {
for {

View File

@ -123,7 +123,14 @@ object LogManager {
context: LoggerContext
): ManagedLogger = {
val console = ConsoleAppender.safe("bg-" + ConsoleAppender.generateName(), ITerminal.current)
LogManager.backgroundLog(data, state, task, console, relay(()), context)
// Use a channel-aware relay appender so background job log output reaches
// the originating client even after the spawning task completes and
// currentExec is cleared.
val channelName = state.currentCommand.flatMap(_.source.map(_.channelName))
val bgRelay = channelName match
case Some(_) => new RelayAppender("bg-Relay" + generateId.incrementAndGet, channelName)
case None => relay(())
LogManager.backgroundLog(data, state, task, console, bgRelay, context)
}
}

View File

@ -13,14 +13,19 @@ import sbt.internal.util.*
import sbt.protocol.LogEvent
import sbt.util.Level
class RelayAppender(override val name: String)
class RelayAppender(override val name: String, targetChannel: Option[String] = None)
extends ConsoleAppender(
name,
ConsoleAppender.Properties.from(ConsoleOut.NullConsoleOut, true, true),
_ => None
) {
def this(name: String) = this(name, None)
lazy val exchange = StandardMain.exchange
override def appendLog(level: Level.Value, message: => String): Unit = {
exchange.logMessage(LogEvent(level = level.toString, message = message))
val event = LogEvent(level = level.toString, message = message)
targetChannel match
case Some(ch) => exchange.logMessage(ch, event)
case None => exchange.logMessage(event)
}
}

View File

@ -12,9 +12,40 @@ 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 _ =>
(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 +69,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 +170,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 +186,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 +206,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 +230,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 +247,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 +266,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 +290,19 @@ 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
// Background runs must not inherit the terminal's stdin/stdout via connectInput.
// Without this, ForkRun.configLogged skips setting LoggedOutput, causing
// the fork to use inheritIO() which bypasses the background logger entirely.
val fo = (run / forkOptions).value.withConnectInput(false)
val log = streams.value.log
// applyJvmArgs only builds a new ForkRun when jvmArgs is non-empty, so force
// construction of a ForkRun that uses the updated ForkOptions above.
val (modifiedRun, _) = applyJvmArgs(scalaRun.value, jvmArgs, fo, log) match
case (_: ForkRun, _) => (new ForkRun(fo.withRunJVMOptions(fo.runJVMOptions ++ jvmArgs)), fo)
case other => other
val wrapper = termWrapper(canonicalInput.value, echoInput.value)
val converter = fileConverter.value
setWindowTitle(mkWindowTitle("bgRunMain", organization.value, name.value, version.value))
@ -268,15 +319,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 +340,20 @@ 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
// Background runs must not inherit the terminal's stdin/stdout via connectInput.
// Without this, ForkRun.configLogged skips setting LoggedOutput, causing
// the fork to use inheritIO() which bypasses the background logger entirely.
val fo = (run / forkOptions).value.withConnectInput(false)
val log = streams.value.log
// applyJvmArgs only builds a new ForkRun when jvmArgs is non-empty, so force
// construction of a ForkRun that uses the updated ForkOptions above.
val (modifiedRun, _) = applyJvmArgs(scalaRun.value, jvmArgs, fo, log) match
case (_: ForkRun, _) => (new ForkRun(fo.withRunJVMOptions(fo.runJVMOptions ++ jvmArgs)), fo)
case other => other
val wrapper = termWrapper(canonicalInput.value, echoInput.value)
val converter = fileConverter.value
setWindowTitle(mkWindowTitle("bgRun", organization.value, name.value, version.value))
@ -309,15 +370,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

@ -58,13 +58,13 @@ private[sbt] object LanguageServerProtocol {
)
)
val opt = Converter.fromJson[InitializeOption](optionJson).get
setInitializeOption(opt)
if (authOptions(ServerAuthentication.Token)) {
val token = opt.token.getOrElse(sys.error("'token' is missing."))
if (authenticate(token)) ()
else throw LangServerError(ErrorCodes.InvalidRequest, "invalid token")
} else ()
setInitialized(true)
setInitializeOption(opt)
if (!opt.skipAnalysis.getOrElse(false)) appendExec("collectAnalyses", None)
jsonRpcRespond(InitializeResult(serverCapabilities), Some(r.id))

View File

@ -165,6 +165,10 @@ final class NetworkChannel(
case _ => false
}
/** True if this channel should receive broadcast events (logMessage, notifyEvent). Default true for backward compatibility. */
private[sbt] def subscribeToAll: Boolean =
Option(initializeOption.get).flatMap(_.subscribeToAll).getOrElse(false)
protected def authenticate(token: String): Boolean = instance.authenticate(token)
protected def setInitialized(value: Boolean): Unit = initialized = value

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

@ -11,23 +11,25 @@ package sbt.internal.protocol
final class InitializeOption private (
val token: Option[String],
val skipAnalysis: Option[Boolean],
val canWork: Option[Boolean]) extends Serializable {
val canWork: Option[Boolean],
val subscribeToAll: Option[Boolean]) extends Serializable {
private def this(token: Option[String]) = this(token, None, None)
private def this(token: Option[String], skipAnalysis: Option[Boolean]) = this(token, skipAnalysis, None)
private def this(token: Option[String]) = this(token, None, None, None)
private def this(token: Option[String], skipAnalysis: Option[Boolean]) = this(token, skipAnalysis, None, None)
private def this(token: Option[String], skipAnalysis: Option[Boolean], canWork: Option[Boolean]) = this(token, skipAnalysis, canWork, None)
override def equals(o: Any): Boolean = this.eq(o.asInstanceOf[AnyRef]) || (o match {
case x: InitializeOption => (this.token == x.token) && (this.skipAnalysis == x.skipAnalysis) && (this.canWork == x.canWork)
case x: InitializeOption => (this.token == x.token) && (this.skipAnalysis == x.skipAnalysis) && (this.canWork == x.canWork) && (this.subscribeToAll == x.subscribeToAll)
case _ => false
})
override def hashCode: Int = {
37 * (37 * (37 * (37 * (17 + "sbt.internal.protocol.InitializeOption".##) + token.##) + skipAnalysis.##) + canWork.##)
37 * (37 * (37 * (37 * (37 * (17 + "sbt.internal.protocol.InitializeOption".##) + token.##) + skipAnalysis.##) + canWork.##) + subscribeToAll.##)
}
override def toString: String = {
"InitializeOption(" + token + ", " + skipAnalysis + ", " + canWork + ")"
"InitializeOption(" + token + ", " + skipAnalysis + ", " + canWork + ", " + subscribeToAll + ")"
}
private def copy(token: Option[String] = token, skipAnalysis: Option[Boolean] = skipAnalysis, canWork: Option[Boolean] = canWork): InitializeOption = {
new InitializeOption(token, skipAnalysis, canWork)
private def copy(token: Option[String] = token, skipAnalysis: Option[Boolean] = skipAnalysis, canWork: Option[Boolean] = canWork, subscribeToAll: Option[Boolean] = subscribeToAll): InitializeOption = {
new InitializeOption(token, skipAnalysis, canWork, subscribeToAll)
}
def withToken(token: Option[String]): InitializeOption = {
copy(token = token)
@ -47,6 +49,12 @@ final class InitializeOption private (
def withCanWork(canWork: Boolean): InitializeOption = {
copy(canWork = Option(canWork))
}
def withSubscribeToAll(subscribeToAll: Option[Boolean]): InitializeOption = {
copy(subscribeToAll = subscribeToAll)
}
def withSubscribeToAll(subscribeToAll: Boolean): InitializeOption = {
copy(subscribeToAll = Option(subscribeToAll))
}
}
object InitializeOption {
@ -56,4 +64,6 @@ object InitializeOption {
def apply(token: String, skipAnalysis: Boolean): InitializeOption = new InitializeOption(Option(token), Option(skipAnalysis))
def apply(token: Option[String], skipAnalysis: Option[Boolean], canWork: Option[Boolean]): InitializeOption = new InitializeOption(token, skipAnalysis, canWork)
def apply(token: String, skipAnalysis: Boolean, canWork: Boolean): InitializeOption = new InitializeOption(Option(token), Option(skipAnalysis), Option(canWork))
def apply(token: Option[String], skipAnalysis: Option[Boolean], canWork: Option[Boolean], subscribeToAll: Option[Boolean]): InitializeOption = new InitializeOption(token, skipAnalysis, canWork, subscribeToAll)
def apply(token: String, skipAnalysis: Boolean, canWork: Boolean, subscribeToAll: Boolean): InitializeOption = new InitializeOption(Option(token), Option(skipAnalysis), Option(canWork), Option(subscribeToAll))
}

View File

@ -14,8 +14,9 @@ given InitializeOptionFormat: JsonFormat[sbt.internal.protocol.InitializeOption]
val token = unbuilder.readField[Option[String]]("token")
val skipAnalysis = unbuilder.readField[Option[Boolean]]("skipAnalysis")
val canWork = unbuilder.readField[Option[Boolean]]("canWork")
val subscribeToAll = unbuilder.readField[Option[Boolean]]("subscribeToAll")
unbuilder.endObject()
sbt.internal.protocol.InitializeOption(token, skipAnalysis, canWork)
sbt.internal.protocol.InitializeOption(token, skipAnalysis, canWork, subscribeToAll)
case None =>
deserializationError("Expected JsObject but found None")
}
@ -25,6 +26,7 @@ given InitializeOptionFormat: JsonFormat[sbt.internal.protocol.InitializeOption]
builder.addField("token", obj.token)
builder.addField("skipAnalysis", obj.skipAnalysis)
builder.addField("canWork", obj.canWork)
builder.addField("subscribeToAll", obj.subscribeToAll)
builder.endObject()
}
}

View File

@ -22,4 +22,5 @@ type InitializeOption {
token: String
skipAnalysis: Boolean @since("1.4.0")
canWork: Boolean @since("1.10.8")
subscribeToAll: Boolean @since("2.0.0")
}

View File

@ -0,0 +1,43 @@
import sbt.internal.LogManager
import sbt.internal.util.{ Appender, ConsoleAppender, ConsoleOut }
import java.io.{ FileWriter, PrintWriter }
lazy val checkBgOutput = taskKey[Unit]("Verify the bgRun forked process output was logged")
lazy val waitForAllBgJobs = taskKey[Unit]("Wait for every bgJobService job to terminate")
lazy val outFile = settingKey[File]("File where bgRun output is captured for the test")
lazy val root = project
.in(file("."))
.settings(
run / fork := true,
outFile := baseDirectory.value / "target" / "bg-output.log",
// Override logManager so the background logger's relay appender writes
// to a file. This lets the test assert that forked-process output
// reached the managed logger (rather than going to the JVM's stdout via
// inheritIO, which is what happens when the bgRun fork-output bug is
// present). In a scripted test there are no network channels, so the
// default relay appender has no observable effect anyway.
logManager := {
val ea = extraAppenders.value
val f = outFile.value
IO.touch(f)
val fileRelay: Unit => Appender = _ => {
val pw = new PrintWriter(new FileWriter(f, true), true)
ConsoleAppender("bg-file-test", ConsoleOut.printWriterOut(pw))
}
LogManager.withLoggers(
screen = (task, state) => ConsoleAppender(s"screen-${task.key.label}"),
relay = fileRelay,
extra = ea
)
},
waitForAllBgJobs := Def.uncached {
val service = bgJobService.value
service.jobs.foreach(service.waitFor)
},
checkBgOutput := Def.uncached {
val f = outFile.value
val content = IO.read(f)
assert(content.contains("foobar"), s"Expected 'foobar' in $f, got:\n$content")
}
)

View File

@ -0,0 +1 @@
@main def test: Unit = println("foobar")

View File

@ -0,0 +1,3 @@
> bgRun
> waitForAllBgJobs
> checkBgOutput

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

View File

@ -0,0 +1,2 @@
name := "client-subscription"
scalaVersion := "2.12.19"

View File

@ -0,0 +1,2 @@
# Exercise server client path (subscribe-to-all by default). Closes #4399.
> show name

View File

@ -0,0 +1,48 @@
/*
* sbt
* Copyright 2023, Scala center
* Copyright 2011 - 2022, Lightbend, Inc.
* Copyright 2008 - 2010, Mark Harrah
* Licensed under Apache License 2.0 (see LICENSE)
*/
package testpkg
import scala.concurrent.duration.*
class ClientSubscriptionTest extends AbstractServerTest {
override val testDirectory: String = "handshake"
test("subscribe-to-all (default) client receives broadcast build/logMessage when command runs") {
svr.sendJsonRpc(
"""{ "jsonrpc": "2.0", "id": 2, "method": "sbt/exec", "params": { "commandLine": "show name" } }"""
)
def isLogMessageNotification(line: String): Boolean =
line.contains("\"method\":\"build/logMessage\"") || line.contains(
"\"method\": \"build/logMessage\""
)
assert(
svr.waitForString(10.seconds)(isLogMessageNotification),
"subscribe-to-all client must receive broadcast build/logMessage when a command produces log output"
)
}
}
class ClientNoSubscriptionTest extends AbstractServerTest {
override val testDirectory: String = "handshake"
override def subscribeToAllForTest: Boolean = false
test("non-subscribed client receives build/logMessage for its own command") {
svr.sendJsonRpc(
"""{ "jsonrpc": "2.0", "id": 2, "method": "sbt/exec", "params": { "commandLine": "show name" } }"""
)
def isLogMessageNotification(line: String): Boolean =
line.contains("\"method\":\"build/logMessage\"") || line.contains(
"\"method\": \"build/logMessage\""
)
assert(
svr.waitForString(10.seconds)(isLogMessageNotification),
"non-subscribed client must still receive build/logMessage for its own command"
)
}
}

View File

@ -31,6 +31,7 @@ trait AbstractServerTest extends AnyFunSuite with BeforeAndAfterAll {
var svr: TestServer = scala.compiletime.uninitialized
def testDirectory: String
def testPath: Path = temp.toPath.resolve(testDirectory)
def subscribeToAllForTest: Boolean = true
private val targetDir: File = {
val p0 = new File("..").getAbsoluteFile.getCanonicalFile / "target"
@ -48,7 +49,14 @@ trait AbstractServerTest extends AnyFunSuite with BeforeAndAfterAll {
val classpath = TestProperties.classpath.split(File.pathSeparator).map(new File(_))
val sbtVersion = TestProperties.version
val scalaVersion = TestProperties.scalaVersion
svr = TestServer.get(testDirectory, scalaVersion, sbtVersion, classpath.toSeq, temp)
svr = TestServer.get(
testDirectory,
scalaVersion,
sbtVersion,
classpath.toSeq,
temp,
subscribeToAllForTest
)
}
override protected def afterAll(): Unit = {
svr.bye()
@ -71,13 +79,14 @@ object TestServer {
scalaVersion: String,
sbtVersion: String,
classpath: Seq[File],
temp: File
temp: File,
subscribeToAll: Boolean = true
): TestServer = {
println(s"Starting test server $testBuild")
IO.copyDirectory(serverTestBase / testBuild, temp / testBuild)
// Each test server instance will be executed in a Thread pool separated from the tests
val testServer = TestServer(temp / testBuild, scalaVersion, sbtVersion, classpath)
val testServer =
TestServer(temp / testBuild, scalaVersion, sbtVersion, classpath, subscribeToAll)
// checking last log message after initialization
// if something goes wrong here the communication streams are corrupted, restarting
val init =
@ -155,7 +164,8 @@ case class TestServer(
baseDirectory: File,
scalaVersion: String,
sbtVersion: String,
classpath: Seq[File]
classpath: Seq[File],
subscribeToAll: Boolean = true
) {
import TestServer.hostLog
@ -227,8 +237,11 @@ case class TestServer(
}
// initiate handshake
private val initOptions =
if subscribeToAll then """{ "skipAnalysis": true, "canWork": true }"""
else """{ "skipAnalysis": true, "canWork": true, "subscribeToAll": false }"""
sendJsonRpc(
s"""{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "initializationOptions": { "skipAnalysis": true, "canWork": true } } }"""
s"""{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "initializationOptions": $initOptions } }"""
)
def test(f: TestServer => Future[Unit]): Future[Unit] = f(this)