OXYGENHUB

feat(runner): restarts cost no job (closes #49, closes #50) 89171fca079c

A job's uploads and declines wait out a hub that is away for up to three
minutes, through refused connections as well as server errors, so a hub
deploy no longer has to wait for idle runners; log lines the hub could not
take are kept and sent with the next batch, 200 at a time, up to 50,000.
Claims still give up at once on a lost connection, so no job is claimed
twice.

The first SIGTERM or SIGINT drains the runner: it claims nothing more and
exits once the jobs it holds are done and reported; a second stops it at
once. With KillMode=mixed and a TimeoutStopSec past the longest job in its
unit, make deploy-runner restarts it without blocking and kills nothing.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-13 14:46 UTC
Signature
✔ signed by Andy McCall
Hash
89171fca079c76d2eeee185ded4654827fea8834b4e6c5fdc2a337465af1e43f
Depends on
1b2229dab1a4 280ef951d193 3007d8f011fa 47c1d64dec8a 62829d15ef09 630ee66034b2 6d53fb7d00ec a083cc802892 a67b97e9a717 b11e30f5031d b262a103bccd bb643c252515 c3db750ed96e d60aac6c3f8a e0918b887850 fc10d9e80383 fd2d4822f7f3 fe128fa67a06
Depended on by
4dd392d16454 85275e1ef3b7 f201e1bc5bcd 1b160d372d9e 06a4c472b385 5cf1e8aefbb6 a4b3f8cb9aae 2be1fe4ad345 46fe2c815cc3 82cf6e91d398
In bookmarks
main
In tags
v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
Edits
28 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Restarts cost no job (#49, #50, spec §11.5.1).** A job's uploads and declines wait out a hub that is away for up to three minutes (a deploy restarting it), through refused connections as well as server errors, and log lines it could not send go with the next batch instead of being dropped. A runner told to stop drains: it claims nothing more, finishes and reports the jobs it holds, then exits; a second signal stops it at once. With `KillMode=mixed` and a `TimeoutStopSec` longer than the longest job in its unit, deploying a runner no longer has to wait for it to be idle.
8 +
7 9
- **A build cache (#28, spec §5.4).** A disk mount may name a `cache` and the `key` files it depends on, such as `Cargo.lock`. The runner keeps passing jobs' disks and starts the next job of the same repository, job and bookmark from one, so builds carry their compiled dependencies forward; another bookmark copies it rather than taking it. Failing jobs' disks are never kept, the receipt names what a disk started from, and the pool stays under `cache_gb` (default 64 GiB).
8 10
9 11
- **Runners share images (#47, docs/jetstream.md §8.1).** A runner with a `[runner.swarm]` section serves the image chunks it holds to other runners in its site, and fetches an image it lacks from them and the hub together, rarest chunks first. The hub is asked only for chunks no peer has or is fetching, so runners starting together split its work. Peers talk over TLS: each runner keeps its own certificate, the hub hands its fingerprint on with the peer, and a fetcher trusts no other. Peers admit each other with a ticket the hub signs, and every chunk is checked against its hash. Uploads are capped (`upload_mib_s`, default 50) at 16 connections. Without the section a runner fetches from the hub alone, as before.
…
28 unchanged lines
modified Makefile +1 −1
…
21 unchanged lines
22 22
		mv bin/flow.new bin/flow; mv bin/flow-init.new bin/flow-init; \
23 23
		h=$$(bin/flow hash bin/flow-init | cut -d" " -f1); \
24 24
		sed -i "s|^init = .*|init = { path = \"bin/flow-init\", blake3 = \"$$h\" }|" flow.toml; \
25 -
		sudo systemctl restart flow-runner; systemctl is-active flow-runner'
25 +
		sudo systemctl restart --no-block flow-runner; echo "flow-runner: restarting (it drains: the job it holds finishes first)"'
26 26
27 27
# Move the pinned oxygen (deps/oxygen) to its main on Earth; the next commit
28 28
# records the new pin. Push oxygen first.
…
3 unchanged lines
modified crates/flow/src/runner.rs +229 −67
…
71 unchanged lines
72 72
73 73
    fn post_with(&self, path: &str, body: &impl serde::Serialize, delays: &[Duration]) -> Result<Option<ureq::Response>> {
74 74
        let url = format!("{}/api/v1{path}", self.base);
75 -
        let mut delays = delays.iter();
76 -
        loop {
77 -
            match self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(body) {
78 -
                Ok(r) if r.status() == 204 => return Ok(None),
79 -
                Ok(r) => return Ok(Some(r)),
80 -
                Err(ureq::Error::Status(code, r)) => {
81 -
                    let text = r.into_string().unwrap_or_default();
82 -
                    match delays.next() {
83 -
                        Some(d) if code >= 500 => std::thread::sleep(*d),
84 -
                        _ if code < 500 => return Err(anyhow::Error::new(Refused { code, text }).context(url)),
85 -
                        _ => bail!("{url}: HTTP {code}: {text}"),
86 -
                    }
87 -
                }
88 -
                Err(e) => bail!("{url}: {e}"),
89 -
            }
90 -
        }
75 +
        let r = retrying(&url, delays, false, || {
76 +
            self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(body).map_err(Box::new)
77 +
        })?;
78 +
        Ok(if r.status() == 204 { None } else { Some(r) })
79 +
    }
80 +
81 +
    /// POST what a job leaves behind (oxygenflow#49): through a hub that is
82 +
    /// away for a few minutes, as while a deploy restarts it, so a job's
83 +
    /// declines and uploads outlast it.
84 +
    fn post_patiently(&self, path: &str, body: &impl serde::Serialize) -> Result<Option<ureq::Response>> {
85 +
        let url = format!("{}/api/v1{path}", self.base);
86 +
        let r = retrying(&url, &PATIENT_DELAYS, true, || {
87 +
            self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(body).map_err(Box::new)
88 +
        })?;
89 +
        Ok(if r.status() == 204 { None } else { Some(r) })
91 90
    }
92 91
93 92
    pub fn claim(&self, req: &ClaimRequest) -> Result<Option<Claim>> {
…
5 unchanged lines
99 98
100 99
    /// Hand job `job` back, saying why this runner will not run it.
101 100
    pub fn decline(&self, job: i64, reason: &str) -> Result<()> {
102 -
        self.post(&format!("/flow/jobs/{job}/decline"), &Decline { reason: reason.into() })?;
101 +
        self.post_patiently(&format!("/flow/jobs/{job}/decline"), &Decline { reason: reason.into() })?;
103 102
        Ok(())
104 103
    }
105 104
…
6 unchanged lines
112 111
    /// a blob of the job's repository and answers with its ObjectRef.
113 112
    pub fn artifact(&self, job: i64, name: &str, bytes: &[u8]) -> Result<String> {
114 113
        let url = format!("{}/api/v1/flow/jobs/{job}/artifacts/{name}", self.base);
115 -
        let mut delays = RETRY_DELAYS.iter();
116 -
        loop {
117 -
            let req = self
118 -
                .agent
114 +
        let r = retrying(&url, &PATIENT_DELAYS, true, || {
115 +
            self.agent
119 116
                .post(&url)
120 117
                .timeout(Duration::from_secs(600))
121 118
                .set("Authorization", &format!("Bearer {}", self.token))
122 -
                .set("Content-Type", "application/octet-stream");
123 -
            match req.send_bytes(bytes) {
124 -
                Ok(r) => {
125 -
                    let v: serde_json::Value = r.into_json()?;
126 -
                    return v["blob"].as_str().map(String::from).context("the hub named no blob");
127 -
                }
128 -
                Err(ureq::Error::Status(code, r)) => {
129 -
                    let text = r.into_string().unwrap_or_default();
130 -
                    match delays.next() {
131 -
                        Some(d) if code >= 500 => std::thread::sleep(*d),
132 -
                        _ => bail!("{url}: HTTP {code}: {text}"),
133 -
                    }
134 -
                }
135 -
                Err(e) => bail!("{url}: {e}"),
136 -
            }
137 -
        }
119 +
                .set("Content-Type", "application/octet-stream")
120 +
                .send_bytes(bytes)
121 +
                .map_err(Box::new)
122 +
        })?;
123 +
        let v: serde_json::Value = r.into_json()?;
124 +
        v["blob"].as_str().map(String::from).context("the hub named no blob")
138 125
    }
139 126
140 127
    /// Upload file `path` of report `name` of job `job` (oxygenhub#127);
…
1 unchanged lines
142 129
    /// report's manifest follows through [`Hub::artifact`].
143 130
    pub fn report_file(&self, job: i64, name: &str, path: &str, bytes: &[u8]) -> Result<String> {
144 131
        let url = format!("{}/api/v1/flow/jobs/{job}/reports/{name}/files", self.base);
145 -
        let mut delays = RETRY_DELAYS.iter();
146 -
        loop {
147 -
            let req = self
148 -
                .agent
132 +
        let r = retrying(&url, &PATIENT_DELAYS, true, || {
133 +
            self.agent
149 134
                .post(&url)
150 135
                .timeout(Duration::from_secs(600))
151 136
                .set("Authorization", &format!("Bearer {}", self.token))
152 137
                .set("Content-Type", "application/octet-stream")
153 -
                .set("X-Flow-Report-Path", path);
154 -
            match req.send_bytes(bytes) {
155 -
                Ok(r) => {
156 -
                    let v: serde_json::Value = r.into_json()?;
157 -
                    return v["blob"].as_str().map(String::from).context("the hub named no blob");
158 -
                }
159 -
                Err(ureq::Error::Status(code, r)) => {
160 -
                    let text = r.into_string().unwrap_or_default();
161 -
                    match delays.next() {
162 -
                        Some(d) if code >= 500 => std::thread::sleep(*d),
163 -
                        _ => bail!("{url}: HTTP {code}: {text}"),
164 -
                    }
165 -
                }
166 -
                Err(e) => bail!("{url}: {e}"),
167 -
            }
168 -
        }
138 +
                .set("X-Flow-Report-Path", path)
139 +
                .send_bytes(bytes)
140 +
                .map_err(Box::new)
141 +
        })?;
142 +
        let v: serde_json::Value = r.into_json()?;
143 +
        v["blob"].as_str().map(String::from).context("the hub named no blob")
169 144
    }
170 145
171 146
    /// Report a job's end until the hub has it (oxygenhub#71): while the hub
…
110 unchanged lines
282 257
283 258
/// Waits before trying a hub request again after a server error.
284 259
const RETRY_DELAYS: [Duration; 3] = [Duration::from_secs(1), Duration::from_secs(2), Duration::from_secs(4)];
260 +
261 +
/// Waits for what a job leaves behind (oxygenflow#49): three minutes in
262 +
/// all, longer than a hub takes to restart.
263 +
const PATIENT_DELAYS: [Duration; 15] = {
264 +
    let mut d = [Duration::from_secs(15); 15];
265 +
    d[0] = Duration::from_secs(1);
266 +
    d[1] = Duration::from_secs(2);
267 +
    d[2] = Duration::from_secs(4);
268 +
    d[3] = Duration::from_secs(8);
269 +
    d
270 +
};
271 +
272 +
/// Send a request until the hub answers, waiting `delays` in turn after a
273 +
/// server error (5xx: nothing was committed) and, when `connection`, after
274 +
/// the hub could not be reached at all. A refusal (4xx) is final, as a
275 +
/// [`Refused`] the caller can look at.
276 +
fn retrying(
277 +
    url: &str,
278 +
    delays: &[Duration],
279 +
    connection: bool,
280 +
    mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
281 +
) -> Result<ureq::Response> {
282 +
    let mut delays = delays.iter();
283 +
    loop {
284 +
        let why = match send().map_err(|e| *e) {
285 +
            Ok(r) => return Ok(r),
286 +
            Err(ureq::Error::Status(code, r)) if code < 500 => {
287 +
                let text = r.into_string().unwrap_or_default();
288 +
                return Err(anyhow::Error::new(Refused { code, text }).context(url.to_string()));
289 +
            }
290 +
            Err(ureq::Error::Status(code, r)) => format!("HTTP {code}: {}", r.into_string().unwrap_or_default()),
291 +
            Err(e) if connection => e.to_string(),
292 +
            Err(e) => bail!("{url}: {e}"),
293 +
        };
294 +
        match delays.next() {
295 +
            Some(d) => std::thread::sleep(*d),
296 +
            None => bail!("{url}: {why}"),
297 +
        }
298 +
    }
299 +
}
285 300
286 301
/// What the runner may run at once (flow.toml `[runner]`): at most
287 302
/// `max_jobs` jobs, whose sandboxes together fit `memory_mib` and `cpus`.
…
175 unchanged lines
463 478
    let hub = Hub::new(&cfg)?;
464 479
    let name = cfg.name.clone().unwrap_or_else(|| "flow".into());
465 480
    let budget = Budget::of(&cfg);
481 +
    drain_on_stop();
466 482
    let key = home.runner_key(&name)?;
467 483
    // The swarm (docs/jetstream.md §8.1): a runner that cannot serve its
468 484
    // peers still runs jobs, fetching images from the hub alone.
…
34 unchanged lines
503 519
        };
504 520
        let mut speed = measure();
505 521
        let mut benched = Instant::now();
522 +
        let mut said_draining = false;
506 523
        loop {
524 +
            // Draining (oxygenflow#50): told to stop, claim nothing more and
525 +
            // stop once every job held is done and reported.
526 +
            if stopping() {
527 +
                let (jobs, holding) = (used.lock().unwrap().jobs, held.lock().unwrap().len());
528 +
                if jobs == 0 && holding == 0 {
529 +
                    eprintln!("flow runner {name}: drained, stopping");
530 +
                    break;
531 +
                }
532 +
                if !said_draining {
533 +
                    eprintln!("flow runner {name}: draining: claiming nothing more, stopping when the {jobs} job(s) held are done (stop again to stop now)");
534 +
                    said_draining = true;
535 +
                }
536 +
                std::thread::sleep(Duration::from_millis(500));
537 +
                continue;
538 +
            }
507 539
            if used.lock().unwrap().jobs == 0 && benched.elapsed() >= Duration::from_secs(15 * 60) {
508 540
                speed = measure();
509 541
                benched = Instant::now();
…
85 unchanged lines
595 627
                }
596 628
            }
597 629
        }
630 +
        Ok(())
598 631
    })
632 +
}
633 +
634 +
/// How many times the runner was told to stop (SIGTERM or SIGINT).
635 +
static STOPS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
636 +
637 +
extern "C" fn on_stop(_: libc::c_int) {
638 +
    // A second stop means now: only async-signal-safe calls here.
639 +
    if STOPS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) >= 1 {
640 +
        unsafe { libc::_exit(143) };
641 +
    }
