mod budget;
mod tools;
use crate::embed::Embedder;
use crate::storage::{locate_project, Db};
use crate::{Error, Result};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::{Duration, Instant, SystemTime};
const IDLE_UNLOAD: Duration = Duration::from_secs(5 * 60);
const MAX_LINE: usize = 1 << 20;
type Loaded = Box<dyn Embedder + Send>;
type DbKey = (PathBuf, u64, Option<SystemTime>, (u64, u64));
#[cfg(unix)]
fn file_id(m: &std::fs::Metadata) -> (u64, u64) {
use std::os::unix::fs::MetadataExt;
(m.dev(), m.ino())
}
#[cfg(not(unix))]
fn file_id(_: &std::fs::Metadata) -> (u64, u64) {
(0, 0)
}
pub(crate) struct State {
start: PathBuf,
db: Mutex<Option<(DbKey, PathBuf, Db)>>,
embedder: Mutex<Option<(Loaded, Instant)>>,
}
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
impl State {
fn new(start: PathBuf) -> State {
State {
start,
db: Mutex::new(None),
embedder: Mutex::new(None),
}
}
pub(crate) fn with_db<T>(&self, f: impl FnOnce(&Path, &Db) -> Result<T>) -> Result<T> {
let path = locate_project(&self.start)
.ok_or_else(|| Error::NoIndex(self.start.display().to_string()))?;
let m = std::fs::metadata(&path)?;
let key = (path.clone(), m.len(), m.modified().ok(), file_id(&m));
let mut g = lock(&self.db);
if g.as_ref().is_none_or(|(k, _, _)| *k != key) {
*g = None; let db = Db::open(&path, None)?;
let root = path.parent().unwrap_or(Path::new(".")).to_path_buf();
*g = Some((key, root, db));
}
let (_, root, db) = g.as_ref().expect("set above");
f(root, db)
}
pub(crate) fn with_embedder<T>(&self, f: impl FnOnce(&dyn Embedder) -> Result<T>) -> Result<T> {
let mut g = lock(&self.embedder);
if g.is_none() {
*g = Some((load_embedder()?, Instant::now()));
}
let (e, used) = g.as_mut().expect("set above");
*used = Instant::now();
f(e.as_ref())
}
fn unload_if_idle(&self, now: Instant, idle: Duration) {
let mut g = lock(&self.embedder);
if g.as_ref()
.is_some_and(|(_, t)| now.saturating_duration_since(*t) >= idle)
{
*g = None;
}
}
}
fn load_embedder() -> Result<Loaded> {
#[cfg(all(feature = "test-hooks", debug_assertions))]
if std::env::var_os("PITH_TEST_FAKE_EMBEDDER").is_some_and(|v| v == "1") {
return Ok(Box::new(crate::embed::FakeEmbedder));
}
#[cfg(feature = "onnx")]
return Ok(Box::new(crate::embed::OnnxEmbedder::load(
&crate::embed::models_dir()?,
)?));
#[cfg(not(feature = "onnx"))]
return Err(Error::Invalid(crate::commands::NO_ONNX.into()));
}
pub fn run(project: Option<PathBuf>) -> Result<()> {
let start = match project {
Some(p) => p,
None => std::env::current_dir()?,
};
if !start.is_dir() {
return Err(Error::Invalid(format!(
"{} is not a directory",
start.display()
)));
}
let state = Arc::new(State::new(start));
let weak = Arc::downgrade(&state);
std::thread::spawn(move || loop {
std::thread::sleep(Duration::from_secs(30));
match weak.upgrade() {
Some(s) => s.unload_if_idle(Instant::now(), IDLE_UNLOAD),
None => return,
}
});
tokio::runtime::Builder::new_current_thread()
.enable_time() .build()?
.block_on(tools::serve(state, MAX_LINE))
}
pub(crate) struct LineCap<R> {
inner: R,
max: usize,
run: usize,
tripped: Arc<std::sync::atomic::AtomicBool>,
}
impl<R> LineCap<R> {
pub(crate) fn new(inner: R, max: usize) -> Self {
LineCap {
inner,
max,
run: 0,
tripped: Arc::default(),
}
}
pub(crate) fn tripped(&self) -> Arc<std::sync::atomic::AtomicBool> {
self.tripped.clone()
}
}
impl<R: tokio::io::AsyncRead + Unpin> tokio::io::AsyncRead for LineCap<R> {
fn poll_read(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
let before = buf.filled().len();
let r = std::pin::Pin::new(&mut self.inner).poll_read(cx, buf);
if let std::task::Poll::Ready(Ok(())) = r {
let new = &buf.filled()[before..];
let mut run = self.run;
for (i, seg) in new.split(|&b| b == b'\n').enumerate() {
run = if i == 0 { run + seg.len() } else { seg.len() };
if run > self.max {
self.tripped
.store(true, std::sync::atomic::Ordering::Relaxed);
buf.set_filled(before);
return std::task::Poll::Ready(Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"MCP message longer than the line limit",
)));
}
}
self.run = run;
}
r
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::embed::FakeEmbedder;
#[test]
fn unload_if_idle_respects_the_boundary() {
let s = State::new(PathBuf::from("."));
let t0 = Instant::now();
*lock(&s.embedder) = Some((Box::new(FakeEmbedder), t0));
s.unload_if_idle(t0 + IDLE_UNLOAD - Duration::from_nanos(1), IDLE_UNLOAD);
assert!(
lock(&s.embedder).is_some(),
"1ns short of the limit keeps it"
);
s.unload_if_idle(t0 + IDLE_UNLOAD, IDLE_UNLOAD);
assert!(lock(&s.embedder).is_none(), "exactly the limit unloads");
}
#[test]
fn reindex_is_picked_up_without_restart() {
let d = tempfile::tempdir().unwrap();
std::fs::write(d.path().join("a.txt"), "one\n").unwrap();
crate::index_project(d.path(), &FakeEmbedder, false).unwrap();
let s = State::new(d.path().to_path_buf());
assert_eq!(s.with_db(|_, db| Ok(db.index().len())).unwrap(), 1);
std::fs::write(d.path().join("b.txt"), "two\n").unwrap();
crate::index_project(d.path(), &FakeEmbedder, false).unwrap();
assert_eq!(s.with_db(|_, db| Ok(db.index().len())).unwrap(), 2);
}
#[cfg(unix)]
#[test]
fn reindex_with_same_size_and_mtime_is_picked_up_by_inode() {
let d = tempfile::tempdir().unwrap();
let db = d.path().join(".pith");
let text = |s: &State| {
s.with_db(|_, db| Ok(db.chunk(0).unwrap().text.clone()))
.unwrap()
};
std::fs::write(d.path().join("a.txt"), "one\n").unwrap();
crate::index_project(d.path(), &FakeEmbedder, false).unwrap();
let before = std::fs::metadata(&db).unwrap();
let s = State::new(d.path().to_path_buf());
assert_eq!(text(&s), "one");
std::fs::write(d.path().join("a.txt"), "two\n").unwrap();
crate::index_project(d.path(), &FakeEmbedder, false).unwrap();
std::fs::File::options()
.write(true)
.open(&db)
.unwrap()
.set_modified(before.modified().unwrap())
.unwrap();
let after = std::fs::metadata(&db).unwrap();
assert_eq!(
(after.len(), after.modified().ok()),
(before.len(), before.modified().ok())
);
assert_eq!(text(&s), "two");
}
#[test]
fn line_cap_rejects_only_overlong_lines() {
use tokio::io::AsyncReadExt;
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let read = |data: Vec<u8>| {
rt.block_on(async {
let mut out = Vec::new();
LineCap::new(&data[..], 8)
.read_to_end(&mut out)
.await
.map(|_| out)
})
};
assert!(read(b"12345678\n12345678\n".to_vec()).is_ok());
assert!(read(b"123456789\n".to_vec()).is_err());
let chunked = |a: &'static [u8], b: &'static [u8]| {
rt.block_on(async {
let mut out = Vec::new();
LineCap::new(a.chain(b), 8)
.read_to_end(&mut out)
.await
.map(|_| out)
})
};
assert!(chunked(b"12345", b"6789\n").is_err());
assert!(chunked(b"1234", b"5678\n1").is_ok());
assert!(chunked(b"12\n345678", b"123").is_err());
}
}