Skip to content

Commit 7d3d6ea

Browse files
committed
Fix LSP deadlocks and hangs under heavy editor activity
1 parent 4a18ca9 commit 7d3d6ea

6 files changed

Lines changed: 91 additions & 36 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5858

5959
### Fixed
6060

61+
- **LSP no longer freezes under heavy editor activity.** Server-to-client requests (diagnostic refresh, progress token creation) could deadlock the service loop when the editor was simultaneously sending bursts of open/close/hover messages. All server-to-client requests are now either fire-and-forget or time-bounded, long-running handlers are cancellation-safe, and the process exits cleanly if the service loop ever terminates unexpectedly.
6162
- **Rename class preserves `self`, `static`, and `parent` keywords.** Renaming a class no longer replaces occurrences of `self::`, `static::`, or `parent::` with the new class name.
6263
- **Rename propagates into closures and arrow functions.** Renaming a variable now follows explicit `use ($var)` captures into closure bodies and implicit captures into arrow function bodies, instead of leaving those occurrences unchanged.
6364
- **Spurious function auto-imports.** Import statements like `use function is_array;` were misidentified as function declarations, polluting the completion list with phantom entries that inserted incorrect imports.

docs/todo/bugs.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,4 @@ handle this automatically.
2121
**Tests:** Assertion lines were removed from
2222
`tests/psalm_assertions/method_call.php` (out of scope until
2323
upstream stubs land).
24+

src/diagnostics/mod.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1728,9 +1728,18 @@ impl Backend {
17281728
client.publish_diagnostics(uri, Vec::new(), None).await;
17291729

17301730
if self.supports_pull_diagnostics.load(Ordering::Acquire) {
1731-
// Also send a refresh so the editor re-pulls (and gets
1732-
// empty results for the now-closed file).
1733-
let _ = client.workspace_diagnostic_refresh().await;
1731+
// Tell the editor to re-pull diagnostics. We spawn this
1732+
// as a detached task instead of awaiting it because
1733+
// workspace_diagnostic_refresh is a server-to-client
1734+
// *request* that blocks until the client responds. When
1735+
// the editor closes many files in a burst, each didClose
1736+
// handler would await a response while the client is busy
1737+
// sending more messages, deadlocking the tower-lsp
1738+
// service loop.
1739+
let client = client.clone();
1740+
tokio::spawn(async move {
1741+
let _ = client.workspace_diagnostic_refresh().await;
1742+
});
17341743
}
17351744
}
17361745
}

src/main.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,13 +283,25 @@ async fn main() {
283283
let (read, write) = tokio::io::split(stream);
284284
let (service, socket) = LspService::build(Backend::new).finish();
285285
Server::new(read, write, socket).serve(service).await;
286+
// The serve loop exited (client disconnected or an
287+
// internal error occurred). Exit the process so the
288+
// editor can restart us instead of leaving a zombie
289+
// that consumes no CPU but never responds.
290+
tracing::warn!("tower-lsp serve loop exited (TCP), shutting down");
291+
std::process::exit(0);
286292
} else {
287293
// Default: run the LSP server over stdin/stdout.
288294
let stdin = tokio::io::stdin();
289295
let stdout = tokio::io::stdout();
290296

291297
let (service, socket) = LspService::build(Backend::new).finish();
292298
Server::new(stdin, stdout, socket).serve(service).await;
299+
// Same as above: the serve loop exited. Without this
300+
// explicit exit, the process hangs because the tokio
301+
// blocking stdin reader thread keeps the runtime alive
302+
// even though no tasks are consuming the data.
303+
tracing::warn!("tower-lsp serve loop exited (stdio), shutting down");
304+
std::process::exit(0);
293305
}
294306
}
295307
}

src/server.rs

