OXYGENHUB

Coverage / crates/flow/src/runner.rs 45.1%

865 of 1920 lines run, 100 of 445 functions · run #222 on main · run missed

1
//! `flow runner` (docs/m0.md §5): claim a job from OxygenHub, fetch the
2
//! repository into a cache with the job's token, write the state into a
3
//! workspace, run it in its tier, stream the log, report the receipt.
4
//! One job at a time.
5
6
use crate::home::{Home, RunnerConfig, RunnerKey};
7
use crate::{microvm, workspace};
8
use anyhow::{bail, Context, Result};
9
use flow_core::api::{Claim, ClaimRequest, Decline, Finish, LogBatch, Record, Register, Registered, RunnerInfo, MAX_RECORD_TEXT};
10
use flow_core::{scope_tree_hash, Receipt};
11
use oxy_core::hash::ChangeHash;
12
use oxy_core::repo::Repo;
13
use std::path::{Path, PathBuf};
14
use std::time::{Duration, Instant};
15
16
/// A request the hub refused (4xx). On a job's endpoints, 409 means the
17
/// job no longer runs: it was cancelled, or given up.
18
#[derive(Debug)]
19
pub struct Refused {
20
    pub code: u16,
21
    pub text: String,
22
}
23
24
impl std::fmt::Display for Refused {
250×
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
260×
        write!(f, "HTTP {}: {}", self.code, self.text)
270×
    }
28
}
29
30
impl std::error::Error for Refused {}
31
32
/// Whether `e` is the hub saying the job no longer runs.
333×
fn job_gone(e: &anyhow::Error) -> bool {
343×
    e.downcast_ref::<Refused>().is_some_and(|r| r.code == 409)
353×
}
36
37
/// What a job ends with when its run was cancelled: nothing is reported,
38
/// as the hub has ended it already.
39
const CANCELLED: &str = "the run was cancelled";
40
410×
fn cancelled() -> Finish {
420×
    Finish {
430×
        exit_code: None,
440×
        duration_ms: 0,
450×
        receipt: None,
460×
        signed_receipt: None,
470×
        error: Some(CANCELLED.into()),
480×
        peak_disk_mib: None,
490×
        peak_memory_mib: None,
500×
        phases: Default::default(),
510×
    }
520×
}
53
54
pub struct Hub {
55
    base: String,
56
    token: String,
57
    agent: ureq::Agent,
58
}
59
60
impl Hub {
610×
    pub fn new(cfg: &RunnerConfig) -> Result<Hub> {
620×
        let tls = oxy_core::protocol::tls_client_config(cfg.ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?;
630×
        let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(60)).build();
640×
        Ok(Hub { base: cfg.hub.trim_end_matches('/').to_string(), token: cfg.token.clone(), agent })
650×
    }
66
67
    /// POST to the hub. A server error (5xx) means nothing was committed,
68
    /// so it is tried again after 1, 2 and 4 seconds before giving up: a
69
    /// busy hub costs a job a few seconds, not its log lines or its claim.
708×
    fn post(&self, path: &str, body: &impl serde::Serialize) -> Result<Option<ureq::Response>> {
718×
        self.post_with(path, body, &RETRY_DELAYS)
728×
    }
73
7411×
    fn post_with(&self, path: &str, body: &impl serde::Serialize, delays: &[Duration]) -> Result<Option<ureq::Response>> {
7511×
        let url = format!("{}/api/v1{path}", self.base);
7616×
        let r = retrying(&url, delays, false, || {
7716×
            self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(body).map_err(Box::new)
7816×
        })?;
796×
        Ok(if r.status() == 204 { None } else { Some(r) })
8011×
    }
81
82
    /// POST what a job leaves behind (oxygenflow#49): through a hub that is
83
    /// away for a few minutes, as while a deploy restarts it, so a job's
84
    /// declines and uploads outlast it.
851×
    fn post_patiently(&self, path: &str, body: &impl serde::Serialize) -> Result<Option<ureq::Response>> {
861×
        let url = format!("{}/api/v1{path}", self.base);
871×
        let r = retrying(&url, &PATIENT_DELAYS, true, || {
881×
            self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(body).map_err(Box::new)
891×
        })?;
901×
        Ok(if r.status() == 204 { None } else { Some(r) })
911×
    }
92
930×
    pub fn claim(&self, req: &ClaimRequest) -> Result<Option<Claim>> {
940×
        match self.post("/flow/claim", req)? {
950×
            Some(r) => Ok(Some(r.into_json()?)),
960×
            None => Ok(None),
97
        }
980×
    }
99
100
    /// Hand job `job` back, saying why this runner will not run it.
1010×
    pub fn decline(&self, job: i64, reason: &str) -> Result<()> {
1020×
        self.post_patiently(&format!("/flow/jobs/{job}/decline"), &Decline { reason: reason.into() })?;
1030×
        Ok(())
1040×
    }
105
106
    /// Hand back a job this runner holds as it leaves (oxygenflow#76): the
107
    /// hub queues it again ahead of new work for another runner.
1081×
    pub fn release(&self, job: i64, reason: &str) -> Result<()> {
1091×
        self.post_patiently(&format!("/flow/jobs/{job}/release"), &Decline { reason: reason.into() })?;
1101×
        Ok(())
1111×
    }
112
113
    /// Say this runner is there while it claims nothing: its slots are full,
114
    /// or it is draining (oxygenhub#243). Once, no retries: the next comes
115
    /// soon. A hub without the endpoint (404) is not an error.
116
    /// A ten-minute certificate for `identity`, asked with a job's token
117
    /// (keyless, oxygenhub#241), and the identity of the hub's key that
118
    /// issued it, from `/.well-known/oxyhub-key`.
1193×
    pub fn certify(
1203×
        &self,
1213×
        job_token: &str,
1223×
        identity: &oxy_core::identity::Identity,
1233×
        proof: &oxy_core::identity::Signature,
1243×
    ) -> Result<(oxy_core::cert::SignedCertificate, oxy_core::identity::Identity)> {
125
        use oxy_core::dsse::{base64, base64_decode};
1263×
        let body = serde_json::json!({
1273×
            "identity": base64(&identity.encode()),
1283×
            "proof": base64(&oxy_core::cbor::encode(&proof.to_value())),
1293×
            "scope": "seal",
130
        });
1313×
        let url = format!("{}/api/v1/keyless/certify", self.base);
1323×
        let r = self
1333×
            .agent
1343×
            .post(&url)
1353×
            .set("Authorization", &format!("Bearer {job_token}"))
1363×
            .send_json(body)
1373×
            .map_err(|e| anyhow::anyhow!("asking the hub to certify the key: {e}"))?;
1383×
        let v: serde_json::Value = r.into_json()?;
1393×
        let cert = base64_decode(v["certificate"].as_str().unwrap_or(""))
1403×
            .context("the hub's answer has no certificate")
1413×
            .and_then(|b| oxy_core::cert::SignedCertificate::decode(&b).map_err(|e| anyhow::anyhow!("{e}")))?;
1423×
        let mut bytes = Vec::new();
1433×
        std::io::Read::read_to_end(
1443×
            &mut self.agent.get(&format!("{}/.well-known/oxyhub-key", self.base)).call()?.into_reader(),
1453×
            &mut bytes,
1460×
        )?;
1473×
        let issuer = oxy_core::identity::Identity::decode(&bytes)?;
1483×
        if issuer.hash() != cert.cert.issuer || cert.verify(&issuer).is_err() {
1491×
            bail!("the certificate is not signed by the hub's published key");
1502×
        }
1512×
        if cert.cert.key_id() != identity.hash() {
1521×
            bail!("the hub certified another key");
1531×
        }
1541×
        Ok((cert, issuer))
1553×
    }
156
1570×
    pub fn heartbeat(&self) -> Result<()> {
1580×
        match self.post_with("/flow/runners/heartbeat", &serde_json::json!({}), &[]) {
1590×
            Ok(_) => Ok(()),
1600×
            Err(e) if format!("{e:#}").contains("404") => Ok(()),
1610×
            Err(e) => Err(e),
162
        }
1630×
    }
164
165
    /// The update policy for this runner and the build it should run
166
    /// (oxygenhub#322); `None` from a hub without runner updates.
1670×
    pub fn update_answer(&self) -> Result<Option<flow_core::api::UpdateAnswer>> {
1680×
        let url = format!("{}/api/v1/flow/runner/update", self.base);
1690×
        match self.agent.get(&url).set("Authorization", &format!("Bearer {}", self.token)).call() {
1700×
            Ok(r) => Ok(Some(r.into_json()?)),
1710×
            Err(ureq::Error::Status(404, _)) => Ok(None),
1720×
            Err(e) => Err(anyhow::anyhow!("{url}: {e}")),
173
        }
1740×
    }
175
176
    /// Tell the hub what this runner did about its update.
1770×
    pub fn report_update(&self, note: &str) -> Result<()> {
1780×
        self.post_with("/flow/runner/update", &flow_core::api::UpdateReport { note: note.to_string() }, &[])?;
1790×
        Ok(())
1800×
    }
181
182
    /// Download an offered build to `to`, refusing it unless its BLAKE3
183
    /// is the one the hub attests and it is the size it said.
1842×
    pub fn download_build(&self, offer: &flow_core::api::UpdateOffer, to: &Path) -> Result<()> {
185
        use std::io::{Read, Write};
1862×
        let url = format!("{}{}", self.base, offer.url);
1872×
        let r = self.agent.get(&url).timeout(Duration::from_secs(1800)).call().map_err(|e| anyhow::anyhow!("{url}: {e}"))?;
1882×
        let part = to.with_extension("partial");
1892×
        let mut out = std::fs::File::create(&part).with_context(|| format!("writing {}", part.display()))?;
1902×
        let (mut hasher, mut n, mut buf) = (blake3::Hasher::new(), 0u64, vec![0u8; 1 << 16]);
1912×
        let mut body = r.into_reader().take(offer.size + 1);
192
        loop {
1934×
            let k = body.read(&mut buf)?;
1944×
            if k == 0 {
1952×
                break;
1962×
            }
1972×
            hasher.update(&buf[..k]);
1982×
            out.write_all(&buf[..k])?;
1992×
            n += k as u64;
200
        }
2012×
        drop(out);
2022×
        let got = hasher.finalize().to_hex().to_string();
2032×
        if n != offer.size || got != offer.blake3 {
2041×
            let _ = std::fs::remove_file(&part);
2051×
            bail!("{} does not match what the hub attests ({n} bytes, BLAKE3 {}…)", offer.asset, &got[..12]);
2061×
        }
2071×
        std::fs::rename(&part, to)?;
2081×
        Ok(())
2092×
    }
210
2118×
    pub fn log(&self, job: i64, records: Vec<Record>) -> Result<()> {
2128×
        self.post(&format!("/flow/jobs/{job}/log"), &LogBatch { records })?;
2135×
        Ok(())
2148×
    }
215
216
    /// Upload artifact `name` of job `job` (spec §5.2); the hub keeps it as
217
    /// a blob of the job's repository and answers with its ObjectRef.
2180×
    pub fn artifact(&self, job: i64, name: &str, bytes: &[u8]) -> Result<String> {
2190×
        let url = format!("{}/api/v1/flow/jobs/{job}/artifacts/{name}", self.base);
2200×
        let r = retrying(&url, &PATIENT_DELAYS, true, || {
2210×
            self.agent
2220×
                .post(&url)
2230×
                .timeout(Duration::from_secs(600))
2240×
                .set("Authorization", &format!("Bearer {}", self.token))
2250×
                .set("Content-Type", "application/octet-stream")
2260×
                .send_bytes(bytes)
2270×
                .map_err(Box::new)
2280×
        })?;
2290×
        let v: serde_json::Value = r.into_json()?;
2300×
        v["blob"].as_str().map(String::from).context("the hub named no blob")
2310×
    }
232
233
    /// Keep a checkpoint of job `job` (oxygenflow#77) as its runner hands
234
    /// it back: the hub answers with its blob, which the next attempt's
235
    /// claim names.
2360×
    pub fn save_checkpoint(&self, job: i64, bytes: &[u8]) -> Result<String> {
2370×
        let url = format!("{}/api/v1/flow/jobs/{job}/checkpoint", self.base);
2380×
        let r = retrying(&url, &PATIENT_DELAYS, true, || {
2390×
            self.agent
2400×
                .post(&url)
2410×
                .timeout(Duration::from_secs(600))
2420×
                .set("Authorization", &format!("Bearer {}", self.token))
2430×
                .set("Content-Type", "application/octet-stream")
2440×
                .send_bytes(bytes)
2450×
                .map_err(Box::new)
2460×
        })?;
2470×
        let v: serde_json::Value = r.into_json()?;
2480×
        v["blob"].as_str().map(String::from).context("the hub named no blob")
2490×
    }
250
251
    /// The checkpoint an earlier attempt of job `job` saved (oxygenflow#77),
252
    /// which the caller checks against the blob its claim names.
2530×
    pub fn checkpoint(&self, job: i64) -> Result<Vec<u8>> {
2540×
        let url = format!("{}/api/v1/flow/jobs/{job}/checkpoint", self.base);
2550×
        let r = retrying(&url, &PATIENT_DELAYS, false, || {
2560×
            self.agent
2570×
                .get(&url)
2580×
                .timeout(Duration::from_secs(600))
2590×
                .set("Authorization", &format!("Bearer {}", self.token))
2600×
                .call()
2610×
                .map_err(Box::new)
2620×
        })?;
2630×
        let mut bytes = Vec::new();
2640×
        std::io::Read::read_to_end(&mut std::io::Read::take(r.into_reader(), crate::checkpoint::MAX_BYTES as u64 + 1), &mut bytes)?;
2650×
        Ok(bytes)
2660×
    }
267
268
    /// Input `index` of job `job` (oxygenflow#58): the bytes of the
269
    /// producing job's artifact, which the caller checks against the blob the
270
    /// claim names.
2710×
    pub fn input(&self, job: i64, index: usize) -> Result<Vec<u8>> {
2720×
        let url = format!("{}/api/v1/flow/jobs/{job}/inputs/{index}", self.base);
2730×
        let r = retrying(&url, &PATIENT_DELAYS, false, || {
2740×
            self.agent
2750×
                .get(&url)
2760×
                .timeout(Duration::from_secs(600))
2770×
                .set("Authorization", &format!("Bearer {}", self.token))
2780×
                .call()
2790×
                .map_err(Box::new)
2800×
        })?;
2810×
        let mut bytes = Vec::new();
2820×
        let mut reader = r.into_reader();
2830×
        std::io::Read::read_to_end(&mut std::io::Read::take(&mut reader, MAX_INPUT_BYTES as u64 + 1), &mut bytes)?;
2840×
        if bytes.len() > MAX_INPUT_BYTES {
2850×
            bail!("larger than {} MiB", MAX_INPUT_BYTES >> 20);
2860×
        }
2870×
        Ok(bytes)
2880×
    }
289
290
    /// Upload file `path` of report `name` of job `job` (oxygenhub#127);
291
    /// the hub keeps it as a blob and answers with its ObjectRef. The
292
    /// report's manifest follows through [`Hub::artifact`].
2930×
    pub fn report_file(&self, job: i64, name: &str, path: &str, bytes: &[u8]) -> Result<String> {
2940×
        let url = format!("{}/api/v1/flow/jobs/{job}/reports/{name}/files", self.base);
2950×
        let r = retrying(&url, &PATIENT_DELAYS, true, || {
2960×
            self.agent
2970×
                .post(&url)
2980×
                .timeout(Duration::from_secs(600))
2990×
                .set("Authorization", &format!("Bearer {}", self.token))
3000×
                .set("Content-Type", "application/octet-stream")
3010×
                .set("X-Flow-Report-Path", path)
3020×
                .send_bytes(bytes)
3030×
                .map_err(Box::new)
3040×
        })?;
3050×
        let v: serde_json::Value = r.into_json()?;
3060×
        v["blob"].as_str().map(String::from).context("the hub named no blob")
3070×
    }
308
309
    /// Report a job's end until the hub has it (oxygenhub#71): while the hub
310
    /// cannot be reached or fails (5xx), try again after `first_wait`,
311
    /// doubling to a minute, for up to `patience`. A refusal (4xx: the job was ended
312
    /// already, say) is final. A result lost here leaves the job running
313
    /// on the hub until its time limit.
3142×
    pub fn finish_until_accepted(&self, job: i64, f: &Finish, patience: Duration, first_wait: Duration) -> Result<()> {
3152×
        let url = format!("{}/api/v1/flow/jobs/{job}/finish", self.base);
3162×
        let deadline = std::time::Instant::now() + patience;
3172×
        let mut wait = first_wait;
318
        loop {
3196×
            let why = match self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(f) {
3201×
                Ok(_) => return Ok(()),
3215×
                Err(ureq::Error::Status(code, r)) if code < 500 => {
3221×
                    bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default())
323
                }
3244×
                Err(ureq::Error::Status(code, _)) => format!("HTTP {code}"),
3250×
                Err(e) => e.to_string(),
326
            };
3274×
            if std::time::Instant::now() + wait > deadline {
3280×
                bail!("{url}: gave up after {} minutes: {why}", patience.as_secs() / 60);
3294×
            }
3304×
            eprintln!("job {job}: the hub did not take the result ({why}); trying again in {} s", wait.as_secs());
3314×
            std::thread::sleep(wait);
3324×
            wait = (wait * 2).min(Duration::from_secs(60));
333
        }
3342×
    }
