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;