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
//! What:
//! How the service puts its marked file in place as `index.lock`:
//! the system calls it makes,
//! the staging files,
//! the atomic placement itself,
//! the removal of a stale lock,
//! and the cleanup of staging files a killed attempt left.
//! Why:
//! The lock must never exist without its marker,
//! so the marker is written to a file that is new and then made the lock in one step (a rename that does not replace,
//! else a hard link).
//! The calls are one `Platform` value so a control can refuse or count them for one acquisition alone.
//! The module is split from `service_index_lock.rs`,
//! which re-exports the public items here under their old names.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! await placeMarked(platform, staged, lock) // renameNoReplace ?? link ?? refuse
//! ```
use super::fs_mount_options::read_mount_facts;
use super::process_identity::{ProcessIdentityError, current_birth_identity, process_birth_identity};
use super::random_id::random_uuid;
use std::io::Write;
use std::path::{Path, PathBuf};
/// What follows the lock's file name in the name of a staging file:
/// the marker is written there before it becomes the lock.
pub const STAGED_INFIX: &str = ".cli-git-marker";
/// What:
/// The system calls the lock makes that a control must be able to refuse or count:
/// the rename that does not replace,
/// the hard link,
/// the removal of a stale lock,
/// and the question of the process's own birth identity.
/// Why:
/// A filesystem with neither atomic placement,
/// a stale lock that cannot be unlinked and a process with no identity are environments no fixture can be made to have,
/// and a count kept in a process-wide static is read by whichever test runs beside it;
/// each acquisition takes the calls it makes from one value,
/// so a control substitutes them for that acquisition alone.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type Platform = { rename(from, to): void; link(from, to): void; unlink(path): void; identity(): string };
/// ```
pub struct Platform<'a> {
/// A rename that fails when the target exists (`renameat2` with `RENAME_NOREPLACE`).
pub rename: &'a dyn Fn(&Path, &Path) -> std::io::Result<()>,
/// A hard link that fails when the target exists.
pub link: &'a dyn Fn(&Path, &Path) -> std::io::Result<()>,
/// The removal of a lock whose holder is gone.
pub unlink: &'a dyn Fn(&Path) -> std::io::Result<()>,
/// The birth identity of this process,
/// which a marker names.
pub identity: &'a dyn Fn() -> Result<String, ProcessIdentityError>,
}
/// The operating system's own calls.
pub const SYSTEM: Platform<'static> = Platform { rename: &rename_noreplace, link: &link_file, unlink: &unlink_file, identity: ¤t_birth_identity };
fn link_file(from: &Path, to: &Path) -> std::io::Result<()> {
return std::fs::hard_link(from, to);
}
fn unlink_file(path: &Path) -> std::io::Result<()> {
return std::fs::remove_file(path);
}
/// What:
/// The name of a staging file of the lock at `lock`:
/// this process,
/// the batch,
/// and a token that no other attempt has.
/// Why:
/// The process number alone recurs once the process is gone,
/// and a name that recurs can be the name of a hard-link alias a killed holder left,
/// which is the lock's own inode;
/// the token makes every name new,
/// and the process number lets a later attempt tell from the name alone whether the writer still runs.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// `${lock}.cli-git-marker-${process.pid}-${seq}-${token}`
/// ```
pub fn staged_path_for(lock: &Path, seq: u64, token: &str) -> PathBuf {
let mut name = lock.as_os_str().to_os_string();
name.push(format!("{STAGED_INFIX}-{}-{seq}-{token}", std::process::id()));
return PathBuf::from(name);
}
/// What:
/// Creates the staging file of the lock at `lock` for batch `seq`,
/// new and holding `marker`.
/// Why:
/// Opened with exclusive creation and never truncation,
/// so a name that already exists (whatever it is a name of) is an error and no file of another attempt is emptied.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await writeFile(stagedPath, marker, { flag: 'wx' })
/// ```
///
/// # Errors
///
/// The storage error when the file cannot be created or written;
/// it is removed again when only the write failed.
pub fn stage_marker(lock: &Path, seq: u64, marker: &str) -> std::io::Result<PathBuf> {
let token: String = random_uuid().map_err(|error| { return std::io::Error::other(error.0); })?;
let staged: PathBuf = staged_path_for(lock, seq, token.as_str());
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&staged)?;
if let Err(error) = file.write_all(marker.as_bytes()) {
drop(file);
let _removed = std::fs::remove_file(&staged);
return Err(error);
}
return Ok(staged);
}
/// Renames `from` to `to` unless `to` exists:
/// one step that is as exclusive as `create_new` and leaves no second name.
#[cfg(target_os = "linux")]
pub fn rename_noreplace(from: &Path, to: &Path) -> std::io::Result<()> {
use std::os::unix::ffi::OsStrExt;
let source = std::ffi::CString::new(from.as_os_str().as_bytes()).map_err(std::io::Error::other)?;
let target = std::ffi::CString::new(to.as_os_str().as_bytes()).map_err(std::io::Error::other)?;
// SAFETY: both strings are valid NUL-terminated paths for the call.
let done: i32 = unsafe { libc::renameat2(libc::AT_FDCWD, source.as_ptr(), libc::AT_FDCWD, target.as_ptr(), libc::RENAME_NOREPLACE) };
return if done == 0 { Ok(()) } else { Err(std::io::Error::last_os_error()) };
}
#[cfg(not(target_os = "linux"))]
pub fn rename_noreplace(_from: &Path, _to: &Path) -> std::io::Result<()> {
return Err(std::io::Error::from(std::io::ErrorKind::Unsupported));
}
/// Whether a failed rename without replacing means the filesystem cannot do one (renameat2(2):
/// `EINVAL`),
/// not that the lock exists or storage failed.
fn rename_unsupported(error: &std::io::Error) -> bool {
if error.kind() == std::io::ErrorKind::Unsupported {
return true;
}
#[cfg(unix)]
{
return matches!(error.raw_os_error(), Some(code) if code == libc::EINVAL || code == libc::ENOSYS || code == libc::EOPNOTSUPP);
}
#[cfg(not(unix))]
{
return false;
}
}
/// Whether a failed hard link means the filesystem makes none (link(2):
/// `EPERM`),
/// not that the lock exists or storage failed.
fn links_unsupported(error: &std::io::Error) -> bool {
if error.kind() == std::io::ErrorKind::Unsupported {
return true;
}
#[cfg(unix)]
{
return matches!(error.raw_os_error(), Some(code) if code == libc::EPERM || code == libc::EOPNOTSUPP);
}
#[cfg(not(unix))]
{
return false;
}
}
/// What:
/// Makes the marked `staged` file the lock at `lock`;
/// `AlreadyExists` when any process holds it,
/// and `staged` then stays for the next attempt.
/// Why:
/// Git's lock is taken by exclusive creation,
/// and a restart tells the service's own stale lock from a foreign one only by its marker,
/// so the lock must never exist without it.
/// A rename that does not replace is exclusive and atomic and leaves no second name;
/// where the filesystem has none,
/// a hard link is exclusive too and its staging name is removed after.
/// Where it has neither,
/// nothing is created:
/// exclusive creation followed by a write would leave an empty lock for as long as a kill between the two lasts,
/// and an empty lock may be a Git mid-write,
/// so no restart could take it over.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await renameNoReplace(staged, lock) ?? await link(staged, lock) ?? refuse()
/// ```
///
/// # Errors
///
/// `AlreadyExists` when the lock exists,
/// `Unsupported` (naming both refusals) when the filesystem can place neither way,
/// and the storage error otherwise.
pub fn place_marked(platform: &Platform, staged: &Path, lock: &Path) -> std::io::Result<()> {
let refused_rename: std::io::Error = match (platform.rename)(staged, lock) {
Ok(()) => return Ok(()),
Err(error) if !rename_unsupported(&error) => return Err(error),
Err(error) => error,
};
match (platform.link)(staged, lock) {
Ok(()) => {
let _removed = std::fs::remove_file(staged);
return Ok(());
}
Err(error) if !links_unsupported(&error) => return Err(error),
Err(refused_link) => {
return Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
format!("renaming without replacing failed ({refused_rename}) and hard links failed ({refused_link})"),
));
}
}
}
/// What:
/// Says in the service's log why a lock was not placed:
/// the lock,
/// the cause,
/// and for a filesystem that can place a lock in neither atomic way,
/// the filesystem and what to do.
/// Why:
/// The clients are answered with a code only;
/// the person who reads the log must find the filesystem and the remedy there.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// console.error(`cli-git-service: could not place ${lock}: ${error}`)
/// ```
pub fn log_placement_failure(lock: &Path, error: &std::io::Error) {
if error.kind() != std::io::ErrorKind::Unsupported {
eprintln!("cli-git-service: cannot take the index lock {}: {error}", lock.display());
return;
}
let directory: &Path = lock.parent().unwrap_or(Path::new("."));
let filesystem: String = match read_mount_facts(directory) {
Ok(Some(facts)) => format!("the {} filesystem mounted at {}", facts.filesystem_type, facts.mount_point.display()),
Ok(None) => format!("the filesystem of {}", directory.display()),
Err(reason) => format!("the filesystem of {} ({reason})", directory.display()),
};
eprintln!(
"cli-git-service: cannot take the index lock {}: {filesystem} can neither rename a file without replacing another nor make a hard link ({error}), \
so the lock cannot be created with its marker in one step, and the service takes no lock that a kill could leave empty. \
Keep this repository's Git directory on a filesystem that supports one of the two (ext4, xfs, btrfs, tmpfs and overlayfs do), or update the kernel.",
lock.display()
);
}
/// Whether the process that a staging file's name carries is gone.
/// A name that carries none,
/// and a process that cannot be asked about,
/// count as running,
/// which keeps the file.
fn staging_writer_is_gone(suffix: &[u8]) -> bool {
if suffix.is_empty() {
// The fixed name an earlier release used: no attempt of this or the last release writes it.
return true;
}
let digits: Vec<u8> = suffix.strip_prefix(b"-").unwrap_or(&[]).iter().copied().take_while(u8::is_ascii_digit).collect();
let Some(pid) = std::str::from_utf8(digits.as_slice()).ok().and_then(|text| { return text.parse::<i64>().ok(); }) else { return false };
return matches!(process_birth_identity(pid), Ok(None));
}
/// What:
/// Removes the staging files of the lock at `lock` that a killed attempt left:
/// the fixed name an earlier release used,
/// and any whose writer is gone.
/// Why:
/// A kill before the rename leaves the staging file behind.
/// The writer is read from the name,
/// never from the content:
/// a file that is empty or half written is the file of a writer that is still at it,
/// and removing it fails that writer's placement.
/// A live writer's file is its own and stays.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// for (const name of await readdir(dir)) if (name.startsWith(prefix) && writerIsGone(name)) await rm(name)
/// ```
pub fn remove_dead_staging(lock: &Path) {
use std::os::unix::ffi::OsStrExt;
let (Some(directory), Some(file)) = (lock.parent(), lock.file_name()) else { return };
let mut prefix: Vec<u8> = file.as_bytes().to_vec();
prefix.extend_from_slice(STAGED_INFIX.as_bytes());
let Ok(entries) = std::fs::read_dir(directory) else { return };
for entry in entries.flatten() {
let name = entry.file_name();
let Some(suffix) = name.as_bytes().strip_prefix(prefix.as_slice()) else { continue };
if staging_writer_is_gone(suffix) {
let _removed = std::fs::remove_file(entry.path());
}
}
}
/// Removes the stale `lock`,
/// whose holder is gone.
/// False,
/// after saying why in the log,
/// when it is still there;
/// a lock that is already gone counts as removed.
pub fn reclaim(platform: &Platform, lock: &Path) -> bool {
return match (platform.unlink)(lock) {
Ok(()) => true,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => true,
Err(error) => {
eprintln!(
"cli-git-service: the index lock {} was left by a service that is gone, and it cannot be removed ({error}); \
remove it by hand, and clear its immutable attribute or fix the directory's permissions first if that is why it stays",
lock.display()
);
false
}
};
}