Skip to content
60 changes: 60 additions & 0 deletions crates/infigraph-core/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
use std::path::Path;
use std::process::Command;

fn main() {
let sha = Command::new("git")
.args(["rev-parse", "--short=12", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());

let dirty = Command::new("git")
.args(["status", "--porcelain"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| !o.stdout.is_empty())
.unwrap_or(false);

let hash = match sha {
Some(s) if dirty => format!("{s}-dirty"),
Some(s) => s,
None => "unknown".to_string(),
};
println!("cargo:rustc-env=INFIGRAPH_BUILD_HASH={hash}");

// `.git/HEAD` alone only changes on a checkout of a different ref; an
// ordinary commit on the current branch instead updates
// `refs/heads/<branch>` (or `packed-refs`, once refs get packed), so all
// three must be watched or build_hash() keeps reporting the previous
// commit's SHA across normal dev-loop rebuilds. Resolve the git dir via
// `git rev-parse --git-dir` rather than assuming a fixed relative path;
// if git isn't available, skip rerun emission entirely -- this matches
// the "unknown" degradation above.
let git_dir = Command::new("git")
.args(["rev-parse", "--git-dir"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());

if let Some(git_dir) = git_dir {
let git_dir = Path::new(&git_dir);
let head_path = git_dir.join("HEAD");
println!("cargo:rerun-if-changed={}", head_path.display());

if let Ok(head_contents) = std::fs::read_to_string(&head_path) {
if let Some(ref_path) = head_contents.trim().strip_prefix("ref: ") {
println!(
"cargo:rerun-if-changed={}",
git_dir.join(ref_path).display()
);
println!(
"cargo:rerun-if-changed={}",
git_dir.join("packed-refs").display()
);
}
}
}
}
58 changes: 26 additions & 32 deletions crates/infigraph-core/src/graph/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,51 +2,39 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf};

use anyhow::Result;
use fs2::FileExt;
use kuzu::{Connection, Database, SystemConfig};

use super::schema::{CREATE_SCHEMA, MIGRATIONS};
use super::store_util::escape;
use crate::lockfile::{self, LockFile};

/// RAII guard for exclusive write access to the graph store.
/// Holds an advisory file lock on `<db_path>.lock`.
/// Holds an advisory file lock on `<db_path>.lock` with an identity
/// payload (see `crate::lockfile`).
#[derive(Debug)]
pub struct WriteLock {
_file: std::fs::File,
_guard: LockFile,
}

/// Role string stamped into the graph write lock's identity payload.
const GRAPH_WRITE_ROLE: &str = "graph-write";

/// Default wait budget for the graph write lock. Individual write calls
/// are short; 30s of waiting means something is wedged — surface it.
const GRAPH_WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);

impl WriteLock {
fn acquire(lock_path: &Path) -> Result<Self> {
if let Some(parent) = lock_path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(lock_path)?;
file.lock_exclusive()
.map_err(|e| anyhow::anyhow!("failed to acquire write lock: {e}"))?;
Ok(Self { _file: file })
Self::acquire_with_timeout(lock_path, GRAPH_WRITE_TIMEOUT)
}

fn acquire_with_timeout(lock_path: &Path, timeout: std::time::Duration) -> Result<Self> {
let guard = lockfile::acquire(lock_path, GRAPH_WRITE_ROLE, timeout)?;
Ok(Self { _guard: guard })
}

fn try_acquire(lock_path: &Path) -> Result<Option<Self>> {
if let Some(parent) = lock_path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(lock_path)?;
match file.try_lock_exclusive() {
Ok(()) => Ok(Some(Self { _file: file })),
Err(ref e)
if e.kind() == std::io::ErrorKind::WouldBlock || e.raw_os_error() == Some(33) =>
{
Ok(None)
}
Err(e) => Err(anyhow::anyhow!("lock error: {e}")),
}
Ok(lockfile::try_acquire(lock_path, GRAPH_WRITE_ROLE)?.map(|guard| Self { _guard: guard }))
}
}

Expand Down Expand Up @@ -82,11 +70,17 @@ impl GraphStore {
Ok(Self { db, lock_path })
}

/// Acquire exclusive write lock. Blocks until available.
/// Acquire exclusive write lock. Waits up to 30s, returning `Busy` if
/// still held at expiry.
pub fn write_lock(&self) -> Result<WriteLock> {
WriteLock::acquire(&self.lock_path)
}

/// Acquire the write lock with a caller-chosen wait budget.
pub fn write_lock_with_timeout(&self, timeout: std::time::Duration) -> Result<WriteLock> {
WriteLock::acquire_with_timeout(&self.lock_path, timeout)
}

/// Try to acquire write lock without blocking. Returns None if already held.
pub fn try_write_lock(&self) -> Result<Option<WriteLock>> {
WriteLock::try_acquire(&self.lock_path)
Expand Down
7 changes: 7 additions & 0 deletions crates/infigraph-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub mod extract;
pub mod graph;
pub mod lang;
pub mod learned;
pub mod lockfile;
pub mod manifest;
pub mod meta;
pub mod model;
Expand Down Expand Up @@ -48,6 +49,12 @@ pub(crate) fn escape_str(s: &str) -> String {
s.replace('\\', "\\\\").replace('\'', "\\'")
}

/// Short git SHA this binary was built from ("-dirty" when the tree had
/// uncommitted changes; "unknown" outside a git checkout). Stamped into
/// lock-file identity payloads so stale-binary holders are identifiable.
pub fn build_hash() -> &'static str {
env!("INFIGRAPH_BUILD_HASH")
}
/// The main entry point for the infigraph framework.
pub struct Infigraph {
root: PathBuf,
Expand Down
181 changes: 181 additions & 0 deletions crates/infigraph-core/src/lockfile.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
//! Lock-file mechanics shared by all infigraph locks (graph write lock,
//! and future operation/session/registry locks).
//!
//! Model: a kernel advisory flock (via `fs2`) is the source of truth for
//! "held" — flocks release automatically when the holder dies, so no
//! liveness polling is needed. The JSON identity payload written into the
//! lock file exists for diagnostics (who holds it, since when, built from
//! what) and is never trusted for liveness decisions. Conservative rule:
//! a held flock with an unreadable payload is an *unknown holder* —
//! bounded-wait then `Busy`, never broken.

use std::fs::File;
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

use anyhow::Result;
use fs2::FileExt;
use serde::{Deserialize, Serialize};

/// Identity payload stamped into a held lock file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockInfo {
pub pid: u32,
pub role: String,
pub build_hash: String,
/// Unix epoch seconds at acquisition.
pub acquired_at: u64,
}

impl LockInfo {
pub fn current(role: &str) -> Self {
Self {
pid: std::process::id(),
role: role.to_string(),
build_hash: crate::build_hash().to_string(),
acquired_at: now_epoch_secs(),
}
}
}

/// Current time as Unix epoch seconds. Falls back to 0 on a clock error
/// (e.g. system time before the epoch) rather than panicking.
fn now_epoch_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}

