[2.x] fix: Stop a displaced server (#9713)

**Problem**
If a client cannot reach the server, it deletes the portfile. Then it
starts a second server. The first server is displaced: its socket path
now belongs to the second server.

A displaced server keeps running. A dropIfIdle notification cannot
reach it, because the proc file it registered names the socket that
the second server now owns.

**Solution**
The portfile holds the serverId of whichever server wrote it. Watch
that file, and exit when the id is not this server's. Leave the
portfile to the server that owns it.

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Albert Meltzer
2026-09-10 12:55:11 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent ce7bb28f05
commit dbeeb199aa
11 changed files with 279 additions and 93 deletions
@@ -11,28 +11,32 @@ package sbt.internal.protocol
* @param sysProps The -D options the thin client passed to this server, as names and salted digests.
* @param sysPropsRecorded Whether sysProps is the whole story. Absent on a server no thin client started, whose
options are its own business and unknown here.
* @param serverId Identifies the server that wrote this file. A server that reads an id other than its own
has been replaced, and no client can reach it any more.
*/
final class PortFile private (
val uri: String,
val tokenfilePath: Option[String],
val tokenfileUri: Option[String],
val sysProps: Vector[String],
val sysPropsRecorded: Option[Boolean]) extends Serializable {
val sysPropsRecorded: Option[Boolean],
val serverId: Option[String]) extends Serializable {
private def this(uri: String, tokenfilePath: Option[String], tokenfileUri: Option[String]) = this(uri, tokenfilePath, tokenfileUri, Vector(), None)
private def this(uri: String, tokenfilePath: Option[String], tokenfileUri: Option[String]) = this(uri, tokenfilePath, tokenfileUri, Vector(), None, None)
private def this(uri: String, tokenfilePath: Option[String], tokenfileUri: Option[String], sysProps: Vector[String], sysPropsRecorded: Option[Boolean]) = this(uri, tokenfilePath, tokenfileUri, sysProps, sysPropsRecorded, None)
override def equals(o: Any): Boolean = this.eq(o.asInstanceOf[AnyRef]) || (o match {
case x: PortFile => (this.uri == x.uri) && (this.tokenfilePath == x.tokenfilePath) && (this.tokenfileUri == x.tokenfileUri) && (this.sysProps == x.sysProps) && (this.sysPropsRecorded == x.sysPropsRecorded)
case x: PortFile => (this.uri == x.uri) && (this.tokenfilePath == x.tokenfilePath) && (this.tokenfileUri == x.tokenfileUri) && (this.sysProps == x.sysProps) && (this.sysPropsRecorded == x.sysPropsRecorded) && (this.serverId == x.serverId)
case _ => false
})
override def hashCode: Int = {
37 * (37 * (37 * (37 * (37 * (37 * (17 + "sbt.internal.protocol.PortFile".##) + uri.##) + tokenfilePath.##) + tokenfileUri.##) + sysProps.##) + sysPropsRecorded.##)
37 * (37 * (37 * (37 * (37 * (37 * (37 * (17 + "sbt.internal.protocol.PortFile".##) + uri.##) + tokenfilePath.##) + tokenfileUri.##) + sysProps.##) + sysPropsRecorded.##) + serverId.##)
}
override def toString: String = {
"PortFile(" + uri + ", " + tokenfilePath + ", " + tokenfileUri + ", " + sysProps + ", " + sysPropsRecorded + ")"
"PortFile(" + uri + ", " + tokenfilePath + ", " + tokenfileUri + ", " + sysProps + ", " + sysPropsRecorded + ", " + serverId + ")"
}
private def copy(uri: String = uri, tokenfilePath: Option[String] = tokenfilePath, tokenfileUri: Option[String] = tokenfileUri, sysProps: Vector[String] = sysProps, sysPropsRecorded: Option[Boolean] = sysPropsRecorded): PortFile = {
new PortFile(uri, tokenfilePath, tokenfileUri, sysProps, sysPropsRecorded)
private def copy(uri: String = uri, tokenfilePath: Option[String] = tokenfilePath, tokenfileUri: Option[String] = tokenfileUri, sysProps: Vector[String] = sysProps, sysPropsRecorded: Option[Boolean] = sysPropsRecorded, serverId: Option[String] = serverId): PortFile = {
new PortFile(uri, tokenfilePath, tokenfileUri, sysProps, sysPropsRecorded, serverId)
}
def withUri(uri: String): PortFile = {
copy(uri = uri)
@@ -58,6 +62,12 @@ final class PortFile private (
def withSysPropsRecorded(sysPropsRecorded: Boolean): PortFile = {
copy(sysPropsRecorded = Option(sysPropsRecorded))
}
def withServerId(serverId: Option[String]): PortFile = {
copy(serverId = serverId)
}
def withServerId(serverId: String): PortFile = {
copy(serverId = Option(serverId))
}
}
object PortFile {
@@ -65,4 +75,6 @@ object PortFile {
def apply(uri: String, tokenfilePath: String, tokenfileUri: String): PortFile = new PortFile(uri, Option(tokenfilePath), Option(tokenfileUri))
def apply(uri: String, tokenfilePath: Option[String], tokenfileUri: Option[String], sysProps: Vector[String], sysPropsRecorded: Option[Boolean]): PortFile = new PortFile(uri, tokenfilePath, tokenfileUri, sysProps, sysPropsRecorded)
def apply(uri: String, tokenfilePath: String, tokenfileUri: String, sysProps: Vector[String], sysPropsRecorded: Boolean): PortFile = new PortFile(uri, Option(tokenfilePath), Option(tokenfileUri), sysProps, Option(sysPropsRecorded))
def apply(uri: String, tokenfilePath: Option[String], tokenfileUri: Option[String], sysProps: Vector[String], sysPropsRecorded: Option[Boolean], serverId: Option[String]): PortFile = new PortFile(uri, tokenfilePath, tokenfileUri, sysProps, sysPropsRecorded, serverId)
def apply(uri: String, tokenfilePath: String, tokenfileUri: String, sysProps: Vector[String], sysPropsRecorded: Boolean, serverId: String): PortFile = new PortFile(uri, Option(tokenfilePath), Option(tokenfileUri), sysProps, Option(sysPropsRecorded), Option(serverId))
}
@@ -16,8 +16,9 @@ given PortFileFormat: JsonFormat[sbt.internal.protocol.PortFile] = new JsonForma
val tokenfileUri = unbuilder.readField[Option[String]]("tokenfileUri")
val sysProps = unbuilder.readField[Vector[String]]("sysProps")
val sysPropsRecorded = unbuilder.readField[Option[Boolean]]("sysPropsRecorded")
val serverId = unbuilder.readField[Option[String]]("serverId")
unbuilder.endObject()
sbt.internal.protocol.PortFile(uri, tokenfilePath, tokenfileUri, sysProps, sysPropsRecorded)
sbt.internal.protocol.PortFile(uri, tokenfilePath, tokenfileUri, sysProps, sysPropsRecorded, serverId)
case None =>
deserializationError("Expected JsObject but found None")
}
@@ -29,6 +30,7 @@ given PortFileFormat: JsonFormat[sbt.internal.protocol.PortFile] = new JsonForma
builder.addField("tokenfileUri", obj.tokenfileUri)
builder.addField("sysProps", obj.sysProps)
builder.addField("sysPropsRecorded", obj.sysPropsRecorded)
builder.addField("serverId", obj.serverId)
builder.endObject()
}
}
@@ -14,6 +14,9 @@ type PortFile {
## Whether sysProps is the whole story. Absent on a server no thin client started, whose
## options are its own business and unknown here.
sysPropsRecorded: Boolean @since("2.0.7")
## Identifies the server that wrote this file. A server that reads an id other than its own
## has been replaced, and no client can reach it any more.
serverId: String @since("2.0.9")
}
type TokenFile {
@@ -21,6 +21,8 @@ import sbt.internal.protocol.codec.{ PortFileFormats, TokenFileFormats }
import sbt.internal.util.Util.isWindows
import org.scalasbt.ipcsocket.*
import scala.util.{ Failure, Success, Try }
object ClientSocket {
private lazy val fileFormats = new BasicJsonProtocol with PortFileFormats with TokenFileFormats {}
@@ -31,19 +33,19 @@ object ClientSocket {
def socket(portfile: File): (Socket, Option[String]) = socket(portfile, false)
/** Parses the connection file written by the server. */
private[sbt] def portFile(portfile: File): PortFile = {
private[sbt] def loadPortFile(portfile: File): Try[PortFile] = {
import fileFormats.given
try
val json: JValue = Parser.parseFromString(sbt.io.IO.read(portfile)).get
Converter.fromJson[PortFile](json).get
catch case NonFatal(e) => throw new ConnectionFileReadException(portfile, e)
val parsed = Try(sbt.io.IO.read(portfile)).flatMap(Parser.parseFromString)
parsed.flatMap(Converter.fromJson[PortFile])
}
def socket(portfile: File, useJNI: Boolean): (Socket, Option[String]) = {
import fileFormats.given
val p = portFile(portfile)
val p = loadPortFile(portfile) match {
case Success(p) => p
case Failure(e) => throw new ConnectionFileReadException(portfile, e)
}
val uri = new URI(p.uri)
// println(uri)
val token = p.tokenfilePath map { tp =>
val tokeFile = new File(tp)
try