Skip to main content

git_policy_cli/service/
service_busy.rs

1//! What:
2//!  What the commit service's one landing thread is doing, shared with the threads that talk to waiting clients: the write it is
3//!  inside when that write is one the wrapper never ends, the requests of the group it lands, and the withdrawals of requests
4//!  queued behind it that can be answered while it is stuck.
5//! Why:
6//!  The owner, 2026-11-04: "Never cut a branch write (Recommended)" (the option read: "Con: a truly hung write keeps the service
7//!  busy, and later commits fail at their own 20-minute ceiling with an error naming it"). With no ceiling on the branch write, a
8//!  write that never returns holds the one landing thread, which also answers every withdrawal between two landings
9//!  (`service_server.rs`); a client queued behind it would reach its answer ceiling, ask to be withdrawn, hear nothing for the
10//!  withdrawal ceiling and end with an error that names nothing. This record lets the thread that reads each connection (a) tell
11//!  the client what the service is stuck in, and (b) answer the withdrawal of a request that has not begun to land.
12//!  The record holds no lock (the owner: "Use as few locks as possible."; the lock inventory guard,
13//!  `source_guard/service_lock_inventory_tests.rs`): one owner per item and exclusive creation, in a directory of the journal.
14//!  Admission and cancellation are one decision per request, made by creating the file `<id>.decision` with `link(2)`, which fails
15//!  when the name exists and which shows the file whole: the landing thread tries to create it with the content `landing` before it
16//!  lands the request, a connection thread tries to create it with `withdrawn` before it answers a withdrawal, and exactly one
17//!  creation succeeds. The loser reads what the winner wrote. A request that is `landing` is the landing thread's to answer; a
18//!  request that is `withdrawn` is dropped whenever a copy of it reaches a group, as often as it comes, until the file ages out
19//!  (the same bounded memory a normal withdrawal has). A `landing` file is removed when its group is done, after the batch is in the
20//!  journal, so a withdrawal that wins afterwards asks the journal again before it answers, and answers `landed` for a request the
21//!  journal names. Anything that cannot be established, a file that cannot be made or read, a journal that cannot be read, is never
22//!  evidence of a cancellation: the withdrawal is left to the landing thread, and a request that cannot be admitted is answered
23//!  with a failure, never dropped silently and never landed on a decision that was not made.
24//!
25//! In TS you'd write (pseudocode):
26//! ```ts
27//! const board = new LandingBoard(journal);
28//! const admission = board.beginGroup(ids); landGroup(); board.endGroup(ids);    // the landing thread
29//! const answer = board.withdrawEarly(id);                                      // a connection thread
30//! ```
31
32use crate::service_journal::{DONE_EXTENSION, DONE_RETAINED, OPEN_EXTENSION, parse_intent};
33use crate::service_messages::RESTART_COMMAND;
34use std::cell::RefCell;
35use std::io::Write;
36use std::path::{Path, PathBuf};
37use std::sync::Arc;
38use std::sync::atomic::{AtomicU64, Ordering};
39use std::time::{Duration, SystemTime, UNIX_EPOCH};
40
41/// The directory of the record inside the journal.
42const DIRECTORY: &str = "busy";
43
44/// The file that names the write the landing thread is inside.
45const STEP_FILE: &str = "step";
46
47/// The extension of the decision file of a request.
48const DECISION_EXTENSION: &str = "decision";
49
50/// What the decision file says when the landing thread took the request.
51const LANDING: &str = "landing";
52
53/// What the decision file says when a withdrawal took the request.
54const WITHDRAWN: &str = "withdrawn";
55
56/// The extension of the file that confirms a withdrawal: the withdrawer that won the decision made it after it asked the journal a
57///  second time and found no batch naming the request. A decision without it is provisional: nobody but its creator may rely on it.
58const CANCELLED_EXTENSION: &str = "cancelled";
59
60/// The longest request identity the record keeps a decision for; a longer one, or one with a character outside letters, digits and
61///  hyphens, gets no early answer, because its name could not be made into a file name that is only a name.
62const LONGEST_ID: usize = 80;
63
64/// How many withdrawals the record remembers: the same as the finished batches the journal remembers, and as the service's own
65///  memory of normal withdrawals (`service_server.rs`, `WITHDRAWN_RETAINED`), since a client that withdrew sends nothing more for
66///  its request.
67const WITHDRAWALS_RETAINED: usize = DONE_RETAINED;
68
69/// How a withdrawal asked of a connection thread is answered.
70#[derive(Clone, Debug, Eq, PartialEq)]
71pub enum EarlyAnswer {
72    /// The landing thread is not stuck, or the request is the landing thread's, or nothing could be established: the withdrawal goes
73    ///  to the landing thread as before.
74    Queued,
75    /// The request had landed: the commit is the answer.
76    Landed(String),
77    /// The request has not begun to land and never will.
78    Withdrawn(String),
79}
80
81/// What the landing thread learns about the requests of a group before it lands them.
82#[derive(Clone, Debug, Default, Eq, PartialEq)]
83pub struct GroupAdmission {
84    /// The requests a withdrawal took: dropped, and answered to nobody, because their clients were told already.
85    pub dropped: Vec<String>,
86    /// The requests whose admission could not be established: answered with a failure and not landed.
87    pub unavailable: Vec<String>,
88}
89
90/// What the journal says about a request.
91#[derive(Clone, Debug, Eq, PartialEq)]
92enum Naming {
93    /// A finished batch names the request, with the commit it became.
94    Landed(String),
95    /// A batch still open names it: its commit may be on the branch.
96    Open,
97    /// No batch names it.
98    No,
99    /// The journal could not be read whole, so nothing is known.
100    Unknown,
101}
102
103/// What a try at the decision came to.
104enum Decision {
105    /// This side made the decision.
106    Won,
107    /// The other side made it first, and wrote this.
108    Lost(String),
109}
110
111/// Whether `id` can be a file name that is only a name.
112fn is_plain(id: &str) -> bool {
113    return !id.is_empty() && id.len() <= LONGEST_ID && id.chars().all(|character| { return character.is_ascii_alphanumeric() || character == '-'; });
114}
115
116/// Milliseconds since 1970, or zero when the clock is before it.
117fn now_millis() -> u128 {
118    return SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |elapsed| { return elapsed.as_millis(); });
119}
120
121/// A temporary file that is removed when it goes out of scope.
122struct TemporaryFile(PathBuf);
123
124impl Drop for TemporaryFile {
125    fn drop(&mut self) {
126        let _removed = std::fs::remove_file(self.0.as_path());
127    }
128}
129
130/// Distinguishes the temporary files of concurrent decisions.
131static TEMPORARY_NUMBER: AtomicU64 = AtomicU64::new(0);
132
133/// What:
134///  The shared record of the landing thread's work.
135/// Why:
136///   See the module comment.
137///
138/// In TS you'd write (pseudocode):
139/// ```ts
140/// class LandingBoard { beginGroup(ids): GroupAdmission; endGroup(ids): void; describe(): string | undefined; withdrawEarly(id): EarlyAnswer }
141/// ```
142pub struct LandingBoard {
143    /// How long a write runs before it counts as stuck (`wait_ceilings.rs`, `NEVER_ENDED_STEP_SLOW_AFTER`; shorter in a control).
144    slow_after: Duration,
145    /// The journal the finished and open batches are read from, for the answer to a withdrawal of a request that already landed.
146    journal: PathBuf,
147    /// The directory of the record.
148    directory: PathBuf,
149}
150
151impl LandingBoard {
152    /// A board for the service whose journal is `journal`, where a write counts as stuck after `slow_after`. Whatever an earlier
153    ///  service left in the record's directory is removed: none of it describes a running thread, and a normal withdrawal's memory
154    ///  does not outlive the service either.
155    pub fn new(journal: PathBuf, slow_after: Duration) -> Arc<Self> {
156        let directory: PathBuf = journal.join(DIRECTORY);
157        let _removed = std::fs::remove_dir_all(directory.as_path());
158        let _made = std::fs::create_dir_all(directory.as_path());
159        return Arc::new(Self { slow_after, journal, directory });
160    }
161
162    /// The decision file of a request.
163    pub(crate) fn decision_path(&self, id: &str) -> PathBuf {
164        return self.directory.join(format!("{id}.{DECISION_EXTENSION}"));
165    }
166
167    /// The file that confirms the withdrawal of a request.
168    fn cancelled_path(&self, id: &str) -> PathBuf {
169        return self.directory.join(format!("{id}.{CANCELLED_EXTENSION}"));
170    }
171
172    /// What:
173    ///  Try to make the decision of `id` with the content `outcome`.
174    /// Why:
175    ///   The file is written whole beside its name and then linked to it, so it never shows partly written, and `link(2)` fails when
176    ///  the name exists, so exactly one side makes the decision. Any other failure is an error: nothing was decided.
177    ///
178    /// In TS you'd write (pseudocode):
179    /// ```ts
180    /// try { link(temporary, decisionPath); return 'won'; } catch (exists) { return { lost: read(decisionPath) }; }
181    /// ```
182    fn decide(&self, id: &str, outcome: &str) -> std::io::Result<Decision> {
183        // Removed whichever way this function leaves, a failed write included (a leaked file is an inode the recovery needs).
184        let beside: TemporaryFile = TemporaryFile(self.directory.join(format!("{id}.{}.{}.tmp", std::process::id(), TEMPORARY_NUMBER.fetch_add(1, Ordering::Relaxed))));
185        std::fs::File::create(beside.0.as_path()).and_then(|mut file| { return file.write_all(outcome.as_bytes()); })?;
186        let linked: std::io::Result<()> = std::fs::hard_link(beside.0.as_path(), self.decision_path(id));
187        return match linked {
188            Ok(()) => Ok(Decision::Won),
189            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(Decision::Lost(std::fs::read_to_string(self.decision_path(id))?)),
190            Err(error) => Err(error),
191        };
192    }
193
194    /// What the journal says about `id`: read whole, and `Unknown` for anything that cannot be read or understood.
195    fn naming(&self, id: &str) -> Naming {
196        let Ok(entries) = std::fs::read_dir(self.journal.as_path()) else {
197            return Naming::Unknown;
198        };
199        let mut open: bool = false;
200        for entry in entries {
201            let Ok(found) = entry else { return Naming::Unknown };
202            let path: PathBuf = found.path();
203            let extension: Option<&str> = path.extension().and_then(|text| { return text.to_str(); });
204            if extension != Some(OPEN_EXTENSION) && extension != Some(DONE_EXTENSION) {
205                continue;
206            }
207            let Some(intent) = std::fs::read_to_string(path.as_path()).ok().and_then(|text| { return parse_intent(text.as_str()); }) else {
208                return Naming::Unknown;
209            };
210            let Some(position) = intent.ids.iter().position(|named| { return named == id; }) else { continue };
211            if extension == Some(DONE_EXTENSION) {
212                return intent.commits.get(position).map_or(Naming::Unknown, |commit| { return Naming::Landed(commit.clone()); });
213            }
214            open = true;
215        }
216        return if open { Naming::Open } else { Naming::No };
217    }
218
219    /// What:
220    ///  Take the requests of a group for landing: decide each as `landing`, learn which a withdrawal took, and which could not be
221    ///  decided.
222    /// Why:
223    ///   A request a withdrawal took is dropped, however many copies of it come; one that cannot be decided is not landed on a
224    ///  decision that was not made.
225    ///
226    /// In TS you'd write (pseudocode):
227    /// ```ts
228    /// for (const id of ids) { const d = decide(id, 'landing'); if (d.error) unavailable.push(id); else if (d.lost === 'withdrawn') dropped.push(id); }
229    /// ```
230    pub fn begin_group(&self, ids: &[String]) -> GroupAdmission {
231        let mut admission: GroupAdmission = GroupAdmission::default();
232        for id in ids.iter().filter(|id| { return is_plain(id.as_str()); }) {
233            match self.decide(id.as_str(), LANDING) {
234                Ok(Decision::Won) => {}
235                Ok(Decision::Lost(content)) if content == LANDING => {}
236                Ok(Decision::Lost(content)) if content == WITHDRAWN => admission.dropped.push(id.clone()),
237                Ok(Decision::Lost(_)) | Err(_) => admission.unavailable.push(id.clone()),
238            }
239        }
240        return admission;
241    }
242
243    /// The group is landed (or given up): its requests are the landing thread's no more, and each is in the journal if it landed.
244    ///  A tombstone of a withdrawal stays.
245    pub fn end_group(&self, ids: &[String]) {
246        for id in ids.iter().filter(|id| { return is_plain(id.as_str()); }) {
247            if std::fs::read_to_string(self.decision_path(id.as_str())).is_ok_and(|content| { return content == LANDING; }) {
248                let _removed = std::fs::remove_file(self.decision_path(id.as_str()));
249            }
250        }
251    }
252
253    /// Whether the request of this identity is in the group the landing thread is landing.
254    pub fn is_in_flight(&self, id: &str) -> bool {
255        return is_plain(id) && std::fs::read_to_string(self.decision_path(id)).is_ok_and(|content| { return content == LANDING; });
256    }
257
258    /// The write the landing thread is inside, as `(millis when it began, command, detail, millis it has run)`, when there is one.
259    fn running(&self) -> Option<(u128, String, Option<String>, u128)> {
260        let text: String = std::fs::read_to_string(self.directory.join(STEP_FILE)).ok()?;
261        let mut lines = text.lines();
262        let began: u128 = lines.next()?.parse().ok()?;
263        let command: String = String::from(lines.next()?);
264        let detail: Option<String> = lines.next().filter(|line| { return !line.is_empty(); }).map(String::from);
265        return Some((began, command, detail, now_millis().saturating_sub(began)));
266    }
267
268    /// The words that tell a client what the service is stuck in, when the landing thread has been inside a write that is never
269    ///  ended for longer than the board's `slow_after`; nothing otherwise.
270    pub fn describe(&self) -> Option<String> {
271        let (_, command, found, running) = self.running()?;
272        if running <= self.slow_after.as_millis() {
273            return None;
274        }
275        let minutes: u128 = running / 60_000;
276        let detail: String = found.map_or_else(String::new, |text| { return format!(" ({text})"); });
277        return Some(format!(
278            "cli-git: the commit service has been inside a write for {minutes} minutes and has not heard back: `{command}`{detail}. The service never ends a write in \
279             the middle, because that could lose data, so commits queue behind it. If it never returns, look at what that process \
280             waits for with `ps` and at the disk, and when no program uses the repository, restart the service with `{RESTART_COMMAND}`.\n"
281        ));
282    }
283
284    /// What:
285    ///  The notice for the client whose own request is in the group being landed while the landing thread is inside a write that is
286    ///  never ended: a key that changes when the notice should be sent again, and the words.
287    /// Why:
288    ///   The client must learn that its commit may be on the branch when the write starts, not a minute later (owner, 2026-11-04,
289    ///  "Short extra wait"); the words that follow a minute's stuck write are the longer ones.
290    ///
291    /// In TS you'd write (pseudocode):
292    /// ```ts
293    /// inFlight(id) && running ? { key: [began, minutes], text } : undefined
294    /// ```
295    pub fn inside_notice(&self, id: &str) -> Option<(String, String)> {
296        let (began, command, found, running) = self.running()?;
297        if !self.is_in_flight(id) {
298            return None;
299        }
300        if let Some(stuck) = self.describe() {
301            return Some((format!("{began}:{}", running / 60_000), stuck));
302        }
303        let detail: String = found.map_or_else(String::new, |text| { return format!(" ({text})"); });
304        return Some((format!("{began}:start"), format!(
305            "cli-git: this commit is inside a write of the commit service, `{command}`{detail}, begun {} seconds ago. The service never ends a write \
306             in the middle, so if it does not return this command reports that the result is not known after its wait.\n",
307            running / 1000
308        )));
309    }
310
311    /// What:
312    ///  Answer a withdrawal on the connection thread when the landing thread is stuck in a write that is never ended and the
313    ///  request is not the landing thread's.
314    /// Why:
315    ///   The landing thread answers withdrawals between two landings, so behind a stuck write none would be answered. A request
316    ///  that has not begun cannot land while the thread is stuck, and the decision keeps it from beginning later. The journal is
317    ///  asked before the decision is tried and again after it is won, because a request that landed and was let go of between the
318    ///  two looks like a request nobody took.
319    ///
320    /// In TS you'd write (pseudocode):
321    /// ```ts
322    /// stuck && journal(id) === 'no' && won(decide(id, 'withdrawn')) && journal(id) === 'no' ? 'withdrawn' : (landed ?? 'queued')
323    /// ```
324    pub fn withdraw_early(&self, id: &str) -> EarlyAnswer {
325        if !is_plain(id) {
326            return EarlyAnswer::Queued;
327        }
328        let Some(words) = self.describe() else {
329            return EarlyAnswer::Queued;
330        };
331        match self.naming(id) {
332            Naming::Landed(commit) => return EarlyAnswer::Landed(commit),
333            Naming::Open | Naming::Unknown => return EarlyAnswer::Queued,
334            Naming::No => {}
335        }
336        match self.decide(id, WITHDRAWN) {
337            Err(_) => return EarlyAnswer::Queued,
338            // Another withdrawer won. Its decision is acknowledged only when it has confirmed it; before that it is provisional, and
339            // may still be taken back (the journal may name the request when the winner asks again).
340            Ok(Decision::Lost(content)) if content == WITHDRAWN && self.cancelled_path(id).exists() => return EarlyAnswer::Withdrawn(words),
341            Ok(Decision::Lost(_)) => return EarlyAnswer::Queued,
342            Ok(Decision::Won) => {}
343        }
344        #[cfg(test)]
345        AFTER_THE_DECISION_IS_WON.with(|hook| { if let Some(pause) = hook.borrow().as_ref() { pause(); } });
346        // The decision is ours, so the request cannot be admitted any more; what could have happened is that it was admitted,
347        // landed and let go of before the decision was tried.
348        match self.naming(id) {
349            Naming::No => {
350                // Confirmed: from now on any withdrawer may acknowledge it, and nothing removes it but the retention.
351                if std::fs::File::create_new(self.cancelled_path(id)).is_err() {
352                    // Not confirmed, and nobody may have acknowledged a decision that was not: it is taken back.
353                    let _undone = std::fs::remove_file(self.decision_path(id));
354                    return EarlyAnswer::Queued;
355                }
356                self.prune_withdrawals();
357                return EarlyAnswer::Withdrawn(words);
358            }
359            // Never confirmed, so never acknowledged by anyone else: taken back.
360            Naming::Landed(commit) => {
361                let _undone = std::fs::remove_file(self.decision_path(id));
362                return EarlyAnswer::Landed(commit);
363            }
364            Naming::Open | Naming::Unknown => {
365                let _undone = std::fs::remove_file(self.decision_path(id));
366                return EarlyAnswer::Queued;
367            }
368        }
369    }
370
371    /// Forget the oldest confirmed withdrawals once more than `WITHDRAWALS_RETAINED` are remembered.
372    fn prune_withdrawals(&self) {
373        let Ok(entries) = std::fs::read_dir(self.directory.as_path()) else { return };
374        let mut remembered: Vec<(SystemTime, PathBuf)> = entries
375            .flatten()
376            .filter(|entry| { return entry.path().extension().and_then(|text| { return text.to_str(); }) == Some(CANCELLED_EXTENSION); })
377            .filter_map(|entry| { return Some((entry.metadata().ok()?.modified().ok()?, entry.path())); })
378            .collect();
379        if remembered.len() <= WITHDRAWALS_RETAINED {
380            return;
381        }
382        remembered.sort();
383        let surplus: usize = remembered.len() - WITHDRAWALS_RETAINED;
384        for (_, confirmation) in remembered.into_iter().take(surplus) {
385            // The decision first, so the confirmation is never left standing for a decision that is gone.
386            let _forgotten = std::fs::remove_file(confirmation.with_extension(DECISION_EXTENSION));
387            let _also = std::fs::remove_file(confirmation);
388        }
389    }
390}
391
392#[cfg(test)]
393thread_local! {
394    /// A control may stop a withdrawal on this thread between the moment it wins the decision and the moment it asks the journal again,
395    ///  which is where the reviews found the window.
396    pub(crate) static AFTER_THE_DECISION_IS_WON: RefCell<Option<Box<dyn Fn()>>> = const { RefCell::new(None) };
397}
398
399thread_local! {
400    /// The board of the service whose landing runs on this thread; no other thread has one.
401    static BOARD: RefCell<Option<Arc<LandingBoard>>> = const { RefCell::new(None) };
402    /// What the landing knows about the next write that is never ended, for the words that name it.
403    static DETAIL: RefCell<Option<String>> = const { RefCell::new(None) };
404}
405
406/// Whether the calling thread is the commit service's landing thread, the one thread that waits for a write it cannot end.
407pub fn on_landing_thread() -> bool {
408    return BOARD.with(|slot| { return slot.borrow().is_some(); });
409}
410
411/// Make `board` the record of the landing that runs on the calling thread.
412pub fn use_on_this_thread(board: Arc<LandingBoard>) {
413    BOARD.with(|slot| { *slot.borrow_mut() = Some(board); });
414}
415
416/// Clears the detail when the work it describes is over.
417pub struct DetailGuard;
418
419impl Drop for DetailGuard {
420    fn drop(&mut self) {
421        DETAIL.with(|slot| { *slot.borrow_mut() = None; });
422    }
423}
424
425/// Say what the write that follows does, for as long as the guard lives; the words of a stuck write carry it.
426pub fn detail(text: String) -> DetailGuard {
427    DETAIL.with(|slot| { *slot.borrow_mut() = Some(text); });
428    return DetailGuard;
429}
430
431/// Clears the running step when the write ends, however it ends.
432pub struct StepGuard {
433    /// The file that names the step.
434    file: PathBuf,
435}
436
437impl Drop for StepGuard {
438    fn drop(&mut self) {
439        let _removed = std::fs::remove_file(self.file.as_path());
440    }
441}
442
443/// Write `text` to `path` as a complete file or not at all: a rename of a file written beside it.
444fn publish(path: &Path, text: &str) -> bool {
445    let beside: PathBuf = path.with_extension("tmp");
446    let written: bool = std::fs::File::create(beside.as_path()).and_then(|mut file| { return file.write_all(text.as_bytes()); }).is_ok();
447    return written && std::fs::rename(beside.as_path(), path).is_ok();
448}
449
450/// What:
451///  Record that this thread is inside a write that is never ended, until the guard is dropped; nothing on a thread that has no
452///  board (the wrapper's own commands, the other threads of the service).
453/// Why:
454///   Only the landing thread holds the service up, so only its steps are recorded.
455///
456/// In TS you'd write (pseudocode):
457/// ```ts
458/// using step = enterStep(commandText);
459/// ```
460pub fn enter_step(command: String) -> Option<StepGuard> {
461    let board: Arc<LandingBoard> = BOARD.with(|slot| { return slot.borrow().clone(); })?;
462    let detail: Option<String> = DETAIL.with(|slot| { return slot.borrow().clone(); });
463    let file: PathBuf = board.directory.join(STEP_FILE);
464    let one_line = |text: &str| -> String { return text.split_whitespace().collect::<Vec<&str>>().join(" "); };
465    let text: String = format!("{}\n{}\n{}\n", now_millis(), one_line(command.as_str()), detail.as_deref().map_or_else(String::new, one_line));
466    // A step that cannot be recorded is still run; the clients just are not told about it.
467    let _recorded = publish(file.as_path(), text.as_str());
468    return Some(StepGuard { file });
469}
470
471/// The record's controls stay out of the release executable.
472#[cfg(test)]
473#[path = "service_busy_tests.rs"]
474mod tests;