fix: Status refresh loop, double project open at startup, jdtls Java requirement (#5)

* fix: Stop the status refresh loop and the double project open at startup

Every status rewrote .git/index, even with nothing to persist: gix's
`write_changes` writes an empty change set too. The watcher reports
.git/index writes as VCS changes, so each status triggered the next one,
together with an ahead/behind walk. On a repository with a long history
(freqtrade, 33k commits behind upstream) that kept a core busy for as long
as the project was open. Write back only when `has_changes()`.

A project passed on the command line was opened twice: the launch and the
"reopen last project" restore ran concurrently, and the restore checked
for an open project before the launch had finished. The second open found
the index cache locked and indexed in memory only; with a different last
project, the window ended up on whichever finished last. The restore now
waits for the startup launch.

* fix(lsp): Run jdtls on the Java version its bundles need

jdtls's launcher checks for Java 21, and so did JDTLS_MIN_JAVA, but the
Eclipse platform a release ships moves on: 1.63 bundles
org.eclipse.core.filesystem with JavaSE-25, so on a JDK 21 the server died
resolving its bundles ("jdtls is not ready (Crashed)") and the error told
users to install the 21 they already had.

Read the requirement from the installed jdtls instead: the highest osgi.ee
capability / Bundle-RequiredExecutionEnvironment among its plugins'
manifests (cached per install), never below 21. The runtime choice, the
rescan while no JDK fits and the "needs JDK N" message use it.

Fixes #4
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-23 15:20:36 +08:00
1 джерело 4be1b152ef
коміт 63a03d1c08
10 files changed
+239 -28

No files matched your search

Створено
+1
Переглянути файл
@@ -4272,6 +4272,7 @@ dependencies = [
"tempfile",
"toml 0.9.12+spec-1.1.0",
"tracing",
"zip 2.4.2",
]
[[package]]
+1
Переглянути файл
@@ -15,6 +15,7 @@ serde_json.workspace = true
specta.workspace = true
toml.workspace = true
tracing.workspace = true
zip = { version = "=2.4.2", default-features = false, features = ["deflate"] }
[dev-dependencies]
tempfile.workspace = true
+140 -23
Переглянути файл
@@ -126,23 +126,101 @@ pub fn installed_jdks() -> Arc<[Jdk]> {
}
}
/// Like [`installed_jdks`], but while none can run jdtls 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() -> 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]> {
let mut slot = JDKS.lock().unwrap_or_else(|e| e.into_inner());
match &*slot {
Some(s) if jdtls_runtime(&s.jdks).is_some() || s.at.elapsed() < RESCAN_AFTER => s.jdks.clone(),
Some(s) if jdtls_runtime(&s.jdks, min_java).is_some() || s.at.elapsed() < RESCAN_AFTER => s.jdks.clone(),
_ => rescan(&mut slot),
}
}
/// Oldest Java version jdtls itself runs on (projects may target older ones).
/// Oldest Java version jdtls runs on when its install can't tell (its launcher's check).
pub const JDTLS_MIN_JAVA: u32 = 21;
/// The JDK to run jdtls with: the first found that is new enough (`JAVA_HOME` wins).
pub fn jdtls_runtime(jdks: &[Jdk]) -> Option<&Jdk> {
jdks.iter().find(|j| j.major >= JDTLS_MIN_JAVA)
pub fn jdtls_runtime(jdks: &[Jdk], min_java: u32) -> Option<&Jdk> {
jdks.iter().find(|j| j.major >= min_java)
}
/// The Java version the jdtls that `cfg` launches needs: the highest any of its
/// bundles requires, never below [`JDTLS_MIN_JAVA`]. Its launcher only checks
/// for 21, but the Eclipse platform it ships moves on (1.63 needs 25), and on
/// an older JDK the server dies resolving its bundles.
pub fn jdtls_min_java(cfg: &LanguageServerConfig) -> u32 {
let search = cfg.env.get("PATH").map(std::ffi::OsString::from).or_else(|| std::env::var_os("PATH"));
let required = search
.and_then(|s| ide_run::which::find_executable(&cfg.command, &s))
.and_then(|exe| dunce::canonicalize(exe).ok())
.and_then(|exe| Some(exe.parent()?.parent()?.to_path_buf()))
.and_then(|home| bundles_min_java(&home));
required.map_or(JDTLS_MIN_JAVA, |v| v.max(JDTLS_MIN_JAVA))
}
/// Per install: (plugins directory mtime, its requirement).
static BUNDLE_JAVA: Mutex<BTreeMap<PathBuf, (std::time::SystemTime, Option<u32>)>> = Mutex::new(BTreeMap::new());
/// Highest Java version required by the bundles in `home`'s `plugins` (or Homebrew's
/// `libexec/plugins`); `None` when there are none to read.
fn bundles_min_java(home: &Path) -> Option<u32> {
let plugins = [home.join("plugins"), home.join("libexec").join("plugins")].into_iter().find(|p| p.is_dir())?;
let modified = std::fs::metadata(&plugins).and_then(|m| m.modified()).ok()?;
let mut cache = BUNDLE_JAVA.lock().unwrap_or_else(|e| e.into_inner());
if let Some((at, v)) = cache.get(&plugins)
&& *at == modified
{
return *v;
}
let required = std::fs::read_dir(&plugins)
.ok()?
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "jar"))
.filter_map(|jar| jar_manifest(&jar))
.filter_map(|manifest| manifest_min_java(&manifest))
.max();
cache.insert(plugins, (modified, required));
required
}
fn jar_manifest(jar: &Path) -> Option<String> {
use std::io::Read;
let mut zip = zip::ZipArchive::new(std::fs::File::open(jar).ok()?).ok()?;
let mut entry = zip.by_name("META-INF/MANIFEST.MF").ok()?;
let mut text = String::new();
entry.read_to_string(&mut text).ok()?;
Some(text)
}
/// The Java version an OSGi bundle manifest requires: its `osgi.ee` capability
/// (`version=25`), else the oldest of its `Bundle-RequiredExecutionEnvironment`s.
fn manifest_min_java(manifest: &str) -> Option<u32> {
// Long headers continue on lines starting with a space.
let mut headers: Vec<String> = Vec::new();
for line in manifest.lines() {
match (line.strip_prefix(' '), headers.last_mut()) {
(Some(rest), Some(last)) => last.push_str(rest),
_ => headers.push(line.to_owned()),
}
}
let header = |name: &str| headers.iter().find_map(|h| h.strip_prefix(name)?.strip_prefix(':')).map(str::trim);
if let Some(caps) = header("Require-Capability") {
let ee = caps.split(',').filter(|c| c.trim_start().starts_with("osgi.ee")).find_map(|c| {
let v = c.split("version=").nth(1)?;
let digits: String = v.chars().take_while(char::is_ascii_digit).collect();
digits.parse::<u32>().ok()
});
if ee.is_some() {
return ee;
}
}
header("Bundle-RequiredExecutionEnvironment")?
.split(',')
.filter_map(|ee| ee.trim().strip_prefix("JavaSE-"))
.filter_map(|v| if v == "1.8" { Some(8) } else { v.parse().ok() })
.min()
}
/// jdtls `java.configuration.runtimes`: one JDK per Java version (the first
@@ -178,14 +256,15 @@ const METADATA_PROPERTY: &str = "java.import.generatesMetadataFilesAtProjectRoot
/// jdtls: its Eclipse workspace (`-data`) lives under the IDE's data
/// directory, one per server root, and no Eclipse metadata is written into
/// the project; it runs on a JDK 21+ (via `JAVA_HOME`, which its launcher
/// reads), installed JDKs are registered as runtimes and `bundles` (e.g. the
/// java-debug plugin jar) are loaded into the server.
/// the project; it runs on a JDK `min_java`+ (via `JAVA_HOME`, which its
/// launcher reads), installed JDKs are registered as runtimes and `bundles`
/// (e.g. the java-debug plugin jar) are loaded into the server.
pub fn configure_jdtls(
cfg: &mut LanguageServerConfig,
server_root: &Path,
data_dir: &Path,
jdks: &[Jdk],
min_java: u32,
bundles: &[PathBuf],
) {
if cfg.name != "jdtls" {
@@ -201,11 +280,11 @@ pub fn configure_jdtls(
cfg.args.push(format!("--jvm-arg=-D{METADATA_PROPERTY}=false"));
}
if !cfg.env.contains_key("JAVA_HOME") {
match jdtls_runtime(jdks) {
match jdtls_runtime(jdks, min_java) {
Some(jdk) => {
cfg.env.insert("JAVA_HOME".to_owned(), jdk.home.to_string_lossy().into_owned());
}
None => cfg.launch_error = Some(missing_runtime_message(jdks)),
None => cfg.launch_error = Some(missing_runtime_message(jdks, min_java)),
}
}
let runtimes = runtimes(jdks);
@@ -218,7 +297,7 @@ pub fn configure_jdtls(
}
}
fn missing_runtime_message(jdks: &[Jdk]) -> String {
fn missing_runtime_message(jdks: &[Jdk], min_java: u32) -> String {
let mut found: Vec<u32> = jdks.iter().map(|j| j.major).collect();
found.sort_unstable();
found.dedup();
@@ -229,7 +308,7 @@ fn missing_runtime_message(jdks: &[Jdk]) -> String {
format!("found JDK {}", list.join(", "))
};
format!(
"jdtls needs JDK {JDTLS_MIN_JAVA} or newer to run ({found}). Install one (or point JAVA_HOME at it) and \
"jdtls needs JDK {min_java} or newer to run ({found}). Install one (or point JAVA_HOME at it) and \
restart the server; the project can still target an older Java version."
)
}
@@ -330,7 +409,7 @@ mod tests {
Jdk { major: 8, home: PathBuf::from("/jdk8") },
];
let bundles = [PathBuf::from("/tools/java-debug/bin/com.microsoft.java.debug.plugin.jar")];
configure_jdtls(&mut cfg, Path::new("/p/demo"), Path::new("/data"), &jdks, &bundles);
configure_jdtls(&mut cfg, Path::new("/p/demo"), Path::new("/data"), &jdks, JDTLS_MIN_JAVA, &bundles);
assert_eq!(cfg.args[0], "-data");
assert!(cfg.args[1].starts_with("/data/lsp/jdtls/"), "{}", cfg.args[1]);
assert_eq!(cfg.args[2], "--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=false");
@@ -345,36 +424,74 @@ mod tests {
// Deterministic (reconfigure compares configs) and idempotent.
let before = cfg.clone();
configure_jdtls(&mut cfg, Path::new("/p/demo"), Path::new("/data"), &jdks, &bundles);
configure_jdtls(&mut cfg, Path::new("/p/demo"), Path::new("/data"), &jdks, JDTLS_MIN_JAVA, &bundles);
assert_eq!(cfg, before);
let mut other: LanguageServerConfig = toml::from_str("name = \"pyright\"\ncommand = \"x\"").unwrap();
configure_jdtls(&mut other, Path::new("/p"), Path::new("/data"), &jdks, &bundles);
configure_jdtls(&mut other, Path::new("/p"), Path::new("/data"), &jdks, JDTLS_MIN_JAVA, &bundles);
assert!(other.args.is_empty() && other.initialization_options.is_none());
}
#[test]
fn jdtls_needs_a_jdk_21_to_run() {
fn jdtls_needs_a_new_enough_jdk_to_run() {
let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() };
let old = [Jdk { major: 17, home: PathBuf::from("/jdk17") }, Jdk { major: 8, home: PathBuf::from("/jdk8") }];
let mut cfg = jdtls();
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, &[]);
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, JDTLS_MIN_JAVA, &[]);
assert!(!cfg.env.contains_key("JAVA_HOME"));
let err = cfg.launch_error.unwrap();
assert!(err.contains("JDK 21 or newer") && err.contains("found JDK 8, 17"), "{err}");
let mut cfg = jdtls();
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], &[]);
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], JDTLS_MIN_JAVA, &[]);
assert!(cfg.launch_error.unwrap().contains("no JDK was found"));
// An explicit JAVA_HOME is the user's call.
let mut cfg = jdtls();
cfg.env.insert("JAVA_HOME".into(), "/custom".into());
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, &[]);
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, JDTLS_MIN_JAVA, &[]);
assert_eq!((cfg.env["JAVA_HOME"].as_str(), cfg.launch_error), ("/custom", None));
let mixed = [old[0].clone(), Jdk { major: 21, home: PathBuf::from("/jdk21") }];
assert_eq!(jdtls_runtime(&mixed).unwrap().major, 21);
assert_eq!(jdtls_runtime(&mixed, 21).unwrap().major, 21);
// A jdtls whose bundles need 25 doesn't start on 21, and says what it needs.
let mut cfg = jdtls();
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &mixed, 25, &[]);
let err = cfg.launch_error.unwrap();
assert!(err.contains("JDK 25 or newer") && err.contains("found JDK 17, 21"), "{err}");
}
#[test]
fn bundle_manifests_give_the_java_jdtls_needs() {
let ee = |m: &str| manifest_min_java(m);
assert_eq!(ee("Bundle-RequiredExecutionEnvironment: JavaSE-25\n"), Some(25));
assert_eq!(ee("Bundle-RequiredExecutionEnvironment: JavaSE-17, JavaSE-1.8\n"), Some(8));
// The capability wins, also when folded over continuation lines.
let folded = "Require-Capability: osgi.service;filter:=\"(x=1)\",osgi.ee;filter:=\"(&(osgi.ee=Ja\r\n \
vaSE)(version=21))\"\r\nBundle-RequiredExecutionEnvironment: JavaSE-17\r\n";
assert_eq!(ee(folded), Some(21));
assert_eq!(ee("Bundle-Name: x\n"), None);
let home = tempfile::tempdir().unwrap();
let plugins = home.path().join("plugins");
std::fs::create_dir(&plugins).unwrap();
let jar = |name: &str, manifest: &str| {
let mut zip = zip::ZipWriter::new(std::fs::File::create(plugins.join(name)).unwrap());
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
zip.start_file("META-INF/MANIFEST.MF", opts).unwrap();
std::io::Write::write_all(&mut zip, manifest.as_bytes()).unwrap();
zip.finish().unwrap();
};
jar(
"org.eclipse.jdt.ls.core_1.63.0.jar",
"Require-Capability: osgi.ee;filter:=\"(&(osgi.ee=JavaSE)(version=21))\"\n",
);
jar("org.eclipse.core.filesystem_1.12.0.jar", "Bundle-RequiredExecutionEnvironment: JavaSE-25\n");
jar("plain.jar", "Manifest-Version: 1.0\n");
std::fs::write(plugins.join("notes.txt"), "not a jar").unwrap();
assert_eq!(bundles_min_java(home.path()), Some(25));
assert_eq!(bundles_min_java(&home.path().join("missing")), None);
}
#[test]
+3
Переглянути файл
@@ -87,7 +87,10 @@ pub(crate) fn status(r: &Repo) -> IdeResult<Vec<FileStatus>> {
}
}
// Persist refreshed stat info so the next status is cheaper (like `git status` does).
// Only when something changed: `write_changes` writes even an empty change set, and
// the rewritten `.git/index` comes back as a VCS change that triggers the next status.
if let Some(mut outcome) = iter.into_outcome()
&& outcome.has_changes()
&& let Some(Err(e)) = outcome.write_changes()
{
tracing::debug!("could not write refreshed index: {e}");
+20
Переглянути файл
@@ -145,6 +145,26 @@ fn repository_without_commits() {
assert!(repo.status().unwrap().is_empty());
}
#[test]
fn status_leaves_an_up_to_date_index_alone() {
// The watcher reports `.git/index` writes as VCS changes, which refresh the
// status: rewriting it unconditionally made that a loop.
let fx = Fixture::new();
fx.write("a.txt", "a\n");
// Older than the index, so it isn't racily clean (that settles after a write or two).
let past = std::time::SystemTime::now() - std::time::Duration::from_secs(60);
std::fs::File::options().write(true).open(fx.path("a.txt")).unwrap().set_modified(past).unwrap();
fx.commit_all("init");
let repo = fx.repo();
repo.status().unwrap(); // may persist refreshed stat info once
let index = fx.path(".git/index");
let before = std::fs::metadata(&index).unwrap().modified().unwrap();
for _ in 0..3 {
assert!(repo.status().unwrap().is_empty());
}
assert_eq!(std::fs::metadata(&index).unwrap().modified().unwrap(), before);
}
#[test]
fn status_matrix() {
let fx = Fixture::new();
+3 -2
Переглянути файл
@@ -246,8 +246,9 @@ impl AppState {
if cfg.name == "jdtls" {
let bundles: Vec<PathBuf> =
ide_tools::managed_bundle(&data_dir, "java-debug").into_iter().collect();
let jdks = ide_sdk::java::jdtls_jdks();
ide_sdk::java::configure_jdtls(cfg, server_root, &data_dir, &jdks, &bundles);
let min_java = ide_sdk::java::jdtls_min_java(cfg);
let jdks = ide_sdk::java::jdtls_jdks(min_java);
ide_sdk::java::configure_jdtls(cfg, server_root, &data_dir, &jdks, min_java, &bundles);
}
}));
}
+2 -2
Переглянути файл
@@ -6,7 +6,7 @@
import type { Action } from "@/platform/actions";
import { t } from "@/platform/i18n";
import { call, commands, isTauri } from "@/platform/ipc";
import { basename, confirmLeaveProject, openProject, useProject } from "@/platform/project";
import { basename, confirmLeaveProject, openProject, provideStartupLaunch, useProject } from "@/platform/project";
import { notifications } from "@/workbench/contributions";
import { ask } from "./prompt";
@@ -195,7 +195,7 @@ export function installLaunchHandling(): () => void {
if (!isTauri()) return () => {};
let disposed = false;
let unlisten: (() => void) | undefined;
void consumeLaunch();
provideStartupLaunch(consumeLaunch());
void import("@tauri-apps/api/webviewWindow").then(async ({ getCurrentWebviewWindow }) => {
const off = await getCurrentWebviewWindow().listen(LAUNCH_EVENT, () => void consumeLaunch());
if (disposed) off();
+54
Переглянути файл
@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const openProject = vi.fn();
vi.mock("@/platform/ipc", async (orig) => {
const real = await orig<typeof import("@/platform/ipc")>();
return {
...real,
isTauri: () => true,
commands: { ...real.commands, settingsGet: async () => ({ general: { reopenLastProject: true } }) },
};
});
vi.mock("@tauri-apps/api/window", () => ({ getCurrentWindow: () => ({ label: "main" }) }));
vi.mock("@/platform/project", async (orig) => {
const real = await orig<typeof import("@/platform/project")>();
return { ...real, openProject: (p: string) => openProject(p) };
});
const { reopenLastProject, _resetSessionForTests } = await import("./session");
const { provideStartupLaunch, useProject } = await import("@/platform/project");
beforeEach(() => {
openProject.mockReset();
useProject.setState({ project: null });
sessionStorage.clear();
localStorage.setItem("openidea.session.openProject", "/p/last");
_resetSessionForTests();
});
describe("reopenLastProject", () => {
it("waits for the command-line project instead of opening the last one too", async () => {
let finishLaunch!: () => void;
provideStartupLaunch(
new Promise<void>((resolve) => {
finishLaunch = () => {
useProject.setState({ project: { root: "/p/launch" } as never });
resolve();
};
}),
);
const reopened = reopenLastProject();
await new Promise((r) => setTimeout(r, 0));
expect(openProject).not.toHaveBeenCalled();
finishLaunch();
await reopened;
expect(openProject).not.toHaveBeenCalled();
});
it("reopens the last project when the launch named none", async () => {
provideStartupLaunch(Promise.resolve());
await reopenLastProject();
expect(openProject).toHaveBeenCalledWith("/p/last");
});
});
+3 -1
Переглянути файл
@@ -6,7 +6,7 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import { create } from "zustand";
import { commands, isTauri, type Settings } from "@/platform/ipc";
import { openProject, useProject } from "@/platform/project";
import { openProject, startupLaunchHandled, useProject } from "@/platform/project";
import { notifications } from "@/workbench/contributions";
const SESSION_KEY = "openidea.session.openProject";
@@ -104,6 +104,8 @@ export async function reopenLastProject(): Promise<void> {
/* use the local preference */
}
if (!reopenEnabled(settings, useSession.getState().reopenPref)) return;
// A project passed on the command line wins over the last session's.
await startupLaunchHandled();
if (useProject.getState().project) return;
await reopen(path);
}
+12
Переглянути файл
@@ -27,6 +27,18 @@ export function confirmLeaveProject(): Promise<boolean> {
return useProject.getState().project && leaveGuard ? leaveGuard() : Promise.resolve(true);
}
let startupLaunch: Promise<void> | null = null;
/** Installs this window's startup launch (the project it was started or created for, features/app/windows.ts). */
export function provideStartupLaunch(launch: Promise<void>) {
startupLaunch = launch;
}
/** Settles once the startup launch has opened its project (if any), so a restore doesn't race it. */
export function startupLaunchHandled(): Promise<void> {
return (startupLaunch ?? Promise.resolve()).catch(() => {});
}
export async function openProject(path: string): Promise<ProjectInfo> {
useProject.setState({ opening: true });
try {