1use std::fs;
17use std::io;
18use std::path::{Path, PathBuf};
19use std::time::{Duration, Instant};
20
21use serde::de::DeserializeOwned;
22use serde::{Deserialize, Serialize};
23
24pub const POLL: Duration = Duration::from_secs(2);
26
27const REQUESTS_DIR: &str = "service-requests";
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct Channel {
33 slots: Slots,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37enum Slots {
38 One {
40 request: &'static str,
41 result: &'static str,
42 },
43 PerRequest(&'static str),
45}
46
47pub const CACHE_PRUNE: Channel = Channel {
51 slots: Slots::One {
52 request: crate::dependency_cache::PRUNE_REQUEST_FILE,
53 result: crate::dependency_cache::PRUNE_RESULT_FILE,
54 },
55};
56
57pub const GITHUB_DISCOVERY: Channel = Channel {
66 slots: Slots::PerRequest("github-discovery"),
67};
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct Wait {
72 pub taken: Duration,
75 pub finished: Duration,
77 pub poll: Duration,
78}
79
80impl Wait {
81 pub const GITHUB: Self = Self {
83 taken: Duration::from_secs(4 * POLL.as_secs()),
84 finished: Duration::from_secs(90),
85 poll: Duration::from_millis(250),
86 };
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
91pub enum AskError {
92 #[error("the request could not be left for the service: {0}")]
93 NotSent(String),
94 #[error("no running service took the request within {0} seconds")]
95 NotTaken(u64),
96 #[error("the service took the request but gave no answer within {0} seconds")]
97 NoAnswer(u64),
98 #[error("the service could not answer: {0}")]
99 Refused(String),
100}
101
102#[derive(Debug, Serialize, Deserialize)]
103struct Answer<T> {
104 request: String,
105 outcome: Result<T, String>,
106}
107
108const MAX_ID: usize = 64;
112
113fn valid_id(id: &str) -> bool {
114 !id.is_empty()
115 && id.len() <= MAX_ID
116 && id
117 .chars()
118 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_'))
119}
120
121const UNCOLLECTED: Duration = Duration::from_secs(10 * 60);
123
124fn read_request(path: &Path) -> Option<String> {
133 use std::io::Read as _;
134 let mut options = fs::OpenOptions::new();
135 options.read(true);
136 #[cfg(unix)]
137 std::os::unix::fs::OpenOptionsExt::custom_flags(
138 &mut options,
139 libc::O_NOFOLLOW | libc::O_NONBLOCK,
140 );
141 #[cfg(windows)]
142 std::os::windows::fs::OpenOptionsExt::custom_flags(
143 &mut options,
144 0x0020_0000,
146 );
147 let file = options.open(path).ok()?;
148 if !file.metadata().ok()?.is_file() {
149 return None;
150 }
151 let mut text = String::new();
152 file.take(MAX_ID as u64 + 2)
153 .read_to_string(&mut text)
154 .ok()?;
155 let id = text.trim();
156 valid_id(id).then(|| id.to_owned())
157}
158
159fn readable_by_the_asker(path: &Path) -> io::Result<()> {
162 #[cfg(unix)]
163 {
164 use std::os::unix::fs::PermissionsExt as _;
165 fs::set_permissions(path, fs::Permissions::from_mode(0o644))
166 }
167 #[cfg(not(unix))]
168 {
169 let _ = path;
170 Ok(())
171 }
172}
173
174impl Channel {
175 #[must_use]
177 pub fn request_path(&self, state_dir: &Path) -> PathBuf {
178 match self.slots {
179 Slots::One { request, .. } => state_dir.join(request),
180 Slots::PerRequest(prefix) => state_dir.join(REQUESTS_DIR).join(prefix),
181 }
182 }
183
184 fn request_file(&self, state_dir: &Path, id: &str) -> PathBuf {
185 match self.slots {
186 Slots::One { request, .. } => state_dir.join(request),
187 Slots::PerRequest(prefix) => state_dir
188 .join(REQUESTS_DIR)
189 .join(format!("{prefix}.{id}.request")),
190 }
191 }
192
193 fn result_file(&self, state_dir: &Path, id: &str) -> PathBuf {
194 match self.slots {
195 Slots::One { result, .. } => state_dir.join(result),
196 Slots::PerRequest(prefix) => state_dir
197 .join(REQUESTS_DIR)
198 .join(format!("{prefix}.{id}.result")),
199 }
200 }
201
202 #[must_use]
205 pub fn take(&self, state_dir: &Path) -> Vec<String> {
206 match self.slots {
207 Slots::One { request, .. } => {
208 let path = state_dir.join(request);
209 if fs::symlink_metadata(&path).is_err() {
210 return Vec::new();
211 }
212 let id = read_request(&path);
213 if fs::remove_file(&path).is_err() {
216 return Vec::new();
217 }
218 id.into_iter().collect()
219 }
220 Slots::PerRequest(prefix) => {
221 let Ok(entries) = fs::read_dir(state_dir.join(REQUESTS_DIR)) else {
222 return Vec::new();
223 };
224 let mut taken = Vec::new();
225 for entry in entries.flatten() {
226 let name = entry.file_name();
227 let Some(name) = name.to_str() else { continue };
228 let Some(rest) = name.strip_prefix(prefix).and_then(|r| r.strip_prefix('.'))
229 else {
230 continue;
231 };
232 if let Some(id) = rest.strip_suffix(".request") {
233 if fs::remove_file(entry.path()).is_ok() && valid_id(id) {
236 taken.push(id.to_owned());
237 }
238 } else if rest.ends_with(".result")
239 && entry
240 .metadata()
241 .and_then(|meta| meta.modified())
242 .ok()
243 .and_then(|at| at.elapsed().ok())
244 .is_some_and(|age| age >= UNCOLLECTED)
245 {
246 let _ = fs::remove_file(entry.path());
247 }
248 }
249 taken
250 }
251 }
252 }
253
254 pub fn answer<T: Serialize>(
259 &self,
260 state_dir: &Path,
261 request: &str,
262 outcome: Result<T, String>,
263 ) -> io::Result<()> {
264 let answer = Answer {
265 request: request.to_owned(),
266 outcome,
267 };
268 let json = serde_json::to_vec_pretty(&answer).map_err(io::Error::other)?;
269 let path = self.result_file(state_dir, request);
270 crate::host_fitness::write_atomically(&path, &json)?;
271 readable_by_the_asker(&path)
272 }
273
274 #[must_use]
276 pub fn read_answer<T: DeserializeOwned>(
277 &self,
278 state_dir: &Path,
279 request: &str,
280 ) -> Option<Result<T, String>> {
281 fs::read(self.result_file(state_dir, request))
282 .ok()
283 .and_then(|bytes| serde_json::from_slice::<Answer<T>>(&bytes).ok())
284 .filter(|answer| answer.request == request)
285 .map(|answer| answer.outcome)
286 }
287
288 pub fn ask<T: DeserializeOwned>(&self, state_dir: &Path, wait: Wait) -> Result<T, AskError> {
293 self.ask_with(state_dir, wait, || {})
294 }
295
296 pub fn ask_with<T: DeserializeOwned>(
302 &self,
303 state_dir: &Path,
304 wait: Wait,
305 sent: impl FnOnce(),
306 ) -> Result<T, AskError> {
307 static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
310 let id = format!(
311 "{}-{}-{}",
312 chrono::Utc::now().format("%Y%m%dT%H%M%S%.9fZ"),
313 std::process::id(),
314 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
315 );
316 let request = self.request_file(state_dir, &id);
317 crate::host_fitness::write_atomically(&request, id.as_bytes())
318 .map_err(|error| AskError::NotSent(format!("{}: {error}", request.display())))?;
319 sent();
320
321 let started = Instant::now();
322 let pending = |request: &Path| match self.slots {
323 Slots::One { .. } => read_request(request).as_deref() == Some(id.as_str()),
324 Slots::PerRequest(_) => fs::symlink_metadata(request).is_ok(),
325 };
326 while pending(&request) {
327 if started.elapsed() >= wait.taken {
328 match fs::remove_file(&request) {
333 Err(error) if error.kind() == io::ErrorKind::NotFound => break,
334 _ => return Err(AskError::NotTaken(wait.taken.as_secs())),
335 }
336 }
337 std::thread::sleep(wait.poll);
338 }
339
340 let taken = Instant::now();
341 loop {
342 if let Some(outcome) = self.read_answer(state_dir, &id) {
343 if matches!(self.slots, Slots::PerRequest(_)) {
344 let _ = fs::remove_file(self.result_file(state_dir, &id));
345 }
346 return outcome.map_err(AskError::Refused);
347 }
348 if taken.elapsed() >= wait.finished {
349 return Err(AskError::NoAnswer(wait.finished.as_secs()));
350 }
351 std::thread::sleep(wait.poll);
352 }
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 const QUICK: Wait = Wait {
361 taken: Duration::from_millis(600),
362 finished: Duration::from_secs(5),
363 poll: Duration::from_millis(20),
364 };
365
366 fn service(
368 state_dir: PathBuf,
369 outcome: Result<u32, String>,
370 ) -> std::thread::JoinHandle<String> {
371 std::thread::spawn(move || {
372 loop {
373 if let Some(request) = GITHUB_DISCOVERY.take(&state_dir).pop() {
374 GITHUB_DISCOVERY
375 .answer(&state_dir, &request, outcome)
376 .unwrap();
377 return request;
378 }
379 std::thread::sleep(Duration::from_millis(10));
380 }
381 })
382 }
383
384 #[test]
385 fn a_running_service_takes_the_request_once_and_answers_it() {
386 let dir = tempfile::tempdir().unwrap();
387 let answering = service(dir.path().to_path_buf(), Ok(7));
388 assert_eq!(GITHUB_DISCOVERY.ask::<u32>(dir.path(), QUICK), Ok(7));
389 answering.join().unwrap();
390 assert!(
391 GITHUB_DISCOVERY.take(dir.path()).is_empty(),
392 "answered once"
393 );
394 }
395
396 #[test]
397 fn a_service_that_cannot_answer_says_why() {
398 let dir = tempfile::tempdir().unwrap();
399 let answering = service(dir.path().to_path_buf(), Err("no credential".into()));
400 assert_eq!(
401 GITHUB_DISCOVERY.ask::<u32>(dir.path(), QUICK),
402 Err(AskError::Refused("no credential".into()))
403 );
404 answering.join().unwrap();
405 }
406
407 #[test]
408 fn with_no_service_listening_the_request_is_withdrawn() {
409 let dir = tempfile::tempdir().unwrap();
410 assert!(matches!(
411 GITHUB_DISCOVERY.ask::<u32>(dir.path(), QUICK),
412 Err(AskError::NotTaken(_))
413 ));
414 assert!(
415 GITHUB_DISCOVERY.take(dir.path()).is_empty(),
416 "a later service must not answer a command that stopped waiting"
417 );
418 }
419
420 #[test]
421 fn an_answer_to_another_request_is_not_taken_for_this_one() {
422 let dir = tempfile::tempdir().unwrap();
423 GITHUB_DISCOVERY
424 .answer(dir.path(), "an-earlier-request", Ok(1_u32))
425 .unwrap();
426 let state_dir = dir.path().to_path_buf();
427 let silent = std::thread::spawn(move || {
429 while GITHUB_DISCOVERY.take(&state_dir).is_empty() {
430 std::thread::sleep(Duration::from_millis(10));
431 }
432 });
433 let wait = Wait {
434 finished: Duration::from_millis(300),
435 ..QUICK
436 };
437 assert!(matches!(
438 GITHUB_DISCOVERY.ask::<u32>(dir.path(), wait),
439 Err(AskError::NoAnswer(_))
440 ));
441 silent.join().unwrap();
442 }
443
444 #[test]
447 fn commands_asking_at_once_each_get_their_own_answer() {
448 let dir = tempfile::tempdir().unwrap();
449 let state_dir = dir.path().to_path_buf();
450 let service = std::thread::spawn(move || {
451 let mut answered = 0;
452 let deadline = Instant::now() + Duration::from_secs(10);
453 while answered < 2 && Instant::now() < deadline {
454 for request in GITHUB_DISCOVERY.take(&state_dir) {
455 GITHUB_DISCOVERY
456 .answer(&state_dir, &request, Ok(request.len()))
457 .unwrap();
458 answered += 1;
459 }
460 std::thread::sleep(Duration::from_millis(10));
461 }
462 answered
463 });
464 let asking =
465 |dir: PathBuf| std::thread::spawn(move || GITHUB_DISCOVERY.ask::<usize>(&dir, QUICK));
466 let first = asking(dir.path().to_path_buf());
467 let second = asking(dir.path().to_path_buf());
468 assert!(first.join().unwrap().is_ok());
469 assert!(second.join().unwrap().is_ok());
470 assert_eq!(service.join().unwrap(), 2);
471 }
472
473 #[cfg(unix)]
476 #[test]
477 fn a_request_that_is_a_link_or_not_an_identifier_is_dropped_unread() {
478 let dir = tempfile::tempdir().unwrap();
479 let secret = dir.path().join("only-the-service-may-read");
480 fs::write(&secret, "the contents of somebody else's file").unwrap();
481 let request = CACHE_PRUNE.request_path(dir.path());
482 std::os::unix::fs::symlink(&secret, &request).unwrap();
483 assert!(CACHE_PRUNE.take(dir.path()).is_empty());
484 assert!(
485 fs::symlink_metadata(&request).is_err(),
486 "the link is removed"
487 );
488 assert!(secret.exists(), "and only the link");
489
490 fs::write(&request, "not an identifier\nat all").unwrap();
491 assert!(CACHE_PRUNE.take(dir.path()).is_empty());
492 fs::write(&request, "20261007T221210.123456789Z-4242\n").unwrap();
493 assert_eq!(
494 CACHE_PRUNE.take(dir.path()),
495 ["20261007T221210.123456789Z-4242"]
496 );
497 }
498}