diff --git a/CLAUDE.md b/CLAUDE.md index f3cbfd5..d67c2d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,6 @@ loon/ ├── crates/ │ ├── loon-lang/ # Core: parser, type checker, interpreter │ ├── loon-cli/ # CLI: run, repl, fmt, explain -│ ├── loon-kernel/ # RISC-V unikernel: Loon as the kernel (own workspace) │ ├── loon-lsp/ # Language server protocol │ └── loon-wasm/ # WASM bindings for browser ├── web/ # Website (written in Loon, uses .loon files) @@ -42,15 +41,12 @@ cargo test --workspace # Run all tests cargo run -p loon-cli -- run samples/hello.oo # Run a sample cargo run -p loon-cli -- fmt samples/ # Format files cargo run -p loon-cli -- new test-proj # Creates pkg.oo + src/main.oo -cargo run -p loon-cli -- image prog.oo # Compile to a bare-metal boot image - -make -C crates/loon-kernel run # Boot Loon under QEMU (needs qemu + riscv64gc target) -make -C crates/loon-kernel check # Boot it and diff against the host +cargo run -p loon-cli -- image prog.oo # Compile to a boot image (consumed by fxos) ``` -`crates/loon-kernel` is deliberately outside the root workspace — it only -builds for `riscv64gc-unknown-none-elf` and must not be swept into -`cargo build --workspace`. +The OS built on Loon lives in its own repo: https://github.com/ecto/fxos. +`loon image` + `eir/image.rs` are the ABI it consumes; `tests/boot_image.rs` +pins that format. ## Key Patterns diff --git a/crates/loon-cli/src/main.rs b/crates/loon-cli/src/main.rs index 9408bea..68c8f66 100644 --- a/crates/loon-cli/src/main.rs +++ b/crates/loon-cli/src/main.rs @@ -33,6 +33,10 @@ enum Command { /// Run a Loon file (interpreter) Run { file: PathBuf, + /// Skip the static checker and go straight to the VM. The VM still + /// fails loudly at runtime; this only drops the up-front pass. + #[arg(long)] + unchecked: bool, /// Run via WASM compilation + wasmtime instead of interpreter #[arg(long)] wasm: bool, @@ -166,6 +170,7 @@ fn main() { match cli.command { Command::Run { ref file, + unchecked, wasm, legacy, native, @@ -186,7 +191,7 @@ fn main() { } else if legacy { run_file_legacy(file); } else { - run_file(file, record.as_deref()); + run_file(file, record.as_deref(), unchecked); } } Command::Replay { @@ -276,7 +281,7 @@ fn precheck_source(path: &std::path::Path, source: &str) { } } -fn run_file(path: &PathBuf, record: Option<&std::path::Path>) { +fn run_file(path: &PathBuf, record: Option<&std::path::Path>, unchecked: bool) { let source = match std::fs::read_to_string(path) { Ok(s) => s, Err(e) => { @@ -285,7 +290,9 @@ fn run_file(path: &PathBuf, record: Option<&std::path::Path>) { } }; - precheck_source(path, &source); + if !unchecked { + precheck_source(path, &source); + } let base_dir = path.parent().unwrap_or_else(|| std::path::Path::new(".")); let result = match record { Some(trace_path) => { diff --git a/crates/loon-kernel/.cargo/config.toml b/crates/loon-kernel/.cargo/config.toml deleted file mode 100644 index 4a796c5..0000000 --- a/crates/loon-kernel/.cargo/config.toml +++ /dev/null @@ -1,6 +0,0 @@ -[build] -target = "riscv64gc-unknown-none-elf" - -[target.riscv64gc-unknown-none-elf] -rustflags = ["-C", "link-arg=-Tlink.ld"] -runner = "qemu-system-riscv64 -machine virt -cpu rv64 -smp 1 -m 128M -nographic -serial mon:stdio -bios default -kernel" diff --git a/crates/loon-kernel/.gitignore b/crates/loon-kernel/.gitignore deleted file mode 100644 index 52831ba..0000000 --- a/crates/loon-kernel/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -target/ -screenshot.png diff --git a/crates/loon-kernel/Cargo.lock b/crates/loon-kernel/Cargo.lock deleted file mode 100644 index f97bef9..0000000 --- a/crates/loon-kernel/Cargo.lock +++ /dev/null @@ -1,7 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "loon-kernel" -version = "0.7.0" diff --git a/crates/loon-kernel/Cargo.toml b/crates/loon-kernel/Cargo.toml deleted file mode 100644 index 1862da7..0000000 --- a/crates/loon-kernel/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "loon-kernel" -version = "0.7.0" -edition = "2021" -description = "Loon as a unikernel: the EIR VM on bare metal, drivers as effect handlers." - -# Deliberately outside the root workspace: this crate only builds for a -# bare-metal target and must not be swept into `cargo build --workspace`. -[workspace] - -[dependencies] - -[profile.dev] -panic = "abort" - -[profile.release] -panic = "abort" -# An interpreter lives or dies on inlining, and `opt-level = "z"` suppresses -# it — the dispatch loop was ~150x slower than a native one under `z`. The -# image is small either way; there is nothing here worth trading speed for. -opt-level = 3 -lto = true -codegen-units = 1 diff --git a/crates/loon-kernel/Makefile b/crates/loon-kernel/Makefile deleted file mode 100644 index bbd9c18..0000000 --- a/crates/loon-kernel/Makefile +++ /dev/null @@ -1,47 +0,0 @@ -# Loon unikernel. Requires qemu-system-riscv64 and the riscv64gc target: -# brew install qemu -# rustup target add riscv64gc-unknown-none-elf - -KERNEL := target/riscv64gc-unknown-none-elf/release/loon-kernel -QEMU := qemu-system-riscv64 -QFLAGS := -machine virt -cpu rv64 -smp 1 -m 128M -nographic -bios default - -.PHONY: build run gui screenshot check clean - -build: - cargo build --release - -# Boot the machine. It powers itself off when init returns. -run: build - $(QEMU) $(QFLAGS) -serial mon:stdio -kernel $(KERNEL) - -# Boot with a display. The kernel finds the ramfb, runs boot/gui.oo, and -# stays up so there is something to look at. Close the window to quit. -gui: build - $(QEMU) $(QFLAGS) -serial mon:stdio -device ramfb -display cocoa -kernel $(KERNEL) - -# Boot headless with a display, grab the framebuffer over QMP as a PNG. -screenshot: build - @python3 tools/screenshot.py screenshot.png - -# The same program on the host, for comparison. Run from the workspace root: -# this directory's .cargo/config.toml pins a bare-metal target that the host -# build must not inherit. -host: - cd ../.. && cargo run -q -p loon-cli -- run crates/loon-kernel/boot/init.oo - -# Boot, and prove the machine agrees with the host byte for byte — for init, -# and for the fractal (which is the float path's parity check in disguise). -check: build - @$(QEMU) $(QFLAGS) -serial mon:stdio -kernel $(KERNEL) < /dev/null 2>/dev/null \ - | tr -d '\r' > /tmp/loon-metal-all.txt - @sed -n '/^hello from loon/,/^init done/p' /tmp/loon-metal-all.txt > /tmp/loon-metal.txt - @sed -n '/^@@@@@@@@@@@@%/,/^@@@@@@@@@@@%%%%%%%%####/p' /tmp/loon-metal-all.txt > /tmp/loon-metal-mandel.txt - @cd ../.. && cargo run -q -p loon-cli -- run crates/loon-kernel/boot/init.oo 2>/dev/null > /tmp/loon-host.txt - @cd ../.. && cargo run -q -p loon-cli -- run crates/loon-kernel/boot/mandel.oo 2>/dev/null > /tmp/loon-host-mandel.txt - @diff /tmp/loon-host.txt /tmp/loon-metal.txt \ - && diff /tmp/loon-host-mandel.txt /tmp/loon-metal-mandel.txt \ - && echo "ok: identical output on the host and on bare metal" - -clean: - cargo clean diff --git a/crates/loon-kernel/README.md b/crates/loon-kernel/README.md deleted file mode 100644 index ebfbce2..0000000 --- a/crates/loon-kernel/README.md +++ /dev/null @@ -1,124 +0,0 @@ -# loon-kernel — Loon as a unikernel - -A RISC-V machine whose kernel is a Loon program. There is no userspace, no -syscall boundary, and no OS underneath: `boot/init.oo` performs effects, and -the outermost handler is a UART driver rather than a call into Linux. - -```bash -brew install qemu -rustup target add riscv64gc-unknown-none-elf - -make run # boot it (serial console) -make gui # boot it with a display — the kernel paints a framebuffer -make screenshot # boot headless, grab the framebuffer over QMP as a PNG -make host # run the same program on the host -make check # boot it and diff the two -``` - -## What is here - -| | | -|---|---| -| `src/main.rs` | entry, `.bss` clear, stack, the `Host` impl that is the machine | -| `src/uart.rs` | NS16550a console driver | -| `src/mmio.rs` | the one place that touches device registers | -| `src/heap.rs` | first-fit free-list allocator over RAM above the image | -| `src/sbi.rs` | the slice of SBI we need (power off) | -| `src/fwcfg.rs` | QEMU fw_cfg, via its DMA interface — used to find and configure the ramfb | -| `src/ramfb.rs` | the display: a linear XRGB framebuffer in RAM that QEMU scans out | -| `tools/screenshot.py` | headless boot + QMP `screendump` → PNG | -| `src/eir/` | boot-image decoder and the EIR interpreter | -| `boot/init.oo` | the init program — ordinary Loon | -| `boot/mandel.oo` | a Mandelbrot set, because a kernel that boots should get to do one gratuitous thing | -| `boot/gui.oo` | first light: a Loon program painting the framebuffer through `Fb` effects | - -The host toolchain is not in this crate's build graph. `build.rs` shells out -to `loon image`, which compiles `boot/init.oo` to a boot image; the kernel -embeds that image and interprets it. Everything upstream of EIR — parser, -checker, ownership, lowering — stays on the host, where it belongs. - -## Why the output has to match - -`make check` diffs the machine against the host — init *and* the fractal, -which doubles as the float path's parity check. That diff is the point of -the exercise: the same program, the same effects, two entirely different -bottom halves. If they ever disagree, one of the two runtimes is wrong about -what Loon means, and a language whose semantics depend on where it runs is -not the language we are trying to build. - -The interpreter therefore mirrors the host VM's structure rather than -reimplementing it freely — same frame stack, same handler stack keyed by -prompt depth, same continuation capture on `perform`. Deep-handler semantics -are load-bearing: a clause that re-performs its own effect must forward -outward, which only works if capturing moves every handler at or above the -prompt into the continuation. `boot/init.oo` exercises forwarding, aborting -(a clause that never resumes) and non-tail resume for exactly this reason. - -## Benchmarking - -```bash -./bench.sh 7 # boot 7 times, report the best result per benchmark -``` - -`boot/bench.oo` (calls and sequences) and `boot/loop.oo` (tail self-recursion, -which lowers to a jump and so never pushes a frame) run before init and report -ops, ns/op and allocation count. Op counts come from the VM's own dispatch -counter and allocation counts from the global allocator, so both are exact and -identical run to run. **Wall-clock is not:** it is measured inside an emulator -on a shared machine and only ever biased upward, which is why `bench.sh` -reports a minimum over several runs. Treat a timing difference under ~30% as -noise unless you can reproduce it by interleaving two builds. - -## Performance notes - -Two things were measured properly, and one of them was a surprise. - -**`opt-level` dominates everything.** The crate was scaffolded with -`opt-level = "z"`, which suppresses the inlining a dispatch loop depends on. -Switching to `3` was worth **3.3x** on `bench` and 1.5x on `loop`, measured -back to back on the same machine. Nothing else came close. - -**Allocation was never the bottleneck.** Pooling register files and keeping -operands off the heap took the `bench` workload from 89,858 allocations to 47, -and bought *no measurable time* — interleaved A/B runs put the two builds -inside each other's noise. The changes are kept because a kernel that -interprets in near-constant memory is worth having on its own terms: no -allocator pressure, no fragmentation over long uptimes, no dependence on a -heap that has no OOM killer behind it. They are not a speedup, and an earlier -claim that they would be was wrong. - -**A slab allocator is therefore not worth building.** There is nothing left -for it to allocate, and even at 90k allocations the existing first-fit list -was not costing measurable time. - -What remains is roughly 500 ns/op against ~7 ns/op for a minimal native -dispatch loop under the same emulator. That gap is real and unexplained; -chasing it needs an idle machine and a profiler, not more guessing. - -## The display - -`Fb` is an effect (`width`, `height`, `clear`, `fill-rect`, `present`) that -falls through the Loon handler stack to the ramfb driver, exactly as -`Console.write` falls through to the UART. `boot/gui.oo` runs only when the -machine was booted with `-device ramfb`; without one, `Fb` ops raise a loud -error naming the missing device, and the headless boot never invokes them. - -Raster stays in Rust behind rectangle-sized primitives on purpose: at the -interpreter's current speed, per-pixel Loon would be ~150 ms per 640×480 -frame. What lives in Loon is the *what*, not the *how*. - -Not yet: text (needs an embedded bitmap font), input (virtio-input — the next -real piece of work), a host-side `Fb` handler so `make check` can diff the -GUI the way it diffs the console, and any notion of time in the event loop. - -## Known limits - -- **Cooperative only.** No timer interrupt yet, so a pure loop owns the - machine. Preemption is the next real milestone. -- **Single hart.** The allocator's "lock" is a bare cell because nothing - races with it. SMP needs a real one. -- **Partial builtin set.** Intrinsics the runtime lacks raise a loud error - naming the builtin; they never silently return `()`. -- **Slow.** Roughly 500 ns per interpreted op under emulation, against ~7 ns - for a minimal native dispatch loop on the same emulator. See the - performance notes above for what that is and is not. diff --git a/crates/loon-kernel/bench.sh b/crates/loon-kernel/bench.sh deleted file mode 100755 index 8b4c017..0000000 --- a/crates/loon-kernel/bench.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/sh -# Run the kernel N times and report the best result for each benchmark. -# Wall-clock under emulation is noisy and only ever biased upward by load, so -# the minimum is the least contaminated estimate. Allocation counts are -# deterministic and identical across runs. -N=${1:-7} -K=target/riscv64gc-unknown-none-elf/release/loon-kernel -for i in $(seq 1 "$N"); do - timeout 300 qemu-system-riscv64 -machine virt -cpu rv64 -smp 1 -m 128M \ - -nographic -serial mon:stdio -bios default -kernel "$K" < /dev/null 2>/dev/null -done | awk ' - /^(loop|bench)/ { - name = $1; sub(/:$/, "", name) - for (i = 1; i <= NF; i++) if ($i == "ns/op,") { ns = $(i-1)+0 } - for (i = 1; i <= NF; i++) if ($i == "allocs") { al = $(i-1)+0 } - if (!(name in best) || ns < best[name]) best[name] = ns - allocs[name] = al - } - END { for (n in best) printf "%-6s min %5d ns/op %7d allocs\n", n, best[n], allocs[n] } -' | sort diff --git a/crates/loon-kernel/boot/bench.oo b/crates/loon-kernel/boot/bench.oo deleted file mode 100644 index b49707c..0000000 --- a/crates/loon-kernel/boot/bench.oo +++ /dev/null @@ -1,15 +0,0 @@ -; Interpreter benchmark. Deliberately IO-free: console bytes are MMIO traps -; into the emulator and would swamp what we are trying to measure. -; -; Two shapes that stress different paths — deep recursion (call frames, the -; register file) and sequence work (operand vectors, closure application). - -[fn fib [n] - [if [< n 2] n [+ [fib [- n 1]] [fib [- n 2]]]]] - -[fn main [] - [let a [fib 20]] - [let xs [map [range 0 400] [fn [n] [* n 3]]]] - [let ys [filter xs [fn [n] [> n 200]]]] - [let b [fold ys 0 [fn [acc n] [+ acc n]]]] - [+ a b]] diff --git a/crates/loon-kernel/boot/gui.oo b/crates/loon-kernel/boot/gui.oo deleted file mode 100644 index fbe656e..0000000 --- a/crates/loon-kernel/boot/gui.oo +++ /dev/null @@ -1,31 +0,0 @@ -; First light. A Loon program painting a framebuffer, as the kernel. -; -; `Fb` is an effect like any other: nothing here knows whether the pixels -; land in a ramfb, a virtio-gpu, a PPM file on the host, or a simulation -; that only checks the draw calls. This one runs when the machine has a -; display (`make gui`) and is skipped when it does not. - -[effect Fb - [width [] Int] - [height [] Int] - [clear [Int] Unit] - [fill-rect [Int Int Int Int Int] Unit] - [present [] Unit]] - -; A column of bars, each a little further along, each a little bluer. -[fn bar [i] - [let w [Fb.width]] - [let y [+ 40 [* i 36]]] - [let len [+ 120 [* i 44]]] - [let blue [+ 96 [* i 16]]] - [Fb.fill-rect 40 y len 24 [+ [* 40 65536] [+ [* 60 256] blue]]]] - -[fn main [] - [Fb.clear 1710618] ; 0x1a1a1a - [each [range 0 10] bar] - ; the loon: a big off-white square with a dark eye - [let w [Fb.width]] - [Fb.fill-rect [- w 200] 60 140 140 15658734] ; 0xeeeeee - [Fb.fill-rect [- w 130] 100 24 24 1710618] - [Fb.present] - [println [str "painted " w "x" [Fb.height]]]] diff --git a/crates/loon-kernel/boot/init.oo b/crates/loon-kernel/boot/init.oo deleted file mode 100644 index 74b614e..0000000 --- a/crates/loon-kernel/boot/init.oo +++ /dev/null @@ -1,79 +0,0 @@ -; The unikernel's init program. -; -; Nothing here knows it is running on bare metal. It performs effects; what -; they mean is decided by whoever handles them, and on this machine the -; outermost handler is a UART driver instead of a syscall. That is the whole -; claim of the design, so this file is deliberately ordinary Loon: -; -; loon run crates/loon-kernel/boot/init.oo ; on the host -; make -C crates/loon-kernel run ; on the machine -; -; Both must print the same thing. - -[effect Console - [write [String] Unit]] - -; The driver. On the host `print` is a libc write; in the unikernel the same -; builtin lands on the 16550 at 0x10000000. Neither is visible from here. -[fn console [thunk] - [handle - [thunk] - [Console.write s] - [do [print s] [resume []]]]] - -; Observability as interposition: prefix each line, then forward the write -; to the next handler out. This only terminates because handlers are deep — -; a clause that re-performs its own effect escapes its own handler. -[fn traced [thunk] - [handle - [thunk] - [Console.write s] - [resume [Console.write [str "[log] " s]]]]] - -[fn line [s] [Console.write [str s "\n"]]] - -; A handler that never resumes: the clause's value becomes the value of the -; whole `handle`, and the rest of the body is dropped on the floor. Aborting -; is the same mechanism as resuming, minus one call. -[effect Halt - [stop [String] Unit]] - -[fn guarded [thunk] - [handle - [thunk] - [Halt.stop why] - [str "aborted: " why]]] - -; A non-tail resume: work happens after the continuation comes back, so the -; clause frame has to survive the resumed segment. -[effect Ask - [num [] Int]] - -[fn doubling [thunk] - [handle - [thunk] - [Ask.num] - [+ 1000 [resume 7]]]] - -[fn fib [n] - [if [< n 2] n [+ [fib [- n 1]] [fib [- n 2]]]]] - -[fn main [] - [console - [fn [] - [line "hello from loon, running as the kernel"] - - ; Pure compute: recursion, closures, the sequence intrinsics. - [line [str "fib 0..14 " [map [range 0 15] fib]]] - [let evens [map [range 1 11] [fn [n] [* 2 n]]]] - [line [str "evens<=20 " evens]] - [line [str "their sum " [fold evens 0 [fn [a b] [+ a b]]]]] - - ; Interposition: the inner write is handled twice on the way out. - [traced [fn [] [line "this write passed through a wrapping handler"]]] - - ; Effects that abort, and effects that resume in non-tail position. - [line [str "abort " [guarded [fn [] [do [Halt.stop "on purpose"] "unreachable"]]]]] - [line [str "non-tail " [doubling [fn [] [* 2 [Ask.num]]]]]] - - [line "init done"]]]] diff --git a/crates/loon-kernel/boot/loop.oo b/crates/loon-kernel/boot/loop.oo deleted file mode 100644 index abe695d..0000000 --- a/crates/loon-kernel/boot/loop.oo +++ /dev/null @@ -1,7 +0,0 @@ -; Pure dispatch cost: tail self-recursion lowers to `Recur` (a jump to block -; zero), so this never pushes a frame. Whatever this costs per op is the -; interpreter's floor; the gap to bench.oo is what calling costs. -[fn spin [n acc] - [if [= n 0] acc [spin [- n 1] [+ acc n]]]] - -[fn main [] [spin 20000 0]] diff --git a/crates/loon-kernel/boot/mandel.oo b/crates/loon-kernel/boot/mandel.oo deleted file mode 100644 index 8137869..0000000 --- a/crates/loon-kernel/boot/mandel.oo +++ /dev/null @@ -1,32 +0,0 @@ -; The kernel draws a fractal. Every float op, every closure application, -; every string concat here runs on the bare-metal interpreter — no libm, -; no libc, no OS. It is a Mandelbrot set because a kernel that boots -; should get to do at least one gratuitous thing. -; -; loon run crates/loon-kernel/boot/mandel.oo ; host -; make -C crates/loon-kernel run ; machine (runs after init) - -[fn escape [zr zi cr ci n] - [if [or [> [+ [* zr zr] [* zi zi]] 4.0] [>= n 24]] - n - [escape [+ [- [* zr zr] [* zi zi]] cr] [+ [* 2.0 [* zr zi]] ci] cr ci [+ n 1]]]] - -[fn shade [n] - [if [>= n 24] " " - [if [< n 2] "@" - [if [< n 3] "%" - [if [< n 4] "#" - [if [< n 6] "*" - [if [< n 9] "+" - [if [< n 13] "=" - [if [< n 18] "-" "."]]]]]]]]] - -[fn pixel [x y] - [let cr [- [* [float [str x]] 0.0375] 2.1]] - [let ci [- [* [float [str y]] 0.09] 1.1]] - [shade [escape 0.0 0.0 cr ci 0]]] - -[fn row [y] [join [map [range 0 80] [fn [x] [pixel x y]]] ""]] - -[fn main [] - [each [range 0 25] [fn [y] [println [row y]]]]] diff --git a/crates/loon-kernel/build.rs b/crates/loon-kernel/build.rs deleted file mode 100644 index 6963e4d..0000000 --- a/crates/loon-kernel/build.rs +++ /dev/null @@ -1,53 +0,0 @@ -//! Compile `boot/init.oo` into a boot image and hand it to the kernel. -//! -//! The unikernel has no frontend, so this is where a Loon program stops -//! being source. Compiling through the workspace CLI (rather than linking -//! loon-lang directly) keeps the host toolchain entirely out of the -//! bare-metal build graph. - -use std::path::{Path, PathBuf}; -use std::process::Command; - -fn main() { - let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); - let workspace = manifest.join("../../Cargo.toml"); - println!("cargo:rerun-if-changed=build.rs"); - - for (name, var) in [ - ("init", "LOON_BOOT_IMAGE"), - ("bench", "LOON_BENCH_IMAGE"), - ("loop", "LOON_LOOP_IMAGE"), - ("mandel", "LOON_MANDEL_IMAGE"), - ("gui", "LOON_GUI_IMAGE"), - ] { - let src = manifest.join(format!("boot/{name}.oo")); - let out = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join(format!("{name}.img")); - println!("cargo:rerun-if-changed={}", src.display()); - image(&workspace, &manifest, &src, &out); - println!("cargo:rustc-env={var}={}", out.display()); - } -} - -fn image(workspace: &Path, manifest: &Path, src: &Path, out: &Path) { - let status = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into())) - // Run from the workspace root: this crate's .cargo/config.toml pins - // a bare-metal target, and the nested build must not inherit it. - .current_dir(manifest.join("../..")) - .args(["run", "-q", "--manifest-path"]) - .arg(workspace) - .args(["-p", "loon-cli", "--", "image"]) - .arg(src) - .arg("-o") - .arg(out) - // Cargo's env leaks the bare-metal target into the nested build and - // makes it try to compile the compiler for riscv; clear it. - .env_remove("CARGO_ENCODED_RUSTFLAGS") - .env_remove("RUSTFLAGS") - .env_remove("CARGO_BUILD_TARGET") - .status() - .expect("failed to run loon-cli to build the boot image"); - - if !status.success() { - panic!("building the boot image from {} failed", src.display()); - } -} diff --git a/crates/loon-kernel/link.ld b/crates/loon-kernel/link.ld deleted file mode 100644 index dac4997..0000000 --- a/crates/loon-kernel/link.ld +++ /dev/null @@ -1,36 +0,0 @@ -/* QEMU `virt` riscv64. OpenSBI runs in M-mode at 0x80000000 and hands us - S-mode control at 0x80200000, so that is where the image must start. */ -OUTPUT_ARCH(riscv) -ENTRY(_start) - -MEMORY { - RAM (rwxa) : ORIGIN = 0x80200000, LENGTH = 120M -} - -SECTIONS { - .text : { - KEEP(*(.text.entry)) - *(.text .text.*) - } > RAM - - .rodata : { *(.rodata .rodata.*) } > RAM - .data : { *(.data .data.*) *(.sdata .sdata.*) } > RAM - - .bss (NOLOAD) : { - __bss_start = .; - *(.bss .bss.*) *(.sbss .sbss.*) *(COMMON) - __bss_end = .; - } > RAM - - /* Boot stack, then everything above is the heap. */ - . = ALIGN(16); - __stack_bottom = .; - . += 512K; - __stack_top = .; - - . = ALIGN(4096); - __heap_start = .; - __heap_end = ORIGIN(RAM) + LENGTH(RAM); - - /DISCARD/ : { *(.eh_frame .eh_frame_hdr) } -} diff --git a/crates/loon-kernel/screenshot.png b/crates/loon-kernel/screenshot.png new file mode 100644 index 0000000..95c9c8f Binary files /dev/null and b/crates/loon-kernel/screenshot.png differ diff --git a/crates/loon-kernel/src/eir/decode.rs b/crates/loon-kernel/src/eir/decode.rs deleted file mode 100644 index 1e726e5..0000000 --- a/crates/loon-kernel/src/eir/decode.rs +++ /dev/null @@ -1,269 +0,0 @@ -//! Boot image → `Module`. -//! -//! Mirrors `loon_lang::eir::image::encode` byte for byte. A malformed image -//! is a hard error: this runs before anything else, and a VM built on a -//! half-decoded module fails much later and much more confusingly. - -use super::*; -use alloc::string::{String, ToString}; -use alloc::vec::Vec; - -pub const MAGIC: &[u8; 8] = b"LOONIMG\0"; -pub const VERSION: u32 = 1; - -pub struct Dec<'a> { - buf: &'a [u8], - pos: usize, -} - -type R = Result; - -impl<'a> Dec<'a> { - fn take(&mut self, n: usize) -> R<&'a [u8]> { - if self.pos + n > self.buf.len() { - return Err("boot image truncated".to_string()); - } - let s = &self.buf[self.pos..self.pos + n]; - self.pos += n; - Ok(s) - } - fn u8(&mut self) -> R { - Ok(self.take(1)?[0]) - } - fn u16(&mut self) -> R { - Ok(u16::from_le_bytes(self.take(2)?.try_into().unwrap())) - } - fn u32(&mut self) -> R { - Ok(u32::from_le_bytes(self.take(4)?.try_into().unwrap())) - } - fn i64(&mut self) -> R { - Ok(i64::from_le_bytes(self.take(8)?.try_into().unwrap())) - } - fn f64(&mut self) -> R { - Ok(f64::from_le_bytes(self.take(8)?.try_into().unwrap())) - } - fn str(&mut self) -> R { - let n = self.u32()? as usize; - let b = self.take(n)?; - core::str::from_utf8(b) - .map(|s| s.to_string()) - .map_err(|_| "boot image has a non-utf8 string".to_string()) - } - fn reg(&mut self) -> R { - Ok(Reg(self.u32()?)) - } - fn regs(&mut self) -> R> { - let n = self.u32()? as usize; - let mut v = Vec::with_capacity(n); - for _ in 0..n { - v.push(self.reg()?); - } - Ok(v) - } -} - -pub fn decode(buf: &[u8]) -> R { - let mut d = Dec { buf, pos: 0 }; - if d.take(8)? != MAGIC { - return Err("not a loon boot image".to_string()); - } - let v = d.u32()?; - if v != VERSION { - return Err(alloc::format!( - "boot image version {v}, this kernel speaks {VERSION}" - )); - } - - let n = d.u32()? as usize; - let mut strings = Vec::with_capacity(n); - for _ in 0..n { - strings.push(d.str()?); - } - - let n = d.u32()? as usize; - let mut ctors = Vec::with_capacity(n); - for _ in 0..n { - ctors.push(Ctor { - name: d.str()?, - tag: d.u16()?, - arity: d.u16()?, - }); - } - - let n = d.u32()? as usize; - let mut builtins = Vec::with_capacity(n); - for _ in 0..n { - let tag = d.u16()?; - builtins.push((tag, d.str()?)); - } - - let entry = FuncId(d.u32()?); - - let n = d.u32()? as usize; - let mut funcs = Vec::with_capacity(n); - for _ in 0..n { - funcs.push(func(&mut d)?); - } - - Ok(Module { - funcs, - strings, - ctors, - builtins, - entry, - }) -} - -fn func(d: &mut Dec) -> R { - let name = if d.u8()? == 1 { Some(d.str()?) } else { None }; - let params = d.u32()?; - let captures = d.u32()?; - let evidence = d.u32()?; - let regs = d.u32()?; - - let n = d.u32()? as usize; - let mut blocks = Vec::with_capacity(n); - for _ in 0..n { - let params = d.regs()?; - let n_ops = d.u32()? as usize; - let mut ops = Vec::with_capacity(n_ops); - for _ in 0..n_ops { - ops.push(op(d)?); - } - blocks.push(Block { - params, - ops, - end: end(d)?, - }); - } - - Ok(Func { - name, - params, - captures, - evidence, - regs, - blocks, - }) -} - -fn op(d: &mut Dec) -> R { - Ok(match d.u8()? { - 0 => Op::Lit(d.reg()?, lit(d)?), - 1 => Op::Mov(d.reg()?, d.reg()?), - 2 => Op::Upval(d.reg()?, d.u16()?), - 3 => { - let dst = d.reg()?; - let o = binop(d.u8()?)?; - Op::Bin(dst, o, d.reg()?, d.reg()?) - } - 4 => { - let dst = d.reg()?; - let o = match d.u8()? { - 0 => UnOp::Neg, - 1 => UnOp::Not, - t => return Err(alloc::format!("unknown unop tag {t}")), - }; - Op::Un(dst, o, d.reg()?) - } - 5 => Op::Call(d.reg()?, FuncId(d.u32()?), d.regs()?), - 6 => Op::Invoke(d.reg()?, d.reg()?, d.regs()?), - 7 => Op::Close(d.reg()?, FuncId(d.u32()?), d.regs()?), - 8 => Op::Vec(d.reg()?, d.regs()?), - 9 => { - let dst = d.reg()?; - let n = d.u32()? as usize; - let mut kvs = Vec::with_capacity(n); - for _ in 0..n { - kvs.push((d.reg()?, d.reg()?)); - } - Op::Map(dst, kvs) - } - 10 => Op::Set(d.reg()?, d.regs()?), - 11 => Op::Tup(d.reg()?, d.regs()?), - 12 => Op::Adt(d.reg()?, d.u16()?, d.regs()?), - 13 => { - let dst = d.reg()?; - let src = d.reg()?; - let sel = match d.u8()? { - 0 => Selector::Index(d.u16()?), - 1 => Selector::Key(StringId(d.u32()?)), - 2 => Selector::Name(StringId(d.u32()?)), - t => return Err(alloc::format!("unknown selector tag {t}")), - }; - Op::Field(dst, src, sel) - } - 14 => Op::Tag(d.reg()?, d.reg()?), - 15 => { - let dst = d.reg()?; - let eff = StringId(d.u32()?); - let o = StringId(d.u32()?); - let args = d.regs()?; - // Evidence is decoded and dropped: like the host VM, dispatch is - // dynamic, because capturing a continuation needs the prompt - // boundary that only the handler stack records. - if d.u8()? == 1 { - let _ = d.u32()?; - } - Op::Perform(dst, eff, o, args) - } - 16 => Op::Builtin(d.reg()?, d.u16()?, d.regs()?), - 17 => Op::PushHandler(d.reg()?, StringId(d.u32()?), StringId(d.u32()?)), - 18 => Op::PopHandler, - t => return Err(alloc::format!("unknown op tag {t}")), - }) -} - -fn binop(t: u8) -> R { - Ok(match t { - 0 => BinOp::Add, - 1 => BinOp::Sub, - 2 => BinOp::Mul, - 3 => BinOp::Div, - 4 => BinOp::Rem, - 5 => BinOp::Eq, - 6 => BinOp::Ne, - 7 => BinOp::Lt, - 8 => BinOp::Gt, - 9 => BinOp::Le, - 10 => BinOp::Ge, - 11 => BinOp::And, - 12 => BinOp::Or, - 13 => BinOp::Concat, - t => return Err(alloc::format!("unknown binop tag {t}")), - }) -} - -fn lit(d: &mut Dec) -> R { - Ok(match d.u8()? { - 0 => Lit::Int(d.i64()?), - 1 => Lit::Float(d.f64()?), - 2 => Lit::Bool(d.u8()? != 0), - 3 => Lit::Str(StringId(d.u32()?)), - 4 => Lit::Keyword(StringId(d.u32()?)), - 5 => Lit::Unit, - t => return Err(alloc::format!("unknown literal tag {t}")), - }) -} - -fn end(d: &mut Dec) -> R { - Ok(match d.u8()? { - 0 => End::Ret(d.reg()?), - 1 => End::Jmp(BlockId(d.u32()?), d.regs()?), - 2 => End::Br(d.reg()?, BlockId(d.u32()?), BlockId(d.u32()?)), - 3 => { - let scrut = d.reg()?; - let n = d.u32()? as usize; - let mut arms = Vec::with_capacity(n); - for _ in 0..n { - arms.push((d.u16()?, BlockId(d.u32()?))); - } - End::Switch(scrut, arms, BlockId(d.u32()?)) - } - 4 => End::Tail(FuncId(d.u32()?), d.regs()?), - 5 => End::TailInvoke(d.reg()?, d.regs()?), - 6 => End::Recur(d.regs()?), - 7 => End::Trap, - t => return Err(alloc::format!("unknown terminator tag {t}")), - }) -} diff --git a/crates/loon-kernel/src/eir/mod.rs b/crates/loon-kernel/src/eir/mod.rs deleted file mode 100644 index 40c1247..0000000 --- a/crates/loon-kernel/src/eir/mod.rs +++ /dev/null @@ -1,154 +0,0 @@ -//! EIR, as the unikernel sees it: already lowered, already checked. -//! -//! The host compiler owns everything upstream of this point. What arrives -//! here is a boot image — a flat instruction graph with a string pool — and -//! the only job left is to run it. - -pub mod decode; -pub mod val; -pub mod vm; - -use alloc::string::String; -use alloc::vec::Vec; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct Reg(pub u32); -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct FuncId(pub u32); -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct StringId(pub u32); -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct BlockId(pub u32); - -/// Decoded in full even where the interpreter does not consult every field -/// yet — a partial decode that silently skips bytes is how image formats -/// drift apart. -#[allow(dead_code)] -pub struct Module { - pub funcs: Vec, - pub strings: Vec, - pub ctors: Vec, - /// Tag → variant name for every builtin this image references. Dispatch - /// goes through the name so a reordered host enum cannot silently - /// remap an intrinsic. - pub builtins: Vec<(u16, String)>, - pub entry: FuncId, -} - -impl Module { - pub fn builtin_name(&self, tag: u16) -> Option<&str> { - self.builtins - .iter() - .find(|(t, _)| *t == tag) - .map(|(_, n)| n.as_str()) - } - - pub fn string(&self, id: StringId) -> &str { - self.strings - .get(id.0 as usize) - .map(|s| s.as_str()) - .unwrap_or("") - } -} - -#[allow(dead_code)] -pub struct Ctor { - pub name: String, - pub tag: u16, - pub arity: u16, -} - -#[allow(dead_code)] -pub struct Func { - pub name: Option, - pub params: u32, - pub captures: u32, - pub evidence: u32, - /// Frame size: one past the highest register the body mentions. - pub regs: u32, - pub blocks: Vec, -} - -pub struct Block { - pub params: Vec, - pub ops: Vec, - pub end: End, -} - -#[derive(Debug, Clone)] -pub enum Op { - Lit(Reg, Lit), - Mov(Reg, Reg), - Upval(Reg, u16), - Bin(Reg, BinOp, Reg, Reg), - Un(Reg, UnOp, Reg), - Call(Reg, FuncId, Vec), - Invoke(Reg, Reg, Vec), - Close(Reg, FuncId, Vec), - Vec(Reg, Vec), - Map(Reg, Vec<(Reg, Reg)>), - Set(Reg, Vec), - Tup(Reg, Vec), - Adt(Reg, u16, Vec), - Field(Reg, Reg, Selector), - Tag(Reg, Reg), - Perform(Reg, StringId, StringId, Vec), - Builtin(Reg, u16, Vec), - PushHandler(Reg, StringId, StringId), - PopHandler, -} - -#[derive(Debug, Clone)] -pub enum End { - Ret(Reg), - Jmp(BlockId, Vec), - Br(Reg, BlockId, BlockId), - Switch(Reg, Vec<(u16, BlockId)>, BlockId), - Tail(FuncId, Vec), - TailInvoke(Reg, Vec), - Recur(Vec), - Trap, -} - -#[derive(Debug, Clone)] -pub enum Lit { - Int(i64), - Float(f64), - Bool(bool), - Str(StringId), - Keyword(StringId), - Unit, -} - -#[derive(Debug, Clone)] -pub enum Selector { - Index(u16), - Key(StringId), - Name(StringId), -} - -/// Tags must match `loon_lang::eir::BinOp` declaration order. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BinOp { - Add, - Sub, - Mul, - Div, - Rem, - Eq, - Ne, - Lt, - Gt, - Le, - Ge, - And, - Or, - Concat, -} - -/// Tags must match `loon_lang::eir::UnOp` declaration order. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum UnOp { - Neg, - Not, -} diff --git a/crates/loon-kernel/src/eir/val.rs b/crates/loon-kernel/src/eir/val.rs deleted file mode 100644 index e4decc7..0000000 --- a/crates/loon-kernel/src/eir/val.rs +++ /dev/null @@ -1,150 +0,0 @@ -//! Runtime values. -//! -//! Plain `Rc`-backed enum rather than the host VM's NaN-boxed `Value64`. -//! The unikernel's bottleneck is not value representation yet, and an -//! obvious layout is worth more here than a fast one. - -use alloc::rc::Rc; -use alloc::string::String; -use alloc::vec::Vec; - -use super::FuncId; - -#[derive(Clone)] -pub enum Val { - Unit, - Int(i64), - Float(f64), - Bool(bool), - Str(Rc), - Keyword(Rc), - Vec(Rc>), - Tup(Rc>), - Set(Rc>), - /// Insertion-ordered association list. Small-map territory; a real - /// hash map only pays off past sizes the kernel does not yet see. - Map(Rc>), - Adt(u16, Rc>), - Closure(FuncId, Rc>), - /// A captured continuation — the value `resume` is bound to. - Cont(Rc), -} - -impl Val { - /// Loon truthiness: only `false` and unit are falsey. Matches the - /// canonical ruling the host VM implements — 0 and "" are truthy. - pub fn truthy(&self) -> bool { - !matches!(self, Val::Bool(false) | Val::Unit) - } - - pub fn type_name(&self) -> &'static str { - match self { - Val::Unit => "unit", - Val::Int(_) => "int", - Val::Float(_) => "float", - Val::Bool(_) => "bool", - Val::Str(_) => "string", - Val::Keyword(_) => "keyword", - Val::Vec(_) => "vector", - Val::Tup(_) => "tuple", - Val::Set(_) => "set", - Val::Map(_) => "map", - Val::Adt(..) => "adt", - Val::Closure(..) => "function", - Val::Cont(_) => "continuation", - } - } -} - -impl PartialEq for Val { - fn eq(&self, other: &Self) -> bool { - match (self, other) { - (Val::Unit, Val::Unit) => true, - (Val::Int(a), Val::Int(b)) => a == b, - (Val::Float(a), Val::Float(b)) => a == b, - (Val::Int(a), Val::Float(b)) | (Val::Float(b), Val::Int(a)) => (*a as f64) == *b, - (Val::Bool(a), Val::Bool(b)) => a == b, - (Val::Str(a), Val::Str(b)) | (Val::Keyword(a), Val::Keyword(b)) => a == b, - (Val::Vec(a), Val::Vec(b)) - | (Val::Tup(a), Val::Tup(b)) - | (Val::Set(a), Val::Set(b)) => a == b, - (Val::Map(a), Val::Map(b)) => { - a.len() == b.len() - && a.iter() - .all(|(k, v)| b.iter().any(|(k2, v2)| k == k2 && v == v2)) - } - (Val::Adt(t1, a), Val::Adt(t2, b)) => t1 == t2 && a == b, - _ => false, - } - } -} - -/// Display, in the same shape the host VM prints. -pub fn show(v: &Val) -> String { - let mut s = String::new(); - write_val(&mut s, v); - s -} - -fn write_val(out: &mut String, v: &Val) { - use core::fmt::Write; - match v { - Val::Unit => out.push_str("()"), - Val::Int(n) => { - let _ = write!(out, "{n}"); - } - Val::Float(f) => { - let _ = write!(out, "{f}"); - } - Val::Bool(b) => out.push_str(if *b { "true" } else { "false" }), - Val::Str(s) => out.push_str(s), - Val::Keyword(s) => { - out.push(':'); - out.push_str(s); - } - Val::Vec(xs) | Val::Set(xs) => { - out.push_str(if matches!(v, Val::Set(_)) { "#{" } else { "#[" }); - for (i, x) in xs.iter().enumerate() { - if i > 0 { - out.push(' '); - } - write_val(out, x); - } - out.push(if matches!(v, Val::Set(_)) { '}' } else { ']' }); - } - Val::Tup(xs) => { - out.push('('); - for (i, x) in xs.iter().enumerate() { - if i > 0 { - out.push(' '); - } - write_val(out, x); - } - out.push(')'); - } - Val::Map(kvs) => { - out.push('{'); - for (i, (k, val)) in kvs.iter().enumerate() { - if i > 0 { - out.push(' '); - } - write_val(out, k); - out.push(' '); - write_val(out, val); - } - out.push('}'); - } - Val::Adt(tag, fields) => { - let _ = write!(out, "'); - } - Val::Closure(f, _) => { - let _ = write!(out, "", f.0); - } - Val::Cont(_) => out.push_str(""), - } -} diff --git a/crates/loon-kernel/src/eir/vm.rs b/crates/loon-kernel/src/eir/vm.rs deleted file mode 100644 index 536283e..0000000 --- a/crates/loon-kernel/src/eir/vm.rs +++ /dev/null @@ -1,1134 +0,0 @@ -//! The EIR interpreter, on bare metal. -//! -//! Structurally this is the host VM with the host removed: same frame stack, -//! same dynamic handler stack keyed by prompt depth, same continuation -//! capture on `Perform`. Deep-handler semantics are load-bearing — a clause -//! that re-performs its own effect must forward *outward*, which only works -//! if capturing moves every handler at or above the prompt into the -//! continuation. Divergence from the host here would mean a program means -//! one thing on Linux and another on hardware, which is the whole thing we -//! are trying not to build. - -use alloc::rc::Rc; -use alloc::string::{String, ToString}; -use alloc::vec; -use alloc::vec::Vec; - -use super::val::{show, Val}; -use super::*; - -/// What the VM reaches for when an effect has no Loon handler left. On this -/// target that means hardware. -pub trait Host { - fn write(&mut self, s: &str); - /// Monotonic ticks since boot. - fn ticks(&mut self) -> i64; - /// Framebuffer, if the machine has one. Ops are named rather than - /// enumerated so the VM does not have to know what a display can do; the - /// host decides, and says loudly when it can't. - fn fb(&mut self, op: &str, args: &[i64]) -> Result, String>; -} - -/// Operands read out of registers for one instruction. -/// -/// They die before the next op is dispatched, so heap-allocating a vector per -/// op bought nothing; this keeps the common case off the heap entirely and -/// spills for the rare wide call. Note this did *not* show up as a speedup -/// (see README) — the value is a near-constant-memory interpreter, not -/// throughput. The inline capacity is kept small deliberately: it is copied -/// on every read, so widening it trades one cost for another. -pub struct Operands { - inline: [Val; INLINE_OPERANDS], - len: usize, - spill: Vec, -} - -const INLINE_OPERANDS: usize = 4; - -impl Operands { - fn as_slice(&self) -> &[Val] { - if self.spill.is_empty() { - &self.inline[..self.len] - } else { - &self.spill - } - } -} - -pub struct Frame { - func: FuncId, - block: BlockId, - ip: usize, - regs: Vec, - captures: Rc>, - /// Where the callee's value lands in this frame, or `DISCARD`. - ret_reg: u32, -} - -/// A frame whose callee's result is thrown away — used to park a caller -/// while Rust drives a nested run, where the value comes back directly. -const DISCARD: u32 = u32::MAX; - -/// A suspended computation: the frames between a `perform` and its prompt. -pub struct Continuation { - saved: Vec, - func: FuncId, - block: BlockId, - ip: usize, - regs: Vec, - captures: Rc>, - perform_dst: u32, - /// Handlers that lived at or above the prompt, with depths stored - /// relative to it so they can be re-established wherever this segment - /// is resumed. - prompt_handlers: Vec, -} - -#[derive(Clone)] -struct DynHandler { - effect: StringId, - op: StringId, - closure: Val, - prompt_depth: usize, - /// Re-established by a resume rather than by a `PushHandler`, so it has - /// no matching `PopHandler` and must be pruned by frame depth instead. - ephemeral: bool, -} - -pub struct Vm<'m, H: Host> { - m: &'m Module, - host: &'m mut H, - frames: Vec, - handlers: Vec, - func: FuncId, - block: BlockId, - ip: usize, - regs: Vec, - captures: Rc>, - /// Bounds runaway programs; there is no watchdog timer to save us yet. - fuel: u64, - /// Dispatch-loop iterations, for benchmarking. One per instruction or - /// terminator, so it is a real op count rather than a wall-clock proxy. - steps: u64, - /// One shared empty capture list. A plain function has no captures, so - /// `Rc::new(Vec::new())` at every call site would allocate an RcBox per - /// call to hold nothing; cloning this is a refcount bump instead. - no_caps: Rc>, - /// Retired register files, kept for the next call. Frames are strictly - /// stack-shaped, so a returning call almost always hands back a buffer - /// the next one can take. - reg_pool: Vec>, -} - -/// How many register files to keep parked. Deep recursion churns through -/// these; past a few hundred the memory is better left to the heap. -const REG_POOL_MAX: usize = 256; - -pub type VmResult = Result; - -impl<'m, H: Host> Vm<'m, H> { - pub fn new(m: &'m Module, host: &'m mut H) -> Self { - let entry = &m.funcs[m.entry.0 as usize]; - Vm { - regs: vec![Val::Unit; entry.regs as usize], - captures: Rc::new(Vec::new()), - func: m.entry, - block: BlockId(0), - ip: 0, - frames: Vec::new(), - handlers: Vec::new(), - m, - host, - fuel: u64::MAX, - steps: 0, - no_caps: Rc::new(Vec::new()), - reg_pool: Vec::new(), - } - } - - /// Instructions and terminators executed so far. - pub fn steps(&self) -> u64 { - self.steps - } - - pub fn with_fuel(mut self, fuel: u64) -> Self { - self.fuel = fuel; - self - } - - // ── Register access ──────────────────────────────────────────────── - - fn r(&self, r: Reg) -> Val { - self.regs.get(r.0 as usize).cloned().unwrap_or(Val::Unit) - } - - fn w(&mut self, r: Reg, v: Val) { - let i = r.0 as usize; - if i >= self.regs.len() { - self.regs.resize(i + 1, Val::Unit); - } - self.regs[i] = v; - } - - /// Read operands without touching the heap unless there are many. - fn read(&self, rs: &[Reg]) -> Operands { - const UNIT: Val = Val::Unit; - if rs.len() <= INLINE_OPERANDS { - let mut inline = [UNIT; INLINE_OPERANDS]; - for (slot, r) in inline.iter_mut().zip(rs) { - *slot = self.r(*r); - } - Operands { - inline, - len: rs.len(), - spill: Vec::new(), - } - } else { - Operands { - inline: [UNIT; INLINE_OPERANDS], - len: 0, - spill: rs.iter().map(|r| self.r(*r)).collect(), - } - } - } - - /// Read operands into an owned vector, for ops that build a value out of - /// them and would have to copy anyway. - fn read_owned(&self, rs: &[Reg]) -> Vec { - rs.iter().map(|r| self.r(*r)).collect() - } - - fn func_def(&self, f: FuncId) -> VmResult<&'m Func> { - self.m - .funcs - .get(f.0 as usize) - .ok_or_else(|| alloc::format!("bad function id {}", f.0)) - } - - // ── Driving ──────────────────────────────────────────────────────── - - /// Run the entry point to completion. - pub fn run(&mut self) -> VmResult { - let base = self.frames.len(); - self.run_until(base) - } - - /// Execute until the frame stack drops back to `base` and the current - /// function returns. Nested runs (a builtin calling back into Loon) use - /// the same loop with a higher floor. - fn run_until(&mut self, base: usize) -> VmResult { - loop { - if self.fuel == 0 { - return Err("out of fuel: the program did not terminate".to_string()); - } - self.fuel -= 1; - self.steps += 1; - - // Borrow the code out of the module reference, not out of `self`: - // `'m` outlives this loop, so ops stay borrowed while `self` is - // mutated. Cloning each instruction instead would allocate on - // every dispatch, which for ops carrying operand vectors is most - // of them. - let m = self.m; - let f = m - .funcs - .get(self.func.0 as usize) - .ok_or_else(|| alloc::format!("bad function id {}", self.func.0))?; - let block = f - .blocks - .get(self.block.0 as usize) - .ok_or_else(|| alloc::format!("bad block id {}", self.block.0))?; - - if self.ip < block.ops.len() { - let op = &block.ops[self.ip]; - self.ip += 1; - self.exec(op)?; - continue; - } - - match &block.end { - End::Ret(r) => { - let v = self.r(*r); - if self.frames.len() == base { - return Ok(v); - } - self.ret(v)?; - } - End::Jmp(b, args) => { - let vals = self.read(args); - self.jump(*b, vals.as_slice())?; - } - End::Br(c, t, e) => { - let target = if self.r(*c).truthy() { *t } else { *e }; - self.jump(target, &[])?; - } - End::Switch(scrut, arms, dflt) => { - let tag = match self.r(*scrut) { - Val::Adt(t, _) => Some(t), - Val::Int(n) => Some(n as u16), - _ => None, - }; - let target = tag - .and_then(|t| arms.iter().find(|(a, _)| *a == t).map(|(_, b)| *b)) - .unwrap_or(*dflt); - self.jump(target, &[])?; - } - End::Recur(args) => { - let vals = self.read(args); - self.jump(BlockId(0), vals.as_slice())?; - } - End::Tail(callee, args) => { - let vals = self.read(args); - let caps = self.no_caps.clone(); - self.enter(*callee, vals.as_slice(), caps)?; - } - End::TailInvoke(f, args) => { - let callee = self.r(*f); - let vals = self.read(args); - let vals = vals.as_slice(); - // A tail call must not push a frame — that is the whole - // promise — so it cannot reuse `invoke`'s path. - match callee { - Val::Closure(fid, caps) => self.enter(fid, vals, caps)?, - Val::Cont(k) => { - let v = vals.first().cloned().unwrap_or(Val::Unit); - self.resume(&k, v)?; - } - other => { - return Err(alloc::format!( - "cannot call a {} in tail position", - other.type_name() - )) - } - } - } - End::Trap => { - return Err("reached unreachable code (non-exhaustive match?)".to_string()) - } - } - } - } - - /// Jump within the current function, binding the target's block params. - fn jump(&mut self, b: BlockId, args: &[Val]) -> VmResult<()> { - // Borrow the param list out of the module (lifetime `'m`), not out - // of `self` — cloning it here cost an allocation on every jump, which - // for a tail-recursive loop is one per iteration. - let m = self.m; - let f = m - .funcs - .get(self.func.0 as usize) - .ok_or_else(|| alloc::format!("bad function id {}", self.func.0))?; - let target = f - .blocks - .get(b.0 as usize) - .ok_or_else(|| alloc::format!("bad block id {}", b.0))?; - for (p, v) in target.params.iter().zip(args.iter()) { - let i = p.0 as usize; - if i >= self.regs.len() { - self.regs.resize(i + 1, Val::Unit); - } - self.regs[i] = v.clone(); - } - self.block = b; - self.ip = 0; - Ok(()) - } - - /// Replace the current frame with a call to `callee` (tail position). - fn enter(&mut self, callee: FuncId, args: &[Val], caps: Rc>) -> VmResult<()> { - let f = self.func_def(callee)?; - if f.blocks.is_empty() { - return Err("function has no blocks".to_string()); - } - // Arguments land in registers 0..n, not in the entry block's params: - // lowering numbers parameters first and the entry block inherits - // them rather than being jumped to with operands. - let n = (f.regs as usize).max(args.len()); - // Reclaim the outgoing register file first. A tail call replaces the - // frame rather than returning through it, so without this a - // tail-recursive loop allocates a fresh one every iteration and the - // pool never sees a buffer. (On the `call` path this is the empty - // vector left behind by the frame push, which costs nothing.) - let dead = core::mem::take(&mut self.regs); - self.recycle(dead); - - let mut regs = self.reg_pool.pop().unwrap_or_default(); - regs.clear(); - regs.resize(n, Val::Unit); - for (i, v) in args.iter().enumerate() { - regs[i] = v.clone(); - } - self.func = callee; - self.block = BlockId(0); - self.ip = 0; - self.regs = regs; - self.captures = caps; - Ok(()) - } - - /// Push a frame and call `callee`, returning into `ret_reg`. - fn call( - &mut self, - callee: FuncId, - args: &[Val], - ret_reg: u32, - caps: Rc>, - ) -> VmResult<()> { - self.frames.push(Frame { - func: self.func, - block: self.block, - ip: self.ip, - regs: core::mem::take(&mut self.regs), - captures: core::mem::replace(&mut self.captures, self.no_caps.clone()), - ret_reg, - }); - if self.frames.len() > 8192 { - return Err("call stack exhausted".to_string()); - } - self.enter(callee, args, caps) - } - - fn ret(&mut self, v: Val) -> VmResult<()> { - let fr = self - .frames - .pop() - .ok_or_else(|| "return with no caller".to_string())?; - self.func = fr.func; - self.block = fr.block; - self.ip = fr.ip; - // The callee's register file is dead now; park it for the next call. - let dead = core::mem::replace(&mut self.regs, fr.regs); - self.recycle(dead); - self.captures = fr.captures; - if fr.ret_reg != DISCARD { - self.w(Reg(fr.ret_reg), v); - } - // A resumed segment can leave ephemeral handlers scoped to a prompt - // frame that just left the stack; drop them before they shadow a - // later handle for the same effect. - self.prune_ephemeral(); - Ok(()) - } - - /// Park a dead register file for reuse, dropping its values so they do - /// not stay alive in the pool. - fn recycle(&mut self, mut regs: Vec) { - // A zero-capacity vector is not a buffer — parking one would push a - // real buffer out of the pool and hand the next call something it - // has to grow from nothing. - if regs.capacity() > 0 && self.reg_pool.len() < REG_POOL_MAX { - regs.clear(); - self.reg_pool.push(regs); - } - } - - /// Call a Loon value from Rust (a builtin taking a function, say) and - /// run it to completion. - fn apply(&mut self, f: &Val, args: &[Val]) -> VmResult { - match f { - Val::Closure(fid, caps) => { - let base = self.frames.len(); - // Park the caller so the nested run has somewhere to return - // to; reg 0 is scratch, the value comes back through `run_until`. - self.call(*fid, args, DISCARD, caps.clone())?; - let out = self.run_until(base + 1)?; - // `run_until` stops *before* popping, so unwind by hand. - self.ret(out.clone())?; - Ok(out) - } - Val::Cont(k) => { - let v = args.first().cloned().unwrap_or(Val::Unit); - let base = self.frames.len(); - self.resume(k, v)?; - self.run_until(base) - } - other => Err(alloc::format!("cannot call a {}", other.type_name())), - } - } -} - -// ── Instruction execution ────────────────────────────────────────────── - -impl<'m, H: Host> Vm<'m, H> { - fn exec(&mut self, op: &Op) -> VmResult<()> { - match op { - Op::Lit(d, l) => { - let v = self.lit(l); - self.w(*d, v); - } - Op::Mov(d, a) => { - let v = self.r(*a); - self.w(*d, v); - } - Op::Upval(d, i) => { - let v = self.captures.get(*i as usize).cloned().unwrap_or(Val::Unit); - self.w(*d, v); - } - Op::Bin(d, o, a, b) => { - let (x, y) = (self.r(*a), self.r(*b)); - let v = self.binop(*o, x, y)?; - self.w(*d, v); - } - Op::Un(d, o, a) => { - let x = self.r(*a); - let v = match o { - UnOp::Neg => match x { - Val::Int(n) => Val::Int(-n), - Val::Float(f) => Val::Float(-f), - v => return Err(alloc::format!("cannot negate a {}", v.type_name())), - }, - UnOp::Not => Val::Bool(!x.truthy()), - }; - self.w(*d, v); - } - Op::Call(d, f, args) => { - let vals = self.read(args); - let caps = self.no_caps.clone(); - self.call(*f, vals.as_slice(), d.0, caps)?; - } - Op::Invoke(d, f, args) => { - let callee = self.r(*f); - let vals = self.read(args); - match callee { - Val::Closure(fid, caps) => self.call(fid, vals.as_slice(), d.0, caps)?, - Val::Cont(k) => { - let v = vals.as_slice().first().cloned().unwrap_or(Val::Unit); - self.resume_at(&k, v, Some(d.0))?; - } - other => { - return Err(alloc::format!("cannot call a {}", other.type_name())); - } - } - } - Op::Close(d, f, caps) => { - let vals = self.read_owned(caps); - self.w(*d, Val::Closure(*f, Rc::new(vals))); - } - Op::Vec(d, rs) => { - let vals = self.read_owned(rs); - self.w(*d, Val::Vec(Rc::new(vals))); - } - Op::Tup(d, rs) => { - let vals = self.read_owned(rs); - self.w(*d, Val::Tup(Rc::new(vals))); - } - Op::Set(d, rs) => { - let mut vals: Vec = Vec::new(); - for v in self.read_owned(rs) { - if !vals.contains(&v) { - vals.push(v); - } - } - self.w(*d, Val::Set(Rc::new(vals))); - } - Op::Map(d, kvs) => { - let mut out: Vec<(Val, Val)> = Vec::with_capacity(kvs.len()); - for (k, v) in kvs { - let (k, v) = (self.r(*k), self.r(*v)); - match out.iter_mut().find(|(k2, _)| *k2 == k) { - Some(slot) => slot.1 = v, - None => out.push((k, v)), - } - } - self.w(*d, Val::Map(Rc::new(out))); - } - Op::Adt(d, tag, rs) => { - let vals = self.read_owned(rs); - self.w(*d, Val::Adt(*tag, Rc::new(vals))); - } - Op::Tag(d, a) => { - let v = match self.r(*a) { - Val::Adt(t, _) => Val::Int(t as i64), - _ => Val::Int(-1), - }; - self.w(*d, v); - } - Op::Field(d, a, sel) => { - let base = self.r(*a); - let v = self.field(&base, sel)?; - self.w(*d, v); - } - Op::Builtin(d, tag, args) => { - let vals = self.read(args); - let v = self.builtin(*tag, vals.as_slice())?; - self.w(*d, v); - } - Op::PushHandler(h, eff, o) => { - let closure = self.r(*h); - self.handlers.push(DynHandler { - effect: *eff, - op: *o, - closure, - // The prompt is the `handle`'s own frame; the body runs - // above it. - prompt_depth: self.frames.len(), - ephemeral: false, - }); - } - Op::PopHandler => { - // Depth-matched, not a blind pop: if the body performed, the - // capture already moved this handle's handlers into the - // continuation, and popping blindly would take some outer - // handle's instead. - let depth = self.frames.len(); - if let Some(i) = self.handlers.iter().rposition(|h| h.prompt_depth == depth) { - self.handlers.remove(i); - } - } - Op::Perform(d, eff, o, args) => { - let vals = self.read(args); - self.perform(*d, *eff, *o, vals.as_slice())?; - } - } - Ok(()) - } - - fn lit(&self, l: &Lit) -> Val { - match l { - Lit::Int(n) => Val::Int(*n), - Lit::Float(f) => Val::Float(*f), - Lit::Bool(b) => Val::Bool(*b), - Lit::Str(s) => Val::Str(Rc::new(self.m.string(*s).to_string())), - Lit::Keyword(s) => Val::Keyword(Rc::new(self.m.string(*s).to_string())), - Lit::Unit => Val::Unit, - } - } - - fn field(&self, base: &Val, sel: &Selector) -> VmResult { - Ok(match sel { - Selector::Index(i) => match base { - Val::Tup(xs) | Val::Vec(xs) | Val::Adt(_, xs) => { - xs.get(*i as usize).cloned().unwrap_or(Val::Unit) - } - v => return Err(alloc::format!("cannot index a {}", v.type_name())), - }, - Selector::Key(s) | Selector::Name(s) => { - let key = self.m.string(*s); - match base { - Val::Map(kvs) => kvs - .iter() - .find(|(k, _)| match k { - Val::Str(t) | Val::Keyword(t) => t.as_str() == key, - _ => false, - }) - .map(|(_, v)| v.clone()) - .unwrap_or(Val::Unit), - v => { - return Err(alloc::format!( - "cannot read field '{key}' of a {}", - v.type_name() - )) - } - } - } - }) - } - - // ── Effects ──────────────────────────────────────────────────────── - - fn prune_ephemeral(&mut self) { - let depth = self.frames.len(); - self.handlers - .retain(|h| !h.ephemeral || h.prompt_depth < depth); - } - - /// Perform an effect: find the innermost handler, capture everything - /// between here and its prompt as a continuation, and run the clause at - /// the prompt with `resume` bound to that continuation. - fn perform(&mut self, dst: Reg, eff: StringId, o: StringId, args: &[Val]) -> VmResult<()> { - let found = self - .handlers - .iter() - .rev() - .find(|h| h.effect == eff && h.op == o) - .map(|h| (h.closure.clone(), h.prompt_depth)); - - let Some((hval, prompt_depth)) = found else { - // Nothing in Loon handles this, so it falls through to hardware. - let v = self.hardware(eff, o, args)?; - self.w(dst, v); - return Ok(()); - }; - - // Deep-handler semantics: every handler at or above the prompt moves - // into the snapshot, including the prompt's own. That is what makes a - // clause re-performing its own effect forward *outward* instead of - // recursing into itself. - let prompt_handlers: Vec = self - .handlers - .iter() - .filter(|h| h.prompt_depth >= prompt_depth) - .map(|h| DynHandler { - prompt_depth: h.prompt_depth - prompt_depth, - ..h.clone() - }) - .collect(); - self.handlers.retain(|h| h.prompt_depth < prompt_depth); - - let saved: Vec = self.frames.split_off(prompt_depth + 1); - let k = Continuation { - saved, - func: self.func, - block: self.block, - ip: self.ip, - regs: core::mem::take(&mut self.regs), - captures: core::mem::replace(&mut self.captures, self.no_caps.clone()), - perform_dst: dst.0, - prompt_handlers, - }; - let k = Val::Cont(Rc::new(k)); - - // Restore the prompt frame as current; its ret_reg is where the whole - // `handle` expression's value belongs. - let f0 = self - .frames - .pop() - .ok_or_else(|| "perform with no prompt frame".to_string())?; - let handle_ret = f0.ret_reg; - self.func = f0.func; - self.block = f0.block; - self.ip = f0.ip; - self.regs = f0.regs; - self.captures = f0.captures; - self.prune_ephemeral(); - - match hval { - Val::Closure(fid, caps) => { - let mut call_args = vec![k]; - call_args.extend_from_slice(args); - self.call(fid, &call_args, handle_ret, caps) - } - other => Err(alloc::format!( - "handler for {}.{} is a {}, not a function", - self.m.string(eff), - self.m.string(o), - other.type_name() - )), - } - } - - fn resume(&mut self, k: &Continuation, v: Val) -> VmResult<()> { - self.resume_inner(k, v, None) - } - - fn resume_at(&mut self, k: &Continuation, v: Val, dst: Option) -> VmResult<()> { - self.resume_inner(k, v, dst) - } - - /// Re-install a captured segment and run on from the `perform` that - /// produced it, with `v` as that perform's value. - fn resume_inner(&mut self, k: &Continuation, v: Val, dst: Option) -> VmResult<()> { - if let Some(dst) = dst { - // Park the clause's frame as a fresh prompt, so the continuation - // stays self-contained even when resumed after its original - // `handle` has already exited. - self.frames.push(Frame { - func: self.func, - block: self.block, - ip: self.ip, - regs: core::mem::take(&mut self.regs), - captures: core::mem::replace(&mut self.captures, self.no_caps.clone()), - ret_reg: dst, - }); - } - - // Snapshot depths are relative to the prompt; the saved frames go - // directly above the current top, so absolute = prompt + relative. - let prompt = self.frames.len().saturating_sub(1); - for h in &k.prompt_handlers { - self.handlers.push(DynHandler { - prompt_depth: prompt + h.prompt_depth, - ephemeral: true, - ..h.clone() - }); - } - for f in &k.saved { - self.frames.push(Frame { - func: f.func, - block: f.block, - ip: f.ip, - regs: f.regs.clone(), - captures: f.captures.clone(), - ret_reg: f.ret_reg, - }); - } - - let mut regs = k.regs.clone(); - let pd = k.perform_dst as usize; - if pd >= regs.len() { - regs.resize(pd + 1, Val::Unit); - } - regs[pd] = v; - self.func = k.func; - self.block = k.block; - self.ip = k.ip; - self.regs = regs; - self.captures = k.captures.clone(); - Ok(()) - } - - /// The bottom of the handler stack: effects nothing in Loon caught. - /// - /// On a hosted runtime these reach the OS. Here there is no OS below to - /// reach, so the set is exactly what the machine can do — and anything - /// outside it is a hard error, never a silent `()`. - fn hardware(&mut self, eff: StringId, o: StringId, args: &[Val]) -> VmResult { - let effect = self.m.string(eff); - let op = self.m.string(o); - match (effect, op) { - ("Console", "write") | ("IO", "print") => { - let s = args.first().map(show).unwrap_or_default(); - self.host.write(&s); - Ok(Val::Unit) - } - ("Console", "line") | ("IO", "println") => { - let s = args.first().map(show).unwrap_or_default(); - self.host.write(&s); - self.host.write("\n"); - Ok(Val::Unit) - } - ("Clock", "now") | ("Clock", "ticks") => Ok(Val::Int(self.host.ticks())), - ("Fb", op) => { - // Everything a framebuffer takes is an integer: coordinates, - // sizes, 0xRRGGBB colours. - let mut ints = Vec::with_capacity(args.len()); - for a in args { - match a { - Val::Int(n) => ints.push(*n), - Val::Float(f) => ints.push(*f as i64), - v => { - return Err(alloc::format!( - "Fb.{op}: expected an integer argument, got a {}", - v.type_name() - )) - } - } - } - Ok(match self.host.fb(op, &ints)? { - Some(n) => Val::Int(n), - None => Val::Unit, - }) - } - ("Fail", "fail") => Err(alloc::format!( - "unhandled failure: {}", - args.first().map(show).unwrap_or_default() - )), - _ => Err(alloc::format!( - "unhandled effect {effect}.{op} — this machine has no handler for it" - )), - } - } -} - -// ── Operators and intrinsics ─────────────────────────────────────────── - -impl<'m, H: Host> Vm<'m, H> { - fn binop(&mut self, o: BinOp, a: Val, b: Val) -> VmResult { - use BinOp::*; - // Comparison and logic first: they accept anything. - match o { - Eq => return Ok(Val::Bool(a == b)), - Ne => return Ok(Val::Bool(a != b)), - And => return Ok(if a.truthy() { b } else { a }), - Or => return Ok(if a.truthy() { a } else { b }), - Concat => return self.concat(a, b), - _ => {} - } - - // String `+` concatenates, matching the host. - if let (Add, Val::Str(x), Val::Str(y)) = (o, &a, &b) { - let mut s = String::with_capacity(x.len() + y.len()); - s.push_str(x); - s.push_str(y); - return Ok(Val::Str(Rc::new(s))); - } - - let num = |v: &Val| -> Option { - match v { - Val::Int(n) => Some(*n as f64), - Val::Float(f) => Some(*f), - _ => None, - } - }; - let (Some(x), Some(y)) = (num(&a), num(&b)) else { - return Err(alloc::format!( - "cannot apply {o:?} to a {} and a {}", - a.type_name(), - b.type_name() - )); - }; - - // Integer arithmetic stays integral; a mixed operand promotes. - let ints = matches!((&a, &b), (Val::Int(_), Val::Int(_))); - Ok(match o { - Lt => Val::Bool(x < y), - Gt => Val::Bool(x > y), - Le => Val::Bool(x <= y), - Ge => Val::Bool(x >= y), - Div if y == 0.0 => return Err("division by zero".to_string()), - Rem if y == 0.0 => return Err("remainder by zero".to_string()), - _ if ints => { - let (i, j) = (x as i64, y as i64); - Val::Int(match o { - Add => i.wrapping_add(j), - Sub => i.wrapping_sub(j), - Mul => i.wrapping_mul(j), - Div => i / j, - Rem => i % j, - _ => unreachable!(), - }) - } - Add => Val::Float(x + y), - Sub => Val::Float(x - y), - Mul => Val::Float(x * y), - Div => Val::Float(x / y), - Rem => Val::Float(x % y), - _ => unreachable!(), - }) - } - - fn concat(&mut self, a: Val, b: Val) -> VmResult { - Ok(match (&a, &b) { - (Val::Vec(x), Val::Vec(y)) => { - let mut v = x.as_ref().clone(); - v.extend(y.iter().cloned()); - Val::Vec(Rc::new(v)) - } - _ => { - let mut s = show(&a); - s.push_str(&show(&b)); - Val::Str(Rc::new(s)) - } - }) - } - - fn builtin(&mut self, tag: u16, args: &[Val]) -> VmResult { - let name = self - .m - .builtin_name(tag) - .ok_or_else(|| alloc::format!("boot image references unknown builtin tag {tag}"))?; - let a0 = || args.first().cloned().unwrap_or(Val::Unit); - let a1 = || args.get(1).cloned().unwrap_or(Val::Unit); - - let seq = |v: &Val| -> Option>> { - match v { - Val::Vec(xs) | Val::Tup(xs) | Val::Set(xs) => Some(xs.clone()), - _ => None, - } - }; - - Ok(match name { - "Println" => { - let mut out = String::new(); - for (i, a) in args.iter().enumerate() { - if i > 0 { - out.push(' '); - } - out.push_str(&show(a)); - } - out.push('\n'); - self.host.write(&out); - Val::Unit - } - "Print" => { - let mut out = String::new(); - for (i, a) in args.iter().enumerate() { - if i > 0 { - out.push(' '); - } - out.push_str(&show(a)); - } - self.host.write(&out); - Val::Unit - } - "Str" => { - let mut out = String::new(); - for a in args { - out.push_str(&show(a)); - } - Val::Str(Rc::new(out)) - } - "Len" => Val::Int(match a0() { - Val::Str(s) => s.chars().count() as i64, - Val::Map(kvs) => kvs.len() as i64, - v => seq(&v).map(|x| x.len()).unwrap_or(0) as i64, - }), - "Empty" => Val::Bool(match a0() { - Val::Str(s) => s.is_empty(), - Val::Map(kvs) => kvs.is_empty(), - Val::Unit => true, - v => seq(&v).map(|x| x.is_empty()).unwrap_or(false), - }), - "Not" => Val::Bool(!a0().truthy()), - "Float" => match a0() { - Val::Float(f) => Val::Float(f), - Val::Int(n) => Val::Float(n as f64), - Val::Str(s) => Val::Float( - s.trim() - .parse::() - .map_err(|_| alloc::format!("cannot parse '{s}' as a float"))?, - ), - v => { - return Err(alloc::format!( - "cannot convert a {} to a float", - v.type_name() - )) - } - }, - "Int" => match a0() { - Val::Int(n) => Val::Int(n), - Val::Float(f) => Val::Int(f as i64), - Val::Str(s) => Val::Int( - s.trim() - .parse::() - .map_err(|_| alloc::format!("cannot parse '{s}' as an int"))?, - ), - v => { - return Err(alloc::format!( - "cannot convert a {} to an int", - v.type_name() - )) - } - }, - "TypeOf" => Val::Str(Rc::new(a0().type_name().to_string())), - "SomeP" => Val::Bool(!matches!(a0(), Val::Unit)), - "NoneP" => Val::Bool(matches!(a0(), Val::Unit)), - "VecP" => Val::Bool(matches!(a0(), Val::Vec(_))), - "MapP" => Val::Bool(matches!(a0(), Val::Map(_))), - "Range" => { - let (lo, hi) = match (a0(), a1()) { - (Val::Int(a), Val::Int(b)) => (a, b), - (Val::Int(n), Val::Unit) => (0, n), - _ => return Err("range expects integers".to_string()), - }; - Val::Vec(Rc::new((lo..hi).map(Val::Int).collect())) - } - "Nth" | "Get" => { - let base = a0(); - match (&base, a1()) { - (Val::Map(kvs), key) => kvs - .iter() - .find(|(k, _)| *k == key) - .map(|(_, v)| v.clone()) - .unwrap_or(Val::Unit), - (_, Val::Int(i)) => seq(&base) - .and_then(|xs| xs.get(i as usize).cloned()) - .unwrap_or(Val::Unit), - _ => Val::Unit, - } - } - "First" => seq(&a0()) - .and_then(|xs| xs.first().cloned()) - .unwrap_or(Val::Unit), - "Last" => seq(&a0()) - .and_then(|xs| xs.last().cloned()) - .unwrap_or(Val::Unit), - "Reverse" => { - let mut xs = seq(&a0()).map(|x| x.as_ref().clone()).unwrap_or_default(); - xs.reverse(); - Val::Vec(Rc::new(xs)) - } - "Conj" => { - let mut xs = seq(&a0()).map(|x| x.as_ref().clone()).unwrap_or_default(); - xs.extend(args.iter().skip(1).cloned()); - Val::Vec(Rc::new(xs)) - } - "Cons" => { - let mut xs = vec![a0()]; - xs.extend(seq(&a1()).map(|x| x.as_ref().clone()).unwrap_or_default()); - Val::Vec(Rc::new(xs)) - } - "Sum" => { - let xs = seq(&a0()).ok_or_else(|| "sum expects a sequence".to_string())?; - let mut acc = Val::Int(0); - for x in xs.iter() { - acc = self.binop(BinOp::Add, acc, x.clone())?; - } - acc - } - "Concat" => { - let mut acc = a0(); - for b in args.iter().skip(1) { - acc = self.concat(acc, b.clone())?; - } - acc - } - "Join" => { - let xs = seq(&a0()).unwrap_or_default(); - let sep = match a1() { - Val::Str(s) => s.as_ref().clone(), - Val::Unit => String::new(), - v => show(&v), - }; - let mut out = String::new(); - for (i, x) in xs.iter().enumerate() { - if i > 0 { - out.push_str(&sep); - } - out.push_str(&show(x)); - } - Val::Str(Rc::new(out)) - } - // Higher-order intrinsics re-enter the interpreter. - "Map" => { - let xs = seq(&a0()).ok_or_else(|| "map expects a sequence".to_string())?; - let f = a1(); - let mut out = Vec::with_capacity(xs.len()); - for x in xs.iter() { - out.push(self.apply(&f, core::slice::from_ref(x))?); - } - Val::Vec(Rc::new(out)) - } - "Filter" => { - let xs = seq(&a0()).ok_or_else(|| "filter expects a sequence".to_string())?; - let f = a1(); - let mut out = Vec::new(); - for x in xs.iter() { - if self.apply(&f, core::slice::from_ref(x))?.truthy() { - out.push(x.clone()); - } - } - Val::Vec(Rc::new(out)) - } - "Each" => { - let xs = seq(&a0()).ok_or_else(|| "each expects a sequence".to_string())?; - let f = a1(); - for x in xs.iter() { - self.apply(&f, core::slice::from_ref(x))?; - } - Val::Unit - } - "Fold" => { - let xs = seq(&a0()).ok_or_else(|| "fold expects a sequence".to_string())?; - let mut acc = a1(); - let f = args.get(2).cloned().unwrap_or(Val::Unit); - for x in xs.iter() { - acc = self.apply(&f, &[acc, x.clone()])?; - } - acc - } - "AssertEq" => { - let (a, b) = (a0(), a1()); - if a != b { - return Err(alloc::format!( - "assertion failed: {} != {}", - show(&a), - show(&b) - )); - } - Val::Unit - } - "MatchFail" => return Err(alloc::format!("no match arm matched {}", show(&a0()))), - "UnboundSym" => return Err(alloc::format!("unbound symbol '{}'", show(&a0()))), - // Everything else exists on the host but has not been ported. - // Saying so is the point: a silently wrong answer on hardware is - // far worse than a refusal. - other => { - return Err(alloc::format!( - "builtin '{other}' is not implemented in the unikernel runtime" - )) - } - }) - } -} diff --git a/crates/loon-kernel/src/fwcfg.rs b/crates/loon-kernel/src/fwcfg.rs deleted file mode 100644 index 0e75a06..0000000 --- a/crates/loon-kernel/src/fwcfg.rs +++ /dev/null @@ -1,115 +0,0 @@ -//! QEMU fw_cfg — the firmware configuration channel. -//! -//! A tiny key/value store the emulator exposes to the guest; on `virt` it -//! sits at 0x1010_0000. We use exactly one thing from it: the `etc/ramfb` -//! file, whose contents tell QEMU where our framebuffer lives. Everything -//! goes through the DMA interface, which is byte-order-defined (big-endian) -//! and does not care about the register's access width. - -use core::sync::atomic::{fence, Ordering}; - -use crate::mmio; - -const BASE: usize = 0x1010_0000; -const SELECTOR: usize = BASE + 0x08; -const DMA: usize = BASE + 0x10; - -const KEY_FILE_DIR: u16 = 0x0019; - -const CTL_ERROR: u32 = 1 << 0; -const CTL_READ: u32 = 1 << 1; -const CTL_SELECT: u32 = 1 << 3; -const CTL_WRITE: u32 = 1 << 4; - -/// One DMA descriptor, in memory, all fields big-endian. -#[repr(C)] -struct DmaAccess { - control: u32, - length: u32, - address: u64, -} - -/// One entry of the file directory: `struct FWCfgFile`. -#[repr(C)] -struct File { - size: u32, - select: u16, - _reserved: u16, - name: [u8; 56], -} - -pub struct FwCfg; - -impl FwCfg { - /// Issue one DMA transfer and wait for it. `control` carries the op bits - /// (and, if selecting, the key in the upper half). - unsafe fn dma(&self, control: u32, buf: *mut u8, len: usize) -> Result<(), ()> { - let desc = DmaAccess { - control: control.to_be(), - length: (len as u32).to_be(), - address: (buf as u64).to_be(), - }; - // The device reads the descriptor and the buffer straight from RAM. - fence(Ordering::SeqCst); - mmio::write64(DMA, (&desc as *const DmaAccess as u64).to_be()); - // Completion is signalled by the device clearing `control`. - loop { - fence(Ordering::SeqCst); - let c = u32::from_be(core::ptr::read_volatile(&desc.control)); - if c == 0 { - return Ok(()); - } - if c & CTL_ERROR != 0 { - return Err(()); - } - core::hint::spin_loop(); - } - } - - unsafe fn read(&self, key: u16, buf: &mut [u8]) -> Result<(), ()> { - self.dma( - ((key as u32) << 16) | CTL_SELECT | CTL_READ, - buf.as_mut_ptr(), - buf.len(), - ) - } - - /// Continue reading the currently selected item. - unsafe fn read_more(&self, buf: &mut [u8]) -> Result<(), ()> { - self.dma(CTL_READ, buf.as_mut_ptr(), buf.len()) - } - - /// Find a named file and return its selector key. - pub fn find(&self, name: &str) -> Option { - unsafe { - let mut count = [0u8; 4]; - self.read(KEY_FILE_DIR, &mut count).ok()?; - let count = u32::from_be_bytes(count); - for _ in 0..count { - let mut raw = [0u8; core::mem::size_of::()]; - self.read_more(&mut raw).ok()?; - let f: File = core::ptr::read_unaligned(raw.as_ptr() as *const File); - let n = f.name.iter().position(|&b| b == 0).unwrap_or(f.name.len()); - if &f.name[..n] == name.as_bytes() { - return Some(u16::from_be(f.select)); - } - } - None - } - } - - /// Overwrite a file's contents (only meaningful for the few writable - /// ones, like `etc/ramfb`). - pub fn write(&self, key: u16, data: &[u8]) -> Result<(), ()> { - unsafe { - // Selecting via the register first is belt and braces: some - // firmware paths do it and it costs nothing. - mmio::write16(SELECTOR, key.to_be()); - self.dma( - ((key as u32) << 16) | CTL_SELECT | CTL_WRITE, - data.as_ptr() as *mut u8, - data.len(), - ) - } - } -} diff --git a/crates/loon-kernel/src/heap.rs b/crates/loon-kernel/src/heap.rs deleted file mode 100644 index 0073970..0000000 --- a/crates/loon-kernel/src/heap.rs +++ /dev/null @@ -1,135 +0,0 @@ -//! The kernel heap: a first-fit free-list allocator over the RAM left above -//! the image. -//! -//! Single-hart and non-reentrant, which is why the lock is a bare `Cell` -//! guard rather than a real spinlock — there is nothing to race with yet. -//! Preemption lands before SMP does, and that is when this needs revisiting. - -use core::alloc::{GlobalAlloc, Layout}; -use core::cell::UnsafeCell; -use core::ptr; - -/// A free region. Stored in the first bytes of the region itself. -#[repr(C)] -struct Block { - size: usize, - next: *mut Block, -} - -const MIN_BLOCK: usize = core::mem::size_of::(); - -pub struct Heap { - free: UnsafeCell<*mut Block>, - /// Allocation count. Wall-clock under emulation is noisy; this is not, - /// so it is what optimisation work should be judged against. - allocs: core::sync::atomic::AtomicU64, -} - -// Single hart, interrupts off: no concurrent access exists. -unsafe impl Sync for Heap {} - -impl Heap { - pub const fn new() -> Self { - Heap { - free: UnsafeCell::new(ptr::null_mut()), - allocs: core::sync::atomic::AtomicU64::new(0), - } - } - - pub fn allocs(&self) -> u64 { - self.allocs.load(core::sync::atomic::Ordering::Relaxed) - } - - /// # Safety - /// `start..start+size` must be untouched, writable RAM that outlives all - /// allocations, and this must be called exactly once. - pub unsafe fn init(&self, start: usize, size: usize) { - let block = start as *mut Block; - (*block).size = size; - (*block).next = ptr::null_mut(); - *self.free.get() = block; - } - - /// Splice a region back into the address-ordered free list, coalescing - /// with whichever neighbours it now touches. - unsafe fn insert(&self, region: *mut Block) { - let mut prev: *mut Block = ptr::null_mut(); - let mut cur = *self.free.get(); - while !cur.is_null() && (cur as usize) < (region as usize) { - prev = cur; - cur = (*cur).next; - } - - (*region).next = cur; - if prev.is_null() { - *self.free.get() = region; - } else { - (*prev).next = region; - } - - // Coalesce forward, then backward. - if !cur.is_null() && (region as usize) + (*region).size == cur as usize { - (*region).size += (*cur).size; - (*region).next = (*cur).next; - } - if !prev.is_null() && (prev as usize) + (*prev).size == region as usize { - (*prev).size += (*region).size; - (*prev).next = (*region).next; - } - } -} - -unsafe impl GlobalAlloc for Heap { - unsafe fn alloc(&self, layout: Layout) -> *mut u8 { - self.allocs - .fetch_add(1, core::sync::atomic::Ordering::Relaxed); - let align = layout.align().max(core::mem::align_of::()); - let size = align_up(layout.size().max(MIN_BLOCK), core::mem::align_of::()); - - let mut prev: *mut Block = ptr::null_mut(); - let mut cur = *self.free.get(); - while !cur.is_null() { - let base = cur as usize; - let start = align_up(base, align); - let end = start + size; - - if end <= base + (*cur).size { - let head = start - base; - let tail = base + (*cur).size - end; - - // Unlink, then give back whatever is left at either end — - // but only if the remainder can hold a link of its own. - let next = (*cur).next; - if prev.is_null() { - *self.free.get() = next; - } else { - (*prev).next = next; - } - if tail >= MIN_BLOCK { - let t = end as *mut Block; - (*t).size = tail; - self.insert(t); - } - if head >= MIN_BLOCK { - (*cur).size = head; - self.insert(cur); - } - return start as *mut u8; - } - prev = cur; - cur = (*cur).next; - } - ptr::null_mut() - } - - unsafe fn dealloc(&self, p: *mut u8, layout: Layout) { - let size = align_up(layout.size().max(MIN_BLOCK), core::mem::align_of::()); - let block = p as *mut Block; - (*block).size = size; - self.insert(block); - } -} - -fn align_up(n: usize, align: usize) -> usize { - (n + align - 1) & !(align - 1) -} diff --git a/crates/loon-kernel/src/main.rs b/crates/loon-kernel/src/main.rs deleted file mode 100644 index 4e17350..0000000 --- a/crates/loon-kernel/src/main.rs +++ /dev/null @@ -1,231 +0,0 @@ -//! Loon as a unikernel. -//! -//! There is no userspace here and no syscall boundary: the Loon program *is* -//! the kernel, and what would be a syscall elsewhere is an effect performed -//! into a handler that happens to touch hardware. - -#![no_std] -#![no_main] - -extern crate alloc; - -use core::arch::global_asm; - -#[global_allocator] -static HEAP: heap::Heap = heap::Heap::new(); - -#[macro_use] -mod uart; -mod eir; -mod fwcfg; -mod heap; -mod mmio; -mod ramfb; -mod sbi; - -// Set up a stack and clear .bss before anything Rust-shaped runs. `a0` holds -// the hart id and `a1` the device tree pointer; we keep them for `kmain`. -global_asm!( - r#" - .section .text.entry - .globl _start -_start: - la sp, __stack_top - - la t0, __bss_start - la t1, __bss_end -1: bgeu t0, t1, 2f - sd zero, 0(t0) - addi t0, t0, 8 - j 1b - -2: tail kmain -"# -); - -extern "C" { - static __heap_start: u8; - static __heap_end: u8; -} - -/// `rdtime` at kernel entry, for the boot-to-init measurement. -static BOOT: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0); - -#[no_mangle] -pub extern "C" fn kmain(hart: usize, dtb: usize) -> ! { - BOOT.store(now(), core::sync::atomic::Ordering::Relaxed); - let (start, end) = unsafe { - ( - &__heap_start as *const u8 as usize, - &__heap_end as *const u8 as usize, - ) - }; - unsafe { HEAP.init(start, end - start) }; - - println!(); - println!("loon unikernel — hart {hart}, dtb {dtb:#x}"); - println!( - "heap {:#x}..{:#x} ({} KiB)", - start, - end, - (end - start) / 1024 - ); - - let image = include_bytes!(env!("LOON_BOOT_IMAGE")); - println!("init image {} bytes", image.len()); - println!(); - - for (name, img) in [ - ("loop ", include_bytes!(env!("LOON_LOOP_IMAGE")).as_slice()), - ("bench", include_bytes!(env!("LOON_BENCH_IMAGE")).as_slice()), - ] { - if let Err(e) = run_bench_named(name, img) { - println!("{name} failed: {e}"); - } - } - println!(); - - // Gratuitous. A kernel that boots should get to do one fun thing. - println!(); - if let Err(e) = run_init(include_bytes!(env!("LOON_MANDEL_IMAGE"))) { - println!("mandel failed: {e}"); - } - println!(); - - let t0 = now(); - match run_init(image) { - Ok(()) => { - println!(); - println!( - "init exited cleanly in {} us ({} us since entry)", - micros_since(t0), - micros_since(BOOT.load(core::sync::atomic::Ordering::Relaxed)), - ); - // If the machine has a display, hand it to the GUI program and - // stay up so there is something to look at. Otherwise we are a - // headless run and the polite thing is to power off. - match ramfb::Ramfb::init(640, 480) { - Some(fb) => { - println!("framebuffer {}x{} — running gui", fb.width, fb.height); - if let Err(e) = run_on( - include_bytes!(env!("LOON_GUI_IMAGE")), - Machine { fb: Some(fb) }, - ) { - println!("gui failed: {e}"); - sbi::shutdown(true); - } - println!("gui up — close the window or ^A x to quit"); - loop { - unsafe { core::arch::asm!("wfi") }; - } - } - None => sbi::shutdown(false), - } - } - Err(e) => { - println!(); - println!("init failed: {e}"); - sbi::shutdown(true) - } - } -} - -/// The machine, as the VM sees it. Effects that no Loon handler caught -/// arrive here, which is the only place in the system that touches hardware. -struct Machine { - fb: Option, -} - -impl eir::vm::Host for Machine { - fn write(&mut self, s: &str) { - print!("{s}"); - } - - fn ticks(&mut self) -> i64 { - now() as i64 - } - - fn fb(&mut self, op: &str, a: &[i64]) -> Result, alloc::string::String> { - let Some(fb) = self.fb.as_mut() else { - return Err(alloc::format!( - "Fb.{op}: this machine has no framebuffer (boot with -device ramfb)" - )); - }; - let arg = |i: usize| -> Result { - a.get(i) - .copied() - .ok_or_else(|| alloc::format!("Fb.{op}: missing argument {i}")) - }; - match op { - "width" => Ok(Some(fb.width as i64)), - "height" => Ok(Some(fb.height as i64)), - "clear" => { - fb.clear(arg(0)? as u32); - Ok(None) - } - "fill-rect" => { - fb.fill_rect(arg(0)?, arg(1)?, arg(2)?, arg(3)?, arg(4)? as u32); - Ok(None) - } - "present" => { - fb.present(); - Ok(None) - } - _ => Err(alloc::format!("Fb.{op}: no such framebuffer operation")), - } - } -} - -/// QEMU's `virt` machine ticks the RISC-V `time` CSR at 10 MHz. -const TIMEBASE_HZ: u64 = 10_000_000; - -fn now() -> u64 { - let t: u64; - unsafe { core::arch::asm!("rdtime {}", out(reg) t) }; - t -} - -/// Microseconds between two `rdtime` reads. -fn micros_since(start: u64) -> u64 { - now().saturating_sub(start) * 1_000_000 / TIMEBASE_HZ -} - -fn run_init(image: &[u8]) -> Result<(), alloc::string::String> { - run_on(image, Machine { fb: None }) -} - -fn run_on(image: &[u8], mut machine: Machine) -> Result<(), alloc::string::String> { - let module = eir::decode::decode(image)?; - let mut vm = eir::vm::Vm::new(&module, &mut machine).with_fuel(500_000_000); - vm.run()?; - Ok(()) -} - -/// Time the interpreter on an IO-free workload and report ns per dispatched -/// op. Steps come from the VM's own loop counter, so this measures the -/// interpreter rather than the console. -fn run_bench_named(name: &str, image: &[u8]) -> Result<(), alloc::string::String> { - let module = eir::decode::decode(image)?; - let mut machine = Machine { fb: None }; - let mut vm = eir::vm::Vm::new(&module, &mut machine).with_fuel(2_000_000_000); - - let t = now(); - let a0 = HEAP.allocs(); - vm.run()?; - let us = micros_since(t); - let allocs = HEAP.allocs() - a0; - let steps = vm.steps(); - - println!( - "{name}: {steps} ops in {us} us = {} ns/op, {allocs} allocs = {} per 100 ops", - (us * 1000).checked_div(steps).unwrap_or(0), - (allocs * 100).checked_div(steps).unwrap_or(0), - ); - Ok(()) -} - -#[panic_handler] -fn panic(info: &core::panic::PanicInfo) -> ! { - println!("\nkernel panic: {info}"); - sbi::shutdown(true) -} diff --git a/crates/loon-kernel/src/mmio.rs b/crates/loon-kernel/src/mmio.rs deleted file mode 100644 index 6e0af27..0000000 --- a/crates/loon-kernel/src/mmio.rs +++ /dev/null @@ -1,33 +0,0 @@ -//! Raw memory-mapped IO. -//! -//! Everything a driver does to hardware bottoms out here. Keeping it in one -//! module is what lets a driver be simulated instead of executed: the same -//! driver code over a different `Mmio` is a test, not a boot. - -/// Volatile read of a device register. -/// -/// # Safety -/// `addr` must be a valid MMIO register for the current machine. -pub unsafe fn read8(addr: usize) -> u8 { - core::ptr::read_volatile(addr as *const u8) -} - -/// Volatile write of a device register. -/// -/// # Safety -/// `addr` must be a valid MMIO register for the current machine. -pub unsafe fn write8(addr: usize, val: u8) { - core::ptr::write_volatile(addr as *mut u8, val) -} - -/// # Safety -/// `addr` must be a valid, naturally aligned MMIO register. -pub unsafe fn write16(addr: usize, val: u16) { - core::ptr::write_volatile(addr as *mut u16, val) -} - -/// # Safety -/// `addr` must be a valid, naturally aligned MMIO register. -pub unsafe fn write64(addr: usize, val: u64) { - core::ptr::write_volatile(addr as *mut u64, val) -} diff --git a/crates/loon-kernel/src/ramfb.rs b/crates/loon-kernel/src/ramfb.rs deleted file mode 100644 index 190d52b..0000000 --- a/crates/loon-kernel/src/ramfb.rs +++ /dev/null @@ -1,88 +0,0 @@ -//! ramfb — a linear framebuffer in guest RAM that QEMU scans out. -//! -//! The simplest display a VM can have: we own a `width * height` array of -//! XRGB pixels, tell QEMU where it is once through fw_cfg, and from then on -//! drawing is writing memory. No command queue, no interrupts, no GPU. Boot -//! with `-device ramfb` and a real `-display` to see it. - -use alloc::vec; -use alloc::vec::Vec; - -use crate::fwcfg::FwCfg; - -/// DRM_FORMAT_XRGB8888 — 'XR24' as a little-endian fourcc. -const FOURCC_XRGB8888: u32 = 0x3432_5258; - -/// The config record QEMU expects in `etc/ramfb`, all fields big-endian. -#[repr(C, packed)] -struct Cfg { - addr: u64, - fourcc: u32, - flags: u32, - width: u32, - height: u32, - stride: u32, -} - -pub struct Ramfb { - pub width: u32, - pub height: u32, - pixels: Vec, -} - -impl Ramfb { - /// Allocate a framebuffer and point QEMU at it. `None` if the machine - /// has no `etc/ramfb` — i.e. was booted without `-device ramfb`. - pub fn init(width: u32, height: u32) -> Option { - let key = FwCfg.find("etc/ramfb")?; - let pixels = vec![0u32; (width * height) as usize]; - let cfg = Cfg { - addr: (pixels.as_ptr() as u64).to_be(), - fourcc: FOURCC_XRGB8888.to_be(), - flags: 0, - width: width.to_be(), - height: height.to_be(), - stride: (width * 4).to_be(), - }; - let bytes = unsafe { - core::slice::from_raw_parts( - &cfg as *const Cfg as *const u8, - core::mem::size_of::(), - ) - }; - FwCfg.write(key, bytes).ok()?; - Some(Ramfb { - width, - height, - pixels, - }) - } - - pub fn clear(&mut self, color: u32) { - self.pixels.fill(color); - } - - /// Fill a rectangle, clipped to the screen. Coordinates are signed so a - /// shape can hang off any edge without the caller doing arithmetic. - pub fn fill_rect(&mut self, x: i64, y: i64, w: i64, h: i64, color: u32) { - let (sw, sh) = (self.width as i64, self.height as i64); - let x0 = x.max(0); - let y0 = y.max(0); - let x1 = (x + w).min(sw); - let y1 = (y + h).min(sh); - if x0 >= x1 || y0 >= y1 { - return; - } - for row in y0..y1 { - let start = (row * sw + x0) as usize; - let end = (row * sw + x1) as usize; - self.pixels[start..end].fill(color); - } - } - - /// Nothing to flush — QEMU reads the buffer on its own refresh timer. - /// Kept as the seam where a double buffer or a dirty-rect hint would go. - pub fn present(&mut self) { - core::sync::atomic::fence(core::sync::atomic::Ordering::SeqCst); - } -} diff --git a/crates/loon-kernel/src/sbi.rs b/crates/loon-kernel/src/sbi.rs deleted file mode 100644 index 0fa0ba2..0000000 --- a/crates/loon-kernel/src/sbi.rs +++ /dev/null @@ -1,29 +0,0 @@ -//! The thin slice of the RISC-V SBI we need from OpenSBI. - -use core::arch::asm; - -fn ecall(eid: usize, fid: usize, a0: usize, a1: usize) -> isize { - let err: isize; - unsafe { - asm!( - "ecall", - inlateout("a0") a0 => err, - in("a1") a1, - in("a6") fid, - in("a7") eid, - options(nostack), - ); - } - err -} - -/// Power off the machine. Returns only if the firmware refuses. -pub fn shutdown(failure: bool) -> ! { - const SRST: usize = 0x5352_5354; - let reason = if failure { 1 } else { 0 }; - ecall(SRST, 0, 0, reason); // system_reset(SHUTDOWN, reason) - ecall(0x08, 0, 0, 0); // legacy shutdown, for older firmware - loop { - core::hint::spin_loop(); - } -} diff --git a/crates/loon-kernel/src/uart.rs b/crates/loon-kernel/src/uart.rs deleted file mode 100644 index 66e71cf..0000000 --- a/crates/loon-kernel/src/uart.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! NS16550a UART — the console driver. -//! -//! QEMU's `virt` machine puts one at 0x1000_0000. OpenSBI has already -//! initialised it by the time we get control, so transmit needs no setup -//! beyond waiting for the holding register to drain. - -use crate::mmio; - -const BASE: usize = 0x1000_0000; -const THR: usize = BASE; // transmit holding register -const RBR: usize = BASE; // receive buffer register -const LSR: usize = BASE + 5; // line status register - -const LSR_RX_READY: u8 = 1 << 0; -const LSR_TX_IDLE: u8 = 1 << 5; - -pub struct Uart; - -// The receive half is unused until init wants a console to read from; it is -// kept because a driver that can only talk is not a console driver. -#[allow(dead_code)] -impl Uart { - pub fn putc(&self, c: u8) { - unsafe { - while mmio::read8(LSR) & LSR_TX_IDLE == 0 { - core::hint::spin_loop(); - } - mmio::write8(THR, c); - } - } - - pub fn getc(&self) -> Option { - unsafe { - if mmio::read8(LSR) & LSR_RX_READY == 0 { - None - } else { - Some(mmio::read8(RBR)) - } - } - } -} - -impl core::fmt::Write for Uart { - fn write_str(&mut self, s: &str) -> core::fmt::Result { - for b in s.bytes() { - // The console is line-oriented; QEMU's terminal wants CRLF. - if b == b'\n' { - self.putc(b'\r'); - } - self.putc(b); - } - Ok(()) - } -} - -#[macro_export] -macro_rules! print { - ($($arg:tt)*) => {{ - use core::fmt::Write; - let _ = write!($crate::uart::Uart, $($arg)*); - }}; -} - -#[macro_export] -macro_rules! println { - () => { $crate::print!("\n") }; - ($($arg:tt)*) => {{ - use core::fmt::Write; - let _ = writeln!($crate::uart::Uart, $($arg)*); - }}; -} diff --git a/crates/loon-kernel/tools/screenshot.py b/crates/loon-kernel/tools/screenshot.py deleted file mode 100755 index 57877de..0000000 --- a/crates/loon-kernel/tools/screenshot.py +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env python3 -"""Boot the kernel headless with a ramfb, wait for the GUI to come up, and -grab the framebuffer through QMP as a PNG. - -This is how the display gets verified without a window: the same path a CI -box would use. Prints the output path on success and exits non-zero if the -GUI never reported in. -""" -import json, os, socket, struct, subprocess, sys, tempfile, time, zlib - -KERNEL = "target/riscv64gc-unknown-none-elf/release/loon-kernel" -OUT = sys.argv[1] if len(sys.argv) > 1 else "screenshot.png" - -tmp = tempfile.mkdtemp() -sock = os.path.join(tmp, "qmp.sock") -ppm = os.path.join(tmp, "fb.ppm") -serial = open(os.path.join(tmp, "serial.txt"), "w+b") - -qemu = subprocess.Popen( - ["qemu-system-riscv64", "-machine", "virt", "-cpu", "rv64", "-smp", "1", - "-m", "128M", "-nographic", "-serial", "mon:stdio", "-bios", "default", - "-device", "ramfb", "-display", "none", - "-qmp", f"unix:{sock},server,nowait", "-kernel", KERNEL], - stdin=subprocess.DEVNULL, stdout=serial, stderr=subprocess.STDOUT, -) - -def serial_text(): - serial.seek(0) - return serial.read().decode("utf-8", "replace") - -deadline = time.time() + 120 -while time.time() < deadline: - if "gui up" in serial_text() or qemu.poll() is not None: - break - time.sleep(0.5) - -if "gui up" not in serial_text(): - print("gui never came up. serial:\n" + serial_text(), file=sys.stderr) - qemu.kill() - sys.exit(1) - -s = socket.socket(socket.AF_UNIX) -s.connect(sock) -f = s.makefile("rw") -f.readline() # greeting - -def cmd(o): - f.write(json.dumps(o) + "\n"); f.flush() - while True: - line = json.loads(f.readline()) - if "return" in line or "error" in line: - return line - -cmd({"execute": "qmp_capabilities"}) -cmd({"execute": "screendump", "arguments": {"filename": ppm}}) -cmd({"execute": "quit"}) -qemu.wait() - -# PPM -> PNG, no dependencies. -data = open(ppm, "rb").read() -magic, dims, _maxval, px = data.split(b"\n", 3) -w, h = map(int, dims.split()) -raw = b"".join(b"\x00" + px[y * w * 3:(y + 1) * w * 3] for y in range(h)) -def chunk(t, b): - return struct.pack(">I", len(b)) + t + b + struct.pack(">I", zlib.crc32(t + b) & 0xFFFFFFFF) -png = (b"\x89PNG\r\n\x1a\n" - + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)) - + chunk(b"IDAT", zlib.compress(raw, 9)) - + chunk(b"IEND", b"")) -open(OUT, "wb").write(png) -print(OUT) diff --git a/crates/loon-lang/tests/boot_image.rs b/crates/loon-lang/tests/boot_image.rs index a706d08..7af3c83 100644 --- a/crates/loon-lang/tests/boot_image.rs +++ b/crates/loon-lang/tests/boot_image.rs @@ -1,6 +1,6 @@ //! The boot image is an ABI between two crates that never link together. //! -//! `loon-kernel` decodes these tags by number. Nothing in the type system +//! fxos (github.com/ecto/fxos, `kernel/src/eir/decode.rs`) decodes these tags by number. Nothing in the type system //! connects the two sides, so reordering an enum here would silently remap //! an operator in every image the kernel runs. These tests are the seam. @@ -10,7 +10,7 @@ use loon_lang::eir::{BinOp, UnOp}; #[test] fn binop_tags_are_pinned() { // Changing any of these means changing `binop()` in - // crates/loon-kernel/src/eir/decode.rs to match. + // fxos `kernel/src/eir/decode.rs` to match. let expected = [ (BinOp::Add, 0), (BinOp::Sub, 1), diff --git a/crates/loon-lang/tests/loon_os.rs b/crates/loon-lang/tests/loon_os.rs deleted file mode 100644 index 55ceb42..0000000 --- a/crates/loon-lang/tests/loon_os.rs +++ /dev/null @@ -1,300 +0,0 @@ -//! Loon OS (os/) integration suite — runs the phase-1/2 OS library and its -//! demos end-to-end on the EIR VM (the reference backend for handler -//! semantics; `loon test`'s tree-walker lacks correct forwarding). -//! -//! These lock in the OS design's load-bearing claims: -//! - handler interposition (kernel <- trace <- sandbox composition) -//! - record/replay determinism (a tape replays bit-identically, kernel-free) -//! - the pure cooperative scheduler (spawn/yield/send/recv, deadlock detection) -//! - sealed deterministic simulation (same seed -> identical world) - -use loon_lang::eir::vm::eval_eir_with_base_dir; -use std::path::{Path, PathBuf}; - -fn os_dir() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("os") -} - -/// Run a Loon source string with `[use ...]` resolved against os/. -fn run(src: &str) -> Vec { - eval_eir_with_base_dir(src, &os_dir()) - .unwrap_or_else(|e| panic!("vm error: {e}")) - .output -} - -/// Run a demo file from os/ and return its printed lines. -fn run_demo(name: &str) -> Vec { - let path = os_dir().join(name); - let src = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {name}: {e}")); - run(&src) -} - -#[test] -fn sandbox_demo_composes_kernel_trace_sandbox_env() { - let out = run_demo("demo-sandbox.oo").join("\n"); - // trace sees the POST-rewrite path (sandbox sits inside the trace) - assert!( - out.contains("[trace] Fs.write-file /tmp/loon-motd"), - "{out}" - ); - assert!(out.contains("[trace] Fs.read-file /tmp/loon-motd"), "{out}"); - // real IO round-tripped through the jail, env fully virtualized - assert!( - out.contains("motd: 'welcome to loon os' (HOME=/home/jailbird)"), - "{out}" - ); -} - -#[test] -fn tape_demo_replays_bit_identically() { - let out = run_demo("demo-tape.oo").join("\n"); - assert!(out.contains("REPLAY IDENTICAL"), "{out}"); -} - -#[test] -fn procs_demo_interleaves_and_quiesces() { - let out = run_demo("demo-procs.oo"); - let joined = out.join("\n"); - // all three processes ran to completion - assert!(joined.contains("ping done"), "{joined}"); - assert!(joined.contains("pong done"), "{joined}"); - assert!(joined.contains("worker done"), "{joined}"); - assert!(joined.contains("world quiescent"), "{joined}"); - // yield actually interleaves: a worker tick lands between ping/pong turns - let tick2 = out - .iter() - .position(|l| l.contains("worker tick 2")) - .unwrap(); - let pong2 = out - .iter() - .position(|l| l.contains("pong got {:n 3")) - .unwrap(); - assert!( - tick2 < pong2, - "worker should interleave with ping-pong: {joined}" - ); -} - -#[test] -fn sim_demo_is_deterministic_per_seed() { - let out = run_demo("demo-sim.oo").join("\n"); - assert!(out.contains("WORLDS IDENTICAL"), "{out}"); - assert!(out.contains("seed 7: different world"), "{out}"); -} - -#[test] -fn kv_demo_survives_lossy_network_deterministically() { - // The phase-2 exit demo: a KV server + 2 clients over a 25%-drop/10%-dup - // network whose dice come from the sim's seeded PRNG. All puts must land - // (retries recover the drops), the same seed must reproduce the identical - // world, and a different seed must converge to the same database. - let out = run_demo("demo-kv.oo").join("\n"); - assert!( - out.contains(r#"final db: {"alpha" 1 "gamma" 3 "beta" 2 "delta" 4}"#), - "{out}" - ); - assert!(out.contains("STORM REPRODUCED EXACTLY"), "{out}"); - assert!(out.contains("SAME converged database"), "{out}"); -} - -#[test] -fn try_recv_is_nonblocking() { - // try-recv returns :none instead of parking — no deadlock when the - // mailbox is empty, {:msg m} when something is waiting. - let out = run(r#" - [use sys] - [use sched] - [fn main [] - [let st [run-procs [fn [] - [let empty [Proc.try-recv]] - [Proc.send [Proc.self] "hi"] - [let full [Proc.try-recv]] - [str empty "/" [get full :msg]]]]] - [println [get [first [get st :done]] :value]]] - "#) - .join("\n"); - assert!(out.contains(":none/hi"), "{out}"); -} - -#[test] -fn scheduler_detects_deadlock() { - let out = run(r#" - [use sys] - [use sched] - [fn stuck [] [Proc.recv]] - [fn main [] - [let st [run-procs [fn [] [do [Proc.spawn stuck] [Proc.recv]]]]] - [IO.println [if [deadlocked? st] "DEADLOCK" "quiet"]]] - "#) - .join("\n"); - assert!(out.contains("DEADLOCK"), "{out}"); -} - -#[test] -fn read_only_sandbox_denies_writes() { - let out = run(r#" - [use sys] - [use kernel] - [use sandbox] - [fn main [] - [let r [kernel [fn [] - [try [read-only [fn [] [Fs.write-file "/tmp/loon-denied" "x"]]] - [fn [msg] [str "caught: " msg]]]]]] - [IO.println r]] - "#) - .join("\n"); - assert!( - out.contains("caught: read-only: denied write to /tmp/loon-denied"), - "{out}" - ); -} - -#[test] -fn agent_demo_contains_untrusted_code() { - let out = run_demo("demo-agent.oo").join("\n"); - // honest work succeeded - assert!( - out.contains("answer file: '42 (compute the answer)'"), - "{out}" - ); - // both hostile ops neutralized: sentinels in the agent's own result... - // (nested strings render quoted now that the EIR VM matches the interp) - assert!( - out.contains(r#":stole "EACCES: /etc/credentials""#), - "{out}" - ); - assert!(out.contains(":exfil-result :denied"), "{out}"); - // ...and the exfil file was never written to the (virtual) filesystem - assert!(out.contains("exfil file: ''"), "{out}"); - // the flight recorder captured every attempt, including the denied ones - assert!( - out.contains(r#"{:op :read :path "/etc/credentials" :got "EACCES"#), - "{out}" - ); - assert!(out.contains(r#"{:op :write :path "/evil/exfil""#), "{out}"); - // whole run is seed-deterministic - assert!(out.contains("AGENT RUN REPRODUCED EXACTLY"), "{out}"); -} - -#[test] -fn gated_denial_is_a_sentinel_not_an_abort() { - // A denied op resumes the agent with a value, so the agent keeps running - // and its later work still completes (no cross-handler abort). - let out = run(r#" - [use sys] - [use kernel] - [use agent] - [fn prog [] - [let a [Fs.read-file "/allowed"]] - [let b [Fs.read-file "/secret"]] - [str "a=" a " b=" b " alive"]] - [fn main [] - [IO.println - [kernel [fn [] - [gated prog [fn [req] [= [get req :path] "/allowed"]]]]]]] - "#) - .join("\n"); - assert!(out.contains("b=EACCES: /secret"), "{out}"); - assert!( - out.contains("alive"), - "denied op must not abort the agent: {out}" - ); -} - -#[test] -fn sandbox_denies_dotdot_traversal() { - // A jailed path containing `..` would resolve above the jail root once the - // real filesystem collapses it; the sandbox rejects it instead. - let out = run(r#" - [use sys] - [use sim] - [use sandbox] - [fn escape [] [Fs.read-file "/../etc/passwd"]] - [fn main [] - [let r [simulate - [fn [] [try [sandboxed escape "/jail"] [fn [m] [str "BLOCKED: " m]]]] - 1 {}]] - [println [get r :result]]] - "#) - .join("\n"); - assert!( - out.contains("BLOCKED: sandbox: path escapes jail: /../etc/passwd"), - "{out}" - ); -} - -#[test] -fn audited_records_clock_sleep() { - // The flight recorder must log Clock.sleep, not let it slip through - // unrecorded while the (virtual) clock still advances. - let out = run(r#" - [use sys] - [use sim] - [use agent] - [fn sleepy [] [do [Clock.sleep 500] [Rand.int 10]]] - [fn main [] - [let w [simulate [fn [] [audited sleepy]] 1 {}]] - [println [str "time=" [get w :time] " audit=" [get [get w :result] :audit]]]] - "#) - .join("\n"); - assert!( - out.contains("time=500"), - "sleep must advance the clock: {out}" - ); - assert!( - out.contains("{:op :sleep :ms 500}"), - "sleep must be audited: {out}" - ); -} - -#[test] -fn replay_detects_desync_loudly() { - // A replay that reads more than the tape recorded has diverged; it must - // fail loudly, not silently resume with Unit. - let out = run(r#" - [use sys] - [use tape] - [fn two-reads [] [str [Fs.read-file "/a"] "-" [Fs.read-file "/b"]]] - [fn main [] - [println [try [replay two-reads #["only-one"]] - [fn [m] [str "CAUGHT: " m]]]]] - "#) - .join("\n"); - assert!( - out.contains("CAUGHT: replay desync: tape exhausted"), - "{out}" - ); -} - -#[test] -fn chaos_demo_reproduces_faults_and_supervisor_recovers() { - let out = run_demo("demo-chaos.oo").join("\n"); - // seed 3 injects faults; the supervisor absorbs them within its budget - assert!(out.contains("[supervisor] child failed: chaos:"), "{out}"); - assert!(out.contains("outcome: ok (v1)"), "{out}"); - assert!(out.contains("result file: 'processed:v1'"), "{out}"); - // the whole storm — faults, restarts, final world — is seed-deterministic - assert!(out.contains("CHAOS REPRODUCED EXACTLY"), "{out}"); -} - -#[test] -fn replay_needs_no_kernel_and_no_real_world() { - // Record against a sealed simulation, then replay the tape with NOTHING - // underneath: proof that a tape fully captures a program's world. - let out = run(r#" - [use sys] - [use sim] - [use tape] - [fn prog [] - [str [Fs.read-file "/cfg"] "-" [Clock.millis] "-" [Rand.int 100]]] - [fn main [] - [let rec [get [simulate [fn [] [record prog]] 42 {:fs {"/cfg" "v1"}}] :result]] - [let ghost [replay prog [get rec :tape]]] - [IO.println [if [= [get rec :result] ghost] "GHOST MATCHES" "diverged"]]] - "#) - .join("\n"); - assert!(out.contains("GHOST MATCHES"), "{out}"); -} diff --git a/crates/loon-lang/tests/unikernel_boot.rs b/crates/loon-lang/tests/unikernel_boot.rs deleted file mode 100644 index c6870c4..0000000 --- a/crates/loon-lang/tests/unikernel_boot.rs +++ /dev/null @@ -1,61 +0,0 @@ -//! Boot the unikernel under QEMU and check it agrees with the host. -//! -//! This is the phase-3 exit criterion in test form: the same Loon program, -//! compiled once, must produce identical output whether the effects land on -//! a host syscall or on a UART. Skipped (not failed) when the bare-metal -//! toolchain is absent, since most contributors will not have it. - -use std::path::PathBuf; -use std::process::Command; - -fn workspace_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(|p| p.parent()) - .expect("workspace root") - .to_path_buf() -} - -fn have(cmd: &str, args: &[&str]) -> bool { - Command::new(cmd) - .args(args) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) -} - -#[test] -fn unikernel_boots_and_matches_the_host() { - let root = workspace_root(); - let kernel_dir = root.join("crates/loon-kernel"); - - if !have("qemu-system-riscv64", &["-version"]) { - eprintln!("skipping: qemu-system-riscv64 not installed"); - return; - } - let targets = Command::new("rustup") - .args(["target", "list", "--installed"]) - .output(); - let has_target = targets - .map(|o| String::from_utf8_lossy(&o.stdout).contains("riscv64gc-unknown-none-elf")) - .unwrap_or(false); - if !has_target { - eprintln!("skipping: rustup target riscv64gc-unknown-none-elf not installed"); - return; - } - - let out = Command::new("make") - .arg("check") - .current_dir(&kernel_dir) - .output() - .expect("running `make check` in crates/loon-kernel"); - - let stdout = String::from_utf8_lossy(&out.stdout); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - out.status.success() && stdout.contains("identical output"), - "unikernel boot diverged from the host.\n--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}" - ); -} diff --git a/docs/plans/2026-07-01-loon-os.md b/docs/plans/2026-07-01-loon-os.md index e917a1c..9c3fa22 100644 --- a/docs/plans/2026-07-01-loon-os.md +++ b/docs/plans/2026-07-01-loon-os.md @@ -1,519 +1,20 @@ -# Loon OS — Syscalls as Effects +# Loon OS → fxos -> **Status: Draft** — design exploration, no code yet. -> -> Working codename: **tarn** (a small mountain lake — the thing a loon actually lives on). +The OS moved to its own repository on 2026-08-18: -## One-liner +**https://github.com/ecto/fxos** -In a conventional OS, a syscall is an opaque trap into privileged code. In Loon OS, a -syscall is an effect, and the kernel is just the outermost handler. Every classic OS -feature — sandboxing, containers, strace, fork, checkpointing, record/replay, fault -injection — collapses into one mechanism: handler interposition. +Everything that lived here — the `os/` library (Sys effects, sandbox, trace, +record/replay, scheduler, simulation, chaos, agent containment), the RISC-V +unikernel (`crates/loon-kernel`, now `fxk`), and the design document — is +there, with history. -## Core model +What stays in Loon is the seam between the two: `loon image` and the boot +image format (`crates/loon-lang/src/eir/image.rs`), pinned by +`crates/loon-lang/tests/boot_image.rs`. That format is the ABI fxos consumes. -A process is a computation. Its "kernel" is whatever handler stack it runs under. - -``` -[effect Fs - [open [String OpenMode] Fd] - [read [Fd Int] Bytes] - [write [Fd Bytes] Int] - [close [Fd] Unit]] - -; the kernel is not special. it's just the last handler standing. -[fn kernel [prog] - [handle [prog] - [Fs.open path mode] [resume [disk.open path mode]] - [Fs.read fd n] [resume [disk.read fd n]] - [Fs.write fd buf] [resume [disk.write fd buf]] - [Fs.close fd] [resume [disk.close fd]]]] -``` - -Consequences, each one a headline OS feature for free: - -| Mechanism | Classic OS equivalent | -|-----------|----------------------| -| Wrapping handler that logs ops | strace / auditd / observability | -| Handler that rewrites paths | chroot / mount namespaces | -| Handler that denies ops | seccomp / pledge | -| Handler stack composition | nested containers | -| Unresumed continuation | blocked process | -| List of continuations | process table / scheduler | -| Multishot resume | fork / speculative execution | -| Serialized continuation | checkpoint / suspend-to-disk / migration | -| Handler with a recorded log | deterministic replay / time-travel debugging | -| Handler on another machine | remote syscall forwarding (Plan 9, but typed) | - -## The Sys effect family - -The OS interface is a small set of effect declarations. This *is* the ABI. Grouping -matters more than completeness at this stage — each group is a separately grantable -capability. - -``` -[effect Fs - [open [String OpenMode] Fd] - [read [Fd Int] Bytes] - [write [Fd Bytes] Int] - [close [Fd] Unit] - [stat [String] FileInfo] - [list [String] [Vec String]] - ; batched ops are first-class, not an afterthought (see Performance) - [submit [[Vec FsReq]] [Vec FsResp]]] - -[effect Net - [connect [String Int] Sock] - [listen [Int] Listener] - [accept [Listener] Sock] - [send [Sock Bytes] Int] ; Bytes moves — zero-copy by ownership - [recv [Sock Int] Bytes]] - -[effect Clock - [now [] Instant] - [sleep [Duration] Unit]] - -[effect Rand - [bytes [Int] Bytes]] - -[effect Proc - [spawn [Thunk] Pid] - [send [Pid Msg] Unit] ; Msg moves — ownership transfer IS the IPC - [recv [] Msg] - [wait [Pid] ExitStatus] - [signal [Pid Signal] Unit]] - -[effect Env - [get [String] [Option String]] - [args [] [Vec String]]] -``` - -Design rules: - -- **Every source of nondeterminism is an effect.** Clock, Rand, scheduling order, - IO results. This is what makes whole-system record/replay possible. Nothing is - ambient. -- **Buffers move.** `Net.send` and `Proc.send` take ownership. No shared memory in - the base model, no copies, no TOCTOU. Loon's ownership system does the work MMUs - do elsewhere. -- **No errno.** Fallible ops use `Result`/`Option` ADTs or the `Fail` effect, - handled like everything else. -- **Effect rows are the permission manifest.** A function whose inferred row lacks - `Net` cannot touch the network. Statically. With invisible types, nobody writes - the annotation — the checker infers the security boundary. - -## Handlers as OS features - -Everything below is userland code against the effects above. This is the standard -library of the OS. - -### Sandbox - -``` -[fn sandboxed [prog root] - [handle [prog] - [Fs.open path mode] [resume [Fs.open [path.join root path] mode]] - [Net.connect _ _] [fail :net-denied] - [Proc.spawn _] [fail :spawn-denied]]] -``` - -### Trace (strace as a library) - -``` -[fn traced [prog] - [handle [prog] - [Fs.open path mode] - [do [IO.println "open({path}, {mode})"] - [resume [Fs.open path mode]]]]] -``` - -### Record / replay - -``` -; record: run under the real kernel, log every effect result -[fn record [prog] - [let log [tape.new]] - [handle [prog] - [Clock.now] [do [let t [Clock.now]] [tape.push log t] [resume t]] - [Rand.bytes n] [do [let b [Rand.bytes n]] [tape.push log b] [resume b]] - [Fs.read fd n] [do [let b [Fs.read fd n]] [tape.push log b] [resume b]] - [return x] {:result x :tape [tape.freeze log]}]] - -; replay: same program, results come from the tape. bit-identical execution. -[fn replay [prog tape] - [handle [prog] - [Clock.now] [resume [tape.next tape]] - [Rand.bytes _] [resume [tape.next tape]] - [Fs.read _ _] [resume [tape.next tape]]]] -``` - -A production crash ships home as a tape. You replay it in the debugger, stepping -backward and forward, deterministically, forever. - -### Simulation (deterministic testing of distributed systems) - -Run N "machines" as processes under a simulated `Net`/`Clock`. The handler owns -virtual time and the packet queue, so it can inject partitions, reorderings, and -clock skew — all seeded, all replayable. - -``` -[fn simulate [machines seed] - [let world [sim.new seed]] - [handle [sim.run-all machines] - [Clock.now] [resume [sim.virtual-now world]] - [Clock.sleep d] [resume [sim.advance world d]] - [Net.send sock buf] [resume [sim.enqueue world sock buf]] ; may drop, delay, dup - [Net.recv sock n] [resume [sim.deliver world sock n]]]] -``` - -FoundationDB spent years building this as bespoke infrastructure. Here it's a -handler. - -## Processes and scheduling - -A blocked syscall is an unresumed continuation. The scheduler is a queue of them. - -``` -; cooperative round-robin: every effect op is a yield point -[fn scheduler [ready] - [match [queue.pop ready] - None :all-done - [Some k] - [handle [k.resume] - [Proc.spawn thunk] - [do [queue.push ready [reify thunk]] - [resume [pid-of thunk]]] - [Proc.recv] - ; park this continuation on the mailbox; run someone else - [do [mailbox.park [current-pid] resume] - [scheduler ready]] - [return _] [scheduler ready]]]] -``` - -- **Phase 1 is cooperative.** Effect ops are the yield points; pure spins can't - block the world only once preemption exists (phase 3+, timer-driven). -- **IPC is ownership transfer.** `[Proc.send pid msg]` moves `msg`. The receiver - gets the same buffer. Pipes are a stream of moves. -- **fork is multishot resume.** Resuming a continuation twice is `fork`. The same - primitive gives speculative execution (try both branches under a sim handler, - commit the winner) and checkpointing (hold the continuation, resume later). -- **Migration is serialization.** A serialized continuation sent to another machine - and resumed there is live process migration. Single-level store / suspend-to-disk - stop being heroic kernel features and become `[serialize k]`. - -## Capability security - -The security model in three lines: - -1. Possession of an effect operation in your row = permission to *request* it. -2. The handler above you decides what the request *means*. -3. There is no ambient authority — no globals, no `/proc`, no default kernel. - -Attenuation is wrapping: grant a child read-only FS by handling `Fs.write` with -`[fail :denied]` and forwarding the rest. Revocation is a handler flipping a flag. -Audit is a tracing wrapper. All of object-capability theory, but the "objects" are -effect rows the type checker already infers. - -> **Shipped: static grant enforcement (2026-07-02).** The checker now enforces -> pkg.oo `:grant` lists (E0403): a manifest dependency whose *inferred* effect -> row uses an ambient-authority effect (IO/Net/Process/Env/Async) outside its -> grant fails `loon check` — a dep declared `{:grant #["Net"]}` that also does -> `IO.read-file` is a capability violation naming the effect and the offending -> function. No grant = declared pure (default-deny). User-declared effects and -> `Fail` are exempt: they need a caller-supplied handler to mean anything, so -> the caller already controls them. Tests: `crates/loon-lang/tests/grants.rs`. - -## Performance - -The honest hard problem: a `read` in a hot loop cannot cost a handler-stack search -plus continuation capture. Three mitigations, designed in from day one: - -1. **Evidence passing.** Resolve which handler serves which effect once, at - `handle` entry; ops become indexed calls, not searches. -2. **One-shot fast path.** Most syscalls resume exactly once. Detect this - (statically where possible) and don't materialize the continuation — run the - handler clause on the same stack, like a function call. Multishot pays full - price only when actually used (fork is rare; read is not). -3. **Batching as ABI.** `Fs.submit` takes a vector of requests and returns a - vector of responses — io_uring-shaped. One handler round-trip amortized over - many ops. This is why batch ops live in the effect signatures, not a bolt-on. - -End state: in a static build where the handler is known (unikernel), an effect op -compiles to a direct call. The syscall boundary costs a function call, because the -type system already proved who handles what. - -### Measured (2026-07-02, phase-2 session) - -`cargo run --release -p loon-lang --example bench_effects` (200k-iteration -`recur` loops on the EIR VM; the VM has no implicit TCO, so `recur` keeps the -stack flat and the numbers isolate dispatch cost): - -| Scenario | ns/iter | vs plain call | -|----------|--------:|--------------:| -| plain function call | ~152 | 1.0x | -| effect op, 1 handler (syscall shape) | 474 | **3.1x** | -| effect op, 2 handlers deep | 448 | 3.0x | -| effect op forwarded through a wrapper | 918 | 6.1x | - -Two findings behind these numbers: - -- **Tail-resume peephole (the first slice of the one-shot fast path).** A - handler clause ending in `[resume ...]` now seals as `End::TailInvoke`, so - the VM splices the continuation without pushing the handler frame as a fresh - prompt. Before this, every perform/resume cycle leaked a frame, the captured - segment grew per iteration, and effect loops were **O(N²) time / O(N) - memory** — the 10GB blowups the first `os/` demos hit. Now O(1) stack - (pinned by `vm_tail_resume_runs_in_constant_stack`). -- **Handler-stack depth is free; interposition layers are linear.** Dispatch - scans top-down (2 stacked handlers cost the same as 1); each forwarding - wrapper adds one extra capture/resume cycle (~2x per layer) — the cost model - the trace/sandbox composition should be designed against. - -Remaining 3x gap to a plain call = one `Obj::Continuation` heap alloc + regs -clone per perform. The full one-shot fast path (don't materialize the -continuation at all when the clause is statically tail-resume) and evidence -passing would close most of it; both are unlocked now that tail-resume shape -is detected at lowering time. Note: `End::TailInvoke` is correct on the VM but -NOT on the native backend (silently returns unit) — the peephole is scoped to -handler clauses, which never compile natively; fix native before widening it. - -## Shipped: phases 1 + 2, in `os/` (2026-07-01) - -The library exists and runs on the EIR VM today — pure Loon, no runtime -changes beyond the handler-forwarding fix below: - -| File | What it is | -|------|-----------| -| `os/sys.oo` | The `Sys` effect family: `Fs`, `Clock` (incl. `sleep`), `Rand`, `Env`, `Proc` | -| `os/kernel.oo` | The kernel handler — translates `Sys` effects to real VM builtins (real file IO, real clock, real env) | -| `os/sandbox.oo` | `sandboxed` (chroot), `read-only` (denies writes via `Fail`), `with-env` (env isolation) | -| `os/trace.oo` | strace as a wrapping handler | -| `os/tape.oo` | `record` / `replay` — tape a live run, replay it bit-identically with no kernel | -| `os/sched.oo` | Pure cooperative scheduler: `spawn`/`yield`/`send`/`recv`/`self`, mailboxes, deadlock detection; the process table is a queue of parked continuations, state threaded functionally | -| `os/sim.oo` | Sealed deterministic world: virtual clock, seeded minstd PRNG, in-memory FS | -| `os/chaos.oo` | `chaotic` (seeded fault injection) + `supervise` (restart-on-Fail — a supervisor is just a Fail handler with a policy) | -| `os/net.oo` | The network is a handler: `lossy` (seeded drops + duplication, delivery into Proc mailboxes) and `reliable`. Under `simulate`, packet loss itself is deterministic (2026-07-02) | -| `os/agent.oo` | `gated` (reference monitor: policy per request, denial resumes with an EACCES sentinel) + `audited` (flight recorder: every Sys op and its result, as data) | -| `os/demo-*.oo` | Runnable demos of each (`loon run os/demo-sim.oo` etc.) | - -Integration suite: `crates/loon-lang/tests/loon_os.rs` (7 tests on the EIR -VM). Highlights that all pass today: - -- `demo-sandbox`: real file IO through `kernel <- trace <- sandbox <- with-env`. -- `demo-tape`: record real IO + wall clock, replay bit-identically, no kernel. -- `demo-procs`: ping-pong message passing interleaved with a yielding worker. -- `demo-sim`: a two-node world with random jitter under `simulate` — same seed - twice → identical worlds (compared with `=` on the whole world state, - virtual FS included); different seed → different world. The scheduler runs - *inside* the simulation handler: handler composition across subsystems. -- record *under* `simulate`, then replay over *nothing* — a tape fully - captures a program's world. -- `demo-chaos`: deterministic chaos engineering — a supervised worker under - 30% seeded fault injection inside a sealed sim. Seed 3 injects four IO - faults; the supervisor restarts through all of them; running seed 3 again - reproduces the identical storm. A failing chaos run is a repro case, not - an anecdote. - -Building the chaos/supervision layer exposed a second compiler bug (also -fixed): `lower_try` compiled the on-fail expression in a fresh function with -NO free-variable capture, so an on-fail closure referencing enclosing locals -(exactly the supervision retry pattern) silently mis-resolved them — -pre-existing, previously silent corruption. `try` now desugars through -`lower_handle` and inherits the real capture machinery. Pinned in -`backend_parity.rs` (the legacy interp truncates the same program silently). - -- `demo-agent` (the 2026 use case): an untrusted agent that does honest work - (read task, write answer) then tries to steal `/etc/credentials` and - exfiltrate to `/evil/exfil`, run under `sim <- gated <- audited <- jail <- - with-env`. The `/workspace/*` policy neutralizes both hostile ops (agent - gets `EACCES`/`:denied`, exfil file never written), the flight recorder - captures all four attempts with verdicts, and the whole run replays from a - seed. The agent's permission grant *is* the handler stack. - -Notes from building it: `loon test` runs on the legacy tree-walker (wrong -handler semantics), so OS tests are Rust integration tests via -`eval_eir_with_base_dir`; `entries` returns tuples that `get`/`nth` cannot -index (use `vals`/`keys`); there is no `mod` or `dissoc` builtin (`imod` in -sys.oo, `:none` sentinels in sched.oo). - -### Review hardening (2026-07-01) - -A multi-agent adversarial review of the branch surfaced eight confirmed -issues, all fixed: - -- **`lower_try` hygiene + evaluation order (4 findings, one root cause).** The - desugar injected the hardcoded names `__fail_msg` and `resume` into scope - where the user's on-fail expression was compiled, so an on-fail closure over - an enclosing `__fail_msg`/`resume` bound to the injected name instead; the - on-fail was also lowered lazily (side effects lost on the success path) and - spliced into head position. Fix: lower on-fail **eagerly in the enclosing - scope**, bind it to a gensym name (a `fresh_name` containing a space, which - the tokenizer can never produce, so no user collision), and apply that value - in the clause. The handler now takes the on-fail from `args[1]` to match the - tree-walker for >2-arg forms. -- **`os/sandbox.oo` `..` traversal.** `sandboxed` re-rooted by string concat, - so `/../etc/passwd` escaped the jail once the real FS collapsed the `..`. Now - rejects any path containing `..` (`escapes?`); deny is the safe default (a - demo jail can't win the symlink-normalization arms race). -- **`os/agent.oo` audit gap.** `audited` had no `Clock.sleep` clause, so a - sleeping agent advanced the (virtual) clock unrecorded. Added. -- **`os/tape.oo` silent replay desync.** `replay` read `[first t]` off an - exhausted tape as Unit, silently corrupting a divergent replay. Now fails - loudly (`replay-next` → `replay desync: tape exhausted`). - -Each is pinned: the `lower_try` cases as backend-parity corpus entries (both -backends must now agree), the os/ cases in `loon_os.rs`. - -## Validated earlier (2026-07-01) - -`samples/os-handlers.oo` runs the core mechanism on today's EIR VM: a program -under `kernel <- trace <- sandbox`, with the sandbox rewriting paths, the trace -logging ops, and the kernel serving them — effects forwarding outward through -the composed stack. - -Getting there required fixing the EIR VM's handler dispatch (`Op::Perform` in -`crates/loon-lang/src/eir/vm.rs`): a handler clause re-performing the handled -effect was intercepted by its own handler and looped forever, instead of -forwarding to the next handler out. The fix implements deep-handler semantics — -at dispatch, every handler at or above the found prompt moves off the dynamic -stack into the continuation snapshot (relative depths), both resume paths -re-establish them, and `PopHandler` is depth-matched so the abort path stays -sound. Pinned regressions live in `crates/loon-lang/tests/backend_parity.rs` -(the legacy tree-walker gets both new cases wrong; the EIR VM is the reference). -Interposition — the substrate every handler in this document is built on — -works today. - -## Roadmap - -### Phase 1 — Hosted personality (the WASI move) - -New crate `loon-os`: the `Sys` effect declarations plus a handler zoo — -`kernel` (translates to real Linux/macOS syscalls), `sandbox`, `trace`, `record`, -`replay`, `simulate`. Runs on the existing interpreter. This phase is weeks, not -months, and delivers ~90% of the value: agent sandboxing, sim testing, hermetic -builds need zero bare metal. - -Exit demo: run an untrusted Loon program sandboxed + traced + recorded, then -replay the exact run in a debugger. - -### Phase 2 — Processes - -Cooperative scheduler, `Proc` effects, mailboxes, ownership-transfer IPC, -supervision trees (a supervisor is a handler that catches `Fail` from children and -applies a restart policy — the Erlang move, but typed). - -Exit demo: 50-node simulated distributed KV store, seeded partition injection, -replayable failure. - -> **Shipped a first cut (2026-07-02):** `os/demo-kv.oo` — KV server + clients -> over a 25%-drop/10%-dup `lossy` network under `simulate`; seq-numbered -> retries (`Proc.try-recv` polling) recover every drop; the packet storm is -> bit-reproducible per seed and different seeds converge to the same -> database. Remaining for the full exit demo: virtual-time delivery delays -> (reordering), partitions as a handler, and node counts at 50. - -### Phase 3 — Unikernel target - -`no_std` runtime, static handler resolution, boot under QEMU/Firecracker. Drivers -are handlers over `Mmio`/`Irq` effects — testable in pure simulation. Timer -interrupt gives real preemption (an interrupt is the runtime injecting a -`Yield` effect at a safe point). - -Exit demo: a Loon web service booting in <10ms as a VM, syscall cost = function -call cost. - -> **It boots (2026-08-18).** `crates/loon-kernel` is a RISC-V unikernel whose -> kernel is a Loon program: `make -C crates/loon-kernel run` boots -> `boot/init.oo` under QEMU `virt`, and `make check` diffs that run against -> `loon run` on the same source — byte-identical, which is the property that -> matters. There is no userspace and no syscall boundary; `Console.write` -> falls through the Loon handler stack to a 16550 driver instead of to Linux. -> -> **Shape.** The frontend stays hosted. `loon image` serializes a lowered -> `Module` (`eir/image.rs`), `build.rs` invokes it, and the kernel embeds and -> interprets the result — so no parser, checker or lowering is in the -> bare-metal build graph. The kernel interpreter mirrors the host VM's -> structure deliberately (same frame stack, same prompt-depth handler stack, -> same continuation capture on `perform`); `init.oo` exercises handler -> forwarding, abort-without-resume, and non-tail resume on hardware, all -> matching the host. -> -> **Not yet.** Preemption (no timer interrupt — cooperative only, so a pure -> loop owns the machine), SMP, static handler resolution, and most of the -> builtin set (missing ones raise a loud error naming the builtin, never a -> silent `()`). -> -> **Perf, measured (2026-08-18).** ~500 ns per interpreted op under emulation, -> against ~7 ns for a minimal native dispatch loop on the same emulator. Two -> findings, one of which corrects the note above as first written: -> -> - `opt-level` dominates. The crate was scaffolded `opt-level = "z"`, which -> suppresses the inlining a dispatch loop lives on; moving to `3` was worth -> **3.3x**, more than everything else combined. -> - **Allocation was never the bottleneck.** Pooling register files and -> keeping operands off the heap cut the benchmark from 89,858 allocations -> to 47 and bought no measurable time. The earlier claim that allocation -> was the cost was wrong. The change is kept for near-constant-memory -> interpretation — valuable in a kernel with no OOM killer — not for speed, -> and a slab allocator is not worth building because nothing is left to -> allocate. -> -> "Syscall cost = function call cost" remains undemonstrated. Note that -> `loop.oo` (tail recursion, no frame pushes) and `bench.oo` (call-heavy) cost -> about the same per op, so the remaining gap is per-op dispatch overhead -> rather than calling. - -### Phase 4 — Bare metal (optional grind) - -Tiny effect-router microkernel on real hardware. Only worth it after phases 1–3 -prove the model; the legend is already built by then. - -## Killer use cases - -- **Agent sandboxing.** An LLM agent is a process whose effect row is its - permission grant. Every action traced, every run replayable, dangerous ops - handled by ask-a-human-then-resume. The multishot backtracking the agent - framework already does is the same mechanism one level up. -- **Deterministic simulation testing** of whole distributed systems, seeded and - replayable, as an OS primitive instead of bespoke infrastructure. -- **Hermetic builds.** A build step's effect tape is its cache key. The OS knows - exactly what the compiler read; incremental correctness stops being a heuristic. -- **Time-travel production debugging.** Crashes ship as tapes. -- **Plugin systems.** Untrusted extensions in-process, isolation proved by the - checker instead of hardware. -- **Embedded.** Statically-dispatched effects, drivers simulated in tests, no MMU - required for safety — ownership is the MMU. - -## Open questions - -- **Continuation serialization.** Checkpoint/migration needs continuations to be - serializable — closures over Fds and Socks need resource re-binding on resume - (a `Restore` effect the resuming host handles?). -- **Preemption points.** Pure non-allocating loops have no effect ops to yield at. - Options: allocation-point safepoints, backward-branch checks, or accept - cooperative until phase 3 interrupts. -- **Effect row granularity.** Is `Fs` one capability or is `Fs.read` separable - from `Fs.write`? Per-op rows are more precise but noisier in inferred - signatures. Leaning: per-op, since types are invisible anyway. -- **Multishot + resources.** Resuming twice duplicates ownership of moved-in - resources. Fork semantics for owned Fds need a rule (Copy-on-resume? Affine - continuations by default, multishot requires `Dup`able captures?). This is the - deepest theory question in the design. -- **The tape format.** Record/replay across versions needs a stable effect-op - serialization — effectively the ABI stability question in disguise. -- **Abort through an intermediate handler clause — RESOLVED (2026-07-01, phase-2 - session).** When a handler clause performs `Fail`, deep-handler dispatch has - already moved every handler at/above that clause's prompt — including any - `try` installed *inside* the handled body — into the continuation snapshot. - So a `Fail` raised in, say, a `gated` clause is not caught by a `try` the - agent wrapped around the denied call; that `try` is part of the suspended - computation. Resuming into it would be unsound (the body hasn't resumed), so - we did NOT do that. Instead we fixed the actual footgun: the EIR VM used to - let an unhandled effect fall through to **silent unit**, collapsing the - result to `()`. It now raises a loud `UnhandledEffect` error (matching the - tree-walking interpreter), so an uncaught abort fails with a message and a - span instead of vanishing. `os/agent.oo` still resumes with an `EACCES` - sentinel — the better monitor semantics — but a genuine abort is now - diagnosable. (An outer `try`, i.e. one enclosing the whole `handle`, still - catches a clause's `Fail` as before; only the frozen-inner-`try` case errors.) +Known debt on the Loon side, surfaced by the split: the OS library is +dynamically correct but does not pass the static checker (~110 errors: +heterogeneous maps used as records, effect ops declared in a `use`d module +not visible to the checker, a few infinite types). fxos runs it with +`loon run --unchecked` until that is fixed here. diff --git a/os/agent.oo b/os/agent.oo deleted file mode 100644 index 96d7c4c..0000000 --- a/os/agent.oo +++ /dev/null @@ -1,76 +0,0 @@ -; Loon OS — agent containment. -; -; An untrusted agent (an LLM tool-runner, a plugin, anyone's code) is just a -; thunk, and its permission grant is the handler stack you run it under: -; -; [gated ...] reference monitor: consult a policy per request; a denied -; op never executes — the agent gets an EACCES sentinel back -; instead (a capability returning "no", not a trap) -; [audited ...] flight recorder: every Sys op the agent performs and the -; value it got back, as data -; -; Compose with sandbox.oo (jail, env isolation), sim.oo (sealed world), and -; tape.oo (record/replay): an agent that can only touch what its row + policy -; allow, whose every action is auditable, and whose whole run replays exactly. -; -; Denials resume with a sentinel rather than aborting with Fail. This is a -; deliberate capability-design choice, not a workaround: the agent sees an -; error value (like a real EACCES) and keeps running, so one denied op does -; not tear down the whole computation, and it composes cleanly under the -; state-threading handlers (audited, simulate). (An abort *would* now surface -; loudly — an uncaught Fail raised in a clause is a hard VM error rather than -; a silent unit — but resume-with-sentinel is the better monitor semantics.) -[use sys] - -; A denied read yields this sentinel; a denied write yields :denied. -[pub fn eacces [path] [str "EACCES: " path]] - -; Reference monitor. `policy` is a fn of a request map -> Bool. Each Fs op -; consults it; on allow the op forwards outward (to the jail/kernel/sim), on -; deny it resumes with a sentinel and the real op never happens. -[pub - fn - gated - [thunk policy] - [handle - [thunk] - [Fs.read-file p] - [if [policy {:op :read :path p}] - [resume [Fs.read-file p]] - [resume [eacces p]]] - [Fs.write-file p c] - [if [policy {:op :write :path p}] - [resume [Fs.write-file p c]] - [resume :denied]]]] - -; Flight recorder: forward every Sys op outward and record what the agent -; asked for and what it got back. Returns {:result r :audit #[{..}]}. Sitting -; INSIDE gated (closer to the agent), it sees every attempt — allowed or -; denied — and the returned value reveals the verdict (real bytes vs sentinel). -[pub - fn - audited - [thunk] - [[handle - [thunk] - [return x] - [fn [log] {:result x :audit log}] - [Fs.read-file p] - [fn [log] - [let v [Fs.read-file p]] - [[resume v] [conj log {:op :read :path p :got v}]]] - [Fs.write-file p c] - [fn [log] - [let v [Fs.write-file p c]] - [[resume v] [conj log {:op :write :path p :bytes [len c] :got v}]]] - [Clock.millis] - [fn [log] [[resume [Clock.millis]] [conj log {:op :clock}]]] - [Clock.now] - [fn [log] [[resume [Clock.now]] [conj log {:op :clock}]]] - [Clock.sleep ms] - [fn [log] [[resume [Clock.sleep ms]] [conj log {:op :sleep :ms ms}]]] - [Rand.int n] - [fn [log] [[resume [Rand.int n]] [conj log {:op :rand}]]] - [Env.get k] - [fn [log] [[resume [Env.get k]] [conj log {:op :env :key k}]]]] - #[]]] diff --git a/os/chaos.oo b/os/chaos.oo deleted file mode 100644 index 93b9b98..0000000 --- a/os/chaos.oo +++ /dev/null @@ -1,39 +0,0 @@ -; Loon OS — chaos engineering as a handler. -; -; A fault-injecting wrapper: each Fs operation fails with probability -; pct/100. Randomness comes from the Rand effect BELOW this handler, so -; under sim.oo the chaos itself is seeded and deterministic: the same seed -; produces the same faults in the same order, forever. Reproducible chaos. -[use sys] - -[pub - fn - chaotic - [thunk pct] - [handle - [thunk] - [Fs.read-file p] - [if [< [Rand.int 100] pct] - [Fail.fail [str "chaos: read error on " p]] - [resume [Fs.read-file p]]] - [Fs.write-file p c] - [if [< [Rand.int 100] pct] - [Fail.fail [str "chaos: write error on " p]] - [resume [Fs.write-file p c]]]]] - -; Supervision: run child-fn, restarting on Fail up to n times. A supervisor -; is nothing but a Fail handler with a restart policy — wrap any thunk -; (including a process body handed to Proc.spawn). -[pub - fn - supervise - [child-fn n] - [try - [child-fn] - [fn [msg] - [if [> n 0] - [do - [IO.println - [str " [supervisor] child failed: " msg " — restarting"]] - [supervise child-fn [- n 1]]] - [str "gave up: " msg]]]]] diff --git a/os/demo-agent.oo b/os/demo-agent.oo deleted file mode 100644 index f9a6acb..0000000 --- a/os/demo-agent.oo +++ /dev/null @@ -1,56 +0,0 @@ -; Demo: agent containment — the 2026 use case. -; -; An untrusted "agent" does legitimate work (read its task, write its -; answer) and then tries to steal credentials and phone home to a path -; outside its workspace. It runs under: -; -; sim <- gated <- audited <- jail <- with-env <- agent -; -; The policy allows only /workspace/*. Denied ops never execute — the agent -; gets an EACCES sentinel instead. The audit log (a flight recorder inside -; the gate) captures every attempt and what it returned, so a denied read -; shows the sentinel, not stolen bytes. And because the whole run sits on a -; sealed sim, the entire agent behavior replays exactly from a seed. -; -; loon run os/demo-agent.oo -[use sys] -[use sim] -[use sandbox] -[use agent] - -; policy: the agent may only touch its own workspace -[fn workspace-only [req] [starts-with? [get req :path ""] "/workspace/"]] - -; the untrusted agent: two honest actions, then two hostile ones -[fn shady-agent [] - [let task [Fs.read-file "/workspace/task"]] - [Fs.write-file "/workspace/answer" [str "42 (" task ")"]] - ; hostile: read credentials and exfiltrate them outside the workspace - [let creds [Fs.read-file "/etc/credentials"]] - [let exfil [Fs.write-file "/evil/exfil" creds]] - {:answer "42" :stole creds :exfil-result exfil}] - -[fn contained [seed] - [simulate - [fn [] - [gated - [fn [] [audited [fn [] [with-env shady-agent {"OPENAI_API_KEY" ""}]]]] - workspace-only]] - seed - {:fs {"/workspace/task" "compute the answer" "/etc/credentials" "hunter2"}}]] - -[fn main [] - [let w [contained 11]] - [let run [get w :result]] - [IO.println "agent finished. what it got away with:"] - [IO.println [str " " [get run :result]]] - [IO.println - [str "answer file: '" [get [get w :fs] "/workspace/answer" ""] "'"]] - [IO.println - [str "exfil file: '" [get [get w :fs] "/evil/exfil" ""] "'"]] - [IO.println "audit log (every attempt + what it returned):"] - [each [get run :audit] [fn [e] [IO.println [str " " e]]]] - [IO.println - [if [= w [contained 11]] - "rerun, same seed: AGENT RUN REPRODUCED EXACTLY" - "rerun diverged (bug!)"]]] diff --git a/os/demo-chaos.oo b/os/demo-chaos.oo deleted file mode 100644 index b4b869b..0000000 --- a/os/demo-chaos.oo +++ /dev/null @@ -1,36 +0,0 @@ -; Demo: deterministic chaos engineering. -; -; A worker reads its config and writes a result, under a fault-injecting -; chaos handler (30% IO failure), supervised with restarts, inside a sealed -; simulation. The chaos draws its randomness from the sim's seeded PRNG, so -; the same seed produces the exact same faults — a failing chaos run is -; perfectly reproducible. -; -; loon run os/demo-chaos.oo -[use sys] -[use sim] -[use chaos] - -[fn worker [] - [let cfg [Fs.read-file "/config"]] - [Fs.write-file "/result" [str "processed:" cfg]] - [str "ok (" cfg ")"]] - -[fn run-world [seed] - [simulate - [fn [] [supervise [fn [] [chaotic worker 30]] 5]] - seed - {:fs {"/config" "v1"}}]] - -[fn main [] - [IO.println "chaos run, seed 3 (a stormy one):"] - [let w1 [run-world 3]] - [IO.println [str " outcome: " [get w1 :result]]] - [IO.println [str " result file: '" [get [get w1 :fs] "/result" ""] "'"]] - [IO.println "same seed again (same storm, same restarts):"] - [let w2 [run-world 3]] - [IO.println - [if [= w1 w2] " CHAOS REPRODUCED EXACTLY" " chaos diverged (bug!)"]] - [IO.println "seed 1 (calm weather):"] - [let w3 [run-world 1]] - [IO.println [str " outcome: " [get w3 :result]]]] diff --git a/os/demo-kv.oo b/os/demo-kv.oo deleted file mode 100644 index d15a27e..0000000 --- a/os/demo-kv.oo +++ /dev/null @@ -1,111 +0,0 @@ -; Demo: a distributed KV store on a deterministically lossy network. -; -; One server process, two client processes. Clients send seq-numbered puts -; as Net datagrams; the network (net.oo `lossy`) drops 25% and duplicates -; 10% of packets — in BOTH directions, requests and acks. Clients retry on -; ack timeout (poll via Proc.try-recv + yield). The dice are the sim's -; seeded PRNG, so the packet loss is bit-for-bit reproducible: same seed, -; same drops, same retries, same world. Different seed, different storm — -; but the protocol still converges to the same final database. -; -; loon run os/demo-kv.oo -[use sys] -[use sched] -[use sim] -[use net] - -; ── client: send-with-retry over the lossy net ────────────────────────── -; Poll for the ack matching `seq`: up to `polls` yields, discarding stale -; (duplicate) acks. :timeout if it never arrives. -[fn await-ack [seq polls] - [if [<= polls 0] - :timeout - [do - [Proc.yield] - [let r [Proc.try-recv]] - [if [= r :none] - [recur seq [- polls 1]] - [if [= [get [get r :msg] :ack -1] seq] - [get r :msg] - [recur seq [- polls 1]]]]]]] - -; Fire the request until its ack lands; returns how many retries it took. -[fn send-until [server req seq attempts] - [Net.send server req] - [if [= [await-ack seq 6] :timeout] - [recur server req seq [+ attempts 1]] - attempts]] - -; Put every item, counting total retries. -[fn client [server me items i retries] - [if [>= i [len items]] - retries - [do - [let item [nth items i]] - [let seq [+ [* me 1000] i]] - [let n - [send-until - server - {:op :put :key [get item :key] :val [get item :val] :seq seq :from me} - seq - 0]] - [recur server me items [+ i 1] [+ retries n]]]]] - -; ── server: apply puts, ack every request (acks may be eaten too) ─────── -[fn server-loop [db] - [let m [Proc.recv]] - [if [= [get m :op] :stop] - db - [do - [let db2 - [if [= [get m :op] :put] [assoc db [get m :key] [get m :val]] db]] - [Net.send [get m :from] {:ack [get m :seq]}] - [recur db2]]]] - -; ── the world ──────────────────────────────────────────────────────────── -[fn spawn-client [server items] - [Proc.spawn - [fn [] - [let r [lossy [fn [] [client server [Proc.self] items 0 0]] 25 10]] - [Proc.send 0 {:done [Proc.self] :retries r}] - r]]] - -[fn world [] - [let server [Proc.spawn [fn [] [lossy [fn [] [server-loop {}]] 25 10]]]] - [let c1 [spawn-client server #[{:key "alpha" :val 1} {:key "beta" :val 2}]]] - [let c2 [spawn-client server #[{:key "gamma" :val 3} {:key "delta" :val 4}]]] - [let d1 [Proc.recv]] - [let d2 [Proc.recv]] - [Proc.send server {:op :stop}] ; control plane: reliable in-world send - [+ [get d1 :retries] [get d2 :retries]]] - -[fn server-db [st] - ; the server is the first spawn -> pid 1; its exit value is the final db - [let entry [first [filter [get st :done] [fn [e] [= [get e :pid] 1]]]]] - [get entry :value]] - -[fn run-world [seed] [simulate [fn [] [run-procs world]] seed {}]] - -[fn main [] - [let w1 [run-world 42]] - [let st1 [get w1 :result]] - [let w2 [run-world 42]] - [let w3 [run-world 7]] - [let db1 [server-db st1]] - [IO.println "kv store over a 25%-loss 10%-dup network, seed 42:"] - [IO.println [str " final db: " db1]] - [IO.println - [str - " total retries: " - [get [first [filter [get st1 :done] [fn [e] [= [get e :pid] 0]]]] :value]]] - [IO.println - [if [= w1 w2] - " same seed: STORM REPRODUCED EXACTLY" - " same seed: diverged (bug!)"]] - [let db3 [server-db [get w3 :result]]] - [IO.println - [str - "seed 7: different storm, " - [if [= db1 db3] - "SAME converged database (protocol wins)" - [str "DIFFERENT database (protocol bug!): " db3]]]]] diff --git a/os/demo-procs.oo b/os/demo-procs.oo deleted file mode 100644 index 5aee9ea..0000000 --- a/os/demo-procs.oo +++ /dev/null @@ -1,39 +0,0 @@ -; Demo: cooperative processes — spawn, yield, and message passing, all in -; pure Loon. A ping-pong pair passes a counter back and forth; a background -; worker interleaves via yield. -; -; loon run os/demo-procs.oo -[use sys] -[use sched] - -[fn ponger [] - [let m [Proc.recv]] - [IO.println [str " pong got " m]] - [Proc.send [get m :from] {:n [+ [get m :n] 1] :from [Proc.self]}] - [let m2 [Proc.recv]] - [IO.println [str " pong got " m2]] - "pong done"] - -[fn worker [label] - [IO.println [str " " label " tick 1"]] - [Proc.yield] - [IO.println [str " " label " tick 2"]] - [Proc.yield] - [IO.println [str " " label " tick 3"]] - [str label " done"]] - -[fn main-proc [] - [let me [Proc.self]] - [let pong [Proc.spawn ponger]] - [let w [Proc.spawn [fn [] [worker "worker"]]]] - [Proc.send pong {:n 1 :from me}] - [let reply [Proc.recv]] - [IO.println [str " ping got " reply]] - [Proc.send pong {:n [+ [get reply :n] 1] :from me}] - "ping done"] - -[fn main [] - [IO.println "booting process world:"] - [let st [run-procs main-proc]] - [IO.println [str "exit values: " [get st :done]]] - [IO.println [if [deadlocked? st] "DEADLOCK detected" "world quiescent"]]] diff --git a/os/demo-sandbox.oo b/os/demo-sandbox.oo deleted file mode 100644 index dcbeabc..0000000 --- a/os/demo-sandbox.oo +++ /dev/null @@ -1,27 +0,0 @@ -; Demo: a real program doing real file IO, under kernel <- trace <- sandbox. -; The program thinks it writes /loon-motd at the filesystem root; the sandbox -; re-roots it into a jail, the trace logs every operation (post-rewrite, -; since the sandbox sits inside the trace), and the kernel does real IO. -; -; loon run os/demo-sandbox.oo -[use sys] -[use kernel] -[use trace] -[use sandbox] - -[fn prog [] - [Fs.write-file "/loon-motd" "welcome to loon os"] - [let motd [Fs.read-file "/loon-motd"]] - [let home [Env.get "HOME"]] - [str "motd: '" motd "' (HOME=" home ")"]] - -[fn main [] - [let jail "/tmp"] - [IO.println "running prog under kernel <- trace <- sandbox <- with-env:"] - [let result - [kernel - [fn [] - [traced - [fn [] - [sandboxed [fn [] [with-env prog {"HOME" "/home/jailbird"}]] jail]]]]]] - [IO.println result]] diff --git a/os/demo-sim.oo b/os/demo-sim.oo deleted file mode 100644 index 56a63fb..0000000 --- a/os/demo-sim.oo +++ /dev/null @@ -1,50 +0,0 @@ -; Demo: deterministic simulation of a whole process world. -; -; Two "nodes" exchange messages with random jitter and sleeps, writing a -; ledger into a virtual filesystem. The scheduler handles Proc effects; the -; simulation handler beneath it seals Clock/Rand/Fs. Same seed -> identical -; world, different seed -> different interleaving of jitter. Hundreds of -; virtual milliseconds elapse in zero real time. -; -; loon run os/demo-sim.oo -[use sys] -[use sched] -[use sim] - -[fn node [name peer rounds] - [if [= rounds 0] - [str name " done at t=" [Clock.millis]] - [do - [let jitter [Rand.int 100]] - [Clock.sleep [+ 50 jitter]] - [Proc.send peer {:from name :at [Clock.millis]}] - [let m [Proc.recv]] - [Fs.write-file - [str "/log/" name] - [str "heard " [get m :from] " at " [get m :at]]] - [node name peer [- rounds 1]]]]] - -[fn world [] - [let a [Proc.self]] - [let b [Proc.spawn [fn [] [node "beta" 0 3]]]] - [node "alpha" b 3]] - -[fn run-world [seed] [simulate [fn [] [run-procs world]] seed {}]] - -[fn main [] - [let w1 [run-world 42]] - [let w2 [run-world 42]] - [let w3 [run-world 7]] - [IO.println - [str "seed 42, virtual end: t=" [get w1 :time] "ms (real time: ~0)"]] - [IO.println [str " alpha log: " [get [get w1 :fs] "/log/alpha" ""]]] - [IO.println [str " beta log: " [get [get w1 :fs] "/log/beta" ""]]] - [IO.println [str " exits: " [get [get w1 :result] :done]]] - [IO.println - [if [= w1 w2] - "seed 42 twice: WORLDS IDENTICAL" - "seed 42 twice: WORLDS DIVERGED (bug!)"]] - [IO.println - [if [= w1 w3] - "seed 7: identical to seed 42 (suspicious!)" - [str "seed 7: different world (t=" [get w3 :time] "ms)"]]]] diff --git a/os/demo-tape.oo b/os/demo-tape.oo deleted file mode 100644 index 83c6c23..0000000 --- a/os/demo-tape.oo +++ /dev/null @@ -1,27 +0,0 @@ -; Demo: record a run with real IO + real clock, then replay it with NO -; kernel at all — every nondeterministic result comes off the tape, so the -; replay is bit-identical even though it touches nothing real. -; -; loon run os/demo-tape.oo -[use sys] -[use kernel] -[use tape] - -[fn prog [] - [Fs.write-file "/tmp/loon-tape-demo" "hello from the past"] - [let contents [Fs.read-file "/tmp/loon-tape-demo"]] - [let t [Clock.millis]] - [let r [Rand.int 1000]] - [str "read '" contents "' at t=" t " (roll: " r ")"]] - -[fn main [] - [IO.println "recording (real IO, real clock):"] - [let rec [kernel [fn [] [record prog]]]] - [let live [get rec :result]] - [let log [get rec :tape]] - [IO.println [str " live: " live]] - [IO.println [str " tape: " log]] - [IO.println "replaying from tape (no kernel, no real IO):"] - [let ghost [replay prog log]] - [IO.println [str " replay: " ghost]] - [IO.println [if [= live ghost] "REPLAY IDENTICAL" "REPLAY DIVERGED"]]] diff --git a/os/kernel.oo b/os/kernel.oo deleted file mode 100644 index 9c3d7fb..0000000 --- a/os/kernel.oo +++ /dev/null @@ -1,32 +0,0 @@ -; Loon OS — the kernel handler. -; -; The kernel is not special: it is just the last handler standing. It -; translates Sys effects into the VM's builtin effects (real syscalls). -; Everything else in the OS is a wrapping handler somewhere above this one. -[use sys] - -[pub - fn - kernel - [thunk] - [handle - [thunk] - [Fs.read-file p] - [resume [IO.read-file p]] - [Fs.write-file p c] - [resume [IO.write-file p c]] - [Clock.now] - [resume [IO.now]] - [Clock.millis] - [resume [IO.millis]] - ; no blocking-sleep builtin yet — a no-op under the real kernel; the - ; simulation handler (sim.oo) gives it meaning by advancing virtual time - [Clock.sleep ms] - [resume []] - ; not cryptographic — clock-derived entropy, fine for jitter/sampling - [Rand.int n] - [resume [imod [IO.millis] n]] - ; forwarding from inside the clause reaches the VM builtin (no handler - ; above the kernel), which reads the real environment - [Env.get k] - [resume [Env.get k]]]] diff --git a/os/net.oo b/os/net.oo deleted file mode 100644 index 263f158..0000000 --- a/os/net.oo +++ /dev/null @@ -1,42 +0,0 @@ -; Loon OS — the network is a handler. -; -; `Net.send` is an unreliable datagram: this handler rolls the dice and -; either drops the packet, duplicates it, or delivers it into the target's -; Proc mailbox. Wrap a process body in `lossy` and its sends become exactly -; as unreliable as you ask for — and because the dice are the Rand effect, -; which the simulation handler beneath seeds, THE PACKET LOSS ITSELF IS -; DETERMINISTIC. A distributed-systems bug found at seed 42 replays at seed -; 42, drop for drop, forever. (FoundationDB's simulation testing, as a -; 20-line wrapper.) -; -; Composition per process: lossy <- process body; Rand forwards out through -; the scheduler to the sim; surviving packets forward as Proc.send to the -; scheduler. Delays/reordering need virtual-time delivery queues — future -; work; drops + dups already exercise every retry path. -[use sys] - -; Wrap a process body: each Net.send is dropped with drop-pct/100 -; probability, duplicated with dup-pct/100, delivered otherwise. -[pub - fn - lossy - [thunk drop-pct dup-pct] - [handle - [thunk] - [Net.send target packet] - [if [< [Rand.int 100] drop-pct] - [resume []] ; the network ate it - [do - [Proc.send target packet] - [if [< [Rand.int 100] dup-pct] [Proc.send target packet] []] - [resume []]]]]] - -; A reliable network, for differential runs against the same protocol. -[pub - fn - reliable - [thunk] - [handle - [thunk] - [Net.send target packet] - [do [Proc.send target packet] [resume []]]]] diff --git a/os/sandbox.oo b/os/sandbox.oo deleted file mode 100644 index 7defbaa..0000000 --- a/os/sandbox.oo +++ /dev/null @@ -1,47 +0,0 @@ -; Loon OS — containment as a library. -; -; chroot, read-only mounts, and env isolation are all the same move: a -; handler that rewrites or denies operations before forwarding them. -; Nesting sandboxes is just handler composition. -[use sys] - -; A path escapes its jail if it contains a `..` component: re-rooting by -; string concat ([str root p]) would otherwise let `/../etc/passwd` resolve -; above `root` once the real filesystem collapses the `..`. Reject any path -; with `..` rather than try to normalize (normalization is subtle and -; symlinks defeat it anyway — deny is the safe default for a demo jail). -[pub fn escapes? [p] [contains? p ".."]] - -; chroot: every path is re-rooted under `root` before forwarding; a path that -; tries to traverse out with `..` is denied (catch with `try`). -[pub - fn - sandboxed - [thunk root] - [handle - [thunk] - [Fs.read-file p] - [if [escapes? p] - [Fail.fail [str "sandbox: path escapes jail: " p]] - [resume [Fs.read-file [str root p]]]] - [Fs.write-file p c] - [if [escapes? p] - [Fail.fail [str "sandbox: path escapes jail: " p]] - [resume [Fs.write-file [str root p] c]]]]] - -; read-only: writes abort with Fail (catch with `try`); reads pass through -[pub - fn - read-only - [thunk] - [handle - [thunk] - [Fs.write-file p c] - [Fail.fail [str "read-only: denied write to " p]]]] - -; env isolation: lookups resolve from a fixed map, never the real environment -[pub - fn - with-env - [thunk env] - [handle [thunk] [Env.get k] [resume [get env k ""]]]] diff --git a/os/sched.oo b/os/sched.oo deleted file mode 100644 index ab054b9..0000000 --- a/os/sched.oo +++ /dev/null @@ -1,109 +0,0 @@ -; Loon OS — the scheduler. -; -; A blocked process is an unresumed continuation; the process table is a -; queue of them. Cooperative round-robin: every Proc effect is a yield -; point. Pure — the whole scheduler state is one immutable map threaded -; through handler clauses (the escaping-continuation pattern): -; -; {:queue #[{:pid n :step [fn [st] st']}] runnable steps -; :mail {pid #[msgs]} undelivered messages -; :waiting {pid k-or-:none} parked on recv -; :next-pid n -; :done #[{:pid n :value v}]} exit values -; -; A "step" is st -> st: run one process slice, return the updated state. -; Fresh processes wrap their thunk in a new handle; parked processes are -; continuations that re-establish their own handler when resumed. -[use sys] - -[fn enqueue [st pid step] - [update st :queue [fn [q] [conj q {:pid pid :step step}]]]] - -; Run one slice of process `pid`. Returns st -> st. -[fn run-slice [pid thunk] - [handle - [thunk] - [return x] - [fn [st] [update st :done [fn [d] [conj d {:pid pid :value x}]]]] - - [Proc.self] - [fn [st] [[resume pid] st]] - - [Proc.spawn t] - [fn [st] - [let child [get st :next-pid]] - [let st2 - [enqueue - [assoc st :next-pid [+ child 1]] - child - [fn [s] [[run-slice child t] s]]]] - [[resume child] st2]] - - [Proc.yield] - ; park the continuation at the back of the run queue - [fn [st] [enqueue st pid [fn [s] [[resume []] s]]]] - - [Proc.recv] - [fn [st] - [let inbox [get [get st :mail] pid #[]]] - [if [> [len inbox] 0] - [do - [let st2 [update st :mail [fn [m] [assoc m pid [drop 1 inbox]]]]] - [[resume [first inbox]] st2]] - ; nothing waiting — park until someone sends to us - [update st :waiting [fn [w] [assoc w pid [fn [msg] [resume msg]]]]]]] - - [Proc.try-recv] - ; non-blocking probe: {:msg m} if something is waiting, :none otherwise - [fn [st] - [let inbox [get [get st :mail] pid #[]]] - [if [> [len inbox] 0] - [do - [let st2 [update st :mail [fn [m] [assoc m pid [drop 1 inbox]]]]] - [[resume {:msg [first inbox]}] st2]] - [[resume :none] st]]] - - [Proc.send target msg] - [fn [st] - [let k [get [get st :waiting] target :none]] - [if [= k :none] - ; not parked — leave the message in the mailbox - [do - [let st2 - [update - st - :mail - [fn [m] [assoc m target [conj [get m target #[]] msg]]]]] - [[resume []] st2]] - ; parked on recv — wake it with the message - [do - [let st2 [update st :waiting [fn [w] [assoc w target :none]]]] - [[resume []] [enqueue st2 target [fn [s] [[k msg] s]]]]]]]]] - -; Drain the run queue round-robin until empty (tail-recursive). -[fn drain [st] - [let q [get st :queue]] - [if [empty? q] - st - [do - [let entry [first q]] - [let st2 [assoc st :queue [drop 1 q]]] - [drain [[get entry :step] st2]]]]] - -; Boot a process world: run `main-thunk` as pid 0, schedule until quiescent. -; Returns the final state; :done holds exit values, and a non-empty :waiting -; with an empty queue means deadlocked processes (reported by deadlocked?). -[pub - fn - run-procs - [main-thunk] - [let st0 {:queue #[] :mail {} :waiting {} :next-pid 1 :done #[]}] - [drain [[run-slice 0 main-thunk] st0]]] - -; processes still parked on recv after the world went quiet -[pub - fn - deadlocked? - [st] - [let stuck [filter [vals [get st :waiting]] [fn [k] [not [= k :none]]]]] - [not [empty? stuck]]] diff --git a/os/sim.oo b/os/sim.oo deleted file mode 100644 index f9051e4..0000000 --- a/os/sim.oo +++ /dev/null @@ -1,41 +0,0 @@ -; Loon OS — deterministic simulation. -; -; A sealed world: virtual clock, seeded PRNG (minstd), and an in-memory -; filesystem, all in one handler. No kernel below it — a simulated program -; touches nothing real, runs at infinite speed (sleep just advances the -; virtual clock), and is bit-for-bit reproducible from its seed. This is -; the substrate for FoundationDB-style deterministic testing of whole -; process worlds (compose with sched.oo). -; -; State: {:time ms :seed s :fs {path contents}} -[use sys] - -; Run thunk in a sealed simulated world. Returns -; {:result r :time end-ms :fs final-fs}. -[pub - fn - simulate - [thunk seed opts] - [[handle - [thunk] - [return x] - [fn [st] {:result x :time [get st :time] :fs [get st :fs]}] - [Clock.now] - [fn [st] [[resume [int [/ [get st :time] 1000]]] st]] - [Clock.millis] - [fn [st] [[resume [get st :time]] st]] - [Clock.sleep ms] - [fn [st] [[resume []] [update st :time [fn [t] [+ t ms]]]]] - [Rand.int n] - [fn [st] - [let s2 [imod [* [get st :seed] 48271] 2147483647]] - [[resume [imod s2 n]] [assoc st :seed s2]]] - [Fs.read-file p] - [fn [st] [[resume [get [get st :fs] p ""]] st]] - [Fs.write-file p c] - [fn [st] [[resume []] [update st :fs [fn [fs] [assoc fs p c]]]]] - [Env.get k] - [fn [st] [[resume [get [get opts :env {}] k ""]] st]]] - {:time [get opts :start 0] - :seed [if [> seed 0] seed 1] - :fs [get opts :fs {}]}]] diff --git a/os/sys.oo b/os/sys.oo deleted file mode 100644 index d81b22f..0000000 --- a/os/sys.oo +++ /dev/null @@ -1,50 +0,0 @@ -; Loon OS — the Sys effect family (phase 1). -; -; This is the OS interface: programs request services by performing these -; effects; whoever handles them decides what they mean. The kernel handler -; (kernel.oo) is just the outermost handler — every other OS feature -; (sandbox, trace, record/replay, scheduling, simulation) is a wrapping -; handler over the same operations. -; -; Design rules (see docs/plans/2026-07-01-loon-os.md): -; - every source of nondeterminism is an effect (Clock, Rand, IO results), -; which is what makes whole-program record/replay possible -; - effect rows are the permission manifest: code whose row lacks Fs cannot -; touch the filesystem, statically -; -; Phase 1 is path-level (no fds) because it maps onto the VM's builtins. -[effect Fs - [read-file [String] String] - [write-file [String String] Unit]] - -[effect Clock - [now [] Int] - [millis [] Int] - [sleep [Int] Unit]] - -; uniform int in [0, n) -[effect Rand - [int [Int] Int]] - -[effect Env - [get [String] String]] - -; cooperative processes — handled by the scheduler (sched.oo). -; try-recv is the non-blocking probe: {:msg m} or :none, never parks — -; the primitive that retry loops over a lossy network are built on. -[effect Proc - [spawn [Any] Int] - [yield [] Unit] - [send [Int Any] Unit] - [recv [] Any] - [try-recv [] Any] - [self [] Int]] - -; unreliable inter-node packets — given meaning by a network handler -; (net.oo): seeded drops/duplication, delivery into the target's Proc -; mailbox when a packet survives -[effect Net - [send [Int Any] Unit]] - -; integer modulo (no builtin `mod`) — non-negative operands only -[pub fn imod [a b] [- a [* b [int [/ a b]]]]] diff --git a/os/tape.oo b/os/tape.oo deleted file mode 100644 index e08182a..0000000 --- a/os/tape.oo +++ /dev/null @@ -1,72 +0,0 @@ -; Loon OS — record / replay. -; -; Every source of nondeterminism is a Sys effect, so recording a run means -; interposing a handler that forwards each operation outward (to whatever -; kernel is below) and logs the result. Replaying means handling the same -; operations from the log instead — no real IO, bit-identical execution. -; A production crash ships home as a tape. -; -; State (the tape) is threaded functionally with the escaping-continuation -; pattern from samples/state.oo: every handler clause evaluates to a function -; of the tape, and `[return x]` closes the loop. -[use sys] - -; Run thunk, forwarding Sys effects to the surrounding kernel and logging -; every result. Returns {:result r :tape #[...]}. -[pub - fn - record - [thunk] - [[handle - [thunk] - [return x] - [fn [tape] {:result x :tape tape}] - [Fs.read-file p] - [fn [tape] [let v [Fs.read-file p]] [[resume v] [conj tape v]]] - [Fs.write-file p c] - ; writes return Unit — forward for the side effect, nothing to tape - [fn [tape] [Fs.write-file p c] [[resume []] tape]] - [Clock.now] - [fn [tape] [let v [Clock.now]] [[resume v] [conj tape v]]] - [Clock.millis] - [fn [tape] [let v [Clock.millis]] [[resume v] [conj tape v]]] - [Rand.int n] - [fn [tape] [let v [Rand.int n]] [[resume v] [conj tape v]]] - [Env.get k] - [fn [tape] [let v [Env.get k]] [[resume v] [conj tape v]]]] - #[]]] - -; Re-run thunk against a recorded tape: every nondeterministic result comes -; from the tape (in order), writes are suppressed. No kernel needed at all — -; a replayed program touches nothing real. -; Pull the next recorded result, or fail loudly if the tape is spent. A -; replay that performs more effects than were recorded (or in a different -; order) has DIVERGED from the recording; detecting that beats silently -; resuming with Unit and returning corrupt data. `resume` is passed in as a -; value — it is the handle's continuation, usable anywhere. -[fn replay-next [t resume label] - [if [empty? t] - [Fail.fail [str "replay desync: tape exhausted at " label]] - [[resume [first t]] [drop 1 t]]]] - -[pub - fn - replay - [thunk tape] - [[handle - [thunk] - [return x] - [fn [t] x] - [Fs.read-file p] - [fn [t] [replay-next t resume "Fs.read-file"]] - [Fs.write-file p c] - [fn [t] [[resume []] t]] - [Clock.now] - [fn [t] [replay-next t resume "Clock.now"]] - [Clock.millis] - [fn [t] [replay-next t resume "Clock.millis"]] - [Rand.int n] - [fn [t] [replay-next t resume "Rand.int"]] - [Env.get k] - [fn [t] [replay-next t resume "Env.get"]]] - tape]] diff --git a/os/trace.oo b/os/trace.oo deleted file mode 100644 index 3e17399..0000000 --- a/os/trace.oo +++ /dev/null @@ -1,27 +0,0 @@ -; Loon OS — strace as a library. -; -; A wrapping handler: log every Sys operation, then forward it to the next -; handler out (the sandbox, the kernel, a recorder — whoever is below). -; Zero mechanism: observability is just interposition. -[use sys] - -[pub - fn - traced - [thunk] - [handle - [thunk] - [Fs.read-file p] - [do [IO.println [str "[trace] Fs.read-file " p]] [resume [Fs.read-file p]]] - [Fs.write-file p c] - [do - [IO.println [str "[trace] Fs.write-file " p " (" [len c] " bytes)"]] - [resume [Fs.write-file p c]]] - [Clock.now] - [do [IO.println "[trace] Clock.now"] [resume [Clock.now]]] - [Clock.millis] - [do [IO.println "[trace] Clock.millis"] [resume [Clock.millis]]] - [Rand.int n] - [do [IO.println [str "[trace] Rand.int " n]] [resume [Rand.int n]]] - [Env.get k] - [do [IO.println [str "[trace] Env.get " k]] [resume [Env.get k]]]]] diff --git a/samples/os-handlers.oo b/samples/os-handlers.oo deleted file mode 100644 index ba1b401..0000000 --- a/samples/os-handlers.oo +++ /dev/null @@ -1,40 +0,0 @@ -; Syscalls as effects: the kernel is just the outermost handler. -; A program requests OS services by performing effects; whoever handles them -; decides what they mean. Composition under test here: -; -; kernel <- trace <- sandbox <- program -; -; strace is a wrapping handler, chroot is a path-rewriting handler, and the -; "kernel" is the last handler standing. See docs/plans/2026-07-01-loon-os.md. - -[effect Fs [read [String] String]] -[effect Clock [now [] Int]] - -; the user program — requests syscalls, knows nothing about who serves them -[fn prog [] - [let cfg [Fs.read "/etc/config"]] - [let t [Clock.now]] - [str "got '" cfg "' at t=" t]] - -; kernel handler: the bottom of the world (mocked "hardware") -[fn kernel [thunk] - [handle [thunk] - [Fs.read path] [resume [str "disk-bytes-of:" path]] - [Clock.now] [resume 1234]]] - -; strace as a library: log the op, forward it to the next handler out -[fn traced [thunk] - [handle [thunk] - [Fs.read path] [do [IO.println [str " trace: Fs.read " path]] - [resume [Fs.read path]]] - [Clock.now] [do [IO.println " trace: Clock.now"] - [resume [Clock.now]]]]] - -; chroot as a library: rewrite paths, forward everything else untouched -[fn sandboxed [thunk root] - [handle [thunk] - [Fs.read path] [resume [Fs.read [str root path]]]]] - -[fn main [] - [IO.println "running prog under kernel <- trace <- sandbox:"] - [IO.println [kernel [fn [] [traced [fn [] [sandboxed prog "/jail"]]]]]]]