OXYGENHUB

flow runner update --from FILE: the runner's side of runner updates 85275e1ef3b7

Checks a new build (this machine's target, --version, a tier it can run
here), keeps the installed one as flow.prev, swaps the new one in with
one rename, and tells the running runner (SIGUSR1, pid in runner.pid)
to leave within --notice so its unit starts the new build. The new
build proves itself by an answer from the hub; four starts or ten
minutes without one put flow.prev back. --dry-run; [runner.update]
allow = false refuses; updates.log records each step. Spec 11.5.3.

closes #85, refs #84
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-26 00:56 UTC
Signature
◈ sealed by Andy McCall
Hash
85275e1ef3b757806addfbb309ed663d825bc085764ddd29692d595a20b3668a
Depends on
06a4c472b385 2814f4213e7c 3007d8f011fa 4be5e4cd0af6 5cf1e8aefbb6 62829d15ef09 67851a868a3b 89171fca079c a083cc802892 a4b3f8cb9aae c3db750ed96e c90dd8d5fc36 e0918b887850 ea81796bb757 f98a8f7881e0 fd2d4822f7f3
Depended on by
21a3ea4aab88 8aa45fa6ece5 4dd392d16454 494a1b9a8ac2 3554afefb6aa cedd12342afa 732b4de405e6
In bookmarks
main
In tags
v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
Edits
16 atoms, 1 tree operation, 8 files

Browse the tree at this change

added crates/flow/src/update.rs +376 −0
1 +
//! Runner updates, the runner's side (docs/jetstream.md §8.2, #85): check
2 +
//! a new `flow` build, stage it beside the running one, swap it in keeping
3 +
//! the old one as `flow.prev`, and have the running runner leave with
4 +
//! notice so its unit starts the new build. The new build proves itself by
5 +
//! reaching the hub; three failed starts, or ten minutes without the hub,
6 +
//! put `flow.prev` back.
7 +
8 +
use anyhow::{bail, Context, Result};
9 +
use serde::{Deserialize, Serialize};
10 +
use std::path::{Path, PathBuf};
11 +
use std::time::Duration;
12 +
13 +
/// The staged build, beside the installed one.
14 +
pub const NEW: &str = "flow.new";
15 +
/// The build an update replaced, kept to go back to.
16 +
pub const PREV: &str = "flow.prev";
17 +
/// In the runner's home: the update not yet proven.
18 +
const PENDING: &str = "update.json";
19 +
/// In the runner's home: every update and rollback, a JSON line each.
20 +
const LOG: &str = "updates.log";
21 +
/// In the runner's home: the running runner's process id.
22 +
const PID: &str = "runner.pid";
23 +
/// Starts of an unproven build after which the previous one comes back.
24 +
pub const MAX_STARTS: u32 = 3;
25 +
/// How long a new build has to reach the hub.
26 +
pub const PROVE_WITHIN: Duration = Duration::from_secs(10 * 60);
27 +
28 +
/// An update swapped in and not yet proven.
29 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
30 +
pub struct Pending {
31 +
    pub from: String,
32 +
    pub to: String,
33 +
    /// The installed binary the update replaced.
34 +
    pub exe: PathBuf,
35 +
    pub at_ms: u64,
36 +
    /// How long the running runner had to finish what it held.
37 +
    pub notice_secs: u64,
38 +
    /// Starts of the new build so far.
39 +
    pub starts: u32,
40 +
}
41 +
42 +
/// What [`install`] did, or would do.
43 +
#[derive(Debug, PartialEq, Eq)]
44 +
pub struct Installed {
45 +
    pub from: String,
46 +
    pub to: String,
47 +
    pub exe: PathBuf,
48 +
    /// The running runner told to leave, by process id.
49 +
    pub told: Option<u32>,
50 +
}
51 +
52 +
/// The version a `flow --version` line names: `flow 0.3.1 (build)` → `0.3.1`.
53 +
pub fn parse_version(line: &str) -> Option<String> {
54 +
    let v = line.trim().strip_prefix("flow ")?.split_whitespace().next()?;
55 +
    v.split('.').all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())).then(|| v.to_string())
