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 write_readable_by_the_asker(path: &Path, bytes: &[u8]) -> io::Result<()> {
162 use std::io::Write as _;
163 let parent = path.parent().unwrap_or_else(|| Path::new("."));
164 fs::create_dir_all(parent)?;
165 let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
166 temporary.write_all(bytes)?;
167 temporary.as_file().sync_all()?;
168 #[cfg(unix)]
172 {
173 use std::os::unix::fs::PermissionsExt as _;
174 temporary
175 .as_file()
176 .set_permissions(fs::Permissions::from_mode(0o644))?;
177 }
178 temporary
179 .persist(path)
180 .map(|_| ())
181 .map_err(|error| error.error)
182}
183
184impl Channel {
185 #[must_use]
187 pub fn request_path(&self, state_dir: &Path) -> PathBuf {
188 match self.slots {
189 Slots::One { request, .. } => state_dir.join(request),
190 Slots::PerRequest(prefix) => state_dir.join(REQUESTS_DIR).join(prefix),
191 }
192 }
193
194 fn request_file(&self, state_dir: &Path, id: &str) -> PathBuf {
195 match self.slots {
196 Slots::One { request, .. } => state_dir.join(request),
197 Slots::PerRequest(prefix) => state_dir
198 .join(REQUESTS_DIR)
199 .join(format!("{prefix}.{id}.request")),
200 }
201 }
202
203 fn result_file(&self, state_dir: &Path, id: &str) -> PathBuf {
204 match self.slots {
205 Slots::One { result, .. } => state_dir.join(result),
206 Slots::PerRequest(prefix) => state_dir
207 .join(REQUESTS_DIR)
208 .join(format!("{prefix}.{id}.result")),
209 }
210 }
211
212 #[must_use]
215 pub fn take(&self, state_dir: &Path) -> Vec<String> {
216 match self.slots {
217 Slots::One { request, .. } => {
218 let path = state_dir.join(request);
219 if fs::symlink_metadata(&path).is_err() {
220 return Vec::new();
221 }
222 let id = read_request(&path);
223 if fs::remove_file(&path).is_err() {
226 return Vec::new();
227 }
228 id.into_iter().collect()
229 }
230 Slots::PerRequest(prefix) => {
231 let Ok(entries) = fs::read_dir(state_dir.join(REQUESTS_DIR)) else {
232 return Vec::new();
233 };
234 let mut taken = Vec::new();
235 for entry in entries.flatten() {
236 let name = entry.file_name();
237 let Some(name) = name.to_str() else { continue };
238 let Some(rest) = name.strip_prefix(prefix).and_then(|r| r.strip_prefix('.'))
239 else {
240 continue;
241 };
242 if let Some(id) = rest.strip_suffix(".request") {
243 if fs::remove_file(entry.path()).is_ok() && valid_id(id) {
246 taken.push(id.to_owned());
247 }
248 } else if rest.ends_with(".result")
249 && entry
250 .metadata()
251 .and_then(|meta| meta.modified())
252 .ok()
253 .and_then(|at| at.elapsed().ok())
254 .is_some_and(|age| age >= UNCOLLECTED)
255 {
256 let _ = fs::remove_file(entry.path());
257 }
258 }
259 taken
260 }
261 }
262 }
263
264 pub fn answer<T: Serialize>(
269 &self,
270 state_dir: &Path,
271 request: &str,
272 outcome: Result<T, String>,
273 ) -> io::Result<()> {
274 let answer = Answer {
275 request: request.to_owned(),
276 outcome,
277 };
278 let json = serde_json::to_vec_pretty(&answer).map_err(io::Error::other)?;
279 let path = self.result_file(state_dir, request);
280 write_readable_by_the_asker(&path, &json)
281 }
282
283 #[must_use]
285 pub fn read_answer<T: DeserializeOwned>(
286 &self,
287 state_dir: &Path,
288 request: &str,
289 ) -> Option<Result<T, String>> {
290 fs::read(self.result_file(state_dir, request))
291 .ok()
292 .and_then(|bytes| serde_json::from_slice::<Answer<T>>(&bytes).ok())
293 .filter(|answer| answer.request == request)
294 .map(|answer| answer.outcome)
295 }
296
297 pub fn ask<T: DeserializeOwned>(&self, state_dir: &Path, wait: Wait) -> Result<T, AskError> {
302 self.ask_with(state_dir, wait, || {})
303 }
304
305 pub fn ask_with<T: DeserializeOwned>(
311 &self,
312 state_dir: &Path,
313 wait: Wait,
314 sent: impl FnOnce(),
315 ) -> Result<T, AskError> {
316 static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
319 let id = format!(
320 "{}-{}-{}",
321 chrono::Utc::now().format("%Y%m%dT%H%M%S%.9fZ"),
322 std::process::id(),
323 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
324 );
325 let request = self.request_file(state_dir, &id);
326 crate::host_fitness::write_atomically(&request, id.as_bytes())
327 .map_err(|error| AskError::NotSent(format!("{}: {error}", request.display())))?;
328 sent();
329
330 let started = Instant::now();
331 let pending = |request: &Path| match self.slots {
332 Slots::One { .. } => read_request(request).as_deref() == Some(id.as_str()),
333 Slots::PerRequest(_) => fs::symlink_metadata(request).is_ok(),
334 };
335 while pending(&request) {
336 if started.elapsed() >= wait.taken {
337 match fs::remove_file(&request) {
342 Err(error) if error.kind() == io::ErrorKind::NotFound => break,
343 _ => return Err(AskError::NotTaken(wait.taken.as_secs())),
344 }
345 }
346 std::thread::sleep(wait.poll);
347 }
348
349 let taken = Instant::now();
350 loop {
351 if let Some(outcome) = self.read_answer(state_dir, &id) {
352 if matches!(self.slots, Slots::PerRequest(_)) {
353 let _ = fs::remove_file(self.result_file(state_dir, &id));
354 }
355 return outcome.map_err(AskError::Refused);
356 }
357 if taken.elapsed() >= wait.finished {
358 return Err(AskError::NoAnswer(wait.finished.as_secs()));
359 }
360 std::thread::sleep(wait.poll);
361 }
362 }
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368
369 const QUICK: Wait = Wait {
370 taken: Duration::from_millis(600),
371 finished: Duration::from_secs(5),
372 poll: Duration::from_millis(20),
373 };
374
375 fn service(
377 state_dir: PathBuf,
378 outcome: Result<u32, String>,
379 ) -> std::thread::JoinHandle<String> {
380 std::thread::spawn(move || {
381 loop {
382 if let Some(request) = GITHUB_DISCOVERY.take(&state_dir).pop() {
383 GITHUB_DISCOVERY
384 .answer(&state_dir, &request, outcome)
385 .unwrap();
386 return request;
387 }
388 std::thread::sleep(Duration::from_millis(10));
389 }
390 })
391 }
392
393 #[test]
394 fn a_running_service_takes_the_request_once_and_answers_it() {
395 let dir = tempfile::tempdir().unwrap();
396 let answering = service(dir.path().to_path_buf(), Ok(7));
397 assert_eq!(GITHUB_DISCOVERY.ask::<u32>(dir.path(), QUICK), Ok(7));
398 answering.join().unwrap();
399 assert!(
400 GITHUB_DISCOVERY.take(dir.path()).is_empty(),
401 "answered once"
402 );
403 }
404
405 #[test]
406 fn a_service_that_cannot_answer_says_why() {
407 let dir = tempfile::tempdir().unwrap();
408 let answering = service(dir.path().to_path_buf(), Err("no credential".into()));
409 assert_eq!(
410 GITHUB_DISCOVERY.ask::<u32>(dir.path(), QUICK),
411 Err(AskError::Refused("no credential".into()))
412 );
413 answering.join().unwrap();
414 }
415
416 #[test]
417 fn with_no_service_listening_the_request_is_withdrawn() {
418 let dir = tempfile::tempdir().unwrap();
419 assert!(matches!(
420 GITHUB_DISCOVERY.ask::<u32>(dir.path(), QUICK),
421 Err(AskError::NotTaken(_))
422 ));
423 assert!(
424 GITHUB_DISCOVERY.take(dir.path()).is_empty(),
425 "a later service must not answer a command that stopped waiting"
426 );
427 }
428
429 #[test]
430 fn an_answer_to_another_request_is_not_taken_for_this_one() {
431 let dir = tempfile::tempdir().unwrap();
432 GITHUB_DISCOVERY
433 .answer(dir.path(), "an-earlier-request", Ok(1_u32))
434 .unwrap();
435 let state_dir = dir.path().to_path_buf();
436 let silent = std::thread::spawn(move || {
438 while GITHUB_DISCOVERY.take(&state_dir).is_empty() {
439 std::thread::sleep(Duration::from_millis(10));
440 }
441 });
442 let wait = Wait {
443 finished: Duration::from_millis(300),
444 ..QUICK
445 };
446 assert!(matches!(
447 GITHUB_DISCOVERY.ask::<u32>(dir.path(), wait),
448 Err(AskError::NoAnswer(_))
449 ));
450 silent.join().unwrap();
451 }
452
453 #[test]
456 fn commands_asking_at_once_each_get_their_own_answer() {
457 let dir = tempfile::tempdir().unwrap();
458 let state_dir = dir.path().to_path_buf();
459 let service = std::thread::spawn(move || {
460 let mut answered = 0;
461 let deadline = Instant::now() + Duration::from_secs(10);
462 while answered < 2 && Instant::now() < deadline {
463 for request in GITHUB_DISCOVERY.take(&state_dir) {
464 GITHUB_DISCOVERY
465 .answer(&state_dir, &request, Ok(request.len()))
466 .unwrap();
467 answered += 1;
468 }
469 std::thread::sleep(Duration::from_millis(10));
470 }
471 answered
472 });
473 let asking =
474 |dir: PathBuf| std::thread::spawn(move || GITHUB_DISCOVERY.ask::<usize>(&dir, QUICK));
475 let first = asking(dir.path().to_path_buf());
476 let second = asking(dir.path().to_path_buf());
477 assert!(first.join().unwrap().is_ok());
478 assert!(second.join().unwrap().is_ok());
479 assert_eq!(service.join().unwrap(), 2);
480 }
481
482 #[cfg(unix)]
485 #[test]
486 fn a_request_that_is_a_link_or_not_an_identifier_is_dropped_unread() {
487 let dir = tempfile::tempdir().unwrap();
488 let secret = dir.path().join("only-the-service-may-read");
489 fs::write(&secret, "the contents of somebody else's file").unwrap();
490 let request = CACHE_PRUNE.request_path(dir.path());
491 std::os::unix::fs::symlink(&secret, &request).unwrap();
492 assert!(CACHE_PRUNE.take(dir.path()).is_empty());
493 assert!(
494 fs::symlink_metadata(&request).is_err(),
495 "the link is removed"
496 );
497 assert!(secret.exists(), "and only the link");
498
499 fs::write(&request, "not an identifier\nat all").unwrap();
500 assert!(CACHE_PRUNE.take(dir.path()).is_empty());
501 fs::write(&request, "20261007T221210.123456789Z-4242\n").unwrap();
502 assert_eq!(
503 CACHE_PRUNE.take(dir.path()),
504 ["20261007T221210.123456789Z-4242"]
505 );
506 }
507}