Файли
openidea/crates/ide-lsp/src/server.rs
T
l0ng-ai 20c439baaa fix(lsp): pull diagnostics from servers that provide them (#1307)
rust-analyzer reports its own analysis (a missing field, a wrong type) only
to clients that pull diagnostics; it pushes just `cargo check`'s, so in the
editor a Rust type error showed up only after saving. The client now
advertises `textDocument/diagnostic` and pulls an open file's diagnostics
from servers with a `diagnosticProvider`: when it opens or its server
starts, after edits (once typing pauses), on save (and other open files
for servers with inter-file dependencies) and on
`workspace/diagnostic/refresh`. Pulled and pushed diagnostics are kept
apart and shown, and given to code actions, together; a newer pull
supersedes an older reply, a server-cancelled pull is retried, and closing
a file drops what was pulled.
2026-11-04 23:33:04 +08:00

752 рядки
28 KiB
Rust

//! One configured server for a `(name, root)` pair and the state of its current instance.
use crate::caps::ServerCaps;
use ide_core::event::ServerStatus;
use ide_core::{CancellationToken, IdeEvent, LanguageId, ServerId, SharedSink, TaskId};
use ide_jsonrpc::Peer;
use ide_language::LanguageServerConfig;
use lsp_types as lsp;
use parking_lot::{Mutex, RwLock};
use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::{Notify, watch};
/// An instance that stayed up this long ran fine: earlier crashes no longer count.
const STABLE_UPTIME: Duration = Duration::from_secs(180);
const MAX_RESTARTS: usize = 3;
const STDERR_LINES: usize = 50;
/// Diagnostics a server publishes for a transiently opened file shortly after
/// its `didClose` (analysis that was already running) are dropped too.
const TRANSIENT_GRACE: Duration = Duration::from_secs(3);
/// A live, initialized instance.
#[derive(Clone)]
pub struct Running {
pub generation: u64,
pub peer: Peer,
pub caps: Arc<RwLock<ServerCaps>>,
/// `workspace/didChangeWatchedFiles` registrations.
pub watchers: Arc<RwLock<crate::file_watch::FileWatchers>>,
}
pub enum Conn {
Idle,
Starting,
Ready(Running),
Stopping,
/// Binary not found; restart re-checks.
Missing,
/// Gave up (spawn failure or repeated crashes); restart re-checks.
Failed,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Phase {
Starting,
Ready,
Down,
}
struct State {
generation: u64,
conn: Conn,
}
/// A file opened on the server by the client itself (not by the user) for the
/// duration of a request, e.g. to widen a references search.
struct Transient {
/// Requests holding the file open.
holders: usize,
generation: u64,
/// The user opened the file meanwhile: its `didOpen` replaced ours.
taken: bool,
closed_at: Option<Instant>,
}
pub struct Progress {
pub task: TaskId,
pub title: String,
pub message: Option<String>,
/// Set while the server accepts `window/workDoneProgress/cancel` for it.
pub cancel: Option<ide_core::task::CancelHandle>,
}
pub struct Server {
pub id: ServerId,
pub name: String,
pub root: PathBuf,
/// Configuration from the language definition.
pub config: LanguageServerConfig,
/// `config` with the settings hook applied, as the current instance was launched.
effective: Mutex<Option<LanguageServerConfig>>,
pub resolved_binary: RwLock<Option<PathBuf>>,
languages: Mutex<Vec<LanguageId>>,
state: Mutex<State>,
/// The instance being initialized, from `initialized` until ready: registrations
/// sent right after `initialized` must not be lost.
initializing: Mutex<Option<Running>>,
/// Fired when the instance being started is abandoned (stop / restart while starting).
start_cancel: Mutex<CancellationToken>,
phase: watch::Sender<Phase>,
status: Mutex<(ServerStatus, Option<String>)>,
/// Consecutive crashes of short-lived instances.
crashes: Mutex<usize>,
/// When the current instance was launched.
started: Mutex<Option<Instant>>,
stderr: Mutex<VecDeque<String>>,
pub progress: Mutex<HashMap<String, Progress>>,
/// What the editor shows per path: the pushed and the pulled diagnostics together.
diagnostics: Mutex<HashMap<PathBuf, Vec<lsp::Diagnostic>>>,
/// `publishDiagnostics` per path.
pushed: Mutex<HashMap<PathBuf, Vec<lsp::Diagnostic>>>,
/// `textDocument/diagnostic` reports per path, and the latest pull asked for each.
pulled: Mutex<HashMap<PathBuf, Pulled>>,
pulls: Mutex<HashMap<PathBuf, u64>>,
/// When diagnostics were last published per path (empty lists included).
published: Mutex<HashMap<PathBuf, Instant>>,
/// Woken on every `publishDiagnostics` (Inspect Code waits on it).
pub published_notify: Notify,
/// Woken when the last `$/progress` task ends.
idle_notify: Notify,
transient: Mutex<HashMap<PathBuf, Transient>>,
pub java_build_lock: Arc<tokio::sync::Mutex<()>>,
pub java_autobuild_suspended: std::sync::atomic::AtomicBool,
pub external_project: tokio::sync::Mutex<crate::external_project::State>,
pub external_diagnostics: std::sync::atomic::AtomicBool,
pub external_refresh_queued: std::sync::atomic::AtomicBool,
}
impl Server {
pub fn new(config: LanguageServerConfig, root: PathBuf, language: LanguageId) -> Self {
Self {
id: ServerId::next(),
name: config.name.clone(),
root,
config,
effective: Mutex::new(None),
resolved_binary: RwLock::new(None),
languages: Mutex::new(vec![language]),
state: Mutex::new(State { generation: 0, conn: Conn::Idle }),
initializing: Mutex::new(None),
start_cancel: Mutex::new(CancellationToken::new()),
phase: watch::channel(Phase::Down).0,
status: Mutex::new((ServerStatus::Stopped, None)),
crashes: Mutex::new(0),
started: Mutex::new(None),
stderr: Mutex::new(VecDeque::new()),
progress: Mutex::new(HashMap::new()),
diagnostics: Mutex::new(HashMap::new()),
pushed: Mutex::new(HashMap::new()),
pulled: Mutex::new(HashMap::new()),
pulls: Mutex::new(HashMap::new()),
published: Mutex::new(HashMap::new()),
published_notify: Notify::new(),
idle_notify: Notify::new(),
transient: Mutex::new(HashMap::new()),
java_build_lock: Arc::new(tokio::sync::Mutex::new(())),
java_autobuild_suspended: std::sync::atomic::AtomicBool::new(false),
external_project: tokio::sync::Mutex::new(Default::default()),
external_diagnostics: std::sync::atomic::AtomicBool::new(false),
external_refresh_queued: std::sync::atomic::AtomicBool::new(false),
}
}
/// Configuration of the current (or last) instance; the base config before the first launch.
pub fn effective_config(&self) -> LanguageServerConfig {
let mut config = self.effective.lock().clone().unwrap_or_else(|| self.config.clone());
if self.java_autobuild_suspended.load(std::sync::atomic::Ordering::SeqCst) {
let mut settings = crate::handler::all_settings(&config);
settings["java"]["autobuild"]["enabled"] = serde_json::json!(false);
config.settings = Some(settings);
}
config
}
pub fn set_effective_config(&self, config: LanguageServerConfig) {
*self.effective.lock() = Some(config);
}
pub fn add_language(&self, language: &LanguageId) {
let mut langs = self.languages.lock();
if !langs.contains(language) {
langs.push(language.clone());
}
}
pub fn languages(&self) -> Vec<LanguageId> {
self.languages.lock().clone()
}
// ---- instance state machine ----
/// Idle → Starting; returns the new generation.
pub fn begin_start(&self) -> Option<u64> {
let mut st = self.state.lock();
if !matches!(st.conn, Conn::Idle) {
return None;
}
st.generation += 1;
st.conn = Conn::Starting;
*self.start_cancel.lock() = CancellationToken::new();
*self.started.lock() = Some(Instant::now());
self.phase.send_replace(Phase::Starting);
Some(st.generation)
}
/// Starting → Missing/Failed, publishing the status the failed start reports
/// before waiters wake: a request that wakes on `Phase::Down` reads the status
/// to say why it has no server, and must not still see `Starting`.
pub fn fail_start(
&self,
generation: u64,
conn: Conn,
sink: &SharedSink,
status: ServerStatus,
message: Option<String>,
) -> bool {
{
let mut st = self.state.lock();
if st.generation != generation || !matches!(st.conn, Conn::Starting) {
return false;
}
st.conn = conn;
}
self.set_status(sink, status, message);
self.announce_down();
true
}
/// Starting → Ready. False when the start was superseded or cancelled.
pub fn mark_ready(&self, generation: u64, run: Running) -> bool {
let mut st = self.state.lock();
if st.generation != generation || !matches!(st.conn, Conn::Starting) {
return false;
}
st.conn = Conn::Ready(run);
drop(st);
self.set_initializing(None);
true
}
/// Wakes requests waiting for the instance (after status and documents are in place).
pub fn announce_ready(&self, generation: u64) {
let st = self.state.lock();
if st.generation == generation && matches!(st.conn, Conn::Ready(_)) {
self.phase.send_replace(Phase::Ready);
}
}
/// Wakes requests waiting for a start that ended without an instance, after
/// its status is published.
pub fn announce_down(&self) {
self.phase.send_replace(Phase::Down);
}
pub fn running(&self) -> Option<Running> {
match &self.state.lock().conn {
Conn::Ready(r) => Some(r.clone()),
_ => None,
}
}
/// The instance told `initialized`, ready or not.
pub fn instance(&self) -> Option<Running> {
self.running().or_else(|| self.initializing.lock().clone())
}
/// Generation `generation` once it was told `initialized`, ready or not.
pub fn initialized_gen(&self, generation: u64) -> Option<Running> {
self.instance().filter(|r| r.generation == generation)
}
pub fn set_initializing(&self, run: Option<Running>) {
*self.initializing.lock() = run;
}
/// The running instance if it is still generation `generation`.
pub fn running_gen(&self, generation: u64) -> Option<Running> {
self.running().filter(|r| r.generation == generation)
}
/// Waits while the server is starting; returns the instance once ready.
pub async fn wait_ready(&self, timeout: Duration) -> Option<Running> {
let mut rx = self.phase.subscribe();
let _ = tokio::time::timeout(timeout, rx.wait_for(|p| *p != Phase::Starting)).await;
self.running()
}
/// Ready → Stopping, returning the instance to shut down. An in-flight
/// start is abandoned instead.
pub fn begin_stop(&self) -> Option<Running> {
let mut st = self.state.lock();
match std::mem::replace(&mut st.conn, Conn::Idle) {
Conn::Ready(run) => {
st.conn = Conn::Stopping;
self.phase.send_replace(Phase::Down);
Some(run)
}
Conn::Starting => {
st.generation += 1; // invalidates the in-flight start
self.start_cancel.lock().cancel();
self.phase.send_replace(Phase::Down);
None
}
other => {
st.conn = other;
None
}
}
}
/// Cancelled when the current start is abandoned.
pub fn start_token(&self) -> CancellationToken {
self.start_cancel.lock().clone()
}
pub fn end_stop(&self, generation: u64) {
let mut st = self.state.lock();
if st.generation == generation && matches!(st.conn, Conn::Stopping) {
st.conn = Conn::Idle;
}
}
/// Instance `generation`'s connection closed. `None` if superseded, else whether
/// the close was intentional. Waiters are woken by [`Self::announce_down`] once
/// the crash is reported, not here: they read the status to say why.
pub fn on_closed(&self, generation: u64) -> Option<bool> {
let mut st = self.state.lock();
if st.generation != generation {
return None;
}
match &st.conn {
Conn::Ready(_) | Conn::Starting => {
st.conn = Conn::Idle;
Some(false)
}
_ => Some(true),
}
}
pub fn is_idle_gen(&self, generation: u64) -> bool {
let st = self.state.lock();
st.generation == generation && matches!(st.conn, Conn::Idle)
}
pub fn give_up(&self, generation: u64) {
let mut st = self.state.lock();
if st.generation == generation && matches!(st.conn, Conn::Idle) {
st.conn = Conn::Failed;
}
}
/// Records a crash; returns the backoff before restarting, or `None` once
/// [`MAX_RESTARTS`] instances in a row died before running [`STABLE_UPTIME`]
/// (a server that crashes on every start, however slowly, is not restarted forever).
pub fn record_crash(&self) -> Option<Duration> {
let uptime = self.started.lock().map(|t| t.elapsed()).unwrap_or_default();
let mut crashes = self.crashes.lock();
if uptime >= STABLE_UPTIME {
*crashes = 0;
}
*crashes += 1;
let n = *crashes;
(n <= MAX_RESTARTS).then(|| Duration::from_millis(500) * (1 << (n - 1)) as u32)
}
pub fn reset_for_restart(&self) {
*self.crashes.lock() = 0;
let mut st = self.state.lock();
if matches!(st.conn, Conn::Missing | Conn::Failed) {
st.conn = Conn::Idle;
}
}
// ---- status ----
pub fn status(&self) -> ServerStatus {
self.status.lock().0
}
pub fn status_message(&self) -> Option<String> {
self.status.lock().1.clone()
}
pub fn set_status(&self, sink: &SharedSink, status: ServerStatus, message: Option<String>) {
{
let mut cur = self.status.lock();
if cur.0 == status && cur.1 == message {
return;
}
*cur = (status, message.clone());
}
sink.emit(IdeEvent::LanguageServer { server: self.id, name: self.name.clone(), status, message });
}
/// Ready ⇄ Busy depending on active `$/progress` tokens.
pub fn refresh_busy_status(&self, sink: &SharedSink) {
if self.running().is_none() {
return;
}
let busy = !self.progress.lock().is_empty();
self.set_status(sink, if busy { ServerStatus::Busy } else { ServerStatus::Ready }, None);
}
/// Whether the server reports work in progress (indexing, analysis).
pub fn is_busy(&self) -> bool {
!self.progress.lock().is_empty()
}
/// Called after a `$/progress` task ended.
pub fn progress_ended(&self) {
if !self.is_busy() {
self.idle_notify.notify_waiters();
}
}
/// Waits until no `$/progress` task is active; false on timeout or cancellation.
pub async fn wait_idle(&self, timeout: Duration, cancel: &CancellationToken) -> bool {
let deadline = tokio::time::Instant::now() + timeout;
loop {
let notified = self.idle_notify.notified();
if !self.is_busy() || self.running().is_none() {
return !self.is_busy();
}
tokio::select! {
() = notified => {}
() = tokio::time::sleep_until(deadline) => return !self.is_busy(),
() = cancel.cancelled() => return false,
}
}
}
// ---- transiently opened files ----
/// Registers a holder of the transient file `path` on instance `generation`;
/// true when the caller must send `didOpen` (first holder).
pub fn hold_transient(&self, path: &Path, generation: u64) -> bool {
let mut map = self.transient.lock();
map.retain(|_, t| t.holders > 0 || t.closed_at.is_some_and(|c| c.elapsed() < TRANSIENT_GRACE));
let t =
map.entry(path.to_owned()).or_insert(Transient { holders: 0, generation, taken: false, closed_at: None });
if t.holders == 0 || t.generation != generation {
*t = Transient { holders: 1, generation, taken: false, closed_at: None };
return true;
}
t.holders += 1;
false
}
/// Drops a holder of `path`; true when the caller must send `didClose`
/// (last holder, same instance, and the user did not open the file meanwhile).
pub fn release_transient(&self, path: &Path, generation: u64) -> bool {
let mut map = self.transient.lock();
let Some(t) = map.get_mut(path) else { return false };
if t.holders == 0 {
return false;
}
t.holders -= 1;
if t.holders > 0 {
return false;
}
let close = !t.taken && t.generation == generation;
if close {
t.closed_at = Some(Instant::now());
} else {
map.remove(path);
}
close
}
/// The user opens `path` on instance `generation`: true when a transient
/// copy is open there and must be closed before the user's `didOpen`.
pub fn take_transient(&self, path: &Path, generation: u64) -> bool {
let mut map = self.transient.lock();
let Some(t) = map.get_mut(path) else { return false };
let open = t.holders > 0 && !t.taken && t.generation == generation;
if t.holders > 0 {
t.taken = true;
} else {
map.remove(path);
}
open
}
/// Whether diagnostics published for `path` belong to a transient copy
/// and must not reach the Problems view.
pub fn suppresses_diagnostics(&self, path: &Path) -> bool {
self.transient
.lock()
.get(path)
.is_some_and(|t| !t.taken && (t.holders > 0 || t.closed_at.is_some_and(|c| c.elapsed() < TRANSIENT_GRACE)))
}
// ---- bookkeeping ----
pub fn push_stderr(&self, line: String) {
let mut buf = self.stderr.lock();
if buf.len() == STDERR_LINES {
buf.pop_front();
}
buf.push_back(line);
}
pub fn stderr_tail(&self, n: usize) -> String {
let buf = self.stderr.lock();
let lines: Vec<&str> = buf.iter().map(String::as_str).collect();
crash_excerpt(&lines, n)
}
pub fn take_progress(&self) -> Vec<(TaskId, String)> {
self.progress.lock().drain().map(|(_, p)| (p.task, p.title)).collect()
}
/// Records the diagnostics the server pushed for `path`; returns what the file now shows.
pub fn set_diagnostics(&self, path: PathBuf, items: Vec<lsp::Diagnostic>) -> Vec<lsp::Diagnostic> {
set_or_remove(&mut self.pushed.lock(), path.clone(), items);
self.combine(path)
}
/// Records a pulled report for `path` (`None` items: unchanged since `result_id`'s
/// predecessor); returns what the file now shows.
pub fn set_pulled(
&self,
path: PathBuf,
result_id: Option<String>,
items: Option<Vec<lsp::Diagnostic>>,
) -> Vec<lsp::Diagnostic> {
{
let mut pulled = self.pulled.lock();
let entry = pulled.entry(path.clone()).or_default();
entry.result_id = result_id;
if let Some(items) = items {
entry.items = items;
}
}
self.combine(path)
}
/// Forgets the pulled report of a closed file; `None` when there was none.
pub fn clear_pulled(&self, path: &Path) -> Option<Vec<lsp::Diagnostic>> {
self.pulls.lock().remove(path);
self.pulled.lock().remove(path)?;
Some(self.combine(path.to_owned()))
}
/// The `resultId` of the last report pulled for `path`.
pub fn pulled_result_id(&self, path: &Path) -> Option<String> {
self.pulled.lock().get(path).and_then(|p| p.result_id.clone())
}
/// Starts a pull for `path`, superseding earlier ones; see [`Server::is_latest_pull`].
pub fn next_pull(&self, path: &Path) -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
self.pulls.lock().insert(path.to_owned(), id);
id
}
pub fn is_latest_pull(&self, path: &Path, id: u64) -> bool {
self.pulls.lock().get(path) == Some(&id)
}
fn combine(&self, path: PathBuf) -> Vec<lsp::Diagnostic> {
let mut items = self.pushed.lock().get(&path).cloned().unwrap_or_default();
if let Some(pulled) = self.pulled.lock().get(&path) {
for d in &pulled.items {
if !items.contains(d) {
items.push(d.clone());
}
}
}
{
let mut d = self.diagnostics.lock();
self.published.lock().insert(path.clone(), Instant::now());
set_or_remove(&mut d, path, items.clone());
}
self.published_notify.notify_waiters();
items
}
/// When diagnostics for `path` were last published by this server.
pub fn last_published(&self, path: &Path) -> Option<Instant> {
self.published.lock().get(path).copied()
}
pub fn diagnostics_for(&self, path: &Path) -> Vec<lsp::Diagnostic> {
self.diagnostics.lock().get(path).cloned().unwrap_or_default()
}
/// Every published diagnostic, by file.
pub fn diagnostics_snapshot(&self) -> Vec<(PathBuf, lsp::Diagnostic)> {
let d = self.diagnostics.lock();
d.iter().flat_map(|(p, items)| items.iter().map(move |i| (p.clone(), i.clone()))).collect()
}
/// The current diagnostics of each file that has some.
pub fn diagnostics_by_path(&self) -> Vec<(PathBuf, Vec<lsp::Diagnostic>)> {
self.diagnostics.lock().iter().map(|(p, d)| (p.clone(), d.clone())).collect()
}
pub fn take_diagnostic_paths(&self) -> Vec<PathBuf> {
self.published.lock().clear();
self.pushed.lock().clear();
self.pulled.lock().clear();
self.pulls.lock().clear();
self.diagnostics.lock().drain().map(|(p, _)| p).collect()
}
}
/// A file's last `textDocument/diagnostic` report.
#[derive(Default)]
struct Pulled {
result_id: Option<String>,
items: Vec<lsp::Diagnostic>,
}
fn set_or_remove(map: &mut HashMap<PathBuf, Vec<lsp::Diagnostic>>, path: PathBuf, items: Vec<lsp::Diagnostic>) {
if items.is_empty() {
map.remove(&path);
} else {
map.insert(path, items);
}
}
/// The last `n` stderr lines, led by the exception headline: Node.js / Python /
/// JVM crashes print the error first and a stack after it, so a plain tail
/// shows frames without saying what went wrong.
pub(crate) fn crash_excerpt(lines: &[&str], n: usize) -> String {
const MAX_LINES: usize = 12;
let start = lines.len().saturating_sub(n);
let excerpt: Vec<&str> = match lines.iter().rposition(|l| is_error_headline(l)) {
Some(i) if i >= start => lines[start..].to_vec(),
Some(i) if lines.len() - i <= MAX_LINES => lines[i..].to_vec(),
Some(i) => [lines[i], "…"].into_iter().chain(lines[start..].iter().copied()).collect(),
None => lines[start..].to_vec(),
};
excerpt.join("\n")
}
fn is_error_headline(line: &str) -> bool {
let l = line.trim_start();
let head = l.split(':').next().unwrap_or_default();
let named = (head.ends_with("Error") || head.ends_with("Exception")) && !head.contains(' ') && head.len() < l.len();
named || l.starts_with("Exception in thread") || l.starts_with("thread '") && l.contains("panicked")
}
#[cfg(test)]
mod tests {
use super::*;
fn server() -> Server {
let cfg: LanguageServerConfig = toml::from_str("name = \"s\"\ncommand = \"s\"").unwrap();
Server::new(cfg, PathBuf::from("/p"), LanguageId::new("vue"))
}
#[test]
fn crashes_of_short_lived_instances_exhaust_the_budget_whatever_the_spacing() {
let s = server();
for _ in 0..MAX_RESTARTS {
// Even crashes spread minutes apart count while each instance dies young.
*s.started.lock() = Some(Instant::now());
assert!(s.record_crash().is_some());
}
*s.started.lock() = Some(Instant::now());
assert_eq!(s.record_crash(), None);
s.reset_for_restart();
assert!(s.record_crash().is_some());
}
#[test]
fn a_stable_run_resets_the_budget() {
let s = server();
for _ in 0..MAX_RESTARTS {
assert!(s.record_crash().is_some());
}
*s.started.lock() = Instant::now().checked_sub(STABLE_UPTIME);
assert_eq!(s.record_crash(), Some(Duration::from_millis(500)));
}
#[test]
fn external_build_suspends_only_the_effective_autobuild_setting() {
let mut config: LanguageServerConfig = toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap();
config.settings = Some(serde_json::json!({"java": {"autobuild": {"enabled": true}, "other": "kept"}}));
let server = Server::new(config.clone(), PathBuf::from("/p"), LanguageId::new("java"));
server.java_autobuild_suspended.store(true, std::sync::atomic::Ordering::SeqCst);
let settings = crate::handler::all_settings(&server.effective_config());
assert_eq!(settings["java"]["autobuild"]["enabled"], false);
assert_eq!(settings["java"]["other"], "kept");
server.java_autobuild_suspended.store(false, std::sync::atomic::Ordering::SeqCst);
assert_eq!(server.effective_config(), config);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_failed_start_reports_its_status_before_waiters_wake() {
// The race behind "jdtls is not ready (Starting)": a request woken by the
// phase change reads the status to say why it has no server, so the status
// must already be the failure's.
let s = Arc::new(server());
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
let generation = s.begin_start().unwrap();
let waiter = {
let s = s.clone();
tokio::spawn(async move {
let run = s.wait_ready(Duration::from_secs(30)).await;
(run.is_none(), s.status())
})
};
tokio::task::yield_now().await;
assert!(s.fail_start(generation, Conn::Failed, &sink, ServerStatus::Crashed, Some("boom".into())));
assert_eq!(waiter.await.unwrap(), (true, ServerStatus::Crashed));
}
#[test]
fn a_superseded_start_is_not_announced() {
let s = server();
let generation = s.begin_start().unwrap();
s.begin_stop(); // abandons the start and invalidates its generation
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
assert!(!s.fail_start(generation, Conn::Failed, &sink, ServerStatus::Crashed, Some("boom".into())));
assert_eq!(s.status(), ServerStatus::Stopped);
}
#[test]
fn crash_excerpt_keeps_the_exception_line() {
// Node.js 24 crash of vue-language-server (issue #25), verbatim shape.
let node = [
"C:\\tools\\node_modules\\@vue\\language-server\\lib\\server.js:40",
" projectInfoPromise = sendTsServerRequest('_vue:' + ts.server.protocol.CommandTypes.ProjectInfo, {",
" ^",
"",
"TypeError: Cannot read properties of undefined (reading 'protocol')",
" at Object.getLanguageService (C:\\tools\\node_modules\\@vue\\language-server\\lib\\server.js:40:86)",
" at Immediate.<anonymous> (C:\\tools\\node_modules\\@volar\\language-server\\lib\\features\\languageFeatures.js:744:62)",
" at process.processImmediate (node:internal/timers:504:21)",
"",
"Node.js v24.12.0",
];
let out = crash_excerpt(&node, 5);
assert!(out.starts_with("TypeError: Cannot read properties of undefined (reading 'protocol')"), "{out}");
assert!(out.ends_with("Node.js v24.12.0"));
let python = [
"Traceback (most recent call last):",
" File \"x.py\", line 1",
"ModuleNotFoundError: No module named 'jedi'",
];
assert_eq!(crash_excerpt(&python, 5), python.join("\n"));
let mut long_stack = vec!["Exception in thread \"main\" java.lang.IllegalStateException: boom"];
long_stack.extend(std::iter::repeat_n("\tat a.b.C.d(C.java:1)", 30));
let out = crash_excerpt(&long_stack, 5);
assert!(out.starts_with("Exception in thread \"main\""), "{out}");
assert_eq!(out.lines().count(), 7);
let plain = ["starting", "listening", "bye"];
assert_eq!(crash_excerpt(&plain, 2), "listening\nbye");
// "Error" in prose is not a headline.
assert_eq!(crash_excerpt(&["No Error here: fine", "a", "b"], 2), "a\nb");
}
}