Skip to main content

git_policy_cli/
service_server.rs

1//! What:
2//!  The resident service's loop: accept connections, queue requests in arrival order, land what is queued as a batch,
3//!  answer each client, and exit when told to retire.
4//! Why:
5//!  One writer lands every commit, so there is no lock to take, hold or wait for: order is the order of the queue, the
6//!  cost of the one durable write is shared by the batch, and a client that waits blocks on its socket instead of
7//!  polling (`doc/planning/cli-git-resident-service.md`). The service never starts itself: the operating system hands it
8//!  the listening socket (socket activation), or a development run binds one.
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! for await (const batch of queue.batches()) { const outcomes = await landBatch(batch); for (...) reply(outcome); }
13//! ```
14
15use super::service_journal::landed_commits;
16use super::service_land::{LandContext, Outcome, land_batch, recover};
17use super::service_work::{GitHandlers, drain};
18use super::service_protocol::{
19    ClientMessage, CommitRequest, FailureCode, MAX_LINE_BYTES, PROTOCOL_VERSION, ServiceMessage, decode_client, encode_service,
20};
21use std::collections::HashMap;
22use std::io::{BufRead, BufReader, Read, Write};
23use std::os::unix::net::{UnixListener, UnixStream};
24use std::sync::atomic::{AtomicUsize, Ordering};
25use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
26use std::sync::Arc;
27use std::time::Duration;
28
29/// The most requests landed as one batch.
30pub const MAX_BATCH: usize = 64;
31
32/// How long a connection may take to send its request line before it is dropped.
33pub const REQUEST_READ_TIMEOUT: Duration = Duration::from_secs(10);
34
35/// How long the service waits with nothing to do before it exits; the manager's socket stays, so the next connection starts it
36///  again. Bazel's server does the same with `--max_idle_secs`, whose default is three hours, which is this value's precedent.
37pub const IDLE_EXIT: Duration = Duration::from_secs(3 * 60 * 60);
38
39/// File descriptor the service manager passes the listening socket on (`SD_LISTEN_FDS_START`).
40pub const INHERITED_DESCRIPTOR: i32 = 3;
41
42/// Where the listening socket comes from.
43#[derive(Clone, Debug, Eq, PartialEq)]
44pub enum Bind {
45    /// A Linux abstract name: one binder at a time, gone with the process, no file to go stale.
46    Abstract(String),
47    /// A path in a private directory, for platforms without abstract names.
48    Path(std::path::PathBuf),
49    /// The socket the service manager opened and passed in.
50    Inherited,
51}
52
53/// What:
54///  Opens the listening socket.
55/// Why:
56///   Socket activation hands over a bound socket, which is the normal way; the others serve tests and development runs.
57///
58/// In TS you'd write (pseudocode):
59/// ```ts
60/// const listener = bind(address)
61/// ```
62pub fn bind(address: &Bind) -> std::io::Result<UnixListener> {
63    return match address {
64        #[cfg(target_os = "linux")]
65        Bind::Abstract(name) => {
66            use std::os::linux::net::SocketAddrExt;
67            let socket = std::os::unix::net::SocketAddr::from_abstract_name(name.as_bytes())?;
68            UnixListener::bind_addr(&socket)
69        }
70        #[cfg(not(target_os = "linux"))]
71        Bind::Abstract(_) => Err(std::io::Error::other("abstract sockets need Linux")),
72        Bind::Path(path) => UnixListener::bind(path),
73        Bind::Inherited => {
74            use std::os::fd::FromRawFd;
75            let announced_for: bool = std::env::var("LISTEN_PID").is_ok_and(|pid| { return pid == std::process::id().to_string(); });
76            let count: i32 = std::env::var("LISTEN_FDS").ok().and_then(|text| { return text.parse().ok(); }).unwrap_or(0);
77            if !announced_for || count < 1 {
78                return Err(std::io::Error::other("no socket was passed in"));
79            }
80            // SAFETY: the manager passed this descriptor to this process and nothing else in the process owns it.
81            Ok(unsafe { UnixListener::from_raw_fd(INHERITED_DESCRIPTOR) })
82        }
83    };
84}
85
86/// The user id of the process on the other end of a connection.
87fn peer_user(stream: &UnixStream) -> Option<u32> {
88    use std::os::fd::AsRawFd;
89    let mut credentials = libc::ucred { pid: 0, uid: 0, gid: 0 };
90    let mut length = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
91    // SAFETY: `credentials` and `length` are valid for the call and `length` is its size.
92    let status = unsafe {
93        libc::getsockopt(stream.as_raw_fd(), libc::SOL_SOCKET, libc::SO_PEERCRED, (&raw mut credentials).cast(), &raw mut length)
94    };
95    return if status == 0 { Some(credentials.uid) } else { None };
96}
97
98enum Job {
99    Commit(Box<CommitRequest>, UnixStream),
100    Retire,
101    /// After-commit work was announced; it only restarts the idle clock.
102    Activity,
103}
104
105fn send(stream: &UnixStream, message: &ServiceMessage) {
106    let mut line: String = encode_service(message);
107    line.push('\n');
108    // A client that went away has nothing to be told.
109    let _written = (&*stream).write_all(line.as_bytes());
110}
111
112/// Reads one request line from a new connection, checks who sent it, and queues it.
113fn handle_connection(stream: UnixStream, queue: &Sender<Job>, work: &Sender<std::path::PathBuf>) {
114    send(&stream, &ServiceMessage::Welcome { version: PROTOCOL_VERSION });
115    // SAFETY: `geteuid` takes no arguments and cannot fail.
116    if peer_user(&stream) != Some(unsafe { libc::geteuid() }) {
117        return;
118    }
119    let _timeout = stream.set_read_timeout(Some(REQUEST_READ_TIMEOUT));
120    let Ok(reader_stream) = stream.try_clone() else { return };
121    let mut line: String = String::new();
122    let limit: u64 = u64::try_from(MAX_LINE_BYTES).unwrap_or(u64::MAX) + 1;
123    if BufReader::new(reader_stream.take(limit)).read_line(&mut line).is_err() {
124        return;
125    }
126    let _cleared = stream.set_read_timeout(None);
127    if line.is_empty() {
128        // A connection that only looked (a readiness probe) sends nothing and is not an error.
129        return;
130    }
131    match decode_client(line.trim_end_matches('\n')) {
132        Ok(ClientMessage::Commit(request)) => {
133            let _queued = queue.send(Job::Commit(request, stream));
134        }
135        Ok(ClientMessage::Retire) => {
136            let _queued = queue.send(Job::Retire);
137        }
138        Ok(ClientMessage::Work { common_dir }) => {
139            let _queued = work.send(common_dir);
140            let _touched = queue.send(Job::Activity);
141        }
142        Err(reason) => {
143            eprintln!("cli-git-service: refused a request line: {reason}");
144            send(&stream, &ServiceMessage::Failed { id: String::new(), code: FailureCode::Rejected });
145        }
146    }
147}
148
149/// Lands one group of jobs that share a branch, and answers each client.
150fn land_group(context: &LandContext, seq: &mut u64, group: Vec<(Box<CommitRequest>, UnixStream)>, landed: &mut HashMap<String, String>) {
151    let (again, pending): (Vec<_>, Vec<_>) = group.into_iter().partition(|(request, _)| { return landed.contains_key(&request.id); });
152    for (request, stream) in again {
153        send(&stream, &ServiceMessage::Landed { id: request.id.clone(), commit: landed[&request.id].clone(), already: true });
154    }
155    // The same request sent twice into one batch (a client that reconnected) is landed once and answered twice.
156    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
157    let (fresh, repeats): (Vec<_>, Vec<_>) = pending.into_iter().partition(|(request, _)| { return seen.insert(request.id.clone()); });
158    if fresh.is_empty() {
159        return;
160    }
161    // A batch whose branch moved but whose index update had to give up (another program held Git's index lock the whole
162    //  time) left its intent open; settling it first puts the index right before anything else changes it.
163    // A batch whose branch moved but whose index update had to give up (another program held Git's index lock the whole
164    //  time) left its intent open; settling it first puts the index right before anything else changes it.
165    let _settled = recover(context);
166    *seq += 1;
167    for (index, (_, stream)) in fresh.iter().enumerate().skip(1) {
168        send(stream, &ServiceMessage::Position { number: index + 1, of: fresh.len() });
169    }
170    let requests: Vec<CommitRequest> = fresh.iter().map(|(request, _)| { return (**request).clone(); }).collect();
171    match land_batch(context, *seq, &requests) {
172        Ok(outcomes) => {
173            super::service_fault::reach("before-ack");
174            for ((request, _), outcome) in fresh.iter().zip(&outcomes) {
175                if let Outcome::Landed { commit } = outcome {
176                    for (repeat, stream) in repeats.iter().filter(|(repeat, _)| { return repeat.id == request.id; }) {
177                        send(stream, &ServiceMessage::Landed { id: repeat.id.clone(), commit: commit.clone(), already: true });
178                    }
179                }
180            }
181            for ((request, stream), outcome) in fresh.iter().zip(outcomes) {
182                match outcome {
183                    Outcome::Landed { commit } => {
184                        landed.insert(request.id.clone(), commit.clone());
185                        send(stream, &ServiceMessage::Landed { id: request.id.clone(), commit, already: false });
186                    }
187                    Outcome::RetryPrepare { base } => send(stream, &ServiceMessage::RetryPrepare { id: request.id.clone(), base }),
188                    Outcome::BranchMoved => send(stream, &ServiceMessage::Failed { id: request.id.clone(), code: FailureCode::BranchMoved }),
189                }
190            }
191        }
192        Err(code) => {
193            for (request, stream) in &fresh {
194                send(stream, &ServiceMessage::Failed { id: request.id.clone(), code });
195            }
196        }
197    }
198}
199
200/// What:
201///  Runs the after-commit work of each repository named, one repository at a time, folding wakes that arrive while it
202///  works into the next round.
203/// Why:
204///   Draining claims everything pending, so a burst of wakes for one repository is one pass.
205///
206/// In TS you'd write (pseudocode):
207/// ```ts
208/// for await (const dir of wakes) await drain(dir, handlers)
209/// ```
210fn work_loop(receiver: &Receiver<std::path::PathBuf>, handlers: &GitHandlers, busy: &AtomicUsize) {
211    while let Ok(first) = receiver.recv() {
212        busy.fetch_add(1, Ordering::SeqCst);
213        let mut directories: Vec<std::path::PathBuf> = vec![first];
214        while let Ok(more) = receiver.try_recv() {
215            if !directories.contains(&more) {
216                directories.push(more);
217            }
218        }
219        for directory in directories {
220            drain(directory.as_path(), handlers, std::time::SystemTime::now());
221        }
222        busy.fetch_sub(1, Ordering::SeqCst);
223    }
224}
225
226/// What:
227///  Runs the service until it is told to retire.
228/// Why:
229///   Recovery runs before the first connection is accepted; then the loop takes everything queued as one batch, groups it
230///  by branch in order of first arrival, lands each group, and answers.
231///
232/// In TS you'd write (pseudocode):
233/// ```ts
234/// await serve(listener, context)
235/// ```
236pub fn serve(listener: UnixListener, context: &LandContext, idle: Duration) {
237    let _reports = recover(context);
238    let mut landed: HashMap<String, String> = landed_commits(context.journal.as_path());
239    let (sender, receiver): (Sender<Job>, Receiver<Job>) = channel();
240    let acceptor_queue: Sender<Job> = sender.clone();
241    // After-commit work (push, upkeep) runs on its own thread, so a slow network never delays a landing.
242    let (work_sender, work_receiver): (Sender<std::path::PathBuf>, Receiver<std::path::PathBuf>) = channel();
243    let handlers: GitHandlers = GitHandlers { context: context.git.clone() };
244    let busy: Arc<AtomicUsize> = Arc::new(AtomicUsize::new(0));
245    let worker_busy: Arc<AtomicUsize> = Arc::clone(&busy);
246    std::thread::spawn(move || { work_loop(&work_receiver, &handlers, &worker_busy); });
247    std::thread::spawn(move || {
248        for connection in listener.incoming().flatten() {
249            let queue: Sender<Job> = acceptor_queue.clone();
250            let work: Sender<std::path::PathBuf> = work_sender.clone();
251            std::thread::spawn(move || { handle_connection(connection, &queue, &work); });
252        }
253    });
254    let mut seq: u64 = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(1, |elapsed| { return u64::try_from(elapsed.as_millis()).unwrap_or(1); });
255    let mut retiring: bool = false;
256    while !retiring {
257        let first: Job = match receiver.recv_timeout(idle) {
258            Ok(job) => job,
259            // Work in flight keeps the service; otherwise it exits and the manager starts it on the next connection.
260            Err(RecvTimeoutError::Timeout) if busy.load(Ordering::SeqCst) > 0 => continue,
261            Err(_) => return,
262        };
263        let mut jobs: Vec<Job> = vec![first];
264        while jobs.len() < MAX_BATCH {
265            match receiver.try_recv() {
266                Ok(job) => jobs.push(job),
267                Err(_) => break,
268            }
269        }
270        // Branches in order of first arrival; within a branch, arrival order.
271        let mut groups: Vec<((std::path::PathBuf, String, bool), Vec<(Box<CommitRequest>, UnixStream)>)> = Vec::new();
272        for job in jobs {
273            match job {
274                Job::Retire => retiring = true,
275                Job::Activity => {}
276                Job::Commit(request, stream) => {
277                    // An amend is a group of its own, and what arrives after it starts another group, so the order on
278                    //  one branch is the order of arrival.
279                    let key = (request.common_dir.clone(), request.reference.clone(), request.lands_alone());
280                    let joins = groups.iter_mut().rev().find(|((common, reference, _), _)| { return *common == key.0 && *reference == key.1; });
281                    match joins {
282                        Some((existing, members)) if !key.2 && !existing.2 => members.push((request, stream)),
283                        _ => groups.push((key, vec![(request, stream)])),
284                    }
285                }
286            }
287        }
288        for (_, group) in groups {
289            land_group(context, &mut seq, group, &mut landed);
290        }
291    }
292}