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::{Notify, Semaphore};
use crate::context::RequestOutcome;
pub struct AnalysisState {
inner: Arc<RwLock<Option<Session<DiskFiles>>>>,
files: SessionFiles<DiskFiles>,
blocking_permits: Arc<Semaphore>,
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())),
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 {
if self
.with_snapshot(|session| session.view(path).is_some())
.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) {
if !self
.with_snapshot(|session| session.needs_catching_up(path))
.unwrap_or(true)
{
return;
}
let path = path.to_path_buf();
self.update_session("a request caught up with its file", move |session| {
session.catch_up(&path)
})
.await;
}
pub fn wake(&self) {
self.work.notify_one();
}
pub async fn read_a_program(&self) -> bool {
let Some(Some(root)) = self.with_snapshot(|session| session.the_program_to_read()) 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) => tokio::task::spawn_blocking(move || materials.read_the_program())
.await
.unwrap_or(None),
None => None,
};
let read = self
.update_session("read a program", move |session| {
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 open(&self, root: PathBuf, filter: WatchFilter, config_file: Option<PathBuf>) {
let files = self.files.clone();
self.update("open the session", move |slot| {
*slot = Some(Session::open_with_config_file(
root,
files,
filter,
config_file.as_deref(),
));
})
.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 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.view(&to_prepare).is_some()
{
return true;
}
self.update_session("prepare a file a pull request named", move |session| {
if session.index().summary(&to_prepare).is_some() && session.view(&to_prepare).is_some()
{
return true;
}
session.load(&to_prepare);
if !session
.project_files()
.iter()
.any(|known| known == &to_prepare)
{
session.add_project_files([to_prepare.clone()]);
}
session.want_refinement(&to_prepare);
session.advance(PREPARE_INDEX_SLICE);
session.cook(&to_prepare).is_some()
})
.await
.unwrap_or(false)
}
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);
}
}