feat: no_std core runtime

This commit is contained in:
Zachery Aaron Shores-Chmielewski 2026-03-03 00:48:03 +07:00
parent 90991fac48
commit 61701bd7f2
17 changed files with 1032 additions and 174 deletions

5
Cargo.lock generated
View file

@ -5324,10 +5324,15 @@ dependencies = [
"proptest",
"proptest-state-machine",
"serde",
"swactor-core",
"tracing",
"web-time 0.2.4",
]
[[package]]
name = "swactor-core"
version = "0.1.0"
[[package]]
name = "swactor-datastore"
version = "0.1.0"

View file

@ -1,6 +1,7 @@
[workspace]
members = [
".",
"crates/core",
"crates/bindings/python",
"crates/bindings/wasm-runtime",
"crates/simulation",
@ -41,6 +42,7 @@ transport = [
wasm = ["no_random", "dep:web-time"] # browser/wasm32 target support
[dependencies]
swactor-core = { path = "crates/core" }
getrandom = { version = "0.2", optional = true }
serde = { version = "1", features = ["derive"], optional = true }
tracing = { version = "0.1", optional = true }

4
crates/core/Cargo.toml Normal file
View file

@ -0,0 +1,4 @@
[package]
name = "swactor-core"
version = "0.1.0"
edition = "2024"

273
crates/core/src/lib.rs Normal file
View file

@ -0,0 +1,273 @@
//! Pure, synchronous, deterministic core for the swactor actor framework.
//!
//! This crate contains the decision logic that maps 1:1 to a TLA+ specification.
//! Every public function is a pure function: `fn(inputs) -> output` with no side
//! effects, no IO, no allocation, and no panics.
//!
//! The runtime crate (`swactor`) calls these functions to make decisions; this crate
//! never calls back into the runtime. If you can't trivially transliterate a
//! function's signature into TLA+, it doesn't belong here.
#![no_std]
#![deny(unsafe_code)]
// ─── Actor Lifecycle Phase ───────────────────────────────────────────────────
/// The lifecycle phase of an actor slot.
///
/// Replaces the four boolean fields (`started`, `poisoned`, `stopping`, `suspended`)
/// with a single state machine. Every phase transition goes through
/// [`lifecycle_transition`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActorPhase {
/// Actor has been inserted into the pool but `on_start` has not been called yet.
Unstarted,
/// Actor is running normally — processing messages from its mailbox.
Running,
/// Actor is suspended — messages continue to queue but are not processed.
Suspended,
/// Actor has been requested to stop gracefully. `on_stop` will be called during cleanup.
Stopping,
/// Actor panicked during `on_start` or message handling. No further processing occurs.
Poisoned,
}
// ─── Stop / Exit Reasons ─────────────────────────────────────────────────────
/// Reason an actor was removed from the runtime.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum StopReason {
/// Graceful stop (via `ctx.stop_self()` or `Runtime::stop_actor()`).
Normal,
/// Actor panicked and could not be restarted.
Panicked,
/// Actor stopped with a typed exit value (via `Ctx::stop_with`).
Completed,
}
/// Why an actor exited — delivered to watchers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExitReason {
/// Actor was explicitly stopped or removed from the pool.
Stopped,
/// Actor panicked during message handling.
Panicked,
/// The node hosting the actor left the cluster (SWIM Dead).
NodeDown,
/// Actor stopped with a typed exit value (via `Ctx::stop_with`).
Completed,
}
// ─── Mailbox ─────────────────────────────────────────────────────────────────
/// What to do when a bounded mailbox is full.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MailboxOverflow {
/// Drop the incoming message (newest). The message is silently discarded.
DropNewest,
/// Drop the oldest message in the queue to make room for the new one.
DropOldest,
}
/// Result of the mailbox capacity check.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MailboxDecision {
/// The mailbox has room (or is unbounded). Accept the message.
Accept,
/// The mailbox is full and the policy is DropNewest — reject the incoming message.
RejectNewest,
/// The mailbox is full and the policy is DropOldest — evict the front of the queue.
EvictOldest,
}
// ─── Lifecycle Events ────────────────────────────────────────────────────────
/// Events that drive the actor lifecycle state machine.
///
/// Each variant corresponds to a runtime action that may cause a phase transition.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LifecycleEvent {
/// `on_start` completed successfully.
Started,
/// `on_start` panicked.
StartPanicked,
/// A message handler panicked.
MessagePanicked,
/// A graceful stop was requested (via `ctx.stop_self()` or `StopSignal`).
StopRequested,
/// A stop-with-value was requested (via `ctx.stop_with()`).
StopWithRequested,
/// The actor was suspended (via `ctx.suspend_self()`).
SuspendRequested,
/// A suspended actor was resumed (via `ResumeSignal`).
Resumed,
}
// ─── Deliver Decision ────────────────────────────────────────────────────────
/// What should happen when a message arrives at an actor in a particular phase.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeliverDecision {
/// Enqueue the message in the actor's mailbox (subject to overflow policy).
Enqueue,
/// The actor is dead/stopping — discard the message silently.
Discard,
/// The actor is suspended — enqueue but do not process until resumed.
Queue,
}
// ─── Pure Functions ─────────────────────────────────────────────────────────
// TLA+: LifecycleTransition
/// The actor lifecycle state machine.
///
/// Given the current phase and an event, returns the new phase. Every phase
/// transition in the runtime must go through this function.
///
/// Illegal transitions (e.g., `Resumed` on a non-suspended actor) return the
/// phase unchanged — the runtime is responsible for not issuing nonsensical
/// events, but the core never panics.
#[must_use]
pub fn lifecycle_transition(phase: ActorPhase, event: LifecycleEvent) -> ActorPhase {
match (phase, event) {
// Unstarted: waiting for on_start result
(ActorPhase::Unstarted, LifecycleEvent::Started) => ActorPhase::Running,
(ActorPhase::Unstarted, LifecycleEvent::StartPanicked) => ActorPhase::Poisoned,
(ActorPhase::Unstarted, LifecycleEvent::StopRequested) => ActorPhase::Stopping,
(ActorPhase::Unstarted, LifecycleEvent::StopWithRequested) => ActorPhase::Stopping,
(ActorPhase::Unstarted, LifecycleEvent::SuspendRequested) => ActorPhase::Suspended,
(ActorPhase::Unstarted, LifecycleEvent::MessagePanicked) => phase,
(ActorPhase::Unstarted, LifecycleEvent::Resumed) => phase,
// Running: normal operation
(ActorPhase::Running, LifecycleEvent::MessagePanicked) => ActorPhase::Poisoned,
(ActorPhase::Running, LifecycleEvent::StopRequested) => ActorPhase::Stopping,
(ActorPhase::Running, LifecycleEvent::StopWithRequested) => ActorPhase::Stopping,
(ActorPhase::Running, LifecycleEvent::SuspendRequested) => ActorPhase::Suspended,
(ActorPhase::Running, LifecycleEvent::Started) => phase,
(ActorPhase::Running, LifecycleEvent::StartPanicked) => phase,
(ActorPhase::Running, LifecycleEvent::Resumed) => phase,
// Suspended: waiting for resume or stop
(ActorPhase::Suspended, LifecycleEvent::Resumed) => ActorPhase::Running,
(ActorPhase::Suspended, LifecycleEvent::StopRequested) => ActorPhase::Stopping,
(ActorPhase::Suspended, LifecycleEvent::StopWithRequested) => ActorPhase::Stopping,
(ActorPhase::Suspended, LifecycleEvent::Started) => phase,
(ActorPhase::Suspended, LifecycleEvent::StartPanicked) => phase,
(ActorPhase::Suspended, LifecycleEvent::MessagePanicked) => phase,
(ActorPhase::Suspended, LifecycleEvent::SuspendRequested) => phase,
// Terminal states: Stopping and Poisoned absorb all events
(ActorPhase::Stopping, _) => ActorPhase::Stopping,
(ActorPhase::Poisoned, _) => ActorPhase::Poisoned,
}
}
// TLA+: MailboxAccept
/// Decide what to do with an incoming message given mailbox state and policy.
///
/// `capacity` of 0 means unbounded (always accept). When the mailbox is full,
/// the overflow policy determines whether the newest or oldest message is dropped.
#[must_use]
pub fn mailbox_accept(
mailbox_len: usize,
capacity: usize,
policy: MailboxOverflow,
) -> MailboxDecision {
if capacity == 0 || mailbox_len < capacity {
MailboxDecision::Accept
} else {
match policy {
MailboxOverflow::DropNewest => MailboxDecision::RejectNewest,
MailboxOverflow::DropOldest => MailboxDecision::EvictOldest,
}
}
}
// TLA+: ShouldTickActor
/// Whether an actor in the given phase should be ticked (i.e., have messages processed).
///
/// Returns `false` for `Poisoned`, `Stopping`, and `Suspended` — these actors
/// either cannot process messages or must not.
#[must_use]
pub fn should_tick_actor(phase: ActorPhase) -> bool {
match phase {
ActorPhase::Unstarted | ActorPhase::Running => true,
ActorPhase::Suspended | ActorPhase::Stopping | ActorPhase::Poisoned => false,
}
}
// TLA+: BudgetExhausted
/// Whether the per-actor message budget has been exhausted.
///
/// `budget` of 0 means unlimited — never exhausted.
#[must_use]
pub fn budget_exhausted(processed: usize, budget: usize) -> bool {
budget > 0 && processed >= budget
}
// TLA+: CleanupStopReason
/// Determine the stop reason for a dead actor given its phase and whether
/// it has a typed exit value.
///
/// - `Poisoned` → `StopReason::Panicked`
/// - Has exit value → `StopReason::Completed`
/// - Otherwise → `StopReason::Normal`
#[must_use]
pub fn cleanup_stop_reason(phase: ActorPhase, has_exit_value: bool) -> StopReason {
if phase == ActorPhase::Poisoned {
StopReason::Panicked
} else if has_exit_value {
StopReason::Completed
} else {
StopReason::Normal
}
}
// ─── Invariant Predicates ───────────────────────────────────────────────────
// TLA+: PhaseIsTerminal
/// Returns `true` if the phase is terminal — the actor will not process any
/// further messages and is awaiting cleanup.
#[must_use]
pub fn phase_is_terminal(phase: ActorPhase) -> bool {
match phase {
ActorPhase::Stopping | ActorPhase::Poisoned => true,
ActorPhase::Unstarted | ActorPhase::Running | ActorPhase::Suspended => false,
}
}
// TLA+: ValidPhaseTransition
/// Returns `true` only for transitions that exist in the lifecycle state machine.
///
/// This is an invariant predicate — useful for property-based testing and Kani
/// harnesses to verify that no illegal transition is ever taken.
#[must_use]
pub fn valid_phase_transition(from: ActorPhase, to: ActorPhase) -> bool {
match (from, to) {
// Self-transitions are always valid (no-op events)
(a, b) if a == b => true,
// From Unstarted
(ActorPhase::Unstarted, ActorPhase::Running) => true,
(ActorPhase::Unstarted, ActorPhase::Poisoned) => true,
(ActorPhase::Unstarted, ActorPhase::Stopping) => true,
(ActorPhase::Unstarted, ActorPhase::Suspended) => true,
// From Running
(ActorPhase::Running, ActorPhase::Poisoned) => true,
(ActorPhase::Running, ActorPhase::Stopping) => true,
(ActorPhase::Running, ActorPhase::Suspended) => true,
// From Suspended
(ActorPhase::Suspended, ActorPhase::Running) => true,
(ActorPhase::Suspended, ActorPhase::Stopping) => true,
// Terminal states never transition out
(ActorPhase::Stopping, _) => false,
(ActorPhase::Poisoned, _) => false,
// Everything else is invalid
_ => false,
}
}

