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