M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,507 @@
|
||||
//! Parallel pipelined index builder with thread-local caching.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use ahash::AHashMap as HashMap;
|
||||
use ignore::WalkBuilder;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::languages::LanguageRegistry;
|
||||
use crate::scope_graph::ScopeGraphIndex;
|
||||
use crate::types::{FileMeta, SymbolAlias, SymbolOccurrence};
|
||||
use kigi_paths::to_relative_path;
|
||||
|
||||
/// Error type for index building operations.
|
||||
#[derive(Debug)]
|
||||
pub enum IndexError {
|
||||
/// Error walking directory.
|
||||
WalkError { message: String },
|
||||
/// Thread panicked.
|
||||
ThreadPanic { message: String },
|
||||
/// IO error.
|
||||
IoError(std::io::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for IndexError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
IndexError::WalkError { message } => write!(f, "Walk error: {}", message),
|
||||
IndexError::ThreadPanic { message } => write!(f, "Thread panic: {}", message),
|
||||
IndexError::IoError(e) => write!(f, "IO error: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for IndexError {}
|
||||
|
||||
impl From<std::io::Error> for IndexError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
IndexError::IoError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Result type for index building operations.
|
||||
pub type Result<T> = std::result::Result<T, IndexError>;
|
||||
|
||||
// Thread-local caches for parsers and queries to avoid repeated initialization
|
||||
thread_local! {
|
||||
static PARSER_CACHE: RefCell<HashMap<String, tree_sitter::Parser>> = RefCell::new(HashMap::new());
|
||||
static QUERY_CACHE: RefCell<HashMap<String, tree_sitter::Query>> = RefCell::new(HashMap::new());
|
||||
}
|
||||
|
||||
/// Extracted symbols for a single file (lightweight, no ScopeGraph overhead)
|
||||
struct FileSymbols {
|
||||
path: Arc<str>,
|
||||
definitions: Vec<SymbolOccurrence>,
|
||||
references: Vec<SymbolOccurrence>,
|
||||
aliases: Vec<SymbolAlias>,
|
||||
file_meta: FileMeta,
|
||||
}
|
||||
|
||||
/// Builder for creating a symbol index.
|
||||
///
|
||||
/// Uses optimized parallel processing with:
|
||||
/// - Thread-local parser and query caching
|
||||
/// - Chunked parallel processing for cache locality
|
||||
/// - Lightweight symbol extraction (no intermediate ScopeGraph)
|
||||
/// - Bounded merge-batching to cap two-phase peak memory
|
||||
pub struct IndexBuilder {
|
||||
registry: LanguageRegistry,
|
||||
num_threads: usize,
|
||||
/// Whether to respect .gitignore files (default: true)
|
||||
respect_gitignore: bool,
|
||||
/// Whether to skip hidden files/directories (default: true)
|
||||
skip_hidden: bool,
|
||||
/// Chunk size for parallel processing / thread-local cache locality (default: 100)
|
||||
chunk_size: usize,
|
||||
/// Maximum number of files whose symbols are held in memory at once during
|
||||
/// the merge phase. Limiting this bounds the two-phase peak: parallel
|
||||
/// parsing produces at most `build_batch_size` FileSymbols before they are
|
||||
/// merged into the index and dropped. Default: 5 000 files per batch.
|
||||
build_batch_size: usize,
|
||||
}
|
||||
|
||||
/// Get the default number of threads (N-1 cores, minimum 1).
|
||||
fn default_num_threads() -> usize {
|
||||
num_cpus::get().saturating_sub(1).max(1)
|
||||
}
|
||||
|
||||
impl IndexBuilder {
|
||||
/// Create a new index builder.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
registry: LanguageRegistry::new(),
|
||||
num_threads: default_num_threads(),
|
||||
respect_gitignore: true,
|
||||
skip_hidden: true,
|
||||
chunk_size: 100,
|
||||
build_batch_size: 5_000,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new index builder with a custom language registry.
|
||||
pub fn with_registry(registry: LanguageRegistry) -> Self {
|
||||
Self {
|
||||
registry,
|
||||
num_threads: default_num_threads(),
|
||||
respect_gitignore: true,
|
||||
skip_hidden: true,
|
||||
chunk_size: 100,
|
||||
build_batch_size: 5_000,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the number of threads to use (default: N-1 cores).
|
||||
#[must_use]
|
||||
pub fn with_threads(mut self, count: usize) -> Self {
|
||||
self.num_threads = count;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the chunk size for parallel processing (default: 100).
|
||||
#[must_use]
|
||||
pub fn with_chunk_size(mut self, size: usize) -> Self {
|
||||
self.chunk_size = size;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the merge-batch size (default: 5 000 files per batch).
|
||||
///
|
||||
/// Controls how many files' symbols are held in memory simultaneously
|
||||
/// during the sequential merge phase. Smaller values reduce peak RSS at
|
||||
/// the cost of slightly more pool scheduling overhead. Values below
|
||||
/// `chunk_size` are clamped to `chunk_size` at build time, so the call
|
||||
/// order of `with_build_batch_size` and `with_chunk_size` does not matter.
|
||||
#[must_use]
|
||||
pub fn with_build_batch_size(mut self, size: usize) -> Self {
|
||||
self.build_batch_size = size;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set whether to respect .gitignore files (default: true).
|
||||
#[must_use]
|
||||
pub fn respect_gitignore(mut self, respect: bool) -> Self {
|
||||
self.respect_gitignore = respect;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set whether to skip hidden files/directories (default: true).
|
||||
#[must_use]
|
||||
pub fn skip_hidden(mut self, skip: bool) -> Self {
|
||||
self.skip_hidden = skip;
|
||||
self
|
||||
}
|
||||
|
||||
/// Build index from a directory, respecting .gitignore.
|
||||
///
|
||||
/// This walks the directory tree, automatically respecting:
|
||||
/// - `.gitignore` files at any level
|
||||
/// - `.git/info/exclude`
|
||||
/// - Global gitignore (`~/.config/git/ignore`)
|
||||
/// - Hidden files/directories (configurable)
|
||||
///
|
||||
/// **Note**: File paths in the index are stored as **relative paths** (to `root_path`)
|
||||
/// for portability across machines/sessions.
|
||||
pub fn build(&self, root_path: &Path) -> Result<ScopeGraphIndex> {
|
||||
// Collect files using the ignore crate
|
||||
let file_paths = self.collect_files(root_path)?;
|
||||
|
||||
if file_paths.is_empty() {
|
||||
let mut index = ScopeGraphIndex::new();
|
||||
// Set query version even for empty index so cache validation works
|
||||
index.set_query_version(self.registry.compute_query_hash());
|
||||
return Ok(index);
|
||||
}
|
||||
|
||||
self.build_fast(root_path, &file_paths)
|
||||
}
|
||||
|
||||
/// Collect all supported files from a directory, respecting gitignore.
|
||||
/// Uses `git ls-files` when available (faster), falls back to directory walking.
|
||||
fn collect_files(&self, root_path: &Path) -> Result<Vec<PathBuf>> {
|
||||
// Try git ls-files first - it's much faster as it reads from git's index
|
||||
// But it only works for tracked files, so we also add untracked files
|
||||
if let Some(files) = self.collect_files_git(root_path)
|
||||
&& !files.is_empty()
|
||||
{
|
||||
return Ok(files);
|
||||
}
|
||||
|
||||
// Fall back to directory walking
|
||||
self.collect_files_walk(root_path)
|
||||
}
|
||||
|
||||
/// Collect files using git2 - reads from the git index (tracked files).
|
||||
/// Untracked files are not included since:
|
||||
/// 1. They are typically a small minority
|
||||
/// 2. They will be picked up by fsnotify when created
|
||||
/// 3. The statuses() call for untracked files is very slow (~10x overhead)
|
||||
fn collect_files_git(&self, root_path: &Path) -> Option<Vec<PathBuf>> {
|
||||
use git2::Repository;
|
||||
|
||||
// Open the repository
|
||||
let repo = Repository::open(root_path).ok()?;
|
||||
|
||||
// Get all files from the index (tracked files)
|
||||
let index = repo.index().ok()?;
|
||||
let files: Vec<PathBuf> = index
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
// git2 stores paths as bytes, convert to str
|
||||
let path_str = std::str::from_utf8(&entry.path).ok()?;
|
||||
if self.registry.is_supported(path_str) {
|
||||
Some(root_path.join(path_str))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Some(files)
|
||||
}
|
||||
|
||||
/// Collect files by walking the directory tree.
|
||||
/// Used as fallback when not in a git repository.
|
||||
fn collect_files_walk(&self, root_path: &Path) -> Result<Vec<PathBuf>> {
|
||||
use std::sync::Mutex;
|
||||
|
||||
let files = Mutex::new(Vec::with_capacity(50000));
|
||||
|
||||
let walker = WalkBuilder::new(root_path)
|
||||
.hidden(self.skip_hidden)
|
||||
.git_ignore(self.respect_gitignore)
|
||||
.git_global(self.respect_gitignore)
|
||||
.git_exclude(self.respect_gitignore)
|
||||
.threads(self.num_threads.min(12)) // Use parallel walking (capped at 12)
|
||||
.build_parallel();
|
||||
|
||||
walker.run(|| {
|
||||
let files = &files;
|
||||
let registry = &self.registry;
|
||||
Box::new(move |entry| {
|
||||
use ignore::WalkState;
|
||||
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
Err(_) => return WalkState::Continue,
|
||||
};
|
||||
|
||||
let path = entry.path();
|
||||
|
||||
// Skip directories
|
||||
if path.is_dir() {
|
||||
return WalkState::Continue;
|
||||
}
|
||||
|
||||
// Check if the file is supported
|
||||
if registry.is_supported(path) {
|
||||
files.lock().unwrap().push(path.to_path_buf());
|
||||
}
|
||||
|
||||
WalkState::Continue
|
||||
})
|
||||
});
|
||||
|
||||
Ok(files.into_inner().unwrap())
|
||||
}
|
||||
|
||||
/// Build index with maximum throughput optimizations:
|
||||
/// - Memory-mapped I/O for zero-copy file reading
|
||||
/// - Direct parsing from mmap (no intermediate buffer copy)
|
||||
/// - Lightweight symbol extraction (skip building full ScopeGraph)
|
||||
/// - Thread-local parser and query caching
|
||||
/// - Chunked parallel processing for better cache locality
|
||||
/// - Single StringInterner for memory-efficient string deduplication
|
||||
///
|
||||
/// Uses two-phase approach:
|
||||
/// 1. Parallel: parse files and extract symbols into Vec<FileSymbols>
|
||||
/// 2. Sequential: aggregate into single ScopeGraphIndex with single interner
|
||||
///
|
||||
/// This ensures all strings are deduplicated in one interner, avoiding
|
||||
/// the memory overhead of multiple interners during parallel aggregation.
|
||||
///
|
||||
/// File paths are stored as **relative paths** (to `root_path`) for portability.
|
||||
fn build_fast(&self, root_path: &Path, file_paths: &[PathBuf]) -> Result<ScopeGraphIndex> {
|
||||
// Configure thread pool with N-1 cores
|
||||
let pool = rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(self.num_threads)
|
||||
.build()
|
||||
.map_err(|e| IndexError::WalkError {
|
||||
message: format!("Failed to create thread pool: {}", e),
|
||||
})?;
|
||||
|
||||
let registry = Arc::new(LanguageRegistry::new());
|
||||
let chunk_size = self.chunk_size;
|
||||
// Clamp here against the final chunk_size so that call order of
|
||||
// with_build_batch_size / with_chunk_size on the builder does not matter.
|
||||
let build_batch_size = self.build_batch_size.max(chunk_size);
|
||||
let root_arc: Arc<Path> = Arc::from(root_path);
|
||||
|
||||
let mut index = ScopeGraphIndex::new();
|
||||
|
||||
// Process files in bounded merge-batches to cap two-phase peak memory.
|
||||
//
|
||||
// Old approach: collect ALL FileSymbols in one go, then merge.
|
||||
// Peak = O(total_files) symbols + growing index simultaneously.
|
||||
//
|
||||
// New approach: for each batch of build_batch_size files:
|
||||
// 1. Parse in parallel (par_chunks preserves thread-local cache locality)
|
||||
// 2. Merge the batch into the index
|
||||
// 3. Drop the batch before starting the next one
|
||||
// Peak = O(build_batch_size) symbols + growing index simultaneously.
|
||||
for batch in file_paths.chunks(build_batch_size) {
|
||||
let batch_symbols: Vec<FileSymbols> = pool.install(|| {
|
||||
batch
|
||||
.par_chunks(chunk_size)
|
||||
.flat_map_iter(|chunk| {
|
||||
chunk
|
||||
.iter()
|
||||
.filter_map(|path| process_file_fast(path, &root_arc, ®istry))
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
for file_syms in batch_symbols {
|
||||
let path_str: &str = &file_syms.path;
|
||||
for sym in file_syms.definitions {
|
||||
index.add_definition(&sym.name, path_str, sym.line);
|
||||
}
|
||||
for sym in file_syms.references {
|
||||
index.add_reference(&sym.name, path_str, sym.line);
|
||||
}
|
||||
for alias in file_syms.aliases {
|
||||
index.add_alias_arc(alias.alias, alias.original);
|
||||
}
|
||||
index.set_file_meta(path_str, file_syms.file_meta);
|
||||
}
|
||||
// batch_symbols dropped here — frees the parallel-extracted symbols
|
||||
// before the next batch is parsed
|
||||
}
|
||||
|
||||
// Set the query version hash so we can detect query changes on cache load
|
||||
index.set_query_version(self.registry.compute_query_hash());
|
||||
|
||||
// Reclaim over-allocated Vec capacity that accumulated during bulk push().
|
||||
// This is a one-time cost paid here (O(symbols)) to permanently reduce RSS.
|
||||
index.compact();
|
||||
|
||||
Ok(index)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for IndexBuilder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a single file using thread-local caching.
|
||||
/// Returns lightweight FileSymbols (no ScopeGraph overhead).
|
||||
///
|
||||
/// File path is stored as **relative** (to `root_path`) for portability.
|
||||
fn process_file_fast(
|
||||
path: &Path,
|
||||
root_path: &Path,
|
||||
registry: &LanguageRegistry,
|
||||
) -> Option<FileSymbols> {
|
||||
use crate::index_manager::MAX_INDEXABLE_FILE_SIZE;
|
||||
|
||||
let lang_config = registry.for_file_path(path)?;
|
||||
let lang_id = lang_config.primary_language_id().to_string();
|
||||
|
||||
let metadata = fs::metadata(path).ok()?;
|
||||
if metadata.len() == 0 || metadata.len() > MAX_INDEXABLE_FILE_SIZE {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Prefix-read binary check: only reads 8KB, not the whole file
|
||||
{
|
||||
use std::io::Read;
|
||||
let mut f = fs::File::open(path).ok()?;
|
||||
let mut buf = [0u8; 8000];
|
||||
let n = f.read(&mut buf).ok()?;
|
||||
if buf[..n].contains(&0) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let content = fs::read(path).ok()?;
|
||||
|
||||
// Parse using thread-local cached parser
|
||||
let tree = PARSER_CACHE.with(|cache| {
|
||||
let mut cache = cache.borrow_mut();
|
||||
let parser = cache.entry(lang_id.clone()).or_insert_with(|| {
|
||||
let mut p = tree_sitter::Parser::new();
|
||||
let ts_lang = lang_config.language();
|
||||
let _ = p.set_language(&ts_lang);
|
||||
p
|
||||
});
|
||||
parser.parse(&content, None)
|
||||
})?;
|
||||
|
||||
let root_node = tree.root_node();
|
||||
|
||||
// Extract symbols using thread-local cached query
|
||||
let (definitions, references, aliases) = QUERY_CACHE.with(|cache| {
|
||||
let mut cache = cache.borrow_mut();
|
||||
let query = cache.entry(lang_id.clone()).or_insert_with(|| {
|
||||
lang_config.compile_query().unwrap_or_else(|_| {
|
||||
let ts_lang = lang_config.language();
|
||||
tree_sitter::Query::new(&ts_lang, "").expect("empty query should always work")
|
||||
})
|
||||
});
|
||||
extract_symbols_fast_inline(query, root_node, &content)
|
||||
});
|
||||
|
||||
// Reuse metadata from the size check above (no re-stat needed)
|
||||
|
||||
// Convert absolute path to relative for portable storage
|
||||
let rel_path = to_relative_path(root_path, path);
|
||||
|
||||
Some(FileSymbols {
|
||||
path: rel_path.to_string_lossy().into(),
|
||||
definitions,
|
||||
references,
|
||||
aliases,
|
||||
file_meta: FileMeta::from_metadata(&metadata),
|
||||
})
|
||||
}
|
||||
|
||||
/// Lightweight symbol extraction - returns proper typed vectors.
|
||||
/// Inlined for maximum performance (avoids function call overhead in hot loop).
|
||||
#[inline]
|
||||
fn extract_symbols_fast_inline(
|
||||
query: &tree_sitter::Query,
|
||||
root_node: tree_sitter::Node<'_>,
|
||||
src: &[u8],
|
||||
) -> (
|
||||
Vec<SymbolOccurrence>,
|
||||
Vec<SymbolOccurrence>,
|
||||
Vec<SymbolAlias>,
|
||||
) {
|
||||
use tree_sitter::StreamingIterator;
|
||||
|
||||
// Pre-compute capture indices for fast lookup
|
||||
let capture_names = query.capture_names();
|
||||
let mut is_def = vec![false; capture_names.len()];
|
||||
let mut is_ref = vec![false; capture_names.len()];
|
||||
let mut alias_original_idx: Option<usize> = None;
|
||||
let mut alias_name_idx: Option<usize> = None;
|
||||
|
||||
for (i, name) in capture_names.iter().enumerate() {
|
||||
if name.starts_with("name.definition.") {
|
||||
is_def[i] = true;
|
||||
} else if name.starts_with("name.reference.") {
|
||||
is_ref[i] = true;
|
||||
} else if *name == "alias.original" {
|
||||
alias_original_idx = Some(i);
|
||||
} else if *name == "alias.name" {
|
||||
alias_name_idx = Some(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-allocate with reasonable capacity
|
||||
let mut definitions: Vec<SymbolOccurrence> = Vec::with_capacity(64);
|
||||
let mut references: Vec<SymbolOccurrence> = Vec::with_capacity(256);
|
||||
let mut aliases: Vec<SymbolAlias> = Vec::with_capacity(8);
|
||||
|
||||
let mut cursor = tree_sitter::QueryCursor::new();
|
||||
let mut matches = cursor.matches(query, root_node, src);
|
||||
|
||||
while let Some(match_) = matches.next() {
|
||||
let mut alias_original: Option<&[u8]> = None;
|
||||
let mut alias_name: Option<&[u8]> = None;
|
||||
|
||||
for capture in match_.captures {
|
||||
let idx = capture.index as usize;
|
||||
let node = capture.node;
|
||||
let byte_range = node.byte_range();
|
||||
|
||||
if is_def.get(idx).copied().unwrap_or(false) {
|
||||
// Convert Cow<str> directly to Arc<str> - avoids intermediate String allocation
|
||||
let text: Arc<str> = String::from_utf8_lossy(&src[byte_range]).into();
|
||||
// Line numbers are 1-indexed
|
||||
definitions.push(SymbolOccurrence::new(text, node.start_position().row + 1));
|
||||
} else if is_ref.get(idx).copied().unwrap_or(false) {
|
||||
let text: Arc<str> = String::from_utf8_lossy(&src[byte_range]).into();
|
||||
references.push(SymbolOccurrence::new(text, node.start_position().row + 1));
|
||||
} else if Some(idx) == alias_original_idx {
|
||||
alias_original = Some(&src[byte_range]);
|
||||
} else if Some(idx) == alias_name_idx {
|
||||
alias_name = Some(&src[byte_range]);
|
||||
}
|
||||
}
|
||||
|
||||
if let (Some(original), Some(alias)) = (alias_original, alias_name) {
|
||||
// Convert Cow<str> directly to Arc<str> - avoids intermediate String allocation
|
||||
let orig_arc: Arc<str> = String::from_utf8_lossy(original).into();
|
||||
let alias_arc: Arc<str> = String::from_utf8_lossy(alias).into();
|
||||
aliases.push(SymbolAlias::new(alias_arc, orig_arc));
|
||||
}
|
||||
}
|
||||
|
||||
(definitions, references, aliases)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Index caching for fast loading.
|
||||
//!
|
||||
//! Uses a custom binary format with magic bytes "SGIX" for the new interned format.
|
||||
//! Automatically detects and skips legacy bincode format (returns error so caller can rebuild).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::scope_graph::ScopeGraphIndex;
|
||||
|
||||
/// Default cache file name.
|
||||
pub const CACHE_FILE_NAME: &str = ".goto_index.bin";
|
||||
|
||||
/// Error type for cache operations.
|
||||
#[derive(Debug)]
|
||||
pub enum CacheError {
|
||||
/// IO error.
|
||||
IoError(std::io::Error),
|
||||
/// Serialization error.
|
||||
SerializeError(String),
|
||||
/// Deserialization error.
|
||||
DeserializeError(String),
|
||||
/// Legacy format detected (caller should rebuild).
|
||||
LegacyFormat,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CacheError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CacheError::IoError(e) => write!(f, "IO error: {}", e),
|
||||
CacheError::SerializeError(msg) => write!(f, "Serialization error: {}", msg),
|
||||
CacheError::DeserializeError(msg) => write!(f, "Deserialization error: {}", msg),
|
||||
CacheError::LegacyFormat => write!(f, "Legacy cache format detected"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CacheError {}
|
||||
|
||||
impl From<std::io::Error> for CacheError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
CacheError::IoError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Result type for cache operations.
|
||||
pub type Result<T> = std::result::Result<T, CacheError>;
|
||||
|
||||
/// Get the default cache path for a repository.
|
||||
pub fn get_cache_path(root_path: &Path) -> std::path::PathBuf {
|
||||
root_path.join(CACHE_FILE_NAME)
|
||||
}
|
||||
|
||||
/// Load an index from cache.
|
||||
///
|
||||
/// Uses the new binary format with magic bytes "SGIX".
|
||||
/// Returns `CacheError::LegacyFormat` if the file uses the old bincode format,
|
||||
/// signaling to the caller that a rebuild is needed.
|
||||
pub fn load_index(cache_path: &Path) -> Result<ScopeGraphIndex> {
|
||||
if !cache_path.exists() {
|
||||
return Err(CacheError::IoError(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"Cache file not found",
|
||||
)));
|
||||
}
|
||||
|
||||
// Use ScopeGraphIndex::load which handles format detection
|
||||
match ScopeGraphIndex::load(cache_path) {
|
||||
Ok(Some(index)) => Ok(index),
|
||||
Ok(None) => {
|
||||
// None means legacy format was detected
|
||||
tracing::info!(
|
||||
cache_path = %cache_path.display(),
|
||||
"Legacy cache format detected, will rebuild"
|
||||
);
|
||||
Err(CacheError::LegacyFormat)
|
||||
}
|
||||
Err(e) => Err(CacheError::IoError(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Save an index to cache using the new binary format.
|
||||
pub fn save_index(cache_path: &Path, index: &ScopeGraphIndex) -> Result<()> {
|
||||
index.save(cache_path).map_err(CacheError::IoError)
|
||||
}
|
||||
|
||||
/// Save an index to cache asynchronously (in a background thread).
|
||||
///
|
||||
/// Returns immediately and spawns a thread to do the actual saving.
|
||||
/// Useful for saving the index without blocking the main thread.
|
||||
pub fn save_index_async(cache_path: std::path::PathBuf, index: ScopeGraphIndex) {
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = save_index(&cache_path, &index) {
|
||||
tracing::warn!("Failed to save index cache: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Check if a cache exists and return its metadata.
|
||||
pub fn cache_exists(cache_path: &Path) -> bool {
|
||||
cache_path.exists()
|
||||
}
|
||||
|
||||
/// Get cache file size in bytes.
|
||||
pub fn cache_size(cache_path: &Path) -> Option<u64> {
|
||||
std::fs::metadata(cache_path).ok().map(|m| m.len())
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
//! Workspace-level locking for index operations.
|
||||
//!
|
||||
//! Provides both in-memory (same-process) and file-based (cross-process)
|
||||
//! coordination to prevent redundant index operations on the same workspace.
|
||||
//!
|
||||
//! ## Design
|
||||
//!
|
||||
//! - **In-memory locks**: Fast path for same-process deduplication using a global registry
|
||||
//! - **File locks**: Cross-process coordination using lock files with PID and timestamp
|
||||
//! - **Stale detection**: Locks are considered stale if the holding process is dead or timeout exceeded
|
||||
//!
|
||||
//! ## Lock Types
|
||||
//!
|
||||
//! - **Shared (Load)**: Multiple readers allowed, blocked during exclusive operations
|
||||
//! - **Exclusive (Save/Build/Refresh)**: Single writer, blocks all other operations
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use dashmap::DashMap;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
const LOAD_STALE_DURATION_SEC: u64 = 120;
|
||||
const SAVE_STALE_DURATION_SEC: u64 = 120;
|
||||
const BUILD_STALE_DURATION_SEC: u64 = 600;
|
||||
const BG_REFRESH_STALE_DURATION_SEC: u64 = 300;
|
||||
|
||||
/// Operations that require locking.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum IndexOperation {
|
||||
/// Loading index from cache (shared/read lock).
|
||||
Load,
|
||||
/// Saving index to cache (exclusive).
|
||||
Save,
|
||||
/// Building index from scratch (exclusive).
|
||||
Build,
|
||||
/// Background validation and refresh (exclusive).
|
||||
BackgroundRefresh,
|
||||
}
|
||||
|
||||
impl IndexOperation {
|
||||
/// String representation for lock file.
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Load => "load",
|
||||
Self::Save => "save",
|
||||
Self::Build => "build",
|
||||
Self::BackgroundRefresh => "background_refresh",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this operation requires exclusive access.
|
||||
pub fn is_exclusive(&self) -> bool {
|
||||
match self {
|
||||
Self::Load => false, // Shared/read access
|
||||
Self::Save | Self::Build | Self::BackgroundRefresh => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Timeout after which a lock is considered stale.
|
||||
fn stale_timeout(&self) -> Duration {
|
||||
match self {
|
||||
Self::Load => Duration::from_secs(LOAD_STALE_DURATION_SEC),
|
||||
Self::Save => Duration::from_secs(SAVE_STALE_DURATION_SEC),
|
||||
Self::Build => Duration::from_secs(BUILD_STALE_DURATION_SEC),
|
||||
Self::BackgroundRefresh => Duration::from_secs(BG_REFRESH_STALE_DURATION_SEC),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for IndexOperation {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory lock state for same-process deduplication.
|
||||
struct InMemoryLockState {
|
||||
operation: IndexOperation,
|
||||
readers: usize, // Count for shared locks
|
||||
exclusive: bool, // Whether an exclusive lock is held
|
||||
}
|
||||
|
||||
/// Global registry of in-memory locks (same process).
|
||||
/// Uses DashMap for lock-free concurrent access.
|
||||
static IN_MEMORY_LOCKS: Lazy<DashMap<PathBuf, InMemoryLockState>> = Lazy::new(DashMap::new);
|
||||
|
||||
/// A guard that releases the lock when dropped.
|
||||
pub struct WorkspaceLockGuard {
|
||||
workspace: PathBuf,
|
||||
lock_file_path: PathBuf,
|
||||
operation: IndexOperation,
|
||||
}
|
||||
|
||||
impl Drop for WorkspaceLockGuard {
|
||||
fn drop(&mut self) {
|
||||
// Release in-memory lock
|
||||
release_in_memory_lock(&self.workspace, self.operation);
|
||||
|
||||
// Release file lock (only for exclusive operations)
|
||||
if self.operation.is_exclusive()
|
||||
&& let Err(e) = std::fs::remove_file(&self.lock_file_path)
|
||||
&& e.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
tracing::warn!(
|
||||
path = %self.lock_file_path.display(),
|
||||
error = %e,
|
||||
"Failed to remove lock file"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of trying to acquire a lock.
|
||||
pub enum LockResult {
|
||||
/// Lock acquired successfully.
|
||||
Acquired(WorkspaceLockGuard),
|
||||
/// Another operation is in progress.
|
||||
Busy {
|
||||
/// Description of the blocking operation.
|
||||
operation: String,
|
||||
/// PID of the process holding the lock (if known).
|
||||
holder_pid: Option<u32>,
|
||||
},
|
||||
}
|
||||
|
||||
impl LockResult {
|
||||
/// Returns true if the lock was acquired.
|
||||
pub fn is_acquired(&self) -> bool {
|
||||
matches!(self, Self::Acquired(_))
|
||||
}
|
||||
|
||||
/// Unwrap the guard, panicking if busy.
|
||||
pub fn unwrap(self) -> WorkspaceLockGuard {
|
||||
match self {
|
||||
Self::Acquired(guard) => guard,
|
||||
Self::Busy { operation, .. } => {
|
||||
panic!("Lock was busy: {}", operation)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to acquire a lock for an index operation on a workspace.
|
||||
///
|
||||
/// Returns `LockResult::Acquired` with a guard if successful, or `LockResult::Busy`
|
||||
/// if another operation is in progress.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `workspace` - The workspace root path
|
||||
/// * `operation` - The type of operation to perform
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// use kigi_codebase_graph::manager::lock::{try_lock, IndexOperation, LockResult};
|
||||
///
|
||||
/// let workspace = Path::new("/path/to/workspace");
|
||||
/// match try_lock(workspace, IndexOperation::Build) {
|
||||
/// LockResult::Acquired(guard) => {
|
||||
/// // Do work...
|
||||
/// // Lock is released when guard is dropped
|
||||
/// }
|
||||
/// LockResult::Busy { operation, holder_pid } => {
|
||||
/// println!("Busy: {} by pid {:?}", operation, holder_pid);
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub fn try_lock(workspace: &Path, operation: IndexOperation) -> LockResult {
|
||||
let workspace = canonicalize_workspace(workspace);
|
||||
let lock_file_path = get_lock_file_path(&workspace);
|
||||
|
||||
// Step 1: Check/acquire in-memory lock (fast path for same process)
|
||||
if !try_acquire_in_memory_lock(&workspace, operation) {
|
||||
tracing::debug!(
|
||||
workspace = %workspace.display(),
|
||||
operation = %operation,
|
||||
"In-memory lock busy"
|
||||
);
|
||||
return LockResult::Busy {
|
||||
operation: format!("{} (same process)", operation),
|
||||
holder_pid: Some(std::process::id()),
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: For exclusive operations, also acquire file lock (cross-process)
|
||||
if operation.is_exclusive() {
|
||||
match try_acquire_file_lock(&lock_file_path, operation) {
|
||||
Ok(()) => {
|
||||
tracing::debug!(
|
||||
workspace = %workspace.display(),
|
||||
operation = %operation,
|
||||
lock_file = %lock_file_path.display(),
|
||||
"Acquired exclusive lock"
|
||||
);
|
||||
}
|
||||
Err((op, pid)) => {
|
||||
// Release in-memory lock since we failed to get file lock
|
||||
release_in_memory_lock(&workspace, operation);
|
||||
tracing::debug!(
|
||||
workspace = %workspace.display(),
|
||||
operation = %operation,
|
||||
blocking_op = %op,
|
||||
blocking_pid = ?pid,
|
||||
"File lock busy"
|
||||
);
|
||||
return LockResult::Busy {
|
||||
operation: op,
|
||||
holder_pid: pid,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LockResult::Acquired(WorkspaceLockGuard {
|
||||
workspace,
|
||||
lock_file_path,
|
||||
operation,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if an operation is currently in progress for a workspace.
|
||||
///
|
||||
/// This is a non-blocking check that doesn't acquire any locks.
|
||||
pub fn is_operation_in_progress(workspace: &Path, operation: IndexOperation) -> bool {
|
||||
let workspace = canonicalize_workspace(workspace);
|
||||
|
||||
// Check in-memory first
|
||||
if let Some(state) = IN_MEMORY_LOCKS.get(&workspace) {
|
||||
if operation.is_exclusive() {
|
||||
if state.readers > 0 || state.exclusive {
|
||||
return true;
|
||||
}
|
||||
} else if state.exclusive {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check file lock for exclusive operations
|
||||
if operation.is_exclusive()
|
||||
&& let Ok(contents) = std::fs::read_to_string(get_lock_file_path(&workspace))
|
||||
&& let Some((_, pid, started)) = parse_lock_file(&contents)
|
||||
{
|
||||
let age = SystemTime::now()
|
||||
.duration_since(started)
|
||||
.unwrap_or(Duration::ZERO);
|
||||
if age < operation.stale_timeout() && is_process_alive(pid) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Canonicalize workspace path for consistent lock keys.
|
||||
fn canonicalize_workspace(workspace: &Path) -> PathBuf {
|
||||
// Try to canonicalize, fall back to the original path
|
||||
dunce::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf())
|
||||
}
|
||||
|
||||
/// Get the lock file path for a workspace.
|
||||
fn get_lock_file_path(workspace: &Path) -> PathBuf {
|
||||
// Use the cache directory (same as where .goto_index.bin is stored)
|
||||
let cache_path = super::get_cache_path(workspace);
|
||||
cache_path.with_extension("lock")
|
||||
}
|
||||
|
||||
/// Try to acquire an in-memory lock for same-process deduplication.
|
||||
fn try_acquire_in_memory_lock(workspace: &Path, operation: IndexOperation) -> bool {
|
||||
// Use entry API for atomic check-and-modify
|
||||
match IN_MEMORY_LOCKS.entry(workspace.to_path_buf()) {
|
||||
dashmap::mapref::entry::Entry::Occupied(mut entry) => {
|
||||
let state = entry.get_mut();
|
||||
if operation.is_exclusive() {
|
||||
// Exclusive operation - must have no readers or existing exclusive
|
||||
if state.readers > 0 || state.exclusive {
|
||||
return false;
|
||||
}
|
||||
state.exclusive = true;
|
||||
state.operation = operation;
|
||||
} else {
|
||||
// Shared operation (Load) - OK if no exclusive lock
|
||||
if state.exclusive {
|
||||
return false;
|
||||
}
|
||||
state.readers += 1;
|
||||
}
|
||||
}
|
||||
dashmap::mapref::entry::Entry::Vacant(entry) => {
|
||||
// No existing lock - create one
|
||||
entry.insert(InMemoryLockState {
|
||||
operation,
|
||||
readers: if operation.is_exclusive() { 0 } else { 1 },
|
||||
exclusive: operation.is_exclusive(),
|
||||
});
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Release an in-memory lock.
|
||||
fn release_in_memory_lock(workspace: &Path, operation: IndexOperation) {
|
||||
// Use entry API for atomic check-and-modify
|
||||
if let dashmap::mapref::entry::Entry::Occupied(mut entry) =
|
||||
IN_MEMORY_LOCKS.entry(workspace.to_path_buf())
|
||||
{
|
||||
let should_remove = {
|
||||
let state = entry.get_mut();
|
||||
if operation.is_exclusive() {
|
||||
state.exclusive = false;
|
||||
} else {
|
||||
state.readers = state.readers.saturating_sub(1);
|
||||
}
|
||||
// Check if we should remove the entry
|
||||
!state.exclusive && state.readers == 0
|
||||
};
|
||||
|
||||
if should_remove {
|
||||
entry.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to acquire a file-based lock for cross-process coordination.
|
||||
fn try_acquire_file_lock(
|
||||
lock_path: &Path,
|
||||
operation: IndexOperation,
|
||||
) -> Result<(), (String, Option<u32>)> {
|
||||
// Check if existing lock file is valid
|
||||
if let Ok(contents) = std::fs::read_to_string(lock_path)
|
||||
&& let Some((op, pid, started)) = parse_lock_file(&contents)
|
||||
{
|
||||
// Check if lock is stale
|
||||
let age = SystemTime::now()
|
||||
.duration_since(started)
|
||||
.unwrap_or(Duration::ZERO);
|
||||
|
||||
if age < operation.stale_timeout() && is_process_alive(pid) {
|
||||
return Err((op, Some(pid)));
|
||||
}
|
||||
// Lock is stale - we can take over
|
||||
tracing::debug!(
|
||||
lock_path = %lock_path.display(),
|
||||
stale_op = %op,
|
||||
stale_pid = pid,
|
||||
age_secs = age.as_secs(),
|
||||
"Taking over stale lock"
|
||||
);
|
||||
}
|
||||
|
||||
// Create parent directory if needed
|
||||
if let Some(parent) = lock_path.parent()
|
||||
&& let Err(e) = std::fs::create_dir_all(parent)
|
||||
{
|
||||
tracing::warn!(
|
||||
path = %parent.display(),
|
||||
error = %e,
|
||||
"Failed to create lock directory"
|
||||
);
|
||||
}
|
||||
|
||||
// Write our lock file
|
||||
let contents = format!(
|
||||
"operation={}\npid={}\nstarted={}\nworkspace={}\n",
|
||||
operation.as_str(),
|
||||
std::process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs(),
|
||||
lock_path
|
||||
.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unknown")
|
||||
);
|
||||
|
||||
std::fs::write(lock_path, contents).map_err(|e| (format!("io_error: {}", e), None))
|
||||
}
|
||||
|
||||
/// Parse a lock file's contents.
|
||||
fn parse_lock_file(contents: &str) -> Option<(String, u32, SystemTime)> {
|
||||
let mut operation = None;
|
||||
let mut pid = None;
|
||||
let mut started = None;
|
||||
|
||||
for line in contents.lines() {
|
||||
if let Some(val) = line.strip_prefix("operation=") {
|
||||
operation = Some(val.to_string());
|
||||
} else if let Some(val) = line.strip_prefix("pid=") {
|
||||
pid = val.parse().ok();
|
||||
} else if let Some(val) = line.strip_prefix("started=")
|
||||
&& let Ok(secs) = val.parse::<u64>()
|
||||
{
|
||||
started = Some(UNIX_EPOCH + Duration::from_secs(secs));
|
||||
}
|
||||
}
|
||||
|
||||
match (operation, pid, started) {
|
||||
(Some(op), Some(p), Some(s)) => Some((op, p, s)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a process is still alive.
|
||||
#[cfg(unix)]
|
||||
fn is_process_alive(pid: u32) -> bool {
|
||||
// kill with signal 0 checks if process exists without sending a signal
|
||||
// Returns 0 if process exists and we have permission to send signals
|
||||
// Returns -1 with ESRCH if process doesn't exist
|
||||
unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn is_process_alive(_pid: u32) -> bool {
|
||||
// On non-Unix platforms, rely on timeout-based stale detection
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_exclusive_lock_blocks_exclusive() {
|
||||
let dir = tempdir().unwrap();
|
||||
let workspace = dir.path();
|
||||
|
||||
// Acquire first exclusive lock
|
||||
let guard1 = try_lock(workspace, IndexOperation::Build);
|
||||
assert!(guard1.is_acquired());
|
||||
|
||||
// Second exclusive lock should fail
|
||||
let result2 = try_lock(workspace, IndexOperation::Build);
|
||||
assert!(!result2.is_acquired());
|
||||
|
||||
// Drop first lock
|
||||
drop(guard1);
|
||||
|
||||
// Now second should succeed
|
||||
let guard3 = try_lock(workspace, IndexOperation::Build);
|
||||
assert!(guard3.is_acquired());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shared_locks_coexist() {
|
||||
let dir = tempdir().unwrap();
|
||||
let workspace = dir.path();
|
||||
|
||||
// Multiple shared locks should work
|
||||
let guard1 = try_lock(workspace, IndexOperation::Load);
|
||||
assert!(guard1.is_acquired());
|
||||
|
||||
let guard2 = try_lock(workspace, IndexOperation::Load);
|
||||
assert!(guard2.is_acquired());
|
||||
|
||||
let guard3 = try_lock(workspace, IndexOperation::Load);
|
||||
assert!(guard3.is_acquired());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exclusive_blocks_shared() {
|
||||
let dir = tempdir().unwrap();
|
||||
let workspace = dir.path();
|
||||
|
||||
// Acquire exclusive lock
|
||||
let guard1 = try_lock(workspace, IndexOperation::Build);
|
||||
assert!(guard1.is_acquired());
|
||||
|
||||
// Shared lock should fail
|
||||
let result2 = try_lock(workspace, IndexOperation::Load);
|
||||
assert!(!result2.is_acquired());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shared_blocks_exclusive() {
|
||||
let dir = tempdir().unwrap();
|
||||
let workspace = dir.path();
|
||||
|
||||
// Acquire shared lock
|
||||
let guard1 = try_lock(workspace, IndexOperation::Load);
|
||||
assert!(guard1.is_acquired());
|
||||
|
||||
// Exclusive lock should fail
|
||||
let result2 = try_lock(workspace, IndexOperation::Build);
|
||||
assert!(!result2.is_acquired());
|
||||
|
||||
// Drop shared lock
|
||||
drop(guard1);
|
||||
|
||||
// Now exclusive should succeed
|
||||
let guard3 = try_lock(workspace, IndexOperation::Build);
|
||||
assert!(guard3.is_acquired());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_workspaces_independent() {
|
||||
let dir1 = tempdir().unwrap();
|
||||
let dir2 = tempdir().unwrap();
|
||||
|
||||
// Locks on different workspaces should be independent
|
||||
let guard1 = try_lock(dir1.path(), IndexOperation::Build);
|
||||
assert!(guard1.is_acquired());
|
||||
|
||||
let guard2 = try_lock(dir2.path(), IndexOperation::Build);
|
||||
assert!(guard2.is_acquired());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lock_file_created_for_exclusive() {
|
||||
let dir = tempdir().unwrap();
|
||||
let workspace = dir.path();
|
||||
|
||||
let lock_file = get_lock_file_path(workspace);
|
||||
|
||||
// No lock file initially
|
||||
assert!(!lock_file.exists());
|
||||
|
||||
// Acquire exclusive lock
|
||||
let guard = try_lock(workspace, IndexOperation::Build);
|
||||
assert!(guard.is_acquired());
|
||||
|
||||
// Lock file should exist
|
||||
assert!(lock_file.exists());
|
||||
|
||||
// Check contents
|
||||
let contents = std::fs::read_to_string(&lock_file).unwrap();
|
||||
assert!(contents.contains("operation=build"));
|
||||
assert!(contents.contains(&format!("pid={}", std::process::id())));
|
||||
|
||||
// Drop guard
|
||||
drop(guard);
|
||||
|
||||
// Lock file should be removed
|
||||
assert!(!lock_file.exists());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! Index management: building, caching, locking, and updating.
|
||||
|
||||
mod builder;
|
||||
pub mod cache;
|
||||
pub mod lock;
|
||||
|
||||
pub use builder::{IndexBuilder, IndexError, Result};
|
||||
pub use cache::{
|
||||
CACHE_FILE_NAME, CacheError, cache_exists, cache_size, get_cache_path, load_index, save_index,
|
||||
save_index_async,
|
||||
};
|
||||
pub use lock::{
|
||||
IndexOperation, LockResult, WorkspaceLockGuard, is_operation_in_progress, try_lock,
|
||||
};
|
||||
Reference in New Issue
Block a user