56 +
}
57 +
58 +
/// Whether version `a` is older than `b`, part by part.
59 +
pub fn older(a: &str, b: &str) -> bool {
60 +
    let parts = |v: &str| v.split('.').map(|p| p.parse::<u64>().unwrap_or(0)).collect::<Vec<_>>();
61 +
    parts(a) < parts(b)
62 +
}
63 +
64 +
/// The ELF machine number of this build's architecture.
65 +
fn machine_here() -> Option<u16> {
66 +
    Some(match std::env::consts::ARCH {
67 +
        "x86_64" => 62,
68 +
        "aarch64" => 183,
69 +
        "riscv64" => 243,
70 +
        _ => return None,
71 +
    })
72 +
}
73 +
74 +
/// Refuse a file that is not a 64-bit little-endian ELF executable for this
75 +
/// machine: a build for another target would not start, and the rollback
76 +
/// would only notice after three tries.
77 +
pub fn check_target(bytes: &[u8]) -> Result<()> {
78 +
    if bytes.len() < 20 || &bytes[..4] != b"\x7fELF" {
79 +
        bail!("not an ELF executable");
80 +
    }
81 +
    if bytes[4] != 2 || bytes[5] != 1 {
82 +
        bail!("not a 64-bit little-endian build");
83 +
    }
84 +
    let machine = u16::from_le_bytes([bytes[18], bytes[19]]);
85 +
    match machine_here() {
86 +
        Some(m) if m == machine => Ok(()),
87 +
        Some(_) => bail!("a build for another machine (ELF machine {machine}); this runner is {}", std::env::consts::ARCH),
88 +
        None => bail!("updates are not supported on {}", std::env::consts::ARCH),
89 +
    }
90 +
}
91 +
92 +
/// Run `file args…` with a time limit, returning its standard output.
93 +
fn run(file: &Path, args: &[&str], limit: Duration) -> Result<String> {
94 +
    let mut child = std::process::Command::new(file)
95 +
        .args(args)
96 +
        .stdin(std::process::Stdio::null())
97 +
        .stdout(std::process::Stdio::piped())
98 +
        .stderr(std::process::Stdio::piped())
99 +
        .spawn()
100 +
        .with_context(|| format!("starting {}", file.display()))?;
101 +
    let start = std::time::Instant::now();
102 +
    loop {
103 +
        if child.try_wait()?.is_some() {
104 +
            break;
105 +
        }
106 +
        if start.elapsed() > limit {
107 +
            let _ = child.kill();
108 +
            let _ = child.wait();
109 +
            bail!("`{} {}` did not finish within {} s", file.display(), args.join(" "), limit.as_secs());
110 +
        }
111 +
        std::thread::sleep(Duration::from_millis(50));
112 +
    }
113 +
    let out = child.wait_with_output()?;
114 +
    if !out.status.success() {
115 +
        bail!("`{} {}` failed ({}): {}", file.display(), args.join(" "), out.status, String::from_utf8_lossy(&out.stderr).trim());
116 +
    }
117 +
    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
118 +
}
119 +
120 +
/// Check a build as the runner would run it: this machine's target, it
121 +
/// starts and names its version, and it finds the tiers this home offers.
122 +
/// Returns its version.
123 +
pub fn check(file: &Path, home: &Path) -> Result<String> {
124 +
    let mut head = vec![0u8; 64];
125 +
    let n = std::io::Read::read(&mut std::fs::File::open(file).with_context(|| format!("opening {}", file.display()))?, &mut head)?;
126 +
    check_target(&head[..n])?;
127 +
    let limit = Duration::from_secs(30);
128 +
    let line = run(file, &["--version"], limit)?;
129 +
    let version = parse_version(&line).with_context(|| format!("{} does not say it is flow: {:?}", file.display(), line.trim()))?;
130 +
    let home_s = home.to_string_lossy();
131 +
    let tiers = run(file, &["--home", &home_s, "tiers"], limit)?;
132 +
    if !tiers.lines().any(|l| l.ends_with(": available")) {
133 +
        bail!("flow {version} finds no tier it can run here:\n{}", tiers.trim());
134 +
    }
135 +
    Ok(version)
136 +
}
137 +
138 +
fn now_ms() -> u64 {
139 +
    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
140 +
}
141 +
142 +
fn log(home: &Path, event: &str, from: &str, to: &str, why: &str) {
143 +
    use std::io::Write;
144 +
    let line = serde_json::json!({"at_ms": now_ms(), "event": event, "from": from, "to": to, "why": why});
145 +
    if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(home.join(LOG)) {
146 +
        let _ = writeln!(f, "{line}");
147 +
    }
148 +
}
149 +
150 +
/// The update not yet proven, if any.
151 +
pub fn pending(home: &Path) -> Option<Pending> {
152 +
    serde_json::from_slice(&std::fs::read(home.join(PENDING)).ok()?).ok()
153 +
}
154 +
155 +
fn save(home: &Path, p: &Pending) -> Result<()> {
156 +
    let tmp = home.join(format!("{PENDING}.tmp"));
157 +
    std::fs::write(&tmp, serde_json::to_vec_pretty(p)?)?;
158 +
    std::fs::rename(&tmp, home.join(PENDING))?;
159 +
    Ok(())
160 +
}
161 +
162 +
/// Put `staged` in place of `exe`, keeping `exe` as [`PREV`] beside it.
163 +
/// `exe` is never missing: it is copied aside, then replaced in one rename.
164 +
pub fn swap(exe: &Path, staged: &Path) -> Result<()> {
165 +
    let prev = exe.with_file_name(PREV);
166 +
    let _ = std::fs::remove_file(&prev);
167 +
    if std::fs::hard_link(exe, &prev).is_err() {
168 +
        std::fs::copy(exe, &prev).with_context(|| format!("keeping {} as {}", exe.display(), prev.display()))?;
169 +
    }
170 +
    std::fs::rename(staged, exe).with_context(|| format!("putting {} in place of {}", staged.display(), exe.display()))?;
171 +
    Ok(())
172 +
}
173 +
174 +
/// Put [`PREV`] back in place of `exe`.
175 +
pub fn restore(exe: &Path) -> Result<()> {
176 +
    let prev = exe.with_file_name(PREV);
177 +
    if !prev.is_file() {
178 +
        bail!("{} is not there to go back to", prev.display());
179 +
    }
180 +
    std::fs::rename(&prev, exe).with_context(|| format!("putting {} back", prev.display()))?;
181 +
    Ok(())
182 +
}
183 +
184 +
/// Check `file`, stage it beside `exe`, and unless `dry_run` swap it in and
185 +
/// tell the running runner (if any) to leave within `notice`, finishing
186 +
/// what fits and handing back the rest, so its unit starts the new build.
187 +
pub fn install(home: &crate::home::Home, exe: &Path, file: &Path, notice: Duration, dry_run: bool, allow_older: bool) -> Result<Installed> {
188 +
    if home.config.runner.as_ref().and_then(|r| r.update.as_ref()).is_some_and(|u| !u.allow) {
189 +
        bail!("flow.toml says [runner.update] allow = false: this runner takes no updates");
190 +
    }
191 +
    if let Some(p) = pending(&home.root) {
192 +
        bail!("the update from {} to {} is not proven yet; wait for it (or for its rollback)", p.from, p.to);
193 +
    }
194 +
    let from = env!("CARGO_PKG_VERSION").to_string();
195 +
    let staged = exe.with_file_name(NEW);
196 +
    let _ = std::fs::remove_file(&staged);
197 +
    std::fs::copy(file, &staged).with_context(|| format!("staging {} as {}", file.display(), staged.display()))?;
198 +
    {
199 +
        use std::os::unix::fs::PermissionsExt;
200 +
        std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))?;
