diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 20913ed2b..bf27e06d9 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -896,6 +896,8 @@ pub struct JavaSettings { /// Extra JVM options for the Java language server (jdtls), space-separated /// (VS Code's `java.jdt.ls.vmargs`). pub jdtls_vm_args: String, + /// Share one JDT LS across builds in an ordinary project window; other windows remain separate. + pub jdtls_shared: bool, /// Build Tools › Maven. pub maven: MavenSettings, /// Build Tools › Gradle. @@ -1477,6 +1479,26 @@ mod maven_linkage_tests { } } +#[cfg(test)] +mod java_settings_tests { + use super::*; + + #[test] + fn shared_jdtls_defaults_off_and_round_trips() { + for old in [r#"{}"#, r#"{"jdk":"/jdk"}"#] { + let java: JavaSettings = serde_json::from_str(old).unwrap(); + assert!(!java.jdtls_shared); + } + for shared in [false, true] { + let java = JavaSettings { jdtls_shared: shared, ..JavaSettings::default() }; + let json = serde_json::to_value(&java).unwrap(); + assert_eq!(json["jdtlsShared"], serde_json::json!(shared)); + assert_eq!(serde_json::from_value::(json).unwrap(), java); + } + assert!(serde_json::from_str::(r#"{"jdtlsShared":"true"}"#).is_err()); + } +} + #[cfg(test)] mod gradle_settings_tests { use super::*; diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 57499866e..e849b9c81 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -83,7 +83,7 @@ impl Host { let opening = self.opening(window); let _opening = opening.lock(); // Reject a damaged manifest before replacing the currently open project. - ide_workspace::workspace::Workspace::load(&root)?; + let is_workspace = ide_workspace::workspace::Workspace::load(&root)?.is_some(); // The window reloaded (or asked again) for the project it already has: keep // it, with its warm index, language servers and unsaved documents. if let Ok(current) = self.project(window) @@ -137,6 +137,16 @@ impl Host { lsp.set_execution_allowed(trusted.load(Ordering::Acquire)); lsp.set_content_roots(extra_roots.clone()); let settings = Arc::new(settings); + { + let settings = settings.clone(); + // Workspace members have independent settings; target servers have their own path mappings. + lsp.set_jdtls_shared_hook(Box::new(move || { + let effective = settings.effective(); + !is_workspace + && effective.languages.java.jdtls_shared + && effective.language_server_targets.get("java").is_none_or(|target| target.target.is_empty()) + })); + } { let settings = settings.clone(); let root = root.clone(); diff --git a/crates/ide-host/src/relay.rs b/crates/ide-host/src/relay.rs index f8d870394..fb5bed5ee 100644 --- a/crates/ide-host/src/relay.rs +++ b/crates/ide-host/src/relay.rs @@ -9,7 +9,7 @@ use std::sync::{Arc, Mutex, mpsc}; use std::time::{Duration, Instant}; const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); -const EOF_GRACE: Duration = Duration::from_secs(5); +const EOF_GRACE: Duration = Duration::from_secs(10); /// ` ` from the arguments after `--connect`. pub fn parse_args(args: &[String]) -> Result<(String, u16), String> { @@ -25,7 +25,7 @@ pub fn parse_args(args: &[String]) -> Result<(String, u16), String> { /// Connects to `host:port`, copies `input` to it and its replies to `output`, and returns when /// the target is done (the copy of `input` may still be blocked: the caller exits). Fails only -/// when the connection can't be opened. An input EOF allows five idle seconds for a final reply. +/// when the connection can't be opened. An input EOF allows ten idle seconds for a final reply. pub fn relay(host: &str, port: u16, mut input: impl Read + Send + 'static, mut output: impl Write) -> io::Result<()> { let mut from_target = connect(host, port)?; from_target.set_read_timeout(Some(Duration::from_millis(100)))?; @@ -140,11 +140,11 @@ mod tests { let mut request = Vec::new(); socket.read_to_end(&mut request).unwrap(); // Remain open after seeing EOF, like an idle keep-alive server. - wait.recv_timeout(Duration::from_secs(10)).unwrap(); + wait.recv_timeout(Duration::from_secs(15)).unwrap(); }); let start = Instant::now(); relay("127.0.0.1", port, io::empty(), io::sink()).unwrap(); - assert!(start.elapsed() < Duration::from_secs(8)); + assert!(start.elapsed() < Duration::from_secs(13)); done.send(()).unwrap(); server.join().unwrap(); } diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index c9603e48b..37292a2e2 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -627,7 +627,11 @@ impl LspManager { .docs .lock() .values() - .filter(|d| d.language.as_str() == "java" && d.path.starts_with(&server.root)) + .filter(|d| { + d.language.as_str() == "java" + && d.path.is_absolute() + && d.servers.iter().any(|a| a.server.id == server.id) + }) .map(|d| crate::uri::path_to_uri_string(&d.path)) .collect(); if uris.is_empty() { @@ -652,26 +656,50 @@ impl LspManager { /// started if needed and waited for. async fn jdtls(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<(Arc, Running)> { let java = LanguageId::new("java"); - let server = self - .servers_for(&java, path) - .into_iter() - .find(|s| s.name == SERVER) - .ok_or_else(|| IdeError::unavailable("no Java language server (jdtls) is configured"))?; - self.ensure_started(&server); - match cancellable_wait(cancel, server.wait_ready(READY_TIMEOUT)).await? { - Some(run) => Ok((server, run)), - None => { - let status = server.status(); - Err(IdeError::unavailable(match status { - ide_core::event::ServerStatus::Missing => { - "jdtls is not installed (Settings › Languages & Frameworks › Language Servers)".to_owned() - } - ide_core::event::ServerStatus::Misconfigured | ide_core::event::ServerStatus::Crashed => { - server.status_message().unwrap_or_else(|| "jdtls cannot start".to_owned()) - } - _ => format!("jdtls is not ready ({status:?})"), - })) + let deadline = tokio::time::Instant::now() + READY_TIMEOUT; + loop { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); } + let server = self + .servers_for(&java, path) + .into_iter() + .find(|s| s.name == SERVER) + .ok_or_else(|| IdeError::unavailable("no Java language server (jdtls) is configured"))?; + self.ensure_started(&server); + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if let Some(run) = cancellable_wait(cancel, server.wait_ready(remaining)).await? { + return Ok((server, run)); + } + let status = server.status(); + if !matches!( + status, + ide_core::event::ServerStatus::Missing + | ide_core::event::ServerStatus::Misconfigured + | ide_core::event::ServerStatus::Crashed + ) && tokio::time::Instant::now() < deadline + && let Ok(_restart) = + cancellable_wait(cancel, tokio::time::timeout_at(deadline, self.reconfigure_lock.lock())).await? + { + // A restart publishes Down while reaping its old JVM. Its successor may + // also have a new identity after changing the shared workspace. + if let Some(current) = self.servers_for(&java, path).into_iter().find(|s| s.name == SERVER) + && current.root.is_dir() + && (current.id != server.id || current.is_starting() || current.running().is_some()) + { + continue; + } + } + let status = server.status(); + return Err(IdeError::unavailable(match status { + ide_core::event::ServerStatus::Missing => { + "jdtls is not installed (Settings › Languages & Frameworks › Language Servers)".to_owned() + } + ide_core::event::ServerStatus::Misconfigured | ide_core::event::ServerStatus::Crashed => { + server.status_message().unwrap_or_else(|| "jdtls cannot start".to_owned()) + } + _ => format!("jdtls is not ready ({status:?})"), + })); } } diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 6910ff74a..b5ce6898c 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -175,6 +175,20 @@ impl DocSnap { /// Receives the folders outside the project that servers want watched. pub type WatchRootsFn = dyn Fn(Vec) + Send + Sync; +pub type JdtlsSharedFn = dyn Fn() -> bool + Send + Sync; + +#[derive(Default)] +struct JdtlsWorkspace { + initialized: bool, + shared: bool, + roots: Vec, + transitioning: bool, +} + +fn is_local_jdtls(config: &LanguageServerConfig) -> bool { + config.name == "jdtls" && config.target_key.is_none() && config.target.is_none() +} + pub struct LspManager { me: Weak, project_root: PathBuf, @@ -195,6 +209,9 @@ pub struct LspManager { candidates_hook: RwLock>>, excludes_hook: RwLock>>, watch_roots_hook: RwLock>>, + jdtls_shared_hook: RwLock>>, + jdtls_workspace: Mutex, + reconfigure_lock: tokio::sync::Mutex<()>, shutting_down: AtomicBool, execution_allowed: AtomicBool, java_usages_warned: AtomicBool, @@ -402,6 +419,9 @@ impl LspManager { candidates_hook: RwLock::new(None), excludes_hook: RwLock::new(None), watch_roots_hook: RwLock::new(None), + jdtls_shared_hook: RwLock::new(None), + jdtls_workspace: Mutex::new(Default::default()), + reconfigure_lock: tokio::sync::Mutex::new(()), shutting_down: AtomicBool::new(false), execution_allowed: AtomicBool::new(true), java_usages_warned: AtomicBool::new(false), @@ -416,7 +436,56 @@ impl LspManager { // ---- server lookup & lifecycle ---- pub fn set_content_roots(&self, roots: Vec) { - *self.content_roots.write() = roots; + let changed = { + let mut current = self.content_roots.write(); + let changed = *current != roots; + *current = roots; + changed + }; + if changed + && self.desired_jdtls_workspace().0 + && let Some(me) = self.me.upgrade() + { + me.reconfigure_soon(); + } + } + + /// Shares JDT LS within this manager; callers reconfigure after the hook's value changes. + pub fn set_jdtls_shared_hook(&self, hook: Box) { + *self.jdtls_shared_hook.write() = Some(Arc::from(hook)); + } + + fn desired_jdtls_workspace(&self) -> (bool, Vec) { + let shared = self.jdtls_shared_hook.read().as_ref().is_some_and(|hook| hook()); + let mut roots = Vec::new(); + if shared { + roots.push(self.project_root.clone()); + for root in self.content_roots.read().iter() { + if root.is_absolute() && root.is_dir() && !roots.contains(root) { + roots.push(root.clone()); + } + } + } + (shared, roots) + } + + fn jdtls_workspace_state(&self) -> parking_lot::MutexGuard<'_, JdtlsWorkspace> { + let mut state = self.jdtls_workspace.lock(); + if !state.initialized { + (state.shared, state.roots) = self.desired_jdtls_workspace(); + state.initialized = true; + } + state + } + + fn server_workspace_roots(&self, server: &Server) -> Vec { + if is_local_jdtls(&server.config) { + let state = self.jdtls_workspace_state(); + if state.shared { + return state.roots.clone(); + } + } + vec![server.root.clone()] } fn project_root_for(&self, path: &Path) -> PathBuf { @@ -431,16 +500,21 @@ impl LspManager { } fn server_for(&self, cfg: &LanguageServerConfig, language: &LanguageId, path: &Path) -> Arc { + let shared = is_local_jdtls(cfg).then(|| self.jdtls_workspace_state()); let mut servers = self.servers.lock(); - let root = launch::find_root(&self.project_root_for(path), path, &cfg.root_markers).unwrap_or_else(|| { - // Outside the project (library sources, …): reuse a running instance. - servers - .iter() - .filter(|s| s.name == cfg.name && s.config.target_key == cfg.target_key) - .min_by_key(|s| s.root != self.project_root) - .map(|s| s.root.clone()) - .unwrap_or_else(|| self.project_root.clone()) - }); + let root = if shared.as_ref().is_some_and(|state| state.shared) { + self.project_root.clone() + } else { + launch::find_root(&self.project_root_for(path), path, &cfg.root_markers).unwrap_or_else(|| { + // Outside the project (library sources, …): reuse a running instance. + servers + .iter() + .filter(|s| s.name == cfg.name && s.config.target_key == cfg.target_key) + .min_by_key(|s| s.root != self.project_root) + .map(|s| s.root.clone()) + .unwrap_or_else(|| self.project_root.clone()) + }) + }; if let Some(s) = servers.iter().find(|s| s.name == cfg.name && s.root == root && s.config.target_key == cfg.target_key) { @@ -490,6 +564,7 @@ impl LspManager { /// to the open documents (and start them). Also attaches servers newly /// enabled in settings. fn attach_fallbacks(&self) { + let _gate = self.injections.gate.lock(); let open: Vec<(DocId, LanguageId, PathBuf)> = self .docs .lock() @@ -538,6 +613,27 @@ impl LspManager { if self.shutting_down.load(Ordering::Acquire) || !self.execution_allowed.load(Ordering::Acquire) { return; } + let workspace = is_local_jdtls(&server.config).then(|| self.jdtls_workspace_state()); + if let Some(state) = &workspace { + if state.transitioning || !self.servers.lock().iter().any(|s| s.id == server.id) { + return; + } + let (shared, roots) = self.desired_jdtls_workspace(); + if state.shared != shared || (shared && state.roots != roots) { + if let Some(me) = self.me.upgrade() { + me.reconfigure_soon(); + } + return; + } + } + let startup = if is_local_jdtls(&server.config) { + match server.startup_lock.clone().try_lock_owned() { + Ok(guard) => Some(guard), + Err(_) => return, + } + } else { + None + }; // `begin_start` only claims Idle servers, so a claimed server is never a // live one. Its root is the process's working directory: starting with // one deleted from disk fails (`os error 267` on Windows). Such a @@ -553,7 +649,10 @@ impl LspManager { return; } let server = server.clone(); - self.runtime.spawn(async move { me.run_instance(server, generation).await }); + self.runtime.spawn(async move { + let _startup = startup; + me.run_instance(server, generation).await; + }); } async fn run_instance(self: Arc, server: Arc, generation: u64) { @@ -561,6 +660,7 @@ impl LspManager { let handler = Arc::new(handler::ClientHandler::new(self.me.clone(), server.clone(), generation)); let me = self.clone(); let tail = server.clone(); + let launch_cancel = cancel.clone(); // The settings hook may touch the filesystem (e.g. scanning for JDKs); TCP // servers may take a while to start listening. let launched = tokio::task::spawn_blocking(move || { @@ -568,17 +668,17 @@ impl LspManager { return Err(LaunchError::Config("Project is in Restricted Mode".into())); } let mut cfg = me.configured(&tail); - if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() { + if !me.execution_allowed.load(Ordering::Acquire) || launch_cancel.is_cancelled() { return Err(LaunchError::Config("Project is in Restricted Mode".into())); } tail.set_effective_config(generation, cfg.clone()); let prepare = me.prepare_hook.read().clone(); if cfg.target.is_none() - && !cancel.is_cancelled() + && !launch_cancel.is_cancelled() && me.launcher.available(&cfg, &tail.root) && let Some(prepare) = prepare { - prepare(&cfg, &tail.root, &cancel); + prepare(&cfg, &tail.root, &launch_cancel); cfg = me.configured(&tail); } tail.set_effective_config(generation, cfg.clone()); @@ -588,7 +688,7 @@ impl LspManager { let missing = cfg.target.is_none() && !me.launcher.available(&cfg, &root); return Err(if missing { LaunchError::Missing } else { LaunchError::Config(message.clone()) }); } - if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() { + if !me.execution_allowed.load(Ordering::Acquire) || launch_cancel.is_cancelled() { return Err(LaunchError::Config("Project is in Restricted Mode".into())); } me.launcher.launch_resolved( @@ -596,7 +696,7 @@ impl LspManager { &root, handler, Box::new(move |line| tail.push_stderr(generation, line)), - &cancel, + &launch_cancel, ) }) .await @@ -605,7 +705,7 @@ impl LspManager { let peer = match launched { Ok((peer, binary)) => { if !server.set_resolved_binary(generation, binary) { - peer.kill(); + Self::kill_peer(&server, &peer).await; return; } peer @@ -662,9 +762,14 @@ impl LspManager { init["initializationOptions"]["triggerFiles"] = json!([uri::path_to_uri_string(&path)]); } } - let result = tokio::time::timeout(INITIALIZE_TIMEOUT, peer.request::<_, Value>("initialize", init, None)) - .await - .unwrap_or(Err(RpcError::Timeout)); + let result = tokio::select! { + biased; + () = cancel.cancelled() => { + Self::kill_peer(&server, &peer).await; + return; + } + result = tokio::time::timeout(INITIALIZE_TIMEOUT, peer.request::<_, Value>("initialize", init, Some(cancel.clone()))) => result.unwrap_or(Err(RpcError::Timeout)), + }; let caps = match result { Ok(result) => { if let Some(info) = result.get("serverInfo") { @@ -681,7 +786,7 @@ impl LspManager { // The server is exiting: let it, so the crash report has its exit code and last output. let _ = tokio::time::timeout(Duration::from_secs(3), peer.closed()).await; } - peer.kill(); + Self::kill_peer(&server, &peer).await; // Treated like a crash so transient failures retry with backoff. self.spawn_supervisor(server, generation, peer); return; @@ -690,7 +795,7 @@ impl LspManager { let run = Running { generation, peer: peer.clone(), caps: Arc::new(RwLock::new(caps)), watchers: Default::default() }; if !server.set_initializing(run.clone()) { - peer.kill(); + Self::kill_peer(&server, &peer).await; return; } if let Err(e) = peer.notify("initialized", json!({})) { @@ -709,24 +814,30 @@ impl LspManager { // Publish readiness and (re)open attached documents atomically w.r.t. did_change. let mut reopened = Vec::new(); - { + let ready = { let mut docs = self.docs.lock(); if !server.mark_ready(generation, run.clone()) { - drop(docs); - peer.kill(); - return; - } - let open_close = run.caps.read().open_close; - for (id, d) in docs.iter_mut() { - let params = d.did_open_params(); - for a in d.servers.iter_mut().filter(|a| a.server.id == server.id && a.opened_gen != Some(generation)) { - if open_close && let Err(e) = peer.notify("textDocument/didOpen", params.clone()) { - tracing::debug!("{}: didOpen: {e}", server.name); + false + } else { + let open_close = run.caps.read().open_close; + for (id, d) in docs.iter_mut() { + let params = d.did_open_params(); + for a in + d.servers.iter_mut().filter(|a| a.server.id == server.id && a.opened_gen != Some(generation)) + { + if open_close && let Err(e) = peer.notify("textDocument/didOpen", params.clone()) { + tracing::debug!("{}: didOpen: {e}", server.name); + } + a.opened_gen = Some(generation); + reopened.push(*id); } - a.opened_gen = Some(generation); - reopened.push(*id); } + true } + }; + if !ready { + Self::kill_peer(&server, &peer).await; + return; } for doc in reopened { self.schedule_pull(doc, Some(&server), Duration::ZERO); @@ -767,7 +878,6 @@ impl LspManager { fn initialize_params(&self, server: &Server) -> Value { let root_uri = uri::path_to_uri(&server.root); - let name = server.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "root".into()); let cfg = server.effective_config(); let mut init_options = cfg.initialization_options.as_ref().and_then(|v| serde_json::to_value(v).ok()).unwrap_or(Value::Null); @@ -776,11 +886,24 @@ impl LspManager { init_options = json!({}); } init_options["settings"] = handler::all_settings(&cfg); + let shared = is_local_jdtls(&server.config) && self.jdtls_workspace_state().shared; + if shared { + init_options["workspaceFolders"] = json!( + self.server_workspace_roots(server) + .iter() + .map(|root| uri::path_to_uri_string(root)) + .collect::>() + ); + } let triggers: Vec<_> = self .docs .lock() .values() - .filter(|d| d.language.as_str() == "java" && d.path.starts_with(&server.root)) + .filter(|d| { + d.language.as_str() == "java" + && d.path.is_absolute() + && d.servers.iter().any(|a| a.server.id == server.id) + }) .map(|d| uri::path_to_uri_string(&d.path)) .collect(); if !triggers.is_empty() && init_options.get("triggerFiles").is_none() { @@ -794,7 +917,7 @@ impl LspManager { "locale": "en", "rootPath": server.root.to_string_lossy(), "rootUri": root_uri, - "workspaceFolders": [{ "uri": root_uri, "name": name }], + "workspaceFolders": self.workspace_folders(server), "capabilities": caps::client_capabilities(), "trace": "off", }); @@ -811,8 +934,16 @@ impl LspManager { } pub(crate) fn workspace_folders(&self, server: &Server) -> Value { - let name = server.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "root".into()); - json!([{ "uri": uri::path_to_uri(&server.root), "name": name }]) + json!( + self.server_workspace_roots(server) + .iter() + .map(|root| { + let name = + root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "root".into()); + json!({"uri": uri::path_to_uri(root), "name": name}) + }) + .collect::>() + ) } pub(crate) fn sink(&self) -> &SharedSink { @@ -862,6 +993,7 @@ impl LspManager { /// Opens a read-only virtual document (`jdt://…`) on the server that /// returned its URI (`didOpen` only: it is never changed or saved). pub fn did_open_virtual(&self, doc: DocId, uri: &str, language: &LanguageId, version: u32, text: &str) { + let _gate = self.injections.gate.lock(); if self.docs.lock().contains_key(&doc) { self.close_injections(doc); self.close_document(doc); @@ -2450,15 +2582,18 @@ impl LspManager { .into_iter() .flat_map(|(paths, change)| paths.iter().map(move |p| (p, change))); let watchers = run.watchers.read(); + let roots = self.server_workspace_roots(&s); let changes: Vec<(&PathBuf, Change)> = if watchers.is_empty() { let languages = s.languages(); all.filter(|(p, change)| { - p.starts_with(&s.root) && languages.contains(&self.languages.detect(p, None)) && send(p, *change) + roots.iter().any(|root| p.starts_with(root)) + && languages.contains(&self.languages.detect(p, None)) + && send(p, *change) }) .collect() } else { all.chain(excluded_dirs.iter().map(|p| (p, Change::Created))) - .filter(|(p, change)| watchers.wants(&s.root, p, *change) && send(p, *change)) + .filter(|(p, change)| roots.iter().any(|root| watchers.wants(root, p, *change)) && send(p, *change)) .collect() }; drop(watchers); @@ -3036,7 +3171,8 @@ impl LspManager { } } if allowed && !previous { - for server in self.servers.lock().iter() { + let servers = self.servers.lock().clone(); + for server in &servers { server.reset_for_restart(); self.ensure_started(server); } @@ -3053,11 +3189,13 @@ impl LspManager { } pub async fn reconfigure(&self) { + let _reconfigure = self.reconfigure_lock.lock().await; if !self.execution_allowed.load(Ordering::Acquire) { let servers = self.servers.lock().clone(); join_all(servers.iter().map(|s| self.stop_instance(s, true))).await; return; } + self.reconfigure_jdtls_workspace().await; self.stop_unlisted().await; let Some(me) = self.me.upgrade() else { return }; let servers: Vec> = self.servers.lock().clone(); @@ -3120,6 +3258,39 @@ impl LspManager { self.injection_providers_changed(); } + async fn reconfigure_jdtls_workspace(&self) { + let (shared, roots) = self.desired_jdtls_workspace(); + let retired = { + // Opens attach atomically with root selection; no document can acquire an old + // instance between removing the registry entries and detaching its documents. + let _gate = self.injections.gate.lock(); + let mut state = self.jdtls_workspace_state(); + if state.shared == shared && (!shared || state.roots == roots) { + return; + } + state.transitioning = true; + state.shared = shared; + state.roots = roots; + let mut servers = self.servers.lock(); + let retired: Vec<_> = servers.iter().filter(|s| is_local_jdtls(&s.config)).cloned().collect(); + servers.retain(|s| !is_local_jdtls(&s.config)); + for doc in self.docs.lock().values_mut() { + doc.servers.retain(|a| !is_local_jdtls(&a.server.config)); + } + retired + }; + for server in &retired { + self.stop_instance(server, true).await; + } + self.forget(&retired); + self.jdtls_workspace.lock().transitioning = false; + self.attach_fallbacks(); + let pending: Vec<_> = self.servers.lock().iter().filter(|s| is_local_jdtls(&s.config)).cloned().collect(); + for server in pending { + self.ensure_started(&server); + } + } + /// Detaches fallbacks shadowed by enabled servers and instances whose target /// no longer matches the document's language. Stop them once unused. async fn detach_shadowed_servers(&self) { @@ -3255,6 +3426,7 @@ impl LspManager { } pub async fn restart(&self, server: ide_core::ServerId) -> IdeResult<()> { + let _reconfigure = self.reconfigure_lock.lock().await; let s = self .servers .lock() @@ -3282,7 +3454,12 @@ impl LspManager { /// Gracefully stops the current instance (`shutdown` + `exit`, then kill). async fn stop_instance(&self, server: &Arc, final_stop: bool) { - let Some(run) = server.begin_stop(&self.sink, final_stop) else { return }; + let Some(run) = server.begin_stop(&self.sink, final_stop) else { + if is_local_jdtls(&server.config) { + let _startup = server.startup_lock.lock().await; + } + return; + }; let peer = run.peer.clone(); // A server we connected to without starting it (an open Godot editor) is not // ours to shut down: only the connection closes. @@ -3302,13 +3479,24 @@ impl LspManager { peer.kill(); let _ = tokio::time::timeout(Duration::from_secs(2), peer.closed()).await; } - peer.kill(); + Self::kill_peer(server, &peer).await; + if is_local_jdtls(&server.config) { + let _startup = server.startup_lock.lock().await; + } server.end_stop(run.generation, &self.sink); self.file_watch_roots_changed(); } + async fn kill_peer(server: &Server, peer: &ide_jsonrpc::Peer) { + peer.kill(); + if is_local_jdtls(&server.config) && peer.pid().is_some() { + peer.exited().await; + } + } + pub async fn shutdown_all(&self) { self.shutting_down.store(true, Ordering::Release); + let _reconfigure = self.reconfigure_lock.lock().await; let servers: Vec> = self.servers.lock().clone(); join_all(servers.iter().map(|s| self.stop_instance(s, true))).await; } diff --git a/crates/ide-lsp/src/server.rs b/crates/ide-lsp/src/server.rs index 2a38dc55d..6fa363374 100644 --- a/crates/ide-lsp/src/server.rs +++ b/crates/ide-lsp/src/server.rs @@ -119,6 +119,8 @@ pub struct Server { phase_notify: Notify, transient: Mutex>, pub java_build_lock: Arc>, + /// JDT retirement waits for launch and initialization cleanup, even before `Ready`. + pub startup_lock: Arc>, pub java_autobuild_suspended: std::sync::atomic::AtomicBool, pub external_project: tokio::sync::Mutex, pub external_diagnostics: std::sync::atomic::AtomicBool, @@ -158,6 +160,7 @@ impl Server { phase_notify: Notify::new(), transient: Mutex::new(HashMap::new()), java_build_lock: Arc::new(tokio::sync::Mutex::new(())), + startup_lock: Arc::new(tokio::sync::Mutex::new(())), java_autobuild_suspended: std::sync::atomic::AtomicBool::new(false), external_project: tokio::sync::Mutex::new(Default::default()), external_diagnostics: std::sync::atomic::AtomicBool::new(false), @@ -269,6 +272,10 @@ impl Server { } } + pub fn is_starting(&self) -> bool { + matches!(self.state.lock().conn, Conn::Starting) + } + /// The instance told `initialized`, ready or not. pub fn instance(&self) -> Option { self.running().or_else(|| self.initializing.lock().clone()) diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 69fef3a03..ad30aac8f 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -35,6 +35,8 @@ struct MockState { responder: Mutex>, /// Holds the handling of notifications of this method until the gate opens. held: Mutex)>>, + initialize_gate: Mutex>>, + launch_gate: Mutex>>, peer: Mutex>, } @@ -73,7 +75,9 @@ fn utf16_to_byte(text: &str, line: u64, col: u64) -> usize { text.len() } -struct MockHandler(Arc); +type MockConnection = Mutex>; + +struct MockHandler(Arc, std::sync::Weak); impl Handler for MockHandler { fn on_notification(&self, method: &str, params: Value) { @@ -118,8 +122,14 @@ impl Handler for MockHandler { s.docs.lock().remove(&uri); } "exit" => { - if let Some(p) = s.peer.lock().take() { - p.kill(); + if let Some(connection) = self.1.upgrade() + && let Some(peer) = connection.lock().take() + { + peer.kill(); + } + let mut latest = s.peer.lock(); + if latest.as_ref().is_some_and(Peer::is_closed) { + *latest = None; } } _ => {} @@ -131,7 +141,13 @@ impl Handler for MockHandler { let s = &self.0; s.requests.lock().push((method.to_owned(), params.clone())); match method { - "initialize" => Ok(json!({ "capabilities": s.caps.lock().clone(), "serverInfo": { "name": "mock" } })), + "initialize" => { + let gate = s.initialize_gate.lock().take(); + if let Some(gate) = gate { + gate.hold(); + } + Ok(json!({ "capabilities": s.caps.lock().clone(), "serverInfo": { "name": "mock" } })) + } "shutdown" => Ok(Value::Null), _ => { if let Some(error) = s.errors.lock().get(method).cloned() { @@ -150,6 +166,7 @@ impl Handler for MockHandler { #[derive(Default)] struct MockLauncher { servers: Mutex>>, + connections: Mutex>>, } impl MockLauncher { @@ -186,10 +203,18 @@ impl Launcher for MockLauncher { state.docs.lock().clear(); let (client_io, server_io) = tokio::io::duplex(1 << 20); let (sr, sw) = tokio::io::split(server_io); - let server = Peer::connect(sr, sw, Arc::new(MockHandler(state.clone()))); + let connection = Arc::new(Mutex::new(None)); + let server = Peer::connect(sr, sw, Arc::new(MockHandler(state.clone(), Arc::downgrade(&connection)))); + *connection.lock() = Some(server.clone()); + self.connections.lock().push(connection); *state.peer.lock() = Some(server); let (cr, cw) = tokio::io::split(client_io); - Ok(Peer::connect(cr, cw, handler)) + let client = Peer::connect(cr, cw, handler); + let gate = state.launch_gate.lock().take(); + if let Some(gate) = gate { + gate.hold(); + } + Ok(client) } } @@ -420,6 +445,451 @@ async fn linked_projects_initialize_separate_language_server_roots() { h.mgr.shutdown_all().await; } +fn java_siblings(h: &Harness) -> Vec { + ["first", "second"] + .into_iter() + .map(|name| { + let root = h.root.join(name); + std::fs::create_dir(&root).unwrap(); + std::fs::write(root.join("mock.toml"), "").unwrap(); + let path = root.join("Main.java"); + std::fs::write(&path, "class Main {}").unwrap(); + path + }) + .collect() +} + +#[tokio::test(flavor = "multi_thread")] +async fn java_sharing_is_opt_in_and_keeps_module_requests_on_one_instance() { + for shared in [false, true] { + let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]); + h.mgr.set_jdtls_shared_hook(Box::new(move || shared)); + let paths = java_siblings(&h); + std::thread::scope(|scope| { + for path in &paths { + scope.spawn(|| h.mgr.did_open(DocId::next(), path, &h.lang, 1, "class Main {}")); + } + }); + let state = h.launcher.state("jdtls"); + let count = if shared { 1 } else { 2 }; + eventually(|| state.requested("initialize").len() == count).await; + assert_eq!(state.launches.load(Ordering::SeqCst), count); + assert_eq!(h.mgr.servers().len(), count); + for path in &paths { + h.mgr.java_classpath(path.parent().unwrap(), false, &Default::default()).await.unwrap(); + } + let requests = state.requested("workspace/executeCommand"); + let classpaths: Vec<_> = requests.iter().filter(|r| r["command"] == "java.project.getClasspaths").collect(); + assert_eq!(classpaths.len(), 2); + for path in &paths { + assert!(classpaths.iter().any(|r| r["arguments"][0] == uri_of(path.parent().unwrap()))); + } + if shared { + let init = &state.requested("initialize")[0]; + assert_eq!(init["rootUri"], uri_of(&h.root)); + assert_eq!(init["initializationOptions"]["workspaceFolders"], json!([uri_of(&h.root)])); + } + h.mgr.shutdown_all().await; + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn java_sharing_toggle_retires_old_instances_and_reopens_every_document() { + let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]); + let shared = Arc::new(std::sync::atomic::AtomicBool::new(false)); + h.mgr.set_jdtls_shared_hook(Box::new({ + let shared = shared.clone(); + move || shared.load(Ordering::SeqCst) + })); + let paths = java_siblings(&h); + for path in &paths { + h.mgr.did_open(DocId::next(), path, &h.lang, 1, "class Main {}"); + } + let state = h.launcher.state("jdtls"); + eventually(|| state.requested("initialize").len() == 2).await; + eventually(|| { + paths + .iter() + .all(|path| state.notified("textDocument/didOpen").iter().any(|v| v["textDocument"]["uri"] == uri_of(path))) + }) + .await; + let old: Vec<_> = h.mgr.servers().into_iter().map(|s| s.id).collect(); + shared.store(true, Ordering::SeqCst); + h.mgr.reconfigure().await; + eventually(|| state.launches.load(Ordering::SeqCst) == 3 && state.docs.lock().len() == 2).await; + assert_eq!(h.mgr.servers().len(), 1); + for id in old { + assert!( + h.events + .lock() + .iter() + .any(|event| matches!(event, IdeEvent::LanguageServerRemoved {server} if *server == id)) + ); + } + shared.store(false, Ordering::SeqCst); + h.mgr.reconfigure().await; + eventually(|| state.launches.load(Ordering::SeqCst) == 5).await; + assert_eq!(h.mgr.servers().len(), 2); + eventually(|| { + paths.iter().all(|path| { + state.notified("textDocument/didOpen").iter().filter(|v| v["textDocument"]["uri"] == uri_of(path)).count() + == 3 + }) + }) + .await; + h.mgr.shutdown_all().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn java_sharing_cancels_a_retiring_initialize_before_starting_its_replacement() { + let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]); + let shared = Arc::new(std::sync::atomic::AtomicBool::new(false)); + h.mgr.set_jdtls_shared_hook(Box::new({ + let shared = shared.clone(); + move || shared.load(Ordering::SeqCst) + })); + let state = h.launcher.state("jdtls"); + let gate = Arc::new(Gate::default()); + *state.initialize_gate.lock() = Some(gate.clone()); + let path = java_siblings(&h).remove(0); + h.mgr.did_open(DocId::next(), &path, &h.lang, 1, "class Main {}"); + eventually(|| gate.entered()).await; + let old = state.peer(); + shared.store(true, Ordering::SeqCst); + let switched = tokio::time::timeout(Duration::from_secs(2), h.mgr.reconfigure()).await; + let closed_before_response = old.is_closed(); + gate.open(); + switched.expect("initialize cancellation must not wait for its response"); + eventually(|| state.requested("initialize").len() == 2).await; + assert!(closed_before_response, "the old transport must close before its initialize response is released"); + assert_eq!(h.mgr.servers().len(), 1); + assert_eq!(h.mgr.servers()[0].root.as_ref(), h.root.as_path()); + h.mgr.shutdown_all().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn shared_java_waits_for_an_abandoned_launcher_before_replacing_the_workspace() { + let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]); + h.mgr.set_jdtls_shared_hook(Box::new(|| true)); + let state = h.launcher.state("jdtls"); + let gate = Arc::new(Gate::default()); + *state.launch_gate.lock() = Some(gate.clone()); + h.open("Main.java", "class Main {}"); + eventually(|| gate.entered()).await; + let old = state.peer(); + let id = h.mgr.servers()[0].id; + let external = tempfile::tempdir().unwrap(); + h.mgr.set_content_roots(vec![dunce::canonicalize(external.path()).unwrap()]); + eventually(|| h.mgr.servers().iter().all(|s| s.id != id)).await; + h.open("Another.java", "class Another {}"); + let mut transition = tokio::spawn({ + let manager = h.mgr.clone(); + async move { manager.reconfigure().await } + }); + let pending = tokio::time::timeout(Duration::from_millis(100), &mut transition).await.is_err(); + let launches = state.launches.load(Ordering::SeqCst); + let old_alive = !old.is_closed(); + gate.open(); + assert!(pending, "retirement must wait for the launcher to return"); + assert_eq!(launches, 1, "the retiring launcher still owns its peer"); + assert!(old_alive); + transition.await.unwrap(); + h.mgr.reconfigure().await; + eventually(|| state.launches.load(Ordering::SeqCst) == 2 && state.docs.lock().len() == 2).await; + assert!(old.is_closed()); + assert_eq!(h.mgr.servers().len(), 1); + h.mgr.shutdown_all().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn java_classpath_waits_for_a_config_restart_that_is_reaping_startup() { + for (shared, failed) in [(false, false), (true, false), (false, true)] { + let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]); + let changed = Arc::new(std::sync::atomic::AtomicBool::new(false)); + h.mgr.set_jdtls_shared_hook(Box::new({ + let changed = changed.clone(); + move || shared && changed.load(Ordering::SeqCst) + })); + h.mgr.set_config_hook(Box::new({ + let changed = changed.clone(); + move |cfg, _| { + let changed = changed.load(Ordering::SeqCst); + cfg.env.insert("MOCK_CONFIG_REVISION".into(), changed.to_string()); + if failed && changed { + cfg.launch_error = Some("restart configuration error".into()); + } + } + })); + let state = h.launcher.state("jdtls"); + let gate = Arc::new(Gate::default()); + *state.launch_gate.lock() = Some(gate.clone()); + let path = java_siblings(&h).remove(0); + h.mgr.did_open(DocId::next(), &path, &h.lang, 1, "class Main {}"); + eventually(|| gate.entered()).await; + let old = h.mgr.servers()[0].id; + changed.store(true, Ordering::SeqCst); + let restarting = tokio::spawn({ + let manager = h.mgr.clone(); + async move { manager.reconfigure().await } + }); + h.wait_status("jdtls", ServerStatus::Stopped).await; + let mut query = tokio::spawn({ + let manager = h.mgr.clone(); + async move { manager.java_classpath(path.parent().unwrap(), false, &Default::default()).await } + }); + let early = tokio::time::timeout(Duration::from_millis(100), &mut query).await; + gate.open(); + restarting.await.unwrap(); + let result = match early { + Ok(result) => result.unwrap(), + Err(_) => query.await.unwrap(), + }; + if failed { + assert!(result.unwrap_err().message.contains("restart configuration error")); + } else { + assert!(result.is_ok(), "a configuration restart is transient: {result:?}"); + assert_eq!(state.launches.load(Ordering::SeqCst), 2); + assert_eq!(h.mgr.servers()[0].id != old, shared); + } + h.mgr.shutdown_all().await; + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn java_classpath_restart_barrier_wait_remains_cancellable() { + let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]); + let changed = Arc::new(std::sync::atomic::AtomicBool::new(false)); + h.mgr.set_config_hook(Box::new({ + let changed = changed.clone(); + move |cfg, _| { + cfg.env.insert("MOCK_CONFIG_REVISION".into(), changed.load(Ordering::SeqCst).to_string()); + } + })); + let state = h.launcher.state("jdtls"); + let gate = Arc::new(Gate::default()); + *state.launch_gate.lock() = Some(gate.clone()); + h.open("Main.java", "class Main {}"); + eventually(|| gate.entered()).await; + changed.store(true, Ordering::SeqCst); + let restarting = tokio::spawn({ + let manager = h.mgr.clone(); + async move { manager.reconfigure().await } + }); + h.wait_status("jdtls", ServerStatus::Stopped).await; + let cancel = CancellationToken::new(); + let mut query = tokio::spawn({ + let manager = h.mgr.clone(); + let root = h.root.clone(); + let cancel = cancel.clone(); + async move { manager.java_classpath(&root, false, &cancel).await } + }); + let early = tokio::time::timeout(Duration::from_millis(100), &mut query).await; + let waiting = early.is_err(); + cancel.cancel(); + let result = match early { + Ok(result) => Ok(result), + Err(_) => tokio::time::timeout(Duration::from_secs(1), query).await, + }; + gate.open(); + restarting.await.unwrap(); + assert!(waiting, "the query must wait behind the restart barrier"); + assert_eq!(result.unwrap().unwrap().unwrap_err().kind, ide_core::error::ErrorKind::Cancelled); + h.mgr.shutdown_all().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn java_classpath_does_not_retry_permanently_unavailable_servers() { + for missing in [false, true] { + let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]); + if missing { + h.launcher.state("jdtls").missing.store(true, Ordering::SeqCst); + } else { + h.mgr.set_config_hook(Box::new(|cfg, _| { + cfg.launch_error = Some("permanent mock configuration error".into()) + })); + } + let result = + tokio::time::timeout(Duration::from_secs(1), h.mgr.java_classpath(&h.root, false, &Default::default())) + .await + .expect("permanent failures must not incur the Java readiness timeout"); + let error = result.unwrap_err(); + assert!(error.message.contains(if missing { "not installed" } else { "permanent mock configuration error" })); + h.mgr.shutdown_all().await; + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn shared_java_external_roots_reach_initialization_queries_and_watches() { + let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]); + h.mgr.set_jdtls_shared_hook(Box::new(|| true)); + let external = tempfile::tempdir().unwrap(); + let external = dunce::canonicalize(external.path()).unwrap(); + h.mgr.set_content_roots(vec![external.clone(), external.clone(), h.root.clone()]); + let file = external.join("External.java"); + std::fs::write(&file, "class External {}").unwrap(); + h.mgr.did_open(DocId::next(), &file, &h.lang, 1, "class External {}"); + let state = h.launcher.state("jdtls"); + h.wait_status("jdtls", ServerStatus::Ready).await; + let init = &state.requested("initialize")[0]; + assert_eq!(init["initializationOptions"]["workspaceFolders"], json!([uri_of(&h.root), uri_of(&external)])); + assert_eq!(init["initializationOptions"]["triggerFiles"], json!([uri_of(&file)])); + let folders: Value = state.peer().request("workspace/workspaceFolders", Value::Null, None).await.unwrap(); + assert_eq!(folders, init["workspaceFolders"]); + state.peer().request::<_, Value>("client/registerCapability", json!({"registrations":[{ + "id":"external-java", "method":"workspace/didChangeWatchedFiles", "registerOptions":{"watchers":[{"globPattern":"**/*.java"}]} + }]}), None).await.unwrap(); + let other = external.join("Other.java"); + h.mgr.watched_files_changed(std::slice::from_ref(&other), &[], &[], &[]); + eventually(|| { + state.notified("workspace/didChangeWatchedFiles").iter().any(|v| v["changes"][0]["uri"] == uri_of(&other)) + }) + .await; + h.mgr.set_content_roots(vec![]); + h.mgr.reconfigure().await; + eventually(|| state.requested("initialize").len() == 2).await; + assert_eq!(state.requested("initialize")[1]["initializationOptions"]["workspaceFolders"], json!([uri_of(&h.root)])); + assert_eq!(h.mgr.servers().len(), 1); + h.mgr.shutdown_all().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn trusting_an_existing_java_instance_restarts_it_without_locking_the_registry() { + for shared in [false, true] { + let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]); + h.mgr.set_jdtls_shared_hook(Box::new(move || shared)); + h.open("Main.java", "class Main {}"); + h.wait_status("jdtls", ServerStatus::Ready).await; + let state = h.launcher.state("jdtls"); + h.mgr.set_execution_allowed(false); + h.mgr.reconfigure().await; + h.mgr.set_execution_allowed(true); + eventually(|| state.launches.load(Ordering::SeqCst) == 2 && state.docs.lock().len() == 1).await; + assert_eq!(h.mgr.servers().len(), 1); + h.mgr.shutdown_all().await; + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn java_sharing_preserves_other_servers_and_marker_scoped_fallbacks() { + for fallback in [false, true] { + let h = harness_for( + "java", + "java", + &[("jdtls", incremental_caps()), ("other", incremental_caps())], + if fallback { &["other"] } else { &[] }, + ); + h.mgr.set_jdtls_shared_hook(Box::new(|| true)); + h.launcher.state("jdtls").missing.store(fallback, Ordering::SeqCst); + for path in java_siblings(&h) { + h.mgr.did_open(DocId::next(), &path, &h.lang, 1, "class Main {}"); + } + eventually(|| h.launcher.state("other").requested("initialize").len() == 2).await; + let servers = h.mgr.servers(); + assert_eq!(servers.iter().filter(|s| s.name == "jdtls").count(), 1); + assert_eq!(servers.iter().filter(|s| s.name == "other").count(), 2); + assert_eq!(h.launcher.state("jdtls").launches.load(Ordering::SeqCst), usize::from(!fallback)); + h.mgr.shutdown_all().await; + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn java_sharing_keeps_target_instances_separate_and_preserves_them_on_toggle() { + let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]); + let shared = Arc::new(std::sync::atomic::AtomicBool::new(true)); + h.mgr.set_jdtls_shared_hook(Box::new({ + let shared = shared.clone(); + move || shared.load(Ordering::SeqCst) + })); + h.mgr.set_servers_hook(Box::new(|_, configs| { + let mut target = configs[0].clone(); + target.target_key = Some("container".into()); + target + .initialization_options + .as_mut() + .unwrap() + .as_table_mut() + .unwrap() + .insert("selectedTarget".into(), toml::Value::Boolean(true)); + vec![configs[0].clone(), target] + })); + h.open("Main.java", "class Main {}"); + for path in java_siblings(&h) { + h.mgr.did_open(DocId::next(), &path, &h.lang, 1, "class Main {}"); + } + let state = h.launcher.state("jdtls"); + eventually(|| h.mgr.servers().iter().filter(|s| s.status == ServerStatus::Ready).count() == 4).await; + let initial = state.requested("initialize"); + assert_eq!(initial.len(), 4); + let targets: Vec<_> = initial.iter().filter(|v| v["initializationOptions"]["selectedTarget"] == true).collect(); + assert_eq!(targets.len(), 3, "target roots retain their marker-based identity"); + assert!(targets.iter().all(|v| v["initializationOptions"].get("workspaceFolders").is_none())); + let local = initial.iter().find(|v| v["initializationOptions"]["selectedTarget"] != true).unwrap(); + assert_eq!(local["rootUri"], uri_of(&h.root)); + assert_eq!(local["initializationOptions"]["workspaceFolders"], json!([uri_of(&h.root)])); + let old: HashSet<_> = h.mgr.servers().into_iter().map(|s| s.id).collect(); + shared.store(false, Ordering::SeqCst); + h.mgr.reconfigure().await; + eventually(|| h.mgr.servers().iter().filter(|s| s.status == ServerStatus::Ready).count() == 6).await; + assert_eq!( + h.mgr.servers().iter().filter(|s| old.contains(&s.id)).count(), + 3, + "local sharing does not retire targets" + ); + h.mgr.shutdown_all().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn virtual_java_documents_opened_during_a_toggle_attach_to_the_successor() { + let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]); + let shared = Arc::new(std::sync::atomic::AtomicBool::new(false)); + h.mgr.set_jdtls_shared_hook(Box::new({ + let shared = shared.clone(); + move || shared.load(Ordering::SeqCst) + })); + let (doc, _) = h.open("Main.java", "class Main {}"); + h.wait_status("jdtls", ServerStatus::Ready).await; + let state = h.launcher.state("jdtls"); + let uris: Vec<_> = (0..16).map(|n| format!("jdt://contents/project/Virtual{n}.class")).collect(); + state.respond( + "textDocument/definition", + json!( + uris.iter() + .map(|uri| json!({"uri":uri,"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}}})) + .collect::>() + ), + ); + h.mgr.goto(doc, Pos::default(), GotoKind::Definition).await.unwrap(); + shared.store(true, Ordering::SeqCst); + let toggle = tokio::spawn({ + let manager = h.mgr.clone(); + async move { manager.reconfigure().await } + }); + let mut opened = Vec::new(); + for uri in uris { + let manager = h.mgr.clone(); + let language = h.lang.clone(); + let doc = DocId::next(); + opened.push(( + doc, + std::thread::spawn(move || manager.did_open_virtual(doc, &uri, &language, 0, "class Virtual {}")), + )); + } + let docs: Vec<_> = opened + .into_iter() + .map(|(doc, thread)| { + thread.join().unwrap(); + doc + }) + .collect(); + toggle.await.unwrap(); + eventually(|| state.docs.lock().len() == docs.len() + 1).await; + assert_eq!(h.mgr.servers().len(), 1); + for doc in docs { + h.mgr.hover(doc, Pos::default(), Default::default()).await.unwrap(); + } + h.mgr.shutdown_all().await; +} + #[tokio::test(flavor = "multi_thread")] async fn files_changed_on_disk_reach_the_servers_of_their_language() { let h = harness(&[("mock", incremental_caps())]); @@ -2730,7 +3200,7 @@ async fn noop_handler_is_usable_for_raw_peers() { let (a, b) = tokio::io::duplex(1024); let (ar, aw) = tokio::io::split(a); let (br, bw) = tokio::io::split(b); - let _server = Peer::connect(br, bw, Arc::new(MockHandler(Arc::default()))); + let _server = Peer::connect(br, bw, Arc::new(MockHandler(Arc::default(), std::sync::Weak::new()))); let client = Peer::connect(ar, aw, Arc::new(NoopHandler)); let v: Value = client.request("shutdown", (), None).await.unwrap(); assert_eq!(v, Value::Null); diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index 7bd547726..b3c4720d8 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -63,6 +63,104 @@ fn lang(id: &str, ext: &str, server: LanguageServerConfig) -> LanguageConfig { } } +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires OPENIDEA_TEST_JDTLS and OPENIDEA_TEST_GRADLE_HOME, with JDK 21+"] +async fn shared_jdtls_imports_independent_gradle_builds_and_external_root() { + use serde_json::json; + + let jdtls = std::env::var("OPENIDEA_TEST_JDTLS").expect("set OPENIDEA_TEST_JDTLS"); + let gradle_home = std::env::var("OPENIDEA_TEST_GRADLE_HOME").expect("set OPENIDEA_TEST_GRADLE_HOME"); + let dir = tempfile::tempdir().unwrap(); + let temp_root = dunce::canonicalize(dir.path()).unwrap(); + let root = temp_root.join("workspace"); + std::fs::create_dir(&root).unwrap(); + let root = dunce::canonicalize(root).unwrap(); + let external = temp_root.join("external"); + let mut files = Vec::new(); + for (name, build) in [("alpha", root.join("alpha")), ("beta", root.join("beta")), ("external", external.clone())] { + let source_dir = build.join("app/src/main/java/demo"); + std::fs::create_dir_all(&source_dir).unwrap(); + std::fs::write(build.join("settings.gradle"), format!("rootProject.name = '{name}'\ninclude 'app'\n")).unwrap(); + std::fs::write(build.join("build.gradle"), "").unwrap(); + std::fs::write(build.join("app/build.gradle"), "plugins { id 'java' }\n").unwrap(); + let source = format!( + "package demo; public class Main {{ public static void main(String[] args) {{ {name}Marker(); }} static void {name}Marker() {{}} }}\n" + ); + let path = source_dir.join("Main.java"); + std::fs::write(&path, &source).unwrap(); + files.push((name, path, source)); + } + let mut java = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("java")).unwrap(); + java.language_servers.retain(|s| s.name == "jdtls"); + let server = &mut java.language_servers[0]; + server.command = jdtls; + server.env.insert("GRADLE_USER_HOME".into(), dir.path().join("gradle-user-home").to_string_lossy().into_owned()); + server.args.extend(["-data".into(), dir.path().join("jdtls-data").to_string_lossy().into_owned()]); + let options = server.initialization_options.as_mut().unwrap().as_table_mut().unwrap(); + let settings = options.get_mut("settings").unwrap().as_table_mut().unwrap(); + let java_settings = settings.get_mut("java").unwrap().as_table_mut().unwrap(); + let imports = java_settings.get_mut("import").unwrap().as_table_mut().unwrap(); + let gradle = imports.get_mut("gradle").unwrap().as_table_mut().unwrap(); + gradle.insert("home".into(), toml::Value::String(gradle_home)); + gradle.insert("wrapper".into(), toml::Value::try_from(json!({"enabled": false})).unwrap()); + if let Ok(home) = std::env::var("OPENIDEA_TEST_GRADLE_JAVA_HOME") { + gradle.insert("java".into(), toml::Value::try_from(json!({"home": home})).unwrap()); + } + let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![java])); + mgr.set_jdtls_shared_hook(Box::new(|| true)); + mgr.set_content_roots(vec![external]); + for (_, path, source) in &files { + mgr.did_open(DocId::next(), path, &LanguageId::new("java"), 1, source); + } + retry("shared jdtls ready", Duration::from_secs(60), || { + let status = last_status(&events, "jdtls"); + if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, message)) = &status { + panic!("shared jdtls failed: {message:?}"); + } + async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) } + }) + .await; + for (name, path, _) in &files { + let cp = retry(&format!("{name} Gradle classpath"), Duration::from_secs(180), || async { + let cp = + tokio::time::timeout(Duration::from_secs(10), mgr.java_classpath(path, false, &Default::default())) + .await + .ok()? + .ok()?; + cp.class_paths.iter().any(|p| p.contains(&format!("/{name}/app/"))).then_some(cp) + }) + .await; + assert!( + cp.class_paths + .iter() + .all(|p| !files.iter().any(|(other, _, _)| other != name && p.contains(&format!("/{other}/app/")))), + "classpath leaked between builds: {cp:?}" + ); + let launch = tokio::time::timeout( + Duration::from_secs(30), + mgr.java_launch(path, false, false, false, false, &Default::default()), + ) + .await + .expect("launch resolution timed out") + .expect("launch resolution failed"); + assert_eq!(launch.class_paths, cp.class_paths, "launch resolved a different build for {name}"); + } + let servers = mgr.servers().into_iter().filter(|s| s.name == "jdtls").collect::>(); + assert_eq!(servers.len(), 1, "expected one JDT LS: {servers:?}"); + assert!(servers[0].pid.is_some(), "JDT LS process is not running"); + for (_, path, _) in [&files[0], &files[2]] { + let bad = path.parent().unwrap().join("Broken.java"); + let bad_source = "package demo; class Broken { MissingType x; }"; + std::fs::write(&bad, bad_source).unwrap(); + mgr.did_open(DocId::next(), &bad, &LanguageId::new("java"), 1, bad_source); + retry("diagnostics from shared JDT LS", Duration::from_secs(120), || { + let found = events.lock().iter().any(|e| matches!(e, IdeEvent::Diagnostics { path, items, .. } if path.0 == bad && items.iter().any(|d| d.message.contains("MissingType")))); + async move { found.then_some(()) } + }).await; + } + tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap(); +} + #[tokio::test(flavor = "multi_thread")] #[ignore = "requires kmp-lsp on PATH (or OPENIDEA_TEST_KMP_LSP)"] async fn kmp_lsp_java_end_to_end() { diff --git a/src/bindings.ts b/src/bindings.ts index 35832e812..3a2f6a69b 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -5271,6 +5271,8 @@ export type JavaSettings = { * (VS Code's `java.jdt.ls.vmargs`). */ jdtlsVmArgs?: string, + /** Share one JDT LS across builds in an ordinary project window; other windows remain separate. */ + jdtlsShared?: boolean, /** Build Tools › Maven. */ maven?: MavenSettings, /** Build Tools › Gradle. */ diff --git a/src/features/settings/__tests__/dialog.test.tsx b/src/features/settings/__tests__/dialog.test.tsx index 47092e220..7610a1427 100644 --- a/src/features/settings/__tests__/dialog.test.tsx +++ b/src/features/settings/__tests__/dialog.test.tsx @@ -514,6 +514,94 @@ describe("Settings dialog", () => { expect(settingsSet.mock.calls).toEqual([["global", "languages.java.jdtlsVmArgs", '"-Xmx2g"']]); }); + it("stages shared JDT LS in the project and writes it on Apply", async () => { + const dialog = await openDialog("languages.java"); + fireEvent.click(within(dialog).getByRole("radio", { name: "This project" })); + const checkbox = within(dialog).getByRole("checkbox", { name: "Share JDT LS across builds" }); + expect(checkbox).not.toBeChecked(); + fireEvent.click(checkbox); + expect(checkbox).toBeChecked(); + expect(settingsSet).not.toHaveBeenCalled(); + await act(async () => fireEvent.click(within(dialog).getByRole("button", { name: "Apply" }))); + expect(settingsSet.mock.calls).toEqual([["project", "languages.java.jdtlsShared", "true"]]); + }); + + it("keeps the global shared JDT LS preference separate from the project's override", async () => { + global = setAt(global, "languages.java.jdtlsShared", true); + current = setAt(global, "languages.java.jdtlsShared", false); + useProject.setState({ settings: current }); + const dialog = await openDialog("languages.java"); + fireEvent.click(within(dialog).getByRole("radio", { name: "This project" })); + expect(within(dialog).getByRole("checkbox", { name: "Share JDT LS across builds" })).not.toBeChecked(); + fireEvent.click(within(dialog).getByRole("radio", { name: "Global" })); + const checkbox = within(dialog).getByRole("checkbox", { name: "Share JDT LS across builds" }); + expect(checkbox).toBeChecked(); + fireEvent.click(checkbox); + await act(async () => fireEvent.click(within(dialog).getByRole("button", { name: "Apply" }))); + expect(settingsSet.mock.calls).toEqual([["global", "languages.java.jdtlsShared", "false"]]); + }); + + it("discards a shared JDT LS toggle on Cancel", async () => { + const dialog = await openDialog("languages.java"); + fireEvent.click(within(dialog).getByRole("radio", { name: "This project" })); + fireEvent.click(within(dialog).getByRole("checkbox", { name: "Share JDT LS across builds" })); + fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + expect(settingsSet).not.toHaveBeenCalled(); + expect(current.languages?.java?.jdtlsShared).toBe(false); + }); + + it("disables shared JDT LS only in a native Workspace's project scope", async () => { + useProject.setState((s) => ({ + project: { ...s.project!, workspace: { version: 1, name: "Workspace", projects: ["/p/member"] } }, + })); + const dialog = await openDialog("languages.java"); + fireEvent.click(within(dialog).getByRole("radio", { name: "This project" })); + expect(within(dialog).getByRole("checkbox", { name: "Share JDT LS across builds" })).toBeDisabled(); + expect(within(dialog).getByText(/Open a workspace member as a project/)).toBeInTheDocument(); + fireEvent.click(within(dialog).getByRole("radio", { name: "Global" })); + const checkbox = within(dialog).getByRole("checkbox", { name: "Share JDT LS across builds" }); + expect(checkbox).toBeEnabled(); + fireEvent.click(checkbox); + await act(async () => fireEvent.click(within(dialog).getByRole("button", { name: "Apply" }))); + expect(settingsSet.mock.calls).toEqual([["global", "languages.java.jdtlsShared", "true"]]); + }); + + it("disables sharing for a Java language server target only in project scope", async () => { + current = setAt(current, "languageServerTargets", { java: { target: "docker:java", server: "jdtls" } }); + useProject.setState({ settings: current }); + const dialog = await openDialog("languages.java"); + fireEvent.click(within(dialog).getByRole("radio", { name: "This project" })); + const checkbox = within(dialog).getByRole("checkbox", { name: "Share JDT LS across builds" }); + expect(checkbox).toBeDisabled(); + expect(within(dialog).getByText(/Select Local machine for the Java language server/)).toBeInTheDocument(); + fireEvent.click(within(dialog).getByRole("radio", { name: "Global" })); + expect(checkbox).toBeEnabled(); + fireEvent.click(checkbox); + await act(async () => fireEvent.click(within(dialog).getByRole("button", { name: "Apply" }))); + expect(settingsSet.mock.calls).toEqual([["global", "languages.java.jdtlsShared", "true"]]); + }); + + it.each([{ java: { target: "", server: "jdtls" } }, { python: { target: "docker:python" } }])( + "keeps sharing available when Java runs locally: %j", + async (targets) => { + current = setAt(current, "languageServerTargets", targets); + useProject.setState({ settings: current }); + const dialog = await openDialog("languages.java"); + fireEvent.click(within(dialog).getByRole("radio", { name: "This project" })); + expect(within(dialog).getByRole("checkbox", { name: "Share JDT LS across builds" })).toBeEnabled(); + }, + ); + + it("uses a staged project Java language server target before Apply", async () => { + const dialog = await openDialog("languages.java"); + fireEvent.click(within(dialog).getByRole("radio", { name: "This project" })); + const checkbox = within(dialog).getByRole("checkbox", { name: "Share JDT LS across builds" }); + expect(checkbox).toBeEnabled(); + act(() => stage("languages.servers", "project", "languageServerTargets.java.target", "docker:java")); + expect(checkbox).toBeDisabled(); + expect(settingsSet).not.toHaveBeenCalled(); + }); + it("Settings for New Projects edits the global layer on every page", async () => { render(); await act(async () => { diff --git a/src/features/settings/__tests__/languages.test.ts b/src/features/settings/__tests__/languages.test.ts index fa5892f28..ed5780487 100644 --- a/src/features/settings/__tests__/languages.test.ts +++ b/src/features/settings/__tests__/languages.test.ts @@ -192,6 +192,15 @@ describe("languages settings", () => { expect(folderRole(ps, "src")).toBeNull(); }); + it("defaults shared JDT LS to off and accepts only boolean values", () => { + expect(DEFAULT_LANGUAGES.java.jdtlsShared).toBe(false); + expect(validateLanguages({}).java.jdtlsShared).toBe(false); + expect(validateSettings({ languages: { java: { jdtlsShared: true } } }).languages.java.jdtlsShared).toBe(true); + expect(validateLanguages({ java: { jdtlsShared: false } }).java.jdtlsShared).toBe(false); + for (const invalid of ["true", 1, null, []]) + expect(validateLanguages({ java: { jdtlsShared: invalid } }).java.jdtlsShared).toBe(false); + }); + it("defaults Java language level to SDK default and keeps only supported levels", () => { expect(validateLanguages({}).java.languageLevel).toBe(""); expect(validateLanguages({ java: { languageLevel: "8" } }).java.languageLevel).toBe("8"); diff --git a/src/features/settings/languages/LanguagePages.tsx b/src/features/settings/languages/LanguagePages.tsx index e159ab2e5..7373aca86 100644 --- a/src/features/settings/languages/LanguagePages.tsx +++ b/src/features/settings/languages/LanguagePages.tsx @@ -4,15 +4,16 @@ * use (applied by `ide-sdk` when language servers start and when run configurations load). */ import { LanguageServerTargetRows, LanguageServerTargetsPage } from "./LanguageServerTarget"; +import { useProject } from "@/platform/project"; import { useRunTargets } from "@/platform/pluginRunTargets"; import { tx } from "@/platform/i18n"; import { useEffect, useId, useMemo, useState } from "react"; -import { call, commands, type Sdk } from "@/platform/ipc"; +import { call, commands, type LanguageServerTarget, type Sdk } from "@/platform/ipc"; import { IconButton, Input, type SelectOption, T } from "@/ui"; import { JAVA_LANGUAGE_LEVELS, PHP_LANGUAGE_LEVELS } from "@/platform/languageSettings"; import { GlobListEditor, Row, Section, Select, Toggle } from "../components/Form"; import { choosePath } from "./api"; -import { useSetting } from "../components/context"; +import { usePageContext, useSetting } from "../components/context"; import { SdkField, tildify, useDetected } from "./components"; import styles from "./Sdk.module.css"; @@ -387,6 +388,11 @@ export const JAVA_LEVEL_OPTIONS: SelectOption[] = JAVA_LANGUAGE_LEVELS.map((leve ); export function JavaPage() { + const [shared, setShared] = useSetting("languages.java.jdtlsShared"); + const [serverTargets] = useSetting>("languageServerTargets", "project"); + const { scope } = usePageContext(); + const workspace = useProject((s) => !!s.project?.workspace); + const sharedDisabled = scope === "project" && (workspace || !!serverTargets?.java?.target); const [vmArgs, setVmArgs] = useSetting("languages.java.jdtlsVmArgs"); const [languageLevel, setLanguageLevel] = useSetting("languages.java.languageLevel"); const id = useId(); @@ -417,6 +423,19 @@ export function JavaPage() {
+ typeof p === "string") diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 45e25142c..02b915f9f 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -1,4 +1,8 @@ { + "Shared JDT LS is available only in local ordinary project windows. Select Local machine for the Java language server to use it.": "Shared JDT LS is available only in local ordinary project windows. Select Local machine for the Java language server to use it.", + "Share JDT LS across builds": "Share JDT LS across builds", + "Shared JDT LS is available only in local ordinary project windows. Open a workspace member as a project to change this setting for it.": "Shared JDT LS is available only in local ordinary project windows. Open a workspace member as a project to change this setting for it.", + "Uses one JDT LS process for linked builds in each local ordinary project window. Other project windows use separate processes.": "Uses one JDT LS process for linked builds in each local ordinary project window. Other project windows use separate processes.", "Finder": "Finder", "Explorer": "Explorer", "Files": "Files", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index e525f2eb6..069609a36 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,8 @@ { + "Shared JDT LS is available only in local ordinary project windows. Select Local machine for the Java language server to use it.": "共享 JDT LS 仅适用于本机的普通项目窗口。请将 Java 语言服务器的运行位置设为本机后再使用。", + "Share JDT LS across builds": "在构建间共享 JDT LS", + "Shared JDT LS is available only in local ordinary project windows. Open a workspace member as a project to change this setting for it.": "共享 JDT LS 仅适用于本机的普通项目窗口。请将工作区成员作为项目打开,再修改其此项设置。", + "Uses one JDT LS process for linked builds in each local ordinary project window. Other project windows use separate processes.": "在每个本机普通项目窗口中,已关联的构建共用一个 JDT LS 进程。其他项目窗口使用各自独立的进程。", "Finder": "访达", "Explorer": "资源管理器", "Reveal in Finder": "在访达中显示",