дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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.
752 рядки
28 KiB
Rust
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");
|
|
}
|
|
}
|