642 +
}
643 +
644 +
/// Drain instead of dying on the first SIGTERM or SIGINT (oxygenflow#50),
645 +
/// so restarting a runner never kills the job it runs: the unit's
646 +
/// `KillMode=mixed` sends the signal to the runner alone, not its microVMs.
647 +
fn drain_on_stop() {
648 +
    let handler = on_stop as extern "C" fn(libc::c_int) as libc::sighandler_t;
649 +
    unsafe {
650 +
        libc::signal(libc::SIGTERM, handler);
651 +
        libc::signal(libc::SIGINT, handler);
652 +
    }
599 653
}
600 654
655 +
fn stopping() -> bool {
656 +
    STOPS.load(std::sync::atomic::Ordering::SeqCst) > 0
657 +
}
658 +
601 659
/// Batches records to the hub every second (or every 200 lines). A quiet
602 660
/// job's sink still asks the hub every few seconds whether the job runs,
603 661
/// with an empty batch; once the hub answers 409 the job is not wanted.
…
4 unchanged lines
608 666
    last: Instant,
609 667
    gone: bool,
610 668
}
669 +
670 +
/// The most log lines sent at once, as a busy job's sink flushes them.
671 +
const BATCH: usize = 200;
611 672
673 +
/// The most log lines kept while the hub cannot be reached.
674 +
const MAX_UNSENT: usize = 50_000;
675 +
612 676
/// How often a quiet job asks whether it was cancelled.
613 677
const HEARTBEAT: Duration = Duration::from_secs(5);
614 678
…
23 unchanged lines
638 702
            r.text.truncate(cut);
