Skip to main content

epics_libcom_rs/runtime/
worker_pool.rs

1//! A bounded set of persistent threads that a connection **borrows** rather
2//! than creates, closing the server-side per-connection thread leak.
3//!
4//! # The defect this closes
5//!
6//! Every `std::thread` *creation* leaves 176–179 B behind permanently on
7//! RTEMS 6 — the thread's TLS key is freed before its destructor runs, so the
8//! value block is never reclaimed. The cost is per *creation*, not per live
9//! thread, so a server that spawns a thread per accepted connection leaks
10//! without a ceiling: a client that connects and disconnects in a loop drains
11//! the target's fixed heap for as long as the IOC runs.
12//!
13//! [`DialPool`](super::blocking_io::DialPool) closed the client *dial* path by
14//! this argument. This is the same argument on the *serve* side, and the two
15//! are separate primitives on purpose: a dial borrows one worker for a short
16//! job and *queues* over capacity; a connection borrows a **set** of workers
17//! for its whole life and is *refused* over capacity.
18//!
19//! # The unit of borrow is a set, not a worker
20//!
21//! A PVA connection needs **three** threads *together* — the connection thread
22//! plus a reader pump plus a writer pump. If it could take two and block for the
23//! third, a server at capacity would deadlock: every connection holding two,
24//! each waiting on one nobody will free. So [`WorkerPool::acquire`] hands out a
25//! whole [`Worker`] array or nothing, and there is no API that borrows one
26//! worker on its own. The roster is heterogeneous within a set (the PVA set is
27//! one `Big` stack and two `Small`), which is exactly why a *set* is the unit
28//! and not N draws from N per-class pools — drawing separately would reopen the
29//! partial-borrow deadlock.
30//!
31//! # It does not raise the connection ceiling
32//!
33//! A pooled connection occupies the same three stacks while it is live, because
34//! it is the same three threads doing the same work; the ceiling is
35//! per-connection *memory* (1,589,554 B measured per PVA connection on
36//! `armv7-rtems-eabihf`), not thread-creation residue. What the pool removes is
37//! the *residue of the creation*. Its other job is to be the single owner of
38//! connection admission — the one place that can refuse with `EAGAIN`.
39//!
40//! # The bound is memory, not just a count
41//!
42//! A count bound cannot know when the target is out of thread memory: on
43//! `x86_64-wrs-vxworks` the CA pool's count bound (141, derived from the
44//! descriptor budget) was never reached, because the process hit a reserved
45//! address-space ceiling at 46 concurrent clients first — and what happened
46//! there was not a refusal. `pthread_create` began to fail, then a `std` mutex
47//! lock returned `EINVAL` and killed a worker, then an allocation of 64 bytes
48//! failed and took the whole RTP down with signal 6. A bound that is reached
49//! *after* the target has run out is not an admission gate.
50//!
51//! So every set's memory is reserved from one **process-wide** budget before a
52//! thread is created (`Reservation::try_reserve`, [`POOL_RESERVATION_ENV`]),
53//! and a set
54//! that does not fit is refused with [`AcquireError::OutOfReservation`] while
55//! the target still has the memory to deliver the refusal. Process-wide because
56//! the resource is: an IOC runs several pools, and three pools each inside
57//! their own bound can still walk the process past the ceiling together. The
58//! count bounds stay exactly what they were — `capacity` is a descriptor bound
59//! for the CA server and an operator's `max_connections` for the PVA server —
60//! because those are different resources and folding them into one number is
61//! what makes a bound unable to say which one ran out.
62//!
63//! # Accounting: busy is what is counted, and a set idles through one gate
64//!
65//! A set returns to the idle pool when **both** its [`SetLease`] has dropped
66//! *and* every job dispatched on it has returned. `running` is incremented only
67//! by [`Worker::run`]/[`Worker::run_detached`] (the actor that really
68//! dispatched) and decremented only by the worker loop after the job's closure
69//! has fully returned and been dropped (the actor that really finished). No side
70//! path pokes it. Every transition — lease drop, job completion — locks the
71//! set's own state once, mutates, and checks the idle condition behind a
72//! `parked` flag so a double push is unrepresentable; only then, and never while
73//! holding the set lock, does it touch the pool lock to push the set back.
74//!
75//! # A worker that dies retires its set
76//!
77//! That accounting is exact only while every dispatched job comes back, and a
78//! worker *thread* can die where the job's `catch_unwind` does not reach — on
79//! target it did, at the memory wall, in a `std` mutex that returned `EINVAL`.
80//! So the set's slot is released by the thread's destructor (`WorkerExit`),
81//! not by a code path that a panic can skip: any exit that was not asked for
82//! marks the set dead, stops its siblings, and gives the slot back to `created`
83//! when the last of its threads is gone. A dead set is never pooled again,
84//! because a set one thread short cannot serve a connection — and a dispatch
85//! that lands on a worker already gone is reported as *not run*, never as a
86//! clean completion.
87
88use std::collections::VecDeque;
89use std::io;
90use std::panic::{AssertUnwindSafe, catch_unwind};
91use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
92use std::sync::mpsc::{Receiver, Sender, SyncSender, channel, sync_channel};
93use std::sync::{Arc, LazyLock, Mutex};
94use std::thread::{self, JoinHandle};
95
96use crate::runtime::log::{ErrlogSevEnum, errlog_sev_printf};
97use crate::runtime::task::{InheritedRuntime, StackSizeClass, ThreadPriority, enter_ioc_thread};
98
99/// One member of a worker set: how its thread is named, sized and banded.
100#[derive(Clone, Copy, Debug)]
101pub struct WorkerRole {
102    /// OS thread-name stem; the thread is `"{pool_prefix}-{suffix} {set}"`.
103    /// Keep it short — RTEMS truncates thread names at 16 bytes.
104    pub suffix: &'static str,
105    /// The stack this role's work needs. The set is heterogeneous: the PVA
106    /// connection thread is `Big`, its two pumps are `Small`.
107    pub stack: StackSizeClass,
108    /// The EPICS band this role's thread takes, for its whole life.
109    pub priority: ThreadPriority,
110}
111
112/// The work handed to one worker for one job, captured on the submitting thread.
113enum Assignment {
114    /// A job whose panic result is handed to a joiner ([`Job::join`]).
115    Joinable {
116        body: Box<dyn FnOnce() + Send + 'static>,
117        ambient: InheritedRuntime,
118        done: SyncSender<thread::Result<()>>,
119    },
120    /// A job nobody joins — the worker itself announces a panic.
121    Detached {
122        body: Box<dyn FnOnce() + Send + 'static>,
123        ambient: InheritedRuntime,
124        label: String,
125    },
126    /// Leave the worker loop. Sent once per worker at pool teardown.
127    Stop,
128}
129
130/// Everything a set mutates while it is leased, under one lock.
131struct SetState {
132    /// A borrower holds the [`SetLease`].
133    leased: bool,
134    /// Jobs dispatched on this set that have not yet returned.
135    running: usize,
136    /// The set is currently sitting in the pool's idle deque. Guards against a
137    /// second push: the lease drop and the last job completion can race, and
138    /// exactly one of them must move the set to idle.
139    parked: bool,
140    /// Threads of this set that have not yet exited. Reaches zero exactly once,
141    /// on the last thread's exit, which is what releases the set's slot.
142    live_workers: usize,
143}
144
145impl SetState {
146    /// Mutate under the lock, then answer *did this transition free the set?* —
147    /// true at most once per lease, because `parked` latches.
148    ///
149    /// Whether a freed set may be *re-pooled* is not decided here: a retired set
150    /// still transitions to free, and [`free_if_idle`] is the single gate that
151    /// drops it instead of pushing it.
152    fn became_free(&mut self) -> bool {
153        if !self.leased && self.running == 0 && !self.parked {
154            self.parked = true;
155            true
156        } else {
157            false
158        }
159    }
160}
161
162/// A live set: its per-slot job senders and its shared accounting.
163struct SetHandle {
164    /// This set's creation index — its thread-name suffix, and how a retirement
165    /// names itself on the console.
166    index: usize,
167    /// One sender per role, cloned into the [`Worker`]s handed out at lease.
168    senders: Vec<Sender<Assignment>>,
169    /// One of this set's threads has exited. A dead set never idles again and
170    /// never leases again: its survivors are stopped and its slot goes back to
171    /// the bound once they are all gone.
172    ///
173    /// An atomic rather than a [`SetState`] field because [`free_if_idle`] must
174    /// read it while holding the *pool* lock, and the two locks have a fixed
175    /// order — the pool lock is never taken inside a set lock — so consulting
176    /// `state` from there would be the deadlock the order exists to prevent.
177    dead: AtomicBool,
178    state: Mutex<SetState>,
179    /// This set's own threads, so the owner that returns the set's slot can
180    /// return its *threads* in the same step.
181    ///
182    /// On the set and not in a flat registry vector because a handle has to be
183    /// findable from the set that is retiring, and the only identity a set has
184    /// is its `Arc` — `index` is reused once a slot comes back, so it cannot key
185    /// this. A pool-wide vector had no way to say which handles had just died,
186    /// and so said nothing: an exited worker stayed neither joined nor detached
187    /// for the life of the process, holding its stack.
188    ///
189    /// Emptied by retirement, which *detaches* — the last thread out is one of
190    /// these threads, and a thread cannot join itself. Drained by teardown,
191    /// which joins, because there the caller is not one of them.
192    joins: Mutex<Vec<JoinHandle<()>>>,
193}
194
195/// Everything the pool mutates, under one lock. Never taken while a set lock is
196/// held.
197struct Registry {
198    /// Sets ready to be leased.
199    idle: VecDeque<Arc<SetHandle>>,
200    /// Every set ever created, leased or idle. Kept so teardown can reach every
201    /// worker's sender regardless of whether its set is currently borrowed.
202    all: Vec<Arc<SetHandle>>,
203    /// Sets created, ever (`== all.len()` once a grow settles). Reserved
204    /// *before* the threads are spawned and only decremented if the spawn fails,
205    /// so it is the true bound on creations — the number the per-connection
206    /// shape grew without limit.
207    created: usize,
208    /// Set once teardown has begun; a set freed after this is not re-pooled.
209    stopping: bool,
210}
211
212struct PoolInner {
213    /// One entry per role; its length is the set size `N`.
214    roster: Box<[WorkerRole]>,
215    /// Thread-name stem shared by every worker.
216    name_prefix: &'static str,
217    /// The most sets that may ever exist — connection admission's hard bound.
218    capacity: usize,
219    /// What one whole set reserves: the sum over the roster of
220    /// [`thread_reservation`]. Fixed by the roster, so admission needs no
221    /// per-set arithmetic.
222    set_reservation: usize,
223    /// The account this pool reserves from and releases to. One and the same
224    /// for every production pool, so a release cannot land anywhere but where
225    /// its reservation came from.
226    reservation: &'static Reservation,
227    /// The object-arena gate — [`materialise_set_mutex`] in every production
228    /// pool. A field so a test can make the target refuse, which is the one
229    /// thing a host cannot be made to do.
230    materialise: fn(&Mutex<SetState>) -> bool,
231    reg: Mutex<Registry>,
232}
233
234impl PoolInner {
235    fn lock(&self) -> std::sync::MutexGuard<'_, Registry> {
236        self.reg.lock().unwrap_or_else(|e| e.into_inner())
237    }
238}
239
240fn lock_set(set: &SetHandle) -> std::sync::MutexGuard<'_, SetState> {
241    set.state.lock().unwrap_or_else(|e| e.into_inner())
242}
243
244/// The set's own thread handles. Innermost of the three locks — taken while the
245/// pool lock is held, and nothing is taken while *it* is held — so it adds no
246/// new order to the pool/set pair.
247fn lock_joins(set: &SetHandle) -> std::sync::MutexGuard<'_, Vec<JoinHandle<()>>> {
248    set.joins.lock().unwrap_or_else(|e| e.into_inner())
249}
250
251/// Push a set back to idle if a transition just freed it. Called from both the
252/// lease drop and the worker loop; the set lock is released before the pool lock
253/// is taken.
254fn free_if_idle(inner: &Arc<PoolInner>, set: &Arc<SetHandle>, freed: bool) {
255    if !freed {
256        return;
257    }
258    let mut reg = inner.lock();
259    if reg.stopping {
260        // Teardown owns this set now; leaving it out of `idle` is what makes a
261        // freed set stay freed.
262        return;
263    }
264    if set.dead.load(Ordering::SeqCst) {
265        // A retiring set must not be pooled. The read is under the pool lock and
266        // so is `WorkerExit`'s removal, so whichever runs second sees the other:
267        // either this push happens first and the removal takes it back out, or
268        // the death is already visible here and no push happens at all.
269        return;
270    }
271    reg.idle.push_back(set.clone());
272}
273
274// ---------------------------------------------------------------------------
275// The lease
276// ---------------------------------------------------------------------------
277
278/// Proof that a set is borrowed. Its `Drop` is half of the return condition:
279/// the set cannot re-idle until this is gone *and* every job has finished.
280///
281/// The borrower holds it for the connection's whole life — the PVA server moves
282/// it into the connection job on the set's own worker, and a pooled client holds
283/// it through the two returned byte adapters — so the set stays out of the idle
284/// pool for exactly as long as the connection lasts.
285pub struct SetLease {
286    inner: Arc<PoolInner>,
287    set: Arc<SetHandle>,
288}
289
290impl Drop for SetLease {
291    fn drop(&mut self) {
292        let freed = {
293            let mut st = lock_set(&self.set);
294            st.leased = false;
295            st.became_free()
296        };
297        free_if_idle(&self.inner, &self.set, freed);
298    }
299}
300
301// ---------------------------------------------------------------------------
302// A single leased worker
303// ---------------------------------------------------------------------------
304
305/// One thread of a leased set, able to run exactly one job.
306///
307/// `run`/`run_detached` consume the worker, so a role cannot be double-booked;
308/// and `acquire` is the only source of a `Worker`, so a connection cannot use a
309/// worker it did not lease. Both facts hold by type, not by review.
310pub struct Worker {
311    inner: Arc<PoolInner>,
312    set: Arc<SetHandle>,
313    tx: Sender<Assignment>,
314}
315
316/// What a [`Job::join`] reports for a job that was never dispatched: the
317/// worker's receiver was already gone, so no body ever ran.
318const NEVER_DISPATCHED: &str =
319    "worker pool: the job was never dispatched — its worker thread had already exited";
320
321impl Worker {
322    /// Count this dispatch against the set before the worker can finish it.
323    fn charge(&self) {
324        lock_set(&self.set).running += 1;
325    }
326
327    /// Dispatch a job whose completion — and any panic — is observed by the
328    /// returned [`Job`].
329    pub fn run<F>(self, body: F) -> Job
330    where
331        F: FnOnce() + Send + 'static,
332    {
333        let (done, done_rx) = sync_channel(1);
334        self.charge();
335        if self
336            .tx
337            .send(Assignment::Joinable {
338                body: Box::new(body),
339                ambient: InheritedRuntime::capture(),
340                done,
341            })
342            .is_err()
343        {
344            // The worker died before this dispatch. Give back the charge the
345            // worker will now never settle, and tell the joiner the truth: the
346            // body did not run.
347            finish_job(&self.inner, &self.set);
348            // The joiner learns *that* the job did not run; only here is the
349            // *reason* known, and a joiner that reports its own loss ("the pump
350            // panicked") would otherwise name the wrong cause.
351            errlog_sev_printf(
352                ErrlogSevEnum::Major,
353                &format!(
354                    "{} worker pool: set {} took no job — {NEVER_DISPATCHED}.\n",
355                    self.inner.name_prefix, self.set.index
356                ),
357            );
358            return Job { done: None };
359        }
360        Job {
361            done: Some(done_rx),
362        }
363    }
364
365    /// Dispatch a job nobody will join. The worker announces a panic through
366    /// `errlog` under `label`; a clean return is silent.
367    pub fn run_detached<F>(self, label: String, body: F)
368    where
369        F: FnOnce() + Send + 'static,
370    {
371        self.charge();
372        if self
373            .tx
374            .send(Assignment::Detached {
375                body: Box::new(body),
376                ambient: InheritedRuntime::capture(),
377                label: label.clone(),
378            })
379            .is_err()
380        {
381            finish_job(&self.inner, &self.set);
382            // Nobody joins a detached job, so `errlog` is the only place this
383            // can be told; silence here is the loss this pool exists to stop.
384            errlog_sev_printf(
385                ErrlogSevEnum::Major,
386                &format!("{label}: {NEVER_DISPATCHED}. This connection is being torn down.\n"),
387            );
388        }
389    }
390}
391
392/// A handle to a running job, joined on the borrower's teardown path.
393pub struct Job {
394    /// `None` when the dispatch itself failed: there is no worker to hear from.
395    done: Option<Receiver<thread::Result<()>>>,
396}
397
398impl Job {
399    /// Block until the job returns, yielding whether it panicked.
400    ///
401    /// A dropped sender (the worker gone at teardown after the job was taken)
402    /// reads as a clean completion: there is no unwind to report and nothing to
403    /// tear down twice. A job that was never dispatched at all is *not* clean —
404    /// it reports `NEVER_DISPATCHED`, because a borrower that is told its
405    /// body succeeded when it never ran is the same silent loss as a dropped
406    /// panic payload.
407    pub fn join(self) -> thread::Result<()> {
408        match self.done {
409            Some(done) => done.recv().unwrap_or(Ok(())),
410            None => Err(Box::new(NEVER_DISPATCHED)),
411        }
412    }
413}
414
415// ---------------------------------------------------------------------------
416// The pool
417// ---------------------------------------------------------------------------
418
419/// Announce a connection job that unwound, the way the byte pumps announce a
420/// lost pump: through `errlog`, which reaches the console whatever the log
421/// configuration is — including an RTEMS console with the in-tree subscriber.
422fn announce_panic(label: &str) {
423    errlog_sev_printf(
424        ErrlogSevEnum::Major,
425        &format!(
426            "{label}: the connection thread panicked; this connection is being \
427             torn down. Other connections are unaffected.\n"
428        ),
429    );
430}
431
432/// Announce a worker thread that left without being asked to. One record per
433/// set, on the first death, naming what the pool lost.
434fn announce_worker_death(prefix: &str, index: usize, roles: usize) {
435    errlog_sev_printf(
436        ErrlogSevEnum::Major,
437        &format!(
438            "{prefix} worker pool: a thread of set {index} exited unexpectedly. \
439             The set's {roles} threads are being retired and its slot returned; \
440             other connections are unaffected.\n"
441        ),
442    );
443}
444
445/// The one exit path of a worker thread.
446///
447/// # The defect this closes
448///
449/// `catch_unwind` around the job body does not make the *thread* unwind-proof:
450/// dropping the panic payload after the joiner is gone, and the two mutex takes
451/// in the return path, all sit outside it. On VxWorks 7 the second of those is
452/// not hypothetical — a `std` mutex lock at the memory wall returns `EINVAL`
453/// and `std` panics, killing the worker between `catch_unwind` and
454/// `finish_job`. The set then had `running == 1` forever with no thread to
455/// settle it: never idle, never re-leased, its slot held against the pool's
456/// bound for the life of the process (`BUSY=2 SETS=50 WORKERS=100 CONNS=0`
457/// measured on target).
458///
459/// So the accounting hangs off the thread's *destructor*, not off a code path:
460/// however the thread ends — `Stop`, a closed channel, or an unwind anywhere
461/// including the prologue — this runs. A death retires the whole set, because a
462/// set one thread short can never serve a connection again.
463struct WorkerExit {
464    inner: Arc<PoolInner>,
465    set: Arc<SetHandle>,
466    /// This thread's share of its set's reservation, given back here — the one
467    /// place that runs for a thread that exists and never for one that does not.
468    reserved: usize,
469    /// Set true only where the worker returns normally. Left false on every
470    /// unwind, which is what tells the two apart in `Drop`.
471    clean: bool,
472}
473
474impl Drop for WorkerExit {
475    fn drop(&mut self) {
476        // Unconditional and first: the thread's memory goes back to the process
477        // budget however the thread ended. Every other decision below is about
478        // the *set*, and a clean exit returns early from those.
479        self.inner.reservation.release(self.reserved);
480
481        let (first_death, last_gone) = {
482            let mut st = lock_set(&self.set);
483            // Read and write `dead` under the set lock, so the first death is
484            // decided once even when two threads of a set die together.
485            let already_dead = self.set.dead.load(Ordering::SeqCst);
486            if self.clean && !already_dead {
487                // The ordinary end of a healthy worker: teardown's `Stop`, or a
488                // failed grow retiring the threads it did create. The set was
489                // never a thread short, so there is nothing to account for.
490                return;
491            }
492            // A survivor of an already-dead set comes through here too, however
493            // it was asked to leave, so the count reaches zero exactly once.
494            st.live_workers -= 1;
495            self.set.dead.store(true, Ordering::SeqCst);
496            (!already_dead, st.live_workers == 0)
497        };
498
499        if first_death {
500            // A dead set never runs another job; its survivors are asked to
501            // leave, and the last one out releases the slot below.
502            for tx in &self.set.senders {
503                let _ = tx.send(Assignment::Stop);
504            }
505        }
506        {
507            let mut reg = self.inner.lock();
508            if !reg.stopping {
509                // Out of `idle` on the first death, so nothing can lease a set
510                // that is short a thread; out of `all` and off `created` only
511                // when the last thread is gone, so the slot is released exactly
512                // once and never while a dying thread still holds its stack.
513                reg.idle.retain(|s| !Arc::ptr_eq(s, &self.set));
514                if last_gone {
515                    reg.all.retain(|s| !Arc::ptr_eq(s, &self.set));
516                    reg.created -= 1;
517                    // The slot and the *threads* come back in one step. Dropping
518                    // a `JoinHandle` detaches, which is the only move available
519                    // here: the caller is the set's last thread and cannot join
520                    // itself. Left undone, an exited worker stayed neither
521                    // joined nor detached until the pool dropped, holding its
522                    // stack for the life of the process.
523                    lock_joins(&self.set).clear();
524                }
525            }
526        }
527        if first_death {
528            announce_worker_death(
529                self.inner.name_prefix,
530                self.set.index,
531                self.inner.roster.len(),
532            );
533        }
534    }
535}
536
537/// A worker's whole life: take a job, run it under the submitter's ambient
538/// runtime inside `catch_unwind`, then return the set — every path, panic
539/// included, through the one return guard.
540fn worker_loop(inner: Arc<PoolInner>, set: Arc<SetHandle>, rx: Receiver<Assignment>) {
541    // The band was taken by the spawned closure (the role's, for the thread's
542    // whole life); the ambient runtime is the *job's*, entered per dispatch (a
543    // pooled worker outlives the runtime that first used it — see
544    // `InheritedRuntime`).
545    while let Ok(assignment) = rx.recv() {
546        match assignment {
547            Assignment::Stop => break,
548            Assignment::Joinable {
549                body,
550                ambient,
551                done,
552            } => {
553                let outcome = ambient.run(|| catch_unwind(AssertUnwindSafe(body)));
554                // The joiner gets the panic payload; if it is gone the payload
555                // drops here, which only happens at teardown.
556                let _ = done.send(outcome);
557                finish_job(&inner, &set);
558            }
559            Assignment::Detached {
560                body,
561                ambient,
562                label,
563            } => {
564                let outcome = ambient.run(|| catch_unwind(AssertUnwindSafe(body)));
565                if outcome.is_err() {
566                    announce_panic(&label);
567                }
568                finish_job(&inner, &set);
569            }
570        }
571    }
572}
573
574/// One job finished: decrement `running` and re-idle the set if that freed it.
575fn finish_job(inner: &Arc<PoolInner>, set: &Arc<SetHandle>) {
576    let freed = {
577        let mut st = lock_set(set);
578        st.running -= 1;
579        st.became_free()
580    };
581    free_if_idle(inner, set, freed);
582}
583
584// ---------------------------------------------------------------------------
585// The process-wide thread-memory reservation
586// ---------------------------------------------------------------------------
587
588/// How many MiB of thread memory every pool in this process may reserve
589/// *together*. Overrides `default_reservation_budget`; read once, on first
590/// admission.
591pub const POOL_RESERVATION_ENV: &str = "EPICS_RS_POOL_RESERVATION_MB";
592
593/// Whose thread-memory measurements apply.
594///
595/// Named targets and not an `embedded: bool`, because the bool was the defect:
596/// it said "not a host" where the numbers below mean "this target", so a figure
597/// measured on VxWorks was charged on RTEMS by default. Every arm below is an
598/// exhaustive `match`, so a fourth target cannot compile until someone decides
599/// what it costs — the decision is forced at the type rather than inherited from
600/// whichever target was measured first.
601#[derive(Debug, Clone, Copy, PartialEq, Eq)]
602enum ThreadMemoryTarget {
603    /// Anything that is not an embedded target of this port.
604    Host,
605    VxWorks,
606    Rtems,
607}
608
609impl ThreadMemoryTarget {
610    /// The target this build runs on.
611    const fn current() -> Self {
612        if cfg!(target_os = "vxworks") {
613            ThreadMemoryTarget::VxWorks
614        } else if cfg!(target_os = "rtems") {
615            ThreadMemoryTarget::Rtems
616        } else {
617            ThreadMemoryTarget::Host
618        }
619    }
620}
621
622/// Address space one pool thread reserves **beyond its declared stack**.
623///
624/// Measured on `x86_64-wrs-vxworks`: three arms of one image differing only in
625/// the connection roster's [`StackSizeClass`] walled at 47 / 59 / 80 concurrent
626/// clients as the declared per-connection stack fell 3,145,728 → 2,097,152 →
627/// 1,048,576 B. Charging each thread its declared stack *plus a flat 1 MiB*
628/// puts all three walls at 246.4 / 247.5 / 251.7 MB — a 2.1 % spread — while
629/// charging the declared stack alone predicts a wall that never happened (the
630/// three-arm measurement is E8's, `caucus/58EWEJWV91/e8-poolprobe-0548dc61-1`
631/// §10).
632///
633/// It is **not** what the OS charges for a thread. A C RTP laddering bare
634/// pthreads on the same guest walls at exactly `n × declared stack` — 127 × 2
635/// MiB, 254 × 1 MiB, 509 × 512 KiB, each matching an `mmap` ceiling to the byte
636/// (§10.2). So the flat MiB is what a *Rust* thread reserves beyond its stack,
637/// consistent with a per-thread allocator arena and with E10's abort landing on
638/// a 64-byte allocation inside a freshly spawned thread. Charge it per thread
639/// for that reason; do not delete it on the theory that a thread costs only its
640/// stack, and do not read it as an address-space constant of the target.
641///
642/// RTEMS charges nothing beyond the declared stack, **measured** and not
643/// assumed: a 30-client ramp on the 256 MB `xilinx-zynq-a9` moved `MEM_FREE`
644/// 233,299,144 → 198,277,640, i.e. 1,167,383 B per client against 1,572,864 B of
645/// declared stack for the pair. The target spends *less* than the stacks it was
646/// asked for; there is no flat term to find, and adding VxWorks' one charged
647/// 3,670,016 B per client — 3.1× — which is why the 160 MiB budget refused at
648/// 30 clients on a target whose count cap is 141. "Conservative" was the wrong
649/// reading of that: over-charging by 3× is not one connection of margin, it is
650/// three quarters of the target's capacity.
651///
652/// A host is not charged, because a host's budget is unbounded anyway.
653const fn per_thread_overhead(target: ThreadMemoryTarget) -> usize {
654    match target {
655        ThreadMemoryTarget::VxWorks => 1 << 20,
656        ThreadMemoryTarget::Rtems | ThreadMemoryTarget::Host => 0,
657    }
658}
659
660/// RTP object-arena bytes one pool thread consumes — measured, and deliberately
661/// **not charged**, because a per-thread byte charge is the wrong shape for what
662/// was measured.
663///
664/// Every VxWorks pthread mutex materialises a kernel `SEMAPHORE` object on its
665/// *first lock*: `pthread_mutex_init` only stamps the magic, and
666/// `pthreadMutexInit` calls `semMCreate` from `pthread_mutex_lock`, returning
667/// `0x16` (`EINVAL`, not `ENOMEM`) when it comes back NULL — which `std`
668/// reports as "failed to lock mutex: invalid argument (os error 22)" and
669/// panics. That is the death this pool saw at the wall, and eager
670/// initialisation cannot avoid it. The objects come from the RTP object arena,
671/// which is **not** the address space charged above and not the allocator heap
672/// either, which is why the same wall shows as an `EINVAL` in one probe and as
673/// a failed 64-byte allocation in another.
674///
675/// E8's on-target `semMCreate` wrap then measured the exhaustion itself, on a
676/// cold 1024M guest: `semMCreate` returned NULL after 588 successful creations,
677/// at 49 sets / 98 workers / 48 connections — and creation **resumed past 1024**
678/// afterwards. So the arena has no fixed per-thread cost to charge: it is a
679/// transient rate limit, not a total, and a byte charge per thread cannot model
680/// a rate. It stays `0` for that reason rather than for want of a number.
681///
682/// What that exhaustion does to this pool is already closed on the other side:
683/// the `EINVAL` kills the worker, and the set it was holding is retired by
684/// [`WorkerExit`] instead of leaking (E8 saw the leak it fixes as
685/// `POOLPROBE BUSY=1 SETS=49 CONNS=0`).
686const PER_THREAD_OBJECT_ARENA: usize = 0;
687
688/// The budget when [`POOL_RESERVATION_ENV`] is unset.
689///
690/// Unbounded on a host: the pool's own set counts are the bound there, and no
691/// host in this workspace has ever met a thread-memory wall.
692///
693/// 160 MiB on an embedded target, chosen from where the measured target stopped
694/// *working*, not from where it stopped admitting. On the ~958 MB VxWorks guest
695/// the CA pool dies at **set 46** (~230 MiB reserved, at 5 MiB a set): a
696/// 64-byte allocation fails, the RTP takes signal 6 and is deleted, and no
697/// refusal is delivered at all. That figure is measured on this exact code, not
698/// inherited: with the budget raised to 320 MiB the same image on the same
699/// guest walks to set 46 and dies there, and with the default it refuses at set
700/// 32 and keeps serving. 160 MiB is 14 sets of headroom below that — what the
701/// margin buys is that the allocator and the object arena still work while the
702/// refusal is being written to the socket and the console.
703///
704/// The ceiling itself moves with the target's RAM, 1:1: an RTP is handed
705/// whatever is left after a fixed ~705 MB, measured as 254 MiB of usable address
706/// space on a ~958 MB guest and 764 MiB on a ~1470 MB one. So this constant is
707/// right for one box and mean to a bigger one — but it stays a constant, because
708/// nothing an RTP can call reports that ceiling. `sysctl`'s `HW_PHYSMEM` and
709/// `KERN_PHYSMEMTOP` answer `ENOENT`, `memFindMax`/`memInfoGet` describe a
710/// 256 KiB heap partition that sits flat while the process reserves a quarter of
711/// a gigabyte, `getrlimit` is in no RTP library, and `_SC_PHYS_PAGES` is not a
712/// constant the RTP `unistd.h` defines. An `mmap` ladder does find the ceiling
713/// exactly, but only by taking it, which in this process means another thread's
714/// allocation aborts. Hence the operator switch, and the arithmetic an
715/// operator needs for it: usable address space ≈ OS memory − 705 MB, and a CA
716/// set costs 5 MiB.
717///
718/// RTEMS takes the same 160 MiB, and there it is not a constant standing in for
719/// a measurement it cannot make: `malloc_free_space` answers on that target, so
720/// the boot check confirms or clamps this figure against the heap the guest
721/// actually has (see [`target_admits`]). It stays 160 MiB because that is what a
722/// 256 MB guest can confirm and a larger one should be allowed to exceed.
723const fn default_reservation_budget(target: ThreadMemoryTarget) -> usize {
724    match target {
725        ThreadMemoryTarget::VxWorks | ThreadMemoryTarget::Rtems => 160 << 20,
726        ThreadMemoryTarget::Host => usize::MAX,
727    }
728}
729
730/// Parse [`POOL_RESERVATION_ENV`] (`None` = unset ⇒ `default`), with the
731/// default injected so a host test can ask what an embedded process would do
732/// with the same input.
733///
734/// A value that is not a positive whole number of MiB is ignored, with a record,
735/// rather than silently becoming a bound nobody chose.
736fn resolve_reservation_budget(raw: Option<&str>, default: usize) -> usize {
737    let Some(raw) = raw else {
738        return default;
739    };
740    match raw.trim().parse::<usize>() {
741        Ok(mb) if mb > 0 => mb.saturating_mul(1 << 20),
742        _ => {
743            errlog_sev_printf(
744                ErrlogSevEnum::Minor,
745                &format!(
746                    "{POOL_RESERVATION_ENV}={raw:?} is not a positive whole number of MiB; \
747                     keeping the built-in worker-pool reservation budget\n"
748                ),
749            );
750            default
751        }
752    }
753}
754
755/// The smallest budget the boot-time check will settle for.
756///
757/// A CA worker set costs ~5 MiB, so a process held to this floor admits one set
758/// and refuses everything after it. That is the terminal behaviour on a target
759/// that confirms no size at all: bounded and loud, rather than an `abort` at the
760/// first client.
761const RESERVATION_PROBE_FLOOR: usize = 8 << 20;
762
763/// One target's answer about a size, carrying the quantity that produced it.
764///
765/// The basis travels *with* the answer because the boot notice has to name it:
766/// "would not reserve that much address space in one mapping" is true on
767/// VxWorks and false on RTEMS, where the same refusal comes from the malloc
768/// heap's free total. Two `cfg` cascades — one choosing the probe, one choosing
769/// the words — can drift apart and did, on target: the RTEMS guest was told
770/// about a mapping it had not made. One cascade returning both cannot drift.
771#[derive(Debug, Clone, Copy, PartialEq, Eq)]
772struct TargetAnswer {
773    /// Whether the target would give the size it was asked about.
774    granted: bool,
775    /// Reads after "this target", e.g. "would not reserve that much address
776    /// space in one mapping" — present on `granted` too, so a refusal at the
777    /// next step down still has words to use.
778    basis: &'static str,
779}
780
781/// Will this target give `bytes` of thread memory, right now?
782///
783/// `Some` is a measurement; `None` means this target has no basis for the
784/// question and the answer must not be invented. One question, answered from
785/// whatever quantity actually tracks the wall on each target — the resource
786/// differs, the veto does not.
787///
788/// On VxWorks the basis is one anonymous `PROT_NONE` mapping, taken and released
789/// immediately. It is the only quantity in the RTP that tracks the wall this
790/// budget exists to stay under: an `mmap` ladder run at three stack classes and
791/// two guest sizes reports a ceiling equal to the `pthread_create` wall **to the
792/// byte**, while `memFindMax`, `memInfoGet`, `sysctl` `HW_PHYSMEM`,
793/// `sysconf(_SC_PHYS_PAGES)`, `getrlimit` and `rtpInfoGet` are each blind to
794/// it. One mapping under-reads that ceiling — 192 MiB confirms on a guest
795/// whose chunked ceiling is 254 MiB — so it is a *lower* bound, which is the
796/// safe direction for a veto: it can refuse a budget the target would in fact
797/// have honoured, never admit one it would not.
798///
799/// On RTEMS the basis is a *query*, not a taking: `malloc_free_space` reports
800/// the free total of the same heap RTEMS pthread stacks are allocated from, and
801/// it tracks — across a ramp that took the free total from 232.3 MB to 9.3 MB
802/// its gap to `MEM_BLK` widened only from 36,216 to 73,872 B, the opposite of
803/// VxWorks' `memFindMax` sitting flat while the process reserved a quarter of a
804/// gigabyte. Declared here rather than reached through `epics-rtems-boot`,
805/// because this crate's dependency on that package is `cfg(target_os =
806/// "vxworks")` by design — it must stay takeable by a consumer bringing its own
807/// boot glue — while the symbol itself is in `librtemscpu`, which every RTEMS
808/// image links.
809///
810/// RTEMS needs this *more* than VxWorks, not less: `csrc/rtems_init.c:331` hands
811/// `main` a fixed argv and `POSIX_Init` never calls `setenv`, so
812/// [`POOL_RESERVATION_ENV`] cannot be set on that target at all and the built-in
813/// default is the only budget it will ever run. A default nobody can override is
814/// a default that has to be checked.
815#[cfg(target_os = "vxworks")]
816fn target_admits(bytes: usize) -> Option<TargetAnswer> {
817    /// `sys/mman.h:66` — VxWorks requires this exact `fd` with `MAP_ANON`.
818    const MAP_ANON_FD: libc::c_int = -1;
819    const BASIS: &str = "would not reserve that much address space in one mapping";
820
821    // SAFETY: an anonymous `PROT_NONE` mapping of `bytes` at an address of the
822    // kernel's choosing. Nothing is read or written through the pointer: it is
823    // compared against `MAP_FAILED` and then unmapped exactly once, with the
824    // same length it was created with.
825    let addr = unsafe {
826        libc::mmap(
827            std::ptr::null_mut(),
828            bytes,
829            libc::PROT_NONE,
830            libc::MAP_PRIVATE | libc::MAP_ANON,
831            MAP_ANON_FD,
832            0,
833        )
834    };
835    if addr == libc::MAP_FAILED {
836        return Some(TargetAnswer {
837            granted: false,
838            basis: BASIS,
839        });
840    }
841    // SAFETY: `addr` came from the mapping above and is released once.
842    unsafe { libc::munmap(addr, bytes) };
843    Some(TargetAnswer {
844        granted: true,
845        basis: BASIS,
846    })
847}
848
849#[cfg(target_os = "rtems")]
850fn target_admits(bytes: usize) -> Option<TargetAnswer> {
851    // RTEMS `rtems/malloc.h`, defined in `librtemscpu`: bytes free in the
852    // malloc heap. No arguments, no output parameters, no allocation.
853    unsafe extern "C" {
854        fn malloc_free_space() -> libc::size_t;
855    }
856    // SAFETY: a pure query into the RTEMS heap allocator.
857    Some(TargetAnswer {
858        granted: unsafe { malloc_free_space() } >= bytes,
859        basis: "has less than that free in the heap its thread stacks come from",
860    })
861}
862
863#[cfg(not(any(target_os = "vxworks", target_os = "rtems")))]
864fn target_admits(_bytes: usize) -> Option<TargetAnswer> {
865    None
866}
867
868/// What the boot-time check concluded about the configured budget.
869///
870/// A verdict rather than a bare `usize` so that *deciding* and *saying* are
871/// separate functions: the defect being closed here is a budget that kills the
872/// process without a word, and a decision that carries its own announcement can
873/// be asserted by a test without a `tracing` subscriber. A verdict that reaches
874/// [`announce_reservation_budget`] cannot arrive silently by accident — silence
875/// is one named variant, [`BudgetVerdict::Confirmed`], and nothing else.
876#[derive(Debug, Clone, Copy, PartialEq, Eq)]
877enum BudgetVerdict {
878    /// The target gave the configured size when asked. Nothing to say.
879    Confirmed(usize),
880    /// The target would not give `asked`; `adopted` is the largest size below it
881    /// that the target did give. `basis` is the quantity that refused, in the
882    /// words of the target that answered.
883    Clamped {
884        asked: usize,
885        adopted: usize,
886        basis: &'static str,
887    },
888    /// This target has nothing that measures the ceiling, so `adopted` stands
889    /// unchecked. `from_env` is whether an operator chose it.
890    Unverifiable { adopted: usize, from_env: bool },
891    /// Nothing down to [`RESERVATION_PROBE_FLOOR`] was confirmed.
892    FloorHeld { asked: usize, basis: &'static str },
893}
894
895impl BudgetVerdict {
896    /// The budget this verdict adopts.
897    const fn budget(self) -> usize {
898        match self {
899            BudgetVerdict::Confirmed(bytes)
900            | BudgetVerdict::Unverifiable { adopted: bytes, .. } => bytes,
901            BudgetVerdict::Clamped { adopted, .. } => adopted,
902            BudgetVerdict::FloorHeld { .. } => RESERVATION_PROBE_FLOOR,
903        }
904    }
905
906    /// What the operator is told at boot, if anything.
907    ///
908    /// A returned value rather than a call into `errlog` so a test can assert
909    /// *which* outcomes are silent: on a shell-less target the whole account of
910    /// the admission policy is what `errlog` said at boot, and "nothing was
911    /// said" has to be a decision this function makes, not an arm somebody
912    /// forgot to write.
913    ///
914    /// Silent in exactly two cases: the target confirmed what was configured, or
915    /// the target cannot check and nobody configured anything — the built-in
916    /// default carries its own measurement (see [`default_reservation_budget`])
917    /// and a warning on every boot would be noise.
918    fn notice(self) -> Option<(ErrlogSevEnum, String)> {
919        match self {
920            BudgetVerdict::Confirmed(_) => None,
921            BudgetVerdict::Unverifiable {
922                from_env: false, ..
923            } => None,
924            BudgetVerdict::Clamped {
925                asked,
926                adopted,
927                basis,
928            } => Some((
929                ErrlogSevEnum::Major,
930                format!(
931                    "worker-pool reservation budget clamped from {} MiB to {} MiB: at {} MiB this \
932                     target {}. {POOL_RESERVATION_ENV} names a ceiling the target still has to \
933                     confirm; it does not add memory",
934                    asked >> 20,
935                    adopted >> 20,
936                    asked >> 20,
937                    basis
938                ),
939            )),
940            BudgetVerdict::Unverifiable { adopted, .. } => Some((
941                ErrlogSevEnum::Minor,
942                format!(
943                    "{POOL_RESERVATION_ENV} sets the worker-pool reservation budget to {} MiB, \
944                     and this target has no measurement that tracks its thread-memory ceiling: \
945                     this value cannot be verified and is taken as given",
946                    adopted >> 20
947                ),
948            )),
949            BudgetVerdict::FloorHeld { asked, basis } => Some((
950                ErrlogSevEnum::Major,
951                format!(
952                    "this target confirmed no worker-pool reservation budget down to {} MiB \
953                     (asked for {} MiB): even at {} MiB it {}. Holding that floor, so the pool \
954                     refuses nearly every client instead of exhausting the target",
955                    RESERVATION_PROBE_FLOOR >> 20,
956                    asked >> 20,
957                    RESERVATION_PROBE_FLOOR >> 20,
958                    basis
959                ),
960            )),
961        }
962    }
963}
964
965/// Reduce `requested` to a budget this target has been *shown* to give.
966///
967/// # The defect this closes
968///
969/// [`POOL_RESERVATION_ENV`] is the operator's only escape hatch, and it took the
970/// operator at their word. Raised past what the address space can honour it does
971/// not raise the ceiling — it removes the refusal that was keeping the process
972/// under it: on the ~958 MB guest, `320` walks the CA pool to set 46 and the RTP
973/// takes signal 6 with no refusal delivered to anyone. A switch that can kill
974/// the IOC silently is worse than no switch.
975///
976/// # The rule
977///
978/// Adopt the largest confirmed size not above `requested`, found by halving.
979/// Uniform: the built-in default is probed on exactly the same path as an
980/// operator's value, because a fallback nobody measured is the same defect one
981/// step down. Halving is coarse on purpose — this is a veto, not a search, and
982/// an operator who wants a size between two halvings names it and has that
983/// confirmed. The descent terminates at [`RESERVATION_PROBE_FLOOR`], so its cost
984/// is `log2(requested / floor)` mappings, and only the first of them is paid
985/// when the configured value is honest.
986fn decide_reservation_budget(
987    requested: usize,
988    from_env: bool,
989    mut admits: impl FnMut(usize) -> Option<TargetAnswer>,
990) -> BudgetVerdict {
991    if requested == usize::MAX {
992        // No wall to stay under, and no mapping of this size to ask about.
993        return BudgetVerdict::Confirmed(requested);
994    }
995    let mut candidate = requested;
996    loop {
997        let Some(TargetAnswer { granted, basis }) = admits(candidate) else {
998            return BudgetVerdict::Unverifiable {
999                adopted: requested,
1000                from_env,
1001            };
1002        };
1003        match granted {
1004            true if candidate == requested => return BudgetVerdict::Confirmed(candidate),
1005            true => {
1006                return BudgetVerdict::Clamped {
1007                    asked: requested,
1008                    adopted: candidate,
1009                    basis,
1010                };
1011            }
1012            false if candidate <= RESERVATION_PROBE_FLOOR => {
1013                return BudgetVerdict::FloorHeld {
1014                    asked: requested,
1015                    basis,
1016                };
1017            }
1018            false => candidate = (candidate / 2).max(RESERVATION_PROBE_FLOOR),
1019        }
1020    }
1021}
1022
1023/// Say what the boot-time check concluded, and hand back the budget it adopts.
1024fn announce_reservation_budget(verdict: BudgetVerdict) -> usize {
1025    if let Some((severity, message)) = verdict.notice() {
1026        errlog_sev_printf(severity, &format!("{message}\n"));
1027    }
1028    verdict.budget()
1029}
1030
1031/// One account of thread memory: a fixed budget and what is held against it.
1032///
1033/// A type rather than a pair of free functions so the budget can be *named* at
1034/// its owner — the process has exactly one account
1035/// ([`PROCESS_RESERVATION`]), and a test can hold its own without an
1036/// environment variable and without a one-shot global it cannot reset.
1037struct Reservation {
1038    budget: usize,
1039    held: AtomicUsize,
1040}
1041
1042impl Reservation {
1043    const fn new(budget: usize) -> Self {
1044        Self {
1045            budget,
1046            held: AtomicUsize::new(0),
1047        }
1048    }
1049
1050    /// Take `bytes`, or refuse with `(held, budget)` — the two numbers a
1051    /// refusal has to report.
1052    ///
1053    /// A whole set is taken in one step, before a single thread is created: the
1054    /// point of the budget is to refuse *before* the target is asked for memory
1055    /// it does not have, and a partial reservation would be no reservation.
1056    fn try_reserve(&self, bytes: usize) -> Result<(), (usize, usize)> {
1057        let mut held = self.held.load(Ordering::SeqCst);
1058        loop {
1059            let Some(next) = held.checked_add(bytes).filter(|n| *n <= self.budget) else {
1060                return Err((held, self.budget));
1061            };
1062            match self
1063                .held
1064                .compare_exchange_weak(held, next, Ordering::SeqCst, Ordering::SeqCst)
1065            {
1066                Ok(_) => return Ok(()),
1067                Err(actual) => held = actual,
1068            }
1069        }
1070    }
1071
1072    /// Take `bytes` that cannot be refused.
1073    ///
1074    /// The pool is elastic and so it asks; a scan thread, the callback bands,
1075    /// the CA acceptor and the audit writer are not — an IOC that declines to
1076    /// create them is not an IOC. They are still *charged*, because the budget's
1077    /// job is to say how much room is left, and a thread the account never heard
1078    /// of makes that number a statement about a different process. Over-budget
1079    /// is representable on purpose: it makes the elastic consumer refuse sooner,
1080    /// which is the correct consequence of the fixed threads having taken the
1081    /// room.
1082    fn charge(&self, bytes: usize) {
1083        self.held.fetch_add(bytes, Ordering::SeqCst);
1084    }
1085
1086    /// Give `bytes` back. Called once per *thread*, by the thread's own exit
1087    /// guard, plus once by a failed grow for the threads it never created — so
1088    /// the account tracks threads that exist, not sets that were planned.
1089    fn release(&self, bytes: usize) {
1090        self.held.fetch_sub(bytes, Ordering::SeqCst);
1091    }
1092
1093    /// What the account currently holds.
1094    #[cfg(test)]
1095    fn held(&self) -> usize {
1096        self.held.load(Ordering::SeqCst)
1097    }
1098}
1099
1100/// Thread memory every pool in this process has reserved and not yet given
1101/// back.
1102///
1103/// Process-wide and not per-pool because the resource is: a target that runs
1104/// out of address space does not care which pool reserved it, and an IOC runs
1105/// several (the CA server's, the CA client's, the PVA server's). A per-pool
1106/// budget would let three pools each stay inside their own bound and still walk
1107/// the process past the ceiling together.
1108/// Forced by the first thread the IOC charges, which on every entry point in
1109/// this workspace is a fixed facility thread created during start-up — so the
1110/// boot-time check in [`decide_reservation_budget`] and whatever it has to say
1111/// land before the first client, not on the first client.
1112static PROCESS_RESERVATION: LazyLock<Reservation> = LazyLock::new(|| {
1113    let default = default_reservation_budget(ThreadMemoryTarget::current());
1114    let requested =
1115        resolve_reservation_budget(std::env::var(POOL_RESERVATION_ENV).ok().as_deref(), default);
1116    Reservation::new(announce_reservation_budget(decide_reservation_budget(
1117        requested,
1118        requested != default,
1119        target_admits,
1120    )))
1121});
1122
1123/// What one thread of `stack` reserves — the whole per-thread formula, in one
1124/// place, so a pool worker and a fixed IOC thread cost the account the same.
1125fn thread_reservation_bytes(stack: StackSizeClass) -> usize {
1126    stack.bytes() + per_thread_overhead(ThreadMemoryTarget::current()) + PER_THREAD_OBJECT_ARENA
1127}
1128
1129/// What one thread of `role` reserves.
1130fn thread_reservation(role: &WorkerRole) -> usize {
1131    thread_reservation_bytes(role.stack)
1132}
1133
1134/// The target refused to materialise a kernel object a mutex needs.
1135///
1136/// # The defect this closes
1137///
1138/// A VxWorks pthread mutex has no kernel `SEMAPHORE` until its **first lock**:
1139/// `pthread_mutex_init` only stamps the magic, and `pthreadMutexInit` calls
1140/// `semMCreate` from inside `pthread_mutex_lock`. When that returns NULL the
1141/// chain hands back `0x16` — `EINVAL`, not `ENOMEM` — and `std::sync::Mutex`
1142/// turns it into "failed to lock mutex: invalid argument (os error 22)" and
1143/// **panics**. Measured on target at 588 live semaphores with 49 sets / 98
1144/// workers / 48 connections; the panicking worker took its set with it.
1145///
1146/// It is not a total. Creation resumed past 1,024 objects after that NULL, so
1147/// the arena is a *transient* refusal and a byte or count budget is the wrong
1148/// shape for it — there is no per-thread figure to add to
1149/// `PER_THREAD_OBJECT_ARENA`, and a cap set at 588 would refuse connections a
1150/// moment later than the target would have served them. What a transient
1151/// refusal needs is for the *rate* of creation to bend to the target, which is
1152/// what this gate does: the pool asks for the object at a point where "no"
1153/// costs one refusal, and the client's retry is the pacing.
1154///
1155/// # Why this is not its own [`AcquireError`] variant
1156///
1157/// It rides as the payload of [`AcquireError::SpawnFailed`], whose meaning it
1158/// shares exactly — *the target said no*, and the pool's own bounds were never
1159/// reached. A consumer that needs to tell an arena refusal from a stack refusal
1160/// downcasts, so the discriminator is a type rather than the message prose that
1161/// [`AcquireError`] exists to stop consumers parsing.
1162#[derive(Debug, Clone, Copy)]
1163pub struct ObjectArenaExhausted {
1164    /// How many objects the set needed — one per worker.
1165    pub objects: usize,
1166}
1167
1168impl std::fmt::Display for ObjectArenaExhausted {
1169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1170        write!(
1171            f,
1172            "the target could not create the kernel mutex objects for a set of \
1173             {} workers; this is transient, and a client that retries will be \
1174             admitted once the target has objects again",
1175            self.objects
1176        )
1177    }
1178}
1179
1180impl std::error::Error for ObjectArenaExhausted {}
1181
1182/// The gate itself: materialise the set's own state mutex, on the thread that
1183/// can still refuse.
1184///
1185/// Not a throwaway probe — this is the very mutex every worker in the set locks
1186/// on entry and again in [`WorkerExit`], so taking its object here *removes* the
1187/// failure site rather than sampling near it. `try_lock` and not `lock`: the
1188/// mutex is one statement old and unreachable by any other thread, so `false`
1189/// cannot mean contention, and `try_lock` reports the target's refusal as a
1190/// value where `lock` would panic.
1191///
1192/// What it does not cover: the objects `std` materialises inside the *spawned*
1193/// thread — the parker behind a blocking `recv`, above all — which no code on
1194/// this side of `Builder::spawn` can take in advance. The gate narrows the
1195/// window and paces the burst that opens it; it does not close it, and the
1196/// set-retirement path in [`WorkerExit`] is what keeps the residue survivable.
1197fn materialise_set_mutex(state: &Mutex<SetState>) -> bool {
1198    state.try_lock().is_ok()
1199}
1200
1201/// One thread's charge against the process account, held for exactly as long as
1202/// the thread is.
1203///
1204/// # Invariant
1205///
1206/// **MUST:** every thread this workspace creates holds one of these for its
1207/// whole life, pool worker or not. **MUST NOT:** any thread reserve stack the
1208/// account has not been told about.
1209///
1210/// # The defect this closes
1211///
1212/// The budget was an account of *pool* threads only, while an IOC also runs
1213/// fixed ones — the scan bands, the delayed-callback timer, the CA acceptor,
1214/// the audit writer, the status pusher, the dial pool's workers. Measured at
1215/// roughly 15 MiB on the VxWorks target: inside the headroom, and therefore
1216/// invisible, which is not the same as accounted for. Two things go wrong while
1217/// it stays invisible. The pool believes it may take the whole budget when it
1218/// may not, so the refusal lands later than the number says; and the moment a
1219/// target has more fixed threads than this one — a second server, more scan
1220/// rates — the error is no longer small and nothing reports that it grew.
1221///
1222/// [`Drop`] is the release, so an exit path cannot forget: the charge is moved
1223/// into the thread body and dies with it, including on unwind, and a
1224/// `Builder::spawn` that fails drops the closure and with it the charge.
1225pub struct ThreadCharge {
1226    bytes: usize,
1227}
1228
1229impl ThreadCharge {
1230    /// Charge one fixed thread of `stack`. Never refuses — see
1231    /// `Reservation::charge`.
1232    pub fn fixed(stack: StackSizeClass) -> Self {
1233        let bytes = thread_reservation_bytes(stack);
1234        PROCESS_RESERVATION.charge(bytes);
1235        Self { bytes }
1236    }
1237}
1238
1239impl Drop for ThreadCharge {
1240    fn drop(&mut self) {
1241        PROCESS_RESERVATION.release(self.bytes);
1242    }
1243}
1244
1245/// Why [`WorkerPool::acquire`] refused.
1246///
1247/// # The defect this closes
1248///
1249/// `acquire` refuses at two gates that mean opposite things to whoever has to
1250/// act on the refusal:
1251///
1252/// * [`AtCapacity`](Self::AtCapacity) — *this process* said no. Every set the
1253///   pool may create is already leased. The remedy is to raise the bound (or to
1254///   accept the bound as the connection limit it is); the target is fine.
1255/// * [`OutOfReservation`](Self::OutOfReservation) — *this process* said no on
1256///   behalf of the target: admitting would reserve more thread memory than the
1257///   process is allowed to hold. The remedy is RAM plus a raised
1258///   [`POOL_RESERVATION_ENV`], and the target is still healthy — which is the
1259///   whole point of refusing here rather than one connection later.
1260/// * [`SpawnFailed`](Self::SpawnFailed) — *the target* said no. The OS refused
1261///   to create the set's threads. The remedy is memory, and the pool's own
1262///   bound is irrelevant because it was never reached.
1263///
1264/// Both used to be an `io::Error`, and both landed on `io::ErrorKind::WouldBlock`
1265/// — the capacity arm by construction, the spawn arm because a failed
1266/// `Builder::spawn` is `EAGAIN` and `std` decodes `EAGAIN` as `WouldBlock`. So
1267/// the one discriminator a consumer had was the message *prose*, and every
1268/// consumer that branched on `kind()` silently answered the wrong question.
1269/// Both server drivers did: the CA server reported both as one status on the
1270/// wire — measured on VxWorks 7, where both gates were reached on one image
1271/// with `available=48` on each — and the PVA server's `kind() == WouldBlock`
1272/// arm reports an out-of-threads target as `max_connections reached`, naming a
1273/// bound that never fired. That second one is by construction, not measured:
1274/// the blocking PVA server has not been driven to its wall on this target.
1275///
1276/// Naming the gate in the type is what makes that class of mistake unwritable:
1277/// a consumer that wants "is this the connection limit" must now say so, and
1278/// gets an answer that cannot be an `EAGAIN` in disguise.
1279///
1280/// The [`From`] conversion to `io::Error` keeps each variant's historical
1281/// `ErrorKind` for callers that only propagate, and carries `self` as the
1282/// error's payload so the gate survives the conversion and stays recoverable
1283/// with `downcast_ref`.
1284#[derive(Debug)]
1285pub enum AcquireError {
1286    /// Every set the pool may ever create is leased out. `capacity` is the
1287    /// bound that was reached — the number to report and the number to raise.
1288    AtCapacity {
1289        /// The pool's declared capacity, in sets.
1290        capacity: usize,
1291    },
1292    /// Admitting would take the process past its thread-memory budget. Nothing
1293    /// was reserved and no thread was created.
1294    OutOfReservation {
1295        /// What this set would have reserved, in bytes.
1296        requested: usize,
1297        /// Already reserved by every pool in the process, in bytes.
1298        reserved: usize,
1299        /// The process budget, in bytes — the number [`POOL_RESERVATION_ENV`]
1300        /// raises.
1301        budget: usize,
1302    },
1303    /// The OS refused to create the set's threads. The pool was below its
1304    /// capacity and `created` is left exactly as it was found.
1305    SpawnFailed(io::Error),
1306    /// The pool is shutting down and will not lease again.
1307    ShuttingDown,
1308}
1309
1310impl std::fmt::Display for AcquireError {
1311    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1312        match self {
1313            // The leading words are load-bearing: they are what the on-target
1314            // consoles already carry, so an operator's existing grep keeps
1315            // working.
1316            AcquireError::AtCapacity { capacity } => {
1317                write!(f, "worker pool at capacity ({capacity} sets)")
1318            }
1319            AcquireError::OutOfReservation {
1320                requested,
1321                reserved,
1322                budget,
1323            } => write!(
1324                f,
1325                "worker pool at its thread-memory budget: this set needs {} KiB, \
1326                 {} of {} MiB already reserved — raise {POOL_RESERVATION_ENV} \
1327                 if the target has the memory",
1328                requested / 1024,
1329                reserved >> 20,
1330                budget >> 20,
1331            ),
1332            AcquireError::SpawnFailed(e) => write!(f, "cannot create a worker set: {e}"),
1333            AcquireError::ShuttingDown => write!(f, "worker pool is shutting down"),
1334        }
1335    }
1336}
1337
1338impl std::error::Error for AcquireError {
1339    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1340        match self {
1341            AcquireError::SpawnFailed(e) => Some(e),
1342            AcquireError::AtCapacity { .. }
1343            | AcquireError::OutOfReservation { .. }
1344            | AcquireError::ShuttingDown => None,
1345        }
1346    }
1347}
1348
1349impl From<AcquireError> for io::Error {
1350    fn from(cause: AcquireError) -> io::Error {
1351        let kind = match &cause {
1352            // The reservation gate is the capacity gate's twin — the process
1353            // refusing, not the target — so it keeps the same historical kind
1354            // for callers that only propagate; the variant is what tells them
1355            // apart.
1356            AcquireError::AtCapacity { .. } | AcquireError::OutOfReservation { .. } => {
1357                io::ErrorKind::WouldBlock
1358            }
1359            // The OS's own kind, so a propagating caller sees what the target
1360            // actually said rather than a re-labelling of it.
1361            AcquireError::SpawnFailed(e) => e.kind(),
1362            AcquireError::ShuttingDown => io::ErrorKind::BrokenPipe,
1363        };
1364        io::Error::new(kind, cause)
1365    }
1366}
1367
1368/// A bounded, per-role set of persistent threads that connections borrow.
1369///
1370/// `N` is the set size — three for the PVA server (`conn`, `reader`, `writer`),
1371/// two for a blocking client's circuit (`reader`, `writer`). `Worker` and
1372/// `SetLease` are **not** generic over `N`, so a leased worker crosses into the
1373/// byte-pump seam without spreading a const parameter through every signature.
1374pub struct WorkerPool<const N: usize> {
1375    inner: Arc<PoolInner>,
1376}
1377
1378impl<const N: usize> WorkerPool<N> {
1379    /// Declare a role's pool. Lazy: no thread exists until the first
1380    /// [`acquire`](Self::acquire) that cannot reuse an idle set.
1381    ///
1382    /// `capacity` is the most sets that may ever exist, and for a server it is
1383    /// the connection limit — admission refuses past it. Not `const`, because a
1384    /// pool owns heap state; a process-lifetime pool is a `LazyLock<WorkerPool>`,
1385    /// a server-lifetime pool is a field dropped with the server.
1386    pub fn new(name_prefix: &'static str, roster: [WorkerRole; N], capacity: usize) -> Self {
1387        Self::with_reservation(name_prefix, roster, capacity, &PROCESS_RESERVATION)
1388    }
1389
1390    /// [`Self::new`] against a named account, so a test can bound a pool by a
1391    /// budget of its own choosing without touching the process's.
1392    fn with_reservation(
1393        name_prefix: &'static str,
1394        roster: [WorkerRole; N],
1395        capacity: usize,
1396        reservation: &'static Reservation,
1397    ) -> Self {
1398        Self::with_reservation_and_gate(
1399            name_prefix,
1400            roster,
1401            capacity,
1402            reservation,
1403            materialise_set_mutex,
1404        )
1405    }
1406
1407    /// [`Self::with_reservation`] with the object-arena gate injected, so a host
1408    /// test can exercise the refusal a target produces.
1409    fn with_reservation_and_gate(
1410        name_prefix: &'static str,
1411        roster: [WorkerRole; N],
1412        capacity: usize,
1413        reservation: &'static Reservation,
1414        materialise: fn(&Mutex<SetState>) -> bool,
1415    ) -> Self {
1416        let set_reservation = roster.iter().map(thread_reservation).sum();
1417        Self {
1418            inner: Arc::new(PoolInner {
1419                roster: Box::new(roster),
1420                name_prefix,
1421                capacity,
1422                set_reservation,
1423                reservation,
1424                materialise,
1425                reg: Mutex::new(Registry {
1426                    idle: VecDeque::new(),
1427                    all: Vec::new(),
1428                    created: 0,
1429                    stopping: false,
1430                }),
1431            }),
1432        }
1433    }
1434
1435    /// Borrow a whole set, or refuse.
1436    ///
1437    /// * an idle set exists → reuse it (no thread created);
1438    /// * none, and `created < capacity` → grow by one set (`N` threads);
1439    /// * none, and at capacity → [`AcquireError::AtCapacity`] carrying the
1440    ///   bound that was reached;
1441    /// * a thread could not be created → [`AcquireError::SpawnFailed`], with
1442    ///   `created` left exactly as it was found.
1443    ///
1444    /// The refusals are a sum type and not an `io::Error` because they mean
1445    /// opposite things — a full process versus a target out of thread resources
1446    /// — and as `io::Error` they were indistinguishable: both are
1447    /// `ErrorKind::WouldBlock`. See [`AcquireError`].
1448    pub fn acquire(&self) -> Result<(SetLease, [Worker; N]), AcquireError> {
1449        // Decide under the pool lock, spawn without it: a reserved-then-spawn
1450        // step keeps `created` an exact bound without holding the lock across a
1451        // thread creation.
1452        enum Decision {
1453            Reuse(Arc<SetHandle>),
1454            Grow(usize),
1455            Full,
1456        }
1457        let decision = {
1458            let mut reg = self.inner.lock();
1459            if reg.stopping {
1460                return Err(AcquireError::ShuttingDown);
1461            }
1462            if let Some(set) = reg.idle.pop_front() {
1463                Decision::Reuse(set)
1464            } else if reg.created < self.inner.capacity {
1465                let index = reg.created;
1466                reg.created += 1;
1467                Decision::Grow(index)
1468            } else {
1469                Decision::Full
1470            }
1471        };
1472
1473        let set = match decision {
1474            Decision::Full => {
1475                return Err(AcquireError::AtCapacity {
1476                    capacity: self.inner.capacity,
1477                });
1478            }
1479            Decision::Reuse(set) => set,
1480            Decision::Grow(index) => {
1481                // The memory this set will hold is taken from the process
1482                // budget *before* the target is asked to create anything, so a
1483                // refusal happens while the target is still healthy enough to
1484                // deliver it. Reusing an idle set reserves nothing: its threads
1485                // already exist and are already charged.
1486                if let Err((reserved, budget)) = self
1487                    .inner
1488                    .reservation
1489                    .try_reserve(self.inner.set_reservation)
1490                {
1491                    self.inner.lock().created -= 1;
1492                    return Err(AcquireError::OutOfReservation {
1493                        requested: self.inner.set_reservation,
1494                        reserved,
1495                        budget,
1496                    });
1497                }
1498                match self.spawn_set(index) {
1499                    Ok(set) => {
1500                        let mut reg = self.inner.lock();
1501                        reg.all.push(set.clone());
1502                        set
1503                    }
1504                    Err(e) => {
1505                        // The slot reservation is given back so a later attempt
1506                        // may grow again; the memory reservation of the threads
1507                        // that were never created was given back by `spawn_set`,
1508                        // and the ones that were created give theirs back as
1509                        // they exit. Nothing else changed.
1510                        self.inner.lock().created -= 1;
1511                        return Err(AcquireError::SpawnFailed(e));
1512                    }
1513                }
1514            }
1515        };
1516
1517        // Lease it: leased, not parked. A grown set starts with these values;
1518        // a reused one is flipped back to them here.
1519        {
1520            let mut st = lock_set(&set);
1521            st.leased = true;
1522            st.parked = false;
1523        }
1524        let workers: Vec<Worker> = (0..N)
1525            .map(|slot| Worker {
1526                inner: self.inner.clone(),
1527                set: set.clone(),
1528                tx: set.senders[slot].clone(),
1529            })
1530            .collect();
1531        let workers: [Worker; N] = workers
1532            .try_into()
1533            .unwrap_or_else(|_| unreachable!("N workers for an N-role set"));
1534        let lease = SetLease {
1535            inner: self.inner.clone(),
1536            set,
1537        };
1538        Ok((lease, workers))
1539    }
1540
1541    /// Spawn one set's `N` threads. On a partway failure the threads already
1542    /// created are stopped and joined, so a failed grow leaks nothing.
1543    fn spawn_set(&self, index: usize) -> io::Result<Arc<SetHandle>> {
1544        let mut senders = Vec::with_capacity(N);
1545        let mut receivers = Vec::with_capacity(N);
1546        for _ in 0..N {
1547            let (tx, rx) = channel::<Assignment>();
1548            senders.push(tx);
1549            receivers.push(rx);
1550        }
1551        let set = Arc::new(SetHandle {
1552            index,
1553            senders,
1554            dead: AtomicBool::new(false),
1555            state: Mutex::new(SetState {
1556                leased: false,
1557                running: 0,
1558                parked: false,
1559                // The set's full roster. A grow that fails partway never
1560                // publishes the set and retires its threads through `Stop`, so
1561                // no short-staffed set is ever counted here.
1562                live_workers: N,
1563            }),
1564            joins: Mutex::new(Vec::with_capacity(N)),
1565        });
1566
1567        // The object-arena gate. Nothing has been created yet, so refusing here
1568        // costs the caller a refusal and the target nothing.
1569        if !(self.inner.materialise)(&set.state) {
1570            let unspawned: usize = self.inner.roster.iter().map(thread_reservation).sum();
1571            self.inner.reservation.release(unspawned);
1572            return Err(io::Error::new(
1573                io::ErrorKind::WouldBlock,
1574                ObjectArenaExhausted { objects: N },
1575            ));
1576        }
1577
1578        let mut joins: Vec<JoinHandle<()>> = Vec::with_capacity(N);
1579        for (slot, rx) in receivers.into_iter().enumerate() {
1580            let role = self.inner.roster[slot];
1581            let name = format!("{}-{} {index}", self.inner.name_prefix, role.suffix);
1582            let inner = self.inner.clone();
1583            let set_for_worker = set.clone();
1584            let spawned = thread::Builder::new()
1585                .name(name)
1586                .stack_size(role.stack.bytes())
1587                .spawn(move || {
1588                    // Installed before anything that can unwind — the prologue
1589                    // included — so no way out of this thread leaves the set
1590                    // counted against the pool's bound. See `WorkerExit`.
1591                    let mut exit = WorkerExit {
1592                        inner: inner.clone(),
1593                        set: set_for_worker.clone(),
1594                        reserved: thread_reservation(&role),
1595                        clean: false,
1596                    };
1597                    // The band is the role's, for the thread's whole life. Taken
1598                    // here in the closure so the crate's thread-prologue guards
1599                    // see it on the spawned body.
1600                    let _ = enter_ioc_thread(role.priority);
1601                    worker_loop(inner, set_for_worker, rx);
1602                    exit.clean = true;
1603                });
1604            match spawned {
1605                Ok(handle) => joins.push(handle),
1606                Err(e) => {
1607                    // The threads from this slot on do not exist and never
1608                    // will, so their share of the set's reservation is given
1609                    // back here — the ones that *were* created give theirs back
1610                    // through their own exit guards, so every byte is released
1611                    // exactly once by whoever it was spent on.
1612                    let unspawned: usize = self.inner.roster[joins.len()..]
1613                        .iter()
1614                        .map(thread_reservation)
1615                        .sum();
1616                    self.inner.reservation.release(unspawned);
1617                    // Retire the workers already spawned for this set. Their
1618                    // senders live in `set`, still held here, so the `Stop`s land.
1619                    for tx in &set.senders {
1620                        let _ = tx.send(Assignment::Stop);
1621                    }
1622                    for handle in joins {
1623                        let _ = handle.join();
1624                    }
1625                    return Err(e);
1626                }
1627            }
1628        }
1629        // The set owns its threads from here on: nothing outside it can retire
1630        // them, and its own retirement cannot forget them.
1631        lock_joins(&set).extend(joins);
1632        Ok(set)
1633    }
1634
1635    /// Threads this pool has created, ever — never more than
1636    /// `capacity × N`. The bound made observable: the number the per-connection
1637    /// shape grew without limit.
1638    pub fn worker_count(&self) -> usize {
1639        self.inner.lock().created * N
1640    }
1641
1642    /// `(busy_sets, created_sets, capacity)` — the admission state.
1643    ///
1644    /// Deliberately not `queue_depth`: there is no queue, admission refuses, and
1645    /// a name that promised one would be the dual meaning this design removes.
1646    pub fn set_usage(&self) -> (usize, usize, usize) {
1647        let reg = self.inner.lock();
1648        let busy = reg.created - reg.idle.len();
1649        (busy, reg.created, self.inner.capacity)
1650    }
1651}
1652
1653impl<const N: usize> Drop for WorkerPool<N> {
1654    /// Retire every worker thread. A process-lifetime pool (a `static`
1655    /// `LazyLock`) never reaches here; a server-lifetime pool does, at server
1656    /// drop, and must be dropped *after* the server's connections have been
1657    /// asked to stop, so the `Stop`s do not queue behind a live connection
1658    /// forever.
1659    fn drop(&mut self) {
1660        let (senders, joins) = {
1661            let mut reg = self.inner.lock();
1662            reg.stopping = true;
1663            reg.idle.clear();
1664            // One `Stop` per worker across *every* set — leased or idle — so no
1665            // worker is left parked on `recv`. `all` is what makes a leased set's
1666            // senders reachable here; the idle deque alone would miss them. A
1667            // retired set is not in `all` and needs neither: its threads were
1668            // stopped and detached when its slot went back.
1669            let mut senders: Vec<Sender<Assignment>> = Vec::new();
1670            let mut joins: Vec<JoinHandle<()>> = Vec::new();
1671            for set in &reg.all {
1672                senders.extend(set.senders.iter().cloned());
1673                joins.append(&mut lock_joins(set));
1674            }
1675            (senders, joins)
1676        };
1677        // An idle worker takes its `Stop` at once; a worker still inside a job
1678        // takes it after that job returns. A correct teardown has already asked
1679        // its connections to stop, so no `Stop` waits behind a live one forever.
1680        for tx in senders {
1681            let _ = tx.send(Assignment::Stop);
1682        }
1683        for handle in joins {
1684            let _ = handle.join();
1685        }
1686    }
1687}
1688
1689#[cfg(test)]
1690mod tests {
1691    use super::*;
1692    use std::sync::atomic::{AtomicUsize, Ordering};
1693    use std::time::{Duration, Instant};
1694
1695    fn roster2() -> [WorkerRole; 2] {
1696        [
1697            WorkerRole {
1698                suffix: "reader",
1699                stack: StackSizeClass::Small,
1700                priority: ThreadPriority::Low,
1701            },
1702            WorkerRole {
1703                suffix: "writer",
1704                stack: StackSizeClass::Small,
1705                priority: ThreadPriority::Low,
1706            },
1707        ]
1708    }
1709
1710    /// A set is borrowed and returned; the next borrow reuses the same threads.
1711    ///
1712    /// The tight spot is the borrow immediately after a return: the set is freed
1713    /// by the last job's completion, and a pool that counted parked workers
1714    /// instead of busy ones would fail to see it as available. So the assertion
1715    /// is inside the loop, not only after it — the direct statement of the
1716    /// closed leak.
1717    #[test]
1718    fn sequential_borrows_reuse_one_set() {
1719        let pool: WorkerPool<2> = WorkerPool::new("test-pool", roster2(), 4);
1720        const BORROWS: usize = 8;
1721        for i in 0..BORROWS {
1722            let (lease, [reader, writer]) = pool.acquire().expect("borrow");
1723            let ran = Arc::new(AtomicUsize::new(0));
1724            let r = ran.clone();
1725            let jr = reader.run(move || {
1726                r.fetch_add(1, Ordering::SeqCst);
1727            });
1728            let w = ran.clone();
1729            let jw = writer.run(move || {
1730                w.fetch_add(1, Ordering::SeqCst);
1731            });
1732            assert!(jr.join().is_ok());
1733            assert!(jw.join().is_ok());
1734            drop(lease);
1735            // The set is freed by the last job's completion / the lease drop,
1736            // both of which may still be settling; wait for the return.
1737            let deadline = Instant::now() + Duration::from_secs(5);
1738            while pool.set_usage().0 != 0 {
1739                assert!(Instant::now() < deadline, "set never returned to idle");
1740                thread::yield_now();
1741            }
1742            assert_eq!(ran.load(Ordering::SeqCst), 2);
1743            assert_eq!(
1744                pool.worker_count(),
1745                2,
1746                "borrow {i} created new threads instead of reusing the idle set"
1747            );
1748        }
1749        assert_eq!(
1750            pool.worker_count(),
1751            2,
1752            "{BORROWS} sequential borrows must have created exactly one set"
1753        );
1754    }
1755
1756    /// A set is not reused while any of its jobs is still running.
1757    #[test]
1758    fn a_set_is_not_reidled_while_a_job_runs() {
1759        let pool: WorkerPool<2> = WorkerPool::new("test-hold", roster2(), 4);
1760        let (lease, [reader, writer]) = pool.acquire().expect("borrow");
1761        // A job that blocks until released.
1762        let gate = Arc::new((Mutex::new(false), std::sync::Condvar::new()));
1763        let g = gate.clone();
1764        let blocking = reader.run(move || {
1765            let (m, cv) = &*g;
1766            let mut open = m.lock().unwrap();
1767            while !*open {
1768                open = cv.wait(open).unwrap();
1769            }
1770        });
1771        let quick = writer.run(|| {});
1772        assert!(quick.join().is_ok());
1773        drop(lease);
1774        // Lease gone and one job done, but the reader is still running: the set
1775        // must NOT be idle.
1776        assert_eq!(
1777            pool.set_usage().0,
1778            1,
1779            "a running job must keep its set busy"
1780        );
1781        // Release the blocked job.
1782        {
1783            let (m, cv) = &*gate;
1784            *m.lock().unwrap() = true;
1785            cv.notify_all();
1786        }
1787        assert!(blocking.join().is_ok());
1788        let deadline = Instant::now() + Duration::from_secs(5);
1789        while pool.set_usage().0 != 0 {
1790            assert!(
1791                Instant::now() < deadline,
1792                "set never returned after its last job"
1793            );
1794            thread::yield_now();
1795        }
1796        assert_eq!(pool.worker_count(), 2);
1797    }
1798
1799    /// At capacity, `acquire` refuses by naming the bound and creates no thread.
1800    #[test]
1801    fn acquire_refuses_at_capacity_without_creating_a_thread() {
1802        let pool: WorkerPool<2> = WorkerPool::new("test-cap", roster2(), 1);
1803        let (lease, _workers) = pool.acquire().expect("first borrow");
1804        let before = pool.worker_count();
1805        let refused = pool.acquire().err();
1806        assert!(
1807            matches!(refused, Some(AcquireError::AtCapacity { capacity: 1 })),
1808            "a full pool must refuse by naming the bound it reached, not queue \
1809             or grow: {refused:?}"
1810        );
1811        assert_eq!(
1812            pool.worker_count(),
1813            before,
1814            "a refusal must create no thread"
1815        );
1816        drop(lease);
1817    }
1818
1819    /// # Invariant
1820    ///
1821    /// MUST: a refusal name which gate refused. MUST NOT: "this process is
1822    /// full" and "this target is out of threads" be the same value.
1823    ///
1824    /// They were, and the collapse is `std`'s, not ours: a failed
1825    /// `Builder::spawn` is `EAGAIN`, `std` decodes `EAGAIN` as
1826    /// `ErrorKind::WouldBlock`, and the capacity refusal was constructed as
1827    /// `WouldBlock` too. So a consumer branching on `kind()` — as the PVA
1828    /// accept path did — could not tell a full server from a target that had
1829    /// run out of thread resources, and reported the second as the first.
1830    ///
1831    /// The spawn failure below is given that same `WouldBlock` kind on
1832    /// purpose: the two refusals have to stay apart in the one case where the
1833    /// kind cannot tell them apart, which is the case that actually occurs.
1834    /// That `EAGAIN` really is what arrives with that kind is a property of
1835    /// the platform's errno table, asserted separately by
1836    /// `eagain_still_decodes_as_would_block`.
1837    #[test]
1838    fn a_full_pool_and_a_refused_spawn_are_not_the_same_refusal() {
1839        let pool: WorkerPool<2> = WorkerPool::new("test-gate", roster2(), 1);
1840        let (lease, _workers) = pool.acquire().expect("first borrow");
1841        let full = pool.acquire().err().expect("the pool is full");
1842        let spawn_failed = AcquireError::SpawnFailed(io::ErrorKind::WouldBlock.into());
1843
1844        assert!(
1845            matches!(full, AcquireError::AtCapacity { .. }),
1846            "a full pool is a capacity refusal: {full:?}"
1847        );
1848        assert!(
1849            !matches!(spawn_failed, AcquireError::AtCapacity { .. }),
1850            "a refused spawn must never present as the capacity gate: it is \
1851             the difference between 'raise the bound' and 'add memory'"
1852        );
1853        // …and the distinction survives the lossy conversion, so even a caller
1854        // that only ever sees `io::Error` can recover the gate.
1855        let as_io: io::Error = full.into();
1856        assert_eq!(
1857            as_io.kind(),
1858            io::ErrorKind::WouldBlock,
1859            "the historical kind is preserved for callers that only propagate"
1860        );
1861        assert!(
1862            matches!(
1863                as_io
1864                    .get_ref()
1865                    .and_then(|e| e.downcast_ref::<AcquireError>()),
1866                Some(AcquireError::AtCapacity { capacity: 1 })
1867            ),
1868            "the gate must survive the io::Error conversion: {as_io:?}"
1869        );
1870        drop(lease);
1871    }
1872
1873    /// The platform half of the invariant above: `std` decoding `EAGAIN` as
1874    /// `WouldBlock` is what makes a full pool and an out-of-threads target
1875    /// indistinguishable by `kind()` alone, and so is what `AcquireError`
1876    /// exists to undo.
1877    ///
1878    /// Unix-only because the errno table is, and `libc::EAGAIN` rather than a
1879    /// literal for the same reason: the number is 11 on Linux but 35 on the
1880    /// BSDs and macOS, where 11 is `EDEADLK` and decodes as `Deadlock`.
1881    /// Windows has no `EAGAIN` here at all — thread exhaustion never surfaces
1882    /// as one — so there is nothing to assert there.
1883    #[cfg(unix)]
1884    #[test]
1885    fn eagain_still_decodes_as_would_block() {
1886        assert_eq!(
1887            io::Error::from_raw_os_error(libc::EAGAIN).kind(),
1888            io::ErrorKind::WouldBlock,
1889            "EAGAIN decodes as WouldBlock — the collapse `AcquireError` exists \
1890             to undo. If this ever stops holding, say so here rather than in a \
1891             comment."
1892        );
1893    }
1894
1895    /// A job that panics returns its set, and the worker keeps serving: the next
1896    /// borrow succeeds and no thread was created to replace the one that
1897    /// panicked.
1898    #[test]
1899    fn a_panicked_job_returns_its_set_and_the_worker_survives() {
1900        let pool: WorkerPool<2> = WorkerPool::new("test-panic", roster2(), 2);
1901        let (lease, [reader, writer]) = pool.acquire().expect("borrow");
1902        let boom = reader.run(|| panic!("job blew up"));
1903        let ok = writer.run(|| {});
1904        assert!(boom.join().is_err(), "the panic must reach the joiner");
1905        assert!(ok.join().is_ok());
1906        drop(lease);
1907        let deadline = Instant::now() + Duration::from_secs(5);
1908        while pool.set_usage().0 != 0 {
1909            assert!(Instant::now() < deadline, "panicked set never returned");
1910            thread::yield_now();
1911        }
1912        let created_before = pool.worker_count();
1913        // The same threads serve the next borrow.
1914        let (lease2, [r2, w2]) = pool.acquire().expect("borrow after panic");
1915        assert!(r2.run(|| {}).join().is_ok());
1916        assert!(w2.run(|| {}).join().is_ok());
1917        drop(lease2);
1918        assert_eq!(
1919            pool.worker_count(),
1920            created_before,
1921            "a lost worker is never recreated, and a survivor needs no replacement"
1922        );
1923    }
1924
1925    /// A detached job runs and returns its set with no joiner.
1926    #[test]
1927    fn a_detached_job_returns_its_set() {
1928        let pool: WorkerPool<2> = WorkerPool::new("test-detach", roster2(), 2);
1929        let (lease, [reader, writer]) = pool.acquire().expect("borrow");
1930        let ran = Arc::new(AtomicUsize::new(0));
1931        let r = ran.clone();
1932        reader.run_detached("conn".into(), move || {
1933            r.fetch_add(1, Ordering::SeqCst);
1934        });
1935        let done = writer.run(|| {});
1936        assert!(done.join().is_ok());
1937        drop(lease);
1938        let deadline = Instant::now() + Duration::from_secs(5);
1939        while pool.set_usage().0 != 0 {
1940            assert!(Instant::now() < deadline, "detached set never returned");
1941            thread::yield_now();
1942        }
1943        assert_eq!(ran.load(Ordering::SeqCst), 1);
1944    }
1945
1946    /// A panic payload whose own `Drop` panics — the deterministic stand-in for
1947    /// a worker thread that dies somewhere the job's `catch_unwind` does not
1948    /// cover.
1949    struct PanicOnDrop;
1950
1951    impl Drop for PanicOnDrop {
1952        fn drop(&mut self) {
1953            panic!("payload drop: the worker thread dies here, outside catch_unwind");
1954        }
1955    }
1956
1957    /// # Invariant
1958    ///
1959    /// MUST: a set whose worker thread has exited be retired — released from
1960    /// `busy`, dropped from the idle deque, and given back to `created`. MUST
1961    /// NOT: a worker's death leave its set counted busy for the life of the
1962    /// process.
1963    ///
1964    /// Measured on `x86_64-wrs-vxworks` at the reservation wall: three worker
1965    /// threads died across two sets and the pool reported `BUSY=2 SETS=50
1966    /// WORKERS=100 CONNS=0` — two sets leased forever with no client attached,
1967    /// so the connection bound was permanently 139 instead of 141 and every
1968    /// further death cost another set
1969    /// (on `caucus/58EWEJWV91/e8-poolprobe-0548dc61-1`).
1970    ///
1971    /// The target's mechanism was a `std` mutex lock returning `EINVAL` inside
1972    /// the loop's return path, which is not reproducible on demand. This
1973    /// reproduces the *same* thread death at the *same* point deterministically:
1974    /// the job's panic is caught, and then the payload is dropped on the worker
1975    /// thread — `let _ = done.send(outcome)` drops it there when the joiner is
1976    /// already gone — so the worker unwinds before it reaches `finish_job`.
1977    /// Any panic on that stretch does this; the payload is only how the test
1978    /// gets one on demand.
1979    #[test]
1980    fn a_worker_that_dies_retires_its_set_instead_of_leaking_it() {
1981        let pool: WorkerPool<2> = WorkerPool::new("test-dead", roster2(), 2);
1982        let (lease, [reader, _writer]) = pool.acquire().expect("borrow");
1983
1984        // The body waits, so the `Job` can be dropped first: the worker's
1985        // `done.send` must fail for the payload to drop on the worker thread.
1986        let (go, wait) = channel::<()>();
1987        let job = reader.run(move || {
1988            let _ = wait.recv();
1989            std::panic::panic_any(PanicOnDrop);
1990        });
1991        drop(job);
1992        drop(lease);
1993        go.send(()).expect("the worker is waiting on this");
1994        drop(go);
1995
1996        let deadline = Instant::now() + Duration::from_secs(10);
1997        loop {
1998            let (busy, created, _cap) = pool.set_usage();
1999            if busy == 0 {
2000                assert_eq!(
2001                    created, 0,
2002                    "a set with a dead worker must not stay countable: its \
2003                     threads are gone, so its slot must return to the bound"
2004                );
2005                break;
2006            }
2007            assert!(
2008                Instant::now() < deadline,
2009                "the set is still busy with no lease and no live job: a worker \
2010                 that died took its set out of circulation permanently, which \
2011                 is the one-set-per-death leak measured on target"
2012            );
2013            thread::yield_now();
2014        }
2015
2016        // And the pool still admits: the retired set freed its slot.
2017        let (lease2, [r2, w2]) = pool.acquire().expect("borrow after a death");
2018        assert!(
2019            r2.run(|| {}).join().is_ok(),
2020            "a fresh set must actually run"
2021        );
2022        assert!(w2.run(|| {}).join().is_ok());
2023        drop(lease2);
2024    }
2025
2026    /// The other side of the death boundary: the lease is still held when the
2027    /// worker dies. Retirement may not wait for the borrower — the slot has to
2028    /// come back while the borrower still holds its (now useless) lease, and the
2029    /// lease drop that follows must not push a thread-short set back to idle.
2030    #[test]
2031    fn a_death_under_a_live_lease_returns_the_slot_and_never_repools_the_set() {
2032        let pool: WorkerPool<2> = WorkerPool::new("test-dead-leased", roster2(), 2);
2033        let (lease, [reader, writer]) = pool.acquire().expect("borrow");
2034
2035        // Same handshake as above: the payload must drop on the *worker*, so the
2036        // `Job` has to be gone before the body panics.
2037        let (go, wait) = channel::<()>();
2038        let job = reader.run(move || {
2039            let _ = wait.recv();
2040            std::panic::panic_any(PanicOnDrop);
2041        });
2042        drop(job);
2043        go.send(()).expect("the worker is waiting on this");
2044        drop(go);
2045
2046        let deadline = Instant::now() + Duration::from_secs(10);
2047        while pool.set_usage().1 != 0 {
2048            assert!(
2049                Instant::now() < deadline,
2050                "a set that lost a thread stayed countable while its lease was \
2051                 held; the slot must return as soon as the threads are gone"
2052            );
2053            thread::yield_now();
2054        }
2055
2056        // The surviving role is unusable, and says so rather than reporting a
2057        // body that never ran as a clean completion.
2058        assert!(
2059            writer.run(|| {}).join().is_err(),
2060            "a job dispatched into a retired set must be reported as not run"
2061        );
2062
2063        drop(lease);
2064        assert_eq!(
2065            pool.set_usage(),
2066            (0, 0, 2),
2067            "the lease drop must not re-pool a retired set"
2068        );
2069        assert!(
2070            pool.acquire().is_ok(),
2071            "the pool must still admit after a death under lease"
2072        );
2073    }
2074
2075    /// Dropping the pool retires its worker threads rather than leaking them.
2076    #[test]
2077    fn dropping_the_pool_joins_its_workers() {
2078        let pool: WorkerPool<2> = WorkerPool::new("test-drop", roster2(), 2);
2079        let (lease, [reader, writer]) = pool.acquire().expect("borrow");
2080        assert!(reader.run(|| {}).join().is_ok());
2081        assert!(writer.run(|| {}).join().is_ok());
2082        drop(lease);
2083        // Give the set time to return so `drop` finds the workers idle.
2084        let deadline = Instant::now() + Duration::from_secs(5);
2085        while pool.set_usage().0 != 0 {
2086            assert!(Instant::now() < deadline, "set never returned before drop");
2087            thread::yield_now();
2088        }
2089        // Must not hang: the `Stop`s reach idle workers and the join completes.
2090        drop(pool);
2091    }
2092
2093    /// One set of [`roster2`] on a 64-bit host: two `Small` stacks and no
2094    /// per-thread overhead charged off-target.
2095    const HOST_SET: usize = 2 * 512 * 1024;
2096
2097    /// # Invariant
2098    ///
2099    /// MUST: admission refuse while the thread memory it would reserve is still
2100    /// unspent. MUST NOT: a pool create a thread whose memory is not already
2101    /// reserved from the budget.
2102    ///
2103    /// The defect: the pool's only bound was a *count*, so on
2104    /// `x86_64-wrs-vxworks` the CA server walked to 41 concurrent clients and
2105    /// the RTP died — a 64-byte allocation failed, `signal 6`, whole process
2106    /// gone — with its count bound of 141 nowhere in sight. A bound reached
2107    /// after the target has run out is not an admission gate.
2108    ///
2109    /// The boundary is exact rather than narrative: a budget of two sets admits
2110    /// two and refuses the third, and the refusal costs no thread.
2111    #[test]
2112    fn admission_refuses_at_the_memory_budget_before_the_count_bound() {
2113        static TWO_SETS: Reservation = Reservation::new(2 * HOST_SET);
2114        // Capacity 8 so the count bound cannot be what refuses.
2115        let pool: WorkerPool<2> =
2116            WorkerPool::with_reservation("test-budget", roster2(), 8, &TWO_SETS);
2117
2118        let (l1, _w1) = pool.acquire().expect("first set fits");
2119        let (l2, _w2) = pool.acquire().expect("second set fits exactly");
2120        assert_eq!(pool.worker_count(), 4, "two sets, two threads each");
2121
2122        let refused = pool.acquire().err().expect("the third set does not fit");
2123        assert!(
2124            matches!(
2125                refused,
2126                AcquireError::OutOfReservation {
2127                    requested,
2128                    reserved,
2129                    budget,
2130                } if requested == HOST_SET
2131                    && reserved == 2 * HOST_SET
2132                    && budget == 2 * HOST_SET
2133            ),
2134            "the refusal must name what was asked for, what is held and the \
2135             budget — the three numbers the remedy needs: {refused:?}"
2136        );
2137        assert_eq!(
2138            pool.worker_count(),
2139            4,
2140            "a refusal must not have created the threads it refused"
2141        );
2142        assert_eq!(
2143            pool.set_usage(),
2144            (2, 2, 8),
2145            "the refused grow must leave the slot reservation exactly as it \
2146             found it"
2147        );
2148
2149        drop(l1);
2150        drop(l2);
2151        // Returning a set does not return its memory: its threads still exist.
2152        // What must come back is the *reuse*, and it does — the fourth borrow
2153        // creates nothing.
2154        let (l3, _w3) = pool.acquire().expect("an idle set is reused, not grown");
2155        assert_eq!(pool.worker_count(), 4);
2156        drop(l3);
2157        drop(pool);
2158        assert_eq!(
2159            TWO_SETS.held.load(Ordering::SeqCst),
2160            0,
2161            "every thread's reservation must come back when the pool is dropped"
2162        );
2163    }
2164
2165    /// The release side of the same invariant on the path that has no `Drop` of
2166    /// its own to lean on: a set whose worker *died*. Its memory must return to
2167    /// the budget, or a target that loses a worker refuses connections it has
2168    /// the memory to serve — for the life of the process.
2169    #[test]
2170    fn a_dead_set_gives_its_memory_back_to_the_budget() {
2171        static ONE_SET: Reservation = Reservation::new(HOST_SET);
2172        let pool: WorkerPool<2> = WorkerPool::with_reservation("test-rel", roster2(), 4, &ONE_SET);
2173
2174        let (lease, [reader, _writer]) = pool.acquire().expect("the one set fits");
2175        let (go, wait) = channel::<()>();
2176        let job = reader.run(move || {
2177            let _ = wait.recv();
2178            std::panic::panic_any(PanicOnDrop);
2179        });
2180        drop(job);
2181        drop(lease);
2182        go.send(()).expect("the worker is waiting on this");
2183        drop(go);
2184
2185        let deadline = Instant::now() + Duration::from_secs(10);
2186        while ONE_SET.held.load(Ordering::SeqCst) != 0 {
2187            assert!(
2188                Instant::now() < deadline,
2189                "a set that lost a worker kept its reservation: held {} of {}",
2190                ONE_SET.held.load(Ordering::SeqCst),
2191                HOST_SET
2192            );
2193            thread::yield_now();
2194        }
2195        pool.acquire()
2196            .expect("the budget freed by the dead set must admit a new one");
2197    }
2198
2199    /// # Invariant
2200    ///
2201    /// MUST: the owner that returns a set's slot return the set's *threads* in
2202    /// the same step. MUST NOT: an exited worker be left neither joined nor
2203    /// detached.
2204    ///
2205    /// The defect: every worker's `JoinHandle` went into one pool-wide vector
2206    /// that nothing ever pruned, because a flat vector cannot say which handles
2207    /// belong to the set that just died — `index` is reused as soon as a slot
2208    /// comes back, so it cannot key them either. A set therefore gave back its
2209    /// slot and its reservation while its threads stayed unreaped until the pool
2210    /// dropped, which for a server-lifetime pool is the life of the process. The
2211    /// handles now live on the set, so the retirement that already owns the slot
2212    /// owns them too.
2213    #[test]
2214    fn a_dead_set_retires_its_thread_handles_with_its_slot() {
2215        let pool: WorkerPool<2> = WorkerPool::new("test-joins", roster2(), 4);
2216        let (lease, [reader, _writer]) = pool.acquire().expect("borrow");
2217        // Held past the set's retirement on purpose: this is the only vantage
2218        // from which "the slot came back but the threads did not" is visible.
2219        let set = lease.set.clone();
2220        assert_eq!(
2221            lock_joins(&set).len(),
2222            2,
2223            "a live set owns one handle per thread"
2224        );
2225
2226        let (go, wait) = channel::<()>();
2227        let job = reader.run(move || {
2228            let _ = wait.recv();
2229            std::panic::panic_any(PanicOnDrop);
2230        });
2231        drop(job);
2232        drop(lease);
2233        go.send(()).expect("the worker is waiting on this");
2234        drop(go);
2235
2236        let deadline = Instant::now() + Duration::from_secs(10);
2237        while pool.set_usage().1 != 0 {
2238            assert!(
2239                Instant::now() < deadline,
2240                "the dead set never gave its slot back: {:?}",
2241                pool.set_usage()
2242            );
2243            thread::yield_now();
2244        }
2245        assert!(
2246            lock_joins(&set).is_empty(),
2247            "the slot came back and the threads did not — {} handle(s) still \
2248             neither joined nor detached",
2249            lock_joins(&set).len()
2250        );
2251    }
2252
2253    /// # Invariant
2254    ///
2255    /// MUST: every thread-memory figure be a number measured on the target it is
2256    /// charged to. MUST NOT: one target's measurement be charged to another by
2257    /// default.
2258    ///
2259    /// The defect: both policy inputs took an `embedded: bool`, which says "not
2260    /// a host" where the numbers mean "this target". VxWorks' flat 1 MiB per
2261    /// thread was therefore charged on RTEMS, where a 30-client ramp spends
2262    /// 1,167,383 B per client against 3,670,016 B charged — 3.1× — so the
2263    /// 160 MiB budget refused at 30 clients on a target whose count cap is 141.
2264    ///
2265    /// The `match` on each figure is exhaustive, so a fourth target cannot
2266    /// compile until someone decides what it costs.
2267    #[test]
2268    fn each_target_is_charged_the_figure_measured_on_it() {
2269        for target in [
2270            ThreadMemoryTarget::Host,
2271            ThreadMemoryTarget::VxWorks,
2272            ThreadMemoryTarget::Rtems,
2273        ] {
2274            let (overhead, budget) = (
2275                per_thread_overhead(target),
2276                default_reservation_budget(target),
2277            );
2278            match target {
2279                ThreadMemoryTarget::Host => {
2280                    assert_eq!(overhead, 0);
2281                    assert_eq!(budget, usize::MAX, "a host meets no thread-memory wall");
2282                }
2283                ThreadMemoryTarget::VxWorks => {
2284                    assert_eq!(
2285                        overhead,
2286                        1024 * 1024,
2287                        "the flat per-thread reservation measured on VxWorks 7: \
2288                         three stack classes, walls within 2.1 %"
2289                    );
2290                    assert_eq!(budget, 160 * 1024 * 1024);
2291                }
2292                ThreadMemoryTarget::Rtems => {
2293                    assert_eq!(
2294                        overhead, 0,
2295                        "RTEMS spends less than the stacks it is asked for — \
2296                         1,167,383 B per client against 1,572,864 B declared — \
2297                         so there is no flat term to charge"
2298                    );
2299                    assert_eq!(budget, 160 * 1024 * 1024);
2300                }
2301            }
2302        }
2303
2304        let default = default_reservation_budget(ThreadMemoryTarget::VxWorks);
2305        assert_eq!(resolve_reservation_budget(None, default), default);
2306        assert_eq!(resolve_reservation_budget(Some("8"), default), 8 << 20);
2307        assert_eq!(resolve_reservation_budget(Some(" 12 "), default), 12 << 20);
2308        // A value that is not a budget leaves the default standing rather than
2309        // becoming a bound nobody chose.
2310        assert_eq!(resolve_reservation_budget(Some("0"), default), default);
2311        assert_eq!(resolve_reservation_budget(Some("lots"), default), default);
2312        assert_eq!(resolve_reservation_budget(Some(""), default), default);
2313    }
2314
2315    /// The words a stand-in target answers with, so a test can assert that the
2316    /// notice repeats the target's own account rather than a hard-coded one.
2317    const TEST_BASIS: &str = "answered from the table this test wrote";
2318
2319    /// A probe that answers from a table and records what it was asked.
2320    fn probe<'a>(
2321        answers: &'static [(usize, Option<bool>)],
2322        asked: &'a mut Vec<usize>,
2323    ) -> impl FnMut(usize) -> Option<TargetAnswer> + 'a {
2324        move |bytes| {
2325            asked.push(bytes);
2326            answers
2327                .iter()
2328                .find(|(size, _)| *size == bytes)
2329                .map(|(_, answer)| *answer)
2330                .unwrap_or(Some(false))
2331                .map(|granted| TargetAnswer {
2332                    granted,
2333                    basis: TEST_BASIS,
2334                })
2335        }
2336    }
2337
2338    /// # Invariant
2339    ///
2340    /// MUST: the adopted reservation budget be one the target answered for, or
2341    /// else be announced as unverified. MUST NOT: a configured budget reach the
2342    /// pool without either a confirmation or a notice.
2343    ///
2344    /// The defect: `EPICS_RS_POOL_RESERVATION_MB` was the operator's only escape
2345    /// hatch and was taken at face value. Raised past what the address space can
2346    /// honour it does not add memory, it deletes the refusal that was keeping the
2347    /// process below the wall — 320 MiB on the ~958 MB guest walks the CA pool to
2348    /// set 46, and the RTP takes `signal 6` with no refusal delivered.
2349    ///
2350    /// One case per boundary of the descent, not per story.
2351    #[test]
2352    fn a_configured_budget_is_confirmed_clamped_or_declared_unverifiable() {
2353        // Host: no wall, and `usize::MAX` is not a mapping anyone can ask about,
2354        // so the probe is not even consulted.
2355        let mut asked = Vec::new();
2356        assert_eq!(
2357            decide_reservation_budget(usize::MAX, false, probe(&[], &mut asked)),
2358            BudgetVerdict::Confirmed(usize::MAX)
2359        );
2360        assert!(asked.is_empty(), "no mapping is asked for on a host");
2361
2362        // The target gives what was configured: one mapping, adopted as asked,
2363        // and nothing is said.
2364        let mut asked = Vec::new();
2365        assert_eq!(
2366            decide_reservation_budget(
2367                160 << 20,
2368                false,
2369                probe(&[(160 << 20, Some(true))], &mut asked)
2370            ),
2371            BudgetVerdict::Confirmed(160 << 20)
2372        );
2373        assert_eq!(asked, vec![160 << 20], "an honest value costs one mapping");
2374
2375        // The measured case: 320 MiB configured on the ~958 MB guest, whose
2376        // single-mapping bound is between 192 and 256 MiB (§10.2/§10.3). The
2377        // descent rejects 320 and adopts 160.
2378        let mut asked = Vec::new();
2379        assert_eq!(
2380            decide_reservation_budget(
2381                320 << 20,
2382                true,
2383                probe(
2384                    &[(320 << 20, Some(false)), (160 << 20, Some(true))],
2385                    &mut asked
2386                )
2387            ),
2388            BudgetVerdict::Clamped {
2389                asked: 320 << 20,
2390                adopted: 160 << 20,
2391                basis: TEST_BASIS
2392            }
2393        );
2394        assert_eq!(asked, vec![320 << 20, 160 << 20]);
2395
2396        // No basis: the value stands, and stands *declared*. `from_env` is the
2397        // whole difference between a notice and silence.
2398        let mut asked = Vec::new();
2399        assert_eq!(
2400            decide_reservation_budget(320 << 20, true, probe(&[(320 << 20, None)], &mut asked)),
2401            BudgetVerdict::Unverifiable {
2402                adopted: 320 << 20,
2403                from_env: true
2404            }
2405        );
2406        assert_eq!(asked, vec![320 << 20], "one question, then no more");
2407        let mut asked = Vec::new();
2408        assert_eq!(
2409            decide_reservation_budget(160 << 20, false, probe(&[(160 << 20, None)], &mut asked)),
2410            BudgetVerdict::Unverifiable {
2411                adopted: 160 << 20,
2412                from_env: false
2413            }
2414        );
2415    }
2416
2417    /// The descent's own boundaries: it ends *on* the floor, never below it, and
2418    /// a target that confirms nothing leaves the pool bounded rather than dead.
2419    #[test]
2420    fn the_budget_descent_terminates_on_the_floor() {
2421        // Nothing is confirmed. The descent halves to the floor and stops there.
2422        let mut asked = Vec::new();
2423        assert_eq!(
2424            decide_reservation_budget(64 << 20, true, probe(&[], &mut asked)),
2425            BudgetVerdict::FloorHeld {
2426                asked: 64 << 20,
2427                basis: TEST_BASIS
2428            }
2429        );
2430        assert_eq!(
2431            asked,
2432            vec![64 << 20, 32 << 20, 16 << 20, 8 << 20],
2433            "halving, and the last question is the floor itself"
2434        );
2435
2436        // A halving that would undershoot lands on the floor instead of below
2437        // it: 10 MiB / 2 is 5 MiB, which is not a size worth asking about.
2438        let mut asked = Vec::new();
2439        assert_eq!(
2440            decide_reservation_budget(
2441                10 << 20,
2442                true,
2443                probe(&[(RESERVATION_PROBE_FLOOR, Some(true))], &mut asked)
2444            ),
2445            BudgetVerdict::Clamped {
2446                asked: 10 << 20,
2447                adopted: RESERVATION_PROBE_FLOOR,
2448                basis: TEST_BASIS
2449            }
2450        );
2451        assert_eq!(asked, vec![10 << 20, RESERVATION_PROBE_FLOOR]);
2452
2453        // Configured below the floor and refused: one question, and the floor is
2454        // held rather than the descent running past it.
2455        let mut asked = Vec::new();
2456        assert_eq!(
2457            decide_reservation_budget(4 << 20, true, probe(&[], &mut asked)),
2458            BudgetVerdict::FloorHeld {
2459                asked: 4 << 20,
2460                basis: TEST_BASIS
2461            }
2462        );
2463        assert_eq!(asked, vec![4 << 20]);
2464    }
2465
2466    /// # Invariant
2467    ///
2468    /// MUST NOT: any outcome but "the target confirmed what was configured"
2469    /// reach the pool without an `errlog` line. The defect being closed is a
2470    /// budget that kills the process without a word, so silence has to be a
2471    /// decision this code makes rather than an arm nobody wrote — the `match` is
2472    /// exhaustive so a new verdict cannot compile until it is classified.
2473    #[test]
2474    fn every_verdict_but_confirmation_is_announced() {
2475        for verdict in [
2476            BudgetVerdict::Confirmed(160 << 20),
2477            BudgetVerdict::Clamped {
2478                asked: 320 << 20,
2479                adopted: 160 << 20,
2480                basis: TEST_BASIS,
2481            },
2482            BudgetVerdict::Unverifiable {
2483                adopted: 320 << 20,
2484                from_env: true,
2485            },
2486            BudgetVerdict::Unverifiable {
2487                adopted: 160 << 20,
2488                from_env: false,
2489            },
2490            BudgetVerdict::FloorHeld {
2491                asked: 320 << 20,
2492                basis: TEST_BASIS,
2493            },
2494        ] {
2495            let notice = verdict.notice();
2496            match verdict {
2497                BudgetVerdict::Confirmed(bytes) => {
2498                    assert_eq!(notice, None, "an honoured budget is not news");
2499                    assert_eq!(verdict.budget(), bytes);
2500                }
2501                BudgetVerdict::Unverifiable {
2502                    adopted,
2503                    from_env: false,
2504                } => {
2505                    assert_eq!(
2506                        notice, None,
2507                        "the built-in default carries its own measurement"
2508                    );
2509                    assert_eq!(verdict.budget(), adopted);
2510                }
2511                BudgetVerdict::Unverifiable { adopted, .. } => {
2512                    let (severity, message) = notice.expect("an unverified value must say so");
2513                    assert_eq!(severity, ErrlogSevEnum::Minor);
2514                    assert!(
2515                        message.contains("cannot be verified")
2516                            && message.contains(POOL_RESERVATION_ENV),
2517                        "the notice must name the switch and its own uncertainty: {message}"
2518                    );
2519                    assert_eq!(verdict.budget(), adopted);
2520                }
2521                BudgetVerdict::Clamped {
2522                    asked,
2523                    adopted,
2524                    basis,
2525                } => {
2526                    let (severity, message) = notice.expect("a clamp must say so");
2527                    assert_eq!(
2528                        severity,
2529                        ErrlogSevEnum::Major,
2530                        "the IOC is not doing what the switch said"
2531                    );
2532                    assert!(
2533                        message.contains(&format!("{} MiB", asked >> 20))
2534                            && message.contains(&format!("{} MiB", adopted >> 20)),
2535                        "both numbers, or the operator cannot tell what happened: {message}"
2536                    );
2537                    assert!(
2538                        message.contains(basis),
2539                        "the notice must give the target's own account of the refusal, not a \
2540                         mechanism it did not use: {message}"
2541                    );
2542                    assert_eq!(verdict.budget(), adopted);
2543                }
2544                BudgetVerdict::FloorHeld { asked, basis } => {
2545                    let (severity, message) = notice.expect("a held floor must say so");
2546                    assert_eq!(severity, ErrlogSevEnum::Major);
2547                    assert!(
2548                        message.contains(&format!("{} MiB", asked >> 20)),
2549                        "the notice must name what was asked for: {message}"
2550                    );
2551                    assert!(
2552                        message.contains(basis),
2553                        "the notice must give the target's own account of the refusal: {message}"
2554                    );
2555                    assert_eq!(verdict.budget(), RESERVATION_PROBE_FLOOR);
2556                }
2557            }
2558        }
2559    }
2560
2561    /// # Invariant
2562    ///
2563    /// MUST: a thread the IOC cannot decline to create still charge the one
2564    /// account the pool spends from, for exactly as long as it runs. MUST NOT:
2565    /// any thread's stack be invisible to the number admission divides.
2566    ///
2567    /// The defect: the budget counted pool workers only, while the same target
2568    /// runs the scan bands, the callback timer, the CA acceptor, the audit
2569    /// writer and the dial pool's workers — about 15 MiB on the VxWorks guest.
2570    /// Being inside the headroom is not the same as being counted: the pool
2571    /// believed it could take the whole budget, and nothing would have reported
2572    /// the error growing on a target with more fixed threads.
2573    #[test]
2574    fn a_fixed_thread_charges_the_process_account_and_gives_it_back() {
2575        let before = PROCESS_RESERVATION.held();
2576        let expect = thread_reservation_bytes(StackSizeClass::Small);
2577        {
2578            let _charge = ThreadCharge::fixed(StackSizeClass::Small);
2579            assert_eq!(
2580                PROCESS_RESERVATION.held(),
2581                before + expect,
2582                "a fixed thread must appear in the account the pool divides"
2583            );
2584        }
2585        assert_eq!(
2586            PROCESS_RESERVATION.held(),
2587            before,
2588            "the charge is released by the guard's `Drop`, not by a caller"
2589        );
2590    }
2591
2592    /// The charge is tied to the *thread*, not to the call that started it.
2593    ///
2594    /// The boundary that matters: the account must still hold the stack while
2595    /// the thread runs, and must be back to where it started once the thread
2596    /// has ended — which is what makes a long-lived fixed thread reduce what
2597    /// the pool may take, and a finished one give it back.
2598    #[test]
2599    fn the_spawn_helper_holds_its_charge_for_the_thread_and_not_the_call() {
2600        use crate::runtime::task::spawn_dedicated_thread;
2601
2602        let before = PROCESS_RESERVATION.held();
2603        let expect = thread_reservation_bytes(StackSizeClass::Small);
2604        let (release, wait) = channel::<()>();
2605        let (started, running) = channel::<()>();
2606
2607        let handle = spawn_dedicated_thread(
2608            "charged-fixed-thread".to_string(),
2609            ThreadPriority::Low,
2610            StackSizeClass::Small,
2611            move || {
2612                let _ = started.send(());
2613                let _ = wait.recv();
2614            },
2615        )
2616        .expect("the host can create one thread");
2617
2618        running.recv().expect("the thread starts");
2619        assert_eq!(
2620            PROCESS_RESERVATION.held(),
2621            before + expect,
2622            "the account must hold the stack while the thread runs"
2623        );
2624
2625        drop(release);
2626        handle.join().expect("the thread ends cleanly");
2627        assert_eq!(
2628            PROCESS_RESERVATION.held(),
2629            before,
2630            "and must be back where it started once the thread is gone"
2631        );
2632    }
2633
2634    /// # Invariant
2635    ///
2636    /// MUST: a target that cannot materialise a set's mutex object refuse the
2637    /// connection. MUST NOT: the pool create a thread that will meet that
2638    /// refusal as a `std` panic, or keep the memory of a set it did not build.
2639    ///
2640    /// The defect this pins: on VxWorks every pthread mutex materialises its
2641    /// `SEMAPHORE` on first lock, so a freshly leased worker's first
2642    /// `std::sync::Mutex::lock` panicked with `EINVAL` at 588 live objects and
2643    /// took its set with it. A host cannot be made to exhaust that arena, so
2644    /// the gate is injected — what is under test is the *refusal path*: no
2645    /// thread, no leaked byte, no leaked slot, and a cause a consumer can
2646    /// recognise by type.
2647    #[test]
2648    fn a_target_that_refuses_a_mutex_object_refuses_the_connection() {
2649        static ARENA: Reservation = Reservation::new(8 * HOST_SET);
2650        fn arena_empty(_: &Mutex<SetState>) -> bool {
2651            false
2652        }
2653        let pool: WorkerPool<2> =
2654            WorkerPool::with_reservation_and_gate("test-arena", roster2(), 8, &ARENA, arena_empty);
2655        let before = ARENA.held();
2656
2657        let refused = pool.acquire().err().expect("the arena refuses the set");
2658        let AcquireError::SpawnFailed(ref e) = refused else {
2659            panic!("an arena refusal is the target saying no: {refused:?}");
2660        };
2661        let arena = e
2662            .get_ref()
2663            .and_then(|src| src.downcast_ref::<ObjectArenaExhausted>())
2664            .expect("the cause must be recognisable by type, not by prose");
2665        assert_eq!(arena.objects, 2, "one object per worker in the set");
2666        assert_eq!(
2667            e.kind(),
2668            io::ErrorKind::WouldBlock,
2669            "a transient refusal is retryable, and a client's retry is the pacing"
2670        );
2671
2672        assert_eq!(pool.worker_count(), 0, "a refusal must create no thread");
2673        assert_eq!(
2674            ARENA.held(),
2675            before,
2676            "the set's memory must go back: it was reserved for threads that do \
2677             not exist"
2678        );
2679
2680        // And the slot: a refused grow must not consume capacity, or eight
2681        // transient refusals would close a pool of eight for good.
2682        let ok: WorkerPool<2> = WorkerPool::with_reservation_and_gate(
2683            "test-arena-recovers",
2684            roster2(),
2685            1,
2686            &ARENA,
2687            materialise_set_mutex,
2688        );
2689        let _lease = ok.acquire().expect("a target with objects admits");
2690        assert!(pool.acquire().is_err(), "still refusing");
2691    }
2692}