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//!  The landing thread alone writes the file `step` (a rename of a complete file) and creates `<id>.flight` for each request of
15//!  the group it is about to land; a connection thread alone creates `<id>.withdrawn`. Each side creates its own marker first and
16//!  then looks for the other's. If both are there, the landing drops the request and the connection thread leaves the withdrawal
17//!  to the landing thread, which answers "withdrawn" for a dropped request, so the answer agrees with what happened; if the
18//!  connection thread's marker is first and complete the request is dropped when its group begins; if the landing's marker is first
19//!  the connection thread removes its own and the withdrawal waits for the landing. A request is withdrawn or landed, never both.
20//!
21//! In TS you'd write (pseudocode):
22//! ```ts
23//! const board = new LandingBoard(journal);
24//! const dropped = board.beginGroup(ids); landGroup(); board.endGroup(ids);     // the landing thread
25//! const answer = board.withdrawEarly(id);                                      // a connection thread
26//! ```
27
28use crate::service_journal::{OPEN_EXTENSION, landed_commits, list_intents};
29use crate::service_messages::RESTART_COMMAND;
30use std::cell::RefCell;
31use std::io::Write;
32use std::path::{Path, PathBuf};
33use std::sync::Arc;
34use std::time::{Duration, SystemTime, UNIX_EPOCH};
35
36/// The directory of the record inside the journal.
37const DIRECTORY: &str = "busy";
38
39/// The file that names the write the landing thread is inside.
40const STEP_FILE: &str = "step";
41
42/// The longest request identity the record keeps a marker for; a longer one, or one with a character outside letters, digits and
43///  hyphens, gets no early answer, because its name could not be made into a file name that is only a name.
44const LONGEST_ID: usize = 80;
45
46/// How a withdrawal asked of a connection thread is answered.
47#[derive(Clone, Debug, Eq, PartialEq)]
48pub enum EarlyAnswer {
49    /// The landing thread is not stuck, or the request is in the group it is landing: the withdrawal goes to it as before.
50    Queued,
51    /// The request had landed: the commit is the answer.
52    Landed(String),
53    /// The request has not begun to land and never will.
54    Withdrawn(String),
55}
56
57/// Whether `id` can be a file name that is only a name.
58fn is_plain(id: &str) -> bool {
59    return !id.is_empty() && id.len() <= LONGEST_ID && id.chars().all(|character| { return character.is_ascii_alphanumeric() || character == '-'; });
60}
61
62/// Milliseconds since 1970, or zero when the clock is before it.
63fn now_millis() -> u128 {
64    return SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |elapsed| { return elapsed.as_millis(); });
65}
66
67/// What:
68///  The shared record of the landing thread's work.
69/// Why:
70///   See the module comment.
71///
72/// In TS you'd write (pseudocode):
73/// ```ts
74/// class LandingBoard { beginGroup(ids): string[]; endGroup(ids): void; describe(): string | undefined; withdrawEarly(id): EarlyAnswer }
75/// ```
76pub struct LandingBoard {
77    /// How long a write runs before it counts as stuck (`wait_ceilings.rs`, `NEVER_ENDED_STEP_SLOW_AFTER`; shorter in a control).
78    slow_after: Duration,
79    /// The journal the finished and open batches are read from, for the answer to a withdrawal of a request that already landed.
80    journal: PathBuf,
81    /// The directory of the record.
82    directory: PathBuf,
83}
84
85impl LandingBoard {
86    /// A board for the service whose journal is `journal`, where a write counts as stuck after `slow_after`. Whatever an earlier
87    ///  service left in the record's directory is removed: none of it describes a running thread.
88    pub fn new(journal: PathBuf, slow_after: Duration) -> Arc<Self> {
89        let directory: PathBuf = journal.join(DIRECTORY);
90        let _removed = std::fs::remove_dir_all(directory.as_path());
91        let _made = std::fs::create_dir_all(directory.as_path());
92        return Arc::new(Self { slow_after, journal, directory });
93    }
94
95    /// The marker file of a request.
96    fn marker(&self, id: &str, kind: &str) -> PathBuf {
97        return self.directory.join(format!("{id}.{kind}"));
98    }
99
100    /// Create a marker, failing when it exists: the exclusive creation that decides who was first.
101    fn create_marker(&self, id: &str, kind: &str) -> bool {
102        return std::fs::OpenOptions::new().write(true).create_new(true).open(self.marker(id, kind)).is_ok();
103    }
104
105    /// What:
106    ///  Mark the requests of a group as being landed, except those a connection thread withdrew while the landing thread was
107    ///  stuck; the identities of those are returned and are not marked.
108    /// Why:
109    ///   Each request is marked before its withdrawal marker is looked for, so a request is withdrawn early or lands, never both.
110    ///
111    /// In TS you'd write (pseudocode):
112    /// ```ts
113    /// ids.filter((id) => { create(`${id}.flight`); return exists(`${id}.withdrawn`) && (remove(`${id}.flight`, `${id}.withdrawn`), true); })
114    /// ```
115    pub fn begin_group(&self, ids: &[String]) -> Vec<String> {
116        let mut dropped: Vec<String> = Vec::new();
117        for id in ids.iter().filter(|id| { return is_plain(id.as_str()); }) {
118            let _created = self.create_marker(id.as_str(), "flight");
119            if self.marker(id.as_str(), "withdrawn").exists() {
120                let _unmarked = std::fs::remove_file(self.marker(id.as_str(), "flight"));
121                let _answered = std::fs::remove_file(self.marker(id.as_str(), "withdrawn"));
122                dropped.push(id.clone());
123            }
124        }
125        return dropped;
126    }
127
128    /// The group is landed (or given up): none of its requests is in flight.
129    pub fn end_group(&self, ids: &[String]) {
130        for id in ids.iter().filter(|id| { return is_plain(id.as_str()); }) {
131            let _removed = std::fs::remove_file(self.marker(id.as_str(), "flight"));
132        }
133    }
134
135    /// Whether the request of this identity is in the group the landing thread is landing.
136    pub fn is_in_flight(&self, id: &str) -> bool {
137        return is_plain(id) && self.marker(id, "flight").exists();
138    }
139
140    /// The write the landing thread is inside, as `(command, detail, millis since it began)`, when there is one.
141    fn running(&self) -> Option<(String, Option<String>, u128)> {
142        let text: String = std::fs::read_to_string(self.directory.join(STEP_FILE)).ok()?;
143        let mut lines = text.lines();
144        let began: u128 = lines.next()?.parse().ok()?;
145        let command: String = String::from(lines.next()?);
146        let detail: Option<String> = lines.next().filter(|line| { return !line.is_empty(); }).map(String::from);
147        return Some((command, detail, now_millis().saturating_sub(began)));
148    }
149
150    /// The words that tell a client what the service is stuck in, when the landing thread has been inside a write that is never
151    ///  ended for longer than the board's `slow_after`; nothing otherwise.
152    pub fn describe(&self) -> Option<String> {
153        let (command, found, running) = self.running()?;
154        if running <= self.slow_after.as_millis() {
155            return None;
156        }
157        let minutes: u128 = running / 60_000;
158        let detail: String = found.map_or_else(String::new, |text| { return format!(" ({text})"); });
159        return Some(format!(
160            "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 \
161             the middle, because that could lose data, so commits queue behind it. If it never returns, look at what that process \
162             waits for with `ps` and at the disk, and when no program uses the repository, restart the service with `{RESTART_COMMAND}`.\n"
163        ));
164    }
165
166    /// What:
167    ///  Answer a withdrawal on the connection thread when the landing thread is stuck in a write that is never ended and the
168    ///  request is not in the group it is landing.
169    /// Why:
170    ///   The landing thread answers withdrawals between two landings, so behind a stuck write none would be answered. A request
171    ///  that has not begun cannot land while the thread is stuck, and the markers keep it from beginning later.
172    ///
173    /// In TS you'd write (pseudocode):
174    /// ```ts
175    /// stuck && !named(openBatches, id) ? (landed(id) ?? (create(`${id}.withdrawn`), exists(`${id}.flight`) ? 'queued' : withdraw(id))) : 'queued'
176    /// ```
177    pub fn withdraw_early(&self, id: &str) -> EarlyAnswer {
178        if !is_plain(id) {
179            return EarlyAnswer::Queued;
180        }
181        let Some(words) = self.describe() else {
182            return EarlyAnswer::Queued;
183        };
184        if let Some(commit) = landed_commits(self.journal.as_path()).remove(id) {
185            return EarlyAnswer::Landed(commit);
186        }
187        // A batch still open may hold the commit on the branch already (its index update did not install, and a restart finishes
188        // it), and only the landing thread knows which: it answers "already" when the write returns, so the withdrawal waits for it.
189        let named_by_open_batch: bool = list_intents(self.journal.as_path(), OPEN_EXTENSION)
190            .iter()
191            .any(|(_, intent)| { return intent.as_ref().is_some_and(|open| { return open.ids.iter().any(|named| { return named == id; }); }); });
192        if named_by_open_batch || self.marker(id, "flight").exists() {
193            return EarlyAnswer::Queued;
194        }
195        // Marked first, then the landing's marker is looked for again: a landing that marked the request in between sees this
196        // marker or is seen by it, and in both orders the request is dropped or the withdrawal is left to the landing thread.
197        let _marked = self.create_marker(id, "withdrawn");
198        if self.marker(id, "flight").exists() {
199            let _unmarked = std::fs::remove_file(self.marker(id, "withdrawn"));
200            return EarlyAnswer::Queued;
201        }
202        return EarlyAnswer::Withdrawn(words);
203    }
204}
205
206thread_local! {
207    /// The board of the service whose landing runs on this thread; no other thread has one.
208    static BOARD: RefCell<Option<Arc<LandingBoard>>> = const { RefCell::new(None) };
209    /// What the landing knows about the next write that is never ended, for the words that name it.
210    static DETAIL: RefCell<Option<String>> = const { RefCell::new(None) };
211}
212
213/// Whether the calling thread is the commit service's landing thread, the one thread that waits for a write it cannot end.
214pub fn on_landing_thread() -> bool {
215    return BOARD.with(|slot| { return slot.borrow().is_some(); });
216}
217
218/// Make `board` the record of the landing that runs on the calling thread.
219pub fn use_on_this_thread(board: Arc<LandingBoard>) {
220    BOARD.with(|slot| { *slot.borrow_mut() = Some(board); });
221}
222
223/// Clears the detail when the work it describes is over.
224pub struct DetailGuard;
225
226impl Drop for DetailGuard {
227    fn drop(&mut self) {
228        DETAIL.with(|slot| { *slot.borrow_mut() = None; });
229    }
230}
231
232/// Say what the write that follows does, for as long as the guard lives; the words of a stuck write carry it.
233pub fn detail(text: String) -> DetailGuard {
234    DETAIL.with(|slot| { *slot.borrow_mut() = Some(text); });
235    return DetailGuard;
236}
237
238/// Clears the running step when the write ends, however it ends.
239pub struct StepGuard {
240    /// The file that names the step.
241    file: PathBuf,
242}
243
244impl Drop for StepGuard {
245    fn drop(&mut self) {
246        let _removed = std::fs::remove_file(self.file.as_path());
247    }
248}
249
250/// Write `text` to `path` as a complete file or not at all: a rename of a file written beside it.
251fn publish(path: &Path, text: &str) -> bool {
252    let beside: PathBuf = path.with_extension("tmp");
253    let written: bool = std::fs::File::create(beside.as_path()).and_then(|mut file| { return file.write_all(text.as_bytes()); }).is_ok();
254    return written && std::fs::rename(beside.as_path(), path).is_ok();
255}
256
257/// What:
258///  Record that this thread is inside a write that is never ended, until the guard is dropped; nothing on a thread that has no
259///  board (the wrapper's own commands, the other threads of the service).
260/// Why:
261///   Only the landing thread holds the service up, so only its steps are recorded.
262///
263/// In TS you'd write (pseudocode):
264/// ```ts
265/// using step = enterStep(commandText);
266/// ```
267pub fn enter_step(command: String) -> Option<StepGuard> {
268    let board: Arc<LandingBoard> = BOARD.with(|slot| { return slot.borrow().clone(); })?;
269    let detail: Option<String> = DETAIL.with(|slot| { return slot.borrow().clone(); });
270    let file: PathBuf = board.directory.join(STEP_FILE);
271    let one_line = |text: &str| -> String { return text.split_whitespace().collect::<Vec<&str>>().join(" "); };
272    let text: String = format!("{}\n{}\n{}\n", now_millis(), one_line(command.as_str()), detail.as_deref().map_or_else(String::new, one_line));
273    // A step that cannot be recorded is still run; the clients just are not told about it.
274    let _recorded = publish(file.as_path(), text.as_str());
275    return Some(StepGuard { file });
276}
277
278/// The record's controls stay out of the release executable.
279#[cfg(test)]
280#[path = "service_busy_tests.rs"]
281mod tests;