Skip to main content

git_policy_cli/push/
push_progress.rs

1//! What:
2//!  The progress of a push: whether the push and everything it started are at work, sampled at a small interval, and the
3//!  clock that stops it on a stall (no sign of work for the stall window, and no open connection to a remote).
4//! Why:
5//!  A fixed deadline stops a slow push that is still working and lets a stuck push take its whole deadline. A process shows
6//!  that it works in three ways, each read from `/proc`, and only a tree that shows none of them is stalled:
7//!       bytes moved: `rchar` plus `wchar` of `/proc/<pid>/io`, summed over the tree;
8//!       processor time: `utime` plus `stime` of `/proc/<pid>/stat` growing;
9//!       a task able to run or waiting for the disk: state `R` or `D` in `/proc/<pid>/task/<tid>/stat`.
10//!  Measured on 2026-11-03, which decided the three:
11//!       a 30 MB push to a local bare remote moved 94 MB of `rchar` and 156 MB of `wchar` over its four processes while
12//!       `read_bytes` was 64 KB and `write_bytes` came late and in bursts, so the block-layer counters see neither pipes nor
13//!       sockets and lag behind the transfer;
14//!       `rchar` and `wchar` count `read` and `write` calls, not `send` and `recv` (102 bytes for 100000 bytes through
15//!       `send`/`recv`, 100102 through `read`), so a stretch that only curl performs shows through the pipe that feeds it;
16//!       a `dd` of 64 MB with `conv=fsync` was in state `D` in six of seven looks and used 0 processor ticks (a tick is 10 ms),
17//!       so a wait for the disk shows in neither bytes nor processor time, only in the state;
18//!       a parent in `vfork` is in state `D` with 0 ticks, the way the controls hold a tree in disk wait exactly;
19//!       a process reading a silent socket and a `sleep` are in state `S` with a constant tick count, so a wait on a server
20//!       that says nothing shows nothing in the process; only the connection's state shows it.
21//!  What each costs: bytes miss work that moves none; processor time makes a process that spins or polls look alive, and its
22//!  resolution is a tick; state `D` makes a process wedged for ever in the kernel (a dead network file system) look alive, and
23//!  state `R` one that is only waiting for a processor; no total time bounds them (owner decision, see below). None of them sees
24//!  a server that checks the push before it answers: that tree is in state `S`, indistinguishable from a dead peer by its
25//!  processes alone. What tells them apart is the connection: a process of the tree that holds a TCP socket in state
26//!  `ESTABLISHED` (read from the kernel's socket diagnostics, `tcp_sockets.rs`) has a remote that has not closed it, and a tree
27//!  with such a socket is waited for, however long it shows nothing. The owner's answer "Wait while connected (Recommended)"
28//!  (2026-11-03) is the rule; the rest is pick 5 of `doc/planning/cli-git-shipped-limits-audit.md`, which the coordinator made
29//!  under the owner's delegation ("Pick options as if I had picked them", 2026-11-03) and the owner may veto: no stall window
30//!  for a connected push, no ceiling for any push, no low-speed abort.
31//!  What the code cannot observe: a peer that vanished without a reset leaves the socket `ESTABLISHED` until TCP gives up
32//!  (retransmission for unacknowledged data, keepalive probes for an idle connection), which the owner accepted.
33//!  Where `/proc/<pid>/io` does not exist (no `/proc`, or a kernel without task I/O accounting) nothing is measured and the
34//!  stall window is a fixed deadline, as before.
35//!
36//! In TS you'd write (pseudocode):
37//! ```ts
38//! const clock = new Clock(child.pid, { stallMs }); while (!exited) { const stop = clock.check(); if (stop) return stop; }
39//! ```
40
41/// The patience a clock keeps, and what a stop reports.
42use crate::bounded_push::{Patience, StopCause, StopReport};
43/// The processes below the push, and the identity of each.
44use crate::process_tree::{TreeMember, descendants_of, member_of};
45/// The connected sockets of the machine with their byte counts, and the sockets a process holds.
46use crate::wait_ceilings::CONNECTED_PUSH_QUIET_WINDOW;
47use crate::tcp_sockets::{ESTABLISHED, established_sockets, socket_inodes_of};
48/// What each process of the tree was last seen to have moved.
49use std::collections::HashMap;
50/// Time spans and the monotonic clock.
51use std::time::{Duration, Instant};
52
53/// How often the counters of the tree are read.
54pub const SAMPLE_INTERVAL: Duration = Duration::from_millis(500);
55
56/// What:
57///  The bytes a process has read and written, from its `/proc/<pid>/io`, or nothing where they cannot be read.
58/// Why:
59///   `rchar` and `wchar` are the counters that see pipes and sockets read and written with `read` and `write`.
60///
61/// In TS you'd write (pseudocode):
62/// ```ts
63/// const io = Object.fromEntries(readFileSync(`/proc/${pid}/io`, 'utf8').split('\n').map((line) => line.split(': ')));
64/// return Number(io.rchar) + Number(io.wchar);
65/// ```
66#[cfg(target_os = "linux")]
67fn bytes_moved_by(pid: i32) -> Option<u64> {
68    let text: String = std::fs::read_to_string(format!("/proc/{pid}/io")).ok()?;
69    let mut read: Option<u64> = None;
70    let mut written: Option<u64> = None;
71    for line in text.lines() {
72        if let Some(value) = line.strip_prefix("rchar: ") {
73            read = value.trim().parse::<u64>().ok();
74        } else if let Some(value) = line.strip_prefix("wchar: ") {
75            written = value.trim().parse::<u64>().ok();
76        }
77    }
78    return Some(read?.saturating_add(written?));
79}
80
81/// Only Linux counts a process's bytes; elsewhere nothing is measured.
82// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
83#[cfg(not(target_os = "linux"))]
84fn bytes_moved_by(_pid: i32) -> Option<u64> {
85    return None;
86}
87
88/// What:
89///  The state letter and the processor ticks (`utime` plus `stime`) of a `/proc/<pid>/stat` line.
90/// Why:
91///   The command name sits in parentheses and may hold spaces and parentheses, so the fields are read after the last
92///  closing parenthesis; the state is the first of them and the two times are the twelfth and thirteenth.
93///
94/// In TS you'd write (pseudocode):
95/// ```ts
96/// const [state, , , , , , , , , , , utime, stime] = text.slice(text.lastIndexOf(')') + 2).split(' ');
97/// ```
98pub(crate) fn parse_activity(stat: &str) -> Option<(char, u64)> {
99    let after_name: &str = stat.get(stat.rfind(')')? + 2..)?;
100    let fields: Vec<&str> = after_name.split(' ').collect();
101    let state: char = fields.first()?.chars().next()?;
102    let user: u64 = fields.get(11)?.parse().ok()?;
103    let system: u64 = fields.get(12)?.parse().ok()?;
104    return Some((state, user.saturating_add(system)));
105}
106
107/// Whether a state letter is one of a task that is at work: able to run (`R`) or waiting for the disk (`D`).
108pub(crate) fn is_working(state: char) -> bool {
109    return state == 'R' || state == 'D';
110}
111
112/// What:
113///  A process's processor ticks, and whether any of its tasks is able to run or waiting for the disk.
114/// Why:
115///   A thread that waits for the disk (an `fsync`) is in state `D` while the process's own state may be `S`, so every task
116///  is read.
117///
118/// In TS you'd write (pseudocode):
119/// ```ts
120/// { cpu: parse(`/proc/${pid}/stat`).utime + stime, working: tasks(pid).some((task) => 'RD'.includes(task.state)) }
121/// ```
122#[cfg(target_os = "linux")]
123fn activity_of(pid: i32) -> Option<(u64, bool)> {
124    let text: String = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
125    let (state, ticks): (char, u64) = parse_activity(text.as_str())?;
126    let mut working: bool = is_working(state);
127    if !working && let Ok(tasks) = std::fs::read_dir(format!("/proc/{pid}/task")) {
128        for task in tasks.flatten() {
129            if let Ok(line) = std::fs::read_to_string(task.path().join("stat"))
130                && let Some((task_state, _)) = parse_activity(line.as_str())
131                && is_working(task_state)
132            {
133                working = true;
134                break;
135            }
136        }
137    }
138    return Some((ticks, working));
139}
140
141/// Only Linux reads what a process is doing; elsewhere nothing is.
142// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
143#[cfg(not(target_os = "linux"))]
144fn activity_of(_pid: i32) -> Option<(u64, bool)> {
145    return None;
146}
147
148/// Whether this system counts the bytes of a process at all.
149///
150/// In TS you'd write (pseudocode):
151/// ```ts
152/// existsSync('/proc/self/io')
153/// ```
154fn system_measures() -> bool {
155    return bytes_moved_by(i32::try_from(std::process::id()).unwrap_or(1)).is_some();
156}
157
158/// What:
159///  The work of the tree under a root so far, and whether a sample saw any sign of work since the one before.
160/// Why:
161///   A process that ends takes its counters with it, so each process is remembered by its number and start time, and only
162///  what it moved or used since it was last seen is added; a task able to run or waiting for the disk is a sign of work at
163///  the sample that finds it.
164///
165/// In TS you'd write (pseudocode):
166/// ```ts
167/// class Meter { moved = 0; sample(rootPid): boolean }
168/// ```
169#[derive(Debug, Default)]
170pub struct Meter {
171    /// What each process of the tree had moved and used when it was last seen: bytes, then processor ticks.
172    seen: HashMap<(i32, u64), (u64, u64)>,
173    /// Every byte the tree has moved since the first sample.
174    moved: u64,
175    /// The bytes each connected socket of the tree had sent (acknowledged) and received when it was last seen, by inode.
176    sockets: HashMap<u64, u64>,
177    /// Every byte the tree's connected sockets have carried since the first sample.
178    socket_moved: u64,
179    /// Whether the last sample found a process of the tree holding a connected socket.
180    connected: bool,
181    /// How many times a sample listed the processes of the whole machine to find the tree; a stand-in for the cost of a
182    ///  sample, which grows with the number of processes the machine runs.
183    scans: u64,
184}
185
186/// What a push that waits for its remote with the connection open has shown, once it has been silent for the stall window.
187#[derive(Clone, Copy, Debug, Eq, PartialEq)]
188pub struct WaitReport {
189    /// How long the tree has shown no sign of work, with its connection open.
190    pub silent: Duration,
191    /// How long the push has run.
192    pub elapsed: Duration,
193}
194
195impl Meter {
196    /// A meter that has seen nothing.
197    pub fn new() -> Self {
198        return Self::default();
199    }
200
201    /// Every byte the tree has moved so far, on its pipes and files and on its connected sockets.
202    pub fn moved(&self) -> u64 {
203        return self.moved.saturating_add(self.socket_moved);
204    }
205
206    /// How many samples listed the machine's processes to find the tree.
207    pub fn scans(&self) -> u64 {
208        return self.scans;
209    }
210
211    /// Every byte the tree's connected sockets have sent (acknowledged) or received since the first sample.
212    pub fn socket_bytes(&self) -> u64 {
213        return self.socket_moved;
214    }
215
216    /// Whether the last sample found the connection to a remote open: some process of the tree holds a socket in state
217    ///  `ESTABLISHED`. This is the whole evidence of "connected"; the phase of the push (making the pack, sent, checked)
218    ///  is not consulted, because a connection that is open is a remote that has not answered yet, whatever the phase.
219    ///  Where the kernel's socket list cannot be read, nothing says the connection is open, and the stall rule applies.
220    pub fn connected(&self) -> bool {
221        return self.connected;
222    }
223
224    /// Take one look at the connected sockets of the tree: true when a socket carried bytes since the last look.
225    fn observe_sockets(&mut self, members: &[TreeMember]) -> bool {
226        let mut held: Vec<u64> = Vec::new();
227        for member in members {
228            held.extend(socket_inodes_of(member.pid));
229        }
230        self.connected = false;
231        if held.is_empty() {
232            return false;
233        }
234        let Some(connected) = established_sockets() else {
235            return false;
236        };
237        let mut progressed: bool = false;
238        for socket in connected {
239            if socket.state != ESTABLISHED || !held.contains(&socket.inode) {
240                continue;
241            }
242            self.connected = true;
243            let total: u64 = socket.bytes_acked.saturating_add(socket.bytes_received);
244            let before: u64 = self.sockets.insert(socket.inode, total).unwrap_or(0);
245            let carried: u64 = total.saturating_sub(before);
246            self.socket_moved = self.socket_moved.saturating_add(carried);
247            if carried > 0 {
248                progressed = true;
249            }
250        }
251        return progressed;
252    }
253
254    /// Take one look at one process: true when it moved bytes or used processor time since the last look, or is at work now.
255    fn observe(&mut self, key: (i32, u64), bytes: u64, ticks: u64, working: bool) -> bool {
256        let (bytes_before, ticks_before): (u64, u64) = self.seen.insert(key, (bytes, ticks)).unwrap_or((0, 0));
257        let moved_now: u64 = bytes.saturating_sub(bytes_before);
258        self.moved = self.moved.saturating_add(moved_now);
259        return moved_now > 0 || ticks > ticks_before || working;
260    }
261
262    /// Read the counters and the states of the root and everything below it; true when any of them shows work since the last
263    ///  sample. A root that cannot be read (it ended) shows nothing.
264    pub fn sample(&mut self, root: i32) -> bool {
265        return self.look(root, true);
266    }
267
268    /// The first look at a child that has just been started: the root alone.
269    ///  Nothing is below a process that has just been started, and a process it starts later is found by the next sample and
270    ///  counted at the first sight, so the listing of every process the machine runs (a status file for each, 7 ms for 841
271    ///  processes) would find nothing and be paid by every Git the wrapper starts, however briefly it lives.
272    pub fn sample_root(&mut self, root: i32) -> bool {
273        return self.look(root, false);
274    }
275
276    /// One look at the root, and at everything below it when `whole_tree` asks for it.
277    fn look(&mut self, root: i32, whole_tree: bool) -> bool {
278        let mut members: Vec<TreeMember> = member_of(root).into_iter().collect();
279        if whole_tree {
280            self.scans += 1;
281            members.extend(descendants_of(root));
282        }
283        let mut progressed: bool = false;
284        for member in &members {
285            let bytes: Option<u64> = bytes_moved_by(member.pid);
286            let activity: Option<(u64, bool)> = activity_of(member.pid);
287            if bytes.is_none() && activity.is_none() {
288                continue;
289            }
290            let (ticks, working): (u64, bool) = activity.unwrap_or((0, false));
291            if self.observe((member.pid, member.started), bytes.unwrap_or(0), ticks, working) {
292                progressed = true;
293            }
294        }
295        // The connected sockets count too: `libcurl` moves the bytes of an HTTP push with `send` and `recv`, which `rchar` and
296        //  `wchar` do not see.
297        if self.observe_sockets(members.as_slice()) {
298            progressed = true;
299        }
300        return progressed;
301    }
302}
303
304/// What:
305///  The clock of one bounded child: it says when the child has stalled or passed its fixed deadline.
306/// Why:
307///   The waiting loop asks it every few milliseconds; it reads the counters only every `SAMPLE_INTERVAL`.
308///
309/// In TS you'd write (pseudocode):
310/// ```ts
311/// class Clock { check(): StopCause | undefined }
312/// ```
313pub struct Clock {
314    /// The process whose tree is measured.
315    root: i32,
316    /// What the child may do: a fixed time, or a stall window that an open connection suspends.
317    patience: Patience,
318    /// When the child started.
319    started: Instant,
320    /// When the tree last moved bytes (or the start, before it first does).
321    last_progress: Instant,
322    /// When the counters are next read.
323    next_sample: Instant,
324    /// The counters, or nothing where the system does not count bytes and the stall window is a fixed deadline.
325    meter: Option<Meter>,
326    /// Since when the tree has shown no sign of work while its connection was open; nothing while it did or was not.
327    waiting_since: Option<Instant>,
328    /// The wait, once the stall window has run out with the connection open.
329    waited: Option<WaitReport>,
330    /// Whether the wait has been handed to the caller (`take_wait_note`), which says so once.
331    wait_noted: bool,
332    /// The longest stretch between two samples that showed a sign of work, as far as the samples saw (the resolution is
333    ///  `SAMPLE_INTERVAL`); a connected wait is not a quiet stretch.
334    longest_quiet: Duration,
335    /// Whether the first look, which is of the root alone, has been taken.
336    looked: bool,
337    /// Whether a system that counts nothing falls back to the stall window as a fixed deadline; a run that must never end by
338    ///  wall time clears it and then goes unjudged where nothing is counted.
339    wall_fallback: bool,
340    /// How long a tree with an open connection may show no sign of work before it is stopped (`wait_ceilings.rs`).
341    connected_window: Duration,
342}
343
344impl Clock {
345    /// Start the clock of a child that has just been started.
346    pub fn start(pid: u32, patience: Patience) -> Self {
347        let now: Instant = Instant::now();
348        let measured: bool = matches!(patience, Patience::Progress { .. }) && system_measures();
349        return Self {
350            root: i32::try_from(pid).unwrap_or(i32::MAX),
351            patience,
352            started: now,
353            last_progress: now,
354            next_sample: now,
355            looked: false,
356            meter: if measured { Some(Meter::new()) } else { None },
357            waiting_since: None,
358            waited: None,
359            wait_noted: false,
360            longest_quiet: Duration::ZERO,
361            wall_fallback: true,
362            connected_window: CONNECTED_PUSH_QUIET_WINDOW,
363        };
364    }
365
366    /// The same clock for a run that must never end by wall time: where nothing is counted it is never judged stalled.
367    pub fn without_wall_fallback(mut self) -> Self {
368        self.wall_fallback = false;
369        return self;
370    }
371
372    /// The same clock with another window for a tree whose connection is open and silent.
373    pub fn with_connected_window(mut self, window: Duration) -> Self {
374        self.connected_window = window;
375        return self;
376    }
377
378    /// Whether this clock reads the tree's signs of work at all (Linux with `/proc/<pid>/io`), so a stall means quiet time.
379    pub fn measures(&self) -> bool {
380        return self.meter.is_some();
381    }
382
383    /// The longest stretch with no sign of work so far, the one still running included. A step that ended by itself gives the
384    ///  figure of a healthy step, which is what an interval must be set above.
385    pub fn longest_quiet(&self) -> Duration {
386        return self.longest_quiet.max(Instant::now().saturating_duration_since(self.last_progress));
387    }
388
389    /// The same clock as on a system that counts no bytes, for the control of that fallback.
390    #[cfg(test)]
391    pub fn unmeasured(mut self) -> Self {
392        self.meter = None;
393        return self;
394    }
395
396    /// The wait the clock has let pass instead of a stop, once the stall window ran out with the connection open.
397    pub fn wait_report(&self) -> Option<WaitReport> {
398        return self.waited;
399    }
400
401    /// The wait, the first time it is asked for after the wait began, so the caller says it once.
402    pub fn take_wait_note(&mut self) -> Option<WaitReport> {
403        if self.wait_noted {
404            return None;
405        }
406        let report: WaitReport = self.waited?;
407        self.wait_noted = true;
408        return Some(report);
409    }
410
411    /// The report of a stop now, for the limit that was hit.
412    fn report(&self, limit: Duration, now: Instant) -> StopReport {
413        return StopReport { limit, moved: self.meter.as_ref().map_or(0, Meter::moved), elapsed: now.duration_since(self.started) };
414    }
415
416    /// Whether the child must be stopped now, and why.
417    pub fn check(&mut self) -> Option<StopCause> {
418        let now: Instant = Instant::now();
419        let elapsed: Duration = now.duration_since(self.started);
420        let stall: Duration = match self.patience {
421            Patience::Fixed(limit) => return (elapsed >= limit).then_some(StopCause::Deadline),
422            Patience::Progress { stall } => stall,
423        };
424        let Some(meter) = self.meter.as_mut() else {
425            // Nothing is measured here, so the stall window is the whole deadline, unless the run must never end by wall time.
426            return (self.wall_fallback && elapsed >= stall).then_some(StopCause::Deadline);
427        };
428        if now >= self.next_sample {
429            let progressed: bool = if self.looked { meter.sample(self.root) } else { meter.sample_root(self.root) };
430            self.looked = true;
431            if progressed {
432                self.longest_quiet = self.longest_quiet.max(now.saturating_duration_since(self.last_progress));
433                self.last_progress = now;
434                self.waiting_since = None;
435            } else if meter.connected() {
436                // The connection to the remote is open: the remote has not answered yet, which shows nothing here. The silence of
437                //  a wait is not counted against the window, so a connection that closes gets a whole window.
438                let since: Instant = *self.waiting_since.get_or_insert(now);
439                self.last_progress = now;
440                if self.waited.is_none() && now.duration_since(since) >= stall {
441                    self.waited = Some(WaitReport { silent: now.duration_since(since), elapsed });
442                }
443                // The owner, 2026-11-03: "Bound it, keep retrying". A connection that stays open and silent for the connected
444                //  window ends the push as a stall; the service's retry plan keeps the push and tries it again.
445                if now.duration_since(since) >= self.connected_window {
446                    return Some(StopCause::Stalled(self.report(self.connected_window, now)));
447                }
448            } else {
449                self.waiting_since = None;
450            }
451            self.next_sample = now + SAMPLE_INTERVAL;
452        }
453        if now.duration_since(self.last_progress) >= stall {
454            return Some(StopCause::Stalled(self.report(stall, now)));
455        }
456        return None;
457    }
458}
459
460/// Progress behavior is controlled in the test module.
461#[cfg(test)]
462#[path = "push_progress_tests.rs"]
463pub(crate) mod tests;