дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
Background tasks often couldn't be cancelled, or cancelling didn't stop them: - A cancel that arrived before the backend registered the request was dropped (build while saving files, multi-repository fetch/pull between repositories). The host now remembers early cancels (bounded, 2 min), and request ids start at a random value per page. - There was no queue and no common cancel path: backend-reported tasks (LSP progress, deployment transfers) couldn't be cancelled, and tool installs, VCS operations and update checks had no cancel button in Background Tasks. - Only the direct child process was killed, and JDK / tool archives were extracted without checking for cancellation. Tool installs and JDK downloads now share a downloads queue; cancelling a queued task removes it without starting any work. Every task cancels through one path that shows "Cancelling…" and then removes it; tasks that can't be interrupted show a disabled cancel button with the reason. IdeEvent::Task carries `cancellable` with a cancel_task command, cancellable LSP progress sends window/workDoneProgress/cancel, installers, curl and tar run in their own process group and are killed as a group, and extraction checks for cancellation between entries. Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
498 рядки
17 KiB
Rust
498 рядки
17 KiB
Rust
//! The background indexing thread: initial load + full scan, then incremental
|
|
//! updates queued by the watcher.
|
|
|
|
use crate::data::{FileEntry, FileRecord, IndexData, StoredSymbol};
|
|
use crate::filter::{Found, PathFilter, mtime_of};
|
|
use crate::store::Store;
|
|
use ide_core::{IdeEvent, SharedSink, TaskId};
|
|
use ide_language::{LanguageRegistry, Syntax};
|
|
use parking_lot::{Condvar, Mutex, RwLock};
|
|
use rayon::prelude::*;
|
|
use std::collections::{HashSet, VecDeque};
|
|
use std::io::Read;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// Files larger than this are listed but not parsed for symbols.
|
|
pub const MAX_SYMBOL_FILE: u64 = 2 * 1024 * 1024;
|
|
const CHUNK: usize = 512;
|
|
const PROGRESS_EVERY: Duration = Duration::from_millis(250);
|
|
pub(crate) const MAX_RECENT: usize = 50;
|
|
|
|
pub(crate) enum Job {
|
|
Changed(Vec<PathBuf>),
|
|
Removed(Vec<PathBuf>),
|
|
SaveRecent,
|
|
/// The roots or excludes changed: scan again (unchanged files are kept).
|
|
Rescan,
|
|
/// File types changed: re-parse the indexed files whose language under the
|
|
/// previous registry differs from the current one.
|
|
Reparse(std::sync::Arc<LanguageRegistry>),
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct Queue {
|
|
jobs: VecDeque<Job>,
|
|
busy: bool,
|
|
started: bool,
|
|
}
|
|
|
|
pub(crate) struct Shared {
|
|
filter: RwLock<std::sync::Arc<PathFilter>>,
|
|
pub languages: std::sync::Arc<LanguageRegistry>,
|
|
pub syntax: std::sync::Arc<Syntax>,
|
|
pub sink: SharedSink,
|
|
pub data: RwLock<IndexData>,
|
|
/// Most recent first.
|
|
pub recent: Mutex<Vec<PathBuf>>,
|
|
pub store: Option<Store>,
|
|
pub indexing: AtomicBool,
|
|
pub cancelled: AtomicBool,
|
|
/// Files whose content was (re)read and parsed; observability for tests.
|
|
pub parsed: std::sync::atomic::AtomicUsize,
|
|
queue: Mutex<Queue>,
|
|
wake: Condvar,
|
|
idle: Condvar,
|
|
}
|
|
|
|
impl Shared {
|
|
pub(crate) fn new(
|
|
filter: PathFilter,
|
|
languages: std::sync::Arc<LanguageRegistry>,
|
|
syntax: std::sync::Arc<Syntax>,
|
|
sink: SharedSink,
|
|
store: Option<Store>,
|
|
) -> Self {
|
|
Self {
|
|
filter: RwLock::new(std::sync::Arc::new(filter)),
|
|
languages,
|
|
syntax,
|
|
sink,
|
|
data: RwLock::default(),
|
|
recent: Mutex::default(),
|
|
store,
|
|
indexing: AtomicBool::new(true),
|
|
cancelled: AtomicBool::new(false),
|
|
parsed: Default::default(),
|
|
queue: Mutex::default(),
|
|
wake: Condvar::new(),
|
|
idle: Condvar::new(),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn filter(&self) -> std::sync::Arc<PathFilter> {
|
|
self.filter.read().clone()
|
|
}
|
|
|
|
pub(crate) fn set_filter(&self, filter: PathFilter) {
|
|
*self.filter.write() = std::sync::Arc::new(filter);
|
|
}
|
|
|
|
pub(crate) fn enqueue(&self, job: Job) {
|
|
self.queue.lock().jobs.push_back(job);
|
|
self.wake.notify_one();
|
|
}
|
|
|
|
pub(crate) fn cancel(&self) {
|
|
self.cancelled.store(true, Ordering::SeqCst);
|
|
let _q = self.queue.lock();
|
|
self.wake.notify_all();
|
|
self.idle.notify_all();
|
|
}
|
|
|
|
fn is_cancelled(&self) -> bool {
|
|
self.cancelled.load(Ordering::Relaxed)
|
|
}
|
|
|
|
/// Blocks until the initial scan and all queued updates are done.
|
|
pub(crate) fn wait_idle(&self, timeout: Duration) -> bool {
|
|
let deadline = Instant::now() + timeout;
|
|
let mut q = self.queue.lock();
|
|
while !(q.started && !q.busy && q.jobs.is_empty()) {
|
|
if self.is_cancelled() || self.idle.wait_until(&mut q, deadline).timed_out() {
|
|
return q.started && !q.busy && q.jobs.is_empty();
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
fn emit_state(&self) {
|
|
let (files, symbols) = {
|
|
let d = self.data.read();
|
|
(d.file_count, d.symbol_count)
|
|
};
|
|
self.sink.emit(IdeEvent::IndexState { indexing: self.indexing.load(Ordering::SeqCst), files, symbols });
|
|
}
|
|
|
|
// ---------------------------------------------------------------- thread
|
|
|
|
/// `scan`: load the persisted index and scan the roots before serving updates.
|
|
pub(crate) fn run(&self, scan: bool) {
|
|
{
|
|
let mut q = self.queue.lock();
|
|
q.busy = true;
|
|
}
|
|
if scan {
|
|
self.load_persisted();
|
|
self.full_scan();
|
|
} else {
|
|
self.indexing.store(false, Ordering::SeqCst);
|
|
}
|
|
{
|
|
let mut q = self.queue.lock();
|
|
q.busy = false;
|
|
q.started = true;
|
|
self.idle.notify_all();
|
|
}
|
|
loop {
|
|
let jobs: Vec<Job> = {
|
|
let mut q = self.queue.lock();
|
|
while q.jobs.is_empty() && !self.is_cancelled() {
|
|
self.wake.wait(&mut q);
|
|
}
|
|
if self.is_cancelled() {
|
|
return;
|
|
}
|
|
q.busy = true;
|
|
q.jobs.drain(..).collect()
|
|
};
|
|
self.process(jobs);
|
|
let mut q = self.queue.lock();
|
|
q.busy = false;
|
|
if q.jobs.is_empty() {
|
|
self.idle.notify_all();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn load_persisted(&self) {
|
|
let Some(store) = &self.store else { return };
|
|
let root = self.filter().root().to_owned();
|
|
match store.load() {
|
|
Ok(records) => {
|
|
let entries: Vec<FileEntry> =
|
|
records.into_par_iter().map(|(rel, rec)| FileEntry::new(root.join(&rel), rel, rec)).collect();
|
|
let mut d = self.data.write();
|
|
for e in entries {
|
|
d.insert(e);
|
|
}
|
|
}
|
|
Err(e) => tracing::warn!("index cache load failed: {e}"),
|
|
}
|
|
let recent: Vec<PathBuf> = store.load_recent().into_iter().map(|rel| root.join(rel)).collect();
|
|
let mut r = self.recent.lock();
|
|
for p in recent {
|
|
if !r.contains(&p) {
|
|
r.push(p);
|
|
}
|
|
}
|
|
r.truncate(MAX_RECENT);
|
|
}
|
|
|
|
/// A cancelled scan (project closed) still ends its progress task and the indexing state.
|
|
fn end_scan(&self, task: TaskId) {
|
|
self.indexing.store(false, Ordering::SeqCst);
|
|
self.sink.emit(IdeEvent::Task {
|
|
id: task,
|
|
title: "Indexing".into(),
|
|
message: None,
|
|
fraction: None,
|
|
done: true,
|
|
cancellable: false,
|
|
});
|
|
self.emit_state();
|
|
}
|
|
|
|
fn full_scan(&self) {
|
|
let started = Instant::now();
|
|
self.indexing.store(true, Ordering::SeqCst);
|
|
self.emit_state();
|
|
let task = TaskId::next();
|
|
self.sink.emit(IdeEvent::Task {
|
|
id: task,
|
|
title: "Indexing".into(),
|
|
message: Some("Scanning files…".into()),
|
|
fraction: None,
|
|
done: false,
|
|
cancellable: false,
|
|
});
|
|
|
|
let filter = self.filter();
|
|
let mut found = Vec::new();
|
|
for root in filter.roots() {
|
|
found.extend(filter.walk(root, &self.cancelled));
|
|
}
|
|
if self.is_cancelled() {
|
|
return self.end_scan(task);
|
|
}
|
|
let seen: HashSet<&Path> = found.iter().map(|f| f.path.as_path()).collect();
|
|
let (todo, stale): (Vec<&Found>, Vec<PathBuf>) = {
|
|
let d = self.data.read();
|
|
let todo = found
|
|
.iter()
|
|
.filter(|f| d.get(&f.path).is_none_or(|e| e.mtime != f.mtime || e.size != f.size))
|
|
.collect();
|
|
let stale = d.paths().filter(|p| !seen.contains(p.as_path())).cloned().collect();
|
|
(todo, stale)
|
|
};
|
|
if !stale.is_empty() {
|
|
let mut d = self.data.write();
|
|
for path in &stale {
|
|
d.remove(path);
|
|
}
|
|
}
|
|
let stale: Vec<String> = stale.iter().filter_map(|p| filter.store_key(p)).collect();
|
|
self.persist(&[], &stale, false);
|
|
|
|
let total = todo.len();
|
|
let mut done = 0usize;
|
|
let mut last_progress = Instant::now();
|
|
for chunk in todo.chunks(CHUNK) {
|
|
if self.is_cancelled() {
|
|
return self.end_scan(task);
|
|
}
|
|
self.index_files(chunk.iter().copied(), false);
|
|
done += chunk.len();
|
|
if last_progress.elapsed() >= PROGRESS_EVERY {
|
|
last_progress = Instant::now();
|
|
self.sink.emit(IdeEvent::Task {
|
|
id: task,
|
|
title: "Indexing".into(),
|
|
message: Some(format!("{done} / {total} files")),
|
|
fraction: Some(done as f32 / total.max(1) as f32),
|
|
done: false,
|
|
cancellable: false,
|
|
});
|
|
}
|
|
}
|
|
self.persist(&[], &[], true);
|
|
self.indexing.store(false, Ordering::SeqCst);
|
|
self.sink.emit(IdeEvent::Task {
|
|
id: task,
|
|
title: "Indexing".into(),
|
|
message: None,
|
|
fraction: Some(1.0),
|
|
done: true,
|
|
cancellable: false,
|
|
});
|
|
self.emit_state();
|
|
let d = self.data.read();
|
|
tracing::info!(
|
|
"indexed {} files ({} parsed, {} symbols) in {:?}",
|
|
d.file_count,
|
|
total,
|
|
d.symbol_count,
|
|
started.elapsed()
|
|
);
|
|
}
|
|
|
|
/// Extracts records in parallel, then publishes them to memory and disk.
|
|
/// `force` re-parses files whose content is unchanged (their language changed).
|
|
fn index_files<'a>(&self, files: impl Iterator<Item = &'a Found>, force: bool) {
|
|
let files: Vec<&Found> = files.collect();
|
|
let filter = self.filter();
|
|
let extracted: Vec<(String, FileRecord, PathBuf)> = files
|
|
.par_iter()
|
|
.filter_map(|f| {
|
|
if self.is_cancelled() {
|
|
return None;
|
|
}
|
|
let rel = filter.rel(&f.path)?;
|
|
let prev_hash = if force { None } else { self.data.read().get(&f.path).map(|e| e.hash) };
|
|
match self.extract(f, prev_hash) {
|
|
Extracted::Unchanged => {
|
|
// Same content, new mtime: keep the symbols, refresh metadata.
|
|
let d = self.data.read();
|
|
let mut rec = d.get(&f.path)?.record();
|
|
rec.mtime = f.mtime;
|
|
rec.size = f.size;
|
|
Some((rel, rec, f.path.clone()))
|
|
}
|
|
Extracted::New(rec) => Some((rel, rec, f.path.clone())),
|
|
}
|
|
})
|
|
.collect();
|
|
if extracted.is_empty() {
|
|
return;
|
|
}
|
|
let puts: Vec<(String, FileRecord)> =
|
|
extracted.iter().filter_map(|(_, rec, path)| Some((filter.store_key(path)?, rec.clone()))).collect();
|
|
{
|
|
let entries: Vec<FileEntry> =
|
|
extracted.into_iter().map(|(rel, rec, path)| FileEntry::new(path, rel, rec)).collect();
|
|
let mut d = self.data.write();
|
|
for e in entries {
|
|
d.insert(e);
|
|
}
|
|
}
|
|
self.persist(&puts, &[], false);
|
|
}
|
|
|
|
fn persist(&self, puts: &[(String, FileRecord)], deletes: &[String], durable: bool) {
|
|
if let Some(store) = &self.store
|
|
&& let Err(e) = store.apply(puts, deletes, durable)
|
|
{
|
|
tracing::warn!("index cache write failed: {e}");
|
|
}
|
|
}
|
|
|
|
fn extract(&self, f: &Found, prev_hash: Option<[u8; 32]>) -> Extracted {
|
|
let mut rec = FileRecord { mtime: f.mtime, size: f.size, ..Default::default() };
|
|
if f.size > MAX_SYMBOL_FILE {
|
|
return Extracted::New(rec);
|
|
}
|
|
let mut bytes = Vec::with_capacity(f.size as usize);
|
|
match std::fs::File::open(&f.path).and_then(|mut file| file.read_to_end(&mut bytes)) {
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
tracing::debug!("index: cannot read {}: {e}", f.path.display());
|
|
return Extracted::New(rec);
|
|
}
|
|
}
|
|
rec.hash = *blake3::hash(&bytes).as_bytes();
|
|
if prev_hash == Some(rec.hash) {
|
|
return Extracted::Unchanged;
|
|
}
|
|
self.parsed.fetch_add(1, Ordering::Relaxed);
|
|
if bytes[..bytes.len().min(8192)].contains(&0) {
|
|
return Extracted::New(rec);
|
|
}
|
|
let text = String::from_utf8_lossy(&bytes);
|
|
let lang = self.languages.detect(&f.path, Some(first_line(&text)));
|
|
if self.syntax.has_symbols(&lang) {
|
|
rec.symbols = self
|
|
.syntax
|
|
.tags(&lang, &text)
|
|
.into_iter()
|
|
.map(|t| StoredSymbol { name: t.name, kind: t.kind, container: t.container, range: t.range })
|
|
.collect();
|
|
}
|
|
Extracted::New(rec)
|
|
}
|
|
|
|
fn process(&self, jobs: Vec<Job>) {
|
|
let mut changed: Vec<PathBuf> = Vec::new();
|
|
let mut removed: Vec<PathBuf> = Vec::new();
|
|
let mut save_recent = false;
|
|
let mut rescan = false;
|
|
let mut reparse: Vec<std::sync::Arc<LanguageRegistry>> = Vec::new();
|
|
for job in jobs {
|
|
match job {
|
|
Job::Changed(p) => changed.extend(p),
|
|
Job::Removed(p) => removed.extend(p),
|
|
Job::SaveRecent => save_recent = true,
|
|
Job::Rescan => rescan = true,
|
|
Job::Reparse(before) => reparse.push(before),
|
|
}
|
|
}
|
|
if rescan {
|
|
// The scan also picks up whatever `changed` / `removed` reported.
|
|
self.full_scan();
|
|
changed.clear();
|
|
removed.clear();
|
|
}
|
|
let filter = self.filter();
|
|
let mut touched = false;
|
|
|
|
// Removals first; a path removed then re-created shows up in `changed` too.
|
|
let mut deletes = Vec::new();
|
|
for path in removed {
|
|
if path.exists() {
|
|
changed.push(path);
|
|
continue;
|
|
}
|
|
let mut d = self.data.write();
|
|
let gone = match d.remove(&path) {
|
|
Some(_) => vec![path],
|
|
None => d.remove_tree(&path),
|
|
};
|
|
deletes.extend(gone.iter().filter_map(|p| filter.store_key(p)));
|
|
}
|
|
if !deletes.is_empty() {
|
|
touched = true;
|
|
self.persist(&[], &deletes, false);
|
|
}
|
|
|
|
let mut files: Vec<Found> = Vec::new();
|
|
let mut seen = HashSet::new();
|
|
for path in changed {
|
|
if !seen.insert(path.clone()) {
|
|
continue;
|
|
}
|
|
let Ok(meta) = std::fs::symlink_metadata(&path) else { continue };
|
|
if filter.is_excluded(&path, meta.is_dir()) {
|
|
continue;
|
|
}
|
|
if meta.is_dir() {
|
|
files.extend(filter.walk(&path, &self.cancelled));
|
|
} else if meta.is_file() {
|
|
files.push(Found { mtime: mtime_of(&meta), size: meta.len(), path });
|
|
}
|
|
}
|
|
let files: Vec<Found> = {
|
|
let d = self.data.read();
|
|
files
|
|
.into_iter()
|
|
.filter(|f| d.get(&f.path).is_none_or(|e| e.mtime != f.mtime || e.size != f.size))
|
|
.collect()
|
|
};
|
|
if !files.is_empty() {
|
|
touched = true;
|
|
for chunk in files.chunks(CHUNK) {
|
|
if self.is_cancelled() {
|
|
return;
|
|
}
|
|
self.index_files(chunk.iter(), false);
|
|
}
|
|
}
|
|
for before in reparse {
|
|
let affected: Vec<Found> = {
|
|
let d = self.data.read();
|
|
d.paths()
|
|
.filter(|p| before.detect(p, None) != self.languages.detect(p, None))
|
|
.filter_map(|p| {
|
|
let e = d.get(p)?;
|
|
Some(Found { path: p.clone(), mtime: e.mtime, size: e.size })
|
|
})
|
|
.collect()
|
|
};
|
|
if !affected.is_empty() {
|
|
touched = true;
|
|
for chunk in affected.chunks(CHUNK) {
|
|
if self.is_cancelled() {
|
|
return;
|
|
}
|
|
self.index_files(chunk.iter(), true);
|
|
}
|
|
}
|
|
}
|
|
if save_recent && let Some(store) = &self.store {
|
|
let rels: Vec<String> = self.recent.lock().iter().filter_map(|p| filter.store_key(p)).collect();
|
|
if let Err(e) = store.save_recent(&rels) {
|
|
tracing::warn!("index cache: saving recent files failed: {e}");
|
|
}
|
|
}
|
|
if touched {
|
|
self.persist(&[], &[], true);
|
|
self.emit_state();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The first line, capped at 256 bytes (for shebang / `<?xml` detection).
|
|
fn first_line(text: &str) -> &str {
|
|
let line = text.lines().next().unwrap_or_default();
|
|
let mut end = line.len().min(256);
|
|
while !line.is_char_boundary(end) {
|
|
end -= 1;
|
|
}
|
|
&line[..end]
|
|
}
|
|
|
|
enum Extracted {
|
|
/// Content hash matches the indexed version.
|
|
Unchanged,
|
|
New(FileRecord),
|
|
}
|