[2.x] feat: drop other idle servers on client exit (#8610) (#8701)

Implements cooperative idle server cleanup for `sbtn` (issue #8610). When a client disconnects from an sbt server, that server notifies all
other registered servers to shut down if they've been idle long enough and have no connected clients. This prevents accumulation of idle
background JVMs across projects.

fixes #8610
This commit is contained in:
calm 2026-02-06 11:52:36 -06:00 committed by GitHub
parent 3af62be0a3
commit 47e7133260
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 108 additions and 4 deletions

View File

@ -441,6 +441,7 @@ object Defaults extends BuildCommon {
semanticdbVersion.value
),
VirtualTerminal.handler,
CommandExchange.idleHandler,
) ++ serverHandlers.value :+ ServerHandler.fallback
},
timeWrappedStamper := Stamps

View File

@ -9,7 +9,7 @@
package sbt
package internal
import java.io.IOException
import java.io.{ File, IOException }
import java.net.Socket
import java.util.concurrent.atomic.*
import java.util.concurrent.{ LinkedBlockingQueue, TimeUnit }
@ -59,6 +59,9 @@ private[sbt] final class CommandExchange {
private val nextChannelId: AtomicInteger = new AtomicInteger(0)
private val lastState = new AtomicReference[State]
private val currentExecRef = new AtomicReference[Exec]
private val lastActivityTime = new AtomicLong(System.currentTimeMillis)
private val shuttingDown = new AtomicBoolean(false)
@volatile private var procFile: Option[File] = None
private[sbt] def hasServer = server.isDefined
addConsoleChannel()
@ -162,6 +165,10 @@ private[sbt] final class CommandExchange {
private def newNetworkName: String = s"network-${nextChannelId.incrementAndGet()}"
private[sbt] def removeChannel(c: CommandChannel): Unit = {
val wasInitialized = c match {
case nc: NetworkChannel => nc.isInitialized
case _ => false
}
channelBufferLock.synchronized {
Util.ignoreResult(channelBuffer -= c)
}
@ -173,6 +180,8 @@ private[sbt] final class CommandExchange {
}
try commandQueue.put(Exec(s"${ContinuousCommands.stopWatch} ${c.name}", None))
catch { case _: InterruptedException => }
// Notify other servers to drop if idle when a real client disconnects
if (wasInitialized && !shuttingDown.get) notifyOtherServers()
}
private def mkAskUser(
@ -246,6 +255,15 @@ private[sbt] final class CommandExchange {
// remember to shutdown only when the server comes up
server = Some(serverInstance)
s.log.info("started sbt server")
// register this server in the shared proc directory
try {
val procDir = SysProp.globalLocalCache / "proc"
IO.createDirectory(procDir)
val pid = ProcessHandle.current().pid()
val pf = procDir / s"$pid.json"
IO.copyFile(portfile, pf)
procFile = Some(pf)
} catch { case scala.util.control.NonFatal(_) => }
case Some(Failure(_: AlreadyRunningException)) =>
s.log.warn(
"sbt server could not start because there's another instance of sbt running on this build."
@ -294,6 +312,12 @@ private[sbt] final class CommandExchange {
}
def shutdown(): Unit = {
shuttingDown.set(true)
procFile.foreach { pf =>
try IO.delete(pf)
catch { case scala.util.control.NonFatal(_) => }
}
procFile = None
fastTrackThread.close()
channels foreach (_.shutdown(true))
// interrupt and kill the thread
@ -376,10 +400,16 @@ private[sbt] final class CommandExchange {
}
}
private[sbt] def setExec(exec: Option[Exec]): Unit = currentExecRef.set(exec.orNull)
private[sbt] def setExec(exec: Option[Exec]): Unit =
currentExecRef.set(exec.orNull)
lastActivityTime.set(System.currentTimeMillis)
private def idleSeconds: Long =
(System.currentTimeMillis - lastActivityTime.get) / 1000
def prompt(event: ConsolePromptEvent): Unit =
currentExecRef.set(null)
lastActivityTime.set(System.currentTimeMillis)
channels.foreach {
case c if ContinuousCommands.isInWatch(lastState.get, c) =>
case c =>
@ -466,6 +496,64 @@ private[sbt] final class CommandExchange {
}
}
/** Handle a dropIfIdle notification from another server. */
private[sbt] def handleDropIfIdle(): Unit = {
val idleSec = idleSeconds
val threshold = SysProp.secondaryIdleTimeoutSec
val idle = idleSec >= threshold
val hasClients = channels.exists {
case nc: NetworkChannel => nc.isInitialized
case _ => false
}
if (idle && !hasClients) {
Terminal.consoleLog("dropping idle server (requested by another sbt instance)")
commandQueue.add(Exec(TerminateAction, Some(CommandSource(ConsoleChannel.defaultName))))
}
}
/** Notify other sbt servers to drop if idle. Runs on a daemon thread to avoid blocking. */
private def notifyOtherServers(): Unit = {
val thread = new Thread("sbt-notify-other-servers") {
setDaemon(true)
override def run(): Unit = {
val procDir = SysProp.globalLocalCache / "proc"
if (!procDir.exists) return
val myPid = ProcessHandle.current().pid()
val files = procDir.listFiles
if (files == null) return
for (f <- files if f.getName.endsWith(".json")) {
val pidStr = f.getName.stripSuffix(".json")
val pid =
try pidStr.toLong
catch { case _: NumberFormatException => -1L }
if (pid != myPid) {
try {
val (socket, _) = sbt.protocol.ClientSocket.socket(f)
try {
val notification = sbt.internal.protocol.JsonRpcNotificationMessage(
"2.0",
sbt.protocol.Serialization.dropIfIdle,
None
)
val bytes = sbt.protocol.Serialization.serializeNotificationMessage(notification)
socket.getOutputStream.write(bytes)
socket.getOutputStream.flush()
} finally {
socket.close()
}
} catch {
case scala.util.control.NonFatal(_) =>
// Server unreachable - clean up stale proc file
try IO.delete(f)
catch { case scala.util.control.NonFatal(_) => }
}
}
}
}
}
thread.start()
}
private class FastTrackThread
extends Thread("sbt-command-exchange-fastTrack")
with AutoCloseable {
@ -519,3 +607,16 @@ private[sbt] final class CommandExchange {
channels.find(_.name == channelName)
private val fastTrackThread = new FastTrackThread
}
private[sbt] object CommandExchange:
import sbt.protocol.Serialization.dropIfIdle
val idleHandler: ServerHandler = ServerHandler: callback =>
ServerIntent(
onRequest = PartialFunction.empty,
onResponse = PartialFunction.empty,
onNotification = {
case n if n.method == dropIfIdle =>
StandardMain.exchange.handleDropIfIdle()
()
}
)

View File

@ -199,6 +199,7 @@ object SysProp {
sbt.nio.Keys.WarnOnSourceChanges
def serverUseJni = getOrFalse("sbt.ipcsocket.jni")
def secondaryIdleTimeoutSec: Long = long("sbt.server.secondaryIdleTimeout", 600L)
private def file(value: String): File = new File(value)
private def home: File = file(sys.props("user.home"))

View File

@ -253,7 +253,7 @@ final class NetworkChannel(
f orElse i.onNotification
}
def handleBody(chunk: Seq[Byte]): Unit = {
def handleBody(chunk: Seq[Byte]): Unit =
Serialization.deserializeJsonMessage(chunk) match {
case Right(req: JsonRpcRequestMessage) =>
try {
@ -281,7 +281,6 @@ final class NetworkChannel(
log.error(msg)
logMessage("error", msg)
}
}
}
private[sbt] def isLanguageServerProtocol: Boolean = true
@ -963,6 +962,7 @@ final class NetworkChannel(
}
}
private[sbt] def isAttached: Boolean = attached.get
private[sbt] def isInitialized: Boolean = initialized
thread.start()
writeThread.start()

View File

@ -47,6 +47,7 @@ object Serialization {
val terminalSetSize = "sbt/terminalSetSize"
val terminalSetEcho = "sbt/terminalSetEcho"
val terminalSetRawMode = "sbt/terminalSetRawMode"
val dropIfIdle = "sbt/dropIfIdle"
val CancelAll = "__CancelAll"
@deprecated("unused", since = "1.4.0")