335
}
336
337
/// How long a runner keeps trying to report a job's end.
338
const FINISH_PATIENCE: Duration = Duration::from_secs(2 * 3600);
339
340
/// Where a job's result waits until the hub has it, so a runner that
341
/// restarts still reports it.
3420×
fn pending_path(home: &Home, job: i64) -> PathBuf {
3430×
    home.root.join("pending").join(format!("{job}.json"))
3440×
}
345
346
/// Report `f`, keeping it on disk until the hub has it.
3470×
fn report_finish(home: &Home, hub: &Hub, job: i64, f: &Finish) {
3480×
    let path = pending_path(home, job);
3490×
    let kept = std::fs::create_dir_all(path.parent().unwrap())
3500×
        .and_then(|_| std::fs::write(&path, serde_json::to_vec(f).unwrap_or_default()))
3510×
        .is_ok();
3520×
    match hub.finish_until_accepted(job, f, FINISH_PATIENCE, Duration::from_secs(5)) {
3530×
        Ok(()) => {}
3540×
        Err(e) => eprintln!("job {job}: reporting failed: {e:#}"),
355
    }
3560×
    if kept {
3570×
        let _ = std::fs::remove_file(&path);
3580×
    }
3590×
}
360
361
/// Results a previous run of the runner could not report, to send again.
3620×
fn report_pending(home: &Home) -> Vec<(i64, Finish)> {
3630×
    let Ok(dir) = std::fs::read_dir(home.root.join("pending")) else {
3640×
        return Vec::new();
365
    };
3660×
    dir.filter_map(|e| {
3670×
        let p = e.ok()?.path();
3680×
        let job: i64 = p.file_stem()?.to_str()?.parse().ok()?;
3690×
        let f: Finish = serde_json::from_slice(&std::fs::read(&p).ok()?).ok()?;
3700×
        Some((job, f))
3710×
    })
3720×
    .collect()
3730×
}
374
375
/// `flow runner register`: exchange a one-time registration token from
376
/// the hub for this runner's credential, binding the runner's signing key,
377
/// and keep hub, credential and name in runner.toml.
3781×
pub fn register(home: &Home, hub: &str, token: &str, name: &str, ca: Option<PathBuf>, labels: Vec<String>) -> Result<Registered> {
3791×
    if home.config.runner.is_some() && !home.root.join(crate::home::RUNNER_TOML).is_file() {
3800×
        bail!("flow.toml already has a [runner] section; remove it to register this runner instead");
3811×
    }
3821×
    let key = home.runner_key(name)?;
3831×
    let hub = hub.trim_end_matches('/').to_string();
3841×
    let tls = oxy_core::protocol::tls_client_config(ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?;
3851×
    let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(60)).build();
3861×
    let req = Register {
3871×
        token: token.trim().to_string(),
3881×
        name: name.to_string(),
3891×
        identity: hex::encode(key.identity.encode()),
3901×
        info: RunnerInfo { version: env!("OXY_VERSION_LONG").into(), ..RunnerInfo::here(labels.clone(), 1, None, None) },
3911×
    };
3921×
    let url = format!("{hub}/api/v1/flow/runners/register");
3931×
    let got: Registered = match agent.post(&url).send_json(&req) {
3941×
        Ok(r) => r.into_json()?,
3950×
        Err(ureq::Error::Status(code, r)) => bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default()),
3960×
        Err(e) => bail!("{url}: {e}"),
397
    };
3981×
    let mut text = format!(
399
        "# Written by `flow runner register`; the credential is secret.\nhub = {hub:?}\ntoken = {:?}\nname = {:?}\n",
400
        got.credential, got.name
401
    );
4021×
    if let Some(ca) = &ca {
4030×
        let kept = keep_ca(&home.root, ca)?;
4040×
        text.push_str(&format!("ca = {:?}\n", kept.display().to_string()));
4051×
    }
4061×
    if !labels.is_empty() {
4071×
        text.push_str(&format!("labels = {labels:?}\n"));
4081×
    }
4091×
    let path = home.root.join(crate::home::RUNNER_TOML);
410
    {
411
        use std::io::Write;
4121×
        let tmp = path.with_extension("toml.tmp");
4131×
        let mut f = crate::portable::private(std::fs::OpenOptions::new().write(true).create(true).truncate(true)).open(&tmp)?;
4141×
        f.write_all(text.as_bytes())?;
4151×
        f.sync_all()?;
4161×
        std::fs::rename(&tmp, &path)?;
417
    }
4181×
    Ok(got)
