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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a
### Added
- Add new `crates/bender-slang` crate that integrates the vendored Slang parser via a Rust/C++ bridge.
- Add new `pickle` command (behind feature `slang`) to parse and re-emit SystemVerilog sources.
- Add cross-process filesystem locks around git database and checkout operations so concurrent `bender` invocations against the same dependency serialize safely.
- Add `db_dir` config field to share bare-repo and lock storage across projects without relocating per-project checkouts; older Bender versions silently ignore the field and fall back to their per-project default.

## 0.31.0 - 2026-03-03
### Fixed
Expand Down
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ indicatif = "0.18.3"
regex = "1.12.2"
log = "0.4"
env_logger = { version = "0.11", default-features = false, features = ["auto-color"] }
fs4 = "1.1"

[target.'cfg(windows)'.dependencies]
dunce = "1.0.4"
Expand Down
9 changes: 9 additions & 0 deletions book/src/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ The directory where Bender stores cloned and checked-out dependencies.
- **Default:** `.bender` in the project root.
- **Example:** `database: /var/cache/bender_dependencies`

### `db_dir`
Optional override for the directory that holds bare git repositories and their lock files (i.e. `<db_dir>/git/db/` and `<db_dir>/git/locks/`). When set, it takes precedence over `database` (whether explicitly configured or left at its default) for these two paths only; the working-tree checkouts continue to follow `database` (or [`workspace.checkout_dir`](./manifest.md) in the project manifest). This makes it possible to share the heavy git data across projects on a persistent runner without also relocating per-project checkouts. See [Continuous Integration › Sharing the Database](./workflow/ci.md#sharing-the-database-across-runs-and-projects) for the recommended setup.
- **Config Key:** `db_dir`
- **Env Var:** `BENDER_DB_DIR` (used only when no configuration file sets `db_dir`; configuration files always take precedence).
- **Default:** unset (falls back to `database`).
- **Example:** `db_dir: /var/cache/bender_shared`

> **Note:** Older Bender versions (pre-`db_dir`) silently ignore this field and fall back to their per-project default, so it is safe to ship in a shared configuration that mixed bender versions may read.

### `git`
The command or path used to invoke Git.
- **Config Key:** `git`
Expand Down
5 changes: 5 additions & 0 deletions book/src/local.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ For a detailed guide on using these commands for multi-package development, see
# Change the directory where dependencies are stored (default is .bender)
database: my_deps_cache

# Share only the bare git repos and lock files across projects, while
# keeping per-project checkouts under each project's own .bender/.
# Older Bender versions silently ignore this field.
db_dir: /var/cache/bender_shared

# Use a custom git binary or wrapper
git: /usr/local/bin/git-wrapper.sh
```
Expand Down
24 changes: 17 additions & 7 deletions book/src/workflow/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,16 +84,26 @@ cache:

## Sharing the Database Across Runs and Projects

When jobs run on a persistent runner, the `database` directory (where Bender stores cloned repositories) can be shared across CI runs and even across projects to drastically reduce fetch times and disk usage.
When jobs run on a persistent runner, the bare git repositories Bender clones can be shared across CI runs and even across projects to drastically reduce fetch times and disk usage. The recommended way is the [`db_dir`](../configuration.md#db_dir) setting, which relocates *only* the bare repos and lock files; per-project working-tree checkouts stay under each project's own `.bender/` directory and cannot collide.

To enable this, point Bender at a shared location via a [`Bender.local`](../local.md) file (for example placed in a parent directory of the project so it is picked up automatically):
Place a [`Bender.local`](../local.md) in a parent directory of your projects so it is picked up automatically:

```yaml
database: /var/cache/bender_shared
db_dir: /var/cache/bender_shared
```

This way, every job that runs in the runner reuses the already-fetched Git data instead of re-cloning from scratch. See [Configuration](../configuration.md) for more on the `database` setting.
Every job on the runner now reuses the already-fetched Git data and serializes safely against concurrent jobs via per-dependency filesystem locks living next to the bare repos (`<db_dir>/git/locks/`).

If you'd rather not place a `Bender.local` on the runner at all, exporting `BENDER_DB_DIR=/var/cache/bender_shared` in the job environment has the same effect — any project that explicitly sets `db_dir` in its own configuration overrides the env var.

> **Older Bender versions** silently ignore both `db_dir` and `BENDER_DB_DIR` and fall back to their normal per-project `.bender/` cache, so the shared config above is safe to deploy on a runner that still hosts pinned-to-old-bender projects — those projects simply won't benefit from the shared cache, but they won't misbehave either.

### Legacy `database:` recipe

Earlier docs recommended sharing via [`database`](../configuration.md#database), which relocates both the bare repos *and* the checkouts:

```yaml
database: /var/cache/bender_shared
```

> **Caveats:**
> - **Checkout collisions:** When sharing the database, checkouts from different projects can collide if the top-level [`Bender.yml`](../manifest.md) does not specify a project-specific `workspace.checkout_dir`. Make sure each top-level project sets its own `checkout_dir` so that checkouts remain isolated even when the database is shared.
> - **Concurrent runs:** Bender does not currently take a lock before performing Git operations on the shared database. Concurrent jobs that touch the same database may occasionally fail with Git errors. This is unlikely, but worth keeping in mind when sizing parallelism.
This still works, but it has a known footgun: two top-level projects that share the same name will collide on the same `<database>/git/checkouts/<name>-<hash>/` directory. If you keep this recipe, give each top-level project its own `workspace.checkout_dir` in [`Bender.yml`](../manifest.md) so the checkouts remain isolated. Prefer `db_dir` for new setups.
7 changes: 6 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -505,9 +505,14 @@ fn load_config(from: &Path, warn_config_loaded: bool) -> Result<Config> {
out = out.merge(cfg);
}

// Assemble and merge the default configuration.
// Assemble and merge the default configuration. Env-var-supplied `db_dir`
// lives here so that any configuration file value still wins via
// `PartialConfig::merge` (which uses `self.or(other)`).
let default_cfg = PartialConfig {
database: Some(from.join(".bender").to_str().unwrap().to_string()),
db_dir: std::env::var("BENDER_DB_DIR")
.ok()
.filter(|s| !s.is_empty()),
git: Some("git".into()),
overrides: None,
plugins: None,
Expand Down
81 changes: 43 additions & 38 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1177,7 +1177,7 @@ impl Validate for PartialSources {

let post_env_files: Vec<PartialSourceFile> = if let Some(fls) = files {
fls.into_iter()
.chain(external_flist_groups?.into_iter())
.chain(external_flist_groups?)
.filter_map(|file| match file {
PartialSourceFile::File(ref filename)
| PartialSourceFile::SvFile(ref filename)
Expand Down Expand Up @@ -1447,46 +1447,38 @@ impl GlobFile for PartialSourceFile {
PartialSourceFile::File(ref path)
| PartialSourceFile::SvFile(ref path)
| PartialSourceFile::VerilogFile(ref path)
| PartialSourceFile::VhdlFile(ref path) => {
// Check if glob patterns used
if path.contains("*") || path.contains("?") {
let glob_matches = glob(path)
.into_diagnostic()
.wrap_err_with(|| format!("Invalid glob pattern for {:?}", path))?;
let out = glob_matches
.map(|glob_match| {
let file_str = glob_match
.into_diagnostic()
.wrap_err_with(|| format!("Glob match failed for {:?}", path))?
.to_str()
.unwrap()
.to_string();
Ok(match self {
PartialSourceFile::File(_) => PartialSourceFile::File(file_str),
PartialSourceFile::SvFile(_) => PartialSourceFile::SvFile(file_str),
PartialSourceFile::VerilogFile(_) => {
PartialSourceFile::VerilogFile(file_str)
}
PartialSourceFile::VhdlFile(_) => {
PartialSourceFile::VhdlFile(file_str)
}
_ => unreachable!(),
})
| PartialSourceFile::VhdlFile(ref path)
if path.contains("*") || path.contains("?") =>
{
let glob_matches = glob(path)
.into_diagnostic()
.wrap_err_with(|| format!("Invalid glob pattern for {:?}", path))?;
let out = glob_matches
.map(|glob_match| {
let file_str = glob_match
.into_diagnostic()
.wrap_err_with(|| format!("Glob match failed for {:?}", path))?
.to_str()
.unwrap()
.to_string();
Ok(match self {
PartialSourceFile::File(_) => PartialSourceFile::File(file_str),
PartialSourceFile::SvFile(_) => PartialSourceFile::SvFile(file_str),
PartialSourceFile::VerilogFile(_) => {
PartialSourceFile::VerilogFile(file_str)
}
PartialSourceFile::VhdlFile(_) => PartialSourceFile::VhdlFile(file_str),
_ => unreachable!(),
})
.collect::<Result<Vec<PartialSourceFile>>>()?;
if out.is_empty() {
Warnings::NoFilesForGlobPattern { path: path.clone() }.emit();
}
Ok(out)
} else {
// Return self if not a glob pattern
Ok(vec![self])
})
.collect::<Result<Vec<PartialSourceFile>>>()?;
if out.is_empty() {
Warnings::NoFilesForGlobPattern { path: path.clone() }.emit();
}
Ok(out)
}
_ => {
// Return self if not a glob pattern
Ok(vec![self])
}
// Return self if not a glob pattern (also matches non-file variants).
_ => Ok(vec![self]),
}
}
}
Expand Down Expand Up @@ -1623,6 +1615,11 @@ where
pub struct Config {
/// The path to the database directory.
pub database: PathBuf,
/// Optional override for the directory containing bare git repositories
/// and their lock files. When set, takes precedence over `database` for
/// these two specific paths; checkouts and everything else still derive
/// from `database`.
pub db_dir: Option<PathBuf>,
/// The git command or path to the binary.
pub git: String,
/// The dependency overrides.
Expand All @@ -1640,6 +1637,8 @@ pub struct Config {
pub struct PartialConfig {
/// The path to the database directory.
pub database: Option<String>,
/// Optional override for the bare-repo and lock directory.
pub db_dir: Option<String>,
/// The git command or path to the binary.
pub git: Option<String>,
/// The dependency overrides.
Expand All @@ -1663,6 +1662,7 @@ impl PrefixPaths for PartialConfig {
fn prefix_paths(self, prefix: &Path) -> Result<Self> {
Ok(PartialConfig {
database: self.database.prefix_paths(prefix)?,
db_dir: self.db_dir.prefix_paths(prefix)?,
overrides: self.overrides.prefix_paths(prefix)?,
plugins: self.plugins.prefix_paths(prefix)?,
..self
Expand All @@ -1674,6 +1674,7 @@ impl Merge for PartialConfig {
fn merge(self, other: PartialConfig) -> PartialConfig {
PartialConfig {
database: self.database.or(other.database),
db_dir: self.db_dir.or(other.db_dir),
git: self.git.or(other.git),
overrides: match (self.overrides, other.overrides) {
(Some(o), None) | (None, Some(o)) => Some(o),
Expand Down Expand Up @@ -1710,6 +1711,10 @@ impl Validate for PartialConfig {
Some(db) => env_path_from_string(&db)?,
None => bail!("Database directory not configured"),
},
db_dir: match self.db_dir {
Some(d) => Some(env_path_from_string(&d)?),
None => None,
},
git: match self.git {
Some(git) => git,
None => bail!("Git command or path to binary not configured"),
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub mod cmd;
pub mod config;
pub mod diagnostic;
pub mod git;
pub mod lock;
pub mod lockfile;
pub mod progress;
pub mod resolver;
Expand Down
90 changes: 90 additions & 0 deletions src/lock.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Copyright (c) 2025 ETH Zurich

//! Cross-process filesystem advisory locks.
//!
//! Bender uses these to serialize concurrent invocations against the same git
//! database and checkout. The lock is taken on a sentinel file in
//! `<database>/git/locks/<name>-<hash>.lock` and released automatically when
//! the [`FsLock`] guard is dropped.

#![deny(missing_docs)]

use std::fs::{File, OpenOptions};
use std::path::{Path, PathBuf};

use fs4::{FileExt, TryLockError};
use miette::{Context as _, IntoDiagnostic as _};

use crate::Result;

/// An exclusive, cross-process advisory lock held on a sentinel file.
///
/// The lock is released when this guard is dropped (or when the process exits).
pub struct FsLock {
file: Option<File>,
path: PathBuf,
}

impl FsLock {
/// Acquire an exclusive lock on `path`, creating the file if missing.
///
/// If the lock is contended, an info message is logged so the user can see
/// why bender is waiting, and the call then blocks until the lock is
/// available. The actual lock acquisition runs on a blocking worker so it
/// does not stall the tokio runtime.
pub async fn acquire_exclusive(path: PathBuf) -> Result<Self> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.into_diagnostic()
.wrap_err_with(|| format!("Failed to create lock directory {:?}.", parent))?;
}
let file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&path)
.into_diagnostic()
.wrap_err_with(|| format!("Failed to open lock file {:?}.", path))?;

let path_for_blocking = path.clone();
let file = tokio::task::spawn_blocking(move || -> Result<File> {
match FileExt::try_lock(&file) {
Ok(()) => Ok(file),
Err(TryLockError::WouldBlock) => {
log::info!("waiting for lock on {:?}", path_for_blocking);
FileExt::lock(&file).into_diagnostic().wrap_err_with(|| {
format!("Failed to acquire lock on {:?}.", path_for_blocking)
})?;
Ok(file)
}
Err(TryLockError::Error(e)) => Err(e)
.into_diagnostic()
.wrap_err_with(|| format!("Failed to try-lock {:?}.", path_for_blocking)),
}
})
.await
.into_diagnostic()
.wrap_err("Lock acquisition task panicked.")??;

Ok(FsLock {
file: Some(file),
path,
})
}

/// The path of the lock file.
pub fn path(&self) -> &Path {
&self.path
}
}

impl Drop for FsLock {
fn drop(&mut self) {
if let Some(file) = self.file.take() {
// Best-effort: errors here are not actionable, and the OS releases
// the lock automatically when the file handle closes.
let _ = FileExt::unlock(&file);
}
}
}
Loading
Loading