improvement: Recover from an unresponsive Bloop server - #8529
Conversation
When Bloop reports itself as running but is wedged (it accepts the BSP socket but never finishes `build/initialize`, or the socket disappears), Metals could not connect and showed only a generic "Failed to connect" error, leaving the user to run `build-restart` by hand. `BloopRifle.check` only verifies the daemon socket is connectable, not that it is processing requests, so a stuck server still looks "running". Metals now recovers automatically on both the initial connection and later reconnects: when connecting to a pre-existing server fails, it stops the server via `BloopRifle.exit` and cold-starts a fresh one, retrying once. The decision is a one-shot guard, so a server Metals just started is never killed and recovery never thrashes. If the server cannot be stopped, the user gets actionable guidance instead of a generic error. `ng-stop` runs on an isolated daemon thread so a truly hung server cannot occupy a shared execution-context thread. Addresses scalameta#3146.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughAdds one-shot recovery for a wedged Bloop build server: detects a reused server, stops it, polls until confirmed stopped, and shows a typed error message if it doesn't stop in time. Introduces ChangesWedged Bloop Server Recovery
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant BuildServerConnection
participant recoverFromWedgedServer
participant BloopRifle
participant awaitBloopStopped
participant languageClient
BuildServerConnection->>recoverFromWedgedServer: recoverConnection() [connectedToPreexisting=true]
recoverFromWedgedServer->>BloopRifle: exit() [daemon thread]
recoverFromWedgedServer->>awaitBloopStopped: poll check() until deadline
awaitBloopStopped->>BloopRifle: check() [scheduled retries]
alt server stops in time
BloopRifle-->>awaitBloopStopped: server down
awaitBloopStopped-->>recoverFromWedgedServer: success
recoverFromWedgedServer-->>BuildServerConnection: recovery complete
else timeout
recoverFromWedgedServer->>languageClient: showMessage(UnresponsiveBloopServer)
recoverFromWedgedServer-->>BuildServerConnection: throw AlreadyReportedConnectException
end
sequenceDiagram
participant fromSockets
participant setupServerWithRecovery
participant setupServer
participant recoverConnection
fromSockets->>setupServerWithRecovery: invoke(retriesLeft, alreadyRecovered)
setupServerWithRecovery->>setupServer: attempt connection
setupServer-->>setupServerWithRecovery: NonFatal exception
setupServerWithRecovery->>setupServerWithRecovery: RecoverConnectAction.apply(error, retriesLeft, alreadyRecovered)
alt RecoverAndRetry
setupServerWithRecovery->>recoverConnection: invoke()
setupServerWithRecovery->>setupServerWithRecovery: recurse(retriesLeft-1, alreadyRecovered=true)
else Retry
setupServerWithRecovery->>setupServerWithRecovery: recurse(retriesLeft-1, alreadyRecovered)
else GiveUp
setupServerWithRecovery-->>fromSockets: Future.failed(error)
end
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
metals/src/main/scala/scala/meta/internal/metals/BloopServers.scala (1)
131-131: 💤 Low valueConsider extracting timeout constants for clarity.
The recovery timeout (10 seconds) and polling interval (100ms on line 162) are hardcoded. Extracting these as named constants in the companion object would improve readability and make them easier to tune if needed.
+object BloopServers { + val name = "Bloop" + private val RecoveryTimeoutMs = 10000L + private val RecoveryPollIntervalMs = 100L + // ... +}Then use
RecoveryTimeoutMsandRecoveryPollIntervalMsin the implementation.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@metals/src/main/scala/scala/meta/internal/metals/BloopServers.scala` at line 131, Extract the hardcoded timeout values as named constants in the BloopServers companion object. Create two constants: RecoveryTimeoutMs set to 10000 (for the 10-second timeout currently hardcoded in the awaitBloopStopped call) and RecoveryPollIntervalMs set to 100 (for the polling interval). Then replace the hardcoded value 10000 in the awaitBloopStopped call on line 131 with RecoveryTimeoutMs, and replace the hardcoded polling interval value of 100 on line 162 with RecoveryPollIntervalMs to improve code readability and maintainability.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@metals/src/main/scala/scala/meta/internal/metals/BloopServers.scala`:
- Line 131: Extract the hardcoded timeout values as named constants in the
BloopServers companion object. Create two constants: RecoveryTimeoutMs set to
10000 (for the 10-second timeout currently hardcoded in the awaitBloopStopped
call) and RecoveryPollIntervalMs set to 100 (for the polling interval). Then
replace the hardcoded value 10000 in the awaitBloopStopped call on line 131 with
RecoveryTimeoutMs, and replace the hardcoded polling interval value of 100 on
line 162 with RecoveryPollIntervalMs to improve code readability and
maintainability.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: a00c1717-0aae-435d-bd73-e6dd77d94fd6
📒 Files selected for processing (6)
metals/src/main/scala/scala/meta/internal/metals/BloopServers.scalametals/src/main/scala/scala/meta/internal/metals/BuildServerConnection.scalametals/src/main/scala/scala/meta/internal/metals/ConnectionProvider.scalametals/src/main/scala/scala/meta/internal/metals/Messages.scalaproject/TestGroups.scalatests/unit/src/test/scala/tests/BuildServerConnectionRecoverySuite.scala
|
@tgodzik could we restart failing CI? It doesn't look like connected to my changes. |
|
Looks like tests.sbt.SbtBloopLspSuite is failing for sbt 2, any idea if that is related? Doesn't seem to fail on main. I will rerun again to be sure. |
Problem
Metals sometimes can't connect to Bloop when a stale, unresponsive server is already running (#3146):
BloopRifle.checkonly confirms the socket is connectable, not that the daemon is responsive, so a wedged server looks "running" and the connection fails with a generic "Failed to connect" error. The old fix (shelling out to bloopgun via coursier) is obsolete — Metals now uses bloop-rifle's in-processBloopRifle.exit().What this does
When connecting to a pre-existing Bloop server fails, Metals stops the wedged server and cold-starts a fresh one (once), on both the initial connection and later reconnects. If it can't be stopped, the user gets actionable guidance ("run
build-restartor stop the process manually") instead of a generic error.Notes for reviewers
setupServerlevel inBuildServerConnection.fromSocketsso it covers both the initial connect and every reconnect (setupConnection); this replaces the previous recursivefromSocketsretry.ConnectionProvideris shared across all build servers — the newAlreadyReportedConnectExceptiononly suppresses the redundant generic "Failed to connect" popup when the user has already been shown a specific message; every other server/failure is unchanged.BloopRifle.exit(ng-stop) runs on a dedicated daemon thread on purpose: it's a synchronous call over the possibly-stuck socket, so isolating it keeps a truly hung server from occupying a shared execution-context thread.Closes #3146.
Summary by CodeRabbit