4191×
}
420
421
/// Copy the hub's CA certificate `ca` into the Flow home `root` (#98) and
422
/// give the copy's absolute path, for runner.toml: a service starts in
423
/// another directory, and the file given may move or go.
4242×
fn keep_ca(root: &Path, ca: &Path) -> Result<PathBuf> {
4252×
    let kept = root.join(crate::home::CA_PEM);
4262×
    let same = matches!((std::fs::canonicalize(ca), std::fs::canonicalize(&kept)), (Ok(a), Ok(b)) if a == b);
4272×
    if !same {
4281×
        std::fs::copy(ca, &kept).with_context(|| format!("copy {} into {}", ca.display(), root.display()))?;
4291×
    }
4302×
    Ok(std::fs::canonicalize(&kept)?)
4312×
}
432
433
/// Waits before trying a hub request again after a server error.
434
const RETRY_DELAYS: [Duration; 3] = [Duration::from_secs(1), Duration::from_secs(2), Duration::from_secs(4)];
435
436
/// Waits for what a job leaves behind (oxygenflow#49): three minutes in
437
/// all, longer than a hub takes to restart.
438
/// Check an input's bytes against the blob the producing job's receipt
439
/// names, then write them at its path under `tree` (the workspace's `/src`).
4406×
fn place_input(tree: &Path, i: &flow_core::api::ArtifactInput, bytes: &[u8]) -> Result<[u8; 32]> {
4416×
    let Some(rel) = i.path.strip_prefix("/src/").filter(|r| r.split('/').all(|p| !p.is_empty() && p != "." && p != ".." && p != ".oxy"))
442
    else {
4434×
        bail!("path {:?} is not a clean path under /src", i.path);
444
    };
4452×
    let blob = Repo::blob_ref(bytes);
4462×
    if blob.to_hex() != i.blob {
4471×
        bail!("it is not what {}'s receipt names (blob {}, not {})", i.from, &blob.to_hex()[..12], &i.blob[..i.blob.len().min(12)]);
4481×
    }
4491×
    let dest = tree.join(rel);
4501×
    if let Some(dir) = dest.parent() {
4511×
        std::fs::create_dir_all(dir)?;
4520×
    }
4531×
    std::fs::write(&dest, bytes)?;
4541×
    Ok(blob.0)
4556×
}
456
457
/// The largest input a job takes: the hub's artifact limit.
458
pub const MAX_INPUT_BYTES: usize = 512 << 20;
459
460
const PATIENT_DELAYS: [Duration; 15] = {
461
    let mut d = [Duration::from_secs(15); 15];
462
    d[0] = Duration::from_secs(1);
463
    d[1] = Duration::from_secs(2);
464
    d[2] = Duration::from_secs(4);
465
    d[3] = Duration::from_secs(8);
466
    d
467
};
468
469
/// Send a request until the hub answers, waiting `delays` in turn after a
470
/// server error (5xx: nothing was committed) and, when `connection`, after
471
/// the hub could not be reached at all. A refusal (4xx) is final, as a
472
/// [`Refused`] the caller can look at.
47314×
fn retrying(
47414×
    url: &str,
47514×
    delays: &[Duration],
47614×
    connection: bool,
47714×
    mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
47814×
) -> Result<ureq::Response> {
47914×
    let mut delays = delays.iter();
480
    loop {
48123×
        let why = match send().map_err(|e| *e) {
4828×
            Ok(r) => return Ok(r),
4839×
            Err(ureq::Error::Status(code, r)) if code < 500 => {
4842×
                let text = r.into_string().unwrap_or_default();
4852×
                return Err(anyhow::Error::new(Refused { code, text }).context(url.to_string()));
486
            }
4877×
            Err(ureq::Error::Status(code, r)) => format!("HTTP {code}: {}", r.into_string().unwrap_or_default()),
4883×
            Err(e) if connection => e.to_string(),
4893×
            Err(e) => bail!("{url}: {e}"),
490
        };
49110×
        match delays.next() {
4929×
            Some(d) => std::thread::sleep(*d),
4931×
            None => bail!("{url}: {why}"),
494
        }
495
    }
49614×
}
497
498
/// What the runner may run at once (flow.toml `[runner]`): at most
499
/// `max_jobs` jobs, whose sandboxes together fit `memory_mib` and `cpus`.
500
#[derive(Debug, Default, PartialEq, Eq)]
501
pub struct Budget {
502
    pub max_jobs: usize,
503
    pub memory_mib: Option<u32>,
504
    pub cpus: Option<u32>,
505
}
506
507
/// What is in use now.
508
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
509
pub struct Used {
510
    pub jobs: usize,
511
    pub memory_mib: u32,
512
    pub cpus: u32,
513
}
514
515
impl Budget {
5160×
    pub fn of(cfg: &RunnerConfig) -> Budget {
5170×
        Budget { max_jobs: cfg.max_jobs.unwrap_or(1).max(1), memory_mib: cfg.memory_mib, cpus: cfg.cpus }
5180×
    }
519
520
    /// The room to ask the hub for, or `None` when no job more can start.
521
    /// A lone job may use the whole budget even if it asks for more, so a
522
    /// large sandbox is never starved.
5237×
    pub fn room(&self, used: Used) -> Option<(Option<u32>, Option<u32>)> {
5247×
        if used.jobs >= self.max_jobs {
5252×
            return None;
5265×
        }
5275×
        if used.jobs == 0 {
5282×
            return Some((None, None));
5293×
        }
5303×
        let mem = self.memory_mib.map(|m| m.saturating_sub(used.memory_mib));
5313×
        let cpus = self.cpus.map(|c| c.saturating_sub(used.cpus));
5323×
        if mem == Some(0) || cpus == Some(0) {
5331×
            return None;
5342×
        }
5352×
        Some((mem, cpus))
5367×
    }
537
}
538
539
/// The isolation tiers this machine can honour now (spec §11.1): the
540
/// MicroVM tier when `/dev/kvm` opens for reading and writing and
541
/// flow.toml pins Firecracker; the syscall tier when this binary may use
542
/// user namespaces, Landlock and seccomp and manage a delegated cgroup.
543
/// `Err` says why there are none; the reasons for missing tiers come back
544
/// with the ones there are.
5453×
pub fn tiers(home: &Home) -> std::result::Result<(Vec<String>, Vec<String>), String> {
5463×
    let mut have = Vec::new();
5473×
    let mut missing = Vec::new();
5483×
    let kvm = std::fs::OpenOptions::new().read(true).write(true).open("/dev/kvm");
5493×
    match (kvm, home.config.microvm.is_some()) {
5503×
        _ if !cfg!(target_os = "linux") => missing.push("microvm: the tier runs only on Linux (Firecracker and KVM)".to_string()),
5510×
        (Ok(_), true) => have.push("microvm".to_string()),
5520×
        (Ok(_), false) => missing.push("microvm: flow.toml has no [microvm] section".to_string()),
5533×
        (Err(e), _) => missing.push(format!(
554
            "microvm: /dev/kvm cannot be opened ({e}); the tier needs hardware virtualisation and this user in the kvm group"
555
        )),
556
    }
5573×
    match crate::syscall::available() {
5580×
        Ok(()) => have.push("syscall".to_string()),
5593×
        Err(e) => missing.push(format!("syscall: {e}")),
560
    }
561
    // wasmtime runs on every machine a build with it does (oxygenflow#75).
5623×
    if crate::wasm::AVAILABLE {
5633×
        have.push("wasm".to_string());
5643×
    } else {
5650×
        missing.push("wasm: this build has no WASM tier".to_string());
5660×
    }
5673×
    if have.is_empty() {
5680×
        return Err(format!("no isolation tier can run here: {}", missing.join("; ")));
5693×
    }
5703×
    Ok((have, missing))
5713×
}
572
573
/// Fetch `claim`'s image from the hub when this home lacks it
574
/// (oxygenflow#39), saying so in the job's log. A hub that does not hold it
575
/// leaves it missing, and the preflight declines the job as before.
5760×
fn fetch_image(home: &Home, hub: &Hub, cfg: &RunnerConfig, swarm: Option<&crate::swarm::Swarm>, claim: &Claim) -> Option<u64> {
5770×
    let hash = &claim.sandbox.rootfs;
5780×
    if hash.is_empty() || home.rootfs(hash).is_file() || !jet::images::is_hash(hash) {
5790×
        return None;
5800×
    }
5810×
    let note = |text: String| {
5820×
        eprintln!("job {}: {text}", claim.job_id);
5830×
        hub.log(claim.job_id, vec![Record { t: 0, stream: "flow".into(), text }]).ok();
5840×
    };
585
    // One fetch of an image at a time: two jobs wanting it wait for one.
586
    static FETCHING: std::sync::Mutex<()> = std::sync::Mutex::new(());
5870×
    let _one = FETCHING.lock().unwrap_or_else(|p| p.into_inner());
5880×
    if home.rootfs(hash).is_file() {
5890×
        return None;
5900×
    }
5910×
    let client = match crate::images::Client::new(&cfg.hub, &cfg.token, cfg.ca.as_deref()) {
5920×
        Ok(c) => c,
5930×
        Err(e) => {
5940×
            note(format!("image {}: cannot reach the hub: {e:#}", &hash[..12]));
5950×
            return None;
596
        }
597
    };
5980×
    let fetching = Instant::now();
5990×
    let fetched = match swarm {
6000×
        Some(sw) => {
6010×
            note(format!("image {} is not on this runner: fetching it from its peers and the hub", &hash[..12]));
6020×
            sw.fetch(home, hash)
603
        }
604
        None => {
6050×
            note(format!("image {} is not on this runner: fetching it from the hub", &hash[..12]));
6060×
            client.fetch(home, hash)
607
        }
608
    };
6090×
    let took = fetching.elapsed().as_millis() as u64;
6100×
    match fetched {
6110×
        Ok(Some(m)) => note(format!(
612
            "image {}: fetched {} chunks, {}{} in {:.1} s",
6130×
            &hash[..12],
614
            m.chunks,
6150×
            crate::images::human(m.bytes),
6160×
            if swarm.is_some() { format!(" ({} from peers)", crate::images::human(m.peer_bytes)) } else { String::new() },
6170×
            m.elapsed.as_secs_f64()
618
        )),
6190×
        Ok(None) => note(format!("image {}: the hub does not hold it either", &hash[..12])),
6200×
        Err(e) => note(format!("image {}: fetch failed: {e:#}", &hash[..12])),
621
    }
6220×
    Some(took)
6230×
}
624
625
/// Why this runner cannot run `claim` now (spec §11.5), checked before
626
/// anything is fetched: the tier, the architecture, the image and the
627
/// memory. The hub chose it from what the runner last said, which may be
628
/// out of date.
6290×
fn preflight(home: &Home, claim: &Claim) -> Option<String> {
6300×
    let sb = &claim.sandbox;
6310×
    match sb.runtime {
632
        flow_core::Runtime::Microvm => {
6330×
            if let Err(e) = std::fs::OpenOptions::new().read(true).write(true).open("/dev/kvm") {
6340×
                return Some(format!("the MicroVM tier needs /dev/kvm, which cannot be opened here ({e})"));
6350×
            }
6360×
            if home.config.microvm.is_none() {
6370×
                return Some("the MicroVM tier is not set up here (flow.toml has no [microvm] section)".into());
6380×
            }
639
        }
640
        flow_core::Runtime::Syscall => {
6410×
            if let Err(e) = crate::syscall::available() {
6420×
                return Some(format!("the syscall tier cannot run here: {e}"));
6430×
            }
644
        }
645
        // No image and no architecture: only the memory matters.
646
        flow_core::Runtime::Wasm => {
6470×
            return available_memory_mib()
6480×
                .filter(|free| *free < u64::from(sb.memory_mib))
6490×
                .map(|free| format!("only {free} MiB of memory is free here; the sandbox needs {} MiB", sb.memory_mib));
650
        }
651
    }
6520×
    sandbox_problem(home, sb, std::env::consts::ARCH, available_memory_mib())
6530×
}
654
655
/// What stops `sb` running on this home's images, on a machine of
656
/// architecture `here` with `free_mib` of memory free.
6578×
fn sandbox_problem(home: &Home, sb: &flow_core::Sandbox, here: &str, free_mib: Option<u64>) -> Option<String> {
6588×
    if let Some(want) = sb.arch.as_deref().filter(|a| *a != here) {
6591×
        return Some(format!("the sandbox needs {want}; this runner is {here}"));
6607×
    }
6617×
    let short = &sb.rootfs[..12.min(sb.rootfs.len())];
6627×
    let image = home.rootfs(&sb.rootfs);
6637×
    if !image.is_file() {
6641×
        return Some(format!("rootfs {short} is not on this runner"));
6656×
    }
6666×
    if let Some(arch) = home.image_arch(&sb.rootfs).filter(|a| a != here) {
6672×
        return Some(format!("rootfs {short} is for {arch}; this runner is {here}"));
6684×
    }
6694×
    if let Err(e) = home.verified(&image, &sb.rootfs) {
6701×
        return Some(format!("rootfs {short} does not verify here: {e:#}"));
6713×
    }
6723×
    if let Some(free) = free_mib.filter(|free| *free < u64::from(sb.memory_mib)) {
6731×
        return Some(format!("only {free} MiB of memory is free here; the sandbox needs {} MiB", sb.memory_mib));
6742×
    }
6752×
    None
6768×
}
677
678
/// The memory to claim with: the budget's room, never more than the host
679
/// has free now. An idle runner's budget is unlimited (a lone job may use
680
/// it all), so without this it was offered sandboxes it then declined for
681
/// want of memory, one after another, having fetched their images first
682
/// (an 8 GiB job on a 7 GB machine).
6835×
fn claim_memory(room: Option<u32>, host_free_mib: Option<u64>) -> Option<u32> {
6845×
    match (room, host_free_mib) {
6852×
        (Some(r), Some(h)) => Some(r.min(u32::try_from(h).unwrap_or(u32::MAX))),
6861×
        (None, Some(h)) => Some(u32::try_from(h).unwrap_or(u32::MAX)),
6872×
        (room, None) => room,
688
    }
6895×
}
690
691
/// `MemAvailable` from /proc/meminfo, in MiB.
6920×
fn available_memory_mib() -> Option<u64> {
6930×
    let info = std::fs::read_to_string("/proc/meminfo").ok()?;
6940×
    let kib: u64 = info.lines().find_map(|l| l.strip_prefix("MemAvailable:"))?.trim().trim_end_matches("kB").trim().parse().ok()?;
6950×
    Some(kib / 1024)
6960×
}
697
6980×
pub fn serve(home: &Home) -> Result<()> {
6990×
    drain_on_stop();
7000×
    crate::update::note_runner(&home.root);
701
    // `flow runner update` swapped a new build in and asks this one to
702
    // leave with the notice it gave (#85).
703
    #[cfg(unix)]
7040×
    {
7050×
        let handler = on_update as extern "C" fn(libc::c_int) as libc::sighandler_t;
7060×
        unsafe { libc::signal(libc::SIGUSR1, handler) };
7070×
    }
7080×
    if let Some(p) = crate::update::pending(&home.root) {
709
        // On trial: a build that does not reach the hub in time goes back.
7100×
        let root = home.root.clone();
7110×
        std::thread::spawn(move || {
7120×
            std::thread::sleep(crate::update::PROVE_WITHIN);
7130×
            if crate::update::pending(&root).is_some_and(|now| now.to == p.to && now.at_ms == p.at_ms) {
7140×
                let why = format!("did not reach the hub within {} minutes", crate::update::PROVE_WITHIN.as_secs() / 60);
7150×
                if crate::update::roll_back(&root, &p, &why).is_ok() {
7160×
                    std::process::exit(1);
7170×
                }
7180×
            }
7190×
        });
7200×
    }
7210×
    let served = serve_until_drained(home);
7220×
    crate::update::forget_runner(&home.root);
723
    // Left for a new build: on Windows, end as a failure, so a Task
724
    // Scheduler task set to restart on failure starts it (systemd's
725
    // Restart=always starts it after a clean exit).
7260×
    if cfg!(windows) && served.is_ok() && LEFT_FOR_UPDATE.load(std::sync::atomic::Ordering::SeqCst) {
7270×
        eprintln!("flow runner: left for the new build; exiting 75 so it is started");
7280×
        std::process::exit(75);
7290×
    }
7300×
    served
7310×
}
732
733
/// Set once the runner leaves for a new build.
734
static LEFT_FOR_UPDATE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
735
736
/// Set by SIGUSR1: a new build is in place; leave for it.
737
static UPDATE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
738
739
#[cfg(unix)]
7400×
extern "C" fn on_update(_: libc::c_int) {
7410×
    UPDATE.store(true, std::sync::atomic::Ordering::SeqCst);
7420×
}
743
744
/// The runner as a library, as the Android app runs it (oxygenhub-android#8):
745
/// no signal handlers; [`stop`] drains it, and this returns once the jobs
746
/// it holds are done and reported. It may be started again afterwards.
7470×
pub fn serve_embedded(home: &Home) -> Result<()> {
7480×
    STOPS.store(0, std::sync::atomic::Ordering::SeqCst);
7490×
    *LEAVING.lock().unwrap_or_else(|p| p.into_inner()) = None;
7500×
    serve_until_drained(home)
7510×
}
752
753
/// A departure ([`leave_within`]): why, and when the runner must be gone.
754
#[derive(Clone, Debug)]
755
struct Leave {
756
    reason: String,
757
    by: Instant,
758
}
759
760
/// Set once the runner is leaving: a job whose expected rest fits before
761
/// the deadline finishes; the others are stopped and handed back
762
/// (oxygenflow#76).
763
static LEAVING: std::sync::Mutex<Option<Leave>> = std::sync::Mutex::new(None);
764
7650×
fn leaving() -> Option<String> {
7660×
    LEAVING.lock().unwrap_or_else(|p| p.into_inner()).as_ref().map(|l| l.reason.clone())
7670×
}
768
769
/// Ask a runner started with [`serve_embedded`] to leave now: claim nothing
770
/// more, stop the jobs it holds and hand them back to the hub, which queues
771
/// them ahead of new work for another runner (oxygenflow#76), then return.
772
/// For a departure with no time to finish: a phone off the allowed network.
7730×
pub fn leave(reason: &str) {
7740×
    leave_within(reason, Duration::ZERO);
7750×
}
776
777
/// [`leave`], with `notice`: a job the hub expects to finish within it
778
/// (the claim's `expected_ms`, less the time it has run) finishes and is
779
/// reported; the others, and any still running when the notice is up, are
780
/// handed back. For a phone taken off charge, a spot reclaim.
7810×
pub fn leave_within(reason: &str, notice: Duration) {
7820×
    *LEAVING.lock().unwrap_or_else(|p| p.into_inner()) = Some(Leave { reason: reason.to_string(), by: Instant::now() + notice });
7830×
    stop();
7840×
}
785
786
/// Ask a runner started with [`serve_embedded`] to drain: claim nothing
787
/// more, finish and report what it holds, then return (oxygenflow#76's
788
/// drain, for a phone taken off charge or off the allowed network).
7891×
pub fn stop() {
7901×
    STOPS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
7911×
}
792
7930×
fn serve_until_drained(home: &Home) -> Result<()> {
7940×
    let cfg = home.config.runner.clone().context("flow.toml has no [runner] section")?;
795
    // A runner offers only what it can run: a machine without KVM claims
796
    // nothing rather than taking MicroVM jobs and failing them (#36).
7970×
    let (runtimes, missing) = tiers(home).map_err(|why| anyhow::anyhow!("flow runner: {why}"))?;
7980×
    for why in &missing {
7990×
        eprintln!("flow runner: not offering {why}");
8000×
    }
8010×
    let hub = Hub::new(&cfg)?;
8020×
    let name = cfg.name.clone().unwrap_or_else(|| "flow".into());
8030×
    let budget = Budget::of(&cfg);
8040×
    let key = home.runner_key(&name)?;
805
    // The swarm (docs/jetstream.md §8.1): a runner that cannot serve its
806
    // peers still runs jobs, fetching images from the hub alone.
8070×
    let swarm = crate::swarm::Swarm::start(home, &cfg).unwrap_or_else(|e| {
8080×
        eprintln!("flow runner: not joining the swarm: {e:#}");
8090×
        None
8100×
    });
8110×
    eprintln!(
812
        "flow runner {name}: polling {} (up to {} jobs at once, tiers {}), signing receipts as {}",
813
        cfg.hub,
814
        budget.max_jobs,
8150×
        runtimes.join(", "),
8160×
        key.identity.hash().to_hex()
817
    );
8180×
    if let Some(addr) = &cfg.metrics {
8190×
        crate::metrics::serve(addr, cfg.metrics_token.clone(), name.clone());
8200×
    }
821
    // The manifests of images held here that the hub keeps none of, so
822
    // peers in this runner's site can fetch them without anyone pushing
823
    // them (oxygenflow#94): at start and every ten minutes, which also
824
    // catches an image imported while it runs. Only a runner that serves
825
    // its peers has anything to offer.
8260×
    if swarm.is_some() {
8270×
        let (root, cfg) = (home.root.clone(), cfg.clone());
8280×
        std::thread::spawn(move || loop {
8290×
            let offered = Home::locate(Some(&root))
8300×
                .and_then(|home| crate::images::Client::new(&cfg.hub, &cfg.token, cfg.ca.as_deref())?.offer_held(&home));
8310×
            match offered {
8320×
                Ok(0) => {}
8330×
                Ok(n) => eprintln!("flow runner: sent the hub the manifests of {n} image(s) only runners hold"),
8340×
                Err(e) => eprintln!("flow runner: offering held images: {e:#}"),
835
            }
8360×
            std::thread::sleep(Duration::from_secs(600));
837
        });
8380×
    }
8390×
    let used = std::sync::Mutex::new(Used::default());
8400×
    let mut idle = Duration::from_secs(2);
8410×
    let pending = report_pending(home);
842
    // The jobs held now, told to the hub at each claim so it can end the
843
    // ones this runner lost: a job is held from its claim until its result
844
    // is reported.
8450×
    let held = std::sync::Mutex::new(std::collections::BTreeSet::<i64>::new());
8460×
    held.lock().unwrap().extend(pending.iter().map(|(job, _)| *job));
8470×
    std::thread::scope(|scope| {
8480×
        for (job, f) in pending {
8490×
            let (hub, held) = (&hub, &held);
8500×
            scope.spawn(move || {
8510×
                eprintln!("job {job}: reporting the result kept from before the restart");
8520×
                report_finish(home, hub, job, &f);
8530×
                held.lock().unwrap().remove(&job);
8540×
            });
855
        }
856
        // The speed (spec §11.1): measured now, and again every 15 minutes
857
        // while no job runs, so a job never shares the CPU with it.
8580×
        let measure = || {
8590×
            let (speed, ms) = crate::facts::bench();
8600×
            eprintln!("flow runner {name}: speed {speed} (class {}), benchmark {ms} ms", flow_core::api::Facts::class_of(speed));
8610×
            speed
8620×
        };
8630×
        let mut speed = measure();
8640×
        let mut benched = Instant::now();
8650×
        let mut said_draining = false;
866
        // When the hub last heard from us (oxygenhub#243): a claim, or a
867
        // heartbeat while there is no room to claim.
8680×
        let mut heard = Instant::now();
8690×
        let beat = |heard: &mut Instant| {
8700×
            if heard.elapsed() >= HUB_HEARTBEAT {
8710×
                if let Err(e) = hub.heartbeat() {
8720×
                    eprintln!("flow runner {name}: heartbeat: {e:#}");
8730×
                }
8740×
                *heard = Instant::now();
8750×
            }
8760×
        };
8770×
        let mut on_trial = crate::update::pending(&home.root).is_some();
878
        // Runner updates (oxygenhub#322): asked of the hub once it has
879
        // answered a claim, then every 15 minutes.
8800×
        let mut update_asked: Option<Instant> = None;
8810×
        let mut update_said = String::new();
882
        loop {
8830×
            if !stopping() && crate::update::drain_requested(&home.root) {
8840×
                eprintln!("flow runner {name}: asked to drain (flow runner drain)");
8850×
                stop();
8860×
            }
8870×
            if UPDATE.swap(false, std::sync::atomic::Ordering::SeqCst) || crate::update::requested(&home.root) {
8880×
                let p = crate::update::pending(&home.root);
8890×
                let notice = Duration::from_secs(p.as_ref().map_or(0, |p| p.notice_secs));
8900×
                let to = p.map_or_else(|| "a new build".to_string(), |p| format!("flow {}", p.to));
8910×
                eprintln!("flow runner {name}: updating to {to}: leaving within {} s", notice.as_secs());
8920×
                LEFT_FOR_UPDATE.store(true, std::sync::atomic::Ordering::SeqCst);
8930×
                leave_within(&format!("updating to {to}"), notice);
8940×
            }
895
            // Draining (oxygenflow#50): told to stop, claim nothing more and
896
            // stop once every job held is done and reported.
8970×
            if stopping() {
8980×
                let (jobs, holding) = (used.lock().unwrap().jobs, held.lock().unwrap().len());
8990×
                if jobs == 0 && holding == 0 {
9000×
                    eprintln!("flow runner {name}: drained, stopping");
9010×
                    break;
9020×
                }
9030×
                if !said_draining {
9040×
                    eprintln!("flow runner {name}: draining: claiming nothing more, stopping when the {jobs} job(s) held are done (stop again to stop now)");
9050×
                    said_draining = true;
9060×
                }
9070×
                beat(&mut heard);
9080×
                std::thread::sleep(Duration::from_millis(500));
9090×
                continue;
9100×
            }
9110×
            if used.lock().unwrap().jobs == 0 && benched.elapsed() >= Duration::from_secs(15 * 60) {
9120×
                speed = measure();
9130×
                benched = Instant::now();
9140×
            }
9150×
            let room = budget.room(*used.lock().unwrap());
9160×
            let Some((free_memory_mib, free_cpus)) = room else {
9170×
                beat(&mut heard);
9180×
                std::thread::sleep(Duration::from_millis(500));
9190×
                continue;
920
            };
9210×
            heard = Instant::now();
9220×
            let cache_times = crate::cache::Pool::new(&home.root, cfg.cache_gb).slot_times();
9230×
            let req = ClaimRequest {
9240×
                runner: name.clone(),
9250×
                runtimes: runtimes.clone(),
9260×
                free_memory_mib: claim_memory(free_memory_mib, available_memory_mib()),
9270×
                free_cpus,
9280×
                info: Some(RunnerInfo {
9290×
                    version: env!("OXY_VERSION_LONG").into(),
9300×
                    ..RunnerInfo::here(cfg.offered_labels(), budget.max_jobs as u32, cfg.memory_mib, cfg.cpus)
9310×
                }),
932
                // Read each time: an image imported while the runner runs counts.
9330×
                images: Some(home.images()),
9340×
                image_archs: Some(home.image_archs()),
9350×
                holding: Some(held.lock().unwrap().iter().copied().collect()),
936
                fetches_images: true,
9370×
                facts: Some(crate::facts::now(home, Some(speed))),
9380×
                swarm: swarm.as_ref().map(|s| s.addr.clone()),
9390×
                caches: Some(cache_times.keys().cloned().collect()),
9400×
                cache_times: Some(cache_times),
941
            };
9420×
            let answer = hub.claim(&req);
9430×
            if on_trial && answer.is_ok() {
9440×
                crate::update::proven(&home.root);
9450×
                on_trial = false;
9460×
            }
9470×
            if answer.is_ok() && update_asked.is_none_or(|t| t.elapsed() >= UPDATE_EVERY) {
9480×
                update_asked = Some(Instant::now());
9490×
                if let Some(note) = crate::update::take_note(&home.root) {
9500×
                    let _ = hub.report_update(&note);
9510×
                }
9520×
                consider_update(home, &hub, swarm.as_ref(), &mut update_said);
9530×
            }
9540×
            match answer {
9550×
                Ok(Some(claim)) => {
9560×
                    idle = Duration::from_secs(2);
9570×
                    crate::metrics::CLAIMS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
9580×
                    let (mem, cpus) = (claim.sandbox.memory_mib, claim.sandbox.cpus);
9590×
                    {
9600×
                        let mut u = used.lock().unwrap();
9610×
                        u.jobs += 1;
9620×
                        u.memory_mib += mem;
9630×
                        u.cpus += cpus;
9640×
                    }
9650×
                    held.lock().unwrap().insert(claim.job_id);
9660×
                    let (home, hub, cfg, used, key, held, swarm) = (home, &hub, &cfg, &used, &key, &held, swarm.as_ref());
9670×
                    scope.spawn(move || {
9680×
                        let id = claim.job_id;
9690×
                        let image_ms = fetch_image(home, hub, cfg, swarm, &claim);
9700×
                        if let Some(why) = preflight(home, &claim) {
9710×
                            eprintln!("job {id}: declined: {why}");
9720×
                            crate::metrics::DECLINED.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
9730×
                            if let Err(e) = hub.decline(id, &why) {
9740×
                                eprintln!("job {id}: decline: {e:#}");
9750×
                            }
9760×
                            held.lock().unwrap().remove(&id);
9770×
                            let mut u = used.lock().unwrap();
9780×
                            u.jobs -= 1;
9790×
                            u.memory_mib -= mem;
9800×
                            u.cpus -= cpus;
9810×
                            return;
9820×
                        }
9830×
                        eprintln!(
984
                            "job {id}: {} {} on {}",
985
                            claim.repo,
986
                            claim.job.name,
9870×
                            claim.heads.first().map(|h| &h[..12]).unwrap_or("-")
988
                        );
9890×
                        crate::metrics::RUNNING.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
9900×
                        let _shown = Running::start(id, format!("{} #{} {}", claim.repo, claim.run_number, claim.job.name));
9910×
                        let finish = run_claim(home, hub, cfg, key, &claim, image_ms).unwrap_or_else(|e| Finish {
9920×
                            exit_code: None,
993
                            duration_ms: 0,
9940×
                            receipt: None,
9950×
                            signed_receipt: None,
9960×
                            error: Some(format!("{e:#}")),
9970×
                            peak_disk_mib: None,
9980×
                            peak_memory_mib: None,
9990×
                            phases: Default::default(),
10000×
                        });
10010×
                        eprintln!(
1002
                            "job {id}: exit {:?}{}",
1003
                            finish.exit_code,
10040×
                            finish.error.as_deref().map(|e| format!(" ({e})")).unwrap_or_default()
1005
                        );
10060×
                        crate::metrics::RUNNING.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
10070×
                        let cancelled = finish.error.as_deref() == Some(CANCELLED);
10080×
                        crate::metrics::finished(finish.exit_code, finish.error.as_deref(), finish.duration_ms, cancelled);
10090×
                        match leaving() {
1010
                            // Stopped as the runner leaves: another runner takes it.
10110×
                            Some(why) if cancelled => {
10120×
                                eprintln!("job {id}: handed back: {why}");
10130×
                                if let Err(e) = hub.release(id, &why) {
10140×
                                    eprintln!("job {id}: handing back: {e:#}; the hub requeues it once this runner's lease lapses");
10150×
                                }
1016
                            }
10170×
                            _ if !cancelled => report_finish(home, hub, id, &finish),
10180×
                            _ => {}
1019
                        }
10200×
                        held.lock().unwrap().remove(&id);
10210×
                        let mut u = used.lock().unwrap();
10220×
                        u.jobs -= 1;
10230×
                        u.memory_mib -= mem;
10240×
                        u.cpus -= cpus;
10250×
                    });
1026
                }
10270×
                Ok(None) => std::thread::sleep(idle),
10280×
                Err(e) => {
10290×
                    eprintln!("claim: {e:#}");
10300×
                    std::thread::sleep(idle);
10310×
                    idle = (idle * 2).min(Duration::from_secs(60));
10320×
                }
1033
            }
1034
        }
10350×
        Ok(())
10360×
    })
10370×
}
1038
1039
/// Pack and keep a handed-back job's checkpoints (oxygenflow#77); what
1040
/// happened goes to its log, and a failure only means the next attempt
1041
/// starts again.
1042
#[allow(clippy::too_many_arguments)]
10430×
fn save_checkpoint(hub: &Hub, id: i64, job: &flow_core::Job, scratch: &Path, dir_tier: bool, wasm: bool, mut say: impl FnMut(String)) {
10440×
    if !dir_tier {
10450×
        say("checkpoint: only the WASM and syscall tiers save one yet; the next attempt starts again".into());
10460×
        return;
10470×
    }
10480×
    let dirs: Vec<(String, PathBuf)> = job
10490×
        .checkpoints
10500×
        .iter()
10510×
        .filter_map(|c| crate::checkpoint::host_dir(c, job, &scratch.join("tree"), scratch, wasm).map(|d| (c.clone(), d)))
10520×
        .collect();
10530×
    match crate::checkpoint::pack(&dirs).and_then(|(bytes, n)| Ok((hub.save_checkpoint(id, &bytes)?, n))) {
10540×
        Ok((blob, n)) => {
10550×
            say(format!("checkpoint {}: {} files, {} bytes, kept for the next attempt", &blob[..12.min(blob.len())], n.files, n.bytes))
1056
        }
10570×
        Err(e) => say(format!("checkpoint: not kept ({e:#}); the next attempt starts again")),
1058
    }
10590×
}
1060
1061
/// How many times the runner was told to stop (SIGTERM or SIGINT).
1062
static STOPS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
1063
10641×
extern "C" fn on_stop(_: libc::c_int) {
1065
    // A second stop means now: only async-signal-safe calls here.
10661×
    if STOPS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) >= 1 {
10670×
        unsafe { libc::_exit(143) };
10681×
    }
