diff --git a/RELEASES.md b/RELEASES.md index 4a859ddff67..d99009ec8e3 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1,5 +1,10 @@ # Enso Next +## Tooling + +- Implemented an HTTP endponint returning the time that the language server has + spent idle ([#1847](https://github.com/enso-org/enso/pull/1847)). + # Enso 0.2.13 (2021-07-09) ## Interpreter/Runtime diff --git a/docs/language-server/README.md b/docs/language-server/README.md index ba7abcc71f1..8130d53d74c 100644 --- a/docs/language-server/README.md +++ b/docs/language-server/README.md @@ -45,3 +45,5 @@ The protocol messages are broken up into documents as follows: The messages and types pertaining to the project manager component. - [**Language Server Message Specification:**](./protocol-language-server.md) The messages and types pertaining to the language server component. +- [**Language Server Http Endpoints Specification**](./language-server-http-endoints.md) + Specification of the Language Server Http endpoints. diff --git a/docs/language-server/language-server-http-endpoints.md b/docs/language-server/language-server-http-endpoints.md new file mode 100644 index 00000000000..a57432b6a4b --- /dev/null +++ b/docs/language-server/language-server-http-endpoints.md @@ -0,0 +1,140 @@ +--- +layout: developer-doc +title: Language Server HTTP Endpoints +category: language-server +tags: [language-server, protocol, specification] +order: 6 +--- + +# HTTP Endpoints + +Language server exposes a number of HTTP endpoints on the same socket as the +JSONRPC protocol. + + + +- [`/_health`](#---health-) +- [`/_health/readiness`](#---health-readiness-) +- [`/_health/liveness`](#---health-liveness-) +- [`/_idle`](#---idle-) + + + +## `/_health` + +HTTP endpoint that provides basic health checking capabilities. + +### `GET | HEAD` + +Returns `200 OK` when the server is started and `500 Internal Server Error` +otherwise. + +#### Request + +```text +> GET /_health HTTP/1.1 +> Host: localhost:63597 +> User-Agent: curl/7.77.0 +> Accept: */* +``` + +#### Response + +```text +< HTTP/1.1 200 OK +< Server: akka-http/10.2.0-RC1 +< Date: Fri, 09 Jul 2021 15:16:16 GMT +< Content-Type: text/plain; charset=UTF-8 +< Content-Length: 2 +< +OK +``` + +## `/_health/readiness` + +The server readiness probe. + +### `GET | HEAD` + +Returns `200 OK` when the server is initialized and `500 Internal Server Error` +otherwise. + +#### Request + +```text +> GET /_health/readiness HTTP/1.1 +> Host: localhost:63597 +> User-Agent: curl/7.77.0 +> Accept: */* +``` + +#### Response + +```text +< HTTP/1.1 200 OK +< Server: akka-http/10.2.0-RC1 +< Date: Fri, 09 Jul 2021 15:30:53 GMT +< Content-Type: text/plain; charset=UTF-8 +< Content-Length: 2 +< +OK +``` + +## `/_health/liveness` + +The server liveness probe. + +### `GET | HEAD` + +Checks if all the server subsystems are functioning and returns `200 OK` or +`500 Internal Server Error` otherwise. + +#### Request + +```text +> GET /_health/liveness HTTP/1.1 +> Host: localhost:60339 +> User-Agent: curl/7.77.0 +> Accept: */* +``` + +#### Response + +```text +< HTTP/1.1 200 OK +< Server: akka-http/10.2.0-RC1 +< Date: Fri, 09 Jul 2021 15:35:43 GMT +< Content-Type: text/plain; charset=UTF-8 +< Content-Length: 2 +< +OK +``` + +## `/_idle` + +The server idleness probe. + +### `GET` + +Return the amount of time the language server is idle. + +#### Request + +```text +> GET /_idle HTTP/1.1 +> Host: localhost:60339 +> User-Agent: curl/7.77.0 +> Accept: */* +``` + +#### Response + +```text +< HTTP/1.1 200 OK +< Server: akka-http/10.2.0-RC1 +< Date: Fri, 09 Jul 2021 15:44:51 GMT +< Content-Type: application/json +< Content-Length: 21 +< +{"idle_time_sec":58} +``` diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/boot/MainModule.scala b/engine/language-server/src/main/scala/org/enso/languageserver/boot/MainModule.scala index 17abe4f7fff..bd14d27ebb5 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/boot/MainModule.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/boot/MainModule.scala @@ -2,6 +2,7 @@ package org.enso.languageserver.boot import java.io.File import java.net.URI +import java.time.Clock import akka.actor.ActorSystem import org.enso.jsonrpc.JsonRpcServer @@ -21,7 +22,11 @@ import org.enso.languageserver.filemanager.{ } import org.enso.languageserver.http.server.BinaryWebSocketServer import org.enso.languageserver.io._ -import org.enso.languageserver.monitoring.HealthCheckEndpoint +import org.enso.languageserver.monitoring.{ + HealthCheckEndpoint, + IdlenessEndpoint, + IdlenessMonitor +} import org.enso.languageserver.protocol.binary.{ BinaryConnectionControllerFactory, InboundMessageDecoder @@ -60,6 +65,8 @@ class MainModule(serverConfig: LanguageServerConfig, logLevel: LogLevel) { logLevel ) + private val utcClock = Clock.systemUTC() + val directoriesConfig = ProjectDirectoriesConfig(serverConfig.contentRootPath) private val contentRoot = ContentRootWithFile( ContentRoot.Project(serverConfig.contentRootUuid), @@ -105,6 +112,9 @@ class MainModule(serverConfig: LanguageServerConfig, logLevel: LogLevel) { val versionsRepo = new SqlVersionsRepo(sqlDatabase)(system.dispatcher) log.trace("Created SQL repos: [{}. {}].", suggestionsRepo, versionsRepo) + val idlenessMonitor = + system.actorOf(IdlenessMonitor.props(utcClock)) + lazy val sessionRouter = system.actorOf(SessionRouter.props(), "session-router") @@ -272,6 +282,7 @@ class MainModule(serverConfig: LanguageServerConfig, logLevel: LogLevel) { stdErrController, stdInController, runtimeConnector, + idlenessMonitor, languageServerConfig ) log.trace( @@ -296,13 +307,16 @@ class MainModule(serverConfig: LanguageServerConfig, logLevel: LogLevel) { serverConfig.computeExecutionContext ) + val idlenessEndpoint = + new IdlenessEndpoint(idlenessMonitor) + val jsonRpcServer = new JsonRpcServer( JsonRpc.protocol, jsonRpcControllerFactory, JsonRpcServer .Config(outgoingBufferSize = 10000, lazyMessageTimeout = 10.seconds), - List(healthCheckEndpoint) + List(healthCheckEndpoint, idlenessEndpoint) ) log.trace("Created JSON RPC Server [{}].", jsonRpcServer) diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/monitoring/IdlenessEndpoint.scala b/engine/language-server/src/main/scala/org/enso/languageserver/monitoring/IdlenessEndpoint.scala new file mode 100644 index 00000000000..0b7e95d6296 --- /dev/null +++ b/engine/language-server/src/main/scala/org/enso/languageserver/monitoring/IdlenessEndpoint.scala @@ -0,0 +1,64 @@ +package org.enso.languageserver.monitoring + +import akka.actor.ActorRef +import akka.http.scaladsl.model.{ + ContentTypes, + HttpEntity, + MessageEntity, + StatusCodes +} +import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.server.Route +import akka.pattern.ask +import akka.util.Timeout +import com.typesafe.scalalogging.LazyLogging +import org.enso.jsonrpc._ + +import scala.concurrent.duration._ +import scala.util.{Failure, Success} + +/** HTTP endpoint that provides idleness capabilities. + * + * @param idlenessMonitor an actor monitoring the server idle time + */ +class IdlenessEndpoint( + idlenessMonitor: ActorRef +) extends Endpoint + with LazyLogging { + + implicit private val timeout: Timeout = Timeout(10.seconds) + + /** @inheritdoc */ + override def route: Route = + idlenessProbe + + private val idlenessProbe = + path("_idle") { + get { + checkIdleness() + } + } + + private def checkIdleness(): Route = { + val future = idlenessMonitor ? MonitoringProtocol.GetIdleTime + + onComplete(future) { + case Failure(_) => + complete(StatusCodes.InternalServerError) + case Success(r: MonitoringProtocol.IdleTime) => + complete(IdlenessEndpoint.toHttpEntity(r)) + case Success(r) => + logger.error("Unexpected response from idleness monitor: [{}]", r) + complete(StatusCodes.InternalServerError) + } + } +} + +object IdlenessEndpoint { + + private def toJsonText(t: MonitoringProtocol.IdleTime): String = + s"""{"idle_time_sec":${t.idleTimeSeconds}}""" + + def toHttpEntity(t: MonitoringProtocol.IdleTime): MessageEntity = + HttpEntity(ContentTypes.`application/json`, toJsonText(t)) +} diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/monitoring/IdlenessMonitor.scala b/engine/language-server/src/main/scala/org/enso/languageserver/monitoring/IdlenessMonitor.scala new file mode 100644 index 00000000000..d4e3246cf6d --- /dev/null +++ b/engine/language-server/src/main/scala/org/enso/languageserver/monitoring/IdlenessMonitor.scala @@ -0,0 +1,35 @@ +package org.enso.languageserver.monitoring + +import java.time.{Clock, Duration, Instant} + +import akka.actor.{Actor, Props} +import org.enso.languageserver.util.UnhandledLogging + +/** An actor that monitors the server time spent idle. + * + * @param clock the system clock + */ +class IdlenessMonitor(clock: Clock) extends Actor with UnhandledLogging { + + override def receive: Receive = initialized(clock.instant()) + + private def initialized(lastActiveTime: Instant): Receive = { + case MonitoringProtocol.ResetIdleTime => + context.become(initialized(clock.instant())) + + case MonitoringProtocol.GetIdleTime => + val idleTime = Duration.between(lastActiveTime, clock.instant()) + sender() ! MonitoringProtocol.IdleTime(idleTime.toSeconds) + } + +} + +object IdlenessMonitor { + + /** Creates a configuration object used to create an idleness monitor. + * + * @return a configuration object + */ + def props(clock: Clock): Props = Props(new IdlenessMonitor(clock)) + +} diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/monitoring/MonitoringProtocol.scala b/engine/language-server/src/main/scala/org/enso/languageserver/monitoring/MonitoringProtocol.scala index 3b2f6ab254b..6814151d62d 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/monitoring/MonitoringProtocol.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/monitoring/MonitoringProtocol.scala @@ -26,4 +26,16 @@ object MonitoringProtocol { */ case object OK extends ReadinessResponse + /** A request to reset the idle time. */ + case object ResetIdleTime + + /** A request to get the server idle time. */ + case object GetIdleTime + + /** A response containing the idle time. + * + * @param idleTimeSeconds the idle time in seconds. + */ + case class IdleTime(idleTimeSeconds: Long) + } diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonConnectionController.scala b/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonConnectionController.scala index be3adba01c1..21c90523278 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonConnectionController.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonConnectionController.scala @@ -1,5 +1,7 @@ package org.enso.languageserver.protocol.json +import java.util.UUID + import akka.actor.{Actor, ActorRef, Cancellable, Props, Stash, Status} import akka.pattern.pipe import akka.util.Timeout @@ -15,6 +17,7 @@ import org.enso.languageserver.capability.CapabilityApi.{ import org.enso.languageserver.capability.CapabilityProtocol import org.enso.languageserver.data.Config import org.enso.languageserver.event.{ + InitializedEvent, JsonSessionInitialized, JsonSessionTerminated } @@ -24,6 +27,7 @@ import org.enso.languageserver.io.InputOutputApi._ import org.enso.languageserver.io.OutputKind.{StandardError, StandardOutput} import org.enso.languageserver.io.{InputOutputApi, InputOutputProtocol} import org.enso.languageserver.monitoring.MonitoringApi.{InitialPing, Ping} +import org.enso.languageserver.monitoring.MonitoringProtocol import org.enso.languageserver.refactoring.RefactoringApi.RenameProject import org.enso.languageserver.requesthandler._ import org.enso.languageserver.requesthandler.capability._ @@ -63,7 +67,6 @@ import org.enso.languageserver.text.TextProtocol import org.enso.languageserver.util.UnhandledLogging import org.enso.languageserver.workspace.WorkspaceApi.ProjectInfo -import java.util.UUID import scala.concurrent.duration._ /** An actor handling communications between a single client and the language @@ -77,6 +80,7 @@ import scala.concurrent.duration._ * @param contentRootManager manages the available content roots * @param contextRegistry a router that dispatches execution context requests * @param suggestionsHandler a reference to the suggestions requests handler + * @param idlenessMonitor a reference to the idleness monitor actor * @param requestTimeout a request timeout */ class JsonConnectionController( @@ -92,6 +96,7 @@ class JsonConnectionController( val stdErrController: ActorRef, val stdInController: ActorRef, val runtimeConnector: ActorRef, + val idlenessMonitor: ActorRef, val languageServerConfig: Config, requestTimeout: FiniteDuration = 10.seconds ) extends Actor @@ -157,6 +162,9 @@ class JsonConnectionController( logger.info("RPC session initialized for client [{}].", clientId) val session = JsonSession(clientId, self) context.system.eventStream.publish(JsonSessionInitialized(session)) + context.system.eventStream.publish( + InitializedEvent.InitializationFinished + ) val cancellable = context.system.scheduler.scheduleOnce( requestTimeout, @@ -179,6 +187,7 @@ class JsonConnectionController( case Status.Failure(ex) => logger.error("Failed to initialize the resources. {}", ex.getMessage) receiver ! ResponseError(Some(request.id), ResourcesInitializationError) + context.system.eventStream.publish(InitializedEvent.InitializationFailed) context.become(connected(webActor)) case _ => stash() @@ -356,6 +365,7 @@ class JsonConnectionController( } case req @ Request(method, _, _) if requestHandlers.contains(method) => + refreshIdleTime(method) val handler = context.actorOf( requestHandlers(method), s"request-handler-$method-${UUID.randomUUID()}" @@ -363,6 +373,15 @@ class JsonConnectionController( handler.forward(req) } + private def refreshIdleTime(method: Method): Unit = { + method match { + case InitialPing | Ping => + // ignore + case _ => + idlenessMonitor ! MonitoringProtocol.ResetIdleTime + } + } + private def createRequestHandlers( rpcSession: JsonSession ): Map[Method, Props] = { @@ -473,6 +492,7 @@ object JsonConnectionController { stdErrController: ActorRef, stdInController: ActorRef, runtimeConnector: ActorRef, + idlenessMonitor: ActorRef, languageServerConfig: Config, requestTimeout: FiniteDuration = 10.seconds ): Props = @@ -490,6 +510,7 @@ object JsonConnectionController { stdErrController, stdInController, runtimeConnector, + idlenessMonitor, languageServerConfig, requestTimeout ) diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonConnectionControllerFactory.scala b/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonConnectionControllerFactory.scala index 652a960d191..d2bec2be2a6 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonConnectionControllerFactory.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonConnectionControllerFactory.scala @@ -26,6 +26,7 @@ class JsonConnectionControllerFactory( stdErrController: ActorRef, stdInController: ActorRef, runtimeConnector: ActorRef, + idlenessMonitor: ActorRef, config: Config )(implicit system: ActorSystem) extends ClientControllerFactory { @@ -50,6 +51,7 @@ class JsonConnectionControllerFactory( stdErrController, stdInController, runtimeConnector, + idlenessMonitor, config ) ) diff --git a/engine/language-server/src/test/scala/org/enso/languageserver/TestClock.scala b/engine/language-server/src/test/scala/org/enso/languageserver/TestClock.scala new file mode 100644 index 00000000000..50ad59130f8 --- /dev/null +++ b/engine/language-server/src/test/scala/org/enso/languageserver/TestClock.scala @@ -0,0 +1,27 @@ +package org.enso.languageserver + +import java.time.{Clock, Instant, ZoneId, ZoneOffset} + +/** Test clock which time flow can be controlled. + * + * @param instant the initial point in time + */ +case class TestClock(var instant: Instant = Instant.EPOCH) extends Clock { + + private val UTC = ZoneOffset.UTC + + /** @inheritdoc */ + override def getZone: ZoneId = UTC + + /** @inheritdoc */ + override def withZone(zone: ZoneId): Clock = + TestClock(instant) + + /** Move time forward by the specified amount of seconds. + * + * @param seconds the amount of seconds + */ + def moveTimeForward(seconds: Long): Unit = { + instant = instant.plusSeconds(seconds) + } +} diff --git a/engine/language-server/src/test/scala/org/enso/languageserver/monitoring/IdlenessEndpointSpec.scala b/engine/language-server/src/test/scala/org/enso/languageserver/monitoring/IdlenessEndpointSpec.scala new file mode 100644 index 00000000000..39a2b86f437 --- /dev/null +++ b/engine/language-server/src/test/scala/org/enso/languageserver/monitoring/IdlenessEndpointSpec.scala @@ -0,0 +1,75 @@ +package org.enso.languageserver.monitoring + +import akka.actor.ActorRef +import akka.http.scaladsl.model.StatusCodes +import akka.http.scaladsl.server.Directives +import akka.http.scaladsl.testkit.{RouteTestTimeout, ScalatestRouteTest} +import org.enso.languageserver.TestClock +import org.enso.testkit.FlakySpec +import org.scalatest.flatspec.AnyFlatSpecLike +import org.scalatest.matchers.should.Matchers + +import scala.concurrent.duration._ + +class IdlenessEndpointSpec + extends AnyFlatSpecLike + with Matchers + with FlakySpec + with ScalatestRouteTest + with Directives { + + implicit val timeout = RouteTestTimeout(25.seconds) + + "An idleness probe" should "reply with server idle time" in withEndpoint { + (_, _, endpoint) => + Get("/_idle") ~> endpoint.route ~> check { + status shouldEqual StatusCodes.OK + responseAs[String] shouldEqual s"""{"idle_time_sec":0}""" + } + } + + it should "count idle time" in withEndpoint { (clock, _, endpoint) => + Get("/_idle") ~> endpoint.route ~> check { + status shouldEqual StatusCodes.OK + responseAs[String] shouldEqual s"""{"idle_time_sec":0}""" + } + + val idleTimeSeconds = 1L + clock.moveTimeForward(idleTimeSeconds) + Get("/_idle") ~> endpoint.route ~> check { + status shouldEqual StatusCodes.OK + responseAs[String] shouldEqual s"""{"idle_time_sec":$idleTimeSeconds}""" + } + } + + it should "reset idle time" in withEndpoint { (clock, monitor, endpoint) => + Get("/_idle") ~> endpoint.route ~> check { + status shouldEqual StatusCodes.OK + responseAs[String] shouldEqual s"""{"idle_time_sec":0}""" + } + + val idleTimeSeconds = 1L + clock.moveTimeForward(idleTimeSeconds) + Get("/_idle") ~> endpoint.route ~> check { + status shouldEqual StatusCodes.OK + responseAs[String] shouldEqual s"""{"idle_time_sec":$idleTimeSeconds}""" + } + + monitor ! MonitoringProtocol.ResetIdleTime + Get("/_idle") ~> endpoint.route ~> check { + status shouldEqual StatusCodes.OK + responseAs[String] shouldEqual s"""{"idle_time_sec":0}""" + } + } + + def withEndpoint( + test: (TestClock, ActorRef, IdlenessEndpoint) => Any + ): Unit = { + val clock = TestClock() + val idlenessMonitor = system.actorOf(IdlenessMonitor.props(clock)) + val endpoint = new IdlenessEndpoint(idlenessMonitor) + + test(clock, idlenessMonitor, endpoint) + } + +} diff --git a/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/BaseServerTest.scala b/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/BaseServerTest.scala index 9501707472f..2485e4b2e93 100644 --- a/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/BaseServerTest.scala +++ b/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/BaseServerTest.scala @@ -1,5 +1,8 @@ package org.enso.languageserver.websocket.json +import java.nio.file.Files +import java.util.UUID + import akka.testkit.TestProbe import io.circe.literal._ import io.circe.parser.parse @@ -18,6 +21,7 @@ import org.enso.languageserver.effect.ZioExec import org.enso.languageserver.event.InitializedEvent import org.enso.languageserver.filemanager._ import org.enso.languageserver.io._ +import org.enso.languageserver.monitoring.IdlenessMonitor import org.enso.languageserver.protocol.json.{ JsonConnectionControllerFactory, JsonRpc @@ -26,6 +30,7 @@ import org.enso.languageserver.refactoring.ProjectNameChangedEvent import org.enso.languageserver.runtime.{ContextRegistry, RuntimeFailureMapper} import org.enso.languageserver.search.SuggestionsHandler import org.enso.languageserver.session.SessionRouter +import org.enso.languageserver.TestClock import org.enso.languageserver.text.BufferRegistry import org.enso.polyglot.data.TypeGraph import org.enso.polyglot.runtime.Runtime.Api @@ -34,8 +39,6 @@ import org.enso.testkit.EitherValue import org.enso.text.Sha3_224VersionCalculator import org.scalatest.OptionValues -import java.nio.file.Files -import java.util.UUID import scala.concurrent.Await import scala.concurrent.duration._ @@ -56,6 +59,7 @@ class BaseServerTest val config = mkConfig val runtimeConnectorProbe = TestProbe() val versionCalculator = Sha3_224VersionCalculator + val clock = TestClock() val typeGraph: TypeGraph = { val graph = TypeGraph("Any") @@ -150,6 +154,10 @@ class BaseServerTest ) ) + val idlenessMonitor = system.actorOf( + IdlenessMonitor.props(clock) + ) + val contextRegistry = system.actorOf( ContextRegistry.props( @@ -209,6 +217,7 @@ class BaseServerTest stdErrController, stdInController, runtimeConnectorProbe.ref, + idlenessMonitor, config ) }