shadow_rs/host/fcntl_lock_table.rs
1use std::{
2 collections::{BTreeMap, BTreeSet},
3 ops::Range,
4};
5
6use linux_api::fcntl::FlockType;
7use rangemap::RangeMap;
8
9use crate::host::process::ProcessId;
10
11/// Error returned by a lock operation.
12#[derive(Debug, Copy, Clone, Eq, PartialEq)]
13pub enum LockError {
14 // lock couldn't be taken, because it's held by another owner.
15 ConflictingLock,
16}
17
18/// Owner of a record lock.
19// Intentionally not `Copy`; see TODO below.
20#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
21pub enum LockOwner {
22 /// For "process-associated" (posix) locks, as created via `fcntl` operation
23 /// `F_SETLK`.
24 // TODO: We might want to make the owner be the *descriptor table* to
25 // more-closely align with the (undocumented? unintentional?) Linux
26 // semantics for processes that shares a descriptor table. See
27 // <https://github.com/shadow/shadow/issues/3783>
28 Process(ProcessId),
29 // TODO: add enumerator for "open file description" locks, as created via
30 // `fcntl` operation `F_OFD_SETLK`. We might want this to involve some sort
31 // of pointer (e.g. Weak) to the file description. To support that, we don't
32 // make this type `Copy`.
33}
34
35/// Internal representation of a record lock.
36#[derive(Clone, Debug, Eq, PartialEq)]
37enum FcntlLock {
38 // Write-lock; one owner.
39 Write(LockOwner),
40 // Read-lock; multiple owners.
41 // Important to use a sorted set here, so that an "arbitrary" conflicting
42 // lock returned via a.g. `F_GETLK` is deterministic.
43 Read(BTreeSet<LockOwner>),
44}
45
46impl FcntlLock {
47 fn new(requester: &LockOwner, flock_type: FlockType) -> Option<FcntlLock> {
48 match flock_type {
49 FlockType::F_UNLCK => None,
50 FlockType::F_RDLCK => Some(FcntlLock::Read(BTreeSet::from_iter([requester.clone()]))),
51 FlockType::F_WRLCK => Some(FcntlLock::Write(requester.clone())),
52 }
53 }
54
55 fn access(&self) -> FlockType {
56 match self {
57 FcntlLock::Write(_) => FlockType::F_WRLCK,
58 FcntlLock::Read(_) => FlockType::F_RDLCK,
59 }
60 }
61
62 fn owners_contains(&self, o: &LockOwner) -> bool {
63 match self {
64 FcntlLock::Write(write_owner) => write_owner == o,
65 FcntlLock::Read(read_owners) => read_owners.contains(o),
66 }
67 }
68
69 /// Return the result of updating this lock with the given request, if possible.
70 /// Otherwise returns an error that describes one of the conflicting locks.
71 ///
72 /// Use `requested_type=FlockType::F_UNLCK` to unlock.
73 ///
74 /// Returns:
75 /// * `Ok(None)` when the result is "no lock"; i.e. when
76 /// `requested_type` is `F_UNLCK`, and `requester` is the only owner of
77 /// `self`.
78 /// * `Ok(Some(x))`, where x is the result of a successful update.
79 /// * `Err(x)`, where x is one of the current owners of a conflicting lock.
80 pub fn with_request_applied(
81 &self,
82 requester: &LockOwner,
83 requested_type: FlockType,
84 ) -> Result<Option<Self>, LockOwner> {
85 match self {
86 FcntlLock::Write(current_owner) => {
87 if current_owner == requester {
88 // requester is already the exclusive owner.
89 // Give them whatever they want.
90 Ok(Self::new(requester, requested_type))
91 } else if requested_type == FlockType::F_UNLCK {
92 // requester is releasing, but isn't an owner of this lock.
93 // Return this lock unchanged.
94 Ok(Some(self.clone()))
95 } else {
96 // Conflict with current owner.
97 Err(current_owner.clone())
98 }
99 }
100 FcntlLock::Read(current_owners) => {
101 match requested_type {
102 FlockType::F_RDLCK => {
103 // Add requester to the set of read-lock owners
104 let mut s = current_owners.clone();
105 s.insert(requester.clone());
106 Ok(Some(FcntlLock::Read(s)))
107 }
108 FlockType::F_WRLCK => {
109 match current_owners.iter().find(|o| o != &requester) {
110 Some(conflicting_owner) => {
111 // requesting a write lock, but a different owner
112 // is holding a read lock.
113 Err(conflicting_owner.clone())
114 }
115 None => {
116 // requesting a write lock, and requester is the sole holder
117 // of a read lock. Upgrade it to a write lock.
118 Ok(FcntlLock::new(requester, FlockType::F_WRLCK))
119 }
120 }
121 }
122 FlockType::F_UNLCK => {
123 // Requesting to unlock, and there's a read-lock.
124 // Remove requester from set of read-lock owners.
125 let new_owners = BTreeSet::from_iter(
126 current_owners.iter().filter(|x| x != &requester).cloned(),
127 );
128 if new_owners.is_empty() {
129 // No owners left -> unlocked.
130 Ok(None)
131 } else {
132 Ok(Some(FcntlLock::Read(new_owners)))
133 }
134 }
135 }
136 }
137 }
138 }
139}
140
141/// Stable identifier for a file, which we use to look up record locks.
142/// Remains valid as long as the file has links in the file system and/or there
143/// are open file descriptors to the file.
144//
145// We implement this using device+inode, as returned by `fstat`.
146//
147// According to
148// [posix](https://pubs.opengroup.org/onlinepubs/9799919799/basedefs/sys_stat.h.html):
149//
150// A file identity is uniquely determined by the combination of st_dev and
151// st_ino. At any given time in a system, distinct files shall have distinct
152// file identities; hard links to the same file shall have the same file
153// identity. Over time, these file identities can be reused for different files.
154// For example, the st_ino value can be reused after the last link to a file is
155// unlinked and the space occupied by the file has been freed, and the st_dev
156// value associated with a file system can be reused if that file system is
157// detached ("unmounted") and another is attached ("mounted").
158//
159// While the posix definition doesn't seem to clearly specify whether an inode
160// number can be reused when there are no more links in the file system, but the
161// file is still open, my understanding is that at least in Linux, files aren't
162// destroyed and their inode numbers made available for reuse until it is no
163// longer open by anyone. e.g.
164// [unlink(2)](https://man7.org/linux/man-pages/man2/unlink.2.html):
165//
166// If the name was the last link to a file but any processes still
167// have the file open, the file will remain in existence until the
168// last file descriptor referring to it is closed.
169#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd)]
170pub struct FileId {
171 device: u64,
172 inode: u64,
173}
174
175impl From<&linux_api::stat::stat> for FileId {
176 fn from(value: &linux_api::stat::stat) -> Self {
177 FileId {
178 device: value.lst_dev,
179 inode: value.lst_ino,
180 }
181 }
182}
183
184impl From<&libc::stat> for FileId {
185 fn from(value: &libc::stat) -> Self {
186 FileId {
187 device: value.st_dev,
188 inode: value.st_ino,
189 }
190 }
191}
192
193#[derive(Debug)]
194struct FcntlLockTableForOneFile {
195 locks: RangeMap<usize, FcntlLock>,
196 // Will need something here to track sleepers
197}
198
199impl FcntlLockTableForOneFile {
200 fn new() -> Self {
201 Self {
202 locks: Default::default(),
203 }
204 }
205
206 pub fn is_empty(&self) -> bool {
207 self.locks.is_empty()
208 }
209
210 /// Apply the requested lock to the given range. Similar semantics as
211 /// `fcntl` operations like `F_SETLK`.
212 ///
213 /// Returns an error if an incompatible lock exists.
214 pub fn set_lock(
215 &mut self,
216 requested_range: Range<usize>,
217 requester: &LockOwner,
218 requested_type: FlockType,
219 ) -> Result<(), LockError> {
220 // Process overlapping locks, collecting what the updated locks will look like.
221 let mut updated_results = Vec::<(Range<usize>, FcntlLock)>::new();
222 for (lock_range, lock) in self.locks.overlapping(&requested_range) {
223 match lock.with_request_applied(requester, requested_type) {
224 Ok(Some(updated_lock)) => {
225 // Record the updated lock.
226 let start = std::cmp::max(lock_range.start, requested_range.start);
227 let end = std::cmp::min(lock_range.end, requested_range.end);
228 updated_results.push((start..end, updated_lock));
229 }
230 Ok(None) => {
231 // Result is no-lock. No need to record anything; we'll clear this below.
232 }
233 Err(_) => {
234 // Conflicting lock; can't update.
235 return Err(LockError::ConflictingLock);
236 }
237 }
238 }
239
240 // Set the whole range based on the requested lock.
241 match FcntlLock::new(requester, requested_type) {
242 Some(l) => {
243 self.locks.insert(requested_range.clone(), l);
244 }
245 None => {
246 self.locks.remove(requested_range.clone());
247 }
248 }
249
250 // Insert the updated locks, potentially overwriting what we just wrote
251 // into the whole range.
252 for (updated_range, updated_lock) in updated_results {
253 self.locks.insert(updated_range, updated_lock);
254 }
255
256 Ok(())
257 }
258
259 /// Return one of the locks that would conflict with the requested lock, if
260 /// any. The return range is *uncoalesced*, meaning that if a conflicting read lock
261 /// is returned, the bounds have not been expanded to include adjancent read locks
262 /// that also have the returned owner.
263 ///
264 /// Intended for `fcntl` operations like `F_GETLK`, but result is the raw
265 /// internal lock, if any.
266 fn get_uncoalesced_conflicting_lock(
267 &self,
268 requested_range: Range<usize>,
269 requester: &LockOwner,
270 requested_type: FlockType,
271 ) -> Option<(LockOwner, Range<usize>, FlockType)> {
272 for (lock_range, lock) in self.locks.overlapping(&requested_range) {
273 match lock.with_request_applied(requester, requested_type) {
274 Ok(_) => (), // No conflict,
275 Err(o) => return Some((o, lock_range.clone(), lock.access())),
276 }
277 }
278 None
279 }
280
281 /// Return one of the locks that would conflict with the requested lock, if
282 /// any. Intended for `fcntl` operations like `F_GETLK`.
283 ///
284 /// The returned range is coalesced for the returned owner: it includes all adjacent
285 /// range over which the owner has the returned access, even if parts of the range
286 /// have different ownership sets. (Reproducing the behavior of `F_GETLK` on Linux).
287 pub fn get_coalesced_conflicting_lock(
288 &self,
289 requested_range: Range<usize>,
290 requester: &LockOwner,
291 requested_type: FlockType,
292 ) -> Option<(LockOwner, Range<usize>, FlockType)> {
293 let (conflicting_owner, mut conflicting_range, conflicting_access) =
294 self.get_uncoalesced_conflicting_lock(requested_range, requester, requested_type)?;
295
296 let can_coalesce = |other_lock: &FcntlLock| -> bool {
297 other_lock.access() == conflicting_access
298 && other_lock.owners_contains(&conflicting_owner)
299 };
300
301 // Coalesce backwards
302 while let Some(x) = conflicting_range.start.checked_sub(1) {
303 let Some((prev_range, prev_lock)) = self.locks.get_key_value(&x) else {
304 break;
305 };
306 if !(can_coalesce(prev_lock)) {
307 break;
308 }
309 conflicting_range.start = prev_range.start
310 }
311
312 // Coalesce forwards
313 while let Some((next_range, next_lock)) = self.locks.get_key_value(&conflicting_range.end) {
314 if !(can_coalesce(next_lock)) {
315 break;
316 }
317 debug_assert_eq!(conflicting_range.end, next_range.start);
318 conflicting_range.end = next_range.end
319 }
320
321 Some((conflicting_owner, conflicting_range, conflicting_access))
322 }
323}
324
325/// All record locks for a Host.
326#[derive(Debug)]
327pub struct FcntlLockTable {
328 /// locks by FileId.
329 locks: BTreeMap<FileId, FcntlLockTableForOneFile>,
330}
331
332impl FcntlLockTable {
333 pub fn new() -> Self {
334 Self {
335 locks: BTreeMap::new(),
336 }
337 }
338
339 pub fn set_lock(
340 &mut self,
341 file_id: FileId,
342 requested_range: Range<usize>,
343 requested_owner: &LockOwner,
344 requested_access: FlockType,
345 ) -> Result<(), LockError> {
346 let file_locks = self
347 .locks
348 .entry(file_id)
349 .or_insert_with(FcntlLockTableForOneFile::new);
350 let res = file_locks.set_lock(requested_range.clone(), requested_owner, requested_access);
351 if res.is_err() {
352 log::debug!(
353 "failed to lock {file_id:?}.{requested_range:?}.{requested_owner:?}.{requested_access:?}"
354 );
355 }
356 if file_locks.is_empty() {
357 self.locks.remove(&file_id);
358 }
359 res
360 }
361
362 /// Return one of the locks that would conflict with the requested lock, if
363 /// any. Intended for `fcntl` operations like `F_GETLK`.
364 pub fn get_coalesced_conflicting_lock(
365 &self,
366 file_id: FileId,
367 requested_range: Range<usize>,
368 requested_owner: &LockOwner,
369 requested_access: FlockType,
370 ) -> Option<(LockOwner, Range<usize>, FlockType)> {
371 let file_locks = self.locks.get(&file_id)?;
372 let start = requested_range.start;
373 let end = requested_range.end;
374 let res = file_locks.get_coalesced_conflicting_lock(
375 requested_range,
376 requested_owner,
377 requested_access,
378 );
379 log::trace!(
380 "get_coalesced_conflicting_lock({file_locks:?}, {file_id:?}, {start}..{end}, {requested_owner:?}, {requested_access:?} -> {res:?})"
381 );
382 res
383 }
384
385 /// Drop all of the given owner's locks on the given file.
386 pub fn remove_owner(&mut self, file_id: FileId, requested_owner: &LockOwner) {
387 // F_UNLCK operation removes the specified owner where applicable for
388 // any part of the range, and has no effect for parts of the range where
389 // the owner doesn't hold a lock.
390 self.set_lock(
391 file_id,
392 usize::MIN..usize::MAX,
393 requested_owner,
394 FlockType::F_UNLCK,
395 )
396 .expect("F_UNLCK should always succeed");
397 }
398}
399
400impl Default for FcntlLockTable {
401 fn default() -> Self {
402 Self::new()
403 }
404}