use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
pub const POLL: Duration = Duration::from_secs(2);
const REQUESTS_DIR: &str = "service-requests";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Channel {
slots: Slots,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Slots {
One {
request: &'static str,
result: &'static str,
},
PerRequest(&'static str),
}
pub const CACHE_PRUNE: Channel = Channel {
slots: Slots::One {
request: crate::dependency_cache::PRUNE_REQUEST_FILE,
result: crate::dependency_cache::PRUNE_RESULT_FILE,
},
};
pub const GITHUB_DISCOVERY: Channel = Channel {
slots: Slots::PerRequest("github-discovery"),
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Wait {
pub taken: Duration,
pub finished: Duration,
pub poll: Duration,
}
impl Wait {
pub const GITHUB: Self = Self {
taken: Duration::from_secs(4 * POLL.as_secs()),
finished: Duration::from_secs(90),
poll: Duration::from_millis(250),
};
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AskError {
#[error("the request could not be left for the service: {0}")]
NotSent(String),
#[error("no running service took the request within {0} seconds")]
NotTaken(u64),
#[error("the service took the request but gave no answer within {0} seconds")]
NoAnswer(u64),
#[error("the service could not answer: {0}")]
Refused(String),
}
#[derive(Debug, Serialize, Deserialize)]
struct Answer<T> {
request: String,
outcome: Result<T, String>,
}
const MAX_ID: usize = 64;
fn valid_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= MAX_ID
&& id
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_'))
}
const UNCOLLECTED: Duration = Duration::from_secs(10 * 60);
fn read_request(path: &Path) -> Option<String> {
use std::io::Read as _;
let mut options = fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::custom_flags(
&mut options,
libc::O_NOFOLLOW | libc::O_NONBLOCK,
);
#[cfg(windows)]
std::os::windows::fs::OpenOptionsExt::custom_flags(
&mut options,
0x0020_0000,
);
let file = options.open(path).ok()?;
if !file.metadata().ok()?.is_file() {
return None;
}
let mut text = String::new();
file.take(MAX_ID as u64 + 2)
.read_to_string(&mut text)
.ok()?;
let id = text.trim();
valid_id(id).then(|| id.to_owned())
}
fn readable_by_the_asker(path: &Path) -> io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(path, fs::Permissions::from_mode(0o644))
}
#[cfg(not(unix))]
{
let _ = path;
Ok(())
}
}
impl Channel {
#[must_use]
pub fn request_path(&self, state_dir: &Path) -> PathBuf {
match self.slots {
Slots::One { request, .. } => state_dir.join(request),
Slots::PerRequest(prefix) => state_dir.join(REQUESTS_DIR).join(prefix),
}
}
fn request_file(&self, state_dir: &Path, id: &str) -> PathBuf {
match self.slots {
Slots::One { request, .. } => state_dir.join(request),
Slots::PerRequest(prefix) => state_dir
.join(REQUESTS_DIR)
.join(format!("{prefix}.{id}.request")),
}
}
fn result_file(&self, state_dir: &Path, id: &str) -> PathBuf {
match self.slots {
Slots::One { result, .. } => state_dir.join(result),
Slots::PerRequest(prefix) => state_dir
.join(REQUESTS_DIR)
.join(format!("{prefix}.{id}.result")),
}
}
#[must_use]
pub fn take(&self, state_dir: &Path) -> Vec<String> {
match self.slots {
Slots::One { request, .. } => {
let path = state_dir.join(request);
if fs::symlink_metadata(&path).is_err() {
return Vec::new();
}
let id = read_request(&path);
if fs::remove_file(&path).is_err() {
return Vec::new();
}
id.into_iter().collect()
}
Slots::PerRequest(prefix) => {
let Ok(entries) = fs::read_dir(state_dir.join(REQUESTS_DIR)) else {
return Vec::new();
};
let mut taken = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
let Some(rest) = name.strip_prefix(prefix).and_then(|r| r.strip_prefix('.'))
else {
continue;
};
if let Some(id) = rest.strip_suffix(".request") {
if fs::remove_file(entry.path()).is_ok() && valid_id(id) {
taken.push(id.to_owned());
}
} else if rest.ends_with(".result")
&& entry
.metadata()
.and_then(|meta| meta.modified())
.ok()
.and_then(|at| at.elapsed().ok())
.is_some_and(|age| age >= UNCOLLECTED)
{
let _ = fs::remove_file(entry.path());
}
}
taken
}
}
}
pub fn answer<T: Serialize>(
&self,
state_dir: &Path,
request: &str,
outcome: Result<T, String>,
) -> io::Result<()> {
let answer = Answer {
request: request.to_owned(),
outcome,
};
let json = serde_json::to_vec_pretty(&answer).map_err(io::Error::other)?;
let path = self.result_file(state_dir, request);
crate::host_fitness::write_atomically(&path, &json)?;
readable_by_the_asker(&path)
}
#[must_use]
pub fn read_answer<T: DeserializeOwned>(
&self,
state_dir: &Path,
request: &str,
) -> Option<Result<T, String>> {
fs::read(self.result_file(state_dir, request))
.ok()
.and_then(|bytes| serde_json::from_slice::<Answer<T>>(&bytes).ok())
.filter(|answer| answer.request == request)
.map(|answer| answer.outcome)
}
pub fn ask<T: DeserializeOwned>(&self, state_dir: &Path, wait: Wait) -> Result<T, AskError> {
self.ask_with(state_dir, wait, || {})
}
pub fn ask_with<T: DeserializeOwned>(
&self,
state_dir: &Path,
wait: Wait,
sent: impl FnOnce(),
) -> Result<T, AskError> {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let id = format!(
"{}-{}-{}",
chrono::Utc::now().format("%Y%m%dT%H%M%S%.9fZ"),
std::process::id(),
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
);
let request = self.request_file(state_dir, &id);
crate::host_fitness::write_atomically(&request, id.as_bytes())
.map_err(|error| AskError::NotSent(format!("{}: {error}", request.display())))?;
sent();
let started = Instant::now();
let pending = |request: &Path| match self.slots {
Slots::One { .. } => read_request(request).as_deref() == Some(id.as_str()),
Slots::PerRequest(_) => fs::symlink_metadata(request).is_ok(),
};
while pending(&request) {
if started.elapsed() >= wait.taken {
match fs::remove_file(&request) {
Err(error) if error.kind() == io::ErrorKind::NotFound => break,
_ => return Err(AskError::NotTaken(wait.taken.as_secs())),
}
}
std::thread::sleep(wait.poll);
}
let taken = Instant::now();
loop {
if let Some(outcome) = self.read_answer(state_dir, &id) {
if matches!(self.slots, Slots::PerRequest(_)) {
let _ = fs::remove_file(self.result_file(state_dir, &id));
}
return outcome.map_err(AskError::Refused);
}
if taken.elapsed() >= wait.finished {
return Err(AskError::NoAnswer(wait.finished.as_secs()));
}
std::thread::sleep(wait.poll);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const QUICK: Wait = Wait {
taken: Duration::from_millis(600),
finished: Duration::from_secs(5),
poll: Duration::from_millis(20),
};
fn service(
state_dir: PathBuf,
outcome: Result<u32, String>,
) -> std::thread::JoinHandle<String> {
std::thread::spawn(move || {
loop {
if let Some(request) = GITHUB_DISCOVERY.take(&state_dir).pop() {
GITHUB_DISCOVERY
.answer(&state_dir, &request, outcome)
.unwrap();
return request;
}
std::thread::sleep(Duration::from_millis(10));
}
})
}
#[test]
fn a_running_service_takes_the_request_once_and_answers_it() {
let dir = tempfile::tempdir().unwrap();
let answering = service(dir.path().to_path_buf(), Ok(7));
assert_eq!(GITHUB_DISCOVERY.ask::<u32>(dir.path(), QUICK), Ok(7));
answering.join().unwrap();
assert!(
GITHUB_DISCOVERY.take(dir.path()).is_empty(),
"answered once"
);
}
#[test]
fn a_service_that_cannot_answer_says_why() {
let dir = tempfile::tempdir().unwrap();
let answering = service(dir.path().to_path_buf(), Err("no credential".into()));
assert_eq!(
GITHUB_DISCOVERY.ask::<u32>(dir.path(), QUICK),
Err(AskError::Refused("no credential".into()))
);
answering.join().unwrap();
}
#[test]
fn with_no_service_listening_the_request_is_withdrawn() {
let dir = tempfile::tempdir().unwrap();
assert!(matches!(
GITHUB_DISCOVERY.ask::<u32>(dir.path(), QUICK),
Err(AskError::NotTaken(_))
));
assert!(
GITHUB_DISCOVERY.take(dir.path()).is_empty(),
"a later service must not answer a command that stopped waiting"
);
}
#[test]
fn an_answer_to_another_request_is_not_taken_for_this_one() {
let dir = tempfile::tempdir().unwrap();
GITHUB_DISCOVERY
.answer(dir.path(), "an-earlier-request", Ok(1_u32))
.unwrap();
let state_dir = dir.path().to_path_buf();
let silent = std::thread::spawn(move || {
while GITHUB_DISCOVERY.take(&state_dir).is_empty() {
std::thread::sleep(Duration::from_millis(10));
}
});
let wait = Wait {
finished: Duration::from_millis(300),
..QUICK
};
assert!(matches!(
GITHUB_DISCOVERY.ask::<u32>(dir.path(), wait),
Err(AskError::NoAnswer(_))
));
silent.join().unwrap();
}
#[test]
fn commands_asking_at_once_each_get_their_own_answer() {
let dir = tempfile::tempdir().unwrap();
let state_dir = dir.path().to_path_buf();
let service = std::thread::spawn(move || {
let mut answered = 0;
let deadline = Instant::now() + Duration::from_secs(10);
while answered < 2 && Instant::now() < deadline {
for request in GITHUB_DISCOVERY.take(&state_dir) {
GITHUB_DISCOVERY
.answer(&state_dir, &request, Ok(request.len()))
.unwrap();
answered += 1;
}
std::thread::sleep(Duration::from_millis(10));
}
answered
});
let asking =
|dir: PathBuf| std::thread::spawn(move || GITHUB_DISCOVERY.ask::<usize>(&dir, QUICK));
let first = asking(dir.path().to_path_buf());
let second = asking(dir.path().to_path_buf());
assert!(first.join().unwrap().is_ok());
assert!(second.join().unwrap().is_ok());
assert_eq!(service.join().unwrap(), 2);
}
#[cfg(unix)]
#[test]
fn a_request_that_is_a_link_or_not_an_identifier_is_dropped_unread() {
let dir = tempfile::tempdir().unwrap();
let secret = dir.path().join("only-the-service-may-read");
fs::write(&secret, "the contents of somebody else's file").unwrap();
let request = CACHE_PRUNE.request_path(dir.path());
std::os::unix::fs::symlink(&secret, &request).unwrap();
assert!(CACHE_PRUNE.take(dir.path()).is_empty());
assert!(
fs::symlink_metadata(&request).is_err(),
"the link is removed"
);
assert!(secret.exists(), "and only the link");
fs::write(&request, "not an identifier\nat all").unwrap();
assert!(CACHE_PRUNE.take(dir.path()).is_empty());
fs::write(&request, "20261007T221210.123456789Z-4242\n").unwrap();
assert_eq!(
CACHE_PRUNE.take(dir.path()),
["20261007T221210.123456789Z-4242"]
);
}
}