#[allow(unused_imports)]
use crate::sync_util::LockExt;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use lsp_types::{
CallHierarchyIncomingCall, CallHierarchyItem, CallHierarchyOutgoingCall, Diagnostic,
DocumentSymbolResponse, GotoDefinitionResponse, Hover, Location, WorkspaceSymbolResponse,
};
use serde::Serialize;
use serde_json::{Value, json};
use tokio::io::BufReader;
use tokio::sync::Notify;
use crate::lsp::client::{LspClient, LspError};
use crate::lsp::init::initialize;
use crate::lsp::rpc::RpcClient;
use crate::lsp::server::{self, ServerInfo};
use crate::lsp::spawn::Spawner;
use crate::lsp::uri::path_to_file_uri_string;
fn request_timeout() -> Duration {
crate::timeout::Timeouts::get().lsp_request
}
#[derive(Debug, Clone, Copy)]
pub enum TouchMode {
Notify,
AwaitPush { after: Instant, timeout: Duration },
}
#[derive(Debug, thiserror::Error)]
pub enum ManagerError {
#[error("LSP server {server_id:?} failed to spawn: {source}")]
SpawnFailed {
server_id: String,
#[source]
source: std::io::Error,
},
#[error("LSP server {server_id:?} initialize handshake failed: {source}")]
InitializeFailed {
server_id: String,
#[source]
source: crate::lsp::rpc::RpcError,
},
#[error(transparent)]
Client(#[from] LspError),
}
pub struct ClientEntry {
client: LspClient,
server_id: String,
root: PathBuf,
#[allow(dead_code)]
guard: Box<dyn std::any::Any + Send + Sync>,
}
#[derive(Debug)]
struct BrokenState {
last_failure: std::time::Instant,
attempts: u32,
}
impl BrokenState {
fn backoff(&self) -> std::time::Duration {
const CAP: std::time::Duration = std::time::Duration::from_secs(600);
let attempts = self.attempts.saturating_sub(1);
let mul = 1u64.checked_shl(attempts.min(20)).unwrap_or(u64::MAX);
std::time::Duration::from_secs(mul).min(CAP)
}
fn still_cooling(&self) -> bool {
self.last_failure.elapsed() < self.backoff()
}
}
#[derive(Default)]
struct ManagerState {
clients: HashMap<(PathBuf, String), Arc<ClientEntry>>,
broken: HashMap<(PathBuf, String), BrokenState>,
spawning: HashMap<(PathBuf, String), Arc<Notify>>,
}
impl ManagerState {
fn is_broken_now(&self, key: &(PathBuf, String)) -> bool {
self.broken.get(key).is_some_and(|s| s.still_cooling())
}
fn mark_broken(&mut self, key: &(PathBuf, String)) {
let entry = self.broken.entry(key.clone()).or_insert(BrokenState {
last_failure: std::time::Instant::now(),
attempts: 0,
});
entry.last_failure = std::time::Instant::now();
entry.attempts = entry.attempts.saturating_add(1);
}
}
#[derive(Clone)]
pub struct LspManager {
spawner: Arc<dyn Spawner>,
worktree: PathBuf,
state: Arc<Mutex<ManagerState>>,
servers: Arc<Vec<ServerInfo>>,
}
impl LspManager {
#[allow(dead_code)]
pub fn new(spawner: Arc<dyn Spawner>, worktree: impl Into<PathBuf>) -> Self {
Self::with_servers(spawner, worktree, server::builtin_servers())
}
pub fn with_servers(
spawner: Arc<dyn Spawner>,
worktree: impl Into<PathBuf>,
servers: Vec<ServerInfo>,
) -> Self {
Self {
spawner,
worktree: worktree.into(),
state: Arc::new(Mutex::new(ManagerState::default())),
servers: Arc::new(servers),
}
}
pub async fn get_clients(&self, file: &Path) -> Vec<Arc<ClientEntry>> {
let ext = file
.extension()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_lowercase();
let mut out = Vec::new();
for server in self.servers.iter() {
if !server
.extensions
.iter()
.any(|e| e.eq_ignore_ascii_case(&ext))
{
continue;
}
let Some(root) = (server.root)(file, &self.worktree) else {
continue;
};
let key = (root.clone(), server.id.to_string());
{
let state = self.state.lock_ignore_poison();
if state.is_broken_now(&key) {
continue;
}
if let Some(entry) = state.clients.get(&key) {
out.push(Arc::clone(entry));
continue;
}
}
match self.get_or_spawn(server, &root, &key).await {
Some(entry) => out.push(entry),
None => continue,
}
}
out
}
async fn get_or_spawn(
&self,
server: &ServerInfo,
root: &Path,
key: &(PathBuf, String),
) -> Option<Arc<ClientEntry>> {
enum Slot {
Wait(Arc<Notify>),
Spawn(Arc<Notify>),
}
let slot = {
let mut state = self.state.lock_ignore_poison();
if let Some(entry) = state.clients.get(key) {
return Some(Arc::clone(entry));
}
if state.is_broken_now(key) {
return None;
}
if let Some(notify) = state.spawning.get(key) {
Slot::Wait(Arc::clone(notify))
} else {
let notify = Arc::new(Notify::new());
state.spawning.insert(key.clone(), Arc::clone(¬ify));
Slot::Spawn(notify)
}
};
let spawn_notify = match slot {
Slot::Wait(notify) => {
let _ = tokio::time::timeout(Duration::from_secs(60), notify.notified()).await;
let state = self.state.lock_ignore_poison();
if let Some(entry) = state.clients.get(key) {
return Some(Arc::clone(entry));
}
return None;
}
Slot::Spawn(notify) => notify,
};
let mut slot_guard = SpawnSlotGuard {
state: Arc::clone(&self.state),
key: key.clone(),
notify: Arc::clone(&spawn_notify),
armed: true,
};
let result = self.do_spawn(server, root).await;
slot_guard.armed = false;
let outcome = {
let mut state = self.state.lock_ignore_poison();
let notify = state.spawning.remove(key);
let slot_missing = notify.is_none();
let notify = notify.unwrap_or_else(|| Arc::new(Notify::new()));
match result {
Ok(entry) => {
let arc = Arc::new(entry);
state.clients.insert(key.clone(), Arc::clone(&arc));
state.broken.remove(key);
SpawnOutcome::Inserted {
arc,
notify,
slot_missing,
}
}
Err(e) => {
state.mark_broken(key);
SpawnOutcome::Failed {
err: e,
notify,
slot_missing,
}
}
}
};
match outcome {
SpawnOutcome::Inserted {
arc,
notify,
slot_missing,
} => {
if slot_missing {
tracing::warn!(
"lsp: spawning slot for {:?} disappeared before spawn finished",
key
);
}
notify.notify_waiters();
Some(arc)
}
SpawnOutcome::Failed {
err,
notify,
slot_missing,
} => {
if slot_missing {
tracing::warn!(
"lsp: spawning slot for {:?} disappeared before spawn failed",
key
);
}
tracing::warn!(
server = %server.id,
root = %root.display(),
"lsp: spawn failed: {err}"
);
notify.notify_waiters();
None
}
}
}
async fn do_spawn(
&self,
server: &ServerInfo,
root: &Path,
) -> Result<ClientEntry, ManagerError> {
let spawned =
self.spawner
.spawn(server.id, root)
.await
.map_err(|e| ManagerError::SpawnFailed {
server_id: server.id.to_string(),
source: e,
})?;
let crate::lsp::spawn::Spawned {
reader,
writer,
init_options,
guard,
} = spawned;
let buf_reader = BufReader::new(reader);
let (rpc, _reader_task) = RpcClient::new(buf_reader, writer);
let _ = initialize(&rpc, root, None, init_options)
.await
.map_err(|e| ManagerError::InitializeFailed {
server_id: server.id.to_string(),
source: e,
})?;
let client = LspClient::new(rpc).await;
Ok(ClientEntry {
client,
server_id: server.id.to_string(),
root: root.to_path_buf(),
guard,
})
}
pub async fn touch_file(&self, path: &Path, mode: TouchMode) {
let clients = self.get_clients(path).await;
for entry in clients {
let send_at = Instant::now();
match entry.client.notify_open(path).await {
Ok(_) => {}
Err(e) => {
tracing::warn!(server = %entry.server_id, path = %path.display(), "notify_open failed: {e}");
continue;
}
}
if let TouchMode::AwaitPush { after, timeout } = mode {
let after = std::cmp::max(after, send_at);
if let Err(e) = entry
.client
.wait_for_push_or_pull(path, after, timeout)
.await
{
tracing::debug!(server = %entry.server_id, path = %path.display(), "wait_for_push_or_pull: {e}");
}
}
}
}
pub fn active_servers(&self) -> Vec<(String, PathBuf)> {
let state = self.state.lock_ignore_poison();
state
.clients
.values()
.map(|e| (e.server_id.clone(), e.root.clone()))
.collect()
}
pub fn broken_servers(&self) -> Vec<(String, PathBuf)> {
let state = self.state.lock_ignore_poison();
state
.broken
.iter()
.filter(|(_, s)| s.still_cooling())
.map(|((root, id), _)| (id.clone(), root.clone()))
.collect()
}
pub async fn close_all_files(&self) {
let entries: Vec<_> = {
let state = self.state.lock_ignore_poison();
state.clients.values().cloned().collect()
};
for entry in entries {
entry.client.close_all().await;
}
}
#[allow(dead_code)]
pub fn diagnostics_for(&self, file: &Path) -> Option<Vec<Diagnostic>> {
let entries: Vec<_> = {
let state = self.state.lock_ignore_poison();
state.clients.values().cloned().collect()
};
let mut merged: Vec<Diagnostic> = Vec::new();
let mut tracked = false;
for entry in entries {
let diags = entry.client.diagnostics_for(file);
if !diags.is_empty() {
tracked = true;
merged.extend(diags);
}
}
tracked.then_some(merged)
}
pub fn all_diagnostics(&self) -> HashMap<PathBuf, Vec<Diagnostic>> {
let state = self.state.lock_ignore_poison();
let entries: Vec<_> = state.clients.values().cloned().collect();
drop(state);
let mut merged: HashMap<PathBuf, Vec<Diagnostic>> = HashMap::new();
for entry in entries {
for (path, diags) in entry.client.all_diagnostics() {
merged.entry(path).or_default().extend(diags);
}
}
merged
}
pub async fn hover(&self, file: &Path, line: u32, character: u32) -> Vec<Hover> {
self.request_all(
file,
"textDocument/hover",
position_params(file, line, character),
)
.await
}
pub async fn definition(
&self,
file: &Path,
line: u32,
character: u32,
) -> Vec<GotoDefinitionResponse> {
self.request_all(
file,
"textDocument/definition",
position_params(file, line, character),
)
.await
}
pub async fn references(&self, file: &Path, line: u32, character: u32) -> Vec<Vec<Location>> {
let mut params = position_params(file, line, character);
params["context"] = json!({"includeDeclaration": true});
self.request_all(file, "textDocument/references", params)
.await
}
pub async fn implementation(
&self,
file: &Path,
line: u32,
character: u32,
) -> Vec<GotoDefinitionResponse> {
self.request_all(
file,
"textDocument/implementation",
position_params(file, line, character),
)
.await
}
pub async fn document_symbol(&self, file: &Path) -> Vec<DocumentSymbolResponse> {
let params = json!({
"textDocument": { "uri": path_to_file_uri_string(file) }
});
self.request_all(file, "textDocument/documentSymbol", params)
.await
}
pub async fn workspace_symbol(
&self,
anchor_file: &Path,
query: &str,
) -> Vec<WorkspaceSymbolResponse> {
let params = json!({ "query": query });
self.request_all(anchor_file, "workspace/symbol", params)
.await
}
pub async fn prepare_call_hierarchy(
&self,
file: &Path,
line: u32,
character: u32,
) -> Vec<Vec<CallHierarchyItem>> {
self.request_all(
file,
"textDocument/prepareCallHierarchy",
position_params(file, line, character),
)
.await
}
pub async fn incoming_calls(
&self,
file: &Path,
line: u32,
character: u32,
) -> Vec<Vec<CallHierarchyIncomingCall>> {
self.call_hierarchy(file, line, character, "callHierarchy/incomingCalls")
.await
}
pub async fn outgoing_calls(
&self,
file: &Path,
line: u32,
character: u32,
) -> Vec<Vec<CallHierarchyOutgoingCall>> {
self.call_hierarchy(file, line, character, "callHierarchy/outgoingCalls")
.await
}
async fn call_hierarchy<R: serde::de::DeserializeOwned + Default>(
&self,
file: &Path,
line: u32,
character: u32,
method: &str,
) -> Vec<R> {
let clients = self.get_clients(file).await;
let mut out = Vec::new();
for entry in clients {
let prepared: Vec<CallHierarchyItem> = match entry
.client
.rpc()
.request(
"textDocument/prepareCallHierarchy",
position_params(file, line, character),
request_timeout(),
)
.await
{
Ok(v) => v,
Err(_) => continue,
};
let Some(first) = prepared.first() else {
continue;
};
match entry
.client
.rpc()
.request(method, json!({ "item": first }), request_timeout())
.await
{
Ok(v) => out.push(v),
Err(_) => continue,
}
}
out
}
async fn request_all<P, R>(&self, file: &Path, method: &str, params: P) -> Vec<R>
where
P: Serialize + Clone,
R: serde::de::DeserializeOwned,
{
let clients = self.get_clients(file).await;
let mut out = Vec::new();
for entry in clients {
match entry
.client
.rpc()
.request(method, params.clone(), request_timeout())
.await
{
Ok(v) => out.push(v),
Err(e) => {
let is_dead = matches!(
e,
crate::lsp::rpc::RpcError::ConnectionClosed
| crate::lsp::rpc::RpcError::Io(_)
);
if is_dead {
let key = (entry.root.clone(), entry.server_id.clone());
let mut state = self.state.lock_ignore_poison();
if let Some(cached) = state.clients.get(&key)
&& Arc::ptr_eq(cached, &entry)
{
state.clients.remove(&key);
}
state.mark_broken(&key);
tracing::warn!(
server = %entry.server_id,
method = %method,
"LSP server died ({e}); will retry after backoff",
);
} else {
tracing::debug!(
server = %entry.server_id,
method = %method,
"request failed: {e}",
);
}
}
}
}
out
}
}
enum SpawnOutcome {
Inserted {
arc: Arc<ClientEntry>,
notify: Arc<Notify>,
slot_missing: bool,
},
Failed {
err: ManagerError,
notify: Arc<Notify>,
slot_missing: bool,
},
}
struct SpawnSlotGuard {
state: Arc<Mutex<ManagerState>>,
key: (PathBuf, String),
notify: Arc<Notify>,
armed: bool,
}
impl Drop for SpawnSlotGuard {
fn drop(&mut self) {
if !self.armed {
return;
}
{
let mut state = self.state.lock_ignore_poison();
state.spawning.remove(&self.key);
}
self.notify.notify_waiters();
}
}
impl ClientEntry {
#[allow(dead_code)]
pub fn server_id(&self) -> &str {
&self.server_id
}
#[allow(dead_code)]
pub fn root(&self) -> &Path {
&self.root
}
#[allow(dead_code)]
pub fn client(&self) -> &LspClient {
&self.client
}
}
fn position_params(file: &Path, line: u32, character: u32) -> Value {
json!({
"textDocument": { "uri": path_to_file_uri_string(file) },
"position": { "line": line, "character": character },
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lsp::spawn::Spawned;
use futures::future::BoxFuture;
use std::sync::Arc as StdArc;
use std::sync::atomic::{AtomicUsize, Ordering};
struct CountingSpawner {
spawn_calls: StdArc<AtomicUsize>,
fail_keys: std::sync::Mutex<std::collections::HashSet<(String, PathBuf)>>,
delay_ms: std::sync::atomic::AtomicU64,
}
impl CountingSpawner {
fn new(delay_ms: u64) -> Self {
Self {
spawn_calls: StdArc::new(AtomicUsize::new(0)),
fail_keys: std::sync::Mutex::new(std::collections::HashSet::new()),
delay_ms: std::sync::atomic::AtomicU64::new(delay_ms),
}
}
fn count(&self) -> usize {
self.spawn_calls.load(Ordering::SeqCst)
}
fn set_delay(&self, ms: u64) {
self.delay_ms.store(ms, Ordering::SeqCst);
}
fn fail_for(&self, server_id: &str, root: &Path) {
self.fail_keys
.lock()
.unwrap()
.insert((server_id.to_string(), root.to_path_buf()));
}
fn clear_failures(&self) {
self.fail_keys.lock().unwrap().clear();
}
}
impl Spawner for CountingSpawner {
fn spawn<'a>(
&'a self,
server_id: &'a str,
root: &'a Path,
) -> BoxFuture<'a, std::io::Result<Spawned>> {
Box::pin(async move {
self.spawn_calls.fetch_add(1, Ordering::SeqCst);
let delay = self.delay_ms.load(Ordering::SeqCst);
if delay > 0 {
tokio::time::sleep(Duration::from_millis(delay)).await;
}
if self
.fail_keys
.lock()
.unwrap()
.contains(&(server_id.to_string(), root.to_path_buf()))
{
return Err(std::io::Error::other("forced fail"));
}
let (client_in, mut server_writer) = tokio::io::duplex(8192);
let (mut server_reader, client_out) = tokio::io::duplex(8192);
let fake_server = tokio::spawn(async move {
use crate::jsonrpc_framing::{decode_frame, encode_frame};
let mut reader = tokio::io::BufReader::new(&mut server_reader);
loop {
let frame = match decode_frame(&mut reader).await {
Ok(b) => b,
Err(_) => break,
};
let req: Value = match serde_json::from_slice(&frame) {
Ok(v) => v,
Err(_) => continue,
};
if req.get("id").is_none() {
continue;
}
let id = req["id"].clone();
let method = req["method"].as_str().unwrap_or("");
let result = if method == "initialize" {
json!({"capabilities": {}})
} else {
Value::Null
};
let resp = json!({"jsonrpc": "2.0", "id": id, "result": result});
if encode_frame(&mut server_writer, &serde_json::to_vec(&resp).unwrap())
.await
.is_err()
{
break;
}
}
});
Ok(Spawned {
reader: Box::new(tokio::io::BufReader::new(client_in)),
writer: Box::new(client_out),
init_options: Value::Null,
guard: Box::new(fake_server),
})
})
}
}
fn cargo_tree(suffix: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!(
"dirge-lsp-manager-test-{}-{}-{suffix}",
std::process::id(),
crate::time_util::now_unix_nanos(),
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("Cargo.toml"), "[workspace]\nmembers = []\n").unwrap();
std::fs::write(root.join("src/lib.rs"), "// hello\n").unwrap();
root
}
#[tokio::test]
async fn first_call_spawns_and_caches() {
let tree = cargo_tree("first-spawn");
let spawner = StdArc::new(CountingSpawner::new(0));
let manager = LspManager::new(spawner.clone(), tree.clone());
let file = tree.join("src/lib.rs");
let clients = manager.get_clients(&file).await;
assert_eq!(clients.len(), 1);
assert_eq!(clients[0].server_id(), "rust");
assert_eq!(spawner.count(), 1);
let clients2 = manager.get_clients(&file).await;
assert_eq!(clients2.len(), 1);
assert_eq!(spawner.count(), 1);
std::fs::remove_dir_all(&tree).ok();
}
#[tokio::test]
async fn cancelled_spawn_releases_slot_for_retry() {
let tree = cargo_tree("cancel-retry");
let spawner = StdArc::new(CountingSpawner::new(10_000));
let manager = LspManager::new(spawner.clone(), tree.clone());
let file = tree.join("src/lib.rs");
{
let fut = manager.get_clients(&file);
tokio::pin!(fut);
let _ = tokio::time::timeout(Duration::from_millis(250), &mut fut).await;
}
tokio::task::yield_now().await;
assert_eq!(spawner.count(), 1, "first spawn should have been attempted");
{
let state = manager.state.lock_ignore_poison();
assert!(
state.spawning.is_empty(),
"cancelled spawn must release its in-flight slot, got {:?}",
state.spawning.keys().collect::<Vec<_>>()
);
assert!(
state.broken.is_empty(),
"a cancelled (not failed) spawn must not mark the server broken",
);
}
spawner.set_delay(0);
let clients = manager.get_clients(&file).await;
assert_eq!(clients.len(), 1, "retry after cancellation must succeed");
assert_eq!(clients[0].server_id(), "rust");
assert_eq!(spawner.count(), 2, "retry must spawn afresh");
std::fs::remove_dir_all(&tree).ok();
}
#[tokio::test]
async fn regression_concurrent_get_clients_only_spawns_once() {
let tree = cargo_tree("concurrent-spawn");
let spawner = StdArc::new(CountingSpawner::new(40));
let manager = LspManager::new(spawner.clone(), tree.clone());
let file = tree.join("src/lib.rs");
let a = {
let manager = manager.clone();
let file = file.clone();
tokio::spawn(async move { manager.get_clients(&file).await })
};
let b = {
let manager = manager.clone();
let file = file.clone();
tokio::spawn(async move { manager.get_clients(&file).await })
};
let c = {
let manager = manager.clone();
let file = file.clone();
tokio::spawn(async move { manager.get_clients(&file).await })
};
let r_a = a.await.unwrap();
let r_b = b.await.unwrap();
let r_c = c.await.unwrap();
assert_eq!(r_a.len(), 1);
assert_eq!(r_b.len(), 1);
assert_eq!(r_c.len(), 1);
assert_eq!(
spawner.count(),
1,
"must dedupe inflight spawns; got {}",
spawner.count()
);
std::fs::remove_dir_all(&tree).ok();
}
#[tokio::test]
async fn regression_failed_spawn_marks_broken_and_no_retry() {
let tree = cargo_tree("broken");
let spawner = StdArc::new(CountingSpawner::new(0));
let root_canon = tree.canonicalize().unwrap();
spawner.fail_for("rust", &root_canon);
let manager = LspManager::new(spawner.clone(), tree.clone());
let file = tree.join("src/lib.rs");
let first = manager.get_clients(&file).await;
assert!(
first.is_empty(),
"first attempt should fail to produce a client"
);
assert_eq!(spawner.count(), 1);
let second = manager.get_clients(&file).await;
assert!(second.is_empty());
assert_eq!(spawner.count(), 1, "must not retry broken servers");
std::fs::remove_dir_all(&tree).ok();
}
#[tokio::test]
async fn failed_spawn_retries_after_backoff_elapses() {
let tree = cargo_tree("broken-retry");
let spawner = StdArc::new(CountingSpawner::new(0));
let root_canon = tree.canonicalize().unwrap();
spawner.fail_for("rust", &root_canon);
let manager = LspManager::new(spawner.clone(), tree.clone());
let file = tree.join("src/lib.rs");
let _ = manager.get_clients(&file).await;
assert_eq!(spawner.count(), 1);
{
let mut state = manager.state.lock().unwrap();
let key = (root_canon.clone(), "rust".to_string());
if let Some(s) = state.broken.get_mut(&key) {
s.last_failure = std::time::Instant::now()
.checked_sub(std::time::Duration::from_secs(30))
.expect("clock supports 30s backwards");
}
}
let _ = manager.get_clients(&file).await;
assert_eq!(
spawner.count(),
2,
"backoff expired — should attempt respawn",
);
{
let state = manager.state.lock().unwrap();
let key = (root_canon, "rust".to_string());
let entry = state.broken.get(&key).expect("still broken");
assert_eq!(entry.attempts, 2, "attempts must escalate");
assert_eq!(
entry.backoff(),
std::time::Duration::from_secs(2),
"backoff escalates exponentially",
);
}
std::fs::remove_dir_all(&tree).ok();
}
#[tokio::test]
async fn successful_respawn_clears_broken_record() {
let tree = cargo_tree("broken-recover");
let spawner = StdArc::new(CountingSpawner::new(0));
let root_canon = tree.canonicalize().unwrap();
spawner.fail_for("rust", &root_canon);
let manager = LspManager::new(spawner.clone(), tree.clone());
let file = tree.join("src/lib.rs");
let _ = manager.get_clients(&file).await;
spawner.clear_failures();
{
let mut state = manager.state.lock().unwrap();
let key = (root_canon.clone(), "rust".to_string());
if let Some(s) = state.broken.get_mut(&key) {
s.last_failure = std::time::Instant::now()
.checked_sub(std::time::Duration::from_secs(30))
.expect("clock supports 30s backwards");
}
}
let _ = manager.get_clients(&file).await;
let state = manager.state.lock().unwrap();
let key = (root_canon, "rust".to_string());
assert!(
!state.broken.contains_key(&key),
"successful respawn must clear broken record",
);
std::fs::remove_dir_all(&tree).ok();
}
#[tokio::test]
async fn no_server_claims_extension_returns_empty() {
let tree = cargo_tree("no-server");
let spawner = StdArc::new(CountingSpawner::new(0));
let manager = LspManager::new(spawner.clone(), tree.clone());
let file = tree.join("notes.unknown");
let clients = manager.get_clients(&file).await;
assert!(clients.is_empty());
assert_eq!(
spawner.count(),
0,
"must not spawn for unsupported extensions"
);
std::fs::remove_dir_all(&tree).ok();
}
#[tokio::test]
async fn touch_file_calls_notify_open_on_attached_client() {
let tree = cargo_tree("touch");
let spawner = StdArc::new(CountingSpawner::new(0));
let manager = LspManager::new(spawner.clone(), tree.clone());
let file = tree.join("src/lib.rs");
manager.touch_file(&file, TouchMode::Notify).await;
let entries = manager.get_clients(&file).await;
assert_eq!(entries.len(), 1);
std::fs::remove_dir_all(&tree).ok();
}
#[tokio::test]
async fn manager_drop_does_not_deadlock() {
let tree = cargo_tree("drop");
let spawner = StdArc::new(CountingSpawner::new(0));
let manager = LspManager::new(spawner.clone(), tree.clone());
let file = tree.join("src/lib.rs");
let _ = manager.get_clients(&file).await;
drop(manager);
std::fs::remove_dir_all(&tree).ok();
}
}