From e8f40d68c3b986362b8d6420adedb34c69c224e5 Mon Sep 17 00:00:00 2001 From: BrianHotopp Date: Mon, 17 Aug 2026 14:08:21 -0400 Subject: [PATCH] [2.x] fix: sbtn to log connection errors onClose now logs sbt server disconnected when the close wasn't initiated by the client. Generated-by: Oh My Pi (kimi-code/k3) --- .../sbt/internal/client/NetworkClient.scala | 26 ++++- .../NetworkClientSilentDisconnectTest.scala | 102 ++++++++++++++++++ notes/2.0.0/silent-reconnect.md | 3 + 3 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 main-command/src/test/scala/sbt/internal/client/NetworkClientSilentDisconnectTest.scala create mode 100644 notes/2.0.0/silent-reconnect.md 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 ed0bd78f8..7b98c82bc 100644 --- a/main-command/src/main/scala/sbt/internal/client/NetworkClient.scala +++ b/main-command/src/main/scala/sbt/internal/client/NetworkClient.scala @@ -156,6 +156,11 @@ class NetworkClient( private def mkSocket(file: File): (Socket, Option[String]) = ClientSocket.socket(file, useJNI) + private[sbt] def logFailure(e: Exception): Unit = { + errorStream.println(s"sbt client failed: $e") + e.printStackTrace(errorStream) + } + private def portfile = arguments.baseDirectory / "project" / "target" / "active.json" def connection: ServerSession = connectionHolder.synchronized { @@ -311,7 +316,12 @@ class NetworkClient( override protected def onRequest(msg: JsonRpcRequestMessage): Unit = self.onRequest(msg) override protected def onResponse(msg: JsonRpcResponseMessage): Unit = self.onResponse(msg) override protected def onClose(): Unit = if (!rebooting.get) { - if (exitClean.get != false) exitClean.set(!running.get) + if (exitClean.get != false) { + val serverDropped = running.get + exitClean.set(!serverDropped) + if (serverDropped && !shutdownOnly) + console.appendLog(Level.Error, "sbt server disconnected") + } running.set(false) Option(interactiveThread.get).foreach(_.interrupt()) } @@ -1473,8 +1483,11 @@ object NetworkClient { try { if (client.connect(promptCompleteUsers = false)) client.run() else 1 - } catch { case _: Exception => 1 } - finally client.close() + } catch { + case e: Exception => + client.logFailure(e) + 1 + } finally client.close() } def client( baseDirectory: File, @@ -1505,8 +1518,11 @@ object NetworkClient { if (client.connect(promptCompleteUsers = false)) client.run() else 1 } - } catch { case _: Exception => 1 } - finally client.close() + } catch { + case e: Exception => + client.logFailure(e) + 1 + } finally client.close() } def client( baseDirectory: File, diff --git a/main-command/src/test/scala/sbt/internal/client/NetworkClientSilentDisconnectTest.scala b/main-command/src/test/scala/sbt/internal/client/NetworkClientSilentDisconnectTest.scala new file mode 100644 index 000000000..d0416d8e1 --- /dev/null +++ b/main-command/src/test/scala/sbt/internal/client/NetworkClientSilentDisconnectTest.scala @@ -0,0 +1,102 @@ +/* + * 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.client + +import java.io.{ ByteArrayOutputStream, File, InputStream, PrintStream } +import java.net.{ ServerSocket, Socket } +import java.nio.file.Files +import java.util.concurrent.CountDownLatch +import verify.BasicTestSuite + +/** + * Regression test for the silent half of #9484: the server is up (its portfile + * exists and accepts connections) but drops the client's connection during a + * batch run. The client must say why it failed instead of exiting 1 silently. + */ +object NetworkClientSilentDisconnectTest extends BasicTestSuite: + + /** A server that accepts one connection, reads nothing, then closes it. */ + class DropServer: + val serverSocket = new ServerSocket(0, 50, java.net.InetAddress.getLoopbackAddress) + val accepted = new CountDownLatch(1) + val thread = new Thread(() => + try + val s: Socket = serverSocket.accept() + accepted.countDown() + Thread.sleep(200) // let the client write its handshake into the kernel buffer + s.close() + catch case _: Exception => () + ) + thread.setDaemon(true) + thread.start() + def port: Int = serverSocket.getLocalPort + def close(): Unit = + try serverSocket.close() + catch case _: Exception => () + + def withDropServer[A](f: DropServer => A): A = + val server = new DropServer + try f(server) + finally server.close() + + def projectWithPortfile(port: Int): File = + val base = Files.createTempDirectory("silent-disconnect-project").toFile + Files.writeString( + base.toPath.resolve("build.sbt"), + "lazy val root = (project in file(\".\"))\n" + ) + val target = base.toPath.resolve("project").resolve("target") + Files.createDirectories(target) + Files.writeString( + target.resolve("active.json"), + s"""{"uri":"tcp://127.0.0.1:$port","tokenfilePath":null,"tokenfileUri":null}""" + ) + base + + test("a dropped connection during a batch run must not exit silently"): + withDropServer: server => + val base = projectWithPortfile(server.port) + val (code, explained) = runBatchClient(base) + assert(code == 1, s"expected exit 1, got $code") + assert( + explained.contains("disconnected"), + s"client exited 1 without explaining the disconnect: '$explained'" + ) + + test("a corrupt portfile must not fail silently"): + val base = Files.createTempDirectory("corrupt-portfile-project").toFile + Files.writeString( + base.toPath.resolve("build.sbt"), + "lazy val root = (project in file(\".\"))\n" + ) + val target = base.toPath.resolve("project").resolve("target") + Files.createDirectories(target) + Files.writeString(target.resolve("active.json"), "not json") + val (code, explained) = runBatchClient(base) + assert(code == 1, s"expected exit 1, got $code") + assert( + explained.contains("sbt client failed"), + s"client exited 1 without explaining the failure: '$explained'" + ) + + private def runBatchClient(base: File): (Int, String) = + val errBytes = new ByteArrayOutputStream + val outBytes = new ByteArrayOutputStream + val err = new PrintStream(errBytes, true) + val out = new PrintStream(outBytes, true) + val code = NetworkClient.client( + base, + Array("compile"), + new InputStream { override def read(): Int = -1 }, + out, + err, + false, + ) + (code, errBytes.toString("UTF-8") + outBytes.toString("UTF-8")) +end NetworkClientSilentDisconnectTest diff --git a/notes/2.0.0/silent-reconnect.md b/notes/2.0.0/silent-reconnect.md new file mode 100644 index 000000000..0d7ab56e1 --- /dev/null +++ b/notes/2.0.0/silent-reconnect.md @@ -0,0 +1,3 @@ +### Fixes + +- Don't exit silently when the thin client loses its connection to the server mid-run or hits an exception during connect. The client now logs `sbt server disconnected` when the server drops the connection, and prints the exception with a stack trace when the connect or run phase throws. Previously these paths returned exit code 1 with no output, which made CI failures undebuggable. Addresses [#9484](https://github.com/sbt/sbt/issues/9484).