Файли
openidea/crates/ide-plugin/src/actor.rs
T
Arnaud Ritti 1446d5592c feat(plugins): refresh diagnostics while typing (#2048)
Closes #1952

API 1.3 adds a diagnostics provider for unsaved documents: the host sends versioned text for the declared languages after a 300 ms debounce, cancels superseded calls, drops late or stale answers under the editor-provider budget, and clears results with the document, project or plugin. Ranges are UTF-16. Actor panics are contained, queued work is rejected after a host panic, and diagnostics arriving after plugin cleanup are discarded. Verified on macOS with the diagnostics fixture.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 04:14:27 +08:00

381 рядки
15 KiB
Rust

//! One thread per active plugin owning its [`Runtime`].
//!
//! Callers queue jobs and wait on a reply channel instead of holding a lock
//! across the call, so an edit-path caller can stop waiting at its soft
//! deadline while the call runs on (and is interrupted at the hard deadline
//! by the store's epoch callback), and the call can be cancelled from any
//! thread. A trap poisons the instance: the actor drops it and ends; the
//! host's supervisor decides when to start a new one.
//!
//! wasmtime's async API would let one thread drive every instance, but needs
//! async host bindings throughout; with a plugin's instance only ever entered
//! by one job at a time a parked thread per active plugin costs little, and
//! idle reclaim ends it.
use crate::runtime::{CallCancel, CallError, Runtime};
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
type Job = Box<dyn FnOnce(&mut Option<Runtime>) + Send>;
fn catch_call<R>(f: impl FnOnce() -> Result<R, CallError>) -> Result<R, CallError> {
catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|panic| {
let message = panic
.downcast_ref::<String>()
.map(String::as_str)
.or_else(|| panic.downcast_ref::<&str>().copied())
.unwrap_or("unknown panic");
Err(CallError::Trap(format!("plugin actor panicked: {message}")))
})
}
/// Why an [`Actor::call`] produced no result.
#[derive(Debug)]
pub enum ActorError {
/// The plugin's own outcome: an error it returned, or a trap (the actor has ended).
Call(CallError),
/// The caller's wait ran out; the result, if any, is dropped.
SoftTimeout,
/// The actor ended (an earlier call trapped, or it was stopped) before running the call.
Stopped,
/// The caller cancelled the call (`call_cancellable`).
Cancelled,
}
/// A call queued with [`Actor::submit`].
pub struct Pending<R> {
/// The outcome and when the plugin's thread produced it.
result: mpsc::Receiver<(Result<R, CallError>, Instant)>,
give_up: Option<Instant>,
alive: Arc<AtomicBool>,
}
impl<R> Pending<R> {
/// Waits for the outcome, until the call's soft deadline if it has one. A
/// result produced after that deadline is dropped even when it is already
/// there (the caller was not scheduled in time, or waited on another call
/// first), so the deadline holds however busy the machine is; a failure is
/// still reported.
pub fn wait(self) -> Result<R, ActorError> {
let outcome = match self.give_up {
Some(t) => match self.result.recv_timeout(t.saturating_duration_since(Instant::now())) {
Ok((Ok(_), done)) if done > t => return Err(ActorError::SoftTimeout),
Ok((r, _)) => r,
Err(mpsc::RecvTimeoutError::Timeout) => return Err(ActorError::SoftTimeout),
Err(mpsc::RecvTimeoutError::Disconnected) => {
// Skipped as too late, or the actor ended.
let alive = self.alive.load(Ordering::Acquire);
return Err(if alive { ActorError::SoftTimeout } else { ActorError::Stopped });
}
},
None => self.result.recv().map_err(|_| ActorError::Stopped)?.0,
};
outcome.map_err(ActorError::Call)
}
}
pub struct Actor {
tx: Mutex<Option<mpsc::Sender<Job>>>,
thread: Mutex<Option<JoinHandle<()>>>,
cancel: Arc<CallCancel>,
alive: Arc<AtomicBool>,
/// The trap that ended the actor, until someone takes it to report it.
crash: Arc<Mutex<Option<String>>>,
busy: Arc<AtomicBool>,
/// Milliseconds since `epoch` at the last call's end.
last_used: Arc<AtomicU64>,
epoch: Instant,
}
impl Actor {
/// Moves an activated runtime onto its own thread.
pub fn spawn(name: &str, runtime: Runtime) -> std::io::Result<Self> {
let (tx, rx) = mpsc::channel::<Job>();
let cancel = runtime.cancel_handle();
let alive = Arc::new(AtomicBool::new(true));
let busy = Arc::new(AtomicBool::new(false));
let crash = Arc::new(Mutex::new(None));
let last_used = Arc::new(AtomicU64::new(0));
let epoch = Instant::now();
let thread = {
let (alive, busy, last_used, crash) = (alive.clone(), busy.clone(), last_used.clone(), crash.clone());
std::thread::Builder::new().name(format!("plugin:{name}")).spawn(move || {
let mut slot = Some(runtime);
while let Ok(job) = rx.recv() {
busy.store(true, Ordering::Release);
// A completion callback may panic too; never leave a dead thread marked alive.
if let Err(CallError::Trap(error)) = catch_call(|| {
job(&mut slot);
Ok(())
}) {
*crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(error);
alive.store(false, Ordering::Release);
slot = None;
}
last_used.store(epoch.elapsed().as_millis() as u64, Ordering::Release);
busy.store(false, Ordering::Release);
if slot.is_none() {
break;
}
}
alive.store(false, Ordering::Release);
// Queued jobs are dropped with `rx`: their callers see `Stopped`.
drop(rx);
if let Some(mut rt) = slot {
rt.deactivate();
}
})?
};
Ok(Self {
tx: Mutex::new(Some(tx)),
thread: Mutex::new(Some(thread)),
cancel,
alive,
crash,
busy,
last_used,
epoch,
})
}
/// Runs `f` on the plugin's thread. With `wait`, gives up after that long
/// (counting the time queued behind other calls) and the call, if it has
/// not started yet, is skipped. Without, waits for the outcome: the
/// runtime's own deadline bounds it.
pub fn call<R: Send + 'static>(
&self,
wait: Option<Duration>,
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
) -> Result<R, ActorError> {
self.submit(wait, f)?.wait()
}
/// Queues `f` like [`call`](Self::call) and returns at once, so a caller
/// can start calls on several plugins and then wait for all of them: the
/// wait is the slowest one's, not the sum.
pub fn submit<R: Send + 'static>(
&self,
wait: Option<Duration>,
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
) -> Result<Pending<R>, ActorError> {
let give_up = wait.map(|w| Instant::now() + w);
let (reply, result) = mpsc::sync_channel(1);
let (crash, alive) = (self.crash.clone(), self.alive.clone());
let job: Job = Box::new(move |slot| {
if give_up.is_some_and(|t| Instant::now() >= t) {
return;
}
let Some(rt) = slot.as_mut() else { return };
let outcome = catch_call(|| f(rt));
if let Err(CallError::Trap(e)) = &outcome {
*crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone());
// Before the reply: the caller sees the actor dead.
alive.store(false, Ordering::Release);
*slot = None;
}
let _ = reply.send((outcome, Instant::now()));
});
{
let tx = self.tx.lock().unwrap_or_else(|e| e.into_inner());
let Some(tx) = tx.as_ref() else { return Err(ActorError::Stopped) };
tx.send(job).map_err(|_| ActorError::Stopped)?;
}
Ok(Pending { result, give_up, alive: self.alive.clone() })
}
/// Like [`call`](Self::call) without a soft deadline, but `cancel` (the
/// caller's request was superseded) cancels the call cooperatively, and
/// interrupts it if it has not returned `grace` later. Returns
/// [`ActorError::Cancelled`] then.
pub fn call_cancellable<R: Send + 'static>(
&self,
cancel: &ide_core::CancellationToken,
grace: Duration,
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
) -> Result<R, ActorError> {
self.call_cancellable_before(cancel, grace, None, f)
}
/// A cancellable editor call: queued work expires after `soft`, and a
/// successful answer produced later is dropped. Running work still has
/// its runtime hard deadline and can be cancelled by further typing.
pub fn call_edit_cancellable<R: Send + 'static>(
&self,
cancel: &ide_core::CancellationToken,
grace: Duration,
soft: Duration,
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
) -> Result<R, ActorError> {
self.call_cancellable_before(cancel, grace, Some(Instant::now() + soft), f)
}
fn call_cancellable_before<R: Send + 'static>(
&self,
cancel: &ide_core::CancellationToken,
grace: Duration,
deadline: Option<Instant>,
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
) -> Result<R, ActorError> {
let (reply, result) = mpsc::sync_channel(1);
let (crash, alive) = (self.crash.clone(), self.alive.clone());
let skip = cancel.clone();
let running = Arc::new(Mutex::new(false));
let in_job = running.clone();
let job: Job = Box::new(move |slot| {
let Some(rt) = slot.as_mut() else { return };
{
let mut running = in_job.lock().unwrap_or_else(|e| e.into_inner());
if skip.is_cancelled() {
return;
}
if deadline.is_some_and(|t| Instant::now() >= t) {
let _ = reply.send(Err(ActorError::SoftTimeout));
return;
}
*running = true;
}
let outcome = catch_call(|| f(rt));
*in_job.lock().unwrap_or_else(|e| e.into_inner()) = false;
if let Err(CallError::Trap(e)) = &outcome {
*crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone());
alive.store(false, Ordering::Release);
*slot = None;
}
let outcome = match outcome {
Ok(_) if deadline.is_some_and(|t| Instant::now() > t) => Err(ActorError::SoftTimeout),
other => other.map_err(ActorError::Call),
};
let _ = reply.send(outcome);
});
{
let tx = self.tx.lock().unwrap_or_else(|e| e.into_inner());
let Some(tx) = tx.as_ref() else { return Err(ActorError::Stopped) };
tx.send(job).map_err(|_| ActorError::Stopped)?;
}
let mut cancelled_at: Option<Instant> = None;
loop {
match result.recv_timeout(Duration::from_millis(5)) {
Ok(outcome) if cancelled_at.is_some() => {
drop(outcome);
return Err(ActorError::Cancelled);
}
Ok(outcome) => return outcome,
Err(mpsc::RecvTimeoutError::Disconnected) => {
return Err(if cancel.is_cancelled() { ActorError::Cancelled } else { ActorError::Stopped });
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
if !cancel.is_cancelled() {
if deadline.is_some_and(|t| Instant::now() >= t) && !*running.lock().unwrap_or_else(|e| e.into_inner())
{
return Err(ActorError::SoftTimeout);
}
continue;
}
match cancelled_at {
None => {
cancelled_at = Some(Instant::now());
let running = running.lock().unwrap_or_else(|e| e.into_inner());
if !*running {
return Err(ActorError::Cancelled);
}
self.cancel.cancel_soft();
}
Some(at) if at.elapsed() >= grace => {
// Did not return in time: interrupt it (the instance is rebuilt).
let running = running.lock().unwrap_or_else(|e| e.into_inner());
if *running {
self.cancel();
}
return Err(ActorError::Cancelled);
}
Some(_) => {}
}
}
}
/// Queues `f` without waiting; `done` gets the outcome on the actor's
/// thread (`Stopped` if the actor ended before running it).
pub fn post<R: Send + 'static>(
&self,
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
done: impl FnOnce(Result<R, ActorError>) + Send + 'static,
) {
let (crash, alive) = (self.crash.clone(), self.alive.clone());
let job: Job = Box::new(move |slot| {
let Some(rt) = slot.as_mut() else { return done(Err(ActorError::Stopped)) };
let outcome = catch_call(|| f(rt));
if let Err(CallError::Trap(e)) = &outcome {
*crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone());
alive.store(false, Ordering::Release);
*slot = None;
}
done(outcome.map_err(ActorError::Call));
});
let tx = self.tx.lock().unwrap_or_else(|e| e.into_inner());
// A job that can't be queued is dropped, and with it `done` (unrun):
// the caller sees the actor dead on its next look.
if let Some(tx) = tx.as_ref() {
let _ = tx.send(job);
}
}
/// Cooperatively cancels the running call (see [`call_cancellable`](Self::call_cancellable)).
pub fn cancel_soft(&self) {
if self.is_busy() {
self.cancel.cancel_soft();
}
}
/// Interrupts the running call (it fails as a trap: the instance is dropped).
pub fn cancel(&self) {
if self.busy.load(Ordering::Acquire) {
self.cancel.cancel();
}
}
/// The trap that ended this actor; `Some` only for the first caller, which
/// reports it (also when the caller that saw it had stopped waiting).
pub fn take_crash(&self) -> Option<String> {
self.crash.lock().unwrap_or_else(|e| e.into_inner()).take()
}
/// The runtime is still usable (no call trapped, not stopped).
pub fn is_alive(&self) -> bool {
self.alive.load(Ordering::Acquire)
}
pub fn is_busy(&self) -> bool {
self.busy.load(Ordering::Acquire)
}
/// Time since the last call ended (or since start).
pub fn idle_for(&self) -> Duration {
let last = Duration::from_millis(self.last_used.load(Ordering::Acquire));
self.epoch.elapsed().saturating_sub(last)
}
/// Deactivates the plugin and ends the thread after the queued calls.
/// With `join`, waits for that (bounded by the runtime's deadlines).
pub fn stop(&self, join: bool) {
self.tx.lock().unwrap_or_else(|e| e.into_inner()).take();
if join {
let thread = self.thread.lock().unwrap_or_else(|e| e.into_inner()).take();
if let Some(t) = thread
&& t.thread().id() != std::thread::current().id()
{
let _ = t.join();
}
}
}
}
impl Drop for Actor {
fn drop(&mut self) {
self.stop(false);
}
}