Commit Graph
16 Commits
Author SHA1 Message Date
Claude 5e0c9d45da feat(runtime): phase 2 — fixed slab, per-slot packed-atomic state machine, raw cold locks
The slot table split (ROADMAP_v0.5 phase 2): slot lookup is lock-free, the
run path (yield/park/unpark/pop/resume) takes zero locks beyond the queue
mutex itself, and SharedState shrinks to { run_queue }.

Core pieces:

- src/raw_mutex.rs: hand-rolled 3-state futex mutex (Drepper mutex3),
  non-poisoning; the guard enters NoPreempt, which both bounds hold times and
  structurally closes the unwind-under-lock hole (the stop sentinel shares
  the gate). Used for per-slot cold data, the free list, and the stack pool.

- Fixed slab Box<[Slot]> (default max_actors = 16_384, ~4 MiB, align(128)
  against false sharing). Slots never move -> stable addresses, lock-free
  index. Exhaustion panics loudly, naming Config::max_actors(n) as the fix.
  Unbounded/segmented slab stays deferred (see ROADMAP).

- Per-slot AtomicU64 packing (generation << 32 | state), states
  Vacant/Queued/Running/RunningNotified/Parked/Done. Every transition CASes
  the packed word, so the generation check is atomic with the transition: no
  ABA, no spurious unparks on recycled slots. RunningNotified replaces the
  pending_unpark bool (the lost-wakeup window is a state, not a flag) and
  uniformly fixes a LATENT LOST WAKEUP in the old Blocking-IO completion
  path, which set the result for a still-Running actor without flagging it.

- Invariant: a pid is in the run queue at most once (pushes pair 1:1 with
  transitions into Queued; only the scheduler does Queued->Running). This is
  what makes phase 3's bounded rings sound.

- sp -> relaxed AtomicUsize; stop flag + first-resume closure as AtomicPtrs
  (closure double-boxed for a thin pointer, swap-to-take): the resume path is
  fully atomic.

