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

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:
BrianHotopp 2026-05-26 14:41:16 -04:00 committed by GitHub
parent 35362bba0e
commit 6ec1f742ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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")}"
)
}
}