10691×
}
1070
1071
/// Drain instead of dying on the first SIGTERM or SIGINT (oxygenflow#50),
1072
/// so restarting a runner never kills the job it runs: the unit's
1073
/// `KillMode=mixed` sends the signal to the runner alone, not its microVMs.
10740×
fn drain_on_stop() {
10750×
    let handler = on_stop as extern "C" fn(libc::c_int) as libc::sighandler_t;
10760×
    unsafe {
10770×
        libc::signal(libc::SIGTERM, handler);
10780×
        libc::signal(libc::SIGINT, handler);
10790×
    }
10800×
}
1081
10823×
fn stopping() -> bool {
10833×
    STOPS.load(std::sync::atomic::Ordering::SeqCst) > 0
10843×
}
1085
1086
/// Batches records to the hub every second (or every 200 lines). A quiet
1087
/// job's sink still asks the hub every few seconds whether the job runs,
1088
/// with an empty batch; once the hub answers 409 the job is not wanted.
1089
struct LogSink<'a> {
1090
    hub: &'a Hub,
1091
    job: i64,
1092
    buf: Vec<Record>,
1093
    last: Instant,
1094
    gone: bool,
1095
    /// Set when the runner leaves ([`LEAVING`]): the job stops, to be
1096
    /// handed back, unless its expected rest fits the notice.
1097
    leaving: &'a std::sync::Mutex<Option<Leave>>,
1098
    /// When the job started, and how long the hub expects it to take.
1099
    started: Instant,
1100
    expected: Option<Duration>,
1101
}
1102
1103
/// The most log lines sent at once, as a busy job's sink flushes them.
1104
const BATCH: usize = 200;
1105
1106
/// The most log lines kept while the hub cannot be reached.
1107
const MAX_UNSENT: usize = 50_000;
1108
1109
/// How often a quiet job asks whether it was cancelled.
1110
const HEARTBEAT: Duration = Duration::from_secs(5);
1111
1112
impl microvm::Sink for LogSink<'_> {
11131×
    fn record(&mut self, r: Record) {
11141×
        self.push(r)
11151×
    }
1116
111710×
    fn cancelled(&mut self) -> bool {
111810×
        let leave = self.leaving.lock().unwrap_or_else(|p| p.into_inner()).clone();
111910×
        if let Some(l) = leave {
1120
            // Finished if what is left of it fits the notice; else handed
1121
            // back as the runner leaves, what the job said so far first.
11225×
            let now = Instant::now();
11235×
            let fits = self.expected.is_some_and(|e| now + e.saturating_sub(self.started.elapsed()) <= l.by);
11245×
            if !fits || now >= l.by {
11254×
                self.flush();
11264×
                return true;
11271×
            }
11285×
        }
11296×
        if !self.gone && self.last.elapsed() >= HEARTBEAT {
11302×
            self.send();
11314×
        }
11326×
        self.gone
113310×
    }
1134
}
1135
1136
impl LogSink<'_> {
11371×
    fn push(&mut self, mut r: Record) {
11381×
        if self.gone {
11391×
            return;
11400×
        }
11410×
        if r.text.len() > MAX_RECORD_TEXT {
11420×
            let mut cut = MAX_RECORD_TEXT;
11430×
            while !r.text.is_char_boundary(cut) {
11440×
                cut -= 1;
11450×
            }
11460×
            r.text.truncate(cut);
11470×
        }
11480×
        self.buf.push(r);
11490×
        if self.buf.len() >= BATCH || self.last.elapsed() >= Duration::from_secs(1) {
11500×
            self.flush();
11510×
        }
11521×
    }
1153
11545×
    fn flush(&mut self) {
11555×
        if !self.buf.is_empty() {
11561×
            self.send();
11574×
        }
11585×
        self.last = Instant::now();
11595×
    }
1160
1161
    /// Send what is buffered, even nothing, in batches of at most
1162
    /// `BATCH` lines.
11636×
    fn send(&mut self) {
1164
        loop {
11658×
            if self.gone {
11660×
                return;
11678×
            }
11688×
            let n = self.buf.len().min(BATCH);
11698×
            let batch: Vec<Record> = self.buf.drain(..n).collect();
11708×
            match self.hub.log(self.job, batch.clone()) {
11715×
                Ok(()) => {}
11723×
                Err(e) if job_gone(&e) => self.gone = true,
11732×
                Err(e) => {
1174
                    // The hub is away (a restart, say): keep the lines for
1175
                    // the next batch, up to a bound, so the log loses none
1176
                    // (oxygenflow#49).
11772×
                    eprintln!("job {}: log: {e:#}; kept for the next batch", self.job);
11782×
                    let mut kept = batch;
11792×
                    kept.append(&mut self.buf);
11802×
                    let over = kept.len().saturating_sub(MAX_UNSENT);
11812×
                    if over > 0 {
11821×
                        kept.drain(..over);
11831×
                        kept.insert(
11841×
                            0,
11851×
                            Record {
11861×
                                t: 0,
11871×
                                stream: "flow".into(),
11881×
                                text: format!("({over} log lines lost while the hub could not be reached)"),
11891×
                            },
11901×
                        );
11911×
                    }
11922×
                    self.buf = kept;
11932×
                    break;
1194
                }
1195
            }
11966×
            if self.buf.is_empty() {
11974×
                break;
11982×
            }
1199
        }
12006×
        self.last = Instant::now();
12016×
    }
