commit a5b1bc4c164eec894ea9b6ca5f9de807da6b7988
parent 1d21190e6bf4de8704273bef5bc4863f5010bd6d
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Tue, 5 May 2026 01:24:42 -0700
Add event substrate: waker, latch, select, runtime
A pollable event abstraction on top of xstep. Three layers:
waker_t Intrusive notification node, doubly-linked while parked
on an event waitlist (O(1) unpark) and singly-linked on
the runtime ready queue. fire(w, value) delivers the
event's payload directly.
event_t Abstract pollable: try / park / unpark. Concrete impls
provided: latch_t (one-shot sticky bool with payload)
and select_event_t (any-of N events, composes since a
select is itself an event).
runtime_t Intrusive FIFO of step-wakers. rt_run drains it,
resuming each xstep with the value its waker captured.
Caller-provided storage throughout — no allocation, no atomics,
single-threaded. The one-waker invariant (an xstep is parked on at
most one event) is what lets a single step_waker live inline in the
xstep and a single next/prev pair serve both list memberships;
multi-wait composes through select rather than per-step bookkeeping.
Diffstat:
| M | Makefile | | | 12 | ++++++------ |
| A | event.c | | | 165 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | event.h | | | 211 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | tests/test_event.c | | | 317 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
4 files changed, 699 insertions(+), 6 deletions(-)
diff --git a/Makefile b/Makefile
@@ -16,12 +16,12 @@ BUILD := build
CPPFLAGS += -I. -I$(ARCHDIR)
-SRCS := xco.c $(ARCHDIR)/xco_arch.c
+SRCS := xco.c event.c $(ARCHDIR)/xco_arch.c
OBJS := $(SRCS:%.c=$(BUILD)/%.o)
LIB := $(BUILD)/libxco.a
-TEST_SRC := tests/test_xco.c
-TEST_BIN := $(BUILD)/test_xco
+TEST_SRCS := tests/test_xco.c tests/test_event.c
+TEST_BINS := $(TEST_SRCS:tests/%.c=$(BUILD)/%)
all: $(LIB)
@@ -32,12 +32,12 @@ $(BUILD)/%.o: %.c
@mkdir -p $(dir $@)
$(CC) $(CPPFLAGS) $(CFLAGS) -c -o $@ $<
-$(TEST_BIN): $(TEST_SRC) $(LIB)
+$(BUILD)/test_%: tests/test_%.c $(LIB)
@mkdir -p $(dir $@)
$(CC) $(CPPFLAGS) $(CFLAGS) -o $@ $< $(LIB)
-test: $(TEST_BIN)
- $(TEST_BIN)
+test: $(TEST_BINS)
+ @for t in $(TEST_BINS); do echo "==> $$t"; $$t || exit 1; done
clean:
rm -rf $(BUILD)
diff --git a/event.c b/event.c
@@ -0,0 +1,165 @@
+/*
+ * event.c — implementations for event.h.
+ *
+ * Each event type is a small struct with a static vtable. Waitlists
+ * are singly-linked LIFO (push at head, pop at head); fire-all detaches
+ * the whole list before iterating so callbacks can do anything,
+ * including re-park or unpark sibling wakers, without iterator hazards.
+ */
+
+#include "event.h"
+
+#include <assert.h>
+#include <stddef.h>
+
+/* ---- Runtime ---------------------------------------------------------- */
+
+/* rt_init and rt_enqueue are defined inline in event.h. */
+
+static waker_t *rt_dequeue(runtime_t *rt) {
+ waker_t *w = rt->head;
+ if (!w) return NULL;
+ rt->head = w->next;
+ if (!rt->head) rt->tail = NULL;
+ w->next = NULL;
+ return w;
+}
+
+void rt_run(runtime_t *rt) {
+ /* The runtime ready queue holds only step-wakers. Other waker
+ * shapes (e.g. select_input) fire synchronously inside event
+ * notify paths and never reach here. */
+ for (waker_t *w; (w = rt_dequeue(rt));) {
+ step_waker_t *sw = (step_waker_t *)w;
+ xstep(sw->step, sw->resume_value);
+ }
+}
+
+/* ---- Step waker ------------------------------------------------------- */
+
+/* Exposed (with leading underscore) so the inline step_waker_init in
+ * event.h can install it without dragging the body into the header. */
+void _step_waker_fire(waker_t *w, uintptr_t value) {
+ step_waker_t *sw = (step_waker_t *)w;
+ sw->resume_value = value;
+ rt_enqueue(sw->rt, w);
+}
+
+/* ---- Latch ------------------------------------------------------------ */
+
+static bool latch_try(event_t *e, uintptr_t *out) {
+ latch_t *l = (latch_t *)e;
+ if (!l->set) return false;
+ if (out) *out = l->value;
+ return true;
+}
+
+static void latch_park(event_t *e, waker_t *w) {
+ latch_t *l = (latch_t *)e;
+ /* Single-threaded contract: caller must have just observed try=false. */
+ assert(!l->set);
+ /* One-waker invariant: w must arrive clean. Detach paths (latch_set
+ * iterator, latch_unpark, select_event_deinit) all leave wakers in
+ * this state. A trip here means a double-park bug. */
+ assert(!w->prev && !w->next);
+ w->next = l->waiters;
+ if (l->waiters) l->waiters->prev = w;
+ l->waiters = w;
+}
+
+static void latch_unpark(event_t *e, waker_t *w) {
+ latch_t *l = (latch_t *)e;
+ /* Detect "not on this list" via the invariant maintained by park
+ * and the detach paths: a parked waker has prev set OR is the
+ * head; a detached one has prev == NULL and is not the head. */
+ if (!w->prev && l->waiters != w) return;
+ if (w->prev) w->prev->next = w->next;
+ else l->waiters = w->next;
+ if (w->next) w->next->prev = w->prev;
+ w->prev = w->next = NULL;
+}
+
+/* Exposed so the inline latch_init in event.h can reference it. */
+const event_vtable_t _latch_vt = {
+ .try_ = latch_try,
+ .park = latch_park,
+ .unpark = latch_unpark,
+};
+
+void latch_set(latch_t *l, uintptr_t value) {
+ if (l->set) return;
+ l->set = true;
+ l->value = value;
+
+ /* Detach the whole waitlist before firing. A waker's fire callback
+ * might do anything (including unpark a sibling on another event),
+ * but it cannot mutate this list — it's already gone. */
+ waker_t *w = l->waiters;
+ l->waiters = NULL;
+ while (w) {
+ waker_t *next = w->next;
+ w->next = NULL;
+ w->prev = NULL;
+ w->fire(w, value);
+ w = next;
+ }
+}
+
+/* ---- Select ----------------------------------------------------------- */
+
+static void select_input_fire(waker_t *w, uintptr_t value) {
+ /* The input's own value is irrelevant here — select delivers the
+ * winning index. Consumers re-try the input event for its payload. */
+ (void)value;
+ select_input_t *in = (select_input_t *)w;
+ select_event_t *s = in->parent;
+ size_t i = (size_t)(in - s->inputs);
+
+ /* Sticky: only the first input to fire records its index. Later
+ * stragglers (if any escape unparking) hit the idempotent guard. */
+ if (s->done.set) return;
+ latch_set(&s->done, i);
+
+ /* Disarm everyone else so their wakers don't dangle on input
+ * waitlists past the select's lifetime. */
+ for (size_t j = 0; j < s->n; j++) {
+ if (j != i) event_unpark(s->inputs[j].src, &s->inputs[j].w);
+ }
+}
+
+void select_event_init(select_event_t *s,
+ select_input_t *inputs, size_t n,
+ event_t *const *srcs) {
+ latch_init(&s->done);
+ s->inputs = inputs;
+ s->n = n;
+
+ /* Fast path: an input already ready. Fire and skip parking entirely
+ * so deinit has nothing to disarm. */
+ for (size_t i = 0; i < n; i++) {
+ uintptr_t v;
+ if (event_try(srcs[i], &v)) {
+ latch_set(&s->done, i);
+ return;
+ }
+ }
+
+ for (size_t i = 0; i < n; i++) {
+ inputs[i].w.next = NULL;
+ inputs[i].w.prev = NULL;
+ inputs[i].w.fire = select_input_fire;
+ inputs[i].src = srcs[i];
+ inputs[i].parent = s;
+ event_park(srcs[i], &inputs[i].w);
+ }
+}
+
+void select_event_deinit(select_event_t *s) {
+ /* If the select fired, survivors were already disarmed by
+ * select_input_fire. If the fast path fired, nothing was ever
+ * parked. Either way, set => nothing to do. */
+ if (s->done.set) return;
+ for (size_t i = 0; i < s->n; i++) {
+ event_unpark(s->inputs[i].src, &s->inputs[i].w);
+ }
+}
diff --git a/event.h b/event.h
@@ -0,0 +1,211 @@
+/*
+ * event.h — minimal pollable event substrate. C11.
+ *
+ * Three layers, built on xstep:
+ *
+ * waker_t Intrusive notification node. Holds a fire callback and a
+ * single next-pointer reused across event waitlists and the
+ * runtime's ready queue (a waker is on at most one list at
+ * a time).
+ *
+ * event_t Abstract pollable: try / park / unpark. Concrete events
+ * (latch, select, channel, timer, ...) embed event_t as
+ * their first member and supply a vtable. Every blocking
+ * primitive in this codebase is just an event.
+ *
+ * runtime_t Intrusive FIFO of step-wakers. rt_run drains it,
+ * resuming each xstep until none are ready.
+ *
+ * Concrete events provided here:
+ * latch_t One-shot sticky bool with optional payload.
+ * select_event_t Fires when any of N input events fires; carries
+ * the index of the winner. Composes (a select is
+ * itself an event_t).
+ *
+ * All storage is caller-provided. No allocation, no atomics.
+ *
+ * Single-threaded only — matches xco's thread affinity. The contract
+ * "try first, park only if try failed" is race-free because nothing
+ * else runs between the two calls.
+ *
+ * One-waker invariant. An xstep, while suspended, is parked on at
+ * most one event. Multi-wait is composed in the event graph: build a
+ * select_event (or any future combinator — all-of, timeout, ...) and
+ * park on that. The xstep never sees more than one event directly.
+ * This is what lets a single step_waker_t live inline in the xstep
+ * and a single next/prev pair serve both event waitlists and the
+ * runtime ready queue (the two list memberships are disjoint in time).
+ *
+ * Standard usage from a state machine:
+ *
+ * uintptr_t v;
+ * if (event_try(e, &v)) { ... use v ... }
+ * else { event_park(e, &my_waker.base); return SUSPENDED; }
+ *
+ * Standard usage from an xco coroutine wrapper:
+ *
+ * uintptr_t v;
+ * if (!event_try(e, &v)) {
+ * step_waker_t sw;
+ * step_waker_init(&sw, rt, &xco_self()->base);
+ * event_park(e, &sw.base);
+ * xco_suspend(0);
+ * (void)event_try(e, &v); // now ready
+ * }
+ */
+
+#ifndef EVENT_H
+#define EVENT_H
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+
+#include "xstep.h"
+
+/* ---- Waker ------------------------------------------------------------ */
+
+typedef struct waker waker_t;
+struct waker {
+ /* Doubly-linked while parked on an event waitlist, so unpark is
+ * O(1). Reused as the singly-linked next pointer while on the
+ * runtime ready queue (FIFO, no removal from middle); prev is
+ * undefined in that state and reset on the next park. */
+ waker_t *next;
+ waker_t *prev;
+ /* Fire callback. value is the event's payload at fire time — sticky
+ * events also store it on themselves, transient events (channels,
+ * one-shot signals) deliver only here. Wakers that don't care
+ * about the value just ignore the parameter. */
+ void (*fire)(waker_t *w, uintptr_t value);
+};
+
+/* ---- Event ------------------------------------------------------------ */
+
+typedef struct event event_t;
+
+typedef struct {
+ /* Inline-succeed if possible. *out receives the event's value when
+ * it has one (latch payload, select winner index, channel data).
+ * out may be NULL if the caller doesn't need the value. */
+ bool (*try_)(event_t *e, uintptr_t *out);
+ /* Arm w on the event's waitlist. Caller's contract: try_ returned
+ * false. Single-threaded runtime guarantees no transition between. */
+ void (*park)(event_t *e, waker_t *w);
+ /* Remove w from the waitlist. Idempotent: no-op if not parked. */
+ void (*unpark)(event_t *e, waker_t *w);
+} event_vtable_t;
+
+struct event { const event_vtable_t *vt; };
+
+static inline bool event_try(event_t *e, uintptr_t *out) {
+ return e->vt->try_(e, out);
+}
+static inline void event_park(event_t *e, waker_t *w) { e->vt->park(e, w); }
+static inline void event_unpark(event_t *e, waker_t *w) { e->vt->unpark(e, w); }
+
+/* ---- Runtime ---------------------------------------------------------- */
+
+typedef struct runtime {
+ waker_t *head, *tail;
+} runtime_t;
+
+static inline void rt_init(runtime_t *rt) { rt->head = rt->tail = NULL; }
+
+/* Append w to the ready queue. Used by step_waker_fire and by anyone
+ * else that wants a waker resumed by the scheduler. */
+static inline void rt_enqueue(runtime_t *rt, waker_t *w) {
+ w->next = NULL;
+ if (rt->tail) rt->tail->next = w;
+ else rt->head = w;
+ rt->tail = w;
+}
+
+/* Drain the ready queue, resuming each step-waker's xstep, until empty.
+ * Steps may re-arm on events (and thus leave the queue) or enqueue
+ * other steps; rt_run keeps going until quiescent. */
+void rt_run(runtime_t *rt);
+
+/* The canonical bridge between events and the scheduler. When fired,
+ * stashes the value and enqueues itself onto rt; rt_run pops it and
+ * calls xstep(step, value), so the resumed step receives the event's
+ * payload directly without a re-try. */
+typedef struct {
+ waker_t base;
+ runtime_t *rt;
+ xstep_t *step;
+ uintptr_t resume_value; /* set by fire, consumed by rt_run */
+} step_waker_t;
+
+/* Defined in event.c; declared here so step_waker_init can install it. */
+void _step_waker_fire(waker_t *w, uintptr_t value);
+
+static inline void step_waker_init(step_waker_t *sw, runtime_t *rt, xstep_t *s) {
+ sw->base.next = NULL;
+ sw->base.prev = NULL;
+ sw->base.fire = _step_waker_fire;
+ sw->rt = rt;
+ sw->step = s;
+ sw->resume_value = 0;
+}
+
+/* ---- Latch ------------------------------------------------------------ */
+
+/* One-shot sticky event. set() flips the bit, stores the payload, and
+ * fires every waiter. Subsequent set() calls are ignored. To re-arm,
+ * reinitialize a fresh latch. */
+typedef struct {
+ event_t base;
+ bool set;
+ uintptr_t value;
+ waker_t *waiters;
+} latch_t;
+
+/* Defined in event.c; referenced by latch_init. */
+extern const event_vtable_t _latch_vt;
+
+static inline void latch_init(latch_t *l) {
+ l->base.vt = &_latch_vt;
+ l->set = false;
+ l->value = 0;
+ l->waiters = NULL;
+}
+
+void latch_set(latch_t *l, uintptr_t value);
+
+/* ---- Select ----------------------------------------------------------- */
+
+/* Fires when any of N input events fires. The winning index is the
+ * payload of the underlying latch; consumers re-try that input to pull
+ * its actual value. Composes: a select_event is itself an event. */
+
+typedef struct select_event select_event_t;
+
+/* Per-input arming record. Caller-allocated as an array of n alongside
+ * the select_event. Internal layout — fields are touched only by
+ * select_event_init / select_input_fire. */
+typedef struct {
+ waker_t w;
+ event_t *src;
+ select_event_t *parent;
+} select_input_t;
+
+struct select_event {
+ latch_t done; /* fired with the winner's index */
+ select_input_t *inputs;
+ size_t n;
+};
+
+/* Initialize. inputs[] is caller-provided storage for n nodes; srcs[]
+ * is the array of n input event pointers (read once during init).
+ * If any input is already ready, the select fires immediately and no
+ * wakers are parked. Use &s->done.base as the resulting event_t. */
+void select_event_init(select_event_t *s,
+ select_input_t *inputs, size_t n,
+ event_t *const *srcs);
+
+/* Disarm any inputs still parked. Safe to call after fire (no-op).
+ * Required before s leaves scope if it has not yet fired. */
+void select_event_deinit(select_event_t *s);
+
+#endif /* EVENT_H */
diff --git a/tests/test_event.c b/tests/test_event.c
@@ -0,0 +1,317 @@
+/*
+ * test_event.c — exercises latch_t, select_event_t, runtime_t.
+ *
+ * Drives hand-coded xstep state machines through the event substrate
+ * — no coroutines required. A "waiter" is the canonical pattern: try
+ * the event, park if not ready, re-try after wake.
+ */
+
+#include "event.h"
+#include "xstep.h"
+
+#include <assert.h>
+#include <stdio.h>
+
+/* ---- Waiter: blocks on one event, captures its value -------------- */
+
+typedef struct {
+ xstep_t base;
+ event_t *e;
+ runtime_t *rt;
+ step_waker_t sw;
+ int phase;
+ uintptr_t got;
+} waiter_t;
+
+static xstep_result_t waiter_step(xstep_t *s, uintptr_t v) {
+ waiter_t *w = (waiter_t *)s;
+ switch (w->phase) {
+ case 0: {
+ uintptr_t out;
+ if (event_try(w->e, &out)) {
+ w->got = out;
+ w->phase = 2;
+ return (xstep_result_t){out, XSTEP_DEAD};
+ }
+ step_waker_init(&w->sw, w->rt, &w->base);
+ event_park(w->e, &w->sw.base);
+ w->phase = 1;
+ return (xstep_result_t){0, XSTEP_SUSPENDED};
+ }
+ case 1:
+ /* v is the value handed in by the fire site (latch payload, or
+ * for select, the winning index). No re-try needed. */
+ w->got = v;
+ w->phase = 2;
+ return (xstep_result_t){v, XSTEP_DEAD};
+ }
+ __builtin_unreachable();
+}
+
+static void waiter_init(waiter_t *w, runtime_t *rt, event_t *e) {
+ w->base = (xstep_t){.step = waiter_step, .status = XSTEP_INIT};
+ w->e = e;
+ w->rt = rt;
+ w->phase = 0;
+ w->got = 0;
+}
+
+/* ---- Latch tests --------------------------------------------------- */
+
+static void test_latch_wake(void) {
+ runtime_t rt; rt_init(&rt);
+ latch_t l; latch_init(&l);
+
+ waiter_t w; waiter_init(&w, &rt, &l.base);
+
+ xstep_result_t r = xstep(&w.base, 0);
+ assert(r.status == XSTEP_SUSPENDED);
+ assert(rt.head == NULL); /* parked, not queued */
+ assert(l.waiters == &w.sw.base); /* armed on the latch */
+
+ latch_set(&l, 42);
+ assert(l.waiters == NULL); /* drained on fire */
+ assert(rt.head != NULL); /* fire enqueued the step */
+
+ rt_run(&rt);
+ assert(xstep_status(&w.base) == XSTEP_DEAD);
+ assert(w.got == 42);
+ assert(rt.head == NULL);
+}
+
+static void test_latch_already_set(void) {
+ /* Already-set latch: try succeeds inline, no park, no queueing. */
+ runtime_t rt; rt_init(&rt);
+ latch_t l; latch_init(&l);
+ latch_set(&l, 7);
+
+ waiter_t w; waiter_init(&w, &rt, &l.base);
+
+ xstep_result_t r = xstep(&w.base, 0);
+ assert(r.status == XSTEP_DEAD);
+ assert(r.value == 7);
+ assert(w.got == 7);
+ assert(rt.head == NULL);
+}
+
+static void test_latch_multi_waiter(void) {
+ /* One latch_set wakes every waiter. */
+ runtime_t rt; rt_init(&rt);
+ latch_t l; latch_init(&l);
+
+ waiter_t a, b, c;
+ waiter_init(&a, &rt, &l.base);
+ waiter_init(&b, &rt, &l.base);
+ waiter_init(&c, &rt, &l.base);
+
+ xstep(&a.base, 0);
+ xstep(&b.base, 0);
+ xstep(&c.base, 0);
+ assert(xstep_status(&a.base) == XSTEP_SUSPENDED);
+ assert(xstep_status(&b.base) == XSTEP_SUSPENDED);
+ assert(xstep_status(&c.base) == XSTEP_SUSPENDED);
+
+ latch_set(&l, 99);
+ rt_run(&rt);
+
+ assert(xstep_status(&a.base) == XSTEP_DEAD && a.got == 99);
+ assert(xstep_status(&b.base) == XSTEP_DEAD && b.got == 99);
+ assert(xstep_status(&c.base) == XSTEP_DEAD && c.got == 99);
+}
+
+static void test_latch_set_idempotent(void) {
+ latch_t l; latch_init(&l);
+ latch_set(&l, 1);
+ latch_set(&l, 2); /* ignored */
+
+ uintptr_t v = 0;
+ assert(event_try(&l.base, &v));
+ assert(v == 1);
+}
+
+static void test_latch_unpark(void) {
+ /* Manually park then unpark — verify the waker is removed cleanly. */
+ runtime_t rt; rt_init(&rt);
+ latch_t l; latch_init(&l);
+
+ step_waker_t sw1, sw2, sw3;
+ /* Step pointer is just a sentinel here; we never run them. */
+ step_waker_init(&sw1, &rt, (xstep_t *)0x1);
+ step_waker_init(&sw2, &rt, (xstep_t *)0x2);
+ step_waker_init(&sw3, &rt, (xstep_t *)0x3);
+ event_park(&l.base, &sw1.base);
+ event_park(&l.base, &sw2.base);
+ event_park(&l.base, &sw3.base);
+
+ /* Remove the middle one. */
+ event_unpark(&l.base, &sw2.base);
+ assert(sw2.base.next == NULL);
+
+ /* Unpark of an already-removed waker is a no-op. */
+ event_unpark(&l.base, &sw2.base);
+
+ /* sw1 and sw3 still on the list. */
+ int seen1 = 0, seen3 = 0;
+ for (waker_t *w = l.waiters; w; w = w->next) {
+ if (w == &sw1.base) seen1 = 1;
+ if (w == &sw3.base) seen3 = 1;
+ assert(w != &sw2.base);
+ }
+ assert(seen1 && seen3);
+
+ /* Drain so we don't leave dangling armed wakers. */
+ event_unpark(&l.base, &sw1.base);
+ event_unpark(&l.base, &sw3.base);
+ assert(l.waiters == NULL);
+}
+
+/* ---- Select tests -------------------------------------------------- */
+
+static void test_select_winner(void) {
+ runtime_t rt; rt_init(&rt);
+ latch_t a, b, c;
+ latch_init(&a); latch_init(&b); latch_init(&c);
+
+ select_event_t s;
+ select_input_t inputs[3];
+ event_t *srcs[3] = {&a.base, &b.base, &c.base};
+ select_event_init(&s, inputs, 3, srcs);
+
+ waiter_t w; waiter_init(&w, &rt, &s.done.base);
+ xstep(&w.base, 0);
+ assert(xstep_status(&w.base) == XSTEP_SUSPENDED);
+ assert(a.waiters && b.waiters && c.waiters); /* all armed */
+
+ latch_set(&b, 0xBBB);
+ rt_run(&rt);
+
+ assert(xstep_status(&w.base) == XSTEP_DEAD);
+ assert(w.got == 1); /* b's index */
+
+ /* Losers were disarmed by select_input_fire. */
+ assert(a.waiters == NULL);
+ assert(c.waiters == NULL);
+
+ /* Re-trying the winning input yields its actual payload. */
+ uintptr_t v;
+ assert(event_try(&b.base, &v));
+ assert(v == 0xBBB);
+
+ select_event_deinit(&s);
+}
+
+static void test_select_fast_path(void) {
+ /* Input already set at init: fire immediately, never park anyone. */
+ latch_t a, b;
+ latch_init(&a); latch_init(&b);
+ latch_set(&a, 0xAAA);
+
+ select_event_t s;
+ select_input_t inputs[2];
+ event_t *srcs[2] = {&a.base, &b.base};
+ select_event_init(&s, inputs, 2, srcs);
+
+ uintptr_t v;
+ assert(event_try(&s.done.base, &v));
+ assert(v == 0); /* a wins */
+ assert(b.waiters == NULL); /* nothing parked on the loser */
+
+ select_event_deinit(&s);
+}
+
+static void test_select_deinit_unparks(void) {
+ /* Selecting then dropping (no input fires) must release input wakers. */
+ latch_t a, b;
+ latch_init(&a); latch_init(&b);
+
+ select_event_t s;
+ select_input_t inputs[2];
+ event_t *srcs[2] = {&a.base, &b.base};
+ select_event_init(&s, inputs, 2, srcs);
+ assert(a.waiters != NULL);
+ assert(b.waiters != NULL);
+
+ select_event_deinit(&s);
+ assert(a.waiters == NULL);
+ assert(b.waiters == NULL);
+}
+
+static void test_select_compose(void) {
+ /* select(select(A, B), C): firing A propagates through both layers. */
+ runtime_t rt; rt_init(&rt);
+ latch_t a, b, c;
+ latch_init(&a); latch_init(&b); latch_init(&c);
+
+ select_event_t inner;
+ select_input_t in_inputs[2];
+ event_t *in_srcs[2] = {&a.base, &b.base};
+ select_event_init(&inner, in_inputs, 2, in_srcs);
+
+ select_event_t outer;
+ select_input_t out_inputs[2];
+ event_t *out_srcs[2] = {&inner.done.base, &c.base};
+ select_event_init(&outer, out_inputs, 2, out_srcs);
+
+ waiter_t w; waiter_init(&w, &rt, &outer.done.base);
+ xstep(&w.base, 0);
+ assert(xstep_status(&w.base) == XSTEP_SUSPENDED);
+
+ latch_set(&a, 0);
+ rt_run(&rt);
+
+ assert(xstep_status(&w.base) == XSTEP_DEAD);
+ assert(w.got == 0); /* outer winner: inner (index 0) */
+
+ uintptr_t v;
+ assert(event_try(&inner.done.base, &v));
+ assert(v == 0); /* inner winner: a (index 0) */
+
+ /* The unrelated source c was disarmed when outer fired. */
+ assert(c.waiters == NULL);
+ /* And b was disarmed when inner fired. */
+ assert(b.waiters == NULL);
+
+ select_event_deinit(&outer);
+ select_event_deinit(&inner);
+}
+
+/* ---- Runtime test -------------------------------------------------- */
+
+static void test_runtime_drains(void) {
+ /* rt_run keeps going until quiescent — including steps queued
+ * during another step's resumption. Three waiters on one latch
+ * all reach DEAD in a single rt_run. */
+ runtime_t rt; rt_init(&rt);
+ latch_t l; latch_init(&l);
+
+ waiter_t w[3];
+ for (int i = 0; i < 3; i++) waiter_init(&w[i], &rt, &l.base);
+ for (int i = 0; i < 3; i++) xstep(&w[i].base, 0);
+
+ latch_set(&l, 0xC0DE);
+ rt_run(&rt);
+
+ assert(rt.head == NULL);
+ for (int i = 0; i < 3; i++) {
+ assert(xstep_status(&w[i].base) == XSTEP_DEAD);
+ assert(w[i].got == 0xC0DE);
+ }
+}
+
+int main(void) {
+ test_latch_wake();
+ test_latch_already_set();
+ test_latch_multi_waiter();
+ test_latch_set_idempotent();
+ test_latch_unpark();
+
+ test_select_winner();
+ test_select_fast_path();
+ test_select_deinit_unparks();
+ test_select_compose();
+
+ test_runtime_drains();
+
+ printf("ok\n");
+ return 0;
+}