fix(lsp): Start jdtls on the JVM directly and report server output outside the protocol

On Windows the downloaded jdtls was started through bin/jdtls.bat, which runs
`python` and then `pause`. Without a working Python the batch file printed
pause's prompt in the console code page on stdout; the client stopped reading at
that "non-UTF-8 header", killed the process and showed only "exited with code 1".

- jdtls: build the JVM command line of the release's launcher script (Equinox
  launcher jar, per-platform shared configuration, JDK 24+ XML limits, --jvm-arg
  options, -data) and start `java` from the selected JDK directly, on every
  platform. Own wrapper scripts and other layouts keep their command.
- JSON-RPC transport: stdout lines that cannot start a message header are
  decoded, logged and handed to the stderr callback instead of ending the
  connection; stderr and these lines are decoded as UTF-8, else in the Windows
  OEM/ANSI code page, else lossily.
- A process that exits on its own is reported closed only after its remaining
  output is read, and a failed initialize waits for the exit, so the crash
  message carries the real exit code and the last lines.
- TCP-started language servers and DAP adapters read their output the same way
  (a non-UTF-8 line no longer stops the reader); stray adapter stdout goes into
  the adapter's error tail.

Fixes #90

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
This commit is contained in:
l0ng-ai
2026-10-27 20:42:08 +08:00
parent 8568ff001b
commit 3c783e8b5a
14 changed files with 789 additions and 68 deletions
Generated
+1
View File
@@ -4146,6 +4146,7 @@ dependencies = [
name = "ide-jsonrpc"
version = "0.1.17"
dependencies = [
"encoding_rs",
"futures",
"ide-core",
"parking_lot",
+14 -12
View File
@@ -14,7 +14,7 @@ use std::pin::Pin;
use std::process::Stdio;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, BufReader};
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::process::{Child, Command};
pub const PORT_PLACEHOLDER: &str = "${port}";
@@ -457,17 +457,19 @@ fn pump_lines<R: AsyncRead + Unpin + Send + 'static>(
name: String,
sink: Arc<parking_lot::Mutex<Vec<String>>>,
) {
tokio::spawn(async move {
let mut lines = BufReader::new(stream).lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::debug!(target: "ide_dap::adapter", "[{name}] {line}");
let mut buf = sink.lock();
if buf.len() >= 40 {
buf.remove(0);
}
buf.push(line);
}
});
tokio::spawn(ide_jsonrpc::output::read_lines(stream, move |line| {
tracing::debug!(target: "ide_dap::adapter", "[{name}] {line}");
push_line(&sink, line);
}));
}
/// Appends to the recent-output buffer of an adapter (the last 40 lines).
pub(crate) fn push_line(sink: &parking_lot::Mutex<Vec<String>>, line: String) {
let mut buf = sink.lock();
if buf.len() >= 40 {
buf.remove(0);
}
buf.push(line);
}
const LOOPBACK: &str = "127.0.0.1";
+21 -2
View File
@@ -95,7 +95,21 @@ pub struct Connection {
impl Connection {
/// Starts reader/writer tasks over `reader`/`writer`. Must be called inside a Tokio runtime.
pub fn start<R, W>(reader: R, mut writer: W) -> (Self, mpsc::UnboundedReceiver<Incoming>)
pub fn start<R, W>(reader: R, writer: W) -> (Self, mpsc::UnboundedReceiver<Incoming>)
where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
Self::start_with_output(reader, writer, Box::new(|_| {}))
}
/// [`start`](Self::start) that hands lines outside the protocol (an adapter's
/// own output on stdout, decoded) to `on_output`.
pub fn start_with_output<R, W>(
reader: R,
mut writer: W,
on_output: Box<dyn Fn(String) + Send + Sync>,
) -> (Self, mpsc::UnboundedReceiver<Incoming>)
where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
@@ -133,9 +147,14 @@ impl Connection {
let reader_inner = inner.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(reader);
let mut noise = |line: &[u8]| {
let text = ide_jsonrpc::output::decode(line);
tracing::warn!("dap: output outside the protocol: {text}");
on_output(text);
};
loop {
let msg = tokio::select! {
msg = framing::read_message(&mut reader) => msg,
msg = framing::read_message_or_noise(&mut reader, &mut noise) => msg,
_ = reader_inner.cancel.cancelled() => break,
};
match msg {
+4 -1
View File
@@ -681,7 +681,10 @@ impl DebugManager {
}
let io = self.launcher.launch(spec.clone(), reuse_port).await?;
let stderr = io.stderr.clone();
let (conn, incoming) = Connection::start(io.reader, io.writer);
let (conn, incoming) = {
let stderr = stderr.clone();
Connection::start_with_output(io.reader, io.writer, Box::new(move |line| adapter::push_line(&stderr, line)))
};
let session =
Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child));
self.sessions.lock().insert(id, session.clone());
+1
View File
@@ -15,3 +15,4 @@ futures.workspace = true
parking_lot.workspace = true
thiserror.workspace = true
tracing.workspace = true
encoding_rs.workspace = true
+112 -46
View File
@@ -6,7 +6,10 @@
//! * [`Peer`]: a JSON-RPC 2.0 endpoint with request/response correlation,
//! cancellation (`$/cancelRequest`) and handlers for server→client traffic.
pub mod output;
pub mod framing {
use crate::output::trim_line_end as trim_line;
use std::io::{Error, ErrorKind};
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWrite, AsyncWriteExt};
@@ -21,6 +24,19 @@ pub mod framing {
/// matched case-insensitively, unknown headers (e.g. `Content-Type`) are
/// ignored, and both `\r\n` and bare `\n` line endings are accepted.
pub async fn read_message<R: AsyncBufRead + Unpin>(reader: &mut R) -> std::io::Result<Option<Vec<u8>>> {
read_message_or_noise(reader, &mut |line| {
tracing::warn!("jsonrpc: ignoring output outside the protocol: {}", crate::output::decode(line));
})
.await
}
/// [`read_message`] that hands lines which can't start a message header (a
/// launcher's or runtime's own output on stdout, in any encoding) to `on_noise`
/// and skips them, instead of failing on them.
pub async fn read_message_or_noise<R: AsyncBufRead + Unpin>(
reader: &mut R,
on_noise: &mut (dyn FnMut(&[u8]) + Send),
) -> std::io::Result<Option<Vec<u8>>> {
let mut content_length: Option<usize> = None;
let mut saw_any = false;
let mut line = Vec::with_capacity(64);
@@ -37,6 +53,11 @@ pub mod framing {
if n as u64 >= MAX_HEADER_LINE {
return Err(Error::new(ErrorKind::InvalidData, "header line too long"));
}
if !saw_any && header(&line).is_none() {
// Output without a final newline, then EOF (`pause` waiting for a key).
on_noise(trim_line(&line));
return Ok(None);
}
return Err(Error::new(ErrorKind::UnexpectedEof, "EOF inside message header"));
}
let text = trim_line(&line);
@@ -50,12 +71,18 @@ pub mod framing {
// Tolerate stray blank lines between messages.
continue;
}
saw_any = true;
let text = std::str::from_utf8(text).map_err(|_| Error::new(ErrorKind::InvalidData, "non-UTF-8 header"))?;
let Some((name, value)) = text.split_once(':') else {
return Err(Error::new(ErrorKind::InvalidData, format!("malformed header line: {text:?}")));
let Some((name, value)) = header(text) else {
if !saw_any {
on_noise(text);
continue;
}
return Err(Error::new(
ErrorKind::InvalidData,
format!("malformed header line: {:?}", crate::output::decode(text)),
));
};
if name.trim().eq_ignore_ascii_case("content-length") {
saw_any = true;
if name.eq_ignore_ascii_case("content-length") {
let len: usize = value
.trim()
.parse()
@@ -72,12 +99,12 @@ pub mod framing {
Ok(Some(body))
}
fn trim_line(line: &[u8]) -> &[u8] {
let mut end = line.len();
while end > 0 && matches!(line[end - 1], b'\n' | b'\r') {
end -= 1;
}
&line[..end]
/// `name: value` with a header-like name (a token, no spaces), else `None`.
fn header(line: &[u8]) -> Option<(&str, &str)> {
let text = std::str::from_utf8(trim_line(line)).ok()?;
let (name, value) = text.split_once(':')?;
let token = !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
token.then_some((name, value))
}
/// Writes one framed message and flushes once.
@@ -126,7 +153,7 @@ use serde_json::{Value, json};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, BufReader};
use tokio::io::{AsyncRead, AsyncWrite, BufReader};
use tokio::sync::{mpsc, oneshot, watch};
use tokio_util::sync::CancellationToken;
@@ -205,6 +232,8 @@ struct Inner {
/// Fired by [`Peer::kill`]; stops tasks and kills the child.
kill: CancellationToken,
pid: Option<u32>,
/// Fired when the reader task has stopped (EOF, error or kill).
reader_done: CancellationToken,
}
impl Inner {
@@ -250,10 +279,20 @@ pub enum Framing {
}
impl Framing {
async fn read<R: tokio::io::AsyncBufRead + Unpin>(self, reader: &mut R) -> std::io::Result<Option<Vec<u8>>> {
async fn read<R: tokio::io::AsyncBufRead + Unpin>(
self,
reader: &mut R,
on_noise: &mut (dyn FnMut(&[u8]) + Send),
) -> std::io::Result<Option<Vec<u8>>> {
match self {
Framing::ContentLength => framing::read_message(reader).await,
Framing::Lines => framing::read_line_message(reader).await,
Framing::ContentLength => framing::read_message_or_noise(reader, on_noise).await,
Framing::Lines => loop {
let Some(line) = framing::read_line_message(reader).await? else { return Ok(None) };
match line.iter().find(|b| !b.is_ascii_whitespace()) {
Some(b'{' | b'[') => return Ok(Some(line)),
_ => on_noise(&line),
}
},
}
}
@@ -265,6 +304,9 @@ impl Framing {
}
}
/// How long a process that exited is given for its last output to be read.
const OUTPUT_DRAIN: std::time::Duration = std::time::Duration::from_secs(1);
/// A connected JSON-RPC peer. Cloning shares the connection.
#[derive(Clone)]
pub struct Peer {
@@ -282,8 +324,9 @@ impl Drop for KillOnDrop {
}
impl Peer {
/// Spawns `command` and speaks JSON-RPC over its stdio. Stderr lines are
/// forwarded to `tracing` and to `on_stderr`.
/// Spawns `command` and speaks JSON-RPC over its stdio. Stderr lines, and
/// stdout lines that are not part of a message (a launcher's own output), are
/// decoded (see [`output::decode`]) and forwarded to `tracing` and to `on_stderr`.
///
/// Must be called from within a Tokio runtime.
pub fn spawn(
@@ -324,42 +367,44 @@ impl Peer {
let stderr = child.stderr.take();
let pid = child.id();
let peer = Self::start(stdout, stdin, handler, false, pid, framing);
let on_output: Arc<dyn Fn(String) + Send + Sync> = Arc::from(on_stderr);
let peer = Self::start(stdout, stdin, handler, false, pid, framing, Some(on_output.clone()));
if let Some(stderr) = stderr {
let stderr_task = stderr.map(|stderr| {
let name = command.to_owned();
tokio::spawn(async move {
let mut reader = BufReader::new(stderr);
let mut line = Vec::new();
loop {
line.clear();
match reader.read_until(b'\n', &mut line).await {
Ok(0) | Err(_) => break,
Ok(_) => {
let text = String::from_utf8_lossy(&line).trim_end_matches(['\r', '\n']).to_owned();
tracing::debug!(target: "ide_jsonrpc::stderr", "[{name}] {text}");
on_stderr(text);
}
}
}
});
}
tokio::spawn(output::read_lines(stderr, move |text| {
tracing::debug!(target: "ide_jsonrpc::stderr", "[{name}] {text}");
on_output(text);
}))
});
peer.supervise(child);
peer.supervise(child, stderr_task);
Ok(peer)
}
/// Kills `child` when the connection is torn down; closes the connection when it exits.
fn supervise(&self, mut child: tokio::process::Child) {
/// After an exit of its own, the connection is reported closed only once what the
/// process wrote last (stderr, stray stdout) has been read, so a crash report has it.
fn supervise(&self, mut child: tokio::process::Child, stderr_task: Option<tokio::task::JoinHandle<()>>) {
let inner = self.inner.clone();
tokio::spawn(async move {
let status = tokio::select! {
status = child.wait() => status.ok(),
let (status, killed) = tokio::select! {
status = child.wait() => (status.ok(), false),
_ = inner.kill.cancelled() => {
let _ = child.start_kill();
child.wait().await.ok()
(child.wait().await.ok(), true)
}
};
if !killed {
// Bounded: a grandchild may keep the pipes open.
let drained = async {
if let Some(task) = stderr_task {
let _ = task.await;
}
inner.reader_done.cancelled().await;
};
let _ = tokio::time::timeout(OUTPUT_DRAIN, drained).await;
}
inner.mark_closed(status.and_then(|s| s.code()));
});
}
@@ -373,7 +418,7 @@ impl Peer {
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
Self::start(reader, writer, handler, true, None, Framing::ContentLength)
Self::start(reader, writer, handler, true, None, Framing::ContentLength, None)
}
/// [`connect`](Self::connect) with the given wire framing.
@@ -382,7 +427,7 @@ impl Peer {
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
Self::start(reader, writer, handler, true, None, framing)
Self::start(reader, writer, handler, true, None, framing, None)
}
/// [`Peer::connect`] over streams served by `child` (a server reached over a socket
@@ -399,8 +444,8 @@ impl Peer {
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
let peer = Self::start(reader, writer, handler, true, child.id(), Framing::ContentLength);
peer.supervise(child);
let peer = Self::start(reader, writer, handler, true, child.id(), Framing::ContentLength, None);
peer.supervise(child, None);
peer
}
@@ -411,6 +456,7 @@ impl Peer {
close_on_eof: bool,
pid: Option<u32>,
framing: Framing,
on_noise: Option<Arc<dyn Fn(String) + Send + Sync>>,
) -> Self
where
R: AsyncRead + Unpin + Send + 'static,
@@ -426,6 +472,7 @@ impl Peer {
closed,
kill: CancellationToken::new(),
pid,
reader_done: CancellationToken::new(),
});
// Writer: a single task drains the queue so messages never interleave.
@@ -476,20 +523,39 @@ impl Peer {
let inner = inner.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(reader);
let seen_message = AtomicBool::new(false);
let mut noise = |line: &[u8]| {
let text = output::decode(line);
if seen_message.load(Ordering::Relaxed) {
tracing::debug!("jsonrpc: output outside the protocol: {text}");
} else {
tracing::warn!("jsonrpc: output before the first message: {text}");
}
if let Some(f) = &on_noise {
f(text);
}
};
loop {
let msg = tokio::select! {
msg = framing.read(&mut reader) => msg,
msg = framing.read(&mut reader, &mut noise) => msg,
_ = inner.kill.cancelled() => break,
};
match msg {
Ok(Some(body)) => dispatch(&inner, &handler, &notif_tx, &body),
Ok(Some(body)) => {
seen_message.store(true, Ordering::Relaxed);
dispatch(&inner, &handler, &notif_tx, &body);
}
Ok(None) => break,
Err(e) => {
tracing::warn!("jsonrpc read failed: {e}");
if let Some(f) = &on_noise {
f(format!("invalid protocol output: {e}"));
}
break;
}
}
}
inner.reader_done.cancel();
inner.close_transport();
if close_on_eof || inner.kill.is_cancelled() {
inner.mark_closed(None);
+136
View File
@@ -0,0 +1,136 @@
//! Text a server process writes outside the protocol: stderr, and stray stdout
//! lines (a launcher script's or the runtime's own messages) before a message.
//!
//! Such text is often not UTF-8: on Windows, `cmd.exe`, batch files and native
//! launchers write in the system code page (GBK on a Chinese system).
use encoding_rs::Encoding;
use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
/// `bytes` as text: UTF-8 when valid, else (on Windows) the console / ANSI code
/// page, else UTF-8 with replacement characters.
pub fn decode(bytes: &[u8]) -> String {
decode_with(bytes, &native_code_pages())
}
/// [`decode`] trying the given Windows code pages, in order, after UTF-8.
pub fn decode_with(bytes: &[u8], code_pages: &[u32]) -> String {
if let Ok(s) = std::str::from_utf8(bytes) {
return s.to_owned();
}
for enc in code_pages.iter().filter_map(|&cp| encoding_for_code_page(cp)) {
let (text, had_errors) = enc.decode_without_bom_handling(bytes);
if !had_errors {
return text.into_owned();
}
}
String::from_utf8_lossy(bytes).into_owned()
}
/// The `encoding_rs` encoding of a Windows code page, when it has one.
pub fn encoding_for_code_page(cp: u32) -> Option<&'static Encoding> {
Some(match cp {
65001 => encoding_rs::UTF_8,
936 => encoding_rs::GBK,
54936 => encoding_rs::GB18030,
950 => encoding_rs::BIG5,
932 => encoding_rs::SHIFT_JIS,
949 => encoding_rs::EUC_KR,
866 => encoding_rs::IBM866,
874 => encoding_rs::WINDOWS_874,
1250 => encoding_rs::WINDOWS_1250,
1251 => encoding_rs::WINDOWS_1251,
1252 => encoding_rs::WINDOWS_1252,
1253 => encoding_rs::WINDOWS_1253,
1254 => encoding_rs::WINDOWS_1254,
1255 => encoding_rs::WINDOWS_1255,
1256 => encoding_rs::WINDOWS_1256,
1257 => encoding_rs::WINDOWS_1257,
1258 => encoding_rs::WINDOWS_1258,
20866 => encoding_rs::KOI8_R,
21866 => encoding_rs::KOI8_U,
_ => return None,
})
}
/// The OEM (console) and ANSI code pages: console programs and batch files write in
/// the former, GUI-subsystem and Java launchers in the latter.
#[cfg(windows)]
fn native_code_pages() -> [u32; 2] {
#[link(name = "kernel32")]
unsafe extern "system" {
fn GetOEMCP() -> u32;
fn GetACP() -> u32;
}
// SAFETY: argument-less Win32 queries.
unsafe { [GetOEMCP(), GetACP()] }
}
#[cfg(not(windows))]
fn native_code_pages() -> [u32; 0] {
[]
}
/// Removes a trailing `\n` / `\r\n` (and stray `\r`s before it).
pub fn trim_line_end(line: &[u8]) -> &[u8] {
let mut end = line.len();
while end > 0 && matches!(line[end - 1], b'\n' | b'\r') {
end -= 1;
}
&line[..end]
}
/// Reads `stream` to its end, calling `on_line` with each line decoded by [`decode`].
/// Unlike `AsyncBufReadExt::lines`, a line that is not UTF-8 does not end the stream.
pub async fn read_lines<R: AsyncRead + Unpin>(stream: R, mut on_line: impl FnMut(String)) {
let mut reader = BufReader::new(stream);
let mut line = Vec::new();
loop {
line.clear();
match reader.read_until(b'\n', &mut line).await {
Ok(0) | Err(_) => break,
Ok(_) => on_line(decode(trim_line_end(&line))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `请按任意键继续. . .`, what `pause` prints on a Chinese Windows (code page 936).
const PAUSE_GBK: &[u8] = b"\xc7\xeb\xb0\xb4\xc8\xce\xd2\xe2\xbc\xfc\xbc\xcc\xd0\xf8. . .";
#[test]
fn utf8_passes_through() {
assert_eq!(decode_with("héllo 中文".as_bytes(), &[936]), "héllo 中文");
}
#[test]
fn code_page_text_is_decoded() {
assert_eq!(decode_with(PAUSE_GBK, &[936, 936]), "请按任意键继续. . .");
// The first code page that decodes cleanly wins.
assert_eq!(decode_with(PAUSE_GBK, &[437, 936]), "请按任意键继续. . .");
assert_eq!(decode_with(b"caf\xe9", &[1252]), "café");
}
#[test]
fn undecodable_text_is_lossy_utf8() {
let out = decode_with(PAUSE_GBK, &[]);
assert!(out.contains('\u{FFFD}') && out.ends_with(". . ."), "{out}");
assert_eq!(decode_with(b"a\xffb", &[65001]), "a\u{FFFD}b");
}
#[tokio::test]
async fn read_lines_survives_non_utf8_lines() {
let mut input = b"first\r\n".to_vec();
input.extend_from_slice(b"\xff\xfe broken\n");
input.extend_from_slice(b"last");
let mut lines = Vec::new();
read_lines(&input[..], |l| lines.push(l)).await;
assert_eq!(lines.len(), 3);
assert_eq!(lines[0], "first");
assert!(lines[1].ends_with(" broken"));
assert_eq!(lines[2], "last");
}
}
+67
View File
@@ -44,6 +44,46 @@ mod framing_tests {
assert!(read_all(long.as_bytes()).await.is_err(), "header too long");
}
async fn read_with_noise(input: &[u8]) -> (std::io::Result<Vec<Vec<u8>>>, Vec<String>) {
let mut reader = BufReader::new(input);
let mut noise = Vec::new();
let mut out = Vec::new();
let result = loop {
match read_message_or_noise(&mut reader, &mut |l| noise.push(crate::output::decode(l))).await {
Ok(Some(msg)) => out.push(msg),
Ok(None) => break Ok(out),
Err(e) => break Err(e),
}
};
(result, noise)
}
#[tokio::test]
async fn output_between_messages_is_reported_and_skipped() {
let input = b"OpenJDK 64-Bit Server VM warning: Sharing is only supported\r\n\xc7\xeb\xb0\xb4 \xff\n\
Content-Length: 2\r\n\r\n{}Picked up JAVA_TOOL_OPTIONS: -Dx\nContent-Length: 4\r\n\r\nnull";
let (msgs, noise) = read_with_noise(input).await;
assert_eq!(msgs.unwrap(), vec![b"{}".to_vec(), b"null".to_vec()]);
assert_eq!(noise.len(), 3, "{noise:?}");
assert_eq!(noise[0], "OpenJDK 64-Bit Server VM warning: Sharing is only supported");
assert_eq!(noise[2], "Picked up JAVA_TOOL_OPTIONS: -Dx");
}
#[tokio::test]
async fn unterminated_output_then_eof_is_reported() {
// `pause` prints its prompt without a newline and the process is gone.
let (msgs, noise) = read_with_noise(b"Press any key to continue . . . ").await;
assert!(msgs.unwrap().is_empty());
assert_eq!(noise, ["Press any key to continue . . . "]);
}
#[tokio::test]
async fn garbage_inside_a_header_is_still_an_error() {
let (msgs, _) = read_with_noise(b"Content-Length: 2\r\nnot a header\r\n\r\n{}").await;
let e = msgs.unwrap_err();
assert!(e.to_string().contains("not a header"), "{e}");
}
#[tokio::test]
async fn handles_partial_reads() {
let (mut client, server) = tokio::io::duplex(4);
@@ -351,3 +391,30 @@ async fn kill_terminates_process() {
peer.kill();
tokio::time::timeout(Duration::from_secs(5), peer.closed()).await.unwrap();
}
/// A launcher that prints in the system code page and exits before speaking the
/// protocol: `jdtls.bat` on a Chinese Windows when its Python step fails, then
/// `pause` prints `请按任意键继续. . .` in GBK on stdout.
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn output_before_the_handshake_is_reported_not_dropped() {
let lines = Arc::new(Mutex::new(Vec::new()));
let sink = lines.clone();
let script = r"printf 'python: not found\n' >&2; printf '\307\353\260\264\310\316\322\342\274\374\274\314\320\370. . .\r\n'; exit 1";
let peer = Peer::spawn(
"sh",
&["-c".into(), script.into()],
&std::env::temp_dir(),
&[],
Arc::new(NoopHandler),
Box::new(move |l| sink.lock().push(l)),
)
.unwrap();
let init = peer.request::<_, Value>("initialize", json!({}), None).await;
assert!(matches!(init, Err(RpcError::Closed)), "{init:?}");
assert_eq!(tokio::time::timeout(Duration::from_secs(5), peer.closed()).await.unwrap(), Some(1));
// Everything the process wrote is in by the time the close is reported.
let lines = lines.lock().clone();
assert!(lines.iter().any(|l| l == "python: not found"), "{lines:?}");
assert!(lines.iter().any(|l| l.ends_with(". . .") && l.len() > 6), "{lines:?}");
}
+4
View File
@@ -406,6 +406,10 @@ impl LspManager {
}
Err(e) => {
tracing::warn!("{}: initialize failed: {e}", server.name);
if matches!(e, RpcError::Closed) {
// 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();
// Treated like a crash so transient failures retry with backoff.
self.spawn_supervisor(server, generation, peer);
+2 -7
View File
@@ -14,7 +14,7 @@ use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
use tokio::io::AsyncRead;
pub(crate) const PORT_PLACEHOLDER: &str = "${port}";
pub(crate) const ROOT_PLACEHOLDER: &str = "${root}";
@@ -157,12 +157,7 @@ pub(crate) fn connect_or_start(
}
fn pump_lines<R: AsyncRead + Unpin + Send + 'static>(stream: R, on_line: Arc<dyn Fn(String) + Send + Sync>) {
tokio::spawn(async move {
let mut lines = BufReader::new(stream).lines();
while let Ok(Some(line)) = lines.next_line().await {
on_line(line);
}
});
tokio::spawn(ide_jsonrpc::output::read_lines(stream, move |line| on_line(line)));
}
#[cfg(test)]
+67
View File
@@ -0,0 +1,67 @@
//! A stdio server that dies before the handshake: what it printed (on stderr, and
//! on stdout outside the protocol, in any encoding) must reach the crash message.
#![cfg(unix)]
use ide_core::event::ServerStatus;
use ide_core::{DocId, IdeEvent, LanguageId};
use ide_language::{LanguageConfig, LanguageRegistry, LanguageServerConfig};
use ide_lsp::{LspManager, ProcessLauncher};
use parking_lot::Mutex;
use std::sync::Arc;
use std::time::Duration;
/// Shaped like `jdtls.bat` on a Chinese Windows whose Python step fails: an error on
/// stderr, then `pause` prompting in GBK on stdout (`请按任意键继续. . .`), reading one
/// key from stdin, exiting with an error.
const LAUNCHER: &str = r#"#!/bin/sh
echo "'python' is not recognized as an internal or external command" >&2
printf '\307\353\260\264\310\316\322\342\274\374\274\314\320\370. . . '
head -c 1 >/dev/null
printf '\r\n'
exit 1
"#;
#[tokio::test(flavor = "multi_thread")]
async fn crash_before_the_handshake_reports_what_the_server_printed() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
let script = root.join("fake-jdtls");
std::fs::write(&script, LAUNCHER).unwrap();
std::fs::set_permissions(&script, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap();
let server: LanguageServerConfig =
toml::from_str(&format!("name = \"fake\"\ncommand = {:?}", script.to_string_lossy())).unwrap();
let lang: LanguageConfig = toml::from_str("id = \"java\"\nname = \"Java\"\nextensions = [\"java\"]").unwrap();
let lang = LanguageConfig { language_servers: vec![server], ..lang };
let registry = Arc::new(LanguageRegistry::from_configs(vec![lang]));
let events = Arc::new(Mutex::new(Vec::new()));
let sink = {
let events = events.clone();
Arc::new(move |e: IdeEvent| events.lock().push(e))
};
let mgr = LspManager::with_launcher(root.clone(), registry, sink, Arc::new(ProcessLauncher));
let path = root.join("A.java");
std::fs::write(&path, "class A {}\n").unwrap();
mgr.did_open(DocId::next(), &path, &LanguageId::new("java"), 1, "class A {}\n");
let crashed = || {
events.lock().iter().find_map(|e| match e {
IdeEvent::LanguageServer { status: ServerStatus::Crashed, message, .. } => message.clone(),
_ => None,
})
};
let mut message = None;
for _ in 0..1000 {
message = crashed();
if message.is_some() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let message = message.expect("the server is reported as crashed");
assert!(message.starts_with("fake exited with code 1:\n"), "{message}");
assert!(message.contains("'python' is not recognized"), "{message}");
// The stdout prompt, decoded (code page 936 on Windows, lossily elsewhere).
assert!(message.contains(". . ."), "{message}");
mgr.shutdown_all().await;
}
+358
View File
@@ -0,0 +1,358 @@
//! Starting jdtls on the JVM directly instead of through its launcher scripts.
//!
//! The release ships `bin/jdtls` (Python), `bin/jdtls.py` and, for Windows,
//! `bin/jdtls.bat`, which runs `python` and then `pause`. Each needs a Python
//! interpreter; without one the batch file prints `pause`'s prompt in the console
//! code page and exits. The command line the script builds is reproduced here, so
//! only a JDK is needed.
use crate::java::{Jdk, jdk_at};
use ide_language::LanguageServerConfig;
use std::path::{Path, PathBuf};
/// The parts of a jdtls install the JVM is started with.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct JdtlsInstall {
/// `plugins/org.eclipse.equinox.launcher_<version>.jar`.
pub launcher: PathBuf,
/// The shared, read-only OSGi configuration for this platform (`config_win`, `config_mac_arm`…).
pub config: PathBuf,
}
/// Names of the release's launcher scripts; anything else is the user's own wrapper.
const SCRIPTS: &[&str] = &["jdtls", "jdtls.bat", "jdtls.cmd", "jdtls.py"];
/// The directory with `plugins/` of the jdtls `executable` belongs to (`<home>/bin/jdtls`,
/// or Homebrew's `<prefix>/bin/jdtls` wrapper with the install under `libexec`).
pub fn jdtls_home(executable: &Path) -> Option<PathBuf> {
let exe = dunce::canonicalize(executable).ok()?;
let home = exe.parent()?.parent()?;
[home.to_path_buf(), home.join("libexec")].into_iter().find(|h| h.join("plugins").is_dir())
}
/// Shared configuration directories to try, best first, for `os` / `arch`
/// (`std::env::consts` values), as the release names them.
pub fn config_dir_names(os: &str, arch: &str) -> Vec<String> {
let base = match os {
"windows" => "config_win",
"macos" => "config_mac",
_ => "config_linux",
};
let mut names = Vec::new();
if arch == "aarch64" {
names.push(format!("{base}_arm"));
}
names.push(base.to_owned());
names
}
/// The launcher jar and configuration of the install at `home`, when it has the
/// release layout.
pub fn find_install(home: &Path, os: &str, arch: &str) -> Option<JdtlsInstall> {
let plugins = home.join("plugins");
let unversioned = plugins.join("org.eclipse.equinox.launcher.jar");
let launcher = if unversioned.is_file() {
unversioned
} else {
let mut jars: Vec<PathBuf> = std::fs::read_dir(&plugins)
.ok()?
.flatten()
.map(|e| e.path())
.filter(|p| {
p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
// Not the native fragments (`org.eclipse.equinox.launcher.win32.win32.x86_64_…`).
n.starts_with("org.eclipse.equinox.launcher_") && n.ends_with(".jar")
})
})
.collect();
jars.sort();
jars.pop()?
};
let config =
config_dir_names(os, arch).into_iter().map(|n| home.join(n)).find(|d| d.join("config.ini").is_file())?;
Some(JdtlsInstall { launcher, config })
}
/// Arguments meant for the launcher script, sorted out.
#[derive(Debug, Default, PartialEq, Eq)]
struct ScriptArgs {
jvm: Vec<String>,
java: Option<String>,
data: Option<String>,
rest: Vec<String>,
}
fn split_script_args(args: &[String]) -> ScriptArgs {
let mut out = ScriptArgs::default();
let mut it = args.iter();
while let Some(a) = it.next() {
if let Some(v) = a.strip_prefix("--jvm-arg=") {
out.jvm.push(v.to_owned());
} else if a == "--jvm-arg" {
out.jvm.extend(it.next().cloned());
} else if let Some(v) = a.strip_prefix("--java-executable=") {
out.java = Some(v.to_owned());
} else if a == "--java-executable" {
out.java = it.next().cloned();
} else if a == "-data" {
out.data = it.next().cloned();
} else if let Some(v) = a.strip_prefix("-data=") {
out.data = Some(v.to_owned());
} else if a != "--validate-java-version" && a != "--no-validate-java-version" {
out.rest.push(a.clone());
}
}
out
}
/// The JVM command line `bin/jdtls` builds: its system properties and module
/// options, the user's `--jvm-arg`s, then the Equinox launcher with `-data`.
pub fn java_args(install: &JdtlsInstall, java_major: u32, script_args: &[String]) -> Vec<String> {
let parsed = split_script_args(script_args);
let mut args: Vec<String> = Vec::new();
if java_major >= 24 {
args.extend(["-Djdk.xml.maxGeneralEntitySizeLimit=0".into(), "-Djdk.xml.totalEntitySizeLimit=0".into()]);
}
args.extend(
[
"-Declipse.application=org.eclipse.jdt.ls.core.id1",
"-Dosgi.bundles.defaultStartLevel=4",
"-Declipse.product=org.eclipse.jdt.ls.core.product",
"-Dosgi.checkConfiguration=true",
]
.map(String::from),
);
args.push(format!("-Dosgi.sharedConfiguration.area={}", install.config.to_string_lossy()));
args.extend(
[
"-Dosgi.sharedConfiguration.area.readOnly=true",
"-Dosgi.configuration.cascaded=true",
"-Xms1G",
"--add-modules=ALL-SYSTEM",
"--add-opens",
"java.base/java.util=ALL-UNNAMED",
"--add-opens",
"java.base/java.lang=ALL-UNNAMED",
]
.map(String::from),
);
args.extend(parsed.jvm);
args.extend(["-jar".to_owned(), install.launcher.to_string_lossy().into_owned()]);
if let Some(data) = parsed.data {
args.extend(["-data".to_owned(), data]);
}
args.extend(parsed.rest);
args
}
/// Rewrites a jdtls launch (`command` = the release's script, `JAVA_HOME` chosen by
/// [`crate::java::configure_jdtls`]) to start `java` directly. Left unchanged when the
/// command is someone's own wrapper, the install has another layout, or no JDK is set
/// (the launch then reports why).
pub fn launch_directly(cfg: &mut LanguageServerConfig) {
if cfg.name != "jdtls" || cfg.launch_error.is_some() {
return;
}
let search = cfg.env.get("PATH").map(std::ffi::OsString::from).or_else(|| std::env::var_os("PATH"));
let Some(script) = search.and_then(|s| ide_run::which::find_executable(&cfg.command, &s)) else { return };
let is_script = dunce::canonicalize(&script)
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_ascii_lowercase()))
.is_some_and(|n| SCRIPTS.contains(&n.as_str()));
if !is_script {
return;
}
let Some(install) =
jdtls_home(&script).and_then(|h| find_install(&h, std::env::consts::OS, std::env::consts::ARCH))
else {
return;
};
let own_java = split_script_args(&cfg.args).java.map(PathBuf::from);
let jdk: Option<(PathBuf, Jdk)> = match own_java {
Some(java) => java.parent().and_then(Path::parent).and_then(jdk_at).map(|jdk| (java, jdk)),
None => cfg.env.get("JAVA_HOME").and_then(|h| jdk_at(Path::new(h))).map(|jdk| {
let java = crate::java::java_executable(&jdk.home);
(java, jdk)
}),
};
let Some((java, jdk)) = jdk.filter(|(java, _)| java.is_file()) else { return };
cfg.args = java_args(&install, jdk.major, &cfg.args);
cfg.command = java.to_string_lossy().into_owned();
}
#[cfg(test)]
mod tests {
use super::*;
fn strings(args: &[&str]) -> Vec<String> {
args.iter().map(|s| s.to_string()).collect()
}
/// What [`crate::java::configure_jdtls`] and the settings pass to the script.
fn script_args(data: &str, agent: &str) -> Vec<String> {
strings(&[
"--jvm-arg=-Xmx2G",
"-data",
data,
"--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=false",
&format!("--jvm-arg=-javaagent:{agent}"),
])
}
#[test]
fn windows_command_line() {
// The reporter's layout: a managed install under %APPDATA%, JDK 25, a user name with a space and CJK.
let tools = r"C:\Users\张 三\AppData\Roaming\ai.l0ng.openidea\tools\jdtls";
let install = JdtlsInstall {
launcher: PathBuf::from(format!(r"{tools}\plugins\org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar")),
config: PathBuf::from(format!(r"{tools}\config_win")),
};
let data = r"C:\Users\张 三\AppData\Roaming\ai.l0ng.openidea\lsp\jdtls\0123456789abcdef";
let agent = r"C:\Users\张 三\AppData\Roaming\ai.l0ng.openidea\tools\lombok\bin\lombok.jar";
let args = java_args(&install, 25, &script_args(data, agent));
assert_eq!(
args,
strings(&[
"-Djdk.xml.maxGeneralEntitySizeLimit=0",
"-Djdk.xml.totalEntitySizeLimit=0",
"-Declipse.application=org.eclipse.jdt.ls.core.id1",
"-Dosgi.bundles.defaultStartLevel=4",
"-Declipse.product=org.eclipse.jdt.ls.core.product",
"-Dosgi.checkConfiguration=true",
&format!(r"-Dosgi.sharedConfiguration.area={tools}\config_win"),
"-Dosgi.sharedConfiguration.area.readOnly=true",
"-Dosgi.configuration.cascaded=true",
"-Xms1G",
"--add-modules=ALL-SYSTEM",
"--add-opens",
"java.base/java.util=ALL-UNNAMED",
"--add-opens",
"java.base/java.lang=ALL-UNNAMED",
"-Xmx2G",
"-Djava.import.generatesMetadataFilesAtProjectRoot=false",
&format!("-javaagent:{agent}"),
"-jar",
&format!(r"{tools}\plugins\org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar"),
"-data",
data,
])
);
}
#[test]
fn unix_command_line() {
let install = JdtlsInstall {
launcher: PathBuf::from(
"/Users/me/Library/Application Support/ai.l0ng.openidea/tools/jdtls/plugins/org.eclipse.equinox.launcher_1.7.0.jar",
),
config: PathBuf::from("/Users/me/Library/Application Support/ai.l0ng.openidea/tools/jdtls/config_mac_arm"),
};
let args = java_args(
&install,
21,
&strings(&["--jvm-arg", "-Dlog.level=ALL", "--no-validate-java-version", "-data", "/d", "--extra"]),
);
assert_eq!(args[0], "-Declipse.application=org.eclipse.jdt.ls.core.id1", "no JDK 24+ flags on 21");
assert!(args.contains(&"-Dosgi.sharedConfiguration.area=/Users/me/Library/Application Support/ai.l0ng.openidea/tools/jdtls/config_mac_arm".to_owned()));
assert_eq!(
args[args.len() - 7..],
strings(&[
"java.base/java.lang=ALL-UNNAMED",
"-Dlog.level=ALL",
"-jar",
"/Users/me/Library/Application Support/ai.l0ng.openidea/tools/jdtls/plugins/org.eclipse.equinox.launcher_1.7.0.jar",
"-data",
"/d",
"--extra",
])
);
assert!(!args.iter().any(|a| a.contains("validate-java-version")));
}
#[test]
fn config_dirs_per_platform() {
assert_eq!(config_dir_names("windows", "x86_64"), ["config_win"]);
assert_eq!(config_dir_names("windows", "aarch64"), ["config_win_arm", "config_win"]);
assert_eq!(config_dir_names("macos", "aarch64"), ["config_mac_arm", "config_mac"]);
assert_eq!(config_dir_names("linux", "x86_64"), ["config_linux"]);
}
#[test]
fn finds_the_launcher_and_configuration_of_a_release_layout() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
std::fs::create_dir_all(home.join("plugins")).unwrap();
for jar in [
"org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar",
"org.eclipse.equinox.launcher.win32.win32.x86_64_1.2.1300.v20250331-1702.jar",
"org.eclipse.jdt.ls.core_1.50.0.jar",
] {
std::fs::write(home.join("plugins").join(jar), "").unwrap();
}
assert_eq!(find_install(home, "windows", "x86_64"), None, "no configuration");
for config in ["config_win", "config_mac", "config_mac_arm"] {
std::fs::create_dir_all(home.join(config)).unwrap();
std::fs::write(home.join(config).join("config.ini"), "").unwrap();
}
let win = find_install(home, "windows", "x86_64").unwrap();
assert_eq!(win.launcher, home.join("plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar"));
assert_eq!(win.config, home.join("config_win"));
assert_eq!(find_install(home, "windows", "aarch64").unwrap().config, home.join("config_win"));
assert_eq!(find_install(home, "macos", "aarch64").unwrap().config, home.join("config_mac_arm"));
}
#[cfg(unix)]
#[test]
fn rewrites_the_release_script_to_java() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join("jdtls");
std::fs::create_dir_all(home.join("bin")).unwrap();
std::fs::create_dir_all(home.join("plugins")).unwrap();
std::fs::write(home.join("plugins/org.eclipse.equinox.launcher_1.7.0.jar"), "").unwrap();
for config in config_dir_names(std::env::consts::OS, std::env::consts::ARCH) {
std::fs::create_dir_all(home.join(&config)).unwrap();
std::fs::write(home.join(&config).join("config.ini"), "").unwrap();
}
let script = home.join("bin/jdtls");
std::fs::write(&script, "#!/usr/bin/env python3\n").unwrap();
std::fs::set_permissions(&script, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap();
let jdk = dir.path().join("jdk-25");
std::fs::create_dir_all(jdk.join("bin")).unwrap();
std::fs::write(jdk.join("release"), "JAVA_VERSION=\"25.0.1\"\n").unwrap();
std::fs::write(jdk.join("bin/java"), "").unwrap();
let cfg = |command: &str| -> LanguageServerConfig {
let mut cfg: LanguageServerConfig =
toml::from_str(&format!("name = \"jdtls\"\ncommand = {command:?}")).unwrap();
cfg.env.insert("PATH".into(), home.join("bin").to_string_lossy().into_owned());
cfg.env.insert("JAVA_HOME".into(), jdk.to_string_lossy().into_owned());
cfg.args = strings(&["-data", "/ws", "--jvm-arg=-Xmx1G"]);
cfg
};
let mut direct = cfg("jdtls");
launch_directly(&mut direct);
let java = dunce::canonicalize(&jdk).unwrap().join("bin/java");
assert_eq!(Path::new(&direct.command), java);
assert_eq!(direct.args[0], "-Djdk.xml.maxGeneralEntitySizeLimit=0");
assert!(direct.args.iter().any(|a| a == "-Xmx1G"));
let jar = direct.args.iter().position(|a| a == "-jar").unwrap();
assert!(direct.args[jar + 1].ends_with("org.eclipse.equinox.launcher_1.7.0.jar"));
assert_eq!(direct.args[jar + 2..], strings(&["-data", "/ws"]));
// Someone's own wrapper, or no usable JDK: the command is kept.
std::fs::write(home.join("bin/my-jdtls"), "#!/bin/sh\n").unwrap();
std::fs::set_permissions(home.join("bin/my-jdtls"), std::os::unix::fs::PermissionsExt::from_mode(0o755))
.unwrap();
let mut own = cfg("my-jdtls");
launch_directly(&mut own);
assert_eq!(own.command, "my-jdtls");
let mut no_jdk = cfg("jdtls");
no_jdk.env.remove("JAVA_HOME");
launch_directly(&mut no_jdk);
assert_eq!(no_jdk.command, "jdtls");
let mut failing = cfg("jdtls");
failing.launch_error = Some("needs JDK 25".into());
launch_directly(&mut failing);
assert_eq!(failing.command, "jdtls");
}
}
+1
View File
@@ -10,6 +10,7 @@ mod discover;
mod exec;
pub mod godot;
pub mod java;
pub mod jdtls;
pub mod libraries;
pub mod locate;
pub mod lombok;
+1
View File
@@ -293,6 +293,7 @@ impl AppState {
&bundles,
lombok.jar(),
);
ide_sdk::jdtls::launch_directly(cfg);
}
}));
}