Skip to main content

beamr/native/
native_process.rs

1//! Native process model — a Rust handler that runs as a first-class,
2//! scheduler-supervised beamr process (Shape B of `NATIVE-PROCESS-DESIGN.md`).
3//!
4//! A native process *is* a [`crate::process::Process`] that additionally
5//! carries a `NativeBody` (a [`NativeHandler`] plus the factory that built
6//! it). It keeps the heap, [`crate::mailbox::Mailbox`], logical clock, the
7//! `ProcessMetadata` swap, the 3-phase park-gap protocol, and exit-tombstones
8//! unchanged. The only genuinely new behaviour is *what executes during a
9//! slice*: if the process is native, the scheduler runs the handler (see
10//! `scheduler::execution::native_slice::run_native_slice`) instead of the
11//! bytecode interpreter.
12//!
13//! Concurrency note: this model introduces NO new synchronisation primitive.
14//! [`NativeContext`] borrows the running `Process` and the shared services for
15//! the duration of one slice only; sends route through the existing
16//! [`LocalSendFacility`], and spawns through the existing [`SpawnFacility`].
17
18use std::rc::Rc;
19use std::sync::{Arc, Mutex};
20
21use crate::error::ExecError;
22#[cfg(feature = "readiness")]
23use crate::native::ReadinessFacility;
24#[cfg(feature = "threads")]
25use crate::native::TeardownAdmissionFacility;
26use crate::native::local_send::{LocalSendError, LocalSendFacility, LocalSendRequest};
27use crate::native::spawn::{SpawnError, SpawnFacility};
28use crate::native::{NativeKey, ProcessContext, WasmAsyncNifFacility};
29use crate::process::{ExitReason, Process};
30use crate::replay::ReplayDriver;
31use crate::term::Term;
32use crate::timer::{TimerKind, TimerRef, TimerWheel};
33
34/// Factory that reconstructs a handler instance.
35///
36/// Taken at `spawn_native` time and stored on the `NativeBody` so a
37/// supervisor can rebuild a crashed native child without cloning a live
38/// handler (NATIVE-002 restart). `Send + Sync` because it is held inside a
39/// scheduler slot that crosses threads.
40pub type NativeHandlerFactory = Box<dyn Fn() -> Box<dyn NativeHandler> + Send + Sync>;
41
42/// What a native process does when the scheduler gives it a slice.
43///
44/// `handle` is called when the process is scheduled (it has mail, was woken,
45/// or just spawned). The handler drains and processes messages via `ctx`,
46/// optionally sends replies or spawns children, and returns a [`NativeOutcome`]
47/// describing how the slice ends.
48pub trait NativeHandler: Send + 'static {
49    /// Run one native slice against `ctx`.
50    fn handle(&mut self, ctx: &mut NativeContext<'_>) -> NativeOutcome;
51}
52
53/// How a native slice ends. Mapped to the scheduler's `SliceOutcome` by
54/// `run_native_slice` (`Continue -> Requeue`, `Wait -> Wait`,
55/// `Stop -> Exited`).
56pub enum NativeOutcome {
57    /// Re-queue immediately (more work to do this turn).
58    Continue,
59    /// Nothing to do; park until a message arrives. Routes through the
60    /// existing 3-phase park-gap path — NOT a separate park.
61    Wait,
62    /// Terminate this process with the given reason (drives
63    /// `cleanup_exited_process` and, later, supervision).
64    Stop(ExitReason),
65}
66
67/// The native handler plus its factory, carried by a `Process`.
68///
69/// The handler is held in an `Option` so `run_native_slice` can take it out
70/// for the duration of a slice (letting the [`NativeContext`] borrow the rest
71/// of the `Process`) and put it back afterwards. The factory is retained for
72/// restart and never dropped silently.
73pub(crate) struct NativeBody {
74    pub(crate) handler: Option<Box<dyn NativeHandler>>,
75    pub(crate) factory: NativeHandlerFactory,
76}
77
78impl NativeBody {
79    /// Build a body by invoking `factory` once to produce the initial handler.
80    pub(crate) fn new(factory: NativeHandlerFactory) -> Self {
81        let handler = factory();
82        Self {
83            handler: Some(handler),
84            factory,
85        }
86    }
87}
88
89impl std::fmt::Debug for NativeBody {
90    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        formatter
92            .debug_struct("NativeBody")
93            .field("has_handler", &self.handler.is_some())
94            .finish()
95    }
96}
97
98/// The capability surface a handler is given for exactly one slice.
99///
100/// Borrows the running `Process` and the shared services. All sends route
101/// through [`LocalSendFacility`] (so sender-clock ticking and replay
102/// validation are reused verbatim); spawns route through [`SpawnFacility`].
103pub struct NativeContext<'a> {
104    process: &'a mut Process,
105    local_send: Arc<dyn LocalSendFacility>,
106    spawn: Arc<dyn SpawnFacility>,
107    replay_driver: Option<Arc<Mutex<ReplayDriver>>>,
108    timers: Option<Arc<Mutex<TimerWheel>>>,
109    #[cfg(feature = "readiness")]
110    readiness_facility: Option<Arc<dyn ReadinessFacility>>,
111    #[cfg(feature = "threads")]
112    teardown_admission_facility: Option<Arc<dyn TeardownAdmissionFacility>>,
113    replay_error: Option<ExecError>,
114    /// Single-threaded host bridge for WASM async native functions (WR-7).
115    ///
116    /// `None` for the threaded runtime and for unit contexts that never start
117    /// host async work; populated by the cooperative native slice from the
118    /// scheduler's installed facility so a `NativeHandler` can start the SAME
119    /// host async op the bytecode async-NIF path uses (see
120    /// [`NativeContext::start_async`]).
121    wasm_async_nif_facility: Option<Rc<dyn WasmAsyncNifFacility>>,
122}
123
124impl<'a> NativeContext<'a> {
125    /// Build a context over the running `Process` and the slice services.
126    ///
127    /// `timers` is the scheduler's shared timer wheel; when `None` (e.g. in
128    /// unit tests that exercise only sends), the timer methods are inert and
129    /// return `None`.
130    pub(crate) fn new(
131        process: &'a mut Process,
132        local_send: Arc<dyn LocalSendFacility>,
133        spawn: Arc<dyn SpawnFacility>,
134        replay_driver: Option<Arc<Mutex<ReplayDriver>>>,
135        timers: Option<Arc<Mutex<TimerWheel>>>,
136    ) -> Self {
137        Self {
138            process,
139            local_send,
140            spawn,
141            replay_driver,
142            timers,
143            #[cfg(feature = "readiness")]
144            readiness_facility: None,
145            #[cfg(feature = "threads")]
146            teardown_admission_facility: None,
147            replay_error: None,
148            wasm_async_nif_facility: None,
149        }
150    }
151
152    /// Install the single-threaded WASM async-NIF host bridge for this slice
153    /// (WR-7), enabling [`NativeContext::start_async`]. Called by the
154    /// cooperative native slice when the scheduler has a facility installed; the
155    /// threaded path never sets it (it has no `WasmAsyncNifFacility`).
156    pub(crate) fn set_wasm_async_nif_facility(
157        &mut self,
158        facility: Option<Rc<dyn WasmAsyncNifFacility>>,
159    ) {
160        self.wasm_async_nif_facility = facility;
161    }
162
163    /// Return the in-slice readiness facility when the scheduler composed it.
164    ///
165    /// `None` is the Disabled service's typed absence: handlers can refuse the
166    /// operation while still runnable instead of parking without an armed fd.
167    #[cfg(feature = "readiness")]
168    #[must_use]
169    pub fn readiness_facility(&self) -> Option<&dyn ReadinessFacility> {
170        self.readiness_facility.as_deref()
171    }
172
173    /// Install the scheduler's route-bound readiness facility for this slice.
174    #[cfg(feature = "readiness")]
175    pub(crate) fn set_readiness_facility(&mut self, facility: Option<Arc<dyn ReadinessFacility>>) {
176        self.readiness_facility = facility;
177    }
178
179    #[cfg(feature = "threads")]
180    pub(crate) fn set_teardown_admission_facility(
181        &mut self,
182        facility: Option<Arc<dyn TeardownAdmissionFacility>>,
183    ) {
184        self.teardown_admission_facility = facility;
185    }
186
187    /// PID of the running native process.
188    #[must_use]
189    pub fn self_pid(&self) -> u64 {
190        self.process.pid()
191    }
192
193    /// True when there is at least one queued message to drain this slice.
194    #[must_use]
195    pub fn has_messages(&self) -> bool {
196        !self.process.mailbox().is_empty()
197    }
198
199    /// True when this native process is trapping exits.
200    #[must_use]
201    pub fn trap_exit(&self) -> bool {
202        self.process.trap_exit()
203    }
204
205    /// Enable or disable exit trapping for this native process — the native
206    /// equivalent of `process_flag(trap_exit, true)`. Returns the previous
207    /// value.
208    ///
209    /// When trapping is enabled, an exit signal from a linked process is
210    /// converted into an `{'EXIT', source, reason}` message and delivered to
211    /// this process's mailbox (drained at the slice boundary by the SAME
212    /// shared store-back the bytecode path uses) instead of terminating it —
213    /// so a native handler can supervise linked children. This flips the flag
214    /// on the underlying `Process`, the single source of truth the pid-keyed
215    /// `propagate_exit` path consults; it adds no native-specific trap state.
216    pub fn set_trap_exit(&mut self, value: bool) -> bool {
217        let previous = self.process.trap_exit();
218        self.process.set_trap_exit(value);
219        previous
220    }
221
222    /// Remove and return the next mailbox message in arrival order, or `None`
223    /// when the mailbox is empty.
224    ///
225    /// Implemented over the existing `Mailbox` API (`current_message` then
226    /// `remove_current_message`) — it adds no new mailbox method. The returned
227    /// term references this process's own heap and is valid for the rest of
228    /// the slice.
229    pub fn recv(&mut self) -> Option<Term> {
230        let message = self.process.mailbox_mut().current_message()?;
231        let _removed = self.process.mailbox_mut().remove_current_message();
232        Some(message)
233    }
234
235    /// Advance the selective-receive save pointer past the current message
236    /// without removing it (the `Mailbox` skip primitive), for handlers that
237    /// want to leave a message in place and scan the next one.
238    pub fn skip_message(&mut self) {
239        self.process.mailbox_mut().advance_save_pointer();
240    }
241
242    /// Send `message` to `target_pid`, routed through the existing
243    /// [`LocalSendFacility`].
244    ///
245    /// Ticks this process's logical clock before delivery and passes the
246    /// `sender_clock` through, exactly like `interpreter::opcodes::messaging`.
247    /// On a replay mismatch the clock tick is rolled back and the error is
248    /// recorded for `run_native_slice` to surface as an exit, so replay stays
249    /// deterministic. A self-send lands in the process's own `Executing` slot
250    /// and is merged into the mailbox at store-back (no special case here).
251    pub fn send(&mut self, target_pid: u64, message: Term) {
252        let previous_sender_clock = self.process.logical_clock();
253        let sender_clock = self.process.tick_logical_clock();
254        let sender_pid = self.process.pid();
255        let request = LocalSendRequest {
256            target_pid,
257            sender_pid,
258            message,
259            sender_clock,
260            replay_driver: self.replay_driver.as_ref(),
261        };
262        if let Err(LocalSendError::ReplayMismatch(detail)) = self.local_send.send_local(request) {
263            self.process.set_logical_clock(previous_sender_clock);
264            self.replay_error = Some(ExecError::ReplayMismatch(detail));
265        }
266    }
267
268    /// Allocate a tuple of `elements` on this process's heap and return the
269    /// tuple term, or `None` when the heap is full.
270    ///
271    /// This is the allocation primitive an
272    /// [`crate::native::actor::ActorMessage`] encode implementation uses to
273    /// build a compound message of immediates/scalars. Every `element` MUST be
274    /// an immediate (small integer, atom, local pid) or a heap term already
275    /// rooted on this process's heap: the native slice performs no garbage
276    /// collection, so this allocator neither triggers a GC nor needs to root
277    /// its arguments. Raw closures with free variables must NOT be exchanged
278    /// this way (the pre-existing ETF closure-encoding limitation documented on
279    /// this module); actors exchange immediates/refs/scalars only.
280    #[must_use]
281    pub fn alloc_tuple(&mut self, elements: &[Term]) -> Option<Term> {
282        let words = 1usize.checked_add(elements.len())?;
283        let slice = self.process.heap_mut().alloc_slice(words).ok()?;
284        crate::term::boxed::write_tuple(slice, elements)
285    }
286
287    /// Deep-copy a self-contained [`crate::ets::OwnedTerm`] graph onto this
288    /// process's heap and return the rooted term, or `None` when the heap is
289    /// full or the copy fails.
290    ///
291    /// This is the delivery primitive a dynamic [`crate::native::actor::Actor`]
292    /// uses when its `Call`/`Reply`/`Cast` payload is carried as an opaque,
293    /// already-detached term graph (e.g. a host value marshalled outside a slice)
294    /// rather than rebuilt element by element with [`Self::alloc_tuple`]. It
295    /// reuses the same `copy_term_to_heap` deep-copier ETS delivery uses, so the
296    /// resulting term is rooted on this heap and valid for the rest of the slice;
297    /// the native slice performs no GC, so no rooting of the argument is required.
298    #[must_use]
299    pub fn alloc_owned_term(&mut self, owned: &crate::ets::OwnedTerm) -> Option<Term> {
300        owned.copy_to_heap(self.process.heap_mut()).ok()
301    }
302
303    /// Spawn a native child from `factory`, optionally linking it to this
304    /// process, delegating to the same [`SpawnFacility`] the scheduler uses.
305    pub fn spawn_native(
306        &mut self,
307        factory: NativeHandlerFactory,
308        link_to: Option<u64>,
309    ) -> Result<u64, SpawnError> {
310        self.spawn
311            .spawn_native(self.process.pid(), factory, link_to)
312    }
313
314    /// Schedule `message` to be delivered to *this* process's mailbox after
315    /// `delay` (a self-tick). Returns the timer reference, or `None` when the
316    /// context was built without a timer wheel.
317    ///
318    /// The timer is a `Deliver` timer: when it fires the scheduler pushes
319    /// `message` into this process's mailbox (via the same Executing-slot-safe
320    /// path that `send`/IO delivery use) and wakes it, so a handler that
321    /// returns [`NativeOutcome::Wait`] is rescheduled when the tick lands.
322    pub fn schedule(&mut self, delay: std::time::Duration, message: Term) -> Option<TimerRef> {
323        let target_pid = self.self_pid();
324        self.send_after(delay, target_pid, message)
325    }
326
327    #[cfg(feature = "threads")]
328    fn reserve_timer_mutation(&self) -> Result<Option<Box<dyn Send>>, ()> {
329        match &self.teardown_admission_facility {
330            Some(facility) => facility.try_reserve().map(Some).ok_or(()),
331            None => Ok(None),
332        }
333    }
334
335    /// Schedule `message` to be delivered to `target_pid`'s mailbox after
336    /// `delay`. Returns the timer reference, or `None` when the context was
337    /// built without a timer wheel.
338    ///
339    /// # Replay determinism
340    ///
341    /// Unlike [`Self::send`], scheduling a timer is NOT itself a replay-recorded
342    /// or replay-validated event — and deliberately so, to stay identical to the
343    /// `erlang:send_after`/`start_timer` BIF path (`ProcessContext::schedule_timer`),
344    /// which also does not record the scheduling call. The replayed event is the
345    /// timer *expiry*: under replay `tick_replay_timers` discards the live wheel's
346    /// wall-clock fires and instead replays the recorded `TimerExpiry` set through
347    /// `expire_timers`, so the delivered message and its ordering come from the log,
348    /// not from wall-clock timing. The scheduled entry left in the live wheel is
349    /// inert under replay (its real fire is discarded). Native timers are therefore
350    /// exactly as replay-deterministic as BIF timers; what they do NOT add is the
351    /// per-call determinism *validation* that `send` performs, because timer
352    /// scheduling has no recorded counterpart to validate against.
353    pub fn send_after(
354        &mut self,
355        delay: std::time::Duration,
356        target_pid: u64,
357        message: Term,
358    ) -> Option<TimerRef> {
359        #[cfg(feature = "threads")]
360        let _admission = self.reserve_timer_mutation().ok()?;
361        let timers = self.timers.as_ref()?;
362        Some(
363            timers
364                .lock()
365                .unwrap_or_else(|error| error.into_inner())
366                .schedule(delay, target_pid, message, TimerKind::Deliver),
367        )
368    }
369
370    /// Cancel a pending timer scheduled through this context, returning its
371    /// remaining duration. `None` when there is no timer wheel or the timer
372    /// already fired or was already cancelled.
373    pub fn cancel_timer(&mut self, reference: TimerRef) -> Option<std::time::Duration> {
374        #[cfg(feature = "threads")]
375        let _admission = self.reserve_timer_mutation().ok()?;
376        let timers = self.timers.as_ref()?;
377        timers
378            .lock()
379            .unwrap_or_else(|error| error.into_inner())
380            .cancel(reference)
381    }
382
383    /// Start host async work through the SAME async-NIF seam the bytecode path
384    /// uses, then park this process pending completion (WR-7).
385    ///
386    /// `mfa` names a host async native (the key the host registered with its
387    /// [`WasmAsyncNifFacility`]); `args` are immediate/heap terms rooted on this
388    /// process's heap. The facility starts the host operation (e.g. a browser
389    /// `fetch`/OPFS Promise) bound to this process's pid and arranges a later
390    /// [`crate::scheduler::WasmScheduler::complete_async`] callback. The handler
391    /// MUST return [`NativeOutcome::Wait`] after a successful `start_async`: the
392    /// completion is delivered into this process's mailbox on a later turn (the
393    /// scheduler converts the pid-keyed completion into a `{ok, Value}` /
394    /// `{error, Reason}` message), and the handler reads it with
395    /// [`NativeContext::recv`] when it next runs — exactly the wake-on-message
396    /// resume model `call_async` uses.
397    ///
398    /// Returns `Ok(())` when the host op was started (now park), or `Err(reason)`
399    /// when no facility is installed or the host rejected synchronously (in which
400    /// case the process is NOT parked and the handler should handle `reason`).
401    ///
402    /// # Errors
403    ///
404    /// Returns the host's error term, or an `undef` atom when no
405    /// [`WasmAsyncNifFacility`] is installed.
406    pub fn start_async(&mut self, mfa: NativeKey, args: &[Term]) -> Result<(), Term> {
407        let Some(facility) = self.wasm_async_nif_facility.clone() else {
408            return Err(Term::atom(crate::atom::Atom::UNDEF));
409        };
410        // Build a process context over the running process so the SAME
411        // `start_async_nif` the bytecode async-NIF path calls can start the host
412        // op bound to this pid. The bytecode path's `request_suspend` lands on
413        // this transient context (the native slice parks via `NativeOutcome::Wait`
414        // instead, so that suspend marker is intentionally not consulted here).
415        let mut context = ProcessContext::new();
416        context.attach_process(self.process, 0);
417        context.set_current_native(Some(mfa));
418        context.set_wasm_async_nif_facility(Some(facility.clone()));
419        let result = facility.start_async_nif(mfa, args, &mut context);
420        context.detach_process();
421        result.map(|_started| ())
422    }
423
424    /// Take any replay-determinism error recorded by [`Self::send`] during the
425    /// slice, so the caller can terminate the process deterministically.
426    ///
427    /// Only the `threads`-gated scheduler `execution` path reads this.
428    #[cfg(feature = "threads")]
429    pub(crate) fn take_replay_error(&mut self) -> Option<ExecError> {
430        self.replay_error.take()
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::{NativeBody, NativeContext, NativeHandler, NativeOutcome};
437    use crate::process::Process;
438
439    struct Noop;
440
441    impl NativeHandler for Noop {
442        fn handle(&mut self, _ctx: &mut NativeContext<'_>) -> NativeOutcome {
443            NativeOutcome::Wait
444        }
445    }
446
447    fn noop_body() -> NativeBody {
448        NativeBody::new(Box::new(|| Box::new(Noop)))
449    }
450
451    #[test]
452    fn process_with_native_body_reports_is_native() {
453        let mut process = Process::new(7, 64);
454        assert!(
455            !process.is_native(),
456            "a fresh bytecode process is not native"
457        );
458        process.set_native_body(noop_body());
459        assert!(
460            process.is_native(),
461            "a process with a native body is native"
462        );
463        // R2: a native process carries no code position or x-registers.
464        assert!(process.code_position().is_none());
465        assert_eq!(process.x_reg(0), crate::term::Term::NIL);
466    }
467
468    #[test]
469    fn structural_clone_is_non_native() {
470        // R2 audit assertion: Process::clone drops the handler — the clone is a
471        // non-native copy, never a dead no-op carrying a silently-lost handler.
472        let mut process = Process::new(7, 64);
473        process.set_native_body(noop_body());
474        assert!(process.is_native());
475
476        let clone = process.clone();
477        assert!(
478            !clone.is_native(),
479            "a structural clone must be non-native (handler not cloned)"
480        );
481        assert!(process.is_native(), "the original retains its handler");
482    }
483}