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 Plus Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (2)
📝 WalkthroughWalkthroughAdds one-shot recovery for a wedged Bloop build server. The change detects reused servers, stops and polls them, reports an unresponsive server, and applies typed retry decisions during connection setup. It also adds tests and CI registration. 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()
recoverFromWedgedServer->>BloopRifle: exit()
recoverFromWedgedServer->>awaitBloopStopped: poll check()
awaitBloopStopped->>BloopRifle: check()
alt server stops
BloopRifle-->>awaitBloopStopped: stopped
awaitBloopStopped-->>recoverFromWedgedServer: success
else timeout
recoverFromWedgedServer->>languageClient: showMessage(UnresponsiveBloopServer)
recoverFromWedgedServer-->>BuildServerConnection: throw AlreadyReportedConnectException
end
Possibly related PRs
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. |
…over-wedged-server
|
I checked the logs — doesn't look related. The recovery in this PR only kicks in after a connect failure and always logs when it does; none of its log lines appear in the failing jobs, so the new path never ran there. Also, the failing test differs between the two runs, and |
|
pinging @tgodzik for another round of review |
| ) | ||
| // An `AlreadyReportedConnectException` has already shown the user a | ||
| // specific, actionable message, so don't stack the generic one on top. | ||
| if (!e.isInstanceOf[AlreadyReportedConnectException]) { |
There was a problem hiding this comment.
We could just use match here:
e match {
case _: AlreadyReportedConnectException =>
case _ => ....
| val name = "Bloop" | ||
|
|
||
| // How long to wait for a wedged Bloop server to stop before giving up. | ||
| private val RecoveryTimeoutMs = 10000L |
There was a problem hiding this comment.
Should we set it under MetalsServerOptions? And maybe default to 20000L?
| * actually wedged: the first connection failure restarts the server once, and | ||
| * recovery is never attempted more than once so we don't thrash. | ||
| */ | ||
| class BuildServerConnectionRecoverySuite extends BaseSuite { |
There was a problem hiding this comment.
Not sure if this suite is all that useful. Ideally we would have one that tests hanging bloop, but not sure how to do that.
So I guess this is the most we can have 😅
| * Poll `BloopRifle.check` until Bloop is down or `deadline` (epoch ms) passes, | ||
| * scheduling the delays on `sh` rather than blocking a thread. | ||
| */ | ||
| private def awaitBloopStopped( |
There was a problem hiding this comment.
I wonder what will happen if we have multiple editors open with Bloop build tool. We might never get to stopped phase, because another metals server will recover the connection and start Bloop in the meantime. We might just want to connect afterwards.
We should for sure test recoverFromWedgedServer separately.
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