201 +
    }
202 +
    let checked = check(&staged, &home.root).and_then(|to| {
203 +
        if older(&to, &from) && !allow_older {
204 +
            bail!("flow {to} is older than this runner's {from} (--allow-older to go back)");
205 +
        }
206 +
        Ok(to)
207 +
    });
208 +
    let to = match checked {
209 +
        Ok(v) => v,
210 +
        Err(e) => {
211 +
            let _ = std::fs::remove_file(&staged);
212 +
            return Err(e);
213 +
        }
214 +
    };
215 +
    if dry_run {
216 +
        let _ = std::fs::remove_file(&staged);
217 +
        return Ok(Installed { from, to, exe: exe.to_path_buf(), told: None });
218 +
    }
219 +
    swap(exe, &staged)?;
220 +
    let p =
221 +
        Pending { from: from.clone(), to: to.clone(), exe: exe.to_path_buf(), at_ms: now_ms(), notice_secs: notice.as_secs(), starts: 0 };
222 +
    save(&home.root, &p)?;
223 +
    log(&home.root, "installed", &from, &to, "flow runner update --from");
224 +
    let told = running(&home.root).filter(|pid| unsafe { libc::kill(*pid as libc::pid_t, libc::SIGUSR1) } == 0);
225 +
    Ok(Installed { from, to, exe: exe.to_path_buf(), told })
