OXYGENHUB

The runner as a library, with the WASM tier in process, for the phone (refs oxygenhub-android#8) 5cf1e8aefbb6

flow is a library (src/lib.rs) and the command built on it, so the
Android app can link the runner through its Rust core; the whole crate
builds for aarch64-linux-android (the seccomp filter's BPF opcodes are
spelled out, as libc lacks them there, checked against glibc's in a
test; flow-init's ioctl request type is portable). runner::serve_embedded
runs it without signal handlers and runner::stop drains it, as a phone
leaving its charger or network will (#76).

In an app the WASM tier cannot start itself as a command, so on Android
(and with FLOW_WASM_IN_PROCESS) modules run in a thread: output read
from WASI pipes every 100 ms and passed on as lines, the time limit and
cancelling through wasmtime's epoch interruption, and the most memory a
module asked for from a resource limiter (both ways). Tests: the
integration pipeline in both ways, streaming, the time limit and a
cancel in process.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key aca6e3c427e8
When
· 2026-10-25 14:33 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
5cf1e8aefbb6118abf4f6617a91ecd727ca92869ca79b30872f85a4a897e7175
Depends on
28cc603f737d 47c1d64dec8a 4be5e4cd0af6 4cf0c99a1486 62829d15ef09 89171fca079c 9e8f4a56630b a67b97e9a717 b14aeaae683a c3db750ed96e d60aac6c3f8a e0918b887850 ea56c56eaf90 ea81796bb757
Depended on by
f3b5b86e23ef 788f57e4d266 21a3ea4aab88 77c8c24066ef 494a1b9a8ac2 3554afefb6aa cc462b56fbbb 85275e1ef3b7 f201e1bc5bcd 06a4c472b385 fa963b1b7eb6
In bookmarks
main
In tags
v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
Edits
39 atoms, 1 tree operation, 9 files

Browse the tree at this change

added crates/flow/src/lib.rs +23 −0
1 +
//! OxygenFlow's runner and tiers as a library: the `flow` command is built
2 +
//! on it, and so is the Android app's runner (oxygenhub-android#8), which
3 +
//! runs the WASM tier in its own process (docs/handover.md §10).
4 +
5 +
pub mod cache;
6 +
pub mod cpio;
7 +
pub mod egress;
8 +
pub mod facts;
9 +
/// The job-starting code `flow-init` uses in a microVM, shared with the
10 +
/// syscall tier.
11 +
#[path = "../../flow-init/src/job.rs"]
12 +
pub mod guest;
13 +
pub mod home;
14 +
pub mod images;
15 +
pub mod metrics;
16 +
pub mod microvm;
17 +
pub mod outputs;
18 +
pub mod runner;
19 +
pub mod swarm;
20 +
pub mod syscall;
21 +
pub mod wait;
22 +
pub mod wasm;
23 +
pub mod workspace;
modified CHANGELOG.md +1 −0
…
4 unchanged lines
5 5
## Unreleased
6 6
7 7
- **The WASM tier (#75):** `runtime "wasm"` runs WASI preview 1 modules with wasmtime 49 on any runner (`flow tiers` offers `wasm` everywhere): no rootfs, no network, the job's files at `/src`, its mounts at their targets, the memory limit enforced by wasmtime. `exec` names a module or a glob of them (`target/wasm32-wasip1/debug/deps/*.wasm` runs each test binary in turn); the receipt names wasmtime's version and every module's hash. The tier phones and browsers will run (docs/handover.md). Secrets and identities are not offered in it yet.
8 +
- **The runner as a library, for the phone (oxygenhub-android#8):** `flow` is now a library and the command built on it, and the whole runner builds for Android (`aarch64-linux-android`); `runner::serve_embedded` and `runner::stop` run it inside an app and drain it. There the WASM tier runs modules in a thread rather than a child process (also with `FLOW_WASM_IN_PROCESS`), streaming their output from pipes, stopping them at the time limit or when the run is cancelled through wasmtime's epoch, and reporting the most memory they asked for. Two portability fixes on the way (the seccomp filter's BPF opcodes and an ioctl request type).
8 9
- **cargo-nextest in the Rust image (#81):** for JUnit test reports beside `cargo llvm-cov`'s coverage.
9 10
## 0.3.1 (2026-10-25)
10 11
…
85 unchanged lines
modified crates/flow-init/src/job.rs +3 −2
…
69 unchanged lines
70 70
        req.ifr_name[i] = *b as libc::c_char;
71 71
    }
72 72
    let ok = unsafe {
73 -
        libc::ioctl(fd, libc::SIOCGIFFLAGS, &mut req) == 0 && {
73 +
        // `as _`: the request's type differs between libcs (Android's is c_int).
74 +
        libc::ioctl(fd, libc::SIOCGIFFLAGS as _, &mut req) == 0 && {
74 75
            req.ifr_ifru.ifru_flags |= libc::IFF_UP as libc::c_short;
75 -
            libc::ioctl(fd, libc::SIOCSIFFLAGS, &req) == 0
76 +
            libc::ioctl(fd, libc::SIOCSIFFLAGS as _, &req) == 0
76 77
        }
77 78
    };
78 79
    let err = std::io::Error::last_os_error();
…
58 unchanged lines
modified crates/flow/Cargo.toml +4 −0
…
4 unchanged lines
5 5
license.workspace = true
6 6
description = "OxygenFlow: check pipelines, run them locally, and run jobs for OxygenHub"
7 7
8 +
[lib]
9 +
name = "flow"
10 +
path = "src/lib.rs"
11 +
8 12
[[bin]]
9 13
name = "flow"
10 14
path = "src/main.rs"
…
26 unchanged lines
modified crates/flow/src/main.rs +1 −19
1 1
//! `flow`: check pipelines, run them locally, and run jobs for OxygenHub
2 2
//! (docs/spec.md, docs/m0.md).
3 3
4 -
mod cache;
5 -
mod cpio;
6 -
mod egress;
7 -
mod facts;
8 -
/// The job-starting code `flow-init` uses in a microVM, shared with the
9 -
/// syscall tier.
10 -
#[path = "../../flow-init/src/job.rs"]
11 -
mod guest;
12 -
mod home;
13 -
mod images;
14 -
mod metrics;
15 -
mod microvm;
16 -
mod outputs;
17 -
mod runner;
18 -
mod swarm;
19 -
mod syscall;
20 -
mod wait;
21 -
mod wasm;
22 -
mod workspace;
4 +
use flow::{facts, home, images, microvm, runner, syscall, wait, wasm, workspace};
23 5
24 6
use anyhow::{bail, Context, Result};
25 7
use clap::{Parser, Subcommand};
…
549 unchanged lines
modified crates/flow/src/runner.rs +25 −1
…
570 unchanged lines
571 571
}
572 572
573 573
pub fn serve(home: &Home) -> Result<()> {
574 +
    drain_on_stop();
575 +
    serve_until_drained(home)
576 +
}
577 +
578 +
/// The runner as a library, as the Android app runs it (oxygenhub-android#8):
579 +
/// no signal handlers; [`stop`] drains it, and this returns once the jobs
580 +
/// it holds are done and reported. It may be started again afterwards.
581 +
pub fn serve_embedded(home: &Home) -> Result<()> {
582 +
    STOPS.store(0, std::sync::atomic::Ordering::SeqCst);
583 +
    serve_until_drained(home)
584 +
}
585 +
586 +
/// Ask a runner started with [`serve_embedded`] to drain: claim nothing
587 +
/// more, finish and report what it holds, then return (oxygenflow#76's
588 +
/// drain, for a phone taken off charge or off the allowed network).
589 +
pub fn stop() {
590 +
    STOPS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
591 +
}
592 +
593 +
fn serve_until_drained(home: &Home) -> Result<()> {
574 594
    let cfg = home.config.runner.clone().context("flow.toml has no [runner] section")?;
575 595
    // A runner offers only what it can run: a machine without KVM claims
576 596
    // nothing rather than taking MicroVM jobs and failing them (#36).
…
4 unchanged lines
581 601
    let hub = Hub::new(&cfg)?;
582 602
    let name = cfg.name.clone().unwrap_or_else(|| "flow".into());
583 603
    let budget = Budget::of(&cfg);
584 -
    drain_on_stop();
585 604
    let key = home.runner_key(&name)?;
586 605
    // The swarm (docs/jetstream.md §8.1): a runner that cannot serve its
587 606
    // peers still runs jobs, fetching images from the hub alone.
…
1161 unchanged lines
1749 1768
        assert!(!stopping());
1750 1769
        on_stop(libc::SIGTERM);
1751 1770
        assert!(stopping(), "told to stop, it claims nothing more");
1771 +
        // An embedded runner starts afresh, and stop() drains it the same way.
1772 +
        STOPS.store(0, std::sync::atomic::Ordering::SeqCst);
1773 +
        stop();
1774 +
        assert!(stopping());
1775 +
        STOPS.store(0, std::sync::atomic::Ordering::SeqCst);
1752 1776
    }
1753 1777
1754 1778
    /// Lines kept for a hub that stays away are bounded, saying how many went.
…
27 unchanged lines
modified crates/flow/src/syscall.rs +27 −3
…
828 unchanged lines
829 829
    libc::SYS_io_uring_register,
830 830
];
831 831
832 +
/// Classic BPF opcodes (linux/bpf_common.h), fixed by the kernel's ABI;
833 +
/// spelled out because libc does not export them for every target
834 +
/// (Android, where the runner is built for the WASM tier only).
835 +
const BPF_LD: u16 = 0x00;
836 +
const BPF_W: u16 = 0x00;
837 +
const BPF_ABS: u16 = 0x20;
838 +
const BPF_JMP: u16 = 0x05;
839 +
const BPF_JEQ: u16 = 0x10;
840 +
const BPF_K: u16 = 0x00;
841 +
const BPF_RET: u16 = 0x06;
842 +
832 843
/// Install a filter that fails each of `denied` with EPERM and `socket`
833 844
/// for any family but `AF_UNIX` with EAFNOSUPPORT (an empty `denied`
834 845
/// still checks sockets). Another architecture's calls kill the process.
835 846
fn seccomp(denied: &[libc::c_long]) -> std::result::Result<(), String> {
836 -
    const LD: u16 = (libc::BPF_LD | libc::BPF_W | libc::BPF_ABS) as u16;
837 -
    const JEQ: u16 = (libc::BPF_JMP | libc::BPF_JEQ | libc::BPF_K) as u16;
838 -
    const RET: u16 = (libc::BPF_RET | libc::BPF_K) as u16;
847 +
    const LD: u16 = BPF_LD | BPF_W | BPF_ABS;
848 +
    const JEQ: u16 = BPF_JMP | BPF_JEQ | BPF_K;
849 +
    const RET: u16 = BPF_RET | BPF_K;
839 850
    let op = |code: u16, k: u32, jt: u8, jf: u8| libc::sock_filter { code, jt, jf, k };
840 851
    let errno = |e: libc::c_int| libc::SECCOMP_RET_ERRNO | (e as u32 & libc::SECCOMP_RET_DATA);
841 852
    // struct seccomp_data: nr at 0, arch at 4, args[0] at 16 (its low
…
20 unchanged lines
862 873
        return Err(format!("seccomp: {}", std::io::Error::last_os_error()));
863 874
    }
864 875
    Ok(())
876 +
}
877 +
878 +
#[cfg(all(test, not(target_os = "android")))]
879 +
mod tests {
880 +
    /// The opcodes written out above are the ABI's, as glibc's libc has them.
881 +
    #[test]
882 +
    fn bpf_opcodes_are_the_kernels() {
883 +
        use super::*;
884 +
        assert_eq!(
885 +
            [BPF_LD, BPF_W, BPF_ABS, BPF_JMP, BPF_JEQ, BPF_K, BPF_RET],
886 +
            [libc::BPF_LD, libc::BPF_W, libc::BPF_ABS, libc::BPF_JMP, libc::BPF_JEQ, libc::BPF_K, libc::BPF_RET].map(|v| v as u16)
887 +
        );
888 +
    }
865 889
}
modified crates/flow/src/wasm.rs +229 −20
…
150 unchanged lines
151 151
        memory_mib: sandbox.memory_mib,
152 152
        preopens,
153 153
    };
154 +
    if in_process() {
155 +
        return run_in_thread(&spec, environment, timeout, sink);
156 +
    }
154 157
    let spec_path = scratch.join("wasm.json");
155 158
    std::fs::write(&spec_path, serde_json::to_vec(&spec)?)?;
156 159
…
74 unchanged lines
231 234
        peak_memory_mib,
232 235
        pruned: Vec::new(),
233 236
    })
