diff --git a/src/gen_server.rs b/src/gen_server.rs index 44d61a8..847012f 100644 --- a/src/gen_server.rs +++ b/src/gen_server.rs @@ -182,7 +182,7 @@ use crate::channel::{channel, select, select_timeout, Receiver, RecvTimeoutError use crate::monitor::{demonitor, monitor, Down, Monitor}; use crate::pid::Pid; use crate::registry::{register_with, resolve_named_sender, RegisterError}; -use crate::scheduler::{cancel_timer, request_stop, send_after_to, spawn, spawn_under}; +use crate::scheduler::{cancel_timer, request_stop, send_after_to}; use crate::timer::TimerId; use std::cell::Cell; use std::collections::HashMap; @@ -643,11 +643,17 @@ pub struct GenServerBuilder { state: G, infos: Vec>, supervisor: Option, + stack_opts: crate::scheduler::SpawnOpts, } impl GenServerBuilder { pub fn new(state: G) -> Self { - GenServerBuilder { state, infos: Vec::new(), supervisor: None } + GenServerBuilder { + state, + infos: Vec::new(), + supervisor: None, + stack_opts: crate::scheduler::SpawnOpts::default(), + } } /// Add an out-of-band channel; messages arriving on it are dispatched to @@ -665,6 +671,14 @@ impl GenServerBuilder { self } + /// Stack shape for the server actor (RFC 019) — see + /// [`SpawnOpts`](crate::SpawnOpts). Useful for servers that recurse + /// deeply or call into FFI with large C frames. + pub fn stack_opts(mut self, opts: crate::scheduler::SpawnOpts) -> Self { + self.stack_opts = opts; + self + } + /// Spawn the server actor and hand back its [`GenServerRef`]. The server's /// lifetime is governed by its refs, not by joining, so the backing join /// handle is dropped. @@ -686,10 +700,16 @@ impl GenServerBuilder { /// under the name before returning. fn spawn_server(self) -> GenServerRef { let (tx, rx) = channel::>(); - let GenServerBuilder { state, infos, supervisor } = self; + let GenServerBuilder { state, infos, supervisor, stack_opts } = self; let handle = match supervisor { - Some(sup) => spawn_under(sup, move || server_loop::(rx, state, infos)), - None => spawn(move || server_loop::(rx, state, infos)), + Some(sup) => { + crate::scheduler::spawn_under_with(sup, stack_opts, move || { + server_loop::(rx, state, infos) + }) + } + None => crate::scheduler::spawn_with(stack_opts, move || { + server_loop::(rx, state, infos) + }), }; GenServerRef { tx, pid: handle.pid() } } @@ -758,6 +778,12 @@ impl NamedGenServerBuilder { self } + /// Stack shape for the server actor (see [`GenServerBuilder::stack_opts`]). + pub fn stack_opts(mut self, opts: crate::scheduler::SpawnOpts) -> Self { + self.builder = self.builder.stack_opts(opts); + self + } + /// Spawn the server and bind its name in one step. Fallible: returns /// [`RegisterError::NameTaken`] if the name is already held by a different /// live server. diff --git a/src/gen_statem.rs b/src/gen_statem.rs index 6bd174a..e23e1ca 100644 --- a/src/gen_statem.rs +++ b/src/gen_statem.rs @@ -71,7 +71,7 @@ use crate::channel::{channel, select, Receiver, Sender}; use crate::pid::Pid; -use crate::scheduler::{cancel_timer, send_after_to, spawn as spawn_actor}; +use crate::scheduler::{cancel_timer, send_after_to}; use crate::timer::TimerId; use std::collections::{HashMap, VecDeque}; use std::marker::PhantomData; @@ -434,8 +434,21 @@ impl GenStatemRef { /// /// Panics if called outside `Runtime::run()`. pub fn spawn(machine: M) -> GenStatemRef { + spawn_with(crate::scheduler::SpawnOpts::default(), machine) +} + +/// [`spawn`] with per-actor stack shape overrides (RFC 019) for the machine's +/// actor — see [`SpawnOpts`](crate::SpawnOpts). gen_statem has no builder +/// (its one-shot `spawn(machine)` shape predates RFC 019), so the opts ride +/// a `_with` variant like the scheduler's own spawns. +/// +/// Panics if called outside `Runtime::run()`. +pub fn spawn_with( + opts: crate::scheduler::SpawnOpts, + machine: M, +) -> GenStatemRef { let (tx, rx) = channel::(); - let handle = spawn_actor(move || statem_loop(rx, machine)); + let handle = crate::scheduler::spawn_with(opts, move || statem_loop(rx, machine)); GenStatemRef { tx, pid: handle.pid() } } diff --git a/src/introspect.rs b/src/introspect.rs index eab8e19..dc34934 100644 --- a/src/introspect.rs +++ b/src/introspect.rs @@ -220,6 +220,22 @@ pub fn snapshot() -> RuntimeSnapshot { /// slot was reused by another), out of range, or was never a real pid at /// all. Unlike [`snapshot`], every field of the result describes the same /// instant, since there is only one actor to read. +/// The stack shape `(reserve, guard)` of a live actor, page-rounded — the +/// RFC 019 introspection surface's first field (depth sampling and shrink +/// counters land with the shrink machinery). `None` if `pid` no longer names +/// a live actor. Takes the actor's cold lock briefly; debugging/assertion +/// use, not a hot-path call. +pub fn stack_shape(pid: Pid) -> Option<(usize, usize)> { + with_runtime(|inner| { + let slot = inner.slot_at(pid)?; + let cold = slot.cold.lock(); + if slot.generation() != pid.generation() { + return None; + } + cold.actor.as_ref().map(|a| a.stack.shape()) + }) +} + pub fn actor_info(pid: Pid) -> Option { with_runtime(|inner| { let slot = inner.slot_at(pid)?; diff --git a/src/lib.rs b/src/lib.rs index 3ced4b4..d04f59e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -83,8 +83,9 @@ pub use runtime::{init, Config, Runtime}; pub use scheduler::{ block_on_io, cancel_timer, request_stop, run, self_pid, send_after, send_after_named, send_after_named_wall, send_after_wall, sleep, sleep_wall, - spawn, spawn_addr, spawn_under, wait_readable, wait_readable_timeout, wait_writable, - wait_writable_timeout, yield_now, FdArm, JoinError, JoinHandle, + spawn, spawn_addr, spawn_addr_with, spawn_under, spawn_under_with, spawn_with, + wait_readable, wait_readable_timeout, wait_writable, + wait_writable_timeout, yield_now, FdArm, JoinError, JoinHandle, SpawnOpts, }; pub use supervisor::{ChildSpec, OneForOne, Restart, Signal, Strategy}; pub use timer::TimerId; diff --git a/src/runtime.rs b/src/runtime.rs index d07dbd4..ac30580 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1360,7 +1360,8 @@ pub const ROOT_PID: Pid = Pid::new(u32::MAX, u32::MAX); // Stack acquisition / recycling — RFC 019 pool rule // --------------------------------------------------------------------------- -/// Get a stack of the requested shape (`None` ⇒ the runtime defaults). +/// Get a stack of the shape `opts` requests (`None` fields ⇒ the runtime +/// defaults). /// /// Pool rule (RFC 019 §1): the pool is a uniform `Vec` of /// default-shaped stacks and stays that way. Default-shaped requests try the @@ -1369,9 +1370,10 @@ pub const ROOT_PID: Pid = Pid::new(u32::MAX, u32::MAX); /// lock is dropped before any mmap: no syscall ever stalls another spawner. pub(crate) fn acquire_stack( inner: &RuntimeInner, - shape: Option<(usize, usize)>, + opts: crate::scheduler::SpawnOpts, ) -> crate::stack::Stack { - let (reserve, guard) = shape.unwrap_or((inner.stack_reserve, inner.stack_guard)); + let reserve = opts.stack_reserve.unwrap_or(inner.stack_reserve); + let guard = opts.guard_size.unwrap_or(inner.stack_guard); let default_shaped = crate::stack::round_to_pages(reserve) == inner.stack_reserve && crate::stack::round_to_pages(guard) == inner.stack_guard; if default_shaped { diff --git a/src/scheduler.rs b/src/scheduler.rs index 8074a0b..049aafd 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -259,6 +259,28 @@ impl Drop for JoinHandle { // spawn / spawn_under / self_pid // --------------------------------------------------------------------------- +/// Per-spawn stack shape overrides (RFC 019). `None` fields resolve to the +/// runtime's [`Config`](crate::runtime::Config) defaults at spawn time, so +/// struct-update syntax works anywhere without a runtime handle: +/// +/// ``` +/// use smarm::SpawnOpts; +/// let opts = SpawnOpts { stack_reserve: Some(8 * 1024 * 1024), ..SpawnOpts::default() }; +/// ``` +/// +/// Both sizes are page-rounded. The reserve is *virtual* (demand-paged): +/// an 8 MiB reserve costs address space, not memory — RSS follows touched +/// pages. The guard is PROT_NONE below the stack; raise it for FFI code +/// with unusually large C frames. Custom-shaped stacks bypass the recycle +/// pool: they are mmapped fresh at spawn and munmapped at death. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct SpawnOpts { + /// Usable stack reservation. `None` ⇒ [`Config::stack_reserve`](crate::runtime::Config::stack_reserve). + pub stack_reserve: Option, + /// PROT_NONE guard below the stack. `None` ⇒ [`Config::stack_guard`](crate::runtime::Config::stack_guard). + pub guard_size: Option, +} + /// Start a new actor running `f`, and return a [`JoinHandle`] for it. /// /// The new actor runs concurrently with its caller and with every other @@ -281,17 +303,34 @@ pub fn spawn(f: impl FnOnce() + Send + 'static) -> JoinHandle { spawn_under(parent, f) } +/// [`spawn`] with per-actor stack shape overrides (RFC 019). +pub fn spawn_with(opts: SpawnOpts, f: impl FnOnce() + Send + 'static) -> JoinHandle { + let parent = current_pid().unwrap_or_else(|| { + with_runtime(|_| crate::runtime::ROOT_PID) + }); + spawn_under_with(parent, opts, f) +} + /// Like [`spawn`], but explicitly attaches the new actor to `supervisor` /// instead of the calling actor. Ordinary code should reach for [`spawn`]; /// this exists for supervision trees (see [`supervisor`](crate::supervisor)) /// and other cases that need to place a child under a specific ancestor /// rather than its true caller. pub fn spawn_under(supervisor: Pid, f: impl FnOnce() + Send + 'static) -> JoinHandle { + spawn_under_with(supervisor, SpawnOpts::default(), f) +} + +/// [`spawn_under`] with per-actor stack shape overrides (RFC 019). +pub fn spawn_under_with( + supervisor: Pid, + opts: SpawnOpts, + f: impl FnOnce() + Send + 'static, +) -> JoinHandle { let supervisor = supervisor.erase(); // Stack + closure boxing happen before the slot locks are taken; the // pool lock inside acquire_stack is dropped before any mmap, so no // syscall ever stalls another scheduler thread. - let stack = with_runtime(|inner| crate::runtime::acquire_stack(inner, None)); + let stack = with_runtime(|inner| crate::runtime::acquire_stack(inner, opts)); let sp = init_actor_stack(stack.top(), crate::actor::trampoline); let closure: crate::runtime::Closure = Box::new(f); @@ -331,6 +370,18 @@ pub fn spawn_addr( crate::pid::assert_type::(pid) } +/// [`spawn_addr`] with per-actor stack shape overrides (RFC 019). +pub fn spawn_addr_with( + opts: SpawnOpts, + body: impl FnOnce(crate::channel::Receiver) + Send + 'static, +) -> Pid { + let (tx, rx) = crate::channel::channel::(); + let handle = spawn_with(opts, move || body(rx)); + let pid = handle.pid(); + crate::registry::install_for::(pid, tx); + crate::pid::assert_type::(pid) +} + use crate::context::init_actor_stack; /// The identity of the actor currently running. Use it to hand your own diff --git a/tests/runtime.rs b/tests/runtime.rs index 8bc20cf..2947315 100644 --- a/tests/runtime.rs +++ b/tests/runtime.rs @@ -545,7 +545,9 @@ fn config_stack_reserve_permits_deep_recursion() { spawn(move || { std::hint::black_box(burn_stack(64)); done2.store(true, Ordering::SeqCst); - }).join(); + }) + .join() + .unwrap(); }); assert!(done.load(Ordering::SeqCst)); } diff --git a/tests/spawn_opts.rs b/tests/spawn_opts.rs new file mode 100644 index 0000000..097503f --- /dev/null +++ b/tests/spawn_opts.rs @@ -0,0 +1,212 @@ +//! RFC 019 commit 2 — the `SpawnOpts` surface. +//! +//! Covers: per-spawn stack shape overrides on every spawn surface, the +//! `None ⇒ Config default` resolution, the pool rule from the outside +//! (obligation 4: a custom-shaped stack never enters the pool), and that a +//! big reserve behaviorally takes effect (deep recursion completes). + +use smarm::runtime::{Config, DEFAULT_STACK_GUARD, DEFAULT_STACK_RESERVE}; +use smarm::{ + self_pid, spawn, spawn_under_with, spawn_with, GenServerBuilder, SpawnOpts, +}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +fn rt1() -> smarm::runtime::Runtime { + smarm::runtime::init(Config::exact(1)) +} + +#[test] +fn default_spawn_has_default_shape() { + rt1().run(|| { + let h = spawn(|| { + let shape = smarm::introspect::stack_shape(self_pid()).unwrap(); + assert_eq!(shape, (DEFAULT_STACK_RESERVE, DEFAULT_STACK_GUARD)); + }); + h.join().unwrap(); + }); +} + +#[test] +fn spawn_with_overrides_reserve_and_guard() { + rt1().run(|| { + let opts = SpawnOpts { + stack_reserve: Some(1024 * 1024), + guard_size: Some(256 * 1024), + }; + let h = spawn_with(opts, || { + let shape = smarm::introspect::stack_shape(self_pid()).unwrap(); + assert_eq!(shape, (1024 * 1024, 256 * 1024)); + }); + h.join().unwrap(); + }); +} + +#[test] +fn spawn_with_partial_override_keeps_config_default_for_the_rest() { + rt1().run(|| { + let opts = SpawnOpts { stack_reserve: Some(1024 * 1024), ..SpawnOpts::default() }; + let h = spawn_with(opts, || { + let shape = smarm::introspect::stack_shape(self_pid()).unwrap(); + assert_eq!(shape, (1024 * 1024, DEFAULT_STACK_GUARD)); + }); + h.join().unwrap(); + }); +} + +#[test] +fn spawn_with_rounds_to_pages() { + rt1().run(|| { + let opts = SpawnOpts { stack_reserve: Some(64 * 1024 + 1), guard_size: Some(4097) }; + let h = spawn_with(opts, || { + let (reserve, guard) = smarm::introspect::stack_shape(self_pid()).unwrap(); + assert_eq!(reserve % 4096, 0); + assert_eq!(guard % 4096, 0); + assert!(reserve >= 64 * 1024 + 1); + assert!(guard >= 4097); + }); + h.join().unwrap(); + }); +} + +#[test] +fn spawn_under_with_takes_opts() { + rt1().run(|| { + let me = self_pid(); + let opts = SpawnOpts { stack_reserve: Some(128 * 1024), ..SpawnOpts::default() }; + let h = spawn_under_with(me, opts, || { + let (reserve, _) = smarm::introspect::stack_shape(self_pid()).unwrap(); + assert_eq!(reserve, 128 * 1024); + }); + h.join().unwrap(); + }); +} + +/// Obligation 4, from the outside: a dead custom stack must not be handed to +/// the next default spawn. The pool is LIFO, so if the custom stack had been +/// (wrongly) pushed at death, the very next default-shaped spawn on this +/// single-threaded runtime would pop it and report a custom shape. +#[test] +fn custom_stack_never_enters_the_pool() { + rt1().run(|| { + spawn_with( + SpawnOpts { stack_reserve: Some(512 * 1024), guard_size: Some(128 * 1024) }, + || {}, + ) + .join() + .unwrap(); + let h = spawn(|| { + let shape = smarm::introspect::stack_shape(self_pid()).unwrap(); + assert_eq!(shape, (DEFAULT_STACK_RESERVE, DEFAULT_STACK_GUARD)); + }); + h.join().unwrap(); + }); +} + +/// The reverse direction of the pool rule: a default-shaped stack IS pooled +/// and reused (cap = threads × 4 ≥ 1 here, pool empty at start). +#[test] +fn default_stack_is_recycled() { + rt1().run(|| { + spawn(|| {}).join().unwrap(); + let h = spawn(|| { + let shape = smarm::introspect::stack_shape(self_pid()).unwrap(); + assert_eq!(shape, (DEFAULT_STACK_RESERVE, DEFAULT_STACK_GUARD)); + }); + h.join().unwrap(); + }); +} + +/// Burn ~`frames` × 4 KiB of stack (see tests/runtime.rs twin). +#[inline(never)] +fn burn_stack(frames: usize) -> u64 { + let mut local = [0u8; 4096]; + local[0] = frames as u8; + let below = if frames == 0 { 0 } else { burn_stack(frames - 1) }; + std::hint::black_box(&mut local); + below.wrapping_add(local[0] as u64) +} + +#[test] +fn big_reserve_behaviorally_takes_effect() { + // ~1 MiB deep on an 8 MiB per-spawn reserve, runtime default untouched. + rt1().run(|| { + let done = Arc::new(AtomicBool::new(false)); + let done2 = done.clone(); + spawn_with( + SpawnOpts { stack_reserve: Some(8 * 1024 * 1024), ..SpawnOpts::default() }, + move || { + std::hint::black_box(burn_stack(256)); + done2.store(true, Ordering::SeqCst); + }, + ) + .join() + .unwrap(); + assert!(done.load(Ordering::SeqCst)); + }); +} + +// --------------------------------------------------------------------------- +// Builder surfaces +// --------------------------------------------------------------------------- + +struct Echo; +impl smarm::GenServer for Echo { + type Call = (); + type Reply = (usize, usize); + type Cast = (); + type Info = (); + type Timer = (); + fn handle_call(&mut self, _c: ()) -> (usize, usize) { + smarm::introspect::stack_shape(self_pid()).unwrap() + } + fn handle_cast(&mut self, _c: ()) {} +} + +#[test] +fn gen_server_builder_stack_opts() { + rt1().run(|| { + let server = GenServerBuilder::new(Echo) + .stack_opts(SpawnOpts { stack_reserve: Some(256 * 1024), ..SpawnOpts::default() }) + .start(); + let (reserve, guard) = server.call(()).unwrap(); + assert_eq!(reserve, 256 * 1024); + assert_eq!(guard, DEFAULT_STACK_GUARD); + server.shutdown(); + }); +} + +struct Probe; +impl smarm::Machine for Probe { + type Ev = smarm::channel::Sender<(usize, usize)>; + fn state_timeout_ev() -> Self::Ev { + unreachable!("no timers in this test") + } + fn timeout_ev(_name: &'static str) -> Self::Ev { + unreachable!("no timers in this test") + } + fn on_start(&mut self, _cx: &mut smarm::Cx) {} + fn handle( + &mut self, + ev: Self::Ev, + _cx: &mut smarm::Cx, + ) -> smarm::gen_statem::Step { + let _ = ev.send(smarm::introspect::stack_shape(self_pid()).unwrap()); + smarm::gen_statem::Step::Stayed + } +} + +#[test] +fn gen_statem_spawn_with_stack_opts() { + rt1().run(|| { + let m = smarm::gen_statem::spawn_with( + SpawnOpts { stack_reserve: Some(256 * 1024), ..SpawnOpts::default() }, + Probe, + ); + let (tx, rx) = smarm::channel::channel(); + m.send(tx).unwrap(); + let (reserve, guard) = rx.recv().unwrap(); + assert_eq!(reserve, 256 * 1024); + assert_eq!(guard, DEFAULT_STACK_GUARD); + }); +}