639 703
        }
640 704
        self.buf.push(r);
641 -
        if self.buf.len() >= 200 || self.last.elapsed() >= Duration::from_secs(1) {
705 +
        if self.buf.len() >= BATCH || self.last.elapsed() >= Duration::from_secs(1) {
642 706
            self.flush();
643 707
        }
644 708
    }
…
5 unchanged lines
650 714
        self.last = Instant::now();
651 715
    }
652 716
653 -
    /// Send what is buffered, even nothing.
717 +
    /// Send what is buffered, even nothing, in batches of at most
718 +
    /// `BATCH` lines.
654 719
    fn send(&mut self) {
655 -
        if self.gone {
656 -
            return;
657 -
        }
658 -
        match self.hub.log(self.job, std::mem::take(&mut self.buf)) {
659 -
            Ok(()) => {}
660 -
            Err(e) if job_gone(&e) => self.gone = true,
661 -
            Err(e) => eprintln!("job {}: log: {e:#}", self.job),
720 +
        loop {
721 +
            if self.gone {
722 +
                return;
723 +
            }
724 +
            let n = self.buf.len().min(BATCH);
725 +
            let batch: Vec<Record> = self.buf.drain(..n).collect();
726 +
            match self.hub.log(self.job, batch.clone()) {
727 +
                Ok(()) => {}
728 +
                Err(e) if job_gone(&e) => self.gone = true,
729 +
                Err(e) => {
730 +
                    // The hub is away (a restart, say): keep the lines for
731 +
                    // the next batch, up to a bound, so the log loses none
732 +
                    // (oxygenflow#49).
733 +
                    eprintln!("job {}: log: {e:#}; kept for the next batch", self.job);
734 +
                    let mut kept = batch;
735 +
                    kept.append(&mut self.buf);
736 +
                    let over = kept.len().saturating_sub(MAX_UNSENT);
737 +
                    if over > 0 {
738 +
                        kept.drain(..over);
739 +
                        kept.insert(
740 +
                            0,
741 +
                            Record {
742 +
                                t: 0,
743 +
                                stream: "flow".into(),
744 +
                                text: format!("({over} log lines lost while the hub could not be reached)"),
745 +
                            },
746 +
                        );
747 +
                    }
748 +
                    self.buf = kept;
749 +
                    break;
750 +
                }
751 +
            }
752 +
            if self.buf.is_empty() {
753 +
                break;
754 +
            }
662 755
        }
663 756
        self.last = Instant::now();
664 757
    }
…
407 unchanged lines
1072 1165
1073 1166
    /// A hub that answers each request with the next of `codes`, counting them.
1074 1167
    fn fake_hub(codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
1168 +
        fake_hub_on(std::net::TcpListener::bind("127.0.0.1:0").unwrap(), codes)
1169 +
    }
1170 +
1171 +
    /// A hub at `listener` answering each request with the next of `codes`.
1172 +
    fn fake_hub_on(listener: std::net::TcpListener, codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
1075 1173
        use std::io::{Read, Write};
1076 -
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1077 1174
        let addr = listener.local_addr().unwrap();
1078 1175
        let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1079 1176
        let count = seen.clone();
…
139 unchanged lines
1219 1316
        sink.flush();
1220 1317
        assert!(sink.cancelled());
1221 1318
        assert_eq!(n(&seen), 1, "nothing is sent for a cancelled job");
1319 +
    }
1320 +
1321 +
    /// oxygenflow#49: a hub that is away for a while (a deploy restarting
1322 +
    /// it) costs a job's uploads a wait, not the upload, and costs its log
1323 +
    /// no lines.
1324 +
    #[test]
1325 +
    fn a_job_outlasts_a_hub_restart() {
1326 +
        let n = |seen: &std::sync::Arc<std::sync::atomic::AtomicUsize>| seen.load(std::sync::atomic::Ordering::SeqCst);
1327 +
        // A port nobody listens on yet: the hub is restarting.
1328 +
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
1329 +
        let base = format!("http://127.0.0.1:{port}");
1330 +
        let hub = hub_at(base.clone());
1331 +
1332 +
        // Log lines sent while it is away are kept, and go when it is back.
1333 +
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: Instant::now(), gone: false };
1334 +
        for i in 0..450 {
1335 +
            sink.buf.push(Record { t: i, stream: "out".into(), text: format!("line {i}") });
1336 +
        }
1337 +
        sink.send();
1338 +
        assert_eq!(sink.buf.len(), 450, "nothing lost while the hub is away");
1339 +
        let (_, seen) = fake_hub_on(std::net::TcpListener::bind(("127.0.0.1", port)).unwrap(), vec![204, 204, 204]);
1340 +
        sink.send();
1341 +
        assert!(sink.buf.is_empty() && !sink.gone);
1342 +
        assert_eq!(n(&seen), 3, "in batches of 200: 200, 200 and 50");
1343 +
1344 +
        // An upload waits for the hub to come back, through refused
1345 +
        // connections and server errors alike.
1346 +
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
1347 +
        let url = format!("http://127.0.0.1:{port}/x");
1348 +
        let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build();
1349 +
        let later = std::thread::spawn(move || {
1350 +
            std::thread::sleep(Duration::from_millis(150));
1351 +
            fake_hub_on(std::net::TcpListener::bind(("127.0.0.1", port)).unwrap(), vec![502, 204])
1352 +
        });
1353 +
        let quick = [Duration::from_millis(50); 20];
1354 +
        let r = retrying(&url, &quick, true, || agent.post(&url).send_string("{}").map_err(Box::new)).unwrap();
1355 +
        assert_eq!(r.status(), 204);
1356 +
        assert_eq!(n(&later.join().unwrap().1), 2, "a 502 from the proxy, then the hub");
1357 +
        // Without `connection`, a hub that cannot be reached ends it at once.
1358 +
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
1359 +
        let url = format!("http://127.0.0.1:{port}/x");
1360 +
        assert!(retrying(&url, &quick, false, || agent.post(&url).send_string("{}").map_err(Box::new)).is_err());
1361 +
        assert!(PATIENT_DELAYS.iter().sum::<Duration>() >= Duration::from_secs(180), "longer than a hub takes to restart");
1362 +
    }
