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")