use crate::commit_timing::{ReachTrace, SubmitTrace, holder_kind_of};
use crate::service_messages::{ClientFailure, Platform, SILENT_NOTICE, STARTING_NOTICE, UPDATING_NOTICE, position_notice};
use crate::service_protocol::{
ClientMessage, CommitRequest, FailureCode, LandedCapture, PROTOCOL_VERSION, RELEASE, ServiceMessage, decode_service, encode_client, encode_retire,
};
use crate::service_instance::service_unit_instance;
use crate::service_provision::ProvisionHost;
use crate::service_review_key::ReviewKey;
use crate::systemd_dbus::{Bus, EXCHANGE_WAIT};
use crate::service_server::Bind;
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::time::{Duration, Instant};
pub const START_WAIT: Duration = Duration::from_secs(10);
pub const START_NOTICE_GRACE: Duration = Duration::from_secs(1);
pub const RECONNECTS: usize = 5;
const CONNECT_PAUSE: Duration = Duration::from_millis(25);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Activation {
Managed,
Unsupported(Platform),
}
#[derive(Clone, Debug)]
pub struct ClientConfig {
pub address: Bind,
pub activation: Activation,
pub start_wait: Duration,
pub heartbeat_silence: Duration,
pub start_watch: Option<ManagerWatch>,
pub keep_fresh: Option<FreshScratch>,
pub answer_ceiling: Duration,
pub withdraw_ceiling: Duration,
pub extra_wait: Duration,
pub start_ceiling: Duration,
}
#[derive(Clone, Debug)]
pub struct FreshScratch {
pub path: std::path::PathBuf,
pub every: Duration,
}
#[derive(Clone, Debug)]
pub struct ManagerWatch {
pub bus: std::path::PathBuf,
pub unit: String,
pub every: Duration,
pub state: fn(&std::path::Path, &str) -> Option<String>,
pub start: fn(&std::path::Path, &str) -> bool,
}
pub const MANAGER_ASK_EVERY: Duration = START_NOTICE_GRACE;
fn unit_state(bus: &std::path::Path, unit: &str) -> Option<String> {
let mut connection: Bus = Bus::connect(bus, EXCHANGE_WAIT).ok()?;
return connection.active_state(unit).ok()?;
}
fn start_unit(bus: &std::path::Path, unit: &str) -> bool {
let Ok(mut connection) = Bus::connect(bus, EXCHANGE_WAIT) else { return false };
return connection.start_unit(unit).is_ok();
}
pub fn manager_watch_for(host: &ProvisionHost) -> ManagerWatch {
return ManagerWatch { bus: host.bus.clone(), unit: service_unit_instance(host.instance.as_str()), every: MANAGER_ASK_EVERY, state: unit_state, start: start_unit };
}
enum Heard {
Message(ServiceMessage),
Closed,
Silent,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Submitted {
Landed { commit: String, already: bool },
PrepareAgain { base: String, landed: Vec<LandedCapture> },
Moved,
}
fn connect(address: &Bind) -> std::io::Result<UnixStream> {
return match address {
#[cfg(target_os = "linux")]
Bind::Abstract(name) => {
use std::os::linux::net::SocketAddrExt;
UnixStream::connect_addr(&std::os::unix::net::SocketAddr::from_abstract_name(name.as_bytes())?)
}
#[cfg(not(target_os = "linux"))]
Bind::Abstract(_) => Err(std::io::Error::other("abstract sockets need Linux")),
Bind::Path(path) => UnixStream::connect(path),
Bind::Inherited => Err(std::io::Error::other("a client does not inherit the socket")),
};
}
fn read_message(reader: &mut BufReader<UnixStream>) -> Option<ServiceMessage> {
return match listen(reader) {
Heard::Message(message) => Some(message),
Heard::Closed | Heard::Silent => None,
};
}
fn listen(reader: &mut BufReader<UnixStream>) -> Heard {
let mut line: String = String::new();
return match reader.read_line(&mut line) {
Ok(read) if read > 0 => decode_service(line.trim_end_matches('\n')).map_or(Heard::Closed, Heard::Message),
Err(error) if matches!(error.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => Heard::Silent,
_ => Heard::Closed,
};
}
fn reach(
config: &ClientConfig,
notices: &mut dyn Write,
announced: &mut bool,
trace: &mut ReachTrace,
service_seen: bool,
) -> Result<(BufReader<UnixStream>, u32, Option<String>), ClientFailure> {
let started: Instant = Instant::now();
let mut refused_here: bool = false;
let mut last_asked: Instant = started;
let mut failed_answers: u32 = 0;
let mut cannot_ask: bool = false;
let mut last_start_request: Option<Instant> = None;
loop {
trace.tries += 1;
match connect(&config.address) {
Ok(stream) => {
let patience: Duration = match (&config.start_watch, service_seen) {
(_, true) => config.heartbeat_silence,
(Some(watch), false) => watch.every,
(None, false) => config.start_wait,
};
let _timeout = stream.set_read_timeout(Some(patience));
let mut reader: BufReader<UnixStream> = BufReader::new(stream);
match listen(&mut reader) {
Heard::Message(ServiceMessage::Welcome { version, release }) => {
let _widened = reader.get_ref().set_read_timeout(Some(config.heartbeat_silence));
trace.saw_release(release.as_deref());
if refused_here {
trace.start_waited += started.elapsed();
}
return Ok((reader, version, release));
}
Heard::Silent if service_seen => return Err(ClientFailure::ServiceSilent),
Heard::Message(_) | Heard::Closed | Heard::Silent => {
trace.refused += 1;
refused_here = true;
}
}
}
Err(_) => match config.activation {
Activation::Unsupported(platform) => return Err(ClientFailure::NoActivation(platform)),
Activation::Managed => {
trace.refused += 1;
refused_here = true;
}
},
}
match &config.start_watch {
Some(watch) => {
if last_asked.elapsed() >= watch.every {
last_asked = Instant::now();
let answer: Option<String> = (watch.state)(watch.bus.as_path(), watch.unit.as_str());
if answer.as_deref() == Some("inactive")
&& started.elapsed() >= config.start_wait
&& last_start_request.is_none_or(|at| { return at.elapsed() >= config.start_wait; })
{
let _taken = (watch.start)(watch.bus.as_path(), watch.unit.as_str());
last_start_request = Some(Instant::now());
}
cannot_ask = answer.is_none();
failed_answers = if answer.as_deref() == Some("failed") { failed_answers + 1 } else { 0 };
}
if failed_answers >= 2 || (cannot_ask && started.elapsed() >= config.start_wait) || started.elapsed() >= config.start_ceiling {
return Err(ClientFailure::DidNotStart);
}
}
None => {
if started.elapsed() >= config.start_wait {
return Err(ClientFailure::DidNotStart);
}
}
}
if !*announced && started.elapsed() >= START_NOTICE_GRACE {
*announced = true;
let _written = writeln!(notices, "{STARTING_NOTICE}");
}
std::thread::sleep(CONNECT_PAUSE);
}
}
fn reach_current(config: &ClientConfig, notices: &mut dyn Write, announced: &mut bool, trace: &mut ReachTrace, service_seen: bool) -> Result<BufReader<UnixStream>, ClientFailure> {
let began: Instant = Instant::now();
let reached = reach_current_untimed(config, notices, announced, trace, service_seen);
trace.elapsed += began.elapsed();
return reached;
}
fn reach_current_untimed(config: &ClientConfig, notices: &mut dyn Write, announced: &mut bool, trace: &mut ReachTrace, service_seen: bool) -> Result<BufReader<UnixStream>, ClientFailure> {
let mut give_up_at: Option<Instant> = None;
loop {
let (mut reader, version, release) = reach(config, notices, announced, trace, service_seen)?;
if version == PROTOCOL_VERSION && release.as_deref() == Some(RELEASE) {
trace.answered_by = release;
return Ok(reader);
}
if version > PROTOCOL_VERSION {
return Err(ClientFailure::NoCommonVersion);
}
let deadline: Instant = *give_up_at.get_or_insert_with(|| {
let _written = writeln!(notices, "{UPDATING_NOTICE}");
return Instant::now() + config.start_wait;
});
if Instant::now() >= deadline {
return Err(ClientFailure::OtherRelease);
}
let mut retire: String = encode_retire(version);
retire.push('\n');
let _sent = reader.get_mut().write_all(retire.as_bytes());
trace.retires_sent += 1;
std::thread::sleep(CONNECT_PAUSE * 4);
}
}
pub fn failure_for(code: FailureCode, request: &CommitRequest) -> ClientFailure {
let repository: String = request.repository.display().to_string();
return match code {
FailureCode::RepositoryFull => ClientFailure::RepositoryFull(repository),
FailureCode::RepositoryReadOnly => ClientFailure::RepositoryReadOnly(repository),
FailureCode::IndexInUse => ClientFailure::IndexInUse(String::from("Another program")),
FailureCode::Rejected => ClientFailure::Refused,
FailureCode::BranchKeepsChanging => ClientFailure::BranchKeepsChanging {
branch: request.reference.trim_start_matches("refs/heads/").to_string(),
times: crate::service_land::LANDING_ATTEMPTS,
},
FailureCode::BranchMoved => ClientFailure::BranchMoved { branch: request.reference.trim_start_matches("refs/heads/").to_string() },
FailureCode::StepTooSlow => ClientFailure::StepTooSlow,
FailureCode::Unavailable => ClientFailure::Unavailable,
};
}
pub fn submit(config: &ClientConfig, request: &CommitRequest, notices: &mut dyn Write) -> Result<Submitted, ClientFailure> {
return submit_traced(config, request, notices, &mut SubmitTrace::default());
}
pub fn submit_traced(config: &ClientConfig, request: &CommitRequest, notices: &mut dyn Write, trace: &mut SubmitTrace) -> Result<Submitted, ClientFailure> {
let began: Instant = Instant::now();
let answered = submit_loop(config, request, notices, trace);
trace.total += began.elapsed();
return answered;
}
fn submit_loop(config: &ClientConfig, request: &CommitRequest, notices: &mut dyn Write, trace: &mut SubmitTrace) -> Result<Submitted, ClientFailure> {
let line: String = encode_client(&ClientMessage::Commit(Box::new(request.clone())));
let mut announced: bool = false;
let mut breaks: usize = 0;
let mut last_position: Option<usize> = None;
let mut holder: Option<String> = None;
let mut service_seen: bool = false;
let mut last_break_was_silence: bool = false;
let waiting_since: Instant = Instant::now();
let mut last_blocker: Option<String> = None;
let mut inside_write: bool = false;
let mut extra_deadline: Option<Instant> = None;
let mut last_renewed: Option<Instant> = None;
let mut renew_scratch = |settings: &ClientConfig| {
if let Some(fresh) = settings.keep_fresh.as_ref()
&& last_renewed.is_none_or(|at| { return at.elapsed() >= fresh.every; })
{
crate::scratch_location::renew(fresh.path.as_path());
last_renewed = Some(Instant::now());
}
};
loop {
let mut reader: BufReader<UnixStream> = reach_current(config, notices, &mut announced, &mut trace.reach, service_seen)?;
service_seen = true;
let mut payload: String = line.clone();
payload.push('\n');
if reader.get_mut().write_all(payload.as_bytes()).is_err() {
breaks += 1;
trace.reconnects += 1;
if breaks > RECONNECTS {
return Err(ClientFailure::MayNotHaveLanded);
}
continue;
}
trace.sends += 1;
renew_scratch(config);
let written_at: Instant = Instant::now();
let answered: Option<Result<Submitted, ClientFailure>> = loop {
let heard: Heard = listen(&mut reader);
if matches!(heard, Heard::Message(_)) {
renew_scratch(config);
}
if waiting_since.elapsed() >= config.answer_ceiling && !matches!(heard, Heard::Message(ServiceMessage::Landed { .. } | ServiceMessage::RetryPrepare { .. } | ServiceMessage::Failed { .. })) {
if !inside_write {
trace.answer_wait += written_at.elapsed();
return withdraw(config, request.id.as_str(), last_blocker.take());
}
let deadline: Instant = *extra_deadline.get_or_insert_with(|| { return Instant::now() + config.extra_wait; });
if Instant::now() >= deadline {
trace.answer_wait += written_at.elapsed();
return Err(ClientFailure::WithdrawalUnconfirmed { blocker: last_blocker.take() });
}
}
match heard {
Heard::Message(ServiceMessage::Position { number, of }) => {
if last_position != Some(number) {
last_position = Some(number);
trace.positions.push(number);
let _written = writeln!(notices, "{}", position_notice(number, of));
}
}
Heard::Message(ServiceMessage::Landed { commit, already, .. }) => break Some(Ok(Submitted::Landed { commit, already })),
Heard::Message(ServiceMessage::RetryPrepare { base, landed, .. }) => break Some(Ok(Submitted::PrepareAgain { base, landed })),
Heard::Message(ServiceMessage::Failed { code: FailureCode::BranchMoved, .. }) => break Some(Ok(Submitted::Moved)),
Heard::Message(ServiceMessage::Failed { code: FailureCode::IndexInUse, .. }) => {
break Some(Err(ClientFailure::IndexInUse(holder.take().unwrap_or_else(|| { return String::from("Another program"); }))));
}
Heard::Message(ServiceMessage::Failed { code, .. }) => break Some(Err(failure_for(code, request))),
Heard::Message(message @ (ServiceMessage::Waiting { .. } | ServiceMessage::InsideWrite { .. })) => {
inside_write = inside_write || matches!(message, ServiceMessage::InsideWrite { .. });
let (ServiceMessage::Waiting { text } | ServiceMessage::InsideWrite { text }) = message else { continue };
last_blocker = Some(text.trim().to_string());
trace.waiting_notices += 1;
trace.holder_kind = Some(holder_kind_of(text.as_str()));
let _written = write!(notices, "{text}");
}
Heard::Message(ServiceMessage::Holder { who }) => holder = Some(who),
Heard::Message(ServiceMessage::Welcome { .. } | ServiceMessage::Heartbeat | ServiceMessage::Withdrawn { .. }) => {}
Heard::Silent => {
last_break_was_silence = true;
break None;
}
Heard::Closed => {
last_break_was_silence = false;
break None;
}
}
};
trace.answer_wait += written_at.elapsed();
if let Some(done) = answered {
return done;
}
breaks += 1;
if breaks > RECONNECTS {
return Err(if last_break_was_silence { ClientFailure::ServiceSilent } else { ClientFailure::MayNotHaveLanded });
}
if last_break_was_silence {
let _written = writeln!(notices, "{SILENT_NOTICE}");
}
std::thread::sleep(CONNECT_PAUSE * u32::try_from(breaks).unwrap_or(1));
}
}
fn withdraw(config: &ClientConfig, id: &str, heard_blocker: Option<String>) -> Result<Submitted, ClientFailure> {
let minutes: u64 = config.answer_ceiling.as_secs() / 60;
let mut blocker: Option<String> = heard_blocker;
let mut announced: bool = true;
let Ok(mut reader) = reach_current(config, &mut std::io::sink(), &mut announced, &mut ReachTrace::default(), true) else {
return Err(ClientFailure::WithdrawalUnconfirmed { blocker });
};
let mut asking: String = encode_client(&ClientMessage::Withdraw { id: id.to_string() });
asking.push('\n');
if reader.get_mut().write_all(asking.as_bytes()).is_err() {
return Err(ClientFailure::WithdrawalUnconfirmed { blocker });
}
let deadline: Instant = Instant::now() + config.withdraw_ceiling;
while Instant::now() < deadline {
let _timeout = reader.get_ref().set_read_timeout(Some(deadline.saturating_duration_since(Instant::now()).max(Duration::from_millis(1)).min(config.heartbeat_silence)));
match listen(&mut reader) {
Heard::Message(ServiceMessage::Landed { commit, .. }) => return Ok(Submitted::Landed { commit, already: true }),
Heard::Message(ServiceMessage::Withdrawn { .. }) => return Err(ClientFailure::StillWithService { minutes, blocker }),
Heard::Message(ServiceMessage::Waiting { text } | ServiceMessage::InsideWrite { text }) => blocker = Some(text.trim().to_string()),
Heard::Message(_) => {}
Heard::Silent | Heard::Closed => return Err(ClientFailure::WithdrawalUnconfirmed { blocker }),
}
}
return Err(ClientFailure::WithdrawalUnconfirmed { blocker });
}
pub fn ask_to_retire(socket: &std::path::Path) {
let Ok(stream) = UnixStream::connect(socket) else { return };
let _timeout = stream.set_read_timeout(Some(START_WAIT));
let mut reader: BufReader<UnixStream> = BufReader::new(stream);
let Some(ServiceMessage::Welcome { version, .. }) = read_message(&mut reader) else { return };
let mut retire: String = encode_retire(version);
retire.push('\n');
let _sent = reader.get_mut().write_all(retire.as_bytes());
}
pub struct ScratchWatch {
_connection: UnixStream,
}
pub fn watch_scratch(config: &ClientConfig, git_dir: &std::path::Path, path: &std::path::Path) -> Option<ScratchWatch> {
let mut connection: UnixStream = connect(&config.address).ok()?;
let mut line: String = encode_client(&ClientMessage::Scratch { git_dir: git_dir.to_path_buf(), path: path.to_path_buf() });
line.push('\n');
connection.write_all(line.as_bytes()).ok()?;
return Some(ScratchWatch { _connection: connection });
}
pub fn wake(config: &ClientConfig, git_dir: &std::path::Path, review_key: Option<&ReviewKey>, notices: &mut dyn Write) -> Result<(), ClientFailure> {
let mut line: String = encode_client(&ClientMessage::Work { git_dir: git_dir.to_path_buf(), review_key: review_key.cloned() });
line.push('\n');
let mut announced: bool = false;
let mut reader: BufReader<UnixStream> = reach_current(config, notices, &mut announced, &mut ReachTrace::default(), false)?;
if reader.get_mut().write_all(line.as_bytes()).is_err() {
return Err(ClientFailure::Unavailable);
}
while read_message(&mut reader).is_some() {}
return Ok(());
}
#[cfg(test)]
#[path = "service_client_tests.rs"]
mod tests;