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
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
//! Prefix-scoped state lock: mutations take exclusive for their whole
//! duration, builds included; readers take shared. The lock lives next
//! to the state (`<prefix>/share/cargo-lbin/lock`), so it protects the
//! prefix, not a user — and must be acquired before `Manifest::load()`
//! or load-mutate-store is a textbook lost update.
//!
//! For a root-owned prefix, exclusive acquisition prepares the lock
//! file via the privileged path (`mkdir -p` + `touch`, idempotent and
//! inode-preserving); cargo and the build still run as the user.
//! Readers on a never-mutated prefix degrade with a warning instead of
//! demanding sudo.
//!
//! `flock(2)` via std's File lock API: advisory, scoped to the open
//! file description, released on drop — including on crash.
use crate::privileged;
use anyhow::{Context, Result};
use std::fs::{File, OpenOptions, TryLockError};
use std::path::Path;
pub enum Mode {
Shared,
Exclusive,
}
/// Held for the guard's lifetime; the lock releases on drop (fd close).
pub struct StateLock {
_file: Option<File>,
}
impl StateLock {
/// A shared lock over state that already exists, or nothing — the
/// read-only auditor's form: opens read-only, never creates the file
/// or its parents (`acquire`'s preparation writes twice, which "never
/// writes" cannot use). `Ok(None)` = no lock to take (absent or
/// permission-denied); the caller reads locklessly — atomic manifest
/// placement keeps that safe from torn files, and an auditor's
/// spurious finding costs a re-run, never state.
/// Blocks like `acquire` when the lock exists, and says so through
/// `notice` — the same channel split as `acquire_with`: the CLI prints
/// to stderr (a silent multi-minute wait reads as a hang), the TUI
/// passes a noop (its spinner already says a job is alive).
pub fn acquire_shared_existing(
prefix: &Path,
notice: &mut dyn FnMut(&str),
) -> Result<Option<Self>> {
let path = prefix.join("share/cargo-lbin/lock");
let file = match OpenOptions::new().read(true).open(&path) {
Ok(file) => file,
// Absent or denied both mean "read locklessly"; anything else (EIO,
// EMFILE) is a real failure of *this* process and must surface, not
// be rounded down to "no lock".
Err(e)
if matches!(
e.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::PermissionDenied
) =>
{
return Ok(None);
}
Err(e) => {
return Err(e).with_context(|| format!("opening {}", path.display()));
}
};
// The same probe-then-block shape as `acquire_impl`: the notice fires
// only when there is genuinely someone to wait for.
match file.try_lock_shared() {
Ok(()) => {}
Err(TryLockError::WouldBlock) => {
notice("another cargo-lbin instance holds the state lock; waiting...");
file.lock_shared()
.with_context(|| format!("locking {} (shared)", path.display()))?;
}
Err(TryLockError::Error(e)) => {
return Err(e).with_context(|| format!("locking {} (shared)", path.display()));
}
}
Ok(Some(Self { _file: Some(file) }))
}
/// Acquire the prefix lock, blocking if another instance holds it (with
/// a notice, so a wait during someone else's 10-minute build is not
/// mistaken for a hang). CLI form: lock-file preparation may prompt
/// via sudo, notices go to stderr.
pub fn acquire(prefix: &Path, mode: &Mode) -> Result<Self> {
Self::acquire_with(
prefix,
mode,
privileged::Policy::for_prefix(prefix),
&mut |s| eprintln!("{s}"),
)
}
/// `acquire` with the two decisions a screen-owning frontend must own:
/// `policy` says whether preparing a missing lock file may prompt (a
/// captured frontend passes noninteractive, so sudo runs `-n`), and
/// `notice` is where the human lines go — stderr, the frontend's
/// stream, or nowhere for an advisory read.
///
/// This is the one escalation that cannot wait for the placement
/// door, where every other password is collected: under a
/// privileged prefix the lock file must exist before the lock can
/// be taken, and the lock is taken before the build. It is a
/// once-per-prefix cost — [`Self::preparation_needs_privilege`]
/// answers false ever after, because the file is made
/// world-readable — and the captured frontend's preflight asks that
/// question before deciding to prompt at all.
pub fn acquire_with(
prefix: &Path,
mode: &Mode,
policy: privileged::Policy,
notice: &mut dyn FnMut(&str),
) -> Result<Self> {
Self::acquire_impl(prefix, mode, policy, notice, true)
.map(|lock| lock.expect("blocking acquisition always returns a lock"))
}
/// `acquire_with` that does not wait: `Ok(None)` when held. For
/// advisory reads on a live UI thread — freezing the screen for
/// someone else's build is no optimization, and the authoritative
/// check happens under the real lock elsewhere. TUI-only caller.
#[cfg_attr(not(feature = "tui"), allow(dead_code))]
pub fn try_acquire_with(
prefix: &Path,
mode: &Mode,
policy: privileged::Policy,
notice: &mut dyn FnMut(&str),
) -> Result<Option<Self>> {
Self::acquire_impl(prefix, mode, policy, notice, false)
}
/// Will acquiring here need the privileged preparation path? Mirrors
/// `acquire`'s first steps, so a frontend deciding on up-front sudo
/// sees what the worker will see; a user-writable prefix answers
/// `false` by creating the same file `acquire` would have. TUI-only
/// caller.
#[cfg_attr(not(feature = "tui"), allow(dead_code))]
pub fn preparation_needs_privilege(prefix: &Path) -> bool {
let path = prefix.join("share/cargo-lbin/lock");
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)
.or_else(|_| OpenOptions::new().read(true).open(&path))
.is_err()
}
fn acquire_impl(
prefix: &Path,
mode: &Mode,
policy: privileged::Policy,
notice: &mut dyn FnMut(&str),
block: bool,
) -> Result<Option<Self>> {
let path = prefix.join("share/cargo-lbin/lock");
// OpenOptions::create makes the file, not its parents.
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
// Plain attempt first: create it (writable prefixes, root), else
// open the existing file read-only — flock needs no write access.
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
// Explicitly no truncation: the file is opened purely to be
// flocked, its (empty) content is never touched.
.truncate(false)
.open(&path)
.or_else(|_| OpenOptions::new().read(true).open(&path));
let file = match (file, mode) {
(Ok(f), _) => f,
(Err(_), Mode::Exclusive) => {
// Mutations must not proceed unsynchronized: prepare with escalation,
// then it must open. Announce first — this is the one path that can
// put a sudo prompt before any other output, and a bare password
// prompt with no context looks exactly like what this tool exists to
// prevent.
notice(&format!(
"initializing state for {}: creating {}",
prefix.display(),
path.display()
));
privileged::ensure_lock_file(policy, &path)
.with_context(|| format!("preparing state lock {}", path.display()))?;
OpenOptions::new()
.read(true)
.open(&path)
.with_context(|| format!("opening state lock {}", path.display()))?
}
(Err(_), Mode::Shared) => {
// A reader on a never-mutated prefix: nothing to protect, no sudo for
// a `list`.
notice(&format!(
"warning: cannot open {} — proceeding without a state lock \
(the file is created by the first mutation)",
path.display()
));
return Ok(Some(Self { _file: None }));
}
};
// Non-blocking probe first, to tell an actual wait apart from an
// instant acquisition — and `WouldBlock` apart from real I/O
// errors, which must propagate rather than masquerade as
// contention.
let probe = match mode {
Mode::Shared => file.try_lock_shared(),
Mode::Exclusive => file.try_lock(),
};
match probe {
Ok(()) => {}
Err(TryLockError::WouldBlock) if !block => return Ok(None),
Err(TryLockError::WouldBlock) => {
notice("another cargo-lbin instance holds the state lock; waiting...");
match mode {
Mode::Shared => file.lock_shared(),
Mode::Exclusive => file.lock(),
}
.context("acquiring state lock")?;
}
Err(TryLockError::Error(e)) => return Err(e).context("acquiring state lock"),
}
Ok(Some(Self { _file: Some(file) }))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_contended_shared_existing_wait_is_announced_and_a_free_one_is_not() {
use std::sync::mpsc;
use std::time::Duration;
let root = std::env::temp_dir().join("cargo-lbin-test-lock-notice");
let _ = std::fs::remove_dir_all(&root);
let prefix = root.join("prefix");
std::fs::create_dir_all(prefix.join("share/cargo-lbin")).unwrap();
let lock_path = prefix.join("share/cargo-lbin/lock");
// Free lock: instant acquisition, and the probe keeps the
// notice quiet — nobody to wait for means nothing to announce.
std::fs::File::create(&lock_path).unwrap();
let mut fired = false;
let got = StateLock::acquire_shared_existing(&prefix, &mut |_| fired = true)
.unwrap()
.expect("the lock file exists");
assert!(!fired, "an instant acquisition says nothing");
drop(got);
// Contended: exclusive holder on a separate description, waiter on a
// thread — the notice must arrive while the holder still holds.
let holder = std::fs::OpenOptions::new()
.write(true)
.open(&lock_path)
.unwrap();
holder.lock().unwrap();
let (tx, rx) = mpsc::channel();
let p2 = prefix.clone();
let waiter = std::thread::spawn(move || {
let mut notice = |m: &str| {
let _ = tx.send(m.to_owned());
};
StateLock::acquire_shared_existing(&p2, &mut notice)
});
let msg = rx
.recv_timeout(Duration::from_secs(10))
.expect("the wait announces itself while the holder holds");
assert!(msg.contains("waiting"), "{msg}");
drop(holder);
let got = waiter.join().unwrap().unwrap();
assert!(got.is_some(), "the lock arrives once the holder lets go");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn try_variant_yields_instead_of_waiting() {
let prefix = std::env::temp_dir().join("cargo-lbin-test-trylock");
let _ = std::fs::remove_dir_all(&prefix);
let quiet = |_: &str| {};
// A writable prefix never needs the privileged preparation path;
// answering the question creates the same lock file `acquire`
// would have created a moment later.
assert!(!StateLock::preparation_needs_privilege(&prefix));
let held = StateLock::acquire_with(
&prefix,
&Mode::Exclusive,
privileged::Policy {
sudo: privileged::Sudo::Forbidden,
screen: privileged::Screen::Inherited,
},
&mut { quiet },
)
.unwrap();
// Advisory read while a mutation holds the prefix: the answer is
// "not now", never a wait — a UI thread is on the other end.
let advisory = StateLock::try_acquire_with(
&prefix,
&Mode::Shared,
privileged::Policy {
sudo: privileged::Sudo::Forbidden,
screen: privileged::Screen::Inherited,
},
&mut { quiet },
)
.unwrap();
assert!(advisory.is_none(), "shared try must yield to exclusive");
drop(held);
let advisory = StateLock::try_acquire_with(
&prefix,
&Mode::Shared,
privileged::Policy {
sudo: privileged::Sudo::Forbidden,
screen: privileged::Screen::Inherited,
},
&mut { quiet },
)
.unwrap();
assert!(advisory.is_some(), "free lock acquires without waiting");
let _ = std::fs::remove_dir_all(&prefix);
}
}