git_policy_cli/process/pid_namespace.rs
1//! What:
2//! The PID namespace an owner record names, and the rule for judging an owner that lives in another one.
3//! Why:
4//! A PID means something only inside its PID namespace. A wrapper in a container that probes a host owner's PID
5//! finds no process (or an unrelated one), so it would judge a live owner dead and recover or retire what the owner
6//! still holds (issues 1781 and 1784). A record therefore names its namespace, and an owner of another namespace is
7//! judged by the age of its record instead of by the PID.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! const sameNamespace = readlinkSync('/proc/self/ns/pid') === record.ownerPidNamespace;
12//! ```
13
14/// The longest a merge waits for a foreign program's `index.lock`, which the patience is derived from.
15use crate::wait_ceilings::LOCK_CEILING;
16use std::path::Path;
17use std::time::{Duration, SystemTime};
18
19/// How long an owner of another PID namespace counts as alive, measured from when its record was written.
20/// A commit or a lock hold lasts seconds to minutes. Past this age a record is neither alive nor dead: the command that finds it stops,
21/// changes nothing and names it (`ExpiredForeignRecord`; owner, 2026-11-04, "Refuse and name it (Recommended)"), because the age proves no
22/// death, and a crashed foreign owner's record is removed by the person with the `rm` the error gives.
23/// Three kinds of record are judged by this age. A merge's intent record (`merge_isolation_journal.rs`, `write_merge_intent`,
24/// written once and synced per merge) and a claim file (`single_owner_claim.rs`, `write_private`, written once) are not
25/// rewritten while the owner works, so the age is the owner's whole hold; the item path of a worktree add is judged by its own
26/// modification time (`worktree_copy_owner.rs`, `is_alive`), which the add's work can move, so its age is at most the add's
27/// length. The patience must outlast the longest such hold.
28/// Basis: twice `LOCK_CEILING` (10 minutes, `wait_ceilings.rs`), the longest a merge waits for a foreign `index.lock` with its
29/// record already written, so 20 minutes. It is derived, not written as a number, so the two cannot drift apart again: it was
30/// an hour because it was twice the 30 minute live lock wait of `wait_limit.rs`, a file that no longer exists, and the owner's
31/// rule (2026-11-03, "nothing should ever wait forever or indefinitely") bounded that wait at `LOCK_CEILING`. Measured: the
32/// longest worktree add in the add timing log (79 adds, 2026-11-03T08:55:46Z to 2026-11-04T13:53:17Z, `totalMs`) took 539.7 s
33/// and the longest commit in the commit timing log (1,838 commits to 2026-11-04T14:08:47Z) 750.6 s, so the patience is 1.6
34/// times the longest command measured and 2.2 times the longest add. The logs hold commands of the host's own PID namespace, so
35/// no foreign-namespace hold was observed directly. If `LOCK_CEILING` changes, this value follows it; revisit the margin then
36/// (`doc/planning/cli-git-shipped-limits-audit.md`, "Foreign-owner patience").
37pub const FOREIGN_OWNER_PATIENCE: Duration = Duration::from_secs(2 * LOCK_CEILING.as_secs());
38
39/// What:
40/// The identity of this process's PID namespace, such as `pid:[4026531836]`, or nothing where the system has none.
41/// Why:
42/// The kernel names a namespace by an inode; two processes share a PID space exactly when the names are equal.
43///
44/// In TS you'd write (pseudocode):
45/// ```ts
46/// readlinkSync('/proc/self/ns/pid')
47/// ```
48pub fn current_pid_namespace() -> Option<String> {
49 #[cfg(target_os = "linux")]
50 {
51 return std::fs::read_link("/proc/self/ns/pid")
52 .ok()
53 .and_then(|target| { return target.into_os_string().into_string().ok(); });
54 }
55 #[cfg(not(target_os = "linux"))]
56 {
57 return None;
58 }
59}
60
61/// What:
62/// Whether a recorded namespace and the reader's namespace are both known and differ.
63/// Why:
64/// Without both names nothing proves the PID space differs, so the PID probe keeps its old meaning; a record
65/// written before this field existed carries no name.
66///
67/// In TS you'd write (pseudocode):
68/// ```ts
69/// recorded !== undefined && current !== undefined && recorded !== current
70/// ```
71pub fn is_other_namespace(recorded: Option<&str>, current: Option<&str>) -> bool {
72 return match (recorded, current) {
73 (Some(recorded_name), Some(current_name)) => recorded_name != current_name,
74 _ => false,
75 };
76}
77
78/// What:
79/// The age of a record file, or nothing when it cannot be measured.
80/// Why:
81/// The record file's modification time stands for when the owner started; a record that cannot be measured keeps its owner alive,
82/// because the wrapper never retires what it cannot prove dead.
83///
84/// In TS you'd write (pseudocode):
85/// ```ts
86/// Date.now() - statSync(recordPath).mtimeMs
87/// ```
88pub fn foreign_record_age(record_path: &Path) -> Option<Duration> {
89 let modified: SystemTime = std::fs::metadata(record_path).and_then(|metadata| { return metadata.modified(); }).ok()?;
90 return SystemTime::now().duration_since(modified).ok();
91}
92
93/// What:
94/// Whether a record of this age has outlived the patience; exactly the patience still counts as alive.
95/// Why:
96/// The boundary is a fact of the rule, so it is separated from the clock to be checked exactly.
97///
98/// In TS you'd write (pseudocode):
99/// ```ts
100/// age > FOREIGN_OWNER_PATIENCE_MS
101/// ```
102pub fn age_outlives_patience(age: Duration) -> bool {
103 return age > FOREIGN_OWNER_PATIENCE;
104}
105
106/// What:
107/// A record of another PID namespace that is older than the patience: neither alive nor dead.
108/// Why:
109/// Owner, 2026-11-04, "Refuse and name it (Recommended)": expiry no longer authorizes a command to treat a foreign owner as dead
110/// and to recover, take over or remove what it holds. The age is a length that nothing bounds (a write that cannot be run twice may
111/// run past any number, and a step the wrapper left running outlives the wrapper), so it proves no death. The command stops, changes
112/// nothing, and says which record it found and what a person checks before removing it.
113///
114/// In TS you'd write (pseudocode):
115/// ```ts
116/// type ExpiredForeignRecord = { record: string; namespace: string; age: number };
117/// ```
118#[derive(Clone, Debug, Eq, PartialEq)]
119pub struct ExpiredForeignRecord {
120 /// The record file.
121 pub record: std::path::PathBuf,
122 /// The PID namespace the record names.
123 pub namespace: String,
124 /// How old the record file is.
125 pub age: Duration,
126}
127
128impl ExpiredForeignRecord {
129 /// What:
130 /// The error a command stops with: the record file, the owner's PID and namespace, the age, that the owner may be live in
131 /// another container or a Git process left running, and the exact `rm`.
132 /// Why:
133 /// The person must be able to decide, and the condition for removing the record covers the Git processes the wrapper leaves
134 /// running (a step that cannot be run twice survives the wrapper that started it), not only a running cli-git command.
135 ///
136 /// In TS you'd write (pseudocode):
137 /// ```ts
138 /// `cli-git: the record ${record} was written by process ${pid} ... rm -- ${quoted(record)}`
139 /// ```
140 pub fn describe(&self, owner_pid: i64) -> String {
141 return format!(
142 "cli-git: the record {record} was written by process {owner_pid} in the PID namespace {namespace}, which is not this one, and it is {minutes} \
143 minutes old, past the {patience} minutes after which cli-git no longer takes such a record for a live owner's. Nothing was \
144 changed. The owner may still be alive in another container, and a Git process that cli-git left running (a write it could not \
145 safely end, such as a merge, a reset or a branch update) may still be working for it. Look in every container and on this host \
146 that use this repository for a cli-git command and for a Git process cli-git left running (`ps` shows them); when none runs, \
147 remove the record with `rm -- {path}` and run the command again.",
148 record = self.record.display(),
149 namespace = self.namespace,
150 minutes = self.age.as_secs() / 60,
151 patience = FOREIGN_OWNER_PATIENCE.as_secs() / 60,
152 path = crate::stale_ref_lock_message::shell_quote(self.record.as_path())
153 );
154 }
155}
156
157/// What:
158/// How a record is judged when it names another PID namespace: alive while it is younger than the patience, refused after it, and
159/// nothing for a record of this namespace or of none, which the PID decides.
160/// Why:
161/// Callers share one rule: an owner of another namespace is alive until its record outlives the patience, and then the command
162/// stops (`ExpiredForeignRecord`) and changes nothing.
163///
164/// In TS you'd write (pseudocode):
165/// ```ts
166/// isOtherNamespace(recorded, current) ? (ageOutlivesPatience(age(recordPath)) ? refused : alive) : undefined
167/// ```
168pub fn foreign_owner_standing(recorded: Option<&str>, record_path: &Path) -> Option<Result<(), ExpiredForeignRecord>> {
169 if !is_other_namespace(recorded, current_pid_namespace().as_deref()) {
170 return None;
171 }
172 let namespace: String = recorded.map_or_else(String::new, String::from);
173 return Some(match foreign_record_age(record_path) {
174 Some(age) if age_outlives_patience(age) => Err(ExpiredForeignRecord { record: record_path.to_path_buf(), namespace, age }),
175 Some(_) | None => Ok(()),
176 });
177}
178
179/// Namespace controls stay out of the release executable.
180#[cfg(test)]
181#[path = "pid_namespace_tests.rs"]
182mod tests;