From b5ecb37ae7135b45d9585ffbebab3f41de1bd2b4 Mon Sep 17 00:00:00 2001 From: BrianHotopp Date: Sun, 12 Jul 2026 15:03:45 -0400 Subject: [PATCH 1/3] [2.x] fix: Discard a cancelled task's output backlog on cancel (#9411) A task that floods stdout could not be stopped promptly: cancelling it (Ctrl+C from the thin client) stopped the task, but the server kept draining the already-queued output to the client for several seconds, because onCancellationRequest never discarded the pending backlog. onCancellationRequest now sets a per-channel isCanceled flag and clears the queued frames and buffered stdout (discardPending), and jsonRpcNotify drops the cancelled task's systemOut/systemErr while the flag is set, so output stops promptly. Co-authored-by: Claude Fable 5 --- .../sbt/internal/server/NetworkChannel.scala | 36 ++++++++++++++----- .../internal/server/NetworkChannelSpec.scala | 35 ++++++++++++++++++ notes/2.0.0/cancel-discards-output.md | 8 +++++ 3 files changed, 70 insertions(+), 9 deletions(-) create mode 100644 main/src/test/scala/sbt/internal/server/NetworkChannelSpec.scala create mode 100644 notes/2.0.0/cancel-discards-output.md diff --git a/main/src/main/scala/sbt/internal/server/NetworkChannel.scala b/main/src/main/scala/sbt/internal/server/NetworkChannel.scala index 6293fafce..b67ad498c 100644 --- a/main/src/main/scala/sbt/internal/server/NetworkChannel.scala +++ b/main/src/main/scala/sbt/internal/server/NetworkChannel.scala @@ -91,6 +91,7 @@ final class NetworkChannel( private val pendingWrites = new LinkedBlockingQueue[(Array[Byte], Boolean)]() private val attached = new AtomicBoolean(false) private val alive = new AtomicBoolean(true) + private val isCanceled = new AtomicBoolean(false) private[sbt] def isInteractive = interactive.get private val interactive = new AtomicBoolean(false) private[sbt] def setInteractive(id: String, value: Boolean) = { @@ -109,6 +110,10 @@ final class NetworkChannel( interactive.set(true) jsonRpcNotify(promptChannel, "") } + override def prompt(e: ConsolePromptEvent): Unit = { + isCanceled.set(false) + super.prompt(e) + } private[sbt] def write(byte: Byte) = inputBuffer.add(byte.toInt) private val terminalHolder = new AtomicReference[Terminal](Terminal.NullTerminal) @@ -532,6 +537,8 @@ final class NetworkChannel( StandardMain.exchange.currentExec.exists(_.source.exists(_.channelName == name))) ) { runningEngine.cancelAndShutdown() + isCanceled.set(true) + discardPending() respondResult( ExecStatusEvent( @@ -625,16 +632,19 @@ final class NetworkChannel( } /** Notify to Language Server's client. */ - private[sbt] def jsonRpcNotify[A: JsonFormat](method: String, params: A): Unit = { - val m = - JsonRpcNotificationMessage("2.0", method, Option(Converter.toJson[A](params).get)) - if (method != Serialization.systemOut) { - forceFlush() - log.debug(s"jsonRpcNotify: $m") + private[sbt] def jsonRpcNotify[A: JsonFormat](method: String, params: A): Unit = + if (isCanceled.get && NetworkChannel.isCanceledOutput(method)) + () + else { + val m = + JsonRpcNotificationMessage("2.0", method, Option(Converter.toJson[A](params).get)) + if (method != Serialization.systemOut) { + forceFlush() + log.debug(s"jsonRpcNotify: $m") + } + val bytes = Serialization.serializeNotificationMessage(m) + publishBytes(bytes) } - val bytes = Serialization.serializeNotificationMessage(m) - publishBytes(bytes) - } /** Notify to Language Server's client. */ private[sbt] def jsonRpcRequest[A: JsonFormat](id: String, method: String, params: A): Unit = { @@ -675,6 +685,11 @@ final class NetworkChannel( import scala.jdk.CollectionConverters.* private val outputBuffer = new LinkedBlockingQueue[Byte] + private def discardPending(): Unit = { + pendingWrites.clear() + outputBuffer.synchronized(outputBuffer.clear()) + } + // Batches writes to the client at most once per 20ms to cut terminal flicker (see // CoalescingFlusher). forceFlush drains now while leaving the timer live. private val flusher = @@ -942,6 +957,9 @@ final class NetworkChannel( } object NetworkChannel { + private[server] def isCanceledOutput(method: String): Boolean = + method == Serialization.systemOut || method == Serialization.systemErr + private[sbt] def cancel( execID: Option[String], id: String, diff --git a/main/src/test/scala/sbt/internal/server/NetworkChannelSpec.scala b/main/src/test/scala/sbt/internal/server/NetworkChannelSpec.scala new file mode 100644 index 000000000..3a5db2825 --- /dev/null +++ b/main/src/test/scala/sbt/internal/server/NetworkChannelSpec.scala @@ -0,0 +1,35 @@ +/* + * sbt + * Copyright 2023, Scala center + * Copyright 2011 - 2022, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt.internal.server + +import sbt.protocol.Serialization +import verify.BasicTestSuite + +object NetworkChannelSpec extends BasicTestSuite: + + test("only systemOut and systemErr are dropped while canceling") { + assert(NetworkChannel.isCanceledOutput(Serialization.systemOut)) + assert(NetworkChannel.isCanceledOutput(Serialization.systemErr)) + } + + test("control-plane and flush methods are never dropped while canceling") { + val kept = Seq( + Serialization.systemOutFlush, + Serialization.systemErrFlush, + Serialization.readSystemIn, + Serialization.promptChannel, + "build/logMessage", + "sbt/exec", + "window/logMessage", + sbt.BasicCommandStrings.Shutdown, + ) + kept.foreach(m => assert(!NetworkChannel.isCanceledOutput(m), s"must not drop: $m")) + } + +end NetworkChannelSpec diff --git a/notes/2.0.0/cancel-discards-output.md b/notes/2.0.0/cancel-discards-output.md new file mode 100644 index 000000000..b37abcee1 --- /dev/null +++ b/notes/2.0.0/cancel-discards-output.md @@ -0,0 +1,8 @@ +### Cancelling a task discards its queued output + +Previously, cancelling a task that was producing a lot of output (Ctrl+C from the +thin client) stopped the task, but the server kept draining the already-queued +output to the client for several seconds, so the cancel appeared not to take +effect. The cancelled task's buffered stdout/stderr is now discarded on cancel, +so output stops promptly. Control-plane messages and the next command's output +are unaffected. From 7d77f44cdcd42c97429c224a473d24acaf87fef7 Mon Sep 17 00:00:00 2001 From: Anatolii Kmetiuk Date: Mon, 13 Jul 2026 10:18:10 +0900 Subject: [PATCH 2/3] Fix #9343 route pipelined dependencyPicklePath through internalDependencyPicklePath (#9425) --- main/src/main/scala/sbt/Defaults.scala | 6 ++-- .../pipelining-java-scala/build.sbt | 28 +++++++++++++++++++ .../src/main/scala/example/Main.scala | 5 ++++ .../src/main/scala/example/Main.scala | 5 ++++ .../src/main/scala/example/Main.scala | 5 ++++ .../pipelining-java-scala/test | 13 +++++++++ .../src/main/java/example/Greeter.java | 7 +++++ .../src/main/scala/example/Marker.scala | 5 ++++ 8 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 sbt-app/src/sbt-test/source-dependencies/pipelining-java-scala/build.sbt create mode 100644 sbt-app/src/sbt-test/source-dependencies/pipelining-java-scala/downstream-direct/src/main/scala/example/Main.scala create mode 100644 sbt-app/src/sbt-test/source-dependencies/pipelining-java-scala/downstream-plusone/src/main/scala/example/Main.scala create mode 100644 sbt-app/src/sbt-test/source-dependencies/pipelining-java-scala/downstream-transitive/src/main/scala/example/Main.scala create mode 100644 sbt-app/src/sbt-test/source-dependencies/pipelining-java-scala/test create mode 100644 sbt-app/src/sbt-test/source-dependencies/pipelining-java-scala/upstream/src/main/java/example/Greeter.java create mode 100644 sbt-app/src/sbt-test/source-dependencies/pipelining-java-scala/upstream/src/main/scala/example/Marker.scala diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index e4e9808a7..c57c9598b 100644 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -2839,7 +2839,7 @@ object Classpaths { case (false, _) => Def.task { filteredDependencyClasspath.value } case (true, DependencyMode.Transitive) => - Def.task { dependencyClasspath.value } + Def.task { internalDependencyPicklePath.value ++ externalDependencyClasspath.value } case (true, DependencyMode.Direct) => Def.task { val internalFiltered = ClasspathImpl.filterInternalByMode( @@ -2847,7 +2847,7 @@ object Classpaths { thisProjectRef.value, settingsData.value, buildDependencies.value, - internalDependencyClasspath.value, + internalDependencyPicklePath.value, ) val externalFiltered = ClasspathImpl.filterByDirectDeps( allDependencies.value, @@ -2862,7 +2862,7 @@ object Classpaths { thisProjectRef.value, settingsData.value, buildDependencies.value, - internalDependencyClasspath.value, + internalDependencyPicklePath.value, ) val externalFiltered = ClasspathImpl.filterByPlusOne( allDependencies.value, diff --git a/sbt-app/src/sbt-test/source-dependencies/pipelining-java-scala/build.sbt b/sbt-app/src/sbt-test/source-dependencies/pipelining-java-scala/build.sbt new file mode 100644 index 000000000..63a0c33b5 --- /dev/null +++ b/sbt-app/src/sbt-test/source-dependencies/pipelining-java-scala/build.sbt @@ -0,0 +1,28 @@ +ThisBuild / scalaVersion := "2.13.18" +ThisBuild / usePipelining := true + +lazy val root = (project in file(".")) + .aggregate(upstream, `downstream-transitive`, `downstream-direct`, `downstream-plusone`) + .settings( + name := "pipelining Java then Scala", + ) + +lazy val upstream = project + +lazy val `downstream-transitive` = project + .in(file("downstream-transitive")) + .dependsOn(upstream) + +lazy val `downstream-direct` = project + .in(file("downstream-direct")) + .dependsOn(upstream) + .settings( + dependencyMode := DependencyMode.Direct, + ) + +lazy val `downstream-plusone` = project + .in(file("downstream-plusone")) + .dependsOn(upstream) + .settings( + dependencyMode := DependencyMode.PlusOne, + ) diff --git a/sbt-app/src/sbt-test/source-dependencies/pipelining-java-scala/downstream-direct/src/main/scala/example/Main.scala b/sbt-app/src/sbt-test/source-dependencies/pipelining-java-scala/downstream-direct/src/main/scala/example/Main.scala new file mode 100644 index 000000000..2f9d069ab --- /dev/null +++ b/sbt-app/src/sbt-test/source-dependencies/pipelining-java-scala/downstream-direct/src/main/scala/example/Main.scala @@ -0,0 +1,5 @@ +package example + +object Main { + def run(): String = Greeter.greet("world") +} diff --git a/sbt-app/src/sbt-test/source-dependencies/pipelining-java-scala/downstream-plusone/src/main/scala/example/Main.scala b/sbt-app/src/sbt-test/source-dependencies/pipelining-java-scala/downstream-plusone/src/main/scala/example/Main.scala new file mode 100644 index 000000000..2f9d069ab --- /dev/null +++ b/sbt-app/src/sbt-test/source-dependencies/pipelining-java-scala/downstream-plusone/src/main/scala/example/Main.scala @@ -0,0 +1,5 @@ +package example + +object Main { + def run(): String = Greeter.greet("world") +} diff --git a/sbt-app/src/sbt-test/source-dependencies/pipelining-java-scala/downstream-transitive/src/main/scala/example/Main.scala b/sbt-app/src/sbt-test/source-dependencies/pipelining-java-scala/downstream-transitive/src/main/scala/example/Main.scala new file mode 100644 index 000000000..2f9d069ab --- /dev/null +++ b/sbt-app/src/sbt-test/source-dependencies/pipelining-java-scala/downstream-transitive/src/main/scala/example/Main.scala @@ -0,0 +1,5 @@ +package example + +object Main { + def run(): String = Greeter.greet("world") +} diff --git a/sbt-app/src/sbt-test/source-dependencies/pipelining-java-scala/test b/sbt-app/src/sbt-test/source-dependencies/pipelining-java-scala/test new file mode 100644 index 000000000..0b73c7c3a --- /dev/null +++ b/sbt-app/src/sbt-test/source-dependencies/pipelining-java-scala/test @@ -0,0 +1,13 @@ +# Regression test for https://github.com/sbt/sbt/issues/9343 +# With usePipelining := true, a downstream Scala source that references a +# Java class defined in an upstream project must still compile; the early +# pickle artifacts from upstream must stay visible on the pipelined classpath. +# Three downstream projects exercise Transitive, Direct, and PlusOne modes. +# A single aggregate compile fans out from one upstream pickle-producing task +# to all three downstream compilations without reusing a pipelining promise. + +> compile + +$ exists target/**/downstream-transitive/classes/example/Main.class +$ exists target/**/downstream-direct/classes/example/Main.class +$ exists target/**/downstream-plusone/classes/example/Main.class diff --git a/sbt-app/src/sbt-test/source-dependencies/pipelining-java-scala/upstream/src/main/java/example/Greeter.java b/sbt-app/src/sbt-test/source-dependencies/pipelining-java-scala/upstream/src/main/java/example/Greeter.java new file mode 100644 index 000000000..fd71f0af1 --- /dev/null +++ b/sbt-app/src/sbt-test/source-dependencies/pipelining-java-scala/upstream/src/main/java/example/Greeter.java @@ -0,0 +1,7 @@ +package example; + +public class Greeter { + public static String greet(String name) { + return "Hello, " + name; + } +} diff --git a/sbt-app/src/sbt-test/source-dependencies/pipelining-java-scala/upstream/src/main/scala/example/Marker.scala b/sbt-app/src/sbt-test/source-dependencies/pipelining-java-scala/upstream/src/main/scala/example/Marker.scala new file mode 100644 index 000000000..0f5040847 --- /dev/null +++ b/sbt-app/src/sbt-test/source-dependencies/pipelining-java-scala/upstream/src/main/scala/example/Marker.scala @@ -0,0 +1,5 @@ +package example + +object Marker { + val tag = "upstream" +} From 33a217ae6a19ff5597e4e22e180b6986933514f1 Mon Sep 17 00:00:00 2001 From: eugene yokota Date: Sat, 11 Jul 2026 16:04:01 -0400 Subject: [PATCH 3/3] [2.0.x] fix: Fixes shutdownall (#9435) **Problem** sbt shutdown all requires --server. **Solution** Fix it so it doesn't need extra flag. --- sbt | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/sbt b/sbt index 57ef1851f..fe738e1c8 100755 --- a/sbt +++ b/sbt @@ -558,6 +558,14 @@ checkWorkingDirectory() { fi } +runShutdownAll() { + local sbt_processes=( $(jps -v | grep sbt-launch | cut -f1 -d ' ') ) + for procId in "${sbt_processes[@]}"; do + kill -9 $procId + done + echoerr "shutdown ${#sbt_processes[@]} sbt processes" +} + run() { # Copy preloaded repo to user's preloaded directory syncPreloaded @@ -594,12 +602,6 @@ run() { echoerr "" echoerr "[info] sbt runner (sbt-the-shell-script) is a runner to run any declared version of sbt." echoerr "[info] Actual version of the sbt is declared using project/build.properties for each build." - elif [[ $shutdownall ]]; then - local sbt_processes=( $(jps -v | grep sbt-launch | cut -f1 -d ' ') ) - for procId in "${sbt_processes[@]}"; do - kill -9 $procId - done - echoerr "shutdown ${#sbt_processes[@]} sbt processes" else checkWorkingDirectory # run sbt @@ -962,6 +964,11 @@ if [[ $print_sbt_script_version ]]; then exit 0 fi +if [[ $shutdownall ]]; then + runShutdownAll + exit 0 +fi + java_version="$(jdk_version)" vlog "[process_args] java_version = '$java_version'" checkJava17ForSbt2