const PREPARE_INDEX_SLICE: usize = 24;
use std::path::PathBuf;
use std::sync::{Arc, RwLock, RwLockReadGuard};
use std::time::Duration;
use cpp_code_analysis::{DiskFiles, OpenDocuments, Session, SessionFiles, WatchFilter};
use tokio::sync::{Mutex, Notify, Semaphore};
use crate::context::RequestOutcome;
pub struct AnalysisState {
inner: Arc<RwLock<Option<Session<DiskFiles>>>>,
files: SessionFiles<DiskFiles>,
blocking_permits: Arc<Semaphore>,
file_read_gate: Arc<Mutex<()>>,
gate: Arc<tokio::sync::RwLock<()>>,
worst_write_hold: Arc<std::sync::atomic::AtomicU64>,
work: Arc<Notify>,
}
pub const WRITE_BUDGET: Duration = Duration::from_millis(50);
pub const QUERY_BUDGET: Duration = Duration::from_millis(50);
impl AnalysisState {
pub fn new() -> Self {
let documents = OpenDocuments::new();
let files = SessionFiles::new(documents, DiskFiles);
Self {
inner: Arc::new(RwLock::new(None)),
files,
gate: Arc::new(tokio::sync::RwLock::new(())),
blocking_permits: Arc::new(Semaphore::new(Self::analysis_parallelism())),
file_read_gate: Arc::new(Mutex::new(())),
worst_write_hold: Arc::new(std::sync::atomic::AtomicU64::new(0)),
work: Arc::new(Notify::new()),
}
}
pub async fn prepare(&self, path: &std::path::Path) -> bool {
let whole = std::time::Instant::now();
let answer = self.prepare_inner(path).await;
if whole.elapsed().as_millis() >= 50 {
log::warn!("prepare {} took {:?}", path.display(), whole.elapsed());
}
answer
}
async fn prepare_inner(&self, path: &std::path::Path) -> bool {
if self
.with_snapshot(|session| {
session.files().held(path).is_some_and(|file| file.open)
|| session.has_rendering(path)
})
.unwrap_or(false)
{
return true;
}
let path = path.to_path_buf();
let queued = self
.update_session("prepare a file a request named", move |session| {
let loaded = session.load(&path).is_some();
if loaded {
session.want_cooked_reading(&path);
}
loaded
})
.await
.unwrap_or(false);
if queued {
self.wake();
}
queued
}
pub async fn prepare_for_a_read(&self, path: &std::path::Path) -> bool {
if let Ok(session) = self.inner.try_read()
&& let Some(session) = session.as_ref()
&& session.index().summary(path).is_some()
{
session.want_refinement(path);
self.wake();
return true;
}
self.prepare(path).await
}
pub async fn catch_up(&self, path: &std::path::Path) {
const MAX_FILES: usize = 9;
for _ in 0..MAX_FILES {
let Some(next) = self.next_file_for_catch_up(path).await else {
return;
};
self.read_queued_file(&next).await;
}
self.wake();
}
async fn next_file_for_catch_up(&self, path: &std::path::Path) -> Option<PathBuf> {
let immediate = match self.inner.try_read() {
Ok(session) => Some(session.as_ref().map(|session| {
(
session.needs_catching_up(path),
session.next_file_to_catch_up(path),
)
})),
Err(_) => None,
};
let (needs_work, next) = match immediate {
Some(state) => state,
None => {
let path = path.to_path_buf();
self.run_blocking(move |session| {
Some((
session.needs_catching_up(&path),
session.next_file_to_catch_up(&path),
))
})
.await
}
}?;
if !needs_work {
return None;
}
if next.is_some() {
return next;
}
let path = path.to_path_buf();
self.update_session("queue an unread include", move |session| {
session.ensure_file_to_catch_up(&path)
})
.await
.flatten()
}
pub async fn read_queued_file(&self, path: &std::path::Path) -> bool {
let guard = self.file_read_gate.clone().lock_owned().await;
let path = path.to_path_buf();
let gathered = self
.run_blocking(move |session| Some((session.gather_a_file_read(&path), guard)))
.await;
let Some((Some(materials), guard)) = gathered else {
return false;
};
let prepared = tokio::task::spawn_blocking(move || (materials.read(), guard)).await;
let (prepared, _guard) = match prepared {
Ok(prepared) => prepared,
Err(error) if error.is_panic() => std::panic::resume_unwind(error.into_panic()),
Err(error) => {
log::error!("the background file read failed: {error}");
return false;
}
};
let committed = self
.update_session("commit a file read", move |session| {
session.commit_a_file_read(prepared)
})
.await
.unwrap_or(false);
if committed {
self.wake();
}
committed
}
pub async fn render_cooks(
&self,
cooks: Vec<cpp_code_analysis::session::DetachedCook<DiskFiles>>,
) -> Vec<cpp_code_analysis::session::RenderedCook> {
if cooks.is_empty() {
return Vec::new();
}
let permit = match self.blocking_permits.clone().acquire_owned().await {
Ok(permit) => permit,
Err(error) => {
log::error!("cannot schedule rendering: {error}");
return Vec::new();
}
};
match tokio::task::spawn_blocking(move || {
let _permit = permit;
cooks.into_iter().filter_map(|cook| cook.render()).collect()
})
.await
{
Ok(rendered) => rendered,
Err(error) if error.is_panic() => std::panic::resume_unwind(error.into_panic()),
Err(error) => {
log::error!("rendering failed: {error}");
Vec::new()
}
}
}
pub fn wake(&self) {
self.work.notify_one();
}
pub async fn read_a_program(&self) -> bool {
let Some(Some((root, revision))) = self.with_snapshot(|session| {
session
.the_program_to_read()
.map(|root| (root, session.revision()))
}) else {
return false;
};
let gathering = root.clone();
let materials = self
.run_blocking(move |session| session.gather_a_unit_read(&gathering))
.await;
let prepared = match materials {
Some(materials) => {
match tokio::task::spawn_blocking(move || materials.read_the_program()).await {
Ok(prepared) => prepared,
Err(error) if error.is_panic() => std::panic::resume_unwind(error.into_panic()),
Err(error) => {
log::error!("the background program read failed: {error}");
return false;
}
}
}
None => None,
};
let read = self
.update_session("read a program", move |session| {
if !session.is_current(&revision) {
log::debug!("discarding a program read from an older session revision");
return None;
}
session.commit_a_program(&root, prepared)
})
.await
.flatten();
if let Some(reading) = &read {
log::info!(
"read a program as one piece: {} file(s), {} token(s), {} error(s) — their declarations are the \
compiler's reading now",
reading.files,
reading.tokens,
reading.errors
);
}
read.is_some()
}
pub async fn wait_for_work(&self, timeout: Duration) -> bool {
tokio::time::timeout(timeout, self.work.notified())
.await
.is_ok()
}
pub fn with_snapshot<T>(&self, f: impl FnOnce(&Session<DiskFiles>) -> T) -> Option<T> {
let session = blocking(move || {
let session = self.read();
session.as_ref().map(f)
})?;
Some(session)
}
pub fn analysis_parallelism() -> usize {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4)
}
#[cfg(test)]
pub fn files(&self) -> &SessionFiles<DiskFiles> {
&self.files
}
pub async fn build_session(
&self,
root: PathBuf,
filter: WatchFilter,
config_file: Option<PathBuf>,
) -> Option<Session<DiskFiles>> {
let files = self.files.clone();
match tokio::task::spawn_blocking(move || {
let session =
Session::open_with_config_file(root, files, filter, config_file.as_deref());
let restored = session.load_the_sharing();
if restored > 0 {
log::info!("restored {restored} shared contribution(s) from the last run");
}
session
})
.await
{
Ok(session) => Some(session),
Err(error) if error.is_panic() => std::panic::resume_unwind(error.into_panic()),
Err(error) => {
log::error!("failed to construct a workspace session: {error}");
None
}
}
}
pub async fn install_session(&self, session: Session<DiskFiles>) {
self.update("install the session", move |slot| *slot = Some(session))
.await;
}
pub async fn run_blocking<R, F>(&self, f: F) -> Option<R>
where
R: Send + 'static,
F: FnOnce(&Session<DiskFiles>) -> Option<R> + Send + 'static,
{
let started = std::time::Instant::now();
let permit = self.blocking_permits.clone().acquire_owned().await.ok()?;
let waited_for_a_permit = started.elapsed().as_millis() as u64;
let inner = self.inner.clone();
let gate = self.gate.clone().read_owned().await;
let waited_for_the_gate = started.elapsed().as_millis() as u64;
let result = tokio::task::spawn_blocking(move || {
let _permit = permit;
let locked = std::time::Instant::now();
let session = inner
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let waited_for_the_session = locked.elapsed().as_millis() as u64;
drop(gate);
let ran = std::time::Instant::now();
let answer = session.as_ref().and_then(f);
(
waited_for_the_session,
ran.elapsed().as_millis() as u64,
answer,
)
})
.await;
match result {
Ok((waited_for_the_session, ran, answer)) => {
let took = started.elapsed().as_millis() as u64;
if took > QUERY_BUDGET.as_millis() as u64 {
log::debug!(
"a query took {took} ms — permit {waited_for_a_permit} ms, gate {gate} ms, session \
{session} ms, ran {ran} ms",
gate = waited_for_the_gate - waited_for_a_permit,
session = waited_for_the_session,
);
}
answer
}
Err(err) => {
if err.is_panic() {
std::panic::resume_unwind(err.into_panic());
}
None
}
}
}
pub async fn query_blocking<R, F>(&self, f: F) -> RequestOutcome<R>
where
R: Send + 'static,
F: FnOnce(&Session<DiskFiles>) -> Option<R> + Send + 'static,
{
match self.run_blocking(f).await {
Some(value) => RequestOutcome::Ready(value),
None => RequestOutcome::Missing,
}
}
pub async fn prepare_for_a_diagnostic(&self, path: &std::path::Path) -> bool {
let to_prepare = path.to_path_buf();
if let Ok(session) = self.inner.try_read()
&& let Some(session) = session.as_ref()
&& session.index().summary(&to_prepare).is_some()
&& session.index().cooked_declarations(&to_prepare).is_some()
&& session.has_rendering(&to_prepare)
{
return true;
}
let target = to_prepare.clone();
let queued = self
.update_session("prepare a file a pull request named", move |session| {
if session.index().summary(&target).is_some()
&& session.index().cooked_declarations(&target).is_some()
&& session.has_rendering(&target)
{
return true;
}
if session.load(&target).is_none() {
return false;
}
if !session.contains_project_file(&target) {
session.add_project_files([target.clone()]);
}
session.want_refinement(&target);
session.want_cooked_reading(&target);
true
})
.await
.unwrap_or(false);
if !queued {
return false;
}
self.wake();
let stage = std::time::Instant::now();
let mut sliced = 0usize;
for _ in 0..PREPARE_INDEX_SLICE {
let Some(next) = self.next_file_for_catch_up(&to_prepare).await else {
break;
};
self.read_queued_file(&next).await;
sliced += 1;
}
let sliced_for = stage.elapsed();
let target = to_prepare.clone();
let ready = self
.run_blocking(move |session| {
Some(
session.index().summary(&target).is_some()
&& session.index().cooked_declarations(&target).is_some()
&& session.has_rendering(&target),
)
})
.await
.unwrap_or(false);
if ready {
log::debug!(
"pull prepare: {sliced} file(s) in {sliced_for:?}, ready after them in {:?} — no cook",
stage.elapsed() - sliced_for
);
return true;
}
let checked_for = stage.elapsed() - sliced_for;
let target = to_prepare.clone();
let Some(materials) = self
.run_blocking(move |session| session.gather_a_cook(&target))
.await
else {
return false;
};
let gathered_for = stage.elapsed() - sliced_for - checked_for;
let Some(rendered) = self.render_cooks(vec![materials]).await.pop() else {
return false;
};
let rendered_for = stage.elapsed() - sliced_for - checked_for - gathered_for;
let committed = self
.update_session("commit a diagnostic rendering", move |session| {
session.commit_a_cooked(rendered).is_some()
})
.await
.unwrap_or(false);
log::debug!(
"pull prepare: {sliced} file(s) in {sliced_for:?}, check {checked_for:?}, gather {gathered_for:?}, \
render {rendered_for:?}, commit {:?} ({committed})",
stage.elapsed() - sliced_for - checked_for - gathered_for - rendered_for
);
committed
}
pub async fn update_session<R>(
&self,
label: &'static str,
f: impl FnOnce(&mut Session<DiskFiles>) -> R,
) -> Option<R> {
self.update(label, |slot| slot.as_mut().map(f)).await
}
pub async fn update<R>(
&self,
label: &'static str,
f: impl FnOnce(&mut Option<Session<DiskFiles>>) -> R,
) -> R {
let inner = self.inner.clone();
let gate = self.gate.clone().write_owned().await;
let held = self.worst_write_hold.clone();
let run = move || {
let mut slot = inner
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner());
drop(gate);
let started = std::time::Instant::now();
let answer = f(&mut slot);
let took = started.elapsed().as_millis() as u64;
held.fetch_max(took, std::sync::atomic::Ordering::Relaxed);
if took > WRITE_BUDGET.as_millis() as u64 {
log::warn!(
"`{label}` held the analysis for {took} ms, over the {budget} ms budget — every request in \
flight waited for it",
budget = WRITE_BUDGET.as_millis()
);
}
answer
};
blocking(run)
}
pub fn worst_write_hold(&self) -> u64 {
self.worst_write_hold
.load(std::sync::atomic::Ordering::Relaxed)
}
fn read(&self) -> RwLockReadGuard<'_, Option<Session<DiskFiles>>> {
self.inner
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
}
fn blocking<R>(f: impl FnOnce() -> R) -> R {
if tokio::runtime::Handle::try_current()
.map(|handle| handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread)
.unwrap_or(false)
{
tokio::task::block_in_place(f)
} else {
f()
}
}
impl Default for AnalysisState {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use cpp_code_analysis::CompilerConfig;
#[tokio::test]
async fn diagnostics_are_answered_before_the_index_has_read_the_file() {
let state = AnalysisState::new();
let root = std::env::temp_dir().join("cppls-analysis-state-tests");
opened(&state, &root).await;
let buffer = "int g();\nstruct Name {\n int middleName;\n void print(int i) {\n middleName = i\n }\n};\n";
state.files().overlay.open("/p/main.cpp", buffer);
state
.update_session("a test's index pass", move |session| {
session.add_project_files([std::path::PathBuf::from("/p/main.cpp")]);
session.index_everything();
session.did_open(std::path::Path::new("/p/main.cpp"), buffer);
session
.diagnostics(std::path::Path::new("/p/main.cpp"))
.map(|found| found.errors.len())
})
.await;
let after_the_edit = state
.with_snapshot(|session| {
session
.diagnostics(std::path::Path::new("/p/main.cpp"))
.map(|found| found.errors.len())
})
.flatten();
assert_eq!(
after_the_edit, None,
"an edit drops the file's summary, so a pass that only reads has no answer — which is the defect"
);
assert!(
state
.prepare_for_a_diagnostic(std::path::Path::new("/p/main.cpp"))
.await,
"the file can be read and cooked"
);
let diagnostics = state
.with_snapshot(|session| {
crate::handlers::diagnostic::diagnose_file(
session,
std::path::Path::new("/p/main.cpp"),
)
})
.flatten()
.expect("a file whose text the client is holding has an answer")
.1;
assert!(
diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains(';')),
"the missing `;` is the only thing wrong with the buffer: {:?}",
diagnostics
.iter()
.map(|diagnostic| diagnostic.message.clone())
.collect::<Vec<_>>()
);
}
async fn opened(state: &AnalysisState, root: &std::path::Path) {
let files = state.files().clone();
let root = root.to_path_buf();
let opened = state
.update(
"a test's session",
move |slot: &mut Option<Session<DiskFiles>>| {
*slot = Some(Session::with_config(
&root,
files,
WatchFilter::new(&root),
CompilerConfig::default(),
));
slot.is_some()
},
)
.await;
assert!(opened);
}
#[tokio::test]
async fn a_pull_leaves_the_queue_for_the_pump() {
let state = AnalysisState::new();
let root = std::env::temp_dir().join("cppls-analysis-state-tests");
opened(&state, &root).await;
state
.files()
.overlay
.open("/p/main.cpp", "int main() { return 0; }\n");
let many: Vec<PathBuf> = (0..1500)
.map(|at| PathBuf::from(format!("/p/file{at}.cpp")))
.collect();
for path in &many {
state.files().overlay.open(
path.to_str().unwrap_or_default(),
"int a_variable_in_this_file = 0;\n",
);
}
state
.update_session("the fixture's discovery", move |session| {
session.add_project_files(many);
})
.await;
state
.prepare_for_a_diagnostic(std::path::Path::new("/p/main.cpp"))
.await;
let left = state
.with_snapshot(|session| session.pending())
.unwrap_or(usize::MAX);
assert!(
left > 0,
"a pull must not drain the whole project — that was 102 seconds of write hold, and every keystroke, \
hover and completion in the editor waited for it"
);
}
#[tokio::test]
async fn a_second_pull_does_not_take_the_analysis_for_writing() {
let state = AnalysisState::new();
let root = std::env::temp_dir().join("cppls-analysis-state-tests");
opened(&state, &root).await;
state
.files()
.overlay
.open("/p/main.cpp", "int main() { return 0; }\n");
let path = std::path::Path::new("/p/main.cpp");
assert!(
state.prepare_for_a_diagnostic(path).await,
"the file can be read and cooked"
);
state
.update_session("a test's drain", |session| session.index_everything())
.await;
assert!(
state
.with_snapshot(|session| session.view(path).is_some())
.unwrap_or(false),
"the fixture must reach the analysed state, or this test proves nothing about skipping it"
);
let held = state.gate.clone().write_owned().await;
let second = tokio::time::timeout(
std::time::Duration::from_secs(2),
state.prepare_for_a_diagnostic(path),
)
.await;
drop(held);
assert_eq!(
second,
Ok(true),
"a file already indexed and cooked is prepared from the read lock alone; a pull that needed the write \
lock would still be waiting here — and that wait is what every other request pays"
);
}
#[tokio::test]
async fn a_buffer_written_through_the_handle_is_read_by_the_session() {
let state = AnalysisState::new();
let root = std::env::temp_dir().join("cppls-analysis-state-tests");
opened(&state, &root).await;
let buffer = "int from_the_handle;\n";
state.files().overlay.open("/p/main.cpp", buffer);
assert!(state.prepare(std::path::Path::new("/p/main.cpp")).await);
let text = state
.with_snapshot(|session| {
session
.files()
.held("/p/main.cpp")
.map(|file| file.text.clone())
})
.flatten();
assert_eq!(text.as_deref(), Some(buffer));
}
#[tokio::test]
async fn opening_again_replaces_the_session_and_keeps_the_buffers() {
let state = AnalysisState::new();
let root = std::env::temp_dir().join("cppls-analysis-state-tests");
opened(&state, &root).await;
state.files().overlay.open("/p/main.cpp", "int first;\n");
opened(&state, &root).await;
assert!(state.prepare(std::path::Path::new("/p/main.cpp")).await);
let text = state
.with_snapshot(|session| {
session
.files()
.held("/p/main.cpp")
.map(|file| file.text.clone())
})
.flatten();
assert_eq!(
text.as_deref(),
Some("int first;\n"),
"the buffer is in the provider chain, which outlives the session"
);
}
#[tokio::test]
async fn a_query_before_the_workspace_is_opened_is_missing() {
let state = AnalysisState::new();
let outcome = state
.query_blocking(|session| Some(session.root().to_path_buf()))
.await;
assert!(matches!(outcome, RequestOutcome::Missing));
let updated = state.update_session("a test's update", |_| 1).await;
assert_eq!(updated, None, "and there is nothing to update either");
}
#[tokio::test]
async fn blocking_query_and_update_complete() {
let state = Arc::new(AnalysisState::new());
let root = std::env::temp_dir().join("cppls-analysis-state-tests");
opened(&state, &root).await;
let read_state = state.clone();
let read = tokio::spawn(async move { read_state.run_blocking(|_| Some(1)).await });
let write_state = state.clone();
let write = tokio::spawn(async move { write_state.update("a test's update", |_| 2).await });
assert_eq!(read.await.unwrap(), Some(1));
assert_eq!(write.await.unwrap(), 2);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cancellation_returns_before_a_running_blocking_query_finishes() {
use super::super::{CancelSource, snapshot_query};
use tokio_util::sync::CancellationToken;
let state = Arc::new(AnalysisState::new());
let root = std::env::temp_dir().join("cppls-analysis-state-tests");
opened(&state, &root).await;
let token = CancellationToken::new();
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel();
let query_state = state.clone();
let query_token = token.clone();
let query = tokio::spawn(async move {
snapshot_query(&query_state, query_token, move |_| {
started_tx.send(()).unwrap();
release_rx.recv().unwrap();
Some(42)
})
.await
});
tokio::time::timeout(std::time::Duration::from_secs(2), started_rx)
.await
.unwrap()
.unwrap();
token.cancel();
let outcome = tokio::time::timeout(std::time::Duration::from_secs(1), query)
.await
.unwrap()
.unwrap();
assert!(matches!(
outcome,
RequestOutcome::Cancelled(CancelSource::Client)
));
let write_state = state.clone();
let mut write = tokio::spawn(async move { write_state.update("test", |_| 1).await });
assert!(
tokio::time::timeout(std::time::Duration::from_millis(50), &mut write)
.await
.is_err()
);
release_tx.send(()).unwrap();
assert_eq!(write.await.unwrap(), 1);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn catching_up_can_be_cancelled_while_waiting_for_the_session_lock() {
let state = AnalysisState::new();
let root = std::env::temp_dir().join("cppls-analysis-state-tests");
opened(&state, &root).await;
let inner = state.inner.clone();
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel();
let writer = tokio::task::spawn_blocking(move || {
let _guard = inner.write().unwrap();
entered_tx.send(()).unwrap();
let _ = release_rx.recv_timeout(std::time::Duration::from_secs(2));
});
entered_rx.await.unwrap();
let result = tokio::time::timeout(
std::time::Duration::from_millis(100),
state.catch_up(&root.join("main.cpp")),
)
.await;
release_tx.send(()).unwrap();
writer.await.unwrap();
assert!(
result.is_err(),
"waiting for the lock must remain cancellable"
);
}
}