mirror of https://github.com/sbt/sbt.git
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:
parent
3af62be0a3
commit
47e7133260
|
|
@ -441,6 +441,7 @@ object Defaults extends BuildCommon {
|
|||
semanticdbVersion.value
|
||||
),
|
||||
VirtualTerminal.handler,
|
||||
CommandExchange.idleHandler,
|
||||
) ++ serverHandlers.value :+ ServerHandler.fallback
|
||||
},
|
||||
timeWrappedStamper := Stamps
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
()
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in New Issue