Skip to main content

murk_engine/
config.rs

1//! World configuration, validation, and error types.
2//!
3//! [`WorldConfig`] is the builder-input for constructing a simulation world.
4//! [`validate()`](WorldConfig::validate) checks structural invariants at
5//! startup; the actual world constructor (WP-5b) calls `validate_pipeline()`
6//! directly to obtain the [`ReadResolutionPlan`](murk_propagator::ReadResolutionPlan).
7
8use std::error::Error;
9use std::fmt;
10
11use murk_arena::ArenaError;
12use murk_core::{FieldDef, FieldId, FieldSet};
13use murk_propagator::{validate_pipeline, PipelineError, Propagator};
14use murk_space::Space;
15
16// ── BackoffConfig ──────────────────────────────────────────────────
17
18/// Configuration for the adaptive command rejection backoff (§6.11).
19///
20/// When consecutive tick rollbacks occur, the engine increases the
21/// allowed skew between the command's basis tick and the current tick.
22/// This struct controls the shape of that backoff curve.
23#[derive(Clone, Debug)]
24pub struct BackoffConfig {
25    /// Initial maximum skew tolerance (ticks). Default: 2.
26    pub initial_max_skew: u64,
27    /// Multiplicative factor applied on each consecutive rollback. Default: 1.5.
28    pub backoff_factor: f64,
29    /// Upper bound on the skew tolerance. Default: 10.
30    pub max_skew_cap: u64,
31    /// Number of ticks after last rollback before skew resets. Default: 60.
32    pub decay_rate: u64,
33    /// Fraction of rejected commands that triggers proactive backoff. Default: 0.20.
34    pub rejection_rate_threshold: f64,
35}
36
37impl Default for BackoffConfig {
38    fn default() -> Self {
39        Self {
40            initial_max_skew: 2,
41            backoff_factor: 1.5,
42            max_skew_cap: 10,
43            decay_rate: 60,
44            rejection_rate_threshold: 0.20,
45        }
46    }
47}
48
49// ── AsyncConfig ───────────────────────────────────────────────────
50
51/// Configuration for [`RealtimeAsyncWorld`](crate::realtime::RealtimeAsyncWorld).
52///
53/// Controls the egress worker pool size and epoch-hold budget that
54/// governs the shutdown state machine and stalled-worker detection.
55#[derive(Clone, Debug)]
56pub struct AsyncConfig {
57    /// Number of egress worker threads. `None` = auto-detect
58    /// (`available_parallelism / 2`, clamped to `[2, 16]`).
59    pub worker_count: Option<usize>,
60    /// Maximum milliseconds a worker may hold an epoch pin before being
61    /// considered stalled and forcibly unpinned. Default: 100.
62    pub max_epoch_hold_ms: u64,
63    /// Grace period (ms) after cancellation before the worker is
64    /// forcibly unpinned. Default: 10.
65    pub cancel_grace_ms: u64,
66}
67
68impl Default for AsyncConfig {
69    fn default() -> Self {
70        Self {
71            worker_count: None,
72            max_epoch_hold_ms: 100,
73            cancel_grace_ms: 10,
74        }
75    }
76}
77
78impl AsyncConfig {
79    /// Resolve the actual worker count, applying auto-detection if `None`.
80    ///
81    /// Explicit values are clamped to `[1, 64]`. Zero workers would
82    /// create an unusable world (no egress threads to service observations).
83    pub fn resolved_worker_count(&self) -> usize {
84        match self.worker_count {
85            Some(n) => n.clamp(1, 64),
86            None => {
87                let cpus = std::thread::available_parallelism()
88                    .map(|n| n.get())
89                    .unwrap_or(4);
90                (cpus / 2).clamp(2, 16)
91            }
92        }
93    }
94}
95
96// ── ConfigError ────────────────────────────────────────────────────
97
98/// Errors detected during [`WorldConfig::validate()`].
99#[derive(Debug, PartialEq)]
100pub enum ConfigError {
101    /// Propagator pipeline validation failed.
102    Pipeline(PipelineError),
103    /// Arena configuration is invalid.
104    Arena(ArenaError),
105    /// Space has zero cells.
106    EmptySpace,
107    /// No fields registered.
108    NoFields,
109    /// Ring buffer size is below the minimum of 2.
110    RingBufferTooSmall {
111        /// The configured size that was too small.
112        configured: usize,
113    },
114    /// Ingress queue capacity is zero.
115    IngressQueueZero,
116    /// tick_rate_hz is NaN, infinite, zero, or negative.
117    InvalidTickRate {
118        /// The invalid value.
119        value: f64,
120    },
121    /// `initial_max_skew` exceeds `max_skew_cap`.
122    BackoffSkewExceedsCap {
123        /// The configured initial max skew.
124        initial: u64,
125        /// The configured cap.
126        cap: u64,
127    },
128    /// `backoff_factor` is not finite or is less than 1.0.
129    BackoffInvalidFactor {
130        /// The invalid value.
131        value: f64,
132    },
133    /// `rejection_rate_threshold` is outside `[0.0, 1.0]` or not finite.
134    BackoffInvalidThreshold {
135        /// The invalid value.
136        value: f64,
137    },
138    /// `decay_rate` is zero.
139    BackoffZeroDecayRate,
140    /// Cell count exceeds `u32::MAX`.
141    CellCountOverflow {
142        /// The value that overflowed.
143        value: usize,
144    },
145    /// Field count exceeds `u32::MAX`.
146    FieldCountOverflow {
147        /// The value that overflowed.
148        value: usize,
149    },
150    /// A field definition failed validation.
151    InvalidField {
152        /// Description of the validation failure.
153        reason: String,
154    },
155    /// Engine could not be recovered from tick thread (e.g. thread panicked).
156    EngineRecoveryFailed,
157    /// A background thread could not be spawned.
158    ThreadSpawnFailed {
159        /// Description of which thread failed.
160        reason: String,
161    },
162}
163
164impl fmt::Display for ConfigError {
165    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166        match self {
167            Self::Pipeline(e) => write!(f, "pipeline: {e}"),
168            Self::Arena(e) => write!(f, "arena: {e}"),
169            Self::EmptySpace => write!(f, "space has zero cells"),
170            Self::NoFields => write!(f, "no fields registered"),
171            Self::RingBufferTooSmall { configured } => {
172                write!(f, "ring_buffer_size {configured} is below minimum of 2")
173            }
174            Self::IngressQueueZero => write!(f, "max_ingress_queue must be at least 1"),
175            Self::InvalidTickRate { value } => {
176                write!(f, "tick_rate_hz must be finite and positive, got {value}")
177            }
178            Self::BackoffSkewExceedsCap { initial, cap } => {
179                write!(f, "invalid backoff config: initial_max_skew ({initial}) exceeds max_skew_cap ({cap})")
180            }
181            Self::BackoffInvalidFactor { value } => {
182                write!(
183                    f,
184                    "invalid backoff config: backoff_factor must be finite and >= 1.0, got {value}"
185                )
186            }
187            Self::BackoffInvalidThreshold { value } => {
188                write!(f, "invalid backoff config: rejection_rate_threshold must be in [0.0, 1.0], got {value}")
189            }
190            Self::BackoffZeroDecayRate => {
191                write!(f, "invalid backoff config: decay_rate must be at least 1")
192            }
193            Self::CellCountOverflow { value } => {
194                write!(f, "cell count {value} exceeds u32::MAX")
195            }
196            Self::FieldCountOverflow { value } => {
197                write!(f, "field count {value} exceeds u32::MAX")
198            }
199            Self::InvalidField { reason } => {
200                write!(f, "invalid field: {reason}")
201            }
202            Self::EngineRecoveryFailed => {
203                write!(f, "engine could not be recovered from tick thread")
204            }
205            Self::ThreadSpawnFailed { reason } => {
206                write!(f, "thread spawn failed: {reason}")
207            }
208        }
209    }
210}
211
212impl Error for ConfigError {
213    fn source(&self) -> Option<&(dyn Error + 'static)> {
214        match self {
215            Self::Pipeline(e) => Some(e),
216            Self::Arena(e) => Some(e),
217            _ => None,
218        }
219    }
220}
221
222impl From<PipelineError> for ConfigError {
223    fn from(e: PipelineError) -> Self {
224        Self::Pipeline(e)
225    }
226}
227
228impl From<ArenaError> for ConfigError {
229    fn from(e: ArenaError) -> Self {
230        Self::Arena(e)
231    }
232}
233
234// ── WorldConfig ────────────────────────────────────────────────────
235
236/// Complete configuration for constructing a simulation world.
237///
238/// Passed to the world constructor (WP-5b). `validate()` checks all
239/// structural invariants without producing intermediate artifacts.
240pub struct WorldConfig {
241    /// Spatial topology for the simulation.
242    pub space: Box<dyn Space>,
243    /// Field definitions. `FieldId(n)` corresponds to `fields[n]`.
244    pub fields: Vec<FieldDef>,
245    /// Propagators executed in pipeline order each tick.
246    pub propagators: Vec<Box<dyn Propagator>>,
247    /// Simulation timestep in seconds.
248    pub dt: f64,
249    /// RNG seed for deterministic simulation.
250    pub seed: u64,
251    /// Number of snapshots retained in the ring buffer. Default: 8. Minimum: 2.
252    pub ring_buffer_size: usize,
253    /// Maximum commands buffered in the ingress queue. Default: 1024.
254    pub max_ingress_queue: usize,
255    /// Optional target tick rate for realtime-async mode.
256    pub tick_rate_hz: Option<f64>,
257    /// Adaptive backoff configuration.
258    pub backoff: BackoffConfig,
259}
260
261impl WorldConfig {
262    /// Validate all structural invariants.
263    ///
264    /// This is a pure validation pass — it does not return a
265    /// `ReadResolutionPlan`. The world constructor calls
266    /// `validate_pipeline()` directly to obtain the plan.
267    pub fn validate(&self) -> Result<(), ConfigError> {
268        // 1. Space must have at least one cell.
269        if self.space.cell_count() == 0 {
270            return Err(ConfigError::EmptySpace);
271        }
272        // 2. Must have at least one field.
273        if self.fields.is_empty() {
274            return Err(ConfigError::NoFields);
275        }
276        // 2a. Each field must pass structural validation.
277        for field in &self.fields {
278            field
279                .validate()
280                .map_err(|reason| ConfigError::InvalidField { reason })?;
281        }
282        // 2b. Cell count must fit in u32 (arena uses u32 internally).
283        let cell_count = self.space.cell_count();
284        if u32::try_from(cell_count).is_err() {
285            return Err(ConfigError::CellCountOverflow { value: cell_count });
286        }
287        // 2c. Field count must fit in u32 (FieldId is u32).
288        if u32::try_from(self.fields.len()).is_err() {
289            return Err(ConfigError::FieldCountOverflow {
290                value: self.fields.len(),
291            });
292        }
293        // 3. Ring buffer >= 2.
294        if self.ring_buffer_size < 2 {
295            return Err(ConfigError::RingBufferTooSmall {
296                configured: self.ring_buffer_size,
297            });
298        }
299        // 4. Ingress queue >= 1.
300        if self.max_ingress_queue == 0 {
301            return Err(ConfigError::IngressQueueZero);
302        }
303        // 5. tick_rate_hz, if present, must be finite and positive, and
304        //    its reciprocal must also be finite (rejects subnormals where
305        //    1.0/hz = inf, which would panic in Duration::from_secs_f64).
306        if let Some(hz) = self.tick_rate_hz {
307            if !hz.is_finite() || hz <= 0.0 || !(1.0 / hz).is_finite() {
308                return Err(ConfigError::InvalidTickRate { value: hz });
309            }
310        }
311        // 6. BackoffConfig invariants.
312        let b = &self.backoff;
313        if b.initial_max_skew > b.max_skew_cap {
314            return Err(ConfigError::BackoffSkewExceedsCap {
315                initial: b.initial_max_skew,
316                cap: b.max_skew_cap,
317            });
318        }
319        if !b.backoff_factor.is_finite() || b.backoff_factor < 1.0 {
320            return Err(ConfigError::BackoffInvalidFactor {
321                value: b.backoff_factor,
322            });
323        }
324        if !b.rejection_rate_threshold.is_finite()
325            || b.rejection_rate_threshold < 0.0
326            || b.rejection_rate_threshold > 1.0
327        {
328            return Err(ConfigError::BackoffInvalidThreshold {
329                value: b.rejection_rate_threshold,
330            });
331        }
332        if b.decay_rate == 0 {
333            return Err(ConfigError::BackoffZeroDecayRate);
334        }
335
336        // 7. Pipeline validation (delegates to murk-propagator).
337        //    The plan is intentionally discarded here — the world constructor
338        //    calls validate_pipeline() again to obtain it.
339        let defined = self.defined_field_set()?;
340        let _ = validate_pipeline(&self.propagators, &defined, self.dt, &*self.space)?;
341
342        Ok(())
343    }
344
345    /// Build a [`FieldSet`] from the configured field definitions.
346    ///
347    /// Returns [`ConfigError::FieldCountOverflow`] if the number of
348    /// fields exceeds `u32::MAX`.
349    pub(crate) fn defined_field_set(&self) -> Result<FieldSet, ConfigError> {
350        (0..self.fields.len())
351            .map(|i| {
352                u32::try_from(i)
353                    .map(FieldId)
354                    .map_err(|_| ConfigError::FieldCountOverflow {
355                        value: self.fields.len(),
356                    })
357            })
358            .collect()
359    }
360}
361
362impl fmt::Debug for WorldConfig {
363    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
364        f.debug_struct("WorldConfig")
365            .field("space_ndim", &self.space.ndim())
366            .field("space_cell_count", &self.space.cell_count())
367            .field("fields", &self.fields.len())
368            .field("propagators", &self.propagators.len())
369            .field("dt", &self.dt)
370            .field("seed", &self.seed)
371            .field("ring_buffer_size", &self.ring_buffer_size)
372            .field("max_ingress_queue", &self.max_ingress_queue)
373            .field("tick_rate_hz", &self.tick_rate_hz)
374            .field("backoff", &self.backoff)
375            .finish()
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use murk_core::{BoundaryBehavior, FieldMutability, FieldType};
383    use murk_space::{EdgeBehavior, Line1D};
384    use murk_test_utils::ConstPropagator;
385
386    fn scalar_field(name: &str) -> FieldDef {
387        FieldDef {
388            name: name.to_string(),
389            field_type: FieldType::Scalar,
390            mutability: FieldMutability::PerTick,
391            units: None,
392            bounds: None,
393            boundary_behavior: BoundaryBehavior::Clamp,
394        }
395    }
396
397    fn valid_config() -> WorldConfig {
398        WorldConfig {
399            space: Box::new(Line1D::new(10, EdgeBehavior::Absorb).unwrap()),
400            fields: vec![scalar_field("energy")],
401            propagators: vec![Box::new(ConstPropagator::new("const", FieldId(0), 1.0))],
402            dt: 0.1,
403            seed: 42,
404            ring_buffer_size: 8,
405            max_ingress_queue: 1024,
406            tick_rate_hz: None,
407            backoff: BackoffConfig::default(),
408        }
409    }
410
411    #[test]
412    fn validate_valid_config_succeeds() {
413        assert!(valid_config().validate().is_ok());
414    }
415
416    #[test]
417    fn validate_empty_propagators_fails() {
418        let mut cfg = valid_config();
419        cfg.propagators.clear();
420        match cfg.validate() {
421            Err(ConfigError::Pipeline(PipelineError::EmptyPipeline)) => {}
422            other => panic!("expected Pipeline(EmptyPipeline), got {other:?}"),
423        }
424    }
425
426    #[test]
427    fn validate_invalid_dt_fails() {
428        let mut cfg = valid_config();
429        cfg.dt = f64::NAN;
430        match cfg.validate() {
431            Err(ConfigError::Pipeline(PipelineError::InvalidDt { .. })) => {}
432            other => panic!("expected Pipeline(InvalidDt), got {other:?}"),
433        }
434    }
435
436    #[test]
437    fn validate_write_conflict_fails() {
438        let mut cfg = valid_config();
439        // Two propagators writing the same field.
440        cfg.propagators
441            .push(Box::new(ConstPropagator::new("conflict", FieldId(0), 2.0)));
442        match cfg.validate() {
443            Err(ConfigError::Pipeline(PipelineError::WriteConflict(_))) => {}
444            other => panic!("expected Pipeline(WriteConflict), got {other:?}"),
445        }
446    }
447
448    #[test]
449    fn validate_dt_exceeds_max_dt_fails() {
450        use murk_core::PropagatorError;
451        use murk_propagator::context::StepContext;
452        use murk_propagator::propagator::WriteMode;
453
454        struct CflProp;
455        impl Propagator for CflProp {
456            fn name(&self) -> &str {
457                "cfl"
458            }
459            fn reads(&self) -> FieldSet {
460                FieldSet::empty()
461            }
462            fn writes(&self) -> Vec<(FieldId, WriteMode)> {
463                vec![(FieldId(0), WriteMode::Full)]
464            }
465            fn max_dt(&self, _space: &dyn murk_space::Space) -> Option<f64> {
466                Some(0.01)
467            }
468            fn step(&self, _ctx: &mut StepContext<'_>) -> Result<(), PropagatorError> {
469                Ok(())
470            }
471        }
472
473        let mut cfg = valid_config();
474        cfg.propagators = vec![Box::new(CflProp)];
475        cfg.dt = 0.1;
476        match cfg.validate() {
477            Err(ConfigError::Pipeline(PipelineError::DtTooLarge { .. })) => {}
478            other => panic!("expected Pipeline(DtTooLarge), got {other:?}"),
479        }
480    }
481
482    #[test]
483    fn validate_empty_space_fails() {
484        use murk_space::error::SpaceError;
485        // Line1D::new(0, ...) returns Err, so we need a custom space with 0 cells.
486        struct EmptySpace(murk_core::SpaceInstanceId);
487        impl Space for EmptySpace {
488            fn ndim(&self) -> usize {
489                1
490            }
491            fn cell_count(&self) -> usize {
492                0
493            }
494            fn neighbours(
495                &self,
496                _: &murk_core::Coord,
497            ) -> smallvec::SmallVec<[murk_core::Coord; 8]> {
498                smallvec::smallvec![]
499            }
500            fn distance(&self, _: &murk_core::Coord, _: &murk_core::Coord) -> f64 {
501                0.0
502            }
503            fn compile_region(
504                &self,
505                _: &murk_space::RegionSpec,
506            ) -> Result<murk_space::RegionPlan, SpaceError> {
507                Err(SpaceError::EmptySpace)
508            }
509            fn canonical_ordering(&self) -> Vec<murk_core::Coord> {
510                vec![]
511            }
512            fn instance_id(&self) -> murk_core::SpaceInstanceId {
513                self.0
514            }
515            fn topology_eq(&self, other: &dyn Space) -> bool {
516                (other as &dyn std::any::Any)
517                    .downcast_ref::<Self>()
518                    .is_some()
519            }
520        }
521
522        let mut cfg = valid_config();
523        cfg.space = Box::new(EmptySpace(murk_core::SpaceInstanceId::next()));
524        match cfg.validate() {
525            Err(ConfigError::EmptySpace) => {}
526            other => panic!("expected EmptySpace, got {other:?}"),
527        }
528    }
529
530    #[test]
531    fn validate_no_fields_fails() {
532        let mut cfg = valid_config();
533        cfg.fields.clear();
534        match cfg.validate() {
535            Err(ConfigError::NoFields) => {}
536            other => panic!("expected NoFields, got {other:?}"),
537        }
538    }
539
540    #[test]
541    fn cell_count_overflow_display_says_cell_count() {
542        let err = ConfigError::CellCountOverflow {
543            value: u32::MAX as usize + 1,
544        };
545        let msg = format!("{err}");
546        assert!(
547            msg.contains("cell count"),
548            "CellCountOverflow Display should say 'cell count', got: {msg}"
549        );
550    }
551
552    #[test]
553    fn field_count_overflow_display_says_field_count() {
554        let err = ConfigError::FieldCountOverflow {
555            value: u32::MAX as usize + 1,
556        };
557        let msg = format!("{err}");
558        assert!(
559            msg.contains("field count"),
560            "FieldCountOverflow Display should say 'field count', got: {msg}"
561        );
562    }
563
564    #[test]
565    fn async_config_resolved_worker_count_clamps_zero() {
566        let cfg = AsyncConfig {
567            worker_count: Some(0),
568            ..AsyncConfig::default()
569        };
570        assert_eq!(cfg.resolved_worker_count(), 1);
571    }
572
573    #[test]
574    fn async_config_resolved_worker_count_clamps_large() {
575        let cfg = AsyncConfig {
576            worker_count: Some(200),
577            ..AsyncConfig::default()
578        };
579        assert_eq!(cfg.resolved_worker_count(), 64);
580    }
581
582    #[test]
583    fn async_config_resolved_worker_count_auto() {
584        let cfg = AsyncConfig::default();
585        let count = cfg.resolved_worker_count();
586        assert!(
587            (2..=16).contains(&count),
588            "auto count {count} out of [2,16]"
589        );
590    }
591
592    // ── BackoffConfig validation ─────────────────────────────
593
594    #[test]
595    fn validate_backoff_initial_exceeds_cap_fails() {
596        let mut cfg = valid_config();
597        cfg.backoff.initial_max_skew = 100;
598        cfg.backoff.max_skew_cap = 5;
599        match cfg.validate() {
600            Err(ConfigError::BackoffSkewExceedsCap {
601                initial: 100,
602                cap: 5,
603            }) => {}
604            other => panic!("expected BackoffSkewExceedsCap, got {other:?}"),
605        }
606    }
607
608    #[test]
609    fn validate_backoff_nan_factor_fails() {
610        let mut cfg = valid_config();
611        cfg.backoff.backoff_factor = f64::NAN;
612        match cfg.validate() {
613            Err(ConfigError::BackoffInvalidFactor { .. }) => {}
614            other => panic!("expected BackoffInvalidFactor, got {other:?}"),
615        }
616    }
617
618    #[test]
619    fn validate_backoff_factor_below_one_fails() {
620        let mut cfg = valid_config();
621        cfg.backoff.backoff_factor = 0.5;
622        match cfg.validate() {
623            Err(ConfigError::BackoffInvalidFactor { value: 0.5 }) => {}
624            other => panic!("expected BackoffInvalidFactor(0.5), got {other:?}"),
625        }
626    }
627
628    #[test]
629    fn validate_backoff_threshold_out_of_range_fails() {
630        let mut cfg = valid_config();
631        cfg.backoff.rejection_rate_threshold = 1.5;
632        match cfg.validate() {
633            Err(ConfigError::BackoffInvalidThreshold { value: 1.5 }) => {}
634            other => panic!("expected BackoffInvalidThreshold(1.5), got {other:?}"),
635        }
636    }
637
638    #[test]
639    fn validate_backoff_zero_decay_rate_fails() {
640        let mut cfg = valid_config();
641        cfg.backoff.decay_rate = 0;
642        match cfg.validate() {
643            Err(ConfigError::BackoffZeroDecayRate) => {}
644            other => panic!("expected BackoffZeroDecayRate, got {other:?}"),
645        }
646    }
647
648    /// BUG-103: ThreadSpawnFailed error variant exists and formats correctly.
649    #[test]
650    fn thread_spawn_failed_error_display() {
651        let err = ConfigError::ThreadSpawnFailed {
652            reason: "tick thread: resource limit".to_string(),
653        };
654        let msg = format!("{err}");
655        assert!(msg.contains("thread spawn failed"));
656        assert!(msg.contains("tick thread"));
657    }
658
659    /// BUG-104: Subnormal tick_rate_hz passes validation but 1/hz = inf
660    /// panics in Duration::from_secs_f64.
661    #[test]
662    fn validate_subnormal_tick_rate_hz_rejected() {
663        let mut cfg = valid_config();
664        cfg.tick_rate_hz = Some(f64::from_bits(1)); // smallest positive subnormal
665        match cfg.validate() {
666            Err(ConfigError::InvalidTickRate { .. }) => {}
667            other => panic!("expected InvalidTickRate, got {other:?}"),
668        }
669    }
670
671    #[test]
672    fn validate_valid_backoff_succeeds() {
673        let mut cfg = valid_config();
674        cfg.backoff = BackoffConfig {
675            initial_max_skew: 5,
676            max_skew_cap: 10,
677            backoff_factor: 1.5,
678            decay_rate: 60,
679            rejection_rate_threshold: 0.20,
680        };
681        assert!(cfg.validate().is_ok());
682    }
683
684    #[test]
685    fn thread_spawn_failed_error_source_is_none() {
686        use std::error::Error;
687        let err = ConfigError::ThreadSpawnFailed {
688            reason: "egress worker 2: resource limit".into(),
689        };
690        assert!(err.source().is_none());
691    }
692
693    #[test]
694    fn thread_spawn_failed_reason_preserved() {
695        let err = ConfigError::ThreadSpawnFailed {
696            reason: "egress worker 2: os error 11".into(),
697        };
698        match &err {
699            ConfigError::ThreadSpawnFailed { reason } => {
700                assert_eq!(reason, "egress worker 2: os error 11");
701            }
702            _ => panic!("wrong variant"),
703        }
704    }
705
706    #[test]
707    fn thread_spawn_failed_debug_contains_reason() {
708        let err = ConfigError::ThreadSpawnFailed {
709            reason: "tick thread: RLIMIT_NPROC".into(),
710        };
711        let dbg = format!("{err:?}");
712        assert!(dbg.contains("RLIMIT_NPROC"), "Debug output: {dbg}");
713    }
714}