[1.x] fix: Pump forked stdout/stderr to the captured active terminal (#9185)

Since #8678 (sbt 1.12.2), Fork.apply has routed the
`connectInput && outputStrategy == StdoutOutput` case through a new
JProcess path that calls `jpb.inheritIO()`. `inheritIO` ties the child
JVM's stdio to the sbt server JVM's OS-level fd 1/2, bypassing the
`Terminal` indirection. In server mode (which `sbt --client` uses),
that indirection is how task output reaches the client terminal — so
the child's stdout/stderr went nowhere observable to the client.

Keep `redirectInput(Redirect.INHERIT)` so REPL/raw-mode keystrokes
still reach the child (preserving the use case #8678 added), but
leave stdout/stderr as PIPE and pump them to the active terminal's
output/error streams in two daemon threads. The pump threads are
joined after `p.waitFor()` so all buffered output drains before the
task completes.

The active terminal is captured once on the task thread rather than
re-resolved per-write via the `System.out`/`err` proxies, otherwise a
concurrent terminal swap can route mid-pump bytes to the wrong client
(observed on the CI JDK 8 matrix; the proxy's `activeTerminal.get()`
is evaluated at every write).

Forced scala/scala3 CI to downgrade from sbt 1.12.10 to 1.12.1 (see
scala/scala3#25995).

Diagnosis credit: @mbovel in #9185.

Fixes sbt/sbt#9185.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brian Hotopp 2026-05-24 11:28:57 -04:00
parent 2fe2e10cc9
commit 0c03718218
4 changed files with 49 additions and 5 deletions

View File

@ -8,12 +8,11 @@
package sbt
import java.io.File
import java.io.PrintWriter
import java.io.{ File, IOException, InputStream, OutputStream, PrintWriter }
import java.lang.ProcessBuilder.Redirect
import scala.sys.process.Process
import OutputStrategy._
import sbt.internal.util.{ RunningProcesses, Util }
import sbt.internal.util.{ RunningProcesses, Terminal, Util }
import Util.{ AnyOps, none }
import java.lang.{ ProcessBuilder => JProcessBuilder, Process => JProcess }
@ -214,14 +213,24 @@ object Fork {
val environment: List[(String, String)] = env.toList ++ extraEnv
workingDirectory.foreach(jpb.directory(_))
environment.foreach { case (k, v) => jpb.environment.put(k, v) }
jpb.inheritIO()
// Inherit stdin for REPL/raw-mode keystrokes. Stdout/stderr stay PIPE so
// `blockJForExitCode` can pump them to the active terminal directly.
jpb.redirectInput(Redirect.INHERIT)
jpb.start()
}
private[sbt] def blockJForExitCode(p: JProcess): Int = {
RunningProcesses.add(p)
try {
// Capture the active terminal's streams once on the task thread. The
// System.out/err proxy resolves `activeTerminal` at every write, so a
// concurrent terminal swap can route mid-pump bytes to the wrong client.
val active = Terminal.current
val outT = pumpStream(p.getInputStream, active.outputStream, "sbt-fork-stdout")
val errT = pumpStream(p.getErrorStream, active.errorStream, "sbt-fork-stderr")
p.waitFor()
outT.join()
errT.join()
p.exitValue()
} finally {
if (p.isAlive()) p.destroy()
@ -237,4 +246,23 @@ object Fork {
RunningProcesses.remove(p)
}
}
private[sbt] def pumpStream(in: InputStream, out: OutputStream, name: String): Thread = {
val t = new Thread(name) {
setDaemon(true)
override def run(): Unit = {
val buf = new Array[Byte](4096)
try {
var n = in.read(buf)
while (n != -1) {
out.write(buf, 0, n)
out.flush()
n = in.read(buf)
}
} catch { case _: IOException => () }
}
}
t.start()
t
}
}

View File

@ -7,3 +7,8 @@ TaskKey[Unit]("willFail") := { throw new Exception("failed") }
libraryDependencies += "org.scalameta" %% "munit" % "1.0.4" % Test
TaskKey[Unit]("fooBar") := { () }
// Exercise the forked interactive code path (connectInput + StdoutOutput).
run / fork := true
run / connectInput := true
run / outputStrategy := Some(StdoutOutput)

View File

@ -1,3 +1,6 @@
class A
@main def hello() = println("Hello, World!")
@main def hello() =
println("STDOUT_MARKER_9185")
Console.err.println("STDERR_MARKER_9185")
println("Hello, World!")

View File

@ -157,4 +157,12 @@ object ClientTest extends AbstractServerTest {
test("quote with semi") { _ =>
assert(complete("\"compile; fooB") == Vector("compile; fooBar"))
}
test("forked run with connectInput relays stdout to --client") { _ =>
val (exit, lines) = clientWithStdoutLines("run")
assert(exit == 0, s"non-zero exit; lines=${lines.mkString("\n")}")
assert(
lines.exists(_.contains("STDOUT_MARKER_9185")),
s"missing STDOUT_MARKER_9185 in: ${lines.mkString("\n")}"
)
}
}