Skip to main content

numa_shim/
lib.rs

1//! `numa-shim` — dependency-free NUMA detection and placement.
2//!
3//! **Key selling point:** zero third-party C/C++ dependencies (no libnuma, no
4//! hwloc) — the crate calls the system libc/Win32 API surface directly via
5//! FFI rather than binding a third-party C library.
6//! - Linux: `mbind(2)` via raw `syscall(2)` (no libnuma, no hwloc).
7//! - Linux node detection: reads `/sys/devices/system/node/nodeN/cpumap` directly
8//!   via `open`/`read`/`close` from the C runtime (always present in glibc/musl).
9//! - Windows: `VirtualAllocExNuma` for NUMA-preferred reservations;
10//!   `GetCurrentProcessorNumberEx` + `GetNumaProcessorNodeEx` for detection.
11//! - macOS / miri: detection reports "unavailable"; the reservation API
12//!   returns `Err(UnsupportedPlatform)` (no silent no-ops — task #1306).
13//!
14//! This is rare in the Rust ecosystem — typical NUMA crates bind to `libnuma` or
15//! `hwloc`, pulling in heavy C dependencies. `numa-shim` has **zero non-system
16//! dependencies** in its default configuration.
17//!
18//! ## Usage
19//!
20//! ```text
21//! use numa_shim::current_node;
22//!
23//! match current_node() {
24//!     Some(node) => println!("Running on NUMA node {node}"),
25//!     None       => println!("NUMA topology unavailable (detection failed or unsupported platform)"),
26//! }
27//! ```
28//!
29//! Runnable form: `tests/smoke.rs`.
30//!
31//! ## Safety
32//!
33//! The public API is safe to call from `#![forbid(unsafe_code)]` consumers —
34//! the crate has NO `pub unsafe fn`. `unsafe` is confined to the per-OS
35//! `mod platform` blocks plus a small set of crate-root Linux mbind FFI
36//! helpers (`mbind_preferred_linux`, `libc_mbind`, and the
37//! `extern "C" { fn syscall(...) }` declaration), each with `// SAFETY:` proof
38//! comments. task #1277 (review N7): the old claim that unsafe was "confined to
39//! platform modules" was false — those crate-root helpers sit outside every
40//! `mod platform`. The `bind_range` byte-range API (previously the single
41//! `pub unsafe fn`) was removed in task #1306 as it was confirmed broken
42//! (unaligned `addr` → silent EINVAL; mbind default flags affect only FUTURE
43//! faults, not already-touched pages).
44//!
45//! ## Feature flags
46//!
47//! | Flag | Effect |
48//! |------|--------|
49//! | `vmem-integration` | Enables `reserve_preferred_on_node`, which uses the `aligned-vmem` crate for the reservation step. Windows path uses `VirtualAllocExNuma`; Linux reserves then calls `mbind`. |
50//!
51//! ## Platform matrix
52//!
53//! | Platform | [`current_node`] | `reserve_preferred_on_node` (feature) |
54//! |----------|-----------------|------------------------------------------|
55//! | Linux x86_64/aarch64 (non-miri) | sched_getcpu + sysfs cpumap | mmap then mbind (complete span, before first touch) |
56//! | Linux other arch (non-miri) | sched_getcpu + sysfs cpumap | `UnsupportedArchitecture` error |
57//! | Windows 64-bit (non-miri) | `GetCurrentProcessorNumberEx` | `VirtualAllocExNuma` |
58//! | macOS | `None` | `UnsupportedPlatform` error |
59//! | miri | `None` | `UnsupportedPlatform` error |
60//! | other | `None` | `UnsupportedPlatform` error |
61//!
62//! Windows is supported on 64-bit targets only (`x86_64-pc-windows-msvc`
63//! and equivalent); 32-bit Windows (`target_pointer_width = "32"`) is
64//! explicitly out of scope — an owner policy decision (task #1313,
65//! fifteenth review finding F11), matching a Windows FFI test layout that
66//! has always assumed a 64-bit pointer width and CI coverage that has only
67//! ever run 64-bit `windows-latest`. This policy is compile-time enforced
68//! (task #1321, sixteenth review P2): the crate root emits `compile_error!`
69//! under `cfg(all(windows, target_pointer_width = "32"))`, so a 32-bit
70//! Windows build fails loudly instead of silently compiling an unsupported
71//! configuration; 32-bit non-Windows targets are unaffected and keep
72//! compiling normally. The README's platform table states the same policy;
73//! the two are kept in sync deliberately.
74
75// This crate intentionally contains unsafe OS FFI code.
76// The public API is safe — all unsafe lives in the per-OS `mod platform`
77// blocks plus a small set of crate-root Linux mbind FFI helpers
78// (`mbind_preferred_linux`, `libc_mbind`, and the
79// `extern "C" { fn syscall(...) }` declaration they call through), each
80// documented with // SAFETY: proof comments. task #1277 (review N7): the
81// old "confined to platform modules" claim was false — those crate-root
82// helpers sit outside every `mod platform`. The `bind_range` byte-range
83// API (previously the single `pub unsafe fn`) was removed in task #1306
84// as it was confirmed broken (unaligned `addr` → silent EINVAL; mbind
85// default flags affect only FUTURE faults, not already-touched pages).
86#![allow(unsafe_code)]
87#![deny(missing_docs)]
88
89// task #1321 (sixteenth review P2): the 64-bit-only Windows policy stated
90// in the platform matrix above is enforced here, not just documented. The
91// condition is Windows-specific by construction — 32-bit non-Windows
92// targets (e.g. i686-unknown-linux-gnu) are not in this crate's scope
93// restriction and keep compiling normally. Placed after the inner
94// attributes because a macro invocation is an item and must not separate
95// `//!` docs / `#![...]` attributes from each other.
96#[cfg(all(windows, target_pointer_width = "32"))]
97compile_error!(
98    "numa-shim supports 64-bit Windows only (owner policy, task #1313/F11, \
99     enforced by task #1321); 32-bit Windows (target_pointer_width = \"32\") \
100     is out of scope -- see the crate-level platform-matrix doc comment and \
101     README.md's platform table for the full policy statement"
102);
103
104/// Sentinel value meaning "no NUMA node / feature disabled / unsupported
105/// platform". This constant is useful when interfacing with APIs that return
106/// a raw `u32` node index and need a "not available" sentinel.
107///
108/// [`current_node`] returns `None` instead of this sentinel; `NO_NODE` is
109/// provided for interop with code that uses the sentinel pattern.
110///
111/// As of task #1306, `NO_NODE` is no longer accepted by the reservation API
112/// (`reserve_preferred_on_node`). It is now used only for detection-side interop;
113/// `current_node()` returns `Option<u32>` for the no-preference case.
114pub const NO_NODE: u32 = u32::MAX;
115
116/// A NUMA node identifier for the reservation/policy API.
117///
118/// The [`NO_NODE`] sentinel is unrepresentable: [`NodeId::new`] rejects it
119/// (task #1309).
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
121pub struct NodeId(u32);
122
123impl NodeId {
124    /// Construct a `NodeId` from a raw `u32`, rejecting ONLY the
125    /// [`NO_NODE`] sentinel (`u32::MAX`).
126    ///
127    /// Returns `None` for exactly one input: [`NO_NODE`], the one value that
128    /// is invalid on EVERY platform — the "no node" state this type exists
129    /// to keep out of the reservation API (task #1309, finding F4 of the
130    /// fifteenth independent review). Every other `u32` constructs,
131    /// including ids a particular platform cannot address: node EXISTENCE
132    /// is platform- and runtime-dependent (Linux's single-`u64` nodemask
133    /// addresses nodes 0..=63 only; Windows forwards any id to the OS), so
134    /// that validation stays where it already lives — the fallible
135    /// `reserve_preferred_on_node` checks
136    /// ([`ReserveNumaError::InvalidNode`]/[`ReserveNumaError::Os`]), not
137    /// construction time. Do not read more validation into this constructor
138    /// than the single sentinel comparison it performs.
139    ///
140    /// # Ergonomic path from detection
141    ///
142    /// [`current_node`] remaps the sentinel to `None` (its `Some(n)` arm can
143    /// never carry `NO_NODE`), so `NodeId::new(n)` in this composition can
144    /// never fail. The reservation call itself can still fail (e.g.
145    /// `UnsupportedArchitecture` on a real Linux target outside
146    /// x86_64/aarch64), so the best-effort `.ok().or_else(...)` fallback is
147    /// used instead of an `.expect(...)` on the reservation:
148    ///
149    /// ```text
150    /// match numa_shim::current_node() {
151    ///     // current_node() never yields the NO_NODE sentinel in its Some
152    ///     // arm, so NodeId::new(n) here is always Some(_).
153    ///     Some(n) => numa_shim::reserve_preferred_on_node(
154    ///         size,
155    ///         align,
156    ///         NodeId::new(n).expect("never the NO_NODE sentinel"),
157    ///     )
158    ///     .ok()
159    ///     .or_else(|| aligned_vmem::reserve_aligned(size, align))
160    ///     .expect("OOM"),
161    ///     None => aligned_vmem::reserve_aligned(size, align).expect("OOM"),
162    /// }
163    /// ```
164    ///
165    /// Composed directly, the two `Option`s flatten:
166    /// `current_node().and_then(NodeId::new)` is an `Option<NodeId>`.
167    ///
168    /// No `new_unchecked`/`unsafe` constructor exists — no path needs to
169    /// bypass the one comparison.
170    // `Option` is already `#[must_use]`; a bare `#[must_use]` here would
171    // trip clippy::double_must_use under this repo's -D warnings gate.
172    pub const fn new(id: u32) -> Option<Self> {
173        if id == NO_NODE {
174            None
175        } else {
176            Some(Self(id))
177        }
178    }
179
180    /// Return the raw node id wrapped by this `NodeId`.
181    #[must_use]
182    pub const fn get(self) -> u32 {
183        self.0
184    }
185}
186
187/// The failure cause of a NUMA-preferred reservation attempt.
188#[non_exhaustive]
189#[derive(Debug)]
190pub enum ReserveNumaError {
191    /// The platform provides no NUMA API (macOS, miri, other unsupported OS).
192    UnsupportedPlatform,
193    /// Linux architecture without a known `SYS_MBIND` syscall number.
194    UnsupportedArchitecture,
195    /// `size`/`align` violated the reservation contract (zero size, align not
196    /// a power of two >= page size, size not a page multiple, or size+align
197    /// overflow). Carried as one variant because the underlying validator
198    /// cannot distinguish which parameter was at fault.
199    InvalidArguments,
200    /// The node id cannot be addressed by this platform's nodemask — the
201    /// documented Linux implementation limit: a single `u64` nodemask
202    /// addresses nodes 0..=63 only.
203    InvalidNode,
204    /// The OS refused an operation; the io::Error was captured immediately
205    /// at the failing syscall, before any cleanup FFI could overwrite errno.
206    Os(std::io::Error),
207}
208
209impl core::fmt::Display for ReserveNumaError {
210    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
211        match self {
212            Self::UnsupportedPlatform => {
213                f.write_str("NUMA-preferred reservation is unsupported on this platform")
214            }
215            Self::UnsupportedArchitecture => {
216                f.write_str("Linux architecture without a known SYS_MBIND syscall number")
217            }
218            Self::InvalidArguments => {
219                f.write_str("invalid arguments (reservation contract violation)")
220            }
221            Self::InvalidNode => {
222                f.write_str("NUMA node id cannot be addressed by this platform's nodemask")
223            }
224            Self::Os(e) => write!(f, "{e}"),
225        }
226    }
227}
228
229impl std::error::Error for ReserveNumaError {
230    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
231        match self {
232            Self::Os(e) => Some(e),
233            _ => None,
234        }
235    }
236}
237
238/// Re-exported so callers of [`reserve_preferred_on_node`] can name the return
239/// type as `numa_shim::Reservation` without adding a direct `aligned-vmem`
240/// dependency of their own. This re-export makes the intentional semver
241/// coupling between the two sibling crates visible in `numa-shim`'s own
242/// public API (item 46, `docs/CORRECTNESS_OPEN_ITEMS.md`) rather than
243/// leaving it implicit — see [`reserve_preferred_on_node`]'s own doc section
244/// on the coupling for the full rationale.
245#[cfg(feature = "vmem-integration")]
246pub use aligned_vmem::Reservation;
247
248/// Test-only mock state replacing platform NUMA syscalls.  Records every
249/// invocation into a thread-local buffer so unit tests can assert the
250/// wrapping logic is correct on any target (including macOS and miri,
251/// where real NUMA syscalls are absent).
252///
253/// Enabled by the build-time cfg flag `numa_shim_mock` (`RUSTFLAGS="--cfg numa_shim_mock"`).
254/// When enabled, the public NUMA functions dispatch into this module instead of
255/// the platform implementations.
256///
257/// The recording log is capped at [`mock::CALLS_CAP`] entries (task
258/// #726/#778) — see [`mock::drain`]'s own doc for what that means for a
259/// caller driving more calls than the cap without an intervening drain.
260///
261/// # Why a `--cfg` flag, not a Cargo feature (task #1288, item 42)
262///
263/// This module used to be gated on a `mock` Cargo feature. That was a hazard
264/// because Cargo unifies features across a build's WHOLE dependency graph, so
265/// any one target enabling `numa-shim/mock` silently replaced the real syscalls
266/// for every consumer sharing that graph (this repo's own root crate demonstrated
267/// it: `numa-aware-mock` forwarded to `numa-shim/mock`). Converted 2026-08-23
268/// (task #1288) mirroring aligned-vmem's task #962. The cfg still applies
269/// build-graph-wide once set — what changed is WHO can set it: only the top-level
270/// build invoker via an explicit RUSTFLAGS/build-script choice, never a
271/// transitive dependency via Cargo's additive feature-unification, and never
272/// `--all-features`/docs.rs/`cargo add` by accident. The flag is declared in this
273/// crate's `[lints.rust unexpected_cfgs]` in Cargo.toml so it produces no
274/// unexpected-cfg warnings. Removing the feature is semver-breaking against
275/// published 0.1.0 (see CHANGELOG "Removed").
276#[cfg(numa_shim_mock)]
277pub mod mock {
278    use crate::NodeResolution;
279    use core::cell::{Cell, RefCell};
280
281    /// Maximum number of calls `CALLS` retains before `record()` stops
282    /// pushing.
283    ///
284    /// task #726 (rust-intel audit §B14): under the documented
285    /// sefer-alloc-as-global `numa-aware-mock` scenario (this module's own
286    /// R11-5 note on `record`), every allocation calls `current_node()` →
287    /// `record()` → `Vec::push` with nothing ever draining the log in that
288    /// scenario — an unbounded insert-only Vec growing linearly with
289    /// allocation count per thread. Once `CALLS` holds this many entries,
290    /// `record()` stops pushing (oldest entries are kept, matching a
291    /// call-log's usual "what happened first" debugging value) rather than
292    /// growing forever; direct mock tests that `drain()` promptly never
293    /// approach this cap.
294    ///
295    /// task #778 (round-closing review, F7): made `pub` so a downstream
296    /// test driving a large number of mocked calls can assert against this
297    /// exact value instead of hardcoding a mirror of it (as
298    /// `tests/mock_dispatch.rs`'s own `calls_log_is_capped_not_unbounded`
299    /// now does).
300    pub const CALLS_CAP: usize = 4096;
301
302    /// One recorded invocation of a public NUMA function.
303    #[non_exhaustive]
304    #[derive(Debug, Clone, PartialEq, Eq)]
305    pub enum MockCall {
306        /// `current_node()` was called; the inner value is the RAW
307        /// pre-remap slot value, which is not necessarily what the function
308        /// returned: when the slot holds `NO_NODE`, the record carries that
309        /// raw sentinel even though `current_node()` remaps it to `None` for
310        /// its caller (record-then-remap order, matching the real dispatch;
311        /// deliberately asserted by `tests/mock_dispatch.rs`'s
312        /// `current_node_scripted_no_node_yields_none`).
313        ///
314        /// task #778 (round-closing review, F13): unlike [`ReservePreferredOnNode`]
315        /// below, this tuple variant deliberately does NOT carry
316        /// `#[non_exhaustive]` -- `current_node()`'s signature is
317        /// `fn() -> Option<u32>`, a single scalar return with no plausible
318        /// second field to grow into (unlike `reserve_preferred_on_node`,
319        /// which takes multiple arguments a future API revision could
320        /// add to). Marking it would force `tests/mock_dispatch.rs`'s two
321        /// `assert_eq!(calls, vec![MockCall::CurrentNode(n)])` equality-
322        /// oracle sites into weaker `matches!` form for no real growth path
323        /// this shape needs to reserve. This variant's single-field layout
324        /// is considered frozen.
325        ///
326        /// [`ReservePreferredOnNode`]: MockCall::ReservePreferredOnNode
327        CurrentNode(u32),
328        /// `current_node_resolution()` was called; the inner value is what
329        /// was returned.
330        ///
331        /// task #1277 (review N6): `current_node_resolution()` previously
332        /// did not record at all, contradicting this module's "records
333        /// every invocation" contract; this variant closes that gap. Like
334        /// [`CurrentNode`], this single-field tuple variant deliberately
335        /// carries no field-level `#[non_exhaustive]` (same reasoning as
336        /// task #778/F13's note on `CurrentNode`): one value with no
337        /// plausible second field to grow into, keeping equality-oracle
338        /// `assert_eq!` sites possible.
339        ///
340        /// [`CurrentNode`]: MockCall::CurrentNode
341        CurrentNodeResolution(NodeResolution),
342        /// `reserve_preferred_on_node(size, align, node)` was called; `node`
343        /// is the raw id from the `NodeId` (recorded BEFORE validation — unlike
344        /// the old `BindRange` which recorded only past its short-circuit —
345        /// so error paths like `InvalidNode` are observable in the log too;
346        /// task #1306).
347        #[non_exhaustive]
348        ReservePreferredOnNode {
349            /// Requested reservation size in bytes.
350            size: usize,
351            /// Required alignment in bytes.
352            align: usize,
353            /// Raw NUMA node id wrapped by the `NodeId`.
354            node: u32,
355        },
356        /// The mock's simulated policy-installation stage ran for a reservation
357        /// that had ALREADY succeeded (mirrors the real Linux backend's post-
358        /// reservation `mbind(2)`).
359        ///
360        /// `reservation_len` is `Reservation::reservation_len()` at policy time —
361        /// the complete OS span the real backend mbinds (not the aligned usable
362        /// subrange). `succeeded == false` only when a scripted failure fired
363        /// via `set_policy_failure`.
364        ///
365        /// task #1311 (F6).
366        #[non_exhaustive]
367        InstallPolicy {
368            /// Raw NUMA node id the policy was applied to.
369            node: u32,
370            /// Complete OS reservation length at policy time.
371            reservation_len: usize,
372            /// Whether the simulated policy installation succeeded.
373            succeeded: bool,
374        },
375        /// The mock RELEASED a just-made reservation because the policy stage
376        /// failed.
377        ///
378        /// This record is pushed strictly AFTER the `Drop` of the reservation ran,
379        /// so its presence is the observable proof that the two-stage cleanup
380        /// contract executed. Exactly one such record per failed call is the
381        /// "released exactly once" postcondition.
382        ///
383        /// task #1311 (F6).
384        PolicyFailureRelease {
385            /// Raw NUMA node id of the released reservation.
386            node: u32,
387        },
388    }
389
390    std::thread_local! {
391        /// Calls recorded since the last `drain()`.
392        ///
393        /// task #726 (rust-intel audit §A3): was `pub`, committing this
394        /// thread-local's internal representation (`RefCell<Vec<MockCall>>`)
395        /// to the crate's semver surface even though the intended API is the
396        /// encapsulating pair [`drain`]/[`set_current_node`] — no code
397        /// anywhere in this workspace (including this crate's own tests)
398        /// touched `CALLS`/`CURRENT_NODE_SLOT` directly. Narrowed to
399        /// `pub(crate)`; external consumers keep `drain()`/`set_current_node()`
400        /// as the only surface.
401        pub(crate) static CALLS: RefCell<Vec<MockCall>> = const { RefCell::new(Vec::new()) };
402        /// Value returned by `current_node()` under the mock.  Default 0.
403        pub(crate) static CURRENT_NODE_SLOT: Cell<u32> = const { Cell::new(0) };
404        /// Scripted policy-installation failure for a specific node id.
405        ///
406        /// Holds `Some((node, err))` when a test has armed a failure for
407        /// calls with that exact node. Consumed by the first matching call
408        /// (`take_policy_failure_for`).
409        ///
410        /// Internal state encapsulated by `set_policy_failure`/`clear_policy_failure`/
411        /// `take_policy_failure_for` — mirrors the convention documented for
412        /// `CALLS`/`CURRENT_NODE_SLOT` above (task #726): `pub(crate)` internals
413        /// behind encapsulating functions, not part of the crate's semver surface.
414        pub(crate) static POLICY_FAILURE_SLOT: RefCell<Option<(u32, std::io::Error)>> = const { RefCell::new(None) };
415    }
416
417    /// Drain every recorded call since the last drain (or test start).
418    ///
419    /// task #778 (round-closing review, F7): truthful only up to
420    /// [`CALLS_CAP`] entries — past that, `record()` has already stopped
421    /// pushing (see `CALLS_CAP`'s own doc), so a caller that drives more
422    /// than `CALLS_CAP` calls without an intervening `drain()` gets a
423    /// silently truncated (oldest-first) prefix here, not the full set.
424    pub fn drain() -> Vec<MockCall> {
425        CALLS.with(|c| c.borrow_mut().drain(..).collect())
426    }
427
428    /// Set the value returned by subsequent `current_node()` calls, until
429    /// changed by a later `set_current_node` call.
430    pub fn set_current_node(node: u32) {
431        CURRENT_NODE_SLOT.with(|c| c.set(node));
432    }
433
434    /// Internal: read the scripted current_node value.
435    pub(crate) fn current_node_slot() -> u32 {
436        CURRENT_NODE_SLOT.with(|c| c.get())
437    }
438
439    /// Script a simulated policy-installation failure for calls with the exact
440    /// node id `node`.
441    ///
442    /// The scripted error surfaces as `ReserveNumaError::Os(err)` — the same
443    /// variant the real Linux backend returns when `mbind(2)` fails after a
444    /// successful reservation.
445    ///
446    /// # One-shot semantics
447    ///
448    /// The failure is consumed by the FIRST matching `reserve_preferred_on_node`
449    /// call with this exact node id. Subsequent calls with the same node succeed
450    /// (unless re-armed).
451    ///
452    /// # Node-scoped semantics
453    ///
454    /// A call with a different node id is unaffected. Test hygiene requires
455    /// calling `clear_policy_failure()` after each test.
456    ///
457    /// task #1311 (F6).
458    pub fn set_policy_failure(node: u32, err: std::io::Error) {
459        POLICY_FAILURE_SLOT.with(|c| *c.borrow_mut() = Some((node, err)));
460    }
461
462    /// Reset the scripted policy-installation failure.
463    ///
464    /// Test hygiene: call this at the start or end of each test that uses
465    /// `set_policy_failure` to avoid leaking state across tests.
466    ///
467    /// task #1311 (F6).
468    pub fn clear_policy_failure() {
469        POLICY_FAILURE_SLOT.with(|c| *c.borrow_mut() = None);
470    }
471
472    /// Internal: consume the scripted policy-installation failure for `node`.
473    ///
474    /// Returns `Some(err)` if a failure is armed for this exact node, consuming
475    /// it. Returns `None` if no failure is armed or the armed failure is for a
476    /// different node.
477    ///
478    /// # Reentrancy safety
479    ///
480    /// Uses `try_with`/`try_borrow_mut` like `record()`: on borrow failure,
481    /// returns `None` rather than panicking. This is defensive — the mock never
482    /// allocates inside the guard, but the pattern matches the established
483    /// reentrancy discipline.
484    ///
485    /// task #1311 (F6).
486    // Its only caller (`reserve_preferred_on_node`'s mock arm) is
487    // `#[cfg(feature = "vmem-integration")]`-gated; CI's mock-clippy step
488    // deliberately builds without that feature (task #1323, CI red on
489    // 9137c51) — same rationale as `mod platform`'s
490    // `#[cfg_attr(numa_shim_mock, allow(dead_code))]` above: compiles in
491    // some configs, structurally unused in others.
492    #[cfg_attr(not(feature = "vmem-integration"), allow(dead_code))]
493    pub(crate) fn take_policy_failure_for(node: u32) -> Option<std::io::Error> {
494        POLICY_FAILURE_SLOT
495            .try_with(|c| {
496                if let Ok(mut b) = c.try_borrow_mut() {
497                    b.take().and_then(|(armed_node, err)| {
498                        if armed_node == node {
499                            Some(err)
500                        } else {
501                            // Different node: put it back and report none
502                            *b = Some((armed_node, err));
503                            None
504                        }
505                    })
506                } else {
507                    None
508                }
509            })
510            .unwrap_or(None)
511    }
512
513    /// Internal: record a call.
514    ///
515    /// R11-5: reentrancy-safe. The `Vec::push` inside the borrow guard
516    /// allocates via the global allocator; if the global allocator IS
517    /// sefer-alloc under `numa-aware-mock` (which requires BOTH the feature
518    /// AND `--cfg numa_shim_mock`), that allocation re-enters `current_node()` → `record()`, which would
519    /// deadlock on a plain `borrow_mut()` (already borrowed). `try_with` +
520    /// `try_borrow_mut` silently drops the recording on re-entry — the
521    /// RETURNED value (from `current_node_slot`) is unaffected; only the
522    /// call-log entry for the re-entrant call is lost, which is acceptable
523    /// because tests that inspect the call log never run under a
524    /// sefer-alloc-as-global scenario.
525    pub(crate) fn record(call: MockCall) {
526        let _ = CALLS.try_with(|c| {
527            if let Ok(mut b) = c.try_borrow_mut() {
528                // task #726 (rust-intel audit §B14): cap the log so an
529                // unbounded numa-aware-mock allocation scenario (see this
530                // fn's own R11-5 note above) cannot grow this Vec forever.
531                if b.len() < CALLS_CAP {
532                    b.push(call);
533                }
534            }
535        });
536    }
537}
538
539/// Outcome of a NUMA-node determination attempt for the calling thread.
540///
541/// This enum provides finer-grained status information than the simpler
542/// `Option<u32>` returned by [`current_node`], exposing WHY a node could
543/// not be determined rather than just that it could not.
544///
545/// As of task #1308, [`current_node`] itself fails closed — it returns `None`
546/// for every non-`Resolved` outcome — so the distinction this enum exposes is
547/// diagnostic ("WHY detection failed") not a way to recover a node-0 answer.
548/// [`current_node`] remains the recommended function for most callers; use
549/// `current_node_resolution()` for diagnostic logging / warnings that NUMA
550/// hints may not be effective.
551///
552/// See task #1266, audit finding F4 for background, and task #1308 for the
553/// fail-closed origin.
554#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
555#[non_exhaustive]
556pub enum NodeResolution {
557    /// The calling thread's CPU was genuinely resolved to this NUMA node
558    /// via the platform topology.
559    ///
560    /// This variant is returned on Linux when the CPU index from
561    /// `sched_getcpu(2)` was found in one of the cached sysfs
562    /// `/sys/devices/system/node/nodeN/cpumap` files, on Windows when
563    /// `GetCurrentProcessorNumberEx` + `GetNumaProcessorNodeEx` succeed,
564    /// or under the `numa_shim_mock` cfg when the scripted node is not
565    /// [`NO_NODE`]. Note that `Resolved(0)` can legitimately indicate a
566    /// genuinely single-node system.
567    ///
568    /// Deliberately carries no field-level `#[non_exhaustive]` (see task
569    /// #778/F13 for the precedent this follows): this is a single scalar
570    /// field (the resolved node ID) with no plausible second field to grow
571    /// into, so marking it would force callers into weaker `matches!`
572    /// patterns for no real growth path this shape needs to reserve. The
573    /// enum-level `#[non_exhaustive]` above still protects against future
574    /// *variants*.
575    Resolved(u32),
576
577    /// Linux only: the CPU index was obtained, but no cached sysfs cpumap
578    /// contains it.
579    ///
580    /// This occurs when:
581    /// - The real topology was unreadable (e.g., sysfs permissions or
582    ///   I/O errors during the first-call cache population).
583    /// - The CPU lives on a NUMA node >= 64 — the implementation scans
584    ///   only nodes 0..63 because `reserve_preferred_on_node` enforces
585    ///   a single-`u64` nodemask limit (see the `InvalidNode` error in that
586    ///   function's documentation).
587    /// - The kernel has no NUMA sysfs at all (single-node system where
588    ///   the `/sys/devices/system/node/` directory is absent).
589    ///
590    /// [`current_node`] returns `None` for this variant as well (task #1308
591    /// — it previously collapsed it into `Some(0)`). This variant exists
592    /// to distinguish "the platform HAS a NUMA API and detection ran, but
593    /// this specific CPU could not be resolved" from [`NodeResolution::Unavailable`]
594    /// ("the platform has no NUMA API / the OS call itself failed") — a real,
595    /// useful distinction for diagnostic/logging callers even though both map
596    /// to `None` in `current_node()`.
597    TopologyUnavailable,
598
599    /// The platform provides no NUMA API, or the OS API failed.
600    ///
601    /// This is returned on:
602    /// - macOS (no public NUMA API).
603    /// - miri (no real OS topology).
604    /// - Unsupported platforms (e.g., FreeBSD, other Unix).
605    /// - Linux when `sched_getcpu(2)` fails (returns -1).
606    /// - Windows when `GetNumaProcessorNodeEx` fails or returns the
607    ///   `MAXUSHORT` sentinel.
608    /// - Under the `numa_shim_mock` cfg when the scripted node is [`NO_NODE`].
609    ///
610    /// [`current_node`] returns `None` for this case.
611    Unavailable,
612}
613
614/// Return the NUMA-node resolution status for the calling thread.
615///
616/// This is an **additive** alternative to [`current_node`] that exposes the
617/// internal outcome of the node-determination logic on Linux. Both functions
618/// now fail closed for non-`Resolved` outcomes (task #1308); this function's
619/// added value is the granular "WHY" for diagnostics, not recovering a
620/// `Some(0)` that `current_node()` no longer produces.
621///
622/// The mapping to [`current_node`] is:
623///
624/// | `current_node_resolution()` | `current_node()` |
625/// |-----------------------------|------------------|
626/// | `Resolved(n)` | `Some(n)` |
627/// | `TopologyUnavailable` | `None` |
628/// | `Unavailable` | `None` |
629///
630/// This function has the same first-call cost on Linux as `current_node()`
631/// (up to 64 `open`/`read`/`close` syscalls to populate the topology cache).
632/// On subsequent calls the topology lookup itself is pure in-memory (a
633/// reverse-index probe), but every call — warm or cold — still samples the
634/// CPU first via `sched_getcpu()`, which remains a platform call on every
635/// invocation (task #1333, eighteenth review F10).
636#[must_use]
637pub fn current_node_resolution() -> NodeResolution {
638    #[cfg(numa_shim_mock)]
639    {
640        let n = mock::current_node_slot();
641        let resolution = if n == NO_NODE {
642            NodeResolution::Unavailable
643        } else {
644            NodeResolution::Resolved(n)
645        };
646        // task #1277 (review N6): this arm used to deliberately skip
647        // recording, with a note claiming recording "would break existing
648        // tests' expectations" — it would not: no existing test inspects
649        // the log after calling this function (`tests/mock_dispatch.rs`
650        // never calls it; `tests/node_resolution.rs` never drains after
651        // it). Skipping contradicted the `mock` module's documented
652        // "records every invocation" contract, so this call now records
653        // like every other public NUMA function. The recorded value is
654        // the RESOLVED outcome returned to the caller — intentionally a
655        // DIFFERENT convention from `CurrentNode`'s raw pre-remap slot
656        // recording (task #1283, review E3: this comment previously and
657        // wrongly claimed the two conventions mirrored): `NodeResolution`
658        // is itself the semantically meaningful output this function
659        // exists to expose, so the resolved outcome is what test
660        // assertions want to compare against.
661        mock::record(mock::MockCall::CurrentNodeResolution(resolution));
662        resolution
663    }
664    #[cfg(not(numa_shim_mock))]
665    {
666        platform::current_node_resolution_impl()
667    }
668}
669
670/// Return the NUMA node id of the calling thread, or `None` if not
671/// determinable.
672///
673/// Returns `Some(n)` only when the calling thread's CPU was genuinely
674/// resolved to node `n` via the platform topology.
675///
676/// Returns `None` when:
677/// - The platform provides no NUMA API (macOS, miri, unsupported OS).
678/// - The OS API call itself failed.
679/// - (Linux, changed in task #1308) The topology could not resolve the
680///   calling thread's CPU to any node — including sysfs being entirely
681///   absent (single-node kernel with no NUMA support compiled in), a CPU
682///   whose real node is >= 64, or any sysfs read/permission failure.
683///
684/// On Linux, `Some(0)` now occurs ONLY for a CPU genuinely resolved to node
685/// 0 — it is NOT returned for absent sysfs or any other undetermined case.
686/// For the granular reason WHY detection failed, use
687/// [`current_node_resolution()`].
688///
689/// Historical note: before task #1308, every undetermined case (unreadable
690/// sysfs, a CPU on a node >= 64, a kernel with no NUMA sysfs at all) collapsed
691/// into the same `Some(0)` as a genuinely node-0-resolved CPU — tasks
692/// #722/#725 documented that collapse, and task #1308 made the mapping
693/// fail-closed (finding F1 of the fifteenth independent review).
694///
695/// **First-call cost on Linux** (task #778, round-closing review, F12): the
696/// VERY FIRST call to this function on a real Linux host performs up to 64
697/// `open`/`read`/`close` syscall triples (one per candidate NUMA node) to
698/// populate a process-lifetime topology cache; every subsequent call's
699/// topology lookup is then a pure in-memory reverse-index probe — but each
700/// call, warm or cold, still samples the CPU via `sched_getcpu()` first,
701/// which remains a platform call on every invocation (task #1333, eighteenth
702/// review F10: a cached lookup is not a syscall-free function). For a crate
703/// whose selling point is "zero dependencies, `forbid(unsafe_code)`-friendly
704/// for consumers," this first-call cost is a contract-level fact a caller on a
705/// latency-sensitive cold path should know about — most callers should call
706/// this once early (e.g. at startup) rather than assuming every call is
707/// equally cheap.
708#[must_use]
709pub fn current_node() -> Option<u32> {
710    #[cfg(numa_shim_mock)]
711    {
712        let n = mock::current_node_slot();
713        mock::record(mock::MockCall::CurrentNode(n));
714        // task #722 (rust-intel audit §F2): this used to unconditionally
715        // wrap the scripted slot in `Some`, so `set_current_node(NO_NODE)`
716        // (`u32::MAX`) produced `Some(NO_NODE)` -- violating this function's
717        // own documented "returns `Option`, never the sentinel" guarantee,
718        // and making every consumer's `None` branch impossible to exercise
719        // under `numa_shim_mock`, the very cfg that exists so CI can assert this
720        // wrapping logic. Mirrored the real dispatch's remapping below.
721        if n == NO_NODE {
722            None
723        } else {
724            Some(n)
725        }
726    }
727    #[cfg(not(numa_shim_mock))]
728    {
729        let raw = platform::current_node_impl();
730        if raw == NO_NODE {
731            None
732        } else {
733            Some(raw)
734        }
735    }
736}
737
738/// Reserve `size` bytes of anonymous virtual memory with a NUMA preference for
739/// `node`, aligned to `align`.
740///
741/// Requires the `vmem-integration` feature.
742///
743/// Installs a NUMA preference at RESERVATION time, before the first page fault —
744/// the only point where a NUMA preference can be installed before any page is
745/// touched (mbind default flags affect only future faults; an already-touched
746/// object cannot be retroactively placed — that is why the old `bind_range` was
747/// removed, task #1306). `MPOL_PREFERRED` remains a SOFT preference even then:
748/// the kernel may fall back under memory pressure, so success means "policy
749/// installed," not "physical placement guaranteed."
750///
751/// ## Per-platform behavior
752///
753/// ```text
754/// // Linux x86_64/aarch64:
755/// try_reserve_aligned then mbind(MPOL_PREFERRED) on the COMPLETE
756/// underlying OS reservation span (reservation_ptr()/reservation_len(),
757/// NOT as_ptr()/len() — policy lifetime aligns with mapping lifetime,
758/// no VMA splitting around alignment slack).
759///
760/// // Linux other arch:
761/// Err(UnsupportedArchitecture).
762///
763/// // Windows:
764/// VirtualAllocExNuma at reservation time.
765///
766/// // macOS / miri / other:
767/// Err(UnsupportedPlatform).
768/// ```
769///
770/// On Linux, node ids >= 64 are rejected with `InvalidNode` (single-`u64`
771/// nodemask limit; `mbind(2)` itself supports `MAX_NUMNODES` — documented
772/// implementation limit, not a kernel limit). Windows forwards any id to the OS
773/// and reports its refusal as `Os`.
774///
775/// ## Best-effort fallback
776///
777/// This function has NO silent fallback to a no-preference reservation — callers
778/// wanting plain memory should call `aligned_vmem::reserve_aligned` directly.
779/// Best-effort belongs at the call site:
780///
781/// ```text
782/// reserve_preferred_on_node(size, align, node)
783///     .ok()
784///     .or_else(|| aligned_vmem::reserve_aligned(size, align))
785/// ```
786///
787/// The `.ok()` deliberately narrows the `Result` to `Option`, discarding
788/// the specific `ReserveNumaError` — that is what best-effort means
789/// here: the caller has already chosen "just get me memory" over the
790/// diagnosis. Callers that need the error should keep the `Result` and
791/// match on it explicitly (the README's `vmem-integration` section shows
792/// that form).
793///
794/// If the NUMA policy fails AFTER a successful reservation, the reservation is
795/// RELEASED (dropped) and the error returned — never a reservation with a
796/// half-installed policy, never `Ok` with the preference silently absent
797/// (task #1306).
798///
799/// ## Errors
800///
801/// - `InvalidArguments`: `size`/`align` violate the reservation contract (zero
802///   size, align not a power of two >= page size, size not a page multiple,
803///   or size+align overflow).
804/// - `InvalidNode`: Linux node id >= 64 (single-u64 nodemask limit).
805/// - `Os`: the OS refused the operation; the io::Error was captured immediately
806///   at the failing syscall, before any cleanup FFI could overwrite errno.
807/// - `UnsupportedPlatform`: the platform provides no NUMA API (macOS, miri, other).
808/// - `UnsupportedArchitecture`: Linux architecture without a known `SYS_MBIND`.
809///
810/// # Semver coupling with `aligned-vmem`
811///
812/// This function's return type is `aligned-vmem`'s own [`Reservation`]
813/// (re-exported here as [`numa_shim::Reservation`](crate::Reservation)), not
814/// a `numa-shim`-owned wrapper. That is an intentional, accepted coupling
815/// (item 46, `docs/CORRECTNESS_OPEN_ITEMS.md`), not an oversight: `numa-shim`
816/// and `aligned-vmem` are sibling crates in this workspace, released
817/// together, so a semver-major bump in `aligned-vmem`'s `Reservation` shape
818/// forces a coordinated `numa-shim` bump in the same release — a cost
819/// already paid by the shared release process, not an extra one. The
820/// alternative (a `numa-shim`-owned newtype) was considered and rejected:
821/// wrapping `Reservation` would need either full API forwarding (permanent
822/// boilerplate that drifts on every `aligned-vmem` change) or an
823/// `into_inner()` escape hatch that re-exposes the same type in a public
824/// signature anyway, reproducing the coupling it was meant to remove.
825#[cfg(feature = "vmem-integration")]
826pub fn reserve_preferred_on_node(
827    size: usize,
828    align: usize,
829    node: NodeId,
830) -> Result<aligned_vmem::Reservation, ReserveNumaError> {
831    #[cfg(numa_shim_mock)]
832    {
833        mock::record(mock::MockCall::ReservePreferredOnNode {
834            size,
835            align,
836            node: node.get(),
837        });
838        // task #1306: mirrors the real Linux backend's documented single-u64
839        // nodemask limit (nodes 0..=63) so the InvalidNode error path is
840        // assertable under the mock on EVERY host, not only Linux.
841        //
842        // task #1311 (F6, doc-honesty): the mock approximates the REAL LINUX
843        // backend's contract here. Real Windows FORWARDS any node id (including >= 64)
844        // to the OS and reports the refusal as `Os`; real macOS returns
845        // `UnsupportedPlatform` unconditionally BEFORE this check. The mock has
846        // no per-platform simulation mode, and making it platform-faithful would
847        // break its run-anywhere purpose (mock tests on Windows hosts assert the
848        // Linux-shaped `InvalidNode`).
849        if node.get() >= 64 {
850            return Err(ReserveNumaError::InvalidNode);
851        }
852        // Mirror the real backends' error mapping instead of the old
853        // collapsed `Option`: contract violations are `InvalidArguments`,
854        // OS refusals are `Os` — so mock-mode tests can assert the
855        // distinction the old `reserve_on_node -> Option` API collapsed.
856        let r = match aligned_vmem::try_reserve_aligned(size, align) {
857            Ok(r) => r,
858            Err(e) => {
859                return Err(if e.is_invalid_argument() {
860                    ReserveNumaError::InvalidArguments
861                } else {
862                    ReserveNumaError::Os(std::io::Error::from(e))
863                })
864            }
865        };
866        let reservation_len = r.reservation_len();
867
868        // task #1311 (F6): two-stage reserve-then-policy, mirroring the real
869        // Linux backend. Check for a scripted policy failure for this node.
870        match mock::take_policy_failure_for(node.get()) {
871            Some(err) => {
872                // task #1311 (F6): mirror the real Linux backend's post-mbind
873                // failure path — release the just-made reservation, then return
874                // the error. The ORIGINAL error is returned untouched: the mock
875                // twin of the real backend's capture-errno-IMMEDIATELY-before-
876                // cleanup contract (see the Linux impl's comment in
877                // `platform::reserve_preferred_on_node_impl`).
878                mock::record(mock::MockCall::InstallPolicy {
879                    node: node.get(),
880                    reservation_len,
881                    succeeded: false,
882                });
883                drop(r);
884                // Record-after-drop ordering is load-bearing: the release record
885                // can only be pushed after the reservation's Drop ran, proving
886                // the cleanup executed.
887                mock::record(mock::MockCall::PolicyFailureRelease { node: node.get() });
888                Err(ReserveNumaError::Os(err))
889            }
890            None => {
891                // Policy succeeded: record the install and return the reservation.
892                mock::record(mock::MockCall::InstallPolicy {
893                    node: node.get(),
894                    reservation_len,
895                    succeeded: true,
896                });
897                Ok(r)
898            }
899        }
900    }
901    #[cfg(not(numa_shim_mock))]
902    {
903        platform::reserve_preferred_on_node_impl(size, align, node)
904    }
905}
906
907// ---------------------------------------------------------------------------
908// Linux cpumap parsing and reverse index (task #721, #1310): extracted from
909// the Linux-only `platform` module below into a target-INDEPENDENT module.
910// This module provides pure byte-slice parsing helpers and a boot-time
911// reverse index (cpu -> node) with no syscalls and no OS dependency
912// whatsoever -- gating them inside `#[cfg(target_os = "linux")]` was an
913// accident of code organization, not a genuine platform requirement, and it
914// meant the crate's own most intricate parsing logic (the
915// most-significant-word-first cpumap bitmask format) could ONLY be exercised
916// on a real Linux host. This crate's own `numa_shim_mock` cfg bypasses the
917// whole `platform` module rather than exercising it, so before this change
918// there was no way to run these functions on ANY host this project's CI or
919// this session actually has. Moving them here (still `#[doc(hidden)]`, not
920// part of this crate's public API — see the established "doc-hidden test-only
921// forwarders" pattern in `CLAUDE.md`) lets `tests/cpumap_parser.rs` and
922// `tests/cpumap_reverse_index.rs` exercise the real parsing logic and reverse
923// index construction directly, on every target, closing the round-closing
924// audit's §D1a finding for this half of its "zero behavioral oracles" claim.
925// ---------------------------------------------------------------------------
926/// Test-oracle-only module: sysfs cpumap parsing helpers and reverse index.
927///
928/// `#[doc(hidden)]` and **exempt from this crate's SemVer guarantees**
929/// (task #1289, following the `serde::__private` convention): everything in
930/// this module — signatures, names, existence — may change or be removed
931/// in ANY release, including patch releases, without a deprecation period.
932/// Do not depend on it from code outside this crate's own `tests/`;
933/// `cargo-semver-checks` likewise excludes `#[doc(hidden)]` items from its
934/// public-API model.
935#[doc(hidden)]
936pub mod cpumap {
937    /// Maximum number of CPUs that can be indexed in the reverse index.
938    ///
939    /// A Linux node cpumap file is the GLOBAL cpumask `cpumask_of_node(node) &
940    /// cpu_online_mask`; bit indices are global logical CPU IDs, all `< nr_cpu_ids
941    /// <= NR_CPUS` (kernel config). The kernel's per-arch `NR_CPUS` ceiling:
942    /// x86_64 caps at 8192 (arch/x86/Kconfig `range 2 8192`), arm64 at 4096;
943    /// other Linux archs are at or below these. This crate's platform matrix
944    /// supports Linux x86_64/aarch64, so 8192 entries (1 byte each, 8 KiB)
945    /// cover every possible set bit on any supported kernel. A CPU ID >= 8192
946    /// stays unmapped and degrades exactly like the old oversized-file case:
947    /// not found → `None` → `TopologyUnavailable`.
948    ///
949    /// This constant deliberately bounds GLOBAL CPU-ID space, correcting the
950    /// old `NODE_CPUMAP_BUF_LEN` comment's wrong per-node reasoning (task
951    /// #1310, review finding F5). The old comment claimed a 1024-byte buffer
952    /// covers "~3640 CPUs on a SINGLE node" — FALSE: on many-node/sparse-ID
953    /// systems ALL nodes' cpumaps can simultaneously exceed a small buffer
954    /// because the width tracks global ID space, not per-node CPU count.
955    pub const MAX_INDEXED_CPUS: usize = 8192;
956
957    /// Sentinel value in the reverse index meaning "no node mapped".
958    ///
959    /// Node values are 0..=63, so 255 (`u8::MAX`) is unambiguous as the unmapped
960    /// sentinel.
961    pub const CPU_UNMAPPED: u8 = u8::MAX;
962
963    /// Parse a Linux cpumap and invoke `on_cpu` for every set bit.
964    ///
965    /// Format: comma-separated hex 32-bit words, most-significant word first,
966    /// optional trailing newline. Example: `"00000000,00000003\n"` means CPUs 0
967    /// and 1 are in this node.
968    ///
969    /// This is the SINGLE format interpreter used by both `parse_contains_cpu`
970    /// and the reverse-index build (task #1310): there is no second divergent
971    /// parsing path.
972    ///
973    /// Returns `true` on full success, `false` on ANY malformed input (fail-closed).
974    /// A malformed token ANYWHERE in the text causes failure — real sysfs never
975    /// produces malformed tokens.
976    ///
977    /// Single linear pass — each byte of `data` is visited O(1) times per call
978    /// (task #1334, finding F11 of the eighteenth independent review):
979    /// `rsplit` walks the comma-delimited words from the rightmost token
980    /// (word 0, lowest CPU indices) to the leftmost (highest CPU indices) —
981    /// exactly the word order the old `word_count`-then-`nth_token`
982    /// formulation produced via `left_index = word_count - 1 - w`, without
983    /// `nth_token`'s O(words²) rescan of all of `data` for every token.
984    pub fn parse_each_set_cpu(data: &[u8], mut on_cpu: impl FnMut(u32)) -> bool {
985        let data = trim_end(data);
986        // rsplit yields the RIGHTMOST word first: word 0 (lowest CPU indices)
987        // is the last token in the text — identical order to the old indexed
988        // loop, in one pass with no word_count precomputation.
989        for (w, word_str) in data.rsplit(|&b| b == b',').enumerate() {
990            let val = match parse_hex_u32(word_str) {
991                Some(v) => v,
992                None => return false,
993            };
994            // Iterate each bit in the word (LSB first = lower CPU IDs).
995            for bit in 0..32 {
996                if (val >> bit) & 1 == 1 {
997                    on_cpu((w * 32 + bit) as u32);
998                }
999            }
1000        }
1001        true
1002    }
1003
1004    /// Write `/sys/devices/system/node/nodeN/cpumap\0` into `buf` and return
1005    /// the nul-terminated slice. Avoids heap allocation.
1006    pub fn format_sysfs_path(buf: &mut [u8; 64], node: u32) -> &[u8] {
1007        const PREFIX: &[u8] = b"/sys/devices/system/node/node";
1008        const SUFFIX: &[u8] = b"/cpumap\0";
1009        let mut pos = 0usize;
1010        for &b in PREFIX {
1011            buf[pos] = b;
1012            pos += 1;
1013        }
1014        // task #727 (rust-intel audit §B7): `tmp` sized `[u8; 10]` -- the
1015        // maximum decimal digit count for ANY `u32` (`u32::MAX` =
1016        // 4294967295, 10 digits) -- rather than the previous `[u8; 4]`,
1017        // which panicked (`tmp[digits]` out of bounds) for `node >= 10000`.
1018        // Unreachable today (the only caller, `topology()` below, iterates
1019        // `0u32..64`), but this is a `#[doc(hidden)] pub` function reachable
1020        // by `tests/cpumap_parser.rs` with an arbitrary `node`, and the old
1021        // doc comment ("up to 3 digits for node < 1000") already disagreed
1022        // with the old buffer size (4 bytes = up to 3 digits + none-needed
1023        // slack, not 4 full digits) -- sizing for the real signature (`u32`)
1024        // removes the latent panic instead of just re-stating the caller's
1025        // unstated bound.
1026        let mut tmp = [0u8; 10];
1027        let mut n = node;
1028        let mut digits = 0usize;
1029        if n == 0 {
1030            tmp[0] = b'0';
1031            digits = 1;
1032        } else {
1033            while n > 0 {
1034                tmp[digits] = b'0' + (n % 10) as u8;
1035                n /= 10;
1036                digits += 1;
1037            }
1038            // Written in reverse; fix ordering.
1039            tmp[..digits].reverse();
1040        }
1041        for &d in tmp.iter().take(digits) {
1042            buf[pos] = d;
1043            pos += 1;
1044        }
1045        for &b in SUFFIX {
1046            buf[pos] = b;
1047            pos += 1;
1048        }
1049        &buf[..pos]
1050    }
1051
1052    /// Parse a Linux cpumap bitmask string and test whether `cpu_idx` is set.
1053    ///
1054    /// Format: comma-separated hex 32-bit words, most-significant first,
1055    /// optional trailing newline. Example: `"00000000,00000003\n"` means
1056    /// CPUs 0 and 1 are in this node.
1057    ///
1058    /// Now layered on the single `parse_each_set_cpu` interpreter (task #1310).
1059    /// One behavioral nuance: a malformed token ANYWHERE in the text fails the
1060    /// probe (previously only the target word's token was validated); both are
1061    /// fail-closed `false`, and real sysfs never produces malformed tokens.
1062    pub fn parse_contains_cpu(data: &[u8], cpu_idx: u32) -> bool {
1063        let mut found = false;
1064        let ok = parse_each_set_cpu(data, |b| {
1065            if b == cpu_idx {
1066                found = true;
1067            }
1068        });
1069        ok && found
1070    }
1071
1072    /// Trim trailing `\n`/`\r`/` ` bytes.
1073    pub fn trim_end(data: &[u8]) -> &[u8] {
1074        let mut end = data.len();
1075        while end > 0 && (data[end - 1] == b'\n' || data[end - 1] == b'\r' || data[end - 1] == b' ')
1076        {
1077            end -= 1;
1078        }
1079        &data[..end]
1080    }
1081
1082    /// Parse a hex string (no `0x` prefix) as `u32`. Returns `None` on error,
1083    /// including a token longer than 8 hex digits (would silently overflow
1084    /// `u32`; see task #727 below).
1085    pub fn parse_hex_u32(s: &[u8]) -> Option<u32> {
1086        if s.is_empty() {
1087            return None;
1088        }
1089        // task #727 (rust-intel audit §B26): previously absent, so a token
1090        // longer than 8 hex digits silently WRAPPED (`wrapping_shl` drops
1091        // the most-significant nibbles) instead of failing like every other
1092        // malformed input this parser rejects (empty token, invalid digit).
1093        // Real sysfs cpumap words are fixed 8 hex chars, so this had no live
1094        // impact, but a silently-wrong value for oversized input is
1095        // inconsistent with the rest of this parser's fail-closed behavior.
1096        if s.len() > 8 {
1097            return None;
1098        }
1099        let mut val: u32 = 0;
1100        for &b in s {
1101            let digit = match b {
1102                b'0'..=b'9' => b - b'0',
1103                b'a'..=b'f' => b - b'a' + 10,
1104                b'A'..=b'F' => b - b'A' + 10,
1105                _ => return None,
1106            };
1107            val = val.wrapping_shl(4) | digit as u32;
1108        }
1109        Some(val)
1110    }
1111
1112    /// Fixed-size reverse index mapping CPU IDs to node IDs.
1113    ///
1114    /// This is the replacement for the per-node raw-text cache (task #1310,
1115    /// review findings F5+F10). The old design cached `[[u8; 1024]; 64]` (~64.5
1116    /// KiB) of raw cpumap text and re-parsed up to ~64 KiB per lookup (O(nodes
1117    /// × bytes)). This design parses each node's cpumap exactly once at init and
1118    /// stores a compact 8 KiB array (`[u8; 8192]`) for O(1) lookup.
1119    ///
1120    /// Built once inside the `OnceLock` topology initializer; allocation-free
1121    /// (static storage only). CPU IDs >= `MAX_INDEXED_CPUS` stay unmapped and
1122    /// degrade exactly like the old oversized-file case (unmapped → `None` →
1123    /// `TopologyUnavailable`).
1124    ///
1125    /// First-mapping-wins semantics for overlapping masks: when `index_node`
1126    /// processes multiple nodes that both claim the same CPU, the first node
1127    /// processed wins. The real caller scans nodes in ascending order (0..63),
1128    /// so this reproduces the old ascending-scan's lowest-node-wins behavior.
1129    pub struct ReverseIndex {
1130        map: [u8; MAX_INDEXED_CPUS],
1131    }
1132
1133    impl Default for ReverseIndex {
1134        fn default() -> Self {
1135            Self::new()
1136        }
1137    }
1138
1139    impl ReverseIndex {
1140        /// Create a new empty reverse index (all entries unmapped).
1141        pub const fn new() -> Self {
1142            Self {
1143                map: [CPU_UNMAPPED; MAX_INDEXED_CPUS],
1144            }
1145        }
1146
1147        /// Index a node's cpumap text into this reverse index.
1148        ///
1149        /// Returns `false` WITHOUT modifying anything if:
1150        /// - `node > 63` (defensive; the real caller scans 0..64)
1151        /// - The text is malformed (any token fails hex parsing)
1152        ///
1153        /// Implementation: two-stage dry-run then actual write (init-time only,
1154        /// so the double parse is acceptable). Stage 1 validates the entire
1155        /// text; stage 2 writes the mapping for each CPU where currently unmapped
1156        /// (first-mapping-wins). CPUs >= `MAX_INDEXED_CPUS` are silently skipped
1157        /// (documented degradation).
1158        pub fn index_node(&mut self, node: u32, data: &[u8]) -> bool {
1159            if node > 63 {
1160                return false;
1161            }
1162            // Stage 1: dry-run validation (fail-closed per node).
1163            if !parse_each_set_cpu(data, |_| {}) {
1164                return false;
1165            }
1166            // Stage 2: actually index, first-mapping-wins.
1167            parse_each_set_cpu(data, |cpu| {
1168                if (cpu as usize) < MAX_INDEXED_CPUS {
1169                    let entry = &mut self.map[cpu as usize];
1170                    if *entry == CPU_UNMAPPED {
1171                        *entry = node as u8;
1172                    }
1173                }
1174                // CPUs >= MAX_INDEXED_CPUS: silently skipped, same as
1175                // old buffer-too-small case.
1176            });
1177            true
1178        }
1179
1180        /// Look up the node for a CPU ID.
1181        ///
1182        /// Returns `Some(node_id)` if the CPU is indexed, `None` otherwise.
1183        /// O(1) array probe after init.
1184        pub fn lookup(&self, cpu: u32) -> Option<u32> {
1185            let entry = self.map.get(cpu as usize)?;
1186            if *entry == CPU_UNMAPPED {
1187                None
1188            } else {
1189                Some(*entry as u32)
1190            }
1191        }
1192    }
1193}
1194
1195// ---------------------------------------------------------------------------
1196// Linux-only test-only forwarders (sanctioned pattern per CLAUDE.md)
1197// ---------------------------------------------------------------------------
1198/// Test-oracle-only module: Linux-only test forwarders.
1199///
1200/// `#[doc(hidden)]` and **exempt from this crate's SemVer guarantees**
1201/// (task #1289, following the `serde::__private` convention): everything in
1202/// this module — signatures, names, existence — may change or be removed
1203/// in ANY release, including patch releases, without a deprecation period.
1204/// Do not depend on it from code outside this crate's own `tests/`;
1205/// `cargo-semver-checks` likewise excludes `#[doc(hidden)]` items from its
1206/// public-API model.
1207#[cfg(all(target_os = "linux", not(miri), not(numa_shim_mock)))]
1208#[doc(hidden)]
1209pub mod linux {
1210    use super::NodeResolution;
1211
1212    /// Test-only forwarder: map a CPU index to a `NodeResolution` without
1213    /// calling `sched_getcpu(2)`.
1214    ///
1215    /// This is the same mapping logic used by `current_node_resolution()`,
1216    /// but with a manually-specified CPU index instead of calling
1217    /// `sched_getcpu(2)`. It is gated on `not(numa_shim_mock)` because the
1218    /// real platform implementation is not used when the `numa_shim_mock` cfg is
1219    /// set.
1220    ///
1221    /// This function is safe to call with arbitrarily large CPU indices
1222    /// (e.g., 1_000_000) — `cpu_to_numa_node_checked` returns `None` when the
1223    /// CPU is not found in any cached cpumap (including when the CPU index
1224    /// exceeds the cached topology's word count), so this will return
1225    /// `NodeResolution::TopologyUnavailable` rather than panicking.
1226    pub fn dbg_node_resolution_for_cpu(cpu: u32) -> NodeResolution {
1227        // No unsafe here: `platform` is defined below under the same cfg
1228        // (`target_os = "linux" && not(miri)`), so `cpu_to_numa_node_checked`
1229        // is available; it's `pub(crate)`, so this sibling module (both live
1230        // directly under the crate root) can call it.
1231        match super::platform::cpu_to_numa_node_checked(cpu) {
1232            Some(n) => NodeResolution::Resolved(n),
1233            None => NodeResolution::TopologyUnavailable,
1234        }
1235    }
1236
1237    /// Test-only forwarder: `current_node()`-equivalent `Option<u32>` mapping
1238    /// for a manually-specified CPU index, without calling `sched_getcpu(2)`.
1239    ///
1240    /// Mirrors `current_node()`'s wrapper around `current_node_impl()`: the
1241    /// raw node from `cpu_to_numa_node` — which returns `NO_NODE` when the
1242    /// topology cannot resolve the CPU (task #1308) — maps to `None`; any
1243    /// genuinely resolved node maps to `Some(n)`. Counterfactual oracle for
1244    /// the fail-closed fix: before task #1308, `cpu_to_numa_node` substituted
1245    /// `0` for lookup failure, so an unmapped CPU produced `Some(0)` here —
1246    /// indistinguishable from a genuinely resolved node 0.
1247    ///
1248    /// Safe to call with arbitrarily large CPU indices (e.g., 1_000_000):
1249    /// returns `None` rather than panicking.
1250    pub fn dbg_current_node_for_cpu(cpu: u32) -> Option<u32> {
1251        let raw = super::platform::cpu_to_numa_node(cpu);
1252        if raw == super::NO_NODE {
1253            None
1254        } else {
1255            Some(raw)
1256        }
1257    }
1258}
1259
1260// ---------------------------------------------------------------------------
1261// Bounded-EINTR retry policy for the sysfs topology scan (task #1319,
1262// seventeenth review P3-1 / task #1327): extracted from the Linux-only
1263// `platform` module below into a target-INDEPENDENT module, exactly like
1264// `cpumap` above. The retry decision is a pure predicate over
1265// `std::io::Error` and a streak counter -- `io::Error` and `ErrorKind`
1266// are fully portable std types -- so gating it inside
1267// `#[cfg(target_os = "linux")]` was an accident of code organization,
1268// not a genuine platform requirement (the same reasoning that extracted
1269// `cpumap`), and it meant the retry policy shipped with ZERO test
1270// coverage on ANY host: the Linux `platform` module does not even
1271// compile on this project's Windows dev machine, and no Linux test
1272// reached the private fn either. Moving it here (still `#[doc(hidden)]`,
1273// semver-exempt -- same pattern as `cpumap`/`linux`) lets
1274// `tests/eintr_retry.rs` exercise it on every target.
1275// ---------------------------------------------------------------------------
1276/// Test-oracle-only module: bounded-EINTR retry decision for the sysfs
1277/// topology scan.
1278///
1279/// `#[doc(hidden)]` and **exempt from this crate's SemVer guarantees**
1280/// (task #1289 convention, same as `cpumap` and `linux`): everything in
1281/// this module — signatures, names, existence — may change or be removed
1282/// in ANY release, including patch releases, without a deprecation period.
1283/// Do not depend on it from code outside this crate's own `tests/`.
1284#[doc(hidden)]
1285pub mod eintr {
1286    /// Bound on consecutive EINTR retries for one `open(2)`/`read(2)`
1287    /// progress step in the Linux platform's `read_cpumap_into` (task
1288    /// #1319, sixteenth review P2; relocated here target-independently by
1289    /// task #1327, seventeenth review P3-1). Bounded rather than unbounded
1290    /// so a pathological signal storm (a profiler or interval timer firing
1291    /// faster than the syscall can complete) cannot spin the `OnceLock`
1292    /// topology initializer forever -- every thread hitting
1293    /// `current_node()` blocks on that initializer, so an unbounded retry
1294    /// would trade the permanent-`None` availability bug for a hang. 16 is
1295    /// far above what a healthy-but-signal-busy process produces per
1296    /// progress step (one stray signal clears on the first retry; each
1297    /// retry is one cheap re-issued syscall on a <=4 KiB kernel-backed
1298    /// sysfs file), while capping worst-case init delay at ~16 syscall
1299    /// re-issues per step. The streak resets on any forward progress, so
1300    /// this bounds CONSECUTIVE interruptions, not total retries across the
1301    /// read loop.
1302    pub const EINTR_RETRY_LIMIT: u32 = 16;
1303
1304    /// Pure retry decision for task #1319: given the error of a failed
1305    /// `open`/`read` (captured by the caller before any cleanup FFI call,
1306    /// per task #1306's errno-timing contract) and the number of
1307    /// consecutive EINTR retries already spent without progress, may the
1308    /// caller re-issue the identical syscall?
1309    ///
1310    /// Interruption is detected via `err.kind() == std::io::ErrorKind::Interrupted`
1311    /// (task #1327, seventeenth review P3-1) rather than a raw errno comparison:
1312    /// std's own `decode_error_kind` maps `EINTR` to `Interrupted` on every Unix,
1313    /// portable by construction, with no `libc`-crate dependency (this
1314    /// crate deliberately has none). On the real call path --
1315    /// `std::io::Error::last_os_error()` on Linux -- `EINTR` is the ONLY
1316    /// errno that decodes to `Interrupted`, so the retry set is
1317    /// byte-for-byte identical to the pre-#1327 `raw_os_error() ==
1318    /// Some(4)` check. Every other error kind fails closed exactly as
1319    /// before the fix.
1320    pub fn should_retry_eintr(err: &std::io::Error, consecutive_eintr: u32) -> bool {
1321        err.kind() == std::io::ErrorKind::Interrupted && consecutive_eintr < EINTR_RETRY_LIMIT
1322    }
1323}
1324
1325// ---------------------------------------------------------------------------
1326// Per-platform implementations
1327// ---------------------------------------------------------------------------
1328
1329// ---- Linux (real hardware, not miri) --------------------------------------
1330#[cfg(all(target_os = "linux", not(miri)))]
1331// Under `mock`, the public API dispatches to the recording mock instead of
1332// these platform impls, so every symbol here is (expectedly) unused. `mock`
1333// exists precisely to bypass the real syscalls; the platform code still must
1334// compile. Suppress dead-code only in that combination.
1335#[cfg_attr(numa_shim_mock, allow(dead_code))]
1336mod platform {
1337    #[cfg(all(
1338        feature = "vmem-integration",
1339        any(target_arch = "x86_64", target_arch = "aarch64")
1340    ))]
1341    use super::mbind_preferred_linux;
1342    #[cfg(feature = "vmem-integration")]
1343    use super::{NodeId, ReserveNumaError};
1344    use super::{NodeResolution, NO_NODE};
1345
1346    pub(super) fn current_node_impl() -> u32 {
1347        // task #1331 (eighteenth review F3,
1348        // docs/reviews/2026-08-24-224323-numa-shim-publication-audit-Sol-codex.md):
1349        // complete topology initialization BEFORE taking the `sched_getcpu()`
1350        // snapshot. The old order (snapshot, then `cpu_to_numa_node` ->
1351        // `topology()`'s `OnceLock` init) ran the up-to-64-file sysfs scan
1352        // BETWEEN the snapshot and the lookup on the first-ever call, so a
1353        // scheduler migration during that scan made the returned node reflect
1354        // where the thread WAS before init started rather than where it is on
1355        // return. With init first, only the small, irreducible
1356        // snapshot-to-lookup window any snapshot-style API has remains; warm
1357        // calls are unaffected (same single `OnceLock` check either way).
1358        // `topo` is obtained once and `.lookup` called on it directly --
1359        // `cpu_to_numa_node` would re-enter `topology()` for a redundant
1360        // second `OnceLock` check (harmless once initialized, but pointless).
1361        let topo = topology();
1362        // SAFETY: `sched_getcpu` is a POSIX function that returns the CPU index
1363        // of the calling thread, or -1 on error. No pointer arguments.
1364        let cpu = unsafe { libc_sched_getcpu() };
1365        if cpu < 0 {
1366            return NO_NODE;
1367        }
1368        topo.lookup(cpu as u32).unwrap_or(NO_NODE)
1369    }
1370
1371    pub(super) fn current_node_resolution_impl() -> NodeResolution {
1372        // task #1331 (eighteenth review F3): same reorder as
1373        // `current_node_impl` above -- topology init completes BEFORE the
1374        // `sched_getcpu()` snapshot, so the first call's sysfs scan can no
1375        // longer widen the snapshot-to-lookup migration window.
1376        let topo = topology();
1377        // SAFETY: `sched_getcpu` is a POSIX function that returns the CPU index
1378        // of the calling thread, or -1 on error. No pointer arguments.
1379        let cpu = unsafe { libc_sched_getcpu() };
1380        if cpu < 0 {
1381            return NodeResolution::Unavailable;
1382        }
1383        match topo.lookup(cpu as u32) {
1384            Some(n) => NodeResolution::Resolved(n),
1385            None => NodeResolution::TopologyUnavailable,
1386        }
1387    }
1388
1389    #[cfg(feature = "vmem-integration")]
1390    pub(super) fn reserve_preferred_on_node_impl(
1391        size: usize,
1392        align: usize,
1393        node: NodeId,
1394    ) -> Result<aligned_vmem::Reservation, ReserveNumaError> {
1395        #[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
1396        {
1397            let raw_node = node.get();
1398            if raw_node >= 64 {
1399                // task #722/#1306: single-u64 nodemask limit, now an explicit error
1400                // instead of the old silent no-op.
1401                return Err(ReserveNumaError::InvalidNode);
1402            }
1403            let r = aligned_vmem::try_reserve_aligned(size, align).map_err(|e| {
1404                if e.is_invalid_argument() {
1405                    ReserveNumaError::InvalidArguments
1406                } else {
1407                    ReserveNumaError::Os(std::io::Error::from(e))
1408                }
1409            })?;
1410            // Apply the policy to the COMPLETE OS reservation span (task #1306):
1411            // reservation_ptr()/reservation_len(), not as_ptr()/len().
1412            // SAFETY: `r` is a fresh live OS reservation we own; mbind only sets
1413            // kernel page-policy metadata, never payload bytes.
1414            let rc = unsafe {
1415                mbind_preferred_linux(r.reservation_ptr(), r.reservation_len(), raw_node)
1416            };
1417            if rc == -1 {
1418                // Capture errno IMMEDIATELY — before the cleanup drop below runs
1419                // munmap and overwrites it (task #1306, the errno-timing contract).
1420                let err = std::io::Error::last_os_error();
1421                drop(r);
1422                return Err(ReserveNumaError::Os(err));
1423            }
1424            Ok(r)
1425        }
1426        #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
1427        {
1428            let _ = (size, align, node);
1429            Err(ReserveNumaError::UnsupportedArchitecture)
1430        }
1431    }
1432
1433    /// Scratch buffer size for reading a single node's cpumap file during init.
1434    ///
1435    /// This is an init-time SCRATCH buffer for reading ONE node's cpumap text
1436    /// at a time inside the `OnceLock` initializer. The raw text is no longer
1437    /// cached per-node (task #1310 replaced the per-node raw-text cache with
1438    /// the reverse index).
1439    ///
1440    /// A complete cpumap for the full indexable global CPU-ID space is
1441    /// `ceil(MAX_INDEXED_CPUS / 32) = 256` words at 9 bytes per word (8 hex
1442    /// chars plus one separator; the final separator is the trailing
1443    /// newline) = 2304 bytes. 4096 (one page, task #720's original size)
1444    /// holds the complete text for any system within `MAX_INDEXED_CPUS`
1445    /// with headroom.
1446    ///
1447    /// A file WIDER than this buffer implies > ~455 words > 14560 global CPU
1448    /// IDs — beyond every supported kernel's `NR_CPUS`. Such a file is treated
1449    /// as a read failure (node not indexed), preserving task #720 §C4's
1450    /// fail-closed no-silent-truncation rule.
1451    ///
1452    /// NOTE: This bound is on GLOBAL CPU-ID space (the file is a global
1453    /// cpumask), NOT per-node CPU count — explicitly correcting the old
1454    /// comment's wrong "3640 CPUs on a SINGLE node" justification (review
1455    /// finding F5, task #1310).
1456    const CPUMAP_READ_BUF_LEN: usize = 4096;
1457
1458    /// Boot-static cpu→node topology, parsed via sysfs ONCE and cached for
1459    /// the life of the process (task #723, rust-intel audit §E5: each
1460    /// `current_node()` call previously re-derived the mapping via up to 64
1461    /// open/read/close syscall triples -- expensive on an ALLOCATION path,
1462    /// since `current_node()` is re-entered from sefer-alloc's `numa-aware`
1463    /// feature). CPU-hotplug changes after the first call are NOT
1464    /// reflected -- acceptable for an `MPOL_PREFERRED` hint, itself already
1465    /// a soft, best-effort preference the kernel can override under memory
1466    /// pressure.
1467    ///
1468    /// task #778 (round-closing review, F12): the caveat above covers
1469    /// hotplug AFTER the first call; it does not cover the narrower window
1470    /// this cache also opens: the initializer below reads 64 sysfs files
1471    /// SEQUENTIALLY, so a hotplug event landing mid-scan can freeze a TORN
1472    /// snapshot for the rest of the process's lifetime (a CPU that existed
1473    /// only after the scan passed its node's file now permanently resolves
1474    /// as undetermined — `None` from `current_node()`, task #1308's fail-closed
1475    /// mapping; previously it silently fell back to the `Some(0)` single-node
1476    /// answer). Still acceptable for the same reason as the broader hotplug
1477    /// caveat above (a soft `MPOL_PREFERRED` hint), but worth naming as its own
1478    /// distinct property rather than folding it into the "after the first call"
1479    /// wording, which reads as covering only post-scan hotplug.
1480    ///
1481    /// task #777 (rust-intel audit round-closing review, finding F1, HIGH):
1482    /// task #723's original design cached `Vec<Vec<u8>>` -- ~65 heap
1483    /// allocations inside the `OnceLock::get_or_init` initializer.
1484    /// `current_node()` is reachable from `AllocCore::alloc` (via
1485    /// `current_node_cached` on a cache miss, inside `reserve_small_segment`
1486    /// / `alloc_large_slow`), and the parent `sefer-alloc` crate's own `M5`
1487    /// invariant declares that entire path allocation-free/reentrancy-free
1488    /// specifically so it never re-enters the global allocator. Under a real
1489    /// `#[global_allocator] = SeferAlloc` + `numa-aware` deployment on
1490    /// Linux, the FIRST allocation needing a NUMA lookup would have
1491    /// triggered heap allocation, which re-enters `GlobalAlloc::alloc`,
1492    /// which re-enters `current_node()`, which re-enters
1493    /// `OnceLock::get_or_init` on the SAME cell mid-initialization --
1494    /// documented by `std::sync::OnceLock` as "an error to reentrantly
1495    /// initialize the cell from `f`... current implementation deadlocks".
1496    /// Fixed by making the cache allocation-free: the reverse index
1497    /// (`crate::cpumap::ReverseIndex`) uses only fixed-size static storage
1498    /// (`[u8; MAX_INDEXED_CPUS]`, 8 KiB), and the scratch buffer below lives
1499    /// on the stack inside the initializer, so populating the topology touches
1500    /// no `Vec`/`Box`/heap at all, and the reentrancy hazard is structurally
1501    /// removed rather than guarded against.
1502    ///
1503    /// task #1340 (nineteenth review P3-3): the task #777 paragraph above
1504    /// analysed and eliminated HEAP use; the initializer's STACK use — the
1505    /// axis that rewrite did not consider — is documented here so a
1506    /// downstream consumer running `current_node()` on a deliberately
1507    /// small thread has the fact. The closure's named locals are the 8 KiB
1508    /// `ReverseIndex` (`[u8; MAX_INDEXED_CPUS]` = 8192 bytes), the 4 KiB
1509    /// cpumap scratch buffer (`[u8; CPUMAP_READ_BUF_LEN]` = 4096 bytes),
1510    /// and the 64-byte sysfs path buffer (plus the small frames of
1511    /// `format_sysfs_path`/`read_cpumap_into`), and returning the index by
1512    /// move into the `OnceLock` cell can add a second transient 8 KiB copy
1513    /// when that move is not elided (Rust does not guarantee NRVO). Peak
1514    /// frame: 12,352 bytes of named locals (~12 KiB) best case, ~20 KiB
1515    /// worst — negligible against the default 2 MiB Rust thread stack, but
1516    /// a stack overflow inside a `OnceLock` initializer ABORTS the process
1517    /// (not a recoverable `Result`) — the same failure class the
1518    /// allocation-free rewrite above eliminated on the heap axis, stated
1519    /// here for the stack axis. Budget for it if calling `current_node()`
1520    /// from a thread built with a small custom
1521    /// `std::thread::Builder::stack_size(...)`.
1522    static TOPOLOGY: std::sync::OnceLock<crate::cpumap::ReverseIndex> = std::sync::OnceLock::new();
1523
1524    fn topology() -> &'static crate::cpumap::ReverseIndex {
1525        TOPOLOGY.get_or_init(|| {
1526            let mut index = crate::cpumap::ReverseIndex::new();
1527            let mut buf = [0u8; CPUMAP_READ_BUF_LEN];
1528            for node in 0u32..64 {
1529                let mut path = [0u8; 64];
1530                let path_str = crate::cpumap::format_sysfs_path(&mut path, node);
1531                if let Some(n) = read_cpumap_into(path_str, &mut buf) {
1532                    index.index_node(node, &buf[..n]);
1533                }
1534            }
1535            index
1536        })
1537    }
1538
1539    /// Map a CPU index to its NUMA node using the cached boot-static
1540    /// topology (`topology()` above). Pure in-memory reverse-index lookup after
1541    /// the topology's one-time syscall-driven populate.
1542    ///
1543    /// Returns `None` when sysfs NUMA topology files are absent, the CPU
1544    /// is not found in any cached cpumap (including when the CPU's real node
1545    /// is >= 64 and thus not in the 0..63 scan range), the topology
1546    /// cache could not be populated, or the CPU ID is at or beyond the
1547    /// reverse index's `MAX_INDEXED_CPUS` capacity (task #1310: same
1548    /// degradation as the old oversized-file case).
1549    pub(crate) fn cpu_to_numa_node_checked(cpu_idx: u32) -> Option<u32> {
1550        topology().lookup(cpu_idx)
1551    }
1552
1553    /// Map a CPU index to its NUMA node using the cached boot-static
1554    /// topology (`topology()` above). Pure in-memory reverse-index lookup after
1555    /// the topology's one-time syscall-driven populate.
1556    ///
1557    /// Returns [`NO_NODE`] when sysfs NUMA topology files are absent, the CPU
1558    /// is not found in any cached cpumap (including when the CPU's real node
1559    /// is >= 64 and thus not in the 0..63 scan range), the topology
1560    /// cache could not be populated, or the CPU ID is at or beyond the
1561    /// reverse index's `MAX_INDEXED_CPUS` capacity (task #1310: same
1562    /// degradation as the old oversized-file case). This is exactly the set of
1563    /// conditions under which `cpu_to_numa_node_checked` returns `None`, and
1564    /// `current_node()` maps this sentinel to `None` (task #1308 — it previously
1565    /// substituted `0`, making an undeterminable node indistinguishable from a
1566    /// genuinely resolved node 0).
1567    pub(crate) fn cpu_to_numa_node(cpu_idx: u32) -> u32 {
1568        cpu_to_numa_node_checked(cpu_idx).unwrap_or(NO_NODE)
1569    }
1570
1571    /// `O_CLOEXEC` — open with close-on-exec, so a concurrent
1572    /// `fork()`+`exec()` during the topology initializer's one-time
1573    /// 64-node sysfs scan cannot leak the cpumap fd into the child
1574    /// (task #1327, seventeenth review P3-2). Defined locally because
1575    /// this crate deliberately avoids the `libc` crate (same
1576    /// local-constant precedent as `SYS_MBIND`/`MPOL_PREFERRED`); value
1577    /// from Linux `asm-generic/fcntl.h` (`#define O_CLOEXEC 02000000`
1578    /// octal), identical on x86_64 (whose arch-specific `fcntl.h` does
1579    /// not override it) and aarch64 (which uses the asm-generic header
1580    /// wholesale), and on every other real rustc Linux target arch
1581    /// EXCEPT sparc/sparc64 (task #1339, nineteenth review P3-1):
1582    /// sparc's UAPI defines `O_CLOEXEC` as `0x400000`, so `0o2000000`
1583    /// (`0x80000`) is not close-on-exec there at all — the hardening
1584    /// would be silently absent with nothing signaling it. (alpha and
1585    /// parisc, where `0o2000000` instead means `O_DIRECT`, diverge too,
1586    /// but neither is a real rustc target — verified against
1587    /// `rustc --print target-list`; hexagon's only Linux target,
1588    /// hexagon-unknown-linux-musl, uses the asm-generic value and is
1589    /// unaffected.) On sparc/sparc64 the sibling constant below applies
1590    /// the correct `0x400000` value instead (task #1345, twenty-second
1591    /// review F1): task #1339 (nineteenth review P3-1) had compiled it
1592    /// to `0` — disabling the hardening rather than risking an unrelated
1593    /// bit — because at the time the correct sparc value was unverified;
1594    /// once the correct value was documented AND independently
1595    /// cross-checked, disabling the hardening was no longer justified, so
1596    /// task #1345 applies it. This is the verification for the flag:
1597    /// actual close-on-exec kernel behavior is not testable without a
1598    /// real fork+exec harness, which is out of scope.
1599    #[cfg(not(any(target_arch = "sparc", target_arch = "sparc64")))]
1600    const O_CLOEXEC: core::ffi::c_int = 0o2000000;
1601    /// task #1339 (nineteenth review P3-1) originally compiled this to
1602    /// `0` (disabling close-on-exec) because sparc/sparc64's UAPI
1603    /// `O_CLOEXEC` value, `0x400000`, differs from the asm-generic
1604    /// `0o2000000` the sibling constant above uses, and passing that bit
1605    /// would not set close-on-exec there — better an honestly-inert flag
1606    /// than a wrong one. task #1345 (twenty-second review F1) applies the
1607    /// correct `0x400000` instead: the value was already documented here
1608    /// and has now been independently cross-checked, so the hardening no
1609    /// longer needs to stay disabled. See the sibling constant's doc
1610    /// comment for the full arch-by-arch rationale.
1611    #[cfg(any(target_arch = "sparc", target_arch = "sparc64"))]
1612    const O_CLOEXEC: core::ffi::c_int = 0x400000;
1613
1614    /// Open the cpumap file at `path` and read its complete contents into
1615    /// the caller-supplied fixed buffer `out`, returning the byte count.
1616    ///
1617    /// The cpumap file format: `"00000000,00000001\n"` — comma-separated
1618    /// hex 32-bit words, most-significant word first; each word covers 32 CPUs.
1619    /// Parsing itself is delegated to `crate::cpumap` (task #721) -- a
1620    /// target-independent module so the parser can be exercised by
1621    /// `tests/cpumap_parser.rs` on every target, not just real Linux.
1622    ///
1623    /// Returns `None` on open/read failure, or if the file is as wide as
1624    /// or wider than `out` (a file exactly `out.len()` bytes long is
1625    /// rejected too: the `total >= out.len()` guard fires before the
1626    /// EOF-proving zero-byte read can ever be issued, so it is
1627    /// indistinguishable from a wider file -- doc precision noted by
1628    /// item 116/task #1353's twenty-fourth review, P3-1) (task #720,
1629    /// rust-intel audit §C4: a truncated read must never
1630    /// be silently treated as complete -- the most-significant-word-first
1631    /// `word_count`/`left_index` arithmetic would misalign on a prefix and
1632    /// return a WRONG node rather than failing loudly). The caller supplies
1633    /// an init-time stack scratch buffer (`CPUMAP_READ_BUF_LEN`), so this
1634    /// function remains allocation-free (task #777: the original heap `Vec`
1635    /// destination created a reentrancy hazard; fixed-size storage removes it).
1636    ///
1637    /// task #1319 (sixteenth review P2): a transient `EINTR` from
1638    /// `open`/`read` is retried a bounded number of times
1639    /// (`crate::eintr::EINTR_RETRY_LIMIT`) before counting as a failure — one signal
1640    /// during the process's first `current_node()` call must not
1641    /// permanently disable NUMA detection (the caller caches the result
1642    /// in a process-lifetime `OnceLock`). Every other errno, and
1643    /// retry-limit exhaustion, fail closed exactly as the pre-#1319 code
1644    /// did. The retry decision itself lives in target-independent
1645    /// `crate::eintr` (task #1327, seventeenth review P3-1), exercised by
1646    /// `tests/eintr_retry.rs` on every host.
1647    fn read_cpumap_into(path: &[u8], out: &mut [u8]) -> Option<usize> {
1648        let mut open_eintr_streak = 0u32;
1649        let fd = loop {
1650            // Flags: `O_RDONLY` (0 on Linux) | `O_CLOEXEC` — read-only with
1651            // close-on-exec (task #1327, seventeenth review P3-2); O_RDONLY
1652            // being 0 means the value below is exactly that combination
1653            // (on sparc/sparc64 `O_CLOEXEC` is `0x400000`, sparc's real
1654            // UAPI value — task #1345, twenty-second review F1, applying
1655            // it after task #1339, nineteenth review P3-1 had left it
1656            // disabled pending cross-check).
1657            // SAFETY: `path` is a valid nul-terminated C string constructed
1658            // by the caller. `open` is a POSIX syscall; we check for a
1659            // negative return on error.
1660            let fd = unsafe { libc_open(path.as_ptr() as *const core::ffi::c_char, O_CLOEXEC) };
1661            if fd >= 0 {
1662                break fd;
1663            }
1664            // Capture errno IMMEDIATELY — before any subsequent FFI call
1665            // can overwrite it (task #1306, the errno-timing contract).
1666            // Allocation-free: see the read loop below.
1667            let err = std::io::Error::last_os_error();
1668            if crate::eintr::should_retry_eintr(&err, open_eintr_streak) {
1669                // Re-issue the identical open: EINTR means no fd was
1670                // created, so there is nothing to close before retrying.
1671                open_eintr_streak += 1;
1672                continue;
1673            }
1674            return None;
1675        };
1676        let mut total = 0usize;
1677        let mut read_eintr_streak = 0u32;
1678        loop {
1679            if total >= out.len() {
1680                // SAFETY: `fd` was opened by us and must be closed exactly once.
1681                unsafe { libc_close(fd) };
1682                return None;
1683            }
1684            // SAFETY: `out[total..]` is a valid writable sub-slice of `out`
1685            // (length `out.len() - total > 0`, checked above);
1686            // `fd` was returned by the successful `open` call above and not
1687            // yet closed.
1688            let n = unsafe {
1689                libc_read(
1690                    fd,
1691                    out[total..].as_mut_ptr() as *mut core::ffi::c_void,
1692                    out.len() - total,
1693                )
1694            };
1695            if n < 0 {
1696                // Capture errno IMMEDIATELY — before the `libc_close`
1697                // cleanup below can overwrite it (task #1306, the
1698                // errno-timing contract). `last_os_error()`/`raw_os_error()`
1699                // are allocation-free (the errno code is stored inline, no
1700                // heap), preserving task #777's allocation-free requirement
1701                // for this OnceLock-initializer path.
1702                let err = std::io::Error::last_os_error();
1703                if crate::eintr::should_retry_eintr(&err, read_eintr_streak) {
1704                    // Re-issue the SAME read — same fd, same buffer offset,
1705                    // same remaining length: POSIX guarantees a read that
1706                    // fails with EINTR transferred zero bytes, so `total`
1707                    // already marks the correct resume point.
1708                    read_eintr_streak += 1;
1709                    continue;
1710                }
1711                // SAFETY: same as above.
1712                unsafe { libc_close(fd) };
1713                return None;
1714            }
1715            if n == 0 {
1716                break; // EOF: `out[..total]` holds the complete file.
1717            }
1718            total += n as usize;
1719            // Forward progress: a subsequent EINTR starts a fresh streak, so
1720            // `EINTR_RETRY_LIMIT` bounds consecutive interruptions, never a
1721            // progressing read loop.
1722            read_eintr_streak = 0;
1723        }
1724        // SAFETY: `fd` was opened by us and must be closed exactly once.
1725        unsafe { libc_close(fd) };
1726        if total == 0 {
1727            return None;
1728        }
1729        Some(total)
1730    }
1731
1732    // -- Raw Linux FFI (no libc crate dependency) ----------------------------
1733
1734    extern "C" {
1735        fn sched_getcpu() -> core::ffi::c_int;
1736        fn open(path: *const core::ffi::c_char, flags: core::ffi::c_int, ...) -> core::ffi::c_int;
1737        fn read(
1738            fd: core::ffi::c_int,
1739            buf: *mut core::ffi::c_void,
1740            count: usize,
1741        ) -> core::ffi::c_long;
1742        fn close(fd: core::ffi::c_int) -> core::ffi::c_int;
1743    }
1744
1745    // Thin private wrappers so every call site has its own // SAFETY: comment.
1746    unsafe fn libc_sched_getcpu() -> core::ffi::c_int {
1747        // SAFETY: no pointer args; returns current CPU index or -1.
1748        sched_getcpu()
1749    }
1750    unsafe fn libc_open(
1751        path: *const core::ffi::c_char,
1752        flags: core::ffi::c_int,
1753    ) -> core::ffi::c_int {
1754        // SAFETY: caller must supply a valid nul-terminated path.
1755        open(path, flags)
1756    }
1757    unsafe fn libc_read(
1758        fd: core::ffi::c_int,
1759        buf: *mut core::ffi::c_void,
1760        count: usize,
1761    ) -> core::ffi::c_long {
1762        // SAFETY: caller must supply a valid fd and a writable buffer of `count` bytes.
1763        read(fd, buf, count)
1764    }
1765    unsafe fn libc_close(fd: core::ffi::c_int) {
1766        // SAFETY: caller must supply a valid, open fd that is closed exactly once.
1767        let _ = close(fd);
1768    }
1769}
1770
1771// ---------------------------------------------------------------------------
1772// Linux mbind: factored out of `platform` so reserve_preferred_on_node_impl
1773// (under vmem-integration) can call it.
1774// ---------------------------------------------------------------------------
1775
1776/// Install an `MPOL_PREFERRED` policy over `[base, base+len)` favoring NUMA
1777/// node `node` via `mbind(2)`, returning the syscall result.
1778///
1779/// Uses `syscall(SYS_MBIND, …)` — avoids a hard dependency on `libnuma`.
1780/// The caller is responsible for checking the return value and capturing
1781/// errno on -1 (task #1306).
1782#[cfg(all(
1783    target_os = "linux",
1784    not(miri),
1785    feature = "vmem-integration",
1786    any(target_arch = "x86_64", target_arch = "aarch64")
1787))]
1788// Reached only from the platform module, which is itself unused under `mock`.
1789#[cfg_attr(numa_shim_mock, allow(dead_code))]
1790unsafe fn mbind_preferred_linux(base: *mut u8, len: usize, node: u32) -> i64 {
1791    // 64-bit nodemask with bit `node` set.
1792    let nodemask: u64 = 1u64 << node;
1793    // task #697 (rust-intel audit §F1): `maxnode` is NOT simply "number of
1794    // bits in the mask" -- the kernel's `get_nodes()` (mm/mempolicy.c)
1795    // decrements `maxnode` internally before computing which bits are
1796    // addressable, so `maxnode = 64` only covers bits 0..62, silently
1797    // dropping bit 63. `libnuma` compensates for this exact
1798    // kernel quirk by always passing bitmask-size + 1; mirrored here.
1799    // task #1306: callers now validate node < 64, so this no longer
1800    // silently drops bit 63 — the caller returns an explicit InvalidNode
1801    // error instead.
1802    let maxnode: u64 = 65;
1803    // SAFETY: `base` is the start of a live OS reservation we own (caller's contract).
1804    // `mbind` only sets kernel page-policy metadata; it never accesses payload
1805    // bytes. Return value IS checked by the caller; errno is captured immediately on -1.
1806    libc_mbind(
1807        base as *mut core::ffi::c_void,
1808        len as u64,
1809        MPOL_PREFERRED,
1810        &nodemask as *const u64,
1811        maxnode,
1812        0,
1813    )
1814}
1815
1816/// `MPOL_PREFERRED`: soft preferred-node policy; kernel falls back on pressure.
1817#[cfg(all(target_os = "linux", not(miri), feature = "vmem-integration"))]
1818#[cfg_attr(numa_shim_mock, allow(dead_code))]
1819const MPOL_PREFERRED: i32 = 1;
1820
1821/// Syscall number for `mbind(2)` on x86_64.
1822#[cfg(all(
1823    target_os = "linux",
1824    not(miri),
1825    feature = "vmem-integration",
1826    target_arch = "x86_64"
1827))]
1828#[cfg_attr(numa_shim_mock, allow(dead_code))]
1829const SYS_MBIND: i64 = 237;
1830
1831/// Syscall number for `mbind(2)` on aarch64.
1832#[cfg(all(
1833    target_os = "linux",
1834    not(miri),
1835    feature = "vmem-integration",
1836    target_arch = "aarch64"
1837))]
1838#[cfg_attr(numa_shim_mock, allow(dead_code))]
1839const SYS_MBIND: i64 = 235;
1840
1841// `syscall(2)` from glibc/musl — always present, does not require libnuma.
1842#[cfg(all(
1843    target_os = "linux",
1844    not(miri),
1845    feature = "vmem-integration",
1846    any(target_arch = "x86_64", target_arch = "aarch64")
1847))]
1848extern "C" {
1849    fn syscall(number: i64, ...) -> i64;
1850}
1851
1852#[cfg(all(
1853    target_os = "linux",
1854    not(miri),
1855    feature = "vmem-integration",
1856    any(target_arch = "x86_64", target_arch = "aarch64")
1857))]
1858#[cfg_attr(numa_shim_mock, allow(dead_code))]
1859unsafe fn libc_mbind(
1860    addr: *mut core::ffi::c_void,
1861    len: u64,
1862    mode: i32,
1863    nodemask: *const u64,
1864    maxnode: u64,
1865    flags: u32,
1866) -> i64 {
1867    // SAFETY: SYS_MBIND is the correct syscall number for this architecture.
1868    // `addr` is a live mapping; `nodemask` points to a valid stack-allocated u64.
1869    // Return value IS checked by the caller; errno is captured immediately on -1.
1870    syscall(
1871        SYS_MBIND,
1872        addr,
1873        len as usize,
1874        mode as i64,
1875        nodemask,
1876        maxnode as usize,
1877        flags as i64,
1878    )
1879}
1880
1881// ---------------------------------------------------------------------------
1882// Windows platform module
1883// ---------------------------------------------------------------------------
1884#[cfg(all(windows, not(miri)))]
1885// Under `mock`, the public API dispatches to the recording mock instead of
1886// these platform impls, so every symbol here is (expectedly) unused. `mock`
1887// exists precisely to bypass the real syscalls; the platform code still must
1888// compile. Suppress dead-code only in that combination.
1889#[cfg_attr(numa_shim_mock, allow(dead_code))]
1890mod platform {
1891    #[cfg(feature = "vmem-integration")]
1892    use super::{NodeId, ReserveNumaError};
1893    use super::{NodeResolution, NO_NODE};
1894
1895    pub(super) fn current_node_impl() -> u32 {
1896        let mut proc_num = ProcessorNumber {
1897            group: 0,
1898            number: 0,
1899            reserved: 0,
1900        };
1901        // SAFETY: `proc_num` is a valid zeroed `PROCESSOR_NUMBER`; this API
1902        // fills it in and never fails (documented to always succeed).
1903        unsafe { GetCurrentProcessorNumberEx(&mut proc_num) };
1904
1905        let mut node: u16 = 0;
1906        // task #722 (rust-intel audit §F1): corrected -- the previous
1907        // comment here said this API "returns 0 on single-node or error",
1908        // conflating the BOOL return (`ok`) with the OUT-parameter
1909        // (`node`). The actual contract: `ok == 0` (Win32 `FALSE`) means the
1910        // call FAILED outright; `node == 0` on a SUCCESSFUL call is the
1911        // genuine single-node-system answer, not an error signal. Separately
1912        // (and NOT previously handled at all): Microsoft's own docs for
1913        // `GetNumaProcessorNodeEx` state the OUT node number is set to
1914        // `MAXUSHORT` (`u16::MAX`) when the given processor does not exist,
1915        // while the call STILL reports success -- that sentinel is checked
1916        // below, after the `ok` check.
1917        // SAFETY: `proc_num` was filled by `GetCurrentProcessorNumberEx`
1918        // above and is a valid `PROCESSOR_NUMBER`; `node` is a valid `u16`
1919        // out-pointer.
1920        let ok = unsafe { GetNumaProcessorNodeEx(&proc_num, &mut node) };
1921        if ok == 0 || node == u16::MAX {
1922            return NO_NODE;
1923        }
1924        node as u32
1925    }
1926
1927    pub(super) fn current_node_resolution_impl() -> NodeResolution {
1928        let mut proc_num = ProcessorNumber {
1929            group: 0,
1930            number: 0,
1931            reserved: 0,
1932        };
1933        // SAFETY: `proc_num` is a valid zeroed `PROCESSOR_NUMBER`; this API
1934        // fills it in and never fails (documented to always succeed).
1935        unsafe { GetCurrentProcessorNumberEx(&mut proc_num) };
1936
1937        let mut node: u16 = 0;
1938        // SAFETY: `proc_num` was filled by `GetCurrentProcessorNumberEx`
1939        // above and is a valid `PROCESSOR_NUMBER`; `node` is a valid `u16`
1940        // out-pointer.
1941        let ok = unsafe { GetNumaProcessorNodeEx(&proc_num, &mut node) };
1942        if ok == 0 || node == u16::MAX {
1943            return NodeResolution::Unavailable;
1944        }
1945        NodeResolution::Resolved(node as u32)
1946    }
1947
1948    #[cfg(feature = "vmem-integration")]
1949    pub(super) fn reserve_preferred_on_node_impl(
1950        size: usize,
1951        align: usize,
1952        node: NodeId,
1953    ) -> Result<aligned_vmem::Reservation, ReserveNumaError> {
1954        reserve_aligned_numa(size, align, node.get())
1955    }
1956
1957    /// Reserve `size` bytes aligned to `align` with a NUMA preference for `node`
1958    /// via `VirtualAllocExNuma` directly. This is the **only** way to attach a
1959    /// NUMA preference to memory on Windows — there is no post-reservation
1960    /// equivalent to Linux `mbind(2)`.
1961    ///
1962    /// Strategy (mirrors `aligned-vmem`'s own Windows reservation,
1963    /// `win_reserve_commit` in `crates/aligned-vmem/src/lib.rs`): over-reserve
1964    /// `size + align` bytes as ADDRESS SPACE ONLY (`MEM_RESERVE`, no
1965    /// `MEM_COMMIT`), find the aligned chunk inside, then commit only the
1966    /// caller-requested `size` bytes at that aligned sub-range (`MEM_COMMIT`,
1967    /// still via `VirtualAllocExNuma` for API-site uniformity, though the
1968    /// NUMA preference is already fixed by the reserve call above — see the
1969    /// task #778/F2 note below). The WHOLE `over`-byte reservation is then
1970    /// adopted into an `aligned_vmem::Reservation` via
1971    /// [`aligned_vmem::Reservation::from_raw_parts`]; its `Drop` / release
1972    /// path will `VirtualFree(MEM_RELEASE)` the entire span exactly once.
1973    ///
1974    /// task #724 (rust-intel audit): the previous version committed the
1975    /// FULL `over = size + align` bytes in one `MEM_RESERVE | MEM_COMMIT`
1976    /// call -- up to double the commit-charge of the byte range the caller
1977    /// actually asked for and can use (e.g. `align == size` commits `2 *
1978    /// size`), silently contradicting this function's own doc claim that it
1979    /// "mirrors aligned-vmem's own Windows reservation" (aligned-vmem's
1980    /// `win_reserve_commit` has always reserved `over` but committed only
1981    /// `commit_len <= size`). Fixed to the same two-call reserve-then-commit
1982    /// shape.
1983    ///
1984    /// task #778 (round-closing review, F2, MEDIUM): the mechanism note
1985    /// above and this function's two `// SAFETY:` comments originally stated
1986    /// the `node` argument "has no effect" on the `MEM_RESERVE` call and
1987    /// "takes effect" on the `MEM_COMMIT` call — the EXACT INVERSE of
1988    /// Microsoft's documented `VirtualAllocExNuma` contract. Per the
1989    /// Win32 API reference, `nndPreferred` is "used only when allocating a
1990    /// NEW VA region (either committed or reserved)... ignored when the API
1991    /// is used to commit pages in a region that already exists" — so `node`
1992    /// takes effect on the `MEM_RESERVE` call (a new VA region) and is
1993    /// IGNORED on the `MEM_COMMIT` call (into the region the reserve call
1994    /// already created). Separately, no `VirtualAllocExNuma` call "actually
1995    /// allocates physical pages" at all — per the same reference, physical
1996    /// pages are allocated ON DEMAND at first touch, regardless of which
1997    /// call reserved/committed the range. The net shipped behavior was
1998    /// still correct (the preference IS recorded, by the reserve call, and
1999    /// the commit charge IS halved) — but purely because `node` happened to
2000    /// be passed on the reserve call too, which the ORIGINAL comments framed
2001    /// as a harmless no-op kept only "for API uniformity." A reader who
2002    /// trusted that framing would have every reason to drop `node` from the
2003    /// (documented-as-inert) reserve call and keep it only on the
2004    /// (documented-as-load-bearing) commit call — silently disabling Windows
2005    /// NUMA binding entirely, with no error from either call. Comments
2006    /// corrected to state the true mechanism.
2007    ///
2008    /// Returns `Err(ReserveNumaError::InvalidArguments)` on contract violation
2009    /// (`align` not a power of two `>= PAGE`, `size` zero or not a multiple of
2010    /// `PAGE`, or `size + align` overflow). Returns `Err(ReserveNumaError::Os(..))`
2011    /// with the GetLastError-captured io::Error when the OS refuses the reservation
2012    /// or the commit (captured immediately at the failing call, before cleanup).
2013    /// Returns `Ok` on success.
2014    #[cfg(feature = "vmem-integration")]
2015    fn reserve_aligned_numa(
2016        size: usize,
2017        align: usize,
2018        node: u32,
2019    ) -> Result<aligned_vmem::Reservation, ReserveNumaError> {
2020        use aligned_vmem::PAGE;
2021        if size == 0 || !align.is_power_of_two() || align < PAGE || !size.is_multiple_of(PAGE) {
2022            return Err(ReserveNumaError::InvalidArguments);
2023        }
2024        let over = size
2025            .checked_add(align)
2026            .ok_or(ReserveNumaError::InvalidArguments)?;
2027
2028        // SAFETY: `VirtualAllocExNuma(GetCurrentProcess(), NULL, over,
2029        // MEM_RESERVE, PAGE_READWRITE, node)` reserves (but does not commit)
2030        // `over` bytes of address space, returning the base or NULL on
2031        // refusal. task #778 (F2): `node` IS load-bearing on this call --
2032        // per Microsoft's documented `nndPreferred` contract, the NUMA
2033        // preference is recorded when allocating a NEW VA region (reserved
2034        // or committed), which this call is; it is the ONLY call in this
2035        // function where `node` has any effect (see the corrected mechanism
2036        // note on this function's own rustdoc above).
2037        let raw = unsafe {
2038            VirtualAllocExNuma(
2039                GetCurrentProcess(),
2040                core::ptr::null_mut(),
2041                over,
2042                MEM_RESERVE,
2043                PAGE_READWRITE,
2044                node,
2045            )
2046        };
2047        if raw.is_null() {
2048            // Capture GetLastError IMMEDIATELY — the reservation was refused,
2049            // so there is nothing to release; no other call has had a chance
2050            // to overwrite it yet (task #1306, the errno-timing contract).
2051            let err = std::io::Error::last_os_error();
2052            return Err(ReserveNumaError::Os(err));
2053        }
2054        // task #1313 (fifteenth review F9, the provenance half): `.addr()`
2055        // reads the address without exposing provenance (strict-provenance-
2056        // legal); the paired `.with_addr()` below reconstructs `base`
2057        // carrying `raw`'s OWN provenance (valid for the whole `over`-byte
2058        // reservation) at the computed aligned address, instead of the
2059        // previous `base_u as *mut u8` cast, which manufactured a pointer
2060        // with no established provenance — mirroring aligned-vmem's own
2061        // task #717 fix (`crates/aligned-vmem/src/os/windows.rs`). Pure
2062        // provenance mechanics: the computed address is byte-identical to
2063        // the previous `raw as usize` → `base_u as *mut u8` round-trip.
2064        let raw_addr = raw.addr();
2065        // Checked alignment arithmetic: `raw_addr` is a Win32-returned base
2066        // (page-aligned, so overflow needs an allocation near the top of the
2067        // address space), but do not rely on that silently wrapping if it
2068        // ever happens — release the reservation and return InvalidArguments
2069        // (syscall SUCCEEDED, so there is no OS error to capture; this is an
2070        // argument-domain overflow).
2071        let Some(rounded) = raw_addr.checked_add(align - 1) else {
2072            // SAFETY: `raw` came from the MEM_RESERVE above and was never
2073            // handed out; releasing before returning InvalidArguments cannot double-free.
2074            unsafe { VirtualFree(raw, 0, MEM_RELEASE) };
2075            return Err(ReserveNumaError::InvalidArguments);
2076        };
2077        let base_addr = rounded & !(align - 1);
2078        // `.with_addr` carries `raw`'s provenance (the live `over`-byte
2079        // reservation) to the aligned address; `.cast::<u8>()` is a plain
2080        // pointer-type cast (provenance-preserving), matching the
2081        // `*mut u8` type `from_raw_parts` expects.
2082        let base = raw.with_addr(base_addr).cast::<u8>();
2083
2084        // SAFETY: `VirtualAllocExNuma(.., base, size, MEM_COMMIT, ..,
2085        // node)` commits exactly the caller-requested `size` bytes at the
2086        // aligned sub-range within the just-reserved `over`-byte region
2087        // (`base + size <= raw + over` by construction: `base <= raw +
2088        // align - 1` rounds down to `raw + align`, and `over = size +
2089        // align`). task #778 (F2): `node` has NO effect on this call --
2090        // per Microsoft's documented `nndPreferred` contract, the NUMA
2091        // preference is ignored when committing pages into a region that
2092        // already exists (the `MEM_RESERVE` call above already created it);
2093        // passed through for API-site uniformity only, not because it does
2094        // anything here. Physical pages are not allocated by EITHER call --
2095        // Windows allocates them on demand at first touch, regardless of
2096        // which call reserved/committed the range. NULL indicates commit-
2097        // charge exhaustion; the reservation is released and Os error returned.
2098        let committed = unsafe {
2099            VirtualAllocExNuma(
2100                GetCurrentProcess(),
2101                base.cast(),
2102                size,
2103                MEM_COMMIT,
2104                PAGE_READWRITE,
2105                node,
2106            )
2107        };
2108        if committed.is_null() {
2109            // Commit failed — capture GetLastError IMMEDIATELY (task #1306, the
2110            // errno-timing contract), BEFORE the VirtualFree cleanup below
2111            // overwrites it. Then release the reservation and return the
2112            // captured error.
2113            let err = std::io::Error::last_os_error();
2114            // Release the reservation. Returning Os(err) is still correct even if
2115            // this release itself fails: the caller never received an owning
2116            // handle, so handing one out now would risk a double-release. The
2117            // release's own failure is unreportable through this Result signature
2118            // (we're already returning the commit error; there's no room to carry
2119            // a second error from cleanup). Silent by choice, matching this file's
2120            // other unrecoverable cleanup paths (task #1275 N5).
2121            //
2122            // SAFETY: `raw` was returned by the `MEM_RESERVE` call above and
2123            // has not been handed to any caller yet; releasing before
2124            // returning `Os(err)` cannot double-free.
2125            let _ = unsafe { VirtualFree(raw, 0, MEM_RELEASE) };
2126            return Err(ReserveNumaError::Os(err));
2127        }
2128
2129        // Win32 contract: committing into an already-reserved region returns the
2130        // base address of that region subrange, i.e. exactly `base`. task
2131        // #1304 (P2): this was a `debug_assert_eq!` — compiled to nothing in
2132        // release builds, so a contract violation there would have proceeded
2133        // to `from_raw_parts` with a mismatched `base`, constructing a
2134        // `Reservation` whose bookkeeping does not match what was actually
2135        // committed. Checked unconditionally now: on mismatch, fail closed —
2136        // release the reservation (the commit succeeded, so there is no OS
2137        // error to capture) and return a contract-violation error.
2138        if committed.cast::<u8>() != base {
2139            // SAFETY: `raw` was returned by the `MEM_RESERVE` call above and
2140            // has not been handed to any caller yet; releasing before
2141            // returning the error cannot double-free.
2142            let _ = unsafe { VirtualFree(raw, 0, MEM_RELEASE) };
2143            return Err(ReserveNumaError::Os(std::io::Error::other(
2144                "VirtualAllocExNuma MEM_COMMIT returned an unexpected base — Win32 contract violation (task #1304)"
2145            )));
2146        }
2147
2148        // SAFETY of from_raw_parts:
2149        // - `base` is non-null, valid for `size` bytes (it's inside the
2150        //   `over`-byte reservation since `align <= over - size`), aligned
2151        //   to `align` (by construction above), and its `size`-byte range
2152        //   was just committed above. Win32 contract: `committed == base` for
2153        //   commit into an already-reserved region, checked unconditionally
2154        //   above (task #1304: a mismatch releases the reservation and
2155        //   returns the error before this call is reached).
2156        // - `raw` is the start of the OS reservation, non-null.
2157        // - `over = size + align` is the full reservation length, multiple of PAGE.
2158        // - `align` was just used to align `base` — same value.
2159        // - The reservation will be released exactly once when the returned
2160        //   handle's `Drop` fires (or via `release` after `into_parts`).
2161        // - The reservation was created with `MEM_RESERVE` and the `size`-byte
2162        //   sub-range separately committed with `MEM_COMMIT` →
2163        //   `VirtualFree(MEM_RELEASE)` on the WHOLE `over`-byte span will
2164        //   accept it (matches aligned-vmem's own `win_reserve_commit` shape).
2165        let r = unsafe {
2166            aligned_vmem::Reservation::from_raw_parts(
2167                base,
2168                size,
2169                raw as *mut u8,
2170                over,
2171                align,
2172                false, // ordinary VirtualAllocExNuma pages -- MEM_LARGE_PAGES is never requested here
2173            )
2174        };
2175        Ok(r)
2176    }
2177
2178    /// Mirrors `PROCESSOR_NUMBER` from the Windows SDK.
2179    #[repr(C)]
2180    struct ProcessorNumber {
2181        group: u16,
2182        number: u8,
2183        reserved: u8,
2184    }
2185
2186    // task #726 (rust-intel audit §B25): `ProcessorNumber` is passed by
2187    // pointer to `GetCurrentProcessorNumberEx`/`GetNumaProcessorNodeEx` --
2188    // the hand-written mirror currently matches the real `PROCESSOR_NUMBER`
2189    // layout (size 4, align 2, offsets 0/2/3), but nothing pinned that
2190    // before this assertion, so a future field edit (reordering, adding a
2191    // field) would silently corrupt the out-parameter write these two FFI
2192    // calls make into it, with no compile-time signal.
2193    const _: () = {
2194        assert!(core::mem::size_of::<ProcessorNumber>() == 4);
2195        assert!(core::mem::align_of::<ProcessorNumber>() == 2);
2196        assert!(core::mem::offset_of!(ProcessorNumber, group) == 0);
2197        assert!(core::mem::offset_of!(ProcessorNumber, number) == 2);
2198        assert!(core::mem::offset_of!(ProcessorNumber, reserved) == 3);
2199    };
2200
2201    extern "system" {
2202        fn GetCurrentProcessorNumberEx(proc_number: *mut ProcessorNumber);
2203        fn GetNumaProcessorNodeEx(processor: *const ProcessorNumber, node_number: *mut u16) -> i32;
2204    }
2205
2206    // `VirtualAllocExNuma` is the load-bearing call: it is the ONLY way to
2207    // attach a NUMA preference to a reservation on Windows (`VirtualAlloc`
2208    // chooses the node by kernel heuristic; there is no `mbind`-equivalent
2209    // for post-reservation policy installation). Declared locally to avoid pulling
2210    // `windows-sys` / `winapi` just for one syscall.
2211    #[cfg(feature = "vmem-integration")]
2212    extern "system" {
2213        // task #778 (round-closing review, F9): moved here from the
2214        // always-compiled extern block above -- its only two call sites
2215        // (`reserve_aligned_numa`) are already `vmem-integration`-gated, so
2216        // leaving it in the unconditional block made `cargo clippy
2217        // --all-targets -- -D warnings` fail on this crate's DEFAULT
2218        // feature set (what `cargo add numa-shim` produces) with "function
2219        // `GetCurrentProcess` is never used" -- every downstream Windows
2220        // consumer's default build saw this warning, and no CI job for this
2221        // crate runs clippy at all to catch it either way.
2222        fn GetCurrentProcess() -> *mut core::ffi::c_void;
2223        fn VirtualAllocExNuma(
2224            h_process: *mut core::ffi::c_void,
2225            lp_address: *mut core::ffi::c_void,
2226            dw_size: usize,
2227            fl_allocation_type: u32,
2228            fl_protect: u32,
2229            nnd_preferred: u32,
2230        ) -> *mut core::ffi::c_void;
2231        // task #724: needed to release a reservation whose commit step
2232        // failed, before any `aligned_vmem::Reservation` (whose own `Drop`
2233        // would otherwise own that release) has been constructed.
2234        fn VirtualFree(
2235            lp_address: *mut core::ffi::c_void,
2236            dw_size: usize,
2237            dw_free_type: u32,
2238        ) -> i32;
2239    }
2240
2241    #[cfg(feature = "vmem-integration")]
2242    const MEM_RESERVE: u32 = 0x0000_2000;
2243    #[cfg(feature = "vmem-integration")]
2244    const MEM_COMMIT: u32 = 0x0000_1000;
2245    #[cfg(feature = "vmem-integration")]
2246    const MEM_RELEASE: u32 = 0x0000_8000;
2247    #[cfg(feature = "vmem-integration")]
2248    const PAGE_READWRITE: u32 = 0x04;
2249}
2250
2251// ---- macOS stub -----------------------------------------------------------
2252// `not(miri)` is required here (matching the other three sibling
2253// `mod platform` blocks above -- Linux, Windows, and the generic
2254// fallback -- task #778/F11: line numbers drift with every edit to this
2255// file, so this is described by role instead of a citation that goes
2256// stale silently): without it, this block and the separate
2257// `#[cfg(miri)] mod platform` block below (any-OS-under-miri stub) BOTH
2258// satisfy their cfg simultaneously when running miri on macOS
2259// (`target_os = "macos"` is true AND `miri` is true), causing `mod platform`
2260// to be defined twice (E0428). No CI job caught this because every miri job
2261// runs on `ubuntu-latest` and the macOS job (`numa-shim-macos`) runs plain
2262// `cargo test`, never miri — the two conditions never crossed until an
2263// explicit macOS+miri CI job was added. If you ever touch this block or the
2264// `#[cfg(miri)]` block below, keep them mutually exclusive.
2265#[cfg(all(target_os = "macos", not(miri)))]
2266#[cfg_attr(numa_shim_mock, allow(dead_code))]
2267mod platform {
2268    #[cfg(feature = "vmem-integration")]
2269    use super::{NodeId, ReserveNumaError};
2270    use super::{NodeResolution, NO_NODE};
2271
2272    /// macOS has no public NUMA API. Always returns `NO_NODE`.
2273    pub(super) fn current_node_impl() -> u32 {
2274        NO_NODE
2275    }
2276
2277    pub(super) fn current_node_resolution_impl() -> NodeResolution {
2278        NodeResolution::Unavailable
2279    }
2280
2281    #[cfg(feature = "vmem-integration")]
2282    pub(super) fn reserve_preferred_on_node_impl(
2283        size: usize,
2284        align: usize,
2285        node: NodeId,
2286    ) -> Result<aligned_vmem::Reservation, ReserveNumaError> {
2287        let _ = (size, align, node);
2288        Err(ReserveNumaError::UnsupportedPlatform)
2289    }
2290}
2291
2292// ---- miri stub (any OS under miri) ----------------------------------------
2293#[cfg(miri)]
2294#[cfg_attr(numa_shim_mock, allow(dead_code))]
2295mod platform {
2296    #[cfg(feature = "vmem-integration")]
2297    use super::{NodeId, ReserveNumaError};
2298    use super::{NodeResolution, NO_NODE};
2299
2300    /// Under miri NUMA detection is not meaningful. Always returns `NO_NODE`.
2301    pub(super) fn current_node_impl() -> u32 {
2302        NO_NODE
2303    }
2304
2305    pub(super) fn current_node_resolution_impl() -> NodeResolution {
2306        NodeResolution::Unavailable
2307    }
2308
2309    #[cfg(feature = "vmem-integration")]
2310    pub(super) fn reserve_preferred_on_node_impl(
2311        size: usize,
2312        align: usize,
2313        node: NodeId,
2314    ) -> Result<aligned_vmem::Reservation, ReserveNumaError> {
2315        let _ = (size, align, node);
2316        Err(ReserveNumaError::UnsupportedPlatform)
2317    }
2318}
2319
2320// ---- Fallback: unsupported platform (e.g. FreeBSD, other Unix) ------------
2321#[cfg(not(any(target_os = "linux", windows, target_os = "macos", miri,)))]
2322#[cfg_attr(numa_shim_mock, allow(dead_code))]
2323mod platform {
2324    #[cfg(feature = "vmem-integration")]
2325    use super::{NodeId, ReserveNumaError};
2326    use super::{NodeResolution, NO_NODE};
2327
2328    /// Unsupported platform: always returns `NO_NODE`.
2329    pub(super) fn current_node_impl() -> u32 {
2330        NO_NODE
2331    }
2332
2333    pub(super) fn current_node_resolution_impl() -> NodeResolution {
2334        NodeResolution::Unavailable
2335    }
2336
2337    #[cfg(feature = "vmem-integration")]
2338    pub(super) fn reserve_preferred_on_node_impl(
2339        size: usize,
2340        align: usize,
2341        node: NodeId,
2342    ) -> Result<aligned_vmem::Reservation, ReserveNumaError> {
2343        let _ = (size, align, node);
2344        Err(ReserveNumaError::UnsupportedPlatform)
2345    }
2346}