Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bsp errors in status #5726

Merged
merged 8 commits into from
Nov 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 164 additions & 16 deletions metals/src/main/scala/scala/meta/internal/bsp/ConnectionBspStatus.scala
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package scala.meta.internal.bsp

import java.util.concurrent.atomic.AtomicBoolean
import java.nio.file.Path
import java.util.concurrent.atomic.AtomicReference

import scala.meta.internal.metals.BspStatus
import scala.meta.internal.metals.Icons
import scala.meta.internal.metals.MetalsEnrichments._
import scala.meta.internal.metals.ReportContext
import scala.meta.internal.metals.ServerCommands
import scala.meta.internal.metals.clients.language.MetalsStatusParams
import scala.meta.internal.metals.clients.language.StatusType
Expand All @@ -13,25 +16,94 @@ class ConnectionBspStatus(
bspStatus: BspStatus,
folderPath: AbsolutePath,
icons: Icons,
) {
private val isServerResponsive = new AtomicBoolean(false)
val status: MetalsStatusParams => Unit = bspStatus.status(folderPath, _)

def connected(serverName: String): Unit =
if (isServerResponsive.compareAndSet(false, true))
status(ConnectionBspStatus.connectedParams(serverName, icons))
def noResponse(serverName: String): Unit =
if (isServerResponsive.compareAndSet(true, false)) {
scribe.debug("server liveness monitor detected no response")
status(ConnectionBspStatus.noResponseParams(serverName, icons))
}
)(implicit rc: ReportContext) {
private val status = new AtomicReference[BspStatusState](
BspStatusState(Disconnected, None, None, shouldShow = false)
)

/** Paths to all bsp error reports from this session (bsp connection). */
private val currentSessionErrors = new AtomicReference[Set[String]](Set())

def connected(serverName: String): Unit = changeState(Connected(serverName))

def noResponse(): Unit = {
scribe.debug("server liveness monitor detected no response")
changeState(NoResponse)
}

def disconnected(): Unit = {
isServerResponsive.set(false)
status(ConnectionBspStatus.disconnectedParams)
currentSessionErrors.set(Set.empty)
changeState(Disconnected)
}

def showError(message: String, pathToReport: Path): Unit = {
val updatedSet =
currentSessionErrors.updateAndGet(_ + pathToReport.toUri().toString())
changeState(ErrorMessage(message), updatedSet)
}

def onReportsUpdate(): Unit = {
jkciesluk marked this conversation as resolved.
Show resolved Hide resolved
status.get().currentState match {
case ErrorMessage(_) => showState(status.get())
case _ =>
}
}

def isBuildServerResponsive: Option[Boolean] =
status.get().currentState match {
case NoResponse => Some(false)
case Disconnected => None
case _ => Some(true)
}

private def changeState(
newState: BspServerState,
errorReports: Set[String] = currentSessionErrors.get(),
) = {
val newServerState = status.updateAndGet(_.changeState(newState))
if (newServerState.shouldShow) {
showState(newServerState, errorReports)
}
}

def isBuildServerResponsive: Boolean = isServerResponsive.get()
private def showState(
statusState: BspStatusState,
errorReports: Set[String] = currentSessionErrors.get(),
) = {
val showParams =
statusState.currentState match {
case Disconnected => ConnectionBspStatus.disconnectedParams
case NoResponse =>
ConnectionBspStatus.noResponseParams(statusState.serverName, icons)
case Connected(serverName) =>
ConnectionBspStatus.connectedParams(serverName, icons)
case ErrorMessage(message) =>
val currentSessionReports = syncWithReportContext(errorReports)
if (currentSessionReports.isEmpty)
ConnectionBspStatus.connectedParams(statusState.serverName, icons)
else
ConnectionBspStatus.bspErrorParams(
statusState.serverName,
icons,
message,
currentSessionReports.size,
)
}
bspStatus.status(folderPath, showParams)
}

/**
* To get the actual number of error reports we take an intersection
* of this session's error reports with the reports in `.metals/.reports/bloop`,
* this allows for two things:
* 1. When user deletes the report from file system the warning will disappear.
* 2. Error deduplication. When for a perticular error a report already exists, we remove the old report.
* For reports management details look [[scala.meta.internal.metals.StdReporter]]
*/
private def syncWithReportContext(errorReports: Set[String]) =
errorReports.intersect(
rc.bloop.getReports().map(_.toPath.toUri().toString()).toSet
)
}

