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}