From 6ec1f742abd9d26c45c7d508797d9af0c02d8190 Mon Sep 17 00:00:00 2001 From: BrianHotopp Date: Tue, 26 May 2026 14:41:16 -0400 Subject: [PATCH] [1.x] fix: Pump forked stdout/stderr to the captured active terminal (#9265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- run/src/main/scala/sbt/Fork.scala | 36 ++++++++++++++++--- server-test/src/server-test/client/build.sbt | 5 +++ .../server-test/client/src/main/scala/A.scala | 5 ++- .../src/test/scala/testpkg/ClientTest.scala | 8 +++++ 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/run/src/main/scala/sbt/Fork.scala b/run/src/main/scala/sbt/Fork.scala index a0d78d70e..0e2dae927 100644 --- a/run/src/main/scala/sbt/Fork.scala +++ b/run/src/main/scala/sbt/Fork.scala @@ -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 + } } diff --git a/server-test/src/server-test/client/build.sbt b/server-test/src/server-test/client/build.sbt index 686d2a7a8..965179d66 100644 --- a/server-test/src/server-test/client/build.sbt +++ b/server-test/src/server-test/client/build.sbt @@ -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) diff --git a/server-test/src/server-test/client/src/main/scala/A.scala b/server-test/src/server-test/client/src/main/scala/A.scala index 171b96e91..008ed02cd 100644 --- a/server-test/src/server-test/client/src/main/scala/A.scala +++ b/server-test/src/server-test/client/src/main/scala/A.scala @@ -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!") diff --git a/server-test/src/test/scala/testpkg/ClientTest.scala b/server-test/src/test/scala/testpkg/ClientTest.scala index 239fddc6b..cb4d6a7d4 100644 --- a/server-test/src/test/scala/testpkg/ClientTest.scala +++ b/server-test/src/test/scala/testpkg/ClientTest.scala @@ -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")}" + ) + } }