Reference counting and Bacon-Rajan trial deletion are gone from the runtime. Traced (inferred-gc) objects now die only by the collector; owned values keep deterministic drops exactly as before. - wo_hdr: rc retired; borrow and the freed 4 bytes become a union — non-traced values keep the borrow word, traced objects use the 8 bytes as the intrusive sweep-list link. Header stays exactly 16 bytes. WHITE is now the all-zero color (allocations born white by memset); WO_F_BUF retired. - gc.c rewritten: snapshot-at-beginning tri-color mark-sweep. Roots (frames' gc+owned masks) shaded atomically at cycle start; Yuasa deletion barrier shades the OLD target of every gcref edge deleted while marking (SETF overwrites + every owned-death path, which all funnel through wo_drop_kind's GCREF case); allocations mid-cycle born black. Mark AND sweep budgeted (WO_GC_BUDGET objects/slice), sweep resumes via a cursor; gray-worklist OOM degrades to a blacken-all cycle (frees nothing, never wrong). Owned interiors walked eagerly (single-owner trees), pruned by a per-class may-gcref bit computed at rt_init (fixpoint over kinds + v2 field_class/field_elem; conservative when metadata is absent). - vm.c: safepoints at NEW (the heap-goal trigger), CALL, and backward JMP; root scan follows vm_unwind's governing-pc convention. Unwind's gc-mask branch just nulls the register. RC_INC/RC_DEC are accepted as no-ops until the emitter stops producing them (next commit) — which also deletes the old RC_DEC-on-nil trap that broke `?Node` gcref field stores. - main.c pump: post-exit, a rootless cycle frees everything unreachable in budgeted slices; the trace line moved into wo_gc_slice (one format for pump and in-program slices). rt_destroy frees traced remnants (trap paths, tests). - WO_GC_GOAL joins WO_GC_BUDGET/WO_GC_TRACE as an rt-owned knob (default 256 KiB; a tiny goal forces mid-program cycles for testing). - tests: test_cycle.c rewritten (abandoned cycle freed, rooted cycle survives, slices bounded, cycle-through-multi, repeated-cycle leak-freedom, and the spec's load-bearing DELETION-BARRIER test: an object hidden behind a black object mid-mark must survive). test_rc.c re-pinned to owned drops + the owned/traced boundary; test_obj.c asserts tracked-white-linked instead of rc=1. Verified: make test + test-iso (all suites, ASan/UBSan; test_cycle 42/0, test_rc 14/0) + cli_smoke; oop-e2e 79/0 (gc corpus traces unchanged: the new slice math reproduces steps=1/freed=2 and steps=2/freed=4); employee 8/0; log-watcher 7/0. THE RING RUNS: docs/examples/gc-cycle prints `ring a -> b -> c -> a`, is reclaimed post-exit (freed=3 remaining=0), is ASan clean, and survives an in-program cycle while rooted (WO_GC_GOAL=64: mid-run slice frees 0, post-exit frees 3). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
251 lines
10 KiB
C
251 lines
10 KiB
C
/* 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 <fcntl.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <sys/mman.h>
|
|
#include <sys/stat.h>
|
|
#include <unistd.h>
|
|
|
|
#include "cont.h"
|
|
#include "gc.h"
|
|
#include "table.h"
|
|
#include "vm.h"
|
|
#include "wal.h"
|
|
|
|
/* stamped by the Makefile from the repo-root VERSION file; the fallback keeps
|
|
* a hand-compiled main.c building. `just dist` asserts it matches `woc`. */
|
|
#ifndef WO_VERSION
|
|
#define WO_VERSION "0.0.0-dev"
|
|
#endif
|
|
|
|
static wo_vm VM; /* 32K value stack: keep it off the C stack */
|
|
static wo_db DB; /* the per-shard engine (one shard until iteration 8) */
|
|
static wo_wal WAL;
|
|
|
|
/* ---- self-exec detection (Task 6, plan 3) -------------------------------
|
|
* `woc build` makes a single executable by copying wovm and appending the
|
|
* .wob image plus a fixed-size trailer; docs/plan/oop-vm/00-wob-format.md's
|
|
* "single-binary trailer" section is the normative layout (writer:
|
|
* compiler/bin/main.ml) -- keep this reader in lock-step with it. Reading
|
|
* this executable's own path via /proc/self/exe is Linux-only, matching
|
|
* wob.h's own platform note. */
|
|
#define WO_TRAILER_MAGIC 0x31544257u /* "WBT1" read as LE u32 */
|
|
#define WO_TRAILER_SIZE 20u /* payload_off u64, payload_len u64, magic u32 */
|
|
|
|
/* 1 = embedded image found and loaded into *mod (caller must ignore argv);
|
|
* 0 = no trailer (plain wovm binary; caller falls back to argv[1] as
|
|
* today); -1 = a trailer is present but corrupt (err filled; caller must
|
|
* report and exit -- never guess or run something unintended). */
|
|
static int load_self_embedded(wo_module *mod, char *err, size_t errlen) {
|
|
int fd = open("/proc/self/exe", O_RDONLY);
|
|
if (fd < 0) return 0;
|
|
struct stat st;
|
|
if (fstat(fd, &st) != 0 || st.st_size < 0) {
|
|
close(fd);
|
|
return 0;
|
|
}
|
|
size_t size = (size_t)st.st_size;
|
|
if (size < WO_TRAILER_SIZE) {
|
|
close(fd);
|
|
return 0;
|
|
}
|
|
uint8_t tail[WO_TRAILER_SIZE];
|
|
if (lseek(fd, (off_t)(size - WO_TRAILER_SIZE), SEEK_SET) < 0 ||
|
|
read(fd, tail, WO_TRAILER_SIZE) != (ssize_t)WO_TRAILER_SIZE) {
|
|
close(fd);
|
|
return 0;
|
|
}
|
|
uint32_t magic;
|
|
memcpy(&magic, tail + 16, 4);
|
|
if (magic != WO_TRAILER_MAGIC) {
|
|
close(fd);
|
|
return 0; /* plain wovm binary, nothing embedded */
|
|
}
|
|
uint64_t payload_off, payload_len;
|
|
memcpy(&payload_off, tail + 0, 8);
|
|
memcpy(&payload_len, tail + 8, 8);
|
|
/* payload must exactly fill everything between its offset and the
|
|
* trailer -- no gap, no overlap. Bounding payload_off first makes the
|
|
* subtraction below safe (no unsigned wraparound on a corrupt value). */
|
|
if (payload_off > size - WO_TRAILER_SIZE) {
|
|
close(fd);
|
|
snprintf(err, errlen, "corrupt trailer (bad payload offset)");
|
|
return -1;
|
|
}
|
|
if (payload_len != size - WO_TRAILER_SIZE - payload_off) {
|
|
close(fd);
|
|
snprintf(err, errlen, "corrupt trailer (bad payload length)");
|
|
return -1;
|
|
}
|
|
void *p = mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd, 0);
|
|
close(fd);
|
|
if (p == MAP_FAILED) {
|
|
snprintf(err, errlen, "cannot mmap self");
|
|
return -1;
|
|
}
|
|
int rc = wo_load_buf(mod, (const uint8_t *)p + payload_off, (size_t)payload_len, err, errlen);
|
|
munmap(p, size);
|
|
return rc == 0 ? 1 : -1;
|
|
}
|
|
|
|
/* ---- gc pump (iteration 7b) ---------------------------------------------
|
|
* After the entry method returns the stack is empty, so a collection cycle
|
|
* has no roots: everything still on the traced list is unreachable and one
|
|
* cycle frees it all, in budgeted slices (WO_GC_BUDGET objects per slice —
|
|
* rt owns the knobs now, read at init). WO_GC_TRACE prints one stderr line
|
|
* per slice (never stdout — the corpus harness diffs stdout byte-for-byte)
|
|
* so a fixture can assert "collection happened in bounded slices", not just
|
|
* "the leak is gone". A mid-program cycle interrupted by exit is finished
|
|
* here the same way. The zero-progress guard turns a would-be hang (a bug)
|
|
* into a leak the ASan gate reports instead. */
|
|
static void gc_pump(wo_vm *vm) {
|
|
wo_rt *rt = &vm->rt;
|
|
size_t at_begin = rt->gc_traced_cnt;
|
|
while (rt->gc_traced) {
|
|
if (rt->gc_phase == WO_GC_IDLE) {
|
|
at_begin = rt->gc_traced_cnt;
|
|
wo_gc_begin(rt); /* no roots: the stack is empty at depth 0 */
|
|
}
|
|
wo_gc_slice(rt, rt->gc_budget);
|
|
if (rt->gc_phase == WO_GC_IDLE && rt->gc_traced_cnt == at_begin) break;
|
|
}
|
|
}
|
|
|
|
int main(int argc, char **argv) {
|
|
wo_module mod;
|
|
char err[256];
|
|
int self_rc = load_self_embedded(&mod, err, sizeof err);
|
|
if (self_rc < 0) {
|
|
fprintf(stderr, "wovm: %s\n", err);
|
|
return 2;
|
|
}
|
|
if (self_rc == 0) {
|
|
/* no embedded image: the image path is argv[1], and program mode
|
|
passes everything after it to the program itself. `--version` is
|
|
handled ONLY here (plain wovm) so a built app never shadows its
|
|
own `version` argument. */
|
|
if (argc >= 2 && (strcmp(argv[1], "--version") == 0 || strcmp(argv[1], "version") == 0)) {
|
|
printf("wovm %s\n", WO_VERSION);
|
|
return 0;
|
|
}
|
|
if (argc < 2) {
|
|
fprintf(stderr, "usage: wovm <file.wob> [args...]\n");
|
|
return 2;
|
|
}
|
|
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;
|
|
}
|
|
/* The database engine boots with the VM: every class IS a table.
|
|
* Durability is opt-in — WO_DATA=<dir> opens <dir>/shard-0.wal,
|
|
* replays it before the entry runs (boot-before-listeners doctrine),
|
|
* and every insert commits before it acknowledges. Without WO_DATA
|
|
* the engine runs RAM-only, which is what the corpus expects. */
|
|
if (wo_db_init(&DB, mod.classes, mod.class_cnt, 0, 1) != 0) {
|
|
fprintf(stderr, "wovm: cannot initialize the database engine\n");
|
|
wo_vm_destroy(&VM);
|
|
wo_module_free(&mod);
|
|
return 2;
|
|
}
|
|
VM.rt.db = &DB;
|
|
const char *data_dir = getenv("WO_DATA");
|
|
if (data_dir && data_dir[0]) {
|
|
char wal_path[512];
|
|
snprintf(wal_path, sizeof wal_path, "%s/shard-0.wal", data_dir);
|
|
if (wo_wal_replay(wal_path, &DB) < 0) {
|
|
fprintf(stderr, "wovm: %s: replay found corruption beyond a torn tail\n", wal_path);
|
|
wo_db_destroy(&DB);
|
|
wo_vm_destroy(&VM);
|
|
wo_module_free(&mod);
|
|
return 2;
|
|
}
|
|
if (wo_wal_open(&WAL, wal_path, 1u << 20) != 0) {
|
|
fprintf(stderr, "wovm: cannot open %s\n", wal_path);
|
|
wo_db_destroy(&DB);
|
|
wo_vm_destroy(&VM);
|
|
wo_module_free(&mod);
|
|
return 2;
|
|
}
|
|
VM.rt.wal = &WAL;
|
|
}
|
|
|
|
/* Program mode: an entry that declares one parameter gets the program's
|
|
* OWN arguments as a `multi Text` — not the program name, and not the
|
|
* image path a plain `wovm image.wob args...` invocation carries. So
|
|
* `args[0]` is the first real argument (the workload's own contract:
|
|
* `args[0] == "watch"`, `args[1]` the log file). An entry with no
|
|
* parameters is called exactly as before. */
|
|
uint64_t argv_val = 0;
|
|
uint32_t entry_argc = mod.methods[mod.entry].arg_cnt;
|
|
if (entry_argc == 1) {
|
|
wo_multi *args = wo_multi_new(&VM.rt, WO_K_TEXT);
|
|
if (!args) {
|
|
fprintf(stderr, "wovm: cannot allocate the argument list\n");
|
|
wo_vm_destroy(&VM);
|
|
wo_module_free(&mod);
|
|
return 2;
|
|
}
|
|
int first = self_rc == 0 ? 2 : 1; /* skip the image path when there is one */
|
|
for (int i = first; i < argc; i++) {
|
|
wo_str *s = wo_str_new(&VM.rt, argv[i], (uint32_t)strlen(argv[i]));
|
|
if (!s || wo_multi_push(args, (uint64_t)(uintptr_t)s) != 0) {
|
|
fprintf(stderr, "wovm: cannot allocate the argument list\n");
|
|
wo_vm_destroy(&VM);
|
|
wo_module_free(&mod);
|
|
return 2;
|
|
}
|
|
}
|
|
argv_val = (uint64_t)(uintptr_t)args;
|
|
}
|
|
uint64_t ret = 0;
|
|
wo_err terr;
|
|
int rc = wo_vm_call(&VM, mod.entry, entry_argc == 1 ? &argv_val : NULL, entry_argc, &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);
|
|
/* the entry's return value IS the exit code (docs/plan/oop-vm/
|
|
* 08-builtin-surface.md's "Program entry"): 0..255, a trap is 1. A stop
|
|
* (rc == 1) is not a failure and not a trap — SIGTERM landing in a
|
|
* blocking call is the operator asking for the shutdown the program
|
|
* would have taken at its own next `env.stopping()` check, so it exits
|
|
* with the status that check's `return 0` would have produced. */
|
|
int exit_code = rc == 0 ? (int)((uint64_t)ret & 0xFF) : (rc > 0 ? 0 : 1);
|
|
/* the entry only BORROWS its arguments -- a parameter is never a `take`,
|
|
* and the drop tables never drop one -- so the runtime that built the
|
|
* container is the one that releases it, elements included. Without this
|
|
* the workload reported a leak on every path, in every mode, which is
|
|
* exactly the noise a soak measurement cannot afford. It runs before the
|
|
* heap is torn down, and after a trap too: the container outlives the
|
|
* unwind. */
|
|
if (argv_val) wo_drop_kind(&VM.rt, WO_K_MULTI, argv_val);
|
|
if (VM.rt.wal) wo_wal_close(&WAL);
|
|
wo_db_destroy(&DB);
|
|
gc_pump(&VM);
|
|
wo_vm_destroy(&VM);
|
|
wo_module_free(&mod);
|
|
return exit_code;
|
|
}
|