use crate::project::ProjectRoot;
use anyhow::{Context, Result, bail};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
use url::Url;
use super::commands::is_allowed_lsp_command;
use super::protocol::{language_id_for_path, poll_readable, read_message, send_message};
use super::registry::resolve_lsp_binary_with_hint;
use super::types::{
LspCodeActionRefactorPlan, LspCodeActionRefactorResult, LspCodeActionRequest, LspDiagnostic,
LspDiagnosticRequest, LspReference, LspRenamePlan, LspRenamePlanRequest, LspRenameRequest,
LspRequest, LspResolveTargetRequest, LspResolvedTarget, LspTypeHierarchyRequest,
LspWorkspaceEditTransaction, LspWorkspaceSymbol, LspWorkspaceSymbolRequest,
};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct SessionKey {
command: String,
args: Vec<String>,
}
impl SessionKey {
fn new(command: &str, args: &[String]) -> Self {
Self {
command: command.to_owned(),
args: args.to_owned(),
}
}
}
#[derive(Debug, Clone)]
struct OpenDocumentState {
version: i32,
text: String,
}
pub struct LspSessionPool {
project: ProjectRoot,
sessions: std::sync::Mutex<HashMap<SessionKey, LspSession>>,
}
pub(super) struct LspSession {
pub(super) project: ProjectRoot,
child: Child,
stdin: ChildStdin,
reader: BufReader<ChildStdout>,
next_request_id: u64,
documents: HashMap<String, OpenDocumentState>,
#[allow(dead_code)] stderr_buffer: std::sync::Arc<std::sync::Mutex<String>>,
server_quiescent: Option<bool>,
}
fn ensure_session<'a>(
sessions: &'a mut HashMap<SessionKey, LspSession>,
project: &ProjectRoot,
command: &str,
args: &[String],
) -> Result<&'a mut LspSession> {
if !is_allowed_lsp_command(command) {
bail!(
"Blocked: '{command}' is not a known LSP server. Only whitelisted LSP binaries are allowed."
);
}
let key = SessionKey::new(command, args);
if let Some(session) = sessions.get_mut(&key) {
match session.child.try_wait() {
Ok(Some(_status)) => {
sessions.remove(&key);
}
Ok(None) => {} Err(_) => {
sessions.remove(&key);
}
}
}
match sessions.entry(key) {
std::collections::hash_map::Entry::Occupied(e) => Ok(e.into_mut()),
std::collections::hash_map::Entry::Vacant(e) => {
let session = LspSession::start(project, command, args)?;
Ok(e.insert(session))
}
}
}
fn is_retriable_lsp_transport_error(err: &anyhow::Error) -> bool {
let text = err.to_string().to_ascii_lowercase();
[
"unexpected eof",
"broken pipe",
"connection reset",
"connection aborted",
"transport endpoint is not connected",
"os error 32",
"os error 54",
]
.iter()
.any(|marker| text.contains(marker))
}
impl LspSessionPool {
pub fn new(project: ProjectRoot) -> Self {
Self {
project,
sessions: std::sync::Mutex::new(HashMap::new()),
}
}
pub fn reset(&self, project: ProjectRoot) -> Self {
self.sessions
.lock()
.unwrap_or_else(|p| p.into_inner())
.clear();
Self::new(project)
}
pub fn shutdown(&self) {
self.sessions
.lock()
.unwrap_or_else(|p| p.into_inner())
.clear();
}
pub fn session_count(&self) -> usize {
self.sessions
.lock()
.unwrap_or_else(|p| p.into_inner())
.len()
}
pub fn has_warm_session(&self, command: &str, args: &[String]) -> bool {
let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
let key = SessionKey::new(command, args);
match sessions.get_mut(&key) {
Some(session) => match session.child.try_wait() {
Ok(None) => true,
_ => {
sessions.remove(&key);
false
}
},
None => false,
}
}
pub fn prewarm_session(&self, command: &str, args: &[String]) -> Result<()> {
let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
ensure_session(&mut sessions, &self.project, command, args).map(|_| ())
}
pub fn warm_session_quiescence(&self, command: &str, args: &[String]) -> Option<Option<bool>> {
let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
let key = SessionKey::new(command, args);
match sessions.get_mut(&key) {
Some(session) => match session.child.try_wait() {
Ok(None) => Some(session.server_quiescent()),
_ => {
sessions.remove(&key);
None
}
},
None => None,
}
}
pub fn find_referencing_symbols(&self, request: &LspRequest) -> Result<Vec<LspReference>> {
let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
let session = ensure_session(
&mut sessions,
&self.project,
&request.command,
&request.args,
)?;
session.find_references(request)
}
pub fn find_referencing_symbols_tracking_spawn(
&self,
request: &LspRequest,
) -> Result<(Vec<LspReference>, bool)> {
let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
let key = SessionKey::new(&request.command, &request.args);
let was_warm = sessions
.get_mut(&key)
.map(|session| matches!(session.child.try_wait(), Ok(None)))
.unwrap_or(false);
let session = ensure_session(
&mut sessions,
&self.project,
&request.command,
&request.args,
)?;
let references = session.find_references(request)?;
Ok((references, !was_warm))
}
pub fn get_diagnostics(&self, request: &LspDiagnosticRequest) -> Result<Vec<LspDiagnostic>> {
let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
let result = {
let session = ensure_session(
&mut sessions,
&self.project,
&request.command,
&request.args,
)?;
session.get_diagnostics(request)
};
match result {
Ok(diagnostics) => Ok(diagnostics),
Err(err) if is_retriable_lsp_transport_error(&err) => {
let key = SessionKey::new(&request.command, &request.args);
sessions.remove(&key);
let session = ensure_session(
&mut sessions,
&self.project,
&request.command,
&request.args,
)?;
session
.get_diagnostics(request)
.with_context(|| "retried diagnostics after stale LSP transport")
}
Err(err) => Err(err),
}
}
pub fn search_workspace_symbols(
&self,
request: &LspWorkspaceSymbolRequest,
) -> Result<Vec<LspWorkspaceSymbol>> {
let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
let session = ensure_session(
&mut sessions,
&self.project,
&request.command,
&request.args,
)?;
session.search_workspace_symbols(request)
}
pub fn get_type_hierarchy(
&self,
request: &LspTypeHierarchyRequest,
) -> Result<HashMap<String, Value>> {
let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
let session = ensure_session(
&mut sessions,
&self.project,
&request.command,
&request.args,
)?;
session.get_type_hierarchy(request)
}
pub fn resolve_symbol_target(
&self,
request: &LspResolveTargetRequest,
) -> Result<Vec<LspResolvedTarget>> {
let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
let session = ensure_session(
&mut sessions,
&self.project,
&request.command,
&request.args,
)?;
session.resolve_symbol_target(request)
}
pub fn get_rename_plan(&self, request: &LspRenamePlanRequest) -> Result<LspRenamePlan> {
let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
let session = ensure_session(
&mut sessions,
&self.project,
&request.command,
&request.args,
)?;
session.get_rename_plan(request)
}
pub fn rename_symbol(&self, request: &LspRenameRequest) -> Result<crate::rename::RenameResult> {
let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
let session = ensure_session(
&mut sessions,
&self.project,
&request.command,
&request.args,
)?;
session.rename_symbol(request)
}
pub fn rename_symbol_transaction(
&self,
request: &LspRenameRequest,
) -> Result<LspWorkspaceEditTransaction> {
let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
let session = ensure_session(
&mut sessions,
&self.project,
&request.command,
&request.args,
)?;
session.rename_symbol_transaction(request)
}
pub fn code_action_refactor(
&self,
request: &LspCodeActionRequest,
) -> Result<LspCodeActionRefactorResult> {
let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
let session = ensure_session(
&mut sessions,
&self.project,
&request.command,
&request.args,
)?;
session.code_action_refactor(request)
}
pub fn code_action_refactor_plan(
&self,
request: &LspCodeActionRequest,
) -> Result<LspCodeActionRefactorPlan> {
let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
let session = ensure_session(
&mut sessions,
&self.project,
&request.command,
&request.args,
)?;
session.code_action_refactor_plan(request)
}
}
impl LspSession {
fn start(project: &ProjectRoot, command: &str, args: &[String]) -> Result<Self> {
let command_path = resolve_lsp_binary_with_hint(command, Some(project.as_path()))
.unwrap_or_else(|| command.into());
let mut child = Command::new(&command_path)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("failed to spawn LSP server {}", command_path.display()))?;
let stdin = child.stdin.take().context("failed to open LSP stdin")?;
let stdout = child.stdout.take().context("failed to open LSP stdout")?;
let stderr_buffer = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
if let Some(stderr) = child.stderr.take() {
let buf = std::sync::Arc::clone(&stderr_buffer);
thread::spawn(move || {
let mut reader = BufReader::new(stderr);
let mut line = String::new();
while reader.read_line(&mut line).unwrap_or(0) > 0 {
if let Ok(mut b) = buf.lock() {
if b.len() > 4096 {
let drain_to = b.len() - 2048;
b.drain(..drain_to);
}
b.push_str(&line);
}
line.clear();
}
});
}
let mut session = Self {
project: project.clone(),
child,
stdin,
reader: BufReader::new(stdout),
next_request_id: 1,
documents: HashMap::new(),
stderr_buffer,
server_quiescent: None,
};
session.initialize(command)?;
if let Some(grace) = configured_startup_grace() {
thread::sleep(grace);
}
Ok(session)
}
fn initialize(&mut self, command: &str) -> Result<()> {
let id = self.next_id();
let root_uri = Url::from_directory_path(self.project.as_path())
.ok()
.map(|url| url.to_string());
let workspace_name = self
.project
.as_path()
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("workspace")
.to_owned();
self.send_request(
id,
"initialize",
initialize_params(
root_uri,
&workspace_name,
initialization_options_for_command(command),
),
)?;
let _ = self.read_response_for_id(id)?;
self.send_notification("initialized", json!({}))?;
Ok(())
}
pub(super) fn prepare_document(&mut self, absolute_path: &Path) -> Result<(String, String)> {
let uri = Url::from_file_path(absolute_path).map_err(|_| {
anyhow::anyhow!("failed to build file uri for {}", absolute_path.display())
})?;
let uri_string = uri.to_string();
let source = std::fs::read_to_string(absolute_path)
.with_context(|| format!("failed to read {}", absolute_path.display()))?;
let language_id = language_id_for_path(absolute_path)?;
self.sync_document(&uri_string, language_id, &source)?;
Ok((uri_string, source))
}
pub(super) fn sync_document(
&mut self,
uri: &str,
language_id: &str,
source: &str,
) -> Result<()> {
if let Some(state) = self.documents.get(uri)
&& state.text == source
{
return Ok(());
}
if let Some(state) = self.documents.get_mut(uri) {
state.version += 1;
state.text = source.to_owned();
let version = state.version;
return self.send_notification(
"textDocument/didChange",
json!({
"textDocument":{"uri":uri,"version":version},
"contentChanges":[{"text":source}]
}),
);
}
self.documents.insert(
uri.to_owned(),
OpenDocumentState {
version: 1,
text: source.to_owned(),
},
);
self.send_notification(
"textDocument/didOpen",
json!({
"textDocument":{
"uri":uri,
"languageId":language_id,
"version":1,
"text":source
}
}),
)
}
pub(super) fn next_id(&mut self) -> u64 {
let id = self.next_request_id;
self.next_request_id += 1;
id
}
pub(super) fn send_request(&mut self, id: u64, method: &str, params: Value) -> Result<()> {
send_message(
&mut self.stdin,
&json!({
"jsonrpc":"2.0",
"id":id,
"method":method,
"params":params
}),
)
}
fn send_notification(&mut self, method: &str, params: Value) -> Result<()> {
send_message(
&mut self.stdin,
&json!({
"jsonrpc":"2.0",
"method":method,
"params":params
}),
)
}
pub(super) fn read_response_for_id(&mut self, expected_id: u64) -> Result<Value> {
let deadline = Instant::now() + Duration::from_secs(30);
let mut discarded = 0u32;
const MAX_DISCARDED: u32 = 500;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
bail!(
"LSP response timeout: no response for request id {expected_id} within 30s \
({discarded} unrelated messages discarded)"
);
}
if discarded >= MAX_DISCARDED {
bail!(
"LSP response loop: discarded {MAX_DISCARDED} messages without finding id {expected_id}"
);
}
if !poll_readable(self.reader.get_ref(), remaining.min(Duration::from_secs(5))) {
continue; }
let message = read_message(&mut self.reader)?;
let method = message.get("method").and_then(Value::as_str);
if let Some(method) = method {
if let Some(request_id) = message.get("id").filter(|id| !id.is_null()) {
let request_id = request_id.clone();
let reply = server_request_reply_payload(method, message.get("params"));
self.answer_server_request(&request_id, reply)?;
} else {
self.observe_server_notification(method, message.get("params"));
discarded += 1;
}
continue;
}
let matches_id = message
.get("id")
.and_then(Value::as_u64)
.map(|id| id == expected_id)
.unwrap_or(false);
if matches_id {
if let Some(error) = message.get("error") {
let code = error.get("code").and_then(Value::as_i64).unwrap_or(-1);
let error_message = error
.get("message")
.and_then(Value::as_str)
.unwrap_or("unknown LSP error");
bail!("LSP request failed ({code}): {error_message}");
}
return Ok(message);
}
discarded += 1;
}
}
fn answer_server_request(
&mut self,
request_id: &Value,
reply: std::result::Result<Value, Value>,
) -> Result<()> {
let body = match reply {
Ok(result) => json!({"jsonrpc":"2.0","id":request_id,"result":result}),
Err(error) => json!({"jsonrpc":"2.0","id":request_id,"error":error}),
};
send_message(&mut self.stdin, &body)
}
fn observe_server_notification(&mut self, method: &str, params: Option<&Value>) {
if method == "experimental/serverStatus"
&& let Some(quiescent) = params
.and_then(|params| params.get("quiescent"))
.and_then(Value::as_bool)
{
self.server_quiescent = Some(quiescent);
}
}
pub(super) fn server_quiescent(&self) -> Option<bool> {
self.server_quiescent
}
fn shutdown(&mut self) -> Result<()> {
let id = self.next_id();
self.send_request(id, "shutdown", Value::Null)?;
let _ = self.read_response_for_id(id)?;
self.send_notification("exit", Value::Null)
}
}
fn server_request_reply_payload(
method: &str,
params: Option<&Value>,
) -> std::result::Result<Value, Value> {
match method {
"workspace/configuration" => {
let item_count = params
.and_then(|params| params.get("items"))
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0);
Ok(Value::Array(vec![Value::Null; item_count]))
}
"client/registerCapability"
| "client/unregisterCapability"
| "window/workDoneProgress/create" => Ok(Value::Null),
"workspace/applyEdit" => Ok(json!({
"applied": false,
"failureReason": "codelens read sessions do not accept server-initiated edits"
})),
_ => Err(json!({
"code": -32601,
"message": format!("method not supported by codelens LSP client: {method}")
})),
}
}
fn initialization_options_for_command(command: &str) -> Option<Value> {
let binary = Path::new(command)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(command);
match binary {
"rust-analyzer" => Some(json!({"checkOnSave": false})),
_ => None,
}
}
fn initialize_params(
root_uri: Option<String>,
workspace_name: &str,
initialization_options: Option<Value>,
) -> Value {
let mut params = json!({
"processId":null,
"rootUri": root_uri.clone(),
"capabilities":{
"workspace":{
"workspaceEdit":{
"documentChanges":true,
"resourceOperations":["create","rename","delete"],
"failureHandling":"textOnlyTransactional"
},
"symbol":{"dynamicRegistration":false}
},
"textDocument":{
"declaration":{"dynamicRegistration":false},
"definition":{"dynamicRegistration":false},
"implementation":{"dynamicRegistration":false},
"typeDefinition":{"dynamicRegistration":false},
"references":{"dynamicRegistration":false},
"rename":{"dynamicRegistration":false,"prepareSupport":true},
"diagnostic":{"dynamicRegistration":false},
"typeHierarchy":{"dynamicRegistration":false},
"codeAction":{
"dynamicRegistration":false,
"codeActionLiteralSupport":{
"codeActionKind":{
"valueSet":["quickfix","refactor","refactor.extract","refactor.inline","refactor.rewrite"]
}
},
"resolveSupport":{"properties":["edit","command"]}
}
},
"experimental":{"serverStatusNotification":true}
},
"workspaceFolders":[
{
"uri": root_uri,
"name": workspace_name
}
]
});
if let Some(options) = initialization_options {
params["initializationOptions"] = options;
}
params
}
fn configured_startup_grace() -> Option<Duration> {
let millis = std::env::var("CODELENS_LSP_STARTUP_GRACE_MS")
.ok()
.and_then(|value| value.trim().parse::<u64>().ok())
.unwrap_or(0)
.min(10_000);
(millis > 0).then(|| Duration::from_millis(millis))
}
impl Drop for LspSession {
fn drop(&mut self) {
let _ = self.shutdown();
let deadline = Instant::now() + Duration::from_millis(250);
while Instant::now() < deadline {
match self.child.try_wait() {
Ok(Some(_status)) => return,
Ok(None) => thread::sleep(Duration::from_millis(10)),
Err(_) => break,
}
}
let _ = self.child.kill();
let _ = self.child.wait();
}
}
#[cfg(test)]
mod warm_probe_tests {
use super::*;
fn temp_project() -> ProjectRoot {
let dir = std::env::temp_dir().join(format!(
"codelens-warmprobe-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
ProjectRoot::new(dir.to_str().unwrap()).unwrap()
}
#[test]
fn has_warm_session_is_false_and_non_spawning_when_cold() {
let pool = LspSessionPool::new(temp_project());
assert!(!pool.has_warm_session("pyright-langserver", &["--stdio".to_owned()]));
assert_eq!(pool.session_count(), 0);
}
#[test]
fn warm_session_quiescence_is_none_and_non_spawning_when_cold() {
let pool = LspSessionPool::new(temp_project());
assert_eq!(
pool.warm_session_quiescence("pyright-langserver", &["--stdio".to_owned()]),
None,
"no live session must report outer None (not 'unknown readiness')"
);
assert_eq!(pool.session_count(), 0, "readiness probe must not spawn");
}
}
#[cfg(test)]
mod initialization_options_tests {
use super::*;
#[test]
fn rust_analyzer_disables_check_on_save() {
assert_eq!(
initialization_options_for_command("rust-analyzer"),
Some(json!({"checkOnSave": false}))
);
}
#[test]
fn path_qualified_rust_analyzer_hits_the_same_entry() {
assert_eq!(
initialization_options_for_command("/opt/homebrew/bin/rust-analyzer"),
Some(json!({"checkOnSave": false}))
);
}
#[test]
fn unknown_servers_get_none() {
for command in [
"pyright-langserver",
"typescript-language-server",
"gopls",
"clangd",
"not-an-lsp",
] {
assert_eq!(
initialization_options_for_command(command),
None,
"{command} must not receive initializationOptions"
);
}
}
#[test]
fn initialize_params_omits_options_field_when_none() {
let params = initialize_params(Some("file:///tmp/proj/".to_owned()), "proj", None);
assert!(
params.get("initializationOptions").is_none(),
"None must omit the field entirely (no null/empty placeholder)"
);
}
#[test]
fn initialize_params_attaches_options_when_some() {
let options = json!({"checkOnSave": false});
let params = initialize_params(
Some("file:///tmp/proj/".to_owned()),
"proj",
Some(options.clone()),
);
assert_eq!(params.get("initializationOptions"), Some(&options));
}
#[test]
fn options_do_not_alter_the_rest_of_the_params() {
let root_uri = Some("file:///tmp/proj/".to_owned());
let without = initialize_params(root_uri.clone(), "proj", None);
let mut with = initialize_params(root_uri, "proj", Some(json!({"checkOnSave": false})));
assert!(
with.as_object_mut()
.expect("params is an object")
.remove("initializationOptions")
.is_some()
);
assert_eq!(with, without);
}
}
#[cfg(test)]
mod server_request_reply_tests {
use super::*;
#[test]
fn workspace_configuration_returns_one_null_per_item() {
let params = json!({"items": [{"section": "rust-analyzer"}, {"section": "python"}]});
let reply = server_request_reply_payload("workspace/configuration", Some(¶ms));
assert_eq!(reply, Ok(json!([null, null])));
}
#[test]
fn workspace_configuration_with_no_items_returns_empty_array() {
let reply = server_request_reply_payload("workspace/configuration", None);
assert_eq!(reply, Ok(json!([])));
}
#[test]
fn capability_registration_and_progress_create_are_acknowledged() {
for method in [
"client/registerCapability",
"client/unregisterCapability",
"window/workDoneProgress/create",
] {
assert_eq!(
server_request_reply_payload(method, None),
Ok(Value::Null),
"{method} must be acknowledged, not discarded"
);
}
}
#[test]
fn server_initiated_apply_edit_is_refused() {
let reply = server_request_reply_payload("workspace/applyEdit", Some(&json!({"edit": {}})))
.expect("applyEdit is answered, not errored");
assert_eq!(reply.get("applied"), Some(&json!(false)));
}
#[test]
fn unknown_server_request_gets_method_not_found() {
let err = server_request_reply_payload("window/showMessageRequest", None)
.expect_err("unknown requests must be rejected explicitly");
assert_eq!(err.get("code"), Some(&json!(-32601)));
}
#[test]
#[ignore = "spawns a live rust-analyzer; run manually"]
fn quiescence_signal_is_harvested_from_live_rust_analyzer() {
use crate::lsp::types::LspRequest;
let dir = std::env::temp_dir().join(format!(
"codelens-quiescence-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"quiescence_fixture\"\nversion = \"0.0.0\"\nedition = \"2021\"\n",
)
.unwrap();
std::fs::write(
dir.join("src/lib.rs"),
"pub fn target() -> u32 { 41 }\npub fn caller() -> u32 { target() + 1 }\n",
)
.unwrap();
let project = ProjectRoot::new(dir.to_str().unwrap()).unwrap();
let pool = LspSessionPool::new(project);
let request = LspRequest {
command: "rust-analyzer".to_owned(),
args: Vec::new(),
file_path: "src/lib.rs".to_owned(),
line: 1,
column: 8,
max_results: 10,
};
let deadline = std::time::Instant::now() + Duration::from_secs(60);
let mut quiescence = None;
while std::time::Instant::now() < deadline {
let _ = pool.find_referencing_symbols(&request);
quiescence = pool.warm_session_quiescence("rust-analyzer", &[]);
if quiescence == Some(Some(true)) {
break;
}
thread::sleep(Duration::from_millis(500));
}
assert_eq!(
quiescence,
Some(Some(true)),
"rust-analyzer must report quiescent=true within 60s (signal harvested)"
);
}
}