Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ ratatui = "0.30.0"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
uuid = { version = "1.22.0", features = ["v4", "serde"] }

[dev-dependencies]
tempfile = "3"
9 changes: 3 additions & 6 deletions src/core/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use std::process::{Command, ExitStatus};

use crate::core::git::{self, RepoContext};
use crate::core::restack::{self, RestackPreview};
use crate::core::store::lock::StoreLock;
use crate::core::store::{
BranchDivergenceState, PendingCommitEntry, PendingCommitOperation, PendingOperationKind,
PendingOperationState, StoreSession, dagger_paths, load_config, load_state, open_initialized,
Expand Down Expand Up @@ -299,19 +300,15 @@ fn maybe_restack_after_commit_inner(
return Ok(PostCommitRestackOutcome::default());
}

let lock = StoreLock::acquire(&store_paths.root)?;
let state = load_state(&store_paths)?;
let Some(node) = state.find_branch_by_name(current_branch).cloned() else {
return Ok(PostCommitRestackOutcome::default());
};

let actions =
restack::plan_after_branch_advance(&state, node.id, &node.branch_name, old_head_oid)?;
let mut session = StoreSession {
repo: context.repo.clone(),
paths: store_paths,
config,
state,
};
let mut session = StoreSession::from_lock(context.repo.clone(), store_paths, config, state, lock);
record_branch_divergence_state(&mut session, node.id, BranchDivergenceState::Diverged)?;
Comment on lines 309 to 312
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StoreSession::from_parts acquires the store lock only at this point, but load_state and plan_after_branch_advance were already done earlier without holding the lock. That means this code can still read state.json while another dgr process holds the lock (and may be mid-write), and the planned restack actions may be based on stale state. Consider acquiring the lock before reading state / planning (or pass an already-acquired StoreLock into session construction) and reloading state after the lock is held.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the latest push.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b5373c0. The lock is now acquired in maybe_restack_after_commit_inner before load_state, and the pre-acquired lock is passed into the session via the new StoreSession::from_lock constructor. Note that load_config remains outside the lock because the config is written once during init and is effectively immutable after that—it is never modified by concurrent operations, so reading it without the lock is safe. The mutable state.json is the resource that needs serialization, and that is now fully covered.

let restack_outcome = match workflow::execute_resumable_restack_operation(
&mut session,
Expand Down
83 changes: 83 additions & 0 deletions src/core/store/lock.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
use std::fs;
use std::io;
use std::path::{Path, PathBuf};

/// An advisory lock file that prevents concurrent dagger operations.
/// The lock is released (file deleted) when the guard is dropped.
pub struct StoreLock {
lock_path: PathBuf,
}

impl StoreLock {
/// Acquire an advisory lock by creating a lock file.
/// Returns an error if another process holds the lock.
pub fn acquire(dagger_root: &Path) -> io::Result<Self> {
fs::create_dir_all(dagger_root)?;
let lock_path = dagger_root.join("lock");

// Try to create the lock file exclusively.
// O_CREAT | O_EXCL ensures atomic creation — fails if file already exists.
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock_path)
Comment on lines +14 to +23
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StoreLock::acquire assumes dagger_root already exists. That prevents taking the lock before store initialization (e.g., in open_or_initialize) and will fail with NotFound if the .dagger dir is missing. Consider creating the directory (e.g., fs::create_dir_all(dagger_root)?) before attempting create_new so locking can protect initialization too.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the latest push.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b5373c0. StoreLock::acquire now calls fs::create_dir_all(dagger_root) before attempting to create the lock file, so the lock can be taken even before the store directory is fully initialized.

{
Ok(mut file) => {
use std::io::Write;
let _ = writeln!(file, "{}", std::process::id());
Ok(Self { lock_path })
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Err(io::Error::other(format!(
"another dgr process appears to be running; \
if this is incorrect, delete '{}'",
lock_path.display()
))),
Err(e) => Err(e),
}
}
}

impl Drop for StoreLock {
fn drop(&mut self) {
let _ = fs::remove_file(&self.lock_path);
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn acquire_creates_lock_file() {
let dir = tempfile::tempdir().unwrap();
let lock = StoreLock::acquire(dir.path()).unwrap();
assert!(dir.path().join("lock").exists());
drop(lock);
}

#[test]
fn drop_removes_lock_file() {
let dir = tempfile::tempdir().unwrap();
let lock = StoreLock::acquire(dir.path()).unwrap();
drop(lock);
assert!(!dir.path().join("lock").exists());
}

#[test]
fn second_acquire_fails_while_held() {
let dir = tempfile::tempdir().unwrap();
let _lock = StoreLock::acquire(dir.path()).unwrap();
let result = StoreLock::acquire(dir.path());
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("another dgr process"));
}

#[test]
fn acquire_succeeds_after_release() {
let dir = tempfile::tempdir().unwrap();
let lock = StoreLock::acquire(dir.path()).unwrap();
drop(lock);
let _lock2 = StoreLock::acquire(dir.path()).unwrap();
}
}
1 change: 1 addition & 0 deletions src/core/store/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ pub(crate) mod bootstrap;
pub(crate) mod config;
pub(crate) mod events;
pub(crate) mod fs;
pub(crate) mod lock;
pub(crate) mod mutations;
pub(crate) mod operation;
pub(crate) mod session;
Expand Down
37 changes: 36 additions & 1 deletion src/core/store/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,71 @@ use std::io;

use crate::core::git::{self, RepoContext};

use super::lock::StoreLock;
use super::{
DaggerConfig, StoreInitialization, dagger_paths, initialize_store, load_config, load_state,
};
use crate::core::store::fs::DaggerPaths;
use crate::core::store::types::DaggerState;

#[derive(Debug, Clone)]
pub struct StoreSession {
pub repo: RepoContext,
pub paths: DaggerPaths,
pub config: DaggerConfig,
pub state: DaggerState,
_lock: StoreLock,
}

impl StoreSession {
/// Build a session from pre-loaded parts with an already-acquired lock.
pub fn from_lock(
repo: RepoContext,
paths: DaggerPaths,
config: DaggerConfig,
state: DaggerState,
lock: StoreLock,
) -> Self {
Self {
repo,
paths,
config,
state,
_lock: lock,
}
}

/// Build a session from pre-loaded parts, acquiring the lock.
pub fn from_parts(
repo: RepoContext,
paths: DaggerPaths,
config: DaggerConfig,
state: DaggerState,
) -> io::Result<Self> {
let lock = StoreLock::acquire(&paths.root)?;
Ok(Self::from_lock(repo, paths, config, state, lock))
}
}

pub fn open_initialized(missing_message: &str) -> io::Result<StoreSession> {
let repo = git::resolve_repo_context()?;
let paths = dagger_paths(&repo.git_dir);
let config = load_config(&paths)?.ok_or_else(|| io::Error::other(missing_message))?;
let lock = StoreLock::acquire(&paths.root)?;
let state = load_state(&paths)?;

Ok(StoreSession {
repo,
paths,
config,
state,
_lock: lock,
})
}

pub fn open_or_initialize(trunk_branch: &str) -> io::Result<(StoreSession, StoreInitialization)> {
let repo = git::resolve_repo_context()?;
let paths = dagger_paths(&repo.git_dir);
let lock = StoreLock::acquire(&paths.root)?;
let store_initialization = initialize_store(&paths, trunk_branch)?;
let config =
load_config(&paths)?.ok_or_else(|| io::Error::other("dagger config is missing"))?;
Expand All @@ -44,6 +78,7 @@ pub fn open_or_initialize(trunk_branch: &str) -> io::Result<(StoreSession, Store
paths,
config,
state,
_lock: lock,
},
store_initialization,
))
Expand Down