226 +
}
227 +
228 +
/// The running runner's process id, from its pid file, if that process is
229 +
/// still a `flow runner`: a stale file's pid may be another program's now,
230 +
/// and SIGUSR1 would end it. Builds before #85 write no pid file (and do
231 +
/// not listen for SIGUSR1), so they are never told.
232 +
fn running(home: &Path) -> Option<u32> {
233 +
    let pid: u32 = std::fs::read_to_string(home.join(PID)).ok()?.trim().parse().ok()?;
234 +
    let cmdline = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
235 +
    is_runner(&cmdline).then_some(pid)
236 +
}
237 +
238 +
/// Whether a NUL-separated command line is `flow … runner` with no subcommand.
239 +
fn is_runner(cmdline: &[u8]) -> bool {
240 +
    let args: Vec<&[u8]> = cmdline.split(|b| *b == 0).filter(|a| !a.is_empty()).collect();
241 +
    let Some((argv0, rest)) = args.split_first() else { return false };
242 +
    let name = argv0.rsplit(|b| *b == b'/').next().unwrap_or(argv0);
243 +
    name.starts_with(b"flow") && rest.last() == Some(&&b"runner"[..])
244 +
}
245 +
246 +
/// Note this process as the home's runner, for `flow runner update` to tell.
247 +
pub fn note_runner(home: &Path) {
248 +
    let _ = std::fs::write(home.join(PID), std::process::id().to_string());
249 +
}
250 +
251 +
/// The runner stopped: forget its pid.
252 +
pub fn forget_runner(home: &Path) {
253 +
    if std::fs::read_to_string(home.join(PID)).is_ok_and(|p| p.trim() == std::process::id().to_string()) {
254 +
        let _ = std::fs::remove_file(home.join(PID));
255 +
    }
256 +
}
257 +
258 +
/// What a runner starting up should do about an update.
259 +
#[derive(Debug, PartialEq, Eq)]
260 +
pub enum Start {
261 +
    /// No update waits to be proven.
262 +
    Settled,
263 +
    /// The new build is on trial: prove it within [`PROVE_WITHIN`].
264 +
    Trial(Pending),
265 +
    /// It failed too often; the previous build is back and should be run.
266 +
    RolledBack(Pending),
267 +
}
268 +
269 +
/// Count this start of an unproven build; after [`MAX_STARTS`], put the
270 +
/// previous build back.
271 +
pub fn on_start(home: &Path) -> Result<Start> {
272 +
    let Some(mut p) = pending(home) else { return Ok(Start::Settled) };
273 +
    p.starts += 1;
274 +
    if p.starts > MAX_STARTS {
275 +
        roll_back(home, &p, &format!("{MAX_STARTS} starts without reaching the hub"))?;
276 +
        return Ok(Start::RolledBack(p));
277 +
    }
278 +
    save(home, &p)?;
279 +
    Ok(Start::Trial(p))
280 +
}
281 +
282 +
/// The new build reached the hub: it stays.
283 +
pub fn proven(home: &Path) {
284 +
    if let Some(p) = pending(home) {
285 +
        let _ = std::fs::remove_file(home.join(PENDING));
286 +
        log(home, "proven", &p.from, &p.to, "reached the hub");
287 +
        eprintln!("flow runner: flow {} proven (it reached the hub); flow {} stays as {PREV}", p.to, p.from);
288 +
    }
289 +
}
290 +
291 +
/// Put the previous build back and forget the update.
292 +
pub fn roll_back(home: &Path, p: &Pending, why: &str) -> Result<()> {
293 +
    restore(&p.exe)?;
294 +
    let _ = std::fs::remove_file(home.join(PENDING));
295 +
    log(home, "rolled back", &p.to, &p.from, why);
296 +
    eprintln!("flow runner: rolled back from flow {} to {} ({why})", p.to, p.from);
297 +
    Ok(())
298 +
}
299 +
300 +
#[cfg(test)]
301 +
mod tests {
302 +
    use super::*;
303 +
304 +
    #[test]
305 +
    fn versions_are_read_and_compared() {
306 +
        assert_eq!(parse_version("flow 0.3.1 (local build)\n").as_deref(), Some("0.3.1"));
307 +
        assert_eq!(parse_version("flow 0.10.0"), Some("0.10.0".into()));
308 +
        assert_eq!(parse_version("oxy 0.6.0"), None);
309 +
        assert_eq!(parse_version("flow banana"), None);
310 +
        assert!(older("0.3.1", "0.10.0"), "by number, not by text");
311 +
        assert!(!older("0.3.1", "0.3.1"));
312 +
        assert!(older("0.3", "0.3.1"));
313 +
    }
314 +
315 +
    #[test]
316 +
    fn only_a_build_for_this_machine_passes() {
317 +
        let me = std::fs::read(std::env::current_exe().unwrap()).unwrap();
318 +
        check_target(&me[..64]).unwrap();
319 +
        assert!(check_target(b"#!/bin/sh\necho flow 9.9.9\n").unwrap_err().to_string().contains("not an ELF"));
320 +
        let mut other = me[..64].to_vec();
321 +
        other[18..20].copy_from_slice(&(if std::env::consts::ARCH == "riscv64" { 62u16 } else { 243u16 }).to_le_bytes());
322 +
        assert!(check_target(&other).unwrap_err().to_string().contains("another machine"));
323 +
    }
324 +
325 +
    #[test]
326 +
    fn a_swap_keeps_the_old_build_and_three_failed_starts_bring_it_back() {
327 +
        let dir = tempfile::tempdir().unwrap();
328 +
        let (bin, home) = (dir.path().join("bin"), dir.path().join("home"));
329 +
        std::fs::create_dir_all(&bin).unwrap();
330 +
        std::fs::create_dir_all(&home).unwrap();
331 +
        let exe = bin.join("flow");
332 +
        std::fs::write(&exe, "old").unwrap();
333 +
        std::fs::write(bin.join(NEW), "new").unwrap();
334 +
        swap(&exe, &bin.join(NEW)).unwrap();
335 +
        assert_eq!(std::fs::read_to_string(&exe).unwrap(), "new");
336 +
        assert_eq!(std::fs::read_to_string(bin.join(PREV)).unwrap(), "old");
337 +
        assert!(!bin.join(NEW).exists());
338 +
339 +
        assert_eq!(on_start(&home).unwrap(), Start::Settled, "no update, nothing to do");
340 +
        let p = Pending { from: "0.3.1".into(), to: "0.3.2".into(), exe: exe.clone(), at_ms: 1, notice_secs: 600, starts: 0 };
341 +
        save(&home, &p).unwrap();
342 +
        for n in 1..=MAX_STARTS {
343 +
            assert_eq!(on_start(&home).unwrap(), Start::Trial(Pending { starts: n, ..p.clone() }));
344 +
        }
345 +
        assert!(matches!(on_start(&home).unwrap(), Start::RolledBack(_)));
346 +
        assert_eq!(std::fs::read_to_string(&exe).unwrap(), "old", "the previous build is back");
347 +
        assert!(pending(&home).is_none());
348 +
        let log = std::fs::read_to_string(home.join(LOG)).unwrap();
349 +
        assert!(log.contains("rolled back") && log.contains("0.3.2"), "{log}");
350 +
    }
351 +
352 +
    #[test]
353 +
    fn only_a_running_runner_is_told() {
354 +
        assert!(is_runner(b"/usr/local/bin/flow\0runner\0"));
355 +
        assert!(is_runner(b"flow\0--home\0/srv/flow\0runner\0"));
356 +
        assert!(!is_runner(b"flow\0runner\0update\0--from\0x\0"), "not the update command itself");
357 +
        assert!(!is_runner(b"/usr/bin/vim\0runner\0"));
358 +
        assert!(!is_runner(b""));
359 +
        let dir = tempfile::tempdir().unwrap();
360 +
        std::fs::write(dir.path().join(PID), std::process::id().to_string()).unwrap();
361 +
        assert_eq!(running(dir.path()), None, "this test is not a flow runner");
362 +
    }
363 +
364 +
    #[test]
365 +
    fn a_proven_build_stays() {
366 +
        let dir = tempfile::tempdir().unwrap();
367 +
        let exe = dir.path().join("flow");
368 +
        std::fs::write(&exe, "new").unwrap();
369 +
        let p = Pending { from: "0.3.1".into(), to: "0.3.2".into(), exe: exe.clone(), at_ms: 1, notice_secs: 0, starts: 1 };
370 +
        save(dir.path(), &p).unwrap();
371 +
        proven(dir.path());
372 +
        assert!(pending(dir.path()).is_none());
373 +
        assert_eq!(on_start(dir.path()).unwrap(), Start::Settled);
374 +
        assert!(std::fs::read_to_string(dir.path().join(LOG)).unwrap().contains("proven"));
375 +
    }
376 +
}
modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **`flow runner update --from FILE` (#85):** the runner's side of self-updating runners (docs/jetstream.md §8.2). It checks a new build (this machine's target, `--version`, a tier it can run here), keeps the installed one as `flow.prev`, swaps the new one in with a single rename, and tells the running runner (SIGUSR1, its pid in `runner.pid`) to leave within `--notice` seconds (600 by default): jobs that fit finish, the rest are handed back with their checkpoints, and the unit starts the new build. The new build proves itself by getting an answer from the hub; four starts or ten minutes without one put `flow.prev` back. `--dry-run` changes nothing; `[runner.update] allow = false` in `flow.toml` refuses every update; `updates.log` in the home records each install, proof and rollback. Spec §11.5.3.
7 8
- **A local `flow run` of a WASM or syscall job copies the workspace whole:** it copied into a directory it never made, so a file at the top of the workspace (a README) failed with `No such file or directory`.
8 9
- **Checkpoints: a handed-back job resumes (#77):** `checkpoint "/src/…"` on a job names directories that hold its progress; a runner handing the job back packs and keeps them (`POST …/checkpoint`), and the next attempt's runner restores them before running the same exec, so it carries on rather than starting again. The receipt lists the checkpoint among its inputs. WASM and syscall tiers for now. Spec §11.5.2.
9 10
- **A leaving runner finishes what fits its notice (#76):** a claim carries `expected_ms` (the median of the job's recent passing runs, from OxygenHub 0.9.4); `runner::leave_within(reason, notice)` finishes and reports a job whose expected rest fits the notice and hands back the others, and any still running when it is up. `runner::leave` is the same with no notice. Spec §11.5.2.
…
94 unchanged lines
modified crates/flow/src/home.rs +16 −0
…
86 unchanged lines
87 87
    /// A bearer token a scrape of the metrics must send.
88 88
    #[serde(default)]
89 89
    pub metrics_token: Option<String>,
90 +
    /// `[runner.update]`: whether this runner takes updates (#85).
91 +
    #[serde(default)]
92 +
    pub update: Option<UpdateConfig>,
93 +
}
94 +
95 +
/// `[runner.update]` in flow.toml (docs/jetstream.md §8.2): the runner's
96 +
/// last word on updates, whatever the hub's policy says.
97 +
#[derive(Clone, Debug, Deserialize)]
98 +
pub struct UpdateConfig {
99 +
    /// `false` refuses every update, so a site can hold a machine back.
100 +
    #[serde(default = "yes")]
101 +
    pub allow: bool,
102 +
}
103 +
104 +
fn yes() -> bool {
105 +
    true
90 106
}
91 107
92 108
impl RunnerConfig {
…
196 unchanged lines
modified crates/flow/src/lib.rs +1 −0
…
18 unchanged lines
19 19
pub mod runner;
20 20
pub mod swarm;
21 21
pub mod syscall;
22 +
pub mod update;
22 23
pub mod wait;
23 24
pub mod wasm;
24 25
pub mod workspace;
modified crates/flow/src/main.rs +56 −0
…
43 unchanged lines
44 44
    /// Measure this machine as a runner does: its speed score (100 on the
45 45
    /// reference machine) and class, free disk and load (spec §11.1)
46 46
    Bench,
47 +
    /// Install a new flow build in place of this one (docs/jetstream.md
48 +
    /// §8.2): check it, keep this one as flow.prev, and have the running
49 +
    /// runner leave with notice so its unit starts the new build. A build
50 +
    /// that does not reach the hub goes back to flow.prev by itself
51 +
    Update {
52 +
        /// The new flow binary
53 +
        #[arg(long)]
54 +
        from: PathBuf,
55 +
        /// Seconds the running runner has to finish what it holds; jobs
56 +
        /// that will not finish in time are handed back with their checkpoints
57 +
        #[arg(long, default_value_t = 600)]
58 +
        notice: u64,
59 +
        /// Check the build and say what would happen, changing nothing
60 +
        #[arg(long)]
61 +
        dry_run: bool,
62 +
        /// Install a build older than this one
63 +
        #[arg(long)]
64 +
        allow_older: bool,
65 +
        /// The installed binary to replace (default: this one)
66 +
        #[arg(long)]
67 +
        exe: Option<PathBuf>,
68 +
    },
47 69
}
48 70
49 71
#[derive(Subcommand)]
…
203 unchanged lines
253 275
            };
254 276
            let got = runner::register(&home, &hub, &token, &name, ca, label)?;
255 277
            println!("registered as {} (runner {}); start it with `flow runner`", got.name, got.id);
278 +
            Ok(())
279 +
        }
