From 9575500dd6e7591646393f2a5b95fb2fcf3c431e Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:38:45 +0800 Subject: [PATCH] fix(java): provision an isolated runtime for the Java language server (#370) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recent Eclipse JDT LS releases need Java 25 to run, so projects on JDK 21 had no Java language support until the user downloaded a newer JDK by hand. When JDT LS is about to start, no installed JDK is new enough, and no language-server JDK is set explicitly, the IDE now downloads the Temurin JRE of the required version (about 50 MB, falling back to the JDK image where no JRE build exists) with a cancellable background task, then starts the server on it. The runtime lives in its own folder under the app data directory, outside the roots scanned for project JDKs, so the project JDK, run configurations and JDT LS's project runtimes are unchanged. One download runs at a time across windows and is reused; a failed attempt falls back to the existing manual Download JDK action and is not retried until the next start. Fixes #258 Refs #271 未在 Windows / Linux 实机验证。 --- crates/ide-host/src/java_runtime.rs | 183 ++++++++++++++++++ crates/ide-host/src/lib.rs | 1 + crates/ide-host/src/project.rs | 21 +- crates/ide-lsp/src/lib.rs | 19 +- crates/ide-lsp/tests/mock_server.rs | 32 +++ crates/ide-sdk/src/java.rs | 99 ++++++++-- crates/ide-tools/src/jdk.rs | 84 +++++++- crates/ide-tools/src/process.rs | 46 ++++- .../settings/languages/LanguagePages.tsx | 4 +- src/platform/locales/text/zh.json | 5 +- 10 files changed, 472 insertions(+), 22 deletions(-) create mode 100644 crates/ide-host/src/java_runtime.rs diff --git a/crates/ide-host/src/java_runtime.rs b/crates/ide-host/src/java_runtime.rs new file mode 100644 index 000000000..dde51032e --- /dev/null +++ b/crates/ide-host/src/java_runtime.rs @@ -0,0 +1,183 @@ +//! Automatic runtime provisioning belongs to the host, not SDK discovery. +use ide_core::{CancellationToken, IdeEvent, SharedSink, TaskId}; +use ide_language::LanguageServerConfig; +use ide_sdk::java::{jdtls_jdks, jdtls_min_java, jdtls_runtime, language_server_runtimes_dir}; +use parking_lot::{Condvar, Mutex}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock, Weak}; +use std::time::Duration; + +#[derive(Default)] +struct State { + busy: bool, + sinks: Vec>, + attempted: HashSet<(PathBuf, u32)>, +} + +#[derive(Default)] +struct Provisioner { + state: Mutex, + changed: Condvar, +} + +static PROVISIONER: LazyLock = LazyLock::new(Provisioner::default); + +fn needs_download(explicit: bool, suitable: bool, attempted: bool) -> bool { + !explicit && !suitable && !attempted +} + +impl Provisioner { + fn subscribe(&self, sink: &SharedSink) { + let mut state = self.state.lock(); + state.sinks.retain(|s| s.strong_count() > 0); + let weak = Arc::downgrade(sink); + if !state.sinks.iter().any(|s| s.ptr_eq(&weak)) { + state.sinks.push(weak); + } + } + + fn emit(&self, event: IdeEvent) { + let sinks: Vec<_> = self.state.lock().sinks.iter().filter_map(Weak::upgrade).collect(); + for sink in sinks { + sink.emit(event.clone()); + } + } + + fn run(&self, key: (PathBuf, u32), cancel: &CancellationToken, work: impl FnOnce()) { + let mut state = self.state.lock(); + while state.busy && !cancel.is_cancelled() { + self.changed.wait_for(&mut state, Duration::from_millis(100)); + } + if cancel.is_cancelled() || !state.attempted.insert(key) { + return; + } + state.busy = true; + drop(state); + // Even a panicking installer must release waiters and retain the attempt. + struct Flight<'a>(&'a Provisioner); + impl Drop for Flight<'_> { + fn drop(&mut self) { + self.0.state.lock().busy = false; + self.0.changed.notify_all(); + } + } + let _flight = Flight(self); + work(); + } +} + +pub(crate) fn prepare( + cfg: &LanguageServerConfig, + data_dir: &Path, + explicit: bool, + sink: &SharedSink, + launch_cancel: &CancellationToken, +) { + let min_java = jdtls_min_java(cfg); + let jdks = jdtls_jdks(min_java, data_dir); + // A launch environment override is explicit too, even if it is invalid. + if !needs_download(explicit || cfg.env.contains_key("JAVA_HOME"), jdtls_runtime(&jdks, min_java).is_some(), false) { + return; + } + PROVISIONER.subscribe(sink); + PROVISIONER.run((data_dir.to_path_buf(), min_java), launch_cancel, || { + if jdtls_runtime(&jdtls_jdks(min_java, data_dir), min_java).is_some() { + return; + } + let id = TaskId::next(); + let title = format!("Downloading Java {min_java} language server runtime"); + // Closing one project must not cancel a download other projects await. + let cancel = CancellationToken::new(); + let _handle = ide_core::task::on_cancel(id, { + let cancel = cancel.clone(); + move || cancel.cancel() + }); + let emit = |message, fraction, done| { + PROVISIONER.emit(IdeEvent::Task { id, title: title.clone(), message, fraction, done, cancellable: !done }); + }; + emit(Some("Eclipse Temurin".into()), None, false); + let result = ide_tools::Tools::new(data_dir).download_java_runtime( + min_java, + &language_server_runtimes_dir(data_dir), + &cancel, + &|event| match event { + ide_tools::InstallEvent::Line { text } => emit(Some(text), None, false), + ide_tools::InstallEvent::Progress { fraction, message } => { + emit(Some(message), fraction.map(|f| f as f32), false) + } + }, + ); + emit(result.as_ref().err().map(ToString::to_string), Some(1.0), true); + if let Err(error) = result { + // The configuration hook retains the existing manual download action. + tracing::warn!("automatic jdtls runtime download: {error}"); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[test] + fn download_decision() { + assert!(needs_download(false, false, false)); + for (explicit, suitable, attempted) in [(true, false, false), (false, true, false), (false, false, true)] { + assert!(!needs_download(explicit, suitable, attempted)); + } + } + + #[test] + fn simultaneous_projects_share_one_attempt_and_failures_do_not_retry() { + let provisioner = Provisioner::default(); + let calls = AtomicUsize::new(0); + let barrier = std::sync::Barrier::new(8); + std::thread::scope(|scope| { + for _ in 0..8 { + scope.spawn(|| { + barrier.wait(); + provisioner.run((PathBuf::from("/data"), 25), &CancellationToken::new(), || { + calls.fetch_add(1, Ordering::SeqCst); + std::thread::sleep(Duration::from_millis(30)); + }); + }); + } + }); + provisioner.run((PathBuf::from("/data"), 25), &CancellationToken::new(), || panic!("retried")); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn different_versions_are_still_single_flight() { + let provisioner = Provisioner::default(); + let active = AtomicUsize::new(0); + let calls = AtomicUsize::new(0); + std::thread::scope(|scope| { + for feature in [25, 26, 27] { + let provisioner = &provisioner; + let active = &active; + let calls = &calls; + scope.spawn(move || { + provisioner.run((PathBuf::from("/data"), feature), &CancellationToken::new(), || { + assert_eq!(active.fetch_add(1, Ordering::SeqCst), 0); + std::thread::sleep(Duration::from_millis(10)); + calls.fetch_add(1, Ordering::SeqCst); + active.fetch_sub(1, Ordering::SeqCst); + }); + }); + } + }); + assert_eq!(calls.load(Ordering::SeqCst), 3); + } + + #[test] + fn cancelled_waiter_does_not_consume_an_attempt() { + let provisioner = Provisioner::default(); + let cancel = CancellationToken::new(); + cancel.cancel(); + provisioner.run((PathBuf::from("/data"), 25), &cancel, || panic!("cancelled")); + assert!(provisioner.state.lock().attempted.is_empty()); + } +} diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index 294d8a98c..0699b791b 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -4,6 +4,7 @@ //! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]). pub mod environment; +mod java_runtime; pub mod notebook; pub mod ops; mod project; diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 4e2069795..d8f616550 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -126,7 +126,7 @@ impl Host { let bundles: Vec = ide_tools::managed_bundle(&data_dir, "java-debug").into_iter().collect(); let min_java = ide_sdk::java::jdtls_min_java(cfg); - let jdks = ide_sdk::java::jdtls_jdks(min_java); + let jdks = ide_sdk::java::jdtls_jdks(min_java, &data_dir); let lombok = ide_sdk::lombok::lombok_agent( server_root, ide_sdk::lombok::agent_in_args(cfg.args.iter().map(String::as_str)), @@ -158,6 +158,25 @@ impl Host { ide_sdk::apply::configure_maven_import(cfg, &root, &excluded_poms); })); } + { + let settings = settings.clone(); + let root = root.clone(); + let data_dir = self.data_dir.clone(); + let sink = sink.clone(); + lsp.set_prepare_hook(Box::new(move |cfg, _, cancel| { + let effective = settings.effective(); + if cfg.name == "jdtls" { + crate::java_runtime::prepare( + cfg, + &data_dir, + !effective.languages.java.jdtls_jdk.trim().is_empty() + || ide_sdk::apply::jdtls_jdk(&root, &effective).is_err(), + &sink, + cancel, + ); + } + })); + } { let settings = settings.clone(); lsp.set_servers_hook(Box::new(move |language, defined| { diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 403a82193..fc00c840b 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -75,6 +75,8 @@ pub type ApplyEditHook = Arc bool + Send + Sync>; /// settings before each launch; receives the server's workspace root. pub type ConfigFn = dyn Fn(&mut LanguageServerConfig, &Path) + Send + Sync; pub type ConfigHook = Arc; +/// Prepares dependencies on a blocking worker immediately before launching an installed server. +pub type PrepareFn = dyn Fn(&LanguageServerConfig, &Path, &CancellationToken) + Send + Sync; /// The servers a language uses, from its definition's list (user settings /// disable some and add others). pub type ServersFn = dyn Fn(&LanguageId, &[LanguageServerConfig]) -> Vec + Send + Sync; @@ -163,6 +165,7 @@ pub struct LspManager { docs: Mutex>, apply_edit_hook: RwLock>, config_hook: RwLock>, + prepare_hook: RwLock>>, servers_hook: RwLock>>, candidates_hook: RwLock>>, shutting_down: AtomicBool, @@ -291,6 +294,7 @@ impl LspManager { docs: Mutex::new(HashMap::new()), apply_edit_hook: RwLock::new(None), config_hook: RwLock::new(None), + prepare_hook: RwLock::new(None), servers_hook: RwLock::new(None), candidates_hook: RwLock::new(None), shutting_down: AtomicBool::new(false), @@ -408,7 +412,16 @@ impl LspManager { // 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 || { - let cfg = me.configured(&tail); + let mut cfg = me.configured(&tail); + tail.set_effective_config(cfg.clone()); + let prepare = me.prepare_hook.read().clone(); + if !cancel.is_cancelled() + && me.launcher.available(&cfg, &tail.root) + && let Some(prepare) = prepare + { + prepare(&cfg, &tail.root, &cancel); + cfg = me.configured(&tail); + } tail.set_effective_config(cfg.clone()); let root = tail.root.clone(); if let Some(message) = &cfg.launch_error { @@ -1901,6 +1914,10 @@ impl LspManager { *self.config_hook.write() = Some(Arc::from(hook)); } + pub fn set_prepare_hook(&self, hook: Box) { + *self.prepare_hook.write() = Some(Arc::from(hook)); + } + /// Installed by `src-tauri` to apply the servers enabled / defined in settings. pub fn set_servers_hook(&self, hook: Box) { *self.servers_hook.write() = Some(Arc::from(hook)); diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 2e57fb675..fca890e8f 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -905,6 +905,38 @@ async fn launch_error_fails_without_starting_the_server() { assert!(!h.statuses("mock").contains(&ServerStatus::Crashed)); } +#[tokio::test(flavor = "multi_thread")] +async fn preparation_refreshes_config_and_survives_unchanged_settings() { + use std::sync::atomic::AtomicBool; + let h = harness(&[("mock", incremental_caps())]); + let prepared = Arc::new(AtomicBool::new(false)); + let started = Arc::new(AtomicUsize::new(0)); + let (release, wait) = std::sync::mpsc::channel(); + let wait = Mutex::new(wait); + h.mgr.set_config_hook(Box::new({ + let prepared = prepared.clone(); + move |cfg, _| { + cfg.launch_error = (!prepared.load(Ordering::SeqCst)).then(|| "needs runtime".into()); + } + })); + h.mgr.set_prepare_hook(Box::new({ + let started = started.clone(); + move |_, _, cancel| { + started.fetch_add(1, Ordering::SeqCst); + wait.lock().recv_timeout(Duration::from_secs(5)).unwrap(); + assert!(!cancel.is_cancelled(), "unchanged settings cancelled preparation"); + prepared.store(true, Ordering::SeqCst); + } + })); + h.open("a.mk", "x"); + eventually(|| started.load(Ordering::SeqCst) > 0).await; + h.mgr.reconfigure().await; + release.send(()).unwrap(); + h.wait_status("mock", ServerStatus::Ready).await; + assert_eq!(started.load(Ordering::SeqCst), 1); + assert_eq!(h.launcher.state("mock").launches.load(Ordering::SeqCst), 1); +} + #[tokio::test(flavor = "multi_thread")] async fn settings_changes_reach_servers_without_restarts() { let h = harness(&[("mock", incremental_caps()), ("extra", incremental_caps())]); diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index 55a56c090..40659ff31 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -44,6 +44,12 @@ fn children(dir: &Path) -> Vec { out } +fn jdk_homes(root: &Path) -> impl Iterator { + children(root) + .into_iter() + .flat_map(|dir| [dir.join("Contents/Home"), dir.join("libexec/openjdk.jdk/Contents/Home"), dir]) +} + /// Vendor directories JDK installers use on Windows, under `Program Files` /// and (per-user installs) `%LOCALAPPDATA%\Programs`. const WINDOWS_VENDORS: &[&str] = @@ -106,9 +112,7 @@ fn scan() -> Vec { if roots[..i].contains(root) { continue; } - for dir in children(root) { - homes.extend([dir.join("Contents/Home"), dir.join("libexec/openjdk.jdk/Contents/Home"), dir]); - } + homes.extend(jdk_homes(root)); } // mise keeps a macOS JDK as its bundle (`/Contents/Home`), asdf flattens it. for (dir, _) in ide_core::version_managers::installs(ide_core::version_managers::Tool::Java) { @@ -157,12 +161,24 @@ pub fn installed_jdks() -> Arc<[Jdk]> { /// Like [`installed_jdks`], but while none can run jdtls (on Java `min_java`) /// the list is rescanned (at most every [`RESCAN_AFTER`]), so a JDK installed /// after startup is picked up when the server is restarted. -pub fn jdtls_jdks(min_java: u32) -> Arc<[Jdk]> { +pub fn jdtls_jdks(min_java: u32, data_dir: &Path) -> Arc<[Jdk]> { let mut slot = JDKS.lock().unwrap_or_else(|e| e.into_inner()); - match &*slot { + let installed = match &*slot { Some(s) if jdtls_runtime(&s.jdks, min_java).is_some() || s.at.elapsed() < RESCAN_AFTER => s.jdks.clone(), _ => rescan(&mut slot), - } + }; + runtime_candidates(&installed, data_dir).into() +} + +/// Runtime-only downloads are never registered as project discovery roots. +pub fn language_server_runtimes_dir(data_dir: &Path) -> PathBuf { + data_dir.join("language-server-runtimes") +} + +fn runtime_candidates(installed: &[Jdk], data_dir: &Path) -> Vec { + let mut jdks = installed.to_vec(); + jdks.extend(jdk_homes(&language_server_runtimes_dir(data_dir)).filter_map(|home| jdk_at(&home))); + jdks } /// For a server with `max-java`: when the `JAVA_HOME` it would get (its env, @@ -341,6 +357,14 @@ pub fn configure_jdtls( cfg.args.push(format!("--jvm-arg=-javaagent:{}", jar.to_string_lossy())); } crate::jdtls::add_default_vm_args(&mut cfg.args); + let managed = language_server_runtimes_dir(data_dir); + let managed = dunce::canonicalize(&managed).unwrap_or(managed); + let project_jdks: Vec<_> = project_jdk + .into_iter() + .chain(jdks) + .filter(|jdk| !jdk.home.starts_with(&managed) || project_jdk == Some(*jdk)) + .cloned() + .collect(); let mut all: Vec = Vec::new(); for j in project_jdk.into_iter().chain(jdks).chain(server_jdk) { if !all.contains(j) { @@ -361,11 +385,12 @@ pub fn configure_jdtls( Err(e) => cfg.launch_error = Some(e), } } - let mut runtimes = runtimes(jdks); - if let Some(project) = project_jdk { - let home = project.home.to_string_lossy(); + let mut runtimes = runtimes(&project_jdks); + if let Some(project) = project_jdk.or_else(|| pick_jdk(&project_jdks, project_java_version(server_root))) { + let name = if project.major == 8 { "JavaSE-1.8".to_owned() } else { format!("JavaSE-{}", project.major) }; for r in &mut runtimes { - if r["path"] == *home { + if r["name"] == name { + r["path"] = json!(project.home.to_string_lossy()); r["default"] = json!(true); } } @@ -502,6 +527,58 @@ fn parse_java_version(v: &str) -> Option { mod tests { use super::*; + #[test] + fn provisioning_runtime_preserves_implicit_project_jdk() { + let data = tempfile::tempdir().unwrap(); + let project_root = data.path().join("tools/jdks"); + let project_home = project_root.join("temurin-21"); + std::fs::create_dir_all(project_home.join("bin")).unwrap(); + std::fs::write(project_home.join("release"), "JAVA_VERSION=\"21.0.1\"\n").unwrap(); + std::fs::write(java_executable(&project_home), "").unwrap(); + let project = jdk_at(&project_home).unwrap(); + let discover = || jdk_homes(&project_root).filter_map(|home| jdk_at(&home)).collect::>(); + let installed = discover(); + assert_eq!(pick_jdk(&installed, None), Some(&project)); + assert!(jdtls_runtime(&runtime_candidates(&installed, data.path()), 25).is_none()); + let home = language_server_runtimes_dir(data.path()).join("temurin-25"); + std::fs::create_dir_all(home.join("bin")).unwrap(); + std::fs::write(home.join("release"), "JAVA_VERSION=\"25.0.1\"\n").unwrap(); + std::fs::write(java_executable(&home), "").unwrap(); + let candidates = runtime_candidates(&installed, data.path()); + assert_eq!(jdtls_runtime(&candidates, 25).unwrap().major, 25); + assert_eq!(jdtls_runtime(&candidates, 21), Some(&project)); + assert_eq!(pick_jdk(&discover(), None), Some(&project)); + let explicit = jdk_at(&home).unwrap(); + assert_eq!(pick_jdk(std::slice::from_ref(&explicit), None), Some(&explicit)); + let mut cfg: LanguageServerConfig = serde_json::from_value(json!({"name":"jdtls", "command":"jdtls"})).unwrap(); + configure_jdtls(&mut cfg, data.path(), data.path(), &candidates, None, None, 25, &[], None); + assert_eq!(cfg.env["JAVA_HOME"], explicit.home.to_string_lossy()); + let options = serde_json::to_string(&cfg.initialization_options).unwrap(); + assert!(!options.contains("temurin-25"), "runtime-only JDK must not become a compiler runtime"); + assert_eq!( + cfg.initialization_options.as_ref().unwrap()["settings"]["java"]["configuration"]["runtimes"][0]["default"] + .as_bool(), + Some(true) + ); + } + + #[test] + fn implicit_compiler_default_matches_project_pick_with_duplicate_versions() { + let installed = + [Jdk { major: 21, home: PathBuf::from("/jdk21a") }, Jdk { major: 21, home: PathBuf::from("/jdk21b") }]; + let server = Jdk { major: 25, home: PathBuf::from("/custom-server-runtime") }; + let mut cfg: LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap(); + configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &installed, None, Some(&server), 25, &[], None); + let opts = serde_json::to_value(cfg.initialization_options.unwrap()).unwrap(); + assert_eq!( + opts["settings"]["java"]["configuration"]["runtimes"], + json!([{ + "name": "JavaSE-21", "path": pick_jdk(&installed, None).unwrap().home, "default": true + }]) + ); + assert_eq!(cfg.env["JAVA_HOME"], "/custom-server-runtime"); + } + #[test] fn release_versions() { assert_eq!(parse_release("IMPLEMENTOR=\"x\"\nJAVA_VERSION=\"21.0.3\"\n"), Some(21)); @@ -544,7 +621,7 @@ mod tests { let rt = &opts["settings"]["java"]["configuration"]["runtimes"]; assert_eq!(rt[0], json!({ "name": "JavaSE-1.8", "path": "/jdk8" })); assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": "/jdk21a" })); - assert_eq!(rt[2], json!({ "name": "JavaSE-25", "path": "/jdk25" })); + assert_eq!(rt[2], json!({ "name": "JavaSE-25", "path": "/jdk25", "default": true })); assert_eq!(opts["bundles"], json!(["/tools/java-debug/bin/com.microsoft.java.debug.plugin.jar"])); assert_eq!(cfg.env["JAVA_HOME"], "/jdk25", "first JDK new enough to run jdtls"); assert_eq!(cfg.launch_error, None); diff --git a/crates/ide-tools/src/jdk.rs b/crates/ide-tools/src/jdk.rs index b5bcf6db8..0b85718e5 100644 --- a/crates/ide-tools/src/jdk.rs +++ b/crates/ide-tools/src/jdk.rs @@ -108,8 +108,28 @@ fn adoptium_arch(arch: Arch) -> &'static str { /// The latest Temurin JDK build of Java `feature` for `platform`. pub fn adoptium_assets_url(feature: u32, platform: Platform) -> String { + assets_url(feature, platform, ImageType::Jdk) +} + +#[derive(Clone, Copy)] +enum ImageType { + Jdk, + Jre, +} + +impl ImageType { + fn name(self) -> &'static str { + match self { + Self::Jdk => "jdk", + Self::Jre => "jre", + } + } +} + +fn assets_url(feature: u32, platform: Platform, image: ImageType) -> String { + let image = image.name(); format!( - "{ADOPTIUM_API}/assets/latest/{feature}/hotspot?architecture={}&image_type=jdk&os={}&vendor=eclipse", + "{ADOPTIUM_API}/assets/latest/{feature}/hotspot?architecture={}&image_type={image}&os={}&vendor=eclipse", adoptium_arch(platform.arch), adoptium_os(platform.os), ) @@ -170,12 +190,16 @@ impl AssetVersion { /// The JDK archive (`.tar.gz` on macOS / Linux, `.zip` on Windows) in an Adoptium /// `assets/latest` response; `None` when there is no build for the platform. pub fn adoptium_pick(json: &str, platform: Platform) -> IdeResult> { + pick_image(json, platform, ImageType::Jdk) +} + +fn pick_image(json: &str, platform: Platform, image: ImageType) -> IdeResult> { let assets: Vec = serde_json::from_str(json).map_err(|e| proto(format!("unexpected Adoptium response: {e}")))?; let ext = if platform.os == Os::Windows { ".zip" } else { ".tar.gz" }; let os = adoptium_os(platform.os); let found = assets.into_iter().find(|a| { - a.binary.image_type.as_deref().is_none_or(|t| t == "jdk") + a.binary.image_type.as_deref().is_none_or(|t| t == image.name()) && a.binary.heap_size.as_deref().is_none_or(|h| h == "normal") && a.binary.os.as_deref().is_none_or(|o| o == os) && a.binary.package.name.ends_with(ext) @@ -255,14 +279,50 @@ impl Tools { location: Option<&Path>, cancel: &CancellationToken, emit: Emit, + ) -> IdeResult { + self.download_java(vendor, feature, location, false, cancel, emit) + } + + /// Downloads a runtime for a language server, preferring a JRE. Only a + /// platform without a JRE build falls back to a full JDK. + pub fn download_java_runtime( + &self, + feature: u32, + location: &Path, + cancel: &CancellationToken, + emit: Emit, + ) -> IdeResult { + self.download_java(TEMURIN, feature, Some(location), true, cancel, emit) + } + + #[allow(clippy::too_many_arguments)] + fn download_java( + &self, + vendor: &str, + feature: u32, + location: Option<&Path>, + runtime: bool, + cancel: &CancellationToken, + emit: Emit, ) -> IdeResult { vendor_or_err(vendor)?; let _guard = InstallGuard::acquire(&format!("jdk-{vendor}-{feature}"))?; let http = self.http()?; let location = location.map(Path::to_path_buf).unwrap_or_else(|| self.root.join("jdks")); emit(InstallEvent::Line { text: format!("Resolving the latest Eclipse Temurin {feature}…") }); - let mut picked = - adoptium_pick(&http.get_text(&adoptium_assets_url(feature, self.platform), cancel)?, self.platform)?; + let mut picked = if runtime { + pick_image( + &http.get_text_or_empty_on_404(&assets_url(feature, self.platform, ImageType::Jre), cancel)?, + self.platform, + ImageType::Jre, + )? + } else { + None + }; + if picked.is_none() { + picked = + adoptium_pick(&http.get_text(&adoptium_assets_url(feature, self.platform), cancel)?, self.platform)?; + } if picked.is_none() && self.platform.os == Os::Mac && self.platform.arch == Arch::Arm64 { // Java 8 has no Apple Silicon build: the Intel one runs under Rosetta. let intel = Platform { os: Os::Mac, arch: Arch::X64 }; @@ -376,6 +436,22 @@ mod tests { assert!(adoptium_pick("{\"errorMessage\":\"x\"}", MAC_ARM).is_err()); } + #[test] + fn runtime_assets_use_jre_without_changing_jdk_downloads() { + for platform in [MAC_ARM, LINUX_X64, WIN_X64] { + assert!(assets_url(25, platform, ImageType::Jre).contains("image_type=jre")); + assert!(adoptium_assets_url(25, platform).contains("image_type=jdk")); + let ext = if platform.os == Os::Windows { "zip" } else { "tar.gz" }; + let jdk = asset(&format!("java.{ext}"), adoptium_os(platform.os), "normal", ""); + let jre = jdk.replace("\"image_type\":\"jdk\"", "\"image_type\":\"jre\""); + assert!(pick_image(&format!("[{jre}]"), platform, ImageType::Jre).unwrap().is_some()); + assert!(adoptium_pick(&format!("[{jre}]"), platform).unwrap().is_none()); + assert!(pick_image(&format!("[{jdk}]"), platform, ImageType::Jre).unwrap().is_none()); + assert!(pick_image("[]", platform, ImageType::Jre).unwrap().is_none()); + assert!(pick_image("offline", platform, ImageType::Jre).is_err()); + } + } + #[test] fn java_8_versions_and_dir_names() { let v = AssetVersion { major: 8, minor: 0, security: 472, openjdk_version: Some("1.8.0_472-b08".into()) }; diff --git a/crates/ide-tools/src/process.rs b/crates/ide-tools/src/process.rs index 81c44bf92..cd3e15788 100644 --- a/crates/ide-tools/src/process.rs +++ b/crates/ide-tools/src/process.rs @@ -221,7 +221,25 @@ impl Http { /// GETs `url` as text, failing when the response is larger than `max_bytes`. pub fn get_text_limited(&self, url: &str, max_bytes: Option, cancel: &CancellationToken) -> IdeResult { + self.get_text_response(url, max_bytes, false, cancel) + } + + /// Adoptium uses 404 as well as an empty array for absent platform builds. + pub fn get_text_or_empty_on_404(&self, url: &str, cancel: &CancellationToken) -> IdeResult { + self.get_text_response(url, None, true, cancel) + } + + fn get_text_response( + &self, + url: &str, + max_bytes: Option, + empty_on_404: bool, + cancel: &CancellationToken, + ) -> IdeResult { let mut cmd = self.base(url); + if empty_on_404 { + cmd.args(["--write-out", "\n%{http_code}"]); + } if let Some(max) = max_bytes { cmd.arg("--max-filesize").arg(max.to_string()); } @@ -246,7 +264,14 @@ impl Http { None => std::thread::sleep(Duration::from_millis(30)), } }; - let body = out_t.join().unwrap_or_default(); + let mut body = out_t.join().unwrap_or_default(); + if empty_on_404 && let Some(split) = body.iter().rposition(|b| *b == b'\n') { + let code = &body[split + 1..]; + if code == b"404" { + return Ok("[]".into()); + } + body.truncate(split); + } if let Some(max) = max_bytes && body.len() as u64 > max { @@ -362,6 +387,25 @@ mod tests { assert!(lines.contains(&"one".to_string()) && lines.contains(&"two".to_string()), "{lines:?}"); } + #[cfg(unix)] + #[test] + fn absent_build_is_empty_but_http_failures_are_not() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let curl = tmp.path().join("curl"); + let http = Http { curl: curl.clone(), https_only: false }; + for (code, exit, expected) in [(404, 22, Some("[]")), (200, 0, Some("payload")), (403, 22, None), (0, 7, None)] + { + std::fs::write(&curl, format!("#!/bin/sh\nprintf 'payload\\n{code:03}'\nexit {exit}\n")).unwrap(); + std::fs::set_permissions(&curl, std::fs::Permissions::from_mode(0o755)).unwrap(); + let result = http.get_text_or_empty_on_404("https://example.test/build", &CancellationToken::new()); + match expected { + Some(body) => assert_eq!(result.unwrap(), body), + None => assert!(result.is_err()), + } + } + } + #[cfg(unix)] #[test] fn cancellation_kills_the_child() { diff --git a/src/features/settings/languages/LanguagePages.tsx b/src/features/settings/languages/LanguagePages.tsx index a106da639..e507588a1 100644 --- a/src/features/settings/languages/LanguagePages.tsx +++ b/src/features/settings/languages/LanguagePages.tsx @@ -240,11 +240,11 @@ export function JavaPage() { label="JDK" kind="jdk" emptyLabel="Auto" - emptyDetail="the project JDK if new enough, else the first installed JDK that is" + emptyDetail="auto-managed; uses an installed JDK or downloads a runtime when needed" directory browseTitle="Select JDK Home Directory" aria-label="Language server JDK" - hint="Runs jdtls only, like IDEA's JDK for importer; the project keeps compiling and running with the Project JDK. jdtls needs JDK 21 or newer (recent releases 25); Download JDK… in the list fetches one. The jdtls executable itself is set in Language Servers." + hint="Runs jdtls only; the project keeps its Project JDK. When empty, a suitable installed JDK is used first, otherwise Eclipse Temurin is downloaded automatically into a separate language-server folder. The jdtls executable itself is set in Language Servers." />