1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
//! Cross-process serialisation and `.bak`-free atomic publish for a Claude Code
//! `settings*.json`.
//!
//! Why (#7762): a dozen writers across this crate run the same
//! read → merge → write-the-whole-file cycle over one `settings.json`, and they
//! do not all run in one process. `tm launch` prepares a session from the CLI
//! process while the daemon prepares one from its own, and `tm doctor --fix`
//! repairs the very same file from a third. Two of those cycles overlapping is a
//! lost update: the slower writer republishes the snapshot it read BEFORE the
//! faster writer's publish, so the faster writer's keys silently disappear with
//! both callers reporting success. [`crate::core::claude_json_guard`] cannot
//! close that — it is a process-wide `Mutex`, and the racing writers are
//! separate processes.
//!
//! What: [`with_settings_lock`] runs a writer's WHOLE cycle under the
//! `flock(2)`-style advisory lock `trusty_common::file_lock` already owns, and
//! [`publish`] replaces the file by `rename` from a per-call temp sibling.
//! Together they are the only settings-file critical section in this crate;
//! every writer of a `settings.json` or `settings.local.json` goes through them.
//!
//! # Contract
//!
//! - **Serialisation.** The lock is held by the open file description, so it
//! serialises separate processes and separate threads alike. A writer holds it
//! across the read AND the write, never just the write.
//! - **Never fail open.** A lock that cannot be created or acquired is an error
//! and the closure never runs, per `trusty_common::file_lock`'s own rule.
//! Proceeding unlocked is the bug this module removes.
//! - **Not reentrant.** Two nested [`with_settings_lock`] calls on one path
//! self-deadlock; the inner acquisition uses a different descriptor. No writer
//! in this crate calls another writer of the same file.
//! - **One identity per DIRECTORY spelling.** The lock sidecar is named from the
//! canonical parent directory, so two processes that spell one directory
//! differently — a symlinked project root, macOS's `/var` → `/private/var` —
//! still take the same lock. The FILE name is not resolved: a `settings.json`
//! that is itself a symlink to another file is two identities and therefore
//! two sidecars, and those two writers do not serialise. No caller creates
//! that shape — every one of them joins a literal `settings.json` or
//! `settings.local.json` onto a `.claude` directory — so it is an untested
//! gap, not a covered case (#7762).
//! - **No `.bak`.** `trusty_common::claude_config::write_json_atomic` publishes a
//! `<path>.bak` sibling on every call, which for a once-per-launch writer is
//! untracked litter in every managed project (#7762). [`publish`] is that
//! function's staging-and-rename half without the copy; writers that REPORT a
//! backup to the operator keep their own.
//!
//! Test: `settings_lock_tests.rs`,
//! `core::session_launch::tests_settings_lock_7762`.
use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use serde_json::Value;
/// Run `f` while holding the cross-process lock guarding `settings_path`.
///
/// Why: see the module doc — this is the one place a settings-file
/// read → merge → write cycle is made safe against a writer in another process.
/// What: resolves `settings_path` to its [`stable_path`] identity, then delegates
/// to `trusty_common::file_lock::with_exclusive_lock`, which blocks until the
/// lock is free and releases it by RAII on every exit path including a panic.
/// `f`'s own return value passes through untouched, so the `Err` returned here is
/// only ever an acquisition failure — a caller can never confuse "could not lock"
/// with "the work failed".
///
/// #7762: an acquisition failure is re-wrapped to name the SIDECAR. Every
/// consumer's own error already names the settings file, and the file the
/// operator has to inspect is the other one — a sidecar left by another uid is
/// opened `O_RDWR` here and fails forever, so a message that named only
/// `settings.json` sent them to `chmod` a file that was never the problem.
/// Test: `serialises_concurrent_threads`, `errors_when_the_lock_is_unopenable`,
/// `errors_when_the_parent_cannot_be_created`,
/// `an_acquisition_failure_names_the_sidecar`.
pub(crate) fn with_settings_lock<R>(settings_path: &Path, f: impl FnOnce() -> R) -> io::Result<R> {
// #8545: every locked settings write passes here; fenced in tests.
crate::core::home_write_fence::check(settings_path);
let stable = stable_path(settings_path)?;
trusty_common::file_lock::with_exclusive_lock(&stable, f).map_err(|source| {
io::Error::new(
source.kind(),
format!("lock sidecar {}: {source}", lock_sidecar(&stable).display()),
)
})
}
/// The `.lock` sidecar spelling for `settings_path`, verbatim.
///
/// Why (#7762): three callers need this name — the acquisition-failure message
/// above, the `.gitignore` block
/// [`crate::core::scaffold_gitignore`] scaffolds into a managed project, and the
/// tests. Spelling `.lock` at each of them is how the three drift apart.
/// What: `trusty_common::file_lock::lock_path` of the path AS GIVEN — pure, no
/// canonicalisation and no I/O — so a relative `.claude/settings.json` yields the
/// relative `.claude/settings.json.lock` a `.gitignore` entry needs, and the
/// [`stable_path`] identity yields the absolute sidecar actually locked.
/// Test: `lock_sidecar_is_named_after_the_settings_file`,
/// `scaffold_gitignore::tests::lock_entries_match_the_settings_lock_sidecar`.
pub(crate) fn lock_sidecar(settings_path: &Path) -> PathBuf {
trusty_common::file_lock::lock_path(settings_path)
}
/// Publish `value` over `settings_path` atomically, leaving no `.bak`.
///
/// Why (#7762): `rename(2)` within a directory is atomic, so a reader — or a
/// writer in another process that is next in line for the lock — sees the whole
/// previous file or the whole new one, never a splice, and a crash between the
/// temp write and the rename leaves the original byte-for-byte intact. The
/// `.bak` half of `write_json_atomic` is deliberately absent: this runs on every
/// launch, and a copy taken on every launch is untracked litter in the
/// operator's project, not a recovery artifact.
/// What: serialises `value` pretty-printed (byte-compatible with
/// `write_json_atomic`, no trailing newline), [`stage`]s it beside
/// `settings_path`, and renames it into place. Any failure up to and including
/// the rename removes the staged file and returns `Err` with `settings_path`
/// untouched.
///
/// The parent-directory fsync that follows is BEST EFFORT (#7762): both the
/// open and the sync have their results discarded, because neither failing
/// un-publishes the rename that already succeeded, and on macOS a directory
/// descriptor is one of the cases `F_FULLFSYNC` can refuse. What it buys when it
/// does work is durability of the rename across a power loss; what it never
/// affects is the atomicity a reader sees, which the rename alone provides.
/// Test: `publish_replaces_the_file_and_leaves_no_bak`,
/// `publish_leaves_no_staged_file_behind`,
/// `a_crash_between_stage_and_rename_leaves_the_original_intact`.
pub(crate) fn publish(settings_path: &Path, value: &Value) -> io::Result<()> {
let serialized = serde_json::to_string_pretty(value).map_err(io::Error::other)?;
let staged = stage(settings_path, serialized.as_bytes())?;
if let Err(err) = std::fs::rename(&staged, settings_path) {
let _ = std::fs::remove_file(&staged);
return Err(err);
}
// #7762: best-effort durability of the rename itself — a failure here does
// not un-publish it, so it is not an error. Unix-only: Windows exposes no
// directory handle to sync, and its rename is already committed to the log.
#[cfg(unix)]
if let Ok(dir) = std::fs::File::open(parent_of(settings_path)) {
let _ = dir.sync_all();
}
Ok(())
}
/// Write `bytes` to a fresh temp sibling of `settings_path` and sync it.
///
/// Why: this is the half of [`publish`] that must be observable on its own — the
/// guarantee that a crash before the rename costs nothing is only testable if a
/// test can stop here. It is also where the per-call name matters: a shared
/// `<path>.tmp` let two writers fill ONE file and rename the splice over the real
/// settings, which is the durable corruption #4077 removed from
/// `write_json_atomic` and must not come back here.
/// What: creates the parent directory, fills `<name>.<pid>.<seq>.tmp` beside
/// `settings_path` (same directory, so the publish is a same-filesystem rename),
/// and `fsync`s it before returning its path. A failed fill removes the partial
/// file rather than leaving litter.
/// Test: `a_crash_between_stage_and_rename_leaves_the_original_intact`,
/// `staged_paths_are_unique_per_call`.
fn stage(settings_path: &Path, bytes: &[u8]) -> io::Result<PathBuf> {
use std::io::Write as _;
std::fs::create_dir_all(parent_of(settings_path))?;
let staged = temp_path(settings_path);
let fill = (|| -> io::Result<()> {
let mut file = std::fs::File::create(&staged)?;
file.write_all(bytes)?;
// Durability of the CONTENT must precede the rename that publishes it.
file.sync_all()
})();
if let Err(err) = fill {
let _ = std::fs::remove_file(&staged);
return Err(err);
}
Ok(staged)
}
/// A staging path no other live writer can hold.
///
/// What: `<file name>.<pid>.<counter>.tmp`, in `settings_path`'s own directory.
/// The pid separates processes and the counter separates calls within one
/// process; unlike a clock reading it cannot repeat under a coarse timer.
/// Test: `staged_paths_are_unique_per_call`.
fn temp_path(settings_path: &Path) -> PathBuf {
static SEQ: AtomicU64 = AtomicU64::new(0);
let mut name = settings_path.file_name().unwrap_or_default().to_os_string();
name.push(format!(
".{}.{}.tmp",
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
));
settings_path.with_file_name(name)
}
/// The directory `settings_path` lives in, as a path that can be created.
///
/// What: the parent, or `.` for a bare file name.
fn parent_of(settings_path: &Path) -> &Path {
match settings_path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent,
_ => Path::new("."),
}
}
/// The one spelling of `settings_path` every process agrees on.
///
/// Why: a lock keyed by the caller's spelling is not a lock. `tm launch` reaches
/// a project through the path the operator typed while the daemon reaches it
/// through a registry entry, and on macOS a temp or `/var` path differs from its
/// `/private/var` resolution — three spellings of one file would take three
/// different sidecars and race anyway. Canonicalising the DIRECTORY (not the
/// file) is what makes them converge while still working for a settings file that
/// does not exist yet.
/// What: creates the parent directory when absent, canonicalises it, and rejoins
/// the file name. Propagates the create or canonicalise failure — never falls
/// back to the raw path, because a fallback is exactly the silent unlocked write
/// this module exists to prevent.
/// Test: `lock_path_is_a_sidecar_of_the_settings_file`,
/// `a_symlinked_directory_resolves_to_one_lock`,
/// `errors_when_the_parent_cannot_be_created`.
fn stable_path(settings_path: &Path) -> io::Result<PathBuf> {
let file_name = settings_path.file_name().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("{} has no file name to lock", settings_path.display()),
)
})?;
let parent = parent_of(settings_path);
std::fs::create_dir_all(parent)?;
Ok(parent.canonicalize()?.join(file_name))
}
#[cfg(test)]
#[path = "settings_lock_tests.rs"]
mod tests;