280 +
        Cmd::Runner { cmd: Some(RunnerCmd::Update { from, notice, dry_run, allow_older, exe }) } => {
281 +
            let home = Home::locate(cli.home.as_deref())?;
282 +
            let exe = match exe {
283 +
                Some(e) => e,
284 +
                None => std::env::current_exe().context("finding this flow binary")?,
285 +
            };
286 +
            let done = flow::update::install(&home, &exe, &from, Duration::from_secs(notice), dry_run, allow_older)?;
287 +
            if dry_run {
288 +
                println!(
289 +
                    "flow {} checks out: it would replace flow {} at {} (dry run: nothing changed)",
290 +
                    done.to,
291 +
                    done.from,
292 +
                    done.exe.display()
293 +
                );
294 +
                return Ok(());
295 +
            }
296 +
            println!("flow {} installed at {}; flow {} kept as {}", done.to, done.exe.display(), done.from, flow::update::PREV);
297 +
            match done.told {
298 +
                Some(pid) => println!("the runner (pid {pid}) leaves within {notice} s, then its unit starts flow {}", done.to),
299 +
                None => println!(
300 +
                    "no runner of this home was told (none runs, or one from before runner updates, which does not listen): \
301 +
                     restart its unit, e.g. `systemctl restart --no-block flow-runner`; it drains and starts flow {}",
302 +
                    done.to
303 +
                ),
304 +
            }
256 305
            Ok(())
257 306
        }
