Skip to main content

hataori/
domain.rs

1use std::fmt;
2use std::sync::atomic::{AtomicBool, Ordering};
3
4/// The P0 execution-domain identifier.
5///
6/// P0 supports only [`DomainId::ZERO`]. Other numeric identifiers are rejected
7/// by [`TryFrom`].
8#[repr(transparent)]
9#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
10pub struct DomainId(u32);
11
12impl DomainId {
13    /// The only domain identifier supported by P0.
14    pub const ZERO: Self = Self(0);
15
16    /// Returns the numeric identifier.
17    pub const fn get(self) -> u32 {
18        self.0
19    }
20}
21
22impl TryFrom<u32> for DomainId {
23    type Error = UnsupportedDomainId;
24
25    fn try_from(requested: u32) -> Result<Self, Self::Error> {
26        match requested {
27            0 => Ok(Self::ZERO),
28            requested => Err(UnsupportedDomainId { requested }),
29        }
30    }
31}
32
33/// The requested domain identifier is not supported by P0.
34#[derive(Debug, Copy, Clone, Eq, PartialEq)]
35pub struct UnsupportedDomainId {
36    requested: u32,
37}
38
39impl UnsupportedDomainId {
40    /// Returns the unsupported identifier that was requested.
41    pub const fn requested(self) -> u32 {
42        self.requested
43    }
44}
45
46impl fmt::Display for UnsupportedDomainId {
47    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48        write!(formatter, "unsupported domain ID: {}", self.requested)
49    }
50}
51
52impl std::error::Error for UnsupportedDomainId {}
53
54/// A rank and its execution-domain identifier.
55///
56/// P0 uses zero-based non-negative ranks and only accepts the supported domain
57/// identifiers. [`Place::new`] rejects a negative rank.
58#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
59pub struct Place {
60    rank: i32,
61    domain_id: DomainId,
62}
63
64impl Place {
65    /// Creates a place, rejecting a negative rank.
66    pub fn new(rank: i32, domain_id: DomainId) -> Result<Self, NegativeRank> {
67        if rank < 0 {
68            return Err(NegativeRank { requested: rank });
69        }
70        Ok(Self { rank, domain_id })
71    }
72
73    /// Returns the non-negative rank.
74    pub const fn rank(self) -> i32 {
75        self.rank
76    }
77
78    /// Returns the execution-domain identifier.
79    pub const fn domain_id(self) -> DomainId {
80        self.domain_id
81    }
82}
83
84/// A negative rank was supplied to [`Place::new`].
85#[derive(Debug, Copy, Clone, Eq, PartialEq)]
86pub struct NegativeRank {
87    requested: i32,
88}
89
90impl NegativeRank {
91    /// Returns the negative rank that was requested.
92    pub const fn requested(self) -> i32 {
93        self.requested
94    }
95}
96
97impl fmt::Display for NegativeRank {
98    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
99        write!(formatter, "rank must be non-negative: {}", self.requested)
100    }
101}
102
103impl std::error::Error for NegativeRank {}
104
105/// Local execution policy for a domain operation.
106#[derive(Debug, Copy, Clone, Eq, PartialEq)]
107pub enum LocalMode {
108    /// Run callbacks one at a time in the selected pool and stop at the first error.
109    Sequential,
110    /// Run every callback in parallel in the selected pool, preserve input order,
111    /// and report the lowest callback-error index after all callbacks finish.
112    Outer,
113    /// Run callbacks one at a time in the selected pool and allow nested work in
114    /// that same pool; stop at the first error.
115    Inner,
116}
117
118#[cfg(feature = "rayon")]
119/// Whether a Rayon pool is owned by Hataori or by its caller.
120#[derive(Debug, Copy, Clone, Eq, PartialEq)]
121pub enum PoolOwnership {
122    /// The domain created and owns the pool.
123    Managed,
124    /// The pool belongs to the caller.
125    External,
126}
127
128#[cfg(feature = "rayon")]
129/// Whether the domain's CPU placement was verified.
130#[derive(Debug, Copy, Clone, Eq, PartialEq)]
131pub enum PlacementStatus {
132    /// Linux worker startup pinned and verified every worker.
133    Verified,
134    /// Placement is a declaration supplied by the caller.
135    CallerDeclared,
136}
137
138#[cfg(feature = "rayon")]
139/// A managed or external Rayon domain could not be constructed.
140pub enum DomainBuildError {
141    EmptyCpuSet,
142    DuplicateCpu {
143        cpu: usize,
144    },
145    ZeroWorkers,
146    TooManyWorkers {
147        worker_count: usize,
148        cpu_count: usize,
149    },
150    CpuOutOfRange {
151        cpu: usize,
152        cpu_set_size: usize,
153    },
154    CpuNotAllowed {
155        cpu: usize,
156    },
157    AffinityQuery {
158        errno: i32,
159    },
160    PoolBuild {
161        message: String,
162    },
163    PinFailed {
164        worker_index: usize,
165        cpu: usize,
166        message: String,
167    },
168    StartTimeout {
169        expected: usize,
170        received: usize,
171    },
172    InvalidReport {
173        worker_index: usize,
174        cpu: usize,
175    },
176    PoolSizeMismatch {
177        expected: usize,
178        actual: usize,
179    },
180}
181
182#[cfg(feature = "rayon")]
183impl fmt::Debug for DomainBuildError {
184    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
185        match self {
186            Self::EmptyCpuSet => formatter.write_str("EmptyCpuSet"),
187            Self::DuplicateCpu { cpu } => formatter
188                .debug_struct("DuplicateCpu")
189                .field("cpu", cpu)
190                .finish(),
191            Self::ZeroWorkers => formatter.write_str("ZeroWorkers"),
192            Self::TooManyWorkers {
193                worker_count,
194                cpu_count,
195            } => formatter
196                .debug_struct("TooManyWorkers")
197                .field("worker_count", worker_count)
198                .field("cpu_count", cpu_count)
199                .finish(),
200            Self::CpuOutOfRange { cpu, cpu_set_size } => formatter
201                .debug_struct("CpuOutOfRange")
202                .field("cpu", cpu)
203                .field("cpu_set_size", cpu_set_size)
204                .finish(),
205            Self::CpuNotAllowed { cpu } => formatter
206                .debug_struct("CpuNotAllowed")
207                .field("cpu", cpu)
208                .finish(),
209            Self::AffinityQuery { errno } => formatter
210                .debug_struct("AffinityQuery")
211                .field("errno", errno)
212                .finish(),
213            Self::PoolBuild { message } => formatter
214                .debug_struct("PoolBuild")
215                .field("message", message)
216                .finish(),
217            Self::PinFailed {
218                worker_index,
219                cpu,
220                message,
221            } => formatter
222                .debug_struct("PinFailed")
223                .field("worker_index", worker_index)
224                .field("cpu", cpu)
225                .field("message", message)
226                .finish(),
227            Self::StartTimeout { expected, received } => formatter
228                .debug_struct("StartTimeout")
229                .field("expected", expected)
230                .field("received", received)
231                .finish(),
232            Self::InvalidReport { worker_index, cpu } => formatter
233                .debug_struct("InvalidReport")
234                .field("worker_index", worker_index)
235                .field("cpu", cpu)
236                .finish(),
237            Self::PoolSizeMismatch { expected, actual } => formatter
238                .debug_struct("PoolSizeMismatch")
239                .field("expected", expected)
240                .field("actual", actual)
241                .finish(),
242        }
243    }
244}
245
246#[cfg(feature = "rayon")]
247impl fmt::Display for DomainBuildError {
248    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
249        match self {
250            Self::EmptyCpuSet => formatter.write_str("CPU set must not be empty"),
251            Self::DuplicateCpu { cpu } => write!(formatter, "CPU set contains duplicate CPU {cpu}"),
252            Self::ZeroWorkers => formatter.write_str("worker count must be greater than zero"),
253            Self::TooManyWorkers {
254                worker_count,
255                cpu_count,
256            } => write!(
257                formatter,
258                "worker count {worker_count} exceeds CPU set size {cpu_count}"
259            ),
260            Self::CpuOutOfRange { cpu, cpu_set_size } => {
261                write!(
262                    formatter,
263                    "CPU {cpu} is outside CPU set size {cpu_set_size}"
264                )
265            }
266            Self::CpuNotAllowed { cpu } => write!(formatter, "CPU {cpu} is not process-allowed"),
267            Self::AffinityQuery { errno } => write!(
268                formatter,
269                "could not query process affinity (errno {errno})"
270            ),
271            Self::PoolBuild { message } => {
272                write!(formatter, "could not build Rayon pool: {message}")
273            }
274            Self::PinFailed {
275                worker_index,
276                cpu,
277                message,
278            } => write!(
279                formatter,
280                "worker {worker_index} could not be placed on CPU {cpu}: {message}"
281            ),
282            Self::StartTimeout { expected, received } => write!(
283                formatter,
284                "timed out waiting for Rayon worker startup reports ({received}/{expected})"
285            ),
286            Self::InvalidReport { worker_index, cpu } => write!(
287                formatter,
288                "invalid Rayon worker startup report for worker {worker_index} on CPU {cpu}"
289            ),
290            Self::PoolSizeMismatch { expected, actual } => write!(
291                formatter,
292                "external Rayon pool has {actual} workers, expected {expected}"
293            ),
294        }
295    }
296}
297
298#[cfg(feature = "rayon")]
299impl std::error::Error for DomainBuildError {}
300
301/// A P0 execution domain.
302///
303/// `Domain::sequential` creates domain zero with one worker and no Rayon pool.
304/// At most one coarse operation may be admitted at a time; [`Domain::try_admit`]
305/// returns [`DomainBusy`] immediately instead of waiting when another operation
306/// owns the domain.
307///
308/// # Example
309///
310/// ```
311/// use hataori::{Domain, DomainBusy};
312///
313/// let domain = Domain::sequential();
314/// let admission = domain.try_admit().unwrap();
315/// assert_eq!(domain.try_admit().unwrap_err(), DomainBusy);
316/// drop(admission);
317/// assert!(domain.try_admit().is_ok());
318/// ```
319// No Clone: a domain owns admission state and, with Rayon, a pool handle.
320pub struct Domain {
321    id: DomainId,
322    worker_count: usize,
323    running: AtomicBool,
324    #[cfg(feature = "rayon")]
325    pool: Option<std::sync::Arc<rayon::ThreadPool>>,
326    #[cfg(feature = "rayon")]
327    cpu_set: Option<Vec<usize>>,
328    #[cfg(feature = "rayon")]
329    ownership: Option<PoolOwnership>,
330    #[cfg(feature = "rayon")]
331    placement: Option<PlacementStatus>,
332}
333
334impl fmt::Debug for Domain {
335    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
336        let mut debug = formatter.debug_struct("Domain");
337        debug
338            .field("id", &self.id)
339            .field("worker_count", &self.worker_count)
340            .field("running", &self.running.load(Ordering::Relaxed));
341        #[cfg(feature = "rayon")]
342        debug
343            .field("has_pool", &self.pool.is_some())
344            .field("cpu_set", &self.cpu_set)
345            .field("ownership", &self.ownership)
346            .field("placement", &self.placement);
347        debug.finish()
348    }
349}
350
351impl Domain {
352    /// Creates the P0 sequential domain: ID zero, one worker, and no running
353    /// operation.
354    pub fn sequential() -> Self {
355        Self {
356            id: DomainId::ZERO,
357            worker_count: 1,
358            running: AtomicBool::new(false),
359            #[cfg(feature = "rayon")]
360            pool: None,
361            #[cfg(feature = "rayon")]
362            cpu_set: None,
363            #[cfg(feature = "rayon")]
364            ownership: None,
365            #[cfg(feature = "rayon")]
366            placement: None,
367        }
368    }
369
370    /// Returns the domain identifier.
371    pub const fn id(&self) -> DomainId {
372        self.id
373    }
374
375    /// Returns the declared worker count.
376    pub const fn worker_count(&self) -> usize {
377        self.worker_count
378    }
379
380    /// Attempts to admit one coarse operation without waiting.
381    pub fn try_admit(&self) -> Result<DomainAdmission<'_>, DomainBusy> {
382        self.running
383            .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
384            .map(|_| DomainAdmission { domain: self })
385            .map_err(|_| DomainBusy)
386    }
387
388    #[cfg(feature = "rayon")]
389    /// Creates and owns a Rayon pool, verifying worker affinity on Linux.
390    pub fn managed(cpu_set: Vec<usize>, worker_count: usize) -> Result<Self, DomainBuildError> {
391        validate_cpu_set(&cpu_set, worker_count)?;
392        #[cfg(target_os = "linux")]
393        {
394            validate_linux_cpu_range(&cpu_set)?;
395            let allowed = process_allowed_cpus()?;
396            for &cpu in &cpu_set {
397                if !allowed.contains(&cpu) {
398                    return Err(DomainBuildError::CpuNotAllowed { cpu });
399                }
400            }
401        }
402        build_managed(cpu_set, worker_count, std::sync::Arc::new(DefaultPinner))
403    }
404
405    #[cfg(feature = "rayon")]
406    /// Uses a caller-owned Rayon pool without changing its worker affinity or
407    /// shutting it down when this domain is dropped.
408    pub fn external(
409        pool: std::sync::Arc<rayon::ThreadPool>,
410        declared_cpu_set: Vec<usize>,
411        worker_count: usize,
412    ) -> Result<Self, DomainBuildError> {
413        validate_cpu_set(&declared_cpu_set, worker_count)?;
414        #[cfg(target_os = "linux")]
415        validate_linux_cpu_range(&declared_cpu_set)?;
416        let actual = pool.current_num_threads();
417        if actual != worker_count {
418            return Err(DomainBuildError::PoolSizeMismatch {
419                expected: worker_count,
420                actual,
421            });
422        }
423        Ok(Self {
424            id: DomainId::ZERO,
425            worker_count,
426            running: AtomicBool::new(false),
427            pool: Some(pool),
428            cpu_set: Some(declared_cpu_set),
429            ownership: Some(PoolOwnership::External),
430            placement: Some(PlacementStatus::CallerDeclared),
431        })
432    }
433
434    #[cfg(feature = "rayon")]
435    /// Returns the declared or verified CPU set, when this domain has a pool.
436    pub fn cpu_set(&self) -> Option<&[usize]> {
437        self.cpu_set.as_deref()
438    }
439
440    #[cfg(feature = "rayon")]
441    /// Returns the pool ownership mode, when this domain has a pool.
442    pub fn pool_ownership(&self) -> Option<PoolOwnership> {
443        self.ownership
444    }
445
446    #[cfg(feature = "rayon")]
447    /// Returns the placement status, when this domain has a pool.
448    pub fn placement_status(&self) -> Option<PlacementStatus> {
449        self.placement
450    }
451
452    #[cfg(feature = "rayon")]
453    /// Clones the Rayon pool handle associated with this domain, when present.
454    ///
455    /// This grants no domain admission. Submitting work directly to the returned
456    /// pool bypasses Hataori scheduling; integration adapters must enter through
457    /// an already admitted [`LocalMode::Inner`] callback.
458    ///
459    /// # Examples
460    ///
461    /// ```
462    /// # #[cfg(feature = "rayon")]
463    /// # {
464    /// use hataori::Domain;
465    /// use std::sync::Arc;
466    ///
467    /// let pool = Arc::new(rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap());
468    /// let domain = Domain::external(Arc::clone(&pool), vec![0], 1).unwrap();
469    /// assert!(Arc::ptr_eq(&pool, &domain.rayon_pool_handle().unwrap()));
470    /// # }
471    /// ```
472    pub fn rayon_pool_handle(&self) -> Option<std::sync::Arc<rayon::ThreadPool>> {
473        self.pool.clone()
474    }
475
476    #[cfg(feature = "rayon")]
477    pub(crate) fn rayon_pool(&self) -> Option<&std::sync::Arc<rayon::ThreadPool>> {
478        self.pool.as_ref()
479    }
480}
481
482#[cfg(feature = "rayon")]
483fn validate_cpu_set(cpu_set: &[usize], worker_count: usize) -> Result<(), DomainBuildError> {
484    if cpu_set.is_empty() {
485        return Err(DomainBuildError::EmptyCpuSet);
486    }
487    let mut seen = std::collections::HashSet::with_capacity(cpu_set.len());
488    for &cpu in cpu_set {
489        if !seen.insert(cpu) {
490            return Err(DomainBuildError::DuplicateCpu { cpu });
491        }
492    }
493    if worker_count == 0 {
494        return Err(DomainBuildError::ZeroWorkers);
495    }
496    if worker_count > cpu_set.len() {
497        return Err(DomainBuildError::TooManyWorkers {
498            worker_count,
499            cpu_count: cpu_set.len(),
500        });
501    }
502    Ok(())
503}
504
505#[cfg(all(feature = "rayon", target_os = "linux"))]
506fn validate_linux_cpu_range(cpu_set: &[usize]) -> Result<(), DomainBuildError> {
507    let cpu_set_size = libc::CPU_SETSIZE as usize;
508    for &cpu in cpu_set {
509        if cpu >= cpu_set_size {
510            return Err(DomainBuildError::CpuOutOfRange { cpu, cpu_set_size });
511        }
512    }
513    Ok(())
514}
515
516#[cfg(all(feature = "rayon", target_os = "linux"))]
517fn process_allowed_cpus() -> Result<std::collections::HashSet<usize>, DomainBuildError> {
518    let mut allowed = unsafe { std::mem::zeroed::<libc::cpu_set_t>() };
519    let result =
520        unsafe { libc::sched_getaffinity(0, std::mem::size_of::<libc::cpu_set_t>(), &mut allowed) };
521    if result != 0 {
522        return Err(DomainBuildError::AffinityQuery {
523            errno: std::io::Error::last_os_error().raw_os_error().unwrap_or(-1),
524        });
525    }
526    let mut cpus = std::collections::HashSet::new();
527    for cpu in 0..libc::CPU_SETSIZE as usize {
528        if unsafe { libc::CPU_ISSET(cpu, &allowed) } {
529            cpus.insert(cpu);
530        }
531    }
532    Ok(cpus)
533}
534
535#[cfg(feature = "rayon")]
536trait Pinner: Send + Sync {
537    fn pin_and_verify(&self, cpu: usize) -> Result<(), String>;
538}
539
540#[cfg(feature = "rayon")]
541struct DefaultPinner;
542
543#[cfg(all(feature = "rayon", target_os = "linux"))]
544impl Pinner for DefaultPinner {
545    fn pin_and_verify(&self, cpu: usize) -> Result<(), String> {
546        let mut set = unsafe { std::mem::zeroed::<libc::cpu_set_t>() };
547        unsafe { libc::CPU_ZERO(&mut set) };
548        unsafe { libc::CPU_SET(cpu, &mut set) };
549        let result = unsafe {
550            libc::pthread_setaffinity_np(
551                libc::pthread_self(),
552                std::mem::size_of::<libc::cpu_set_t>(),
553                &set,
554            )
555        };
556        if result != 0 {
557            return Err(format!("pthread_setaffinity_np failed (errno {result})"));
558        }
559        let actual = unsafe { libc::sched_getcpu() };
560        if actual != cpu as i32 {
561            return Err(format!("worker ran on CPU {actual}"));
562        }
563        Ok(())
564    }
565}
566
567#[cfg(all(feature = "rayon", not(target_os = "linux")))]
568impl Pinner for DefaultPinner {
569    fn pin_and_verify(&self, _cpu: usize) -> Result<(), String> {
570        Ok(())
571    }
572}
573
574#[cfg(feature = "rayon")]
575struct WorkerReport {
576    index: usize,
577    cpu: usize,
578    result: Result<(), String>,
579}
580
581#[cfg(feature = "rayon")]
582fn build_managed(
583    cpu_set: Vec<usize>,
584    worker_count: usize,
585    pinner: std::sync::Arc<dyn Pinner>,
586) -> Result<Domain, DomainBuildError> {
587    let expected_cpus = cpu_set.clone();
588    let start_cpus = expected_cpus.clone();
589    let (sender, receiver) = std::sync::mpsc::channel();
590    let builder = rayon::ThreadPoolBuilder::new()
591        .num_threads(worker_count)
592        .thread_name(|index| format!("hataori-worker-{index}"))
593        .start_handler(move |index| {
594            let cpu = start_cpus.get(index).copied().unwrap_or(usize::MAX);
595            let result = if cpu == usize::MAX {
596                Err(String::from("worker index is outside the declared CPU set"))
597            } else {
598                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
599                    pinner.pin_and_verify(cpu)
600                }))
601                .unwrap_or_else(|_| Err(String::from("worker pinner panicked")))
602            };
603            let _ = sender.send(WorkerReport { index, cpu, result });
604        });
605    let pool = builder
606        .build()
607        .map_err(|error| DomainBuildError::PoolBuild {
608            message: error.to_string(),
609        })?;
610
611    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
612    let mut seen = vec![false; worker_count];
613    for received in 0..worker_count {
614        let report = match receiver
615            .recv_timeout(deadline.saturating_duration_since(std::time::Instant::now()))
616        {
617            Ok(report) => report,
618            Err(_) => {
619                drop(pool);
620                return Err(DomainBuildError::StartTimeout {
621                    expected: worker_count,
622                    received,
623                });
624            }
625        };
626        if report.index >= worker_count
627            || seen[report.index]
628            || expected_cpus.get(report.index).copied() != Some(report.cpu)
629        {
630            drop(pool);
631            return Err(DomainBuildError::InvalidReport {
632                worker_index: report.index,
633                cpu: report.cpu,
634            });
635        }
636        seen[report.index] = true;
637        if let Err(message) = report.result {
638            drop(pool);
639            return Err(DomainBuildError::PinFailed {
640                worker_index: report.index,
641                cpu: report.cpu,
642                message,
643            });
644        }
645    }
646
647    Ok(Domain {
648        id: DomainId::ZERO,
649        worker_count,
650        running: AtomicBool::new(false),
651        pool: Some(std::sync::Arc::new(pool)),
652        cpu_set: Some(cpu_set),
653        ownership: Some(PoolOwnership::Managed),
654        placement: Some(if cfg!(target_os = "linux") {
655            PlacementStatus::Verified
656        } else {
657            PlacementStatus::CallerDeclared
658        }),
659    })
660}
661
662/// RAII ownership of a domain's one running-operation slot.
663///
664/// Dropping the admission releases the slot with release ordering. The guard
665/// is scoped and does not provide a waiting or reentrant admission path.
666#[must_use]
667#[derive(Debug)]
668pub struct DomainAdmission<'a> {
669    domain: &'a Domain,
670}
671
672impl Drop for DomainAdmission<'_> {
673    fn drop(&mut self) {
674        self.domain.running.store(false, Ordering::Release);
675    }
676}
677
678/// The domain's admission slot is already occupied.
679#[derive(Debug, Copy, Clone, Eq, PartialEq)]
680pub struct DomainBusy;
681
682impl fmt::Display for DomainBusy {
683    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
684        formatter.write_str("domain is busy")
685    }
686}
687
688impl std::error::Error for DomainBusy {}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693    use std::panic::{catch_unwind, AssertUnwindSafe};
694    use std::sync::{mpsc, Barrier};
695    use std::thread;
696
697    #[test]
698    fn domain_id_zero_conversion_and_nonzero_error() {
699        let zero = DomainId::try_from(0).unwrap();
700        assert_eq!(zero, DomainId::ZERO);
701        assert_eq!(zero.get(), 0);
702
703        let error = DomainId::try_from(1).unwrap_err();
704        assert_eq!(error.requested(), 1);
705        assert_eq!(error.to_string(), "unsupported domain ID: 1");
706    }
707
708    #[test]
709    fn place_valid_getters_and_negative_rank_error() {
710        let place = Place::new(3, DomainId::ZERO).unwrap();
711        assert_eq!(place.rank(), 3);
712        assert_eq!(place.domain_id(), DomainId::ZERO);
713
714        let error = Place::new(-1, DomainId::ZERO).unwrap_err();
715        assert_eq!(error.requested(), -1);
716        assert_eq!(error.to_string(), "rank must be non-negative: -1");
717    }
718
719    #[test]
720    fn sequential_domain_has_zero_id_and_one_worker() {
721        let domain = Domain::sequential();
722        assert_eq!(domain.id(), DomainId::ZERO);
723        assert_eq!(domain.worker_count(), 1);
724        #[cfg(feature = "rayon")]
725        {
726            assert_eq!(domain.cpu_set(), None);
727            assert_eq!(domain.pool_ownership(), None);
728            assert_eq!(domain.placement_status(), None);
729            assert!(domain.pool.is_none());
730        }
731    }
732
733    #[test]
734    fn nested_admission_is_busy_and_reacquires_after_drop() {
735        let domain = Domain::sequential();
736        let admission = domain.try_admit().unwrap();
737        assert_eq!(domain.try_admit().unwrap_err(), DomainBusy);
738        drop(admission);
739        assert!(domain.try_admit().is_ok());
740    }
741
742    fn returns_error(domain: &Domain) -> Result<(), &'static str> {
743        let _admission = domain.try_admit().unwrap();
744        Err("ordinary error")
745    }
746
747    #[test]
748    fn admission_releases_after_helper_returns_error() {
749        let domain = Domain::sequential();
750        assert_eq!(returns_error(&domain), Err("ordinary error"));
751        assert!(domain.try_admit().is_ok());
752    }
753
754    #[test]
755    fn admission_releases_during_unwind() {
756        let domain = Domain::sequential();
757        let result = catch_unwind(AssertUnwindSafe(|| {
758            let _admission = domain.try_admit().unwrap();
759            panic!("test panic");
760        }));
761        assert!(result.is_err());
762        assert!(domain.try_admit().is_ok());
763    }
764
765    #[test]
766    fn scoped_cross_thread_admission_is_nonblocking_and_reacquires() {
767        let domain = Domain::sequential();
768        let barrier = Barrier::new(2);
769        let (release_tx, release_rx) = mpsc::channel();
770        let (busy_tx, busy_rx) = mpsc::channel();
771        let (released_tx, released_rx) = mpsc::channel();
772        let (success_tx, success_rx) = mpsc::channel();
773        let domain_ref = &domain;
774
775        thread::scope(|scope| {
776            let holder_domain = domain_ref;
777            let holder_barrier = &barrier;
778            scope.spawn(move || {
779                let admission = holder_domain.try_admit().unwrap();
780                holder_barrier.wait();
781                release_rx.recv().unwrap();
782                drop(admission);
783                released_tx.send(()).unwrap();
784            });
785
786            barrier.wait();
787            let contender_domain = domain_ref;
788            scope.spawn(move || {
789                busy_tx
790                    .send(matches!(contender_domain.try_admit(), Err(DomainBusy)))
791                    .unwrap();
792                release_tx.send(()).unwrap();
793                released_rx.recv().unwrap();
794                success_tx
795                    .send(contender_domain.try_admit().is_ok())
796                    .unwrap();
797            });
798
799            assert!(busy_rx.recv().unwrap());
800            assert!(success_rx.recv().unwrap());
801        });
802    }
803
804    #[cfg(feature = "rayon")]
805    struct FailingPinner;
806
807    #[cfg(feature = "rayon")]
808    impl Pinner for FailingPinner {
809        fn pin_and_verify(&self, _cpu: usize) -> Result<(), String> {
810            Err(String::from("injected failure"))
811        }
812    }
813
814    #[cfg(feature = "rayon")]
815    #[test]
816    fn managed_rejects_invalid_worker_configuration() {
817        assert!(matches!(
818            Domain::managed(Vec::new(), 1),
819            Err(DomainBuildError::EmptyCpuSet)
820        ));
821        assert!(matches!(
822            Domain::managed(vec![0, 0], 1),
823            Err(DomainBuildError::DuplicateCpu { cpu: 0 })
824        ));
825        assert!(matches!(
826            Domain::managed(vec![0], 0),
827            Err(DomainBuildError::ZeroWorkers)
828        ));
829        assert!(matches!(
830            Domain::managed(vec![0], 2),
831            Err(DomainBuildError::TooManyWorkers {
832                worker_count: 2,
833                cpu_count: 1
834            })
835        ));
836    }
837
838    #[cfg(feature = "rayon")]
839    #[test]
840    fn external_rejects_pool_size_mismatch() {
841        let pool = std::sync::Arc::new(
842            rayon::ThreadPoolBuilder::new()
843                .num_threads(2)
844                .build()
845                .unwrap(),
846        );
847        assert!(matches!(
848            Domain::external(pool, vec![0], 1),
849            Err(DomainBuildError::PoolSizeMismatch {
850                expected: 1,
851                actual: 2
852            })
853        ));
854    }
855
856    #[cfg(feature = "rayon")]
857    #[test]
858    fn external_does_not_own_or_pin_caller_pool() {
859        let starts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
860        let starts_in_handler = std::sync::Arc::clone(&starts);
861        let caller_pool = std::sync::Arc::new(
862            rayon::ThreadPoolBuilder::new()
863                .num_threads(1)
864                .start_handler(move |_| {
865                    starts_in_handler.fetch_add(1, Ordering::Relaxed);
866                })
867                .build()
868                .unwrap(),
869        );
870        let domain = Domain::external(std::sync::Arc::clone(&caller_pool), vec![0], 1).unwrap();
871        assert_eq!(domain.pool_ownership(), Some(PoolOwnership::External));
872        assert_eq!(
873            domain.placement_status(),
874            Some(PlacementStatus::CallerDeclared)
875        );
876        drop(domain);
877
878        caller_pool.install(|| assert_eq!(rayon::current_num_threads(), 1));
879        assert_eq!(starts.load(Ordering::Relaxed), 1);
880    }
881
882    #[cfg(all(feature = "rayon", target_os = "linux"))]
883    #[test]
884    fn managed_rejects_linux_cpu_bounds_and_process_disallowed_cpu() {
885        let out_of_range = libc::CPU_SETSIZE as usize;
886        assert!(matches!(
887            Domain::managed(vec![out_of_range], 1),
888            Err(DomainBuildError::CpuOutOfRange {
889                cpu,
890                cpu_set_size
891            }) if cpu == out_of_range && cpu_set_size == out_of_range
892        ));
893
894        let allowed = process_allowed_cpus().unwrap();
895        let disallowed = (0..libc::CPU_SETSIZE as usize).find(|cpu| !allowed.contains(cpu));
896        let Some(disallowed) = disallowed else {
897            return;
898        };
899        assert!(matches!(
900            Domain::managed(vec![disallowed], 1),
901            Err(DomainBuildError::CpuNotAllowed { cpu }) if cpu == disallowed
902        ));
903    }
904
905    #[cfg(feature = "rayon")]
906    #[test]
907    fn managed_pin_failure_is_typed_and_does_not_panic() {
908        let result = catch_unwind(AssertUnwindSafe(|| {
909            build_managed(vec![0], 1, std::sync::Arc::new(FailingPinner))
910        }))
911        .unwrap();
912        assert!(matches!(result, Err(DomainBuildError::PinFailed { .. })));
913    }
914
915    #[cfg(all(feature = "rayon", target_os = "linux"))]
916    #[test]
917    fn managed_workers_are_on_distinct_allowed_cpus() {
918        let allowed = process_allowed_cpus().unwrap();
919        assert!(!allowed.is_empty());
920        let cpus: Vec<_> = allowed.iter().copied().take(2).collect();
921        let worker_count = cpus.len().max(1);
922        let domain = Domain::managed(cpus.clone(), worker_count).unwrap();
923        assert_eq!(domain.placement_status(), Some(PlacementStatus::Verified));
924        let observed = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
925        let copy = std::sync::Arc::clone(&observed);
926        domain.pool.as_ref().unwrap().broadcast(|_| {
927            let cpu = unsafe { libc::sched_getcpu() } as usize;
928            copy.lock().unwrap().push(cpu);
929        });
930        let observed = observed.lock().unwrap();
931        assert_eq!(observed.len(), worker_count);
932        assert!(observed.iter().all(|cpu| cpus.contains(cpu)));
933        let distinct: std::collections::HashSet<_> = observed.iter().copied().collect();
934        assert_eq!(distinct.len(), worker_count);
935    }
936
937    #[cfg(all(feature = "rayon", not(target_os = "linux")))]
938    #[test]
939    fn managed_non_linux_placement_is_caller_declared() {
940        let domain = Domain::managed(vec![0], 1).unwrap();
941        assert_eq!(
942            domain.placement_status(),
943            Some(PlacementStatus::CallerDeclared)
944        );
945    }
946}