Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
540 lines
17 KiB
Rust
540 lines
17 KiB
Rust
//! 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 {
|
|
// Shared/read access
|
|
Self::Load => false,
|
|
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,
|
|
// Count for shared locks
|
|
readers: usize,
|
|
// Whether an exclusive lock is held
|
|
exclusive: bool,
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
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);
|
|
|
|
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);
|
|
|
|
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());
|
|
}
|
|
}
|