object ConnectionBspStatus {
Expand All @@ -55,4 +127,80 @@ object ConnectionBspStatus {
command = ServerCommands.ConnectBuildServer.id,
commandTooltip = "Reconnect.",
).withStatusType(StatusType.bsp)

def bspErrorParams(
serverName: String,
icons: Icons,
message: String,
errorsNumber: Int,
): MetalsStatusParams =
MetalsStatusParams(
s"$serverName $errorsNumber ${icons.alert}",
"warn",
show = true,
tooltip = message.trimTo(TOOLTIP_MAX_LENGTH),
command = ServerCommands.RunDoctor.id,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a follow up, would it be possible (probably on vs-code side) to make doctor open on the part with error reports?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that sounds like a good idea.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also open them in that case, currently they are folded.

commandTooltip = "Open doctor.",
).withStatusType(StatusType.bsp)

private val TOOLTIP_MAX_LENGTH = 150
}

trait BspServerState
case object Disconnected extends BspServerState
case class Connected(serverName: String) extends BspServerState
case class ErrorMessage(message: String) extends BspServerState
case object NoResponse extends BspServerState

case class BspStatusState(
currentState: BspServerState,
lastError: Option[ErrorMessage],
optServerName: Option[String],
shouldShow: Boolean,
) {
val serverName: String = optServerName.getOrElse("bsp")
def changeState(
newState: BspServerState
): BspStatusState = {
newState match {
case Disconnected if currentState != Disconnected => moveTo(Disconnected)
case NoResponse if currentState != NoResponse => moveTo(NoResponse)
case ErrorMessage(msg) =>
currentState match {
case NoResponse =>
BspStatusState(
NoResponse,
Some(ErrorMessage(msg)),
optServerName,
shouldShow = false,
)
case _ => moveTo(ErrorMessage(msg))
}
case Connected(serverName) =>
currentState match {
case Disconnected => moveTo(Connected(serverName))
case NoResponse =>
lastError match {
case Some(error) => moveTo(error)
case _ => moveTo(Connected(serverName))
}
case _ => this.copy(shouldShow = false)
}
case _ => this.copy(shouldShow = false)
}
}

def moveTo(
newState: BspServerState
): BspStatusState = {
val newServerName =
newState match {
case Connected(serverName) => Some(serverName)
case _ => optServerName
}
val lastError = Some(currentState).collect { case error: ErrorMessage =>
error
}
BspStatusState(newState, lastError, newServerName, shouldShow = true)
}
}
154 changes: 29 additions & 125 deletions metals/src/main/scala/scala/meta/internal/builds/BSPErrorHandler.scala
Original file line number Diff line number Diff line change
@@ -1,49 +1,26 @@
package scala.meta.internal.builds

import java.nio.file.Files
import java.util.concurrent.atomic.AtomicReference

import scala.concurrent.ExecutionContext
import scala.concurrent.Future
import scala.util.control.NonFatal
import java.security.MessageDigest

import scala.meta.internal.bsp.BspSession
import scala.meta.internal.metals.ClientCommands
import scala.meta.internal.metals.ConcurrentHashSet
import scala.meta.internal.metals.Directories
import scala.meta.internal.bsp.ConnectionBspStatus
import scala.meta.internal.metals.MetalsEnrichments._
import scala.meta.internal.metals.Report
import scala.meta.internal.metals.ReportContext
import scala.meta.internal.metals.Tables
import scala.meta.internal.metals.clients.language.MetalsLanguageClient
import scala.meta.io.AbsolutePath

import org.eclipse.{lsp4j => l}