1202
}
1203
1204
/// How often a runner with no room to claim tells the hub it is there:
1205
/// well inside the hub's 90 s before it shows a runner offline.
1206
const HUB_HEARTBEAT: Duration = Duration::from_secs(30);
1207
1208
/// How often a runner asks the hub whether it should run another build.
1209
const UPDATE_EVERY: Duration = Duration::from_secs(15 * 60);
1210
1211
/// Notice a runner gives itself to leave for an update the hub offered.
1212
const UPDATE_NOTICE: Duration = Duration::from_secs(600);
1213
1214
/// Ask the hub for this runner's update, and act on it: download the build
1215
/// it offers, check it against the hub's BLAKE3 and as a runner build, and
1216
/// install it, or for a dry run say what would happen. What it did goes to
1217
/// the hub once per outcome (`said`).
12180×
fn consider_update(home: &Home, hub: &Hub, swarm: Option<&crate::swarm::Swarm>, said: &mut String) {
12190×
    let answer = match hub.update_answer() {
12200×
        Ok(Some(a)) => a,
12210×
        Ok(None) => return,
12220×
        Err(e) => {
12230×
            eprintln!("flow runner: asking for an update: {e:#}");
12240×
            return;
1225
        }
1226
    };
12270×
    let Some(offer) = answer.offer else { return };
12280×
    let mut say = |note: String| {
12290×
        if *said != note {
12300×
            eprintln!("flow runner: {note}");
12310×
            if let Err(e) = hub.report_update(&note) {
12320×
                eprintln!("flow runner: telling the hub: {e:#}");
12330×
            }
12340×
            *said = note;
12350×
        }
12360×
    };
12370×
    if home.config.runner.as_ref().and_then(|r| r.update.as_ref()).is_some_and(|u| !u.allow) {
12380×
        return say(format!("refuses flow {}: flow.toml says [runner.update] allow = false", offer.version));
12390×
    }
12400×
    let dir = home.root.join("updates");
12410×
    let file = dir.join(format!("flow-{}", offer.version));
12420×
    let fresh = std::fs::read(&file).is_ok_and(|b| blake3::hash(&b).to_hex().as_str() == offer.blake3);
12430×
    if !fresh {
12440×
        if let Err(e) =
12450×
            std::fs::create_dir_all(&dir).map_err(anyhow::Error::from).and_then(|_| fetch_build(home, hub, swarm, &offer, &file))
1246
        {
12470×
            return say(format!("flow {}: not fetched: {e:#}", offer.version));
12480×
        }
12490×
    }
12500×
    let exe = match std::env::current_exe() {
12510×
        Ok(e) => e,
12520×
        Err(e) => return say(format!("flow {}: cannot find its own binary: {e}", offer.version)),
1253
    };
12540×
    match crate::update::install(home, &exe, &file, UPDATE_NOTICE, answer.policy.dry_run, offer.older) {
12550×
        Ok(done) if answer.policy.dry_run => say(format!("dry run: flow {} checks out here and would replace flow {}", done.to, done.from)),
12560×
        Ok(done) => {
12570×
            let _ = std::fs::remove_file(&file);
12580×
            say(format!("installed flow {} (was {}); leaving within {} s to start it", done.to, done.from, UPDATE_NOTICE.as_secs()))
1259
        }
12600×
        Err(e) => say(format!("flow {}: not installed: {e:#}", offer.version)),
1261
    }
12620×
}
1263
1264
/// Fetch an offered build to `file`: through the swarm when this runner is
1265
/// in one (oxygenflow#84), as an image named by the build's BLAKE3, which
1266
/// the hub chunks when it first offers it, so runners of a site fetch it
1267
/// from each other and the hub serves it about once per site; over HTTP
1268
/// otherwise, or when the swarm cannot get it. Either way it must be the
1269
/// size and BLAKE3 the hub attests.
12700×
fn fetch_build(
12710×
    home: &Home,
12720×
    hub: &Hub,
12730×
    swarm: Option<&crate::swarm::Swarm>,
12740×
    offer: &flow_core::api::UpdateOffer,
12750×
    file: &Path,
12760×
) -> Result<()> {
12770×
    if let Some(sw) = swarm.filter(|_| jet::images::is_hash(&offer.blake3)) {
12780×
        match sw.fetch(home, &offer.blake3) {
12790×
            Ok(Some(m)) => {
12800×
                let image = home.rootfs(&offer.blake3);
12810×
                match crate::update::copy_checked(&image, file, offer.size, &offer.blake3) {
1282
                    Ok(()) => {
12830×
                        eprintln!(
1284
                            "flow runner: fetched flow {} through the swarm: {} chunks, {} ({} from peers)",
1285
                            offer.version,
1286
                            m.chunks,
12870×
                            crate::images::human(m.bytes),
12880×
                            crate::images::human(m.peer_bytes)
1289
                        );
12900×
                        return Ok(());
1291
                    }
12920×
                    Err(e) => eprintln!("flow runner: flow {} from the swarm: {e:#}; fetching it over HTTP", offer.version),
1293
                }
1294
            }
12950×
            Ok(None) => eprintln!("flow runner: the hub has not shared flow {} with the swarm yet; fetching it over HTTP", offer.version),
12960×
            Err(e) => eprintln!("flow runner: flow {} through the swarm: {e:#}; fetching it over HTTP", offer.version),
1297
        }
12980×
    }
12990×
    hub.download_build(offer, file)
13000×
}
1301
1302
/// The jobs this runner is running now, for a host to show (the Android
1303
/// app's card and notification, oxygenhub-android#8): by job id,
1304
/// "owner/name #run job".
1305
static RUNNING_NOW: std::sync::Mutex<std::collections::BTreeMap<i64, String>> = std::sync::Mutex::new(std::collections::BTreeMap::new());
1306
1307
/// A job shown in [`running`] while this lives.
1308
struct Running(i64);
1309
1310
impl Running {
13112×
    fn start(id: i64, what: String) -> Running {
13122×
        RUNNING_NOW.lock().unwrap_or_else(|p| p.into_inner()).insert(id, what);
13132×
        Running(id)
13142×
    }
1315
}
1316
1317
impl Drop for Running {
13182×
    fn drop(&mut self) {
13192×
        RUNNING_NOW.lock().unwrap_or_else(|p| p.into_inner()).remove(&self.0);
13202×
    }
1321
}
1322
1323
/// What this runner is running now: "owner/name #run job" for each job, in
1324
/// the order claimed.
13253×
pub fn running() -> Vec<String> {
13263×
    RUNNING_NOW.lock().unwrap_or_else(|p| p.into_inner()).values().cloned().collect()
13273×
}
1328
1329
/// Held while a job takes its cache disks and images its workspace (see
1330
/// `run_claim`): one job at a time on the runner's disk and cache pool.
1331
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(());
1332
1333
/// Held while the first clone of any repository passes its token through
1334
/// the process environment.
1335
static CLONE: std::sync::Mutex<()> = std::sync::Mutex::new(());
1336
1337
/// One lock per cached repository: fetches into it, and exports from it,
1338
/// never overlap, while jobs of other repositories go on. A slow repository
1339
/// (one kept in a bucket, say) once held every job on the runner waiting
1340
/// behind a single global lock.
13410×
fn repo_lock(name: &str) -> std::sync::Arc<std::sync::Mutex<()>> {
1342
    static LOCKS: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, std::sync::Arc<std::sync::Mutex<()>>>>> =
1343
        std::sync::OnceLock::new();
13440×
    let mut locks = LOCKS.get_or_init(Default::default).lock().unwrap_or_else(|p| p.into_inner());
13450×
    locks.entry(name.to_string()).or_default().clone()
13460×
}
1347
1348
/// Seconds, to one decimal place, for the job's log.
13490×
fn secs(d: std::time::Duration) -> String {
13500×
    format!("{:.1} s", d.as_secs_f64())
13510×
}
1352
1353
/// `image_ms`: how long fetching its image took, when the runner lacked it.
13540×
fn run_claim(home: &Home, hub: &Hub, cfg: &RunnerConfig, key: &RunnerKey, claim: &Claim, image_ms: Option<u64>) -> Result<Finish> {
13550×
    let started = Instant::now();
13560×
    let mut sink = LogSink {
13570×
        hub,
13580×
        job: claim.job_id,
13590×
        buf: Vec::new(),
13600×
        last: Instant::now(),
13610×
        gone: false,
13620×
        leaving: &LEAVING,
13630×
        started: Instant::now(),
13640×
        expected: claim.expected_ms.map(Duration::from_millis),
13650×
    };
13660×
    let note =
13670×
        |sink: &mut LogSink, text: String| sink.push(Record { t: started.elapsed().as_millis() as u64, stream: "flow".into(), text });
13680×
    let heads: Vec<ChangeHash> = claim.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad head")).collect::<Result<_>>()?;
13690×
    note(&mut sink, format!("fetching {} for state {}", claim.repo, claim.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+")));
1370
    // Preparing a workspace: fetches into a cached repository, and exports
1371
    // from it, are one job at a time per repository (`repo_lock`); taking
1372
    // cache disks and imaging the workspace are one job at a time on the
1373
    // runner (`PREPARE`); a first clone, whose token goes through the process
1374
    // environment, one at a time (`CLONE`).
13750×
    let main_lock = repo_lock(&claim.repo);
13760×
    let asked = Instant::now();
13770×
    let mut main_guard = Some(main_lock.lock().unwrap_or_else(|p| p.into_inner()));
13780×
    if asked.elapsed().as_secs() >= 2 {
13790×
        note(&mut sink, format!("waited {} for another job fetching {}", secs(asked.elapsed()), claim.repo));
13800×
    }
13810×
    let fetching = Instant::now();
13820×
    let repo = cached_repo(home, cfg, claim)?;
13830×
    let fetched = fetching.elapsed();
13840×
    let mut preparing = None;
13850×
    let scratch = home.root.join("work").join(format!("job-{}", claim.job_id));
13860×
    let _ = std::fs::remove_dir_all(&scratch);
13870×
    std::fs::create_dir_all(scratch.join("tree"))?;
13880×
    let result = (|| -> Result<Finish> {
13890×
        let exporting = Instant::now();
13900×
        let mut files = workspace::export_state(&repo, &heads, &scratch.join("tree"))?;
13910×
        main_guard.take();
13920×
        note(&mut sink, format!("fetched in {}, {} files exported in {}", secs(fetched), files.len(), secs(exporting.elapsed())));
1393
        // Pinned subrepos (Oxygen spec §7.8), at their paths, as part of the
1394
        // workspace and of the scope hash.
13950×
        for sub in &claim.subrepos {
13960×
            if sub.path.split('/').any(|p| p.is_empty() || p == "." || p == ".." || p == ".oxy") {
13970×
                bail!("subrepo path {:?} is not a clean relative path", sub.path);
13980×
            }
13990×
            note(
14000×
                &mut sink,
14010×
                format!(
1402
                    "fetching subrepo {} at {} for {}",
1403
                    sub.repo,
14040×
                    sub.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+"),
1405
                    sub.path
1406
                ),
1407
            );
14080×
            let sub_lock = repo_lock(&sub.repo);
14090×
            let asked = Instant::now();
14100×
            let sub_guard = sub_lock.lock().unwrap_or_else(|p| p.into_inner());
14110×
            if asked.elapsed().as_secs() >= 2 {
14120×
                note(&mut sink, format!("waited {} for another job fetching {}", secs(asked.elapsed()), sub.repo));
14130×
            }
14140×
            let sub_repo = cached_repo_at(home, cfg, &sub.repo, &sub.clone_url, &claim.token)?;
14150×
            let sub_heads: Vec<ChangeHash> =
14160×
                sub.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad subrepo head")).collect::<Result<_>>()?;
14170×
            let dir = scratch.join("tree").join(&sub.path);
14180×
            std::fs::create_dir_all(&dir)?;
14190×
            let sub_files = workspace::export_state(&sub_repo, &sub_heads, &dir)?;
14200×
            drop(sub_guard);
14210×
            files.extend(sub_files.into_iter().map(|(p, h)| (format!("{}/{p}", sub.path), h)));
1422
        }
14230×
        let scope_hash = scope_tree_hash(&claim.job.scope, files.iter().map(|(p, h)| (p.as_str(), *h)));
1424
        // Inputs (oxygenflow#58): each producing job's artifact, checked
1425
        // against the blob its receipt names, then put at its path. Not part
1426
        // of the scope: they are the run's, not the repository's.
14270×
        let mut input_blobs = Vec::new();
14280×
        for (index, i) in claim.inputs.iter().enumerate() {
14290×
            let what = format!("input {} from {}", i.artifact, i.from);
14300×
            let bytes = hub.input(claim.job_id, index).with_context(|| format!("{what}: fetching it"))?;
14310×
            let blob = place_input(&scratch.join("tree"), i, &bytes).with_context(|| what.clone())?;
14320×
            note(&mut sink, format!("{what}: {} bytes at {}, blob {} as its receipt names", bytes.len(), i.path, &i.blob[..12]));
14330×
            input_blobs.push(blob);
1434
        }
1435
        // The syscall tier runs on the tree itself; a microVM gets an image.
14360×
        let syscall_tier = claim.sandbox.runtime == flow_core::Runtime::Syscall;
14370×
        let wasm_tier = claim.sandbox.runtime == flow_core::Runtime::Wasm;
1438
        // The syscall and WASM tiers see the job's files as a directory;
1439
        // the MicroVM tier as an ext4 image.
14400×
        let dir_tier = syscall_tier || wasm_tier;
14410×
        let job = claim.job.in_sandbox(&claim.sandbox).with_flow_env(&claim.flow_env());
1442
        // Disk mounts that name a cache start from a kept disk (#28): a
1443
        // microVM takes an image already in place as it is, the syscall tier
1444
        // a directory (#70). Taken before the workspace is imaged: the
1445
        // sources the disk was built from give unchanged files their old
1446
        // times back (#52).
14470×
        let asked = Instant::now();
14480×
        preparing = Some(PREPARE.lock().unwrap_or_else(|p| p.into_inner()));
14490×
        if asked.elapsed().as_secs() >= 2 {
14500×
            note(&mut sink, format!("waited {} for another job imaging its workspace", secs(asked.elapsed())));
14510×
        }
14520×
        let pool = crate::cache::Pool::new(&home.root, cfg.cache_gb);
14530×
        let form = if dir_tier { crate::cache::Form::Dir } else { crate::cache::Form::Image };
14540×
        let cache_ctx =
14550×
            crate::cache::Ctx { repo: &claim.repo, bookmark: &claim.bookmark, job: &claim.job.name, image: &claim.sandbox.rootfs, form };
14560×
        let mut cached: Vec<(usize, String, String)> = Vec::new();
14570×
        let mut built_from: Option<crate::cache::Sources> = None;
14580×
        for (i, m) in job.mounts.iter().filter(|m| m.source == flow_core::pipeline::MountSource::Disk).enumerate() {
14590×
            let Some(name) = &m.cache else { continue };
14600×
            let key = crate::cache::Pool::key(&cache_ctx, m, |p| files.iter().find(|(f, _)| f == p).map(|(_, h)| hex::encode(h)));
14610×
            let how = match pool.take(&cache_ctx, m, &key, &crate::cache::disk_path(form, &scratch, i)) {
14620×
                Some(t) => {
14630×
                    if built_from.is_none() {
14640×
                        built_from = t.sources;
14650×
                    }
14660×
                    t.how
1467
                }
14680×
                None => format!("cache {name}: none kept yet, an empty disk"),
1469
            };
14700×
            note(&mut sink, format!("{} ({})", how, m.target));
1471
            // What the job reads is seen when the disk is pruned (#72).
14720×
            if let Err(e) = crate::cache::forget_reads(form, &crate::cache::disk_path(form, &scratch, i)) {
14730×
                note(&mut sink, format!("cache {name}: {e:#}"));
14740×
            }
14750×
            cached.push((i, key, how));
1476
        }
1477
        // What the job reads or writes on its cache from now on is what it
1478
        // used; the rest may be pruned before the cache is kept (#72).
14790×
        let cache_started = if cached.is_empty() { std::time::SystemTime::now() } else { flow_init::prune::start_clock() };
14800×
        let sources = if cached.is_empty() {
14810×
            crate::cache::Sources::new()
1482
        } else {
14830×
            let (sources, restored) = workspace::restore_times(&scratch.join("tree"), &files, built_from.as_ref());
14840×
            if built_from.is_some() {
14850×
                note(
14860×
                    &mut sink,
14870×
                    format!(
14880×
                        "cache: {restored} of {} files and directories unchanged since the cached build, their times kept",
14890×
                        sources.len()
14900×
                    ),
14910×
                );
14920×
            }
14930×
            sources
1494
        };
1495
        // An earlier attempt's checkpoint (oxygenflow#77): back in place,
1496
        // over the cache, before the same exec runs again; the receipt
1497
        // lists it among the job's inputs.
14980×
        if let Some(blob) = &claim.checkpoint {
14990×
            if dir_tier {
15000×
                let bytes = hub.checkpoint(claim.job_id).context("fetching the checkpoint")?;
15010×
                if Repo::blob_ref(&bytes).to_hex() != *blob {
15020×
                    bail!("the checkpoint fetched is not blob {}, as the claim names", &blob[..12.min(blob.len())]);
15030×
                }
15040×
                let place = |root: &str| crate::checkpoint::host_dir(root, &job, &scratch.join("tree"), &scratch, wasm_tier);
15050×
                let n = crate::checkpoint::unpack(&bytes, &job.checkpoints, place)?;
15060×
                note(
15070×
                    &mut sink,
15080×
                    format!("restored checkpoint {}: {} files, {} bytes, from the attempt handed back", &blob[..12], n.files, n.bytes),
15090×
                );
15100×
                input_blobs.push(Repo::blob_ref(&bytes).0);
15110×
            } else {
15120×
                note(&mut sink, "a checkpoint was saved, but only the WASM and syscall tiers restore one yet: starting again".into());
15130×
            }
15140×
        }
15150×
        if !dir_tier {
15160×
            workspace::image(&scratch.join("tree"), &scratch.join("workspace.ext4"))?;
15170×
            std::fs::remove_dir_all(scratch.join("tree"))?;
15180×
        }
15190×
        preparing.take();
1520
        // Cancelled while it was being prepared: no sandbox.
15210×
        if microvm::Sink::cancelled(&mut sink) {
15220×
            return Ok(cancelled());
15230×
        }
15240×
        if wasm_tier {
15250×
            note(&mut sink, format!("starting WASM (wasmtime {}): {} MiB", crate::wasm::WASMTIME, claim.sandbox.memory_mib));
15260×
        } else {
15270×
            note(
15280×
                &mut sink,
15290×
                format!(
1530
                    "{}: {} {}, {} MiB, rootfs {}",
15310×
                    if syscall_tier { "starting a syscall sandbox" } else { "booting a microVM" },
1532
                    claim.sandbox.cpus,
15330×
                    if syscall_tier { "CPU" } else { "vCPU" },
1534
                    claim.sandbox.memory_mib,
15350×
                    &claim.sandbox.rootfs[..12]
1536
                ),
1537
            );
1538
        }
15390×
        let timeout = Duration::from_secs(claim.timeout_s);
1540
        // Where the time goes (oxygenflow#35): what came before the
1541
        // sandbox, the sandbox, and what comes after.
15420×
        let prepare_ms = started.elapsed().as_millis() as u64;
15430×
        let sandbox_started = Instant::now();
15440×
        let mut outcome = if wasm_tier {
15450×
            crate::wasm::run(&claim.sandbox, &job, &scratch.join("tree"), &scratch, timeout, &mut sink)?
15460×
        } else if syscall_tier {
15470×
            let group = format!("job-{}", claim.job_id);
15480×
            crate::syscall::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("tree"), &scratch, timeout, &mut sink, &group)?
1549
        } else {
15500×
            microvm::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("workspace.ext4"), &scratch, timeout, &mut sink)?
1551
        };
15520×
        let run_ms = sandbox_started.elapsed().as_millis() as u64;
15530×
        if outcome.cancelled {
1554
            // Handed back as the runner leaves: its checkpoints first, for
1555
            // the next attempt (oxygenflow#77).
15560×
            if leaving().is_some() && !job.checkpoints.is_empty() {
15570×
                save_checkpoint(hub, claim.job_id, &job, &scratch, dir_tier, wasm_tier, |t| note(&mut sink, t));
15580×
            }
15590×
            return Ok(cancelled());
15600×
        }
1561
        // The receipt says what a cached disk started from.
15620×
        let disk_mounts: Vec<&flow_core::pipeline::Mount> =
15630×
            job.mounts.iter().filter(|m| m.source == flow_core::pipeline::MountSource::Disk).collect();
15640×
        for (i, key, how) in &cached {
15650×
            outcome.environment.parts.push((format!("cache {}", disk_mounts[*i].target), format!("{key}: {how}")));
15660×
        }
1567
        // A passing job's artifacts, read from its disks and uploaded; a
1568
        // declared artifact that cannot be kept fails the job. A failing
1569
        // job keeps only its `when "always"` ones (oxygenflow#37), outside
1570
        // its receipt, and one it cannot keep is only noted.
15710×
        let mut outputs = vec![];
15720×
        let mut output_error = None;
15730×
        let passed = outcome.exit_code == Some(0);
15740×
        let wanted = claim.job.outputs.iter().filter(|o| passed || o.always);
15750×
        for o in wanted {
15760×
            if o.kind == flow_core::OutputKind::Report {
15770×
                let read = if dir_tier {
15780×
                    let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path);
15790×
                    crate::outputs::read_report_dir(&dir, &inner)
1580
                } else {
15810×
                    let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path);
15820×
                    crate::outputs::read_report(&image, &inner, &scratch)
1583
                };
15840×
                let kept = read.and_then(|files| upload_report(hub, claim.job_id, &o.name, &files));
15850×
                match kept {
15860×
                    Ok((blob, files, bytes)) => {
15870×
                        let unattested = if passed { "" } else { " (the job failed: not in its receipt)" };
15880×
                        let hex = blob.to_hex();
15890×
                        note(&mut sink, format!("report {}: {files} files, {bytes} bytes, manifest {}{unattested}", o.name, &hex[..12]));
15900×
                        if passed {
15910×
                            outputs.push(blob.0);
15920×
                        }
1593
                    }
15940×
                    Err(e) if passed => {
15950×
                        output_error = Some(format!("report {}: {e:#}", o.name));
15960×
                        break;
1597
                    }
15980×
                    Err(e) => note(&mut sink, format!("report {}: not kept: {e:#}", o.name)),
1599
                }
16000×
                continue;
16010×
            }
16020×
            let read = if dir_tier {
16030×
                let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path);
16040×
                crate::outputs::read_dir_file(&dir, &inner)
1605
            } else {
16060×
                let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path);
16070×
                crate::outputs::read(&image, &inner, &scratch)
1608
            };
