Skip to main content

subetha_sidecar/
lib.rs

1//! Sidecar control plane for adaptive primitives.
2//!
3//! One background thread per detected NUMA node; each thread polls
4//! the registered primitive instances bound to its node:
5//!
6//! 1. Drains each instance's [`ObservationRing`] into a per-instance
7//!    [`InstanceStats`] accumulator.
8//! 2. Asks the instance's [`Policy`] whether a strategy migration is
9//!    warranted.
10//! 3. If yes, calls [`HandshakeHeader::set_tag`] to install the new
11//!    strategy.
12//!
13//! Heavy migrations (data swap) are NOT handled here; primitives
14//! that need them invoke their own migration logic from within the
15//! policy callback (e.g. `subetha-cxc::AdaptiveIpc::migrate_to`).
16//!
17//! # Safety model
18//!
19//! Registration takes raw pointers to the user's `HandshakeHeader` and
20//! `ObservationRing`. The contract is:
21//!
22//! - The user must keep these alive until `unregister` returns.
23//! - `unregister` blocks until any in-flight scan finishes, so the user
24//!   can drop the underlying memory immediately after.
25//!
26//! The [`SidecarBox<T>`] wrapper enforces this contract by holding a
27//! `Box<T>` (stable address) alongside an auto-unregistering
28//! [`SidecarHandle`].
29
30use std::ptr::NonNull;
31use std::sync::Arc;
32use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
33use std::thread::{self, JoinHandle};
34use std::time::{Duration, Instant};
35
36use subetha_core::{HandshakeHeader, ObservationRing};
37use once_cell::sync::Lazy;
38use parking_lot::{Mutex, RwLock};
39
40pub mod bench_safe;
41
42/// Stable identifier for a registered primitive instance.
43pub type InstanceId = u32;
44
45/// Number of op_kind slots the InstanceStats tracks. Primitives use
46/// `op_kind` values 1..=N to identify per-op-kind buckets (e.g., load
47/// vs store for `AdaptiveCell`; insert/get/remove/snapshot for the
48/// snapshot map). Op kind 0 is reserved for "unspecified".
49pub const N_OP_KINDS: usize = 8;
50
51/// Maximum distinct producer thread ids tracked per op kind. Picked
52/// to be cheap (4*8*4 = 128 bytes per instance) while sufficient for
53/// the policy decisions that read this - once cardinality crosses 1,
54/// the policy migrates regardless of the exact count.
55pub const MAX_TRACKED_THREADS_PER_KIND: usize = 4;
56
57/// Aggregated statistics for one registered instance.
58///
59/// Updated by the sidecar each poll cycle from drained observations.
60#[derive(Debug, Clone, Copy)]
61pub struct InstanceStats {
62    pub ops_observed: u64,
63    pub total_latency_ticks: u64,
64    pub contention_ops: u64,
65    /// Per-op-kind counts. Index by `Observation.op_kind` (clamped to
66    /// the valid range). Primitives that adapt based on a ratio of
67    /// op kinds (e.g., reads vs writes) consume these.
68    pub op_kind_counts: [u64; N_OP_KINDS],
69    pub last_seen_us_ago: u64,
70    /// Number of migrations the sidecar has triggered on this instance
71    /// (apply_migration calls that resulted in a tag change). Used by
72    /// bench harnesses to measure adaptation-latency convergence.
73    pub migrations_triggered: u64,
74    /// Per-op-kind distinct-thread-id cache. Filled lazily by the
75    /// drain as observations arrive. Once a slot fills with a tid
76    /// that doesn't match any earlier slot, the corresponding count
77    /// in `per_op_kind_distinct_count` increments.
78    pub per_op_kind_distinct_threads: [[u32; MAX_TRACKED_THREADS_PER_KIND]; N_OP_KINDS],
79    /// Distinct thread count observed per op_kind (saturates at
80    /// `MAX_TRACKED_THREADS_PER_KIND + 1` - meaning "more than the
81    /// slot table can hold"). `>= 2` is the typical multi-producer
82    /// or multi-consumer detection threshold for primitive policies.
83    pub per_op_kind_distinct_count: [u8; N_OP_KINDS],
84}
85
86impl Default for InstanceStats {
87    fn default() -> Self {
88        Self {
89            ops_observed: 0,
90            total_latency_ticks: 0,
91            contention_ops: 0,
92            op_kind_counts: [0; N_OP_KINDS],
93            last_seen_us_ago: 0,
94            migrations_triggered: 0,
95            per_op_kind_distinct_threads: [[0; MAX_TRACKED_THREADS_PER_KIND]; N_OP_KINDS],
96            per_op_kind_distinct_count: [0; N_OP_KINDS],
97        }
98    }
99}
100
101impl InstanceStats {
102    pub fn average_latency_ticks(&self) -> u64 {
103        self.total_latency_ticks.checked_div(self.ops_observed).unwrap_or(0)
104    }
105
106    pub fn contention_rate(&self) -> f64 {
107        if self.ops_observed == 0 {
108            0.0
109        } else {
110            self.contention_ops as f64 / self.ops_observed as f64
111        }
112    }
113
114    /// Total ops observed across all op kinds. Equals `ops_observed`
115    /// for primitives that always set a non-zero op_kind on their
116    /// observations.
117    pub fn op_kind_total(&self) -> u64 {
118        self.op_kind_counts.iter().sum()
119    }
120
121    /// Ratio of one op kind to the total of two op kinds. Returns 0.0
122    /// when both counts are zero (avoids divide-by-zero in policies).
123    pub fn ratio_of(&self, kind: u16, total_kinds: &[u16]) -> f64 {
124        let k = (kind as usize).min(N_OP_KINDS - 1);
125        let kind_count = self.op_kind_counts[k];
126        let total: u64 = total_kinds.iter()
127            .map(|&i| self.op_kind_counts[(i as usize).min(N_OP_KINDS - 1)])
128            .sum();
129        if total == 0 {
130            0.0
131        } else {
132            kind_count as f64 / total as f64
133        }
134    }
135
136    /// Distinct producer-thread count observed for one op kind.
137    ///
138    /// `>= 2` indicates true multi-producer (or multi-consumer for the
139    /// recv-side op kind) usage on this primitive - the right signal
140    /// for a ChannelPolicy promoting SPSC → MPMC, an AdaptiveCell
141    /// noticing multi-writer churn, etc. Saturates at
142    /// `MAX_TRACKED_THREADS_PER_KIND + 1`.
143    pub fn distinct_threads_for(&self, kind: u16) -> u8 {
144        let k = (kind as usize).min(N_OP_KINDS - 1);
145        self.per_op_kind_distinct_count[k]
146    }
147
148    /// True when the given op kind has been observed from more than
149    /// one distinct producer thread.
150    pub fn is_multi_thread_for(&self, kind: u16) -> bool {
151        self.distinct_threads_for(kind) >= 2
152    }
153}
154
155/// Internal helper: record a thread_id against `(op_kind, stats)`,
156/// updating `per_op_kind_distinct_threads` + `per_op_kind_distinct_count`
157/// when the tid hasn't been seen for that op kind. No-op when tid is 0
158/// (the unspecified sentinel) or the saturation cap is already hit.
159#[inline]
160fn record_thread_for_op(
161    stats: &mut InstanceStats,
162    op_kind: u16,
163    tid: u32,
164) {
165    if tid == 0 {
166        return;
167    }
168    let k = (op_kind as usize).min(N_OP_KINDS - 1);
169    let count = stats.per_op_kind_distinct_count[k];
170    if (count as usize) > MAX_TRACKED_THREADS_PER_KIND {
171        // Already saturated: we know cardinality > MAX_TRACKED; we
172        // don't add slots beyond the cache size, and the count remains
173        // pinned at the saturation value.
174        return;
175    }
176    let slots = &mut stats.per_op_kind_distinct_threads[k];
177    // Linear scan over the populated slots; tids are inserted in
178    // arrival order so the count is exactly the number of populated
179    // slots when below saturation.
180    let n = (count as usize).min(MAX_TRACKED_THREADS_PER_KIND);
181    if slots[..n].contains(&tid) {
182        return;
183    }
184    // New tid: either append to the cache (when there's room) or just
185    // bump the saturated count to MAX+1 (when full).
186    if n < MAX_TRACKED_THREADS_PER_KIND {
187        slots[n] = tid;
188        stats.per_op_kind_distinct_count[k] = (n as u8) + 1;
189    } else {
190        // Saturation transition: count moves from MAX to MAX+1; we
191        // know "more threads than the cache can hold" without
192        // remembering which ones.
193        stats.per_op_kind_distinct_count[k] = (MAX_TRACKED_THREADS_PER_KIND as u8) + 1;
194    }
195}
196
197/// Decides when and how to migrate a primitive instance's strategy.
198///
199/// Called by the sidecar after each scan iteration that observed
200/// at least one new op. Return `Some(new_tag)` to install a new
201/// strategy via [`HandshakeHeader::set_tag`]; `None` to leave it alone.
202pub trait Policy: Send + Sync + 'static {
203    fn decide(&self, stats: &InstanceStats, current_tag: u32) -> Option<u32>;
204}
205
206/// Convenient policy that always returns the same tag (testing).
207pub struct FixedPolicy(pub u32);
208impl Policy for FixedPolicy {
209    fn decide(&self, _stats: &InstanceStats, _current_tag: u32) -> Option<u32> {
210        Some(self.0)
211    }
212}
213
214/// Convenient policy that never migrates (default for primitives that
215/// haven't shipped their adaptation logic yet).
216pub struct NoMigrationPolicy;
217impl Policy for NoMigrationPolicy {
218    fn decide(&self, _stats: &InstanceStats, _current_tag: u32) -> Option<u32> {
219        None
220    }
221}
222
223struct Registration {
224    header: NonNull<HandshakeHeader>,
225    ring: NonNull<ObservationRing>,
226    /// Optional pointer to the registered instance via its trait object.
227    /// When present, the sidecar calls `apply_migration` on it after the
228    /// policy returns a new tag; when absent (raw registration without
229    /// a known instance), the sidecar falls back to `header.set_tag`.
230    instance: Option<NonNull<dyn AdaptiveInstance>>,
231    policy: Box<dyn Policy>,
232    stats: Mutex<InstanceStats>,
233    registered_at: Instant,
234    last_observation_at: Mutex<Option<Instant>>,
235}
236
237// SAFETY: The contract requires `header`, `ring`, and the optional
238// `instance` pointer to be valid for the lifetime of this Registration
239// (i.e., until unregister returns). The sidecar accesses them only
240// while holding a read lock on the instances vec, which blocks
241// unregister.
242unsafe impl Send for Registration {}
243unsafe impl Sync for Registration {}
244
245/// Safety cap on drained observations per scan iteration per instance.
246///
247/// In normal operation the sidecar drains the entire ring on each scan
248/// (no sampling bias from FIFO order) - the ring's natural capacity
249/// (4096 slots) bounds the work. This cap is the catastrophe-mode
250/// limit: if a misconfigured ring ever exceeds this number of slots,
251/// the cap kicks in to keep one busy instance from starving the
252/// others.
253///
254/// Worst-case per-scan cost: 4096 observations * ~10 ns drain cost =
255/// ~40 us per instance, holding the read lock that long. With 100
256/// instances this caps a single scan loop at ~4 ms.
257const DRAIN_SAFETY_CAP: usize = 8192;
258
259/// Sidecar poll interval. Trade-off: shorter = faster reaction to
260/// transitions; longer = less CPU spent on cold/idle instances.
261const POLL_INTERVAL: Duration = Duration::from_micros(200);
262
263/// A single node's instance vec + scanning thread. One per NUMA node.
264struct NodeSidecar {
265    instances: RwLock<Vec<Option<Registration>>>,
266}
267
268impl NodeSidecar {
269    fn new() -> Self {
270        Self { instances: RwLock::new(Vec::new()) }
271    }
272}
273
274/// The sidecar singleton. Internally a pool of one `NodeSidecar` per
275/// detected NUMA node; each node has its own scanning thread + Vec of
276/// registered primitive instances. Registration routes by
277/// `current_numa_node()` so cross-NUMA cache traffic on the scan path
278/// stays minimal. InstanceId encodes (node_index, slot) so unregister
279/// + stats can find the right node's vec.
280pub struct Sidecar {
281    nodes: Vec<NodeSidecar>,
282    shutdown: Arc<AtomicBool>,
283    join_handles: Mutex<Vec<JoinHandle<()>>>,
284    /// Currently-registered instance count (monotonic over the lifetime
285    /// of this Sidecar). Incremented in `register_raw`, decremented in
286    /// `unregister`. Read via [`Sidecar::instance_count`].
287    instance_count: AtomicUsize,
288    /// Hard cap on simultaneously-registered instances. When
289    /// `register_raw` would cross this, it panics with a diagnostic.
290    /// The default ([`DEFAULT_MAX_INSTANCES`]) covers all realistic
291    /// production workloads; raise via [`Sidecar::set_max_instances`]
292    /// when intentional heavy registration is needed.
293    max_instances: AtomicUsize,
294}
295
296/// Default hard cap on registered instances. Sized to fail fast on
297/// the "bench creates a SidecarBox per `b.iter()`" mistake, which
298/// exhausts the host near 94k registrations: this cap refuses an
299/// order of magnitude before that, while leaving room above the
300/// 10..1000 range production workloads sit in.
301pub const DEFAULT_MAX_INSTANCES: usize = 10_000;
302
303/// Number of bits in InstanceId reserved for the node index (upper).
304const NODE_ID_BITS: u32 = 8;
305/// Mask for the slot portion of InstanceId (lower).
306const SLOT_MASK: u32 = (1 << (32 - NODE_ID_BITS)) - 1;
307
308fn pack_id(node: u32, slot: u32) -> InstanceId {
309    (node << (32 - NODE_ID_BITS)) | (slot & SLOT_MASK)
310}
311
312fn unpack_id(id: InstanceId) -> (u32, u32) {
313    (id >> (32 - NODE_ID_BITS), id & SLOT_MASK)
314}
315
316impl Sidecar {
317    fn new() -> Arc<Self> {
318        let shutdown = Arc::new(AtomicBool::new(false));
319        let num_nodes = numa_node_count().max(1) as usize;
320        let mut nodes = Vec::with_capacity(num_nodes);
321        for _ in 0..num_nodes {
322            nodes.push(NodeSidecar::new());
323        }
324        let sidecar = Arc::new(Self {
325            nodes,
326            shutdown: shutdown.clone(),
327            join_handles: Mutex::new(Vec::with_capacity(num_nodes)),
328            instance_count: AtomicUsize::new(0),
329            max_instances: AtomicUsize::new(DEFAULT_MAX_INSTANCES),
330        });
331
332        let mut handles = Vec::with_capacity(num_nodes);
333        for node_idx in 0..num_nodes {
334            let runner = sidecar.clone();
335            let handle = thread::Builder::new()
336                .name(format!("subetha-sidecar-node{node_idx}"))
337                .spawn(move || runner.run_loop_for_node(node_idx))
338                .expect("failed to spawn subetha-sidecar node thread");
339            handles.push(handle);
340        }
341        *sidecar.join_handles.lock() = handles;
342
343        sidecar
344    }
345
346    fn run_loop_for_node(self: Arc<Self>, node_idx: usize) {
347        while !self.shutdown.load(Ordering::Acquire) {
348            self.scan_node(node_idx);
349            thread::sleep(POLL_INTERVAL);
350        }
351    }
352
353    fn scan_node(&self, node_idx: usize) {
354        let Some(node) = self.nodes.get(node_idx) else { return };
355        let guard = node.instances.read();
356        Self::scan_instances(&guard);
357    }
358
359    fn scan_instances(instances: &[Option<Registration>]) {
360        for reg_opt in instances.iter() {
361            let Some(reg) = reg_opt else { continue };
362            let ring = unsafe { reg.ring.as_ref() };
363            let header = unsafe { reg.header.as_ref() };
364            let mut drained_ops: u64 = 0;
365            let mut drained_lat: u64 = 0;
366            let mut drained_cont: u64 = 0;
367            let mut drained_kinds: [u64; N_OP_KINDS] = [0; N_OP_KINDS];
368            // Per-scan dedupe of (op_kind, tid) pairs. Bounded at
369            // N_OP_KINDS * MAX_TRACKED_THREADS_PER_KIND so even a
370            // burst of distinct threads costs O(constant) per scan
371            // instead of saturating the stats-lock window.
372            const DEDUPE_CAP: usize = N_OP_KINDS * MAX_TRACKED_THREADS_PER_KIND;
373            let mut tid_dedupe: [(u16, u32); DEDUPE_CAP] = [(0, 0); DEDUPE_CAP];
374            let mut tid_dedupe_len: usize = 0;
375            for _ in 0..DRAIN_SAFETY_CAP {
376                let Some(obs) = ring.pop() else { break };
377                drained_ops += 1;
378                drained_lat = drained_lat.saturating_add(obs.latency_ticks);
379                if obs.flags & 1 != 0 {
380                    drained_cont += 1;
381                }
382                let k = (obs.op_kind as usize).min(N_OP_KINDS - 1);
383                drained_kinds[k] = drained_kinds[k].saturating_add(1);
384                // Inline dedupe of (op_kind, tid) pairs.
385                if obs.producer_thread_id != 0 && tid_dedupe_len < DEDUPE_CAP {
386                    let pair = (obs.op_kind, obs.producer_thread_id);
387                    let seen = tid_dedupe[..tid_dedupe_len].contains(&pair);
388                    if !seen {
389                        tid_dedupe[tid_dedupe_len] = pair;
390                        tid_dedupe_len += 1;
391                    }
392                }
393            }
394            if drained_ops == 0 {
395                continue;
396            }
397            let stats_snapshot = {
398                let mut s = reg.stats.lock();
399                s.ops_observed = s.ops_observed.saturating_add(drained_ops);
400                s.total_latency_ticks = s.total_latency_ticks.saturating_add(drained_lat);
401                s.contention_ops = s.contention_ops.saturating_add(drained_cont);
402                for (slot, drained) in s.op_kind_counts.iter_mut().zip(drained_kinds.iter()) {
403                    *slot = slot.saturating_add(*drained);
404                }
405                // Fold deduped (op_kind, tid) pairs into per-op-kind
406                // distinct-thread tracking on the stats struct.
407                for &(op_kind, tid) in tid_dedupe[..tid_dedupe_len].iter() {
408                    record_thread_for_op(&mut s, op_kind, tid);
409                }
410                let now = Instant::now();
411                *reg.last_observation_at.lock() = Some(now);
412                s.last_seen_us_ago = now
413                    .duration_since(reg.registered_at)
414                    .as_micros() as u64;
415                *s
416            };
417            let current_tag = header.tag();
418            if let Some(new_tag) = reg.policy.decide(&stats_snapshot, current_tag)
419                && new_tag != current_tag {
420                    if let Some(inst_ptr) = reg.instance {
421                        let inst = unsafe { &*inst_ptr.as_ptr() };
422                        inst.apply_migration(new_tag);
423                    } else {
424                        header.set_tag(new_tag);
425                    }
426                    let mut s = reg.stats.lock();
427                    s.migrations_triggered = s.migrations_triggered.saturating_add(1);
428                }
429        }
430    }
431
432    /// Register a primitive instance.
433    ///
434    /// # Safety
435    ///
436    /// `header`, `ring`, and (when provided) `instance` must remain
437    /// valid until `unregister(id)` returns for the returned `id`.
438    /// Prefer [`SidecarBox`] which enforces this invariant automatically.
439    pub unsafe fn register_raw(
440        &self,
441        header: NonNull<HandshakeHeader>,
442        ring: NonNull<ObservationRing>,
443        instance: Option<NonNull<dyn AdaptiveInstance>>,
444        policy: Box<dyn Policy>,
445    ) -> InstanceId {
446        // Hard cap enforced before any allocation. Panics with a
447        // diagnostic identifying the likely cause; the diagnostic
448        // text is part of the API surface and tested below.
449        let cap = self.max_instances.load(Ordering::Acquire);
450        let prev = self.instance_count.fetch_add(1, Ordering::AcqRel);
451        if prev >= cap {
452            self.instance_count.fetch_sub(1, Ordering::AcqRel);
453            panic!(
454                "subetha-sidecar: instance cap ({cap}) exceeded.\n\
455                 Likely cause: SidecarBox<Adaptive*> is being created \
456                 inside a tight loop (criterion b.iter(), test fixture, \
457                 or runaway production code). Move construction outside \
458                 the loop and reuse the instance, or call \
459                 Sidecar::set_max_instances() if the load is intentional."
460            );
461        }
462        // Arm the ring now that a consumer (this sidecar) is taking
463        // ownership of draining it. Until this point producers skip every
464        // push, so raw `create()` handles pay nothing for observation.
465        // SAFETY: `ring` is valid per this function's safety contract.
466        unsafe { ring.as_ref().arm(); }
467
468        let reg = Registration {
469            header,
470            ring,
471            instance,
472            policy,
473            stats: Mutex::new(InstanceStats::default()),
474            registered_at: Instant::now(),
475            last_observation_at: Mutex::new(None),
476        };
477
478        // Route by current NUMA node; clamp to available nodes.
479        let node_idx = (current_numa_node() as usize) % self.nodes.len();
480        let node = &self.nodes[node_idx];
481        let mut guard = node.instances.write();
482
483        // Find a vacant slot or push at the end.
484        for (slot_idx, slot) in guard.iter_mut().enumerate() {
485            if slot.is_none() {
486                *slot = Some(reg);
487                return pack_id(node_idx as u32, slot_idx as u32);
488            }
489        }
490        let slot_idx = guard.len();
491        guard.push(Some(reg));
492        pack_id(node_idx as u32, slot_idx as u32)
493    }
494
495    /// Remove a registered instance.
496    ///
497    /// Blocks until any in-flight scan iteration finishes, so the caller
498    /// can safely drop the underlying header/ring memory immediately
499    /// after this returns.
500    pub fn unregister(&self, id: InstanceId) {
501        let (node_idx, slot_idx) = unpack_id(id);
502        let Some(node) = self.nodes.get(node_idx as usize) else { return };
503        let mut guard = node.instances.write();
504        if let Some(slot) = guard.get_mut(slot_idx as usize)
505            && slot.is_some() {
506                *slot = None;
507                self.instance_count.fetch_sub(1, Ordering::AcqRel);
508            }
509    }
510
511    /// Currently-registered instance count.
512    pub fn instance_count(&self) -> usize {
513        self.instance_count.load(Ordering::Acquire)
514    }
515
516    /// Configured maximum simultaneously-registered instances. See
517    /// [`DEFAULT_MAX_INSTANCES`] for the default and
518    /// [`Self::set_max_instances`] to change it.
519    pub fn max_instances(&self) -> usize {
520        self.max_instances.load(Ordering::Acquire)
521    }
522
523    /// Raise or lower the instance cap. Intentional heavy-registration
524    /// workloads (e.g., a server that legitimately wants > 10,000
525    /// adaptive primitives live at once) should call this once at
526    /// startup. The cap is per-process; the global Sidecar inherits
527    /// it via [`global()`].
528    pub fn set_max_instances(&self, cap: usize) {
529        self.max_instances.store(cap, Ordering::Release);
530    }
531
532    /// Snapshot the stats for a registered instance.
533    pub fn stats(&self, id: InstanceId) -> Option<InstanceStats> {
534        let (node_idx, slot_idx) = unpack_id(id);
535        let node = self.nodes.get(node_idx as usize)?;
536        let guard = node.instances.read();
537        guard.get(slot_idx as usize)?.as_ref().map(|r| *r.stats.lock())
538    }
539
540    /// Force one scan iteration synchronously across all NUMA nodes.
541    /// Useful for tests where we don't want to wait for the poll interval.
542    pub fn scan_now(&self) {
543        for node_idx in 0..self.nodes.len() {
544            self.scan_node(node_idx);
545        }
546    }
547
548    /// Number of NUMA-pinned sidecar threads in this pool.
549    pub fn node_count(&self) -> usize {
550        self.nodes.len()
551    }
552
553}
554
555impl Drop for Sidecar {
556    fn drop(&mut self) {
557        self.shutdown.store(true, Ordering::Release);
558        let handles: Vec<JoinHandle<()>> = std::mem::take(&mut *self.join_handles.lock());
559        for h in handles {
560            // Worker panic on shutdown is non-fatal.
561            h.join().ok();
562        }
563    }
564}
565
566static GLOBAL: Lazy<Arc<Sidecar>> = Lazy::new(|| {
567    let s = Sidecar::new();
568    // Register an `atexit` callback that signals shutdown + joins
569    // the sidecar threads before process teardown. Without this, the
570    // `static Lazy<Arc<Sidecar>>` never drops at exit (Rust statics
571    // with non-trivial Drop aren't run for late-initialised Lazy);
572    // the OS terminates sidecar threads mid-action, occasionally
573    // producing STATUS_ACCESS_VIOLATION at process exit when their
574    // parking_lot/crossbeam TLS state races with the main thread's
575    // CRT shutdown.
576    register_sidecar_atexit();
577    s
578});
579
580/// One-shot registration of the atexit callback. Idempotent across
581/// processes that re-initialise the Lazy (e.g., on fork + re-exec).
582fn register_sidecar_atexit() {
583    static REGISTERED: std::sync::Once = std::sync::Once::new();
584    REGISTERED.call_once(|| {
585        // SAFETY: `atexit` accepts an `extern "C" fn()` callback that
586        // the CRT invokes from the main thread during normal process
587        // teardown (after `main` returns, before final OS exit). The
588        // callback we register only touches `GLOBAL` (a static),
589        // which outlives the call by construction.
590        unsafe {
591            unsafe extern "C" {
592                fn atexit(cb: extern "C" fn()) -> i32;
593            }
594            atexit(sidecar_atexit_shutdown);
595        }
596    });
597}
598
599/// atexit-registered callback: signal sidecar shutdown + join the
600/// per-NUMA scanning threads. Runs on the main thread during normal
601/// process teardown so the OS doesn't have to TerminateThread the
602/// sidecar workers mid-action.
603extern "C" fn sidecar_atexit_shutdown() {
604    if let Some(sidecar) = Lazy::get(&GLOBAL) {
605        sidecar.shutdown.store(true, Ordering::Release);
606        let handles: Vec<JoinHandle<()>> = std::mem::take(
607            &mut *sidecar.join_handles.lock(),
608        );
609        for h in handles {
610            h.join().ok();
611        }
612        // After joining the scan threads, also clear the registry so
613        // that any other static drop chain that touches Sidecar sees
614        // an empty state instead of dangling raw pointers from leaked
615        // SidecarBox<T> instances. (Tests may leak via panic; tear-
616        // down code must tolerate it.)
617        for node in &sidecar.nodes {
618            let mut g = node.instances.write();
619            for slot in g.iter_mut() {
620                *slot = None;
621            }
622        }
623        eprintln!("[subetha-sidecar atexit] shutdown complete");
624    }
625}
626
627/// Get the process-wide sidecar singleton.
628pub fn global() -> Arc<Sidecar> {
629    GLOBAL.clone()
630}
631
632/// Number of NUMA nodes detected on this host. Used by the (in-progress)
633/// per-NUMA sidecar sharding to decide how many sidecar threads to spawn.
634///
635/// On Windows this calls `GetNumaHighestNodeNumber`. On other platforms
636/// it returns 1 (no NUMA awareness). Returns at least 1.
637pub fn numa_node_count() -> u32 {
638    #[cfg(target_os = "windows")]
639    {
640        // SAFETY: GetNumaHighestNodeNumber takes a pointer to a ULONG
641        // and writes the highest node number through it. No allocation.
642        unsafe {
643            let mut highest: u32 = 0;
644            unsafe extern "system" {
645                fn GetNumaHighestNodeNumber(HighestNodeNumber: *mut u32) -> i32;
646            }
647            // Link against kernel32.lib (auto-linked on MSVC targets).
648            let result = GetNumaHighestNodeNumber(&mut highest);
649            if result == 0 {
650                // BOOL FALSE means failure; fall back to 1 node.
651                1
652            } else {
653                highest.saturating_add(1)
654            }
655        }
656    }
657    #[cfg(not(target_os = "windows"))]
658    {
659        1
660    }
661}
662
663/// Per-NUMA-node sidecar sharding scaffolding. The default global
664/// sidecar handles all instances; multi-sidecar deployment with
665/// per-NUMA pinning would spawn one Sidecar per node and route
666/// registrations by spawn-thread affinity. The detection function
667/// [`numa_node_count`] surfaces the topology; the routing layer plugs
668/// in here when load testing on a multi-socket host motivates it.
669///
670/// Uses `GetCurrentProcessorNumberEx` + `GetNumaProcessorNodeEx` on
671/// Windows: these two work across processor groups (Windows splits
672/// logical processors into groups of up to 64), so the >64-logical-
673/// processor case (dual-socket servers, large core-count workstations)
674/// is handled correctly. The legacy `GetNumaProcessorNode` (capped at
675/// processor 255) is no longer called.
676pub fn current_numa_node() -> u32 {
677    #[cfg(target_os = "windows")]
678    {
679        // PROCESSOR_NUMBER per
680        // https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-processor_number
681        // sized layout: Group (USHORT) + Number (BYTE) + Reserved (BYTE) = 4 bytes.
682        #[repr(C)]
683        #[derive(Default, Clone, Copy)]
684        struct ProcessorNumber {
685            group: u16,
686            number: u8,
687            reserved: u8,
688        }
689        // SAFETY: GetCurrentProcessorNumberEx writes through &mut, no allocation.
690        // GetNumaProcessorNodeEx reads from the struct and writes one u16 out.
691        unsafe {
692            unsafe extern "system" {
693                fn GetCurrentProcessorNumberEx(ProcNumber: *mut ProcessorNumber);
694                fn GetNumaProcessorNodeEx(
695                    Processor: *const ProcessorNumber,
696                    NodeNumber: *mut u16,
697                ) -> i32;
698            }
699            let mut proc = ProcessorNumber::default();
700            GetCurrentProcessorNumberEx(&mut proc);
701            let mut node: u16 = 0;
702            if GetNumaProcessorNodeEx(&proc, &mut node) != 0 {
703                // u16::MAX (0xFFFF) is a documented "no node" sentinel
704                // returned by the API for un-NUMA-classified procs;
705                // route those to node 0.
706                if node == u16::MAX { 0 } else { node as u32 }
707            } else {
708                0
709            }
710        }
711    }
712    #[cfg(not(target_os = "windows"))]
713    {
714        // Linux: read /sys/devices/system/cpu/cpu<id>/topology/physical_package_id
715        // for the current CPU. sched_getcpu() returns the logical CPU index;
716        // /sys exposes the NUMA mapping. Fall back to node 0 if any step
717        // fails (no sysfs, container without /sys, etc).
718        current_numa_node_linux()
719    }
720}
721
722#[cfg(not(target_os = "windows"))]
723fn current_numa_node_linux() -> u32 {
724    use std::fs;
725    // libc::sched_getcpu would be the direct call but we avoid the libc
726    // dep by reading /proc/self/stat field 39 (last_cpu) or by parsing
727    // /sys/.../cpu<N>/topology. For portability across kernels we read
728    // /proc/self/stat which exposes the last-scheduled CPU.
729    let stat = match fs::read_to_string("/proc/self/stat") {
730        Ok(s) => s,
731        Err(_) => return 0,
732    };
733    // /proc/self/stat fields are space-separated AFTER the comm field
734    // (which is parenthesised). Skip past the closing paren.
735    let after_comm = match stat.rfind(')') {
736        Some(i) => &stat[i + 1..],
737        None => return 0,
738    };
739    // Last-scheduled CPU is field 39 in proc(5); we count fields from
740    // after_comm (which is at field 3 boundary because pid, comm are 1-2).
741    let cpu = match after_comm.split_whitespace().nth(36) {
742        Some(s) => match s.parse::<u32>() { Ok(v) => v, Err(_) => return 0 },
743        None => return 0,
744    };
745    let path = format!(
746        "/sys/devices/system/cpu/cpu{cpu}/topology/physical_package_id"
747    );
748    match fs::read_to_string(&path) {
749        Ok(s) => s.trim().parse::<u32>().unwrap_or(0),
750        Err(_) => 0,
751    }
752}
753
754/// RAII handle that auto-unregisters its instance on drop.
755pub struct SidecarHandle {
756    id: InstanceId,
757    sidecar: Arc<Sidecar>,
758}
759
760impl SidecarHandle {
761    pub fn id(&self) -> InstanceId {
762        self.id
763    }
764
765    pub fn stats(&self) -> Option<InstanceStats> {
766        self.sidecar.stats(self.id)
767    }
768}
769
770impl Drop for SidecarHandle {
771    fn drop(&mut self) {
772        self.sidecar.unregister(self.id);
773    }
774}
775
776/// Trait implemented by adaptive primitive instances that opt into
777/// sidecar observation. The Box guarantees stable addresses for the
778/// header and ring.
779pub trait AdaptiveInstance: Send + Sync + 'static {
780    fn header(&self) -> &HandshakeHeader;
781    fn ring(&self) -> &ObservationRing;
782    fn make_policy(&self) -> Box<dyn Policy>;
783
784    /// Called by the sidecar when the policy returns a new strategy
785    /// tag. Default implementation: just set the tag on the header.
786    /// Primitives that need heavier migration (data-layout swap)
787    /// override this to perform the swap before (or after) updating
788    /// the tag.
789    fn apply_migration(&self, new_tag: u32) {
790        self.header().set_tag(new_tag);
791    }
792}
793
794/// Boxed primitive + auto-unregistering sidecar handle.
795///
796/// `Drop` order is well-defined: handle drops first (blocks on scan,
797/// then clears the registry slot), then the box drops (frees the
798/// header/ring memory). No raw-pointer-after-free race.
799pub struct SidecarBox<T: AdaptiveInstance> {
800    // ORDER MATTERS: handle drops before inner.
801    handle: SidecarHandle,
802    inner: Box<T>,
803}
804
805impl<T: AdaptiveInstance> SidecarBox<T> {
806    pub fn new(value: T) -> Self {
807        let inner = Box::new(value);
808        // SAFETY: Box guarantees stable address until inner is dropped.
809        // Field references and the instance pointer are valid as long
810        // as inner is alive. SidecarHandle::drop runs before inner::drop,
811        // calling unregister(), which blocks until any in-flight scan
812        // finishes.
813        let header = NonNull::from(inner.header());
814        let ring = NonNull::from(inner.ring());
815        let instance_ref: &dyn AdaptiveInstance = &*inner;
816        let instance_ptr: *const dyn AdaptiveInstance = instance_ref;
817        let instance = unsafe {
818            NonNull::new_unchecked(instance_ptr as *mut dyn AdaptiveInstance)
819        };
820        let policy = inner.make_policy();
821        let sidecar = global();
822        let id = unsafe { sidecar.register_raw(header, ring, Some(instance), policy) };
823        Self {
824            handle: SidecarHandle { id, sidecar },
825            inner,
826        }
827    }
828
829    pub fn id(&self) -> InstanceId {
830        self.handle.id
831    }
832
833    pub fn stats(&self) -> Option<InstanceStats> {
834        self.handle.stats()
835    }
836}
837
838impl<T: AdaptiveInstance> std::ops::Deref for SidecarBox<T> {
839    type Target = T;
840    fn deref(&self) -> &T {
841        &self.inner
842    }
843}
844
845#[cfg(test)]
846mod tests {
847    use super::*;
848    use subetha_core::Observation;
849
850    /// A bare instance with just header + ring for sidecar testing.
851    struct BareInstance {
852        header: HandshakeHeader,
853        ring: ObservationRing,
854    }
855
856    impl BareInstance {
857        fn new() -> Self {
858            Self {
859                header: HandshakeHeader::new(),
860                ring: ObservationRing::new(),
861            }
862        }
863    }
864
865    impl AdaptiveInstance for BareInstance {
866        fn header(&self) -> &HandshakeHeader { &self.header }
867        fn ring(&self) -> &ObservationRing { &self.ring }
868        fn make_policy(&self) -> Box<dyn Policy> { Box::new(NoMigrationPolicy) }
869    }
870
871    /// Policy that escalates the tag whenever average latency > threshold.
872    struct EscalatingPolicy {
873        threshold_ticks: u64,
874        escalate_to: u32,
875    }
876
877    impl Policy for EscalatingPolicy {
878        fn decide(&self, stats: &InstanceStats, current_tag: u32) -> Option<u32> {
879            if stats.average_latency_ticks() > self.threshold_ticks && current_tag < self.escalate_to {
880                Some(self.escalate_to)
881            } else {
882                None
883            }
884        }
885    }
886
887    struct EscalatingInstance {
888        header: HandshakeHeader,
889        ring: ObservationRing,
890    }
891
892    impl EscalatingInstance {
893        fn new() -> Self {
894            Self {
895                header: HandshakeHeader::new(),
896                ring: ObservationRing::new(),
897            }
898        }
899    }
900
901    impl AdaptiveInstance for EscalatingInstance {
902        fn header(&self) -> &HandshakeHeader { &self.header }
903        fn ring(&self) -> &ObservationRing { &self.ring }
904        fn make_policy(&self) -> Box<dyn Policy> {
905            Box::new(EscalatingPolicy {
906                threshold_ticks: 500,
907                escalate_to: 2,
908            })
909        }
910    }
911
912    #[test]
913    fn register_unregister_balances() {
914        let s = global();
915        let inst = Box::new(BareInstance::new());
916        let header = NonNull::from(inst.header());
917        let ring = NonNull::from(inst.ring());
918        let id = unsafe {
919            s.register_raw(header, ring, None, Box::new(NoMigrationPolicy))
920        };
921        assert!(s.stats(id).is_some());
922        s.unregister(id);
923        assert!(s.stats(id).is_none());
924        drop(inst);
925    }
926
927    #[test]
928    fn sidecar_drains_observations() {
929        let inst = SidecarBox::new(BareInstance::new());
930
931        // Push 10 observations.
932        for i in 0..10 {
933            assert!(inst.ring.push(Observation {
934                instance_id: 0,
935                op_kind: 1,
936                flags: 0,
937                latency_ticks: 100 + i,
938                ..Observation::ZERO
939            }));
940        }
941
942        // Force a scan.
943        global().scan_now();
944
945        let stats = inst.stats().expect("instance should be registered");
946        assert_eq!(stats.ops_observed, 10);
947        assert!(stats.total_latency_ticks >= 1000);
948    }
949
950    #[test]
951    fn policy_migrates_strategy_when_threshold_crossed() {
952        let inst = SidecarBox::new(EscalatingInstance::new());
953        assert_eq!(inst.header().tag(), 0);
954
955        // Push observations with latency well above threshold (500).
956        for _ in 0..50 {
957            inst.ring.push(Observation {
958                instance_id: 0,
959                op_kind: 1,
960                flags: 0,
961                latency_ticks: 5000,
962                ..Observation::ZERO
963            });
964        }
965
966        global().scan_now();
967
968        // EscalatingPolicy should have set tag to 2.
969        assert_eq!(inst.header().tag(), 2,
970                   "policy should have escalated tag to 2 after high-latency observations");
971    }
972
973    #[test]
974    fn unregister_blocks_safe_drop() {
975        // This is the load-bearing race-safety test. We register an
976        // instance, push observations, drop the SidecarBox while the
977        // sidecar may be mid-scan, and rely on the unregister-blocks-on-
978        // scan contract to prevent use-after-free.
979        for _ in 0..50 {
980            let inst = SidecarBox::new(BareInstance::new());
981            // Push observations to make the sidecar dereference our pointers.
982            for _ in 0..100 {
983                inst.ring.push(Observation {
984                    instance_id: 0,
985                    op_kind: 1,
986                    flags: 0,
987                    latency_ticks: 10,
988                    ..Observation::ZERO
989                });
990            }
991            // Drop while sidecar may be scanning. If unregister doesn't
992            // block correctly, this leads to use-after-free under TSAN/ASAN.
993            drop(inst);
994        }
995    }
996
997    #[test]
998    fn fixed_policy_sets_tag_immediately() {
999        struct Inst { h: HandshakeHeader, r: ObservationRing }
1000        impl AdaptiveInstance for Inst {
1001            fn header(&self) -> &HandshakeHeader { &self.h }
1002            fn ring(&self) -> &ObservationRing { &self.r }
1003            fn make_policy(&self) -> Box<dyn Policy> { Box::new(FixedPolicy(7)) }
1004        }
1005        let inst = SidecarBox::new(Inst {
1006            h: HandshakeHeader::new(),
1007            r: ObservationRing::new(),
1008        });
1009
1010        inst.r.push(Observation { instance_id: 0, op_kind: 0, flags: 0, latency_ticks: 1, ..Observation::ZERO });
1011        global().scan_now();
1012        assert_eq!(inst.h.tag(), 7);
1013    }
1014
1015    #[test]
1016    fn instance_count_tracks_register_and_unregister() {
1017        // Use a local Sidecar so this test does not interfere with
1018        // the global one used by other tests.
1019        let s = Sidecar::new();
1020        let start = s.instance_count();
1021
1022        let inst = Box::new(BareInstance::new());
1023        let header = NonNull::from(inst.header());
1024        let ring = NonNull::from(inst.ring());
1025        let id = unsafe {
1026            s.register_raw(header, ring, None, Box::new(NoMigrationPolicy))
1027        };
1028        assert_eq!(s.instance_count(), start + 1);
1029
1030        s.unregister(id);
1031        assert_eq!(s.instance_count(), start);
1032    }
1033
1034    #[test]
1035    fn cap_panic_message_is_actionable() {
1036        // Build a Sidecar with a tiny cap and verify the panic
1037        // message names the actual cap value AND mentions the
1038        // diagnostic guidance about loops / b.iter() / set_max_instances.
1039        let s = Sidecar::new();
1040        s.set_max_instances(2);
1041        assert_eq!(s.max_instances(), 2);
1042
1043        // Register up to the cap (no panic).
1044        let inst1 = Box::new(BareInstance::new());
1045        let id1 = unsafe {
1046            s.register_raw(
1047                NonNull::from(inst1.header()),
1048                NonNull::from(inst1.ring()),
1049                None,
1050                Box::new(NoMigrationPolicy),
1051            )
1052        };
1053        let inst2 = Box::new(BareInstance::new());
1054        let id2 = unsafe {
1055            s.register_raw(
1056                NonNull::from(inst2.header()),
1057                NonNull::from(inst2.ring()),
1058                None,
1059                Box::new(NoMigrationPolicy),
1060            )
1061        };
1062
1063        // Third must panic with the documented diagnostic.
1064        let inst3 = Box::new(BareInstance::new());
1065        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1066            unsafe {
1067                s.register_raw(
1068                    NonNull::from(inst3.header()),
1069                    NonNull::from(inst3.ring()),
1070                    None,
1071                    Box::new(NoMigrationPolicy),
1072                )
1073            }
1074        }));
1075        let payload = result.expect_err("must panic when over cap");
1076        let msg = payload.downcast_ref::<String>().map(String::as_str)
1077            .or_else(|| payload.downcast_ref::<&'static str>().copied())
1078            .expect("panic payload must be a string");
1079        assert!(msg.contains("instance cap (2) exceeded"),
1080                "panic must name the cap value: {msg}");
1081        assert!(msg.contains("b.iter()") || msg.contains("loop"),
1082                "panic must hint at b.iter() / loop misuse: {msg}");
1083        assert!(msg.contains("set_max_instances"),
1084                "panic must mention the escape hatch: {msg}");
1085
1086        // Failed register must NOT have incremented the count past cap.
1087        assert_eq!(s.instance_count(), 2,
1088                   "count must roll back on cap-rejected register");
1089
1090        // Cleanup so test does not leak.
1091        s.unregister(id1);
1092        s.unregister(id2);
1093    }
1094}