From 47e71332604680b177051d21b85eeabc2dfa768d Mon Sep 17 00:00:00 2001 From: calm <148254234+calm329@users.noreply.github.com> Date: Fri, 6 Feb 2026 11:52:36 -0600 Subject: [PATCH] [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 --- main/src/main/scala/sbt/Defaults.scala | 1 + .../scala/sbt/internal/CommandExchange.scala | 105 +++++++++++++++++- .../src/main/scala/sbt/internal/SysProp.scala | 1 + .../sbt/internal/server/NetworkChannel.scala | 4 +- .../scala/sbt/protocol/Serialization.scala | 1 + 5 files changed, 108 insertions(+), 4 deletions(-) diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index 5b4035094..93fc49597 100644 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -441,6 +441,7 @@ object Defaults extends BuildCommon { semanticdbVersion.value ), VirtualTerminal.handler, + CommandExchange.idleHandler, ) ++ serverHandlers.value :+ ServerHandler.fallback }, timeWrappedStamper := Stamps diff --git a/main/src/main/scala/sbt/internal/CommandExchange.scala b/main/src/main/scala/sbt/internal/CommandExchange.scala index fc78ecf82..a83d0ed94 100644 --- a/main/src/main/scala/sbt/internal/CommandExchange.scala +++ b/main/src/main/scala/sbt/internal/CommandExchange.scala @@ -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() + () + } + ) diff --git a/main/src/main/scala/sbt/internal/SysProp.scala b/main/src/main/scala/sbt/internal/SysProp.scala index d1d67ede6..341f028cf 100644 --- a/main/src/main/scala/sbt/internal/SysProp.scala +++ b/main/src/main/scala/sbt/internal/SysProp.scala @@ -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")) diff --git a/main/src/main/scala/sbt/internal/server/NetworkChannel.scala b/main/src/main/scala/sbt/internal/server/NetworkChannel.scala index 04137421c..7b1fe9ed4 100644 --- a/main/src/main/scala/sbt/internal/server/NetworkChannel.scala +++ b/main/src/main/scala/sbt/internal/server/NetworkChannel.scala @@ -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() diff --git a/protocol/src/main/scala/sbt/protocol/Serialization.scala b/protocol/src/main/scala/sbt/protocol/Serialization.scala index 525e8c592..310bc6b5d 100644 --- a/protocol/src/main/scala/sbt/protocol/Serialization.scala +++ b/protocol/src/main/scala/sbt/protocol/Serialization.scala @@ -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")