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;