View file

@ -1,7 +1,9 @@
use std::any::Any;
use std::any::TypeId;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
#[cfg(not(feature = "std"))]
use crate::compat::{Box, String, Vec};
use core::any::{Any, TypeId};
use crate::compat::{Arc, TypeIdMap, TypeIdSet};
use crate::delivery::AddrSet;
use crate::Error;
@ -36,8 +38,8 @@ impl PartialEq for ExitValue {
impl Eq for ExitValue {}
impl std::fmt::Debug for ExitValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
impl core::fmt::Debug for ExitValue {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str("ExitValue(..)")
}
}
@ -55,36 +57,60 @@ pub struct SystemInfo {
pub uptime_ms: u64,
}
/// Why an actor exited.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ExitReason {
/// Actor was explicitly stopped or removed from the pool.
Stopped,
/// Actor panicked during message handling.
Panicked,
/// The node hosting the actor left the cluster (SWIM Dead).
NodeDown,
/// Actor stopped with a typed exit value (via [`Ctx::stop_with`]).
Completed,
}
pub use swactor_core::ExitReason;
/// Delivered to watchers when a watched actor exits.
///
/// Implements `Message` (Clone + Send + Sync + 'static) so it can be
/// delivered through normal mailbox channels.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ActorExited {
/// The address of the actor that died.
pub addr: ActorAddress,
/// Why it exited.
pub reason: ExitReason,
/// Typed exit value if the actor called [`Ctx::stop_with`].
#[cfg_attr(feature = "serde", serde(skip))]
pub exit_value: Option<ExitValue>,
}
#[cfg(feature = "serde")]
impl serde::Serialize for ActorExited {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
let mut s = serializer.serialize_struct("ActorExited", 2)?;
s.serialize_field("addr", &self.addr)?;
let reason_str = match self.reason {
ExitReason::Stopped => "Stopped",
ExitReason::Panicked => "Panicked",
ExitReason::NodeDown => "NodeDown",
ExitReason::Completed => "Completed",
};
s.serialize_field("reason", reason_str)?;
s.end()
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for ActorExited {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(serde::Deserialize)]
struct Helper {
addr: ActorAddress,
reason: HelperExitReason,
}
#[derive(serde::Deserialize)]
enum HelperExitReason { Stopped, Panicked, NodeDown, Completed }
let h = Helper::deserialize(deserializer)?;
let reason = match h.reason {
HelperExitReason::Stopped => ExitReason::Stopped,
HelperExitReason::Panicked => ExitReason::Panicked,
HelperExitReason::NodeDown => ExitReason::NodeDown,
HelperExitReason::Completed => ExitReason::Completed,
};
Ok(ActorExited { addr: h.addr, reason, exit_value: None })
}
}
impl PartialEq for ActorExited {
fn eq(&self, other: &Self) -> bool {
self.addr == other.addr && self.reason == other.reason
@ -140,9 +166,9 @@ pub struct ActorAddress(pub [u8; 32]);
/// Custom Hash: only hash the first 8 bytes since all 32 are random.
/// SipHash on 8 bytes is ~3x faster than on 32 bytes, with identical
/// collision properties (2^64 possible values from cryptographic randomness).
impl std::hash::Hash for ActorAddress {
impl core::hash::Hash for ActorAddress {
#[inline]
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
// SAFETY: ActorAddress is always 32 bytes, so [..8] is valid.
state.write_u64(u64::from_ne_bytes(
self.0[..8].try_into().unwrap(),
@ -150,8 +176,20 @@ impl std::hash::Hash for ActorAddress {
}
}
impl std::fmt::Display for ActorAddress {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
impl PartialOrd for ActorAddress {
fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for ActorAddress {
fn cmp(&self, other: &Self) -> core::cmp::Ordering {
self.0.cmp(&other.0)
}
}
impl core::fmt::Display for ActorAddress {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
for b in &self.0[..8] {
write!(f, "{:02x}", b)?;
}
@ -177,14 +215,14 @@ impl ActorAddress {
/// can clone individual entries cheaply (Arc bump) for copy-on-write overrides.
#[derive(Clone, Default)]
pub struct Environment {
inner: Arc<HashMap<TypeId, Arc<dyn Any + Send + Sync>>>,
inner: Arc<TypeIdMap<Arc<dyn Any + Send + Sync>>>,
}
impl Environment {
/// Create an empty environment.
pub fn new() -> Self {
Self {
inner: Arc::new(HashMap::new()),
inner: Arc::new(TypeIdMap::new()),
}
}
@ -223,14 +261,14 @@ impl Environment {
///
/// Allows inserting/replacing typed values before freezing into an immutable `Environment`.
pub struct EnvironmentBuilder {
map: HashMap<TypeId, Arc<dyn Any + Send + Sync>>,
map: TypeIdMap<Arc<dyn Any + Send + Sync>>,
}
impl EnvironmentBuilder {
/// Create an empty builder.
pub fn new() -> Self {
Self {
map: HashMap::new(),
map: TypeIdMap::new(),
}
}
@ -304,14 +342,14 @@ impl LogicalName {
#[derive(Clone, Debug)]
pub struct ServiceBinding<S: 'static + Send + Sync> {
pub addr: ActorAddress,
_marker: std::marker::PhantomData<S>,
_marker: core::marker::PhantomData<S>,
}
impl<S: 'static + Send + Sync> ServiceBinding<S> {
pub fn new(addr: ActorAddress) -> Self {
Self {
addr,
_marker: std::marker::PhantomData,
_marker: core::marker::PhantomData,
}
}
}
@ -328,11 +366,11 @@ impl<S: 'static + Send + Sync> ServiceBinding<S> {
/// Built via fluent API: `CapabilitySet::new().with_send(addr).with_spawn()`.
#[derive(Clone, Default)]
pub struct CapabilitySet {
send_any: HashSet<ActorAddress, crate::delivery::AddrBuildHasher>,
send_typed: HashSet<(TypeId, ActorAddress)>,
send_any: AddrSet,
send_typed: crate::compat::TypedAddrSet,
can_spawn: bool,
service_types: HashSet<TypeId>,
monitor_targets: HashSet<ActorAddress, crate::delivery::AddrBuildHasher>,
service_types: TypeIdSet,
monitor_targets: AddrSet,
}
impl CapabilitySet {
@ -386,8 +424,8 @@ impl CapabilitySet {
}
}
impl std::fmt::Debug for CapabilitySet {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
impl core::fmt::Debug for CapabilitySet {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("CapabilitySet")
.field("send_any", &self.send_any.len())
.field("send_typed", &self.send_typed.len())
@ -442,7 +480,7 @@ where
let msg = match msg.downcast::<A::Incoming>() {
Ok(typed) => {
self.inner.handle(ctx, *typed);
return Some(std::any::type_name::<A::Incoming>());
return Some(core::any::type_name::<A::Incoming>());
}
Err(msg) => msg,
};
@ -484,16 +522,7 @@ impl MonitorRef {
}
}
/// Reason an actor was removed from the runtime.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum StopReason {
/// Graceful stop (via `ctx.stop_self()` or `Runtime::stop_actor()`).
Normal,
/// Actor panicked and could not be restarted.
Panicked,
/// Actor stopped with a typed exit value (via [`Ctx::stop_with`]).
Completed,
}
pub use swactor_core::StopReason;
/// Death notification delivered as a normal message when a monitored actor dies.
///

View file

@ -1,4 +1,4 @@
use std::sync::Arc;
use crate::compat::Arc;
use crossbeam_queue::{ArrayQueue, SegQueue};

190
src/compat.rs Normal file
View file

@ -0,0 +1,190 @@
// Compatibility module: re-exports types from std or core/alloc depending on feature flags.
//
// Core types (Any, fmt, atomics, etc.) are imported directly from `core::` in each file.
// This module provides conditional re-exports for types that differ between std and no_std.
// --- alloc types (conditional on std feature) ---
#[cfg(feature = "std")]
pub use std::sync::Arc;
#[cfg(not(feature = "std"))]
pub use alloc::sync::Arc;
#[cfg(feature = "std")]
pub use std::collections::VecDeque;
#[cfg(not(feature = "std"))]
pub use alloc::collections::VecDeque;
// Vec, String, Box — in std prelude normally, but need explicit import in no_std.
// vec! and format! macros are available via `extern crate alloc` in lib.rs.
#[cfg(not(feature = "std"))]
pub use alloc::{boxed::Box, string::String, string::ToString, vec::Vec};
// --- sync primitives (std vs spin-based) ---
#[cfg(feature = "std")]
pub use std::sync::{RwLock, OnceLock};
#[cfg(not(feature = "std"))]
pub use crate::sync_impl::SpinRwLock as RwLock;
// --- collections (std vs alloc-based alternatives) ---
#[cfg(feature = "std")]
pub use std::collections::HashMap;
#[cfg(not(feature = "std"))]
pub use alloc::collections::BTreeMap as HashMap;
// --- TypeId-keyed map/set ---
//
// TypeId does not implement Ord, so BTreeMap cannot be used in no_std.
// In std mode: thin wrappers around HashMap/HashSet.
// In no_std mode: linear-scan Vec wrappers (<10 entries typical).
use core::any::TypeId as StdTypeId;
/// A map keyed by `TypeId`.
/// std: HashMap<TypeId, V>. no_std: linear-scan Vec.
#[derive(Clone)]
pub struct TypeIdMap<V> {
#[cfg(feature = "std")]
inner: std::collections::HashMap<StdTypeId, V>,
#[cfg(not(feature = "std"))]
inner: Vec<(StdTypeId, V)>,
}
impl<V> TypeIdMap<V> {
pub fn new() -> Self {
Self {
#[cfg(feature = "std")]
inner: std::collections::HashMap::new(),
#[cfg(not(feature = "std"))]
inner: Vec::new(),
}
}
#[cfg(feature = "std")]
pub fn get(&self, key: &StdTypeId) -> Option<&V> {
self.inner.get(key)
}
#[cfg(not(feature = "std"))]
pub fn get(&self, key: &StdTypeId) -> Option<&V> {
self.inner.iter().find(|(k, _)| k == key).map(|(_, v)| v)
}
#[cfg(feature = "std")]
pub fn contains_key(&self, key: &StdTypeId) -> bool {
self.inner.contains_key(key)
}
#[cfg(not(feature = "std"))]
pub fn contains_key(&self, key: &StdTypeId) -> bool {
self.inner.iter().any(|(k, _)| k == key)
}
#[cfg(feature = "std")]
pub fn insert(&mut self, key: StdTypeId, value: V) -> Option<V> {
self.inner.insert(key, value)
}
#[cfg(not(feature = "std"))]
pub fn insert(&mut self, key: StdTypeId, value: V) -> Option<V> {
for entry in &mut self.inner {
if entry.0 == key {
let old = core::mem::replace(&mut entry.1, value);
return Some(old);
}
}
self.inner.push((key, value));
None
}
pub fn len(&self) -> usize {
self.inner.len()
}
pub fn is_empty(&self) -> bool {
self.inner.is_empty()
}
}
impl<V> Default for TypeIdMap<V> {
fn default() -> Self {
Self::new()
}
}
/// A set of `TypeId` values.
/// std: HashSet<TypeId>. no_std: linear-scan Vec.
#[derive(Clone, Default)]
pub struct TypeIdSet {
#[cfg(feature = "std")]
inner: std::collections::HashSet<StdTypeId>,
#[cfg(not(feature = "std"))]
inner: Vec<StdTypeId>,
}
impl TypeIdSet {
#[allow(dead_code)]
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, id: StdTypeId) -> bool {
#[cfg(feature = "std")]
{ self.inner.insert(id) }
#[cfg(not(feature = "std"))]
{
if self.inner.contains(&id) {
false
} else {
self.inner.push(id);
true
}
}
}
pub fn contains(&self, id: &StdTypeId) -> bool {
self.inner.contains(id)
}
pub fn len(&self) -> usize {
self.inner.len()
}
}
/// A set of `(TypeId, ActorAddress)` pairs.
/// Used for typed send capabilities. std: HashSet. no_std: linear-scan Vec.
#[derive(Clone, Default)]
pub struct TypedAddrSet {
#[cfg(feature = "std")]
inner: std::collections::HashSet<(StdTypeId, crate::actor::ActorAddress)>,
#[cfg(not(feature = "std"))]
inner: Vec<(StdTypeId, crate::actor::ActorAddress)>,
}
impl TypedAddrSet {
#[allow(dead_code)]
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, pair: (StdTypeId, crate::actor::ActorAddress)) -> bool {
#[cfg(feature = "std")]
{ self.inner.insert(pair) }
#[cfg(not(feature = "std"))]
{
if self.inner.contains(&pair) {
false
} else {
self.inner.push(pair);
true
}
}
}
pub fn contains(&self, pair: &(StdTypeId, crate::actor::ActorAddress)) -> bool {
self.inner.contains(pair)
}
pub fn len(&self) -> usize {
self.inner.len()
}
}

View file

@ -1,11 +1,4 @@
/// What to do when a bounded mailbox is full.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MailboxOverflow {
/// Drop the incoming message (newest). The message is silently discarded.
DropNewest,
/// Drop the oldest message in the queue to make room for the new one.
DropOldest,
}
pub use swactor_core::MailboxOverflow;
/// The tunable settings for the runtime.
pub struct RuntimeConfig {

View file

@ -1,8 +1,16 @@
use std::any::Any;
use std::collections::{HashMap, HashSet};
use std::hash::{BuildHasher, Hasher};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, OnceLock, RwLock};
#[cfg(not(feature = "std"))]
use crate::compat::{Box, Vec};
use core::any::Any;
#[cfg(feature = "std")]
use core::hash::{BuildHasher, Hasher};
#[cfg(feature = "std")]
use std::collections::HashMap;
use core::sync::atomic::{AtomicUsize, Ordering};
use crate::compat::{Arc, RwLock};
#[cfg(feature = "std")]
use crate::compat::OnceLock;
#[cfg(feature = "std")]
use std::thread::Thread;
use crate::actor::{ActorAddress, Message, SpawnRequest};
@ -21,8 +29,10 @@ use crate::Error;
///
/// This is safe because the input is already random (uniform distribution),
/// so additional mixing would be redundant.
#[cfg(feature = "std")]
pub struct AddrHasher(u64);
#[cfg(feature = "std")]
impl Hasher for AddrHasher {
#[inline]
fn finish(&self) -> u64 {
@ -41,9 +51,11 @@ impl Hasher for AddrHasher {
}
/// BuildHasher for creating AddrHasher instances.
#[cfg(feature = "std")]
#[derive(Default, Clone)]
pub struct AddrBuildHasher;
#[cfg(feature = "std")]
impl BuildHasher for AddrBuildHasher {
type Hasher = AddrHasher;
@ -55,10 +67,18 @@ impl BuildHasher for AddrBuildHasher {
/// HashMap optimized for ActorAddress keys.
/// Uses identity hashing since ActorAddress bytes are already random.
/// In no_std: BTreeMap using ActorAddress's Ord impl.
#[cfg(feature = "std")]
pub type AddrMap<V> = HashMap<ActorAddress, V, AddrBuildHasher>;
#[cfg(not(feature = "std"))]
pub type AddrMap<V> = alloc::collections::BTreeMap<ActorAddress, V>;
/// HashSet optimized for ActorAddress keys.
pub type AddrSet = HashSet<ActorAddress, AddrBuildHasher>;
/// In no_std: BTreeSet using ActorAddress's Ord impl.
#[cfg(feature = "std")]
pub type AddrSet = std::collections::HashSet<ActorAddress, AddrBuildHasher>;
#[cfg(not(feature = "std"))]
pub type AddrSet = alloc::collections::BTreeSet<ActorAddress>;
// ─── Address Map Types ───────────────────────────────────────────────────────
@ -80,12 +100,20 @@ pub(crate) struct AddressMap {
}
impl AddressMap {
#[cfg(feature = "std")]
pub fn with_capacity(cap: usize) -> Self {
Self {
inner: RwLock::new(HashMap::with_capacity_and_hasher(cap, AddrBuildHasher)),
}
}
#[cfg(not(feature = "std"))]
pub fn with_capacity(_cap: usize) -> Self {
Self {
inner: RwLock::new(AddrMap::new()),
}
}
pub fn insert(&self, addr: ActorAddress, worker: WorkerId) {
self.inner.write().unwrap().insert(addr, worker);
}
@ -200,18 +228,26 @@ pub(crate) struct InboxRegistry {
}
impl InboxRegistry {
#[cfg(feature = "std")]
pub fn new() -> Self {
Self {
senders: RwLock::new(HashMap::with_hasher(AddrBuildHasher)),
}
}
#[cfg(not(feature = "std"))]
pub fn new() -> Self {
Self {
senders: RwLock::new(AddrMap::new()),
}
}
pub fn register(&self, addr: ActorAddress, sender: Arc<dyn SenderT>) {
self.senders.write().unwrap().insert(addr, sender);
}
/// Check if an address is registered without consuming a message.
#[cfg(feature = "transport")]
#[cfg(all(feature = "transport", feature = "std"))]
pub fn contains(&self, addr: &ActorAddress) -> bool {
self.senders.read().unwrap().contains_key(addr)
}
@ -242,14 +278,16 @@ pub(crate) struct TickContext<'a> {
pub(crate) extension: Option<&'a dyn crate::extension::RuntimeExtension>,
pub(crate) stats_hook: Option<&'a dyn crate::stats::StatsHook>,
/// Thread handles for waking parked workers on cross-worker sends.
#[cfg(feature = "std")]
pub(crate) worker_threads: &'a [OnceLock<Thread>],
/// Per-worker stats for summing total_actors across workers.
pub(crate) worker_stats: &'a [Arc<WorkerStats>],
/// Runtime creation time for computing uptime_ms.
#[cfg(feature = "std")]
pub(crate) created_at: crate::Instant,
#[cfg(feature = "transport")]
#[cfg(all(feature = "transport", feature = "std"))]
pub(crate) codec_registry: Option<&'a crate::transport::CodecRegistry>,
#[cfg(feature = "transport")]
#[cfg(all(feature = "transport", feature = "std"))]
pub(crate) transport_router: Option<&'a crate::transport::TransportRouter>,
}
@ -261,7 +299,7 @@ impl<'a> TickContext<'a> {
addr: ActorAddress,
msg: Box<dyn Any + Send>,
) -> Result<(), Error> {
#[cfg(feature = "transport")]
#[cfg(all(feature = "transport", feature = "std"))]
{
if self.inbox_registry.contains(&addr) {
return self.inbox_registry.try_deliver(addr, msg);

View file

@ -1,3 +1,6 @@
#[cfg(not(feature = "std"))]
use crate::compat::{Box, String, ToString};
/// Simple, ergonomic, local `Error` type.
/// # Usage
/// ```
@ -13,16 +16,29 @@
/// }
/// ```
#[derive(Debug)]
pub struct Error(Box<dyn std::error::Error + Send + Sync + 'static>);
pub struct Error(Box<dyn core::error::Error + Send + Sync + 'static>);
/// Small wrapper so `String` can become `Box<dyn core::error::Error>` in both
/// std and no_std (where String doesn't auto-impl core::error::Error via blanket).
#[derive(Debug)]
struct StringError(String);
impl core::fmt::Display for StringError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(&self.0)
}
}
impl core::error::Error for StringError {}
impl<T: AsRef<str>> From<T> for Error {
fn from(value: T) -> Self {
Error(value.as_ref().to_string().into())
Error(Box::new(StringError(value.as_ref().to_string())))
}
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
impl core::fmt::Display for Error {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "{}", self.0)
}
}

View file

@ -1,4 +1,7 @@
use std::any::Any;
#[cfg(not(feature = "std"))]
use crate::compat::{Box, Vec};
use core::any::Any;
use crate::actor::{ActorAddress, Environment, ExitValue, StopReason};

View file

@ -1,3 +1,12 @@
#![cfg_attr(not(feature = "std"), no_std)]
#[cfg(not(feature = "std"))]
extern crate alloc;
pub(crate) mod compat;
#[cfg(not(feature = "std"))]
pub(crate) mod sync_impl;
pub mod actor;
pub mod extension;
pub mod worker;
@ -10,7 +19,9 @@ pub(crate) mod error;
pub use error::Error;
// Re-export identity hashing types for ActorAddress-keyed collections.
pub use delivery::{AddrBuildHasher, AddrMap, AddrSet};
#[cfg(feature = "std")]
pub use delivery::AddrBuildHasher;
pub use delivery::{AddrMap, AddrSet};
pub mod config;
pub(crate) mod delivery;
@ -18,7 +29,7 @@ pub mod stats;
pub mod runtime;
#[cfg(feature = "transport")]
#[cfg(all(feature = "transport", feature = "std"))]
pub mod transport;
#[cfg(feature = "std")]
@ -26,9 +37,10 @@ pub mod std;
// Platform-aware Instant: web_time on wasm, std::time on native.
// web_time is a no-op re-export of std::time::Instant on non-wasm targets.
// In no_std (without wasm), Instant is not available — timing returns 0.
#[cfg(feature = "wasm")]
pub(crate) use web_time::Instant;
#[cfg(not(feature = "wasm"))]
#[cfg(all(not(feature = "wasm"), feature = "std"))]
pub(crate) use ::std::time::Instant;
#[cfg(feature = "getrandom")]
@ -36,7 +48,7 @@ pub(crate) fn get_random(buf: &mut [u8]) {
getrandom::getrandom(buf).unwrap()
}
#[cfg(all(feature = "no_random", not(feature = "getrandom")))]
#[cfg(not(feature = "getrandom"))]
pub(crate) fn get_random(buf: &mut [u8]) {
use core::sync::atomic::{AtomicUsize, Ordering};

View file

@ -1,11 +1,16 @@
use std::any::Any;
use std::cell::RefCell;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
#[cfg(not(target_arch = "wasm32"))]
#[cfg(not(feature = "std"))]
use crate::compat::{Box, Vec};
use core::any::Any;
use core::cell::RefCell;
use core::sync::atomic::{AtomicBool, Ordering};
use crate::compat::Arc;
#[cfg(feature = "std")]
use crate::compat::OnceLock;
#[cfg(all(not(target_arch = "wasm32"), feature = "std"))]
use std::thread::{self, JoinHandle};
#[cfg(feature = "std")]
use std::thread::Thread;
use crate::Instant;
use crate::actor::{Actor, ActorAddress, ActorInterface, AnyActor, Environment, ExitValue, Message, ResumeSignal, SpawnRequest, StopSignal, StopWithSignal, SystemInfo};
use crate::channel::{Receiver, Sender};
@ -69,13 +74,13 @@ impl<R: Message> Ask<R> {
}
/// Handle for dealing with a runtime that has started via the `Runtime::run()` method.
#[cfg(not(target_arch = "wasm32"))]
#[cfg(all(not(target_arch = "wasm32"), feature = "std"))]
pub struct RuntimeHandle {
pub runtime: Arc<Runtime>,
threads: Vec<JoinHandle<()>>,
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(all(not(target_arch = "wasm32"), feature = "std"))]
impl RuntimeHandle {
pub fn join(self) {
for handle in self.threads {
@ -110,11 +115,13 @@ pub struct Runtime {
/// Workers available for tick(). run() drains this and moves workers to threads.
tick_workers: RefCell<Vec<Worker>>,
/// Thread handles for waking parked workers. Set by workers on startup via OnceLock.
#[cfg(feature = "std")]
worker_threads: Arc<Vec<OnceLock<Thread>>>,
created_at: Instant,
#[cfg(feature = "transport")]
#[cfg(feature = "std")]
created_at: crate::Instant,
#[cfg(all(feature = "transport", feature = "std"))]
codec_registry: Option<Arc<crate::transport::CodecRegistry>>,
#[cfg(feature = "transport")]
#[cfg(all(feature = "transport", feature = "std"))]
transport_router: Option<Arc<crate::transport::TransportRouter>>,
}
@ -129,8 +136,8 @@ unsafe impl Sync for Runtime {}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RuntimeAddress(pub [u8; 32]);
impl std::fmt::Display for RuntimeAddress {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
impl core::fmt::Display for RuntimeAddress {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
for b in &self.0[..8] {
write!(f, "{:02x}", b)?;
}
@ -155,6 +162,7 @@ impl RuntimeAddress {
pub struct ExternalSender {
address_map: Arc<AddressMap>,
transfer_txs: Vec<Sender<Envelope>>,
#[cfg(feature = "std")]
worker_threads: Arc<Vec<OnceLock<Thread>>>,
}
@ -163,6 +171,7 @@ impl Clone for ExternalSender {
Self {
address_map: self.address_map.clone(),
transfer_txs: self.transfer_txs.clone(),
#[cfg(feature = "std")]
worker_threads: self.worker_threads.clone(),
}
}
@ -182,6 +191,7 @@ impl ExternalSender {
Some(wid) => {
self.transfer_txs[wid.as_usize()]
.send(Envelope::new(addr, Box::new(msg)));
#[cfg(feature = "std")]
notify_worker(&self.worker_threads, wid.as_usize());
Ok(())
}
@ -232,6 +242,7 @@ impl Runtime {
let placement = Placement::new(num_workers, worker_stats.clone());
#[cfg(feature = "std")]
let worker_threads: Arc<Vec<OnceLock<Thread>>> =
Arc::new((0..num_workers).map(|_| OnceLock::new()).collect());
@ -247,11 +258,13 @@ impl Runtime {
worker_stats,
stats_hook: None,
tick_workers: RefCell::new(workers),
#[cfg(feature = "std")]
worker_threads,
created_at: Instant::now(),
#[cfg(feature = "transport")]
#[cfg(feature = "std")]
created_at: crate::Instant::now(),
#[cfg(all(feature = "transport", feature = "std"))]
codec_registry: None,
#[cfg(feature = "transport")]
#[cfg(all(feature = "transport", feature = "std"))]
transport_router: None,
};
@ -369,6 +382,7 @@ impl Runtime {
ExternalSender {
address_map: self.address_map.clone(),
transfer_txs: self.transfer_txs.iter().cloned().collect(),
#[cfg(feature = "std")]
worker_threads: self.worker_threads.clone(),
}
}
@ -383,12 +397,14 @@ impl Runtime {
config: &self.config,
extension: self.extension.as_deref(),
stats_hook: self.stats_hook.as_deref(),
#[cfg(feature = "std")]
worker_threads: &self.worker_threads,
worker_stats: &self.worker_stats,
#[cfg(feature = "std")]
created_at: self.created_at,
#[cfg(feature = "transport")]
#[cfg(all(feature = "transport", feature = "std"))]
codec_registry: self.codec_registry.as_deref(),
#[cfg(feature = "transport")]
#[cfg(all(feature = "transport", feature = "std"))]
transport_router: self.transport_router.as_deref(),
}
}
@ -414,7 +430,7 @@ impl Runtime {
/// In single-threaded mode, one background thread is spawned.
///
/// Not available on wasm32 — use the browser crate's Web Worker-based run instead.
#[cfg(not(target_arch = "wasm32"))]
#[cfg(all(not(target_arch = "wasm32"), feature = "std"))]
pub fn run(self) -> Result<RuntimeHandle, Error> {
self.is_running.store(true, Ordering::Release);
@ -464,7 +480,10 @@ impl Runtime {
.map(|ws| ws.drain_tick_timings())
.collect();
#[cfg(feature = "std")]
let uptime_ms = self.created_at.elapsed().as_millis() as u64;
#[cfg(not(feature = "std"))]
let uptime_ms = 0u64;
RuntimeStats { num_workers, uptime_ms, actors, workers, actor_details: Vec::new(), tick_timings }
}
@ -480,6 +499,7 @@ impl Runtime {
Some(wid) => {
self.transfer_txs[wid.as_usize()]
.send(Envelope::new(addr, Box::new(StopSignal)));
#[cfg(feature = "std")]
notify_worker(&self.worker_threads, wid.as_usize());
Ok(())
}
@ -494,9 +514,12 @@ impl Runtime {
self.is_running.store(false, Ordering::Release);
// Wake all parked workers so they see the shutdown flag immediately
for thread in self.worker_threads.iter() {
if let Some(t) = thread.get() {
t.unpark();
#[cfg(feature = "std")]
{
for thread in self.worker_threads.iter() {
if let Some(t) = thread.get() {
t.unpark();
}
}
}
}
@ -509,13 +532,13 @@ impl Runtime {
}
/// Set the codec registry for remote transport.
#[cfg(feature = "transport")]
#[cfg(all(feature = "transport", feature = "std"))]
pub fn set_codec_registry(&mut self, registry: Arc<crate::transport::CodecRegistry>) {
self.codec_registry = Some(registry);
}
/// Set the transport router for remote message delivery.
#[cfg(feature = "transport")]
#[cfg(all(feature = "transport", feature = "std"))]
pub fn set_transport_router(&mut self, router: Arc<crate::transport::TransportRouter>) {
self.transport_router = Some(router);
}
@ -524,7 +547,7 @@ impl Runtime {
///
/// Used by [`CodecRegistry::receive`](crate::transport::CodecRegistry::receive)
/// to inject incoming messages from remote runtimes.
#[cfg(feature = "transport")]
#[cfg(all(feature = "transport", feature = "std"))]
pub fn deliver_raw(
&self,
addr: ActorAddress,
@ -543,6 +566,7 @@ impl Runtime {
/// Wake a parked worker thread so it can process new work.
/// No-op if the thread handle hasn't been registered yet (single-threaded tick mode).
#[cfg(feature = "std")]
#[inline]
pub(crate) fn notify_worker(threads: &[OnceLock<Thread>], wid: usize) {
if let Some(t) = threads.get(wid).and_then(|o| o.get()) {
@ -557,6 +581,7 @@ impl ContextInner for Runtime {
Some(wid) => {
self.transfer_txs[wid.as_usize()]
.send(Envelope::new(addr, msg));
#[cfg(feature = "std")]
notify_worker(&self.worker_threads, wid.as_usize());
Ok(())
}
@ -569,6 +594,7 @@ impl ContextInner for Runtime {
self.address_map.insert(request.addr, worker_id);
self.spawn_txs[worker_id.as_usize()]
.send(request);
#[cfg(feature = "std")]
notify_worker(&self.worker_threads, worker_id.as_usize());
}
@ -577,6 +603,7 @@ impl ContextInner for Runtime {
if let Some(wid) = self.address_map.lookup(&addr) {
self.transfer_txs[wid.as_usize()]
.send(Envelope::new(addr, Box::new(StopSignal)));
#[cfg(feature = "std")]
notify_worker(&self.worker_threads, wid.as_usize());
}
}
@ -585,19 +612,22 @@ impl ContextInner for Runtime {
if let Some(wid) = self.address_map.lookup(&addr) {
self.transfer_txs[wid.as_usize()]
.send(Envelope::new(addr, Box::new(StopWithSignal(value))));
#[cfg(feature = "std")]
notify_worker(&self.worker_threads, wid.as_usize());
}
}
fn request_suspend(&self, addr: ActorAddress) {
fn request_suspend(&self, _addr: ActorAddress) {
// Outside worker context — not supported (suspend is per-actor, from handler)
eprintln!("swactor: request_suspend called outside worker context for {addr} — ignored");
#[cfg(feature = "std")]
eprintln!("swactor: request_suspend called outside worker context for {_addr} — ignored");
}
fn request_resume(&self, addr: ActorAddress) {
if let Some(wid) = self.address_map.lookup(&addr) {
self.transfer_txs[wid.as_usize()]
.send(Envelope::new(addr, Box::new(ResumeSignal)));
#[cfg(feature = "std")]
notify_worker(&self.worker_threads, wid.as_usize());
}
}
@ -605,6 +635,7 @@ impl ContextInner for Runtime {
fn post_worker_request(&self, _request: Box<dyn Any + Send>) {
// Worker requests (e.g., timers) are per-worker; posting from outside
// a worker context (e.g., rt.spawn() callback) is not supported.
#[cfg(feature = "std")]
eprintln!("swactor: post_worker_request called outside worker context — ignored");
}
@ -617,11 +648,15 @@ impl ContextInner for Runtime {
let total_actors: usize = self.worker_stats.iter()
.map(|ws| ws.num_actors.load(Ordering::Relaxed))
.sum();
#[cfg(feature = "std")]
let uptime_ms = self.created_at.elapsed().as_millis() as u64;
#[cfg(not(feature = "std"))]
let uptime_ms = 0u64;
SystemInfo {
worker_id: 0,
num_workers,
total_actors,
uptime_ms: self.created_at.elapsed().as_millis() as u64,
uptime_ms,
}
}
}

View file

@ -1,4 +1,7 @@
use std::sync::atomic::{AtomicU64, AtomicUsize};
#[cfg(not(feature = "std"))]
use crate::compat::{String, Vec};
use core::sync::atomic::{AtomicU64, AtomicUsize};
use crossbeam_queue::ArrayQueue;
@ -83,7 +86,7 @@ impl WorkerStats {
/// Create a point-in-time snapshot as a [`WorkerInfo`].
pub fn snapshot(&self, id: usize) -> WorkerInfo {
use std::sync::atomic::Ordering::Relaxed;
use core::sync::atomic::Ordering::Relaxed;
WorkerInfo {
id,
num_actors: self.num_actors.load(Relaxed),

230
src/sync_impl.rs Normal file
View file

@ -0,0 +1,230 @@
//! Spin-based sync primitives for `#[cfg(not(feature = "std"))]`.
//!
//! These are minimal implementations sufficient for the swactor runtime
//! in single-threaded cooperative (no_std) environments. They use atomic
//! spin loops and are NOT suitable for highly contended multi-threaded use.
#![allow(dead_code)]
use core::cell::UnsafeCell;
use core::ops::{Deref, DerefMut};
use core::sync::atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering};
// ─── SpinMutex ──────────────────────────────────────────────────────────────
/// A simple spin-lock mutex.
///
/// Returns `Result<Guard, E>` from `lock()` to match `std::sync::Mutex` API
/// (the codebase calls `.lock().unwrap()`). The `Err` variant is never produced.
pub struct SpinMutex<T> {
locked: AtomicBool,
data: UnsafeCell<T>,
}
unsafe impl<T: Send> Send for SpinMutex<T> {}
unsafe impl<T: Send> Sync for SpinMutex<T> {}
impl<T> SpinMutex<T> {
pub const fn new(value: T) -> Self {
Self {
locked: AtomicBool::new(false),
data: UnsafeCell::new(value),
}
}
pub fn lock(&self) -> Result<SpinMutexGuard<'_, T>, core::convert::Infallible> {
while self
.locked
.compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_err()
{
core::hint::spin_loop();
}
Ok(SpinMutexGuard { lock: self })
}
}
pub struct SpinMutexGuard<'a, T> {
lock: &'a SpinMutex<T>,
}
impl<T> Deref for SpinMutexGuard<'_, T> {
type Target = T;
fn deref(&self) -> &T {
unsafe { &*self.lock.data.get() }
}
}
impl<T> DerefMut for SpinMutexGuard<'_, T> {
fn deref_mut(&mut self) -> &mut T {
unsafe { &mut *self.lock.data.get() }
}
}
impl<T> Drop for SpinMutexGuard<'_, T> {
fn drop(&mut self) {
self.lock.locked.store(false, Ordering::Release);
}
}
// ─── SpinRwLock ─────────────────────────────────────────────────────────────
/// A simple spin-lock reader-writer lock.
///
/// State encoding in `state: AtomicUsize`:
/// - 0 = unlocked
/// - 1..usize::MAX-1 = N active readers
/// - usize::MAX = writer holds the lock
const WRITER: usize = usize::MAX;
pub struct SpinRwLock<T> {
state: AtomicUsize,
data: UnsafeCell<T>,
}
unsafe impl<T: Send> Send for SpinRwLock<T> {}
unsafe impl<T: Send + Sync> Sync for SpinRwLock<T> {}
impl<T> SpinRwLock<T> {
pub const fn new(value: T) -> Self {
Self {
state: AtomicUsize::new(0),
data: UnsafeCell::new(value),
}
}
pub fn read(&self) -> Result<SpinRwLockReadGuard<'_, T>, core::convert::Infallible> {
loop {
let s = self.state.load(Ordering::Relaxed);
if s == WRITER {
core::hint::spin_loop();
continue;
}
if self
.state
.compare_exchange_weak(s, s + 1, Ordering::Acquire, Ordering::Relaxed)
.is_ok()
{
return Ok(SpinRwLockReadGuard { lock: self });
}
core::hint::spin_loop();
}
}
pub fn write(&self) -> Result<SpinRwLockWriteGuard<'_, T>, core::convert::Infallible> {
loop {
if self
.state
.compare_exchange_weak(0, WRITER, Ordering::Acquire, Ordering::Relaxed)
.is_ok()
{
return Ok(SpinRwLockWriteGuard { lock: self });
}
core::hint::spin_loop();
}
}
}
pub struct SpinRwLockReadGuard<'a, T> {
lock: &'a SpinRwLock<T>,
}
impl<T> Deref for SpinRwLockReadGuard<'_, T> {
type Target = T;
fn deref(&self) -> &T {
unsafe { &*self.lock.data.get() }
}
}
impl<T> Drop for SpinRwLockReadGuard<'_, T> {
fn drop(&mut self) {
self.lock.state.fetch_sub(1, Ordering::Release);
}
}
pub struct SpinRwLockWriteGuard<'a, T> {
lock: &'a SpinRwLock<T>,
}
impl<T> Deref for SpinRwLockWriteGuard<'_, T> {
type Target = T;
fn deref(&self) -> &T {
unsafe { &*self.lock.data.get() }
}
}
impl<T> DerefMut for SpinRwLockWriteGuard<'_, T> {
fn deref_mut(&mut self) -> &mut T {
unsafe { &mut *self.lock.data.get() }
}
}
impl<T> Drop for SpinRwLockWriteGuard<'_, T> {
fn drop(&mut self) {
self.lock.state.store(0, Ordering::Release);
}
}
// ─── SpinOnceLock ───────────────────────────────────────────────────────────
const EMPTY: u8 = 0;
const INITIALIZING: u8 = 1;
const READY: u8 = 2;
/// A spin-based `OnceLock` matching the `std::sync::OnceLock` API surface.
pub struct SpinOnceLock<T> {
state: AtomicU8,
data: UnsafeCell<Option<T>>,
}
unsafe impl<T: Send + Sync> Send for SpinOnceLock<T> {}
unsafe impl<T: Send + Sync> Sync for SpinOnceLock<T> {}
impl<T> SpinOnceLock<T> {
pub const fn new() -> Self {
Self {
state: AtomicU8::new(EMPTY),
data: UnsafeCell::new(None),
}
}
pub fn get(&self) -> Option<&T> {
if self.state.load(Ordering::Acquire) == READY {
unsafe { (*self.data.get()).as_ref() }
} else {
None
}
}
pub fn set(&self, value: T) -> Result<(), T> {
if self
.state
.compare_exchange(EMPTY, INITIALIZING, Ordering::Acquire, Ordering::Relaxed)
.is_ok()
{
unsafe { *self.data.get() = Some(value) };
self.state.store(READY, Ordering::Release);
Ok(())
} else {
Err(value)
}
}
pub fn get_or_init<F: FnOnce() -> T>(&self, f: F) -> &T {
if self.state.load(Ordering::Acquire) == READY {
return unsafe { (*self.data.get()).as_ref().unwrap() };
}
if self
.state
.compare_exchange(EMPTY, INITIALIZING, Ordering::Acquire, Ordering::Relaxed)
.is_ok()
{
unsafe { *self.data.get() = Some(f()) };
self.state.store(READY, Ordering::Release);
} else {
while self.state.load(Ordering::Acquire) != READY {
core::hint::spin_loop();
}
}
unsafe { (*self.data.get()).as_ref().unwrap() }
}
}

View file

@ -8,7 +8,7 @@
//! - **[`Codec<M>`]**: HOW bytes are encoded — gRPC/protobuf, bincode, custom, etc.
//! - **[`Transport`]**: WHERE bytes are sent — in-memory, gRPC channel, etc.
use std::any::{Any, TypeId};
use core::any::{Any, TypeId};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};

View file

@ -1,15 +1,22 @@
use std::any::Any;
use std::cell::RefCell;
use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
#[cfg(not(feature = "std"))]
use crate::compat::{Box, Vec};
use core::any::Any;
use core::cell::RefCell;
use core::sync::atomic::Ordering;
#[cfg(feature = "std")]
use core::sync::atomic::AtomicBool;
use crate::compat::{Arc, HashMap, VecDeque};
#[cfg(feature = "std")]
use std::thread;
#[cfg(feature = "std")]
use crate::Instant;
use crate::actor::{ActorAddress, AnyActor, ContextInner, Ctx, Environment, ExitValue, ResumeSignal, SpawnRequest, StopReason, StopSignal, StopWithSignal, SystemInfo};
use crate::channel::Receiver;
use crate::config::MailboxOverflow;
use crate::delivery::{AddrBuildHasher, AddrMap, Envelope, TickContext, WorkerId};
use crate::delivery::{AddrMap, Envelope, TickContext, WorkerId};
use swactor_core::{ActorPhase, LifecycleEvent, lifecycle_transition};
use crate::stats::{ActorSnapshot, TickTiming, WorkerStats};
use crate::Error;
@ -29,6 +36,7 @@ fn route_to_pool_or_remote(
match tc.address_map.lookup(&dest) {
Some(wid) => {
tc.transfer_txs[wid.as_usize()].send(Envelope::new(dest, msg));
#[cfg(feature = "std")]
crate::runtime::notify_worker(tc.worker_threads, wid.as_usize());
}
None => {
@ -86,7 +94,11 @@ impl Worker {
let mut spawn_count: usize = 0;
while let Some(mut req) = self.spawn_rx.try_recv() {
if let Some(ext) = tc.extension {
req.env = ext.on_spawn(req.addr, req.parent, req.env, tc.created_at.elapsed().as_millis() as u64);
#[cfg(feature = "std")]
let uptime_ms = tc.created_at.elapsed().as_millis() as u64;
#[cfg(not(feature = "std"))]
let uptime_ms = 0u64;
req.env = ext.on_spawn(req.addr, req.parent, req.env, uptime_ms);
}
self.pool.insert(req);
#[cfg(feature = "tracing")]
@ -169,10 +181,12 @@ impl Worker {
}
let mut did_work = false;
#[cfg(feature = "std")]
let t0 = Instant::now();
// 1. Drain spawn queue → add actors to pool
did_work |= self.drain_spawns(tc);
#[cfg(feature = "std")]
let t1 = Instant::now();
// 2. Drain transfer queue → deliver envelopes to actors
@ -182,6 +196,7 @@ impl Worker {
self.pool.deliver(&dest, payload);
did_work = true;
}
#[cfg(feature = "std")]
let t2 = Instant::now();
// 2.5. Fire per-worker extension (e.g., timers) → deliver before tick_all
@ -218,6 +233,7 @@ impl Worker {
did_work = true;
}
}
#[cfg(feature = "std")]
let t3 = Instant::now();
#[cfg(feature = "tracing")]
@ -232,6 +248,7 @@ impl Worker {
// 4. Drain spawn queue again — actors spawned during step 3
// must be in the pool before pending_local delivery.
did_work |= self.drain_spawns(tc);
#[cfg(feature = "std")]
let t4 = Instant::now();
// 5. Drain pending_local buffer → deliver to local actors
@ -250,6 +267,7 @@ impl Worker {
}
}
#[cfg(feature = "std")]
let t5 = Instant::now();
// 6. Publish stats (skip entirely when idle to avoid allocation + mutex)
@ -268,10 +286,12 @@ impl Worker {
}
}
#[cfg(feature = "std")]
let t6 = Instant::now();
// Record tick timing
let timing = TickTiming {
#[cfg(feature = "std")]
phase_us: [
t1.duration_since(t0).as_micros() as u64,
t2.duration_since(t1).as_micros() as u64,
@ -280,6 +300,8 @@ impl Worker {
t5.duration_since(t4).as_micros() as u64,
t6.duration_since(t5).as_micros() as u64,
],
#[cfg(not(feature = "std"))]
phase_us: [0; 6],
messages_processed: processed,
did_work,
};
@ -303,6 +325,7 @@ impl Worker {
did_work
}
#[cfg(feature = "std")]
pub(crate) fn run(&mut self, tc: &TickContext, is_running: &AtomicBool) {
#[cfg(feature = "tracing")]
let _span = tracing::info_span!("worker.run", worker_id = self.id.0).entered();
@ -343,6 +366,7 @@ impl ContextInner for WorkerContext<'_> {
Some(wid) => {
self.stats.cross_sends.fetch_add(1, Ordering::Relaxed);
self.tc.transfer_txs[wid.as_usize()].send(Envelope::new(addr, msg));
#[cfg(feature = "std")]
crate::runtime::notify_worker(self.tc.worker_threads, wid.as_usize());
Ok(())
}
@ -358,6 +382,7 @@ impl ContextInner for WorkerContext<'_> {
self.tc.address_map.insert(request.addr, worker_id);
self.tc.spawn_txs[worker_id.as_usize()]
.send(request);
#[cfg(feature = "std")]
crate::runtime::notify_worker(self.tc.worker_threads, worker_id.as_usize());
}
@ -392,11 +417,15 @@ impl ContextInner for WorkerContext<'_> {
let total_actors: usize = self.tc.worker_stats.iter()
.map(|ws| ws.num_actors.load(Ordering::Relaxed))
.sum();
#[cfg(feature = "std")]
let uptime_ms = self.tc.created_at.elapsed().as_millis() as u64;
#[cfg(not(feature = "std"))]
let uptime_ms = 0u64;
SystemInfo {
worker_id: self.worker_id.0,
num_workers,
total_actors,
uptime_ms: self.tc.created_at.elapsed().as_millis() as u64,
uptime_ms,
}
}
}
@ -404,13 +433,8 @@ impl ContextInner for WorkerContext<'_> {
struct ActorSlot {
mailbox: VecDeque<Box<dyn Any + Send>>,
actor: Box<dyn AnyActor>,
poisoned: bool,
/// Graceful stop requested (via StopSignal).
stopping: bool,
/// Whether on_start has been called for this actor.
started: bool,
/// Actor is suspended — messages queue but are not processed.
suspended: bool,
/// Lifecycle phase — replaces the old `started`, `poisoned`, `stopping`, `suspended` booleans.
phase: ActorPhase,
last_msg_type: Option<&'static str>,
messages_processed: u64,
/// Per-message-type counters (bounded to 32 entries).
@ -438,7 +462,10 @@ pub(crate) struct ActorPool {
impl ActorPool {
pub fn new(default_mailbox_capacity: usize, default_overflow_policy: MailboxOverflow) -> Self {
Self {
actors: HashMap::with_hasher(AddrBuildHasher),
#[cfg(feature = "std")]
actors: std::collections::HashMap::with_hasher(crate::delivery::AddrBuildHasher),
#[cfg(not(feature = "std"))]
actors: AddrMap::new(),
default_mailbox_capacity,
default_overflow_policy,
drops_this_tick: 0,
@ -451,10 +478,7 @@ impl ActorPool {
self.actors.insert(req.addr, ActorSlot {
mailbox: VecDeque::with_capacity(prealloc),
actor: req.actor,
poisoned: false,
stopping: false,
started: false,
suspended: false,
phase: ActorPhase::Unstarted,
last_msg_type: None,
messages_processed: 0,
msg_type_counts: HashMap::new(),
@ -472,13 +496,13 @@ impl ActorPool {
if let Some(slot) = self.actors.get_mut(addr) {
// Intercept control signals for suspended actors: they skip tick_all
// so we must handle resume/stop at delivery time.
if slot.suspended {
if slot.phase == ActorPhase::Suspended {
if msg.is::<ResumeSignal>() {
slot.suspended = false;
slot.phase = lifecycle_transition(slot.phase, LifecycleEvent::Resumed);
return true;
}
if msg.is::<StopSignal>() {
slot.stopping = true;
slot.phase = lifecycle_transition(slot.phase, LifecycleEvent::StopRequested);
slot.mailbox.clear();
return true;
}
@ -486,21 +510,20 @@ impl ActorPool {
if let Ok(sig) = msg.downcast::<StopWithSignal>() {
slot.exit_value = Some(sig.0);
}
slot.stopping = true;
slot.phase = lifecycle_transition(slot.phase, LifecycleEvent::StopWithRequested);
slot.mailbox.clear();
return true;
}
}
if slot.mailbox_capacity > 0 && slot.mailbox.len() >= slot.mailbox_capacity {
match slot.overflow_policy {
MailboxOverflow::DropNewest => {
self.drops_this_tick += 1;
return true;
}
MailboxOverflow::DropOldest => {
slot.mailbox.pop_front();
self.drops_this_tick += 1;
}
match swactor_core::mailbox_accept(slot.mailbox.len(), slot.mailbox_capacity, slot.overflow_policy) {
swactor_core::MailboxDecision::Accept => {}
swactor_core::MailboxDecision::RejectNewest => {
self.drops_this_tick += 1;
return true;
}
swactor_core::MailboxDecision::EvictOldest => {
slot.mailbox.pop_front();
self.drops_this_tick += 1;
}
}
slot.mailbox.push_back(msg);
@ -512,7 +535,7 @@ impl ActorPool {
/// Take and reset the drop counter for this tick.
pub fn take_drops(&mut self) -> usize {
std::mem::replace(&mut self.drops_this_tick, 0)
core::mem::replace(&mut self.drops_this_tick, 0)
}
/// Tick all actors in the pool. Returns the number of messages processed.
@ -530,14 +553,11 @@ impl ActorPool {
) -> usize {
let mut count = 0;
for (&addr, slot) in self.actors.iter_mut() {
if slot.poisoned || slot.stopping {
// Discard all messages for poisoned/stopping actors
slot.mailbox.clear();
continue;
}
// Skip suspended actors — messages keep queueing
if slot.suspended {
if !swactor_core::should_tick_actor(slot.phase) {
// Discard mailbox for poisoned/stopping; suspended keeps queueing
if slot.phase == ActorPhase::Poisoned || slot.phase == ActorPhase::Stopping {
slot.mailbox.clear();
}
continue;
}
@ -554,26 +574,30 @@ impl ActorPool {
let ctx = Ctx::new(inner, addr, slot.parent_addr, slot.env.clone(), snap_processed, snap_depth, snap_type_counts);
// Call on_start once, before first message
if !slot.started {
if slot.phase == ActorPhase::Unstarted {
#[cfg(feature = "std")]
let start_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
slot.actor.on_start(&ctx);
}));
slot.started = true;
#[cfg(not(feature = "std"))]
let start_result: Result<(), ()> = { slot.actor.on_start(&ctx); Ok(()) };
if start_result.is_err() {
stats.panics.fetch_add(1, Ordering::Relaxed);
#[cfg(feature = "std")]
eprintln!("swactor: actor {addr} panicked in on_start — poisoned");
#[cfg(feature = "tracing")]
tracing::error!(actor_addr = %addr, "actor.on_start_panicked");
slot.poisoned = true;
slot.phase = lifecycle_transition(slot.phase, LifecycleEvent::StartPanicked);
slot.mailbox.clear();
continue;
}
slot.phase = lifecycle_transition(slot.phase, LifecycleEvent::Started);
// Check if on_start requested stop or stop_with
{
let stops = stop_requests.borrow();
if !stops.is_empty() && stops.contains(&addr) {
drop(stops);
slot.stopping = true;
slot.phase = lifecycle_transition(slot.phase, LifecycleEvent::StopRequested);
stats.stops.fetch_add(1, Ordering::Relaxed);
slot.mailbox.clear();
// Check for stop_with value
@ -591,7 +615,7 @@ impl ActorPool {
if let Some(pos) = sws.iter().position(|(a, _)| *a == addr) {
let (_, val) = sws.swap_remove(pos);
slot.exit_value = Some(val);
slot.stopping = true;
slot.phase = lifecycle_transition(slot.phase, LifecycleEvent::StopWithRequested);
stats.stops.fetch_add(1, Ordering::Relaxed);
slot.mailbox.clear();
continue;
@ -602,7 +626,7 @@ impl ActorPool {
let suspends = suspend_requests.borrow();
if !suspends.is_empty() && suspends.contains(&addr) {
drop(suspends);
slot.suspended = true;
slot.phase = lifecycle_transition(slot.phase, LifecycleEvent::SuspendRequested);
continue;
}
}
@ -612,7 +636,7 @@ impl ActorPool {
while let Some(msg) = slot.mailbox.pop_front() {
// Intercept StopSignal (from external runtime.stop_actor)
if msg.is::<StopSignal>() {
slot.stopping = true;
slot.phase = lifecycle_transition(slot.phase, LifecycleEvent::StopRequested);
stats.stops.fetch_add(1, Ordering::Relaxed);
slot.mailbox.clear();
#[cfg(feature = "tracing")]
@ -625,15 +649,18 @@ impl ActorPool {
if let Ok(sig) = msg.downcast::<StopWithSignal>() {
slot.exit_value = Some(sig.0);
}
slot.stopping = true;
slot.phase = lifecycle_transition(slot.phase, LifecycleEvent::StopWithRequested);
stats.stops.fetch_add(1, Ordering::Relaxed);
slot.mailbox.clear();
break;
}
#[cfg(feature = "std")]
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
slot.actor.handle_any(&ctx, msg)
}));
#[cfg(not(feature = "std"))]
let result: Result<Option<&'static str>, ()> = Ok(slot.actor.handle_any(&ctx, msg));
match result {
Ok(None) => {
stats.type_mismatches.fetch_add(1, Ordering::Relaxed);
@ -641,10 +668,11 @@ impl ActorPool {
Err(_) => {
stats.panics.fetch_add(1, Ordering::Relaxed);
slot.mailbox.clear();
#[cfg(feature = "std")]
eprintln!("swactor: actor {addr} panicked — poisoned, future messages will be discarded");
#[cfg(feature = "tracing")]
tracing::error!(actor_addr = %addr, "actor.panicked");
slot.poisoned = true;
slot.phase = lifecycle_transition(slot.phase, LifecycleEvent::MessagePanicked);
slot.mailbox.clear();
break;
}
@ -675,7 +703,7 @@ impl ActorPool {
slot.exit_value = Some(val);
}
drop(sws);
slot.stopping = true;
slot.phase = lifecycle_transition(slot.phase, LifecycleEvent::StopRequested);
stats.stops.fetch_add(1, Ordering::Relaxed);
slot.mailbox.clear();
break;
@ -688,12 +716,12 @@ impl ActorPool {
let suspends = suspend_requests.borrow();
if !suspends.is_empty() && suspends.contains(&addr) {
drop(suspends);
slot.suspended = true;
slot.phase = lifecycle_transition(slot.phase, LifecycleEvent::SuspendRequested);
break; // stop processing this actor's messages this tick
}
}
if budget > 0 && actor_count >= budget {
if swactor_core::budget_exhausted(actor_count, budget) {
break;
}
}
@ -723,21 +751,15 @@ impl ActorPool {
let dead_addrs: Vec<ActorAddress> = self
.actors
.iter()
.filter(|(_, slot)| slot.poisoned || slot.stopping)
.filter(|(_, slot)| slot.phase == ActorPhase::Poisoned || slot.phase == ActorPhase::Stopping)
.map(|(&addr, _)| addr)
.collect();
let mut dead = Vec::with_capacity(dead_addrs.len());
for addr in dead_addrs {
if let Some(mut slot) = self.actors.remove(&addr) {
let reason = if slot.poisoned {
StopReason::Panicked
} else if slot.exit_value.is_some() {
StopReason::Completed
} else {
StopReason::Normal
};
let reason = swactor_core::cleanup_stop_reason(slot.phase, slot.exit_value.is_some());
// Call on_stop for gracefully stopping actors only
if slot.stopping && !slot.poisoned {
if slot.phase == ActorPhase::Stopping {
let mut type_counts: Vec<(&'static str, u64)> =
slot.msg_type_counts.iter().map(|(&k, &v)| (k, v)).collect();
type_counts.sort_by(|a, b| b.1.cmp(&a.1));
@ -747,9 +769,12 @@ impl ActorPool {
slot.mailbox.len(),
type_counts,
);
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
#[cfg(feature = "std")]
{ let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
slot.actor.on_stop(&ctx);
}));
})); }
#[cfg(not(feature = "std"))]
{ slot.actor.on_stop(&ctx); }
}
dead.push((addr, reason, slot.exit_value.take()));
// slot is dropped here — actor resources freed
@ -770,7 +795,7 @@ impl ActorPool {
mailbox_depth: slot.mailbox.len(),
last_msg_type: slot.last_msg_type,
messages_processed: slot.messages_processed,
poisoned: slot.poisoned,
poisoned: slot.phase == ActorPhase::Poisoned,
message_type_counts: type_counts,
}
}));