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 |