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}