From 631d277e0c4ab83ac08a2ec799038fd76708e622 Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Mon, 10 Aug 2026 09:35:55 +0200 Subject: [PATCH] feat(runtime): wovm VM core (Iteration 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 16 tasks complete: arena, object model, borrow word, containers, RC + budgeted cycle collector, wob_build, validating loader, interpreter core (dual dispatch), object opcodes, drop-map unwinding, builtins + DB_STUB + TRAP, ICALL, wovm CLI + just recipes - 13 test suites × 2 dispatch flavors (ASan+UBSan) + CLI smoke, all green - .wob v1 format pinned in src/wob.h + docs/plan/oop-vm/00-wob-format.md - wo-rt.c reference event-loop preserved for sub-project 2 This is Iteration 2 of the OOP milestone; compiler front (Iteration 3) is in progress on this branch. They meet at Iteration 4 (emitter+e2e). --- runtime/Makefile | 59 +++ runtime/README.md | 136 +++++ runtime/bench/bench.c | 201 +++++++ runtime/bench/goref/go.mod | 3 + runtime/bench/goref/main.go | 69 +++ runtime/src/.gitkeep | 0 runtime/src/borrow.c | 17 + runtime/src/borrow.h | 17 + runtime/src/builtin.c | 157 ++++++ runtime/src/builtin.h | 12 + runtime/src/cont.c | 86 +++ runtime/src/cont.h | 37 ++ runtime/src/gc.c | 296 +++++++++++ runtime/src/gc.h | 32 ++ runtime/src/loader.c | 465 +++++++++++++++++ runtime/src/loader.h | 70 +++ runtime/src/main.c | 50 ++ runtime/src/obj.c | 110 ++++ runtime/src/obj.h | 63 +++ runtime/src/vm.c | 474 +++++++++++++++++ runtime/src/vm.h | 44 ++ runtime/src/wob.h | 177 +++++++ runtime/test/.gitkeep | 0 runtime/test/cli_smoke.sh | 31 ++ runtime/test/mkwob.c | 62 +++ runtime/test/t.h | 50 ++ runtime/test/test_arena.c | 47 ++ runtime/test/test_borrow.c | 37 ++ runtime/test/test_builtin.c | 185 +++++++ runtime/test/test_cont.c | 89 ++++ runtime/test/test_cycle.c | 115 ++++ runtime/test/test_icall.c | 99 ++++ runtime/test/test_loader.c | 116 +++++ runtime/test/test_obj.c | 85 +++ runtime/test/test_objops.c | 95 ++++ runtime/test/test_rc.c | 103 ++++ runtime/test/test_unwind.c | 133 +++++ runtime/test/test_vm.c | 126 +++++ runtime/test/test_wobbuild.c | 69 +++ runtime/test/wob_build.c | 143 +++++ runtime/test/wob_build.h | 52 ++ runtime/wo-rt.c | 979 +++++++++++++++++++++++++++++++++++ 42 files changed, 5191 insertions(+) create mode 100644 runtime/Makefile create mode 100644 runtime/README.md create mode 100644 runtime/bench/bench.c create mode 100644 runtime/bench/goref/go.mod create mode 100644 runtime/bench/goref/main.go create mode 100644 runtime/src/.gitkeep create mode 100644 runtime/src/borrow.c create mode 100644 runtime/src/borrow.h create mode 100644 runtime/src/builtin.c create mode 100644 runtime/src/builtin.h create mode 100644 runtime/src/cont.c create mode 100644 runtime/src/cont.h create mode 100644 runtime/src/gc.c create mode 100644 runtime/src/gc.h create mode 100644 runtime/src/loader.c create mode 100644 runtime/src/loader.h create mode 100644 runtime/src/main.c create mode 100644 runtime/src/obj.c create mode 100644 runtime/src/obj.h create mode 100644 runtime/src/vm.c create mode 100644 runtime/src/vm.h create mode 100644 runtime/src/wob.h create mode 100644 runtime/test/.gitkeep create mode 100755 runtime/test/cli_smoke.sh create mode 100644 runtime/test/mkwob.c create mode 100644 runtime/test/t.h create mode 100644 runtime/test/test_arena.c create mode 100644 runtime/test/test_borrow.c create mode 100644 runtime/test/test_builtin.c create mode 100644 runtime/test/test_cont.c create mode 100644 runtime/test/test_cycle.c create mode 100644 runtime/test/test_icall.c create mode 100644 runtime/test/test_loader.c create mode 100644 runtime/test/test_obj.c create mode 100644 runtime/test/test_objops.c create mode 100644 runtime/test/test_rc.c create mode 100644 runtime/test/test_unwind.c create mode 100644 runtime/test/test_vm.c create mode 100644 runtime/test/test_wobbuild.c create mode 100644 runtime/test/wob_build.c create mode 100644 runtime/test/wob_build.h create mode 100644 runtime/wo-rt.c diff --git a/runtime/Makefile b/runtime/Makefile new file mode 100644 index 0000000..eca9d10 --- /dev/null +++ b/runtime/Makefile @@ -0,0 +1,59 @@ +CC ?= cc +CFLAGS ?= -O2 -Wall -Wextra -std=c11 +LDFLAGS ?= -pthread + +wo-rt: wo-rt.c + $(CC) $(CFLAGS) -o $@ $< $(LDFLAGS) + +# ---- wovm VM core (src/) + unit tests (test/) ---- +# Each test/test_*.c builds into its own ASan+UBSan binary linked against +# every src/*.c except main.c; `make test` runs them all. +VMSRC := $(filter-out src/main.c,$(wildcard src/*.c)) +VMHDR := $(wildcard src/*.h) $(wildcard test/*.h) +TESTS := $(wildcard test/test_*.c) +TESTBIN := $(TESTS:test/%.c=build/%) +TCFLAGS := -std=c11 -Wall -Wextra -Werror -g -O1 \ + -fsanitize=address,undefined -fno-omit-frame-pointer + +build: + mkdir -p build + +TESTHELP := $(wildcard test/wob_build.c) + +build/%: test/%.c $(VMSRC) $(TESTHELP) $(VMHDR) | build + $(CC) $(TCFLAGS) -Isrc -Itest -o $@ $< $(TESTHELP) $(VMSRC) + +test: $(TESTBIN) + @for t in $(TESTBIN); do echo "== $$t"; ./$$t || exit 1; done + +# ISO dispatch flavor (switch instead of computed goto) — gated in every +# run so the fallback can never rot +ISOBIN := $(TESTS:test/%.c=build/iso_%) + +build/iso_%: test/%.c $(VMSRC) $(TESTHELP) $(VMHDR) | build + $(CC) $(TCFLAGS) -DWO_ISO_C -Isrc -Itest -o $@ $< $(TESTHELP) $(VMSRC) + +test-iso: $(ISOBIN) + @for t in $(ISOBIN); do echo "== $$t"; ./$$t || exit 1; done + +# the wovm binary (plain optimized build; the test suite is the ASan gate) +wovm: src/main.c $(VMSRC) $(VMHDR) + $(CC) $(CFLAGS) -Isrc -o $@ src/main.c $(VMSRC) + +# fixture generator for the CLI smoke test +build/mkwob: test/mkwob.c test/wob_build.c $(VMHDR) | build + $(CC) $(CFLAGS) -Isrc -Itest -o $@ test/mkwob.c test/wob_build.c + +bench/bench: bench/bench.c + $(CC) $(CFLAGS) -o $@ $< $(LDFLAGS) + +bench: bench/bench + +run: wo-rt + ./wo-rt + +clean: + rm -f wo-rt bench/bench wovm + rm -rf build + +.PHONY: bench run clean test test-iso diff --git a/runtime/README.md b/runtime/README.md new file mode 100644 index 0000000..b5b5bd2 --- /dev/null +++ b/runtime/README.md @@ -0,0 +1,136 @@ +# `wo-rt-c` — the writeonce runtime environment, in C + +A single-file C implementation of the **runtime layer** the writeonce language runs on — now at **phase E: a durable RAM database**. Writes follow the dual-write order — RAM apply, framed WAL record (`len|crc32|payload|COMMIT`) to a per-shard `fallocate`'d log, one group-commit `fdatasync` per loop tick, **HTTP ack only after the fsync completion**. Boot performs the **first load, hard drive → RAM**: each shard replays its snapshot + WAL tail into its arena slice in parallel before any accept arms; clean shutdown snapshots each slice and truncates the WAL; a `meta` file pins the shard count so a mismatched `WO_THREADS` refuses to boot. `./wo-rt wal-check ` validates a log offline. `WO_THREADS` pinned threads (default = online cores), each owning its own raw io_uring ring (`io_uring_setup` + mmap'd SQ/CQ rings + `io_uring_enter` — **no liburing**), its own `SO_REUSEPORT` listener (multishot accept), its own keep-alive connections, and its own slice of the one mlock'd mmap arena — shared-nothing, no locks. Steady state is one `io_uring_enter` syscall per loop tick. Thread 0 owns the `signalfd`; shutdown broadcasts through per-thread `eventfd`s, both watched via `POLL_ADD` SQEs. **Zero dependencies beyond libc + kernel uapi headers.** The kernel is the runtime. + +This is the runtime-layer sibling of [`prototypes/wo-db/`](../prototypes/wo-db/) (the C++ query-layer prototype): a reference card showing, with no abstraction in the way, exactly which kernel primitives the production Rust runtime (`crates/rt/`) drives through `libc`. Same role, different layer. + +``` +prototypes/wo-db/ C++ what the LANGUAGE executes (parser, engine, transactions) +runtime/ C what the RUNTIME stands on (epoll, signalfd, sockets) +crates/rt/ Rust the product — both layers, libc only +``` + +> **Moved from `prototypes/wo-rt-c/` to root-level `runtime/`** (monorepo layout, per the OOP compiler + VM design spec). `wo-rt.c` is untouched — it stays the event-loop reference described below. The **`wovm` bytecode VM now lives under `runtime/src/`** — see [The wovm bytecode VM](#the-wovm-bytecode-vm-runtimesrc) for what shipped and [Debugging](#debugging) for how to step through it. + +## Build, run, poke + +```bash +make # cc -O2 -Wall -Wextra -std=c11 -pthread — no libraries +./wo-rt # 127.0.0.1:8085 (WO_PORT=9000 WO_THREADS=4 ./wo-rt to override) + +curl localhost:8085/ # {"runtime":"wo-rt-c","loop":"epoll-et","threads":4, + # "shard":2,"shard_requests":[68,36,44,53]} +curl -X POST localhost:8085/api/notes -d '{"title":"hello"}' + # {"id":2,"title":"hello","shard":1} ← ids interleave per shard +curl localhost:8085/api/notes # the connection's shard only — shared-nothing +# ctrl-C → signalfd on shard 0 → eventfd broadcast → all shards join +``` + +Each connection hashes to one shard for life (`SO_REUSEPORT` 4-tuple): a list may land on a different shard than the create that preceded it. That is the architecture, not a bug — cross-shard reads are a later phase / design decision (see the [architecture doc's improvements](../docs/plan/exploration/c-runtime/01-architecture.md)). + +Or from the repo root: `just rt-c-demo`. + +## Module map + +Every block in `wo-rt.c` corresponds one-to-one to a module of the Rust runtime, which in turn mirrors Go's netpoller — the same lineage the docs trace: + +| `wo-rt.c` block | Rust (`crates/rt/src/`) | Go (`.dev/reference/go/src/runtime/`) | Kernel reference card | +| --- | --- | --- | --- | +| `main` event loop (`epoll_create1` / `epoll_wait`, `EPOLLET`) | `runtime/netpoll_epoll.rs` | `netpoll_epoll.go` | [`linux/01-epoll.md`](../docs/plan/exploration/linux/01-epoll.md) | +| `sig_setup` (`sigprocmask` + `signalfd`) | `runtime/signalfd.rs` | signal mask handling | [`linux/04-signalfd.md`](../docs/plan/exploration/linux/04-signalfd.md) | +| `listener_bind` (`SOCK_NONBLOCK`, `accept4`-to-EAGAIN) | `http/listener.rs` | `net.Listen` + accept loop | `socket(7)` | +| `conn_drive` (read-to-EAGAIN, one buffer per fd) | `http/connection.rs` | `conn.Read` loop | the edge-triggered contract | +| `notes[]` store | `engine.rs` (BTreeMaps) | — | [`03-inmemory-engine.md`](../docs/runtime/database/03-inmemory-engine.md) | + +## What it demonstrates + +- **One thread owns each shard outright.** Accept, parse, store, respond — no locks, no worker pool, no connection migration. Scaling past one core is more shards ([`09-concurrency-scaleout.md`](../docs/plan/09-concurrency-scaleout.md)), never shared mutable state. The single cross-thread touch is the relaxed-atomic stats counters on `/` — monotonic, never on the data path. +- **Edge-triggered discipline.** Every registration sets `EPOLLET`; every readiness event is drained to `EAGAIN` (the accept loop and the read loop both). Get this wrong and connections silently hang — the reason the Rust module documents the same contract at the top of `netpoll_epoll.rs`. +- **Signals as fd events.** `SIGINT`/`SIGTERM` are blocked, then read from a `signalfd` on the same epoll — no async-signal-unsafe handler, no self-pipe trick. +- **RAM is the read path.** `GET /api/notes` touches a C array. The production engine is the same idea with MVCC and a WAL behind it. + +## Architecture and roadmap + +Documentation lives under `docs/` (repo convention) — this README stays here as the directory's orientation page only: + +- [`docs/plan/exploration/c-runtime/01-architecture.md`](../docs/plan/exploration/c-runtime/01-architecture.md) — the runtime defined by tracing **one memory address** through user space, kernel space, and hardware under a million concurrent connections, plus seven improvement proposals (seqlock reads, registered buffers, zero-copy send, SQPOLL, …). +- [`docs/plan/exploration/c-runtime/00-plan.md`](../docs/plan/exploration/c-runtime/00-plan.md) — the phase sequence: **A → B → C → D → E → F, all ✅ shipped.** +- [`docs/plan/exploration/blue-green-vm/00-vision.md`](../docs/plan/exploration/blue-green-vm/00-vision.md) — where the runtime goes next: port-free transports, fibers, source embedded in the binary, agent-managed source over MCP, and the Blue/Green two-VM hot-swap deployment model. + +## Measured (phase F, 20-core Linux 6.14, tmpfs data dir, `just rt-c-bench`) + +Same C bench client (`bench/bench.c`, keep-alive, only 2xx counted) against both servers: + +| Benchmark | **wo-rt-c** (8 shards, durable WAL, io_uring + group commit) | **Go `net/http`** (go1.25.1, 20 cores, no durability) | **Rust `wo`** (release, 8 shards, durable WAL, io_uring group commit)¹ | +| --- | --- | --- | --- | +| `GET /healthz` | **859,033 req/s** · p50 71 µs · p99 159 µs | 336,444 req/s · p50 70 µs · p99 1,277 µs | 746,340 req/s · p50 73 µs · p99 186 µs | +| `GET /` (JSON) | **671,312 req/s** · p99 180 µs | — | 692,671 req/s · p99 167 µs | +| `POST` write (tmpfs) | **618,343 commits/s** — fsync-acked · p99 194 µs | 320,516 req/s — RAM only, no WAL · p99 1,581 µs | 330,285 commits/s — fsync-acked · p99 360 µs | +| `POST` write (real ext4/NVMe) | — | — | **27,014 commits/s group commit vs 5,765 per-commit (4.7×)** · p50 2.2 ms | +| 10,000 idle conns | 0 errors | 0 errors | 0 errors | + +¹ All numbers measured on a clean box with the same client (earlier parasite-contaminated runs superseded). The Rust column's history is the architecture roadmap, measured: 09a global mutex (74.9k writes/s) → 09b sharded engine (+51%) → 09c per-shard WAL (~1% durability cost) → **keep-alive: reads ×3.4 to 770k/s, durable writes ×1.9 to 331k/s, p99 under 350 µs everywhere**. Rust now beats Go on both columns *while fsyncing every write*, and sits within ~10% of the C prototype on reads — converging exactly as the same-architecture argument predicted. The one remaining C advantage is **group commit on io_uring** (one batched fsync + one syscall per tick vs per-commit fsync over epoll), which is the next port. Bonus finding: with `/tmp` accidentally full, the C runtime **refused to ack non-durable writes under ENOSPC** — the durability guarantee holding in an unplanned failure mode. + +wo-rt-c on 8 cores outpaces Go on 20 with ~8× tighter p99 (Go's GC shows there) — while fsyncing every write Go doesn't. Honest caveats: `net/http` does full general-purpose HTTP; our parser is minimal; .NET was not installed on the box. **ACID under load:** three crash rounds (`kill -9` mid-bench at ~2M commits) all showed WAL records ≥ acked; isolation probe: 300 concurrent commits → 300 distinct ids; torn-tail records drop whole by CRC. + +The crash-under-load test **found and fixed two real bugs** the lighter phase-D test missed: an ack-before-fsync race (`conn_continue` armed the send in the same tick the commit was staged) and an fd-reuse ABA hazard in ack parking (fixed with per-connection generation stamps). That is what phase F is for. +- [`docs/plan/exploration/c-runtime/02-single-binary.md`](../docs/plan/exploration/c-runtime/02-single-binary.md) — the end goal: how the `wo build` **single binary** runs on this runtime environment — the runtime kernel is statically linked into every writeonce app (Go model, nothing to install), with the catalog/routes/bytecode payload consumed at boot. + +## Deliberate simplifications + +Single-shot RECV re-armed per request (multishot recv + buffer rings are a phase-F improvement), one outstanding SQE per connection, fixed-size buffers, naive `"title"` extraction instead of a JSON parser, no `timerfd`. Requires kernel ≥ 5.19 (multishot accept). This file is for reading; `crates/rt` is for running writeonce. + +## The wovm bytecode VM (runtime/src/) + +Milestone 1 of the OOP track (spec: [`docs/superpowers/specs/2026-08-01-oop-compiler-vm-design.md`](../docs/superpowers/specs/2026-08-01-oop-compiler-vm-design.md), plan 1: [`docs/superpowers/plans/2026-08-01-wob-format-and-vm-core.md`](../docs/superpowers/plans/2026-08-01-wob-format-and-vm-core.md)) — a register VM that executes `.wob` bytecode (format: [`docs/plan/oop-vm/00-wob-format.md`](../docs/plan/oop-vm/00-wob-format.md)) with the full milestone-1 memory model. C11, libc only, same doctrine as `wo-rt.c`. + +**Shipped features:** + +- **Register interpreter** — fixed 32-bit instructions, Lua-style window-overlap calls (callee r0 = caller slot A), dual dispatch: computed goto under GNU C, `switch` under `-DWO_ISO_C` (both flavors gated in CI so neither rots). +- **Owned objects with a runtime borrow word** — shared-reader count / exclusive sentinel in every 16-byte header; violations trap `T_BORROW`. The compiler elides provable sites; the VM enforces the residual ones (hybrid model, spec §4). +- **`@gc` reference counting + budgeted cycle collection** — rc at zero frees immediately; possible cycles buffer as candidates (Bacon–Rajan trial deletion), collected in budgeted epochs per shard — no stop-the-world by construction. +- **Deterministic drops** — kind-directed drop plans (scalar/owned/gcref/text/multi/map), recursive over class fields and container elements. +- **Trap unwinding that never leaks** — per-method drop tables (pc → owned/gc register masks); a trap walks every frame and frees what was live; structured error `{code, method, line, message}` via line tables. +- **Validating loader** — bounds-checked parse, aligned copies, const-string interning, full static validation (opcodes, registers, indexes, jump targets, terminators, builtin arity, call windows, sorted vtables); what the loader accepts, the interpreter trusts — no UB on any input. +- **Structural interfaces** — `ICALL` binary-searches sorted (class, slot, method) vtable triples by receiver class. +- **Native containers + builtins** — `multi`/`map` with element-kind tags; `now/print/print_int/words/multi_*/map_*`; `DB_STUB` traps "engine not linked" until the DB engine binds (plan 5). +- **CLI contract** — `wovm app.wob`: exit 0 = ran; exit 1 = trap, one stderr line `trap CODE in METHOD at line N: MESSAGE`; exit 2 = usage/load failure. `WO_HEAP_MB` overrides the 64 MiB arena. + +**File map:** + +| File | What it is | +| --- | --- | +| `src/wob.h` | the `.wob` v1 contract: opcodes, field kinds, trap codes, builtin ids, limits, the 16-byte object header (machine-readable twin of the format doc) | +| `src/obj.h/.c` | arena allocator (16-byte size-class free lists ≤ 1024 B, malloc above), `wo_rt` context, object creation, strings | +| `src/borrow.h/.c` | the borrow word: acquire shared/exclusive, unconditional releases | +| `src/cont.h/.c` | native containers: growable `multi`, linear-scan `map` (content-compared text keys) | +| `src/gc.h/.c` | kind-directed drop dispatcher, rc inc/dec, cycle-candidate buffer, budgeted Bacon–Rajan collector | +| `src/loader.h/.c` | `.wob` parse + full static validation + mmap file path | +| `src/vm.h/.c` | the interpreter: dispatch, frames, traps, drop-map unwinding, `ICALL` | +| `src/builtin.h/.c` | builtin dispatcher | +| `src/main.c` | the `wovm` CLI | +| `test/t.h` | 20-line assert harness (no framework) | +| `test/wob_build.h/.c` | in-memory `.wob` assembler — the second, independent encoding of the format; builder/loader disagreements fail tests | +| `test/test_*.c` | 13 suites, one binary each, ASan+UBSan | +| `test/mkwob.c` | fixture generator for the CLI smoke | +| `test/cli_smoke.sh` | end-to-end exit-code/stderr-shape check | + +**Gates:** `just wovm-build` · `just wovm-test` (unit suites + ISO flavor + CLI smoke, all ASan-clean) · in `runtime/`: `make test`, `make test-iso`, `make wovm`. + +## Debugging + +VS Code: `.vscode/launch.json` ships four configs (needs the *C/C++* extension, `ms-vscode.cpptools`): + +1. **wovm: debug hello.wob** — rebuilds `wovm` at `-O0 -g`, regenerates fixtures, breaks anywhere in the VM. +2. **wovm: debug a .wob file** — same, prompts for the image path. +3. **wovm: debug unit test** — pick one of the 13 ASan test binaries (already `-g`), step through it. +4. **wo-rt: debug server** — the event-loop reference at `-O0 -g`, `WO_THREADS=1` so one shard owns everything. + +How to work on the VM under a debugger: + +- **Step the ISO flavor, not the computed-goto one.** The goto interpreter jumps label-to-label and single-stepping is disorienting. Test binaries have an ISO twin (`build/iso_test_*`, plain `switch`) where `next`/`step` behave normally. For `wovm` itself, build `make -B wovm CFLAGS='-O0 -g -std=c11 -DWO_ISO_C'`. +- **`break vm_trap`** — one breakpoint catches every trap at the moment of failure, with the trapping frame intact (`vm->frames[vm->depth-1]`, `pc` already rewound to the faulting instruction). `vm_unwind` is the next frame down if you're chasing a leak-on-trap. +- **Other load-bearing breakpoints:** `wo_load_buf` (validation rejects), `recv_check` (residual field checks), `wo_builtin` (all builtins), `wo_gc_step` (cycle collection epochs). +- **ASan under gdb:** `ASAN_OPTIONS=abort_on_error=1` makes the first report SIGABRT so the debugger stops on it with the full stack; without gdb the report alone usually names the exact free you missed. +- **CLI knobs:** `WO_HEAP_MB=1 ./wovm app.wob` forces early `T_OOM` paths; exit codes 0/1/2 are stable for scripting. +- **gdb without VS Code:** `gdb --args ./wovm build/hello.wob`, or `gdb ./build/iso_test_unwind`. diff --git a/runtime/bench/bench.c b/runtime/bench/bench.c new file mode 100644 index 0000000..a238591 --- /dev/null +++ b/runtime/bench/bench.c @@ -0,0 +1,201 @@ +/* + * bench.c — load client for wo-rt-c (phase F). Zero deps beyond libc. + * + * T threads, each driving ONE keep-alive connection in a tight request/ + * response loop (TCP_NODELAY, full-response framing via Content-Length). + * Every request is latency-stamped; the run prints req/s, p50, p99, errors. + * + * usage: ./bench [post-json] + * read: ./bench 127.0.0.1 8085 64 5 /api/notes + * write: ./bench 127.0.0.1 8085 64 5 /api/notes '{"title":"bench"}' + * idle: ./bench 127.0.0.1 8085 10000 0 /healthz # seconds=0: open conns, + * # one request each, hold, exit + */ + +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define MAX_SAMPLES 400000 /* per thread; counting continues past it */ + +static char g_req[2048]; +static int g_reqlen; +static struct sockaddr_in g_addr; +static long g_deadline_us; /* 0 = idle-connection mode */ +static int g_hold_secs; + +struct worker { + pthread_t tid; + long reqs, errs; + long ok2xx, non2xx; /* honest accounting: only 2xx is success */ + long *lat; /* µs samples */ + int nlat; +}; + +static long now_us(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return ts.tv_sec * 1000000L + ts.tv_nsec / 1000; +} + +static int conn_open(void) { + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) return -1; + int one = 1; + setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one); + if (connect(fd, (struct sockaddr *)&g_addr, sizeof g_addr) < 0) { close(fd); return -1; } + return fd; +} + +/* One request/response round-trip. Returns HTTP status, or -1 conn-dead. */ +static int round_trip(int fd) { + size_t off = 0; + while (off < (size_t)g_reqlen) { + ssize_t n = write(fd, g_req + off, (size_t)g_reqlen - off); + if (n <= 0) { if (n < 0 && errno == EINTR) continue; return -1; } + off += (size_t)n; + } + static _Thread_local char buf[131072]; + size_t got = 0, need = 0; + for (;;) { + ssize_t n = read(fd, buf + got, sizeof buf - 1 - got); + if (n <= 0) { if (n < 0 && errno == EINTR) continue; return -1; } + got += (size_t)n; + buf[got] = 0; + if (!need) { + char *he = strstr(buf, "\r\n\r\n"); + if (!he) { if (got >= sizeof buf - 1) return -1; continue; } + size_t hdr = (size_t)(he + 4 - buf); + long cl = 0; + char *p = strcasestr(buf, "Content-Length:"); + if (p) cl = strtol(p + 15, NULL, 10); + need = hdr + (size_t)cl; + } + if (got >= need) { + int code = 0; + sscanf(buf, "HTTP/1.1 %d", &code); + return code; + } + if (got >= sizeof buf - 1) return -1; + } +} + +static void *worker_main(void *arg) { + struct worker *w = arg; + int fd = conn_open(); + if (fd < 0) { w->errs++; return NULL; } + + if (g_deadline_us == 0) { /* idle-connection mode */ + if (round_trip(fd) > 0) w->reqs++; else w->errs++; + sleep((unsigned)g_hold_secs); + close(fd); + return NULL; + } + + while (now_us() < g_deadline_us) { + long t0 = now_us(); + int code = round_trip(fd); + if (code < 0) { /* reconnect once, then count errs */ + close(fd); + fd = conn_open(); + if (fd < 0) { w->errs++; break; } + w->errs++; + continue; + } + long dt = now_us() - t0; + if (w->nlat < MAX_SAMPLES) w->lat[w->nlat++] = dt; + w->reqs++; + if (code >= 200 && code < 300) w->ok2xx++; else w->non2xx++; + } + close(fd); + return NULL; +} + +static int cmp_long(const void *a, const void *b) { + long x = *(const long *)a, y = *(const long *)b; + return (x > y) - (x < y); +} + +int main(int argc, char **argv) { + if (argc < 6) { + fprintf(stderr, "usage: %s [post-json]\n", argv[0]); + return 2; + } + const char *host = argv[1]; + int port = atoi(argv[2]); + int threads = atoi(argv[3]); + int secs = atoi(argv[4]); + const char *path = argv[5]; + const char *body = argc > 6 ? argv[6] : NULL; + + struct rlimit rl; + if (getrlimit(RLIMIT_NOFILE, &rl) == 0 && rl.rlim_cur < rl.rlim_max) { + rl.rlim_cur = rl.rlim_max; + setrlimit(RLIMIT_NOFILE, &rl); + } + + memset(&g_addr, 0, sizeof g_addr); + g_addr.sin_family = AF_INET; + g_addr.sin_port = htons((uint16_t)port); + inet_pton(AF_INET, host, &g_addr.sin_addr); + + if (body) + g_reqlen = snprintf(g_req, sizeof g_req, + "POST %s HTTP/1.1\r\nHost: %s\r\nContent-Type: application/json\r\n" + "Content-Length: %zu\r\nConnection: keep-alive\r\n\r\n%s", + path, host, strlen(body), body); + else + g_reqlen = snprintf(g_req, sizeof g_req, + "GET %s HTTP/1.1\r\nHost: %s\r\nConnection: keep-alive\r\n\r\n", path, host); + + g_hold_secs = 3; + g_deadline_us = secs > 0 ? now_us() + (long)secs * 1000000L : 0; + + struct worker *ws = calloc((size_t)threads, sizeof *ws); + for (int i = 0; i < threads; i++) { + ws[i].lat = secs > 0 ? malloc(MAX_SAMPLES * sizeof(long)) : NULL; + pthread_create(&ws[i].tid, NULL, worker_main, &ws[i]); + } + + long t0 = now_us(); + long total = 0, errs = 0, nlat = 0, ok = 0, bad = 0; + for (int i = 0; i < threads; i++) { + pthread_join(ws[i].tid, NULL); + total += ws[i].reqs; + errs += ws[i].errs; + nlat += ws[i].nlat; + ok += ws[i].ok2xx; + bad += ws[i].non2xx; + } + long wall_us = now_us() - t0; + + if (secs == 0) { + printf("idle-conns: opened %ld / %d connections (errs %ld), held %ds, server survived\n", + total, threads, errs, g_hold_secs); + return errs ? 1 : 0; + } + + long *all = malloc((size_t)nlat * sizeof(long)); + long k = 0; + for (int i = 0; i < threads; i++) { + memcpy(all + k, ws[i].lat, (size_t)ws[i].nlat * sizeof(long)); + k += ws[i].nlat; + } + qsort(all, (size_t)nlat, sizeof(long), cmp_long); + + double rps = (double)ok / ((double)wall_us / 1e6); + printf("%-22s %d conns %ds %ld ok (2xx) %.0f ok/s p50 %ld µs p99 %ld µs non-2xx %ld errs %ld\n", + body ? "WRITE (POST)" : path, threads, secs, ok, rps, + nlat ? all[nlat / 2] : 0, nlat ? all[(long)((double)nlat * 0.99)] : 0, bad, errs); + return 0; +} diff --git a/runtime/bench/goref/go.mod b/runtime/bench/goref/go.mod new file mode 100644 index 0000000..7d924dd --- /dev/null +++ b/runtime/bench/goref/go.mod @@ -0,0 +1,3 @@ +module goref + +go 1.25.1 diff --git a/runtime/bench/goref/main.go b/runtime/bench/goref/main.go new file mode 100644 index 0000000..d0539ec --- /dev/null +++ b/runtime/bench/goref/main.go @@ -0,0 +1,69 @@ +// goref — the Go net/http comparison server for the phase-F benchmark. +// Same endpoints and semantics as wo-rt-c's RAM read path: /healthz, /, +// GET/POST /api/notes against an in-memory store. Durability is NOT +// implemented here (Go side has no WAL), so only READ benchmarks are +// apples-to-apples; the POST comparison measures Go's non-durable path +// against wo-rt-c's fsync-backed path and is labeled as such. +// +// go build -o goref . && ./goref # :8095, GOMAXPROCS = all cores +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "sync" +) + +type note struct { + ID int `json:"id"` + Title string `json:"title"` +} + +var ( + mu sync.RWMutex + notes []note + nextID = 1 +) + +func main() { + http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, "ok") + }) + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, `{"runtime":"go-net-http"}`) + }) + http.HandleFunc("/api/notes", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + mu.RLock() + b, _ := json.Marshal(notes) + mu.RUnlock() + w.Header().Set("Content-Type", "application/json") + w.Write(b) + case http.MethodPost: + var in struct { + Title string `json:"title"` + } + if json.NewDecoder(r.Body).Decode(&in) != nil || in.Title == "" { + http.Error(w, `{"error":"expected {\"title\":\"...\"}"}`, http.StatusBadRequest) + return + } + mu.Lock() + n := note{ID: nextID, Title: in.Title} + nextID++ + if len(notes) < 100000 { + notes = append(notes, n) + } + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(n) + default: + http.Error(w, "method", http.StatusMethodNotAllowed) + } + }) + fmt.Println("[goref] listening on :8095") + http.ListenAndServe("127.0.0.1:8095", nil) +} diff --git a/runtime/src/.gitkeep b/runtime/src/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/runtime/src/borrow.c b/runtime/src/borrow.c new file mode 100644 index 0000000..42d02c2 --- /dev/null +++ b/runtime/src/borrow.c @@ -0,0 +1,17 @@ +#include "borrow.h" + +int wo_borrow_shared(wo_hdr *o) { + if (o->borrow == WO_BORROW_EXCL) return -1; + o->borrow++; + return 0; +} + +int wo_borrow_excl(wo_hdr *o) { + if (o->borrow != WO_BORROW_FREE) return -1; + o->borrow = WO_BORROW_EXCL; + return 0; +} + +void wo_release_shared(wo_hdr *o) { o->borrow--; } + +void wo_release_excl(wo_hdr *o) { o->borrow = WO_BORROW_FREE; } diff --git a/runtime/src/borrow.h b/runtime/src/borrow.h new file mode 100644 index 0000000..8573aa4 --- /dev/null +++ b/runtime/src/borrow.h @@ -0,0 +1,17 @@ +/* borrow.h — the residual-check primitive of the hybrid model (spec §4). + * The header's borrow word counts shared readers; WO_BORROW_EXCL means + * exclusively borrowed. Acquires return 0 or -1 (the VM maps -1 to + * WO_T_BORROW); releases are unconditional — the compiler emits them + * balanced. Small on purpose: this module is the semantic heart of the + * hybrid model and the compiler's emit rules cite it. */ +#ifndef WO_BORROW_H +#define WO_BORROW_H + +#include "wob.h" + +int wo_borrow_shared(wo_hdr *o); /* fails only against exclusive */ +int wo_borrow_excl(wo_hdr *o); /* fails unless completely free */ +void wo_release_shared(wo_hdr *o); +void wo_release_excl(wo_hdr *o); + +#endif /* WO_BORROW_H */ diff --git a/runtime/src/builtin.c b/runtime/src/builtin.c new file mode 100644 index 0000000..e5c909a --- /dev/null +++ b/runtime/src/builtin.c @@ -0,0 +1,157 @@ +#define _POSIX_C_SOURCE 199309L /* clock_gettime under -std=c11 */ + +#include "builtin.h" + +#include +#include + +#include "cont.h" +#include "gc.h" + +/* type checks on receiver headers: wrong native class traps BOUNDS */ +static void *native_check(uint64_t v, uint32_t cls, const char **msg) { + if (!v) { + *msg = "null receiver"; + return NULL; + } + wo_hdr *o = (wo_hdr *)(uintptr_t)v; + if (o->class_id != cls) { + *msg = "wrong container type"; + return NULL; + } + return o; +} + +int wo_builtin(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) { + wo_rt *rt = &vm->rt; + uint8_t A = wo_ins_a(ins), B = wo_ins_b(ins), C = wo_ins_c(ins); + switch (C) { + case WO_B_NOW: { /* wall-clock milliseconds */ + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + R[A] = (uint64_t)((int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000); + return 0; + } + case WO_B_PRINT: { + wo_str *s = native_check(R[B], WO_CLS_STR, msg); + if (!s) return WO_T_BOUNDS; + FILE *out = rt->out; + fwrite(s->data, 1, s->len, out); + fputc('\n', out); + R[A] = 0; + return 0; + } + case WO_B_PRINT_INT: { + fprintf((FILE *)rt->out, "%lld\n", (long long)(int64_t)R[B]); + R[A] = 0; + return 0; + } + case WO_B_WORDS: { /* whitespace-separated token count */ + wo_str *s = native_check(R[B], WO_CLS_STR, msg); + if (!s) return WO_T_BOUNDS; + uint64_t n = 0; + int in_tok = 0; + for (uint32_t i = 0; i < s->len; i++) { + char ch = s->data[i]; + int ws = ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r'; + if (!ws && !in_tok) n++; + in_tok = !ws; + } + R[A] = n; + return 0; + } + case WO_B_MULTI_NEW: { /* element kind immediate in B */ + wo_multi *m = wo_multi_new(rt, B); + if (!m) { + *msg = "out of memory"; + return WO_T_OOM; + } + R[A] = (uint64_t)(uintptr_t)m; + return 0; + } + case WO_B_MULTI_PUSH: { + wo_multi *m = native_check(R[B], WO_CLS_MULTI, msg); + if (!m) return WO_T_BOUNDS; + if (wo_multi_push(m, R[B + 1]) != 0) { + *msg = "out of memory"; + return WO_T_OOM; + } + R[A] = 0; + return 0; + } + case WO_B_MULTI_GET: { + wo_multi *m = native_check(R[B], WO_CLS_MULTI, msg); + if (!m) return WO_T_BOUNDS; + if (wo_multi_get(m, R[B + 1], &R[A]) != 0) { + *msg = "multi index out of range"; + return WO_T_BOUNDS; + } + return 0; + } + case WO_B_COUNT: { /* length of either container */ + wo_hdr *o = (wo_hdr *)(uintptr_t)R[B]; + if (R[B] && o->class_id == WO_CLS_MULTI) { + R[A] = ((wo_multi *)o)->len; + return 0; + } + if (R[B] && o->class_id == WO_CLS_MAP) { + R[A] = ((wo_map *)o)->len; + return 0; + } + *msg = "count of a non-container"; + return WO_T_BOUNDS; + } + case WO_B_LATEST: { + wo_multi *m = native_check(R[B], WO_CLS_MULTI, msg); + if (!m) return WO_T_BOUNDS; + if (m->len == 0) { + *msg = "latest of an empty multi"; + return WO_T_BOUNDS; + } + R[A] = m->items[m->len - 1]; + return 0; + } + case WO_B_MAP_NEW: { /* key/value kind nibbles immediate in B */ + wo_map *m = wo_map_new(rt, B & 0x0F, B >> 4); + if (!m) { + *msg = "out of memory"; + return WO_T_OOM; + } + R[A] = (uint64_t)(uintptr_t)m; + return 0; + } + case WO_B_MAP_SET: { + wo_map *m = native_check(R[B], WO_CLS_MAP, msg); + if (!m) return WO_T_BOUNDS; + uint64_t old = 0; + int rc = wo_map_set(m, R[B + 1], R[B + 2], &old); + if (rc < 0) { + *msg = "out of memory"; + return WO_T_OOM; + } + /* insert-or-replace hands the displaced value back: drop it here — + * closing the loose end cont.c documents */ + if (rc == 1) wo_drop_kind(rt, m->val_kind, old); + R[A] = 0; + return 0; + } + case WO_B_MAP_GET: { + wo_map *m = native_check(R[B], WO_CLS_MAP, msg); + if (!m) return WO_T_BOUNDS; + if (wo_map_get(m, R[B + 1], &R[A]) != 0) { + *msg = "missing map key"; + return WO_T_KEY; + } + return 0; + } + case WO_B_MAP_HAS: { + wo_map *m = native_check(R[B], WO_CLS_MAP, msg); + if (!m) return WO_T_BOUNDS; + R[A] = wo_map_has(m, R[B + 1]) ? 1 : 0; + return 0; + } + default: /* unreachable: loader validated the id */ + *msg = "unknown builtin"; + return WO_T_EXPLICIT; + } +} diff --git a/runtime/src/builtin.h b/runtime/src/builtin.h new file mode 100644 index 0000000..7ce73f4 --- /dev/null +++ b/runtime/src/builtin.h @@ -0,0 +1,12 @@ +/* builtin.h — runtime services bytecode can't express (spec §3/§5): + * now/print/print_int/words plus the container bridge to cont.c. One + * dispatcher: takes the VM, the current frame's registers, and the + * instruction; 0 = ok, else a WO_T_* trap code with *msg set. */ +#ifndef WO_BUILTIN_H +#define WO_BUILTIN_H + +#include "vm.h" + +int wo_builtin(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg); + +#endif /* WO_BUILTIN_H */ diff --git a/runtime/src/cont.c b/runtime/src/cont.c new file mode 100644 index 0000000..474d8d3 --- /dev/null +++ b/runtime/src/cont.c @@ -0,0 +1,86 @@ +#include "cont.h" + +#include +#include + +wo_multi *wo_multi_new(wo_rt *rt, uint8_t elem_kind) { + wo_multi *m = wo_arena_alloc(&rt->arena, sizeof(wo_multi)); + if (!m) return NULL; + memset(m, 0, sizeof(*m)); + m->h.class_id = WO_CLS_MULTI; + m->elem_kind = elem_kind; + return m; +} + +int wo_multi_push(wo_multi *m, uint64_t v) { + if (m->len == m->cap) { + uint32_t ncap = m->cap ? m->cap * 2 : 8; + uint64_t *ni = realloc(m->items, (size_t)ncap * sizeof(uint64_t)); + if (!ni) return -1; + m->items = ni; + m->cap = ncap; + } + m->items[m->len++] = v; + return 0; +} + +int wo_multi_get(const wo_multi *m, uint64_t idx, uint64_t *out) { + if (idx >= m->len) return -1; + *out = m->items[idx]; + return 0; +} + +wo_map *wo_map_new(wo_rt *rt, uint8_t key_kind, uint8_t val_kind) { + wo_map *m = wo_arena_alloc(&rt->arena, sizeof(wo_map)); + if (!m) return NULL; + memset(m, 0, sizeof(*m)); + m->h.class_id = WO_CLS_MAP; + m->key_kind = key_kind; + m->val_kind = val_kind; + return m; +} + +static int key_eq(const wo_map *m, uint64_t a, uint64_t b) { + if (m->key_kind == WO_K_TEXT) + return wo_str_eq((const wo_str *)(uintptr_t)a, + (const wo_str *)(uintptr_t)b); + return a == b; +} + +static int map_find(const wo_map *m, uint64_t k) { + for (uint32_t i = 0; i < m->len; i++) + if (key_eq(m, m->keys[i], k)) return (int)i; + return -1; +} + +int wo_map_set(wo_map *m, uint64_t k, uint64_t v, uint64_t *old) { + int i = map_find(m, k); + if (i >= 0) { + *old = m->vals[i]; + m->vals[i] = v; + return 1; + } + if (m->len == m->cap) { + uint32_t ncap = m->cap ? m->cap * 2 : 8; + uint64_t *nk = realloc(m->keys, (size_t)ncap * sizeof(uint64_t)); + if (!nk) return -1; + m->keys = nk; + uint64_t *nv = realloc(m->vals, (size_t)ncap * sizeof(uint64_t)); + if (!nv) return -1; + m->vals = nv; + m->cap = ncap; + } + m->keys[m->len] = k; + m->vals[m->len] = v; + m->len++; + return 0; +} + +int wo_map_get(const wo_map *m, uint64_t k, uint64_t *out) { + int i = map_find(m, k); + if (i < 0) return -1; + *out = m->vals[i]; + return 0; +} + +int wo_map_has(const wo_map *m, uint64_t k) { return map_find(m, k) >= 0; } diff --git a/runtime/src/cont.h b/runtime/src/cont.h new file mode 100644 index 0000000..90fecbf --- /dev/null +++ b/runtime/src/cont.h @@ -0,0 +1,37 @@ +/* cont.h — native containers (spec §3): `multi T` and `map` are + * runtime-provided native classes, not user generics. Struct heads live in + * the arena; backing arrays are malloc/realloc'd. Map lookup is a linear + * scan — deliberate milestone-1 KISS. Insert-or-replace hands the replaced + * old value back to the caller (dropping needs gc.c; the builtin layer does + * it). Element-dropping frees also live in gc.c. */ +#ifndef WO_CONT_H +#define WO_CONT_H + +#include "obj.h" + +typedef struct wo_multi { + wo_hdr h; /* class_id WO_CLS_MULTI */ + uint8_t elem_kind; + uint32_t len, cap; + uint64_t *items; +} wo_multi; + +wo_multi *wo_multi_new(wo_rt *rt, uint8_t elem_kind); /* NULL = OOM */ +int wo_multi_push(wo_multi *m, uint64_t v); /* 0 ok, -1 OOM */ +int wo_multi_get(const wo_multi *m, uint64_t idx, uint64_t *out); /* -1 oob */ + +typedef struct wo_map { + wo_hdr h; /* class_id WO_CLS_MAP */ + uint8_t key_kind, val_kind; + uint32_t len, cap; + uint64_t *keys, *vals; /* parallel arrays */ +} wo_map; + +wo_map *wo_map_new(wo_rt *rt, uint8_t key_kind, uint8_t val_kind); +/* 0 = inserted, 1 = replaced (*old set to the displaced value), -1 = OOM. + * Keys compare by content when key_kind is TEXT, by bits otherwise. */ +int wo_map_set(wo_map *m, uint64_t k, uint64_t v, uint64_t *old); +int wo_map_get(const wo_map *m, uint64_t k, uint64_t *out); /* -1 = miss */ +int wo_map_has(const wo_map *m, uint64_t k); + +#endif /* WO_CONT_H */ diff --git a/runtime/src/gc.c b/runtime/src/gc.c new file mode 100644 index 0000000..fd8f01a --- /dev/null +++ b/runtime/src/gc.c @@ -0,0 +1,296 @@ +#include "gc.h" + +#include + +#include "cont.h" + +static void multi_free(wo_rt *rt, wo_multi *m) { + for (uint32_t i = 0; i < m->len; i++) + wo_drop_kind(rt, m->elem_kind, m->items[i]); + free(m->items); + wo_arena_free(&rt->arena, m, sizeof(wo_multi)); +} + +static void map_free(wo_rt *rt, wo_map *m) { + for (uint32_t i = 0; i < m->len; i++) { + wo_drop_kind(rt, m->key_kind, m->keys[i]); + wo_drop_kind(rt, m->val_kind, m->vals[i]); + } + free(m->keys); + free(m->vals); + wo_arena_free(&rt->arena, m, sizeof(wo_map)); +} + +/* release a class object's contents then the object itself */ +static void class_free(wo_rt *rt, wo_hdr *o) { + const wo_classdesc *c = &rt->classes[o->class_id]; + uint64_t *f = wo_fields(o); + for (uint32_t i = 0; i < c->field_cnt; i++) + wo_drop_kind(rt, c->kinds[i], f[i]); + wo_arena_free(&rt->arena, o, wo_obj_size(c)); +} + +void wo_drop_obj(wo_rt *rt, wo_hdr *o) { + if (!o) return; + switch (o->class_id) { + case WO_CLS_STR: + wo_str_free(rt, (wo_str *)o); + return; + case WO_CLS_MULTI: + multi_free(rt, (wo_multi *)o); + return; + case WO_CLS_MAP: + map_free(rt, (wo_map *)o); + return; + default: + class_free(rt, o); + return; + } +} + +void wo_drop_kind(wo_rt *rt, uint8_t kind, uint64_t v) { + if (!v) return; /* null values ignored for every kind */ + switch (kind) { + case WO_K_SCALAR: + return; + case WO_K_OWNED: + case WO_K_MULTI: + case WO_K_MAP: + wo_drop_obj(rt, (wo_hdr *)(uintptr_t)v); + return; + case WO_K_GCREF: + wo_rc_dec(rt, (wo_hdr *)(uintptr_t)v); + return; + case WO_K_TEXT: + wo_str_free(rt, (wo_str *)(uintptr_t)v); + return; + default: + return; /* loader guarantees kinds; defensive no-op */ + } +} + +void wo_rc_inc(wo_hdr *o) { o->rc++; } + +/* ---- cycle-candidate buffer ---- */ + +/* only classes that can point at other @gc objects can close a cycle */ +static int class_possibly_cyclic(const wo_rt *rt, const wo_hdr *o) { + const wo_classdesc *c = &rt->classes[o->class_id]; + for (uint32_t i = 0; i < c->field_cnt; i++) { + uint8_t k = c->kinds[i]; + if (k == WO_K_GCREF || k == WO_K_MULTI || k == WO_K_MAP) return 1; + } + return 0; +} + +static void buf_push(wo_rt *rt, wo_hdr *o) { + if (rt->cycbuf.len == rt->cycbuf.cap) { + size_t ncap = rt->cycbuf.cap ? rt->cycbuf.cap * 2 : 16; + wo_hdr **ni = realloc(rt->cycbuf.items, ncap * sizeof(wo_hdr *)); + if (!ni) return; /* can't buffer: conservative leak, never corrupt */ + rt->cycbuf.items = ni; + rt->cycbuf.cap = ncap; + } + o->flags |= WO_F_BUF; + rt->cycbuf.items[rt->cycbuf.len++] = o; +} + +/* swap-remove a specific object from the buffer (whites purged mid-step) */ +static void buf_remove(wo_rt *rt, wo_hdr *o) { + for (size_t i = 0; i < rt->cycbuf.len; i++) { + if (rt->cycbuf.items[i] == o) { + rt->cycbuf.items[i] = rt->cycbuf.items[--rt->cycbuf.len]; + return; + } + } +} + +void wo_rc_dec(wo_rt *rt, wo_hdr *o) { + o->rc--; + if (o->rc == 0) { + /* zombie guard: a buffered candidate's death belongs to the cycle + * collector — it will notice rc 0 and free it in its own sweep */ + if (o->flags & WO_F_BUF) return; + class_free(rt, o); + return; + } + /* survived a decrement and can sit on a cycle: buffer as a candidate, + * deduplicated by the flag (Bacon–Rajan possible-root heuristic) */ + if (!(o->flags & WO_F_BUF) && class_possibly_cyclic(rt, o)) + buf_push(rt, o); +} + +/* ---- budgeted Bacon–Rajan trial deletion ---- */ + +static uint8_t color_of(const wo_hdr *o) { return o->flags & WO_F_COLOR; } +static void set_color(wo_hdr *o, uint8_t c) { + o->flags = (uint8_t)((o->flags & ~WO_F_COLOR) | c); +} + +/* visit every @gc edge out of a class object: gcref fields, plus gcref + * elements inside multi/map fields */ +typedef void (*child_fn)(wo_rt *rt, wo_hdr *child, void *ctx); +static void visit_children(wo_rt *rt, wo_hdr *o, child_fn fn, void *ctx) { + const wo_classdesc *c = &rt->classes[o->class_id]; + uint64_t *f = wo_fields(o); + for (uint32_t i = 0; i < c->field_cnt; i++) { + uint8_t k = c->kinds[i]; + if (k == WO_K_GCREF) { + if (f[i]) fn(rt, (wo_hdr *)(uintptr_t)f[i], ctx); + } else if (k == WO_K_MULTI) { + wo_multi *m = (wo_multi *)(uintptr_t)f[i]; + if (m && m->elem_kind == WO_K_GCREF) + for (uint32_t j = 0; j < m->len; j++) + if (m->items[j]) fn(rt, (wo_hdr *)(uintptr_t)m->items[j], ctx); + } else if (k == WO_K_MAP) { + wo_map *mp = (wo_map *)(uintptr_t)f[i]; + if (!mp) continue; + if (mp->key_kind == WO_K_GCREF) + for (uint32_t j = 0; j < mp->len; j++) + if (mp->keys[j]) fn(rt, (wo_hdr *)(uintptr_t)mp->keys[j], ctx); + if (mp->val_kind == WO_K_GCREF) + for (uint32_t j = 0; j < mp->len; j++) + if (mp->vals[j]) fn(rt, (wo_hdr *)(uintptr_t)mp->vals[j], ctx); + } + } +} + +static void mark_gray(wo_rt *rt, wo_hdr *o); +static void mark_gray_child(wo_rt *rt, wo_hdr *c, void *ctx) { + (void)ctx; + c->rc--; /* trial-delete this edge */ + mark_gray(rt, c); +} +static void mark_gray(wo_rt *rt, wo_hdr *o) { + if (color_of(o) == WO_COLOR_GRAY) return; + set_color(o, WO_COLOR_GRAY); + visit_children(rt, o, mark_gray_child, NULL); +} + +static void scan_black(wo_rt *rt, wo_hdr *o); +static void scan_black_child(wo_rt *rt, wo_hdr *c, void *ctx) { + (void)ctx; + c->rc++; /* restore the trial-deleted edge */ + if (color_of(c) != WO_COLOR_BLACK) scan_black(rt, c); +} +static void scan_black(wo_rt *rt, wo_hdr *o) { + set_color(o, WO_COLOR_BLACK); + visit_children(rt, o, scan_black_child, NULL); +} + +static void scan(wo_rt *rt, wo_hdr *o, void *ctx); +static void scan_(wo_rt *rt, wo_hdr *o) { + if (color_of(o) != WO_COLOR_GRAY) return; + if (o->rc > 0) { + scan_black(rt, o); /* externally held: restore the whole subgraph */ + return; + } + set_color(o, WO_COLOR_WHITE); + visit_children(rt, o, scan, NULL); +} +static void scan(wo_rt *rt, wo_hdr *o, void *ctx) { + (void)ctx; + scan_(rt, o); +} + +/* gather whites into a step-local list (post-marking, pre-free) */ +typedef struct { + wo_hdr **items; + size_t len, cap; + int oom; +} whites_t; + +static void collect_white(wo_rt *rt, wo_hdr *o, void *ctx) { + whites_t *w = ctx; + if (color_of(o) != WO_COLOR_WHITE) return; + set_color(o, WO_COLOR_BLACK); /* dedup: gathered exactly once */ + visit_children(rt, o, collect_white, ctx); + if (w->len == w->cap) { + size_t ncap = w->cap ? w->cap * 2 : 16; + wo_hdr **ni = realloc(w->items, ncap * sizeof(wo_hdr *)); + if (!ni) { + w->oom = 1; + return; + } + w->items = ni; + w->cap = ncap; + } + w->items[w->len++] = o; +} + +/* Free a dead white: release contents but SKIP every @gc edge — all edge + * accounting was already settled by the gray/scan phases, and the pointed- + * at whites die in this same sweep. Containers holding gcref elements free + * only their backing. */ +static void white_free(wo_rt *rt, wo_hdr *o) { + const wo_classdesc *c = &rt->classes[o->class_id]; + uint64_t *f = wo_fields(o); + for (uint32_t i = 0; i < c->field_cnt; i++) { + uint8_t k = c->kinds[i]; + uint64_t v = f[i]; + if (!v || k == WO_K_SCALAR || k == WO_K_GCREF) continue; + if (k == WO_K_MULTI) { + wo_multi *m = (wo_multi *)(uintptr_t)v; + if (m->elem_kind != WO_K_GCREF) + for (uint32_t j = 0; j < m->len; j++) + wo_drop_kind(rt, m->elem_kind, m->items[j]); + free(m->items); + wo_arena_free(&rt->arena, m, sizeof(wo_multi)); + } else if (k == WO_K_MAP) { + wo_map *mp = (wo_map *)(uintptr_t)v; + for (uint32_t j = 0; j < mp->len; j++) { + if (mp->key_kind != WO_K_GCREF) + wo_drop_kind(rt, mp->key_kind, mp->keys[j]); + if (mp->val_kind != WO_K_GCREF) + wo_drop_kind(rt, mp->val_kind, mp->vals[j]); + } + free(mp->keys); + free(mp->vals); + wo_arena_free(&rt->arena, mp, sizeof(wo_map)); + } else { + wo_drop_kind(rt, k, v); /* OWNED subtree, TEXT */ + } + } + wo_arena_free(&rt->arena, o, wo_obj_size(c)); +} + +size_t wo_gc_step(wo_rt *rt, size_t budget) { + size_t freed = 0, consumed = 0; + while (consumed < budget && rt->cycbuf.len > 0) { + wo_hdr *root = rt->cycbuf.items[--rt->cycbuf.len]; + root->flags &= (uint8_t)~WO_F_BUF; + consumed++; + if (root->rc == 0) { + /* died while buffered (zombie guard) — plain deterministic free */ + class_free(rt, root); + freed++; + continue; + } + /* trial deletion over this root's component (atomic per component) */ + mark_gray(rt, root); + scan_(rt, root); + whites_t w = {0}; + collect_white(rt, root, &w); + if (w.oom) { /* can't track whites: restore and retry next step */ + scan_black(rt, root); + free(w.items); + buf_push(rt, root); + break; + } + /* purge gathered whites still sitting in the buffer, then free — + * deferred freeing removes every dangling-candidate hazard */ + for (size_t i = 0; i < w.len; i++) { + if (w.items[i]->flags & WO_F_BUF) { + w.items[i]->flags &= (uint8_t)~WO_F_BUF; + buf_remove(rt, w.items[i]); + consumed++; + } + } + for (size_t i = 0; i < w.len; i++) { + white_free(rt, w.items[i]); + freed++; + } + free(w.items); + } + return freed; +} diff --git a/runtime/src/gc.h b/runtime/src/gc.h new file mode 100644 index 0000000..a75cbaa --- /dev/null +++ b/runtime/src/gc.h @@ -0,0 +1,32 @@ +/* gc.h — deterministic destruction + @gc reference counting (spec §4). + * Owned objects die deterministically via drop plans; @gc objects die at + * refcount zero. One kind-directed dispatcher is the workhorse: scalars + * ignored, owned values drop recursively, gc refs decrement, texts free, + * containers free element-wise then their backing. The budgeted cycle + * collector extends this module (Bacon–Rajan trial deletion). */ +#ifndef WO_GC_H +#define WO_GC_H + +#include "obj.h" + +/* Drop an 8-byte field/register value known to be of field kind `kind`. + * Null (0) values are ignored for every kind. */ +void wo_drop_kind(wo_rt *rt, uint8_t kind, uint64_t v); + +/* Drop any heap value by its header: native sentinels route to their own + * frees; class objects walk their kind array over the field slots, then + * free themselves. */ +void wo_drop_obj(wo_rt *rt, wo_hdr *o); + +void wo_rc_inc(wo_hdr *o); +/* Decrement; at zero, release contents and free — unless the object sits + * in the cycle-candidate buffer (WO_F_BUF): the collector owns its death. */ +void wo_rc_dec(wo_rt *rt, wo_hdr *o); + +/* Budgeted cycle collection step (Bacon–Rajan trial deletion over the + * candidate buffer). Processes up to `budget` buffered roots (whole + * strongly-connected components process atomically, so overshoot is + * bounded); returns how many objects it freed. */ +size_t wo_gc_step(wo_rt *rt, size_t budget); + +#endif /* WO_GC_H */ diff --git a/runtime/src/loader.c b/runtime/src/loader.c new file mode 100644 index 0000000..70dc19b --- /dev/null +++ b/runtime/src/loader.c @@ -0,0 +1,465 @@ +#include "loader.h" + +#include +#include +#include +#include +#include +#include +#include + +/* bounds-checked cursor: every read validates remaining length */ +typedef struct { + const uint8_t *p; + size_t len, off; +} cur_t; + +static int rd(cur_t *c, void *dst, size_t n) { + if (n > c->len - c->off) return -1; + memcpy(dst, c->p + c->off, n); + c->off += n; + return 0; +} +static int rd_u8(cur_t *c, uint8_t *v) { return rd(c, v, 1); } +static int rd_u16(cur_t *c, uint16_t *v) { return rd(c, v, 2); } +static int rd_u32(cur_t *c, uint32_t *v) { return rd(c, v, 4); } +static int rd_u64(cur_t *c, uint64_t *v) { return rd(c, v, 8); } + +/* every rejection frees everything parsed so far and reports a reason */ +#define BAIL(...) \ + do { \ + snprintf(err, errlen, __VA_ARGS__); \ + wo_module_free(m); \ + return -1; \ + } while (0) + +/* per-builtin fixed arity (args at B..B+arity-1); kind-immediate builtins + * (multi_new/map_new) carry kinds in B and take no register args */ +static const uint8_t b_arity[WO_B_MAX + 1] = { + [WO_B_NOW] = 0, [WO_B_PRINT] = 1, [WO_B_PRINT_INT] = 1, + [WO_B_WORDS] = 1, [WO_B_MULTI_NEW] = 0, [WO_B_MULTI_PUSH] = 2, + [WO_B_MULTI_GET] = 2, [WO_B_COUNT] = 1, [WO_B_LATEST] = 1, + [WO_B_MAP_NEW] = 0, [WO_B_MAP_SET] = 3, [WO_B_MAP_GET] = 2, + [WO_B_MAP_HAS] = 2, +}; + +static int vtab_cmp(const void *a, const void *b) { + const wo_vtabent *x = a, *y = b; + if (x->class_id != y->class_id) return x->class_id < y->class_id ? -1 : 1; + if (x->slot != y->slot) return x->slot < y->slot ? -1 : 1; + return 0; +} + +/* Validation contract (what the interpreter is allowed to assume forever): + * magic/version; register counts 1..64 with args <= registers; every opcode + * known; every static register operand < reg_cnt; constant/class/callee/ + * slot indexes in range; CALL argument windows fit the caller's frame; jump + * targets inside the code; the last instruction is a terminator; builtin + * ids in range with arity fitting the frame and kind immediates in range; + * line/drop tables ascending, in range, masks within reg_cnt; vtable ids in + * range, rows unique per (class,slot); entry (if present) a zero-arg free + * fn. GETF/SETF field indexes stay RUNTIME checks (registers are untyped) — + * the spec's residual-check doctrine. */ +int wo_load_buf(wo_module *m, const uint8_t *buf, size_t len, char *err, + size_t errlen) { + memset(m, 0, sizeof(*m)); + m->entry = WOB_NONE; + if (errlen) err[0] = '\0'; + + cur_t h = {buf, len, 0}; + uint32_t magic, ver, coff, ccnt, koff, kcnt, ioff, icnt, moff, mcnt, entry; + if (rd_u32(&h, &magic) || rd_u32(&h, &ver) || rd_u32(&h, &coff) || + rd_u32(&h, &ccnt) || rd_u32(&h, &koff) || rd_u32(&h, &kcnt) || + rd_u32(&h, &ioff) || rd_u32(&h, &icnt) || rd_u32(&h, &moff) || + rd_u32(&h, &mcnt) || rd_u32(&h, &entry)) + BAIL("truncated header"); + if (magic != WOB_MAGIC) BAIL("bad magic"); + if (ver != WOB_VERSION) BAIL("unsupported version %u", (unsigned)ver); + if (coff > len || koff > len || ioff > len || moff > len) + BAIL("section offset out of range"); + + /* ---- constants ---- */ + cur_t c = {buf, len, coff}; + if (ccnt) { + m->consts = calloc(ccnt, sizeof(wo_const)); + if (!m->consts) BAIL("out of memory"); + } + for (uint32_t i = 0; i < ccnt; i++) { + uint8_t tag; + if (rd_u8(&c, &tag)) BAIL("constant %u: truncated", (unsigned)i); + if (tag == WOB_K_INT) { + uint64_t v; + if (rd_u64(&c, &v)) BAIL("constant %u: truncated", (unsigned)i); + m->consts[i].tag = tag; + m->consts[i].i = (int64_t)v; + } else if (tag == WOB_K_TEXT) { + uint32_t l; + if (rd_u32(&c, &l)) BAIL("constant %u: truncated", (unsigned)i); + if (l > c.len - c.off) + BAIL("constant %u: text overruns buffer", (unsigned)i); + wo_str *s = malloc(sizeof(wo_str) + l); + if (!s) BAIL("out of memory"); + memset(&s->h, 0, sizeof(s->h)); + s->h.class_id = WO_CLS_STR; + s->h.flags = WO_F_CONST; /* interned: outlives everything */ + s->len = l; + memcpy(s->data, buf + c.off, l); + c.off += l; + m->consts[i].tag = tag; + m->consts[i].s = s; + } else { + BAIL("constant %u: unknown tag %u", (unsigned)i, (unsigned)tag); + } + m->const_cnt = i + 1; + } + + /* ---- classes (kind bytes pooled; pointers fixed up after parse) ---- */ + cur_t k = {buf, len, koff}; + if (kcnt) { + m->classes = calloc(kcnt, sizeof(wo_classdesc)); + if (!m->classes) BAIL("out of memory"); + } + size_t pool_len = 0; + for (uint32_t i = 0; i < kcnt; i++) { + uint32_t name, flags, fcnt; + if (rd_u32(&k, &name) || rd_u32(&k, &flags) || rd_u32(&k, &fcnt)) + BAIL("class %u: truncated", (unsigned)i); + if (name >= m->const_cnt || m->consts[name].tag != WOB_K_TEXT) + BAIL("class %u: bad name constant", (unsigned)i); + if (flags & ~WO_CLASSF_GC) + BAIL("class %u: unknown flags", (unsigned)i); + if (fcnt > 65535) BAIL("class %u: too many fields", (unsigned)i); + if (fcnt > k.len - k.off) BAIL("class %u: truncated kinds", (unsigned)i); + for (uint32_t j = 0; j < fcnt; j++) + if (buf[k.off + j] > WO_K_MAX) + BAIL("class %u field %u: bad kind", (unsigned)i, (unsigned)j); + uint8_t *np = realloc(m->kindpool, pool_len + (fcnt ? fcnt : 1)); + if (!np) BAIL("out of memory"); + m->kindpool = np; + memcpy(m->kindpool + pool_len, buf + k.off, fcnt); + k.off += fcnt; + uint32_t pad = (4u - fcnt % 4u) % 4u; + if (pad > k.len - k.off) BAIL("class %u: truncated pad", (unsigned)i); + k.off += pad; + m->classes[i].name = name; + m->classes[i].flags = flags; + m->classes[i].field_cnt = fcnt; + m->classes[i].kinds = (const uint8_t *)(uintptr_t)pool_len; /* offset */ + pool_len += fcnt ? fcnt : 1; + m->class_cnt = i + 1; + } + for (uint32_t i = 0; i < m->class_cnt; i++) + m->classes[i].kinds = m->kindpool + (uintptr_t)m->classes[i].kinds; + + /* ---- interfaces + vtable rows (expanded to sorted triples) ---- */ + cur_t s = {buf, len, ioff}; + uint32_t *slotbase = NULL, *imcnt = NULL; +#define BAILI(...) \ + do { \ + free(slotbase); \ + free(imcnt); \ + BAIL(__VA_ARGS__); \ + } while (0) + if (icnt) { + slotbase = malloc(icnt * sizeof(uint32_t)); + imcnt = malloc(icnt * sizeof(uint32_t)); + if (!slotbase || !imcnt) BAILI("out of memory"); + } + uint32_t slots = 0; + for (uint32_t i = 0; i < icnt; i++) { + uint32_t name, mc; + if (rd_u32(&s, &name) || rd_u32(&s, &mc)) + BAILI("interface %u: truncated", (unsigned)i); + if (name >= m->const_cnt || m->consts[name].tag != WOB_K_TEXT) + BAILI("interface %u: bad name constant", (unsigned)i); + if (mc == 0 || mc > 1024) + BAILI("interface %u: bad method count", (unsigned)i); + slotbase[i] = slots; + imcnt[i] = mc; + slots += mc; + } + m->slot_cnt = slots; + uint32_t vrows; + if (rd_u32(&s, &vrows)) BAILI("truncated vtable count"); + for (uint32_t r = 0; r < vrows; r++) { + uint32_t cid, iid; + if (rd_u32(&s, &cid) || rd_u32(&s, &iid)) + BAILI("vtable row %u: truncated", (unsigned)r); + if (cid >= m->class_cnt) BAILI("vtable row %u: bad class", (unsigned)r); + if (iid >= icnt) BAILI("vtable row %u: bad interface", (unsigned)r); + wo_vtabent *nv = realloc( + m->vtabs, (m->vtab_cnt + imcnt[iid]) * sizeof(wo_vtabent)); + if (!nv) BAILI("out of memory"); + m->vtabs = nv; + for (uint32_t j = 0; j < imcnt[iid]; j++) { + uint32_t meth; + if (rd_u32(&s, &meth)) BAILI("vtable row %u: truncated", (unsigned)r); + m->vtabs[m->vtab_cnt].class_id = cid; + m->vtabs[m->vtab_cnt].slot = slotbase[iid] + j; + m->vtabs[m->vtab_cnt].method = meth; /* range-checked below */ + m->vtab_cnt++; + } + } + free(slotbase); + free(imcnt); +#undef BAILI + + /* ---- methods ---- */ + cur_t t = {buf, len, moff}; + if (mcnt) { + m->methods = calloc(mcnt, sizeof(wo_methodrec)); + if (!m->methods) BAIL("out of memory"); + } + for (uint32_t i = 0; i < mcnt; i++) { + wo_methodrec *mm = &m->methods[i]; + uint32_t name, cid, clen; + uint16_t reserved; + if (rd_u32(&t, &name) || rd_u32(&t, &cid) || rd_u8(&t, &mm->arg_cnt) || + rd_u8(&t, &mm->reg_cnt) || rd_u16(&t, &reserved) || + rd_u32(&t, &clen)) + BAIL("method %u: truncated", (unsigned)i); + m->method_cnt = i + 1; /* partial-free safety from here on */ + if (name >= m->const_cnt || m->consts[name].tag != WOB_K_TEXT) + BAIL("method %u: bad name constant", (unsigned)i); + if (cid != WOB_NONE && cid >= m->class_cnt) + BAIL("method %u: bad class", (unsigned)i); + if (mm->reg_cnt < 1 || mm->reg_cnt > WO_MAX_REGS) + BAIL("method %u: register count out of range", (unsigned)i); + if (mm->arg_cnt > mm->reg_cnt) + BAIL("method %u: more args than registers", (unsigned)i); + if (clen == 0 || clen % 4) + BAIL("method %u: bad code length", (unsigned)i); + mm->name = name; + mm->class_id = cid; + mm->ninstr = clen / 4; + mm->code = malloc(clen); /* aligned owned copy */ + if (!mm->code) BAIL("out of memory"); + if (rd(&t, mm->code, clen)) BAIL("method %u: truncated code", (unsigned)i); + + if (rd_u32(&t, &mm->line_cnt)) BAIL("method %u: truncated", (unsigned)i); + if (mm->line_cnt) { + if (mm->line_cnt > mm->ninstr) + BAIL("method %u: line table too long", (unsigned)i); + mm->lines = malloc(mm->line_cnt * sizeof(wo_lineent)); + if (!mm->lines) BAIL("out of memory"); + for (uint32_t j = 0; j < mm->line_cnt; j++) { + if (rd_u32(&t, &mm->lines[j].pc) || rd_u32(&t, &mm->lines[j].line)) + BAIL("method %u: truncated line table", (unsigned)i); + if (mm->lines[j].pc >= mm->ninstr || + (j && mm->lines[j].pc <= mm->lines[j - 1].pc)) + BAIL("method %u: line table not ascending", (unsigned)i); + } + } + if (rd_u32(&t, &mm->drop_cnt)) BAIL("method %u: truncated", (unsigned)i); + if (mm->drop_cnt) { + if (mm->drop_cnt > mm->ninstr) + BAIL("method %u: drop table too long", (unsigned)i); + mm->drops = malloc(mm->drop_cnt * sizeof(wo_dropent)); + if (!mm->drops) BAIL("out of memory"); + for (uint32_t j = 0; j < mm->drop_cnt; j++) { + if (rd_u32(&t, &mm->drops[j].pc) || + rd_u64(&t, &mm->drops[j].owned) || + rd_u64(&t, &mm->drops[j].gc)) + BAIL("method %u: truncated drop table", (unsigned)i); + if (mm->drops[j].pc >= mm->ninstr || + (j && mm->drops[j].pc <= mm->drops[j - 1].pc)) + BAIL("method %u: drop table not ascending", (unsigned)i); + if (mm->reg_cnt < 64 && + ((mm->drops[j].owned | mm->drops[j].gc) >> mm->reg_cnt)) + BAIL("method %u: drop mask out of range", (unsigned)i); + } + } + } + + /* ---- static instruction validation ---- */ + for (uint32_t i = 0; i < m->method_cnt; i++) { + wo_methodrec *mm = &m->methods[i]; + uint32_t regc = mm->reg_cnt; +#define RCHK(r) \ + do { \ + if ((uint32_t)(r) >= regc) \ + BAIL("method %u pc %u: register out of range", (unsigned)i, \ + (unsigned)pc); \ + } while (0) + for (uint32_t pc = 0; pc < mm->ninstr; pc++) { + uint32_t ins = mm->code[pc]; + uint8_t op = wo_ins_op(ins), A = wo_ins_a(ins), B = wo_ins_b(ins), + C = wo_ins_c(ins); + uint16_t bx = wo_ins_bx(ins); + switch (op) { + case WOP_NOP: + break; + case WOP_LOADK: + RCHK(A); + if (bx >= m->const_cnt) + BAIL("method %u pc %u: constant out of range", (unsigned)i, + (unsigned)pc); + break; + case WOP_MOVE: + case WOP_NEG: + RCHK(A); + RCHK(B); + break; + case WOP_ADD: + case WOP_SUB: + case WOP_MUL: + case WOP_DIV: + case WOP_CONCAT: + case WOP_EQ: + case WOP_LT: + case WOP_LE: + case WOP_EQS: + RCHK(A); + RCHK(B); + RCHK(C); + break; + case WOP_JMP: + case WOP_JZ: { + if (op == WOP_JZ) RCHK(A); + int64_t tgt = (int64_t)pc + 1 + wo_ins_sbx(ins); + if (tgt < 0 || tgt >= (int64_t)mm->ninstr) + BAIL("method %u pc %u: jump out of code", (unsigned)i, + (unsigned)pc); + break; + } + case WOP_CALL: { + RCHK(A); + if (bx >= m->method_cnt) + BAIL("method %u pc %u: callee out of range", (unsigned)i, + (unsigned)pc); + if ((uint32_t)A + m->methods[bx].arg_cnt > regc) + BAIL("method %u pc %u: call window exceeds frame", + (unsigned)i, (unsigned)pc); + break; + } + case WOP_ICALL: + RCHK(A); + if (bx >= m->slot_cnt) + BAIL("method %u pc %u: interface slot out of range", + (unsigned)i, (unsigned)pc); + break; + case WOP_RET: + RCHK(A); + break; + case WOP_RET0: + break; + case WOP_NEW: + RCHK(A); + if (bx >= m->class_cnt) + BAIL("method %u pc %u: class out of range", (unsigned)i, + (unsigned)pc); + break; + case WOP_GETF: + RCHK(A); + RCHK(B); + break; /* field idx C: runtime residual check */ + case WOP_SETF: + RCHK(A); + RCHK(C); + break; /* field idx B: runtime residual check */ + case WOP_DROP: + case WOP_BORROW_S: + case WOP_BORROW_X: + case WOP_RELEASE_S: + case WOP_RELEASE_X: + case WOP_RC_INC: + case WOP_RC_DEC: + RCHK(A); + break; + case WOP_BUILTIN: { + RCHK(A); + if (C > WO_B_MAX) + BAIL("method %u pc %u: builtin out of range", (unsigned)i, + (unsigned)pc); + if (C == WO_B_MULTI_NEW) { + if (B > WO_K_MAX) + BAIL("method %u pc %u: bad element kind", (unsigned)i, + (unsigned)pc); + } else if (C == WO_B_MAP_NEW) { + if ((B & 0x0F) > WO_K_MAX || (B >> 4) > WO_K_MAX) + BAIL("method %u pc %u: bad key/value kind", (unsigned)i, + (unsigned)pc); + } else if (b_arity[C]) { + RCHK(B); + RCHK((uint32_t)B + b_arity[C] - 1); + } + break; + } + case WOP_DB_STUB: + case WOP_TRAP: + break; + default: + BAIL("method %u pc %u: unknown opcode %u", (unsigned)i, + (unsigned)pc, (unsigned)op); + } + } +#undef RCHK + uint8_t last = wo_ins_op(mm->code[mm->ninstr - 1]); + if (last != WOP_RET && last != WOP_RET0 && last != WOP_TRAP && + last != WOP_DB_STUB && last != WOP_JMP) + BAIL("method %u: last instruction is not a terminator", (unsigned)i); + } + + /* vtable method indexes (methods parsed now); sort; reject duplicates */ + for (uint32_t i = 0; i < m->vtab_cnt; i++) + if (m->vtabs[i].method >= m->method_cnt) + BAIL("vtable entry %u: method out of range", (unsigned)i); + if (m->vtab_cnt) + qsort(m->vtabs, m->vtab_cnt, sizeof(wo_vtabent), vtab_cmp); + for (uint32_t i = 1; i < m->vtab_cnt; i++) + if (m->vtabs[i].class_id == m->vtabs[i - 1].class_id && + m->vtabs[i].slot == m->vtabs[i - 1].slot) + BAIL("duplicate vtable entry for class %u", + (unsigned)m->vtabs[i].class_id); + + /* entry: a zero-arg free fn */ + if (entry != WOB_NONE) { + if (entry >= m->method_cnt) BAIL("entry method out of range"); + if (m->methods[entry].arg_cnt != 0 || + m->methods[entry].class_id != WOB_NONE) + BAIL("entry must be a zero-arg free fn"); + } + m->entry = entry; + return 0; +} + +int wo_load_file(wo_module *m, const char *path, char *err, size_t errlen) { + memset(m, 0, sizeof(*m)); + m->entry = WOB_NONE; + int fd = open(path, O_RDONLY); + if (fd < 0) { + snprintf(err, errlen, "cannot open %s", path); + return -1; + } + struct stat st; + if (fstat(fd, &st) != 0 || st.st_size <= 0) { + close(fd); + snprintf(err, errlen, "cannot stat %s (or empty)", path); + return -1; + } + void *p = mmap(NULL, (size_t)st.st_size, PROT_READ, MAP_PRIVATE, fd, 0); + close(fd); + if (p == MAP_FAILED) { + snprintf(err, errlen, "cannot mmap %s", path); + return -1; + } + int rc = wo_load_buf(m, p, (size_t)st.st_size, err, errlen); + munmap(p, (size_t)st.st_size); + return rc; +} + +void wo_module_free(wo_module *m) { + for (uint32_t i = 0; i < m->const_cnt; i++) free(m->consts[i].s); + free(m->consts); + free(m->classes); + free(m->kindpool); + free(m->vtabs); + for (uint32_t i = 0; i < m->method_cnt; i++) { + free(m->methods[i].code); + free(m->methods[i].lines); + free(m->methods[i].drops); + } + free(m->methods); + memset(m, 0, sizeof(*m)); + m->entry = WOB_NONE; +} diff --git a/runtime/src/loader.h b/runtime/src/loader.h new file mode 100644 index 0000000..91e5d9c --- /dev/null +++ b/runtime/src/loader.h @@ -0,0 +1,70 @@ +/* loader.h — .wob loading + the full static-validation contract. + * The "no UB on any input" constraint lives here: everything is parsed + * through a bounds-checked cursor and COPIED OUT into aligned, malloc'd + * structures (misaligned files and input-buffer lifetimes are non-issues); + * text constants intern as const-flagged strings. What the loader accepts, + * the interpreter trusts forever — the validation list is in loader.c. + * Field indexes on GETF/SETF stay runtime checks (registers are untyped): + * the spec's residual-check doctrine. */ +#ifndef WO_LOADER_H +#define WO_LOADER_H + +#include "obj.h" + +typedef struct wo_lineent { + uint32_t pc, line; +} wo_lineent; + +typedef struct wo_dropent { + uint32_t pc; + uint64_t owned; /* register bitmask: live owned values at pc */ + uint64_t gc; /* register bitmask: live @gc references at pc */ +} wo_dropent; + +typedef struct wo_methodrec { + uint32_t name; /* constant index of a text constant */ + uint32_t class_id; /* WOB_NONE = free fn */ + uint8_t arg_cnt, reg_cnt; + uint32_t *code; /* aligned owned copy */ + uint32_t ninstr; + wo_lineent *lines; + uint32_t line_cnt; + wo_dropent *drops; + uint32_t drop_cnt; +} wo_methodrec; + +typedef struct wo_const { + uint8_t tag; /* WOB_K_INT / WOB_K_TEXT */ + int64_t i; /* tag INT */ + wo_str *s; /* tag TEXT: interned, WO_F_CONST, module-owned */ +} wo_const; + +/* vtable rows expanded flat and sorted by (class_id, slot) for binary + * search at ICALL time */ +typedef struct wo_vtabent { + uint32_t class_id, slot, method; +} wo_vtabent; + +typedef struct wo_module { + wo_const *consts; + uint32_t const_cnt; + wo_classdesc *classes; + uint32_t class_cnt; + uint8_t *kindpool; /* pooled field-kind bytes the classes point into */ + uint32_t slot_cnt; /* total interface slots across all interfaces */ + wo_vtabent *vtabs; + uint32_t vtab_cnt; + wo_methodrec *methods; + uint32_t method_cnt; + uint32_t entry; /* WOB_NONE = none */ +} wo_module; + +/* 0 ok; -1 reject with a human-readable reason in err (never empty) and + * everything parsed so far freed. */ +int wo_load_buf(wo_module *m, const uint8_t *buf, size_t len, char *err, + size_t errlen); +/* mmap -> parse -> munmap */ +int wo_load_file(wo_module *m, const char *path, char *err, size_t errlen); +void wo_module_free(wo_module *m); + +#endif /* WO_LOADER_H */ diff --git a/runtime/src/main.c b/runtime/src/main.c new file mode 100644 index 0000000..694780b --- /dev/null +++ b/runtime/src/main.c @@ -0,0 +1,50 @@ +/* main.c — the wovm CLI. Exit-code contract the toolchain scripts against: + * 0 = ran to completion + * 1 = trap; one stderr line: "trap CODE in METHOD at line N: MESSAGE" + * 2 = usage or load failure (loader's message on stderr) + * Heap cap defaults to 64 MiB, overridable via WO_HEAP_MB. */ +#include +#include + +#include "vm.h" + +static wo_vm VM; /* 32K value stack: keep it off the C stack */ + +int main(int argc, char **argv) { + if (argc != 2) { + fprintf(stderr, "usage: wovm \n"); + return 2; + } + wo_module mod; + char err[256]; + if (wo_load_file(&mod, argv[1], err, sizeof err) != 0) { + fprintf(stderr, "wovm: %s\n", err); + return 2; + } + if (mod.entry == WOB_NONE) { + fprintf(stderr, "wovm: module has no entry method\n"); + wo_module_free(&mod); + return 2; + } + size_t heap_mb = 64; + const char *env = getenv("WO_HEAP_MB"); + if (env && env[0]) { + char *end = NULL; + unsigned long v = strtoul(env, &end, 10); + if (end && *end == '\0' && v >= 1 && v <= 1048576) heap_mb = v; + } + if (wo_vm_init(&VM, &mod, heap_mb << 20) != 0) { + fprintf(stderr, "wovm: cannot allocate %zu MiB heap\n", heap_mb); + wo_module_free(&mod); + return 2; + } + uint64_t ret = 0; + wo_err terr; + int rc = wo_vm_call(&VM, mod.entry, NULL, 0, &ret, &terr); + if (rc != 0) + fprintf(stderr, "trap %u in %s at line %u: %s\n", (unsigned)terr.code, + terr.method, (unsigned)terr.line, terr.msg); + wo_vm_destroy(&VM); + wo_module_free(&mod); + return rc == 0 ? 0 : 1; +} diff --git a/runtime/src/obj.c b/runtime/src/obj.c new file mode 100644 index 0000000..9a10dd5 --- /dev/null +++ b/runtime/src/obj.c @@ -0,0 +1,110 @@ +#include "obj.h" + +#include +#include +#include + +static size_t round16(size_t n) { return (n + 15u) & ~(size_t)15u; } + +int wo_arena_init(wo_arena *a, size_t cap) { + memset(a, 0, sizeof(*a)); + a->base = malloc(cap ? cap : 1); + if (!a->base) return -1; + a->cap = cap; + return 0; +} + +void wo_arena_destroy(wo_arena *a) { + free(a->base); + memset(a, 0, sizeof(*a)); +} + +void *wo_arena_alloc(wo_arena *a, size_t size) { + size = round16(size ? size : 1); + if (size > WO_ARENA_MAX_CLASS) return malloc(size); + size_t cls = size / 16u - 1u; + if (a->freelist[cls]) { + void *p = a->freelist[cls]; + memcpy(&a->freelist[cls], p, sizeof(void *)); + return p; + } + if (a->used + size > a->cap) return NULL; /* region OOM -> trap upstream */ + void *p = a->base + a->used; + a->used += size; + return p; +} + +void wo_arena_free(wo_arena *a, void *p, size_t size) { + if (!p) return; + size = round16(size ? size : 1); + if (size > WO_ARENA_MAX_CLASS) { + free(p); + return; + } + size_t cls = size / 16u - 1u; + memcpy(p, &a->freelist[cls], sizeof(void *)); + a->freelist[cls] = p; +} + +int wo_rt_init(wo_rt *rt, size_t heap_cap, const wo_classdesc *classes, + uint32_t class_cnt) { + memset(rt, 0, sizeof(*rt)); + if (wo_arena_init(&rt->arena, heap_cap) != 0) return -1; + rt->classes = classes; + rt->class_cnt = class_cnt; + rt->out = stdout; + return 0; +} + +void wo_rt_destroy(wo_rt *rt) { + free(rt->cycbuf.items); + wo_arena_destroy(&rt->arena); + memset(rt, 0, sizeof(*rt)); +} + +wo_hdr *wo_obj_new(wo_rt *rt, uint32_t class_id) { + const wo_classdesc *c = &rt->classes[class_id]; + size_t sz = wo_obj_size(c); + wo_hdr *o = wo_arena_alloc(&rt->arena, sz); + if (!o) return NULL; + memset(o, 0, sz); + o->class_id = class_id; + if (c->flags & WO_CLASSF_GC) { + o->flags = WO_F_GC; + o->rc = 1; /* the creating reference */ + } + return o; +} + +static wo_str *str_alloc(wo_rt *rt, uint32_t len) { + wo_str *s = wo_arena_alloc(&rt->arena, sizeof(wo_str) + len); + if (!s) return NULL; + memset(&s->h, 0, sizeof(s->h)); + s->h.class_id = WO_CLS_STR; + s->len = len; + return s; +} + +wo_str *wo_str_new(wo_rt *rt, const char *bytes, uint32_t len) { + wo_str *s = str_alloc(rt, len); + if (!s) return NULL; + memcpy(s->data, bytes, len); + return s; +} + +wo_str *wo_str_concat(wo_rt *rt, const wo_str *a, const wo_str *b) { + wo_str *s = str_alloc(rt, a->len + b->len); + if (!s) return NULL; + memcpy(s->data, a->data, a->len); + memcpy(s->data + a->len, b->data, b->len); + return s; +} + +int wo_str_eq(const wo_str *a, const wo_str *b) { + return a->len == b->len && memcmp(a->data, b->data, a->len) == 0; +} + +void wo_str_free(wo_rt *rt, wo_str *s) { + if (!s || (s->h.flags & WO_F_CONST)) return; /* interned: outlives all */ + wo_arena_free(&rt->arena, s, sizeof(wo_str) + s->len); +} diff --git a/runtime/src/obj.h b/runtime/src/obj.h new file mode 100644 index 0000000..45807e6 --- /dev/null +++ b/runtime/src/obj.h @@ -0,0 +1,63 @@ +/* obj.h — per-shard arena allocator (spec section 4). Object model and + * strings extend this header in the next task. */ +#ifndef WO_OBJ_H +#define WO_OBJ_H + +#include "wob.h" + +/* Arena: one malloc'd region, bump allocation, 16-byte-granular size-class + * free lists up to 1024 bytes (freed blocks chain through their first word). + * Larger sizes use plain malloc/free — the caller always passes the size + * back on free, so no size headers exist anywhere. Exhaustion returns NULL; + * the VM maps that to WO_T_OOM, never an abort. */ +#define WO_ARENA_MAX_CLASS 1024u +#define WO_ARENA_NCLASSES (WO_ARENA_MAX_CLASS / 16u) /* 64 lists: 16..1024 */ + +typedef struct wo_arena { + uint8_t *base; /* malloc'd region */ + size_t cap; /* region capacity in bytes */ + size_t used; /* bump offset */ + void *freelist[WO_ARENA_NCLASSES]; /* index = size/16 - 1 */ +} wo_arena; + +int wo_arena_init(wo_arena *a, size_t cap); /* 0 ok, -1 malloc failure */ +void wo_arena_destroy(wo_arena *a); +void *wo_arena_alloc(wo_arena *a, size_t size); /* NULL = region OOM */ +void wo_arena_free(wo_arena *a, void *p, size_t size); + +/* Runtime context: what every module needs. One per shard (one total in + * milestone 1): the arena, the loaded class table, the cycle-candidate + * buffer (filled by gc.c), and the output stream builtin print writes to + * (tests point it at a temp file to capture output). */ +typedef struct wo_rt { + wo_arena arena; + const wo_classdesc *classes; + uint32_t class_cnt; + struct { + wo_hdr **items; + size_t len, cap; + } cycbuf; + void *out; /* FILE*; kept void* so obj.h needn't pull in stdio */ +} wo_rt; + +int wo_rt_init(wo_rt *rt, size_t heap_cap, const wo_classdesc *classes, + uint32_t class_cnt); /* 0 ok, -1 alloc failure; out = stdout */ +void wo_rt_destroy(wo_rt *rt); + +/* New zeroed instance of a class-table class. @gc classes get the GC flag + * and rc 1 (the creating reference). NULL = OOM (VM traps WO_T_OOM). */ +wo_hdr *wo_obj_new(wo_rt *rt, uint32_t class_id); + +/* Strings: header + length + inline bytes, class id WO_CLS_STR. */ +typedef struct wo_str { + wo_hdr h; + uint32_t len; + char data[]; /* len bytes, no NUL */ +} wo_str; + +wo_str *wo_str_new(wo_rt *rt, const char *bytes, uint32_t len); /* NULL=OOM */ +wo_str *wo_str_concat(wo_rt *rt, const wo_str *a, const wo_str *b); +int wo_str_eq(const wo_str *a, const wo_str *b); /* content equality */ +void wo_str_free(wo_rt *rt, wo_str *s); /* no-op on WO_F_CONST */ + +#endif /* WO_OBJ_H */ diff --git a/runtime/src/vm.c b/runtime/src/vm.c new file mode 100644 index 0000000..caeb5db --- /dev/null +++ b/runtime/src/vm.c @@ -0,0 +1,474 @@ +#include "vm.h" + +#include +#include +#include + +#include "borrow.h" +#include "builtin.h" +#include "cont.h" +#include "gc.h" + +uint32_t wo_vm_depth(const wo_vm *vm) { return vm->depth; } + +int wo_vm_init(wo_vm *vm, const wo_module *mod, size_t heap_cap) { + memset(vm, 0, sizeof(*vm)); + vm->mod = mod; + return wo_rt_init(&vm->rt, heap_cap, mod->classes, mod->class_cnt); +} + +void wo_vm_destroy(wo_vm *vm) { wo_rt_destroy(&vm->rt); } + +/* Trap unwinding — the spec's "traps never leak" promise (spec §6). Walk + * frames innermost to outermost; in each, look up the drop-table entry for + * that frame's current instruction (the trap pc for the innermost frame; + * the instruction before the saved resume pc — i.e. the CALL — for outer + * frames); apply the owned mask by recursive drop and the gc mask by + * decrement, nulling registers as they go. A borrow held by a dying + * register does not block its drop — the borrower IS the dying frame. + * Window overlap is safe: a slot dropped by the callee frame is nulled, so + * an outer mask covering the same physical slot sees 0 and skips. */ +static void vm_unwind(wo_vm *vm) { + const wo_module *mod = vm->mod; + for (uint32_t d = vm->depth; d > 0; d--) { + const wo_frame *f = &vm->frames[d - 1]; + const wo_methodrec *me = &mod->methods[f->method]; + uint32_t fpc = (d == vm->depth) ? f->pc : f->pc - 1; + const wo_dropent *ent = NULL; /* last entry with pc <= fpc */ + for (uint32_t i = 0; i < me->drop_cnt && me->drops[i].pc <= fpc; i++) + ent = &me->drops[i]; + if (!ent) continue; /* no entry: nothing live in this frame */ + uint64_t *R = vm->regs + f->base; + for (uint32_t r = 0; r < me->reg_cnt; r++) { + uint64_t bit = 1ull << r; + if ((ent->owned & bit) && R[r]) { + wo_drop_obj(&vm->rt, (wo_hdr *)(uintptr_t)R[r]); + R[r] = 0; + } + if ((ent->gc & bit) && R[r]) { + wo_rc_dec(&vm->rt, (wo_hdr *)(uintptr_t)R[r]); + R[r] = 0; + } + } + } + vm->depth = 0; +} + +/* Residual runtime checks the loader cannot do statically (registers are + * untyped): non-null receiver, an actual class object (not a native + * sentinel), field index inside the class. NULL return = trap BOUNDS with + * *why naming the reason. */ +static wo_hdr *recv_check(wo_vm *vm, uint64_t v, uint32_t fidx, + const char **why) { + if (!v) { + *why = "null receiver"; + return NULL; + } + wo_hdr *o = (wo_hdr *)(uintptr_t)v; + if (o->class_id >= vm->mod->class_cnt) { + *why = "native object has no fields"; + return NULL; + } + if (fidx >= vm->mod->classes[o->class_id].field_cnt) { + *why = "field index out of range"; + return NULL; + } + return o; +} + +static wo_str *str_check(uint64_t v, const char **why) { + if (!v) { + *why = "null text"; + return NULL; + } + wo_str *s = (wo_str *)(uintptr_t)v; + if (s->h.class_id != WO_CLS_STR) { + *why = "not a text value"; + return NULL; + } + return s; +} + +static int vm_trap(wo_vm *vm, wo_err *err, uint32_t tcode, const char *fmt, + ...) { + if (err) { + const wo_module *mod = vm->mod; + const wo_frame *f = &vm->frames[vm->depth - 1]; + const wo_methodrec *me = &mod->methods[f->method]; + err->code = tcode; + err->line = 0; /* last line-table entry with pc <= trapping pc */ + for (uint32_t i = 0; i < me->line_cnt && me->lines[i].pc <= f->pc; i++) + err->line = me->lines[i].line; + const wo_str *nm = mod->consts[me->name].s; + int nlen = nm->len < 63 ? (int)nm->len : 63; + snprintf(err->method, sizeof(err->method), "%.*s", nlen, nm->data); + va_list ap; + va_start(ap, fmt); + vsnprintf(err->msg, sizeof(err->msg), fmt, ap); + va_end(ap); + } + vm_unwind(vm); + return -1; +} + +static int vm_run(wo_vm *vm, uint64_t *ret, wo_err *err) { + const wo_module *mod = vm->mod; + const wo_methodrec *me; + const uint32_t *code; + uint32_t pc; + uint64_t *R; + uint32_t ins = 0; + +#define RELOAD() \ + do { \ + me = &mod->methods[vm->frames[vm->depth - 1].method]; \ + code = me->code; \ + pc = vm->frames[vm->depth - 1].pc; \ + R = vm->regs + vm->frames[vm->depth - 1].base; \ + } while (0) + +/* pc is post-incremented at dispatch: the trapping instruction is pc-1 */ +#define TRAPF(tcode, ...) \ + do { \ + vm->frames[vm->depth - 1].pc = pc - 1; \ + return vm_trap(vm, err, tcode, __VA_ARGS__); \ + } while (0) + + RELOAD(); + + /* dual-flavor dispatch, one shared case-body text (spec §5): computed + * goto under GNU C, plain switch under -DWO_ISO_C — the ISO flavor has + * its own make target so the fallback can never rot */ +#ifndef WO_ISO_C + static const void *JT[WOP_MAX + 1] = { + [WOP_NOP] = &&L_NOP, [WOP_LOADK] = &&L_LOADK, + [WOP_MOVE] = &&L_MOVE, [WOP_ADD] = &&L_ADD, + [WOP_SUB] = &&L_SUB, [WOP_MUL] = &&L_MUL, + [WOP_DIV] = &&L_DIV, [WOP_NEG] = &&L_NEG, + [WOP_CONCAT] = &&L_CONCAT, [WOP_EQ] = &&L_EQ, + [WOP_LT] = &&L_LT, [WOP_LE] = &&L_LE, + [WOP_EQS] = &&L_EQS, [WOP_JMP] = &&L_JMP, + [WOP_JZ] = &&L_JZ, [WOP_CALL] = &&L_CALL, + [WOP_ICALL] = &&L_ICALL, [WOP_RET] = &&L_RET, + [WOP_RET0] = &&L_RET0, [WOP_NEW] = &&L_NEW, + [WOP_GETF] = &&L_GETF, [WOP_SETF] = &&L_SETF, + [WOP_DROP] = &&L_DROP, [WOP_BORROW_S] = &&L_BORROW_S, + [WOP_BORROW_X] = &&L_BORROW_X, [WOP_RELEASE_S] = &&L_RELEASE_S, + [WOP_RELEASE_X] = &&L_RELEASE_X, [WOP_RC_INC] = &&L_RC_INC, + [WOP_RC_DEC] = &&L_RC_DEC, [WOP_BUILTIN] = &&L_BUILTIN, + [WOP_DB_STUB] = &&L_DB_STUB, [WOP_TRAP] = &&L_TRAP, + }; +#define CASE(name) L_##name +#define NEXT() \ + do { \ + ins = code[pc++]; \ + goto *JT[wo_ins_op(ins)]; \ + } while (0) + NEXT(); +#else +#define CASE(name) case WOP_##name +#define NEXT() goto dispatch +dispatch: + ins = code[pc++]; + switch (wo_ins_op(ins)) { +#endif + + CASE(NOP) : NEXT(); + + CASE(LOADK) : { + const wo_const *k = &mod->consts[wo_ins_bx(ins)]; + R[wo_ins_a(ins)] = k->tag == WOB_K_INT ? (uint64_t)k->i + : (uint64_t)(uintptr_t)k->s; + NEXT(); + } + + CASE(MOVE) : { + /* for owned values this IS the move: the compiler guarantees the + * source register is dead afterwards */ + R[wo_ins_a(ins)] = R[wo_ins_b(ins)]; + NEXT(); + } + + /* i64 arithmetic: two's-complement wrapping via unsigned math */ + CASE(ADD) : { + R[wo_ins_a(ins)] = R[wo_ins_b(ins)] + R[wo_ins_c(ins)]; + NEXT(); + } + CASE(SUB) : { + R[wo_ins_a(ins)] = R[wo_ins_b(ins)] - R[wo_ins_c(ins)]; + NEXT(); + } + CASE(MUL) : { + R[wo_ins_a(ins)] = R[wo_ins_b(ins)] * R[wo_ins_c(ins)]; + NEXT(); + } + CASE(DIV) : { + int64_t x = (int64_t)R[wo_ins_b(ins)], y = (int64_t)R[wo_ins_c(ins)]; + if (y == 0) TRAPF(WO_T_DIV0, "division by zero"); + if (x == INT64_MIN && y == -1) + TRAPF(WO_T_DIV0, "INT64_MIN / -1 overflows"); + R[wo_ins_a(ins)] = (uint64_t)(x / y); + NEXT(); + } + CASE(NEG) : { + R[wo_ins_a(ins)] = 0u - R[wo_ins_b(ins)]; + NEXT(); + } + + CASE(EQ) : { + R[wo_ins_a(ins)] = R[wo_ins_b(ins)] == R[wo_ins_c(ins)] ? 1 : 0; + NEXT(); + } + CASE(LT) : { + R[wo_ins_a(ins)] = + (int64_t)R[wo_ins_b(ins)] < (int64_t)R[wo_ins_c(ins)] ? 1 : 0; + NEXT(); + } + CASE(LE) : { + R[wo_ins_a(ins)] = + (int64_t)R[wo_ins_b(ins)] <= (int64_t)R[wo_ins_c(ins)] ? 1 : 0; + NEXT(); + } + + CASE(JMP) : { + pc = (uint32_t)((int64_t)pc + wo_ins_sbx(ins)); + NEXT(); + } + CASE(JZ) : { + if (R[wo_ins_a(ins)] == 0) + pc = (uint32_t)((int64_t)pc + wo_ins_sbx(ins)); + NEXT(); + } + + CASE(CALL) : { + /* Lua-style window overlap: callee r0 = caller slot A; args sit at + * A..A+argc-1; the return value lands back in slot A */ + const wo_methodrec *callee = &mod->methods[wo_ins_bx(ins)]; + uint32_t nbase = vm->frames[vm->depth - 1].base + wo_ins_a(ins); + if (vm->depth >= WO_MAX_FRAMES) + TRAPF(WO_T_STACK, "frame stack overflow (%u frames)", + (unsigned)WO_MAX_FRAMES); + if (nbase + callee->reg_cnt > WO_STACK_SLOTS) + TRAPF(WO_T_STACK, "value stack overflow"); + vm->frames[vm->depth - 1].pc = pc; + vm->frames[vm->depth].method = wo_ins_bx(ins); + vm->frames[vm->depth].pc = 0; + vm->frames[vm->depth].base = nbase; + vm->depth++; + /* zero non-argument registers: drop masks must never see stale bits */ + memset(vm->regs + nbase + callee->arg_cnt, 0, + (size_t)(callee->reg_cnt - callee->arg_cnt) * 8u); + RELOAD(); + NEXT(); + } + + CASE(RET) : { + uint64_t rv = R[wo_ins_a(ins)]; + vm->regs[vm->frames[vm->depth - 1].base] = rv; + vm->depth--; + if (vm->depth == 0) { + *ret = rv; + return 0; + } + RELOAD(); + NEXT(); + } + CASE(RET0) : { + vm->regs[vm->frames[vm->depth - 1].base] = 0; + vm->depth--; + if (vm->depth == 0) { + *ret = 0; + return 0; + } + RELOAD(); + NEXT(); + } + + CASE(NEW) : { + wo_hdr *o = wo_obj_new(&vm->rt, wo_ins_bx(ins)); + if (!o) TRAPF(WO_T_OOM, "out of memory"); + R[wo_ins_a(ins)] = (uint64_t)(uintptr_t)o; + NEXT(); + } + + CASE(GETF) : { + const char *why; + wo_hdr *o = recv_check(vm, R[wo_ins_b(ins)], wo_ins_c(ins), &why); + if (!o) TRAPF(WO_T_BOUNDS, "%s", why); + R[wo_ins_a(ins)] = wo_fields(o)[wo_ins_c(ins)]; + NEXT(); + } + + CASE(SETF) : { + /* overwriting a non-scalar field does NOT auto-drop the old value: + * the compiler emits the drop (format doc) */ + const char *why; + wo_hdr *o = recv_check(vm, R[wo_ins_a(ins)], wo_ins_b(ins), &why); + if (!o) TRAPF(WO_T_BOUNDS, "%s", why); + wo_fields(o)[wo_ins_b(ins)] = R[wo_ins_c(ins)]; + NEXT(); + } + + CASE(DROP) : { + uint64_t v = R[wo_ins_a(ins)]; + if (v) wo_drop_obj(&vm->rt, (wo_hdr *)(uintptr_t)v); + R[wo_ins_a(ins)] = 0; /* unwinding must never double-free */ + NEXT(); + } + + CASE(BORROW_S) : { + uint64_t v = R[wo_ins_a(ins)]; + if (!v) TRAPF(WO_T_BOUNDS, "null receiver"); + if (wo_borrow_shared((wo_hdr *)(uintptr_t)v) != 0) + TRAPF(WO_T_BORROW, "shared borrow of exclusively borrowed value"); + NEXT(); + } + CASE(BORROW_X) : { + uint64_t v = R[wo_ins_a(ins)]; + if (!v) TRAPF(WO_T_BOUNDS, "null receiver"); + if (wo_borrow_excl((wo_hdr *)(uintptr_t)v) != 0) + TRAPF(WO_T_BORROW, "exclusive borrow of already borrowed value"); + NEXT(); + } + CASE(RELEASE_S) : { + /* releases are unconditional: the compiler emits them balanced */ + wo_release_shared((wo_hdr *)(uintptr_t)R[wo_ins_a(ins)]); + NEXT(); + } + CASE(RELEASE_X) : { + wo_release_excl((wo_hdr *)(uintptr_t)R[wo_ins_a(ins)]); + NEXT(); + } + + CASE(RC_INC) : { + uint64_t v = R[wo_ins_a(ins)]; + if (!v) TRAPF(WO_T_BOUNDS, "null receiver"); + wo_rc_inc((wo_hdr *)(uintptr_t)v); + NEXT(); + } + CASE(RC_DEC) : { + uint64_t v = R[wo_ins_a(ins)]; + if (!v) TRAPF(WO_T_BOUNDS, "null receiver"); + wo_rc_dec(&vm->rt, (wo_hdr *)(uintptr_t)v); + NEXT(); + } + + CASE(CONCAT) : { + const char *why; + wo_str *x = str_check(R[wo_ins_b(ins)], &why); + if (!x) TRAPF(WO_T_BOUNDS, "%s", why); + wo_str *y = str_check(R[wo_ins_c(ins)], &why); + if (!y) TRAPF(WO_T_BOUNDS, "%s", why); + wo_str *z = wo_str_concat(&vm->rt, x, y); + if (!z) TRAPF(WO_T_OOM, "out of memory"); + R[wo_ins_a(ins)] = (uint64_t)(uintptr_t)z; /* new owned text */ + NEXT(); + } + CASE(EQS) : { + const char *why; + wo_str *x = str_check(R[wo_ins_b(ins)], &why); + if (!x) TRAPF(WO_T_BOUNDS, "%s", why); + wo_str *y = str_check(R[wo_ins_c(ins)], &why); + if (!y) TRAPF(WO_T_BOUNDS, "%s", why); + R[wo_ins_a(ins)] = wo_str_eq(x, y) ? 1 : 0; + NEXT(); + } + + CASE(BUILTIN) : { + const char *bmsg = "builtin failed"; + int brc = wo_builtin(vm, R, ins, &bmsg); + if (brc) TRAPF((uint32_t)brc, "%s", bmsg); + NEXT(); + } + + CASE(ICALL) : { + /* structural-interface dispatch (spec §2): binary search the sorted + * (class, slot, method) triples by the RECEIVER's class. The + * compiler's type checker makes a miss unreachable in compiled + * code; the VM keeps the trap as defense (spec §6). */ + uint64_t v = R[wo_ins_a(ins)]; + if (!v) TRAPF(WO_T_BOUNDS, "null receiver"); + wo_hdr *o = (wo_hdr *)(uintptr_t)v; + if (o->class_id >= mod->class_cnt) + TRAPF(WO_T_BOUNDS, "interface call on a native value"); + uint32_t slot = wo_ins_bx(ins); + const wo_vtabent *hit = NULL; + for (uint32_t lo = 0, hi = mod->vtab_cnt; lo < hi;) { + uint32_t mid = lo + (hi - lo) / 2; + const wo_vtabent *e = &mod->vtabs[mid]; + if (e->class_id < o->class_id || + (e->class_id == o->class_id && e->slot < slot)) { + lo = mid + 1; + } else if (e->class_id == o->class_id && e->slot == slot) { + hit = e; + break; + } else { + hi = mid; + } + } + if (!hit) TRAPF(WO_T_BOUNDS, "no vtable entry for receiver class"); + /* exactly the CALL sequence at the same window base: the receiver + * already sits in slot A = callee's self */ + const wo_methodrec *callee = &mod->methods[hit->method]; + if ((uint32_t)wo_ins_a(ins) + callee->arg_cnt > me->reg_cnt) + TRAPF(WO_T_STACK, "call window exceeds frame"); /* runtime: callee + unknown to the loader here */ + uint32_t nbase = vm->frames[vm->depth - 1].base + wo_ins_a(ins); + if (vm->depth >= WO_MAX_FRAMES) + TRAPF(WO_T_STACK, "frame stack overflow (%u frames)", + (unsigned)WO_MAX_FRAMES); + if (nbase + callee->reg_cnt > WO_STACK_SLOTS) + TRAPF(WO_T_STACK, "value stack overflow"); + vm->frames[vm->depth - 1].pc = pc; + vm->frames[vm->depth].method = hit->method; + vm->frames[vm->depth].pc = 0; + vm->frames[vm->depth].base = nbase; + vm->depth++; + memset(vm->regs + nbase + callee->arg_cnt, 0, + (size_t)(callee->reg_cnt - callee->arg_cnt) * 8u); + RELOAD(); + NEXT(); + } + + CASE(DB_STUB) : { TRAPF(WO_T_DB, "engine not linked"); } + + CASE(TRAP) : { TRAPF(wo_ins_bx(ins), "explicit trap"); } + +#ifdef WO_ISO_C + default: + TRAPF(WO_T_EXPLICIT, "unknown opcode"); /* unreachable: loader */ + } +#endif + +#undef CASE +#undef NEXT +#undef RELOAD +#undef TRAPF +} + +int wo_vm_call(wo_vm *vm, uint32_t method_idx, const uint64_t *args, + uint32_t argc, uint64_t *ret, wo_err *err) { + if (err) memset(err, 0, sizeof(*err)); + if (method_idx >= vm->mod->method_cnt) { + if (err) { + err->code = WO_T_EXPLICIT; + snprintf(err->msg, sizeof(err->msg), "no such method"); + } + return -1; + } + const wo_methodrec *me = &vm->mod->methods[method_idx]; + if (argc != me->arg_cnt) { + if (err) { + err->code = WO_T_EXPLICIT; + snprintf(err->msg, sizeof(err->msg), "bad call arity"); + } + return -1; + } + vm->depth = 1; + vm->frames[0].method = method_idx; + vm->frames[0].pc = 0; + vm->frames[0].base = 0; + if (argc) memcpy(vm->regs, args, (size_t)argc * 8u); + memset(vm->regs + argc, 0, (size_t)(me->reg_cnt - argc) * 8u); + return vm_run(vm, ret, err); +} diff --git a/runtime/src/vm.h b/runtime/src/vm.h new file mode 100644 index 0000000..017a2b2 --- /dev/null +++ b/runtime/src/vm.h @@ -0,0 +1,44 @@ +/* vm.h — the register interpreter (spec §5): Lua-style window-overlap + * calls, dual-flavor dispatch (computed goto / WO_ISO_C switch), structured + * trap errors with line lookup, drop-map unwinding on trap. */ +#ifndef WO_VM_H +#define WO_VM_H + +#include "loader.h" + +/* structured trap error (spec §6): one shape forever — the CLI prints it, + * the future service layer maps it to HTTP */ +typedef struct wo_err { + uint32_t code; /* WO_T_* */ + uint32_t line; /* source line at the trapping pc; 0 = unknown */ + char method[64]; /* name of the trapping method */ + char msg[96]; /* human-readable reason */ +} wo_err; + +typedef struct wo_frame { + uint32_t method; /* method index */ + uint32_t pc; /* saved resume pc (next instruction) */ + uint32_t base; /* register-window base in the value stack */ +} wo_frame; + +typedef struct wo_vm { + const wo_module *mod; + wo_rt rt; + uint64_t regs[WO_STACK_SLOTS]; + wo_frame frames[WO_MAX_FRAMES]; + uint32_t depth; +} wo_vm; + +/* heap_cap = arena byte capacity (the CLI's WO_HEAP_MB feeds this) */ +int wo_vm_init(wo_vm *vm, const wo_module *mod, size_t heap_cap); +void wo_vm_destroy(wo_vm *vm); + +/* Call a method with raw argument words. argc must equal the method's + * declared arity. 0 = done, *ret filled; -1 = trapped, *err filled and the + * stack fully unwound (depth 0). */ +int wo_vm_call(wo_vm *vm, uint32_t method_idx, const uint64_t *args, + uint32_t argc, uint64_t *ret, wo_err *err); + +uint32_t wo_vm_depth(const wo_vm *vm); + +#endif /* WO_VM_H */ diff --git a/runtime/src/wob.h b/runtime/src/wob.h new file mode 100644 index 0000000..c629884 --- /dev/null +++ b/runtime/src/wob.h @@ -0,0 +1,177 @@ +/* wob.h — the .wob v1 compiler↔VM contract. + * Single source of truth for format constants, opcodes, field kinds, trap + * codes, builtin ids, limits, and the 16-byte object header shared by every + * runtime module. Normative prose: docs/plan/oop-vm/00-wob-format.md. + * All on-disk integers are little-endian; x86-64/ARM64 Linux only (M1). + */ +#ifndef WO_WOB_H +#define WO_WOB_H + +#include +#include + +/* ---- file header (44 bytes, absolute offsets) ---- */ +#define WOB_MAGIC 0x31424F57u /* "WOB1" read as LE u32 */ +#define WOB_VERSION 1u +#define WOB_HDR_SIZE 44u +#define WOB_OFF_MAGIC 0u +#define WOB_OFF_VERSION 4u +#define WOB_OFF_CONST 8u /* u32 offset, u32 count */ +#define WOB_OFF_CLASS 16u +#define WOB_OFF_IFACE 24u +#define WOB_OFF_METHOD 32u +#define WOB_OFF_ENTRY 40u +#define WOB_NONE 0xFFFFFFFFu /* "no entry method" / "free fn" class id */ + +/* ---- constant pool tags ---- */ +#define WOB_K_INT 0u /* tag byte, then i64 */ +#define WOB_K_TEXT 1u /* tag byte, then u32 len + bytes (no NUL) */ + +/* ---- field kinds (one byte per field in the class table) ---- */ +enum { + WO_K_SCALAR = 0, + WO_K_OWNED = 1, + WO_K_GCREF = 2, + WO_K_TEXT = 3, + WO_K_MULTI = 4, + WO_K_MAP = 5, +}; +#define WO_K_MAX 5u + +/* ---- loader-enforced limits ---- */ +#define WO_MAX_REGS 64u +#define WO_STACK_SLOTS 4096u +#define WO_MAX_FRAMES 256u + +/* ---- object header: every heap value carries this (spec section 4) ---- */ +typedef struct wo_hdr { + uint32_t class_id; /* class-table index or a WO_CLS_* sentinel */ + uint16_t shard_id; /* always 0 in milestone 1; reserved for sub-project 2 */ + uint8_t flags; + uint8_t pad; + uint32_t borrow; /* WO_BORROW_FREE / reader count / WO_BORROW_EXCL */ + uint32_t rc; /* strong count, @gc objects only */ +} wo_hdr; +_Static_assert(sizeof(wo_hdr) == 16, "object header must be exactly 16 bytes"); + +/* header flags */ +#define WO_F_GC 0x01u /* instance of a @gc class: rc rules apply */ +#define WO_F_BUF 0x02u /* sitting in the cycle-candidate buffer */ +#define WO_F_CONST 0x04u /* loader-interned constant (strings): free is a no-op */ +/* two color bits for Bacon–Rajan trial deletion */ +#define WO_F_COLOR 0x18u +#define WO_COLOR_BLACK 0x00u +#define WO_COLOR_GRAY 0x08u +#define WO_COLOR_WHITE 0x10u + +/* native class-id sentinels (top of the u32 range; loader rejects user + * class counts anywhere near these) */ +#define WO_CLS_STR 0xFFFFFFFCu +#define WO_CLS_MAP 0xFFFFFFFDu +#define WO_CLS_MULTI 0xFFFFFFFEu + +/* borrow word states */ +#define WO_BORROW_FREE 0u +#define WO_BORROW_EXCL 0xFFFFFFFFu + +/* ---- trap codes (spec section 6) ---- */ +enum { + WO_T_DIV0 = 1, + WO_T_BORROW = 2, + WO_T_STACK = 3, + WO_T_OOM = 4, + WO_T_DB = 5, + WO_T_BOUNDS = 6, + WO_T_KEY = 7, + WO_T_EXPLICIT = 8, +}; + +/* ---- opcodes (spec section 5; semantics in the format doc) ---- */ +enum { + WOP_NOP = 0, + WOP_LOADK = 1, /* A Bx : r[A] = const[Bx] */ + WOP_MOVE = 2, /* A B : r[A] = r[B] (owned move: source is dead) */ + WOP_ADD = 3, /* A B C: i64, wrapping */ + WOP_SUB = 4, + WOP_MUL = 5, + WOP_DIV = 6, /* traps DIV0 on 0 and INT64_MIN / -1 */ + WOP_NEG = 7, /* A B */ + WOP_CONCAT = 8, /* A B C: new owned text */ + WOP_EQ = 9, /* A B C: i64 compare, 0/1 */ + WOP_LT = 10, + WOP_LE = 11, + WOP_EQS = 12, /* A B C: text content equality */ + WOP_JMP = 13, /* sBx */ + WOP_JZ = 14, /* A sBx: jump when r[A] == 0 */ + WOP_CALL = 15, /* A Bx : call method Bx, window at caller base + A */ + WOP_ICALL = 16, /* A Bx : interface call by global slot id Bx */ + WOP_RET = 17, /* A */ + WOP_RET0 = 18, + WOP_NEW = 19, /* A Bx : zeroed instance of class Bx */ + WOP_GETF = 20, /* A B C: r[A] = field C of object r[B] */ + WOP_SETF = 21, /* A B C: field B of object r[A] = r[C]; no auto-drop */ + WOP_DROP = 22, /* A : recursive owned drop, nulls the register */ + WOP_BORROW_S = 23, + WOP_BORROW_X = 24, + WOP_RELEASE_S = 25, + WOP_RELEASE_X = 26, + WOP_RC_INC = 27, + WOP_RC_DEC = 28, + WOP_BUILTIN = 29, /* A B C: r[A] = builtin C, args from r[B] */ + WOP_DB_STUB = 30, /* traps WO_T_DB "engine not linked" */ + WOP_TRAP = 31, /* Bx: explicit trap */ +}; +#define WOP_MAX 31u + +/* ---- builtin ids (WOP_BUILTIN operand C) ---- */ +enum { + WO_B_NOW = 0, /* () -> i64 wall-clock ms */ + WO_B_PRINT = 1, /* (text) newline-terminated to rt output stream */ + WO_B_PRINT_INT = 2, /* (i64) */ + WO_B_WORDS = 3, /* (text) -> i64 whitespace token count */ + WO_B_MULTI_NEW = 4, /* elem kind immediate in B */ + WO_B_MULTI_PUSH = 5, + WO_B_MULTI_GET = 6, + WO_B_COUNT = 7, + WO_B_LATEST = 8, + WO_B_MAP_NEW = 9, /* key/val kind nibbles immediate in B */ + WO_B_MAP_SET = 10, + WO_B_MAP_GET = 11, /* missing key traps WO_T_KEY */ + WO_B_MAP_HAS = 12, +}; +#define WO_B_MAX 12u + +/* ---- instruction encode/decode: op:8 A:8 then B:8 C:8 or Bx:16 ---- */ +static inline uint32_t wo_ins_abc(uint8_t op, uint8_t a, uint8_t b, uint8_t c) { + return (uint32_t)op | ((uint32_t)a << 8) | ((uint32_t)b << 16) | ((uint32_t)c << 24); +} +static inline uint32_t wo_ins_abx(uint8_t op, uint8_t a, uint16_t bx) { + return (uint32_t)op | ((uint32_t)a << 8) | ((uint32_t)bx << 16); +} +/* signed jumps: sBx encodes as Bx - 32768 */ +static inline uint32_t wo_ins_asbx(uint8_t op, uint8_t a, int32_t sbx) { + return wo_ins_abx(op, a, (uint16_t)(sbx + 32768)); +} +static inline uint8_t wo_ins_op(uint32_t i) { return (uint8_t)(i & 0xFFu); } +static inline uint8_t wo_ins_a(uint32_t i) { return (uint8_t)((i >> 8) & 0xFFu); } +static inline uint8_t wo_ins_b(uint32_t i) { return (uint8_t)((i >> 16) & 0xFFu); } +static inline uint8_t wo_ins_c(uint32_t i) { return (uint8_t)((i >> 24) & 0xFFu); } +static inline uint16_t wo_ins_bx(uint32_t i) { return (uint16_t)(i >> 16); } +static inline int32_t wo_ins_sbx(uint32_t i) { return (int32_t)wo_ins_bx(i) - 32768; } + +/* ---- class descriptor shared by loader and runtime ---- */ +typedef struct wo_classdesc { + uint32_t name; /* constant index of the class name */ + uint32_t flags; /* bit0: instances are @gc */ + uint32_t field_cnt; + const uint8_t *kinds; /* field_cnt kind bytes, declaration order */ +} wo_classdesc; +#define WO_CLASSF_GC 0x01u + +/* runtime object layout: 16-byte header then one 8-byte slot per field */ +static inline size_t wo_obj_size(const wo_classdesc *c) { + return sizeof(wo_hdr) + (size_t)c->field_cnt * 8u; +} +static inline uint64_t *wo_fields(wo_hdr *o) { return (uint64_t *)(o + 1); } + +#endif /* WO_WOB_H */ diff --git a/runtime/test/.gitkeep b/runtime/test/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/runtime/test/cli_smoke.sh b/runtime/test/cli_smoke.sh new file mode 100755 index 0000000..5a885bf --- /dev/null +++ b/runtime/test/cli_smoke.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# cli_smoke — end-to-end check of the wovm CLI exit-code contract: +# 0 = success, 1 = trap (one stderr line: "trap CODE in METHOD at line N: +# MESSAGE"), 2 = usage/load failure. Fixtures come from mkwob. +set -u +cd "$(dirname "$0")/.." || exit 1 + +make wovm build/mkwob >/dev/null || { echo "cli_smoke: build failed"; exit 1; } +./build/mkwob build || { echo "cli_smoke: mkwob failed"; exit 1; } + +# success path: stdout diffed, exit 0 +out=$(./wovm build/hello.wob) +rc=$? +[ "$rc" -eq 0 ] || { echo "cli_smoke: hello exit $rc, want 0"; exit 1; } +expected=$'hello, wovm\n42' +[ "$out" = "$expected" ] || { echo "cli_smoke: unexpected stdout: $out"; exit 1; } + +# trap path: exit 1, fixed stderr shape +./wovm build/trap.wob >/dev/null 2>build/trap.err +rc=$? +[ "$rc" -eq 1 ] || { echo "cli_smoke: trap exit $rc, want 1"; exit 1; } +grep -q '^trap 1 in main at line 7: division by zero$' build/trap.err || + { echo "cli_smoke: bad trap line: $(cat build/trap.err)"; exit 1; } + +# load-failure path: exit 2 +./wovm build/does-not-exist.wob 2>/dev/null +[ $? -eq 2 ] || { echo "cli_smoke: missing-file exit not 2"; exit 1; } +./wovm 2>/dev/null +[ $? -eq 2 ] || { echo "cli_smoke: usage exit not 2"; exit 1; } + +echo "cli_smoke: OK" diff --git a/runtime/test/mkwob.c b/runtime/test/mkwob.c new file mode 100644 index 0000000..38a3466 --- /dev/null +++ b/runtime/test/mkwob.c @@ -0,0 +1,62 @@ +/* mkwob — fixture generator for the CLI smoke test, built from the + * test-side assembler: writes /hello.wob and /trap.wob. */ +#include +#include + +#include "wob_build.h" + +static int write_img(const char *dir, const char *name, uint8_t *img, + size_t len) { + char path[512]; + snprintf(path, sizeof path, "%s/%s", dir, name); + FILE *f = fopen(path, "wb"); + if (!f) { + fprintf(stderr, "mkwob: cannot write %s\n", path); + return -1; + } + size_t n = fwrite(img, 1, len, f); + fclose(f); + free(img); + return n == len ? 0 : -1; +} + +int main(int argc, char **argv) { + if (argc != 2) { + fprintf(stderr, "usage: mkwob \n"); + return 2; + } + size_t len; + + /* hello.wob: print "hello, wovm", print_int 42 */ + wb_t *b = wb_new(); + uint32_t kh = wb_const_text(b, "hello, wovm"); + uint32_t k42 = wb_const_int(b, 42); + uint32_t kn = wb_const_text(b, "main"); + uint32_t hello[] = { + wo_ins_abx(WOP_LOADK, 1, (uint16_t)kh), + wo_ins_abc(WOP_BUILTIN, 0, 1, WO_B_PRINT), + wo_ins_abx(WOP_LOADK, 1, (uint16_t)k42), + wo_ins_abc(WOP_BUILTIN, 0, 1, WO_B_PRINT_INT), + wo_ins_abc(WOP_RET0, 0, 0, 0), + }; + wb_entry(b, wb_method(b, kn, WOB_NONE, 0, 2, hello, 5, NULL, 0, NULL, 0)); + uint8_t *img = wb_finish(b, &len); + if (write_img(argv[1], "hello.wob", img, len) != 0) return 1; + + /* trap.wob: division by zero at source line 7 */ + b = wb_new(); + uint32_t k1 = wb_const_int(b, 1); + uint32_t k0 = wb_const_int(b, 0); + kn = wb_const_text(b, "main"); + uint32_t trap[] = { + wo_ins_abx(WOP_LOADK, 0, (uint16_t)k1), + wo_ins_abx(WOP_LOADK, 1, (uint16_t)k0), + wo_ins_abc(WOP_DIV, 2, 0, 1), + wo_ins_abc(WOP_RET, 2, 0, 0), + }; + uint32_t lines[] = {0, 5, 2, 7}; + wb_entry(b, wb_method(b, kn, WOB_NONE, 0, 3, trap, 4, lines, 2, NULL, 0)); + img = wb_finish(b, &len); + if (write_img(argv[1], "trap.wob", img, len) != 0) return 1; + return 0; +} diff --git a/runtime/test/t.h b/runtime/test/t.h new file mode 100644 index 0000000..2d20182 --- /dev/null +++ b/runtime/test/t.h @@ -0,0 +1,50 @@ +/* t.h — tiny assert harness. No framework, per doctrine: counters + report. */ +#ifndef WO_T_H +#define WO_T_H + +#include +#include + +static int t_pass, t_fail; + +#define T_CHECK(cond) \ + do { \ + if (cond) { \ + t_pass++; \ + } else { \ + t_fail++; \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + } \ + } while (0) + +#define T_EQ(got, want) \ + do { \ + long long g_ = (long long)(got), w_ = (long long)(want); \ + if (g_ == w_) { \ + t_pass++; \ + } else { \ + t_fail++; \ + fprintf(stderr, "FAIL %s:%d: %s == %lld, want %lld\n", __FILE__, \ + __LINE__, #got, g_, w_); \ + } \ + } while (0) + +#define T_STREQ(got, want) \ + do { \ + const char *g_ = (got), *w_ = (want); \ + if (g_ && w_ && strcmp(g_, w_) == 0) { \ + t_pass++; \ + } else { \ + t_fail++; \ + fprintf(stderr, "FAIL %s:%d: %s == \"%s\", want \"%s\"\n", \ + __FILE__, __LINE__, #got, g_ ? g_ : "(null)", \ + w_ ? w_ : "(null)"); \ + } \ + } while (0) + +static int t_report(const char *name) { + fprintf(stderr, "%s: %d pass, %d fail\n", name, t_pass, t_fail); + return t_fail ? 1 : 0; +} + +#endif /* WO_T_H */ diff --git a/runtime/test/test_arena.c b/runtime/test/test_arena.c new file mode 100644 index 0000000..20e6f2f --- /dev/null +++ b/runtime/test/test_arena.c @@ -0,0 +1,47 @@ +/* test_arena — size-class reuse, region OOM, malloc fallback above 1024. */ +#include "obj.h" +#include "t.h" + +static void test_size_class_reuse(void) { + wo_arena a; + T_EQ(wo_arena_init(&a, 4096), 0); + void *p = wo_arena_alloc(&a, 48); + T_CHECK(p != NULL); + wo_arena_free(&a, p, 48); + /* same size class: the freed block must come straight back */ + void *q = wo_arena_alloc(&a, 48); + T_CHECK(q == p); + wo_arena_free(&a, q, 48); + wo_arena_destroy(&a); +} + +static void test_region_oom_returns_null(void) { + wo_arena a; + T_EQ(wo_arena_init(&a, 256), 0); + void *p1 = wo_arena_alloc(&a, 128); + void *p2 = wo_arena_alloc(&a, 128); + T_CHECK(p1 != NULL && p2 != NULL); + /* region exhausted and nothing on the free lists: null, never abort */ + T_CHECK(wo_arena_alloc(&a, 128) == NULL); + /* freeing one block makes its class allocatable again */ + wo_arena_free(&a, p1, 128); + T_CHECK(wo_arena_alloc(&a, 128) == p1); + wo_arena_destroy(&a); +} + +static void test_large_bypasses_region(void) { + wo_arena a; + T_EQ(wo_arena_init(&a, 64), 0); /* tiny region on purpose */ + /* >1024 goes to plain malloc/free regardless of region capacity */ + void *big = wo_arena_alloc(&a, 4096); + T_CHECK(big != NULL); + wo_arena_free(&a, big, 4096); + wo_arena_destroy(&a); +} + +int main(void) { + test_size_class_reuse(); + test_region_oom_returns_null(); + test_large_bypasses_region(); + return t_report("test_arena"); +} diff --git a/runtime/test/test_borrow.c b/runtime/test/test_borrow.c new file mode 100644 index 0000000..ec05442 --- /dev/null +++ b/runtime/test/test_borrow.c @@ -0,0 +1,37 @@ +/* test_borrow — the borrow-word state machine (spec section 4). */ +#include "borrow.h" +#include "t.h" + +int main(void) { + wo_hdr o = {0}; + + /* free -> shared -> shared: readers stack */ + T_EQ(wo_borrow_shared(&o), 0); + T_EQ(wo_borrow_shared(&o), 0); + T_EQ(o.borrow, 2); + + /* shared blocks exclusive */ + T_EQ(wo_borrow_excl(&o), -1); + + /* release readers back to free */ + wo_release_shared(&o); + wo_release_shared(&o); + T_EQ(o.borrow, WO_BORROW_FREE); + + /* free -> exclusive */ + T_EQ(wo_borrow_excl(&o), 0); + T_EQ(o.borrow, WO_BORROW_EXCL); + + /* exclusive blocks both */ + T_EQ(wo_borrow_excl(&o), -1); + T_EQ(wo_borrow_shared(&o), -1); + + /* release restores free; full cycle works again */ + wo_release_excl(&o); + T_EQ(o.borrow, WO_BORROW_FREE); + T_EQ(wo_borrow_shared(&o), 0); + wo_release_shared(&o); + T_EQ(o.borrow, WO_BORROW_FREE); + + return t_report("test_borrow"); +} diff --git a/runtime/test/test_builtin.c b/runtime/test/test_builtin.c new file mode 100644 index 0000000..8dbc718 --- /dev/null +++ b/runtime/test/test_builtin.c @@ -0,0 +1,185 @@ +/* test_builtin — builtins driven from bytecode: print output captured and + * diffed (the same observation seam the conformance corpus will use), + * containers, words, missing-key and empty-latest traps, DB_STUB, and the + * text ops CONCAT/EQS. */ +#include +#include + +#include "loader.h" +#include "t.h" +#include "vm.h" +#include "wob_build.h" + +static wo_vm VM; +static wo_module MOD; + +/* module with one method per scenario; built once */ +enum { M_PRINT, M_MULTI, M_MAP, M_KEY, M_LATEST, M_WORDS, M_DB, M_CONCAT }; + +static uint8_t *build_module(size_t *len) { + wb_t *b = wb_new(); + uint32_t khi = wb_const_text(b, "hi"); /* 0 */ + uint32_t k5 = wb_const_int(b, 5); /* 1 */ + uint32_t k9 = wb_const_int(b, 9); /* 2 */ + uint32_t k100 = wb_const_int(b, 100); /* 3 */ + uint32_t k1 = wb_const_int(b, 1); /* 4 */ + uint32_t kwords = wb_const_text(b, " two words \t here\n"); /* 5 */ + uint32_t kab = wb_const_text(b, "ab"); /* 6 */ + uint32_t kcd = wb_const_text(b, "cd"); /* 7 */ + uint32_t kabcd = wb_const_text(b, "abcd"); /* 8 */ + uint32_t kn = wb_const_text(b, "m"); /* shared method name */ + + { /* M_PRINT: print "hi", print_int 5 */ + uint32_t code[] = { + wo_ins_abx(WOP_LOADK, 1, (uint16_t)khi), + wo_ins_abc(WOP_BUILTIN, 0, 1, WO_B_PRINT), + wo_ins_abx(WOP_LOADK, 1, (uint16_t)k5), + wo_ins_abc(WOP_BUILTIN, 0, 1, WO_B_PRINT_INT), + wo_ins_abc(WOP_RET0, 0, 0, 0), + }; + wb_method(b, kn, WOB_NONE, 0, 2, code, 5, NULL, 0, NULL, 0); + } + { /* M_MULTI: push 5, push 9 -> count*100 + latest = 209 */ + uint32_t code[] = { + wo_ins_abc(WOP_BUILTIN, 0, WO_K_SCALAR, WO_B_MULTI_NEW), + wo_ins_abx(WOP_LOADK, 1, (uint16_t)k5), + wo_ins_abc(WOP_BUILTIN, 2, 0, WO_B_MULTI_PUSH), + wo_ins_abx(WOP_LOADK, 1, (uint16_t)k9), + wo_ins_abc(WOP_BUILTIN, 2, 0, WO_B_MULTI_PUSH), + wo_ins_abc(WOP_BUILTIN, 3, 0, WO_B_COUNT), + wo_ins_abc(WOP_BUILTIN, 4, 0, WO_B_LATEST), + wo_ins_abx(WOP_LOADK, 1, (uint16_t)k100), + wo_ins_abc(WOP_MUL, 3, 3, 1), + wo_ins_abc(WOP_ADD, 3, 3, 4), + wo_ins_abc(WOP_DROP, 0, 0, 0), + wo_ins_abc(WOP_RET, 3, 0, 0), + }; + wb_method(b, kn, WOB_NONE, 0, 5, code, 12, NULL, 0, NULL, 0); + } + { /* M_MAP: set 1->5; get(1)*10 + has(1)*100 + has(9) = 150 */ + uint32_t code[] = { + wo_ins_abc(WOP_BUILTIN, 0, 0 /* scalar/scalar nibbles */, + WO_B_MAP_NEW), + wo_ins_abx(WOP_LOADK, 1, (uint16_t)k1), + wo_ins_abx(WOP_LOADK, 2, (uint16_t)k5), + wo_ins_abc(WOP_BUILTIN, 3, 0, WO_B_MAP_SET), + wo_ins_abc(WOP_BUILTIN, 3, 0, WO_B_MAP_GET), /* r3 = 5 */ + wo_ins_abc(WOP_BUILTIN, 4, 0, WO_B_MAP_HAS), /* r4 = has(1) = 1 */ + wo_ins_abx(WOP_LOADK, 5, (uint16_t)k100), + wo_ins_abc(WOP_MUL, 4, 4, 5), + wo_ins_abx(WOP_LOADK, 5, (uint16_t)k9), + wo_ins_abc(WOP_MOVE, 1, 5, 0), + wo_ins_abc(WOP_BUILTIN, 5, 0, WO_B_MAP_HAS), /* r5 = has(9) = 0 */ + wo_ins_abx(WOP_LOADK, 6, (uint16_t)k5), + /* r3 = r3*10: 10 not in pool — use 5+5 */ + wo_ins_abc(WOP_ADD, 6, 6, 6), + wo_ins_abc(WOP_MUL, 3, 3, 6), + wo_ins_abc(WOP_ADD, 3, 3, 4), + wo_ins_abc(WOP_ADD, 3, 3, 5), + wo_ins_abc(WOP_DROP, 0, 0, 0), + wo_ins_abc(WOP_RET, 3, 0, 0), + }; + wb_method(b, kn, WOB_NONE, 0, 7, code, 18, NULL, 0, NULL, 0); + } + { /* M_KEY: get of a missing key traps KEY */ + uint32_t code[] = { + wo_ins_abc(WOP_BUILTIN, 0, 0, WO_B_MAP_NEW), + wo_ins_abx(WOP_LOADK, 1, (uint16_t)k1), + wo_ins_abc(WOP_BUILTIN, 2, 0, WO_B_MAP_GET), + wo_ins_abc(WOP_RET0, 0, 0, 0), + }; + wb_drop drops[] = {{.pc = 1, .owned = 1u << 0, .gc = 0}}; + wb_method(b, kn, WOB_NONE, 0, 3, code, 4, NULL, 0, drops, 1); + } + { /* M_LATEST: latest of an empty multi traps BOUNDS */ + uint32_t code[] = { + wo_ins_abc(WOP_BUILTIN, 0, WO_K_SCALAR, WO_B_MULTI_NEW), + wo_ins_abc(WOP_BUILTIN, 1, 0, WO_B_LATEST), + wo_ins_abc(WOP_RET0, 0, 0, 0), + }; + wb_drop drops[] = {{.pc = 1, .owned = 1u << 0, .gc = 0}}; + wb_method(b, kn, WOB_NONE, 0, 2, code, 3, NULL, 0, drops, 1); + } + { /* M_WORDS: words(" two words \t here\n") = 3 */ + uint32_t code[] = { + wo_ins_abx(WOP_LOADK, 1, (uint16_t)kwords), + wo_ins_abc(WOP_BUILTIN, 0, 1, WO_B_WORDS), + wo_ins_abc(WOP_RET, 0, 0, 0), + }; + wb_method(b, kn, WOB_NONE, 0, 2, code, 3, NULL, 0, NULL, 0); + } + { /* M_DB: DB_STUB traps DB */ + uint32_t code[] = {wo_ins_abc(WOP_DB_STUB, 0, 0, 0)}; + wb_method(b, kn, WOB_NONE, 0, 1, code, 1, NULL, 0, NULL, 0); + } + { /* M_CONCAT: "ab"+"cd" == "abcd" -> 1 */ + uint32_t code[] = { + wo_ins_abx(WOP_LOADK, 1, (uint16_t)kab), + wo_ins_abx(WOP_LOADK, 2, (uint16_t)kcd), + wo_ins_abc(WOP_CONCAT, 3, 1, 2), + wo_ins_abx(WOP_LOADK, 4, (uint16_t)kabcd), + wo_ins_abc(WOP_EQS, 5, 3, 4), + wo_ins_abc(WOP_DROP, 3, 0, 0), + wo_ins_abc(WOP_RET, 5, 0, 0), + }; + wb_method(b, kn, WOB_NONE, 0, 6, code, 7, NULL, 0, NULL, 0); + } + return wb_finish(b, len); +} + +static int call(uint32_t mi, uint64_t *ret, wo_err *err, FILE *cap) { + T_EQ(wo_vm_init(&VM, &MOD, 1 << 20), 0); + if (cap) VM.rt.out = cap; + int rc = wo_vm_call(&VM, mi, NULL, 0, ret, err); + wo_vm_destroy(&VM); + return rc; +} + +int main(void) { + size_t len; + uint8_t *img = build_module(&len); + char lerr[256]; + if (wo_load_buf(&MOD, img, len, lerr, sizeof lerr) != 0) { + fprintf(stderr, "loader rejected: %s\n", lerr); + return 1; + } + uint64_t ret; + wo_err err; + + /* print output captured and diffed */ + FILE *cap = tmpfile(); + T_EQ(call(M_PRINT, &ret, &err, cap), 0); + fflush(cap); + rewind(cap); + char out[64] = {0}; + size_t n = fread(out, 1, sizeof out - 1, cap); + (void)n; + T_STREQ(out, "hi\n5\n"); + fclose(cap); + + T_EQ(call(M_MULTI, &ret, &err, NULL), 0); + T_EQ(ret, 209); + + T_EQ(call(M_MAP, &ret, &err, NULL), 0); + T_EQ(ret, 150); + + T_EQ(call(M_KEY, &ret, &err, NULL), -1); + T_EQ(err.code, WO_T_KEY); + + T_EQ(call(M_LATEST, &ret, &err, NULL), -1); + T_EQ(err.code, WO_T_BOUNDS); + + T_EQ(call(M_WORDS, &ret, &err, NULL), 0); + T_EQ(ret, 3); + + T_EQ(call(M_DB, &ret, &err, NULL), -1); + T_EQ(err.code, WO_T_DB); + T_STREQ(err.msg, "engine not linked"); + + T_EQ(call(M_CONCAT, &ret, &err, NULL), 0); + T_EQ(ret, 1); + + wo_module_free(&MOD); + free(img); + return t_report("test_builtin"); +} diff --git a/runtime/test/test_cont.c b/runtime/test/test_cont.c new file mode 100644 index 0000000..25ea196 --- /dev/null +++ b/runtime/test/test_cont.c @@ -0,0 +1,89 @@ +/* test_cont — native containers: multi growth/bounds, map int + text keys. + * Element drops land in the gc task; here backing stores are freed by hand. */ +#include + +#include "cont.h" +#include "t.h" + +static void free_multi_raw(wo_rt *rt, wo_multi *m) { + free(m->items); + wo_arena_free(&rt->arena, m, sizeof(wo_multi)); +} + +static void free_map_raw(wo_rt *rt, wo_map *m) { + free(m->keys); + free(m->vals); + wo_arena_free(&rt->arena, m, sizeof(wo_map)); +} + +static void test_multi_push_get_growth(void) { + wo_rt rt; + T_EQ(wo_rt_init(&rt, 1 << 16, NULL, 0), 0); + wo_multi *m = wo_multi_new(&rt, WO_K_SCALAR); + T_CHECK(m != NULL); + T_EQ(m->h.class_id, WO_CLS_MULTI); + for (uint64_t i = 0; i < 100; i++) T_EQ(wo_multi_push(m, i * 7), 0); + T_EQ(m->len, 100); + uint64_t v = 0; + T_EQ(wo_multi_get(m, 0, &v), 0); + T_EQ(v, 0); + T_EQ(wo_multi_get(m, 99, &v), 0); + T_EQ(v, 99 * 7); + /* bounds miss */ + T_EQ(wo_multi_get(m, 100, &v), -1); + free_multi_raw(&rt, m); + wo_rt_destroy(&rt); +} + +static void test_map_int_keys(void) { + wo_rt rt; + T_EQ(wo_rt_init(&rt, 1 << 16, NULL, 0), 0); + wo_map *m = wo_map_new(&rt, WO_K_SCALAR, WO_K_SCALAR); + T_CHECK(m != NULL); + T_EQ(m->h.class_id, WO_CLS_MAP); + uint64_t old = 0; + T_EQ(wo_map_set(m, 1, 10, &old), 0); /* insert */ + T_EQ(wo_map_set(m, 2, 20, &old), 0); + T_EQ(wo_map_set(m, 1, 11, &old), 1); /* replace hands old back */ + T_EQ(old, 10); + uint64_t v = 0; + T_EQ(wo_map_get(m, 1, &v), 0); + T_EQ(v, 11); + T_EQ(wo_map_get(m, 2, &v), 0); + T_EQ(v, 20); + T_EQ(wo_map_get(m, 3, &v), -1); /* miss */ + T_CHECK(wo_map_has(m, 2)); + T_CHECK(!wo_map_has(m, 3)); + free_map_raw(&rt, m); + wo_rt_destroy(&rt); +} + +static void test_map_text_keys_by_content(void) { + wo_rt rt; + T_EQ(wo_rt_init(&rt, 1 << 16, NULL, 0), 0); + wo_map *m = wo_map_new(&rt, WO_K_TEXT, WO_K_SCALAR); + wo_str *k1 = wo_str_new(&rt, "sku-1", 5); + uint64_t old = 0; + T_EQ(wo_map_set(m, (uint64_t)(uintptr_t)k1, 999, &old), 0); + /* lookup through a DIFFERENT pointer with equal content must hit */ + wo_str *k1b = wo_str_new(&rt, "sku-1", 5); + T_CHECK(k1 != k1b); + uint64_t v = 0; + T_EQ(wo_map_get(m, (uint64_t)(uintptr_t)k1b, &v), 0); + T_EQ(v, 999); + T_CHECK(wo_map_has(m, (uint64_t)(uintptr_t)k1b)); + wo_str *k2 = wo_str_new(&rt, "sku-2", 5); + T_CHECK(!wo_map_has(m, (uint64_t)(uintptr_t)k2)); + wo_str_free(&rt, k1); + wo_str_free(&rt, k1b); + wo_str_free(&rt, k2); + free_map_raw(&rt, m); + wo_rt_destroy(&rt); +} + +int main(void) { + test_multi_push_get_growth(); + test_map_int_keys(); + test_map_text_keys_by_content(); + return t_report("test_cont"); +} diff --git a/runtime/test/test_cycle.c b/runtime/test/test_cycle.c new file mode 100644 index 0000000..8013650 --- /dev/null +++ b/runtime/test/test_cycle.c @@ -0,0 +1,115 @@ +/* test_cycle — budgeted Bacon–Rajan cycle collection. + * Cycle classes use the malloc-path trick (~130 fields) so ASan proves + * every free. Budget semantics: a step consumes candidates from the buffer + * (roots popped + whites purged) up to `budget`, whole components atomic. */ +#include "cont.h" +#include "gc.h" +#include "t.h" + +#define BIG 130 + +/* @gc class "GNode": field0 GCREF, field1 MULTI, rest scalars */ +static uint8_t gnode_kinds[BIG]; +static wo_classdesc CLASSES[1]; + +static void setup(void) { + gnode_kinds[0] = WO_K_GCREF; + gnode_kinds[1] = WO_K_MULTI; + CLASSES[0] = (wo_classdesc){ + .name = 0, .flags = WO_CLASSF_GC, .field_cnt = BIG, .kinds = gnode_kinds}; +} + +/* helper: link a->f0 = b, taking a reference on b */ +static void link(wo_hdr *a, wo_hdr *b) { + wo_fields(a)[0] = (uint64_t)(uintptr_t)b; + wo_rc_inc(b); +} + +static void test_two_object_cycle_collects(void) { + wo_rt rt; + T_EQ(wo_rt_init(&rt, 1 << 16, CLASSES, 1), 0); + wo_hdr *a = wo_obj_new(&rt, 0); + wo_hdr *b = wo_obj_new(&rt, 0); + link(a, b); + link(b, a); + /* drop both external handles: objects survive on cycle edges alone */ + wo_rc_dec(&rt, a); + wo_rc_dec(&rt, b); + T_EQ(rt.cycbuf.len, 2); /* both buffered as candidates */ + T_EQ(wo_gc_step(&rt, 16), 2); /* whole cycle freed (ASan proves it) */ + T_EQ(rt.cycbuf.len, 0); + wo_rt_destroy(&rt); +} + +static void test_budget_one_cycle_per_step(void) { + wo_rt rt; + T_EQ(wo_rt_init(&rt, 1 << 16, CLASSES, 1), 0); + wo_hdr *a = wo_obj_new(&rt, 0), *b = wo_obj_new(&rt, 0); + wo_hdr *c = wo_obj_new(&rt, 0), *d = wo_obj_new(&rt, 0); + link(a, b); + link(b, a); + link(c, d); + link(d, c); + wo_rc_dec(&rt, a); + wo_rc_dec(&rt, b); + wo_rc_dec(&rt, c); + wo_rc_dec(&rt, d); + T_EQ(rt.cycbuf.len, 4); + /* budget 2 = one two-object component per step */ + T_EQ(wo_gc_step(&rt, 2), 2); + T_EQ(rt.cycbuf.len, 2); + T_EQ(wo_gc_step(&rt, 2), 2); + T_EQ(rt.cycbuf.len, 0); + /* nothing left: a further step frees nothing */ + T_EQ(wo_gc_step(&rt, 2), 0); + wo_rt_destroy(&rt); +} + +static void test_externally_held_cycle_survives_then_dies(void) { + wo_rt rt; + T_EQ(wo_rt_init(&rt, 1 << 16, CLASSES, 1), 0); + wo_hdr *a = wo_obj_new(&rt, 0), *b = wo_obj_new(&rt, 0); + link(a, b); + link(b, a); + /* keep the external handle on a; drop only b's */ + wo_rc_dec(&rt, b); + T_EQ(rt.cycbuf.len, 1); + T_EQ(wo_gc_step(&rt, 16), 0); /* held from outside: survives */ + T_EQ(rt.cycbuf.len, 0); /* candidate consumed, flag cleared */ + /* counts fully restored */ + T_EQ(a->rc, 2); + T_EQ(b->rc, 1); + T_EQ(a->flags & (WO_F_BUF | WO_F_COLOR), 0); + T_EQ(b->flags & (WO_F_BUF | WO_F_COLOR), 0); + /* still usable, then truly dead */ + wo_rc_dec(&rt, a); + T_EQ(rt.cycbuf.len, 1); /* re-buffered on the last external decrement */ + T_EQ(wo_gc_step(&rt, 16), 2); + wo_rt_destroy(&rt); +} + +static void test_cycle_through_multi_elements(void) { + wo_rt rt; + T_EQ(wo_rt_init(&rt, 1 << 16, CLASSES, 1), 0); + wo_hdr *a = wo_obj_new(&rt, 0), *b = wo_obj_new(&rt, 0); + /* a --(multi element)--> b --(gcref field)--> a */ + wo_multi *m = wo_multi_new(&rt, WO_K_GCREF); + T_EQ(wo_multi_push(m, (uint64_t)(uintptr_t)b), 0); + wo_rc_inc(b); + wo_fields(a)[1] = (uint64_t)(uintptr_t)m; + link(b, a); + wo_rc_dec(&rt, a); + wo_rc_dec(&rt, b); + T_EQ(wo_gc_step(&rt, 16), 2); /* multi head + backing freed with a */ + T_EQ(rt.cycbuf.len, 0); + wo_rt_destroy(&rt); +} + +int main(void) { + setup(); + test_two_object_cycle_collects(); + test_budget_one_cycle_per_step(); + test_externally_held_cycle_survives_then_dies(); + test_cycle_through_multi_elements(); + return t_report("test_cycle"); +} diff --git a/runtime/test/test_icall.c b/runtime/test/test_icall.c new file mode 100644 index 0000000..2308bf2 --- /dev/null +++ b/runtime/test/test_icall.c @@ -0,0 +1,99 @@ +/* test_icall — structural-interface dispatch: one ICALL site, two classes, + * two different answers chosen by the receiver's class; missing vtable + * entry traps with the no-vtable message. */ +#include + +#include "loader.h" +#include "t.h" +#include "vm.h" +#include "wob_build.h" + +static wo_vm VM; + +static int run_img(uint8_t *img, size_t len, uint32_t mi, uint64_t *ret, + wo_err *err) { + wo_module mod; + char lerr[256]; + if (wo_load_buf(&mod, img, len, lerr, sizeof lerr) != 0) { + fprintf(stderr, "loader rejected test image: %s\n", lerr); + return -2; + } + if (wo_vm_init(&VM, &mod, 1 << 20) != 0) { + wo_module_free(&mod); + return -2; + } + int rc = wo_vm_call(&VM, mi, NULL, 0, ret, err); + wo_vm_destroy(&VM); + wo_module_free(&mod); + return rc; +} + +static uint8_t *build(int with_vtabs, size_t *len) { + wb_t *b = wb_new(); + uint32_t kA = wb_const_text(b, "CA"); + uint32_t kB = wb_const_text(b, "CB"); + uint32_t kI = wb_const_text(b, "Priced"); + uint32_t km = wb_const_text(b, "price"); + uint32_t kmain = wb_const_text(b, "main"); + uint32_t k10 = wb_const_int(b, 10); + uint32_t k20 = wb_const_int(b, 20); + uint8_t kinds[] = {WO_K_SCALAR}; + uint32_t ca = wb_class(b, kA, 0, kinds, 1); + uint32_t cb = wb_class(b, kB, 0, kinds, 1); + wb_iface(b, kI, 1); /* one method -> global slot 0 */ + + /* method 0: CA.price(self) -> 10 ; method 1: CB.price(self) -> 20 */ + uint32_t code_a[] = { + wo_ins_abx(WOP_LOADK, 1, (uint16_t)k10), + wo_ins_abc(WOP_RET, 1, 0, 0), + }; + uint32_t ma = wb_method(b, km, ca, 1, 2, code_a, 2, NULL, 0, NULL, 0); + uint32_t code_b[] = { + wo_ins_abx(WOP_LOADK, 1, (uint16_t)k20), + wo_ins_abc(WOP_RET, 1, 0, 0), + }; + uint32_t mb = wb_method(b, km, cb, 1, 2, code_b, 2, NULL, 0, NULL, 0); + if (with_vtabs) { + wb_vtab(b, ca, 0, &ma, 1); + wb_vtab(b, cb, 0, &mb, 1); + } + + /* method 2: main — same ICALL site, both classes */ + uint32_t code_m[] = { + wo_ins_abx(WOP_NEW, 1, (uint16_t)ca), + wo_ins_abx(WOP_ICALL, 1, 0), /* r1 = priced(r1) via slot 0 */ + wo_ins_abx(WOP_NEW, 2, (uint16_t)cb), + wo_ins_abx(WOP_ICALL, 2, 0), /* r2 = priced(r2) via the SAME slot */ + wo_ins_abc(WOP_ADD, 0, 1, 2), + wo_ins_abc(WOP_RET, 0, 0, 0), + }; + wb_method(b, kmain, WOB_NONE, 0, 3, code_m, 6, NULL, 0, NULL, 0); + return wb_finish(b, len); +} + +static void test_dynamic_dispatch_by_class(void) { + size_t len; + uint8_t *img = build(1, &len); + uint64_t ret = 0; + wo_err err; + T_EQ(run_img(img, len, 2, &ret, &err), 0); + T_EQ(ret, 30); /* 10 + 20: both impls reached through one call site */ + free(img); +} + +static void test_missing_vtable_traps(void) { + size_t len; + uint8_t *img = build(0, &len); /* same classes, no vtable rows */ + uint64_t ret = 0; + wo_err err = {0}; + T_EQ(run_img(img, len, 2, &ret, &err), -1); + T_EQ(err.code, WO_T_BOUNDS); + T_CHECK(strstr(err.msg, "no vtable entry") != NULL); + free(img); +} + +int main(void) { + test_dynamic_dispatch_by_class(); + test_missing_vtable_traps(); + return t_report("test_icall"); +} diff --git a/runtime/test/test_loader.c b/runtime/test/test_loader.c new file mode 100644 index 0000000..ef7c966 --- /dev/null +++ b/runtime/test/test_loader.c @@ -0,0 +1,116 @@ +/* test_loader — happy path from a builder image, then a rejection battery. + * Every reject must produce a nonempty error and leak nothing (ASan). */ +#include + +#include "loader.h" +#include "t.h" +#include "wob_build.h" + +/* a valid two-const, one-class, one-method image */ +static uint8_t *valid_image(size_t *len) { + wb_t *b = wb_new(); + uint32_t k42 = wb_const_int(b, 42); + uint32_t kname = wb_const_text(b, "main"); + uint8_t kinds[] = {WO_K_SCALAR, WO_K_SCALAR}; + wb_class(b, kname, 0, kinds, 2); + uint32_t code[] = { + wo_ins_abx(WOP_LOADK, 0, (uint16_t)k42), + wo_ins_abc(WOP_RET, 0, 0, 0), + }; + uint32_t lines[] = {0, 10, 1, 11}; + uint32_t m = wb_method(b, kname, WOB_NONE, 0, 2, code, 2, lines, 2, NULL, 0); + wb_entry(b, m); + return wb_finish(b, len); +} + +static void test_happy_path(void) { + size_t len; + uint8_t *img = valid_image(&len); + wo_module m; + char err[256] = ""; + T_EQ(wo_load_buf(&m, img, len, err, sizeof err), 0); + T_EQ(m.const_cnt, 2); + T_EQ(m.consts[0].tag, WOB_K_INT); + T_EQ(m.consts[0].i, 42); + T_EQ(m.consts[1].tag, WOB_K_TEXT); + T_CHECK(m.consts[1].s != NULL); + T_CHECK(m.consts[1].s->h.flags & WO_F_CONST); /* interned */ + T_EQ(m.consts[1].s->len, 4); + T_CHECK(memcmp(m.consts[1].s->data, "main", 4) == 0); + T_EQ(m.class_cnt, 1); + T_EQ(m.classes[0].field_cnt, 2); + T_EQ(m.method_cnt, 1); + T_EQ(m.methods[0].reg_cnt, 2); + T_EQ(m.methods[0].ninstr, 2); + T_EQ(m.methods[0].line_cnt, 2); + T_EQ(m.methods[0].lines[1].pc, 1); + T_EQ(m.methods[0].lines[1].line, 11); + T_EQ(m.entry, 0); + wo_module_free(&m); + free(img); +} + +/* build an image with one patched method-code instruction */ +static uint8_t *image_with_code(uint32_t i0, uint32_t i1, size_t *len) { + wb_t *b = wb_new(); + wb_const_int(b, 42); + uint32_t kname = wb_const_text(b, "main"); + uint32_t code[] = {i0, i1}; + wb_method(b, kname, WOB_NONE, 0, 2, code, 2, NULL, 0, NULL, 0); + return wb_finish(b, len); +} + +static void expect_reject(uint8_t *img, size_t len, const char *what) { + wo_module m; + char err[256] = ""; + int rc = wo_load_buf(&m, img, len, err, sizeof err); + T_EQ(rc, -1); + if (rc != 0 && err[0] == '\0') + fprintf(stderr, "empty error for reject case: %s\n", what); + T_CHECK(err[0] != '\0'); +} + +static void test_rejects(void) { + size_t len; + + /* corrupted magic */ + uint8_t *img = valid_image(&len); + img[0] ^= 0xFF; + expect_reject(img, len, "magic"); + free(img); + + /* unknown opcode */ + img = image_with_code(wo_ins_abc(200, 0, 0, 0), + wo_ins_abc(WOP_RET, 0, 0, 0), &len); + expect_reject(img, len, "opcode"); + free(img); + + /* constant index out of range */ + img = image_with_code(wo_ins_abx(WOP_LOADK, 0, 99), + wo_ins_abc(WOP_RET, 0, 0, 0), &len); + expect_reject(img, len, "const oob"); + free(img); + + /* register out of range (regc = 2) */ + img = image_with_code(wo_ins_abc(WOP_MOVE, 5, 0, 0), + wo_ins_abc(WOP_RET, 0, 0, 0), &len); + expect_reject(img, len, "reg oob"); + free(img); + + /* last instruction is not a terminator */ + img = image_with_code(wo_ins_abc(WOP_RET, 0, 0, 0), + wo_ins_abx(WOP_LOADK, 0, 0), &len); + expect_reject(img, len, "non-terminator tail"); + free(img); + + /* truncated buffer */ + img = valid_image(&len); + expect_reject(img, len / 2, "truncated"); + free(img); +} + +int main(void) { + test_happy_path(); + test_rejects(); + return t_report("test_loader"); +} diff --git a/runtime/test/test_obj.c b/runtime/test/test_obj.c new file mode 100644 index 0000000..63828fb --- /dev/null +++ b/runtime/test/test_obj.c @@ -0,0 +1,85 @@ +/* test_obj — object creation (owned vs @gc), strings, const interning. */ +#include "obj.h" +#include "t.h" + +/* two test classes: 0 = owned Point{x,y}, 1 = @gc Cache{hits} */ +static const uint8_t point_kinds[] = {WO_K_SCALAR, WO_K_SCALAR}; +static const uint8_t cache_kinds[] = {WO_K_SCALAR}; +static const wo_classdesc CLASSES[] = { + {.name = 0, .flags = 0, .field_cnt = 2, .kinds = point_kinds}, + {.name = 0, .flags = WO_CLASSF_GC, .field_cnt = 1, .kinds = cache_kinds}, +}; + +static void test_owned_object_zeroed(void) { + wo_rt rt; + T_EQ(wo_rt_init(&rt, 1 << 16, CLASSES, 2), 0); + wo_hdr *o = wo_obj_new(&rt, 0); + T_CHECK(o != NULL); + T_EQ(o->class_id, 0); + T_EQ(o->flags, 0); + T_EQ(o->borrow, WO_BORROW_FREE); + T_EQ(o->rc, 0); + T_EQ(wo_fields(o)[0], 0); + T_EQ(wo_fields(o)[1], 0); + wo_arena_free(&rt.arena, o, wo_obj_size(&CLASSES[0])); + wo_rt_destroy(&rt); +} + +static void test_gc_object_rc1(void) { + wo_rt rt; + T_EQ(wo_rt_init(&rt, 1 << 16, CLASSES, 2), 0); + wo_hdr *o = wo_obj_new(&rt, 1); + T_CHECK(o != NULL); + T_CHECK(o->flags & WO_F_GC); + T_EQ(o->rc, 1); + wo_arena_free(&rt.arena, o, wo_obj_size(&CLASSES[1])); + wo_rt_destroy(&rt); +} + +static void test_strings(void) { + wo_rt rt; + T_EQ(wo_rt_init(&rt, 1 << 16, CLASSES, 2), 0); + wo_str *a = wo_str_new(&rt, "hello ", 6); + wo_str *b = wo_str_new(&rt, "world", 5); + T_CHECK(a && b); + T_EQ(a->h.class_id, WO_CLS_STR); + T_EQ(a->len, 6); + T_CHECK(memcmp(a->data, "hello ", 6) == 0); + + wo_str *c = wo_str_concat(&rt, a, b); + T_CHECK(c != NULL); + T_EQ(c->len, 11); + T_CHECK(memcmp(c->data, "hello world", 11) == 0); + + wo_str *c2 = wo_str_new(&rt, "hello world", 11); + T_CHECK(wo_str_eq(c, c2)); /* content equality, different pointers */ + T_CHECK(!wo_str_eq(a, b)); + + wo_str_free(&rt, a); + wo_str_free(&rt, b); + wo_str_free(&rt, c); + wo_str_free(&rt, c2); + wo_rt_destroy(&rt); +} + +static void test_const_string_survives_free(void) { + wo_rt rt; + T_EQ(wo_rt_init(&rt, 1 << 16, CLASSES, 2), 0); + wo_str *s = wo_str_new(&rt, "interned", 8); + T_CHECK(s != NULL); + s->h.flags |= WO_F_CONST; /* as the loader will mark interned constants */ + wo_str_free(&rt, s); /* must be a no-op */ + T_EQ(s->len, 8); /* still readable: free didn't touch it */ + T_CHECK(memcmp(s->data, "interned", 8) == 0); + s->h.flags &= (uint8_t)~WO_F_CONST; + wo_str_free(&rt, s); /* real free so ASan sees no leak */ + wo_rt_destroy(&rt); +} + +int main(void) { + test_owned_object_zeroed(); + test_gc_object_rc1(); + test_strings(); + test_const_string_survives_free(); + return t_report("test_obj"); +} diff --git a/runtime/test/test_objops.c b/runtime/test/test_objops.c new file mode 100644 index 0000000..41df598 --- /dev/null +++ b/runtime/test/test_objops.c @@ -0,0 +1,95 @@ +/* test_objops — NEW / GETF / SETF / DROP through bytecode, plus the + * residual runtime checks (field bounds, null receiver) trapping BOUNDS. */ +#include + +#include "loader.h" +#include "t.h" +#include "vm.h" +#include "wob_build.h" + +static wo_vm VM; + +static int run_img(uint8_t *img, size_t len, uint64_t *ret, wo_err *err) { + wo_module mod; + char lerr[256]; + if (wo_load_buf(&mod, img, len, lerr, sizeof lerr) != 0) { + fprintf(stderr, "loader rejected test image: %s\n", lerr); + return -2; + } + if (wo_vm_init(&VM, &mod, 1 << 20) != 0) { + wo_module_free(&mod); + return -2; + } + int rc = wo_vm_call(&VM, 0, NULL, 0, ret, err); + wo_vm_destroy(&VM); + wo_module_free(&mod); + return rc; +} + +/* image with class Point{x,y} and a single free fn built from `code` */ +static uint8_t *point_image(const uint32_t *code, uint32_t n, size_t *len) { + wb_t *b = wb_new(); + uint32_t kp = wb_const_text(b, "Point"); + uint32_t kf = wb_const_text(b, "go"); + wb_const_int(b, 7); /* constant index 2 */ + uint8_t kinds[] = {WO_K_SCALAR, WO_K_SCALAR}; + wb_class(b, kp, 0, kinds, 2); + wb_method(b, kf, WOB_NONE, 0, 3, code, n, NULL, 0, NULL, 0); + return wb_finish(b, len); +} + +static void test_new_set_get_drop_roundtrip(void) { + uint32_t code[] = { + wo_ins_abx(WOP_NEW, 0, 0), /* r0 = new Point */ + wo_ins_abx(WOP_LOADK, 1, 2), /* r1 = 7 */ + wo_ins_abc(WOP_SETF, 0, 0, 1), /* r0.f0 = r1 */ + wo_ins_abc(WOP_GETF, 2, 0, 0), /* r2 = r0.f0 */ + wo_ins_abc(WOP_DROP, 0, 0, 0), /* drop r0 (nulls the register) */ + wo_ins_abc(WOP_RET, 2, 0, 0), + }; + size_t len; + uint8_t *img = point_image(code, 6, &len); + uint64_t ret = 0; + wo_err err; + T_EQ(run_img(img, len, &ret, &err), 0); + T_EQ(ret, 7); + free(img); +} + +static void test_field_index_out_of_range_traps(void) { + uint32_t code[] = { + wo_ins_abx(WOP_NEW, 0, 0), + wo_ins_abc(WOP_GETF, 2, 0, 9), /* Point has 2 fields */ + wo_ins_abc(WOP_RET, 2, 0, 0), + }; + size_t len; + uint8_t *img = point_image(code, 3, &len); + uint64_t ret = 0; + wo_err err = {0}; + T_EQ(run_img(img, len, &ret, &err), -1); + T_EQ(err.code, WO_T_BOUNDS); + T_EQ(wo_vm_depth(&VM), 0); + free(img); +} + +static void test_null_receiver_traps(void) { + uint32_t code[] = { + /* r0 was never assigned: zeroed by the frame setup */ + wo_ins_abc(WOP_GETF, 2, 0, 0), + wo_ins_abc(WOP_RET, 2, 0, 0), + }; + size_t len; + uint8_t *img = point_image(code, 2, &len); + uint64_t ret = 0; + wo_err err = {0}; + T_EQ(run_img(img, len, &ret, &err), -1); + T_EQ(err.code, WO_T_BOUNDS); + free(img); +} + +int main(void) { + test_new_set_get_drop_roundtrip(); + test_field_index_out_of_range_traps(); + test_null_receiver_traps(); + return t_report("test_objops"); +} diff --git a/runtime/test/test_rc.c b/runtime/test/test_rc.c new file mode 100644 index 0000000..3028dc3 --- /dev/null +++ b/runtime/test/test_rc.c @@ -0,0 +1,103 @@ +/* test_rc — RC + drop plans: deterministic destruction. + * + * Testing trick used by every memory test from here on: classes get ~130 + * fields so instances exceed the 1024-byte size-class ceiling and take the + * arena's malloc path — any missed free is a hard ASan leak report. */ +#include "cont.h" +#include "gc.h" +#include "t.h" + +#define BIG 130 + +/* class 0 "Node": field0 OWNED (child Node), field1 TEXT, rest scalars */ +static uint8_t node_kinds[BIG]; +/* class 1 "Shared" (@gc): all scalars */ +static uint8_t shared_kinds[BIG]; +/* class 2 "Holder": field0 GCREF, field1 MULTI (of TEXT), rest scalars */ +static uint8_t holder_kinds[BIG]; + +static wo_classdesc CLASSES[3]; + +static void setup_classes(void) { + node_kinds[0] = WO_K_OWNED; + node_kinds[1] = WO_K_TEXT; + shared_kinds[0] = WO_K_SCALAR; + holder_kinds[0] = WO_K_GCREF; + holder_kinds[1] = WO_K_MULTI; + CLASSES[0] = (wo_classdesc){.name = 0, .flags = 0, .field_cnt = BIG, .kinds = node_kinds}; + CLASSES[1] = (wo_classdesc){.name = 0, .flags = WO_CLASSF_GC, .field_cnt = BIG, .kinds = shared_kinds}; + CLASSES[2] = (wo_classdesc){.name = 0, .flags = 0, .field_cnt = BIG, .kinds = holder_kinds}; +} + +/* Owned tree: parent -> child -> grandchild, each with an owned name text. + * One drop of the root must free all six allocations (ASan-proven). */ +static void test_owned_tree_recursive_drop(void) { + wo_rt rt; + T_EQ(wo_rt_init(&rt, 1 << 16, CLASSES, 3), 0); + wo_hdr *grand = wo_obj_new(&rt, 0); + wo_fields(grand)[1] = (uint64_t)(uintptr_t)wo_str_new(&rt, "grand", 5); + wo_hdr *child = wo_obj_new(&rt, 0); + wo_fields(child)[0] = (uint64_t)(uintptr_t)grand; + wo_fields(child)[1] = (uint64_t)(uintptr_t)wo_str_new(&rt, "child", 5); + wo_hdr *root = wo_obj_new(&rt, 0); + wo_fields(root)[0] = (uint64_t)(uintptr_t)child; + wo_fields(root)[1] = (uint64_t)(uintptr_t)wo_str_new(&rt, "root", 4); + wo_drop_obj(&rt, root); + /* nothing to assert beyond "ASan stays silent" — that IS the test */ + T_CHECK(1); + wo_rt_destroy(&rt); +} + +/* A holder's gcref field decrements on drop; the final external decrement + * frees the @gc object. */ +static void test_gcref_field_decrements(void) { + wo_rt rt; + T_EQ(wo_rt_init(&rt, 1 << 16, CLASSES, 3), 0); + wo_hdr *shared = wo_obj_new(&rt, 1); /* rc = 1 (creating ref) */ + wo_rc_inc(shared); /* holder's reference */ + T_EQ(shared->rc, 2); + wo_hdr *holder = wo_obj_new(&rt, 2); + wo_fields(holder)[0] = (uint64_t)(uintptr_t)shared; + wo_drop_obj(&rt, holder); /* drops holder, decrements shared to 1 */ + T_EQ(shared->rc, 1); + wo_rc_dec(&rt, shared); /* final ref gone -> freed (ASan-proven) */ + wo_rt_destroy(&rt); +} + +/* Text and multi-of-text fields are freed with the holder. */ +static void test_container_fields_freed_with_holder(void) { + wo_rt rt; + T_EQ(wo_rt_init(&rt, 1 << 16, CLASSES, 3), 0); + wo_multi *tags = wo_multi_new(&rt, WO_K_TEXT); + T_EQ(wo_multi_push(tags, (uint64_t)(uintptr_t)wo_str_new(&rt, "a", 1)), 0); + T_EQ(wo_multi_push(tags, (uint64_t)(uintptr_t)wo_str_new(&rt, "b", 1)), 0); + wo_hdr *holder = wo_obj_new(&rt, 2); + wo_fields(holder)[1] = (uint64_t)(uintptr_t)tags; + wo_drop_obj(&rt, holder); /* frees holder + multi + both strings */ + T_CHECK(1); + wo_rt_destroy(&rt); +} + +/* rc_inc/rc_dec pairing frees exactly at zero. */ +static void test_rc_zero_frees(void) { + wo_rt rt; + T_EQ(wo_rt_init(&rt, 1 << 16, CLASSES, 3), 0); + wo_hdr *s = wo_obj_new(&rt, 1); + wo_rc_inc(s); + wo_rc_inc(s); + T_EQ(s->rc, 3); + wo_rc_dec(&rt, s); + wo_rc_dec(&rt, s); + T_EQ(s->rc, 1); + wo_rc_dec(&rt, s); /* freed here */ + wo_rt_destroy(&rt); +} + +int main(void) { + setup_classes(); + test_owned_tree_recursive_drop(); + test_gcref_field_decrements(); + test_container_fields_freed_with_holder(); + test_rc_zero_frees(); + return t_report("test_rc"); +} diff --git a/runtime/test/test_unwind.c b/runtime/test/test_unwind.c new file mode 100644 index 0000000..313c836 --- /dev/null +++ b/runtime/test/test_unwind.c @@ -0,0 +1,133 @@ +/* test_unwind — borrow/rc opcodes and drop-map trap unwinding: the spec's + * "traps never leak" promise. Classes use the malloc-path trick (~130 + * fields) so ASan proves every free on the trap paths. */ +#include + +#include "loader.h" +#include "t.h" +#include "vm.h" +#include "wob_build.h" + +#define BIG 130 + +static wo_vm VM; + +static int run_img(uint8_t *img, size_t len, uint64_t *ret, wo_err *err) { + wo_module mod; + char lerr[256]; + if (wo_load_buf(&mod, img, len, lerr, sizeof lerr) != 0) { + fprintf(stderr, "loader rejected test image: %s\n", lerr); + return -2; + } + if (wo_vm_init(&VM, &mod, 1 << 20) != 0) { + wo_module_free(&mod); + return -2; + } + int rc = wo_vm_call(&VM, 0, NULL, 0, ret, err); + wo_vm_destroy(&VM); + wo_module_free(&mod); + return rc; +} + +static uint8_t big_kinds[BIG]; /* all scalars */ + +/* Double-exclusive borrow traps BORROW at the right line; the owned object + * named in the frame's drop mask is freed during unwinding. */ +static void test_borrow_violation_frees_owned(void) { + wb_t *b = wb_new(); + uint32_t kc = wb_const_text(b, "Big"); + uint32_t kf = wb_const_text(b, "main"); + wb_class(b, kc, 0, big_kinds, BIG); + uint32_t code[] = { + wo_ins_abx(WOP_NEW, 0, 0), + wo_ins_abc(WOP_BORROW_X, 0, 0, 0), /* ok */ + wo_ins_abc(WOP_BORROW_X, 0, 0, 0), /* violation: line 99 */ + wo_ins_abc(WOP_RET0, 0, 0, 0), + }; + uint32_t lines[] = {2, 99}; + wb_drop drops[] = {{.pc = 1, .owned = 1u << 0, .gc = 0}}; + wb_method(b, kf, WOB_NONE, 0, 2, code, 4, lines, 1, drops, 1); + size_t len; + uint8_t *img = wb_finish(b, &len); + uint64_t ret = 0; + wo_err err = {0}; + T_EQ(run_img(img, len, &ret, &err), -1); + T_EQ(err.code, WO_T_BORROW); + T_EQ(err.line, 99); + T_EQ(wo_vm_depth(&VM), 0); + free(img); /* ASan: the Big instance must have been freed by unwinding */ +} + +/* A child method traps mid-body: owned objects in BOTH frames are freed — + * the child's argument (moved in), the child's own allocation, and the + * caller's local. */ +static void test_two_frame_unwind_frees_both(void) { + wb_t *b = wb_new(); + uint32_t kc = wb_const_text(b, "Big"); + uint32_t kchild = wb_const_text(b, "child"); + uint32_t kmain = wb_const_text(b, "main"); + wb_class(b, kc, 0, big_kinds, BIG); + /* method 0 = child(o): allocates its own Big, then traps */ + uint32_t ccode[] = { + wo_ins_abx(WOP_NEW, 1, 0), + wo_ins_abx(WOP_TRAP, 0, WO_T_EXPLICIT), + }; + wb_drop cdrops[] = {{.pc = 1, .owned = (1u << 0) | (1u << 1), .gc = 0}}; + wb_method(b, kchild, WOB_NONE, 1, 3, ccode, 2, NULL, 0, cdrops, 1); + /* method 1 = main: local Big in r0, arg Big in the call window r2 */ + uint32_t mcode[] = { + wo_ins_abx(WOP_NEW, 0, 0), + wo_ins_abx(WOP_NEW, 2, 0), + wo_ins_abx(WOP_CALL, 2, 0), /* pc 2: ownership of r2 moves in */ + wo_ins_abc(WOP_DROP, 0, 0, 0), + wo_ins_abc(WOP_RET0, 0, 0, 0), + }; + wb_drop mdrops[] = {{.pc = 2, .owned = 1u << 0, .gc = 0}}; + wb_method(b, kmain, WOB_NONE, 0, 4, mcode, 5, NULL, 0, mdrops, 1); + wb_entry(b, 1); + size_t len; + uint8_t *img = wb_finish(b, &len); + + wo_module mod; + char lerr[256]; + T_EQ(wo_load_buf(&mod, img, len, lerr, sizeof lerr), 0); + T_EQ(wo_vm_init(&VM, &mod, 1 << 20), 0); + uint64_t ret = 0; + wo_err err = {0}; + T_EQ(wo_vm_call(&VM, 1, NULL, 0, &ret, &err), -1); + T_EQ(err.code, WO_T_EXPLICIT); + T_STREQ(err.method, "child"); + T_EQ(wo_vm_depth(&VM), 0); + wo_vm_destroy(&VM); + wo_module_free(&mod); + free(img); /* ASan: all three Big instances freed across both frames */ +} + +/* rc inc/dec through opcodes frees exactly at zero (@gc malloc-path class) */ +static void test_rc_opcodes_free_at_zero(void) { + wb_t *b = wb_new(); + uint32_t kc = wb_const_text(b, "GBig"); + uint32_t kf = wb_const_text(b, "main"); + wb_class(b, kc, WO_CLASSF_GC, big_kinds, BIG); + uint32_t code[] = { + wo_ins_abx(WOP_NEW, 0, 0), /* rc 1 */ + wo_ins_abc(WOP_RC_INC, 0, 0, 0), /* rc 2 */ + wo_ins_abc(WOP_RC_DEC, 0, 0, 0), /* rc 1 */ + wo_ins_abc(WOP_RC_DEC, 0, 0, 0), /* rc 0: freed */ + wo_ins_abc(WOP_RET0, 0, 0, 0), + }; + wb_method(b, kf, WOB_NONE, 0, 1, code, 5, NULL, 0, NULL, 0); + size_t len; + uint8_t *img = wb_finish(b, &len); + uint64_t ret = 0; + wo_err err; + T_EQ(run_img(img, len, &ret, &err), 0); /* no trap; ASan proves the free */ + free(img); +} + +int main(void) { + test_borrow_violation_frees_owned(); + test_two_frame_unwind_frees_both(); + test_rc_opcodes_free_at_zero(); + return t_report("test_unwind"); +} diff --git a/runtime/test/test_vm.c b/runtime/test/test_vm.c new file mode 100644 index 0000000..b027df2 --- /dev/null +++ b/runtime/test/test_vm.c @@ -0,0 +1,126 @@ +/* test_vm — interpreter core: window calls, fib recursion, div-by-zero + * with line lookup, frame-cap stack overflow, depth zero after traps. */ +#include + +#include "loader.h" +#include "t.h" +#include "vm.h" +#include "wob_build.h" + +static wo_vm VM; /* 32K value stack: keep it off the C stack */ + +/* run one image's entry-shaped method by index with args */ +static int run(uint8_t *img, size_t len, uint32_t mi, const uint64_t *args, + uint32_t argc, uint64_t *ret, wo_err *err) { + wo_module mod; + char lerr[256]; + if (wo_load_buf(&mod, img, len, lerr, sizeof lerr) != 0) { + fprintf(stderr, "loader rejected test image: %s\n", lerr); + return -2; + } + if (wo_vm_init(&VM, &mod, 1 << 20) != 0) { + wo_module_free(&mod); + return -2; + } + int rc = wo_vm_call(&VM, mi, args, argc, ret, err); + int depth_after = (int)wo_vm_depth(&VM); + wo_vm_destroy(&VM); + wo_module_free(&mod); + return rc == 0 ? 0 : (depth_after == 0 ? -1 : -3); +} + +static void test_add_window_convention(void) { + wb_t *b = wb_new(); + uint32_t kn = wb_const_text(b, "add"); + uint32_t code[] = { + wo_ins_abc(WOP_ADD, 2, 0, 1), + wo_ins_abc(WOP_RET, 2, 0, 0), + }; + wb_method(b, kn, WOB_NONE, 2, 3, code, 2, NULL, 0, NULL, 0); + size_t len; + uint8_t *img = wb_finish(b, &len); + uint64_t args[] = {3, 4}, ret = 0; + wo_err err; + T_EQ(run(img, len, 0, args, 2, &ret, &err), 0); + T_EQ(ret, 7); + free(img); +} + +static void test_fib_recursion(void) { + wb_t *b = wb_new(); + uint32_t kn = wb_const_text(b, "fib"); + uint32_t k1 = wb_const_int(b, 1); + uint32_t k2 = wb_const_int(b, 2); + /* fib(n): n<2 -> n; else fib(n-1)+fib(n-2). regc 6, argc 1. */ + uint32_t code[] = { + wo_ins_abx(WOP_LOADK, 1, (uint16_t)k2), /* 0: r1 = 2 */ + wo_ins_abc(WOP_LT, 2, 0, 1), /* 1: r2 = n < 2 */ + wo_ins_asbx(WOP_JZ, 2, 1), /* 2: if !r2 -> pc 4 */ + wo_ins_abc(WOP_RET, 0, 0, 0), /* 3: return n */ + wo_ins_abx(WOP_LOADK, 1, (uint16_t)k1), /* 4: r1 = 1 */ + wo_ins_abc(WOP_SUB, 4, 0, 1), /* 5: r4 = n-1 */ + wo_ins_abx(WOP_CALL, 4, 0), /* 6: r4 = fib(r4) */ + wo_ins_abx(WOP_LOADK, 1, (uint16_t)k2), /* 7: r1 = 2 */ + wo_ins_abc(WOP_SUB, 5, 0, 1), /* 8: r5 = n-2 */ + wo_ins_abx(WOP_CALL, 5, 0), /* 9: r5 = fib(r5) */ + wo_ins_abc(WOP_ADD, 0, 4, 5), /* 10: r0 = r4+r5 */ + wo_ins_abc(WOP_RET, 0, 0, 0), /* 11 */ + }; + wb_method(b, kn, WOB_NONE, 1, 6, code, 12, NULL, 0, NULL, 0); + size_t len; + uint8_t *img = wb_finish(b, &len); + uint64_t args[] = {10}, ret = 0; + wo_err err; + T_EQ(run(img, len, 0, args, 1, &ret, &err), 0); + T_EQ(ret, 55); + free(img); +} + +static void test_div_zero_traps_with_line(void) { + wb_t *b = wb_new(); + uint32_t kn = wb_const_text(b, "divit"); + uint32_t k10 = wb_const_int(b, 10); + uint32_t k0 = wb_const_int(b, 0); + uint32_t code[] = { + wo_ins_abx(WOP_LOADK, 0, (uint16_t)k10), + wo_ins_abx(WOP_LOADK, 1, (uint16_t)k0), + wo_ins_abc(WOP_DIV, 2, 0, 1), /* pc 2, line 42 */ + wo_ins_abc(WOP_RET, 2, 0, 0), + }; + uint32_t lines[] = {0, 40, 2, 42, 3, 43}; + wb_method(b, kn, WOB_NONE, 0, 3, code, 4, lines, 3, NULL, 0); + size_t len; + uint8_t *img = wb_finish(b, &len); + uint64_t ret = 0; + wo_err err = {0}; + T_EQ(run(img, len, 0, NULL, 0, &ret, &err), -1); /* trap, depth 0 */ + T_EQ(err.code, WO_T_DIV0); + T_EQ(err.line, 42); + T_STREQ(err.method, "divit"); + free(img); +} + +static void test_stack_overflow_at_frame_cap(void) { + wb_t *b = wb_new(); + uint32_t kn = wb_const_text(b, "boom"); + uint32_t code[] = { + wo_ins_abx(WOP_CALL, 1, 0), /* call self forever, window +1 */ + wo_ins_abc(WOP_RET0, 0, 0, 0), + }; + wb_method(b, kn, WOB_NONE, 0, 2, code, 2, NULL, 0, NULL, 0); + size_t len; + uint8_t *img = wb_finish(b, &len); + uint64_t ret = 0; + wo_err err = {0}; + T_EQ(run(img, len, 0, NULL, 0, &ret, &err), -1); + T_EQ(err.code, WO_T_STACK); + free(img); +} + +int main(void) { + test_add_window_convention(); + test_fib_recursion(); + test_div_zero_traps_with_line(); + test_stack_overflow_at_frame_cap(); + return t_report("test_vm"); +} diff --git a/runtime/test/test_wobbuild.c b/runtime/test/test_wobbuild.c new file mode 100644 index 0000000..a98c6ad --- /dev/null +++ b/runtime/test/test_wobbuild.c @@ -0,0 +1,69 @@ +/* test_wobbuild — the in-memory .wob assembler emits exactly the format: + * raw header bytes, section offsets, first constant at its stated offset. */ +#include + +#include "t.h" +#include "wob_build.h" + +static uint32_t rd_u32(const uint8_t *p, size_t off) { + uint32_t v; + memcpy(&v, p + off, 4); + return v; +} + +int main(void) { + wb_t *b = wb_new(); + uint32_t k_int = wb_const_int(b, 42); + uint32_t k_name = wb_const_text(b, "main"); + T_EQ(k_int, 0); + T_EQ(k_name, 1); + + uint32_t code[] = { + wo_ins_abx(WOP_LOADK, 0, (uint16_t)k_int), + wo_ins_abc(WOP_RET, 0, 0, 0), + }; + uint32_t m = wb_method(b, k_name, WOB_NONE, 0, 1, code, 2, NULL, 0, NULL, 0); + T_EQ(m, 0); + wb_entry(b, m); + + size_t len = 0; + uint8_t *img = wb_finish(b, &len); + T_CHECK(img != NULL); + T_CHECK(len > WOB_HDR_SIZE); + + /* header */ + T_EQ(rd_u32(img, WOB_OFF_MAGIC), WOB_MAGIC); + T_EQ(rd_u32(img, WOB_OFF_VERSION), WOB_VERSION); + uint32_t const_off = rd_u32(img, WOB_OFF_CONST); + T_EQ(const_off, WOB_HDR_SIZE); /* constants sit right after the header */ + T_EQ(rd_u32(img, WOB_OFF_CONST + 4), 2); /* const count */ + T_EQ(rd_u32(img, WOB_OFF_CLASS + 4), 0); + T_EQ(rd_u32(img, WOB_OFF_IFACE + 4), 0); + T_EQ(rd_u32(img, WOB_OFF_METHOD + 4), 1); + T_EQ(rd_u32(img, WOB_OFF_ENTRY), 0); + + /* first constant: tag 0 (int), i64 42 */ + T_EQ(img[const_off], WOB_K_INT); + int64_t iv; + memcpy(&iv, img + const_off + 1, 8); + T_EQ(iv, 42); + + /* second constant: tag 1 (text), len 4, "main" */ + size_t t2 = const_off + 1 + 8; + T_EQ(img[t2], WOB_K_TEXT); + T_EQ(rd_u32(img, t2 + 1), 4); + T_CHECK(memcmp(img + t2 + 5, "main", 4) == 0); + + /* method section: name, class, argc/regc, code length */ + uint32_t moff = rd_u32(img, WOB_OFF_METHOD); + T_EQ(rd_u32(img, moff), k_name); + T_EQ(rd_u32(img, moff + 4), WOB_NONE); + T_EQ(img[moff + 8], 0); /* argc */ + T_EQ(img[moff + 9], 1); /* regc */ + T_EQ(rd_u32(img, moff + 12), 8); /* code_len bytes */ + T_EQ(rd_u32(img, moff + 16), code[0]); + T_EQ(rd_u32(img, moff + 20), code[1]); + + free(img); + return t_report("test_wobbuild"); +} diff --git a/runtime/test/wob_build.c b/runtime/test/wob_build.c new file mode 100644 index 0000000..7a86281 --- /dev/null +++ b/runtime/test/wob_build.c @@ -0,0 +1,143 @@ +#include "wob_build.h" + +#include +#include + +/* growable byte buffer */ +typedef struct { + uint8_t *p; + size_t len, cap; +} buf_t; + +static void put(buf_t *b, const void *src, size_t n) { + if (n == 0) return; + if (b->len + n > b->cap) { + size_t ncap = b->cap ? b->cap : 64; + while (ncap < b->len + n) ncap *= 2; + b->p = realloc(b->p, ncap); /* test helper: abort-on-OOM is fine */ + b->cap = ncap; + } + memcpy(b->p + b->len, src, n); + b->len += n; +} +static void put_u8(buf_t *b, uint8_t v) { put(b, &v, 1); } +static void put_u16(buf_t *b, uint16_t v) { put(b, &v, 2); } +static void put_u32(buf_t *b, uint32_t v) { put(b, &v, 4); } +static void put_u64(buf_t *b, uint64_t v) { put(b, &v, 8); } + +struct wb_t { + buf_t consts, classes, ifaces, vtabs, methods; + uint32_t const_cnt, class_cnt, iface_cnt, vtab_cnt, method_cnt; + uint32_t next_slot; /* global interface slot ids accumulate */ + uint32_t entry; +}; + +wb_t *wb_new(void) { + wb_t *b = calloc(1, sizeof(wb_t)); + b->entry = WOB_NONE; + return b; +} + +uint32_t wb_const_int(wb_t *b, int64_t v) { + put_u8(&b->consts, WOB_K_INT); + put_u64(&b->consts, (uint64_t)v); + return b->const_cnt++; +} + +uint32_t wb_const_text(wb_t *b, const char *s) { + uint32_t len = (uint32_t)strlen(s); + put_u8(&b->consts, WOB_K_TEXT); + put_u32(&b->consts, len); + put(&b->consts, s, len); + return b->const_cnt++; +} + +uint32_t wb_class(wb_t *b, uint32_t name_const, uint32_t flags, + const uint8_t *kinds, uint32_t field_cnt) { + put_u32(&b->classes, name_const); + put_u32(&b->classes, flags); + put_u32(&b->classes, field_cnt); + put(&b->classes, kinds, field_cnt); + for (uint32_t pad = field_cnt; pad % 4; pad++) put_u8(&b->classes, 0); + return b->class_cnt++; +} + +uint32_t wb_iface(wb_t *b, uint32_t name_const, uint32_t method_cnt) { + put_u32(&b->ifaces, name_const); + put_u32(&b->ifaces, method_cnt); + b->next_slot += method_cnt; + return b->iface_cnt++; +} + +void wb_vtab(wb_t *b, uint32_t class_id, uint32_t iface_id, + const uint32_t *methods, uint32_t method_cnt) { + put_u32(&b->vtabs, class_id); + put_u32(&b->vtabs, iface_id); + for (uint32_t i = 0; i < method_cnt; i++) put_u32(&b->vtabs, methods[i]); + b->vtab_cnt++; +} + +uint32_t wb_method(wb_t *b, uint32_t name_const, uint32_t class_id, + uint8_t argc, uint8_t regc, const uint32_t *code, + uint32_t ninstr, const uint32_t *lines, uint32_t nlines, + const wb_drop *drops, uint32_t ndrops) { + buf_t *m = &b->methods; + put_u32(m, name_const); + put_u32(m, class_id); + put_u8(m, argc); + put_u8(m, regc); + put_u16(m, 0); /* reserved */ + put_u32(m, ninstr * 4u); + for (uint32_t i = 0; i < ninstr; i++) put_u32(m, code[i]); + put_u32(m, nlines); + for (uint32_t i = 0; i < 2 * nlines; i++) put_u32(m, lines[i]); + put_u32(m, ndrops); + for (uint32_t i = 0; i < ndrops; i++) { + put_u32(m, drops[i].pc); + put_u64(m, drops[i].owned); + put_u64(m, drops[i].gc); + } + return b->method_cnt++; +} + +void wb_entry(wb_t *b, uint32_t method_idx) { b->entry = method_idx; } + +uint8_t *wb_finish(wb_t *b, size_t *len) { + /* interface section = interface entries, then vtab count, then rows */ + buf_t iface_all = {0}; + put(&iface_all, b->ifaces.p ? (void *)b->ifaces.p : (void *)"", b->ifaces.len); + put_u32(&iface_all, b->vtab_cnt); + if (b->vtabs.len) put(&iface_all, b->vtabs.p, b->vtabs.len); + + buf_t out = {0}; + uint32_t off = WOB_HDR_SIZE; + put_u32(&out, WOB_MAGIC); + put_u32(&out, WOB_VERSION); + put_u32(&out, off); + put_u32(&out, b->const_cnt); + off += (uint32_t)b->consts.len; + put_u32(&out, off); + put_u32(&out, b->class_cnt); + off += (uint32_t)b->classes.len; + put_u32(&out, off); + put_u32(&out, b->iface_cnt); + off += (uint32_t)iface_all.len; + put_u32(&out, off); + put_u32(&out, b->method_cnt); + put_u32(&out, b->entry); + if (b->consts.len) put(&out, b->consts.p, b->consts.len); + if (b->classes.len) put(&out, b->classes.p, b->classes.len); + put(&out, iface_all.p, iface_all.len); + if (b->methods.len) put(&out, b->methods.p, b->methods.len); + + free(b->consts.p); + free(b->classes.p); + free(b->ifaces.p); + free(b->vtabs.p); + free(b->methods.p); + free(iface_all.p); + uint8_t *img = out.p; + *len = out.len; + free(b); + return img; +} diff --git a/runtime/test/wob_build.h b/runtime/test/wob_build.h new file mode 100644 index 0000000..935b8d0 --- /dev/null +++ b/runtime/test/wob_build.h @@ -0,0 +1,52 @@ +/* wob_build.h — in-memory .wob assembler for tests and fixture generation. + * Deliberately a SECOND, independent encoding of the format (the loader is + * the first): builder/loader disagreements surface as test failures, + * cross-checking docs/plan/oop-vm/00-wob-format.md. Test-side only — never + * linked into wovm itself. */ +#ifndef WO_WOB_BUILD_H +#define WO_WOB_BUILD_H + +#include + +#include "wob.h" + +typedef struct wb_t wb_t; + +typedef struct wb_drop { + uint32_t pc; + uint64_t owned; /* registers holding live owned values at pc */ + uint64_t gc; /* registers holding live @gc references at pc */ +} wb_drop; + +wb_t *wb_new(void); + +/* constants: returns the constant index */ +uint32_t wb_const_int(wb_t *b, int64_t v); +uint32_t wb_const_text(wb_t *b, const char *s); /* strlen'd, no NUL stored */ + +/* classes: returns the class id */ +uint32_t wb_class(wb_t *b, uint32_t name_const, uint32_t flags, + const uint8_t *kinds, uint32_t field_cnt); + +/* interfaces: returns the interface id; global slot ids accumulate in + * declaration order (first interface's methods get slots 0..n-1, etc.) */ +uint32_t wb_iface(wb_t *b, uint32_t name_const, uint32_t method_cnt); +/* vtable row: this class implements this interface with these method + * indexes (one per interface method, in interface order) */ +void wb_vtab(wb_t *b, uint32_t class_id, uint32_t iface_id, + const uint32_t *methods, uint32_t method_cnt); + +/* methods: returns the method index. lines = flattened ascending pc,line + * pairs (2*nlines u32s). drops = ascending drop-table entries. */ +uint32_t wb_method(wb_t *b, uint32_t name_const, uint32_t class_id, + uint8_t argc, uint8_t regc, const uint32_t *code, + uint32_t ninstr, const uint32_t *lines, uint32_t nlines, + const wb_drop *drops, uint32_t ndrops); + +void wb_entry(wb_t *b, uint32_t method_idx); + +/* concatenate sections, compute header offsets, free the builder; + * returns one malloc'd image (caller frees) */ +uint8_t *wb_finish(wb_t *b, size_t *len); + +#endif /* WO_WOB_BUILD_H */ diff --git a/runtime/wo-rt.c b/runtime/wo-rt.c new file mode 100644 index 0000000..feee257 --- /dev/null +++ b/runtime/wo-rt.c @@ -0,0 +1,979 @@ +/* + * wo-rt.c — the writeonce runtime environment, in C. Phase E: first load. + * + * Phases A–D: N pinned threads with raw io_uring loops, SO_REUSEPORT + * listeners, keep-alive connections, one mlock'd mmap arena, and durable + * commits (RAM apply → framed WAL record → per-tick group fdatasync → ack + * on the fsync CQE). Phase E closes the loop: at boot — BEFORE any accept + * is armed — each shard thread loads its snapshot and replays its WAL into + * its arena slice, in parallel, validating every frame's CRC + COMMIT + * trailer and truncating at the first torn record. Appends resume at the + * validated tail. A clean shutdown writes a per-shard snapshot + * (`shard-.data`) and truncates the WAL; boot prefers snapshot + WAL + * tail. The data directory carries a `meta` file pinning the shard count — + * a restart with a different WO_THREADS refuses to start (resharding is + * plan 09f, not silent data loss). Zero deps beyond libc + kernel uapi. + * + * build: make run: ./wo-rt [WO_PORT=8085 WO_THREADS=4 WO_DATA=./wo-data ./wo-rt] + * poke: curl -X POST localhost:8085/api/notes -d '{"title":"hello"}' # acked after fsync + * wal: ./wo-rt wal-check wo-data/shard-0.wal # offline frame/CRC validation + * + * Phase map: docs/plan/exploration/c-runtime/00-plan.md (A ✅ threads, + * B ✅ arena, C ✅ io_uring, D ✅ WAL, E this file, F bench). One-address + * trace: 01-architecture.md. Single-binary end goal: 02-single-binary.md. + * + * Module map (C ↔ Rust ↔ kernel reference card): + * ring_init/ring_enter ↔ (plan 09 decision 4: per-thread ring) ↔ linux/07-io_uring.md + * arena_init ↔ (plan 10 storage foundations) ↔ linux/08-mmap.md + * wal_flush / OP_FSYNC ↔ (plan 11 WAL + 09c per-shard WAL) ↔ linux/12-pwrite-fsync.md, 09-fallocate.md + * sig/evfd via POLL_ADD↔ runtime/{signalfd,eventfd}.rs ↔ linux/04-signalfd.md, 02-eventfd.md + * + * Requires IORING_FEAT_SINGLE_MMAP (≥5.4) and multishot accept (≥5.19). + * Phase D opens WALs with O_TRUNC (fresh log each boot) — replay-on-boot and + * snapshots are phase E; the crash test inspects the WAL offline via + * `wal-check` BEFORE any restart. Simplifications: single-shot RECV re-armed + * per request, naive JSON extraction, fixed-size WAL payloads. + */ + +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef IORING_ACCEPT_MULTISHOT +#define IORING_ACCEPT_MULTISHOT (1U << 0) +#endif + +#define MAX_THREADS 64 +#ifndef MAX_FDS +#define MAX_FDS 16384 /* conn slots per shard, fd-indexed */ +#endif +#define IN_CAP 8192 +#define OUT_CAP 65536 +#define PAGE 4096 +#define HUGE_2M (2u * 1024 * 1024) +#ifndef SLOT_SIZE +#define SLOT_SIZE 256 /* -D overridable for scale runs (phase F) */ +#endif +#ifndef SLOTS_PER_SHARD +#define SLOTS_PER_SHARD 256 +#endif +#define RING_ENTRIES 1024 +#define WAL_PREALLOC (4u * 1024 * 1024) /* fallocate per shard */ +#define WAL_COMMIT 0xC0FFEE42u /* frame trailer magic */ +#define WAL_BATCH_CAP 65536 /* staged bytes per group commit */ +#define WAL_BATCH_CONNS 256 /* acks parked per batch */ + +/* ---------------------------------------------------------------- crc32 -- + * Hand-rolled (poly 0xEDB88320), table built once at boot. Zero deps. */ + +static uint32_t crc_table[256]; + +static void crc32_init(void) { + for (uint32_t i = 0; i < 256; i++) { + uint32_t c = i; + for (int k = 0; k < 8; k++) c = (c & 1) ? 0xEDB88320u ^ (c >> 1) : c >> 1; + crc_table[i] = c; + } +} + +static uint32_t crc32(const void *buf, size_t len) { + const uint8_t *p = buf; + uint32_t c = 0xFFFFFFFFu; + while (len--) c = crc_table[(c ^ *p++) & 0xFF] ^ (c >> 8); + return c ^ 0xFFFFFFFFu; +} + +/* ------------------------------------------------------------ WAL frame -- + * [u32 len][u32 crc(payload)][payload][u32 WAL_COMMIT]. A record replays + * whole or not at all: bad len, bad crc, or missing trailer = torn tail. + * The payload carries the (shard,slot) coordinates phase B made stable. */ + +struct wal_payload { + uint32_t op; /* 1 = insert note */ + uint32_t slot; + int32_t id; + char title[128]; +}; + +#define WAL_FRAME_BYTES (4 + 4 + sizeof(struct wal_payload) + 4) + +/* ---------------------------------------------------------------- arena -- + * Unchanged from phase B: [header page][shard 0: bitmap page + slots]... */ + +struct arena_hdr { + char magic[8]; + uint32_t version; + uint32_t n_shards; + uint32_t slots_per_shard; + uint32_t slot_size; +}; + +struct slot_note { int32_t id; char title[128]; }; + +static uint8_t *arena; +static size_t arena_bytes, arena_map_bytes, slice_bytes, bitmap_bytes; +static int arena_huge = 0, arena_locked = 0; + +static int arena_init(int n_shards) { + bitmap_bytes = ((size_t)SLOTS_PER_SHARD / 8 + PAGE - 1) & ~((size_t)PAGE - 1); + slice_bytes = bitmap_bytes + (size_t)SLOTS_PER_SHARD * SLOT_SIZE; + arena_bytes = PAGE + (size_t)n_shards * slice_bytes; + + arena_map_bytes = (arena_bytes + HUGE_2M - 1) & ~((size_t)HUGE_2M - 1); + arena = mmap(NULL, arena_map_bytes, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB | MAP_POPULATE, -1, 0); + if (arena != MAP_FAILED) { + arena_huge = 1; + } else { + arena_map_bytes = (arena_bytes + PAGE - 1) & ~((size_t)PAGE - 1); + arena = mmap(NULL, arena_map_bytes, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_POPULATE, -1, 0); + if (arena == MAP_FAILED) { perror("mmap arena"); return -1; } + } + + arena_locked = (mlock(arena, arena_map_bytes) == 0); + if (!arena_locked) + fprintf(stderr, "[wo-rt-c] warn: mlock refused (%s) — arena not pinned\n", strerror(errno)); + + struct arena_hdr *hdr = (struct arena_hdr *)arena; + memcpy(hdr->magic, "WORTC\0\0", 8); + hdr->version = 3; /* phase C */ + hdr->n_shards = (uint32_t)n_shards; + hdr->slots_per_shard = SLOTS_PER_SHARD; + hdr->slot_size = SLOT_SIZE; + return 0; +} + +static uint64_t *shard_bitmap(int t) { return (uint64_t *)(arena + PAGE + (size_t)t * slice_bytes); } +static uint8_t *shard_slots (int t) { return arena + PAGE + (size_t)t * slice_bytes + bitmap_bytes; } +static struct slot_note *slot_at(int t, uint32_t i) { + return (struct slot_note *)(shard_slots(t) + (size_t)i * SLOT_SIZE); +} + +/* ------------------------------------------------------------- io_uring -- + * The raw ring: three pieces of memory shared with the kernel — the SQ/CQ + * ring headers+arrays (one mmap, IORING_FEAT_SINGLE_MMAP) and the SQE array. + * Submission: fill sqes[tail&mask], publish tail with a release store, tell + * the kernel with ONE io_uring_enter that also waits for completions. */ + +struct ring { + int fd; + unsigned *sq_head, *sq_tail, *sq_mask, *sq_array; + unsigned *cq_head, *cq_tail, *cq_mask; + struct io_uring_sqe *sqes; + struct io_uring_cqe *cqes; + unsigned local_tail; /* SQEs filled, not yet published */ + unsigned to_submit; +}; + +static int ring_init(struct ring *r) { + struct io_uring_params p; + memset(&p, 0, sizeof p); + r->fd = (int)syscall(__NR_io_uring_setup, RING_ENTRIES, &p); + if (r->fd < 0) { perror("io_uring_setup"); return -1; } + if (!(p.features & IORING_FEAT_SINGLE_MMAP)) { + fprintf(stderr, "[wo-rt-c] kernel lacks IORING_FEAT_SINGLE_MMAP (need >= 5.4)\n"); + return -1; + } + + size_t sq_sz = p.sq_off.array + p.sq_entries * sizeof(unsigned); + size_t cq_sz = p.cq_off.cqes + p.cq_entries * sizeof(struct io_uring_cqe); + size_t sz = sq_sz > cq_sz ? sq_sz : cq_sz; + uint8_t *sqcq = mmap(NULL, sz, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE, + r->fd, IORING_OFF_SQ_RING); + if (sqcq == MAP_FAILED) { perror("mmap sq/cq ring"); return -1; } + + r->sq_head = (unsigned *)(sqcq + p.sq_off.head); + r->sq_tail = (unsigned *)(sqcq + p.sq_off.tail); + r->sq_mask = (unsigned *)(sqcq + p.sq_off.ring_mask); + r->sq_array = (unsigned *)(sqcq + p.sq_off.array); + r->cq_head = (unsigned *)(sqcq + p.cq_off.head); + r->cq_tail = (unsigned *)(sqcq + p.cq_off.tail); + r->cq_mask = (unsigned *)(sqcq + p.cq_off.ring_mask); + r->cqes = (struct io_uring_cqe *)(sqcq + p.cq_off.cqes); + + r->sqes = mmap(NULL, p.sq_entries * sizeof(struct io_uring_sqe), + PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE, + r->fd, IORING_OFF_SQES); + if (r->sqes == MAP_FAILED) { perror("mmap sqes"); return -1; } + + r->local_tail = *r->sq_tail; + r->to_submit = 0; + return 0; +} + +static struct io_uring_sqe *sqe_get(struct ring *r) { + unsigned idx = r->local_tail & *r->sq_mask; + struct io_uring_sqe *s = &r->sqes[idx]; + memset(s, 0, sizeof *s); + r->sq_array[idx] = idx; + r->local_tail++; + r->to_submit++; + return s; +} + +/* user_data = (op << 32) | fd-or-batch-index */ +enum { OP_ACCEPT = 1, OP_RECV, OP_SEND, OP_EVFD, OP_SIGFD, OP_WALWR, OP_FSYNC }; +static uint64_t ud(int op, int fd) { return ((uint64_t)op << 32) | (uint32_t)fd; } + +static int ring_enter(struct ring *r, unsigned wait) { + __atomic_store_n(r->sq_tail, r->local_tail, __ATOMIC_RELEASE); + unsigned n = r->to_submit; + r->to_submit = 0; + for (;;) { + int rc = (int)syscall(__NR_io_uring_enter, r->fd, n, wait, + IORING_ENTER_GETEVENTS, NULL, 0); + if (rc >= 0) return rc; + if (errno == EINTR) { n = 0; continue; } /* already submitted */ + perror("io_uring_enter"); + return -1; + } +} + +/* ----------------------------------------------------------- connection -- + * Keep-alive state machine. Exactly one outstanding SQE per connection: + * RECV while a request is being assembled, SEND while a response drains. + * Leftover bytes after a request (pipelining) are carried over and parsed + * before the next RECV is armed. */ + +struct conn { + char in[IN_CAP]; size_t in_len; + char out[OUT_CAP]; size_t out_len, out_off; + int in_use; + int closing; /* close once the out buffer drains */ + int await_durable;/* response parked until this tick's fsync CQE */ + uint64_t gen; /* incarnation stamp — kernel fds get reused */ +}; + +/* One group-commit batch: staged WAL bytes + the connections whose acks ride + * its fsync. Double-buffered: while batch[k] is in flight (write→fsync + * linked SQEs), new commits stage into batch[k^1]. + * Acks are parked as (fd, gen) pairs: an fd alone is ABA-unsafe — a parked + * connection can die, the kernel reuses its fd for a NEW connection whose + * commit sits in the OTHER batch, and a bare-fd release would ack that new + * connection before ITS record is durable. Found by the phase-F crash test + * (7 acked-but-unwritten records out of ~990k under reconnect churn). */ +struct wal_batch { + char buf[WAL_BATCH_CAP]; + size_t len; + int conns[WAL_BATCH_CONNS]; + uint64_t gens[WAL_BATCH_CONNS]; + int n_conns; +}; + +struct shard { + int id; + int lfd, evfd; + struct ring ring; + pthread_t tid; + int next_id; + _Atomic int used; + int wal_fd; + _Atomic size_t wal_off; /* owner-written; stats-readable cross-shard */ + struct wal_batch batch[2]; + int active; /* batch being staged */ + int in_flight; /* a write→fsync pair is on the ring */ + char snap_path[320]; + struct conn conns[MAX_FDS]; +}; + +/* Snapshot file: [snap_hdr][bitmap page][slot bytes]. Written on clean + * shutdown, loaded at boot before WAL replay. */ +struct snap_hdr { + char magic[8]; /* "WOSNAP\0\0" */ + uint32_t version; + int32_t next_id; + int32_t used; +}; + +static struct shard *shards; +static int n_threads = 1; +static int sigfd = -1; +static _Atomic unsigned long reqs[MAX_THREADS]; + +static int slot_alloc(struct shard *sh) { + uint64_t *bm = shard_bitmap(sh->id); + for (uint32_t w = 0; w < SLOTS_PER_SHARD / 64; w++) { + if (bm[w] == UINT64_MAX) continue; + uint32_t b = (uint32_t)__builtin_ctzll(~bm[w]); + uint32_t i = w * 64 + b; + if (i >= SLOTS_PER_SHARD) break; + bm[w] |= (1ULL << b); + atomic_fetch_add_explicit(&sh->used, 1, memory_order_relaxed); + return (int)i; + } + return -1; +} + +/* --------------------------------------------------------- SQE builders -- */ + +static void arm_accept(struct shard *sh) { /* multishot: arm once */ + struct io_uring_sqe *s = sqe_get(&sh->ring); + s->opcode = IORING_OP_ACCEPT; + s->fd = sh->lfd; + s->ioprio = IORING_ACCEPT_MULTISHOT; + s->user_data = ud(OP_ACCEPT, sh->lfd); +} + +static void arm_poll(struct shard *sh, int fd, int op) { + struct io_uring_sqe *s = sqe_get(&sh->ring); + s->opcode = IORING_OP_POLL_ADD; + s->fd = fd; + s->poll_events = POLLIN; + s->user_data = ud(op, fd); +} + +static void arm_recv(struct shard *sh, int fd) { + struct conn *c = &sh->conns[fd]; + struct io_uring_sqe *s = sqe_get(&sh->ring); + s->opcode = IORING_OP_RECV; + s->fd = fd; + s->addr = (uint64_t)(uintptr_t)(c->in + c->in_len); + s->len = (uint32_t)(IN_CAP - 1 - c->in_len); + s->user_data = ud(OP_RECV, fd); +} + +static void arm_send(struct shard *sh, int fd) { + struct conn *c = &sh->conns[fd]; + struct io_uring_sqe *s = sqe_get(&sh->ring); + s->opcode = IORING_OP_SEND; + s->fd = fd; + s->addr = (uint64_t)(uintptr_t)(c->out + c->out_off); + s->len = (uint32_t)(c->out_len - c->out_off); + s->msg_flags = MSG_NOSIGNAL; + s->user_data = ud(OP_SEND, fd); +} + +/* ------------------------------------------------------------ WAL flush -- + * Called once per loop tick. If commits were staged and no batch is in + * flight, submit ONE write SQE for the whole batch at the shard's tail + * offset, hard-linked to ONE fdatasync SQE. Every parked ack in the batch + * is released when the fsync CQE arrives — group commit. */ + +static void wal_flush(struct shard *sh) { + if (sh->in_flight) return; + struct wal_batch *b = &sh->batch[sh->active]; + if (b->len == 0) return; + + size_t off = atomic_load_explicit(&sh->wal_off, memory_order_relaxed); + + struct io_uring_sqe *w = sqe_get(&sh->ring); + w->opcode = IORING_OP_WRITE; + w->fd = sh->wal_fd; + w->addr = (uint64_t)(uintptr_t)b->buf; + w->len = (uint32_t)b->len; + w->off = off; + w->flags = IOSQE_IO_LINK; /* fsync follows the write */ + w->user_data = ud(OP_WALWR, sh->active); + + struct io_uring_sqe *f = sqe_get(&sh->ring); + f->opcode = IORING_OP_FSYNC; + f->fd = sh->wal_fd; + f->fsync_flags = IORING_FSYNC_DATASYNC; + f->user_data = ud(OP_FSYNC, sh->active); + + sh->in_flight = 1; + sh->active ^= 1; /* new commits stage in the twin */ +} + +/* Stage one commit's frame + park the connection's ack on the active batch. + * Returns 0 if the batch has no room (caller responds 503, no RAM apply). */ +static int wal_append(struct shard *sh, int connfd, uint32_t slot, int32_t id, const char *title) { + struct wal_batch *b = &sh->batch[sh->active]; + if (b->len + WAL_FRAME_BYTES > WAL_BATCH_CAP || b->n_conns >= WAL_BATCH_CONNS) + return 0; + + struct wal_payload p; + memset(&p, 0, sizeof p); + p.op = 1; + p.slot = slot; + p.id = id; + snprintf(p.title, sizeof p.title, "%s", title); + b->gens[b->n_conns] = sh->conns[connfd].gen; + + uint32_t len = (uint32_t)sizeof p; + uint32_t crc = crc32(&p, sizeof p); + uint32_t end = WAL_COMMIT; + char *dst = b->buf + b->len; + memcpy(dst, &len, 4); + memcpy(dst + 4, &crc, 4); + memcpy(dst + 8, &p, sizeof p); + memcpy(dst + 8 + sizeof p, &end, 4); + b->len += WAL_FRAME_BYTES; + b->conns[b->n_conns++] = connfd; + return 1; +} + +/* ----------------------------------------------------------------- http -- */ + +static void respond(struct conn *c, const char *status, const char *ctype, const char *body) { + size_t blen = strlen(body); + int n = snprintf(c->out + c->out_len, OUT_CAP - c->out_len, + "HTTP/1.1 %s\r\nContent-Type: %s\r\nContent-Length: %zu\r\nConnection: %s\r\n\r\n%s", + status, ctype, blen, c->closing ? "close" : "keep-alive", body); + if (n > 0 && (size_t)n < OUT_CAP - c->out_len) c->out_len += (size_t)n; + else c->closing = 1; /* response too big — drop conn */ +} + +static int json_title(const char *body, char *out, size_t cap) { + const char *p = strstr(body, "\"title\""); + if (!p) return 0; + p = strchr(p + 7, ':'); if (!p) return 0; + p = strchr(p, '"'); if (!p) return 0; + p++; + size_t i = 0; + while (*p && *p != '"' && i + 1 < cap) out[i++] = *p++; + out[i] = 0; + return i > 0; +} + +static void route(struct shard *sh, struct conn *c, const char *method, const char *path, const char *body) { + atomic_fetch_add_explicit(&reqs[sh->id], 1, memory_order_relaxed); + + if (!strcmp(method, "GET") && !strcmp(path, "/")) { + char out[1024]; + size_t off = (size_t)snprintf(out, sizeof out, + "{\"runtime\":\"wo-rt-c\",\"loop\":\"io_uring\",\"threads\":%d,\"shard\":%d," + "\"arena\":{\"bytes\":%zu,\"mapped\":%zu,\"hugepages\":%s,\"mlocked\":%s," + "\"slot_size\":%d,\"slots_per_shard\":%d},\"shard_used\":[", + n_threads, sh->id, arena_bytes, arena_map_bytes, + arena_huge ? "true" : "false", arena_locked ? "true" : "false", + SLOT_SIZE, SLOTS_PER_SHARD); + for (int t = 0; t < n_threads; t++) + off += (size_t)snprintf(out + off, sizeof out - off, "%s%d", t ? "," : "", + atomic_load_explicit(&shards[t].used, memory_order_relaxed)); + off += (size_t)snprintf(out + off, sizeof out - off, "],\"shard_requests\":["); + for (int t = 0; t < n_threads; t++) + off += (size_t)snprintf(out + off, sizeof out - off, "%s%lu", t ? "," : "", + atomic_load_explicit(&reqs[t], memory_order_relaxed)); + off += (size_t)snprintf(out + off, sizeof out - off, "],\"wal_bytes\":["); + for (int t = 0; t < n_threads; t++) + off += (size_t)snprintf(out + off, sizeof out - off, "%s%zu", t ? "," : "", + atomic_load_explicit(&shards[t].wal_off, memory_order_relaxed)); + snprintf(out + off, sizeof out - off, "]}"); + respond(c, "200 OK", "application/json", out); + } else if (!strcmp(method, "GET") && !strcmp(path, "/healthz")) { + respond(c, "200 OK", "text/plain", "ok"); + } else if (!strcmp(method, "GET") && !strcmp(path, "/api/notes")) { + static _Thread_local char out[SLOTS_PER_SHARD * 160 + 64]; + uint64_t *bm = shard_bitmap(sh->id); + size_t off = (size_t)snprintf(out, sizeof out, "{\"shard\":%d,\"notes\":[", sh->id); + int first = 1; + for (uint32_t i = 0; i < SLOTS_PER_SHARD; i++) { + if (!(bm[i / 64] & (1ULL << (i % 64)))) continue; + struct slot_note *n = slot_at(sh->id, i); + off += (size_t)snprintf(out + off, sizeof out - off, + "%s{\"id\":%d,\"title\":\"%s\"}", first ? "" : ",", n->id, n->title); + first = 0; + } + snprintf(out + off, sizeof out - off, "]}"); + respond(c, "200 OK", "application/json", out); + } else if (!strcmp(method, "POST") && !strcmp(path, "/api/notes")) { + char title[128]; + int i; + if (!json_title(body, title, sizeof title)) { + respond(c, "400 Bad Request", "application/json", "{\"error\":\"expected {\\\"title\\\":\\\"...\\\"}\"}"); + return; + } + /* Capacity gates BEFORE the RAM apply — an aborted op writes nothing. */ + struct wal_batch *b = &sh->batch[sh->active]; + if (b->len + WAL_FRAME_BYTES > WAL_BATCH_CAP || b->n_conns >= WAL_BATCH_CONNS) { + respond(c, "503 Service Unavailable", "application/json", "{\"error\":\"commit batch full, retry\"}"); + return; + } + if ((i = slot_alloc(sh)) < 0) { + respond(c, "507 Insufficient Storage", "application/json", "{\"error\":\"shard full\"}"); + return; + } + struct slot_note *n = slot_at(sh->id, (uint32_t)i); /* 1. the RAM apply */ + n->id = sh->next_id; + sh->next_id += n_threads; + snprintf(n->title, sizeof n->title, "%s", title); + char out[224]; + snprintf(out, sizeof out, "{\"id\":%d,\"title\":\"%s\",\"shard\":%d,\"slot\":%d}", + n->id, n->title, sh->id, i); + respond(c, "201 Created", "application/json", out); /* built, NOT sent */ + int connfd = (int)(c - sh->conns); /* conns is fd-indexed */ + wal_append(sh, connfd, (uint32_t)i, n->id, n->title);/* 2. stage WAL frame */ + c->await_durable = 1; /* 4. ack rides fsync */ + } else { + respond(c, "404 Not Found", "application/json", "{\"error\":\"no such route\"}"); + } +} + +/* ----------------------------------------------------- state machine ----- */ + +static void conn_open(struct shard *sh, int fd) { + struct conn *c = &sh->conns[fd]; + c->in_len = c->out_len = c->out_off = 0; + c->closing = 0; + c->await_durable = 0; + c->gen++; /* new incarnation — stale parked acks won't match */ + c->in_use = 1; +} + +static void conn_close(struct shard *sh, int fd) { + if (fd >= 0 && fd < MAX_FDS) sh->conns[fd].in_use = 0; + close(fd); +} + +/* Try to consume ONE complete request from the in buffer. Returns 1 if a + * response was produced (out has bytes), 0 if the request is incomplete. */ +static int try_process(struct shard *sh, struct conn *c) { + c->in[c->in_len] = 0; + char *hdr_end = strstr(c->in, "\r\n\r\n"); + if (!hdr_end) return 0; + char *body = hdr_end + 4; + size_t total = (size_t)(body - c->in); + + const char *cl = strcasestr(c->in, "Content-Length:"); + if (cl) { + long want = strtol(cl + 15, NULL, 10); + if (want < 0) want = 0; + if (c->in_len < total + (size_t)want) return 0; + total += (size_t)want; + } + + /* HTTP/1.1 defaults to keep-alive; honor an explicit close. */ + if (strcasestr(c->in, "connection: close") || + (strstr(c->in, "HTTP/1.0") && !strcasestr(c->in, "connection: keep-alive"))) + c->closing = 1; + + char method[8] = {0}, path[256] = {0}; + if (sscanf(c->in, "%7s %255s", method, path) == 2) + route(sh, c, method, path, body); + else + c->closing = 1; + + memmove(c->in, c->in + total, c->in_len - total); /* carry pipelined tail */ + c->in_len -= total; + return 1; +} + +/* Advance a connection: drain out via SEND, else parse, else arm RECV. + * A parked commit ack arms nothing — the fsync CQE handler resumes it. */ +static void conn_continue(struct shard *sh, int fd) { + struct conn *c = &sh->conns[fd]; + if (c->await_durable) { return; } + if (c->out_off < c->out_len) { arm_send(sh, fd); return; } + c->out_off = c->out_len = 0; + if (c->closing) { conn_close(sh, fd); return; } + if (try_process(sh, c)) { + /* route() may have JUST parked this response (await set inside + * try_process) — sending now would race the fsync. The fsync CQE + * re-enters here with await cleared and arms the send. + * (Found by the phase-F crash-under-load test: ~6 acked-but- + * unwritten records per ~750k at the kill instant.) */ + if (!c->await_durable) arm_send(sh, fd); + return; + } + if (c->in_len >= IN_CAP - 1) { conn_close(sh, fd); return; } /* oversize head */ + arm_recv(sh, fd); +} + +/* --------------------------------------------------------------- recovery -- + * First load: hard drive → RAM, per shard, in parallel, BEFORE accept arms. + * Snapshot (if any) restores the slice wholesale; the WAL tail replays + * commits since that snapshot. Frame validation is wal-check's logic with + * the printf swapped for the arena apply. */ + +static long now_ms(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return ts.tv_sec * 1000 + ts.tv_nsec / 1000000; +} + +static int snap_load(struct shard *sh) { + int fd = open(sh->snap_path, O_RDONLY | O_CLOEXEC); + if (fd < 0) return 0; + struct snap_hdr h; + size_t slot_bytes = (size_t)SLOTS_PER_SHARD * SLOT_SIZE; + if (read(fd, &h, sizeof h) != (ssize_t)sizeof h || + memcmp(h.magic, "WOSNAP\0", 8) != 0 || + pread(fd, shard_bitmap(sh->id), bitmap_bytes, (off_t)sizeof h) != (ssize_t)bitmap_bytes || + pread(fd, shard_slots(sh->id), slot_bytes, (off_t)(sizeof h + bitmap_bytes)) != (ssize_t)slot_bytes) { + fprintf(stderr, "[wo-rt-c] shard %d: snapshot unreadable — starting from WAL only\n", sh->id); + memset(shard_bitmap(sh->id), 0, bitmap_bytes + slot_bytes); + close(fd); + return 0; + } + sh->next_id = h.next_id; + atomic_store_explicit(&sh->used, h.used, memory_order_relaxed); + close(fd); + return h.used; +} + +static int wal_replay(struct shard *sh) { + size_t off = 0; + int recs = 0; + int32_t maxid = 0; + for (;;) { + uint32_t len, crc, end; + struct wal_payload p; + if (pread(sh->wal_fd, &len, 4, (off_t)off) != 4 || len == 0) break; + if (len != sizeof p || + pread(sh->wal_fd, &crc, 4, (off_t)(off + 4)) != 4 || + pread(sh->wal_fd, &p, sizeof p, (off_t)(off + 8)) != (ssize_t)sizeof p || + pread(sh->wal_fd, &end, 4, (off_t)(off + 8 + sizeof p)) != 4 || + crc32(&p, sizeof p) != crc || end != WAL_COMMIT) { + fprintf(stderr, "[wo-rt-c] shard %d: torn WAL record at byte %zu — truncating\n", + sh->id, off); + break; + } + if (p.op == 1 && p.slot < SLOTS_PER_SHARD) { /* idempotent apply */ + uint64_t *bm = shard_bitmap(sh->id); + if (!(bm[p.slot / 64] & (1ULL << (p.slot % 64)))) { + bm[p.slot / 64] |= (1ULL << (p.slot % 64)); + atomic_fetch_add_explicit(&sh->used, 1, memory_order_relaxed); + } + struct slot_note *n = slot_at(sh->id, p.slot); + n->id = p.id; + snprintf(n->title, sizeof n->title, "%s", p.title); + if (p.id > maxid) maxid = p.id; + } + recs++; + off += WAL_FRAME_BYTES; + } + /* Resume appends at the validated tail; drop torn bytes, re-preallocate. */ + if (ftruncate(sh->wal_fd, (off_t)off) == 0) + (void)!fallocate(sh->wal_fd, 0, 0, off > WAL_PREALLOC ? off : WAL_PREALLOC); + atomic_store_explicit(&sh->wal_off, off, memory_order_relaxed); + if (maxid > 0 && maxid + n_threads > sh->next_id) + sh->next_id = maxid + n_threads; /* interleaved high-water */ + return recs; +} + +/* Clean-shutdown snapshot: write slice → fsync → atomic rename → truncate WAL. */ +static void snap_write(struct shard *sh) { + char tmp[336]; + snprintf(tmp, sizeof tmp, "%s.tmp", sh->snap_path); + int fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644); + if (fd < 0) { perror("snapshot open"); return; } + struct snap_hdr h; + memset(&h, 0, sizeof h); + memcpy(h.magic, "WOSNAP\0", 8); + h.version = 1; + h.next_id = sh->next_id; + h.used = atomic_load_explicit(&sh->used, memory_order_relaxed); + size_t slot_bytes = (size_t)SLOTS_PER_SHARD * SLOT_SIZE; + int ok = write(fd, &h, sizeof h) == (ssize_t)sizeof h + && write(fd, shard_bitmap(sh->id), bitmap_bytes) == (ssize_t)bitmap_bytes + && write(fd, shard_slots(sh->id), slot_bytes) == (ssize_t)slot_bytes + && fsync(fd) == 0; + close(fd); + if (!ok || rename(tmp, sh->snap_path) < 0) { fprintf(stderr, "[wo-rt-c] shard %d: snapshot failed\n", sh->id); unlink(tmp); return; } + if (ftruncate(sh->wal_fd, 0) == 0) { /* WAL now redundant */ + (void)!fallocate(sh->wal_fd, 0, 0, WAL_PREALLOC); + fsync(sh->wal_fd); + } + printf("[wo-rt-c] shard %d: snapshot %d rows → %s, wal truncated\n", sh->id, h.used, sh->snap_path); +} + +/* ------------------------------------------------------------ shard loop -- */ + +static void *shard_main(void *arg) { + struct shard *sh = arg; + struct ring *r = &sh->ring; + + cpu_set_t set; + CPU_ZERO(&set); + CPU_SET((unsigned)sh->id % (unsigned)sysconf(_SC_NPROCESSORS_ONLN), &set); + pthread_setaffinity_np(pthread_self(), sizeof set, &set); + + /* First load: disk → RAM, before any accept is armed. */ + long t0 = now_ms(); + int srows = snap_load(sh); + int wrecs = wal_replay(sh); + if (srows || wrecs) + printf("[wo-rt-c] shard %d: recovered %d snapshot rows + %d wal records in %ld ms\n", + sh->id, srows, wrecs, now_ms() - t0); + + arm_accept(sh); + arm_poll(sh, sh->evfd, OP_EVFD); + if (sh->id == 0) arm_poll(sh, sigfd, OP_SIGFD); + + for (;;) { + if (ring_enter(r, 1) < 0) break; /* ONE syscall per tick */ + + unsigned head = *r->cq_head; + unsigned tail = __atomic_load_n(r->cq_tail, __ATOMIC_ACQUIRE); + for (; head != tail; head++) { + struct io_uring_cqe *cqe = &r->cqes[head & *r->cq_mask]; + int op = (int)(cqe->user_data >> 32); + int fd = (int)(uint32_t)cqe->user_data; + int res = cqe->res; + + switch (op) { + case OP_EVFD: { + uint64_t v; + (void)!read(sh->evfd, &v, sizeof v); + __atomic_store_n(r->cq_head, head + 1, __ATOMIC_RELEASE); + return NULL; + } + case OP_SIGFD: { /* shard 0 only */ + struct signalfd_siginfo si; + if (read(sigfd, &si, sizeof si) == sizeof si) + printf("\n[wo-rt-c] signal %u — broadcasting shutdown to %d shards\n", + si.ssi_signo, n_threads); + uint64_t one = 1; + for (int t = 0; t < n_threads; t++) + (void)!write(shards[t].evfd, &one, sizeof one); + break; + } + case OP_ACCEPT: { + if (res >= 0) { + int cfd = res; + if (cfd >= MAX_FDS) close(cfd); + else { conn_open(sh, cfd); arm_recv(sh, cfd); } + } + if (!(cqe->flags & IORING_CQE_F_MORE)) arm_accept(sh); /* re-arm */ + break; + } + case OP_RECV: { + struct conn *c = &sh->conns[fd]; + if (!c->in_use) break; + if (res <= 0) { conn_close(sh, fd); break; } + c->in_len += (size_t)res; + conn_continue(sh, fd); + break; + } + case OP_SEND: { + struct conn *c = &sh->conns[fd]; + if (!c->in_use) break; + if (res <= 0) { conn_close(sh, fd); break; } + c->out_off += (size_t)res; + conn_continue(sh, fd); + break; + } + case OP_WALWR: { /* fd field carries the batch idx */ + struct wal_batch *b = &sh->batch[fd]; + if (res != (int)b->len) + fprintf(stderr, "[wo-rt-c] shard %d: WAL write %d != %zu\n", sh->id, res, b->len); + else + atomic_fetch_add_explicit(&sh->wal_off, b->len, memory_order_relaxed); + break; + } + case OP_FSYNC: { /* group commit lands: release acks */ + struct wal_batch *b = &sh->batch[fd]; + int failed = (res < 0); /* incl. -ECANCELED from a failed link */ + if (failed) + fprintf(stderr, "[wo-rt-c] shard %d: fsync failed (%d) — dropping %d acks\n", + sh->id, res, b->n_conns); + for (int k = 0; k < b->n_conns; k++) { + int cfd = b->conns[k]; + if (cfd < 0 || cfd >= MAX_FDS) continue; + struct conn *c = &sh->conns[cfd]; + if (!c->in_use || !c->await_durable) continue; + if (c->gen != b->gens[k]) continue; /* fd reused — not ours */ + c->await_durable = 0; + if (failed) conn_close(sh, cfd); /* never ack non-durable */ + else conn_continue(sh, cfd); + } + b->len = 0; + b->n_conns = 0; + sh->in_flight = 0; + break; + } + } + } + __atomic_store_n(r->cq_head, head, __ATOMIC_RELEASE); + wal_flush(sh); /* one write→fsync pair per tick */ + } + return NULL; +} + +/* ------------------------------------------------------------- listener -- */ + +static int listener_bind(uint16_t port) { + int fd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (fd < 0) { perror("socket"); return -1; } + int one = 1; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); + setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof one); + struct sockaddr_in addr = {0}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (bind(fd, (struct sockaddr *)&addr, sizeof addr) < 0) { perror("bind"); close(fd); return -1; } + if (listen(fd, SOMAXCONN) < 0) { perror("listen"); close(fd); return -1; } + return fd; +} + +static int sig_setup(void) { + sigset_t mask; + sigemptyset(&mask); + sigaddset(&mask, SIGINT); + sigaddset(&mask, SIGTERM); + if (sigprocmask(SIG_BLOCK, &mask, NULL) < 0) { perror("sigprocmask"); return -1; } + int fd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC); + if (fd < 0) perror("signalfd"); + return fd; +} + +/* ------------------------------------------------------------ wal-check -- + * Offline frame walker: validates every record's len/CRC/COMMIT trailer, + * reports the count and where (if anywhere) the log tears. This is the + * crash test's witness, and the skeleton of phase E's replay loop. */ + +static int wal_check(const char *path) { + int fd = open(path, O_RDONLY); + if (fd < 0) { fprintf(stderr, "wal-check: %s: %s\n", path, strerror(errno)); return 1; } + size_t off = 0; + int recs = 0; + for (;;) { + uint32_t len, crc, end; + struct wal_payload p; + if (pread(fd, &len, 4, (off_t)off) != 4) break; + if (len == 0) break; /* fallocate'd tail */ + if (len != sizeof p) { + printf("%s: TORN at byte %zu (bad len %u) — %d whole records before it\n", + path, off, len, recs); + close(fd); + return 0; + } + if (pread(fd, &crc, 4, (off_t)(off + 4)) != 4 || + pread(fd, &p, sizeof p, (off_t)(off + 8)) != (ssize_t)sizeof p || + pread(fd, &end, 4, (off_t)(off + 8 + sizeof p)) != 4 || + crc32(&p, sizeof p) != crc || end != WAL_COMMIT) { + printf("%s: TORN at byte %zu (bad crc/trailer) — %d whole records before it\n", + path, off, recs); + close(fd); + return 0; + } + printf("%s: rec %d op=%u slot=%u id=%d title=\"%s\"\n", path, recs, p.op, p.slot, p.id, p.title); + recs++; + off += WAL_FRAME_BYTES; + } + printf("%s: %d records, all frames valid, clean tail at byte %zu\n", path, recs, off); + close(fd); + return 0; +} + +/* ----------------------------------------------------------------- main -- */ + +int main(int argc, char **argv) { + crc32_init(); + + if (argc >= 3 && !strcmp(argv[1], "wal-check")) { + int rc = 0; + for (int a = 2; a < argc; a++) rc |= wal_check(argv[a]); + return rc; + } + + uint16_t port = 8085; + const char *env = getenv("WO_PORT"); + if (env && atoi(env) > 0) port = (uint16_t)atoi(env); + + long cores = sysconf(_SC_NPROCESSORS_ONLN); + n_threads = (int)cores; + env = getenv("WO_THREADS"); + if (env && atoi(env) > 0) n_threads = atoi(env); + if (n_threads < 1) n_threads = 1; + if (n_threads > MAX_THREADS) n_threads = MAX_THREADS; + + /* Million-connection posture: lift the fd ceiling to the hard max. */ + struct rlimit rl; + if (getrlimit(RLIMIT_NOFILE, &rl) == 0 && rl.rlim_cur < rl.rlim_max) { + rl.rlim_cur = rl.rlim_max; + setrlimit(RLIMIT_NOFILE, &rl); + } + + sigfd = sig_setup(); + if (sigfd < 0) return 1; + if (arena_init(n_threads) < 0) return 1; + + const char *data_dir = getenv("WO_DATA"); + if (!data_dir || !*data_dir) data_dir = "./wo-data"; + if (mkdir(data_dir, 0755) < 0 && errno != EEXIST) { perror("mkdir data dir"); return 1; } + + /* The data dir is sharded for exactly n_threads. A different WO_THREADS + * would strand WAL/snapshot files silently — refuse (resharding = 09f). */ + char mpath[512]; + snprintf(mpath, sizeof mpath, "%s/meta", data_dir); + FILE *mf = fopen(mpath, "r"); + if (mf) { + int prev = 0; + if (fscanf(mf, "%d", &prev) == 1 && prev != n_threads) { + fprintf(stderr, "[wo-rt-c] %s was written with WO_THREADS=%d — restart with that, or wipe the dir\n", + data_dir, prev); + fclose(mf); + return 1; + } + fclose(mf); + } else if ((mf = fopen(mpath, "w"))) { + fprintf(mf, "%d\n", n_threads); + fclose(mf); + } + + shards = calloc((size_t)n_threads, sizeof(struct shard)); + if (!shards) { perror("calloc"); return 1; } + + for (int t = 0; t < n_threads; t++) { + struct shard *sh = &shards[t]; + sh->id = t; + sh->next_id = t + 1; + sh->lfd = listener_bind(port); + sh->evfd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC); + if (sh->lfd < 0 || sh->evfd < 0) return 1; + if (ring_init(&sh->ring) < 0) return 1; + + /* Per-shard WAL, preallocated, NOT truncated — boot replays it. */ + char path[512]; + snprintf(path, sizeof path, "%s/shard-%d.wal", data_dir, t); + sh->wal_fd = open(path, O_RDWR | O_CREAT | O_CLOEXEC, 0644); + if (sh->wal_fd < 0) { perror("open wal"); return 1; } + if (fallocate(sh->wal_fd, 0, 0, WAL_PREALLOC) < 0) + fprintf(stderr, "[wo-rt-c] warn: fallocate %s refused (%s)\n", path, strerror(errno)); + snprintf(sh->snap_path, sizeof sh->snap_path, "%s/shard-%d.data", data_dir, t); + } + + printf("[wo-rt-c] %d shard%s on http://127.0.0.1:%u — io_uring loops, keep-alive — arena %zu KB (%s pages, %s) — ctrl-C to stop\n", + n_threads, n_threads == 1 ? "" : "s", port, + arena_map_bytes / 1024, + arena_huge ? "2M huge" : "4K", + arena_locked ? "mlocked" : "NOT locked"); + printf(" GET / runtime + arena info, per-shard stats\n GET /healthz liveness\n"); + printf(" GET /api/notes list (connection's shard)\n POST /api/notes create {\"title\":\"...\"}\n"); + fflush(stdout); + + for (int t = 0; t < n_threads; t++) + pthread_create(&shards[t].tid, NULL, shard_main, &shards[t]); + for (int t = 0; t < n_threads; t++) + pthread_join(shards[t].tid, NULL); + + /* Clean shutdown: persist each slice as a snapshot, truncate the WALs. + * A kill -9 skips this — that's what boot-time WAL replay is for. */ + for (int t = 0; t < n_threads; t++) + snap_write(&shards[t]); + + printf("[wo-rt-c] all %d shards joined — bye\n", n_threads); + for (int t = 0; t < n_threads; t++) { + close(shards[t].ring.fd); close(shards[t].lfd); close(shards[t].evfd); + close(shards[t].wal_fd); + } + close(sigfd); + munmap(arena, arena_map_bytes); + free(shards); + return 0; +}