mirror of https://github.com/sbt/sbt.git
[2.x] feat: sbtn subscription level (#8796)
Closes #4399 - Add subscribeToAll to InitializeOption (protocol); default true for backward compatibility. - CommandExchange: send broadcast notifyEvent/logMessage only to channels with subscribeToAll. - TestServer: support subscribeToAll parameter for tests; AbstractServerTest: subscribeToAllForTest. - ClientSubscriptionTest: assert default client receives build/logMessage when command runs. - Scripted test server/client-subscription: run show name to exercise server client path.
This commit is contained in:
parent
f976330759
commit
130a332100
|
|
@ -317,6 +317,7 @@ class NetworkClient(
|
||||||
token = tkn,
|
token = tkn,
|
||||||
skipAnalysis = Some(skipAnalysis),
|
skipAnalysis = Some(skipAnalysis),
|
||||||
canWork = Some(true),
|
canWork = Some(true),
|
||||||
|
subscribeToAll = Some(false),
|
||||||
)
|
)
|
||||||
val initCommand = InitCommand(
|
val initCommand = InitCommand(
|
||||||
token = tkn, // duplicated with opts for compatibility
|
token = tkn, // duplicated with opts for compatibility
|
||||||
|
|
|
||||||
|
|
@ -366,12 +366,11 @@ private[sbt] final class CommandExchange {
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is an interface to directly notify events.
|
// This is an interface to directly notify events.
|
||||||
private[sbt] def notifyEvent[A: JsonFormat](method: String, params: A): Unit = {
|
private[sbt] def notifyEvent[A: JsonFormat](method: String, params: A): Unit =
|
||||||
channels.foreach {
|
channels.foreach:
|
||||||
case c: NetworkChannel => tryTo(_.notifyEvent(method, params))(c)
|
case c: NetworkChannel if c.subscribeToAll || isChannelOwner(c) =>
|
||||||
|
tryTo(_.notifyEvent(method, params))(c)
|
||||||
case _ =>
|
case _ =>
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private def tryTo(f: NetworkChannel => Unit)(
|
private def tryTo(f: NetworkChannel => Unit)(
|
||||||
channel: NetworkChannel
|
channel: NetworkChannel
|
||||||
|
|
@ -418,12 +417,14 @@ private[sbt] final class CommandExchange {
|
||||||
}
|
}
|
||||||
def unprompt(event: ConsoleUnpromptEvent): Unit = channels.foreach(_.unprompt(event))
|
def unprompt(event: ConsoleUnpromptEvent): Unit = channels.foreach(_.unprompt(event))
|
||||||
|
|
||||||
def logMessage(event: LogEvent): Unit = {
|
def logMessage(event: LogEvent): Unit =
|
||||||
channels.foreach {
|
channels.foreach:
|
||||||
case c: NetworkChannel => tryTo(_.notifyEvent(event))(c)
|
case c: NetworkChannel if c.subscribeToAll || isChannelOwner(c) =>
|
||||||
|
tryTo(_.notifyEvent(event))(c)
|
||||||
case _ =>
|
case _ =>
|
||||||
}
|
|
||||||
}
|
private def isChannelOwner(c: NetworkChannel): Boolean =
|
||||||
|
currentExec.exists(_.source.exists(_.channelName == c.name))
|
||||||
|
|
||||||
def notifyStatus(event: ExecStatusEvent): Unit = {
|
def notifyStatus(event: ExecStatusEvent): Unit = {
|
||||||
for {
|
for {
|
||||||
|
|
|
||||||
|
|
@ -58,13 +58,13 @@ private[sbt] object LanguageServerProtocol {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val opt = Converter.fromJson[InitializeOption](optionJson).get
|
val opt = Converter.fromJson[InitializeOption](optionJson).get
|
||||||
|
setInitializeOption(opt)
|
||||||
if (authOptions(ServerAuthentication.Token)) {
|
if (authOptions(ServerAuthentication.Token)) {
|
||||||
val token = opt.token.getOrElse(sys.error("'token' is missing."))
|
val token = opt.token.getOrElse(sys.error("'token' is missing."))
|
||||||
if (authenticate(token)) ()
|
if (authenticate(token)) ()
|
||||||
else throw LangServerError(ErrorCodes.InvalidRequest, "invalid token")
|
else throw LangServerError(ErrorCodes.InvalidRequest, "invalid token")
|
||||||
} else ()
|
} else ()
|
||||||
setInitialized(true)
|
setInitialized(true)
|
||||||
setInitializeOption(opt)
|
|
||||||
if (!opt.skipAnalysis.getOrElse(false)) appendExec("collectAnalyses", None)
|
if (!opt.skipAnalysis.getOrElse(false)) appendExec("collectAnalyses", None)
|
||||||
jsonRpcRespond(InitializeResult(serverCapabilities), Some(r.id))
|
jsonRpcRespond(InitializeResult(serverCapabilities), Some(r.id))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,10 @@ final class NetworkChannel(
|
||||||
case _ => false
|
case _ => false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True if this channel should receive broadcast events (logMessage, notifyEvent). Default true for backward compatibility. */
|
||||||
|
private[sbt] def subscribeToAll: Boolean =
|
||||||
|
Option(initializeOption.get).flatMap(_.subscribeToAll).getOrElse(false)
|
||||||
|
|
||||||
protected def authenticate(token: String): Boolean = instance.authenticate(token)
|
protected def authenticate(token: String): Boolean = instance.authenticate(token)
|
||||||
|
|
||||||
protected def setInitialized(value: Boolean): Unit = initialized = value
|
protected def setInitialized(value: Boolean): Unit = initialized = value
|
||||||
|
|
|
||||||
|
|
@ -11,23 +11,25 @@ package sbt.internal.protocol
|
||||||
final class InitializeOption private (
|
final class InitializeOption private (
|
||||||
val token: Option[String],
|
val token: Option[String],
|
||||||
val skipAnalysis: Option[Boolean],
|
val skipAnalysis: Option[Boolean],
|
||||||
val canWork: Option[Boolean]) extends Serializable {
|
val canWork: Option[Boolean],
|
||||||
|
val subscribeToAll: Option[Boolean]) extends Serializable {
|
||||||
|
|
||||||
private def this(token: Option[String]) = this(token, None, None)
|
private def this(token: Option[String]) = this(token, None, None, None)
|
||||||
private def this(token: Option[String], skipAnalysis: Option[Boolean]) = this(token, skipAnalysis, None)
|
private def this(token: Option[String], skipAnalysis: Option[Boolean]) = this(token, skipAnalysis, None, None)
|
||||||
|
private def this(token: Option[String], skipAnalysis: Option[Boolean], canWork: Option[Boolean]) = this(token, skipAnalysis, canWork, None)
|
||||||
|
|
||||||
override def equals(o: Any): Boolean = this.eq(o.asInstanceOf[AnyRef]) || (o match {
|
override def equals(o: Any): Boolean = this.eq(o.asInstanceOf[AnyRef]) || (o match {
|
||||||
case x: InitializeOption => (this.token == x.token) && (this.skipAnalysis == x.skipAnalysis) && (this.canWork == x.canWork)
|
case x: InitializeOption => (this.token == x.token) && (this.skipAnalysis == x.skipAnalysis) && (this.canWork == x.canWork) && (this.subscribeToAll == x.subscribeToAll)
|
||||||
case _ => false
|
case _ => false
|
||||||
})
|
})
|
||||||
override def hashCode: Int = {
|
override def hashCode: Int = {
|
||||||
37 * (37 * (37 * (37 * (17 + "sbt.internal.protocol.InitializeOption".##) + token.##) + skipAnalysis.##) + canWork.##)
|
37 * (37 * (37 * (37 * (37 * (17 + "sbt.internal.protocol.InitializeOption".##) + token.##) + skipAnalysis.##) + canWork.##) + subscribeToAll.##)
|
||||||
}
|
}
|
||||||
override def toString: String = {
|
override def toString: String = {
|
||||||
"InitializeOption(" + token + ", " + skipAnalysis + ", " + canWork + ")"
|
"InitializeOption(" + token + ", " + skipAnalysis + ", " + canWork + ", " + subscribeToAll + ")"
|
||||||
}
|
}
|
||||||
private def copy(token: Option[String] = token, skipAnalysis: Option[Boolean] = skipAnalysis, canWork: Option[Boolean] = canWork): InitializeOption = {
|
private def copy(token: Option[String] = token, skipAnalysis: Option[Boolean] = skipAnalysis, canWork: Option[Boolean] = canWork, subscribeToAll: Option[Boolean] = subscribeToAll): InitializeOption = {
|
||||||
new InitializeOption(token, skipAnalysis, canWork)
|
new InitializeOption(token, skipAnalysis, canWork, subscribeToAll)
|
||||||
}
|
}
|
||||||
def withToken(token: Option[String]): InitializeOption = {
|
def withToken(token: Option[String]): InitializeOption = {
|
||||||
copy(token = token)
|
copy(token = token)
|
||||||
|
|
@ -47,6 +49,12 @@ final class InitializeOption private (
|
||||||
def withCanWork(canWork: Boolean): InitializeOption = {
|
def withCanWork(canWork: Boolean): InitializeOption = {
|
||||||
copy(canWork = Option(canWork))
|
copy(canWork = Option(canWork))
|
||||||
}
|
}
|
||||||
|
def withSubscribeToAll(subscribeToAll: Option[Boolean]): InitializeOption = {
|
||||||
|
copy(subscribeToAll = subscribeToAll)
|
||||||
|
}
|
||||||
|
def withSubscribeToAll(subscribeToAll: Boolean): InitializeOption = {
|
||||||
|
copy(subscribeToAll = Option(subscribeToAll))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
object InitializeOption {
|
object InitializeOption {
|
||||||
|
|
||||||
|
|
@ -56,4 +64,6 @@ object InitializeOption {
|
||||||
def apply(token: String, skipAnalysis: Boolean): InitializeOption = new InitializeOption(Option(token), Option(skipAnalysis))
|
def apply(token: String, skipAnalysis: Boolean): InitializeOption = new InitializeOption(Option(token), Option(skipAnalysis))
|
||||||
def apply(token: Option[String], skipAnalysis: Option[Boolean], canWork: Option[Boolean]): InitializeOption = new InitializeOption(token, skipAnalysis, canWork)
|
def apply(token: Option[String], skipAnalysis: Option[Boolean], canWork: Option[Boolean]): InitializeOption = new InitializeOption(token, skipAnalysis, canWork)
|
||||||
def apply(token: String, skipAnalysis: Boolean, canWork: Boolean): InitializeOption = new InitializeOption(Option(token), Option(skipAnalysis), Option(canWork))
|
def apply(token: String, skipAnalysis: Boolean, canWork: Boolean): InitializeOption = new InitializeOption(Option(token), Option(skipAnalysis), Option(canWork))
|
||||||
|
def apply(token: Option[String], skipAnalysis: Option[Boolean], canWork: Option[Boolean], subscribeToAll: Option[Boolean]): InitializeOption = new InitializeOption(token, skipAnalysis, canWork, subscribeToAll)
|
||||||
|
def apply(token: String, skipAnalysis: Boolean, canWork: Boolean, subscribeToAll: Boolean): InitializeOption = new InitializeOption(Option(token), Option(skipAnalysis), Option(canWork), Option(subscribeToAll))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,9 @@ given InitializeOptionFormat: JsonFormat[sbt.internal.protocol.InitializeOption]
|
||||||
val token = unbuilder.readField[Option[String]]("token")
|
val token = unbuilder.readField[Option[String]]("token")
|
||||||
val skipAnalysis = unbuilder.readField[Option[Boolean]]("skipAnalysis")
|
val skipAnalysis = unbuilder.readField[Option[Boolean]]("skipAnalysis")
|
||||||
val canWork = unbuilder.readField[Option[Boolean]]("canWork")
|
val canWork = unbuilder.readField[Option[Boolean]]("canWork")
|
||||||
|
val subscribeToAll = unbuilder.readField[Option[Boolean]]("subscribeToAll")
|
||||||
unbuilder.endObject()
|
unbuilder.endObject()
|
||||||
sbt.internal.protocol.InitializeOption(token, skipAnalysis, canWork)
|
sbt.internal.protocol.InitializeOption(token, skipAnalysis, canWork, subscribeToAll)
|
||||||
case None =>
|
case None =>
|
||||||
deserializationError("Expected JsObject but found None")
|
deserializationError("Expected JsObject but found None")
|
||||||
}
|
}
|
||||||
|
|
@ -25,6 +26,7 @@ given InitializeOptionFormat: JsonFormat[sbt.internal.protocol.InitializeOption]
|
||||||
builder.addField("token", obj.token)
|
builder.addField("token", obj.token)
|
||||||
builder.addField("skipAnalysis", obj.skipAnalysis)
|
builder.addField("skipAnalysis", obj.skipAnalysis)
|
||||||
builder.addField("canWork", obj.canWork)
|
builder.addField("canWork", obj.canWork)
|
||||||
|
builder.addField("subscribeToAll", obj.subscribeToAll)
|
||||||
builder.endObject()
|
builder.endObject()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,4 +22,5 @@ type InitializeOption {
|
||||||
token: String
|
token: String
|
||||||
skipAnalysis: Boolean @since("1.4.0")
|
skipAnalysis: Boolean @since("1.4.0")
|
||||||
canWork: Boolean @since("1.10.8")
|
canWork: Boolean @since("1.10.8")
|
||||||
|
subscribeToAll: Boolean @since("2.0.0")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
name := "client-subscription"
|
||||||
|
scalaVersion := "2.12.19"
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Exercise server client path (subscribe-to-all by default). Closes #4399.
|
||||||
|
> show name
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
/*
|
||||||
|
* sbt
|
||||||
|
* Copyright 2023, Scala center
|
||||||
|
* Copyright 2011 - 2022, Lightbend, Inc.
|
||||||
|
* Copyright 2008 - 2010, Mark Harrah
|
||||||
|
* Licensed under Apache License 2.0 (see LICENSE)
|
||||||
|
*/
|
||||||
|
|
||||||
|
package testpkg
|
||||||
|
|
||||||
|
import scala.concurrent.duration.*
|
||||||
|
|
||||||
|
class ClientSubscriptionTest extends AbstractServerTest {
|
||||||
|
override val testDirectory: String = "handshake"
|
||||||
|
|
||||||
|
test("subscribe-to-all (default) client receives broadcast build/logMessage when command runs") {
|
||||||
|
svr.sendJsonRpc(
|
||||||
|
"""{ "jsonrpc": "2.0", "id": 2, "method": "sbt/exec", "params": { "commandLine": "show name" } }"""
|
||||||
|
)
|
||||||
|
def isLogMessageNotification(line: String): Boolean =
|
||||||
|
line.contains("\"method\":\"build/logMessage\"") || line.contains(
|
||||||
|
"\"method\": \"build/logMessage\""
|
||||||
|
)
|
||||||
|
assert(
|
||||||
|
svr.waitForString(10.seconds)(isLogMessageNotification),
|
||||||
|
"subscribe-to-all client must receive broadcast build/logMessage when a command produces log output"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ClientNoSubscriptionTest extends AbstractServerTest {
|
||||||
|
override val testDirectory: String = "handshake"
|
||||||
|
override def subscribeToAllForTest: Boolean = false
|
||||||
|
|
||||||
|
test("non-subscribed client receives build/logMessage for its own command") {
|
||||||
|
svr.sendJsonRpc(
|
||||||
|
"""{ "jsonrpc": "2.0", "id": 2, "method": "sbt/exec", "params": { "commandLine": "show name" } }"""
|
||||||
|
)
|
||||||
|
def isLogMessageNotification(line: String): Boolean =
|
||||||
|
line.contains("\"method\":\"build/logMessage\"") || line.contains(
|
||||||
|
"\"method\": \"build/logMessage\""
|
||||||
|
)
|
||||||
|
assert(
|
||||||
|
svr.waitForString(10.seconds)(isLogMessageNotification),
|
||||||
|
"non-subscribed client must still receive build/logMessage for its own command"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,7 @@ trait AbstractServerTest extends AnyFunSuite with BeforeAndAfterAll {
|
||||||
var svr: TestServer = scala.compiletime.uninitialized
|
var svr: TestServer = scala.compiletime.uninitialized
|
||||||
def testDirectory: String
|
def testDirectory: String
|
||||||
def testPath: Path = temp.toPath.resolve(testDirectory)
|
def testPath: Path = temp.toPath.resolve(testDirectory)
|
||||||
|
def subscribeToAllForTest: Boolean = true
|
||||||
|
|
||||||
private val targetDir: File = {
|
private val targetDir: File = {
|
||||||
val p0 = new File("..").getAbsoluteFile.getCanonicalFile / "target"
|
val p0 = new File("..").getAbsoluteFile.getCanonicalFile / "target"
|
||||||
|
|
@ -48,7 +49,14 @@ trait AbstractServerTest extends AnyFunSuite with BeforeAndAfterAll {
|
||||||
val classpath = TestProperties.classpath.split(File.pathSeparator).map(new File(_))
|
val classpath = TestProperties.classpath.split(File.pathSeparator).map(new File(_))
|
||||||
val sbtVersion = TestProperties.version
|
val sbtVersion = TestProperties.version
|
||||||
val scalaVersion = TestProperties.scalaVersion
|
val scalaVersion = TestProperties.scalaVersion
|
||||||
svr = TestServer.get(testDirectory, scalaVersion, sbtVersion, classpath.toSeq, temp)
|
svr = TestServer.get(
|
||||||
|
testDirectory,
|
||||||
|
scalaVersion,
|
||||||
|
sbtVersion,
|
||||||
|
classpath.toSeq,
|
||||||
|
temp,
|
||||||
|
subscribeToAllForTest
|
||||||
|
)
|
||||||
}
|
}
|
||||||
override protected def afterAll(): Unit = {
|
override protected def afterAll(): Unit = {
|
||||||
svr.bye()
|
svr.bye()
|
||||||
|
|
@ -71,13 +79,14 @@ object TestServer {
|
||||||
scalaVersion: String,
|
scalaVersion: String,
|
||||||
sbtVersion: String,
|
sbtVersion: String,
|
||||||
classpath: Seq[File],
|
classpath: Seq[File],
|
||||||
temp: File
|
temp: File,
|
||||||
|
subscribeToAll: Boolean = true
|
||||||
): TestServer = {
|
): TestServer = {
|
||||||
println(s"Starting test server $testBuild")
|
println(s"Starting test server $testBuild")
|
||||||
IO.copyDirectory(serverTestBase / testBuild, temp / testBuild)
|
IO.copyDirectory(serverTestBase / testBuild, temp / testBuild)
|
||||||
|
|
||||||
// Each test server instance will be executed in a Thread pool separated from the tests
|
val testServer =
|
||||||
val testServer = TestServer(temp / testBuild, scalaVersion, sbtVersion, classpath)
|
TestServer(temp / testBuild, scalaVersion, sbtVersion, classpath, subscribeToAll)
|
||||||
// checking last log message after initialization
|
// checking last log message after initialization
|
||||||
// if something goes wrong here the communication streams are corrupted, restarting
|
// if something goes wrong here the communication streams are corrupted, restarting
|
||||||
val init =
|
val init =
|
||||||
|
|
@ -155,7 +164,8 @@ case class TestServer(
|
||||||
baseDirectory: File,
|
baseDirectory: File,
|
||||||
scalaVersion: String,
|
scalaVersion: String,
|
||||||
sbtVersion: String,
|
sbtVersion: String,
|
||||||
classpath: Seq[File]
|
classpath: Seq[File],
|
||||||
|
subscribeToAll: Boolean = true
|
||||||
) {
|
) {
|
||||||
import TestServer.hostLog
|
import TestServer.hostLog
|
||||||
|
|
||||||
|
|
@ -227,8 +237,11 @@ case class TestServer(
|
||||||
}
|
}
|
||||||
|
|
||||||
// initiate handshake
|
// initiate handshake
|
||||||
|
private val initOptions =
|
||||||
|
if subscribeToAll then """{ "skipAnalysis": true, "canWork": true }"""
|
||||||
|
else """{ "skipAnalysis": true, "canWork": true, "subscribeToAll": false }"""
|
||||||
sendJsonRpc(
|
sendJsonRpc(
|
||||||
s"""{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "initializationOptions": { "skipAnalysis": true, "canWork": true } } }"""
|
s"""{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "initializationOptions": $initOptions } }"""
|
||||||
)
|
)
|
||||||
|
|
||||||
def test(f: TestServer => Future[Unit]): Future[Unit] = f(this)
|
def test(f: TestServer => Future[Unit]): Future[Unit] = f(this)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue