OXYGENHUB

Build caches for the syscall tier 2594278f49b2

The syscall tier's cached ephemeral://disk mounts now start from a
directory a passing job of the slot kept and go back into the runner's
pool when the job passes, with the microVM tier's semantics and code:
the same pool, slot naming, take order (move for its own bookmark, copy
with modes and times from another's), cache_gb eviction (oldest first,
counting everything in a directory) and claim reporting in caches and
cache_times. Each tier takes only its own form (<key>.ext4 images,
<key>.tree directories). A failing job's disk is thrown away. Pool
changes are serialised so a directory is never copied while it is moved
or removed. Kept directories are sealed: nothing group- or
world-writable, set-id or sticky; the pool and its sidecars are
owner-only. Spec §5.4, docs/m0.md and the changelog say so.

Closes #70
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 8b93d387fa7c
When
· 2026-10-19 15:15 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
2594278f49b265d0ee5b2b7056aafb52f851602ca9184773013caa5061d20b0b
Depends on
1e6b9fb1e73f 82cf6e91d398 a083cc802892 c3db750ed96e d60aac6c3f8a e0918b887850 ea56c56eaf90 ed4aef8b70ca
Depended on by
3554afefb6aa 28cc603f737d 077a65242359 da5118d25db2
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
51 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **The syscall tier keeps its build caches too (#70, spec §5.4).** A cached `ephemeral://disk` mount in a `runtime "syscall"` job now starts from the directory a passing job of the same slot kept, and goes back into the runner's pool when the job passes, with the same order, bookmark rules, `cache_gb` eviction and claim reporting as the microVM tier's disks, so jobs go where their cache is on boards without KVM as well. Nothing kept is writable by others or set-id, and the pool is the runner's alone.
8 +
7 9
- **The build cache pays off (#51, #52, spec §5.4).** A cached disk keeps the content hashes and times of the sources it was built from, and the next job gives every unchanged file its old time back, so cargo rebuilds only the crates that changed instead of every path crate. Claims list the caches a runner holds, and the hub sends a job to the runner holding its cache, as it does for images.
8 10
9 11
- **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.
…
32 unchanged lines
modified crates/flow/src/cache.rs +412 −52
…
17 unchanged lines
18 18
//! receipt names what the job started from. The pool is kept under its
19 19
//! size limit by dropping the disks used longest ago.
20 20
//!
21 -
//! Beside each disk, `<key>.sources` lists the sources it was built from
22 -
//! (oxygenflow#52): each file's content hash and the mtime it had. A job
23 -
//! that takes the disk gives every file whose content is unchanged that
24 -
//! mtime back, so a build tool that judges freshness by time (cargo)
25 -
//! rebuilds only what changed; a changed file keeps the time of its
26 -
//! checkout, newer than anything on the disk, so it is always rebuilt.
21 +
//! A disk is in the [`Form`] its tier mounts (oxygenflow#70): a microVM's
22 +
//! is an ext4 image, `<key>.ext4`; the syscall tier's is the host
23 +
//! directory it binds at the mount's target, `<key>.tree`, taken and kept
24 +
//! the same way. A directory goes into the pool with nothing in it
25 +
//! writable by others or set-id, and the pool itself is the runner's
26 +
//! alone.
27 +
//!
28 +
//! Beside each disk, `<key>.sources` (`<key>.tree-sources` for a
29 +
//! directory) lists the sources it was built from (oxygenflow#52): each
30 +
//! file's content hash and the mtime it had. A job that takes the disk
31 +
//! gives every file whose content is unchanged that mtime back, so a build
32 +
//! tool that judges freshness by time (cargo) rebuilds only what changed;
33 +
//! a changed file keeps the time of its checkout, newer than anything on
34 +
//! the disk, so it is always rebuilt.
27 35
28 36
use anyhow::{Context, Result};
29 37
use flow_core::pipeline::Mount;
30 -
use std::os::unix::fs::MetadataExt;
38 +
use std::os::unix::fs::{MetadataExt, PermissionsExt};
31 39
use std::path::{Path, PathBuf};
32 40
use std::time::SystemTime;
33 41
…
10 unchanged lines
44 52
    pub how: String,
45 53
    /// The sources it was built from, when they were kept with it.
46 54
    pub sources: Option<Sources>,
55 +
}
56 +
57 +
/// How a tier holds a disk mount's files (oxygenflow#70).
58 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59 +
pub enum Form {
60 +
    /// An ext4 image of the mount's size, attached to a microVM.
61 +
    Image,
62 +
    /// A host directory, bound into the syscall sandbox.
63 +
    Dir,
64 +
}
65 +
66 +
impl Form {
67 +
    fn ext(self) -> &'static str {
68 +
        match self {
69 +
            Form::Image => "ext4",
70 +
            Form::Dir => "tree",
71 +
        }
72 +
    }
73 +
}
74 +
75 +
/// Which form a kept entry of the pool is in, by its name.
76 +
fn form_of(p: &Path) -> Option<Form> {
77 +
    match p.extension()?.to_str()? {
78 +
        "ext4" => Some(Form::Image),
79 +
        "tree" => Some(Form::Dir),
80 +
        _ => None,
81 +
    }
47 82
}
48 83
49 84
fn sources_of(disk: &Path) -> PathBuf {
50 -
    disk.with_extension("sources")
85 +
    match form_of(disk) {
86 +
        Some(Form::Dir) => disk.with_extension("tree-sources"),
87 +
        _ => disk.with_extension("sources"),
88 +
    }
51 89
}
52 90
53 91
fn read_sources(disk: &Path) -> Option<Sources> {
…
6 unchanged lines
60 98
    limit: u64,
61 99
}
62 100
63 -
/// Which job: the pool's coordinates other than the key.
101 +
/// Which job: the pool's coordinates other than the key, and the form its
102 +
/// tier keeps a disk in.
64 103
pub struct Ctx<'a> {
65 104
    pub repo: &'a str,
66 105
    pub bookmark: &'a str,
67 106
    pub job: &'a str,
68 107
    pub image: &'a str,