258 307
        Cmd::Runner { cmd: None } => {
259 308
            let home = Home::locate(cli.home.as_deref())?;
309 +
            // A new build on trial that keeps failing to start goes back to
310 +
            // the one before, which then runs in its place (#85).
311 +
            if let flow::update::Start::RolledBack(p) = flow::update::on_start(&home.root)? {
312 +
                use std::os::unix::process::CommandExt;
313 +
                let err = std::process::Command::new(&p.exe).args(std::env::args_os().skip(1)).exec();
314 +
                bail!("starting flow {} again after the rollback: {err}", p.from);
315 +
            }
260 316
            // The runner's oxy state (identity, user config) stays in its home.
261 317
            if std::env::var_os("OXY_HOME").is_none() {
262 318
                std::env::set_var("OXY_HOME", home.root.join("oxy"));
…
294 unchanged lines
modified crates/flow/src/runner.rs +42 −2
…
613 unchanged lines
614 614
615 615
pub fn serve(home: &Home) -> Result<()> {
616 616
    drain_on_stop();
617 -
    serve_until_drained(home)
617 +
    crate::update::note_runner(&home.root);
618 +
    // `flow runner update` swapped a new build in and asks this one to
619 +
    // leave with the notice it gave (#85).
620 +
    let handler = on_update as extern "C" fn(libc::c_int) as libc::sighandler_t;
621 +
    unsafe { libc::signal(libc::SIGUSR1, handler) };
622 +
    if let Some(p) = crate::update::pending(&home.root) {
623 +
        // On trial: a build that does not reach the hub in time goes back.
624 +
        let root = home.root.clone();
625 +
        std::thread::spawn(move || {
626 +
            std::thread::sleep(crate::update::PROVE_WITHIN);
627 +
            if crate::update::pending(&root).is_some_and(|now| now.to == p.to && now.at_ms == p.at_ms) {
628 +
                let why = format!("did not reach the hub within {} minutes", crate::update::PROVE_WITHIN.as_secs() / 60);
629 +
                if crate::update::roll_back(&root, &p, &why).is_ok() {
630 +
                    std::process::exit(1);
631 +
                }
632 +
            }
633 +
        });
634 +
    }
635 +
    let served = serve_until_drained(home);
636 +
    crate::update::forget_runner(&home.root);
637 +
    served
618 638
}
619 639
640 +
/// Set by SIGUSR1: a new build is in place; leave for it.
641 +
static UPDATE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
642 +
643 +
extern "C" fn on_update(_: libc::c_int) {
644 +
    UPDATE.store(true, std::sync::atomic::Ordering::SeqCst);
645 +
}
646 +
620 647
/// The runner as a library, as the Android app runs it (oxygenhub-android#8):
621 648
/// no signal handlers; [`stop`] drains it, and this returns once the jobs
622 649
/// it holds are done and reported. It may be started again afterwards.
…
109 unchanged lines
732 759
                *heard = Instant::now();
733 760
            }
734 761
        };
