Skip to main content

moirai/
app.rs

1//! Top-level ECS application owning [`World`] and [`Schedule`].
2//!
3//! Construction flows through [`AppBuilder`]. [`App::update`] and [`App::render`] advance
4//! [`crate::time::WorldTick`], run fixed substeps when configured, flush deferred commands,
5//! clear frame-scoped events, and emit [`crate::diagnostics::DiagnosticEvent`]s to an optional
6//! [`crate::diagnostics::Observer`]. The first terminal [`AppFault`] is retained on exhaustion,
7//! system failure, or panic.
8
9use alloc::boxed::Box;
10use alloc::string::String;
11use core::time::Duration;
12
13use crate::diagnostics::{DiagnosticEvent, Observer};
14use crate::operation::StageOperation;
15use crate::schedule::stage;
16pub use crate::schedule::BuildError;
17use crate::schedule::RunOutcome;
18use crate::schedule::{
19    FlushMode, RunContext, Schedule, ScheduleBuilder, ScheduleError, System, SystemId, SystemSet,
20    UpdatePlan,
21};
22use crate::time::{FixedConfig, FixedWork};
23use crate::world::{World, WorldBuilder};
24
25/// Terminal execution record retained after the first fault.
26#[derive(Clone, Debug, Eq, PartialEq)]
27pub struct AppFault {
28    /// Stage label active when the fault occurred, if known.
29    pub stage: Option<String>,
30    /// System name active when the fault occurred, if known.
31    pub system: Option<String>,
32    /// Human-readable detail such as exhaustion or panic text.
33    pub detail: Option<String>,
34}
35
36/// Recoverable preflight failures and terminal execution faults for [`App`].
37#[non_exhaustive]
38#[derive(Clone, Debug, Eq, PartialEq)]
39pub enum AppError {
40    /// `delta_seconds` was negative, NaN, or infinite.
41    InvalidDelta,
42    /// Deferred commands remain unflushed before the next pass.
43    PendingIdleCommands,
44    /// A prior mutation left the world in a poisoned state.
45    WorldMutationPoisoned,
46    /// The application already recorded a terminal fault.
47    TerminalFault,
48    /// [`crate::time::WorldTick`] cannot advance further.
49    WorldTickExhausted,
50    /// Fixed-step indices cannot advance further.
51    FixedStepExhausted,
52    /// [`UpdatePlan`] selection failed schedule validation.
53    InvalidUpdatePlan(ScheduleError),
54    /// A system or stage pass aborted with location detail.
55    Fault(AppFault),
56}
57
58/// Runnable ECS host pairing one [`World`] with one compiled [`Schedule`].
59pub struct App {
60    world: World,
61    schedule: Schedule,
62    run_context: RunContext,
63    faulted: bool,
64    fault: Option<AppFault>,
65    observer: Option<Box<dyn Observer>>,
66}
67
68/// Checked application construction: world seeding, schedule authoring, observer wiring.
69pub struct AppBuilder {
70    world_builder: WorldBuilder,
71    schedule_builder: ScheduleBuilder,
72    observer: Option<Box<dyn Observer>>,
73}
74
75impl App {
76    /// Starts checked construction with a standard schedule template.
77    pub fn builder() -> AppBuilder {
78        AppBuilder::new()
79    }
80
81    /// Assembles an application from an already-built world and schedule.
82    ///
83    /// Rejects pending commands, active run guards, poisoned mutation state, and lease mismatch.
84    pub fn from_parts(world: World, schedule: Schedule) -> Result<Self, BuildError> {
85        if world.has_pending_commands() {
86            return Err(BuildError::PendingCommands);
87        }
88        if !world.run_guard_is_idle() {
89            return Err(BuildError::WorldRunning);
90        }
91        if world.is_mutation_poisoned() {
92            return Err(BuildError::WorldMutationPoisoned);
93        }
94        if !world.validate_execution_lease(schedule.execution_lease()) {
95            return Err(BuildError::LeaseMismatch);
96        }
97        let set_count = schedule.set_count();
98        Ok(Self {
99            world,
100            schedule,
101            run_context: RunContext::with_set_capacity(set_count),
102            faulted: false,
103            fault: None,
104            observer: None,
105        })
106    }
107
108    /// Read-only access to the owned world.
109    pub fn world(&self) -> &World {
110        &self.world
111    }
112
113    /// Mutable world access between passes when the app is not faulted.
114    pub fn world_mut(&mut self) -> &mut World {
115        &mut self.world
116    }
117
118    /// Read-only access to the compiled schedule graph.
119    pub fn schedule(&self) -> &Schedule {
120        &self.schedule
121    }
122
123    /// Whether a terminal fault prevents further execution.
124    pub fn is_faulted(&self) -> bool {
125        self.faulted
126    }
127
128    /// Returns the first terminal fault retained by this application.
129    pub fn fault(&self) -> Option<&AppFault> {
130        self.fault.as_ref()
131    }
132
133    /// Enables or disables one compiled system without rebuilding the schedule.
134    pub fn set_system_enabled(
135        &mut self,
136        id: &SystemId,
137        enabled: bool,
138    ) -> Result<(), ScheduleError> {
139        self.schedule.set_system_enabled(id, enabled)
140    }
141
142    /// Runs the full Update pass for `delta_seconds`.
143    pub fn update(&mut self, delta_seconds: f32) -> Result<(), AppError> {
144        self.update_with(delta_seconds, |_| ())
145    }
146
147    /// Runs a validated subset of Update stages.
148    ///
149    /// Startup still runs once before the first successful planned update. The
150    /// selected stages retain compiled order, share one world tick, final flush,
151    /// and Update-frame event cleanup.
152    pub fn update_plan(&mut self, delta_seconds: f32, plan: &UpdatePlan) -> Result<(), AppError> {
153        self.update_inner(delta_seconds, Some(plan), |_| ())
154    }
155
156    /// Runs Update and returns a value observed from the world after frame cleanup.
157    pub fn update_with<R>(
158        &mut self,
159        delta_seconds: f32,
160        observe: impl FnOnce(&World) -> R,
161    ) -> Result<R, AppError> {
162        self.update_inner(delta_seconds, None, observe)
163    }
164
165    fn update_inner<R>(
166        &mut self,
167        delta_seconds: f32,
168        plan: Option<&UpdatePlan>,
169        observe: impl FnOnce(&World) -> R,
170    ) -> Result<R, AppError> {
171        if let Some(plan) = plan {
172            self.schedule
173                .validate_update_plan(plan)
174                .map_err(AppError::InvalidUpdatePlan)?;
175        }
176        self.ensure_ready()?;
177        validate_delta(delta_seconds)?;
178        emit(
179            &mut self.observer,
180            DiagnosticEvent::UpdateStart { delta_seconds },
181        );
182
183        let frame_delta = duration_from_seconds(delta_seconds)?;
184        let fixed_stage_selected = plan.map_or(true, |plan| {
185            self.schedule
186                .update_stage_indices()
187                .iter()
188                .copied()
189                .any(|index| {
190                    self.schedule.stage_label_at(index) == stage::FIXED_UPDATE
191                        && self.schedule.plan_contains_stage(plan, index)
192                })
193        });
194        let fixed_config = self.schedule.fixed_config().copied();
195        let fixed_plan = if fixed_stage_selected {
196            if let Some(config) = fixed_config {
197                let peek = self
198                    .schedule
199                    .fixed_accumulator()
200                    .peek_plan(frame_delta, &config);
201                let planned_steps = match peek.work {
202                    FixedWork::Steps(steps) => steps as u128,
203                    FixedWork::Coalesced { steps, .. } => steps,
204                };
205                self.world
206                    .preflight_world_tick()
207                    .map_err(|_| self.fault_tick_exhaustion())?;
208                self.schedule
209                    .fixed_accumulator()
210                    .preflight_steps(planned_steps)
211                    .map_err(|_| self.fault_fixed_exhaustion())?;
212                let fixed_plan = self
213                    .schedule
214                    .fixed_accumulator_mut()
215                    .plan(frame_delta, &config);
216                if let Some(debt) = fixed_plan.dropped {
217                    emit(
218                        &mut self.observer,
219                        DiagnosticEvent::FixedDebtDropped { steps: debt.steps },
220                    );
221                }
222                if let Some(debt) = fixed_plan.coalesced {
223                    emit(
224                        &mut self.observer,
225                        DiagnosticEvent::FixedDebtCoalesced { steps: debt.steps },
226                    );
227                }
228                Some(fixed_plan)
229            } else {
230                self.world
231                    .preflight_world_tick()
232                    .map_err(|_| self.fault_tick_exhaustion())?;
233                None
234            }
235        } else {
236            self.world
237                .preflight_world_tick()
238                .map_err(|_| self.fault_tick_exhaustion())?;
239            None
240        };
241
242        self.world
243            .advance_world_tick()
244            .map_err(|_| self.fault_tick_exhaustion())?;
245
246        self.run_context.fixed_step = None;
247        let update_stage_count = self.schedule.update_stage_indices().len();
248        for stage_order_index in 0..update_stage_count {
249            let stage_index = self.schedule.update_stage_indices()[stage_order_index];
250            let stage_label = self.schedule.stage_label_at(stage_index);
251            let selected = plan.map_or(true, |plan| {
252                self.schedule.plan_contains_stage(plan, stage_index)
253            });
254            let startup_pending = stage_label == stage::STARTUP;
255            if !selected && !startup_pending {
256                continue;
257            }
258            if stage_label == stage::FIXED_UPDATE {
259                if let Some(config) = fixed_config {
260                    if let Some(fixed_plan) = fixed_plan {
261                        match fixed_plan.work {
262                            FixedWork::Steps(substeps) => {
263                                for _ in 0..substeps {
264                                    let step =
265                                        self.schedule.fixed_accumulator_mut().next_step(&config);
266                                    self.world.set_fixed_step(Some(step));
267                                    self.run_context.fixed_step = Some(step);
268                                    let result = self
269                                        .run_stage(stage_index, seconds_from_duration(step.delta));
270                                    self.world.set_fixed_step(None);
271                                    self.run_context.fixed_step = None;
272                                    result?;
273                                }
274                            }
275                            FixedWork::Coalesced { steps, delta } => {
276                                let steps = u64::try_from(steps)
277                                    .expect("fixed-step preflight accepts coalesced step count");
278                                let step = self
279                                    .schedule
280                                    .fixed_accumulator_mut()
281                                    .next_coalesced(steps, delta);
282                                self.world.set_fixed_step(Some(step));
283                                self.run_context.fixed_step = Some(step);
284                                let result =
285                                    self.run_stage(stage_index, seconds_from_duration(delta));
286                                self.world.set_fixed_step(None);
287                                self.run_context.fixed_step = None;
288                                result?;
289                            }
290                        }
291                    }
292                }
293                continue;
294            }
295            self.run_stage(stage_index, delta_seconds)?;
296        }
297
298        self.run_final_flush()?;
299        let observed = self.observe_with_cleanup(StageOperation::Update, observe);
300        self.schedule
301            .clear_frame_events(&mut self.world, StageOperation::Update);
302        emit(&mut self.observer, DiagnosticEvent::UpdateFinish);
303        Ok(observed)
304    }
305
306    /// Runs the Render pass for `delta_seconds`.
307    pub fn render(&mut self, delta_seconds: f32) -> Result<(), AppError> {
308        self.render_with(delta_seconds, |_| ())
309    }
310
311    /// Runs Render and returns a value observed from the world after frame cleanup.
312    pub fn render_with<R>(
313        &mut self,
314        delta_seconds: f32,
315        observe: impl FnOnce(&World) -> R,
316    ) -> Result<R, AppError> {
317        self.ensure_ready()?;
318        validate_delta(delta_seconds)?;
319        emit(
320            &mut self.observer,
321            DiagnosticEvent::RenderStart { delta_seconds },
322        );
323        self.run_context.fixed_step = None;
324        let run_result = {
325            let schedule = &mut self.schedule;
326            let world = &mut self.world;
327            let observer = &mut self.observer;
328            let context = &mut self.run_context;
329            let faulted = &mut self.faulted;
330            let fault = &mut self.fault;
331            catch_schedule_panic(|| {
332                with_terminal_unwind_cleanup(
333                    world,
334                    context,
335                    faulted,
336                    fault,
337                    StageOperation::Render,
338                    |world, context| {
339                        schedule.run_operation(
340                            world,
341                            StageOperation::Render,
342                            context,
343                            delta_seconds,
344                            observer,
345                        )
346                    },
347                )
348            })
349        };
350        handle_guarded_run(self, run_result)?;
351        let observed = self.observe_with_cleanup(StageOperation::Render, observe);
352        self.schedule
353            .clear_frame_events(&mut self.world, StageOperation::Render);
354        emit(&mut self.observer, DiagnosticEvent::RenderFinish);
355        Ok(observed)
356    }
357
358    fn ensure_ready(&self) -> Result<(), AppError> {
359        if self.faulted {
360            return Err(AppError::TerminalFault);
361        }
362        if self.world.is_mutation_poisoned() {
363            return Err(AppError::WorldMutationPoisoned);
364        }
365        if self.world.has_pending_commands() {
366            return Err(AppError::PendingIdleCommands);
367        }
368        Ok(())
369    }
370
371    fn run_stage(&mut self, stage_index: usize, dt: f32) -> Result<(), AppError> {
372        let run_result = {
373            let schedule = &mut self.schedule;
374            let world = &mut self.world;
375            let observer = &mut self.observer;
376            let context = &mut self.run_context;
377            let faulted = &mut self.faulted;
378            let fault = &mut self.fault;
379            catch_schedule_panic(|| {
380                with_terminal_unwind_cleanup(
381                    world,
382                    context,
383                    faulted,
384                    fault,
385                    StageOperation::Update,
386                    |world, context| schedule.run_stage(world, stage_index, context, dt, observer),
387                )
388            })
389        };
390        handle_guarded_run(self, run_result)
391    }
392
393    fn observe_with_cleanup<R>(
394        &mut self,
395        operation: StageOperation,
396        observe: impl FnOnce(&World) -> R,
397    ) -> R {
398        let run_result = {
399            let world = &mut self.world;
400            let context = &mut self.run_context;
401            let faulted = &mut self.faulted;
402            let fault = &mut self.fault;
403            catch_schedule_panic(|| {
404                with_terminal_unwind_cleanup(
405                    world,
406                    context,
407                    faulted,
408                    fault,
409                    operation,
410                    |world, _context| observe(world),
411                )
412            })
413        };
414        handle_guarded_value(self, run_result)
415    }
416
417    fn fault_tick_exhaustion(&mut self) -> AppError {
418        self.record_exhaustion_fault("world tick exhausted");
419        AppError::WorldTickExhausted
420    }
421
422    fn fault_fixed_exhaustion(&mut self) -> AppError {
423        self.record_exhaustion_fault("fixed step exhausted");
424        AppError::FixedStepExhausted
425    }
426
427    fn record_exhaustion_fault(&mut self, detail: &str) {
428        self.faulted = true;
429        if self.fault.is_none() {
430            self.fault = Some(AppFault {
431                stage: None,
432                system: None,
433                detail: Some(String::from(detail)),
434            });
435        }
436        self.world.set_fixed_step(None);
437        self.run_context.fixed_step = None;
438        let _ = self.world.discard_commands();
439        self.world.end_run();
440        emit(
441            &mut self.observer,
442            DiagnosticEvent::Fault {
443                stage: None,
444                system: None,
445            },
446        );
447    }
448
449    fn run_final_flush(&mut self) -> Result<(), AppError> {
450        let run_result = {
451            let schedule = &mut self.schedule;
452            let world = &mut self.world;
453            let observer = &mut self.observer;
454            let context = &mut self.run_context;
455            let faulted = &mut self.faulted;
456            let fault = &mut self.fault;
457            catch_schedule_panic(|| {
458                with_terminal_unwind_cleanup(
459                    world,
460                    context,
461                    faulted,
462                    fault,
463                    StageOperation::Update,
464                    |world, _context| schedule.run_final_update_flush(world, observer),
465                )
466            })
467        };
468        handle_guarded_run(self, run_result)
469    }
470
471    fn fault_from(&mut self, outcome: RunOutcome) -> AppError {
472        self.record_fault(&outcome);
473        AppError::Fault(AppFault {
474            stage: outcome.fault_stage,
475            system: outcome.fault_system,
476            detail: outcome.fault_detail,
477        })
478    }
479
480    fn record_fault(&mut self, outcome: &RunOutcome) {
481        self.faulted = true;
482        if self.fault.is_none() {
483            self.fault = Some(AppFault {
484                stage: outcome.fault_stage.clone(),
485                system: outcome.fault_system.clone(),
486                detail: outcome.fault_detail.clone(),
487            });
488        }
489        let _ = self.world.discard_commands();
490        self.world.set_fixed_step(None);
491        self.run_context.fixed_step = None;
492        self.world.end_run();
493        emit(
494            &mut self.observer,
495            DiagnosticEvent::Fault {
496                stage: outcome.fault_stage.as_deref(),
497                system: outcome.fault_system.as_deref(),
498            },
499        );
500    }
501
502    #[cfg(feature = "std")]
503    fn record_panic_fault(&mut self) {
504        self.faulted = true;
505        if self.fault.is_none() {
506            self.fault = Some(AppFault {
507                stage: None,
508                system: None,
509                detail: Some(String::from("panic during execution")),
510            });
511        }
512        let _ = self.world.discard_commands();
513        self.world.set_fixed_step(None);
514        self.run_context.fixed_step = None;
515        self.world.end_run();
516        emit(
517            &mut self.observer,
518            DiagnosticEvent::Fault {
519                stage: None,
520                system: None,
521            },
522        );
523    }
524}
525
526impl AppBuilder {
527    /// Creates a builder with a fresh world and standard schedule template.
528    pub fn new() -> AppBuilder {
529        Self {
530            world_builder: WorldBuilder::new(),
531            schedule_builder: ScheduleBuilder::standard(),
532            observer: None,
533        }
534    }
535
536    /// Mutable world construction surface for component and resource registration.
537    pub fn world_builder(&mut self) -> &mut WorldBuilder {
538        &mut self.world_builder
539    }
540
541    /// Mutable schedule authoring surface for systems, sets, and ordering.
542    pub fn schedule_builder(&mut self) -> &mut ScheduleBuilder {
543        &mut self.schedule_builder
544    }
545
546    /// Registers one system before schedule validation.
547    pub fn add_system(&mut self, system: System) -> Result<&mut Self, BuildError> {
548        self.schedule_builder.add_system(system)?;
549        Ok(self)
550    }
551
552    /// Registers and seeds a resource before schedule validation.
553    pub fn insert_resource<R: 'static>(&mut self, value: R) -> &mut Self {
554        self.world_builder.insert_resource(value);
555        self
556    }
557
558    /// Registers and seeds state before schedule validation.
559    pub fn insert_state<S: Eq + 'static>(&mut self, initial: S) -> &mut Self {
560        self.world_builder.insert_state(initial);
561        self
562    }
563
564    /// Installs fixed-timestep configuration for [`crate::schedule::stage::FIXED_UPDATE`].
565    pub fn fixed(&mut self, config: FixedConfig) -> &mut Self {
566        self.schedule_builder.fixed(config);
567        self
568    }
569
570    /// Overrides deferred-command flush policy for one stage label.
571    pub fn set_stage_flush_mode(
572        &mut self,
573        label: impl AsRef<str>,
574        mode: FlushMode,
575    ) -> Result<&mut Self, BuildError> {
576        self.schedule_builder.set_stage_flush_mode(label, mode)?;
577        Ok(self)
578    }
579
580    /// Registers a diagnostic observer invoked at pass and system boundaries.
581    pub fn observer(&mut self, observer: impl Observer + 'static) -> &mut Self {
582        self.observer = Some(Box::new(observer));
583        self
584    }
585
586    /// Validates and compiles the world and schedule into a runnable [`App`].
587    pub fn build(self) -> Result<App, BuildError> {
588        let mut world = self.world_builder.build()?;
589        let schedule = self.schedule_builder.build(&mut world)?;
590        let mut app = App::from_parts(world, schedule)?;
591        app.observer = self.observer;
592        Ok(app)
593    }
594
595    /// Declares a named [`SystemSet`] for ordering and run-if gates.
596    pub fn register_set(
597        &mut self,
598        set: crate::schedule::SystemSet,
599    ) -> Result<&mut Self, BuildError> {
600        self.schedule_builder.register_set(set)?;
601        Ok(self)
602    }
603
604    /// Attaches a [`crate::schedule::Condition`] to one registered set.
605    pub fn set_run_if(
606        &mut self,
607        set: &crate::schedule::SystemSet,
608        condition: crate::schedule::Condition,
609    ) -> Result<&mut Self, BuildError> {
610        self.schedule_builder.set_run_if(set, condition)?;
611        Ok(self)
612    }
613
614    /// Orders one set before another within shared stage ordering.
615    pub fn order_set_before(
616        &mut self,
617        before: &SystemSet,
618        after: &SystemSet,
619    ) -> Result<&mut Self, BuildError> {
620        self.schedule_builder.order_set_before(before, after)?;
621        Ok(self)
622    }
623
624    /// Orders one set after another within shared stage ordering.
625    pub fn order_set_after(
626        &mut self,
627        after: &SystemSet,
628        before: &SystemSet,
629    ) -> Result<&mut Self, BuildError> {
630        self.schedule_builder.order_set_after(after, before)?;
631        Ok(self)
632    }
633}
634
635impl Default for AppBuilder {
636    fn default() -> Self {
637        Self::new()
638    }
639}
640
641fn validate_delta(delta_seconds: f32) -> Result<(), AppError> {
642    if delta_seconds.is_sign_negative() || delta_seconds.is_nan() || delta_seconds.is_infinite() {
643        return Err(AppError::InvalidDelta);
644    }
645    Ok(())
646}
647
648fn duration_from_seconds(delta_seconds: f32) -> Result<Duration, AppError> {
649    Duration::try_from_secs_f32(delta_seconds).map_err(|_| AppError::InvalidDelta)
650}
651
652fn seconds_from_duration(duration: Duration) -> f32 {
653    duration.as_secs_f32()
654}
655
656fn emit(observer: &mut Option<Box<dyn Observer>>, event: DiagnosticEvent<'_>) {
657    if let Some(observer) = observer.as_mut() {
658        observer.observe(event);
659    }
660}
661
662enum GuardedRun<T> {
663    Completed(T),
664    #[cfg(feature = "std")]
665    Panicked(alloc::boxed::Box<dyn core::any::Any + Send>),
666}
667
668struct TerminalUnwindGuard<'a> {
669    world: &'a mut World,
670    run_context: &'a mut RunContext,
671    faulted: &'a mut bool,
672    fault: &'a mut Option<AppFault>,
673    operation: StageOperation,
674    armed: bool,
675}
676
677impl Drop for TerminalUnwindGuard<'_> {
678    fn drop(&mut self) {
679        if !self.armed {
680            return;
681        }
682
683        *self.faulted = true;
684        if self.fault.is_none() {
685            *self.fault = Some(AppFault {
686                stage: None,
687                system: None,
688                detail: Some(String::from("panic during execution")),
689            });
690        }
691        self.run_context.fixed_step = None;
692        self.world.set_fixed_step(None);
693        self.world.end_run();
694        let _ = self.world.discard_commands();
695        self.world.clear_frame_events(self.operation);
696    }
697}
698
699fn with_terminal_unwind_cleanup<R>(
700    world: &mut World,
701    run_context: &mut RunContext,
702    faulted: &mut bool,
703    fault: &mut Option<AppFault>,
704    operation: StageOperation,
705    run: impl FnOnce(&mut World, &mut RunContext) -> R,
706) -> R {
707    let mut guard = TerminalUnwindGuard {
708        world,
709        run_context,
710        faulted,
711        fault,
712        operation,
713        armed: true,
714    };
715    let result = run(&mut *guard.world, &mut *guard.run_context);
716    guard.armed = false;
717    result
718}
719
720fn catch_schedule_panic<R>(f: impl FnOnce() -> R) -> GuardedRun<R> {
721    #[cfg(feature = "std")]
722    {
723        match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
724            Ok(result) => GuardedRun::Completed(result),
725            Err(payload) => GuardedRun::Panicked(payload),
726        }
727    }
728    #[cfg(not(feature = "std"))]
729    {
730        GuardedRun::Completed(f())
731    }
732}
733
734fn handle_guarded_run<T>(
735    app: &mut App,
736    run: GuardedRun<Result<T, RunOutcome>>,
737) -> Result<T, AppError> {
738    match run {
739        GuardedRun::Completed(Ok(value)) => Ok(value),
740        GuardedRun::Completed(Err(outcome)) => Err(app.fault_from(outcome)),
741        #[cfg(feature = "std")]
742        GuardedRun::Panicked(payload) => {
743            app.world.end_run();
744            app.record_panic_fault();
745            std::panic::resume_unwind(payload);
746        }
747    }
748}
749
750fn handle_guarded_value<T>(_app: &mut App, run: GuardedRun<T>) -> T {
751    match run {
752        GuardedRun::Completed(value) => value,
753        #[cfg(feature = "std")]
754        GuardedRun::Panicked(payload) => {
755            _app.record_panic_fault();
756            std::panic::resume_unwind(payload);
757        }
758    }
759}
760
761#[cfg(feature = "std")]
762impl core::fmt::Display for AppError {
763    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
764        match self {
765            Self::InvalidDelta => f.write_str("invalid delta"),
766            Self::PendingIdleCommands => f.write_str("pending idle commands"),
767            Self::WorldMutationPoisoned => f.write_str("world mutation poisoned"),
768            Self::TerminalFault => f.write_str("terminal app fault"),
769            Self::WorldTickExhausted => f.write_str("world tick exhausted"),
770            Self::FixedStepExhausted => f.write_str("fixed step exhausted"),
771            Self::InvalidUpdatePlan(_) => f.write_str("invalid update plan"),
772            Self::Fault(_) => f.write_str("app execution fault"),
773        }
774    }
775}
776
777#[cfg(feature = "std")]
778impl std::error::Error for AppError {}
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783    use crate::component::ComponentOptions;
784    use crate::schedule::{stage, ScheduleBuilder, System};
785    use crate::time::{ChangeTick, FixedConfig};
786    use crate::world::WorldBuilder;
787    use alloc::vec::Vec;
788    use core::time::Duration;
789
790    #[derive(Clone, Copy)]
791    struct PoisonedComponent;
792
793    fn poison_world(world: &mut World) {
794        let entity = world.spawn().expect("entity");
795        world.insert(entity, PoisonedComponent).expect("seed");
796        world.set_change_tick_for_test(ChangeTick::from_raw(u64::MAX - 1));
797        world
798            .insert(entity, PoisonedComponent)
799            .expect("consume last tick");
800        assert!(matches!(
801            world.insert(entity, PoisonedComponent),
802            Err(crate::world::WorldError::ChangeTickExhausted)
803        ));
804    }
805
806    #[test]
807    fn from_parts_rejects_poisoned_world() {
808        let mut builder = WorldBuilder::new();
809        builder
810            .register_component::<PoisonedComponent>(ComponentOptions::sparse())
811            .expect("component");
812        let mut world = builder.build().expect("world");
813        let schedule = ScheduleBuilder::standard()
814            .build(&mut world)
815            .expect("schedule");
816        poison_world(&mut world);
817
818        assert!(matches!(
819            App::from_parts(world, schedule),
820            Err(BuildError::WorldMutationPoisoned)
821        ));
822    }
823
824    #[test]
825    fn update_rejects_poisoned_world() {
826        let mut builder = AppBuilder::new();
827        builder
828            .world_builder()
829            .register_component::<PoisonedComponent>(ComponentOptions::sparse())
830            .expect("component");
831        builder
832            .add_system(System::new("noop", stage::UPDATE, |_world, _dt| {}))
833            .expect("system");
834        let mut app = builder.build().expect("app");
835        poison_world(app.world_mut());
836
837        assert!(matches!(
838            app.update(1.0 / 60.0),
839            Err(AppError::WorldMutationPoisoned)
840        ));
841    }
842
843    #[test]
844    fn world_tick_exhaustion_faults_app() {
845        let mut app = AppBuilder::new().build().expect("app");
846        app.world_mut().set_world_tick_for_test(u64::MAX);
847
848        assert!(matches!(
849            app.update(1.0 / 60.0),
850            Err(AppError::WorldTickExhausted)
851        ));
852        assert!(app.is_faulted());
853        assert_eq!(
854            app.fault().and_then(|fault| fault.detail.as_deref()),
855            Some("world tick exhausted")
856        );
857    }
858
859    #[test]
860    fn caught_tick_exhaustion_faults_before_next_system() {
861        use core::sync::atomic::{AtomicU32, Ordering};
862
863        static LATER_RUNS: AtomicU32 = AtomicU32::new(0);
864        LATER_RUNS.store(0, Ordering::SeqCst);
865
866        #[derive(Clone, Copy)]
867        struct Counter;
868
869        let mut builder = AppBuilder::new();
870        builder.insert_resource(Counter);
871        builder
872            .add_system(System::new("poison", stage::UPDATE, |world, _dt| {
873                let _ = world.resource_mut::<Counter>();
874            }))
875            .expect("poison system");
876        builder
877            .add_system(System::new("later", stage::UPDATE, |_world, _dt| {
878                LATER_RUNS.fetch_add(1, Ordering::SeqCst);
879            }))
880            .expect("later system");
881        let mut app = builder.build().expect("app");
882        app.world_mut()
883            .set_change_tick_for_test(ChangeTick::from_raw(u64::MAX));
884
885        assert!(matches!(app.update(0.0), Err(AppError::Fault(_))));
886        assert_eq!(LATER_RUNS.load(Ordering::SeqCst), 0);
887        assert_eq!(
888            app.fault().and_then(|fault| fault.system.as_deref()),
889            Some("poison")
890        );
891    }
892
893    #[test]
894    fn fixed_step_exhaustion_records_fault() {
895        let fixed = FixedConfig::new(Duration::from_millis(16))
896            .expect("fixed")
897            .with_max_substeps(1)
898            .expect("cap");
899        let mut world = WorldBuilder::new().build().expect("world");
900        let mut schedule_builder = ScheduleBuilder::standard();
901        schedule_builder.fixed(fixed);
902        schedule_builder
903            .add_system(System::new("fixed", stage::FIXED_UPDATE, |_world, _dt| {}))
904            .expect("fixed");
905        let mut schedule = schedule_builder.build(&mut world).expect("schedule");
906        schedule
907            .fixed_accumulator_mut()
908            .set_next_index_for_test(u64::MAX);
909        let mut app = App::from_parts(world, schedule).expect("app");
910        assert!(matches!(app.update(1.0), Err(AppError::FixedStepExhausted)));
911        assert!(app.is_faulted());
912    }
913
914    #[test]
915    fn later_faults_preserve_the_first_terminal_fault() {
916        let mut app = AppBuilder::default().build().expect("app");
917        let first = AppFault {
918            stage: Some(String::from("first-stage")),
919            system: Some(String::from("first-system")),
920            detail: Some(String::from("first-detail")),
921        };
922        app.fault = Some(first.clone());
923
924        app.record_exhaustion_fault("later exhaustion");
925        assert_eq!(app.fault(), Some(&first));
926
927        app.record_fault(&RunOutcome {
928            fault_stage: Some(String::from("later-stage")),
929            fault_system: Some(String::from("later-system")),
930            fault_detail: Some(String::from("later-detail")),
931        });
932        assert_eq!(app.fault(), Some(&first));
933    }
934
935    #[test]
936    #[cfg(feature = "std")]
937    fn unwind_cleanup_preserves_an_existing_fault() {
938        let mut world = WorldBuilder::new().build().expect("world");
939        let mut context = RunContext::new();
940        let mut faulted = false;
941        let first = AppFault {
942            stage: Some(String::from("first-stage")),
943            system: Some(String::from("first-system")),
944            detail: Some(String::from("first-detail")),
945        };
946        let mut fault = Some(first.clone());
947
948        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
949            with_terminal_unwind_cleanup(
950                &mut world,
951                &mut context,
952                &mut faulted,
953                &mut fault,
954                StageOperation::Update,
955                |_world, _context| panic!("test panic"),
956            );
957        }));
958
959        assert!(result.is_err());
960        assert!(faulted);
961        assert_eq!(fault, Some(first));
962        assert!(world.run_guard_is_idle());
963    }
964
965    #[test]
966    #[cfg(feature = "std")]
967    fn fixed_system_panic_clears_world_and_run_context_steps() {
968        let fixed = FixedConfig::new(Duration::from_millis(16)).expect("fixed");
969        let mut builder = AppBuilder::new();
970        builder.fixed(fixed);
971        builder
972            .add_system(System::new("panic", stage::FIXED_UPDATE, |world, _dt| {
973                assert!(world.fixed_step().is_some());
974                world
975                    .commands()
976                    .expect("commands")
977                    .spawn()
978                    .expect("reserve");
979                panic!("fixed panic");
980            }))
981            .expect("system");
982        let mut app = builder.build().expect("app");
983
984        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
985            let _ = app.update(0.016);
986        }));
987
988        assert!(result.is_err());
989        assert!(app.world.fixed_step().is_none());
990        assert!(app.run_context.fixed_step.is_none());
991        assert!(app.world.run_guard_is_idle());
992        assert!(!app.world.has_pending_commands());
993    }
994
995    #[cfg(feature = "std")]
996    #[test]
997    fn panic_fault_can_be_recorded_directly_without_prior_fault() {
998        let mut app = AppBuilder::default().build().expect("app");
999        app.record_panic_fault();
1000        assert!(app.is_faulted());
1001        assert_eq!(
1002            app.fault().and_then(|fault| fault.detail.as_deref()),
1003            Some("panic during execution")
1004        );
1005    }
1006
1007    #[test]
1008    fn builder_set_order_after_delegates_and_default_constructs() {
1009        let before = SystemSet::new("before");
1010        let after = SystemSet::new("after");
1011        let mut builder = AppBuilder::default();
1012        builder.insert_resource(Vec::<&'static str>::new());
1013        builder.register_set(before.clone()).expect("before set");
1014        builder.register_set(after.clone()).expect("after set");
1015        builder
1016            .add_system(
1017                System::new("after", stage::UPDATE, |world, _dt| {
1018                    world
1019                        .resource_mut::<Vec<&'static str>>()
1020                        .expect("trace access")
1021                        .expect("trace resource")
1022                        .push("after");
1023                })
1024                .in_set(&after),
1025            )
1026            .expect("after system");
1027        builder
1028            .add_system(
1029                System::new("before", stage::UPDATE, |world, _dt| {
1030                    world
1031                        .resource_mut::<Vec<&'static str>>()
1032                        .expect("trace access")
1033                        .expect("trace resource")
1034                        .push("before");
1035                })
1036                .in_set(&before),
1037            )
1038            .expect("before system");
1039        builder
1040            .order_set_after(&after, &before)
1041            .expect("order after");
1042
1043        let mut app = builder.build().expect("app");
1044        app.update(0.0).expect("update");
1045        assert_eq!(
1046            app.world()
1047                .resource::<Vec<&'static str>>()
1048                .expect("trace access")
1049                .expect("trace resource")
1050                .as_slice(),
1051            ["before", "after"]
1052        );
1053    }
1054}