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." />