762 +
        let mut on_trial = crate::update::pending(&home.root).is_some();
735 763
        loop {
764 +
            if UPDATE.swap(false, std::sync::atomic::Ordering::SeqCst) {
765 +
                let p = crate::update::pending(&home.root);
766 +
                let notice = Duration::from_secs(p.as_ref().map_or(0, |p| p.notice_secs));
767 +
                let to = p.map_or_else(|| "a new build".to_string(), |p| format!("flow {}", p.to));
768 +
                eprintln!("flow runner {name}: updating to {to}: leaving within {} s", notice.as_secs());
769 +
                leave_within(&format!("updating to {to}"), notice);
770 +
            }
736 771
            // Draining (oxygenflow#50): told to stop, claim nothing more and
737 772
            // stop once every job held is done and reported.
738 773
            if stopping() {
…
41 unchanged lines
780 815
                caches: Some(cache_times.keys().cloned().collect()),
781 816
                cache_times: Some(cache_times),
782 817
            };
783 -
            match hub.claim(&req) {
818 +
            let answer = hub.claim(&req);
819 +
            if on_trial && answer.is_ok() {
820 +
                crate::update::proven(&home.root);
821 +
                on_trial = false;
822 +
            }
823 +
            match answer {
784 824
                Ok(Some(claim)) => {
785 825
                    idle = Duration::from_secs(2);
786 826
                    crate::metrics::CLAIMS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
…
1269 unchanged lines
modified docs/jetstream.md +2 −1
…
372 unchanged lines
373 373
app only through its store or an install), so they follow the app.
374 374
375 375
**Order of work:** (1) the runner side, with a local source (`flow runner
376 -
update --from FILE`): check, stage, drain, swap, prove, roll back; (2) the
376 +
update --from FILE`): check, stage, drain, swap, prove, roll back (done,
377 +
#85: spec §11.5.3); (2) the
377 378
policy on the hub (opt in and out, channels, windows, dry run) and the
378 379
update in the claim answer; (3) test groups, waves and health; (4) the
379 380
swarm as transport; (5) Admin › Runners › Updates and `oh runner update`.
…
169 unchanged lines
modified docs/spec.md +30 −0
…
853 unchanged lines
854 854
by finishing what it holds. Checkpoints (#77), so the next attempt
855 855
resumes rather than restarts, come next.
856 856
857 +
### 11.5.3 Updating a runner (oxygenflow#85)
858 +
859 +
`flow runner update --from FILE` installs a new `flow` build in place of
860 +
the one it runs as (`--exe` names another), the runner's side of
861 +
self-updating runners (docs/jetstream.md §8.2):
862 +
863 +
1. **Refused** when the home's `flow.toml` says `[runner.update] allow =
864 +
   false`, when an earlier update is not yet proven, or when the build is
865 +
   older than the installed one (unless `--allow-older`).
866 +
2. **Checked:** a 64-bit little-endian ELF for this machine; it names
867 +
   itself (`--version`, `flow X.Y.Z …`) and finds a tier it can run here
868 +
   (`tiers` against the runner's home), each within 30 seconds.
869 +
3. **Staged and swapped:** copied beside the installed binary as
870 +
   `flow.new` and checked there; the installed one is kept as
871 +
   `flow.prev` (a hard link, else a copy), then `flow.new` replaces it in
872 +
   one rename, so `flow` is never missing. `--dry-run` stops before the
873 +
   swap and changes nothing.
874 +
4. **The running runner leaves:** a runner writes its pid to
875 +
   `runner.pid` in its home, and the update sends it SIGUSR1 (after
876 +
   checking that pid is still a `flow … runner`). It then leaves with
877 +
   `--notice` (default 600 s, §11.5.2): jobs expected to finish in time
878 +
   finish, the rest are handed back with their checkpoints, and it exits
879 +
   0, so its unit's `Restart=always` starts the new build. A runner from
880 +
   before #85 is not told; restarting its unit drains it as before.
881 +
5. **Proven or rolled back:** the update waits in `update.json` until the
882 +
   new build's first claim gets an answer from the hub. Each start counts;
883 +
   on the fourth start without that answer, or after ten minutes running
884 +
   without it, `flow.prev` is put back (and on a start, run in its place).
885 +
   `updates.log` in the home records every install, proof and rollback.
886 +
857 887
### 11.6 Conformance
858 888
859 889
1. Architecture Safety: a job whose image is `x86_64` MUST never be
…
10 unchanged lines