16090×
            let kept = read.and_then(|bytes| {
16100×
                let blob = Repo::blob_ref(&bytes);
16110×
                let named = hub.artifact(claim.job_id, &o.name, &bytes)?;
16120×
                if named != blob.to_hex() {
16130×
                    bail!("the hub kept it as {named}, not {}", blob.to_hex());
16140×
                }
16150×
                let unattested = if passed { "" } else { " (the job failed: not in its receipt)" };
16160×
                note(&mut sink, format!("artifact {}: {} bytes, blob {}{unattested}", o.name, bytes.len(), &named[..12]));
16170×
                Ok(blob.0)
16180×
            });
16190×
            match kept {
16200×
                Ok(b) if passed => outputs.push(b),
16210×
                Ok(_) => {}
16220×
                Err(e) if passed => {
16230×
                    output_error = Some(format!("artifact {}: {e:#}", o.name));
16240×
                    break;
1625
                }
16260×
                Err(e) => note(&mut sink, format!("artifact {}: not kept: {e:#}", o.name)),
1627
            }
1628
        }
16290×
        if let Some(e) = &output_error {
16300×
            note(&mut sink, e.clone());
16310×
        }
16320×
        let receipt = outcome.exit_code.filter(|_| output_error.is_none()).map(|code| Receipt {
16330×
            job_name: claim.job.name.clone(),
16340×
            pipeline_hash: hex32(&claim.pipeline_hash),
16350×
            scope_tree_hash: scope_hash,
16360×
            inputs: input_blobs.clone(),
16370×
            environment: outcome.environment.hash(),
16380×
            outputs,
16390×
            exit_code: code as u32,
16400×
            duration_ms: outcome.duration_ms,
16410×
        });
16420×
        let signed_receipt = match &receipt {
16430×
            Some(r) => {
16440×
                let mut signed = sign_receipt(&repo, &heads, cfg, key, claim, r)?;
16450×
                if cfg.keyless {
16460×
                    match sign_keyless(&repo, &heads, cfg, hub, claim, r) {
16470×
                        Ok(k) => signed = k,
16480×
                        Err(e) => {
16490×
                            note(&mut sink, format!("receipt: signed with the runner's key; a short-lived key was not certified: {e:#}"))
1650
                        }
1651
                    }
16520×
                }
16530×
                Some(hex::encode(signed.encode()))
1654
            }
16550×
            None => None,
1656
        };
1657
        // Measured before a cached disk leaves the scratch directory.
16580×
        let peak_disk_mib = crate::facts::allocated_mib(&scratch);
1659
        // A passing job's cached disks are pruned and kept (#72); a
1660
        // failing job's are thrown away, so nothing it wrote reaches a
1661
        // later job.
16620×
        let kept = passed && output_error.is_none();
16630×
        for (i, key, _) in &cached {
16640×
            let disk = crate::cache::disk_path(form, &scratch, *i);
16650×
            let m = disk_mounts[*i];
16660×
            let prune = crate::cache::Prune {
16670×
                started: cache_started,
16680×
                percent: cfg.cache_prune_percent.unwrap_or(flow_init::prune::DEFAULT_PERCENT),
16690×
                guest: outcome.pruned.iter().find(|(t, _)| *t == m.target).map(|(_, p)| p),
1670
            };
16710×
            match pool.settle(&cache_ctx, m, key, &disk, &sources, kept, &prune) {
16720×
                Ok(Some(line)) => note(&mut sink, line),
16730×
                Ok(None) => {}
16740×
                Err(e) => note(&mut sink, format!("cache: not {}: {e:#}", if kept { "kept" } else { "discarded" })),
1675
            }
1676
        }
16770×
        let total_ms = started.elapsed().as_millis() as u64;
16780×
        let mut phases: std::collections::BTreeMap<String, u64> =
16790×
            [("prepare", prepare_ms), ("run", run_ms), ("finish", total_ms.saturating_sub(prepare_ms + run_ms))]
16800×
                .into_iter()
16810×
                .map(|(k, v)| (k.to_string(), v))
16820×
                .collect();
16830×
        if let Some(ms) = image_ms {
16840×
            phases.insert("image".into(), ms);
16850×
        }
1686
        Ok(Finish {
16870×
            phases,
16880×
            exit_code: outcome.exit_code,
16890×
            duration_ms: total_ms,
16900×
            receipt,
16910×
            signed_receipt,
16920×
            error: output_error.or_else(|| outcome.timed_out.then(|| "stopped at the time limit".to_string())),
1693
            // What the job took (oxygenflow#41): its disks are sparse files
1694
            // in the scratch directory, so their blocks now are their peak.
16950×
            peak_disk_mib,
16960×
            peak_memory_mib: outcome.peak_memory_mib,
1697
        })
1698
    })();
16990×
    let _ = workspace::remove_tree(&scratch);
17000×
    sink.flush();
17010×
    result
17020×
}
1703
1704
/// Bind `receipt` to the state of `heads` in `repo` and sign it as the
1705
/// runner (Oxygen spec §14.6). The state is computed here, not taken from
1706
/// the claim, so the runner vouches only for what it fetched.
1707
/// Upload a report's files, then its manifest under the report's name, and
1708
/// check the hub kept each as the blob it should (oxygenhub#127). Answers
1709
/// the manifest's blob, the file count and their total size.
17100×
fn upload_report(hub: &Hub, job: i64, name: &str, files: &[(String, Vec<u8>)]) -> Result<(oxy_core::hash::ObjectRef, usize, u64)> {
17110×
    let mut listed = Vec::with_capacity(files.len());
17120×
    let mut total = 0u64;
17130×
    for (path, bytes) in files {
17140×
        let blob = Repo::blob_ref(bytes).to_hex();
17150×
        let named = hub.report_file(job, name, path, bytes).with_context(|| format!("uploading {path}"))?;
17160×
        if named != blob {
17170×
            bail!("the hub kept {path} as {named}, not {blob}");
17180×
        }
17190×
        total += bytes.len() as u64;
17200×
        listed.push(serde_json::json!({ "path": path, "blob": blob, "size": bytes.len() }));
1721
    }
17220×
    let manifest = serde_json::to_vec(&serde_json::json!({ "version": 1, "files": listed }))?;
17230×
    let blob = Repo::blob_ref(&manifest);
17240×
    let named = hub.artifact(job, name, &manifest)?;
17250×
    if named != blob.to_hex() {
17260×
        bail!("the hub kept the manifest as {named}, not {}", blob.to_hex());
17270×
    }
17280×
    Ok((blob, files.len(), total))
17290×
}
1730
17310×
fn sign_receipt(
17320×
    repo: &Repo,
17330×
    heads: &[ChangeHash],
17340×
    cfg: &RunnerConfig,
17350×
    key: &RunnerKey,
17360×
    claim: &Claim,
17370×
    receipt: &Receipt,
17380×
) -> Result<oxy_core::receipt::SignedReceipt> {
17390×
    let state = state_of(heads);
17400×
    let run = format!("{}/{}/flow/{}", cfg.hub.trim_end_matches('/'), claim.repo, claim.run_number);
17410×
    Ok(oxy_core::receipt::SignedReceipt::sign(receipt.to_oxy(), repo.id()?, state, &run, &key.identity, &key.secret))
17420×
}
1743
1744
/// Sign `receipt` with a fresh key the hub certifies for this job now,
1745
/// just before signing, so the certificate covers the moment the hub
1746
/// accepts it (keyless, oxygenhub#241). The key is dropped with the
1747
/// receipt signed: nothing long-lived signs.
17480×
fn sign_keyless(
17490×
    repo: &Repo,
17500×
    heads: &[ChangeHash],
17510×
    cfg: &RunnerConfig,
17520×
    hub: &Hub,
17530×
    claim: &Claim,
17540×
    receipt: &Receipt,
17550×
) -> Result<oxy_core::receipt::SignedReceipt> {
17560×
    let sk = oxy_core::identity::SecretKey::generate();
17570×
    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
17580×
    let name = format!("{} job {}", cfg.name.as_deref().unwrap_or("runner"), claim.job_id);
17590×
    let id = sk.identity(&name, "", now);
17600×
    let proof = sk.sign_challenge(&id.hash(), &oxy_core::cert::certify_challenge(&claim.token)).map_err(|e| anyhow::anyhow!("{e}"))?;
17610×
    let (cert, issuer) = hub.certify(&claim.token, &id, &proof)?;
17620×
    let state = state_of(heads);
17630×
    let run = format!("{}/{}/flow/{}", cfg.hub.trim_end_matches('/'), claim.repo, claim.run_number);
17640×
    Ok(oxy_core::receipt::SignedReceipt::sign(receipt.to_oxy(), repo.id()?, state, &run, &id, &sk).with_certificate(cert, issuer))
17650×
}
1766
1767
/// The ObjectRef of the state whose heads are `heads` (Oxygen spec §10.1).
17680×
pub fn state_of(heads: &[ChangeHash]) -> oxy_core::hash::ObjectRef {
1769
    use oxy_core::hash::{hash_object, ObjectType};
17700×
    hash_object(ObjectType::State, &oxy_core::objects::StateObject::new(heads.to_vec()).encode())
17710×
}
1772
17730×
fn hex32(s: &str) -> [u8; 32] {
17740×
    let mut out = [0; 32];
17750×
    if let Ok(v) = hex::decode(s) {
17760×
        if v.len() == 32 {
17770×
            out.copy_from_slice(&v);
17780×
        }
17790×
    }
17800×
    out
17810×
}
1782
1783
/// Whether a network error is worth another try: DNS, a refused or reset
1784
/// connection, a timeout, or a gateway answering 502, 503 or 504 while the
1785
/// hub restarts (oxygenhub#111).
17868×
fn transient(msg: &str) -> bool {
17878×
    let m = msg.to_lowercase();
17888×
    [
17898×
        "dns failed",
17908×
        "name resolution",
17918×
        "connection refused",
17928×
        "connection reset",
17938×
        "timed out",
17948×
        "timeout",
17958×
        "http 502",
17968×
        "http 503",
17978×
        "http 504",
17988×
        "broken pipe",
17998×
        "unexpected eof",
18008×
    ]
18018×
    .iter()
180247×
    .any(|k| m.contains(k))
18038×
}
1804
1805
/// Run `f` again after a transient network error, waiting `waits` between
1806
/// tries (about a minute in all), and give up on any other error at once.
18073×
fn with_retries<T>(waits: &[Duration], mut f: impl FnMut() -> Result<T>) -> Result<T> {
18083×
    let mut tries = waits.iter();
1809
    loop {
18107×
        match f() {
18111×
            Ok(v) => return Ok(v),
18126×
            Err(e) => match tries.next() {
18135×
                Some(w) if transient(&format!("{e:#}")) => std::thread::sleep(*w),
18142×
                _ => return Err(e),
1815
            },
1816
        }
1817
    }
18183×
}
1819
1820
/// The waits between tries of a fetch: 2, 4, 8, 16 and 30 seconds.
1821
const FETCH_WAITS: [Duration; 5] =
1822
    [Duration::from_secs(2), Duration::from_secs(4), Duration::from_secs(8), Duration::from_secs(16), Duration::from_secs(30)];
