feat: bounded mailboxes + WO_T_ACTOR (trap 13); try-arm place-copy fix

- 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 <noreply@anthropic.com>
This commit is contained in:
shoney.arickathil 2026-08-23 01:05:13 +02:00
parent 5fc32b4926
commit dd7dd42bd1
11 changed files with 216 additions and 6 deletions

View file

@ -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;

View file

@ -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 */
{

View file

@ -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;
}

View file

@ -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;

View file

@ -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) ---- */

View file

@ -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 <pthread.h>
#include <stdio.h>
#include <string.h>
#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");
}

View file

@ -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:

View file

@ -0,0 +1,3 @@
caught: No such file or directory
place: boxed
still: boxed

View file

@ -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;
}

View file

@ -0,0 +1,2 @@
trap: actor mailbox full
bounded: cap respected

View file

@ -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;
}