1363 +
1364 +
    /// oxygenflow#50: the first stop drains (the second, not called here,
1365 +
    /// exits at once).
1366 +
    #[test]
1367 +
    fn a_stop_drains_the_runner() {
1368 +
        assert!(!stopping());
1369 +
        on_stop(libc::SIGTERM);
1370 +
        assert!(stopping(), "told to stop, it claims nothing more");
1371 +
    }
1372 +
1373 +
    /// Lines kept for a hub that stays away are bounded, saying how many went.
1374 +
    #[test]
1375 +
    fn unsent_log_lines_are_bounded() {
1376 +
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
1377 +
        let hub = hub_at(format!("http://127.0.0.1:{port}"));
1378 +
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: Instant::now(), gone: false };
1379 +
        sink.buf = (0..MAX_UNSENT as u64 + 10).map(|i| Record { t: i, stream: "out".into(), text: String::new() }).collect();
1380 +
        sink.send();
1381 +
        assert_eq!(sink.buf.len(), MAX_UNSENT + 1);
1382 +
        assert_eq!(sink.buf[0].text, "(10 log lines lost while the hub could not be reached)");
1383 +
        assert_eq!(sink.buf[1].t, 10, "the oldest go first");
1222 1384
    }
1223 1385
1224 1386
    #[test]