108 +
    pub form: Form,
69 109
}
70 110
71 111
use flow_core::api::segment;
112 +
113 +
/// Held while the pool changes: jobs run side by side, and a directory,
114 +
/// unlike an image, can be half moved or half removed while another job
115 +
/// copies it.
116 +
static CHANGING: std::sync::Mutex<()> = std::sync::Mutex::new(());
72 117
118 +
fn changing() -> std::sync::MutexGuard<'static, ()> {
119 +
    CHANGING.lock().unwrap_or_else(|p| p.into_inner())
120 +
}
121 +
73 122
impl Pool {
74 123
    pub fn new(home_root: &Path, gb: Option<u64>) -> Pool {
75 124
        Pool { root: home_root.join("cache"), limit: gb.unwrap_or(DEFAULT_GB).saturating_mul(1 << 30) }
…
20 unchanged lines
96 145
        h.finalize().to_hex()[..32].to_string()
97 146
    }
98 147
99 -
    /// Start `dest` from the pool for mount `m`, a disk of `m.size_mib`.
100 -
    /// What it started from, or `None` (the caller makes an empty disk).
148 +
    /// Start `dest` from the pool for mount `m`: an image of `m.size_mib`,
149 +
    /// or a directory, as `ctx.form` says; `dest` must not exist yet. What
150 +
    /// it started from, or `None` (the caller makes an empty disk).
151 +
    ///
152 +
    /// A disk this job's bookmark kept is moved out of the pool, so a job
153 +
    /// of the same slot running beside this one can never take it too.
101 154
    pub fn take(&self, ctx: &Ctx, m: &Mount, key: &str, dest: &Path) -> Option<Taken> {
102 155
        let name = m.cache.as_deref()?;
156 +
        let _changing = changing();
103 157
        let dir = self.cache_dir(ctx, name);
104 158
        let size = m.size_mib as u64 * (1 << 20);
105 -
        let fits = |p: &Path| std::fs::metadata(p).is_ok_and(|md| md.len() == size);
159 +
        let form = ctx.form;
160 +
        let fits = |p: &Path| match form {
161 +
            Form::Image => std::fs::metadata(p).is_ok_and(|md| md.len() == size),
162 +
            Form::Dir => std::fs::symlink_metadata(p).is_ok_and(|md| md.is_dir()),
163 +
        };
164 +
        let ext = form.ext();
106 165
        let mine = dir.join(segment(ctx.bookmark));
107 -
        let exact = mine.join(format!("{key}.ext4"));
166 +
        let exact = mine.join(format!("{key}.{ext}"));
108 167
        // A moved disk takes its sources with it.
109 168
        if fits(&exact) {
110 169
            let sources = read_sources(&exact);
…
2 unchanged lines
113 172
                return Some(Taken { how: format!("cache {name}: this bookmark's, same key"), sources });
114 173
            }
115 174
        }
116 -
        if let Some(p) = newest(&mine, |p| fits(p)) {
175 +
        if let Some(p) = newest(&mine, form, |p| fits(p)) {
117 176
            let sources = read_sources(&p);
118 177
            if std::fs::rename(&p, dest).is_ok() {
119 178
                let _ = std::fs::remove_file(sources_of(&p));
…
4 unchanged lines
124 183
            std::fs::read_dir(&dir).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.is_dir() && *p != mine).collect();
125 184
        let pick = others
126 185
            .iter()
127 -
            .map(|d| d.join(format!("{key}.ext4")))
186 +
            .map(|d| d.join(format!("{key}.{ext}")))
128 187
            .filter(|p| fits(p))
129 188
            .max_by_key(|p| modified(p))
130 189
            .map(|p| (p, "same key"))
131 -
            .or_else(|| others.iter().filter_map(|d| newest(d, |p| fits(p))).max_by_key(|p| modified(p)).map(|p| (p, "an older key")));
190 +
            .or_else(|| {
191 +
                others.iter().filter_map(|d| newest(d, form, |p| fits(p))).max_by_key(|p| modified(p)).map(|p| (p, "an older key"))
192 +
            });
132 193
        if let Some((p, which)) = pick {
133 194
            let from = p.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
134 -
            if sparse_copy(&p, dest).is_ok() {
195 +
            if copy(form, &p, dest).is_ok() {
135 196
                return Some(Taken {
136 197
                    how: format!("cache {name}: copied from another bookmark's ({from}), {which}"),
137 198
                    sources: read_sources(&p),
…
7 unchanged lines
145 206
    /// the pool under its limit.
146 207
    pub fn keep(&self, ctx: &Ctx, m: &Mount, key: &str, disk: &Path, sources: &Sources) -> Result<()> {
147 208
        let Some(name) = m.cache.as_deref() else { return Ok(()) };
209 +
        let _changing = changing();
210 +
        // Whatever the job left, nothing in the pool is anyone else's to
211 +
        // write or runs as the runner (oxygenflow#70).
212 +
        if ctx.form == Form::Dir {
213 +
            seal(disk).with_context(|| format!("seal {}", disk.display()))?;
214 +
        }
148 215
        let mine = self.cache_dir(ctx, name).join(segment(ctx.bookmark));
149 -
        std::fs::create_dir_all(&mine)?;
150 -
        let dest = mine.join(format!("{key}.ext4"));
216 +
        std::os::unix::fs::DirBuilderExt::mode(std::fs::DirBuilder::new().recursive(true), 0o700).create(&mine)?;
217 +
        std::fs::set_permissions(&self.root, std::fs::Permissions::from_mode(0o700))?;
218 +
        let dest = mine.join(format!("{key}.{}", ctx.form.ext()));
219 +
        if ctx.form == Form::Dir {
220 +
            // Another job of this slot kept one since: this one is newer.
221 +
            crate::workspace::remove_tree(&dest)?;
222 +
        }
151 223
        if std::fs::rename(disk, &dest).is_err() {
152 -
            sparse_copy(disk, &dest).with_context(|| format!("keep {}", disk.display()))?;
224 +
            copy(ctx.form, disk, &dest).with_context(|| format!("keep {}", disk.display()))?;
153 225
        }
154 226
        // Moved disks keep their old time: mark it used now.
155 -
        std::fs::File::options().write(true).open(&dest)?.set_modified(SystemTime::now())?;
156 -
        let tmp = sources_of(&dest).with_extension("sources-part");
157 -
        std::fs::write(&tmp, serde_json::to_vec(sources)?)?;
158 -
        std::fs::rename(&tmp, sources_of(&dest))?;
227 +
        std::fs::File::open(&dest)?.set_modified(SystemTime::now())?;
228 +
        let sidecar = sources_of(&dest);
229 +
        let tmp = sidecar.with_extension("sources-part");
230 +
        let _ = std::fs::remove_file(&tmp);
231 +
        {
232 +
            use std::io::Write;
233 +
            use std::os::unix::fs::OpenOptionsExt;
234 +
            std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(&tmp)?.write_all(&serde_json::to_vec(sources)?)?;
235 +
        }
236 +
        std::fs::rename(&tmp, sidecar)?;
159 237
        // One disk per key is enough; older keys of this bookmark go once
160 238
        // the pool is over its limit.
161 239
        self.evict();
162 240
        Ok(())
241 +
    }
242 +
243 +
    /// After the job: a passing job's disk is kept; a failing one's is
244 +
    /// thrown away, so nothing it wrote reaches a later job.
245 +
    pub fn settle(&self, ctx: &Ctx, m: &Mount, key: &str, disk: &Path, sources: &Sources, passed: bool) -> Result<()> {
246 +
        if passed {
247 +
            return self.keep(ctx, m, key, disk, sources);
248 +
        }
249 +
        let gone = match ctx.form {
250 +
            Form::Dir => crate::workspace::remove_tree(disk),
251 +
            Form::Image => std::fs::remove_file(disk).or_else(|e| if e.kind() == std::io::ErrorKind::NotFound { Ok(()) } else { Err(e) }),
252 +
        };
253 +
        gone.with_context(|| format!("discard {}", disk.display()))
163 254
    }
164 255
165 256
    /// The repository and job slots holding at least one disk, as
166 257
    /// [`flow_core::api::cache_slot`] names them, for the claim
167 258
    /// (oxygenflow#51), each with when its newest disk was kept (Unix ms):
168 259
    /// a disk's time is set when it is kept, so the newest is the freshest
169 -
    /// build (oxygenflow#54).
260 +
    /// build (oxygenflow#54). Images and directories alike, so a job goes
261 +
    /// where its cache is whichever tier keeps it.
170 262
    pub fn slot_times(&self) -> std::collections::BTreeMap<String, u64> {
171 263
        let mut out = std::collections::BTreeMap::new();
172 264
        for repo in std::fs::read_dir(&self.root).into_iter().flatten().flatten() {
173 265
            for job in std::fs::read_dir(repo.path()).into_iter().flatten().flatten() {
174 266
                let mut newest: Option<SystemTime> = None;
175 -
                walk(&job.path(), &mut |p| {
176 -
                    if p.extension().is_some_and(|e| e == "ext4") {
267 +
                // `<job>/<cache>/<bookmark>/<disk>`
268 +
                kept(&job.path(), 2, &mut |p, _| {
177 269
                    let t = modified(p);
178 270
                    newest = Some(newest.map_or(t, |n| n.max(t)));
179 -
                    }
180 271
                });
181 272
                if let Some(t) = newest {
182 273
                    let ms = t.duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
…
4 unchanged lines
187 278
        out
188 279
    }
189 280
190 -
    /// Drop the disks used longest ago until the pool's blocks fit.
191 -
    pub fn evict(&self) -> u64 {
281 +
    /// Drop the disks used longest ago until the pool's blocks fit: an
282 +
    /// image's blocks, or everything in a directory. The caller holds
283 +
    /// [`changing`].
284 +
    fn evict(&self) -> u64 {
192 285
        let mut disks: Vec<(SystemTime, u64, PathBuf)> = Vec::new();
193 -
        walk(&self.root, &mut |p| {
194 -
            if p.extension().is_some_and(|e| e == "ext4") {
195 -
                if let Ok(md) = std::fs::metadata(p) {
196 -
                    disks.push((md.modified().unwrap_or(SystemTime::UNIX_EPOCH), md.blocks() * 512, p.to_path_buf()));
197 -
                }
286 +
        // `<repo>/<job>/<cache>/<bookmark>/<disk>`
287 +
        kept(&self.root, 4, &mut |p, form| {
288 +
            if let Ok(md) = std::fs::symlink_metadata(p) {
289 +
                let bytes = match form {
290 +
                    Form::Image => md.blocks() * 512,
291 +
                    Form::Dir => tree_bytes(p),
292 +
                };
293 +
                disks.push((md.modified().unwrap_or(SystemTime::UNIX_EPOCH), bytes, p.to_path_buf()));
198 294
            }
199 295
        });
200 296
        let mut total: u64 = disks.iter().map(|d| d.1).sum();
…
3 unchanged lines
204 300
            if total <= self.limit {
205 301
                break;
206 302
            }
207 -
            if std::fs::remove_file(&p).is_ok() {
303 +
            let gone = match form_of(&p) {
304 +
                Some(Form::Dir) => crate::workspace::remove_tree(&p),
305 +
                _ => std::fs::remove_file(&p),
306 +
            };
307 +
            if gone.is_ok() {
208 308
                let _ = std::fs::remove_file(sources_of(&p));
209 309
                total -= bytes;
210 310
                freed += bytes;
…
3 unchanged lines
214 314
    }
215 315
}
216 316
317 +
/// Where a job's `n`th disk mount is in its scratch directory, in the form
318 +
/// its tier mounts it: `microvm::run` attaches `mount-N.ext4`, and
319 +
/// `syscall::run` binds the directory `mount-N`.
320 +
pub fn disk_path(form: Form, scratch: &Path, n: usize) -> PathBuf {
321 +
    match form {
322 +
        Form::Image => scratch.join(format!("mount-{n}.ext4")),
323 +
        Form::Dir => crate::syscall::disk_dir(scratch, n),
324 +
    }
325 +
}
326 +
217 327
fn modified(p: &Path) -> SystemTime {
218 -
    std::fs::metadata(p).and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH)
328 +
    std::fs::symlink_metadata(p).and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH)
219 329
}
220 330
221 -
fn newest(dir: &Path, ok: impl Fn(&Path) -> bool) -> Option<PathBuf> {
222 -
    std::fs::read_dir(dir)
223 -
        .ok()?
224 -
        .flatten()
225 -
        .map(|e| e.path())
226 -
        .filter(|p| p.extension().is_some_and(|e| e == "ext4") && ok(p))
227 -
        .max_by_key(|p| modified(p))
331 +
fn newest(dir: &Path, form: Form, ok: impl Fn(&Path) -> bool) -> Option<PathBuf> {
332 +
    std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).filter(|p| form_of(p) == Some(form) && ok(p)).max_by_key(|p| modified(p))
228 333
}
229 334
230 -
fn walk(dir: &Path, f: &mut dyn FnMut(&Path)) {
335 +
/// Each kept image or directory `depth` levels of directories below `dir`,
336 +
/// with its form; never inside a kept directory.
337 +
fn kept(dir: &Path, depth: usize, f: &mut dyn FnMut(&Path, Form)) {
231 338
    for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
232 339
        let p = e.path();
233 -
        if p.is_dir() {
234 -
            walk(&p, f);
235 -
        } else {
236 -
            f(&p);
340 +
        let Ok(t) = e.file_type() else { continue };
341 +
        if depth == 0 {
342 +
            match form_of(&p) {
343 +
                Some(Form::Image) if t.is_file() => f(&p, Form::Image),
344 +
                Some(Form::Dir) if t.is_dir() => f(&p, Form::Dir),
345 +
                _ => {}
346 +
            }
347 +
        } else if t.is_dir() {
348 +
            kept(&p, depth - 1, f);
349 +
        }
350 +
    }
351 +
}
352 +
353 +
/// The blocks a directory's files take, each file once however many links
354 +
/// it has; links are not followed.
355 +
fn tree_bytes(dir: &Path) -> u64 {
356 +
    fn walk(p: &Path, seen: &mut std::collections::HashSet<(u64, u64)>) -> u64 {
357 +
        let Ok(md) = std::fs::symlink_metadata(p) else { return 0 };
358 +
        if md.nlink() > 1 && !md.is_dir() && !seen.insert((md.dev(), md.ino())) {
359 +
            return 0;
360 +
        }
361 +
        let mut bytes = md.blocks() * 512;
362 +
        if md.is_dir() {
363 +
            for e in std::fs::read_dir(p).into_iter().flatten().flatten() {
364 +
                bytes += walk(&e.path(), seen);
365 +
            }
366 +
        }
367 +
        bytes
368 +
    }
369 +
    walk(dir, &mut Default::default())
370 +
}
371 +
372 +
/// Make a directory a job left fit for the pool: nothing in it writable by
373 +
/// group or others, nothing set-id or sticky, and everything readable by
374 +
/// the runner (directories searchable and writable too, so it can be
375 +
/// copied and removed). Links are not followed. The job ran as the
376 +
/// runner's user, so all of it is the runner's to change; times are kept.
377 +
fn seal(p: &Path) -> std::io::Result<()> {
378 +
    let md = std::fs::symlink_metadata(p)?;
379 +
    if md.file_type().is_symlink() {
380 +
        return Ok(());
381 +
    }
382 +
    let mode = md.mode() & 0o7777;
383 +
    let want = (mode & !0o7022) | if md.is_dir() { 0o700 } else { 0o400 };
384 +
    if want != mode {
385 +
        std::fs::set_permissions(p, std::fs::Permissions::from_mode(want))?;
386 +
    }
387 +
    if md.is_dir() {
388 +
        for e in std::fs::read_dir(p)? {
389 +
            seal(&e?.path())?;
237 390
        }
391 +
    }
392 +
    Ok(())
393 +
}
394 +
395 +
fn copy(form: Form, from: &Path, to: &Path) -> Result<()> {
396 +
    match form {
397 +
        Form::Image => sparse_copy(from, to),
398 +
        Form::Dir => tree_copy(from, to),
238 399
    }
239 400
}
240 401
…
9 unchanged lines
250 411
    Ok(())
251 412
}
252 413
414 +
/// Copy a directory with its modes and times (a build tool judges by
415 +
/// them), sharing blocks where the filesystem can.
416 +
fn tree_copy(from: &Path, to: &Path) -> Result<()> {
417 +
    let tmp = to.with_extension("tree-part");
418 +
    crate::workspace::remove_tree(&tmp)?;
419 +
    let st = std::process::Command::new("cp")
420 +
        .args(["-a", "--reflink=auto", "--no-target-directory"])
421 +
        .arg(from)
422 +
        .arg(&tmp)
423 +
        .status()
424 +
        .context("run cp")?;
425 +
    if !st.success() {
426 +
        let _ = crate::workspace::remove_tree(&tmp);
427 +
        anyhow::bail!("cp {} failed", from.display());
428 +
    }
429 +
    std::fs::rename(&tmp, to).inspect_err(|_| {
430 +
        let _ = crate::workspace::remove_tree(&tmp);
431 +
    })?;
432 +
    Ok(())
433 +
}
434 +
253 435
#[cfg(test)]
254 436
mod tests {
255 437
    use super::*;
…
25 unchanged lines
281 463
        let work = tempfile::tempdir().unwrap();
282 464
        let pool = Pool::new(home.path(), None);
283 465
        let m = mount(&["Cargo.lock"]);
284 -
        let main = Ctx { repo: "andy/hub", bookmark: "main", job: "test", image: "img" };
466 +
        let main = Ctx { repo: "andy/hub", bookmark: "main", job: "test", image: "img", form: Form::Image };
285 467
        let key = Pool::key(&main, &m, |_| Some("lock-1".into()));
286 468
        // Nothing yet: the caller makes an empty disk.
287 469
        assert!(pool.take(&main, &m, &key, &work.path().join("a.ext4")).is_none());
…
41 unchanged lines
329 511
330 512
    #[test]
331 513
    fn the_key_follows_the_image_the_job_and_the_key_files() {
332 -
        let ctx = Ctx { repo: "r", bookmark: "main", job: "test", image: "img-1" };
514 +
        let ctx = Ctx { repo: "r", bookmark: "main", job: "test", image: "img-1", form: Form::Image };
333 515
        let m = mount(&["Cargo.lock", "rust-toolchain.toml"]);
334 516
        let k = Pool::key(&ctx, &m, |p| Some(format!("{p}-v1")));
335 517
        assert_eq!(k, Pool::key(&ctx, &m, |p| Some(format!("{p}-v1"))));
…
8 unchanged lines
344 526
        let work = tempfile::tempdir().unwrap();
345 527
        let pool = Pool { root: home.path().join("cache"), limit: 0 };
346 528
        let m = mount(&[]);
347 -
        let ctx = Ctx { repo: "r", bookmark: "main", job: "j", image: "i" };
529 +
        let ctx = Ctx { repo: "r", bookmark: "main", job: "j", image: "i", form: Form::Image };
348 530
        let full = |p: &Path| {
349 531
            let f = std::fs::File::create(p).unwrap();
350 532
            std::os::unix::fs::FileExt::write_at(&f, &vec![7u8; 1 << 20], 0).unwrap();
…
6 unchanged lines
357 539
        full(&work.path().join("c.ext4"));
358 540
        roomy.keep(&ctx, &m, "k2", &work.path().join("c.ext4"), &Sources::new()).unwrap();
359 541
        assert!(roomy.take(&ctx, &m, "k2", &work.path().join("d.ext4")).is_some(), "under the limit it stays");
542 +
    }
543 +
544 +
    /// A directory as the syscall tier leaves one: `marker` in
545 +
    /// `debug/marker`, and a file of `kib` KiB.
546 +
    fn tree(path: &Path, marker: &[u8], kib: usize) {
547 +
        std::fs::create_dir_all(path.join("debug")).unwrap();
548 +
        std::fs::write(path.join("debug/marker"), marker).unwrap();
549 +
        // Not all one byte, so a compressing filesystem stores it whole.
550 +
        let bytes: Vec<u8> = (0..kib << 10).map(|i: usize| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect();
551 +
        std::fs::write(path.join("debug/blob"), bytes).unwrap();
552 +
    }
553 +
554 +
    fn dir_ctx<'a>(bookmark: &'a str) -> Ctx<'a> {
555 +
        Ctx { repo: "andy/flow", bookmark, job: "test", image: "img", form: Form::Dir }
556 +
    }
557 +
558 +
    /// oxygenflow#70: the syscall tier's directories are taken and kept as
559 +
    /// a microVM's images are, and never mixed with them.
560 +
    #[test]
561 +
    fn a_kept_directory_starts_the_next_syscall_job_and_others_copy_it() {
562 +
        let home = tempfile::tempdir().unwrap();
563 +
        let work = tempfile::tempdir().unwrap();
564 +
        let pool = Pool::new(home.path(), None);
565 +
        let m = mount(&["Cargo.lock"]);
566 +
        let main = dir_ctx("main");
567 +
        let key = Pool::key(&main, &m, |_| Some("lock-1".into()));
568 +
        let w = |n: &str| work.path().join(n);
569 +
        assert!(pool.take(&main, &m, &key, &w("a")).is_none(), "nothing kept yet");
570 +
        // A passing job's directory goes into the pool, times and all.
571 +
        tree(&w("a"), b"FIRST", 4);
572 +
        // Named like an image: the pool never looks inside a directory.
573 +
        std::fs::write(w("a/debug/inner.ext4"), b"not a disk").unwrap();
574 +
        let old = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000);
575 +
        std::fs::File::open(w("a/debug/marker")).unwrap().set_modified(old).unwrap();
576 +
        let built: Sources = [("src/lib.rs".to_string(), ("h1".to_string(), 1_000))].into();
577 +
        pool.settle(&main, &m, &key, &w("a"), &built, true).unwrap();
578 +
        assert!(!w("a").exists(), "moved, not copied");
579 +
        assert_eq!(
580 +
            pool.slot_times().into_keys().collect::<Vec<_>>(),
581 +
            vec![flow_core::api::cache_slot("andy/flow", "test")],
582 +
            "the claim reports it like an image (#51)"
583 +
        );
584 +
        // The next job of its bookmark moves it out: a job of the same
585 +
        // slot beside it finds nothing to take.
586 +
        let taken = pool.take(&main, &m, &key, &w("b")).unwrap();
587 +
        assert!(taken.how.contains("this bookmark's, same key"), "{}", taken.how);
588 +
        assert_eq!(taken.sources.as_ref(), Some(&built), "its sources come with it (#52)");
589 +
        assert_eq!(std::fs::read(w("b/debug/marker")).unwrap(), b"FIRST");
590 +
        assert_eq!(modified(&w("b/debug/marker")), old, "its files keep their times");
591 +
        assert_eq!(std::fs::read(w("b/debug/inner.ext4")).unwrap(), b"not a disk");
592 +
        assert!(pool.take(&main, &m, &key, &w("c")).is_none(), "taken once");
593 +
        assert!(pool.slot_times().is_empty());
594 +
        // Kept again, and under a changed key the newest is taken all the same.
595 +
        std::fs::write(w("b/debug/marker"), b"SECOND").unwrap();
596 +
        pool.settle(&main, &m, &key, &w("b"), &built, true).unwrap();
597 +
        let key2 = Pool::key(&main, &m, |_| Some("lock-2".into()));
598 +
        let taken = pool.take(&main, &m, &key2, &w("d")).unwrap();
599 +
        assert!(taken.how.contains("older key"), "{}", taken.how);
600 +
        assert_eq!(std::fs::read(w("d/debug/marker")).unwrap(), b"SECOND");
601 +
        pool.settle(&main, &m, &key2, &w("d"), &built, true).unwrap();
602 +
        // Another bookmark copies it, with its times, and main keeps its own.
603 +
        let feature = dir_ctx("feature");
604 +
        let taken = pool.take(&feature, &m, &key2, &w("e")).unwrap();
605 +
        assert!(taken.how.contains("copied from another bookmark's (main-"), "{}", taken.how);
606 +
        assert_eq!(taken.sources.as_ref(), Some(&built));
607 +
        assert_eq!(std::fs::read(w("e/debug/marker")).unwrap(), b"SECOND");
608 +
        assert!(pool.take(&main, &m, &key2, &w("f")).is_some(), "main's is still there");
609 +
        assert_eq!(modified(&w("e/debug/marker")), modified(&w("f/debug/marker")), "a copy keeps the times");
610 +
        pool.settle(&main, &m, &key2, &w("f"), &built, true).unwrap();
611 +
        // A microVM job of the same slot never takes a directory, nor a
612 +
        // syscall job an image.
613 +
        let image = Ctx { form: Form::Image, ..main };
614 +
        assert!(pool.take(&image, &m, &key2, &w("g.ext4")).is_none());
615 +
        disk(&w("h.ext4"), b"DISK");
616 +
        pool.keep(&image, &m, &key2, &w("h.ext4"), &built).unwrap();
617 +
        let taken = pool.take(&main, &m, &key2, &w("i")).unwrap();
618 +
        assert!(w("i/debug/marker").is_file(), "{}", taken.how);
619 +
        assert!(pool.take(&main, &m, &key2, &w("j")).is_none(), "the image is not a directory");
620 +
        assert!(pool.take(&image, &m, &key2, &w("k.ext4")).is_some(), "and is still there for a microVM");
621 +
    }
622 +
623 +
    #[test]
624 +
    fn a_failing_jobs_directory_is_thrown_away() {
625 +
        let home = tempfile::tempdir().unwrap();
626 +
        let work = tempfile::tempdir().unwrap();
627 +
        let pool = Pool::new(home.path(), None);
628 +
        let m = mount(&[]);
629 +
        let main = dir_ctx("main");
630 +
        tree(&work.path().join("a"), b"GOOD", 1);
631 +
        pool.settle(&main, &m, "k", &work.path().join("a"), &Sources::new(), true).unwrap();
632 +
        let taken = pool.take(&main, &m, "k", &work.path().join("b"));
633 +
        assert!(taken.is_some());
634 +
        // The job that took it fails after writing to it: not kept, gone.
635 +
        std::fs::write(work.path().join("b/debug/marker"), b"POISON").unwrap();
636 +
        pool.settle(&main, &m, "k", &work.path().join("b"), &Sources::new(), false).unwrap();
637 +
        assert!(!work.path().join("b").exists());
638 +
        assert!(pool.take(&main, &m, "k", &work.path().join("c")).is_none(), "nothing it wrote reaches a later job");
639 +
        assert!(pool.slot_times().is_empty());
640 +
        // A failed image is thrown away the same way.
641 +
        let image = Ctx { form: Form::Image, ..main };
642 +
        disk(&work.path().join("d.ext4"), b"BAD!");
643 +
        pool.settle(&image, &m, "k", &work.path().join("d.ext4"), &Sources::new(), false).unwrap();
644 +
        assert!(!work.path().join("d.ext4").exists() && pool.slot_times().is_empty());
645 +
    }
646 +
647 +
    #[test]
648 +
    fn directories_are_evicted_by_their_size_oldest_first() {
649 +
        let home = tempfile::tempdir().unwrap();
650 +
        let work = tempfile::tempdir().unwrap();
651 +
        // Room for one 512 KiB directory, not two.
652 +
        let pool = Pool { root: home.path().join("cache"), limit: 800 << 10 };
653 +
        let m = mount(&[]);
654 +
        let (main, feature) = (dir_ctx("main"), dir_ctx("feature"));
655 +
        tree(&work.path().join("a"), b"OLD", 512);
656 +
        pool.keep(&main, &m, "k1", &work.path().join("a"), &Sources::new()).unwrap();
657 +
        let kept = pool.cache_dir(&main, "target").join(segment("main")).join("k1.tree");
658 +
        assert!(kept.is_dir(), "under the limit it stays");
659 +
        assert!(tree_bytes(&kept) >= 512 << 10, "a directory counts what is in it");
660 +
        // Older than the next one, however fast this test runs.
661 +
        std::fs::File::open(&kept).unwrap().set_modified(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1)).unwrap();
662 +
        tree(&work.path().join("b"), b"NEW", 512);
663 +
        pool.keep(&feature, &m, "k1", &work.path().join("b"), &Sources::new()).unwrap();
664 +
        assert!(!kept.exists() && !kept.with_extension("tree-sources").exists(), "the one used longest ago goes, sources and all");
665 +
        let taken = pool.take(&main, &m, "k1", &work.path().join("c")).unwrap();
666 +
        assert!(taken.how.contains("bookmark's (feature-"), "{}", taken.how);
667 +
        assert_eq!(std::fs::read(work.path().join("c/debug/marker")).unwrap(), b"NEW");
668 +
    }
669 +
670 +
    /// oxygenflow#70: the job wrote what it liked; the pool keeps nothing
671 +
    /// others may write or that runs as the runner, and is the runner's.
672 +
    #[test]
673 +
    fn nothing_kept_is_writable_by_others_or_set_id() {
674 +
        let home = tempfile::tempdir().unwrap();
675 +
        let work = tempfile::tempdir().unwrap();
676 +
        let pool = Pool::new(home.path(), None);
677 +
        let m = mount(&[]);
678 +
        let main = dir_ctx("main");
679 +
        let a = work.path().join("a");
680 +
        tree(&a, b"X", 1);
681 +
        let mode = |p: &Path, m: u32| std::fs::set_permissions(p, std::fs::Permissions::from_mode(m)).unwrap();
682 +
        std::fs::write(a.join("open"), b"").unwrap();
683 +
        mode(&a.join("open"), 0o777);
684 +
        std::fs::write(a.join("suid"), b"").unwrap();
685 +
        mode(&a.join("suid"), 0o4755);
686 +
        std::fs::write(a.join("debug/sealed"), b"secret").unwrap();
687 +
        mode(&a.join("debug/sealed"), 0o000);
688 +
        std::os::unix::fs::symlink("/etc/passwd", a.join("link")).unwrap();
689 +
        let when = modified(&a.join("debug/marker"));
690 +
        mode(&a.join("debug"), 0o1777);
691 +
        mode(&a, 0o500);
692 +
        pool.keep(&main, &m, "k", &a, &Sources::new()).unwrap();
693 +
        let root = home.path().join("cache");
694 +
        assert_eq!(std::fs::metadata(&root).unwrap().mode() & 0o777, 0o700, "the pool is the runner's alone");
695 +
        let mut seen = 0;
696 +
        fn each(p: &Path, f: &mut dyn FnMut(&Path, &std::fs::Metadata)) {
697 +
            let md = std::fs::symlink_metadata(p).unwrap();
698 +
            f(p, &md);
699 +
            if md.is_dir() {
700 +
                for e in std::fs::read_dir(p).unwrap() {
701 +
                    each(&e.unwrap().path(), f);
702 +
                }
703 +
            }
704 +
        }
705 +
        each(&root, &mut |p, md| {
706 +
            seen += 1;
707 +
            if !md.file_type().is_symlink() {
708 +
                assert_eq!(md.mode() & 0o7022, 0, "{} is {:o}", p.display(), md.mode());
709 +
            }
710 +
        });
711 +
        assert!(seen >= 10, "{seen}");
712 +
        // Still readable, with its times, so the next job builds on it.
713 +
        let taken = pool.take(&main, &m, "k", &work.path().join("b"));
714 +
        assert!(taken.is_some());
715 +
        let b = work.path().join("b");
716 +
        assert_eq!(std::fs::read(b.join("debug/sealed")).unwrap(), b"secret");
717 +
        assert_eq!(std::fs::metadata(b.join("open")).unwrap().mode() & 0o777, 0o755);
718 +
        assert_eq!(std::fs::read_link(b.join("link")).unwrap(), Path::new("/etc/passwd"), "a link is kept, not followed");
719 +
        assert_eq!(modified(&b.join("debug/marker")), when);
360 720
    }
361 721
}
modified crates/flow/src/runner.rs +26 −26
…
847 unchanged lines
848 848
        // The syscall tier runs on the tree itself; a microVM gets an image.
849 849
        let syscall_tier = claim.sandbox.runtime == flow_core::Runtime::Syscall;
850 850
        let job = claim.job.in_sandbox(&claim.sandbox).with_flow_env(&claim.flow_env());
851 -
        // Disk mounts that name a cache start from a kept disk (#28); the
852 -
        // microVM takes a disk already in place as it is. Taken before the
853 -
        // workspace is imaged: the sources the disk was built from give
854 -
        // unchanged files their old times back (#52).
851 +
        // Disk mounts that name a cache start from a kept disk (#28): a
852 +
        // microVM takes an image already in place as it is, the syscall tier
853 +
        // a directory (#70). Taken before the workspace is imaged: the
854 +
        // sources the disk was built from give unchanged files their old
855 +
        // times back (#52).
855 856
        let asked = Instant::now();
856 857
        preparing = Some(PREPARE.lock().unwrap_or_else(|p| p.into_inner()));
857 858
        if asked.elapsed().as_secs() >= 2 {
858 859
            note(&mut sink, format!("waited {} for another job imaging its workspace", secs(asked.elapsed())));
859 860
        }
860 861
        let pool = crate::cache::Pool::new(&home.root, cfg.cache_gb);
862 +
        let form = if syscall_tier { crate::cache::Form::Dir } else { crate::cache::Form::Image };
861 863
        let cache_ctx =
862 -
            crate::cache::Ctx { repo: &claim.repo, bookmark: &claim.bookmark, job: &claim.job.name, image: &claim.sandbox.rootfs };
864 +
            crate::cache::Ctx { repo: &claim.repo, bookmark: &claim.bookmark, job: &claim.job.name, image: &claim.sandbox.rootfs, form };
863 865
        let mut cached: Vec<(usize, String, String)> = Vec::new();
864 866
        let mut built_from: Option<crate::cache::Sources> = None;
865 -
        if !syscall_tier {
866 -
            for (i, m) in job.mounts.iter().filter(|m| m.source == flow_core::pipeline::MountSource::Disk).enumerate() {
867 -
                let Some(name) = &m.cache else { continue };
868 -
                let key = crate::cache::Pool::key(&cache_ctx, m, |p| files.iter().find(|(f, _)| f == p).map(|(_, h)| hex::encode(h)));
869 -
                let how = match pool.take(&cache_ctx, m, &key, &scratch.join(format!("mount-{i}.ext4"))) {
870 -
                    Some(t) => {
871 -
                        if built_from.is_none() {
872 -
                            built_from = t.sources;
873 -
                        }
874 -
                        t.how
867 +
        for (i, m) in job.mounts.iter().filter(|m| m.source == flow_core::pipeline::MountSource::Disk).enumerate() {
868 +
            let Some(name) = &m.cache else { continue };
869 +
            let key = crate::cache::Pool::key(&cache_ctx, m, |p| files.iter().find(|(f, _)| f == p).map(|(_, h)| hex::encode(h)));
870 +
            let how = match pool.take(&cache_ctx, m, &key, &crate::cache::disk_path(form, &scratch, i)) {
871 +
                Some(t) => {
872 +
                    if built_from.is_none() {
873 +
                        built_from = t.sources;
875 874
                    }
876 -
                    None => format!("cache {name}: none kept yet, an empty disk"),
877 -
                };
878 -
                note(&mut sink, format!("{} ({})", how, m.target));
879 -
                cached.push((i, key, how));
880 -
            }
875 +
                    t.how
876 +
                }
877 +
                None => format!("cache {name}: none kept yet, an empty disk"),
878 +
            };
879 +
            note(&mut sink, format!("{} ({})", how, m.target));
880 +
            cached.push((i, key, how));
881 881
        }
882 882
        let sources = if cached.is_empty() {
883 883
            crate::cache::Sources::new()
…
121 unchanged lines
1005 1005
        };
1006 1006
        // Measured before a cached disk leaves the scratch directory.
1007 1007
        let peak_disk_mib = crate::facts::allocated_mib(&scratch);
1008 -
        // A passing job's cached disks are kept; a failing job's are not,
1009 -
        // so nothing it wrote reaches a later job.
1010 -
        if passed && output_error.is_none() {
1008 +
        // A passing job's cached disks are kept; a failing job's are
1009 +
        // thrown away, so nothing it wrote reaches a later job.
1010 +
        let kept = passed && output_error.is_none();
1011 1011
        for (i, key, _) in &cached {
1012 -
                if let Err(e) = pool.keep(&cache_ctx, disk_mounts[*i], key, &scratch.join(format!("mount-{i}.ext4")), &sources) {
1013 -
                    note(&mut sink, format!("cache: not kept: {e:#}"));
1014 -
                }
1012 +
            let disk = crate::cache::disk_path(form, &scratch, *i);
1013 +
            if let Err(e) = pool.settle(&cache_ctx, disk_mounts[*i], key, &disk, &sources, kept) {
1014 +
                note(&mut sink, format!("cache: not {}: {e:#}", if kept { "kept" } else { "discarded" }));
1015 1015
            }
1016 1016
        }
1017 1017
        Ok(Finish {
…
452 unchanged lines
modified docs/m0.md +1 −1
…
174 unchanged lines
175 175
- **Root:** PID 1 binds the unpacked tree read-only as the new root, the workspace at `/src`, tmpfs at `/tmp`, `/run` and `/run/secrets`, a fresh `/proc`, and a `/dev` of `null`, `zero`, `full`, `random` and `urandom` with `/dev/shm`. It brings `lo` up for `network "loopback"`, pivots into the root and detaches the old one, so the host's files, the spec and the scratch directory are out of reach.
176 176
- **Job:** the job gets no new privileges, then Landlock (read and execute everywhere, write beneath `/src`, `/tmp`, `/dev` and its mounts), then the seccomp filter, then `execve` with exactly the job's environment, found the way `flow-init` finds it (the two share that code). The filter also refuses io_uring (which could open sockets past it), the new mount calls, kexec, swap and reboot.
177 177
- **Unpacking:** `debugfs rdump` unpacks the image into `trees/<hash>` in the home once, without mount rights. Set-id bits and device nodes are dropped.
178 -
- **Ephemeral mounts:** a `tmpfs` mount is a tmpfs of its size. An `ephemeral://disk` mount is a scratch directory, so its size is not enforced in this tier.
178 +
- **Ephemeral mounts:** a `tmpfs` mount is a tmpfs of its size. An `ephemeral://disk` mount is a scratch directory, so its size is not enforced in this tier. One that names a `cache` starts from a directory a passing job of the slot kept, and goes back into the runner's cache pool when the job passes (spec §5.4, oxygenflow#70).
179 179
- **Artifacts:** they are read from the workspace and mount directories without following links.
180 180
- **Receipts:** the environment's runtime is `syscall`. Its parts are the `flow` binary's BLAKE3, the host kernel's release, memory, CPUs and mounts.
181 181
- **Limits:** the job's cgroup gets `memory.max`, `memory.swap.max 0` and `cpu.max`. A job killed for memory is told so in its log. At the end the group is killed and removed.
…
74 unchanged lines
modified docs/spec.md +18 −3
…
289 unchanged lines
290 290
starts as, in order: its bookmark's disk with the same key (moved), its
291 291
bookmark's newest disk (moved), another bookmark's with the same key and
292 292
then another's newest (copied, so a proposal never takes the default
293 -
bookmark's), and otherwise an empty disk. A disk must be the size the
294 -
mount asks for.
293 +
bookmark's), and otherwise an empty disk. A microVM's disk must be the
294 +
size the mount asks for.
295 295
296 296
- **Only passing jobs keep a disk.** When the job and its outputs pass,
297 297
  its cached disks go back into the pool under their key; otherwise they
…
23 unchanged lines
321 321
  kept a disk (`cache_times`, Unix ms), and a copy kept at least a minute
322 322
  later ranks above an older one, before the image and speed are
323 323
  weighed; `placed` says how old the copy is.
324 -
- The syscall tier keeps its disks empty for now.
324 +
- **The syscall tier keeps directories** (oxygenflow#70). Its
325 +
  `ephemeral://disk` mount is a host directory bound at the target, so
326 +
  its pool holds directories (`<key>.tree`) beside the microVM's images
327 +
  (`<key>.ext4`), taken and kept the same way: moved out of the pool for a
328 +
  job of the same bookmark (so two jobs of one slot running at once never
329 +
  take the same one), copied with their modes and times from another
330 +
  bookmark's, kept only when the job and its outputs pass and otherwise
331 +
  thrown away, and counted by the blocks of everything in them toward
332 +
  `cache_gb`. A directory has no size, so `size` does not choose between
333 +
  them. Each tier takes only its own form: a syscall job never starts from
334 +
  a microVM's image, nor a microVM from a directory. The job runs as the
335 +
  runner's user, so what it wrote is the runner's; before a directory goes
336 +
  into the pool nothing in it is left writable by group or others, or
337 +
  set-id, and the pool is readable by the runner alone. Claims report the
338 +
  slots of both forms in `caches` and `cache_times`, so jobs go where
339 +
  their cache is whichever tier they run in.
325 340
- A cache is a speed-up, never a source of truth: jobs must build
326 341
  correctly from an empty disk, which a key change or an eviction gives
327 342
  them at any time.
…
317 unchanged lines