- finalize_actor: Done published under the dying slot's cold lock (join's
  check-or-register is linearized by it); link cascade locks peers ONE AT A
  TIME (cold locks are leaves) with the acyclicity argument written at the
  site; link() registers on the target first, then self (stale self-entries
  are benign, every walk re-verifies the peer's word).

- Termination by counters: live_actors incremented in spawn pre-enqueue,
  decremented at the very END of finalize after all wakeup enqueues; exit on
  io_out == 0 (read before the queue lock, phase-1 ordering) && queue empty
  && live == 0. Soundness note at the site: any enqueue targets a live actor.

- spawn boxes the closure and acquires the stack BEFORE any runtime lock: no
  allocation ever happens under a global lock anymore.

- with_runtime/try_with_runtime now enter NoPreempt for their full span.
  This fixes a bug the rework exposed: install_actor allocated with
  preemption enabled while the RUNTIME RefCell borrow was live; a timeslice
  preemption there migrates the actor across OS threads and the borrow guard
  increments one thread's RefCell count and decrements another's — underflow
  to 'permanently mutably borrowed', cascading panics (caught by stress
  suite: deterministic non-unwinding-panic abort in lost_wakeup_many_pairs).
  The old code was safe only by accident; now it's structural.

- Behavior note: request_stop on a RUNNING target now marks it
  RunningNotified, so its next park returns immediately to an observation
  point — faster stop observation; parked/queued/done semantics unchanged.

Validated: 22 suites green in release (1/2/8-thread oversubscribed on the
1-core sandbox) and in debug with all debug_asserts live; stress suite x5.
2026-06-09 20:08:12 +00:00
smarm-dev 518103750b feat(monitor): demonitor + per-monitor MonitorId (roadmap #5)
Monitors could be installed but never taken down. That gap was about to
bite: the gen_server call timeout we want next is the Erlang dance —
monitor the server, wait for the reply or a Down or a deadline, then
demonitor — and without a way to remove a registration, every timed-out
call would leak a monitor on the server's slot and risk a stale Down
arriving later. So this lands the cleanup primitive before anything
depends on the old shape.

The decision flagged in the roadmap ("decide the monitor API NOW") is
resolved by giving each registration a process-unique MonitorId and
returning it to the caller. monitor() now hands back a
Monitor { id, target, rx } rather than a bare Receiver<Down>: read the
notice from rx as before, and pass &Monitor to demonitor to tear exactly
that registration down. The id comes from a monotonic counter in shared
state, bumped under the same lock that does the registration, so it's
deterministic and never reused — which is what lets demonitor name one of
several monitors on the same target unambiguously. target rode along on
the struct (over the sketched {id, rx}) purely so demonitor can go
straight to the slot instead of scanning every slot for the id.

demonitor returns Option<MonitorId> rather than a bool: Some(id) when a
live registration was found and removed, None when there was nothing left
to remove — it already fired (the registration is drained on finalize),
it was a NoProc, or the slot has been reclaimed. The generation half of
the pid quietly protects that last case: a recycled slot index fails
slot_mut's generation check, so a late demonitor is a clean no-op and can
never strip a different actor's monitor that happens to share the index.

The one piece of real care is reentrancy. Removing a registration drops
the slot's Sender, and Sender::drop can unpark a parked receiver, which
re-enters the shared mutex — which is not reentrant. So demonitor moves
the sender out of the Vec under the lock and lets it drop only after the
lock is released, the same discipline finalize_actor already follows for
its monitor and supervisor sends.

Flushing a Down the target already queued isn't a separate flag; it falls
out of dropping the Monitor. demonitor(&m); drop(m) stops future notices
and discards any queued one — the gen_server-call cleanup in one move.

Storage is now Vec<(MonitorId, Sender<Down>)>. The three slot-reset sites
were left alone on purpose: they clear/rebuild the Vec, which doesn't care
about the element type, so there's no fourth reset obligation. finalize
just destructures (_, m). Because chained_spawn and yield_many register no
monitors, that Vec stays empty on the hot path — taking an empty Vec costs
the same and the notify loop runs zero times — and a before/after general
probe confirmed both medians sit within noise.

Tests cover the three behaviours that matter: a demonitored watcher gets
no Down (its channel closes), demonitoring one of several leaves the
siblings firing normally, and demonitoring after the Down has already
fired reports None.
2026-06-07 21:51:56 +00:00
smarm-dev 55221e9e98 feat(gen_server): synchronous call / async cast over a single actor (roadmap #5)
Thin request-reply layer on channels, no runtime change. A server is an
actor owning a GenServer state; clients hold a clonable ServerRef and issue
call (sync, parks for the reply) or cast (fire-and-forget).

- Single inbox carrying an internal Envelope { Call(req, reply_tx) | Cast },
  forced by the no-select / no-unified-mailbox invariant; call makes a
  one-shot reply channel and parks on it.
- Server-down detection is pure channel closure (no monitor): send fails if
  the inbox is gone; the reply sender drops on the server's unwind so a
  parked caller wakes to Err. Both collapse to CallError/CastError::ServerDown.
- init/terminate are optional trait hooks; terminate runs via a drop guard so
  it fires on every exit path (clean close, panic, request_stop). Must stay
  non-blocking — may run mid-unwind.
- ServerRef carries pid() for monitor/request_stop/link; start + start_under.

Tests: cast-then-call roundtrip, init/terminate ordering, both server-down
paths (reply-channel close on handler panic; inbox-send failure when gone).
2026-06-07 21:51:56 +00:00
smarm-dev 8ac11a57ac feat(link): bidirectional links + trap_exit (roadmap #3)
Add Erlang-style process links so an abnormal death fate-shares across a
link set, with trap_exit to convert that into a message instead.

- Slot.links: Vec<Pid>, bidirectional; reset in all three lifecycle sites.
- Actor.trap: Option<Sender<ExitSignal>>, fresh per spawn (a restarted
  child starts un-trapped; no fourth reset site).
- link/unlink free functions on self_pid(); trap_exit() -> Receiver<ExitSignal>
  (a dedicated inbox, distinct from the monitor Down channel).
- finalize_actor clears reverse links under the lock (always; keeps the
  cascade acyclic), then propagates abnormal deaths post-lock: trapping peer
  gets an ExitSignal message and survives, non-trapping peer is request_stop'd.
  Normal exit never propagates. Dead-pid link delivers an immediate NoProc
  (message if trapping, else request_stop(self) -- never a silent no-op).
- ExitSignal reuses DownReason and carries no panic payload (joiner-only).

Tests in tests/link.rs cover the propagate/trap/normal/dead-pid/unlink cases.
2026-06-07 21:51:56 +00:00
smarm-dev 68c7c96749 feat(supervisor): one_for_all and rest_for_one strategies + ordered shutdown
Adds a Strategy enum (OneForOne default, OneForAll, RestForOne) selected
via OneForOne::strategy(). The triggering child's Restart policy still
decides whether anything restarts; the strategy decides which siblings
are cycled with it:
  - OneForAll : all live siblings
  - RestForOne: siblings started after the failed child

Group restarts stop the affected survivors cooperatively (request_stop)
in reverse start order, await each one's termination signal on the
existing funnel (no new channel, no select), then restart the whole set
in start order. Signals that arrive for a child we aren't currently
stopping are stashed and replayed by the main loop. A Stopped signal now
counts as abnormal for the restart decision.

On giving up (intensity cap) or any exit with survivors, an ordered
shutdown stops the remaining children in reverse start order instead of
leaking them; on the normal all-settled exit it's a no-op.

The struct keeps the OneForOne name for compatibility (existing tests
unchanged); rename is a later refactor.
2026-06-07 21:51:56 +00:00
smarm-dev d9a6520a24 feat(cancel): cooperative cancellation via sentinel unwind
request_stop(pid) sets a per-actor flag and wakes a parked target. The
actor realizes the stop as a controlled unwind at its next observation
point (check!()/alloc via maybe_preempt, or the wakeup side of any
blocking park): a StopSentinel panic tears the stack down via the
trampoline's existing catch_unwind, running Drop, and is reported as the
new Outcome::Stopped (distinct from a user Panic).

Death surface kept distinct from Exit: Signal::Stopped(pid) +
DownReason::Stopped, so the supervisor await logic to come can tell a
requested stop apart from a self-termination.

Flag lives on Actor behind Arc<AtomicBool>; the scheduler resume path
takes a raw pointer into it (no per-resume refcount traffic), keeping
yield throughput at baseline.
2026-06-07 21:51:56 +00:00
smarm-dev 09236b8bf4 feat(supervisor): one-for-one supervisor with restart policies + intensity cap
Builds on the existing supervisor_channel funnel (one mailbox per
supervisor; better than N monitor channels given there is no select).

- supervisor.rs: Restart{Permanent,Transient,Temporary}, ChildSpec (an
  Fn factory so children can be re-instantiated), and OneForOne with a
  builder (child/intensity) and run() supervision loop. Restart decision
  keys off whether the Signal was a panic; a sliding-window intensity cap
  stops crash loops and returns instead of spinning.
- Does NOT forcibly terminate children: shared-heap + Drop make async
  teardown of a peer unsound, so one_for_all/rest_for_one and true links
  wait on a cooperative-cancellation primitive. Cap-trip just stops
  restarting; live children are left as-is.

tests/supervisor.rs: transient restart-then-settle, transient/temporary
no-restart, permanent crash-loop hitting the cap, and one-for-one
isolation across two children. Full suite green.
2026-06-07 21:51:56 +00:00
smarm-dev 461de2c451 feat(monitor): unidirectional one-shot process monitors
`monitor(pid) -> Receiver<Down>` lets any actor watch any other and
receive a single Down{pid, reason} when it terminates. Generalizes the
existing single supervisor_channel (which is just a hard-wired monitor
the parent installs at spawn).

- src/monitor.rs: Down / DownReason{Exit,Panic,NoProc} + monitor().
  Payload-free: a panic payload has one owner and goes to the joiner via
  JoinError, so monitors learn only that it panicked. Monitoring an
  already-gone pid yields NoProc immediately.
- runtime: Slot grows `monitors: Vec<Sender<Down>>`; finalize_actor
  drains and notifies them outside the shared lock (send can unpark a
  receiver, which re-takes the non-reentrant mutex). Cleared in
  reclaim_slot and on slot reuse at spawn.
- Registration and finalize both serialize on the shared mutex, so a
  target alive at registration always delivers a real Down (no race
  between liveness check and registration).

tests/monitor.rs: Exit, Panic, NoProc-on-dead-target, and fan-out to
multiple monitors. Full suite green.
2026-06-07 21:51:56 +00:00
Claude 978678a46e feat: full runtime redesign (v0.6)
Complete rewrite with improved architecture & correctness:
- src/runtime.rs: Simplified task scheduling with proper state transitions
- src/scheduler.rs: Decoupled from runtime, pure task queue logic
- src/io.rs, src/mutex.rs: Refactored for clarity & performance
- New actor model framework (src/actor.rs, src/context.rs)
- Channel primitives (src/channel.rs) & process IDs (src/pid.rs)
- Preemption framework (src/preempt.rs) for fair timeslicing
- Expanded benchmarks & tests (multi_scheduler, primes, runtime)
2026-05-23 16:09:35 +00:00
Claude 078447539c chore: reset working tree (v0.5)
Temporary commit clearing working tree for v0.6 rebuild
2026-05-23 16:09:35 +00:00
Claude e9fdbb1160 refactor: centralize runtime logic (v0.4)
Extract scheduler responsibilities into a dedicated Runtime component:
- src/runtime.rs: New centralized control flow (669 lines)
- src/scheduler.rs: Simplified to task queue & preemption management
- tests/runtime.rs: Comprehensive runtime test suite
- benches/multi_scheduler.rs: Multi-runtime scheduling benchmarks
- Improves modularity and enables per-runtime configuration
2026-05-23 16:09:32 +00:00
Claude 8cbef1dfc1 feat: I/O and mutex support (v0.3)
Add epoll-based non-blocking I/O and kernel-like mutexes:
- src/io.rs: Complete epoll backend with timeout & error handling
- src/mutex.rs: Fair mutex with waiter queues & parking integration
- Enhanced scheduler to support synchronous I/O blocking
- Comprehensive test suites for I/O (epoll) and mutex behavior
- Documentation: LOOM.md concurrency model & README
2026-05-23 16:09:29 +00:00
Claude d3ab81b833 preempt: explicit check!() macro for no-alloc loops
Stable Rust emits stack probes inline (subq/movq/jne loop) rather than
calling __rust_probestack, so there's no transparent hook for stack-
frame preemption. Override of __rust_probestack links cleanly but never
runs. Falling back to an explicit check!() that users drop into hot
compute loops.

check!() decrements the same ALLOC_COUNT counter as the heap path, so
both event sources fire timeslice checks at the same rate. Documents
the prep-to-park invariant on maybe_preempt — library code that
registers a wakeup and then parks must keep that window alloc-free and
check-free, or a preemption-driven yield in the middle would lose the
wakeup.
2026-05-22 05:37:04 +00:00
Claude 51bfccc3c2 feat: I/O and mutex support (v0.3)
Add epoll-based non-blocking I/O and kernel-like mutexes:
- src/io.rs: Complete epoll backend with timeout & error handling
- src/mutex.rs: Fair mutex with waiter queues & parking integration
- Enhanced scheduler to support synchronous I/O blocking
- Comprehensive test suites for I/O (epoll) and mutex behavior
- Documentation: LOOM.md concurrency model & README
2026-05-22 05:32:24 +00:00
Claude 2cf75febdc timer: sleep(duration) via min-heap of (deadline, pid)
Adds a BinaryHeap of timer entries on SchedulerState. sleep() inserts
an entry and parks; schedule_loop pops due entries each iteration and
unparks them. When the run queue is empty but timers are pending, the
OS thread sleeps until the soonest deadline.

Single-threaded only; thread::sleep is fine because no other thread
can wake us. The IO thread coming next will need a Condvar or pipe
wakeup to break this OS-sleep early.
2026-05-22 05:22:55 +00:00
Claude 0e9d9d7d5f v0.1: green-thread actors, supervision, channels, benchmark
Hand-rolled context switching on mmap'd stacks with guard pages,
allocator-driven RDTSC preemption, unbounded MPSC channels, supervision
via per-slot Signal mailboxes, root supervisor as sentinel PID.

Lib + tests + benches clean check/clippy. All 29 tests pass.
Bench: smarm 3.4% over serial baseline, within 160us of tokio
current-thread on prime-counting fan-out.
2026-05-22 05:01:51 +00:00