Skip to content

improvement: Recover from an unresponsive Bloop server - #8529

Open
jozanek wants to merge 3 commits into
scalameta:mainfrom
jozanek:improvement/bloop-recover-wedged-server
Open

improvement: Recover from an unresponsive Bloop server#8529
jozanek wants to merge 3 commits into
scalameta:mainfrom
jozanek:improvement/bloop-recover-wedged-server

Conversation

@jozanek

@jozanek jozanek commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Problem

Metals sometimes can't connect to Bloop when a stale, unresponsive server is already running (#3146): BloopRifle.check only 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-process BloopRifle.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-restart or stop the process manually") instead of a generic error.

Notes for reviewers

  • Recovery is wired at the setupServer level in BuildServerConnection.fromSockets so it covers both the initial connect and every reconnect (setupConnection); this replaces the previous recursive fromSockets retry.
  • ConnectionProvider is shared across all build servers — the new AlreadyReportedConnectException only 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

  • Bug Fixes
    • Improved recovery when the build server appears to be running but is unresponsive.
    • Added clearer error handling to avoid duplicate connection-failure messages.
    • Made connection startup more resilient by retrying a broader range of socket failures.
    • Added a specific notification when an unresponsive server cannot be stopped automatically.
  • Tests
    • Added coverage for build-server recovery and retry behavior.

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.
@coderabbitai

coderabbitai Bot commented Jun 14, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3a171429-88d1-4acd-9f30-9fd9f7e5b6fc

📥 Commits

Reviewing files that changed from the base of the PR and between f6157fe and 28a2e7f.

📒 Files selected for processing (2)
  • metals/src/main/scala/scala/meta/internal/metals/Messages.scala
  • project/TestGroups.scala
🚧 Files skipped from review as they are similar to previous changes (2)
  • metals/src/main/scala/scala/meta/internal/metals/Messages.scala
  • project/TestGroups.scala

📝 Walkthrough

Walkthrough

Adds 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.

Changes

Wedged Bloop Server Recovery

Layer / File(s) Summary
Recovery contracts and error message
metals/.../BuildServerConnection.scala, metals/.../Messages.scala
Adds AlreadyReportedConnectException, RecoverConnectAction, and UnresponsiveBloopServer.
Connection recovery and retry flow
metals/.../BuildServerConnection.scala
Adds the recoverConnection callback and applies recovery decisions to non-fatal connection failures.
Wedged server detection and recovery
metals/.../BloopServers.scala
Tracks reused servers, stops and polls wedged servers, refactors startup, normalizes socket failures, and adds recovery timing constants.
Suppress duplicate connection errors
metals/.../ConnectionProvider.scala
Skips the generic connection message when recovery already reported the failure.
Recovery policy validation
tests/unit/.../BuildServerConnectionRecoverySuite.scala, project/TestGroups.scala
Tests recovery decisions and registers the suite in CI test groups.

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
Loading

Possibly related PRs

Suggested reviewers: zielinsky, tgodzik

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes recovery from an unresponsive Bloop server.
Linked Issues check ✅ Passed The changes satisfy issue #3146 by restarting wedged Bloop servers and improving connection failure diagnostics.
Out of Scope Changes check ✅ Passed The implementation, diagnostics, tests, and test registration are directly related to the linked issue and PR objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
metals/src/main/scala/scala/meta/internal/metals/BloopServers.scala (1)

131-131: 💤 Low value

Consider 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 RecoveryTimeoutMs and RecoveryPollIntervalMs in 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

📥 Commits

Reviewing files that changed from the base of the PR and between 9c32f06 and 408a691.

📒 Files selected for processing (6)
  • metals/src/main/scala/scala/meta/internal/metals/BloopServers.scala
  • metals/src/main/scala/scala/meta/internal/metals/BuildServerConnection.scala
  • metals/src/main/scala/scala/meta/internal/metals/ConnectionProvider.scala
  • metals/src/main/scala/scala/meta/internal/metals/Messages.scala
  • project/TestGroups.scala
  • tests/unit/src/test/scala/tests/BuildServerConnectionRecoverySuite.scala

@jozanek
jozanek marked this pull request as ready for review July 8, 2026 17:42
@jozanek

jozanek commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

@tgodzik could we restart failing CI? It doesn't look like connected to my changes.

@tgodzik

tgodzik commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

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.

@jozanek

jozanek commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

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 force-command-multiple fails with the identical assertion diff on an unrelated PR (Sbt integration 1/3 in run https://github.com/scalameta/metals/actions/runs/31542587814). Looks like pre-existing PR-CI flakiness — happy to dig deeper if it reproduces after the rerun.

@jozanek

jozanek commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

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]) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Restart Bloop via console when unable to connect via launcher

2 participants