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;