From e64db7927ca8105f26418121aa8b23a4de2241e9 Mon Sep 17 00:00:00 2001 From: BitToby <218712309+bittoby@users.noreply.github.com> Date: Mon, 13 Apr 2026 00:24:30 +0200 Subject: [PATCH 1/2] [2.x] fix: stack traces suppressed in thin client batch mode (#9058) In sbt 2.x, running batch commands through the thin client (sbtn) suppresses stack traces for all tasks because the server's shell command unconditionally sets state.interactive = true. This causes LogManager.defaultTraceLevel to return -1 (suppressed) even when the client explicitly signals non-interactive (batch) mode via Attach(interactive=false). This fixes the shell command to check the originating NetworkChannel's interactive flag before setting state.interactive, so thin client batch commands correctly get Int.MaxValue trace level and display full stack traces. --- main/src/main/scala/sbt/Main.scala | 10 +++++++++- .../sbt-test/actions/streams-trace-level/build.sbt | 11 +++++++++++ sbt-app/src/sbt-test/actions/streams-trace-level/test | 2 ++ 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 sbt-app/src/sbt-test/actions/streams-trace-level/build.sbt create mode 100644 sbt-app/src/sbt-test/actions/streams-trace-level/test diff --git a/main/src/main/scala/sbt/Main.scala b/main/src/main/scala/sbt/Main.scala index f41a26cec..2f87ff5bc 100644 --- a/main/src/main/scala/sbt/Main.scala +++ b/main/src/main/scala/sbt/Main.scala @@ -1088,12 +1088,20 @@ object BuiltinCommands { .getOpt(Keys.minForcegcInterval) .getOrElse(GCUtil.defaultMinForcegcInterval) val exec: Exec = getExec(s1, minGCInterval) + val isInteractive = exec.source match { + case Some(src) if src.channelName.startsWith("network") => + exchange.channelForName(src.channelName) match { + case Some(nc: NetworkChannel) => nc.isInteractive + case _ => true + } + case _ => true + } val newState = s1 .copy( onFailure = Some(Exec(Shell, None)), remainingCommands = exec +: Exec(Shell, None) +: s1.remainingCommands ) - .setInteractive(true) + .setInteractive(isInteractive) val res = if (exec.commandLine.trim.isEmpty) newState else newState.clearGlobalLog diff --git a/sbt-app/src/sbt-test/actions/streams-trace-level/build.sbt b/sbt-app/src/sbt-test/actions/streams-trace-level/build.sbt new file mode 100644 index 000000000..e2b32014e --- /dev/null +++ b/sbt-app/src/sbt-test/actions/streams-trace-level/build.sbt @@ -0,0 +1,11 @@ +lazy val helloWithoutStreams = taskKey[Unit]("") +lazy val helloWithStreams = taskKey[Unit]("") + +helloWithoutStreams := { + throw new RuntimeException("boom without streams!") +} + +helloWithStreams := { + val log = streams.value.log + throw new RuntimeException("boom with streams!") +} diff --git a/sbt-app/src/sbt-test/actions/streams-trace-level/test b/sbt-app/src/sbt-test/actions/streams-trace-level/test new file mode 100644 index 000000000..92bc6373f --- /dev/null +++ b/sbt-app/src/sbt-test/actions/streams-trace-level/test @@ -0,0 +1,2 @@ +-> helloWithoutStreams +-> helloWithStreams From e8757c85e218c5a72eb6221ce9c5f6e8ce07345a Mon Sep 17 00:00:00 2001 From: eugene yokota Date: Mon, 13 Apr 2026 00:41:35 -0400 Subject: [PATCH 2/2] [2.x] fix: Fixes client-side run status (#9081) **Problem** In sbt 2.x, if we execute a run task from the shell, and if the program fails, it ends up taking down the entire shell because client-side run rethrows, which is not desirable for the sbt shell. **Solution** 1. Omit printing out success for ClientJobParams, which is the runinfo. 2. Print out success or error on the client-side for shell usage case. --- .../sbt/internal/client/NetworkClient.scala | 21 ++++++++++++++----- .../main/scala/sbt/internal/Aggregation.scala | 11 ++++++---- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/main-command/src/main/scala/sbt/internal/client/NetworkClient.scala b/main-command/src/main/scala/sbt/internal/client/NetworkClient.scala index 5d5f089e0..399b3cdd4 100644 --- a/main-command/src/main/scala/sbt/internal/client/NetworkClient.scala +++ b/main-command/src/main/scala/sbt/internal/client/NetworkClient.scala @@ -179,6 +179,7 @@ class NetworkClient( def success(message: => String): Unit = () def log(level: Level.Value, message: => String): Unit = console.appendLog(level, message) } + private val interactive = arguments.commandArguments.isEmpty private[sbt] def connectOrStartServerAndConnect( promptCompleteUsers: Boolean, @@ -706,8 +707,19 @@ class NetworkClient( case (`clientJob`, Some(json)) => import sbt.internal.worker.codec.JsonProtocol.given Converter.fromJson[ClientJobParams](json) match { - case Success(params) => clientSideRun(params).get; Vector.empty - case Failure(_) => Vector.empty + case Success(params) => + clientSideRun(params) match + case Success(_) => + if interactive then console.success("ok") + else () + Vector.empty + case Failure(e) => + if interactive then + Vector( + (Level.Error, e.getMessage) + ) + else throw e + case Failure(_) => Vector.empty } case (`Shutdown`, Some(_)) => Vector.empty case (msg, _) if msg.startsWith("build/") => Vector.empty @@ -915,9 +927,8 @@ class NetworkClient( withSignalHandler(contHandler, Signals.CONT) { interactiveThread.set(Thread.currentThread) val cleaned = arguments.commandArguments - val userCommands = cleaned.takeWhile(_ != TerminateAction) - val interactive = cleaned.isEmpty - val exit = cleaned.nonEmpty && userCommands.isEmpty + val userCommands = arguments.commandArguments.takeWhile(_ != TerminateAction) + val exit = arguments.commandArguments.nonEmpty && userCommands.isEmpty attachUUID.set(sendJson(attach, s"""{"interactive": $interactive}""")) val handler: () => Unit = () => { def exitAbruptly() = { diff --git a/main/src/main/scala/sbt/internal/Aggregation.scala b/main/src/main/scala/sbt/internal/Aggregation.scala index bdde24c48..6e1105484 100644 --- a/main/src/main/scala/sbt/internal/Aggregation.scala +++ b/main/src/main/scala/sbt/internal/Aggregation.scala @@ -17,6 +17,7 @@ import sbt.internal.util.complete.Parser import sbt.internal.util.complete.Parser.{ failure, seq, success } import sbt.internal.util.* import sbt.internal.client.NetworkClient +import sbt.internal.worker.ClientJobParams import sbt.std.Transform.DummyTaskMap import sbt.util.{ Logger, Show } import scala.annotation.tailrec @@ -87,16 +88,18 @@ object Aggregation { import complete.* val log = state.log val extracted = Project.extract(state) - val success = results match - case Result.Value(_) => true - case Result.Inc(_) => false + // omit success printing for client-side run + val (success, jobParams) = results match + case Result.Value(Seq(KeyValue(_, p: ClientJobParams))) => (true, true) + case Result.Value(_) => (true, false) + case Result.Inc(_) => (false, false) val isPaused = currentChannel(state) match case Some(channel) => channel.isPaused case None => false results.toEither.foreach { r => if show.taskValues then printSettings(r, show.print) else () } - if !isPaused && show.success && !state.get(suppressShow).getOrElse(false) then + if !isPaused && show.success && !state.get(suppressShow).getOrElse(false) && !jobParams then printSuccess(start, stop, extracted, success, cacheSummary, log) else ()