feat(java): share JDT LS across builds in a project window (#2272)

* feat(java): share JDT LS across builds in a project window

* fix(java): wait for JDT restart before launch requests

* fix(host): give relay final replies ten seconds of EOF grace

relay::tests::final_responses_keep_flowing_after_input_eof flakes on
Windows CI: the target writes one byte per second for seven seconds
after input EOF, but the relay only waits five idle seconds, so a
slow first byte makes the relay return with nothing received.

Raise EOF_GRACE to ten seconds and update the companion test's
server wait and elapsed bound to match.

Refs #2272 (Windows CI failure)

---------

Co-authored-by: arookieofc <[email protected]>
Цей коміт міститься в:
yoo hyeong chanтаarookieofc authored and GitHub committed 2026-11-09 17:53:59 +08:00
1 джерело 5d3432a000
коміт a46d873f6c
16 files changed
+1034 -79

No files matched your search

+22
Переглянути файл
@@ -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::<JavaSettings>(json).unwrap(), java);
}
assert!(serde_json::from_str::<JavaSettings>(r#"{"jdtlsShared":"true"}"#).is_err());
}
}
#[cfg(test)]
mod gradle_settings_tests {
use super::*;
+11 -1
Переглянути файл
@@ -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();
+4 -4
Переглянути файл
@@ -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);
/// `<host> <port>` 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();
}
+48 -20
Переглянути файл
@@ -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<Server>, 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:?})"),
}));
}
}
+233 -45
Переглянути файл
@@ -175,6 +175,20 @@ impl DocSnap {
/// Receives the folders outside the project that servers want watched.
pub type WatchRootsFn = dyn Fn(Vec<PathBuf>) + Send + Sync;
pub type JdtlsSharedFn = dyn Fn() -> bool + Send + Sync;
#[derive(Default)]
struct JdtlsWorkspace {
initialized: bool,
shared: bool,
roots: Vec<PathBuf>,
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<LspManager>,
project_root: PathBuf,
@@ -195,6 +209,9 @@ pub struct LspManager {
candidates_hook: RwLock<Option<Arc<CandidateFn>>>,
excludes_hook: RwLock<Option<Arc<external_project::ExcludesFn>>>,
watch_roots_hook: RwLock<Option<Arc<WatchRootsFn>>>,
jdtls_shared_hook: RwLock<Option<Arc<JdtlsSharedFn>>>,
jdtls_workspace: Mutex<JdtlsWorkspace>,
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<PathBuf>) {
*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<JdtlsSharedFn>) {
*self.jdtls_shared_hook.write() = Some(Arc::from(hook));
}
fn desired_jdtls_workspace(&self) -> (bool, Vec<PathBuf>) {
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<PathBuf> {
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<Server> {
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<Self>, server: Arc<Server>, 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::<Vec<_>>()
);
}
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::<Vec<_>>()
)
}
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<Arc<Server>> = 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<Server>, 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<Arc<Server>> = self.servers.lock().clone();
join_all(servers.iter().map(|s| self.stop_instance(s, true))).await;
}
+7
Переглянути файл
@@ -119,6 +119,8 @@ pub struct Server {
phase_notify: Notify,
transient: Mutex<HashMap<PathBuf, Transient>>,
pub java_build_lock: Arc<tokio::sync::Mutex<()>>,
/// JDT retirement waits for launch and initialization cleanup, even before `Ready`.
pub startup_lock: Arc<tokio::sync::Mutex<()>>,
pub java_autobuild_suspended: std::sync::atomic::AtomicBool,
pub external_project: tokio::sync::Mutex<crate::external_project::State>,
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<Running> {
self.running().or_else(|| self.initializing.lock().clone())
+477 -7
Переглянути файл
@@ -35,6 +35,8 @@ struct MockState {
responder: Mutex<Option<Responder>>,
/// Holds the handling of notifications of this method until the gate opens.
held: Mutex<Option<(String, Arc<Gate>)>>,
initialize_gate: Mutex<Option<Arc<Gate>>>,
launch_gate: Mutex<Option<Arc<Gate>>>,
peer: Mutex<Option<Peer>>,
}
@@ -73,7 +75,9 @@ fn utf16_to_byte(text: &str, line: u64, col: u64) -> usize {
text.len()
}
struct MockHandler(Arc<MockState>);
type MockConnection = Mutex<Option<Peer>>;
struct MockHandler(Arc<MockState>, std::sync::Weak<MockConnection>);
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<HashMap<String, Arc<MockState>>>,
connections: Mutex<Vec<Arc<MockConnection>>>,
}
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<PathBuf> {
["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::<Vec<_>>()
),
);
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);
+98
Переглянути файл
@@ -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::<Vec<_>>();
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() {
+2
Переглянути файл
@@ -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. */
+88
Переглянути файл
@@ -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(<SettingsDialog />);
await act(async () => {
+9
Переглянути файл
@@ -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");
+21 -2
Переглянути файл
@@ -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<boolean>("languages.java.jdtlsShared");
const [serverTargets] = useSetting<Record<string, LanguageServerTarget>>("languageServerTargets", "project");
const { scope } = usePageContext();
const workspace = useProject((s) => !!s.project?.workspace);
const sharedDisabled = scope === "project" && (workspace || !!serverTargets?.java?.target);
const [vmArgs, setVmArgs] = useSetting<string>("languages.java.jdtlsVmArgs");
const [languageLevel, setLanguageLevel] = useSetting<string>("languages.java.languageLevel");
const id = useId();
@@ -417,6 +423,19 @@ export function JavaPage() {
</Row>
</Section>
<Section title="Language Server">
<Toggle
label="Share JDT LS across builds"
checked={shared}
onChange={setShared}
disabled={sharedDisabled}
hint={
sharedDisabled
? workspace
? "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. Select Local machine for the Java language server to use it."
: "Uses one JDT LS process for linked builds in each local ordinary project window. Other project windows use separate processes."
}
/>
<SdkRow
k="languages.java.jdtlsJdk"
label="JDK"
+3
Переглянути файл
@@ -132,6 +132,9 @@ export const LANGUAGE_PAGES: PageSpec[] = [
"lombok",
"javaagent",
"eclipse jdt",
"shared jdtls",
"linked builds",
"server process",
],
component: JavaPage,
},
+3
Переглянути файл
@@ -164,6 +164,7 @@ export interface LanguagesValid {
languageLevel: (typeof JAVA_LANGUAGE_LEVELS)[number];
jdtlsJdk: string;
jdtlsVmArgs: string;
jdtlsShared: boolean;
gradle: {
linkedProjects: string[] | null;
jvm: string;
@@ -243,6 +244,7 @@ export const DEFAULT_LANGUAGES: LanguagesValid = {
languageLevel: "",
jdtlsJdk: "",
jdtlsVmArgs: "",
jdtlsShared: false,
gradle: { linkedProjects: null, jvm: "", distribution: "wrapper", home: "", userHome: "", runUsing: "gradle" },
maven: {
runnerJre: "",
@@ -401,6 +403,7 @@ export function validateLanguages(raw: unknown): LanguagesValid {
languageLevel: oneOf(java.languageLevel, JAVA_LANGUAGE_LEVELS, d.java.languageLevel),
jdtlsJdk: text(java.jdtlsJdk, d.java.jdtlsJdk),
jdtlsVmArgs: text(java.jdtlsVmArgs, d.java.jdtlsVmArgs),
jdtlsShared: bool(java.jdtlsShared, d.java.jdtlsShared),
gradle: {
linkedProjects: Array.isArray(gradle.linkedProjects)
? gradle.linkedProjects.filter((p): p is string => typeof p === "string")
+4
Переглянути файл
@@ -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",
+4
Переглянути файл
@@ -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": "在访达中显示",