class BspErrorHandler(
languageClient: MetalsLanguageClient,
workspaceFolder: AbsolutePath,
currentSession: () => Option[BspSession],
tables: Tables,
)(implicit context: ExecutionContext) {

protected def logsPath: AbsolutePath =
workspaceFolder.resolve(Directories.log)
private val lastError = new AtomicReference[String]("")
private val dismissedErrors = ConcurrentHashSet.empty[String]

def onError(message: String): Future[Unit] = {
if (
!tables.dismissedNotifications.BspErrors.isDismissed &&
shouldShowBspError &&
!dismissedErrors.contains(message)
) {
val previousError = lastError.getAndSet(message)
if (message != previousError) {
showError(message)
} else Future.successful(())
} else {
logError(message)
Future.successful(())
}
bspStatus: ConnectionBspStatus,
)(implicit reportContext: ReportContext) {
def onError(message: String): Unit = {
if (shouldShowBspError) {
for {
report <- createReport(message)
if !tables.dismissedNotifications.BspErrors.isDismissed
} bspStatus.showError(message, report)
} else logError(message)
}

def shouldShowBspError: Boolean = currentSession().exists(session =>
Expand All @@ -52,96 +29,23 @@ class BspErrorHandler(

protected def logError(message: String): Unit = scribe.error(message)

private def showError(message: String): Future[Unit] = {
val bspError = s"${BspErrorHandler.errorInBsp} $message"
logError(bspError)
val params = BspErrorHandler.makeShowMessage(message)
languageClient.showMessageRequest(params).asScala.flatMap {
case BspErrorHandler.goToLogs =>
val errorMsgStartLine =
bspError.linesIterator.headOption
.flatMap(findLine(_))
.getOrElse(0)
Future.successful(gotoLogs(errorMsgStartLine))
case BspErrorHandler.dismiss =>
Future.successful(dismissedErrors.add(message)).ignoreValue
case BspErrorHandler.doNotShowErrors =>
Future.successful {
tables.dismissedNotifications.BspErrors.dismissForever
}.ignoreValue
case _ => Future.successful(())
}
}

private def findLine(line: String): Option[Int] =
try {
val lineNumber =
Files
.readAllLines(logsPath.toNIO)
.asScala
.lastIndexWhere(_.contains(line))
if (lineNumber >= 0) Some(lineNumber) else None
} catch {
case NonFatal(_) => None
}

private def gotoLogs(line: Int) = {
val pos = new l.Position(line, 0)
val location = new l.Location(
logsPath.toURI.toString(),
new l.Range(pos, pos),
)
languageClient.metalsExecuteClientCommand(
ClientCommands.GotoLocation.toExecuteCommandParams(
ClientCommands.WindowLocation(
location.getUri(),
location.getRange(),
)
private def createReport(message: String) = {
val id = MessageDigest
.getInstance("MD5")
.digest(message.getBytes)
.map(_.toChar)
.mkString
val sanitized = reportContext.bloop.sanitize(message)
reportContext.bloop.create(
Report(
sanitized.trimTo(20),
s"""|### Bloop error:
|
|$message""".stripMargin,
shortSummary = sanitized.trimTo(100),
path = None,
id = Some(id),
)
)
}
}

object BspErrorHandler {
def makeShowMessage(
message: String
): l.ShowMessageRequestParams = {
val (msg, actions) =
if (message.length() <= MESSAGE_MAX_LENGTH) {
(
makeShortMessage(message),
List(dismiss, doNotShowErrors),
)
} else {
(
makeLongMessage(message),
List(goToLogs, dismiss, doNotShowErrors),
)
}
val params = new l.ShowMessageRequestParams()
params.setType(l.MessageType.Error)
params.setMessage(msg)
params.setActions(actions.asJava)
params
}

def makeShortMessage(message: String): String =
s"""|$errorHeader
|$message""".stripMargin

def makeLongMessage(message: String): String =
s"""|${makeShortMessage(s"${message.take(MESSAGE_MAX_LENGTH)}...")}
|$gotoLogsToSeeFull""".stripMargin

val goToLogs = new l.MessageActionItem("Go to logs.")
val dismiss = new l.MessageActionItem("Dismiss.")
val doNotShowErrors = new l.MessageActionItem("Stop showing bsp errors.")

val errorHeader = "Encountered an error in the build server:"
private val gotoLogsToSeeFull = "Go to logs to see the full error"

val errorInBsp = "Build server error:"

private val MESSAGE_MAX_LENGTH = 150

}
Loading