Skip to content

Commit 1c17001

Browse files
authored
Merge pull request #1835 from cachix/safe-write-watched-files
direnv: fix excessive reloads
2 parents bb9a3fd + 0fad495 commit 1c17001

File tree

7 files changed

+102
-31
lines changed

7 files changed

+102
-31
lines changed

Cargo.lock

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
[workspace]
22
resolver = "2"
33
members = [
4-
"devenv",
5-
"devenv-generate",
6-
"devenv-eval-cache",
7-
"devenv-run-tests",
8-
"devenv-tasks",
9-
"http-client-tls",
10-
"nix-conf-parser",
11-
"xtask",
4+
"devenv",
5+
"devenv-generate",
6+
"devenv-eval-cache",
7+
"devenv-run-tests",
8+
"devenv-tasks",
9+
"http-client-tls",
10+
"nix-conf-parser",
11+
"xtask",
1212
]
1313

1414
[workspace.package]
@@ -35,6 +35,7 @@ cli-table = "0.4.9"
3535
console = "0.15.8"
3636
dotlock = "0.5.0"
3737
dialoguer = "0.11.0"
38+
fd-lock = "4"
3839
futures = "0.3.30"
3940
hex = "0.4.3"
4041
include_dir = "0.7.3"
@@ -51,10 +52,10 @@ pretty_assertions = { version = "1.4.0", features = ["unstable"] }
5152
regex = "1.10.3"
5253
schemars = "0.8.16"
5354
schematic = { version = "0.17.11", features = [
54-
"schema",
55-
"yaml",
56-
"renderer_template",
57-
"renderer_json_schema",
55+
"schema",
56+
"yaml",
57+
"renderer_template",
58+
"renderer_json_schema",
5859
] }
5960
serde = { version = "1.0.197", features = ["derive"] }
6061
serde_json = "1.0.114"
@@ -69,13 +70,13 @@ tracing = "0.1.40"
6970
tracing-core = "0.1.32"
7071
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
7172
tokio = { version = "1.39.3", features = [
72-
"process",
73-
"fs",
74-
"io-util",
75-
"macros",
76-
"rt-multi-thread",
77-
"sync",
78-
"time",
73+
"process",
74+
"fs",
75+
"io-util",
76+
"macros",
77+
"rt-multi-thread",
78+
"sync",
79+
"time",
7980
] }
8081
tokio-util = { version = "0.7.12", features = ["io"] }
8182
which = "7.0.2"

devenv-eval-cache/src/command.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ impl<'a> CachedCommand<'a> {
4545
}
4646

4747
/// Watch additional paths for changes.
48+
///
49+
/// WARN: Be careful watching generated files.
50+
/// External tools like direnv are triggered solely by the modification date and don't compare file contents.
51+
/// Use [util::write_file_with_lock] to safely write such files.
4852
pub fn watch_path<P: AsRef<Path>>(&mut self, path: P) -> &mut Self {
4953
self.extra_paths.push(path.as_ref().to_path_buf());
5054
self

devenv/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ cli-table.workspace = true
2020
console.workspace = true
2121
dialoguer.workspace = true
2222
dotlock.workspace = true
23+
fd-lock.workspace = true
2324
hex.workspace = true
2425
include_dir.workspace = true
2526
indoc.workspace = true

devenv/src/devenv.rs

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use super::{cli, cnix, config, tasks};
1+
use super::{cli, cnix, config, tasks, util};
22
use clap::crate_version;
33
use cli_table::Table;
44
use cli_table::{print_stderr, WithTitle};
@@ -866,11 +866,10 @@ impl Devenv {
866866
}
867867
}
868868
}
869-
fs::write(
869+
util::write_file_with_lock(
870870
self.devenv_dotfile.join("flake.json"),
871-
serde_json::to_string(&flake_inputs).unwrap(),
872-
)
873-
.expect("Failed to write flake.json");
871+
&serde_json::to_string(&flake_inputs).unwrap(),
872+
)?;
874873
fs::write(
875874
self.devenv_dotfile.join("devenv.json"),
876875
serde_json::to_string(&self.config).unwrap(),
@@ -916,13 +915,7 @@ impl Devenv {
916915
);
917916
let flake = FLAKE_TMPL.replace("__DEVENV_VARS__", &vars);
918917
let flake_path = self.devenv_root.join(DEVENV_FLAKE);
919-
920-
// Avoid writing the flake if it hasn't changed.
921-
// direnv's watch_file triggers a reload based solely on mtime, which becomes annoying if we constantly touch this file.
922-
let existing_flake = fs::read_to_string(&flake_path).unwrap_or_default();
923-
if flake != existing_flake {
924-
fs::write(flake_path, flake).expect("Failed to write flake.nix");
925-
}
918+
util::write_file_with_lock(&flake_path, &flake)?;
926919

927920
self.assembled = true;
928921
Ok(())

devenv/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ pub(crate) mod cnix;
33
pub mod config;
44
mod devenv;
55
pub mod log;
6+
mod util;
67

78
pub use cli::{default_system, GlobalOptions};
89
pub use devenv::{Devenv, DevenvOptions, DIRENVRC, DIRENVRC_VERSION};

devenv/src/util.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
use fd_lock::RwLock;
2+
use miette::{miette, IntoDiagnostic, Result};
3+
use std::fs;
4+
use std::io::Write;
5+
use std::path::Path;
6+
7+
/// Safely write a file with locking, avoiding writing if the content hasn't changed.
8+
///
9+
/// Returns Ok(true) if the file was written, Ok(false) if no write was needed.
10+
pub fn write_file_with_lock<P: AsRef<Path>>(path: P, content: &str) -> Result<bool> {
11+
let path = path.as_ref();
12+
13+
// Create parent directories if they don't exist
14+
if let Some(parent) = path.parent() {
15+
if !parent.exists() {
16+
fs::create_dir_all(parent)
17+
.into_diagnostic()
18+
.map_err(|e| miette!("Failed to create directory {}: {}", parent.display(), e))?;
19+
}
20+
}
21+
22+
// Open or create the file with locking
23+
let file = fs::OpenOptions::new()
24+
.read(true)
25+
.write(true)
26+
.create(true)
27+
.open(path)
28+
.into_diagnostic()
29+
.map_err(|e| miette!("Failed to open file {}: {}", path.display(), e))?;
30+
31+
// Acquire an exclusive lock on the file
32+
let mut file_lock = RwLock::new(file);
33+
let mut file_handle = file_lock
34+
.write()
35+
.into_diagnostic()
36+
.map_err(|e| miette!("Failed to lock file {}: {}", path.display(), e))?;
37+
38+
// Read existing content
39+
let existing_content = fs::read_to_string(path).unwrap_or_default();
40+
41+
// Compare and write only if different
42+
if content != existing_content {
43+
file_handle
44+
.set_len(0)
45+
.into_diagnostic()
46+
.map_err(|e| miette!("Failed to truncate file {}: {}", path.display(), e))?;
47+
48+
file_handle
49+
.write_all(content.as_bytes())
50+
.into_diagnostic()
51+
.map_err(|e| miette!("Failed to write to file {}: {}", path.display(), e))?;
52+
53+
// File was written
54+
Ok(true)
55+
} else {
56+
// No write needed
57+
Ok(false)
58+
}
59+
}

0 commit comments

Comments
 (0)