From 9812f3d39612667d8543b7705c5bf19be2be1a5b Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Sun, 23 Aug 2026 01:05:13 +0200 Subject: [PATCH] feat: bounded mailboxes + WO_T_ACTOR (trap 13); try-arm place-copy fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cap 1024 (WO_MAILBOX override at boot): sender-side atomic reserve/release on every path — same-shard, cross-shard envelope, OOM rollbacks; full mailbox traps the SENDER catchably; delivery pop releases; overshoot bounded by in-flight sends (disclosed) - test_mailbox 12/0: exact cap single-threaded, two racing senders win exactly cap slots, drain/refill clean - corpus run/mailbox-full-trap: parked sleeper, send loop catches "actor mailbox full" after >= 1024 sends - pre-existing compiler bug found + fixed: a try ARM yielding a Text PLACE (bare e.msg, try box.field) aliased a register the arm's scope end freed — ASan use-after-free, SEGV on the next unwind's double-walk; emit_try now applies copy_place_text to both arm results; pinned by corpus run/catch-msg-place - db-bench driver: msgrate keeps iteration 22's unbounded-flood contract via WO_MAILBOX=MSG_N (the cap is 24's policy, not 22's) - battery 12/12 fresh-built Co-Authored-By: Claude Fable 5 --- compiler/src/emit.ml | 14 ++++- runtime/src/main.c | 9 ++++ runtime/src/vm.c | 39 ++++++++++++-- runtime/src/vm.h | 14 ++++- runtime/src/wob.h | 5 ++ runtime/test/test_mailbox.c | 53 +++++++++++++++++++ scripts/db-bench.py | 7 ++- tests/corpus/run/catch-msg-place/fixture.out | 3 ++ tests/corpus/run/catch-msg-place/fixture.wo | 25 +++++++++ .../corpus/run/mailbox-full-trap/fixture.out | 2 + tests/corpus/run/mailbox-full-trap/fixture.wo | 51 ++++++++++++++++++ 11 files changed, 216 insertions(+), 6 deletions(-) create mode 100644 runtime/test/test_mailbox.c create mode 100644 tests/corpus/run/catch-msg-place/fixture.out create mode 100644 tests/corpus/run/catch-msg-place/fixture.wo create mode 100644 tests/corpus/run/mailbox-full-trap/fixture.out create mode 100644 tests/corpus/run/mailbox-full-trap/fixture.wo diff --git a/compiler/src/emit.ml b/compiler/src/emit.ml index 0fec3cb..67fc864 100644 --- a/compiler/src/emit.ml +++ b/compiler/src/emit.ml @@ -2600,6 +2600,12 @@ and emit_try (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e : Ast (match expected with | Some t -> emit_expr p f v ~dst ~expected:t body | None -> emit_expr p f v ~dst body); + (* iteration 24 fix: a try ARM's value crosses an ownership boundary (the + binding the whole try feeds), but the outer binding only sees the Try + node — it cannot apply its own place-copy. A body arm that is a Text + place (`try r.field catch ...`) must copy here or the binding aliases + a register the arm's scope end frees. Same rule as any binding. *) + copy_place_text p f dst body; f.f_cur_line <- e.pos.line; put f (ins_abc op_endtry 0 0 0); emit_join_drops p f v ~node:e.id ~label:"TRYBODY"; @@ -2635,7 +2641,13 @@ and emit_try (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e : Ast f.f_cur_line <- last.Ast.s_pos.line; (match expected with | Some t -> emit_expr p f v ~dst ~expected:t ve - | None -> emit_expr p f v ~dst ve) + | None -> emit_expr p f v ~dst ve); + (* iteration 24 fix (the catch half of the arm-copy rule): a bare + `e.msg` arm aliases the Error record's field, and the record is + dropped at CATCH scope end below — ASan-confirmed use-after-free + (then a double-walk SEGV when a later trap unwinds the frame). + Copy the place out before the record dies. *) + copy_place_text p f dst ve | _ -> emit_stmt p f v last)); emit_scope_drops p f v ~node:e.id ~label:"CATCH"; f.f_nlocals <- saved_locals; diff --git a/runtime/src/main.c b/runtime/src/main.c index af72a83..28e8b6f 100644 --- a/runtime/src/main.c +++ b/runtime/src/main.c @@ -216,6 +216,15 @@ int main(int argc, char **argv) { } VM.rt.wal = &WAL; } + /* iteration 24: the one mailbox cap; WO_MAILBOX shrinks it in soak + * tests to force the fail-fast policy (0/garbage keeps the default) */ + { + const char *me = getenv("WO_MAILBOX"); + if (me && me[0]) { + unsigned long v = strtoul(me, NULL, 10); + if (v >= 1 && v <= 0x7FFFFFFFul) wo_mailbox_cap = (uint32_t)v; + } + } /* the arc's stage 2: all cores by default (the brave landing), one * pinned worker vm per extra core; WO_SHARDS caps or forces it */ { diff --git a/runtime/src/vm.c b/runtime/src/vm.c index b2a06ac..1b236fe 100644 --- a/runtime/src/vm.c +++ b/runtime/src/vm.c @@ -92,9 +92,14 @@ static int wo_vm_adopt(wo_vm *vm) { e->actor->next_all = vm->actors; vm->actors = e->actor; break; - case 0: /* a cross-shard send: mailbox + activation on the HOME thread */ - if (actor_push(e->actor, e->payload) == 0 && !e->actor->active) - (void)actor_activate(vm, e->actor); + case 0: /* a cross-shard send: mailbox + activation on the HOME thread. + The sender already reserved the cap slot; a failed push + (OOM) must hand it back or the slot leaks forever. */ + if (actor_push(e->actor, e->payload) == 0) { + if (!e->actor->active) (void)actor_activate(vm, e->actor); + } else { + wo_mbox_release(e->actor); + } break; case 2: /* a home-routed free: this arena owns the object */ wo_drop_obj(&vm->rt, (wo_hdr *)(uintptr_t)e->payload); @@ -600,10 +605,30 @@ static void fib_reap_all(wo_vm *vm) { /* ---- actors (arc stage 1 Task 3) -------------------------------------- */ +/* iteration 24: fail-fast backpressure. The SENDER reserves a slot before + * anything is enqueued anywhere (same-shard push or cross-shard envelope); + * the home thread releases it when the message is popped for delivery. + * Reserve/release are the unit-testable core (test_mailbox.c). */ +uint32_t wo_mailbox_cap = 1024; + +int wo_mbox_reserve(wo_actor *a) { + uint32_t old = __atomic_fetch_add(&a->pending, 1, __ATOMIC_ACQ_REL); + if (old >= wo_mailbox_cap) { + __atomic_fetch_sub(&a->pending, 1, __ATOMIC_ACQ_REL); + return -1; + } + return 0; +} + +void wo_mbox_release(wo_actor *a) { + __atomic_fetch_sub(&a->pending, 1, __ATOMIC_ACQ_REL); +} + static uint64_t actor_pop(wo_actor *a) { uint64_t m = a->msgs[a->mhead]; a->mhead = (a->mhead + 1) % a->mcap; a->mlen--; + wo_mbox_release(a); return m; } @@ -689,12 +714,19 @@ int wo_vm_actor_send(wo_vm *vm, uint64_t addr, uint64_t msg_val, const char **ms *msg = "send: nil message"; return WO_T_BOUNDS; } + /* iteration 24: the cap check happens SENDER-side on every path, so + * the sender always learns — fail-fast backpressure, catchable. */ + if (wo_mbox_reserve(a) != 0) { + *msg = "actor mailbox full"; + return WO_T_ACTOR; + } if (a->home != vm->shard_id) { /* cross-shard: the HOME thread owns the mailbox — send travels as * an inbox envelope, ownership moves with it (the mutex is the * happens-before edge TSan sees) */ wo_envelope *e = calloc(1, sizeof *e); if (!e) { + wo_mbox_release(a); *msg = "out of memory"; return WO_T_OOM; } @@ -705,6 +737,7 @@ int wo_vm_actor_send(wo_vm *vm, uint64_t addr, uint64_t msg_val, const char **ms return 0; } if (actor_push(a, msg_val) != 0) { + wo_mbox_release(a); *msg = "out of memory"; return WO_T_OOM; } diff --git a/runtime/src/vm.h b/runtime/src/vm.h index 2c8c96b..13aca19 100644 --- a/runtime/src/vm.h +++ b/runtime/src/vm.h @@ -96,12 +96,24 @@ typedef struct wo_actor { uint64_t instance; /* the moved-in state object (runtime-owned) */ uint32_t method; /* receive's method index (self + msg = 2 args) */ uint32_t home; /* the shard whose thread owns mailbox + delivery */ - uint64_t *msgs; /* FIFO ring, growable */ + uint64_t *msgs; /* FIFO ring, growable up to the cap */ uint32_t mhead, mlen, mcap; + /* iteration 24: sent-but-not-delivered count, incremented by the + * SENDER on any shard (the cap check), decremented by the home + * thread at delivery pop. Accessed ONLY through __atomic builtins + * (wo_mbox_reserve/release) because senders race; the cap can + * overshoot by at most the number of in-flight sends — disclosed. */ + uint32_t pending; wo_fiber *active; /* the delivery fiber, NULL when idle */ struct wo_actor *next_all; /* the vm's all-actors list */ } wo_actor; +/* iteration 24: the one mailbox cap (default 1024, WO_MAILBOX overrides + * at boot — soak tests shrink it to force the fail-fast policy). */ +extern uint32_t wo_mailbox_cap; +int wo_mbox_reserve(wo_actor *a); /* 0 = slot reserved; -1 = full */ +void wo_mbox_release(wo_actor *a); /* delivery pop / failed enqueue */ + typedef struct wo_vm { const wo_module *mod; wo_rt rt; diff --git a/runtime/src/wob.h b/runtime/src/wob.h index b8d5ed4..c18d1a9 100644 --- a/runtime/src/wob.h +++ b/runtime/src/wob.h @@ -190,6 +190,11 @@ enum { honesty precedent DIV0 set (trap, never x86's silent count%64). Literal counts never get here: woc rejects them (WO-E223). */ WO_T_SHIFT = 12, + /* iteration 24 (absorbing 31): the actor lifecycle's one trap kind — + a send/call against a full mailbox (fail-fast backpressure, the + sender always learns), call to a dead actor, callee died mid-call. + The message names which. Catchable like every trap. */ + WO_T_ACTOR = 13, }; /* ---- opcodes (spec section 5; semantics in the format doc) ---- */ diff --git a/runtime/test/test_mailbox.c b/runtime/test/test_mailbox.c new file mode 100644 index 0000000..5e99430 --- /dev/null +++ b/runtime/test/test_mailbox.c @@ -0,0 +1,53 @@ +/* test_mailbox — the fail-fast cap core (iteration 24). Reserve/release + * arithmetic single-threaded, then two racing senders: successful + * reserves never exceed the cap (each success must observe old < cap, + * and increments are permanent — see wo_mbox_reserve). */ +#include +#include +#include + +#include "t.h" +#include "vm.h" + +static wo_actor A; + +static void *hammer(void *arg) { + (void)arg; + long wins = 0; + for (int i = 0; i < 1000; i++) + if (wo_mbox_reserve(&A) == 0) wins++; + return (void *)wins; +} + +int main(void) { + /* single-threaded: cap honored exactly, release frees a slot */ + wo_mailbox_cap = 4; + memset(&A, 0, sizeof A); + T_EQ(wo_mbox_reserve(&A), 0); + T_EQ(wo_mbox_reserve(&A), 0); + T_EQ(wo_mbox_reserve(&A), 0); + T_EQ(wo_mbox_reserve(&A), 0); + T_EQ(wo_mbox_reserve(&A), -1); /* full */ + wo_mbox_release(&A); + T_EQ(wo_mbox_reserve(&A), 0); /* freed slot reusable */ + T_EQ(wo_mbox_reserve(&A), -1); + T_EQ(A.pending, 4); + + /* two racing senders against cap 8: exactly 8 wins, pending == 8 */ + wo_mailbox_cap = 8; + memset(&A, 0, sizeof A); + pthread_t t1, t2; + void *w1, *w2; + pthread_create(&t1, NULL, hammer, NULL); + pthread_create(&t2, NULL, hammer, NULL); + pthread_join(t1, &w1); + pthread_join(t2, &w2); + T_EQ((long)w1 + (long)w2, 8); + T_EQ(A.pending, 8); + /* drain and refill: the counter did not corrupt under the race */ + for (int i = 0; i < 8; i++) wo_mbox_release(&A); + T_EQ(A.pending, 0); + T_EQ(wo_mbox_reserve(&A), 0); + + return t_report("test_mailbox"); +} diff --git a/scripts/db-bench.py b/scripts/db-bench.py index 1c9db6a..9d814cc 100755 --- a/scripts/db-bench.py +++ b/scripts/db-bench.py @@ -127,7 +127,12 @@ def campaign(): # msgrate once per shard count, RAM only (no store dependency) for shards in (1, ncores): tag = f"msg.s{'1' if shards == 1 else 'N'}" - rc, lines, _, _ = run(["msgrate", str(MSG_N)], {"WO_SHARDS": str(shards)}, 300) + # iteration 24 gave mailboxes a cap (default 1024, fail-fast trap); + # msgrate's contract is an UNBOUNDED one-way flood, so the driver + # raises the cap to the flood size — the measured number keeps + # iteration 22's semantics exactly. + rc, lines, _, _ = run(["msgrate", str(MSG_N)], + {"WO_SHARDS": str(shards), "WO_MAILBOX": str(MSG_N)}, 300) if rc != 0: bad(tag, f"rc={rc}") else: diff --git a/tests/corpus/run/catch-msg-place/fixture.out b/tests/corpus/run/catch-msg-place/fixture.out new file mode 100644 index 0000000..4577796 --- /dev/null +++ b/tests/corpus/run/catch-msg-place/fixture.out @@ -0,0 +1,3 @@ +caught: No such file or directory +place: boxed +still: boxed diff --git a/tests/corpus/run/catch-msg-place/fixture.wo b/tests/corpus/run/catch-msg-place/fixture.wo new file mode 100644 index 0000000..9b4cc36 --- /dev/null +++ b/tests/corpus/run/catch-msg-place/fixture.wo @@ -0,0 +1,25 @@ +-- iteration 24 fix pin: a try ARM's value that is a Text PLACE must be +-- copied out before the arm's scope dies. The catch half: bare `e.msg` +-- aliased the Error record's field, the record dropped at arm end, and +-- the binding dangled (ASan use-after-free, then a double-walk SEGV on +-- the next unwind). The body half: `try box.name catch ...` aliased the +-- box's field across the same boundary. +use fs + +class Box { + name: Text +} + +fn read_place(b: Box) -> Text { + return try b.name catch (e) "unreachable"; +} + +fn main() -> Int { + let r = try fs.read_all("/nonexistent-woc-fixture", 10) catch (e) e.msg; + print("caught: ${r}"); + let b = Box { name: "boxed" }; + let t = read_place(b); + print("place: ${t}"); + print("still: ${b.name}"); + return 0; +} diff --git a/tests/corpus/run/mailbox-full-trap/fixture.out b/tests/corpus/run/mailbox-full-trap/fixture.out new file mode 100644 index 0000000..4eed76d --- /dev/null +++ b/tests/corpus/run/mailbox-full-trap/fixture.out @@ -0,0 +1,2 @@ +trap: actor mailbox full +bounded: cap respected diff --git a/tests/corpus/run/mailbox-full-trap/fixture.wo b/tests/corpus/run/mailbox-full-trap/fixture.wo new file mode 100644 index 0000000..bc0a708 --- /dev/null +++ b/tests/corpus/run/mailbox-full-trap/fixture.wo @@ -0,0 +1,51 @@ +use time + +-- iteration 24: fail-fast backpressure from .wo. The sleeper parks in its +-- first receive, so the mailbox only ever drains by one; a send loop must +-- hit the cap (default 1024) and catch WO_T_ACTOR. The exact send count +-- at the first trap depends on scheduling, so the fixture prints the +-- trap's message once plus a bound check, never the count. `try` is an +-- expression, so the send is wrapped in a helper whose success is nil. +class Tick { + n: Int +} + +class Sleeper { + pad: Int + fn receive(msg: Tick) { + time.sleep(5000); + } +} + +fn send_one(s: actor Tick, n: Int) -> Text { + send(s, Tick { n: n }); + return ""; +} + +fn main() -> Int { + let s: actor Tick = spawn Sleeper { pad: 0 }; + let sent = 0; + let caught = ""; + let i = 0; + while i < 2000 { + i = i + 1; + let r = try send_one(s, i) catch (e) e.msg; + if r == "" { + sent = sent + 1; + } else { + caught = "${r}"; + i = 2000; + } + } + if caught == "actor mailbox full" { + print("trap: ${caught}"); + } else { + print("NO TRAP after ${sent} sends"); + } + if sent >= 1024 { + print("bounded: cap respected"); + } else { + print("bounded: TRAPPED EARLY at ${sent}"); + } + return 0; +}