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::{DONE_RETAINED, landed_commits, prune_done};
16use super::service_index_lock::Announce;
17use super::service_land::{LandContext, Outcome, land_batch_announcing, recover, recover_instance};
18use super::private_storage::remove_tree;
19use super::service_instance::instance_of;
20use super::claim_time::ClaimTime;
21use super::service_retry::RetrySchedule;
22use super::service_work::{GitHandlers, Wake, run_round};
23use super::service_protocol::{
24    ClientMessage, CommitRequest, FailureCode, MAX_LINE_BYTES, PROTOCOL_VERSION, RELEASE, ServiceMessage, decode_client, encode_service,
25};
26use std::collections::HashMap;
27use std::io::{BufRead, BufReader, Read, Write};
28use std::os::unix::net::{UnixListener, UnixStream};
29use std::sync::atomic::{AtomicUsize, Ordering};
30use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
31use std::sync::Arc;
32use std::path::PathBuf;
33use std::time::{Duration, Instant};
34
35/// The most requests landed as one batch.
36pub const MAX_BATCH: usize = 64;
37
38/// How long a connection may take to send its request line before it is dropped.
39pub const REQUEST_READ_TIMEOUT: Duration = Duration::from_secs(10);
40
41/// How long the service waits with nothing to do before it exits; the manager's socket stays, so the next connection starts it
42///  again. Bazel's server does the same with `--max_idle_secs`, whose default is three hours, which is this value's precedent.
43pub const IDLE_EXIT: Duration = Duration::from_secs(3 * 60 * 60);
44
45/// File descriptor the service manager passes the listening socket on (`SD_LISTEN_FDS_START`).
46pub const INHERITED_DESCRIPTOR: i32 = 3;
47
48/// How a service run behaves apart from where it listens and what it lands.
49#[derive(Clone, Debug, Eq, PartialEq)]
50pub struct ServeSettings {
51    /// How long the service waits with nothing to do before it exits.
52    pub idle: Duration,
53    /// The release the service announces to every client. It is this build's release (`RELEASE`) except in a test that
54    ///  needs a service of another release.
55    pub release: String,
56    /// The worktree this instance serves, as `service_instance::instance_of` names it; every message about another worktree
57    ///  is refused. Nothing in a development run that serves any worktree.
58    pub instance: Option<String>,
59}
60
61impl ServeSettings {
62    /// The settings of an ordinary run: the given idle exit and this build's release.
63    pub fn new(idle: Duration) -> Self {
64        return Self { idle, release: String::from(RELEASE), instance: None };
65    }
66}
67
68/// Where the listening socket comes from.
69#[derive(Clone, Debug, Eq, PartialEq)]
70pub enum Bind {
71    /// A Linux abstract name: one binder at a time, gone with the process, no file to go stale.
72    Abstract(String),
73    /// A path in a private directory, for platforms without abstract names.
74    Path(std::path::PathBuf),
75    /// The socket the service manager opened and passed in.
76    Inherited,
77}
78
79/// What:
80///  Opens the listening socket.
81/// Why:
82///   Socket activation hands over a bound socket, which is the normal way; the others serve tests and development runs.
83///
84/// In TS you'd write (pseudocode):
85/// ```ts
86/// const listener = bind(address)
87/// ```
88pub fn bind(address: &Bind) -> std::io::Result<UnixListener> {
89    return match address {
90        #[cfg(target_os = "linux")]
91        Bind::Abstract(name) => {
92            use std::os::linux::net::SocketAddrExt;
93            let socket = std::os::unix::net::SocketAddr::from_abstract_name(name.as_bytes())?;
94            UnixListener::bind_addr(&socket)
95        }
96        #[cfg(not(target_os = "linux"))]
97        Bind::Abstract(_) => Err(std::io::Error::other("abstract sockets need Linux")),
98        Bind::Path(path) => bind_path(path),
99        Bind::Inherited => {
100            use std::os::fd::FromRawFd;
101            let announced_for: bool = std::env::var("LISTEN_PID").is_ok_and(|pid| { return pid == std::process::id().to_string(); });
102            let count: i32 = std::env::var("LISTEN_FDS").ok().and_then(|text| { return text.parse().ok(); }).unwrap_or(0);
103            if !announced_for || count < 1 {
104                return Err(std::io::Error::other("no socket was passed in"));
105            }
106            // SAFETY: the manager passed this descriptor to this process and nothing else in the process owns it.
107            Ok(unsafe { UnixListener::from_raw_fd(INHERITED_DESCRIPTOR) })
108        }
109    };
110}
111
112/// What:
113///  Binds a socket file, taking over one that no service listens on.
114/// Why:
115///   A service that was killed leaves its socket file behind, and the next bind of the same path then fails with
116///  "Address already in use" although nothing answers there. Only a socket that refuses a connection is replaced: one that
117///  answers belongs to a running service, and any other file is the person's. Two services started on one path at the same
118///  moment are the caller's to avoid (the service manager's socket, which production uses, never comes here).
119///
120/// In TS you'd write (pseudocode):
121/// ```ts
122/// try { return listen(path) } catch (inUse) { if (await refusesConnections(path)) { unlink(path); return listen(path) } throw inUse }
123/// ```
124fn bind_path(path: &std::path::Path) -> std::io::Result<UnixListener> {
125    use std::os::unix::fs::FileTypeExt;
126    let first: std::io::Error = match UnixListener::bind(path) {
127        Ok(listener) => return Ok(listener),
128        Err(error) if error.kind() == std::io::ErrorKind::AddrInUse => error,
129        Err(error) => return Err(error),
130    };
131    let is_socket: bool = std::fs::symlink_metadata(path).is_ok_and(|meta| { return meta.file_type().is_socket(); });
132    let refused: bool = matches!(UnixStream::connect(path), Err(error) if error.kind() == std::io::ErrorKind::ConnectionRefused);
133    if !is_socket || !refused {
134        return Err(first);
135    }
136    std::fs::remove_file(path)?;
137    return UnixListener::bind(path);
138}
139
140/// The user id of the process on the other end of a connection.
141fn peer_user(stream: &UnixStream) -> Option<u32> {
142    use std::os::fd::AsRawFd;
143    let mut credentials = libc::ucred { pid: 0, uid: 0, gid: 0 };
144    let mut length = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
145    // SAFETY: `credentials` and `length` are valid for the call and `length` is its size.
146    let status = unsafe {
147        libc::getsockopt(stream.as_raw_fd(), libc::SOL_SOCKET, libc::SO_PEERCRED, (&raw mut credentials).cast(), &raw mut length)
148    };
149    return if status == 0 { Some(credentials.uid) } else { None };
150}
151
152enum Job {
153    Commit(Box<CommitRequest>, UnixStream),
154    Retire,
155    /// After-commit work was announced; it only restarts the idle clock.
156    Activity,
157    /// A client that held a commit's scratch directory is gone; the directory is removed between batches, never during a
158    ///  landing that may read it.
159    Release(std::path::PathBuf),
160}
161
162/// Returns when the other end of the connection has closed it, or the connection failed. Anything the client sends is ignored.
163fn wait_for_close(stream: &UnixStream) {
164    let mut sink: [u8; 64] = [0; 64];
165    let mut reader = stream;
166    loop {
167        match reader.read(&mut sink) {
168            Ok(0) | Err(_) => return,
169            Ok(_) => {}
170        }
171    }
172}
173
174fn send(stream: &UnixStream, message: &ServiceMessage) {
175    let mut line: String = encode_service(message);
176    line.push('\n');
177    // A client that went away has nothing to be told.
178    let _written = (&*stream).write_all(line.as_bytes());
179}
180
181/// Reads one request line from a new connection, checks who sent it, and queues it.
182fn handle_connection(stream: UnixStream, queue: &Sender<Job>, work: &Sender<Wake>, busy: &AtomicUsize, release: &str, instance: Option<&str>) {
183    send(&stream, &ServiceMessage::Welcome { version: PROTOCOL_VERSION, release: Some(release.to_string()) });
184    // SAFETY: `geteuid` takes no arguments and cannot fail.
185    if peer_user(&stream) != Some(unsafe { libc::geteuid() }) {
186        return;
187    }
188    let _timeout = stream.set_read_timeout(Some(REQUEST_READ_TIMEOUT));
189    let Ok(reader_stream) = stream.try_clone() else { return };
190    let mut line: String = String::new();
191    let limit: u64 = u64::try_from(MAX_LINE_BYTES).unwrap_or(u64::MAX) + 1;
192    if BufReader::new(reader_stream.take(limit)).read_line(&mut line).is_err() {
193        return;
194    }
195    let _cleared = stream.set_read_timeout(None);
196    if line.is_empty() {
197        // A connection that only looked (a readiness probe) sends nothing and is not an error.
198        return;
199    }
200    // A message about another worktree is not this instance's to act on: each worktree has its own instance, so one that
201    //  reaches the wrong socket is a mistake of the sender's, refused where it can be seen.
202    let foreign = |git_dir: &std::path::Path| -> bool { return instance.is_some_and(|mine| { return instance_of(git_dir) != mine; }); };
203    match decode_client(line.trim_end_matches('\n')) {
204        Ok(ClientMessage::Commit(request)) if foreign(request.git_dir.as_path()) => {
205            eprintln!("cli-git-service: refused a commit for {}, which another instance serves", request.git_dir.display());
206            send(&stream, &ServiceMessage::Failed { id: request.id.clone(), code: FailureCode::Rejected });
207        }
208        Ok(ClientMessage::Scratch { git_dir, .. } | ClientMessage::Work { git_dir, .. }) if foreign(git_dir.as_path()) => {
209            eprintln!("cli-git-service: refused a message for {}, which another instance serves", git_dir.display());
210            send(&stream, &ServiceMessage::Failed { id: String::new(), code: FailureCode::Rejected });
211        }
212        Ok(ClientMessage::Commit(request)) => {
213            let _queued = queue.send(Job::Commit(request, stream));
214        }
215        Ok(ClientMessage::Retire) => {
216            let _queued = queue.send(Job::Retire);
217        }
218        Ok(ClientMessage::Scratch { git_dir, path }) => {
219            // The connection is the client's claim on the directory. While it is open the service is not idle, and when it
220            //  closes, by the client's end or its death, the directory is no one's.
221            busy.fetch_add(1, Ordering::SeqCst);
222            wait_for_close(&stream);
223            busy.fetch_sub(1, Ordering::SeqCst);
224            let _unused = git_dir;
225            let _queued = queue.send(Job::Release(path));
226        }
227        Ok(ClientMessage::Work { git_dir, review_key }) => {
228            let _queued = work.send((git_dir, review_key));
229            let _touched = queue.send(Job::Activity);
230        }
231        Err(reason) => {
232            eprintln!("cli-git-service: refused a request line: {reason}");
233            send(&stream, &ServiceMessage::Failed { id: String::new(), code: FailureCode::Rejected });
234        }
235    }
236}
237
238/// Finishes the open batches this instance owns: those of its worktree when it serves one, all of them in a development run.
239fn settle_open_batches(context: &LandContext, instance: Option<&str>) -> Vec<super::service_land::RecoveryReport> {
240    return match instance {
241        Some(mine) => recover_instance(context, mine),
242        None => recover(context),
243    };
244}
245
246/// Lands one group of jobs that share a branch, and answers each client.
247fn land_group(context: &LandContext, instance: Option<&str>, seq: &mut u64, group: Vec<(Box<CommitRequest>, UnixStream)>, landed: &mut HashMap<String, String>) {
248    let (again, pending): (Vec<_>, Vec<_>) = group.into_iter().partition(|(request, _)| { return landed.contains_key(&request.id); });
249    for (request, stream) in again {
250        send(&stream, &ServiceMessage::Landed { id: request.id.clone(), commit: landed[&request.id].clone(), already: true });
251    }
252    // The same request sent twice into one batch (a client that reconnected) is landed once and answered twice.
253    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
254    let (fresh, repeats): (Vec<_>, Vec<_>) = pending.into_iter().partition(|(request, _)| { return seen.insert(request.id.clone()); });
255    if fresh.is_empty() {
256        return;
257    }
258    // A batch whose branch moved but whose index update had to give up (another program held Git's index lock the whole
259    //  time) left its intent open; settling it first puts the index right before anything else changes it.
260    let _settled = settle_open_batches(context, instance);
261    *seq += 1;
262    for (index, (_, stream)) in fresh.iter().enumerate().skip(1) {
263        send(stream, &ServiceMessage::Position { number: index + 1, of: fresh.len() });
264    }
265    let requests: Vec<CommitRequest> = fresh.iter().map(|(request, _)| { return (**request).clone(); }).collect();
266    let announce = |note: Announce| {
267        for (_, stream) in &fresh {
268            match &note {
269                Announce::Waiting(text) => send(stream, &ServiceMessage::Waiting { text: text.clone() }),
270                Announce::Holder(who) => send(stream, &ServiceMessage::Holder { who: who.clone() }),
271            }
272        }
273    };
274    match land_batch_announcing(context, *seq, &requests, &announce) {
275        Ok(outcomes) => {
276            super::service_fault::reach("before-ack");
277            for ((request, _), outcome) in fresh.iter().zip(&outcomes) {
278                if let Outcome::Landed { commit } = outcome {
279                    for (repeat, stream) in repeats.iter().filter(|(repeat, _)| { return repeat.id == request.id; }) {
280                        send(stream, &ServiceMessage::Landed { id: repeat.id.clone(), commit: commit.clone(), already: true });
281                    }
282                }
283            }
284            for ((request, stream), outcome) in fresh.iter().zip(outcomes) {
285                match outcome {
286                    Outcome::Landed { commit } => {
287                        landed.insert(request.id.clone(), commit.clone());
288                        send(stream, &ServiceMessage::Landed { id: request.id.clone(), commit, already: false });
289                    }
290                    Outcome::RetryPrepare { base } => {
291                        let captures = super::service_captures::remembered(request.common_dir.as_path(), request.repository.as_path());
292                        send(stream, &ServiceMessage::RetryPrepare { id: request.id.clone(), base, landed: captures });
293                    }
294                    Outcome::BranchMoved => send(stream, &ServiceMessage::Failed { id: request.id.clone(), code: FailureCode::BranchMoved }),
295                }
296            }
297        }
298        Err(code) => {
299            for (request, stream) in &fresh {
300                send(stream, &ServiceMessage::Failed { id: request.id.clone(), code });
301            }
302        }
303    }
304}
305
306/// What:
307///  Runs the after-commit work of each repository named, one repository at a time, folding wakes that arrive while it
308///  works into the next round, and looks again at a repository whose claims another instance held when their lease is over.
309/// Why:
310///   Draining claims everything pending, so a burst of wakes for one repository is one pass. The instance that held a claim
311///  may have died, and nothing but this loop would then wake the work: it waits for a wake no longer than the first lease still
312///  running, and the service counts itself busy meanwhile, so it does not exit idle with work claimed by a dead holder.
313///
314/// In TS you'd write (pseudocode):
315/// ```ts
316/// for await (const dir of wakesOrDueRetries) await drain(dir, handlers)
317/// ```
318fn work_loop(receiver: &Receiver<Wake>, handlers: &GitHandlers, busy: &AtomicUsize) {
319    let mut retries: RetrySchedule = RetrySchedule::default();
320    let mut counted_waiting: bool = false;
321    loop {
322        let first: Option<Wake> = match retries.next_wait(Instant::now()) {
323            None => match receiver.recv() {
324                Ok(wake) => Some(wake),
325                Err(_) => return,
326            },
327            Some(wait) => match receiver.recv_timeout(wait) {
328                Ok(wake) => Some(wake),
329                Err(RecvTimeoutError::Timeout) => None,
330                Err(RecvTimeoutError::Disconnected) => return,
331            },
332        };
333        busy.fetch_add(1, Ordering::SeqCst);
334        let mut wakes: Vec<Wake> = first.into_iter().chain(std::iter::from_fn(|| { return receiver.try_recv().ok(); })).collect();
335        wakes.extend(retries.take_due(Instant::now()).into_iter().map(|directory| { return (directory, None); }));
336        let ran: Vec<PathBuf> = wakes.iter().map(|(directory, _)| { return directory.clone(); }).collect();
337        let asked: Vec<(PathBuf, Duration)> = run_round(wakes, handlers, &ClaimTime::read());
338        retries.replace(ran.as_slice(), asked, Instant::now());
339        // A repository waiting to be looked at again keeps the service from exiting idle, as running work does.
340        if !retries.is_empty() && !counted_waiting {
341            busy.fetch_add(1, Ordering::SeqCst);
342            counted_waiting = true;
343        } else if retries.is_empty() && counted_waiting {
344            busy.fetch_sub(1, Ordering::SeqCst);
345            counted_waiting = false;
346        }
347        busy.fetch_sub(1, Ordering::SeqCst);
348    }
349}
350
351/// What:
352///  Runs the service until it is told to retire.
353/// Why:
354///   Recovery runs before the first connection is accepted; then the loop takes everything queued as one batch, groups it
355///  by branch in order of first arrival, lands each group, and answers.
356///
357/// In TS you'd write (pseudocode):
358/// ```ts
359/// await serve(listener, context)
360/// ```
361pub fn serve(listener: UnixListener, context: &LandContext, settings: &ServeSettings) {
362    let idle: Duration = settings.idle;
363    let _reports = settle_open_batches(context, settings.instance.as_deref());
364    // The memory of landed requests is bounded: the oldest finished batches go before the rest are read.
365    let pruned: usize = prune_done(context.journal.as_path(), DONE_RETAINED);
366    if pruned > 0 {
367        eprintln!("cli-git-service: removed the records of {pruned} old finished batches from {}", context.journal.display());
368    }
369    let mut landed: HashMap<String, String> = landed_commits(context.journal.as_path());
370    let (sender, receiver): (Sender<Job>, Receiver<Job>) = channel();
371    let acceptor_queue: Sender<Job> = sender.clone();
372    // After-commit work (push, upkeep) runs on its own thread, so a slow network never delays a landing.
373    let (work_sender, work_receiver): (Sender<Wake>, Receiver<Wake>) = channel();
374    let handlers: GitHandlers = GitHandlers::new(context.git.clone());
375    let busy: Arc<AtomicUsize> = Arc::new(AtomicUsize::new(0));
376    let worker_busy: Arc<AtomicUsize> = Arc::clone(&busy);
377    std::thread::spawn(move || { work_loop(&work_receiver, &handlers, &worker_busy); });
378    let announced_release: String = settings.release.clone();
379    let connection_busy: Arc<AtomicUsize> = Arc::clone(&busy);
380    let instance: Option<String> = settings.instance.clone();
381    std::thread::spawn(move || {
382        for connection in listener.incoming().flatten() {
383            let queue: Sender<Job> = acceptor_queue.clone();
384            let work: Sender<Wake> = work_sender.clone();
385            let release: String = announced_release.clone();
386            let counted: Arc<AtomicUsize> = Arc::clone(&connection_busy);
387            let mine: Option<String> = instance.clone();
388            std::thread::spawn(move || { handle_connection(connection, &queue, &work, &counted, release.as_str(), mine.as_deref()); });
389        }
390    });
391    // Microseconds, so that instances started at nearly the same time do not number their batches alike in a shared journal.
392    let mut seq: u64 = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(1, |elapsed| { return u64::try_from(elapsed.as_micros()).unwrap_or(1); });
393    let mut retiring: bool = false;
394    while !retiring {
395        let first: Job = match receiver.recv_timeout(idle) {
396            Ok(job) => job,
397            // Work in flight keeps the service; otherwise it exits and the manager starts it on the next connection.
398            Err(RecvTimeoutError::Timeout) if busy.load(Ordering::SeqCst) > 0 => continue,
399            Err(_) => return,
400        };
401        let mut jobs: Vec<Job> = vec![first];
402        while jobs.len() < MAX_BATCH {
403            match receiver.try_recv() {
404                Ok(job) => jobs.push(job),
405                Err(_) => break,
406            }
407        }
408        // Branches in order of first arrival; within a branch, arrival order.
409        let mut groups: Vec<((std::path::PathBuf, String, bool), Vec<(Box<CommitRequest>, UnixStream)>)> = Vec::new();
410        let mut releases: Vec<std::path::PathBuf> = Vec::new();
411        for job in jobs {
412            match job {
413                Job::Retire => retiring = true,
414                Job::Activity => {}
415                Job::Release(path) => releases.push(path),
416                Job::Commit(request, stream) => {
417                    // An amend is a group of its own, and what arrives after it starts another group, so the order on
418                    //  one branch is the order of arrival.
419                    // `HEAD` is a head of one worktree, so two detached worktrees of one repository are two branches.
420                    let queue_reference: String = if request.reference == "HEAD" {
421                        format!("HEAD@{}", request.repository.display())
422                    } else {
423                        request.reference.clone()
424                    };
425                    let key = (request.common_dir.clone(), queue_reference, request.lands_alone());
426                    let joins = groups.iter_mut().rev().find(|((common, reference, _), _)| { return *common == key.0 && *reference == key.1; });
427                    match joins {
428                        Some((existing, members)) if !key.2 && !existing.2 => members.push((request, stream)),
429                        _ => groups.push((key, vec![(request, stream)])),
430                    }
431                }
432            }
433        }
434        for (_, group) in groups {
435            land_group(context, settings.instance.as_deref(), &mut seq, group, &mut landed);
436        }
437        for path in releases {
438            if let Err(error) = remove_tree(path.as_path()) {
439                eprintln!("cli-git-service: the scratch directory {} could not be removed: {error}", path.display());
440            }
441        }
442    }
443}
444
445/// Socket controls stay out of the release executable.
446#[cfg(test)]
447#[path = "service_server_tests.rs"]
448mod tests;