…
14 unchanged lines
replaced deps/oxygen +0 −0

No line changes.

modified docs/m0.md +3 −1
…
242 unchanged lines
243 243
`kernel/vmlinux` (pinned), `cas/` (rootfs images), `work/` (scratch), and
244 244
`runner.toml`. `flow runner` runs as the `flow-runner` systemd service
245 245
under the `claude` user (member of `kvm`), polling the hub at
246 -
`https://earth.andymccall.xyz:7343`.
246 +
`https://earth.andymccall.xyz:7343`. The unit has `KillMode=mixed` and
247 +
`TimeoutStopSec=75min`, so a restart drains the runner instead of killing
248 +
its job (spec §11.5.1).
247 249
248 250
## 7. Not in M0
249 251
…
4 unchanged lines
modified docs/spec.md +22 −0
…
587 unchanged lines
588 588
works out why each ready job waits every 15 seconds, and the run page and
589 589
the API (`waiting`) show it.
590 590
591 +
### 11.5.1 Restarts (oxygenflow#49, #50)
592 +
593 +
Neither a hub restart nor a runner restart costs a job anything.
594 +
595 +
- **The hub away.** A job's uploads (artifacts, report files) and declines
596 +
  are tried again through refused connections and server errors (5xx,
597 +
  as a proxy answers while the hub restarts) for three minutes, waiting
598 +
  1, 2, 4, 8 and then 15 seconds; a refusal (4xx) is final. Log lines
599 +
  the hub could not take are kept and sent with the next batch, 200 lines
600 +
  at a time, up to 50,000 (beyond that the oldest go, and the log says how
601 +
  many). A job's result is kept on disk and offered for two hours, as
602 +
  before. Claims are not retried through a lost connection, so a job is
603 +
  never claimed twice; the runner simply asks again.
604 +
- **The runner restarted.** The first SIGTERM or SIGINT drains the runner:
605 +
  it claims nothing more and stops once every job it holds has ended and
606 +
  been reported. A second signal stops it at once. Its systemd unit says
607 +
  `KillMode=mixed`, so only the runner is signalled and its microVMs run
608 +
  on, and `TimeoutStopSec` longer than the longest job, after which
609 +
  systemd kills what is left. A new binary is installed by replacing it
610 +
  and restarting the unit (`systemctl restart --no-block`): the old runner
611 +
  finishes its job, and `Restart=always` starts the new one.
612 +
591 613
### 11.6 Conformance
592 614
593 615
1. Architecture Safety: a job whose image is `x86_64` MUST never be
…
10 unchanged lines