Lines changed: 50 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -597,24 +597,30 @@ impl LanguageServer for Backend {
597597

598598
// Run on a blocking thread so the async runtime stays free to
599599
// flush progress notifications to the client.
600+
//
601+
// Wrapped in tokio::spawn for cancellation safety (see references handler).
600602
let backend = self.clone_for_blocking();
601603
let uri_clone = uri.clone();
602-
let result = tokio::task::spawn_blocking(move || {
603-
backend.handle_with_position(
604-
"goto_implementation",
605-
&uri_clone,
606-
position,
607-
|content, pos| {
608-
backend
609-
.resolve_implementation(&uri_clone, content, pos)
610-
.map(|locs| {
611-
locs.into_iter()
612-
.map(|l| backend.translate_location(l))
613-
.collect()
614-
})
615-
.and_then(wrap_locations)
616-
},
617-
)
604+
let result = tokio::spawn(async move {
605+
tokio::task::spawn_blocking(move || {
606+
backend.handle_with_position(
607+
"goto_implementation",
608+
&uri_clone,
609+
position,
610+
|content, pos| {
611+
backend
612+
.resolve_implementation(&uri_clone, content, pos)
613+
.map(|locs| {
614+
locs.into_iter()
615+
.map(|l| backend.translate_location(l))
616+
.collect()
617+
})
618+
.and_then(wrap_locations)
619+
},
620+
)
621+
})
622+
.await
623+
.unwrap_or(Ok(None))
618624
})
619625
.await
620626
.unwrap_or(Ok(None));
@@ -720,18 +726,29 @@ impl LanguageServer for Backend {
720726

721727
// Run on a blocking thread so the async runtime stays free to
722728
// flush progress notifications to the client.
729+
//
730+
// We wrap spawn_blocking inside tokio::spawn so the blocking
731+
// task is always awaited to completion even if tower-lsp
732+
// cancels this handler future via $/cancelRequest. Without
733+
// this wrapper, dropping the handler future detaches the
734+
// spawn_blocking JoinHandle, and tower-lsp 0.20 may corrupt
735+
// its internal state when the orphaned task completes.
723736
let backend = self.clone_for_blocking();
724737
let uri_clone = uri.clone();
725-
let result = tokio::task::spawn_blocking(move || {
726-
backend.handle_with_position("references", &uri_clone, position, |content, pos| {
727-
backend
728-
.find_references(&uri_clone, content, pos, include_declaration)
729-
.map(|locs| {
730-
locs.into_iter()
731-
.map(|l| backend.translate_location(l))
732-
.collect()
733-
})
738+
let result = tokio::spawn(async move {
739+
tokio::task::spawn_blocking(move || {
740+
backend.handle_with_position("references", &uri_clone, position, |content, pos| {
741+
backend
742+
.find_references(&uri_clone, content, pos, include_declaration)
743+
.map(|locs| {
744+
locs.into_iter()
745+
.map(|l| backend.translate_location(l))
746+
.collect()
747+
})
748+
})
734749
})
750+
.await
751+
.unwrap_or(Ok(None))
735752
})
736753
.await
737754
.unwrap_or(Ok(None));
@@ -1055,9 +1072,14 @@ impl LanguageServer for Backend {
10551072
.await;
10561073
}
10571074

1058-
let result = tokio::task::spawn_blocking(move || backend.subtypes_impl(&item))
1059-
.await
1060-
.unwrap_or(None);
1075+
// Wrapped in tokio::spawn for cancellation safety (see references handler).
1076+
let result = tokio::spawn(async move {
1077+
tokio::task::spawn_blocking(move || backend.subtypes_impl(&item))
1078+
.await
1079+
.unwrap_or(None)
1080+
})
1081+
.await
1082+
.unwrap_or(None);
10611083

10621084
if let Some(ref tok) = token {
10631085
self.progress_end(tok, Some("Done".to_string())).await;

src/util.rs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1719,11 +1719,21 @@ impl Backend {
17191719
let params = WorkDoneProgressCreateParams {
17201720
token: token.clone(),
17211721
};
1722-
client
1723-
.send_request::<WorkDoneProgressCreate>(params)
1724-
.await
1725-
.ok()?;
1726-
Some(token)
1722+
// Use a timeout to avoid deadlocking the service loop.
1723+
// progress_create is a server-to-client request; if the
1724+
// client is busy (e.g. flooding didClose/hover), awaiting
1725+
// indefinitely could block the handler and starve other
1726+
// requests. Progress reporting is best-effort, so timing
1727+
// out is harmless.
1728+
match tokio::time::timeout(
1729+
std::time::Duration::from_secs(2),
1730+
client.send_request::<WorkDoneProgressCreate>(params),
1731+
)
1732+
.await
1733+
{
1734+
Ok(Ok(())) => Some(token),
1735+
_ => None,
1736+
}
17271737
}
17281738

17291739
/// Send a `WorkDoneProgressBegin` notification for the given token.

0 commit comments

Comments
 (0)