Skip to main content

banc_host/net/
lease.rs

1//! Network-backed rig lease: exclusive rig access arbitrated by the rig
2//! daemon instead of a local flock, for rigs whose runners do not share a
3//! filesystem.
4//!
5//! Semantics mirror the flock: one holder at a time, held for the life of
6//! the `Rig`. The TTL replaces the OS releasing a dead process's lock: a
7//! holder that stops renewing (crashed runner, dead CI job) loses the lease
8//! after `ttl` and the rig frees itself.
9//!
10//! The client is deliberately synchronous (`Rig::acquire` must not create
11//! runtime-bound resources); renewal runs on a plain thread owned by the
12//! [`LeaseClient`], which releases on drop.
13
14use super::{handshake_client_sync, read_frame_sync, write_frame_sync, Role};
15use serde::{Deserialize, Serialize};
16use std::net::TcpStream;
17use std::sync::mpsc;
18use std::time::{Duration, Instant};
19
20/// Default lease TTL. Renewals go out every `ttl / 3`, so a couple of missed
21/// renewals (GC pause, WAN hiccup) do not lose the rig.
22pub const DEFAULT_TTL: Duration = Duration::from_secs(60);
23
24#[derive(Serialize, Deserialize, Debug, Clone)]
25pub enum LeaseRequest {
26    Acquire { holder: String, ttl_s: u32 },
27    Renew { id: u64 },
28    Release { id: u64 },
29}
30
31#[derive(Serialize, Deserialize, Debug, Clone)]
32pub enum LeaseReply {
33    Granted { id: u64 },
34    /// Someone else holds the lease; retry after backoff.
35    Busy { holder: String, expires_in_s: u32 },
36    /// Renew/Release acknowledged.
37    Ok,
38    /// The lease id is not current (expired and possibly re-granted).
39    Gone,
40}
41
42fn roundtrip(stream: &mut TcpStream, req: &LeaseRequest) -> anyhow::Result<LeaseReply> {
43    write_frame_sync(stream, &postcard::to_stdvec(req)?)?;
44    Ok(postcard::from_bytes(&read_frame_sync(stream)?)?)
45}
46
47fn connect(addr: &str, token: &str) -> anyhow::Result<TcpStream> {
48    let mut stream = TcpStream::connect(addr)
49        .map_err(|e| anyhow::anyhow!("connecting to lease server at {addr}: {e}"))?;
50    stream.set_nodelay(true)?;
51    handshake_client_sync(&mut stream, Role::Lease, token)?;
52    Ok(stream)
53}
54
55/// A held rig lease. Renewed in the background; released on drop.
56pub struct LeaseClient {
57    stop: Option<mpsc::Sender<()>>,
58    thread: Option<std::thread::JoinHandle<()>>,
59}
60
61impl LeaseClient {
62    /// Acquire the lease, retrying while it is busy until `timeout`.
63    pub fn acquire(
64        addr: &str,
65        token: &str,
66        holder: &str,
67        timeout: Duration,
68    ) -> anyhow::Result<LeaseClient> {
69        let ttl = DEFAULT_TTL;
70        let deadline = Instant::now() + timeout;
71        let mut stream = connect(addr, token)?;
72        let id = loop {
73            let req = LeaseRequest::Acquire {
74                holder: holder.to_owned(),
75                ttl_s: ttl.as_secs() as u32,
76            };
77            match roundtrip(&mut stream, &req)? {
78                LeaseReply::Granted { id } => break id,
79                LeaseReply::Busy { holder: other, expires_in_s } => {
80                    if Instant::now() >= deadline {
81                        anyhow::bail!(
82                            "rig lease held by '{other}' (expires in {expires_in_s}s), \
83                             gave up after {timeout:?}"
84                        );
85                    }
86                    std::thread::sleep(Duration::from_secs(1));
87                }
88                other => anyhow::bail!("unexpected reply to Acquire: {other:?}"),
89            }
90        };
91
92        let (stop, stopped) = mpsc::channel::<()>();
93        let (addr, token, holder) = (addr.to_owned(), token.to_owned(), holder.to_owned());
94        let thread = std::thread::Builder::new()
95            .name("banc-lease-renew".into())
96            .spawn(move || renew_loop(stream, id, ttl, &addr, &token, &holder, stopped))?;
97
98        Ok(LeaseClient { stop: Some(stop), thread: Some(thread) })
99    }
100}
101
102impl Drop for LeaseClient {
103    fn drop(&mut self) {
104        drop(self.stop.take());
105        if let Some(t) = self.thread.take() {
106            let _ = t.join();
107        }
108    }
109}
110
111/// Renew every ttl/3 until the stop channel closes, then release. A broken
112/// connection is reconnected and the lease re-acquired; losing the lease to
113/// another holder mid-run is loudly reported but cannot fail the suite from
114/// here (the daemon side is what refuses the usurped session's traffic).
115fn renew_loop(
116    mut stream: TcpStream,
117    mut id: u64,
118    ttl: Duration,
119    addr: &str,
120    token: &str,
121    holder: &str,
122    stopped: mpsc::Receiver<()>,
123) {
124    let period = ttl / 3;
125    loop {
126        match stopped.recv_timeout(period) {
127            // Sender dropped: the Rig is going away, release and exit.
128            Err(mpsc::RecvTimeoutError::Disconnected) | Ok(()) => {
129                let _ = roundtrip(&mut stream, &LeaseRequest::Release { id });
130                return;
131            }
132            Err(mpsc::RecvTimeoutError::Timeout) => {}
133        }
134        let outcome = roundtrip(&mut stream, &LeaseRequest::Renew { id });
135        match outcome {
136            Ok(LeaseReply::Ok) => {}
137            Ok(LeaseReply::Gone) => {
138                eprintln!("banc: rig lease expired mid-run; re-acquiring");
139                match reacquire(addr, token, holder, ttl) {
140                    Ok((s, new_id)) => {
141                        stream = s;
142                        id = new_id;
143                    }
144                    Err(e) => eprintln!("banc: rig lease lost and re-acquire failed: {e}"),
145                }
146            }
147            Ok(other) => eprintln!("banc: unexpected reply to Renew: {other:?}"),
148            Err(e) => {
149                eprintln!("banc: lease renew failed ({e}); reconnecting");
150                match reacquire(addr, token, holder, ttl) {
151                    Ok((s, new_id)) => {
152                        stream = s;
153                        id = new_id;
154                    }
155                    Err(e) => eprintln!("banc: lease reconnect failed: {e}"),
156                }
157            }
158        }
159    }
160}
161
162fn reacquire(
163    addr: &str,
164    token: &str,
165    holder: &str,
166    ttl: Duration,
167) -> anyhow::Result<(TcpStream, u64)> {
168    let mut stream = connect(addr, token)?;
169    let req = LeaseRequest::Acquire { holder: holder.to_owned(), ttl_s: ttl.as_secs() as u32 };
170    match roundtrip(&mut stream, &req)? {
171        LeaseReply::Granted { id } => Ok((stream, id)),
172        LeaseReply::Busy { holder: other, .. } => {
173            anyhow::bail!("lease now held by '{other}'")
174        }
175        other => anyhow::bail!("unexpected reply to Acquire: {other:?}"),
176    }
177}
178
179// --- server side ---
180
181/// Single-slot lease state for a rig daemon. Expiry is evaluated lazily on
182/// each request; a holder that stops renewing is displaced by the next
183/// Acquire after its TTL passes.
184pub struct LeaseServer {
185    state: std::sync::Mutex<Option<Held>>,
186    next_id: std::sync::atomic::AtomicU64,
187}
188
189struct Held {
190    id: u64,
191    holder: String,
192    expires_at: Instant,
193    ttl: Duration,
194}
195
196impl Default for LeaseServer {
197    fn default() -> Self {
198        Self::new()
199    }
200}
201
202impl LeaseServer {
203    pub fn new() -> Self {
204        LeaseServer {
205            state: std::sync::Mutex::new(None),
206            next_id: std::sync::atomic::AtomicU64::new(1),
207        }
208    }
209
210    /// Who currently holds the lease, if anyone.
211    pub fn holder(&self) -> Option<String> {
212        let state = self.state.lock().unwrap();
213        state
214            .as_ref()
215            .filter(|h| h.expires_at > Instant::now())
216            .map(|h| h.holder.clone())
217    }
218
219    pub fn handle(&self, req: LeaseRequest) -> LeaseReply {
220        let mut state = self.state.lock().unwrap();
221        let now = Instant::now();
222        if state.as_ref().is_some_and(|h| h.expires_at <= now) {
223            *state = None;
224        }
225        match req {
226            LeaseRequest::Acquire { holder, ttl_s } => match &*state {
227                Some(held) => LeaseReply::Busy {
228                    holder: held.holder.clone(),
229                    expires_in_s: held.expires_at.saturating_duration_since(now).as_secs() as u32,
230                },
231                None => {
232                    let id = self.next_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
233                    let ttl = Duration::from_secs(ttl_s.clamp(5, 3600) as u64);
234                    *state = Some(Held { id, holder, expires_at: now + ttl, ttl });
235                    LeaseReply::Granted { id }
236                }
237            },
238            LeaseRequest::Renew { id } => match state.as_mut() {
239                Some(held) if held.id == id => {
240                    held.expires_at = now + held.ttl;
241                    LeaseReply::Ok
242                }
243                _ => LeaseReply::Gone,
244            },
245            LeaseRequest::Release { id } => {
246                if state.as_ref().is_some_and(|h| h.id == id) {
247                    *state = None;
248                }
249                LeaseReply::Ok
250            }
251        }
252    }
253
254    /// Serve the lease protocol on one authenticated connection until the
255    /// peer disconnects.
256    pub async fn serve_conn(&self, stream: &mut tokio::net::TcpStream) -> anyhow::Result<()> {
257        loop {
258            let frame = match super::read_frame(stream).await {
259                Ok(f) => f,
260                // Peer hung up: normal end of a lease session.
261                Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(()),
262                Err(e) => return Err(e.into()),
263            };
264            let req: LeaseRequest = postcard::from_bytes(&frame)?;
265            let reply = self.handle(req);
266            super::write_frame(stream, &postcard::to_stdvec(&reply)?).await?;
267        }
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn lease_excludes_second_holder_until_released() {
277        let srv = LeaseServer::new();
278        let LeaseReply::Granted { id } =
279            srv.handle(LeaseRequest::Acquire { holder: "a".into(), ttl_s: 60 })
280        else {
281            panic!("first acquire must be granted");
282        };
283        assert!(matches!(
284            srv.handle(LeaseRequest::Acquire { holder: "b".into(), ttl_s: 60 }),
285            LeaseReply::Busy { .. }
286        ));
287        assert!(matches!(srv.handle(LeaseRequest::Renew { id }), LeaseReply::Ok));
288        srv.handle(LeaseRequest::Release { id });
289        assert!(matches!(
290            srv.handle(LeaseRequest::Acquire { holder: "b".into(), ttl_s: 60 }),
291            LeaseReply::Granted { .. }
292        ));
293    }
294
295    #[test]
296    fn expired_lease_is_displaced_and_stale_renew_refused() {
297        let srv = LeaseServer::new();
298        let LeaseReply::Granted { id: stale } =
299            srv.handle(LeaseRequest::Acquire { holder: "a".into(), ttl_s: 5 })
300        else {
301            panic!("first acquire must be granted");
302        };
303        // Force expiry rather than sleeping: rewind the deadline.
304        srv.state.lock().unwrap().as_mut().unwrap().expires_at =
305            Instant::now() - Duration::from_secs(1);
306        assert!(matches!(
307            srv.handle(LeaseRequest::Acquire { holder: "b".into(), ttl_s: 60 }),
308            LeaseReply::Granted { .. }
309        ));
310        assert!(matches!(srv.handle(LeaseRequest::Renew { id: stale }), LeaseReply::Gone));
311    }
312}