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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

- Fixed `devenv --version` and `devenv -V` failing with `'devenv' requires a subcommand but one was not provided`. The flags now print the version and exit, matching the behavior of `devenv --help` ([#2791](https://github.com/cachix/devenv/issues/2791)).
- Fixed `devenv hook fish` not activating when starting a new fish shell directly inside a project directory. The initial activation now runs on the first `fish_prompt` event instead of inline during `source`, so the spawned `devenv shell` inherits the real terminal as stdin instead of the closed pipe from `devenv hook fish | source` ([#2798](https://github.com/cachix/devenv/issues/2798)).
- Fixed `devenv shell` self-triggering hot-reload in an infinite loop after the first reload. The eval cache (`.devenv/profiles/<profile>/nix-eval-cache.db`) and its SQLite WAL/SHM sidecars were being added to the reload watch set; every Nix evaluation rewrote the WAL and the watcher saw it as a content change. Devenv's own state directory is now excluded both from the reload watch set and from the eval cache's tracked input paths.

### Improvements

Expand Down
10 changes: 9 additions & 1 deletion devenv-nix-backend/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,15 @@ impl NixCBackend {
force_refresh: self.cache_settings.refresh_eval_cache,
extra_watch_paths: core_config_watch_paths(&self.paths.root),
excluded_envs: vec!["NIXPKGS_CONFIG".to_string()],
excluded_paths: vec![self.nixpkgs_config_path.clone()],
// Exclude devenv's own state dir. Its SQLite eval cache
// (and the WAL/SHM sidecars) lives under `.devenv/` and
// is rewritten on every evaluation; tracking it as an
// input poisons cache validity and lets the reload
// watcher self-trigger in a loop.
excluded_paths: vec![
self.nixpkgs_config_path.clone(),
self.paths.dotfile.clone(),
],
};
let service = CachingEvalService::with_config(pool.clone(), config.clone());
let invalidation_flag = self.devenv_value_invalidated.clone();
Expand Down
78 changes: 75 additions & 3 deletions devenv/src/reload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,24 @@ use devenv_core::config::Clean;
use devenv_reload::{BuildContext, BuildError, CommandBuilder, ShellBuilder};
use devenv_shell::dialect::{BashDialect, RcfileContext, ShellDialect, create_dialect};
use std::collections::BTreeMap;
use std::path::Path;
use std::sync::Arc;
use tokio::runtime::Handle;
use tokio::sync::Mutex;

/// Drop paths that should never end up in the reload watch set.
///
/// Excludes:
/// - missing files (likely deleted between eval and reload setup)
/// - `/nix/store` paths (immutable)
/// - anything inside devenv's own state dir (`.devenv/profiles/<profile>/`).
/// The eval cache lives there as a SQLite DB and its WAL/SHM files are
/// rewritten on every Nix evaluation. Watching them would let devenv
/// self-trigger reloads in an infinite loop.
fn is_watchable_input(path: &Path, dotfile: &Path) -> bool {
path.exists() && !path.starts_with("/nix/store") && !path.starts_with(dotfile)
}

/// Shell builder that evaluates devenv environment on each build.
pub struct DevenvShellBuilder {
/// Tokio runtime handle for running async code in sync context
Expand Down Expand Up @@ -264,6 +278,7 @@ impl ShellBuilder for DevenvShellBuilder {
let watcher = ctx.watcher.clone();
let eval_cache_pool = self.eval_cache_pool.clone();
let shell_cache_key = self.shell_cache_key.clone();
let dotfile = self.dotfile.clone();

rt.block_on(async move {
let devenv = devenv.lock().await;
Expand Down Expand Up @@ -294,7 +309,7 @@ impl ShellBuilder for DevenvShellBuilder {
Ok(inputs) => {
let paths: Vec<_> = inputs
.into_iter()
.filter(|i| i.path.exists() && !i.path.starts_with("/nix/store"))
.filter(|i| is_watchable_input(&i.path, &dotfile))
.map(|i| i.path)
.collect();
watcher.watch_many(paths).await;
Expand Down Expand Up @@ -335,7 +350,7 @@ impl DevenvShellBuilder {
tracing::debug!("Found {} file inputs for shell key", inputs.len());
let paths: Vec<_> = inputs
.into_iter()
.filter(|i| i.path.exists() && !i.path.starts_with("/nix/store"))
.filter(|i| is_watchable_input(&i.path, &self.dotfile))
.map(|i| i.path)
.collect();
ctx.watcher.watch_many(paths).await;
Expand All @@ -356,7 +371,7 @@ impl DevenvShellBuilder {
tracing::debug!("Found {} total tracked files in eval cache", paths.len());
let filtered: Vec<_> = paths
.into_iter()
.filter(|p| p.exists() && !p.starts_with("/nix/store"))
.filter(|p| is_watchable_input(p, &self.dotfile))
.collect();
ctx.watcher.watch_many(filtered).await;
}
Expand All @@ -366,3 +381,60 @@ impl DevenvShellBuilder {
}
}
}

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

#[test]
fn rejects_paths_inside_dotfile_dir() {
// The eval cache's own SQLite WAL lives inside the dotfile dir and
// changes on every evaluation. Watching it would self-trigger reloads.
let temp = TempDir::new().unwrap();
let dotfile = temp.path().join(".devenv/profiles/lean");
std::fs::create_dir_all(&dotfile).unwrap();

for sidecar in [
"nix-eval-cache.db",
"nix-eval-cache.db-wal",
"nix-eval-cache.db-shm",
] {
let path = dotfile.join(sidecar);
std::fs::write(&path, b"").unwrap();
assert!(
!is_watchable_input(&path, &dotfile),
"{} must be excluded from watch set",
sidecar
);
}
}

#[test]
fn rejects_nix_store_and_missing_paths() {
let temp = TempDir::new().unwrap();
let dotfile = temp.path().join(".devenv");
std::fs::create_dir_all(&dotfile).unwrap();

assert!(!is_watchable_input(
Path::new("/nix/store/abc-foo/x.nix"),
&dotfile
));
assert!(!is_watchable_input(
&temp.path().join("does-not-exist"),
&dotfile
));
}

#[test]
fn accepts_real_user_input_files() {
let temp = TempDir::new().unwrap();
let dotfile = temp.path().join(".devenv");
std::fs::create_dir_all(&dotfile).unwrap();

let user_file = temp.path().join("devenv.nix");
std::fs::write(&user_file, b"{}").unwrap();

assert!(is_watchable_input(&user_file, &dotfile));
}
}