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 ®.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}