1823
1824
/// The runner's copy of a repository, fetched up to date with the job's token.
18250×
fn cached_repo(home: &Home, cfg: &RunnerConfig, claim: &Claim) -> Result<Repo> {
18260×
    cached_repo_at(home, cfg, &claim.repo, &claim.clone_url, &claim.token)
18270×
}
1828
1829
/// The runner's copy of `repo`, cloned or fetched with `token`.
18300×
fn cached_repo_at(home: &Home, cfg: &RunnerConfig, repo_name: &str, clone_url: &str, token: &str) -> Result<Repo> {
18310×
    let dir: PathBuf = home.root.join("repos").join(repo_name.replace('/', "__"));
18320×
    let set_remote = |repo: &mut Repo| -> Result<()> {
18330×
        repo.config.set("remote.origin.url", clone_url).map_err(|e| anyhow::anyhow!("{e}"))?;
18340×
        repo.config.set("remote.origin.token", token).map_err(|e| anyhow::anyhow!("{e}"))?;
18350×
        if let Some(ca) = &cfg.ca {
18360×
            repo.config.set("remote.origin.ca", &ca.to_string_lossy()).map_err(|e| anyhow::anyhow!("{e}"))?;
18370×
        }
18380×
        repo.save_config().map_err(|e| anyhow::anyhow!("{e}"))
18390×
    };
18400×
    if dir.join(".oxy").is_dir() {
18410×
        let mut repo = Repo::open(&dir).map_err(|e| anyhow::anyhow!("{e}"))?;
18420×
        set_remote(&mut repo)?;
18430×
        with_retries(&FETCH_WAITS, || repo.fetch("origin").map(|_| ()).map_err(|e| anyhow::anyhow!("fetch {clone_url}: {e}")))?;
18440×
        return Ok(repo);
18450×
    }
1846
    // First job for this repository: clone with the job's token, which goes
1847
    // through the process environment, so one clone at a time.
18480×
    let _clone = CLONE.lock().unwrap_or_else(|p| p.into_inner());
18490×
    std::env::set_var("OXY_TOKEN", token);
18500×
    if let Some(ca) = &cfg.ca {
18510×
        std::env::set_var("OXY_CA", ca);
18520×
    }
18530×
    let cloned = with_retries(&FETCH_WAITS, || {
18540×
        let _ = std::fs::remove_dir_all(&dir);
18550×
        Repo::clone(clone_url, &dir).map_err(|e| anyhow::anyhow!("{e}"))
18560×
    });
18570×
    std::env::remove_var("OXY_TOKEN");
18580×
    std::env::remove_var("OXY_CA");
18590×
    let mut repo = cloned.map_err(|e| {
18600×
        let _ = std::fs::remove_dir_all(&dir);
18610×
        anyhow::anyhow!("clone {}: {e}", clone_url)
18620×
    })?;
18630×
    set_remote(&mut repo)?;
18640×
    Ok(repo)
18650×
}
1866
1867
#[cfg(test)]
1868
mod tests {
1869
1870
    #[test]
18711×
    fn running_jobs_show_while_they_run() {
18721×
        let a = super::Running::start(-41, "andy/demo #7 test".into());
18731×
        let b = super::Running::start(-40, "andy/demo #7 lint".into());
18741×
        let now = super::running();
18751×
        assert!(now.contains(&"andy/demo #7 test".to_string()) && now.contains(&"andy/demo #7 lint".to_string()));
18761×
        drop(a);
18771×
        assert!(!super::running().contains(&"andy/demo #7 test".to_string()));
18781×
        drop(b);
18791×
        assert!(!super::running().iter().any(|r| r.starts_with("andy/demo #7")));
18801×
    }
1881
1882
    /// #98: register keeps a copy of the CA in the home, by absolute path,
1883
    /// and keeping the home's own copy again is a no-op.
1884
    #[test]
18851×
    fn the_ca_is_kept_in_the_home_by_absolute_path() {
18861×
        let home = tempfile::tempdir().unwrap();
18871×
        let elsewhere = tempfile::tempdir().unwrap();
18881×
        let given = elsewhere.path().join("ca.pem");
18891×
        std::fs::write(&given, "PEM").unwrap();
18901×
        let kept = super::keep_ca(home.path(), &given).unwrap();
18911×
        assert!(kept.is_absolute() && kept.ends_with(crate::home::CA_PEM), "{kept:?}");
18921×
        assert_eq!(std::fs::read_to_string(&kept).unwrap(), "PEM");
18931×
        std::fs::remove_file(&given).unwrap();
18941×
        assert_eq!(super::keep_ca(home.path(), &kept).unwrap(), kept, "the home's copy again");
18951×
        assert_eq!(std::fs::read_to_string(&kept).unwrap(), "PEM");
18961×
    }
1897
1898
    #[test]
18991×
    fn an_input_is_placed_only_when_it_is_what_the_receipt_names() {
19001×
        let dir = tempfile::tempdir().unwrap();
19011×
        let bytes = b"the reviewed plan";
19021×
        let blob = Repo::blob_ref(bytes).to_hex();
19031×
        let input = |path: &str, blob: &str| flow_core::api::ArtifactInput {
19046×
            artifact: "tfplan".into(),
19056×
            from: "plan".into(),
19066×
            path: path.into(),
19076×
            blob: blob.into(),
19086×
        };
19091×
        let got = super::place_input(dir.path(), &input("/src/plans/tfplan", &blob), bytes).unwrap();
19101×
        assert_eq!(hex::encode(got), blob);
19111×
        assert_eq!(std::fs::read(dir.path().join("plans/tfplan")).unwrap(), bytes);
19121×
        let wrong = super::place_input(dir.path(), &input("/src/other", &"0".repeat(64)), bytes).unwrap_err();
19131×
        assert!(format!("{wrong:#}").contains("not what plan's receipt names"), "{wrong:#}");
19141×
        assert!(!dir.path().join("other").exists(), "nothing written when it does not match");
19154×
        for bad in ["/etc/passwd", "/src/../x", "/src/.oxy/x", "/src/"] {
19164×
            assert!(super::place_input(dir.path(), &input(bad, &blob), bytes).is_err(), "{bad}");
1917
        }
19181×
    }
1919
1920
    use super::*;
1921
1922
    #[test]
19231×
    fn a_runner_names_what_stops_a_sandbox() {
19241×
        let dir = tempfile::tempdir().unwrap();
19251×
        let home = Home::locate(Some(dir.path())).unwrap();
19261×
        let bytes = b"not really ext4";
19271×
        let hash = blake3::hash(bytes).to_hex().to_string();
19281×
        let mut sb: flow_core::Sandbox = serde_json::from_value(serde_json::json!({
19291×
            "name": "s", "runtime": "syscall", "rootfs": "ab".repeat(32), "memory_mib": 1024, "cpus": 1, "network": "isolated"
1930
        }))
19311×
        .unwrap();
19326×
        let problem = |sb: &flow_core::Sandbox, free| sandbox_problem(&home, sb, "x86_64", free);
19331×
        assert_eq!(problem(&sb, None).unwrap(), "rootfs abababababab is not on this runner");
19341×
        std::fs::write(home.rootfs(&hash), bytes).unwrap();
19351×
        sb.rootfs = hash.clone();
19361×
        assert_eq!(problem(&sb, Some(4096)), None);
19371×
        assert_eq!(problem(&sb, Some(512)).unwrap(), "only 512 MiB of memory is free here; the sandbox needs 1024 MiB");
19381×
        std::fs::write(home.image_manifest(&hash), r#"{"arch":"riscv64"}"#).unwrap();
19391×
        assert_eq!(problem(&sb, None).unwrap(), format!("rootfs {} is for riscv64; this runner is x86_64", &hash[..12]));
19401×
        sb.arch = Some("aarch64".into());
19411×
        assert_eq!(problem(&sb, None).unwrap(), "the sandbox needs aarch64; this runner is x86_64");
19421×
        sb.arch = None;
19431×
        std::fs::remove_file(home.image_manifest(&hash)).unwrap();
19441×
        std::fs::write(home.rootfs(&hash), b"changed").unwrap();
19451×
        assert!(problem(&sb, None).unwrap().contains("does not verify"));
19461×
    }
1947
1948
    #[test]
19491×
    fn a_runner_reads_an_unrecorded_images_architecture_and_declines_another() {
1950
        // An image fetched or imported with no recorded architecture, built
1951
        // for riscv64: the runner reads its shell, records it, and declines
1952
        // instead of running it and failing (spec §11.2, §11.5).
19531×
        let dir = tempfile::tempdir().unwrap();
19541×
        let home = Home::locate(Some(dir.path().join("home").as_path())).unwrap();
19551×
        let tree = dir.path().join("tree");
19561×
        std::fs::create_dir_all(tree.join("bin")).unwrap();
19571×
        let mut elf = vec![0u8; 64];
19581×
        elf[..7].copy_from_slice(b"\x7fELF\x02\x01\x01");
19591×
        elf[18..20].copy_from_slice(&0xF3u16.to_le_bytes());
19601×
        std::fs::write(tree.join("bin/sh"), &elf).unwrap();
19611×
        let img = dir.path().join("rootfs.ext4");
19621×
        let made = std::process::Command::new("mkfs.ext4").args(["-q", "-F", "-d"]).arg(&tree).arg(&img).arg("8M").status();
19631×
        if !made.is_ok_and(|s| s.success()) {
19640×
            eprintln!("mkfs.ext4 not found: skipped");
19650×
            return;
19661×
        }
19671×
        let hash = crate::home::hash_file(&img).unwrap();
19681×
        std::fs::create_dir_all(home.rootfs(&hash).parent().unwrap()).unwrap();
19691×
        std::fs::rename(&img, home.rootfs(&hash)).unwrap();
19701×
        assert!(!home.image_manifest(&hash).exists());
19711×
        let sb: flow_core::Sandbox = serde_json::from_value(serde_json::json!({
19721×
            "name": "s", "runtime": "syscall", "rootfs": hash, "memory_mib": 64, "cpus": 1, "network": "isolated"
1973
        }))
19741×
        .unwrap();
19751×
        assert_eq!(
19761×
            sandbox_problem(&home, &sb, "x86_64", None).unwrap(),
19771×
            format!("rootfs {} is for riscv64; this runner is x86_64", &hash[..12])
1978
        );
19791×
        assert_eq!(sandbox_problem(&home, &sb, "riscv64", None), None, "a riscv64 runner takes it");
1980
        // Learned: recorded once, and reported with the runner's claims.
19811×
        let manifest: serde_json::Value = serde_json::from_slice(&std::fs::read(home.image_manifest(&hash)).unwrap()).unwrap();
19821×
        assert_eq!(manifest["arch"], "riscv64");
19831×
        assert_eq!(home.image_archs().get(&hash).map(String::as_str), Some("riscv64"));
19841×
    }
1985
1986
    #[test]
19871×
    fn transient_network_errors_are_tried_again_and_others_are_not() {
19881×
        assert!(transient("fetch x: Dns Failed: resolve dns name 'oxygenhub.io:443': Temporary failure in name resolution"));
19891×
        assert!(transient("HTTP 503: the hub is restarting"));
19901×
        assert!(!transient("HTTP 404: no such repository"));
19911×
        let mut n = 0;
19923×
        let got: Result<u32> = with_retries(&[Duration::ZERO, Duration::ZERO], || {
19933×
            n += 1;
19943×
            if n < 3 {
19952×
                Err(anyhow::anyhow!("connection refused"))
1996
            } else {
19971×
                Ok(7)
1998
            }
19993×
        });
20001×
        assert_eq!((got.unwrap(), n), (7, 3), "two retries, then success");
20011×
        let mut m = 0;
20021×
        let got: Result<u32> = with_retries(&[Duration::ZERO; 4], || {
20031×
            m += 1;
20041×
            Err(anyhow::anyhow!("HTTP 403: forbidden"))
20051×
        });
20061×
        assert!(got.is_err());
20071×
        assert_eq!(m, 1, "a refusal is not tried again");
20081×
        let mut k = 0;
20093×
        let got: Result<u32> = with_retries(&[Duration::ZERO; 2], || {
20103×
            k += 1;
20113×
            Err(anyhow::anyhow!("timed out"))
20123×
        });
20131×
        assert!(got.is_err());
20141×
        assert_eq!(k, 3, "gives up after the last wait");
20151×
    }
2016
2017
    /// A hub that answers each request with the next of `codes`, counting them.
20189×
    fn fake_hub(codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
20199×
        fake_hub_on(std::net::TcpListener::bind("127.0.0.1:0").unwrap(), codes)
20209×
    }
2021
2022
    /// A hub at `listener` answering each request with the next of `codes`.
202311×
    fn fake_hub_on(listener: std::net::TcpListener, codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
2024
        use std::io::{Read, Write};
202511×
        let addr = listener.local_addr().unwrap();
202611×
        let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
202711×
        let count = seen.clone();
202811×
        std::thread::spawn(move || {
202923×
            for code in codes {
203023×
                let (mut s, _) = listener.accept().unwrap();
2031
                // Read the whole request (headers, then Content-Length bytes
2032
                // of body) before answering: closing early would reset the
2033
                // connection while the client is still sending.
203423×
                let mut req = Vec::new();
203523×
                let mut buf = [0u8; 8192];
2036
                loop {
203734×
                    let n = s.read(&mut buf).unwrap_or(0);
203834×
                    if n == 0 {
20390×
                        break;
204034×
                    }
204134×
                    req.extend_from_slice(&buf[..n]);
204234×
                    let text = String::from_utf8_lossy(&req);
204334×
                    if let Some(end) = text.find("\r\n\r\n") {
204434×
                        let len = text[..end]
204534×
                            .lines()
2046268×
                            .find_map(|l| {
2047268×
                                l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))
2048268×
                            })
204934×
                            .unwrap_or(0);
205034×
                        if req.len() >= end + 4 + len {
205123×
                            break;
205211×
                        }
20530×
                    }
2054
                }
205523×
                count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
205623×
                let reason = if code == 204 { "No Content" } else { "Error" };
205723×
                let _ = write!(s, "HTTP/1.1 {code} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
2058
            }
205911×
        });
206011×
        (format!("http://{addr}"), seen)
206111×
    }
2062
2063
    /// A hub that answers one request with `body` (status 200) and hands
2064
    /// back what it was sent.
20651×
    fn fake_json_hub(body: &'static str) -> (String, std::sync::mpsc::Receiver<String>) {
2066
        use std::io::{Read, Write};
20671×
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
20681×
        let addr = listener.local_addr().unwrap();
20691×
        let (tx, rx) = std::sync::mpsc::channel();
20701×
        std::thread::spawn(move || {
20711×
            let (mut s, _) = listener.accept().unwrap();
20721×
            let mut req = Vec::new();
20731×
            let mut buf = [0u8; 65536];
2074
            loop {
20751×
                let n = s.read(&mut buf).unwrap_or(0);
20761×
                if n == 0 {
20770×
                    break;
20781×
                }
20791×
                req.extend_from_slice(&buf[..n]);
20801×
                let text = String::from_utf8_lossy(&req);
20811×
                if let Some(end) = text.find("\r\n\r\n") {
20821×
                    let len = text[..end]
20831×
                        .lines()
20847×
                        .find_map(|l| {
20857×
                            l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))
20867×
                        })
20871×
                        .unwrap_or(0);
20881×
                    if req.len() >= end + 4 + len {
20891×
                        break;
20900×
                    }
20910×
                }
2092
            }
20931×
            tx.send(String::from_utf8_lossy(&req).into_owned()).unwrap();
20941×
            let _ = write!(
20951×
                s,
2096
                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
20971×
                body.len()
2098
            );
20991×
        });
21001×
        (format!("http://{addr}"), rx)
21011×
    }
2102
2103
    #[test]
21041×
    fn registering_binds_the_key_and_keeps_the_credential_private() {
2105
        use std::os::unix::fs::PermissionsExt;
21061×
        let dir = tempfile::tempdir().unwrap();
21071×
        let home = Home::locate(Some(dir.path())).unwrap();
21081×
        let (hub, sent) = fake_json_hub(r#"{"id":7,"name":"plex","credential":"flowrunner_secret"}"#);
21091×
        let got = register(&home, &hub, " reg-token ", "plex", None, vec!["gpu".into()]).unwrap();
21101×
        assert_eq!((got.id, got.name.as_str()), (7, "plex"));
21111×
        let req = sent.recv().unwrap();
21121×
        assert!(req.starts_with("POST /api/v1/flow/runners/register"), "{req}");
21131×
        let body: Register = serde_json::from_str(&req[req.find("\r\n\r\n").unwrap() + 4..]).unwrap();
21141×
        assert_eq!((body.token.as_str(), body.info.labels.clone()), ("reg-token", vec!["gpu".to_string()]));
21151×
        let key = home.runner_key("plex").unwrap();
21161×
        assert_eq!(body.identity, hex::encode(key.identity.encode()), "the runner's signing identity");
21171×
        let path = dir.path().join(crate::home::RUNNER_TOML);
21181×
        assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
2119
        // The runner then starts from runner.toml.
21201×
        let again = Home::locate(Some(dir.path())).unwrap();
21211×
        let cfg = again.config.runner.unwrap();
21221×
        assert_eq!((cfg.hub.as_str(), cfg.token.as_str(), cfg.name.as_deref()), (hub.as_str(), "flowrunner_secret", Some("plex")));
21231×
        assert_eq!(cfg.labels, ["gpu"]);
21241×
    }
2125
2126
    /// A hub that answers certify with a certificate `sign_as` signs for
2127
    /// `cert_key`, and publishes `published` as its key: two requests.
21283×
    fn fake_certify_hub(
21293×
        sign_as: (oxy_core::identity::Identity, oxy_core::identity::SecretKey),
21303×
        cert_key: oxy_core::identity::Identity,
21313×
        published: oxy_core::identity::Identity,
21323×
    ) -> String {
2133
        use std::io::{Read, Write};
21343×
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
21353×
        let addr = listener.local_addr().unwrap();
21363×
        std::thread::spawn(move || {
21373×
            for _ in 0..2 {
21386×
                let Ok((mut s, _)) = listener.accept() else { return };
21396×
                let mut req = Vec::new();
21406×
                let mut buf = [0u8; 65536];
2141
                loop {
21427×
                    let n = s.read(&mut buf).unwrap_or(0);
21437×
                    if n == 0 {
21440×
                        break;
21457×
                    }
21467×
                    req.extend_from_slice(&buf[..n]);
21477×
                    let text = String::from_utf8_lossy(&req);
21487×
                    if let Some(end) = text.find("\r\n\r\n") {
21497×
                        let len = text[..end]
21507×
                            .lines()
215147×
                            .find_map(|l| {
215247×
                                l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))
215347×
                            })
21547×
                            .unwrap_or(0);
21557×
                        if req.len() >= end + 4 + len {
21566×
                            break;
21571×
                        }
21580×
                    }
2159
                }
21606×
                let line = String::from_utf8_lossy(&req).lines().next().unwrap_or("").to_string();
21616×
                let body: Vec<u8> = if line.contains("/keyless/certify") {
21623×
                    let cert = oxy_core::cert::SignedCertificate::sign(
21633×
                        oxy_core::cert::Certificate {
21643×
                            key: cert_key.clone(),
21653×
                            issuer: sign_as.0.hash(),
21663×
                            subject: "repo:andy/demo:bookmark:main".into(),
21673×
                            claims: vec![],
21683×
                            scope: oxy_core::identity::Scope::SEAL,
21693×
                            not_before: 1,
21703×
                            expires: 600_001,
21713×
                            grant: None,
21723×
                        },
21733×
                        &sign_as.0,
21743×
                        &sign_as.1,
2175
                    );
21763×
                    serde_json::json!({ "certificate": oxy_core::dsse::base64(&cert.encode()) }).to_string().into_bytes()
2177
                } else {
21783×
                    published.encode()
2179
                };
21806×
                let _ = write!(s, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len());
21816×
                let _ = s.write_all(&body);
2182
            }
21833×
        });
21843×
        format!("http://{addr}")
21853×
    }
2186
2187
    /// keyless (oxygenhub#241): the runner takes a certificate only when it
2188
    /// is for its fresh key and signed by the key the hub publishes.
2189
    #[test]
