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
//! What:
//! Whether a published transaction directory without an owner record is abandoned,
//! and the message for one that is not.
//! Why:
//! A power loss can persist the rename that published a transaction directory and the bytes of `owner.json`
//! but not the name `owner.json` in it.
//! Nothing then says which process runs the transaction,
//! so the PID and birth identity that decide every other directory are gone.
//! The wrapper already judges an owner it cannot probe by age:
//! an owner of another PID namespace counts as alive until its record outlives `FOREIGN_OWNER_PATIENCE`
//! (`pid_namespace.rs`).
//! The same rule serves here:
//! a directory is abandoned once nothing in it has changed for the patience
//! and no process has a file or working directory in it.
//! A live wrapper writes its records within minutes,
//! and a wrapper cannot lose its own `owner.json` name while it runs,
//! so the age bound can fail only for a directory whose record someone removed by hand and whose wrapper then sat idle
//! for longer than the patience.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! const standing = await ownerlessStanding(directory); // 'abandoned' | { unproven: reason }
//! ```
/// The patience an owner that cannot be probed is given.
use super::pid_namespace::{FOREIGN_OWNER_PATIENCE, age_outlives_patience};
/// Processes that use a path.
use super::process_use::{UseScan, UseTarget, processes_using};
/// The fail-closed recovery failure.
use super::recovery_error::{RecoveryError, io_failure};
/// `Path` and `PathBuf` are borrowed and owned filesystem paths.
use std::path::{Path, PathBuf};
/// Wall-clock times.
use std::time::SystemTime;
/// Most entries the age walk reads before it gives up on proving the directory old.
/// A transaction directory holds a few dozen records at most.
const ENTRY_LIMIT: usize = 4096;
/// What:
/// What a directory without an owner record is.
/// Why:
/// Recovery treats `Abandoned` like a dead owner's directory and fails closed on `Unproven`, naming the reason.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type OwnerlessStanding = 'abandoned' | { unproven: string };
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum OwnerlessStanding {
/// Nothing in the directory changed for the patience and no process uses it.
Abandoned,
/// The directory may still be in use; the text says what is not proven.
Unproven(String),
}
/// What:
/// The newest modification time of a directory and everything in it,
/// or why the walk stopped.
/// Why:
/// A live wrapper writes records anywhere in its directory,
/// so the age of the directory is the age of its newest entry.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// Math.max(...(await walk(directory)).map((entry) => entry.mtimeMs))
/// ```
fn newest_change(directory: &Path) -> Result<Result<SystemTime, String>, RecoveryError> {
let mut newest: SystemTime = SystemTime::UNIX_EPOCH;
let mut pending: Vec<PathBuf> = vec![directory.to_path_buf()];
let mut seen: usize = 0;
while let Some(path) = pending.pop() {
seen += 1;
if seen > ENTRY_LIMIT {
return Ok(Err(format!("It holds more than {ENTRY_LIMIT} entries, too many to date")));
}
let metadata: std::fs::Metadata = match std::fs::symlink_metadata(&path) {
Ok(found) => found,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(io_failure("inspecting", path.as_path(), &error)),
};
let modified: SystemTime = match metadata.modified() {
Ok(found) => found,
Err(error) => return Ok(Err(format!("The change time of {} is unreadable ({error})", path.display()))),
};
newest = newest.max(modified);
if metadata.is_dir() {
let listing: std::fs::ReadDir = match std::fs::read_dir(&path) {
Ok(found) => found,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(io_failure("listing", path.as_path(), &error)),
};
for item in listing.flatten() {
pending.push(item.path());
}
}
}
return Ok(Ok(newest));
}
/// What:
/// The description of the processes found using a directory.
/// Why:
/// The message names the process the person has to deal with.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// scan.users.map((user) => `PID ${user.pid} (${user.command})`).join(', ')
/// ```
fn describe_users(scan: &UseScan) -> String {
let mut named: Vec<String> = Vec::new();
for user in &scan.users {
match &user.command {
Some(command) => named.push(format!("PID {} ({command})", user.pid)),
None => named.push(format!("PID {}", user.pid)),
}
}
return named.join(", ");
}
/// What:
/// Judge a published transaction directory that has no owner record,
/// with the age of its newest entry against `now` and the processes found using it.
/// Why:
/// The two proofs are separate so a control can set the clock and the scan.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// ownerlessStandingAt({ directory, now, scan })
/// ```
pub fn ownerless_standing_with(
directory: &Path,
now: SystemTime,
scan: fn(&Path) -> UseScan,
) -> Result<OwnerlessStanding, RecoveryError> {
let newest: SystemTime = match newest_change(directory)? {
Ok(found) => found,
Err(reason) => return Ok(OwnerlessStanding::Unproven(reason)),
};
let Ok(age) = now.duration_since(newest) else {
return Ok(OwnerlessStanding::Unproven(String::from("Its newest entry is dated in the future")));
};
if !age_outlives_patience(age) {
return Ok(OwnerlessStanding::Unproven(format!(
"Its newest entry changed {} seconds ago",
age.as_secs()
)));
}
let found: UseScan = scan(directory);
if !found.users.is_empty() {
return Ok(OwnerlessStanding::Unproven(format!(
"{} has a file or working directory in it",
describe_users(&found)
)));
}
return Ok(OwnerlessStanding::Abandoned);
}
/// What:
/// The processes using the canonical form of a directory.
/// Why:
/// Links in `/proc` resolve to canonical paths, so the directory is compared in that form.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// processesUsing({ inside: await realpath(directory) })
/// ```
fn scan_canonical(directory: &Path) -> UseScan {
let canonical: PathBuf = std::fs::canonicalize(directory).unwrap_or_else(|_| { return directory.to_path_buf(); });
return processes_using(&UseTarget::Inside(canonical.as_path()));
}
/// What:
/// Judge a published transaction directory that has no owner record,
/// by the clock and the host's process table.
/// Why:
/// The production judgement.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await ownerlessStanding(directory)
/// ```
pub fn ownerless_standing(directory: &Path) -> Result<OwnerlessStanding, RecoveryError> {
return ownerless_standing_with(directory, SystemTime::now(), scan_canonical);
}
/// What:
/// The failure for a directory whose owner is not proven gone.
/// Why:
/// The diagnostic names the directory, says why nothing is recovered, when cli-git recovers it by itself,
/// and the one manual way out with the condition under which it is safe.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// new CommitTransactionRecoveryError(`Transaction owner record is missing: ${directory}. ...`)
/// ```
pub fn ownerless_refusal(directory: &Path, reason: &str) -> RecoveryError {
let minutes: u64 = FOREIGN_OWNER_PATIENCE.as_secs() / 60;
return RecoveryError(format!(
"Transaction owner record is missing: {dir}. A published transaction directory names the cli-git process that \
runs it in `owner.json`, so cli-git cannot tell whether that process is still running, and every guarded \
command stops (exit 2) until this directory is dealt with. {reason}. Once nothing in it has changed for \
{minutes} minutes and no process has a file or working directory in it, the next guarded command recovers \
the directory by itself, with no action from you. To continue sooner, first confirm that no cli-git or git command is running \
in this repository (for example with `ps`), then remove the directory with `rm -r {dir}`. Remove it by hand \
only when it holds no `landing-<n>.json` record: with one, the commit may already have moved the branch, and \
removing the directory skips the index installation that cli-git's own recovery performs, so wait for the \
automatic recovery instead. The usual cause is a power loss that kept the file `owner.json` but not its name \
in the directory.",
dir = directory.display()
));
}
/// Ownerless-directory controls stay out of the release executable.
#[cfg(test)]
#[path = "ownerless_directory_tests.rs"]
mod tests;