Skip to main content

aft/
cold_build_limiter.rs

1use std::collections::VecDeque;
2use std::sync::atomic::{AtomicUsize, Ordering};
3use std::sync::{Arc, LazyLock, Mutex};
4use std::time::{Duration, Instant};
5
6#[cfg(not(test))]
7const DEFAULT_COLD_BUILD_LIMIT: usize = 2;
8#[cfg(test)]
9const DEFAULT_COLD_BUILD_LIMIT: usize = 1024;
10
11// This is an internal harness record, not a user-facing limiter setting. The
12// test harness performs 32 release/admission cycles, so retain enough events to
13// cover that exercise while bounding memory use in a long-lived daemon.
14const ADMISSION_EVENT_RETENTION: usize = 64;
15
16static GLOBAL_COLD_BUILD_LIMITER: LazyLock<Arc<ColdBuildLimiter>> =
17    LazyLock::new(|| Arc::new(ColdBuildLimiter::new(DEFAULT_COLD_BUILD_LIMIT)));
18
19pub(crate) fn global_limiter() -> Arc<ColdBuildLimiter> {
20    Arc::clone(&GLOBAL_COLD_BUILD_LIMITER)
21}
22
23pub(crate) fn isolated_limiter(limit: usize) -> Arc<ColdBuildLimiter> {
24    Arc::new(ColdBuildLimiter::new(limit))
25}
26
27pub fn try_acquire() -> Option<ColdBuildPermit> {
28    GLOBAL_COLD_BUILD_LIMITER.try_acquire()
29}
30
31/// Block until a build slot is free, then take it.
32///
33/// For build sites with no reschedule path (search-index builds spawn once per
34/// configure): skipping would strand the index, so past-cap work waits instead.
35/// Production captures showed concurrent per-root builds starving dispatch
36/// while CPU sat idle; waiting serializes that pressure at the source. Only
37/// call from dedicated background threads, never the dispatch thread or an
38/// executor worker.
39pub fn acquire_blocking(kind: &str) -> ColdBuildPermit {
40    acquire_blocking_while(kind, || true).expect("unconditional cold-build admission")
41}
42
43/// Wait for a build slot while `admitted` remains true. The predicate is checked
44/// before every attempt, so a root that becomes unbound does not consume a slot
45/// after spending time queued behind the process-wide cap.
46pub fn acquire_blocking_while(kind: &str, admitted: impl Fn() -> bool) -> Option<ColdBuildPermit> {
47    acquire_blocking_while_with_limiter(&GLOBAL_COLD_BUILD_LIMITER, kind, admitted)
48}
49
50pub(crate) fn acquire_blocking_while_with_limiter(
51    limiter: &Arc<ColdBuildLimiter>,
52    kind: &str,
53    admitted: impl Fn() -> bool,
54) -> Option<ColdBuildPermit> {
55    let request = ColdBuildAdmissionRequest::new(kind, ColdBuildAdmissionClass::Maintenance);
56    acquire_blocking_while_inner(limiter, kind, Some(&request), admitted, || false)
57}
58
59/// Identify the source of a cold-build request without exposing a limiter knob.
60///
61/// The classes deliberately have no absolute priority ordering. When a class
62/// was admitted most recently and another class is waiting, the limiter defers
63/// that repeat admission. Standing adds a yielding class to this existing
64/// rotation; it never installs a priority retry path.
65#[derive(Clone, Copy, Debug, Eq, PartialEq)]
66pub(crate) enum ColdBuildAdmissionClass {
67    InspectTriggered,
68    Maintenance,
69    Standing,
70}
71
72const ADMISSION_CLASS_COUNT: usize = 3;
73
74impl ColdBuildAdmissionClass {
75    const fn index(self) -> usize {
76        match self {
77            Self::InspectTriggered => 0,
78            Self::Maintenance => 1,
79            Self::Standing => 2,
80        }
81    }
82
83    const fn label(self) -> &'static str {
84        match self {
85            Self::InspectTriggered => "inspect-triggered",
86            Self::Maintenance => "maintenance",
87            Self::Standing => "standing",
88        }
89    }
90}
91
92/// Internal request metadata attached to an admission attempt.
93#[derive(Clone, Debug, Eq, PartialEq)]
94pub(crate) struct ColdBuildAdmissionRequest {
95    request_id: String,
96    class: ColdBuildAdmissionClass,
97}
98
99impl ColdBuildAdmissionRequest {
100    pub(crate) fn new(request_id: impl Into<String>, class: ColdBuildAdmissionClass) -> Self {
101        Self {
102            request_id: request_id.into(),
103            class,
104        }
105    }
106}
107
108/// A structured record of a successful cold-build admission.
109///
110/// `admission_order` records the order in which permits were admitted, not the
111/// order in which waiters arrived. Arrival-order and overtake checks require a
112/// separate ticketed-ordering design.
113#[derive(Clone, Debug, Eq, PartialEq)]
114pub(crate) struct ColdBuildAdmissionEvent {
115    pub(crate) request_id: String,
116    pub(crate) class: ColdBuildAdmissionClass,
117    pub(crate) admission_order: u64,
118}
119
120/// Attempt immediate admission without bypassing queued requests from the other
121/// class. Background schedulers use this when they can defer rejected work.
122pub(crate) fn try_acquire_classified_with_limiter(
123    limiter: &Arc<ColdBuildLimiter>,
124    request: &ColdBuildAdmissionRequest,
125) -> Option<ColdBuildPermit> {
126    limiter.try_acquire_classified(request, || true)
127}
128
129/// Acquire a limiter permit for a classified request while it remains admitted
130/// and uncancelled.
131///
132/// Cancellation is sampled before every acquisition attempt and again after a
133/// permit has been acquired. The second check closes the gap before a build can
134/// start: a newly cancelled request returns the permit without emitting an
135/// admission event.
136pub(crate) fn acquire_blocking_while_cancellable_with_limiter(
137    limiter: &Arc<ColdBuildLimiter>,
138    kind: &str,
139    request: ColdBuildAdmissionRequest,
140    admitted: impl Fn() -> bool,
141    cancelled: impl Fn() -> bool,
142) -> Option<ColdBuildPermit> {
143    acquire_blocking_while_inner(limiter, kind, Some(&request), admitted, cancelled)
144}
145
146/// A Standing permit preserves the lifecycle admission epoch captured before
147/// limiter acquisition. Checkpoint code drops it before yielding and carries the
148/// same epoch into the next attempt, so an obsolete build cannot become current
149/// merely by waiting for a slot.
150#[derive(Debug)]
151pub(crate) struct StandingColdBuildPermit {
152    _permit: ColdBuildPermit,
153    pub(crate) admission_epoch: u64,
154}
155
156/// Standing performs the same waiter inspection before initial acquisition and
157/// checkpoint reacquisition because both call this one function. It declines
158/// immediately when an interactive or normal-maintenance waiter is visible.
159pub(crate) fn acquire_standing_while_cancellable_with_limiter(
160    limiter: &Arc<ColdBuildLimiter>,
161    kind: &str,
162    request_id: impl Into<String>,
163    admission_epoch: u64,
164    admitted: impl Fn() -> bool,
165    cancelled: impl Fn() -> bool,
166) -> Option<StandingColdBuildPermit> {
167    let request = ColdBuildAdmissionRequest::new(request_id, ColdBuildAdmissionClass::Standing);
168    acquire_blocking_while_inner(limiter, kind, Some(&request), admitted, cancelled).map(|permit| {
169        StandingColdBuildPermit {
170            _permit: permit,
171            admission_epoch,
172        }
173    })
174}
175
176fn acquire_blocking_while_inner(
177    limiter: &Arc<ColdBuildLimiter>,
178    kind: &str,
179    request: Option<&ColdBuildAdmissionRequest>,
180    admitted: impl Fn() -> bool,
181    cancelled: impl Fn() -> bool,
182) -> Option<ColdBuildPermit> {
183    let _waiter = request.map(|request| AdmissionWaiter::register(limiter, request.class));
184    let started = Instant::now();
185    let mut logged = false;
186    loop {
187        if !admitted() || cancelled() {
188            return None;
189        }
190        // Standing yields to any already-queued interactive or ordinary
191        // maintenance contender. Returning None keeps it resumable; callers
192        // use the same path again at the next checkpoint without priority tags.
193        if request.is_some_and(|request| request.class == ColdBuildAdmissionClass::Standing)
194            && limiter.has_non_standing_waiters()
195        {
196            return None;
197        }
198        let revoked_after_acquire = std::cell::Cell::new(false);
199        let permit = match request {
200            Some(request) => limiter.try_acquire_classified(request, || {
201                let still_admitted = admitted()
202                    && !cancelled()
203                    && (request.class != ColdBuildAdmissionClass::Standing
204                        || !limiter.has_non_standing_waiters());
205                revoked_after_acquire.set(!still_admitted);
206                still_admitted
207            }),
208            None => limiter.try_acquire().and_then(|permit| {
209                // A request can become unbound or cancelled after the pre-attempt
210                // check but before the permit is acquired. Recheck while owning
211                // the slot; dropping the permit returns it before any build starts.
212                if admitted() && !cancelled() {
213                    Some(permit)
214                } else {
215                    revoked_after_acquire.set(true);
216                    drop(permit);
217                    None
218                }
219            }),
220        };
221        if revoked_after_acquire.get() {
222            return None;
223        }
224        if let Some(permit) = permit {
225            let wait_ms = started.elapsed().as_millis().min(u64::MAX as u128) as u64;
226            if wait_ms > 0 {
227                crate::logging::note_tool_call_wait(
228                    crate::run_tool_call::WaitingOn::Limiter,
229                    None,
230                    wait_ms,
231                );
232            }
233            if logged {
234                match request {
235                    Some(request) => crate::slog_info!(
236                        "{} cold-build slot acquired after {}ms wait: request={} kind={}",
237                        request.class.label(),
238                        wait_ms,
239                        request.request_id,
240                        kind
241                    ),
242                    None => crate::slog_info!(
243                        "maintenance build slot acquired after {}ms wait: {}",
244                        wait_ms,
245                        kind
246                    ),
247                }
248            }
249            return Some(permit);
250        }
251        if !logged {
252            match request {
253                Some(request) => crate::slog_info!(
254                    "{} cold-build request queued behind concurrency cap ({}): request={} kind={}",
255                    request.class.label(),
256                    limiter.limit(),
257                    request.request_id,
258                    kind
259                ),
260                None => crate::slog_info!(
261                    "maintenance build queued behind concurrency cap ({}): {}",
262                    limiter.limit(),
263                    kind
264                ),
265            }
266            logged = true;
267        }
268        std::thread::sleep(Duration::from_millis(100));
269    }
270}
271
272pub fn limit() -> usize {
273    GLOBAL_COLD_BUILD_LIMITER.limit()
274}
275
276#[cfg(test)]
277pub(crate) fn test_limiter(limit: usize) -> Arc<ColdBuildLimiter> {
278    Arc::new(ColdBuildLimiter::new(limit))
279}
280
281#[cfg(test)]
282pub(crate) fn acquire_blocking_while_with_test_limiter(
283    limiter: &Arc<ColdBuildLimiter>,
284    kind: &str,
285    admitted: impl Fn() -> bool,
286) -> Option<ColdBuildPermit> {
287    acquire_blocking_while_with_limiter(limiter, kind, admitted)
288}
289
290#[derive(Debug)]
291pub(crate) struct ColdBuildLimiter {
292    available: AtomicUsize,
293    limit: usize,
294    /// Waiter counts are atomics so a Standing contender can yield without a
295    /// second hot-path lock. Rotation still uses `admission_state` below.
296    waiting_by_class: [AtomicUsize; ADMISSION_CLASS_COUNT],
297    admission_state: Mutex<AdmissionState>,
298}
299
300#[derive(Debug)]
301struct AdmissionState {
302    last_admitted_class: Option<ColdBuildAdmissionClass>,
303    next_admission_order: u64,
304    events: VecDeque<ColdBuildAdmissionEvent>,
305}
306
307impl ColdBuildLimiter {
308    fn new(limit: usize) -> Self {
309        let limit = limit.max(1);
310        Self {
311            available: AtomicUsize::new(limit),
312            limit,
313            waiting_by_class: std::array::from_fn(|_| AtomicUsize::new(0)),
314            admission_state: Mutex::new(AdmissionState {
315                last_admitted_class: None,
316                next_admission_order: 1,
317                events: VecDeque::with_capacity(ADMISSION_EVENT_RETENTION),
318            }),
319        }
320    }
321
322    pub(crate) fn limit(&self) -> usize {
323        self.limit
324    }
325
326    pub(crate) fn try_acquire(self: &Arc<Self>) -> Option<ColdBuildPermit> {
327        loop {
328            let available = self.available.load(Ordering::Acquire);
329            if available == 0 {
330                return None;
331            }
332            if self
333                .available
334                .compare_exchange(
335                    available,
336                    available - 1,
337                    Ordering::AcqRel,
338                    Ordering::Acquire,
339                )
340                .is_ok()
341            {
342                return Some(ColdBuildPermit {
343                    limiter: Arc::clone(self),
344                });
345            }
346        }
347    }
348
349    fn try_acquire_classified(
350        self: &Arc<Self>,
351        request: &ColdBuildAdmissionRequest,
352        admitted_after_acquire: impl FnOnce() -> bool,
353    ) -> Option<ColdBuildPermit> {
354        let mut state = self
355            .admission_state
356            .lock()
357            .unwrap_or_else(std::sync::PoisonError::into_inner);
358        // Class alternation arbitrates the final released slot. When several
359        // slots are free, admitting both classes is not starvation and avoids
360        // stranding independent roots behind an artificial one-at-a-time turn.
361        let available = self.available.load(Ordering::Acquire);
362        if available <= 1
363            && self.has_waiter_from_another_class(request.class)
364            && state.last_admitted_class == Some(request.class)
365        {
366            return None;
367        }
368        let permit = self.try_acquire()?;
369        if !admitted_after_acquire() {
370            drop(permit);
371            return None;
372        }
373        Self::record_admission_locked(&mut state, request);
374        Some(permit)
375    }
376
377    fn record_admission_locked(state: &mut AdmissionState, request: &ColdBuildAdmissionRequest) {
378        let event = ColdBuildAdmissionEvent {
379            request_id: request.request_id.clone(),
380            class: request.class,
381            admission_order: state.next_admission_order,
382        };
383        state.next_admission_order += 1;
384        state.last_admitted_class = Some(request.class);
385        if state.events.len() == ADMISSION_EVENT_RETENTION {
386            state.events.pop_front();
387        }
388        state.events.push_back(event);
389    }
390
391    /// Expose recorded admissions to internal tests and harness code so they
392    /// can verify limiter behavior without parsing log output.
393    #[cfg_attr(not(test), allow(dead_code))]
394    pub(crate) fn admission_events(&self) -> Vec<ColdBuildAdmissionEvent> {
395        self.admission_state
396            .lock()
397            .unwrap_or_else(std::sync::PoisonError::into_inner)
398            .events
399            .iter()
400            .cloned()
401            .collect()
402    }
403
404    /// O(1) waiter-set inspection used by Standing before its initial permit
405    /// and every checkpoint reacquisition. The counters are maintained by RAII
406    /// waiters and therefore need no scheduler-state lock on this hot path.
407    pub(crate) fn has_non_standing_waiters(&self) -> bool {
408        self.waiting_by_class[ColdBuildAdmissionClass::InspectTriggered.index()]
409            .load(Ordering::Acquire)
410            > 0
411            || self.waiting_by_class[ColdBuildAdmissionClass::Maintenance.index()]
412                .load(Ordering::Acquire)
413                > 0
414    }
415
416    fn has_waiter_from_another_class(&self, class: ColdBuildAdmissionClass) -> bool {
417        self.waiting_by_class
418            .iter()
419            .enumerate()
420            .any(|(index, waiters)| index != class.index() && waiters.load(Ordering::Acquire) > 0)
421    }
422
423    #[cfg(test)]
424    fn waiting_by_class_for_test(&self) -> [usize; ADMISSION_CLASS_COUNT] {
425        std::array::from_fn(|index| self.waiting_by_class[index].load(Ordering::Acquire))
426    }
427}
428
429struct AdmissionWaiter {
430    limiter: Arc<ColdBuildLimiter>,
431    class: ColdBuildAdmissionClass,
432}
433
434impl AdmissionWaiter {
435    fn register(limiter: &Arc<ColdBuildLimiter>, class: ColdBuildAdmissionClass) -> Self {
436        limiter.waiting_by_class[class.index()].fetch_add(1, Ordering::AcqRel);
437        Self {
438            limiter: Arc::clone(limiter),
439            class,
440        }
441    }
442}
443
444impl Drop for AdmissionWaiter {
445    fn drop(&mut self) {
446        let previous =
447            self.limiter.waiting_by_class[self.class.index()].fetch_sub(1, Ordering::AcqRel);
448        debug_assert!(previous > 0);
449    }
450}
451
452#[derive(Debug)]
453pub struct ColdBuildPermit {
454    limiter: Arc<ColdBuildLimiter>,
455}
456
457impl Drop for ColdBuildPermit {
458    fn drop(&mut self) {
459        let previous = self.limiter.available.fetch_add(1, Ordering::Release);
460        debug_assert!(previous < self.limiter.limit);
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    // These tests mutate the process-global limiter; run them one at a time.
469    fn serial() -> std::sync::MutexGuard<'static, ()> {
470        static M: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
471        M.get_or_init(|| std::sync::Mutex::new(()))
472            .lock()
473            .unwrap_or_else(std::sync::PoisonError::into_inner)
474    }
475
476    fn wait_for_waiters(limiter: &ColdBuildLimiter) {
477        let deadline = Instant::now() + Duration::from_secs(3);
478        loop {
479            let waiting = limiter.waiting_by_class_for_test();
480            if waiting[ColdBuildAdmissionClass::InspectTriggered.index()] > 0
481                && waiting[ColdBuildAdmissionClass::Maintenance.index()] > 0
482            {
483                return;
484            }
485            assert!(
486                Instant::now() < deadline,
487                "both admission classes must remain queued; waiting={waiting:?}"
488            );
489            std::thread::yield_now();
490        }
491    }
492
493    #[test]
494    fn permits_release_on_drop() {
495        let _serial = serial();
496        let before = GLOBAL_COLD_BUILD_LIMITER.available.load(Ordering::Acquire);
497        {
498            let _a = acquire_blocking("test-a");
499            let _b = acquire_blocking("test-b");
500            assert_eq!(
501                GLOBAL_COLD_BUILD_LIMITER.available.load(Ordering::Acquire),
502                before - 2
503            );
504        }
505        assert_eq!(
506            GLOBAL_COLD_BUILD_LIMITER.available.load(Ordering::Acquire),
507            before
508        );
509    }
510
511    #[test]
512    fn acquire_blocking_waits_until_release() {
513        let _serial = serial();
514        // Drain every slot, then prove a waiter blocks until one holder drops.
515        let mut held: Vec<ColdBuildPermit> = Vec::new();
516        while let Some(permit) = try_acquire() {
517            held.push(permit);
518        }
519        let waiter = std::thread::spawn(|| {
520            let _p = acquire_blocking("waiter");
521        });
522        std::thread::sleep(std::time::Duration::from_millis(250));
523        assert!(!waiter.is_finished(), "waiter must block while cap is full");
524        drop(held.pop());
525        waiter.join().expect("waiter finishes after release");
526        drop(held);
527    }
528
529    #[test]
530    fn admission_revoked_between_check_and_permit_drops_the_slot() {
531        let _serial = serial();
532        let before = GLOBAL_COLD_BUILD_LIMITER.available.load(Ordering::Acquire);
533        let checks = AtomicUsize::new(0);
534
535        let permit = acquire_blocking_while("revoked-after-cas", || {
536            checks.fetch_add(1, Ordering::SeqCst) == 0
537        });
538
539        assert!(permit.is_none());
540        assert_eq!(checks.load(Ordering::SeqCst), 2);
541        assert_eq!(
542            GLOBAL_COLD_BUILD_LIMITER.available.load(Ordering::Acquire),
543            before,
544            "revoked admission must return the just-acquired slot"
545        );
546    }
547
548    #[test]
549    fn conditional_waiter_cancels_without_consuming_a_released_slot() {
550        let _serial = serial();
551        let mut held = Vec::new();
552        while let Some(permit) = try_acquire() {
553            held.push(permit);
554        }
555        let admitted = Arc::new(std::sync::atomic::AtomicBool::new(true));
556        let waiter_admitted = Arc::clone(&admitted);
557        let waiter = std::thread::spawn(move || {
558            acquire_blocking_while("conditional waiter", || {
559                waiter_admitted.load(Ordering::SeqCst)
560            })
561        });
562        std::thread::sleep(Duration::from_millis(150));
563        admitted.store(false, Ordering::SeqCst);
564        assert!(
565            waiter.join().expect("conditional waiter joins").is_none(),
566            "revoked work must leave the cold-build queue without taking a permit"
567        );
568        drop(held);
569    }
570
571    #[test]
572    fn cancellation_after_acquisition_returns_the_permit_without_an_event() {
573        let limiter = test_limiter(1);
574        let cancellation_checks = AtomicUsize::new(0);
575
576        let permit = acquire_blocking_while_cancellable_with_limiter(
577            &limiter,
578            "cancel-after-acquire",
579            ColdBuildAdmissionRequest::new(
580                "inspect-cancelled",
581                ColdBuildAdmissionClass::InspectTriggered,
582            ),
583            || true,
584            || cancellation_checks.fetch_add(1, Ordering::SeqCst) > 0,
585        );
586
587        assert!(permit.is_none());
588        assert_eq!(cancellation_checks.load(Ordering::SeqCst), 2);
589        assert_eq!(
590            limiter.available.load(Ordering::Acquire),
591            1,
592            "post-acquisition cancellation must return the permit"
593        );
594        assert!(
595            limiter.admission_events().is_empty(),
596            "cancelled work must not emit a successful admission"
597        );
598    }
599
600    #[test]
601    fn standing_yields_before_initial_and_checkpoint_reacquisition_when_non_standing_waits() {
602        let limiter = test_limiter(1);
603        let non_standing_waiter =
604            AdmissionWaiter::register(&limiter, ColdBuildAdmissionClass::Maintenance);
605
606        assert!(acquire_standing_while_cancellable_with_limiter(
607            &limiter,
608            "standing-initial",
609            "standing-initial",
610            41,
611            || true,
612            || false,
613        )
614        .is_none());
615
616        drop(non_standing_waiter);
617        let first = acquire_standing_while_cancellable_with_limiter(
618            &limiter,
619            "standing-checkpoint",
620            "standing-checkpoint",
621            41,
622            || true,
623            || false,
624        )
625        .expect("standing may acquire once ordinary waiters clear");
626        assert_eq!(first.admission_epoch, 41);
627        drop(first);
628
629        let non_standing_waiter =
630            AdmissionWaiter::register(&limiter, ColdBuildAdmissionClass::InspectTriggered);
631        assert!(acquire_standing_while_cancellable_with_limiter(
632            &limiter,
633            "standing-reacquire",
634            "standing-reacquire",
635            41,
636            || true,
637            || false,
638        )
639        .is_none());
640        drop(non_standing_waiter);
641    }
642
643    #[test]
644    fn inspect_waiter_takes_next_release_ahead_of_queued_maintenance() {
645        let limiter = test_limiter(1);
646        let active_request = ColdBuildAdmissionRequest::new(
647            "active-semantic-seed",
648            ColdBuildAdmissionClass::Maintenance,
649        );
650        let active = try_acquire_classified_with_limiter(&limiter, &active_request)
651            .expect("active maintenance build holds the slot");
652        let (admitted_tx, admitted_rx) = std::sync::mpsc::channel();
653        let mut waiters = Vec::new();
654
655        for (request_id, class) in [
656            ("queued-refresh", ColdBuildAdmissionClass::Maintenance),
657            (
658                "blocking-inspect",
659                ColdBuildAdmissionClass::InspectTriggered,
660            ),
661        ] {
662            let limiter = Arc::clone(&limiter);
663            let admitted_tx = admitted_tx.clone();
664            waiters.push(std::thread::spawn(move || {
665                let permit = acquire_blocking_while_cancellable_with_limiter(
666                    &limiter,
667                    request_id,
668                    ColdBuildAdmissionRequest::new(request_id, class),
669                    || true,
670                    || false,
671                )
672                .expect("queued build is admitted");
673                admitted_tx
674                    .send((class, permit))
675                    .expect("test receives admitted permit");
676            }));
677        }
678        drop(admitted_tx);
679        wait_for_waiters(&limiter);
680        drop(active);
681
682        let (first_class, first_permit) = admitted_rx
683            .recv_timeout(Duration::from_secs(3))
684            .expect("released slot admits interactive inspect");
685        assert_eq!(first_class, ColdBuildAdmissionClass::InspectTriggered);
686        assert!(
687            admitted_rx.try_recv().is_err(),
688            "maintenance remains deferred"
689        );
690        drop(first_permit);
691
692        let (second_class, second_permit) = admitted_rx
693            .recv_timeout(Duration::from_secs(3))
694            .expect("maintenance resumes after inspect releases its slot");
695        assert_eq!(second_class, ColdBuildAdmissionClass::Maintenance);
696        drop(second_permit);
697        for waiter in waiters {
698            waiter.join().expect("admission waiter joins");
699        }
700    }
701
702    #[test]
703    fn admission_events_cover_both_classes_across_the_fixed_32_release_schedule() {
704        const RELEASE_COUNT: usize = 32;
705
706        let limiter = test_limiter(1);
707        let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
708        let (permit_tx, permit_rx) = std::sync::mpsc::channel();
709        let initial_permit = limiter.try_acquire().expect("hold the only slot");
710        let mut waiters = Vec::new();
711
712        for (request_id, class) in [
713            ("inspect-request", ColdBuildAdmissionClass::InspectTriggered),
714            ("maintenance-request", ColdBuildAdmissionClass::Maintenance),
715        ] {
716            let limiter = Arc::clone(&limiter);
717            let cancelled = Arc::clone(&cancelled);
718            let permit_tx = permit_tx.clone();
719            waiters.push(std::thread::spawn(move || {
720                while !cancelled.load(Ordering::SeqCst) {
721                    let permit = acquire_blocking_while_cancellable_with_limiter(
722                        &limiter,
723                        "fixed-release-test",
724                        ColdBuildAdmissionRequest::new(request_id, class),
725                        || true,
726                        || cancelled.load(Ordering::SeqCst),
727                    );
728                    let Some(permit) = permit else {
729                        return;
730                    };
731                    if permit_tx.send(permit).is_err() {
732                        return;
733                    }
734                }
735            }));
736        }
737        drop(permit_tx);
738
739        wait_for_waiters(&limiter);
740        let mut released_permit = Some(initial_permit);
741        for release in 1..RELEASE_COUNT {
742            wait_for_waiters(&limiter);
743            drop(released_permit.take());
744            released_permit = Some(
745                permit_rx
746                    .recv_timeout(Duration::from_secs(3))
747                    .unwrap_or_else(|error| {
748                        panic!("release {release} must admit a waiter: {error}")
749                    }),
750            );
751        }
752        wait_for_waiters(&limiter);
753        drop(released_permit);
754        let consumed_by_build = permit_rx
755            .recv_timeout(Duration::from_secs(3))
756            .unwrap_or_else(|error| panic!("release {RELEASE_COUNT} must admit a waiter: {error}"));
757
758        cancelled.store(true, Ordering::SeqCst);
759        for waiter in waiters {
760            waiter.join().expect("cancelled waiter joins");
761        }
762
763        let events = limiter.admission_events();
764        assert_eq!(events.len(), RELEASE_COUNT);
765        assert!(events
766            .iter()
767            .any(|event| event.class == ColdBuildAdmissionClass::InspectTriggered));
768        assert!(events
769            .iter()
770            .any(|event| event.class == ColdBuildAdmissionClass::Maintenance));
771        assert!(events.iter().all(|event| matches!(
772            event.request_id.as_str(),
773            "inspect-request" | "maintenance-request"
774        )));
775        assert!(events
776            .iter()
777            .enumerate()
778            .all(|(index, event)| event.admission_order == index as u64 + 1));
779
780        assert_eq!(
781            limiter.available.load(Ordering::Acquire),
782            0,
783            "the final acquired permit must remain accounted for by the consumed build"
784        );
785        drop(consumed_by_build);
786        assert_eq!(
787            limiter.available.load(Ordering::Acquire),
788            1,
789            "releasing the consumed build permit must restore the limiter slot"
790        );
791    }
792}