[2.x] fix: Probe for a live server before refusing to start (#9337)

Any IOException while creating the boot io socket was wrapped in
ServerAlreadyBootingException and reported as "sbt thinks that server
is already booting" with a stack trace, and non-interactive
invocations exited with code 2. Permission or path-length problems
with XDG_RUNTIME_DIR or the temp directory and Windows named-pipe
access errors all hit this, blocking sbt entirely (#6777). Raw
IOExceptions from the constructor (socket directory creation) were
not caught at all and crashed startup.

getSocketOrExit now connects to the socket (BootServerSocketProbe,
shared with the test suite) to check for a live server before
believing the exception.

Co-authored-by: Claude Fable 5 <[email protected]>
This commit is contained in:
BrianHotopp
2026-07-01 17:02:50 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent c8312cdd88
commit bcd7fe1fbc
5 changed files with 161 additions and 12 deletions
@@ -123,12 +123,17 @@ public class BootServerSocket implements AutoCloseable {
bytes.put(b);
clientSocketReads.put(ClientSocket.this);
} else {
// close() deregisters from clientSockets like the write
// methods do; a dead entry left behind would block the
// NO_BOOT_CLIENTS_CONNECTED signal in inputStream.read.
alive.set(false);
close();
}
}
} catch (IOException e) {
alive.set(false);
close();
}
}
} catch (final Exception ex) {
@@ -0,0 +1,42 @@
/*
* 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
import java.util.concurrent.{ CountDownLatch, TimeUnit }
import java.util.concurrent.atomic.AtomicBoolean
import sbt.protocol.ClientSocket
import scala.util.control.NonFatal
private[sbt] object BootServerSocketProbe:
private val timeoutMillis = 2000L
/**
* True only if something answers on the boot socket at `location`. A live server answers
* immediately, so the connect runs on a daemon thread bounded by [[timeoutMillis]]: the
* underlying native connect has no timeout and blocks indefinitely against a bound socket whose
* listen backlog is saturated, which must never hang startup. LinkageError is caught alongside
* NonFatal because the connect may perform the JVM's first JNI/JNA load.
*/
def liveServerDetected(location: String, useJni: Boolean): Boolean =
val answered = new AtomicBoolean(false)
val done = new CountDownLatch(1)
val t = new Thread(
() =>
try
ClientSocket.localSocket(location, useJni).close()
answered.set(true)
catch case NonFatal(_) | (_: LinkageError) => ()
finally done.countDown(),
"sbt-boot-socket-probe"
)
t.setDaemon(true)
t.start()
done.await(timeoutMillis, TimeUnit.MILLISECONDS)
answered.get()
end BootServerSocketProbe
@@ -0,0 +1,77 @@
/*
* 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
import java.nio.file.{ Files, Paths }
import sbt.internal.util.Util
import verify.BasicTestSuite
object BootServerSocketSpec extends BasicTestSuite:
// the constructor only reads baseDirectory; provider is never touched
private def config(base: java.io.File): xsbti.AppConfiguration =
new xsbti.AppConfiguration {
override def arguments(): Array[String] = Array.empty
override def baseDirectory(): java.io.File = base
override def provider(): xsbti.AppProvider = null
}
private def useJni: Boolean =
BootServerSocket.requiresJNI() || sys.props.getOrElse("sbt.ipcsocket.jni", "false") == "true"
private def probe(location: String): Boolean =
BootServerSocketProbe.liveServerDetected(location, useJni)
private def freshBase(prefix: String): (java.io.File, Long) =
val base = Files.createTempDirectory(prefix).toRealPath().toFile
(base, base.getAbsolutePath.hashCode.toLong ^ System.nanoTime())
test("a live boot server is detected by the probe") {
val (base, token) = freshBase("boot-socket-live")
val location = BootServerSocket.socketLocation(base.toPath, token)
val server = new BootServerSocket(config(base), token)
val live =
try probe(location)
finally server.close()
assert(live)
}
test("the probe reports no live server when nothing is listening") {
val (base, token) = freshBase("boot-socket-none")
val location = BootServerSocket.socketLocation(base.toPath, token)
val live = probe(location)
assert(!live)
}
test("after close, the probe reports no live server") {
val (base, token) = freshBase("boot-socket-closed")
val location = BootServerSocket.socketLocation(base.toPath, token)
val server = new BootServerSocket(config(base), token)
server.close()
val live = probe(location)
assert(!live)
}
test("a stale socket file is not a live server and does not block a new socket") {
if (!Util.isWindows) {
val (base, token) = freshBase("boot-socket-stale")
val location = Paths.get(BootServerSocket.socketLocation(base.toPath, token))
Files.createDirectories(location.getParent)
Files.createFile(location) // leftover from a killed process
val staleLooksLive = probe(location.toString)
assert(!staleLooksLive)
val server = new BootServerSocket(config(base), token) // reclaims the path
val liveAfterReclaim =
try probe(location.toString)
finally server.close()
assert(liveAfterReclaim)
}
}
end BootServerSocketSpec