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
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
//! What:
//! How one reference lock is judged: too new, held, stale on a proof, or unproven.
//! Why:
//! Git writes no holder into a lock, so only a few facts prove a holder gone; everything else is left alone. The order of
//! the checks and the reason for each are in `SPEC.md`, "Reference locks left by a Git that was killed". A time stamp is not
//! among the proofs: a correction of the wall clock moves every time that a lock and the boot are compared by (finding S-2 of
//! the cross-vendor review of 2026-11-03T11:33:50-05:00).
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! const examined = judge.examine(lockPath); // { verdict, pidFile, size, born, identity }
//! ```
use super::fs_mount_options::read_mount_facts;
use super::pid_namespace::current_pid_namespace;
use super::process_start::{ProcessStart, linux_start, now_ms};
use super::process_use::{UseScan, UseTarget, processes_using_through};
use super::stale_ref_lock_found::PidFile;
use super::stale_ref_lock_nested_numbers::nested_holders_of;
use super::stopped_git_locks::pid_named_by;
use super::stale_ref_lock_walk::pid_file_beside;
pub use super::stale_ref_lock_identity::{Identity, identity_of};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
/// The longest step of a landing that updates a reference, in a live service: 3,438 ms, the largest `ref_ms` of 1,302 `answered`
/// entries of the service journal (`journalctl --user SYSLOG_IDENTIFIER=cli-git-service`, 2026-11-02T09:01:38Z to
/// 2026-11-03T15:11:58Z; 1 entry above 2,000 ms and 2 at or above 1,000 ms; median 2 ms, 99th percentile 64 ms). The step starts
/// Git, takes the reference lock, fills it and renames it, so a lock a live Git holds is gone within it. Real Git on a
/// throwaway repository, traced with `strace`, held a lock for at most 2.8 ms (7,700 updates, load average 3 to 14), so this
/// is the loaded tail, not the common case.
pub const LONGEST_LIVE_REF_STEP: Duration = Duration::from_millis(3438);
/// How many times the longest live reference step the margin allows: twice, because the longest of a sample understates the
/// tail (one entry of 1,302 reached 3,438 ms, so the next sample may reach more). Option B of the question on the margin, "twice
/// the sample maximum, seven seconds", picked by the coordinator under the owner's delegation, open to the owner's veto
/// (`doc/planning/cli-git-shipped-limits-audit.md`, "Picked for these follow-ups").
pub const MARGIN_MULTIPLE: u64 = 2;
/// How old a lock must be before it is judged: long enough for a live Git to have filled and renamed a lock it just made. The
/// whole seconds at or above `MARGIN_MULTIPLE` times the longest step measured in `LONGEST_LIVE_REF_STEP` (6.876 s, so 7 s),
/// because a lock younger than the longest live hold may still be live. A stale lock is left that long before it is cleared,
/// which is a few seconds of a failing update, against a live lock removed (`doc/planning/cli-git-shipped-limits-audit.md`,
/// "Margins for removing left-behind locks").
pub const MARGIN: Duration = Duration::from_secs((LONGEST_LIVE_REF_STEP.as_millis() as u64 * MARGIN_MULTIPLE).div_ceil(1000));
/// The kernel's name for the initial PID namespace, where every process of the machine has the number it is seen by.
const INITIAL_PID_NAMESPACE: &str = "pid:[4026531836]";
/// The mount point of the process table the judge reads.
const PROC_MOUNT_POINT: &str = "/proc";
/// The start of the mount option that hides other accounts' processes from this account.
const HIDEPID_OPTION: &str = "hidepid=";
/// The values of `hidepid` that hide nothing.
const HIDEPID_OFF: [&str; 2] = ["0", "off"];
/// The file system types a lock may sit on: disks and memory of this machine. A network or user-space file system can be
/// written by a Git on another machine, whose process numbers mean nothing here.
const LOCAL_FILE_SYSTEMS: [&str; 8] = ["btrfs", "ext4", "xfs", "f2fs", "tmpfs", "overlay", "zfs", "bcachefs"];
/// How far a file's birth may precede the clock reading taken before a child started, for the file to count as born after
/// the child started. The kernel stamps files from a coarse clock that lags the one `SystemTime` reads by up to one tick.
/// Measured on this host (`CONFIG_HZ=1000`, so a tick is 1 ms): a new file's birth time lagged a clock read taken just before
/// the create by at most 1.684 ms and 1.729 ms in two runs of 10,000 creates each, so 50 ms is about thirty times the
/// longest lag. Kept; recorded in `doc/planning/cli-git-shipped-limits-audit.md`, "Margins for removing left-behind locks"
/// (the coordinator's pick under the owner's delegation, open to the owner's veto).
pub const COARSE_CLOCK_SLACK: Duration = Duration::from_millis(50);
/// How far after the lock's birth a process must have started to count as a later user of the same number, beyond the
/// clock's resolution. Measured: the start time derived from `/proc/<pid>/stat`, `/proc/uptime` and the clock read together
/// exceeded the clock read just before the process was spawned by at most 2.331 ms and 2.329 ms in two runs of 1,000 spawns, so
/// 100 ms is over forty times the longest error beyond the 20 ms resolution. Kept; the coordinator's pick under the owner's
/// delegation, open to the owner's veto.
const REUSE_SLACK_MS: f64 = 100.0;
/// The command name of Git, and of its helpers (`git-remote-https`), as the kernel reports it.
const GIT_COMMAND_PREFIX: &str = "git";
/// Why a lock may be removed.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Proof {
/// The process the pid file names is gone.
PidGone(i64),
/// The process number now belongs to a program that is not Git.
PidReused {
/// The number the pid file names.
pid: i64,
/// The command name of the process that has it now.
command: String,
},
/// The staging file `packed-refs.new` has no lock beside it, and Git creates it only while holding that lock.
OrphanStaging,
}
/// What a judgement concluded.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Verdict {
/// Younger than the margin, or not a regular file: left, and not mentioned.
TooNew,
/// A running process holds it; the text names the process and how that is known.
Held(String),
/// Nothing proves the holder gone; the text says why.
Unproven(String),
/// Removable, on this proof.
Stale(Proof),
}
/// What:
/// The processes the wrapper itself started, stopped and saw end, and when the first of them started.
/// Why:
/// A pid file that such a process wrote holds a number of the wrapper's own PID namespace, whatever view of the machine the
/// judge has (a container has a view of its own), and the wrapper knows the process is gone. This is the one proof that
/// needs no inference about namespaces, and it covers only locks born after the child started.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type OwnEnded = { pids: number[]; since: Date };
/// ```
#[derive(Clone, Debug)]
pub struct OwnEnded {
/// The numbers of the processes of the stopped tree that have ended.
pub pids: Vec<i64>,
/// The wall-clock time read before the stopped child started.
pub since: SystemTime,
}
/// The evidence a judgement may use, and the limits of what it can see.
#[derive(Clone, Debug)]
pub struct Judge {
/// The process table.
pub proc_root: PathBuf,
/// How old a lock must be before it is judged at all.
pub margin: Duration,
/// Whether the process numbers read here mean the same processes as in the pid files.
pub pid_view_complete: bool,
/// When the machine booted by the wall clock, where known. Used only to say that a lock looks older than the boot,
/// never as a proof: a correction of the clock moves it.
pub boot: Option<SystemTime>,
/// The file system types a lock may sit on.
pub local_file_systems: Vec<String>,
/// The ended processes of a child this wrapper stopped, where the judgement follows a stop.
pub own_ended: Option<OwnEnded>,
}
/// Everything one judgement found out.
#[derive(Clone, Debug)]
pub struct Examined {
/// The conclusion.
pub verdict: Verdict,
/// What the pid file beside the lock says.
pub pid_file: PidFile,
/// The lock's identity when it was judged.
pub identity: Identity,
/// When the lock was born, where known.
pub born: Option<SystemTime>,
}
impl Judge {
/// The judge of this process: the real process table, the real boot time, and a complete view of the process numbers
/// only where this process runs in the initial PID namespace and its process table hides no process.
pub fn here() -> Self {
return Self {
proc_root: PathBuf::from(PROC_MOUNT_POINT),
margin: MARGIN,
pid_view_complete: current_pid_namespace().as_deref() == Some(INITIAL_PID_NAMESPACE) && proc_hides_no_process(),
boot: boot_time(),
local_file_systems: LOCAL_FILE_SYSTEMS.iter().map(|name| { return String::from(*name); }).collect(),
own_ended: None,
};
}
/// Whether the file system holding `path` is on the local list; the reason when it is not or cannot be told.
fn local_check(&self, path: &Path) -> Result<(), String> {
let facts = read_mount_facts(path)?;
let Some(mount) = facts else {
return Err(String::from("the file system that holds it is not in the mount table"));
};
if self.local_file_systems.contains(&mount.filesystem_type) {
return Ok(());
}
return Err(format!("it sits on a `{}` file system, which a Git on another machine may share", mount.filesystem_type));
}
/// The pid file beside `lock`, as evidence about this lock's holder.
fn pid_file_state(lock: &Path, lock_born: Option<SystemTime>) -> PidFile {
let pid_path: PathBuf = pid_file_beside(lock);
let Some(pid) = pid_named_by(pid_path.as_path()) else {
return PidFile::Absent;
};
let pid_born: Option<SystemTime> = std::fs::symlink_metadata(pid_path.as_path()).ok().and_then(|metadata| { return metadata.created().ok(); });
return match (lock_born, pid_born) {
// Git creates the pid file after the lock, so a pid file born earlier belongs to an earlier holder.
(Some(lock_at), Some(pid_at)) if pid_at >= lock_at => PidFile::Names(i64::from(pid)),
_ => PidFile::Older(i64::from(pid)),
};
}
/// What the pid file's process is now: gone, a program that is not Git, a Git, or not known.
/// A number that is gone or reused here proves the writer gone only when no process of the table has that number
/// inside a PID namespace of its own, because the pid file holds the number its writer had in the writer's namespace.
fn judge_pid(&self, pid: i64, lock_born: Option<SystemTime>) -> Verdict {
if !self.pid_view_complete {
if self.names_ended_own_child(pid, lock_born) {
return self.judge_own_ended_pid(pid);
}
return Verdict::Unproven(format!(
"the pid file beside it names process {pid}, but this process runs in a PID namespace of its own, or cannot see every process of the machine, so it cannot tell whether {pid} is gone"
));
}
let verdict: Verdict = self.judge_listed_pid(pid, lock_born);
if !matches!(verdict, Verdict::Stale(_)) {
return verdict;
}
return match nested_holders_of(self.proc_root.as_path(), pid) {
Ok(holders) if holders.is_empty() => verdict,
Ok(holders) => Verdict::Unproven(format!(
"the pid file beside it names process {pid}, and process {} is process {pid} of a PID namespace of its own, so the writer may be that process",
holders.first().copied().unwrap_or_default()
)),
Err(reason) => Verdict::Unproven(format!(
"the pid file beside it names process {pid}, and the processes could not be searched for one that is process {pid} of a PID namespace of its own: {reason}"
)),
};
}
/// Whether the pid file names a process of the tree this wrapper stopped and saw end, and the lock was born after that
/// child started: then the number is the wrapper's own namespace's, and it was the child's.
fn names_ended_own_child(&self, pid: i64, lock_born: Option<SystemTime>) -> bool {
let Some(own) = self.own_ended.as_ref() else {
return false;
};
return own.pids.contains(&pid) && lock_born.is_some_and(|born| { return born + COARSE_CLOCK_SLACK >= own.since; });
}
/// The verdict for a number of a process this wrapper saw end, when the table is not known to be the machine's: it is
/// gone if the table shows nothing with that number now, and anything running with it is left alone.
fn judge_own_ended_pid(&self, pid: i64) -> Verdict {
return match linux_start(pid, self.proc_root.as_path(), now_ms()) {
ProcessStart::Missing => Verdict::Stale(Proof::PidGone(pid)),
ProcessStart::Unknown(reason) => Verdict::Unproven(format!("the pid file beside it names process {pid}, which could not be looked at: {reason}")),
ProcessStart::Running { .. } => Verdict::Held(format!("process {pid}, which the pid file beside it names, ended with the child this wrapper stopped, and a process has the number again")),
};
}
/// What the pid file's process is in the process table as listed: gone, a program that is not Git, a Git, or not known.
fn judge_listed_pid(&self, pid: i64, lock_born: Option<SystemTime>) -> Verdict {
return match linux_start(pid, self.proc_root.as_path(), now_ms()) {
ProcessStart::Missing => Verdict::Stale(Proof::PidGone(pid)),
ProcessStart::Unknown(reason) => Verdict::Unproven(format!("the pid file beside it names process {pid}, which could not be looked at: {reason}")),
ProcessStart::Running { started_at_ms, resolution_ms } => {
let command: String = std::fs::read_to_string(self.proc_root.join(pid.to_string()).join("comm")).map(|text| { return text.trim().to_owned(); }).unwrap_or_default();
// The number was reused only if the program is not Git AND it started after the lock was made; when the two
// disagree (a Git under another name, a clock that moved) the process is taken for the holder.
let born_ms: Option<f64> = lock_born.and_then(|at| { return at.duration_since(SystemTime::UNIX_EPOCH).ok(); }).map(|elapsed| { return elapsed.as_secs_f64() * 1000.0; });
let started_after: bool = born_ms.is_some_and(|born| { return started_at_ms > born + resolution_ms as f64 + REUSE_SLACK_MS; });
if command.starts_with(GIT_COMMAND_PREFIX) || !started_after {
Verdict::Held(format!("process {pid} ({command}), which the pid file beside it names, is running"))
} else {
Verdict::Stale(Proof::PidReused { pid, command })
}
}
};
}
/// Whether the lock looks older than the boot by the wall clock. Only words the notice: a forward correction of the clock
/// during the boot makes a lock that a live Git holds look older than the boot, so no lock is removed on it.
fn looks_older_than_boot(&self, born: Option<SystemTime>, identity: &Identity) -> bool {
let Some(boot) = self.boot else {
return false;
};
let modified: SystemTime = nanoseconds_to_time(identity.modified_ns);
return born.is_some_and(|at| { return at < boot; }) && modified < boot;
}
/// The first process of this account that the scan could not look into and that is a Git (or whose name cannot be read
/// back): a program that is not Git, such as the user's service manager, cannot hold a Git lock. The reason text of the
/// scan starts `PID <number>`.
fn unreadable_git(&self, scan: &UseScan) -> Option<String> {
for reason in &scan.unreadable {
let number: Option<&str> = reason.strip_prefix("PID ").and_then(|rest| { return rest.split(' ').next(); });
let command: Option<String> = number.and_then(|pid| { return std::fs::read_to_string(self.proc_root.join(pid).join("comm")).ok(); });
match (number, command) {
(Some(_), None) => {}
(_, Some(name)) if !name.trim().starts_with(GIT_COMMAND_PREFIX) => {}
_ => return Some(reason.clone()),
}
}
return None;
}
/// Judge the staging file `packed-refs.new` at `staging`, which has no lock beside it: old enough, on a local disk, and
/// open in no process. The caller has checked that the lock is absent.
pub fn examine_staging(&self, staging: &Path) -> Option<Examined> {
let identity: Identity = identity_of(staging)?;
let born: Option<SystemTime> = std::fs::symlink_metadata(staging).ok()?.created().ok();
let finish = |verdict: Verdict| { return Some(Examined { verdict, pid_file: PidFile::Absent, identity, born }); };
let newest: SystemTime = born.map_or(nanoseconds_to_time(identity.modified_ns), |at| { return at.max(nanoseconds_to_time(identity.modified_ns)); });
if SystemTime::now().duration_since(newest).unwrap_or_default() < self.margin {
return finish(Verdict::TooNew);
}
if let Err(reason) = self.local_check(staging) {
return finish(Verdict::Unproven(reason));
}
let scan: UseScan = processes_using_through(self.proc_root.as_path(), &UseTarget::File { device: identity.device, inode: identity.inode }, std::process::id());
if let Some(reason) = scan.unavailable.as_ref() {
return finish(Verdict::Unproven(format!("the process table could not be listed: {reason}")));
}
if let Some(user) = scan.users.first() {
return finish(Verdict::Held(format!("process {} ({}) has the file open", user.pid, user.command.clone().unwrap_or_default())));
}
return finish(Verdict::Stale(Proof::OrphanStaging));
}
/// Judge the lock at `lock`; nothing when it is not a regular file any more.
pub fn examine(&self, lock: &Path) -> Option<Examined> {
let identity: Identity = identity_of(lock)?;
let metadata: std::fs::Metadata = std::fs::symlink_metadata(lock).ok()?;
let born: Option<SystemTime> = metadata.created().ok();
let pid_file: PidFile = Self::pid_file_state(lock, born);
let finish = |verdict: Verdict| { return Some(Examined { verdict, pid_file: pid_file.clone(), identity, born }); };
if !metadata.is_file() {
return finish(Verdict::TooNew);
}
let newest: SystemTime = born.map_or(nanoseconds_to_time(identity.modified_ns), |at| { return at.max(nanoseconds_to_time(identity.modified_ns)); });
// A lock whose pid file names a process this wrapper stopped and saw end needs no margin: its holder is known to be gone.
let own: bool = matches!(&pid_file, PidFile::Names(pid) if self.names_ended_own_child(*pid, born));
if !own && SystemTime::now().duration_since(newest).unwrap_or_default() < self.margin {
return finish(Verdict::TooNew);
}
if let Err(reason) = self.local_check(lock) {
return finish(Verdict::Unproven(reason));
}
let scan: UseScan = processes_using_through(self.proc_root.as_path(), &UseTarget::File { device: identity.device, inode: identity.inode }, std::process::id());
if let Some(reason) = scan.unavailable {
return finish(Verdict::Unproven(format!("the process table could not be listed: {reason}")));
}
if let Some(user) = scan.users.first() {
return finish(Verdict::Held(format!("process {} ({}) has the file open", user.pid, user.command.clone().unwrap_or_default())));
}
if let Some(reason) = self.unreadable_git(&scan) {
return finish(Verdict::Unproven(format!("a Git process of this account could not be looked into ({reason}), so it may hold the lock")));
}
if let PidFile::Names(pid) = pid_file {
return finish(self.judge_pid(pid, born));
}
let mut why: String = match pid_file {
PidFile::Older(pid) => format!("the pid file beside it (process {pid}) is older than the lock, so it belongs to an earlier holder"),
_ => String::from("no pid file beside it names a holder (Git writes one only under `core.lockfilePid`), and no process has it open"),
};
if self.looks_older_than_boot(born, &identity) {
why.push_str("; its times are earlier than the start of this boot, which a reboot would explain, but so would a correction of the clock, so the times alone prove nothing");
}
return finish(Verdict::Unproven(why));
}
}
/// Whether the mount of `/proc` shows this account every process: it has no `hidepid` option, or one that hides nothing.
/// A table that hides processes lists fewer than exist, so a number missing from it is no proof. Where the mount cannot be
/// read the answer is no.
fn proc_hides_no_process() -> bool {
let Ok(Some(mount)) = read_mount_facts(Path::new(PROC_MOUNT_POINT)) else {
return false;
};
return !mount.mount_options.iter().chain(mount.super_options.iter()).any(|option| {
return option.strip_prefix(HIDEPID_OPTION).is_some_and(|value| { return !HIDEPID_OFF.contains(&value); });
});
}
/// A time given as nanoseconds since the epoch.
fn nanoseconds_to_time(nanoseconds: i128) -> SystemTime {
let magnitude: u64 = u64::try_from(nanoseconds.unsigned_abs()).unwrap_or(u64::MAX);
return if nanoseconds >= 0 { SystemTime::UNIX_EPOCH + Duration::from_nanos(magnitude) } else { SystemTime::UNIX_EPOCH - Duration::from_nanos(magnitude) };
}
/// When the machine booted: the `btime` line of `/proc/stat`, in whole seconds since the epoch.
fn boot_time() -> Option<SystemTime> {
let text: String = std::fs::read_to_string("/proc/stat").ok()?;
let seconds: u64 = text.lines().find_map(|line| { return line.strip_prefix("btime ")?.trim().parse::<u64>().ok(); })?;
return Some(SystemTime::UNIX_EPOCH + Duration::from_secs(seconds));
}
/// Controls on the margins stay out of the release executable.
#[cfg(test)]
#[path = "stale_ref_lock_judge_tests.rs"]
mod tests;