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