gunnar_server/readiness.rs
1//! **Is this server ready to serve git?** — one flag, set once, and the honest
2//! answer while it is clear.
3//!
4//! # The boot window — MEASURED 2026-08-30
5//!
6//! After a power cut the appliance answered ssh :2222 and grpc :50051
7//! **0.6–0.7 s before its stores had finished their post-crash open**, and a
8//! read in that window came back `the repository is unavailable; try again` —
9//! which is [`gunnar_wire::Error::Store`]'s sentence, arrives with a *denial*
10//! exit status, and is what a client hears when a repository is genuinely
11//! broken. So nothing could tell "not yet" from "gone": the first
12//! power-cut door reported **64 acked pushes missing** that a boot one second
13//! later found every one of, and the same window wedged a whole bench run's
14//! clone and fetch phases.
15//!
16//! # Where the 0.6–0.7 s comes from
17//!
18//! Not from `ServerStores::open` — that runs before `serve` is called at all.
19//! It is [`crate::disktest`]. `serve::run` spawns the start-up disk test on
20//! `/data` and then binds the listeners **immediately**, without waiting for
21//! it; the test writes a gigabyte sequentially, reads it back with `O_DIRECT`
22//! and then does 4 KiB `fsync`s for its own timing loop. The rig measured that
23//! run at `4k sync 4517 ops/s (0.7 s)` on the rig — the same 0.6–0.7 s. For
24//! that whole window a repository's first post-crash store open is contending
25//! for a disk that another thread of the same process is deliberately
26//! saturating.
27//!
28//! # The fix is not to bind later
29//!
30//! PID 1 must answer quickly: an operator watching a console needs the box to
31//! be *there*, `gunnar control status` has to work on a server nobody holds a
32//! key for, and the dashboard band is drawn from the same process. So the
33//! listeners still bind at the same instant. What changes is the **answer**:
34//! while this flag is clear, every git transfer is refused in git's own
35//! protocol with `EXIT_BUSY` (75, `EX_TEMPFAIL`) and a sentence containing the
36//! word **starting** ([`gunnar_ssh::STARTING_MESSAGE`]), and the flag is
37//! reported by `Bootstrap.Status` — the one call an unenrolled server answers —
38//! so `gunnar control status` says it too.
39//!
40//! Boot stays fast and stores stay lazy: this gates on the one boot task that
41//! competes with the store for the disk, not on opening every repository. A
42//! box with a thousand repositories does not pay a thousand opens to say hello.
43
44/// What [`begin`] said it was waiting for, so [`detail`] can name it instead of
45/// guessing. Empty until a boot window is opened.
46static WAITING_FOR: std::sync::Mutex<String> = std::sync::Mutex::new(String::new());
47
48use std::sync::atomic::{AtomicBool, Ordering};
49use std::sync::Arc;
50use std::sync::OnceLock;
51
52/// The process-wide readiness flag.
53///
54/// An `Arc<AtomicBool>` and not a trait, because `gunnar-ssh` may not read the
55/// operating system: "have the boot tasks finished" is this crate's fact to
56/// publish, not that crate's to discover. It is handed to the listener with
57/// `GunnarSsh::with_readiness`.
58/// It starts **true**, and that is the safe default rather than an oversight.
59/// Only a boot path that actually opens a window closes it ([`begin`]): a
60/// library caller that builds a listener directly — every integration test in
61/// this workspace, `gunnar-solo` on a developer machine — has nothing competing
62/// for the disk and must not be made to refuse every transfer because a boot it
63/// never performed never finished.
64pub fn flag() -> Arc<AtomicBool> {
65 static FLAG: OnceLock<Arc<AtomicBool>> = OnceLock::new();
66 Arc::clone(FLAG.get_or_init(|| Arc::new(AtomicBool::new(true))))
67}
68
69/// **Open the boot window**: from here until [`mark_ready`], git transfers are
70/// refused with `starting` and `EX_TEMPFAIL`.
71///
72/// Called by the one boot task that competes with the store for the disk. If
73/// nothing calls this, the server is ready from its first bound socket, which
74/// is the truth on every path that has no such task.
75pub fn begin(what: &str) {
76 // Remember WHAT, so the status door can name it. `begin` has always taken the
77 // task's name and thrown it away, while `detail` spelled "the start-up disk
78 // test" into the answer — true only because that is the single caller today.
79 // The moment a second boot task opens this window that sentence becomes a lie,
80 // and an operator asking "what are you waiting for" is the one person who must
81 // not be lied to.
82 if let Ok(mut waiting) = WAITING_FOR.lock() {
83 waiting.clear();
84 waiting.push_str(what);
85 }
86 flag().store(false, Ordering::Relaxed);
87 tracing::info!(
88 target: "gunnar::limits",
89 event = "serve.starting",
90 waiting_for = what,
91 "git transfers are refused with `starting` (EX_TEMPFAIL, a RETRY) until {} finishes: it \
92 is saturating the same disk a repository's first post-crash open has to read",
93 what
94 );
95}
96
97/// Whether this server is serving git yet.
98pub fn is_ready() -> bool {
99 flag().load(Ordering::Relaxed)
100}
101
102/// **Say the server is ready**, once, with one INFO row naming what it waited
103/// for and how long.
104///
105/// Idempotent: a second call is ignored rather than logged again, so a boot
106/// path that can finish two ways (the disk test ran, or it was skipped) may
107/// call it from both.
108pub fn mark_ready(waited_for: &str, since: std::time::Instant) {
109 if flag().swap(true, Ordering::Relaxed) {
110 return;
111 }
112
113 tracing::info!(
114 target: "gunnar::limits",
115 event = "serve.ready",
116 waited_for,
117 ms = since.elapsed().as_millis() as u64,
118 "serving git: {} finished after {} ms. Until this row every git transfer was refused \
119 with `starting` and EX_TEMPFAIL, which is a RETRY and not a missing repository.",
120 waited_for,
121 since.elapsed().as_millis()
122 );
123}
124
125/// **Where the git listener actually bound.**
126///
127/// A pair of addresses and not a flag, because the defect this closes WAS a
128/// flag. [`is_ready`] is a *boot latch*: it starts **true** and is closed only
129/// by a boot task that opens a window, so `Bootstrap.Status.serving_git` has
130/// always answered "no boot task is still running" — a question about this
131/// process's start-up, never about a socket. MEASURED 2026-09-20 on the live
132/// estate: 10.13.8.101 answered `git serving` with a build, a role and an
133/// applied sequence, while every `git-receive-pack` to
134/// `ssh://git@10.13.8.101:2222` failed `Connection refused (os error 111)` —
135/// an RST, so nothing was bound there at all. A health line that cannot be
136/// false is not a health line.
137///
138/// `asked` is what `--ssh-bind` named; `bound` is what `local_addr()` returned.
139/// They differ on exactly one path today, and it is the one that matters:
140/// `serve::start` binds a **twin**'s listener to `127.0.0.1:0` whatever was
141/// asked for, because a twin serves no git to anyone.
142///
143/// Carried per `CoreControlPlane` rather than in a static: two servers in one
144/// test process are two listeners, and a process-wide slot would let the
145/// second one answer for the first.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub struct GitListener {
148 /// The address `--ssh-bind` named.
149 pub asked: std::net::SocketAddr,
150 /// The address the listener actually took.
151 pub bound: std::net::SocketAddr,
152}
153
154impl GitListener {
155 /// **Is git reachable at the door that was asked for?**
156 ///
157 /// The addresses, not a flag: the listener serves git when the socket it
158 /// took is the socket that was named. A port of `0` in `asked` is a
159 /// deliberate "any port" — every integration test in this workspace binds
160 /// that way — so only the interface is compared there.
161 pub fn serves_git(&self) -> bool {
162 self.bound.ip() == self.asked.ip()
163 && (self.asked.port() == 0 || self.bound.port() == self.asked.port())
164 }
165
166 /// One sentence for an operator, naming both addresses.
167 fn detail(&self) -> String {
168 if self.serves_git() {
169 format!("serving git on {}", self.bound)
170 } else {
171 format!(
172 "NOT serving git: the listener was asked for {} and took {} instead, which is \
173 what a TWIN does — it serves no git to anyone, so a clone, a fetch or a \
174 replication push to {} is refused by the operating system before gunnar sees \
175 the connection",
176 self.asked, self.bound, self.asked
177 )
178 }
179 }
180}
181
182/// **The predicate `Bootstrap.Status.serving_git` answers**: the boot window is
183/// closed AND the git listener is bound where it was asked to be.
184///
185/// Both halves can be false, and each is false for a different box: an
186/// appliance for 0.6 s after a power cut (the window), a twin for its whole
187/// life (the listener). `None` — nothing handed this plane a listener, which is
188/// `gunnar-solo` and every library caller that built one itself — keeps the
189/// answer this door has always given, because an unbound-because-unasked is not
190/// an unbound-because-twin and a status door must not turn silence into a
191/// refusal.
192pub fn is_serving_git(listener: Option<GitListener>) -> bool {
193 is_ready() && listener.map(|l| l.serves_git()).unwrap_or(true)
194}
195
196/// One sentence for `Bootstrap.Status`, so an operator with no key can ask.
197///
198/// [`detail_with`] with no listener: the boot window alone, which is the whole
199/// truth for a caller that has no listener to report.
200pub fn detail() -> String {
201 detail_with(None)
202}
203
204/// One sentence for `Bootstrap.Status`, given the listener this server bound.
205pub fn detail_with(listener: Option<GitListener>) -> String {
206 if is_ready() {
207 // The boot window is closed, so the answer belongs to the LISTENER and
208 // not to this latch.
209 return match listener {
210 Some(listener) => listener.detail(),
211 None => "serving git".to_string(),
212 };
213 }
214 // A poisoned lock must not take the status door down with it: a server that
215 // cannot say WHICH task it waits for can still say that it is waiting.
216 let what = WAITING_FOR
217 .lock()
218 .ok()
219 .map(|w| w.clone())
220 .filter(|w| !w.is_empty())
221 .unwrap_or_else(|| "a boot task".to_string());
222 format!(
223 "starting: {what} is still running, so git transfers are refused with EX_TEMPFAIL \
224 (retry); the control plane answers now"
225 )
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 /// The flag is one flag, and marking it twice says so once.
233 /// Ready by default, closed only by a boot that opens a window, and one
234 /// latch for the whole process.
235 #[test]
236 fn readiness_is_one_latch_that_starts_open() {
237 let a = flag();
238 let b = flag();
239 assert!(Arc::ptr_eq(&a, &b), "two flags is two truths");
240 assert!(
241 is_ready(),
242 "a process with no boot task serves from its first socket"
243 );
244 begin("a test");
245 assert!(!is_ready());
246 assert!(detail().contains("starting"));
247 // The door names the task that actually opened the window.
248 assert!(
249 detail().contains("a test"),
250 "detail() must name what begin() is waiting for: {}",
251 detail()
252 );
253 assert!(
254 !detail().contains("disk test"),
255 "detail() spelled the disk test into an answer about another task: {}",
256 detail()
257 );
258 mark_ready("a test", std::time::Instant::now());
259 assert!(is_ready());
260 assert!(detail().contains("serving"));
261 // Idempotent.
262 mark_ready("a test again", std::time::Instant::now());
263 assert!(is_ready());
264 }
265
266 /// **A twin's listener is not the listener it was asked for, and that is
267 /// what `serving_git` has to say.**
268 ///
269 /// The shapes are the live ones: the appliance asked for `0.0.0.0:2222` and
270 /// took it; the twin asked for the same and `serve::start` gave it
271 /// `127.0.0.1:<ephemeral>` instead. Before this, both answered the same
272 /// `true`, because the only thing consulted was a boot latch that no twin
273 /// ever closes.
274 #[test]
275 fn serving_git_is_the_listener_and_a_twin_says_no() {
276 let asked: std::net::SocketAddr = "0.0.0.0:2222".parse().expect("an address");
277 let primary = GitListener {
278 asked,
279 bound: asked,
280 };
281 assert!(
282 primary.serves_git(),
283 "the door that was asked for is the door that was taken"
284 );
285 // The boot window is the OTHER half, and it is a process-wide latch
286 // that the test beside this one opens and closes: a primary serves git
287 // exactly when that window is shut, which is what this says without
288 // racing it.
289 assert_eq!(
290 is_serving_git(Some(primary)),
291 is_ready(),
292 "with a listener that serves, the boot window is the only thing left to say no"
293 );
294
295 // What `serve.rs` does for `stores.is_twin() || config.twin_role`.
296 let twin = GitListener {
297 asked,
298 bound: "127.0.0.1:41337".parse().expect("an address"),
299 };
300 assert!(
301 !twin.serves_git(),
302 "a listener on loopback is not the 0.0.0.0:2222 an operator was told about"
303 );
304 assert!(
305 !is_serving_git(Some(twin)),
306 "THE DEFECT: the twin answered `git serving` while :2222 answered ECONNREFUSED"
307 );
308 let why = twin.detail();
309 assert!(why.contains("NOT serving git"), "{why}");
310 assert!(
311 why.contains("TWIN"),
312 "an operator must be told WHY, not just no: {why}"
313 );
314 assert!(
315 why.contains("0.0.0.0:2222") && why.contains("127.0.0.1:41337"),
316 "{why}"
317 );
318
319 // An ephemeral port is what a test asks for, and taking one is not a
320 // failure to serve.
321 let ephemeral = GitListener {
322 asked: "127.0.0.1:0".parse().expect("an address"),
323 bound: "127.0.0.1:52001".parse().expect("an address"),
324 };
325 assert!(
326 ephemeral.serves_git(),
327 "port 0 means any port, and any port was taken"
328 );
329
330 // Nothing handed this plane a listener: the old answer, unchanged —
331 // the boot window and nothing else.
332 assert_eq!(is_serving_git(None), is_ready(), "silence is not a refusal");
333 assert!(primary.detail().contains("serving git on 0.0.0.0:2222"));
334 }
335}