21901×
    fn a_certificate_is_taken_only_for_this_key_from_the_hubs_key() {
2191
        use oxy_core::identity::SecretKey;
21921×
        let hub_sk = SecretKey::generate();
21931×
        let hub_id = hub_sk.identity("hub", "", 1);
21941×
        let sk = SecretKey::generate();
21951×
        let id = sk.identity("earth-1 job 7", "", 1);
21961×
        let proof = sk.sign_challenge(&id.hash(), &oxy_core::cert::certify_challenge("flowjob_x")).unwrap();
2197
21981×
        let base = fake_certify_hub((hub_id.clone(), hub_sk.clone()), id.clone(), hub_id.clone());
21991×
        let (cert, issuer) = hub_at(base).certify("flowjob_x", &id, &proof).unwrap();
22001×
        assert_eq!((cert.cert.key_id(), issuer.hash()), (id.hash(), hub_id.hash()));
2201
22021×
        let mallory_sk = SecretKey::generate();
22031×
        let mallory = mallory_sk.identity("mallory", "", 1);
22041×
        let base = fake_certify_hub((mallory.clone(), mallory_sk), id.clone(), hub_id.clone());
22051×
        let e = hub_at(base).certify("flowjob_x", &id, &proof).unwrap_err().to_string();
22061×
        assert!(e.contains("not signed by the hub's published key"), "{e}");
2207
22081×
        let other = SecretKey::generate().identity("other", "", 1);
22091×
        let base = fake_certify_hub((hub_id.clone(), hub_sk), other, hub_id);
22101×
        let e = hub_at(base).certify("flowjob_x", &id, &proof).unwrap_err().to_string();
22111×
        assert!(e.contains("another key"), "{e}");
22121×
    }
2213
221416×
    fn hub_at(base: String) -> Hub {
221516×
        Hub { base, token: "t".into(), agent: ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build() }
221616×
    }
2217
2218
    /// oxygenhub#322: an offered build is kept only if it is the bytes the
2219
    /// hub attests; a peer or a proxy cannot change what is installed.
2220
    #[test]
22211×
    fn a_build_is_kept_only_if_it_matches_the_hubs_blake3() {
22221×
        let (body, other) = (b"#!flow build 0.3.2".to_vec(), b"#!something else!!".to_vec());
22232×
        let serve = |bytes: Vec<u8>| {
22242×
            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
22252×
            let addr = listener.local_addr().unwrap();
22262×
            std::thread::spawn(move || {
2227
                use std::io::{Read, Write};
22282×
                let (mut s, _) = listener.accept().unwrap();
22292×
                let mut buf = [0u8; 4096];
22302×
                let _ = s.read(&mut buf);
22312×
                let _ = write!(s, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", bytes.len());
22322×
                let _ = s.write_all(&bytes);
22332×
            });
22342×
            format!("http://{addr}")
22352×
        };
22361×
        let offer = flow_core::api::UpdateOffer {
22371×
            version: "0.3.2".into(),
22381×
            asset: "flow-linux-x86_64".into(),
22391×
            url: "/andy/oxygenflow/asset/1/flow-linux-x86_64".into(),
22401×
            blake3: blake3::hash(&body).to_hex().to_string(),
22411×
            size: body.len() as u64,
22421×
            ..Default::default()
22431×
        };
22441×
        let dir = tempfile::tempdir().unwrap();
22451×
        let to = dir.path().join("flow-0.3.2");
22461×
        hub_at(serve(body.clone())).download_build(&offer, &to).unwrap();
22471×
        assert_eq!(std::fs::read(&to).unwrap(), body);
22481×
        std::fs::remove_file(&to).unwrap();
22491×
        let e = hub_at(serve(other)).download_build(&offer, &to).unwrap_err();
22501×
        assert!(e.to_string().contains("does not match what the hub attests"), "{e:#}");
22511×
        assert!(!to.exists() && !to.with_extension("partial").exists(), "nothing of it is left");
22521×
    }
2253
2254
    /// oxygenhub#71: a job's result is offered until the hub takes it,
2255
    /// through server errors, and a refusal ends the trying at once.
2256
    #[test]
22571×
    fn a_result_is_reported_until_the_hub_takes_it() {
22581×
        let f = Finish {
22591×
            exit_code: Some(0),
22601×
            duration_ms: 1,
22611×
            receipt: None,
22621×
            signed_receipt: None,
22631×
            error: None,
22641×
            peak_disk_mib: None,
22651×
            peak_memory_mib: None,
22661×
            phases: Default::default(),
22671×
        };
22681×
        let quick = Duration::from_millis(10);
22691×
        let (base, seen) = fake_hub(vec![502, 503, 500, 500, 204]);
22701×
        hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).unwrap();
22711×
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 5, "kept trying through four server errors");
22721×
        let (base, seen) = fake_hub(vec![409]);
22731×
        assert!(hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).is_err());
22741×
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final");
22751×
    }
2276
2277
    #[test]
22781×
    fn server_errors_are_tried_again_and_client_errors_are_not() {
22791×
        let quick = [Duration::from_millis(10); 3];
22801×
        let (base, seen) = fake_hub(vec![500, 503, 204]);
22811×
        let r = hub_at(base).post_with("/flow/jobs/1/log", &serde_json::json!({}), &quick).unwrap();
22821×
        assert!(r.is_none());
22831×
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 3, "two retries, then accepted");
2284
22851×
        let (base, seen) = fake_hub(vec![500, 500, 500, 500]);
22861×
        let e = hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).unwrap_err().to_string();
22871×
        assert!(e.contains("HTTP 500"), "{e}");
22881×
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 4, "gives up after the last delay");
2289
22901×
        let (base, seen) = fake_hub(vec![401]);
22911×
        assert!(hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).is_err());
22921×
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final");
22931×
    }
2294
2295
    static NOT_LEAVING: std::sync::Mutex<Option<Leave>> = std::sync::Mutex::new(None);
2296
2297
    /// oxygenflow#76: once the runner leaves, a job's sink says it is
2298
    /// cancelled, having sent what the job said so far, and the job is
2299
    /// handed back to the hub.
2300
    #[test]
23011×
    fn a_leaving_runner_stops_its_job_and_hands_it_back() {
2302
        use microvm::Sink;
23031×
        let leaving = std::sync::Mutex::new(None);
23041×
        let (base, seen) = fake_hub(vec![204, 204]);
23051×
        let hub = hub_at(base);
23061×
        let mut sink = LogSink {
23071×
            hub: &hub,
23081×
            job: 7,
23091×
            buf: Vec::new(),
23101×
            last: Instant::now(),
23111×
            gone: false,
23121×
            leaving: &leaving,
23131×
            started: Instant::now(),
23141×
            expected: None,
23151×
        };
23161×
        sink.buf.push(Record { t: 1, stream: "out".into(), text: "step 1".into() });
23171×
        assert!(!sink.cancelled());
23181×
        *leaving.lock().unwrap() = Some(Leave { reason: "off Wi-Fi".into(), by: Instant::now() });
23191×
        assert!(sink.cancelled());
23201×
        assert!(sink.buf.is_empty(), "flushed before the job stops");
23211×
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1);
23221×
        hub.release(7, "off Wi-Fi").unwrap();
23231×
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2);
23241×
    }
2325
2326
    /// oxygenflow#76: with notice, a job the hub expects to finish within
2327
    /// it runs on; one that will not, or whose time is unknown, is handed
2328
    /// back at once, and any job still running when the notice is up.
2329
    #[test]
23301×
    fn a_job_that_fits_the_notice_finishes_and_the_others_go_back() {
2331
        use microvm::Sink;
23321×
        let (base, _) = fake_hub(vec![204; 8]);
23331×
        let hub = hub_at(base);
23341×
        let leaving = std::sync::Mutex::new(Some(Leave { reason: "off charge".into(), by: Instant::now() + Duration::from_secs(60) }));
23351×
        let sink = |expected: Option<u64>, ran: u64| LogSink {
23364×
            hub: &hub,
2337
            job: 1,
23384×
            buf: Vec::new(),
23394×
            last: Instant::now(),
2340
            gone: false,
23414×
            leaving: &leaving,
23424×
            started: Instant::now() - Duration::from_secs(ran),
23434×
            expected: expected.map(Duration::from_secs),
23444×
        };
23451×
        assert!(!sink(Some(70), 30).cancelled(), "40 s left of it, within a minute: it finishes");
23461×
        assert!(sink(Some(600), 30).cancelled(), "nearly ten minutes left: handed back");
23471×
        assert!(sink(None, 0).cancelled(), "no idea how long: handed back");
23481×
        *leaving.lock().unwrap() = Some(Leave { reason: "off charge".into(), by: Instant::now() - Duration::from_secs(1) });
23491×
        assert!(sink(Some(70), 30).cancelled(), "the notice is up: handed back whatever was expected");
23501×
    }
2351
2352
    /// oxygenflow#32: a quiet job asks the hub every few seconds whether
2353
    /// it still runs; a 409 marks it cancelled, and nothing more is sent.
2354
    #[test]
23551×
    fn a_quiet_job_learns_that_its_run_was_cancelled() {
2356
        use microvm::Sink;
23572×
        let n = |seen: &std::sync::Arc<std::sync::atomic::AtomicUsize>| seen.load(std::sync::atomic::Ordering::SeqCst);
23581×
        let quiet = Instant::now() - HEARTBEAT - Duration::from_secs(1);
23591×
        let (base, seen) = fake_hub(vec![204]);
23601×
        let hub = hub_at(base);
23611×
        let mut sink = LogSink {
23621×
            hub: &hub,
23631×
            job: 1,
23641×
            buf: Vec::new(),
23651×
            last: quiet,
23661×
            gone: false,
23671×
            leaving: &NOT_LEAVING,
23681×
            started: Instant::now(),
23691×
            expected: None,
23701×
        };
23711×
        assert!(!sink.cancelled());
23721×
        assert!(!sink.cancelled(), "asked again only after the heartbeat");
23731×
        assert_eq!(n(&seen), 1);
2374
23751×
        let (base, seen) = fake_hub(vec![409]);
23761×
        let hub = hub_at(base);
23771×
        let mut sink = LogSink {
23781×
            hub: &hub,
23791×
            job: 1,
23801×
            buf: Vec::new(),
23811×
            last: quiet,
23821×
            gone: false,
23831×
            leaving: &NOT_LEAVING,
23841×
            started: Instant::now(),
23851×
            expected: None,
23861×
        };
23871×
        assert!(sink.cancelled());
23881×
        sink.record(Record { t: 0, stream: "out".into(), text: "late".into() });
23891×
        sink.flush();
23901×
        assert!(sink.cancelled());
23911×
        assert_eq!(n(&seen), 1, "nothing is sent for a cancelled job");
23921×
    }
2393
2394
    /// oxygenflow#49: a hub that is away for a while (a deploy restarting
2395
    /// it) costs a job's uploads a wait, not the upload, and costs its log
2396
    /// no lines.
2397
    #[test]
23981×
    fn a_job_outlasts_a_hub_restart() {
23992×
        let n = |seen: &std::sync::Arc<std::sync::atomic::AtomicUsize>| seen.load(std::sync::atomic::Ordering::SeqCst);
2400
        // A port nobody listens on yet: the hub is restarting.
24011×
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
24021×
        let base = format!("http://127.0.0.1:{port}");
24031×
        let hub = hub_at(base.clone());
2404
2405
        // Log lines sent while it is away are kept, and go when it is back.
24061×
        let mut sink = LogSink {
24071×
            hub: &hub,
24081×
            job: 1,
24091×
            buf: Vec::new(),
24101×
            last: Instant::now(),
24111×
            gone: false,
24121×
            leaving: &NOT_LEAVING,
24131×
            started: Instant::now(),
24141×
            expected: None,
24151×
        };
2416450×
        for i in 0..450 {
2417450×
            sink.buf.push(Record { t: i, stream: "out".into(), text: format!("line {i}") });
2418450×
        }
24191×
        sink.send();
24201×
        assert_eq!(sink.buf.len(), 450, "nothing lost while the hub is away");
24211×
        let (_, seen) = fake_hub_on(std::net::TcpListener::bind(("127.0.0.1", port)).unwrap(), vec![204, 204, 204]);
24221×
        sink.send();
24231×
        assert!(sink.buf.is_empty() && !sink.gone);
24241×
        assert_eq!(n(&seen), 3, "in batches of 200: 200, 200 and 50");
2425
2426
        // An upload waits for the hub to come back, through refused
2427
        // connections and server errors alike.
24281×
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
24291×
        let url = format!("http://127.0.0.1:{port}/x");
24301×
        let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build();
24311×
        let later = std::thread::spawn(move || {
24321×
            std::thread::sleep(Duration::from_millis(150));
24331×
            fake_hub_on(std::net::TcpListener::bind(("127.0.0.1", port)).unwrap(), vec![502, 204])
24341×
        });
24351×
        let quick = [Duration::from_millis(50); 20];
24365×
        let r = retrying(&url, &quick, true, || agent.post(&url).send_string("{}").map_err(Box::new)).unwrap();
24371×
        assert_eq!(r.status(), 204);
24381×
        assert_eq!(n(&later.join().unwrap().1), 2, "a 502 from the proxy, then the hub");
2439
        // Without `connection`, a hub that cannot be reached ends it at once.
24401×
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
24411×
        let url = format!("http://127.0.0.1:{port}/x");
24421×
        assert!(retrying(&url, &quick, false, || agent.post(&url).send_string("{}").map_err(Box::new)).is_err());
24431×
        assert!(PATIENT_DELAYS.iter().sum::<Duration>() >= Duration::from_secs(180), "longer than a hub takes to restart");
24441×
    }
2445
2446
    /// oxygenflow#50: the first stop drains (the second, not called here,
2447
    /// exits at once).
2448
    #[test]
24491×
    fn a_stop_drains_the_runner() {
24501×
        assert!(!stopping());
24511×
        on_stop(libc::SIGTERM);
24521×
        assert!(stopping(), "told to stop, it claims nothing more");
2453
        // An embedded runner starts afresh, and stop() drains it the same way.
24541×
        STOPS.store(0, std::sync::atomic::Ordering::SeqCst);
24551×
        stop();
24561×
        assert!(stopping());
24571×
        STOPS.store(0, std::sync::atomic::Ordering::SeqCst);
24581×
    }
2459
2460
    /// Lines kept for a hub that stays away are bounded, saying how many went.
2461
    #[test]
24621×
    fn unsent_log_lines_are_bounded() {
24631×
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
24641×
        let hub = hub_at(format!("http://127.0.0.1:{port}"));
24651×
        let mut sink = LogSink {
24661×
            hub: &hub,
24671×
            job: 1,
24681×
            buf: Vec::new(),
24691×
            last: Instant::now(),
24701×
            gone: false,
24711×
            leaving: &NOT_LEAVING,
24721×
            started: Instant::now(),
24731×
            expected: None,
24741×
        };
247550010×
        sink.buf = (0..MAX_UNSENT as u64 + 10).map(|i| Record { t: i, stream: "out".into(), text: String::new() }).collect();
24761×
        sink.send();
24771×
        assert_eq!(sink.buf.len(), MAX_UNSENT + 1);
24781×
        assert_eq!(sink.buf[0].text, "(10 log lines lost while the hub could not be reached)");
24791×
        assert_eq!(sink.buf[1].t, 10, "the oldest go first");
24801×
    }
2481
2482
    #[test]
24831×
    fn the_budget_admits_jobs_that_fit() {
24841×
        let b = Budget { max_jobs: 3, memory_mib: Some(24_576), cpus: Some(8) };
24857×
        let used = |jobs, memory_mib, cpus| Used { jobs, memory_mib, cpus };
24861×
        assert_eq!(b.room(used(0, 0, 0)), Some((None, None)), "a lone job may take everything");
24871×
        assert_eq!(b.room(used(1, 8192, 4)), Some((Some(16_384), Some(4))));
24881×
        assert_eq!(b.room(used(2, 16_384, 8)), None, "no vCPU left");
24891×
        assert_eq!(b.room(used(3, 1024, 1)), None, "at max_jobs");
24901×
        let one = Budget { max_jobs: 1, memory_mib: None, cpus: None };
24911×
        assert_eq!(one.room(used(0, 0, 0)), Some((None, None)));
24921×
        assert_eq!(one.room(used(1, 0, 0)), None, "the default runs one job at a time");
24931×
        let open = Budget { max_jobs: 2, memory_mib: None, cpus: None };
24941×
        assert_eq!(open.room(used(1, 8192, 4)), Some((None, None)), "no limits configured");
24951×
    }
2496
2497
    #[test]
24981×
    fn a_claim_never_asks_for_more_memory_than_the_host_has_free() {
24991×
        assert_eq!(claim_memory(None, Some(6541)), Some(6541), "idle: the host's free memory, not unlimited");
25001×
        assert_eq!(claim_memory(Some(16_384), Some(6541)), Some(6541));
25011×
        assert_eq!(claim_memory(Some(4096), Some(6541)), Some(4096), "the budget when it is smaller");
25021×
        assert_eq!(claim_memory(None, None), None, "nothing known: no limit, as before");
25031×
        assert_eq!(claim_memory(Some(4096), None), Some(4096));
25041×
    }
2505
}