Skip to main content

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}