[2.x] Fix console output not appearing with bgRun and run / fork := true (#9166)

Bug 1: bgRun forks with inheritIO() instead of LoggedOutput

bgRunTask / bgRunMainTask resolve their fork options via (run / forkOptions), which inherits run / connectInput := true. That has two downstream consequences:

1. ForkRun.configLogged (in run/src/main/scala/sbt/Run.scala) skips installing OutputStrategy.LoggedOutput(log) whenever config.connectInput == true.
2. Fork.apply (in run/src/main/scala/sbt/Fork.scala) sees connectInput && outputStrategy == StdoutOutput and takes the interactiveFork path, which calls jpb.inheritIO().

Bug 2: Background-job log relay drops messages after the spawning task ends

Even after Bug 1 is fixed, LoggedOutput routes the forked process's stdout into the background ManagedLogger, whose appenders include a shared RelayAppender. The relay calls CommandExchange.logMessage(event), which uses isChannelOwner(c) to pick the target channel.
This commit is contained in:
Matt Dziuban 2026-05-02 14:04:10 -04:00 committed by GitHub
parent 0af7c2f4a6
commit 5757955fc7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 89 additions and 7 deletions

View File

@ -424,6 +424,15 @@ private[sbt] final class CommandExchange {
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))

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

@ -293,9 +293,16 @@ object RunUtil:
val (mainClass, allArgs) = parser.parsed
val (jvmArgs, appArgs) = splitArgs(allArgs)
val hashClasspath = (bgRunMain / bgHashClasspath).value
val fo = (run / forkOptions).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
val (modifiedRun, _) = applyJvmArgs(scalaRun.value, jvmArgs, fo, 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))
@ -337,9 +344,16 @@ object RunUtil:
val service = bgJobService.value
val mainClass = getMainClass(mainClassTask.value)
val hashClasspath = (bgRun / bgHashClasspath).value
val fo = (run / forkOptions).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
val (modifiedRun, _) = applyJvmArgs(scalaRun.value, jvmArgs, fo, 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))

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