From f88aa11cef5fe245e4b195ddd9f171d23bef4022 Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Thu, 20 Aug 2026 07:03:40 +0200 Subject: [PATCH] feat(runtime): fiber run queue + reduction budget (arc T2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fiber states (RUNNABLE/PARKED/DONE), intrusive FIFO run queue, wo_vm_spawn_fiber (calloc'd context, frame 0 set up like wo_vm_call) - reduction budget: WO_REDUCTIONS (default 4000), checked at loop BACK-EDGES AFTER the jump lands so the saved pc is the loop head — a pre-instruction save at budget 1 re-executes the jump into the same decrement and livelocks (found by reasoning, pinned by the budget-1 test; deviation from the spec's three-site wording, recorded in the yield macro's comment) - FIBER_DONE: main returning ends the program and reaps every remaining fiber through vm_unwind (drop maps run); a spawned fiber ending frees silently; its return value is discarded by contract - TRAPF: an uncaught trap in a spawned fiber kills that fiber ALONE (stderr report, program lives); in main it stays the program's death - WO_SYS_STOPPED reaps all fibers wherever it lands (main unlinked from the queue and unwound if a spawned fiber caught the stop) - vm_gc_roots walks the live fiber plus every queued one - test_fiber (45 checks, ASan): EXACT round-robin interleave at budget 1 across three fibers pushing tags into one shared multi; main-return reaps a spinning fiber holding an owned Big (ASan proves the free); a DIV0 fiber dies alone, main answers 0 - full battery green: wovm-test, oop-e2e 89/0, woc-test, log-watcher 7/0, employee 8/0, web-app 21/0, deps-accept 8/0 (scheduler dormant = one branch per back-edge) Co-Authored-By: Claude Opus 5 (1M context) --- runtime/src/vm.c | 177 ++++++++++++++++++++++++++++++++-- runtime/src/vm.h | 21 ++++- runtime/test/test_fiber.c | 193 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 380 insertions(+), 11 deletions(-) create mode 100644 runtime/test/test_fiber.c diff --git a/runtime/src/vm.c b/runtime/src/vm.c index 6aef2ec..86ad58c 100644 --- a/runtime/src/vm.c +++ b/runtime/src/vm.c @@ -2,6 +2,7 @@ #include #include +#include #include #include "borrow.h" @@ -15,11 +16,80 @@ int wo_vm_init(wo_vm *vm, const wo_module *mod, size_t heap_cap) { memset(vm, 0, sizeof(*vm)); vm->mod = mod; vm->cur = &vm->f0; /* fiber 0: main — the one-fiber degenerate case */ + vm->budget0 = 4000; /* reductions per slice, the BEAM-ish default */ + { + const char *e = getenv("WO_REDUCTIONS"); + if (e && *e) { + long v = atol(e); + if (v > 0) vm->budget0 = v; + } + } + vm->budget = vm->budget0; 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); } +/* ---- the run queue (stage 1 Task 2) ---------------------------------- */ + +static void vm_unwind(wo_vm *vm, uint32_t stop_depth); + +static void fib_enqueue(wo_vm *vm, wo_fiber *fb) { + fb->state = WO_FIB_RUNNABLE; + fb->next = NULL; + if (vm->qtail) vm->qtail->next = fb; + else vm->qhead = fb; + vm->qtail = fb; +} + +static wo_fiber *fib_dequeue(wo_vm *vm) { + wo_fiber *fb = vm->qhead; + if (fb) { + vm->qhead = fb->next; + if (!vm->qhead) vm->qtail = NULL; + fb->next = NULL; + } + return fb; +} + +wo_fiber *wo_vm_spawn_fiber(wo_vm *vm, uint32_t method_idx, const uint64_t *args, + uint32_t argc) { + if (method_idx >= vm->mod->method_cnt) return NULL; + const wo_methodrec *sme = &vm->mod->methods[method_idx]; + if (argc != sme->arg_cnt) return NULL; + wo_fiber *fb = calloc(1, sizeof(*fb)); + if (!fb) return NULL; + fb->depth = 1; + fb->frames[0].method = method_idx; + fb->frames[0].pc = 0; + fb->frames[0].base = 0; + if (argc) memcpy(fb->regs, args, (size_t)argc * 8u); + memset(fb->regs + argc, 0, (size_t)(sme->reg_cnt - argc) * 8u); + vm->nfibers++; + fib_enqueue(vm, fb); + return fb; +} + +/* Unwind and release one fiber's live frames (drop maps run — parked and + * queued fibers die as cleanly as trapped ones), then free it if it is a + * spawned one. `vm->cur` is borrowed to do it, restored after. */ +static void fib_reap(wo_vm *vm, wo_fiber *fb) { + wo_fiber *save = vm->cur; + vm->cur = fb; + vm_unwind(vm, 0); + vm->cur = save; + if (fb != &vm->f0) { + vm->nfibers--; + free(fb); + } +} + +/* Main finished (return or stop): every remaining fiber unwinds clean. */ +static void fib_reap_all(wo_vm *vm) { + wo_fiber *fb; + while ((fb = fib_dequeue(vm)) != NULL) fib_reap(vm, fb); +} + /* The drop-table entry governing instruction [pc]: the last one recorded * at or before it. NULL = nothing live there. */ static const wo_dropent *vm_dropent(const wo_methodrec *me, uint32_t pc) { @@ -89,7 +159,13 @@ static void vm_gc_roots_fiber(wo_vm *vm, const wo_fiber *fb) { } } -static void vm_gc_roots(wo_vm *vm) { vm_gc_roots_fiber(vm, &vm->f0); } +static void vm_gc_roots(wo_vm *vm) { + /* the live fiber plus every queued one; fiber 0 is always one of + * those two (parked fibers join here in stage 1 Task 4) */ + vm_gc_roots_fiber(vm, vm->cur); + for (const wo_fiber *fb = vm->qhead; fb; fb = fb->next) + vm_gc_roots_fiber(vm, fb); +} /* One safepoint: start a cycle when the trigger says so (snapshot the * roots before the mutator resumes), then run one budgeted slice while a @@ -235,6 +311,24 @@ static int vm_run(wo_vm *vm, uint64_t *ret, wo_err *err) { RELOAD(); \ NEXT(); \ } \ + /* uncaught: the fiber's stack is already unwound. Main dying is \ + * the program dying (unchanged); a spawned fiber dies ALONE — \ + * the report goes to stderr the uncaught-trap way and the \ + * program lives (the arc's isolation rule). */ \ + if (vm->cur != &vm->f0) { \ + if (err) \ + fprintf(stderr, \ + "wovm: fiber trap %d at %s:%d: %s\n", \ + err->code, err->method, err->line, err->msg); \ + wo_fiber *dead = vm->cur; \ + vm->cur = fib_dequeue(vm); \ + vm->nfibers--; \ + free(dead); \ + vm->budget = vm->budget0; \ + RELOAD(); \ + NEXT(); \ + } \ + fib_reap_all(vm); \ return -1; \ } while (0) @@ -250,6 +344,27 @@ static int vm_run(wo_vm *vm, uint64_t *ret, wo_err *err) { } \ } while (0) +/* Reduction budget (stage 1 Task 2). Checked ONLY at loop back-edges, + * AFTER the jump has landed, so the saved pc is the loop head and resume + * makes progress — a pre-instruction save at budget 1 would re-execute + * the jump, hit the same decrement, and livelock. (Deviation from the + * spec's "same three sites as the GC": NEW/CALL re-execution has the + * identical livelock shape; back-edges alone bound every loop, which is + * what preemption is for. Recorded in the arc plan.) */ +#define FIBER_BUDGET() \ + do { \ + if (--vm->budget <= 0) { \ + vm->budget = vm->budget0; \ + if (vm->qhead) { \ + vm->cur->frames[vm->cur->depth - 1].pc = pc; \ + fib_enqueue(vm, vm->cur); \ + vm->cur = fib_dequeue(vm); \ + RELOAD(); \ + NEXT(); \ + } \ + } \ + } while (0) + RELOAD(); /* dual-flavor dispatch, one shared case-body text (spec §5): computed @@ -347,7 +462,12 @@ dispatch: } CASE(JMP) : { - if (wo_ins_sbx(ins) < 0) GC_SAFEPOINT(); /* loop back-edge */ + if (wo_ins_sbx(ins) < 0) { + GC_SAFEPOINT(); /* loop back-edge */ + pc = (uint32_t)((int64_t)pc + wo_ins_sbx(ins)); + FIBER_BUDGET(); /* after the jump lands: resume = the loop head */ + NEXT(); + } pc = (uint32_t)((int64_t)pc + wo_ins_sbx(ins)); NEXT(); } @@ -387,15 +507,35 @@ dispatch: while (vm->cur->ncatch && vm->cur->catches[vm->cur->ncatch - 1].depth > vm->cur->depth) \ vm->cur->ncatch-- +/* A fiber's last frame returned. Main ending IS the program ending: every + * other fiber unwinds through its drop maps (clean, ASan-proven) and the + * program's value is main's. A spawned fiber ending just leaves the + * scheduler; its return value is discarded (the spawn surface's entry + * wrapper returns nothing owned — Task 3's contract). The queue cannot be + * empty when a spawned fiber ends: main never parks in stage 1, so it is + * either live or queued. */ +#define FIBER_DONE(rv) \ + do { \ + if (vm->cur == &vm->f0) { \ + fib_reap_all(vm); \ + *ret = (rv); \ + return 0; \ + } \ + wo_fiber *dead = vm->cur; \ + vm->cur = fib_dequeue(vm); \ + vm->nfibers--; \ + free(dead); \ + vm->budget = vm->budget0; \ + RELOAD(); \ + NEXT(); \ + } while (0) + CASE(RET) : { uint64_t rv = R[wo_ins_a(ins)]; vm->cur->regs[vm->cur->frames[vm->cur->depth - 1].base] = rv; vm->cur->depth--; DROP_CATCHES(); - if (vm->cur->depth == 0) { - *ret = rv; - return 0; - } + if (vm->cur->depth == 0) FIBER_DONE(rv); RELOAD(); NEXT(); } @@ -403,10 +543,7 @@ dispatch: vm->cur->regs[vm->cur->frames[vm->cur->depth - 1].base] = 0; vm->cur->depth--; DROP_CATCHES(); - if (vm->cur->depth == 0) { - *ret = 0; - return 0; - } + if (vm->cur->depth == 0) FIBER_DONE(0); RELOAD(); NEXT(); } @@ -536,6 +673,25 @@ dispatch: vm->cur->frames[vm->cur->depth - 1].pc = pc - 1; vm->cur->ncatch = 0; vm_unwind(vm, 0); + /* a stop ends the PROGRAM: every fiber — the stopped one, + * queued ones, main wherever it is — unwinds clean */ + if (vm->cur != &vm->f0) { + wo_fiber *dead = vm->cur; + vm->cur = &vm->f0; + vm->nfibers--; + free(dead); + if (vm->f0.depth) { + /* main was queued mid-run: release its frames too */ + wo_fiber *q = vm->qhead, *prev = NULL; + while (q && q != &vm->f0) { prev = q; q = q->next; } + if (q) { /* unlink f0 from the queue */ + if (prev) prev->next = q->next; else vm->qhead = q->next; + if (vm->qtail == q) vm->qtail = prev; + vm_unwind(vm, 0); + } + } + } + fib_reap_all(vm); return 1; } if (brc) TRAPF((uint32_t)brc, "%s", bmsg); @@ -624,6 +780,7 @@ dispatch: #undef RELOAD #undef TRAPF #undef GC_SAFEPOINT +#undef FIBER_BUDGET #undef DROP_CATCHES } diff --git a/runtime/src/vm.h b/runtime/src/vm.h index 3a89f8b..aaf898d 100644 --- a/runtime/src/vm.h +++ b/runtime/src/vm.h @@ -40,6 +40,12 @@ typedef struct wo_catch { * the shard-wide pieces (module, runtime, and which fiber is live). * Stage 1 Task 1 is a pure extraction: one embedded fiber, `cur` always * points at it, behavior byte-identical. */ +typedef enum { + WO_FIB_RUNNABLE = 0, + WO_FIB_PARKED = 1, /* stage 1 Task 4: waiting on an fd/deadline */ + WO_FIB_DONE = 2, +} wo_fib_state; + typedef struct wo_fiber { uint64_t regs[WO_STACK_SLOTS]; wo_frame frames[WO_MAX_FRAMES]; @@ -51,15 +57,28 @@ typedef struct wo_fiber { /* the error a caught trap landed with, read by WO_B_ERR_FILL while * the catch arm builds its record */ wo_err caught; + wo_fib_state state; + struct wo_fiber *next; /* intrusive FIFO link (run queue) */ } wo_fiber; typedef struct wo_vm { const wo_module *mod; wo_rt rt; - wo_fiber f0; /* fiber 0: main. Stage 1 Task 2 grows the run queue. */ + wo_fiber f0; /* fiber 0: main — embedded; spawned fibers are calloc'd */ wo_fiber *cur; /* the live fiber — every interpreter access goes here */ + wo_fiber *qhead, *qtail; /* RUNNABLE fibers awaiting the interpreter */ + uint32_t nfibers; /* live fibers besides main */ + int64_t budget0; /* reductions per slice (WO_REDUCTIONS, default 4000) */ + int64_t budget; /* countdown for the live fiber */ } wo_vm; +/* Spawn a fiber that will run method_idx(args) — the runtime half the + * `spawn` expression lowers onto (stage 1 Task 3); Task 2's tests drive it + * directly. The fiber is RUNNABLE and queued; it runs when the scheduler + * reaches it. Returns NULL on allocation failure or bad method/arity. */ +wo_fiber *wo_vm_spawn_fiber(wo_vm *vm, uint32_t method_idx, const uint64_t *args, + uint32_t argc); + /* 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); diff --git a/runtime/test/test_fiber.c b/runtime/test/test_fiber.c new file mode 100644 index 0000000..04ae1f4 --- /dev/null +++ b/runtime/test/test_fiber.c @@ -0,0 +1,193 @@ +/* test_fiber — the arc's stage 1 Task 2: run queue + reduction budget. + * + * 1. Under WO_REDUCTIONS=1, three fibers pushing tags into one shared + * multi interleave in EXACT round-robin — the deterministic- + * scheduling criterion. + * 2. Main returning reaps a still-looping fiber holding an owned + * object: the drop map runs (ASan proves the free). + * 3. A spawned fiber's uncaught trap (DIV0) kills that fiber alone; + * main finishes with rc 0 — the isolation rule. + */ +#define _POSIX_C_SOURCE 200112L /* setenv/unsetenv under -std=c11 */ +#include + +#include "cont.h" +#include "gc.h" +#include "loader.h" +#include "t.h" +#include "vm.h" +#include "wob_build.h" + +#define BIG 130 /* malloc-path class so ASan sees the free (test_unwind's trick) */ + +static wo_vm VM; +static uint8_t big_kinds[BIG]; + +/* Worker: argc=2 (r0 = shared multi as a raw word, r1 = tag), pushes the + * tag K times. The backward JMP is the budget's yield site. + * pc0 LOADK r2,#0 pc1 LOADK r3,#K + * pc2 LT r4,r2,r3 pc3 JZ r4,+4 -> pc8 + * pc4 BUILTIN r4, base=0, MULTI_PUSH (container r0, element r1) + * pc5 LOADK r4,#1 pc6 ADD r2,r2,r4 + * pc7 JMP -6 -> pc2 pc8 RET0 */ +static void worker_code(uint32_t *code, uint32_t k0, uint32_t kK, uint32_t k1) { + code[0] = wo_ins_abx(WOP_LOADK, 2, (uint16_t)k0); + code[1] = wo_ins_abx(WOP_LOADK, 3, (uint16_t)kK); + code[2] = wo_ins_abc(WOP_LT, 4, 2, 3); + code[3] = wo_ins_asbx(WOP_JZ, 4, 4); + code[4] = wo_ins_abc(WOP_BUILTIN, 4, 0, WO_B_MULTI_PUSH); + code[5] = wo_ins_abx(WOP_LOADK, 4, (uint16_t)k1); + code[6] = wo_ins_abc(WOP_ADD, 2, 2, 4); + code[7] = wo_ins_asbx(WOP_JMP, 0, -6); + code[8] = wo_ins_abc(WOP_RET0, 0, 0, 0); +} + +static void test_round_robin(void) { + wb_t *b = wb_new(); + uint32_t kname = wb_const_text(b, "worker"); + uint32_t k0 = wb_const_int(b, 0); + uint32_t kK = wb_const_int(b, 5); + uint32_t k1 = wb_const_int(b, 1); + uint32_t code[9]; + worker_code(code, k0, kK, k1); + uint32_t lines[] = {0, 1}; + wb_method(b, kname, WOB_NONE, 2, 8, code, 9, lines, 1, NULL, 0); + 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); + setenv("WO_REDUCTIONS", "1", 1); + T_EQ(wo_vm_init(&VM, &mod, 1 << 20), 0); + unsetenv("WO_REDUCTIONS"); + + wo_multi *m = wo_multi_new(&VM.rt, WO_K_SCALAR); + T_CHECK(m != NULL); + uint64_t a2[2] = {(uint64_t)(uintptr_t)m, 2}; + uint64_t a3[2] = {(uint64_t)(uintptr_t)m, 3}; + T_CHECK(wo_vm_spawn_fiber(&VM, 0, a2, 2) != NULL); + T_CHECK(wo_vm_spawn_fiber(&VM, 0, a3, 2) != NULL); + + uint64_t ret = 0; + wo_err err = {0}; + uint64_t a1[2] = {(uint64_t)(uintptr_t)m, 1}; + T_EQ(wo_vm_call(&VM, 0, a1, 2, &ret, &err), 0); + + /* budget 1: every backward JMP yields, so one push per turn — the + * order is main(1), fiber(2), fiber(3), repeated exactly */ + T_EQ(m->len, 15u); + for (uint32_t i = 0; i < 15; i++) { + uint64_t v = 0; + T_EQ(wo_multi_get(m, i, &v), 0); + T_EQ(v, (uint64_t)(i % 3) + 1); + } + wo_drop_obj(&VM.rt, (wo_hdr *)m); /* the test owns m; drop frees items */ + wo_vm_destroy(&VM); + wo_module_free(&mod); + free(img); +} + +/* Main loops 3 times (yielding), then returns; the worker allocated a Big + * (owned, in its drop mask) and loops forever. Main's return must reap it + * drop-clean — ASan fails this test if the Big leaks. */ +static void test_main_return_reaps(void) { + wb_t *b = wb_new(); + uint32_t kbig = wb_const_text(b, "Big"); + uint32_t kw = wb_const_text(b, "spin"); + uint32_t km = wb_const_text(b, "main"); + wb_class(b, kbig, 0, big_kinds, BIG); + /* spin: NEW r0 Big, then loop forever (backward JMP = yields) */ + uint32_t wcode[] = { + wo_ins_abx(WOP_NEW, 0, 0), + wo_ins_asbx(WOP_JMP, 0, -1), /* pc1 -> pc1: jump to itself */ + }; + wb_drop wdrops[] = {{.pc = 1, .owned = 1u << 0, .gc = 0}}; + uint32_t wl[] = {0, 1}; + wb_method(b, kw, WOB_NONE, 0, 2, wcode, 2, wl, 1, wdrops, 1); + /* main: count 0..3 with backward jumps, then RET0 */ + uint32_t k0 = wb_const_int(b, 0); + uint32_t kK = wb_const_int(b, 3); + uint32_t k1 = wb_const_int(b, 1); + uint32_t mcode[] = { + wo_ins_abx(WOP_LOADK, 0, (uint16_t)k0), + wo_ins_abx(WOP_LOADK, 1, (uint16_t)kK), + wo_ins_abc(WOP_LT, 2, 0, 1), + wo_ins_asbx(WOP_JZ, 2, 3), + wo_ins_abx(WOP_LOADK, 2, (uint16_t)k1), + wo_ins_abc(WOP_ADD, 0, 0, 2), + wo_ins_asbx(WOP_JMP, 0, -5), + wo_ins_abc(WOP_RET0, 0, 0, 0), + }; + uint32_t ml[] = {0, 1}; + wb_method(b, km, WOB_NONE, 0, 3, mcode, 8, ml, 1, NULL, 0); + 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); + setenv("WO_REDUCTIONS", "1", 1); + T_EQ(wo_vm_init(&VM, &mod, 1 << 20), 0); + unsetenv("WO_REDUCTIONS"); + T_CHECK(wo_vm_spawn_fiber(&VM, 0, NULL, 0) != NULL); /* spin */ + uint64_t ret = 0; + wo_err err = {0}; + T_EQ(wo_vm_call(&VM, 1, NULL, 0, &ret, &err), 0); /* main */ + wo_vm_destroy(&VM); + wo_module_free(&mod); + free(img); /* ASan: spin's Big must have been freed by the reap */ +} + +/* A spawned fiber divides by zero; the program (main) still answers 0. */ +static void test_fiber_trap_isolated(void) { + wb_t *b = wb_new(); + uint32_t kw = wb_const_text(b, "boom"); + uint32_t km = wb_const_text(b, "main"); + uint32_t kone = wb_const_int(b, 1); + uint32_t kzero = wb_const_int(b, 0); + uint32_t wcode[] = { + wo_ins_abx(WOP_LOADK, 0, (uint16_t)kone), + wo_ins_abx(WOP_LOADK, 1, (uint16_t)kzero), + wo_ins_abc(WOP_DIV, 2, 0, 1), /* DIV0: uncaught, fiber dies alone */ + wo_ins_abc(WOP_RET0, 0, 0, 0), + }; + uint32_t wl[] = {0, 1}; + wb_method(b, kw, WOB_NONE, 0, 3, wcode, 4, wl, 1, NULL, 0); + uint32_t kK = wb_const_int(b, 3); + uint32_t mcode[] = { + wo_ins_abx(WOP_LOADK, 0, (uint16_t)kzero), + wo_ins_abx(WOP_LOADK, 1, (uint16_t)kK), + wo_ins_abc(WOP_LT, 2, 0, 1), + wo_ins_asbx(WOP_JZ, 2, 3), + wo_ins_abx(WOP_LOADK, 2, (uint16_t)kone), + wo_ins_abc(WOP_ADD, 0, 0, 2), + wo_ins_asbx(WOP_JMP, 0, -5), + wo_ins_abc(WOP_RET0, 0, 0, 0), + }; + uint32_t ml[] = {0, 1}; + wb_method(b, km, WOB_NONE, 0, 3, mcode, 8, ml, 1, NULL, 0); + 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); + setenv("WO_REDUCTIONS", "1", 1); + T_EQ(wo_vm_init(&VM, &mod, 1 << 20), 0); + unsetenv("WO_REDUCTIONS"); + T_CHECK(wo_vm_spawn_fiber(&VM, 0, NULL, 0) != NULL); /* boom */ + uint64_t ret = 0; + wo_err err = {0}; + T_EQ(wo_vm_call(&VM, 1, NULL, 0, &ret, &err), 0); + wo_vm_destroy(&VM); + wo_module_free(&mod); + free(img); +} + +int main(void) { + test_round_robin(); + test_main_return_reaps(); + test_fiber_trap_isolated(); + return t_report("test_fiber"); +}