From 8753a98161bf624d9f3d436425a6a32301e09775 Mon Sep 17 00:00:00 2001 From: eugene yokota Date: Wed, 19 Aug 2026 02:26:44 -0400 Subject: [PATCH] [2.x] fix: sbtn to retry on corrupt active.json (#9617) **Problem** sbtn gets stuck when active.json is corrupt. **Solution** Delete active.json, and retry. --- .../sbt/internal/client/NetworkClient.scala | 12 ++-- .../NetworkClientSilentDisconnectTest.scala | 56 +++++++++++++++---- .../scala/sbt/protocol/ClientSocket.scala | 19 +++++-- 3 files changed, 67 insertions(+), 20 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 7b98c82bc..ee1bb7948 100644 --- a/main-command/src/main/scala/sbt/internal/client/NetworkClient.scala +++ b/main-command/src/main/scala/sbt/internal/client/NetworkClient.scala @@ -196,7 +196,7 @@ class NetworkClient( promptCompleteUsers: Boolean, retry: Boolean ): (Socket, Option[String]) = - try { + try if (!portfile.exists) { if (shutdownOnly) { console.appendLog(Level.Info, "no sbt server is running. ciao") @@ -246,12 +246,14 @@ class NetworkClient( } } connect(0) - } catch { - case e: ConnectionRefusedException if retry => - if (Files.deleteIfExists(portfile.toPath)) + catch + case e @ (_: ConnectionRefusedException | _: ClientSocket.ConnectionFileReadException) + if retry => + errorStream.println(s"${e.getMessage}; starting a new server") + if Files.deleteIfExists(portfile.toPath) then connectOrStartServerAndConnect(promptCompleteUsers, retry = false) else throw e - } + end connectOrStartServerAndConnect // Open server connection based on the portfile def init(promptCompleteUsers: Boolean, retry: Boolean): ServerSession = { diff --git a/main-command/src/test/scala/sbt/internal/client/NetworkClientSilentDisconnectTest.scala b/main-command/src/test/scala/sbt/internal/client/NetworkClientSilentDisconnectTest.scala index d0416d8e1..ae4f31096 100644 --- a/main-command/src/test/scala/sbt/internal/client/NetworkClientSilentDisconnectTest.scala +++ b/main-command/src/test/scala/sbt/internal/client/NetworkClientSilentDisconnectTest.scala @@ -69,7 +69,7 @@ object NetworkClientSilentDisconnectTest extends BasicTestSuite: s"client exited 1 without explaining the disconnect: '$explained'" ) - test("a corrupt portfile must not fail silently"): + test("a corrupt portfile is replaced and a fresh server connection is attempted"): val base = Files.createTempDirectory("corrupt-portfile-project").toFile Files.writeString( base.toPath.resolve("build.sbt"), @@ -77,22 +77,58 @@ object NetworkClientSilentDisconnectTest extends BasicTestSuite: ) 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'" - ) + val portfile = target.resolve("active.json") + Files.writeString(portfile, "not json") + try + val (_, explained) = runBatchClient(base) + assert( + explained.contains("corrupt or unreadable") && + explained.contains("active.json") && + explained.contains("starting a new server"), + s"client did not explain the corrupt portfile before retrying: '$explained'" + ) + assert( + !Files.exists(portfile) || Files.readString(portfile) != "not json", + "corrupt portfile should have been replaced by a fresh server connection" + ) + finally shutdownServer(base) - private def runBatchClient(base: File): (Int, String) = + test("an empty portfile is replaced and a fresh server connection is attempted"): + val base = Files.createTempDirectory("empty-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) + val portfile = target.resolve("active.json") + Files.writeString(portfile, "") + try + val (_, explained) = runBatchClient(base) + assert( + explained.contains("corrupt or unreadable") && + explained.contains("active.json") && + explained.contains("starting a new server"), + s"client did not explain the corrupt portfile before retrying: '$explained'" + ) + assert( + !Files.exists(portfile) || Files.readString(portfile).nonEmpty, + "empty portfile should have been replaced by a fresh server connection" + ) + finally shutdownServer(base) + + private def shutdownServer(base: File): Unit = + try runBatchClient(base, Array("shutdown")) + catch case _: Exception => () + + private def runBatchClient(base: File, args: Array[String] = Array("compile")): (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"), + args, new InputStream { override def read(): Int = -1 }, out, err, diff --git a/protocol/src/main/scala/sbt/protocol/ClientSocket.scala b/protocol/src/main/scala/sbt/protocol/ClientSocket.scala index 8e65ab0df..b7611aee7 100644 --- a/protocol/src/main/scala/sbt/protocol/ClientSocket.scala +++ b/protocol/src/main/scala/sbt/protocol/ClientSocket.scala @@ -12,6 +12,7 @@ package protocol import java.io.{ File, InputStream, OutputStream } import java.net.{ InetAddress, Socket, StandardProtocolFamily, URI, UnixDomainSocketAddress } import java.nio.channels.{ Channels, SocketChannel } +import scala.util.control.NonFatal import sjsonnew.BasicJsonProtocol import sjsonnew.support.scalajson.unsafe.{ Parser, Converter } import sjsonnew.shaded.scalajson.ast.unsafe.JValue @@ -23,18 +24,26 @@ import org.scalasbt.ipcsocket.* object ClientSocket { private lazy val fileFormats = new BasicJsonProtocol with PortFileFormats with TokenFileFormats {} + /** Thrown when a server connection file can't be read or parsed as JSON. */ + final class ConnectionFileReadException(file: File, cause: Throwable) + extends Exception(s"sbt connection file $file is corrupt or unreadable: $cause", cause) + def socket(portfile: File): (Socket, Option[String]) = socket(portfile, false) def socket(portfile: File, useJNI: Boolean): (Socket, Option[String]) = { import fileFormats.given - val json: JValue = Parser.parseFromString(sbt.io.IO.read(portfile)).get - val p = Converter.fromJson[PortFile](json).get + val p = + try + val json: JValue = Parser.parseFromString(sbt.io.IO.read(portfile)).get + Converter.fromJson[PortFile](json).get + catch case NonFatal(e) => throw new ConnectionFileReadException(portfile, e) val uri = new URI(p.uri) // println(uri) val token = p.tokenfilePath map { tp => val tokeFile = new File(tp) - val json: JValue = Parser.parseFromFile(tokeFile).get - val t = Converter.fromJson[TokenFile](json).get - t.token + try + val json: JValue = Parser.parseFromFile(tokeFile).get + Converter.fromJson[TokenFile](json).get.token + catch case NonFatal(e) => throw new ConnectionFileReadException(tokeFile, e) } val sk = uri.getScheme match { case "local" => localSocket(uri.getSchemeSpecificPart, useJNI)