//! The git processes the site runs: `git http-backend` for clones and //! fetches, and `git cat-file` for the raw view. libgit2 answers the //! site's questions in-process, but it cannot stream a packed object and //! pack generation is git's job anyway, so bytes that leave the site in //! bulk come from git itself. Every such process occupies one of a //! bounded number of slots, and is killed once nothing has moved between //! it and its client for longer than the idle timeout, so that a crowd of //! slow or silent clients cannot pin processes for good. use std::{ io, path::{Path, PathBuf}, process::Stdio, sync::{ Arc, atomic::{AtomicU64, Ordering}, }, time::{Duration, Instant}, }; use anyhow::{Context as _, Error, anyhow}; use poem::{ Body, IntoResponse, http::{StatusCode, header}, }; use tokio::{ io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader, DuplexStream}, process::{Child, ChildStdin, ChildStdout, Command}, sync::{OwnedSemaphorePermit, Semaphore}, time::timeout, }; // Bytes moved per read or write between a process and its client. const CHUNK: usize = 64 * 1024; /// Where git processes are started from: the store they may touch, and /// the bounds every one of them runs under. pub struct Git { root: PathBuf, slots: Arc, idle: Duration, } impl Git { /// Runs git against the repositories under `root`, at most `processes` /// at a time, none of them left stalled for longer than `idle`. pub fn new(root: PathBuf, processes: u32, idle: Duration) -> Self { Git { root, slots: Arc::new(Semaphore::new(processes as usize)), idle, } } /// The directory of repositories. pub fn root(&self) -> &Path { &self.root } /// Starts `git` in a free slot, waiting up to the idle timeout for /// one. `configure` adds the arguments and environment of the /// particular command to a process that otherwise sees nothing but /// `PATH` and the store declared safe. `input`, when given, is fed to /// the process as it arrives; otherwise the process has no input. /// `what` names the command in the journal. pub async fn start( &self, what: &'static str, input: Option, configure: impl FnOnce(&mut Command), ) -> Result { let slot = match timeout(self.idle, self.slots.clone().acquire_owned()).await { Ok(Ok(slot)) => slot, Ok(Err(closed)) => return Err(StartError::Failed(closed.into())), Err(_) => { return Err(StartError::Busy { retry_after: self.idle, }); } }; let mut command = Command::new("git"); command .env_clear() .env("PATH", std::env::var_os("PATH").unwrap_or_default()) // The repositories belong to the hosting user, not to the one // serving them, and git refuses to touch a repository owned by // someone else unless it is declared safe. The configured // store is trusted by definition. .env("GIT_CONFIG_COUNT", "1") .env("GIT_CONFIG_KEY_0", "safe.directory") .env("GIT_CONFIG_VALUE_0", format!("{}/*", self.root.display())) .stdin(if input.is_some() { Stdio::piped() } else { Stdio::null() }) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) .kill_on_drop(true); configure(&mut command); let mut child = command .spawn() .with_context(|| format!("starting git {what}"))?; let stdout = child .stdout .take() .ok_or_else(|| anyhow!("no output pipe for git {what}"))?; let activity = Arc::new(Activity::new()); if let Some(body) = input { let stdin = child .stdin .take() .ok_or_else(|| anyhow!("no input pipe for git {what}"))?; tokio::spawn(feed(body, stdin, activity.clone(), self.idle, what)); } Ok(Process { child, output: BufReader::with_capacity(CHUNK, stdout), activity, _slot: slot, idle: self.idle, what, }) } } /// Why a git process could not be started. #[derive(Debug)] pub enum StartError { /// Every slot stayed taken for the whole idle timeout. Busy { retry_after: Duration, }, Failed(Error), } impl From for StartError { fn from(err: Error) -> Self { StartError::Failed(err) } } // Being refused for lack of a slot is an answer to the client, not a // failure of the site, so it travels as a complete response: a plain 503 // naming the wait the client should observe. impl From for poem::Error { fn from(err: StartError) -> Self { match err { StartError::Busy { retry_after } => poem::Error::from_response( "The site is serving as many clones and downloads as it can at once. Try again shortly.\n" .with_status(StatusCode::SERVICE_UNAVAILABLE) .with_header(header::RETRY_AFTER, retry_after.as_secs().max(1).to_string()) .into_response(), ), StartError::Failed(err) => err.into(), } } } /// A running git process in its slot. Dropping it kills the process and /// frees the slot. pub struct Process { child: Child, output: BufReader, activity: Arc, _slot: OwnedSemaphorePermit, idle: Duration, what: &'static str, } impl Process { /// The next line of output without its line ending, or `None` once /// the output is over. pub async fn read_line(&mut self) -> Result, Error> { let mut line = String::new(); let read = within(self.idle, self.what, self.output.read_line(&mut line)).await?; if read == 0 { return Ok(None); } line.truncate(line.trim_end_matches(['\r', '\n']).len()); Ok(Some(line)) } /// The output produced so far, left in place for whatever reads next; /// empty once the output is over. pub async fn peek(&mut self) -> Result<&[u8], Error> { within(self.idle, self.what, self.output.fill_buf()).await } /// The rest of the output, as a response body. The process is reaped /// once its output is over, and killed if it or the client stalls. pub fn into_body(self) -> Body { // A pipe whose ends notice each other going away: the body sees // the end of the output once the pump drops its end, and the pump // fails to write once the body has been dropped. let (ours, theirs) = tokio::io::duplex(2 * CHUNK); tokio::spawn(self.pump(ours)); Body::from_async_read(theirs) } // Moves output to the client until it is over, then sees the process // out. async fn pump(mut self, mut sink: DuplexStream) { let outcome = self.copy_output(&mut sink).await; drop(sink); let stall = match outcome { Ok(()) => return self.finish().await, Err(stall) => stall, }; let seconds = self.idle.as_secs(); match stall { Stall::Silent => tracing::warn!( command = self.what, "nothing moved for {seconds}s; killing the process" ), Stall::ClientNotReading => tracing::info!( command = self.what, "client read nothing for {seconds}s; killing the process" ), Stall::ClientGone => { tracing::debug!(command = self.what, "client went away; killing the process") } Stall::ReadFailed(err) => tracing::warn!( command = self.what, error = %err, "reading git output failed; killing the process" ), } self.kill().await; } async fn copy_output(&mut self, sink: &mut DuplexStream) -> Result<(), Stall> { let mut buf = vec![0u8; CHUNK]; loop { let read = loop { match timeout(self.idle, self.output.read(&mut buf)).await { Ok(Ok(read)) => break read, Ok(Err(err)) => return Err(Stall::ReadFailed(err)), // Silence on the output is fine while input is still // arriving: the process is working on it. Err(_) if self.activity.idle_for() < self.idle => {} Err(_) => return Err(Stall::Silent), } }; if read == 0 { return Ok(()); } match timeout(self.idle, sink.write_all(&buf[..read])).await { Ok(Ok(())) => self.activity.touch(), Ok(Err(_)) => return Err(Stall::ClientGone), Err(_) => return Err(Stall::ClientNotReading), } } } // Reaps a process whose output is over. One that keeps running // regardless is killed. async fn finish(mut self) { let waited = timeout(self.idle, self.child.wait()).await; match waited { Ok(Ok(status)) if status.success() => {} Ok(Ok(status)) => tracing::warn!(command = self.what, %status, "git failed"), Ok(Err(err)) => tracing::warn!(command = self.what, error = %err, "waiting for git"), Err(_) => { tracing::warn!( command = self.what, "git kept running for {}s after its output ended; killing it", self.idle.as_secs() ); self.kill().await; } } } async fn kill(mut self) { if let Err(err) = self.child.kill().await { tracing::debug!(command = self.what, error = %err, "killing git"); } } } // How the output pump stopped short. enum Stall { Silent, ClientNotReading, ClientGone, ReadFailed(io::Error), } // Waits for an operation on the process's output, for at most the idle // timeout. async fn within( idle: Duration, what: &str, operation: impl Future>, ) -> Result { match timeout(idle, operation).await { Ok(result) => Ok(result?), Err(_) => Err(anyhow!( "git {what} produced no output for {}s", idle.as_secs() )), } } // Feeds a request body to a process as it arrives, closing the process's // input once the body is over or the client has sent nothing for the idle // timeout. A process whose input ends early fails on its own, and the // output side sees it out. async fn feed( body: Body, mut stdin: ChildStdin, activity: Arc, idle: Duration, what: &'static str, ) { let mut source = body.into_async_read(); let mut buf = vec![0u8; CHUNK]; loop { let read = match timeout(idle, source.read(&mut buf)).await { Ok(Ok(0)) => return, Ok(Ok(read)) => read, Ok(Err(err)) => { tracing::debug!(command = what, error = %err, "request body ended early"); return; } Err(_) => { tracing::info!( command = what, "client sent no input for {}s; closing the process's input", idle.as_secs() ); return; } }; activity.touch(); match timeout(idle, stdin.write_all(&buf[..read])).await { Ok(Ok(())) => {} // The process is gone; the output side reports why. Ok(Err(_)) => return, Err(_) => { tracing::warn!( command = what, "git read no input for {}s; closing its input", idle.as_secs() ); return; } } } } // When bytes last moved in either direction, shared by both sides of a // process. struct Activity { started: Instant, last_millis: AtomicU64, } impl Activity { fn new() -> Self { Activity { started: Instant::now(), last_millis: AtomicU64::new(0), } } fn touch(&self) { self.last_millis .store(self.started.elapsed().as_millis() as u64, Ordering::Relaxed); } fn idle_for(&self) -> Duration { let last = Duration::from_millis(self.last_millis.load(Ordering::Relaxed)); self.started.elapsed().saturating_sub(last) } }