237 +
}
238 +
239 +
/// Whether modules run in a thread of this process rather than in a child
240 +
/// `flow __wasm`: on Android, where the runner is a library inside an app
241 +
/// that cannot start itself as a command (oxygenhub-android#8), and
242 +
/// anywhere `FLOW_WASM_IN_PROCESS` is set (how the tests check this way).
243 +
pub fn in_process() -> bool {
244 +
    cfg!(target_os = "android") || std::env::var_os("FLOW_WASM_IN_PROCESS").is_some()
234 245
}
235 246
247 +
/// The most of a module's output kept before it is read, per stream.
248 +
const PIPE_BYTES: usize = 64 << 20;
249 +
250 +
/// Run the spec's modules in a thread of this process, streaming their
251 +
/// output as it comes: the pipes are read every 100 ms, and a time limit
252 +
/// or a cancelled run interrupts the module through wasmtime's epoch.
253 +
fn run_in_thread(spec: &Spec, environment: Environment, timeout: Duration, sink: &mut dyn Sink) -> Result<Outcome> {
254 +
    use wasmtime_wasi::p2::pipe::MemoryOutputPipe;
255 +
    let mut config = wasmtime::Config::new();
256 +
    config.epoch_interruption(true);
257 +
    let engine = ww(wasmtime::Engine::new(&config), || "start wasmtime".into())?;
258 +
    let (stdout, stderr) = (MemoryOutputPipe::new(PIPE_BYTES), MemoryOutputPipe::new(PIPE_BYTES));
259 +
    let (notes_tx, notes) = mpsc::channel::<String>();
260 +
    let io = Io::Pipes { stdout: stdout.clone(), stderr: stderr.clone(), notes: notes_tx };
261 +
    let started = Instant::now();
262 +
    let t = |s: &Instant| s.elapsed().as_millis() as u64;
263 +
    let deadline = started + timeout;
264 +
    let (mut timed_out, mut cancelled) = (false, false);
265 +
    // Bytes of each stream already passed on, and the part line not ended yet.
266 +
    let mut said = [(0usize, Vec::<u8>::new()), (0usize, Vec::<u8>::new())];
267 +
    let mut pass_on = |sink: &mut dyn Sink, last: bool| {
268 +
        for (i, pipe) in [&stdout, &stderr].into_iter().enumerate() {
269 +
            let all = pipe.contents();
270 +
            let (offset, partial) = &mut said[i];
271 +
            partial.extend_from_slice(&all[*offset..]);
272 +
            *offset = all.len();
273 +
            while let Some(n) = partial.iter().position(|b| *b == b'\n') {
274 +
                let line: Vec<u8> = partial.drain(..=n).collect();
275 +
                let text = String::from_utf8_lossy(&line[..line.len() - 1]).into_owned();
276 +
                sink.record(Record { t: t(&started), stream: if i == 0 { "out" } else { "err" }.into(), text });
277 +
            }
278 +
            if last && !partial.is_empty() {
279 +
                let text = String::from_utf8_lossy(partial).into_owned();
280 +
                partial.clear();
281 +
                sink.record(Record { t: t(&started), stream: if i == 0 { "out" } else { "err" }.into(), text });
282 +
            }
283 +
        }
284 +
        while let Ok(note) = notes.try_recv() {
285 +
            sink.record(Record { t: t(&started), stream: "flow".into(), text: note });
286 +
        }
287 +
    };
288 +
    let ran = std::thread::scope(|scope| {
289 +
        let worker = scope.spawn(|| run_modules(&engine, spec, &io));
290 +
        while !worker.is_finished() {
291 +
            std::thread::sleep(Duration::from_millis(100));
292 +
            pass_on(sink, false);
293 +
            if !timed_out && !cancelled && sink.cancelled() {
294 +
                cancelled = true;
295 +
                engine.increment_epoch();
296 +
                sink.record(Record { t: t(&started), stream: "flow".into(), text: "stopped: the run was cancelled".into() });
297 +
            }
298 +
            if Instant::now() >= deadline && !timed_out && !cancelled {
299 +
                timed_out = true;
300 +
                engine.increment_epoch();
301 +
                sink.record(Record {
302 +
                    t: t(&started),
303 +
                    stream: "flow".into(),
304 +
                    text: format!("stopped at the time limit of {} s", timeout.as_secs()),
305 +
                });
306 +
            }
307 +
        }
308 +
        worker.join().unwrap_or_else(|_| Err(anyhow::anyhow!("the WASM worker panicked")))
309 +
    });
310 +
    pass_on(sink, true);
311 +
    let (exit_code, peak) = match ran {
312 +
        Ok((code, peak)) => (Some(code), Some(peak)),
313 +
        Err(e) => {
314 +
            sink.record(Record { t: t(&started), stream: "flow".into(), text: format!("the WASM tier could not run the job: {e:#}") });
315 +
            (Some(125), None)
316 +
        }
317 +
    };
318 +
    Ok(Outcome {
319 +
        exit_code: if timed_out || cancelled { None } else { exit_code },
320 +
        duration_ms: t(&started),
321 +
        environment,
322 +
        timed_out,
323 +
        cancelled,
324 +
        peak_memory_mib: peak.map(|b| (b as u64).div_ceil(1 << 20)),
325 +
        pruned: Vec::new(),
326 +
    })
327 +
}
328 +
236 329
/// `flow __wasm SPEC`: run the spec's modules in turn, each in a fresh
237 330
/// instance; the exit code is the first non-zero one, after all have run,
238 331
/// so one failing module does not hide another's results.
239 332
pub fn wasm_main(spec_path: &Path) -> i32 {
240 -
    match helper(spec_path) {
241 -
        Ok(code) => code,
333 +
    let ran = (|| {
334 +
        let spec: Spec = serde_json::from_slice(&std::fs::read(spec_path)?).context("read the spec")?;
335 +
        let engine = ww(wasmtime::Engine::new(&wasmtime::Config::new()), || "start wasmtime".into())?;
336 +
        run_modules(&engine, &spec, &Io::Inherit)
337 +
    })();
338 +
    match ran {
339 +
        Ok((code, _)) => code,
242 340
        Err(e) => {
243 341
            eprintln!("{FAILED}{e:#}");
244 342
            125
…
4 unchanged lines
249 347
/// wasmtime's own error, as this crate's: with its whole chain of causes.
250 348
fn ww<T>(r: wasmtime::Result<T>, what: impl FnOnce() -> String) -> Result<T> {
251 349
    r.map_err(|e| anyhow::anyhow!("{}: {e:#}", what()))
350 +
}
351 +
352 +
/// Where a module's output and the tier's notes go: inherited by the
353 +
/// helper process (notes on stderr, marked), or pipes the runner reads.
354 +
enum Io {
355 +
    Inherit,
356 +
    Pipes {
357 +
        stdout: wasmtime_wasi::p2::pipe::MemoryOutputPipe,
358 +
        stderr: wasmtime_wasi::p2::pipe::MemoryOutputPipe,
359 +
        notes: mpsc::Sender<String>,
360 +
    },
361 +
}
362 +
363 +
impl Io {
364 +
    fn note(&self, text: String) {
365 +
        match self {
366 +
            Io::Inherit => eprintln!("{NOTE}{text}"),
367 +
            Io::Pipes { notes, .. } => {
368 +
                let _ = notes.send(text);
369 +
            }
370 +
        }
371 +
    }
252 372
}
253 373
374 +
/// The sandbox's memory limit, and the most a module asked for within it.
375 +
struct Limits {
376 +
    memory: usize,
377 +
    peak: usize,
378 +
}
379 +
380 +
impl wasmtime::ResourceLimiter for Limits {
381 +
    fn memory_growing(&mut self, _current: usize, desired: usize, maximum: Option<usize>) -> wasmtime::Result<bool> {
382 +
        let ok = desired <= self.memory && maximum.is_none_or(|m| desired <= m);
383 +
        if ok {
384 +
            self.peak = self.peak.max(desired);
385 +
        }
386 +
        Ok(ok)
387 +
    }
388 +
389 +
    fn table_growing(&mut self, _current: usize, desired: usize, maximum: Option<usize>) -> wasmtime::Result<bool> {
390 +
        Ok(maximum.is_none_or(|m| desired <= m))
391 +
    }
392 +
}
393 +
254 394
struct State {
255 395
    wasi: wasmtime_wasi::p1::WasiP1Ctx,
256 -
    limits: wasmtime::StoreLimits,
396 +
    limits: Limits,
257 397
}
258 398
259 -
fn helper(spec_path: &Path) -> Result<i32> {
260 -
    let spec: Spec = serde_json::from_slice(&std::fs::read(spec_path)?).context("read the spec")?;
261 -
    let engine = ww(wasmtime::Engine::new(&wasmtime::Config::new()), || "start wasmtime".into())?;
262 -
    let mut linker: wasmtime::Linker<State> = wasmtime::Linker::new(&engine);
399 +
/// Run each module in turn: the first non-zero exit code after all ran,
400 +
/// and the most memory any of them held, in bytes.
401 +
fn run_modules(engine: &wasmtime::Engine, spec: &Spec, io: &Io) -> Result<(i32, usize)> {
402 +
    let mut linker: wasmtime::Linker<State> = wasmtime::Linker::new(engine);
263 403
    ww(wasmtime_wasi::p1::add_to_linker_sync(&mut linker, |s: &mut State| &mut s.wasi), || "link WASI".into())?;
264 -
    let mut first_failure = 0;
404 +
    let (mut first_failure, mut peak) = (0, 0);
265 405
    for path in &spec.modules {
266 -
        let code = run_module(&engine, &linker, &spec, path)?;
267 406
        if spec.modules.len() > 1 {
268 -
            eprintln!("{NOTE}{path}: exit {code}");
407 +
            io.note(format!("running {path}"));
269 408
        }
409 +
        let (code, held) = run_module(engine, &linker, spec, path, io)?;
410 +
        peak = peak.max(held);
411 +
        if spec.modules.len() > 1 {
412 +
            io.note(format!("{path}: exit {code}"));
413 +
        }
270 414
        if first_failure == 0 && code != 0 {
271 415
            first_failure = code;
272 416
        }
273 417
    }
274 -
    Ok(first_failure)
418 +
    Ok((first_failure, peak))
275 419
}
276 420
277 -
fn run_module(engine: &wasmtime::Engine, linker: &wasmtime::Linker<State>, spec: &Spec, path: &str) -> Result<i32> {
421 +
fn run_module(engine: &wasmtime::Engine, linker: &wasmtime::Linker<State>, spec: &Spec, path: &str, io: &Io) -> Result<(i32, usize)> {
278 422
    use wasmtime_wasi::{FsPerms, WasiCtxBuilder};
279 -
    if spec.modules.len() > 1 {
280 -
        eprintln!("{NOTE}running {path}");
281 -
    }
282 423
    let module = ww(wasmtime::Module::from_file(engine, spec.workspace.join(path)), || format!("module {path} does not load"))?;
283 424
    let name = path.rsplit('/').next().unwrap_or(path);
284 425
    let mut argv = vec![name.to_string()];
285 426
    argv.extend(spec.args.iter().cloned());
286 427
    let mut wasi = WasiCtxBuilder::new();
287 -
    wasi.inherit_stdout().inherit_stderr().args(&argv).envs(&spec.env).env("PWD", "/src").allow_blocking_current_thread(true);
428 +
    match io {
429 +
        Io::Inherit => {
430 +
            wasi.inherit_stdout().inherit_stderr();
431 +
        }
432 +
        Io::Pipes { stdout, stderr, .. } => {
433 +
            wasi.stdout(stdout.clone()).stderr(stderr.clone());
434 +
        }
435 +
    }
436 +
    wasi.args(&argv).envs(&spec.env).env("PWD", "/src").allow_blocking_current_thread(true);
288 437
    let open = |wasi: &mut WasiCtxBuilder, dir: &Path, at: &str| {
289 438
        ww(wasi.preopened_dir(dir, at, FsPerms::ReadWrite).map(|_| ()), || format!("open {} as {at}", dir.display()))
290 439
    };
…
3 unchanged lines
294 443
    for (dir, target) in &spec.preopens {
295 444
        open(&mut wasi, dir, target)?;
296 445
    }
297 -
    let limits = wasmtime::StoreLimitsBuilder::new().memory_size(spec.memory_mib as usize * 1024 * 1024).build();
446 +
    let limits = Limits { memory: spec.memory_mib as usize * 1024 * 1024, peak: 0 };
298 447
    let mut store = wasmtime::Store::new(engine, State { wasi: wasi.build_p1(), limits });
299 448
    store.limiter(|s| &mut s.limits);
449 +
    // With epoch interruption on (in process), one tick from now traps:
450 +
    // the runner ticks only to stop the module.
451 +
    store.set_epoch_deadline(1);
300 452
    let instance = ww(linker.instantiate(&mut store, &module), || format!("module {path} cannot start here"))?;
301 453
    let start =
302 454
        ww(instance.get_typed_func::<(), ()>(&mut store, "_start"), || format!("module {path} has no _start: is it a WASI command?"))?;
303 -
    Ok(match start.call(&mut store, ()) {
455 +
    let code = match start.call(&mut store, ()) {
304 456
        Ok(()) => 0,
305 457
        Err(e) => match e.downcast_ref::<wasmtime_wasi::I32Exit>() {
306 458
            Some(exit) => exit.0,
307 459
            None => {
308 -
                eprintln!("{NOTE}{path} trapped: {e:#}");
460 +
                io.note(format!("{path} trapped: {e:#}"));
309 461
                TRAPPED
310 462
            }
311 463
        },
312 -
    })
464 +
    };
465 +
    Ok((code, store.data().limits.peak))
313 466
}
314 467
315 468
#[cfg(test)]
316 469
mod tests {
317 470
    use super::*;
471 +
472 +
    /// A spec running `text` (WAT) as the only module, with 64 MiB.
473 +
    fn spec_of(dir: &Path, text: &str) -> Spec {
474 +
        std::fs::write(dir.join("m.wasm"), wat::parse_str(text).unwrap()).unwrap();
475 +
        Spec { workspace: dir.to_path_buf(), modules: vec!["m.wasm".into()], args: vec![], env: vec![], memory_mib: 64, preopens: vec![] }
476 +
    }
477 +
478 +
    fn env() -> Environment {
479 +
        Environment { runtime: "wasm".into(), parts: vec![], rootfs: String::new(), argv: vec![], env: vec![] }
480 +
    }
481 +
482 +
    /// Records, and whether to say the run was cancelled.
483 +
    struct Collect(Vec<Record>, bool);
484 +
    impl Sink for Collect {
485 +
        fn record(&mut self, r: Record) {
486 +
            self.0.push(r);
487 +
        }
488 +
        fn cancelled(&mut self) -> bool {
489 +
            self.1
490 +
        }
491 +
    }
492 +
493 +
    const PRINT: &str = r#"(module
494 +
      (import "wasi_snapshot_preview1" "fd_write" (func $w (param i32 i32 i32 i32) (result i32)))
495 +
      (memory (export "memory") 2)
496 +
      (data (i32.const 16) "line one\nline two")
497 +
      (func (export "_start")
498 +
        (i32.store (i32.const 0) (i32.const 16)) (i32.store (i32.const 4) (i32.const 17))
499 +
        (drop (call $w (i32.const 1) (i32.const 0) (i32.const 1) (i32.const 8)))))"#;
500 +
501 +
    const SPIN: &str = r#"(module (func (export "_start") (loop $l (br $l))))"#;
502 +
503 +
    #[test]
504 +
    fn in_process_output_streams_as_lines() {
505 +
        let dir = tempfile::tempdir().unwrap();
506 +
        let mut sink = Collect(vec![], false);
507 +
        let o = run_in_thread(&spec_of(dir.path(), PRINT), env(), Duration::from_secs(30), &mut sink).unwrap();
508 +
        assert_eq!(o.exit_code, Some(0));
509 +
        let out: Vec<&str> = sink.0.iter().filter(|r| r.stream == "out").map(|r| r.text.as_str()).collect();
510 +
        assert_eq!(out, ["line one", "line two"], "a last line without its newline still comes");
511 +
        assert_eq!(o.peak_memory_mib, Some(1), "two 64 KiB pages, in whole MiB");
512 +
    }
513 +
514 +
    #[test]
515 +
    fn in_process_stops_at_the_time_limit_and_when_cancelled() {
516 +
        let dir = tempfile::tempdir().unwrap();
517 +
        let mut sink = Collect(vec![], false);
518 +
        let started = Instant::now();
519 +
        let o = run_in_thread(&spec_of(dir.path(), SPIN), env(), Duration::from_millis(300), &mut sink).unwrap();
520 +
        assert!(o.timed_out && o.exit_code.is_none(), "{:?}", sink.0);
521 +
        assert!(started.elapsed() < Duration::from_secs(5), "the endless loop was interrupted");
522 +
        let mut sink = Collect(vec![], true);
523 +
        let o = run_in_thread(&spec_of(dir.path(), SPIN), env(), Duration::from_secs(30), &mut sink).unwrap();
524 +
        assert!(o.cancelled && o.exit_code.is_none());
525 +
        assert!(sink.0.iter().any(|r| r.text == "stopped: the run was cancelled"));
526 +
    }
318 527
319 528
    #[test]
320 529
    fn globs_match_names_with_stars() {
…
27 unchanged lines
modified crates/flow/tests/wasm.rs +16 −1
…
29 unchanged lines
30 30
31 31
#[test]
32 32
fn wasi_modules_run_confined_in_the_wasm_tier() {
33 +
    confined(false);
34 +
}
35 +
36 +
/// The same in a thread of the runner, as on Android, where the runner is
37 +
/// a library in an app that cannot start itself as a command.
38 +
#[test]
39 +
fn wasi_modules_run_confined_in_process() {
40 +
    confined(true);
41 +
}
42 +
43 +
fn confined(in_process: bool) {
33 44
    let dir = tempfile::tempdir().unwrap();
34 45
    let home = dir.path().join("home");
35 46
    let work = dir.path().join("work");
…
75 unchanged lines
111 122
"#,
112 123
    )
113 124
    .unwrap();
114 -
    let run = Command::new(env!("CARGO_BIN_EXE_flow")).arg("--home").arg(&home).arg("run").current_dir(&work).output().unwrap();
125 +
    let mut flow = Command::new(env!("CARGO_BIN_EXE_flow"));
126 +
    if in_process {
127 +
        flow.env("FLOW_WASM_IN_PROCESS", "1");
128 +
    }
129 +
    let run = flow.arg("--home").arg(&home).arg("run").current_dir(&work).output().unwrap();
115 130
    let out = String::from_utf8_lossy(&run.stdout).to_string() + &String::from_utf8_lossy(&run.stderr);
116 131
    for want in [
117 132
        "hello from wasm",
…
16 unchanged lines