/// Returned when a lock could not be acquired within the wait budget.
#[derive(Debug)]
pub struct Busy {
pub lock_path: PathBuf,
pub holder: Option<LockInfo>,
pub waited: Duration,
}

impl std::fmt::Display for Busy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.holder {
Some(h) => {
let held_for = now_epoch_secs().saturating_sub(h.acquired_at);
write!(
f,
"{} is locked by {} (PID {}), held {}s — waited {}s, giving up",
self.lock_path.display(),
h.role,
h.pid,
held_for,
self.waited.as_secs()
)
}
None => write!(
f,
"{} is locked by an unknown holder — waited {}s, giving up",
self.lock_path.display(),
self.waited.as_secs()
),
}
}
}

impl std::error::Error for Busy {}

/// RAII guard for a held lock file. Releasing (drop) truncates the payload
/// then unlocks, so a cleanly-released lock file is empty.
#[derive(Debug)]
pub struct LockFile {
file: File,
path: PathBuf,
}

impl LockFile {
pub fn path(&self) -> &Path {
&self.path
}
}

impl Drop for LockFile {
fn drop(&mut self) {
// Best-effort: clear payload before the flock releases so readers
// never see a stale identity on a free lock we released cleanly.
let _ = self.file.set_len(0);
let _ = fs2::FileExt::unlock(&self.file);
}
}

fn open_lock_file(path: &Path) -> Result<File> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
Ok(std::fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(path)?)
}

fn stamp(file: &mut File, role: &str) -> Result<()> {
let info = LockInfo::current(role);
let json = serde_json::to_string(&info)?;
file.set_len(0)?;
file.seek(SeekFrom::Start(0))?;
file.write_all(json.as_bytes())?;
file.flush()?;
Ok(())
}

/// Best-effort read of the current holder's identity. `None` when the file
/// is missing, empty, or unparseable (old binary / mid-write).
pub fn read_holder(path: &Path) -> Option<LockInfo> {
let mut buf = String::new();
File::open(path).ok()?.read_to_string(&mut buf).ok()?;
serde_json::from_str(buf.trim()).ok()
}

/// Non-blocking acquisition. `Ok(None)` when another open file description
/// holds the flock. On success the identity payload is stamped.
pub fn try_acquire(path: &Path, role: &str) -> Result<Option<LockFile>> {
let mut file = open_lock_file(path)?;
match file.try_lock_exclusive() {
Ok(()) => {
stamp(&mut file, role)?;
Ok(Some(LockFile {
file,
path: path.to_path_buf(),
}))
}
Err(ref e)
if e.kind() == std::io::ErrorKind::WouldBlock || e.raw_os_error() == Some(33) =>
{
Ok(None)
}
Err(e) => Err(anyhow::anyhow!("lock error on {}: {e}", path.display())),
}
}

/// Blocking acquisition with a wait budget. Polls `try_acquire` with
/// backoff (1ms doubling to a 500ms cap). On expiry returns a `Busy`
/// error carrying the holder identity when the payload is readable.
pub fn acquire(path: &Path, role: &str, timeout: Duration) -> Result<LockFile> {
let start = Instant::now();
let mut delay = Duration::from_millis(1);
loop {
if let Some(guard) = try_acquire(path, role)? {
return Ok(guard);
}
if start.elapsed() >= timeout {
return Err(anyhow::Error::new(Busy {
lock_path: path.to_path_buf(),
holder: read_holder(path),
waited: start.elapsed(),
}));
}
let remaining = timeout.saturating_sub(start.elapsed());
std::thread::sleep(delay.min(remaining));
delay = (delay * 2).min(Duration::from_millis(500));
}
}
Loading
Loading