Skip to main content

net/ffi/
mod.rs

1//! C FFI bindings for cross-language integration.
2//!
3//! This module provides a C-compatible API for using Net from
4//! other languages (Python, Node.js, Go, etc.).
5//!
6//! # Safety
7//!
8//! All public FFI functions in this module accept raw pointers from C code.
9//! Each is declared `pub unsafe extern "C" fn` so the unsafety is
10//! explicit at the type level; the module-wide contract callers
11//! must uphold is:
12//! - Pointers are valid and properly aligned
13//! - Opaque handle pointers (`*mut T`) were produced by this crate's
14//!   matching constructor (`Box::into_raw` inside the FFI surface).
15//!   Foreign-allocated pointers, even if valid and aligned, will UB
16//!   when consumed by `Box::from_raw` in the corresponding `_free`.
17//! - String pointers point to valid UTF-8 data
18//! - Buffer sizes are accurate
19//! - Handles are not used after `net_shutdown`
20//!
21//! The per-function `# Safety` rustdoc is intentionally suppressed
22//! at the module level — every entry point shares the same contract
23//! and the module doc-comment above (plus `include/README.md`) is
24//! the source of truth. Adding individual `# Safety` blocks would
25//! duplicate the same wording 200 times without adding signal.
26//!
27//! # Thread Safety
28//!
29//! All FFI functions are thread-safe. The event bus handle can be shared
30//! across threads.
31//!
32#![allow(clippy::missing_safety_doc)]
33// The cross-cutting C-side safety contract for every `unsafe` block in
34// this module is documented in the `# Safety` section above:
35// caller-validated pointer / length / lifetime / handle-not-after-shutdown
36// invariants documented in `include/net.h`. Inlining `// SAFETY:` on each
37// block would add ~200 identical "see module preamble" comments without
38// adding any signal beyond what the preamble already says.
39#![expect(
40    clippy::undocumented_unsafe_blocks,
41    reason = "module-wide FFI safety contract documented in the # Safety preamble above"
42)]
43#![expect(
44    clippy::multiple_unsafe_ops_per_block,
45    reason = "FFI entry points routinely deref + write to multiple out-parameter fields under the same caller contract; splitting per-op would obscure the single boundary-cross"
46)]
47
48//! # Tokio runtime restriction
49//!
50//! Internal FFI ops (`net_poll`, `net_flush`, `net_shutdown`,
51//! `net_redex_*`, `net_mesh_new`, the cortex FFI, the mesh FFI)
52//! drive the bus's tokio runtime via `Runtime::block_on`. That
53//! function panics with "Cannot start a runtime from within a
54//! runtime" if the calling thread is already inside a tokio
55//! runtime context. The functions are `extern "C"`, so a panic
56//! unwinds across the FFI boundary into C / Go-cgo / Python /
57//! NAPI — undefined behavior.
58//!
59//! **The common-case C / Go / Python caller has no Rust tokio
60//! runtime, so this is unreachable for them.** The narrow path is:
61//!
62//! - A **Rust** caller loads the cdylib and calls these
63//!   functions from inside its own `#[tokio::main]` (or any
64//!   thread that has called `Runtime::enter()`).
65//! - A non-Rust caller embeds a Rust library that runs its own
66//!   tokio runtime and forwards calls into this cdylib on the
67//!   same thread.
68//!
69//! Both forms are unusual but reachable. **Do not call any FFI
70//! op from a thread that already holds a tokio runtime
71//! context.** If you must, spawn the FFI call on a fresh OS
72//! thread that doesn't carry a runtime guard, or wrap the call
73//! with `tokio::task::spawn_blocking(|| net_xxx(...))` to escape
74//! the worker pool.
75//!
76//! `net_init` (`mod.rs:284-316`) hardens against this for runtime
77//! *construction*; the steady-state ops do not, since the cost
78//! of a `Handle::try_current()` check on every poll would be
79//! measurable for the common path that doesn't hit the bug.
80//!
81//! # `catch_unwind` + caller-held locks
82//!
83//! Several FFI entries (`net_blob_publish`, `net_blob_resolve`,
84//! `net_*_wait_for_token`) wrap their body in
85//! `std::panic::catch_unwind(AssertUnwindSafe(...))` so a panic
86//! during the call returns a typed `NET_ERR_BLOB_PANIC` /
87//! `NET_ERR_PANIC` code rather than unwinding across the FFI
88//! boundary. That stops the substrate-side undefined behavior,
89//! but it does NOT make the wrapped code transparently panic-safe
90//! from the caller's perspective.
91//!
92//! **If the caller invokes an FFI op while holding an OS-level
93//! lock, a `sync.Mutex` (Go), `threading.Lock` (Python), or any
94//! other mutex with poisoning semantics, and the FFI body panics,
95//! the mutex is left in a poisoned state.** Subsequent acquires
96//! on the same mutex by the caller observe the poisoning and
97//! either error (Rust `parking_lot` with `poison_on_unwind`) or
98//! deadlock (Go's `sync.Mutex` doesn't poison; the caller has
99//! observed a return value that may not reflect the state of
100//! the FFI op).
101//!
102//! Recommended caller pattern: **do not hold a caller-side lock
103//! across an FFI call**. Acquire the lock, prepare the inputs,
104//! release the lock, then call the FFI. Re-acquire if you need
105//! to update caller state with the result.
106//!
107//! The hazard is documented per-binding in:
108//!   - Python: `bindings/python/README.md` (caller-mutex notes)
109//!   - Node:   `bindings/node/README.md`
110//!   - Go:     `bindings/go/net/redex.go` lifecycle docs
111//!   - C:      `include/net.h` (every wait-family declaration)
112//!
113//! # Memory Management
114//!
115//! - Handles returned by `net_init` must be freed with `net_shutdown`
116//! - String buffers passed to `net_poll` are owned by the caller
117//! - Error codes are returned as integers (0 = success, negative = error)
118//!
119//! # Example (C)
120//!
121//! ```c
122//! #include "net.h"
123//!
124//! int main() {
125//!     // Initialize with default config
126//!     void* bus = net_init("{\"num_shards\": 4}");
127//!     if (!bus) return 1;
128//!
129//!     // Ingest an event
130//!     int result = net_ingest(bus, "{\"token\": \"hello\"}", 19);
131//!     if (result < 0) { /* handle error */ }
132//!
133//!     // Poll events
134//!     char buffer[65536];
135//!     result = net_poll(bus, "{\"limit\": 100}", buffer, sizeof(buffer));
136//!
137//!     // Shutdown
138//!     net_shutdown(bus);
139//!     return 0;
140//! }
141//! ```
142
143// FFI functions accept raw pointers but are not marked `unsafe` to maintain
144// C ABI compatibility. Safety is documented in the module-level docs.
145#![allow(clippy::not_unsafe_ptr_arg_deref)]
146
147use std::ffi::CStr;
148use std::os::raw::{c_char, c_int};
149use std::ptr;
150
151use tokio::runtime::Runtime;
152
153use crate::bus::EventBus;
154use crate::config::EventBusConfig;
155use crate::consumer::{ConsumeRequest, ConsumeResponse};
156use crate::event::{Event, RawEvent};
157
158/// C FFI for CortEX / NetDb / RedexFile. Requires `netdb` (for the
159/// unified facade) and `redex-disk` (for persistent storage paths on
160/// `Redex` / `RedexFile`). Go / cgo consumers target this surface.
161///
162/// `missing_docs` is suppressed on this module: these are extern "C"
163/// shims over already-documented Rust adapters, and the per-function
164/// contract is documented in the binding-side READMEs (Go / TS / Py).
165/// Re-documenting each shim would duplicate with drift risk.
166/// Per-FFI-handle quiescing protocol shared by cortex / mesh
167/// handles to close the audit-#23/#24/#25 use-after-free hazards
168/// when a `_free` races a concurrent op. See module docs for the
169/// soundness story (intentional box leak) and the per-handle
170/// recipe.
171#[cfg(any(
172    all(feature = "netdb", feature = "redex-disk"),
173    feature = "net",
174    feature = "redis",
175))]
176pub mod handle_guard;
177
178#[cfg(all(feature = "netdb", feature = "redex-disk"))]
179#[allow(missing_docs)]
180pub mod cortex;
181
182/// C FFI for the Dataforts Phase 3 blob surface. Exposes the
183/// BlobRef wire codec, the global adapter registry, and the
184/// `publish_blob` / `resolve_payload` helpers for cgo / native
185/// consumers.
186#[cfg(feature = "dataforts")]
187#[allow(missing_docs)]
188pub mod blob;
189
190/// Stub definitions for the `net_mesh_blob_adapter_*` symbols
191/// when the `dataforts / netdb / redex-disk` feature triple is
192/// off. cgo / dlsym consumers link these symbols unconditionally
193/// (see `bindings/go/blob.go`), so a libnet built without the
194/// triple must still satisfy them — each stub returns
195/// `NET_ERR_FEATURE_NOT_BUILT` (or null) so Go programs route to
196/// a clean error rather than fail at program load. The module is
197/// empty when the feature triple is on (the real impls in
198/// `ffi::blob` cover the same symbol names).
199#[allow(missing_docs)]
200pub mod blob_stubs;
201
202/// C FFI for the encrypted-UDP mesh transport + channels. Requires
203/// the `net` feature (which brings in the crypto + transport). Go /
204/// cgo consumers target this surface alongside `ffi::cortex`. See
205/// the `ffi::cortex` note for why `missing_docs` is suppressed here.
206#[cfg(feature = "net")]
207#[allow(missing_docs)]
208pub mod mesh;
209
210/// C FFI for the transport surface (blob + directory transfer over the
211/// fairscheduler stream transport — Transport SDK plan T-C). Drives the
212/// node's transfer engine via the existing `MeshNodeHandle` +
213/// `MeshBlobAdapterHandle`, so it rides `net` + the blob-adapter feature
214/// set (the adapter handle needs `netdb` + `redex-disk`). Feature-off
215/// stubs for builds missing the quad live in `transport_stubs` below.
216#[cfg(all(
217    feature = "net",
218    feature = "dataforts",
219    feature = "netdb",
220    feature = "redex-disk"
221))]
222#[allow(missing_docs)]
223pub mod transport;
224
225/// Feature-off stubs for the transport symbols
226/// (`net_serve_blob_transfer` / `net_fetch_blob*` / `net_store_dir` /
227/// `net_fetch_dir` / `net_dir_manifest_read` / `net_transport_free_buffer`)
228/// when the quad above is not fully built. The Go binding
229/// (`bindings/go/net/transport.go`) links these unconditionally, so a
230/// libnet without the quad must still satisfy them — each stub returns
231/// `NET_ERR_FEATURE_NOT_BUILT` (or null / no-op) so Go programs route to
232/// a clean error rather than fail at program load. Empty (compiled out)
233/// when the quad is on — the real impls in `ffi::transport` then own the
234/// symbol names. Mirrors `ffi::blob_stubs`.
235#[allow(missing_docs)]
236pub mod transport_stubs;
237
238/// C FFI for the `aggregator.registry` RPC client + channel
239/// visibility setter. Stage 5 of `SDK_AGGREGATOR_SUBNET_PLAN.md`.
240/// Rides the `net` feature alongside `ffi::mesh` because every
241/// op needs a `MeshNodeHandle`, and `cortex` because the
242/// underlying `behavior::aggregator` module's RPC surface is
243/// cortex-only (`mesh_rpc`, `cortex::rpc`, `postcard`).
244#[cfg(all(feature = "net", feature = "cortex"))]
245#[allow(missing_docs)]
246pub mod aggregator;
247
248/// C FFI for stateless predicate evaluation (Phase 9c of
249/// `CAPABILITY_SYSTEM_SDK_PLAN.md`). Pure helpers — no handles,
250/// no state. Mirrors the SDK-layer `evaluatePredicate` /
251/// `evaluate_predicate` surface every binding ships, exposed at
252/// the C ABI for raw consumers (C / C++ / Zig / Swift / etc.).
253#[cfg(feature = "net")]
254pub mod predicate;
255
256/// C FFI for stateless capability-set validation (Phase 9a of
257/// `CAPABILITY_SYSTEM_SDK_PLAN.md`). Pure helper — `caps_json`
258/// in, `report_json` out. Mirrors the SDK-layer
259/// `validate_capabilities` surface, exposed at the C ABI for raw
260/// consumers.
261#[cfg(feature = "net")]
262pub mod schema;
263
264/// C FFI for predicate debug-session helpers (Phase 9d of
265/// `CAPABILITY_SYSTEM_SDK_PLAN.md`). Pure helpers — single-eval
266/// `evaluate_with_trace`, corpus-wide
267/// `aggregate_debug_report`, and host-side
268/// `redact_metadata_keys`. Mirror what every other binding
269/// ships at the SDK layer; exposed at the C ABI for raw
270/// consumers.
271#[cfg(feature = "net")]
272pub mod predicate_debug;
273
274/// C FFI for the Redis Streams consumer-side dedup helper. Mirrors
275/// the Rust `net::adapter::RedisStreamDedup` surface for Go / C / Zig
276/// consumers. See `ffi::redis_dedup` module docs for the wire
277/// shape and the dedup contract.
278#[cfg(feature = "redis")]
279pub mod redis_dedup;
280
281#[cfg(feature = "net")]
282use crate::adapter::net::{NetAdapterConfig, ReliabilityConfig, StaticKeypair};
283#[cfg(any(feature = "redis", feature = "jetstream", feature = "net"))]
284use crate::config::AdapterConfig;
285#[cfg(feature = "jetstream")]
286use crate::config::JetStreamAdapterConfig;
287#[cfg(feature = "redis")]
288use crate::config::RedisAdapterConfig;
289#[cfg(feature = "net")]
290use std::ffi::CString;
291
292/// Opaque handle to an event bus instance.
293///
294/// This wraps the EventBus along with a Tokio runtime for async operations.
295///
296/// # Lifetime / soundness
297///
298/// The handle storage is *intentionally leaked* on `net_shutdown` rather
299/// than freed via `Box::from_raw`. Reasoning: every FFI entry point
300/// dereferences the C-side `*mut NetHandle` to access the atomics that
301/// gate shutdown. The previous Dekker-style SeqCst handshake between
302/// `FfiOpGuard::try_enter` (which calls `fetch_add` on `active_ops`) and
303/// `net_shutdown` (which loads `active_ops` then `Box::from_raw`s the
304/// handle) was unsound: SeqCst orders the atomic operations only — the
305/// non-atomic `Box::from_raw` could deallocate the storage between
306/// shutdown's load and a concurrent FFI op's `fetch_add`, producing a
307/// use-after-free on the freed atomic. By never freeing the box, the
308/// atomic memory backing the handle is always valid; concurrent FFI ops
309/// observe `shutting_down=true` after shutdown signals it and bail
310/// before touching `bus`/`runtime`.
311///
312/// `bus` and `runtime` are stored in `ManuallyDrop` so that
313/// `net_shutdown` can `take` them out (via `ptr::read`) in order to
314/// call `bus.shutdown().await`. Because `shutting_down` is set first
315/// and shutdown waits for `active_ops` to drop to zero before reading
316/// these fields, no FFI op can be racing the read. If the wait times
317/// out, the `ptr::read` is skipped and both fields are leaked along
318/// with the box.
319pub struct NetHandle {
320    /// Owned `EventBus`. Read out via `ManuallyDrop::take` during
321    /// shutdown once `active_ops` has drained to zero. After that
322    /// point, `shutting_down` is `true` and no FFI op may access this
323    /// field.
324    bus: std::mem::ManuallyDrop<EventBus>,
325    /// Owned tokio runtime. Same lifetime contract as `bus`.
326    runtime: std::mem::ManuallyDrop<Runtime>,
327    /// Set to `true` once `net_shutdown` begins. All other FFI
328    /// functions check this flag and return `ShuttingDown` before
329    /// touching `bus` / `runtime`.
330    shutting_down: std::sync::atomic::AtomicBool,
331    /// Number of in-flight FFI operations (excluding shutdown itself).
332    /// `net_shutdown` spins until this drops to zero (with a deadline)
333    /// before reading `bus` / `runtime` to call shutdown.
334    active_ops: std::sync::atomic::AtomicU32,
335    /// Set to `true` after `net_shutdown` has consumed `bus` /
336    /// `runtime` via `ManuallyDrop::take`. A second `net_shutdown`
337    /// call observes this and returns `Success` without re-taking
338    /// (which would be UB). FFI ops also check this before touching
339    /// `bus` / `runtime`, defending against a contract-violating
340    /// caller that races a post-shutdown call.
341    bus_taken: std::sync::atomic::AtomicBool,
342    /// Set to `true` after `bus.shutdown()` returns from the
343    /// first `net_shutdown` call. A second/third concurrent
344    /// `net_shutdown` caller spins until this flips before
345    /// returning success — without this gate the second caller
346    /// observed `bus_taken == true` and returned `Success` while
347    /// the first caller was still mid-`block_on(bus.shutdown())`,
348    /// falsely signaling completion of an in-progress shutdown.
349    shutdown_completed: std::sync::atomic::AtomicBool,
350}
351
352/// Maximum time `net_shutdown` will wait for in-flight FFI operations
353/// to complete before giving up. If the deadline expires, the bus is
354/// leaked rather than read out — leaking is correct (the box is
355/// already leaked permanently for soundness reasons) but means the
356/// adapter's `flush()` / `shutdown()` won't run.
357const FFI_SHUTDOWN_DEADLINE: std::time::Duration = std::time::Duration::from_secs(5);
358
359/// RAII guard that increments `active_ops` on creation and decrements on drop.
360struct FfiOpGuard<'a> {
361    handle: &'a NetHandle,
362}
363
364impl<'a> FfiOpGuard<'a> {
365    /// Try to enter an FFI operation. Returns `None` if the handle is
366    /// shutting down or if `bus` / `runtime` have already been taken.
367    ///
368    /// Soundness rests on the fact that the box backing `handle` is
369    /// never freed (see `NetHandle` doc). The `fetch_add` is therefore
370    /// always on valid memory regardless of whether shutdown is in
371    /// progress. The subsequent loads decide whether the op is allowed
372    /// to proceed; if shutdown was signaled or `bus_taken` flipped
373    /// before our increment was visible, we bail without touching
374    /// `bus` / `runtime`. The `bus_taken` check defends against a
375    /// contract-violating caller that races a post-shutdown call: even
376    /// if `shutting_down` was reset somehow, an op that would touch the
377    /// already-taken `ManuallyDrop` fields is rejected.
378    fn try_enter(handle: &'a NetHandle) -> Option<Self> {
379        handle
380            .active_ops
381            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
382        if handle
383            .shutting_down
384            .load(std::sync::atomic::Ordering::SeqCst)
385            || handle.bus_taken.load(std::sync::atomic::Ordering::SeqCst)
386        {
387            handle
388                .active_ops
389                .fetch_sub(1, std::sync::atomic::Ordering::AcqRel);
390            None
391        } else {
392            Some(Self { handle })
393        }
394    }
395}
396
397impl Drop for FfiOpGuard<'_> {
398    fn drop(&mut self) {
399        self.handle
400            .active_ops
401            .fetch_sub(1, std::sync::atomic::Ordering::AcqRel);
402    }
403}
404
405/// Returns `true` when `handle` is non-null and aligned for
406/// `NetHandle`. Every `extern "C"` entry point that derefs the
407/// raw handle must gate on this — a misaligned pointer produced
408/// by an over-eager `void *` cast in a foreign caller would be
409/// immediate UB on `&*handle`, even before the `is_null` check.
410#[inline]
411fn handle_is_valid(handle: *const NetHandle) -> bool {
412    !handle.is_null() && (handle as usize).is_multiple_of(std::mem::align_of::<NetHandle>())
413}
414
415/// Declares [`NetError`] and, from the same list, the
416/// `ALL_NET_ERRORS` table the C-header parity guard iterates.
417///
418/// The coupling is the point. `rust_values` used to be a hand-written
419/// `&[i32]`, and it silently missed `InvalidArgument = -12` for the
420/// whole of the scoped-capabilities work — so the guard reported green
421/// while not covering the newest code. Replacing it with an exhaustive
422/// `match` was not enough either: exhaustiveness forces a new variant to
423/// get an arm, but the header asserts iterate the *list*, so a variant
424/// added to the enum and to the match and omitted from the list still
425/// passed.
426///
427/// Generating both from one declaration removes the possibility. A new
428/// variant cannot exist without appearing in the table.
429macro_rules! net_error_codes {
430    ($( $(#[$meta:meta])* $variant:ident = $value:expr ),+ $(,)?) => {
431        /// Error codes returned by FFI functions.
432        ///
433        /// `Copy` because it is a fieldless `#[repr(C)]` enum that is
434        /// passed by value everywhere already; having it lets the
435        /// header-parity guard read each variant's discriminant
436        /// directly.
437        #[repr(C)]
438        #[derive(Clone, Copy)]
439        pub enum NetError {
440            $( $(#[$meta])* $variant = $value, )+
441        }
442
443        /// Every [`NetError`] variant paired with the discriminant the
444        /// C headers must mirror. Generated alongside the enum, so it
445        /// cannot fall behind it.
446        #[cfg(test)]
447        const ALL_NET_ERRORS: &[(NetError, i32)] = &[ $( (NetError::$variant, $value), )+ ];
448    };
449}
450
451net_error_codes! {
452    /// Success (no error).
453    Success = 0,
454    /// Null pointer passed.
455    NullPointer = -1,
456    /// Invalid UTF-8 string.
457    InvalidUtf8 = -2,
458    /// Invalid JSON.
459    InvalidJson = -3,
460    /// Initialization failed.
461    InitFailed = -4,
462    /// Ingestion failed (backpressure).
463    IngestionFailed = -5,
464    /// Poll failed.
465    PollFailed = -6,
466    /// Buffer too small.
467    BufferTooSmall = -7,
468    /// Shutting down.
469    ShuttingDown = -8,
470    /// Integer overflow: result does not fit in `c_int`.
471    IntOverflow = -9,
472    /// Stream handle does not belong to the supplied node handle.
473    /// Previously the send-family FFIs accepted any (stream, node)
474    /// pair without verifying they were created from the same node,
475    /// allowing silent cross-session traffic.
476    MismatchedHandles = -10,
477    /// `CString::new` failure: the input bytes are valid UTF-8 by
478    /// Rust's `String` invariant but contain an interior NUL byte
479    /// — and the C ABI cannot represent that, since C strings are
480    /// NUL-terminated. Pre-fix this was reported as
481    /// `InvalidUtf8`, which was wrong: the input is UTF-8-valid;
482    /// it just has a NUL where C expects it not to. A binding
483    /// reading the typed error and seeing "invalid UTF-8" would
484    /// chase the wrong cause.
485    InteriorNul = -11,
486    /// Input deserialized cleanly but is semantically unusable — e.g. a
487    /// scope filter whose `kind` is unrecognized, or whose required
488    /// selector is missing / empty / an all-empty list.
489    ///
490    /// Distinct from [`Self::InvalidJson`], which means the bytes were
491    /// not valid JSON at all. Reporting these as `InvalidJson` would send
492    /// a caller looking for a syntax error that isn't there — the same
493    /// reasoning that made [`Self::InteriorNul`] its own code.
494    ///
495    /// Returning an error rather than silently widening is the point:
496    /// a narrowing filter that cannot narrow must fail closed.
497    InvalidArgument = -12,
498    /// Unknown error.
499    Unknown = -99,
500}
501
502impl From<NetError> for c_int {
503    fn from(e: NetError) -> Self {
504        e as c_int
505    }
506}
507
508/// Enter an FFI operation with lifetime protection. Returns an `FfiOpGuard`
509/// that prevents `net_shutdown` from deallocating the handle until the guard
510/// is dropped. Returns `Err` with the error code if shutdown is in progress.
511#[inline]
512fn enter_ffi_op(handle: &NetHandle) -> Result<FfiOpGuard<'_>, c_int> {
513    FfiOpGuard::try_enter(handle).ok_or(NetError::ShuttingDown.into())
514}
515
516/// Initialize a new event bus.
517///
518/// # Parameters
519///
520/// - `config_json`: JSON configuration string (UTF-8, null-terminated).
521///   Pass NULL or empty string for default configuration.
522///
523/// # Returns
524///
525/// Opaque handle to the event bus, or NULL on failure.
526/// The handle must be freed with `net_shutdown`.
527///
528/// # Example Configuration
529///
530/// ```json
531/// {
532///   "num_shards": 8,
533///   "ring_buffer_capacity": 1048576,
534///   "backpressure_mode": "DropOldest",
535///   "batch": {
536///     "min_size": 1000,
537///     "max_size": 10000,
538///     "max_delay_ms": 10
539///   }
540/// }
541/// ```
542#[unsafe(no_mangle)]
543pub unsafe extern "C" fn net_init(config_json: *const c_char) -> *mut NetHandle {
544    // Parse and validate the config BEFORE constructing the tokio
545    // runtime. Building the runtime first would let any subsequent
546    // early-return path (`CStr::to_str` Err, `parse_config_json`
547    // returning None, `EventBus::new` returning Err) drop the
548    // local `Runtime` on function return. Dropping a multi-thread
549    // tokio runtime from inside ANOTHER tokio runtime's worker
550    // thread panics with "Cannot drop a runtime in a context where
551    // blocking is not allowed", unwinding across this `extern "C"`
552    // boundary into a Python / Go-cgo / NAPI / PyO3 caller —
553    // undefined behaviour. By validating inputs first, the runtime
554    // is only built once we know it will be installed into the
555    // `NetHandle` and survive the call.
556    let config = if config_json.is_null() {
557        EventBusConfig::default()
558    } else {
559        let config_str = match unsafe { CStr::from_ptr(config_json) }.to_str() {
560            Ok("") => EventBusConfig::default(),
561            Ok(s) => match parse_config_json(s) {
562                Some(cfg) => cfg,
563                None => return ptr::null_mut(),
564            },
565            Err(_) => return ptr::null_mut(),
566        };
567        config_str
568    };
569
570    // Now construct the runtime — its lifetime is tied to the
571    // returned `NetHandle` (via `create_with_config`), so the only
572    // remaining drop is on `net_shutdown`, which already handles
573    // it via `runtime.block_on(...)` (see #74) outside any other
574    // tokio context.
575    let runtime = match Runtime::new() {
576        Ok(rt) => rt,
577        Err(_) => return ptr::null_mut(),
578    };
579
580    create_with_config(runtime, config)
581}
582
583/// Parse JSON configuration into EventBusConfig.
584///
585/// Supports:
586/// - `num_shards`: number of shards
587/// - `ring_buffer_capacity`: ring buffer size per shard
588/// - `backpressure_mode`: "DropNewest", "DropOldest", "FailProducer"
589fn parse_config_json(json_str: &str) -> Option<EventBusConfig> {
590    let value: serde_json::Value = serde_json::from_str(json_str).ok()?;
591
592    let mut builder = EventBusConfig::builder();
593
594    if let Some(num_shards) = value.get("num_shards").and_then(|v| v.as_u64()) {
595        let num_shards = u16::try_from(num_shards).ok()?;
596        builder = builder.num_shards(num_shards);
597    }
598
599    if let Some(capacity) = value.get("ring_buffer_capacity").and_then(|v| v.as_u64()) {
600        let capacity = usize::try_from(capacity).ok()?;
601        builder = builder.ring_buffer_capacity(capacity);
602    }
603
604    if let Some(bp_value) = value.get("backpressure_mode") {
605        let bp_mode = if let Some(mode) = bp_value.as_str() {
606            match mode {
607                "DropNewest" | "drop_newest" => crate::config::BackpressureMode::DropNewest,
608                "DropOldest" | "drop_oldest" => crate::config::BackpressureMode::DropOldest,
609                "FailProducer" | "fail_producer" => crate::config::BackpressureMode::FailProducer,
610                // Pre-fix every other string silently fell back to
611                // `DropNewest`. A typo (`"DropOldset"`) thus
612                // changed durability profile at deploy time with
613                // no error. Reject unknowns to match the contract
614                // already enforced by `parse_poll_request_json`.
615                _ => return None,
616            }
617        } else {
618            let obj = bp_value.as_object()?;
619            // Object form: `{"Sample": {"rate": N}}` for the
620            // sampling mode that has an associated value.
621            let sample = obj.get("Sample").or_else(|| obj.get("sample"))?;
622            let rate = sample.get("rate").and_then(|v| v.as_u64())?;
623            let rate = u32::try_from(rate).ok()?;
624            if rate == 0 {
625                // Validated again by `EventBusConfig::validate`,
626                // but reject earlier so the parser surface
627                // matches the validator surface.
628                return None;
629            }
630            crate::config::BackpressureMode::Sample { rate }
631        };
632        builder = builder.backpressure_mode(bp_mode);
633    }
634
635    // Parse Redis config
636    #[cfg(feature = "redis")]
637    if let Some(redis) = value.get("redis") {
638        if let Some(url) = redis.get("url").and_then(|v| v.as_str()) {
639            let mut redis_config = RedisAdapterConfig::new(url);
640
641            if let Some(prefix) = redis.get("prefix").and_then(|v| v.as_str()) {
642                redis_config = redis_config.with_prefix(prefix);
643            }
644            if let Some(max_len) = redis.get("max_stream_len").and_then(|v| v.as_u64()) {
645                let max_len = usize::try_from(max_len).ok()?;
646                redis_config = redis_config.with_max_stream_len(max_len);
647            }
648            if let Some(pipeline_size) = redis.get("pipeline_size").and_then(|v| v.as_u64()) {
649                let pipeline_size = usize::try_from(pipeline_size).ok()?;
650                redis_config = redis_config.with_pipeline_size(pipeline_size);
651            }
652
653            builder = builder.adapter(AdapterConfig::Redis(redis_config));
654        }
655    }
656
657    // Parse JetStream config
658    #[cfg(feature = "jetstream")]
659    if let Some(jetstream) = value.get("jetstream") {
660        if let Some(url) = jetstream.get("url").and_then(|v| v.as_str()) {
661            let mut js_config = JetStreamAdapterConfig::new(url);
662
663            if let Some(prefix) = jetstream.get("prefix").and_then(|v| v.as_str()) {
664                js_config = js_config.with_prefix(prefix);
665            }
666            if let Some(max_messages) = jetstream.get("max_messages").and_then(|v| v.as_i64()) {
667                js_config = js_config.with_max_messages(max_messages);
668            }
669            if let Some(replicas) = jetstream.get("replicas").and_then(|v| v.as_u64()) {
670                let replicas = usize::try_from(replicas).ok()?;
671                js_config = js_config.with_replicas(replicas);
672            }
673
674            builder = builder.adapter(AdapterConfig::JetStream(js_config));
675        }
676    }
677
678    // Parse Net config
679    #[cfg(feature = "net")]
680    if let Some(net) = value.get("net") {
681        let bind_addr: std::net::SocketAddr = net
682            .get("bind_addr")
683            .and_then(|v| v.as_str())
684            .and_then(|s| s.parse().ok())?;
685
686        let peer_addr: std::net::SocketAddr = net
687            .get("peer_addr")
688            .and_then(|v| v.as_str())
689            .and_then(|s| s.parse().ok())?;
690
691        let psk: [u8; 32] = net
692            .get("psk")
693            .and_then(|v| v.as_str())
694            .and_then(|s| hex::decode(s).ok())
695            .and_then(|v| v.try_into().ok())?;
696
697        let role = net
698            .get("role")
699            .and_then(|v| v.as_str())
700            .unwrap_or("initiator");
701
702        let mut net_config = match role {
703            "initiator" => {
704                let peer_pubkey: [u8; 32] = net
705                    .get("peer_public_key")
706                    .and_then(|v| v.as_str())
707                    .and_then(|s| hex::decode(s).ok())
708                    .and_then(|v| v.try_into().ok())?;
709                NetAdapterConfig::initiator(bind_addr, peer_addr, psk, peer_pubkey)
710            }
711            "responder" => {
712                let secret_key: [u8; 32] = net
713                    .get("secret_key")
714                    .and_then(|v| v.as_str())
715                    .and_then(|s| hex::decode(s).ok())
716                    .and_then(|v| v.try_into().ok())?;
717                let public_key: [u8; 32] = net
718                    .get("public_key")
719                    .and_then(|v| v.as_str())
720                    .and_then(|s| hex::decode(s).ok())
721                    .and_then(|v| v.try_into().ok())?;
722                let keypair = StaticKeypair::from_keys(secret_key, public_key);
723                NetAdapterConfig::responder(bind_addr, peer_addr, psk, keypair)
724            }
725            _ => return None,
726        };
727
728        // Apply optional settings
729        if let Some(reliability) = net.get("reliability").and_then(|v| v.as_str()) {
730            net_config = net_config.with_reliability(match reliability {
731                "light" => ReliabilityConfig::Light,
732                "full" => ReliabilityConfig::Full,
733                _ => ReliabilityConfig::None,
734            });
735        }
736
737        if let Some(pool_size) = net.get("packet_pool_size").and_then(|v| v.as_u64()) {
738            if let Ok(size) = usize::try_from(pool_size) {
739                net_config = net_config.with_pool_size(size);
740            }
741        }
742
743        // Reject `0` for `heartbeat_interval_ms` and
744        // `session_timeout_ms`. `EventBusConfig::validate` rejects
745        // zero `Duration`s for `cooldown`, `metrics_window`, etc.,
746        // but the Net adapter's JSON parser had no equivalent guard
747        // — a `0` here flowed through to `Duration::from_millis(0)`,
748        // which on the heartbeat path busy-loops the heartbeat task
749        // and saturates a CPU. Treat zero as a misconfig and refuse
750        // to build the bus, surfacing as `InvalidJson` so the FFI
751        // caller sees a typed failure rather than a hung daemon.
752        if let Some(interval_ms) = net.get("heartbeat_interval_ms").and_then(|v| v.as_u64()) {
753            if interval_ms == 0 {
754                return None;
755            }
756            net_config =
757                net_config.with_heartbeat_interval(std::time::Duration::from_millis(interval_ms));
758        }
759
760        if let Some(timeout_ms) = net.get("session_timeout_ms").and_then(|v| v.as_u64()) {
761            if timeout_ms == 0 {
762                return None;
763            }
764            net_config =
765                net_config.with_session_timeout(std::time::Duration::from_millis(timeout_ms));
766        }
767
768        if let Some(batched) = net.get("batched_io").and_then(|v| v.as_bool()) {
769            net_config = net_config.with_batched_io(batched);
770        }
771
772        builder = builder.adapter(AdapterConfig::Net(Box::new(net_config)));
773    }
774
775    builder.build().ok()
776}
777
778fn create_with_config(runtime: Runtime, config: EventBusConfig) -> *mut NetHandle {
779    let bus = match runtime.block_on(EventBus::new(config)) {
780        Ok(bus) => bus,
781        Err(_) => {
782            // Send the runtime off to a fresh OS thread for
783            // dropping. Dropping a multi-thread tokio `Runtime`
784            // from inside another tokio runtime's worker thread
785            // panics ("Cannot drop a runtime in a context where
786            // blocking is not allowed"); a panic here would unwind
787            // across this `extern "C"` frame. The fresh thread
788            // guarantees a non-tokio context, so the drop is sound
789            // regardless of the caller's runtime environment. We
790            // don't `join()` the thread — the drop completes on
791            // its own and the caller has already been told
792            // `net_init` failed (returning null).
793            std::thread::spawn(move || drop(runtime));
794            return ptr::null_mut();
795        }
796    };
797
798    let handle = Box::new(NetHandle {
799        bus: std::mem::ManuallyDrop::new(bus),
800        runtime: std::mem::ManuallyDrop::new(runtime),
801        shutting_down: std::sync::atomic::AtomicBool::new(false),
802        active_ops: std::sync::atomic::AtomicU32::new(0),
803        bus_taken: std::sync::atomic::AtomicBool::new(false),
804        shutdown_completed: std::sync::atomic::AtomicBool::new(false),
805    });
806
807    Box::into_raw(handle)
808}
809
810/// Ingest a single event.
811///
812/// # Parameters
813///
814/// - `handle`: Event bus handle from `net_init`.
815/// - `event_json`: JSON event string (UTF-8).
816/// - `len`: Length of the event string in bytes.
817///
818/// # Returns
819///
820/// - `0` on success
821/// - Negative error code on failure
822#[unsafe(no_mangle)]
823pub unsafe extern "C" fn net_ingest(
824    handle: *mut NetHandle,
825    event_json: *const c_char,
826    len: usize,
827) -> c_int {
828    if !handle_is_valid(handle) || event_json.is_null() {
829        return NetError::NullPointer.into();
830    }
831
832    let handle = unsafe { &*handle };
833    let _guard = match enter_ffi_op(handle) {
834        Ok(g) => g,
835        Err(err) => return err,
836    };
837
838    // `slice::from_raw_parts` requires `len <= isize::MAX`. A
839    // C caller passing a sign-extended `-1` (or any
840    // `len > isize::MAX as usize`) triggers immediate UB before
841    // any other validation runs. Reject such inputs explicitly
842    // — caller should never see this in practice; surfacing a
843    // typed error is safer than UB.
844    if len > isize::MAX as usize {
845        return NetError::InvalidJson.into();
846    }
847    // Parse event JSON
848    let json_bytes = unsafe { std::slice::from_raw_parts(event_json as *const u8, len) };
849    let json_str = match std::str::from_utf8(json_bytes) {
850        Ok(s) => s,
851        Err(_) => return NetError::InvalidUtf8.into(),
852    };
853
854    let event = match Event::from_str(json_str) {
855        Ok(e) => e,
856        Err(_) => return NetError::InvalidJson.into(),
857    };
858
859    // Ingest
860    match handle.bus.ingest(event) {
861        Ok(_) => NetError::Success.into(),
862        Err(_) => NetError::IngestionFailed.into(),
863    }
864}
865
866/// Ingest a raw JSON string (fastest path).
867///
868/// The JSON string is stored directly without parsing.
869/// This is the recommended method for high-throughput ingestion.
870///
871/// # Parameters
872///
873/// - `handle`: Event bus handle from `net_init`.
874/// - `json`: JSON string (UTF-8).
875/// - `len`: Length of the JSON string in bytes.
876///
877/// # Returns
878///
879/// - `0` on success
880/// - Negative error code on failure
881#[unsafe(no_mangle)]
882pub unsafe extern "C" fn net_ingest_raw(
883    handle: *mut NetHandle,
884    json: *const c_char,
885    len: usize,
886) -> c_int {
887    if !handle_is_valid(handle) || json.is_null() {
888        return NetError::NullPointer.into();
889    }
890
891    let handle = unsafe { &*handle };
892    let _guard = match enter_ffi_op(handle) {
893        Ok(g) => g,
894        Err(err) => return err,
895    };
896
897    // `slice::from_raw_parts` requires `len <= isize::MAX`.
898    if len > isize::MAX as usize {
899        return NetError::InvalidJson.into();
900    }
901    let json_bytes = unsafe { std::slice::from_raw_parts(json as *const u8, len) };
902    let json_str = match std::str::from_utf8(json_bytes) {
903        Ok(s) => s,
904        Err(_) => return NetError::InvalidUtf8.into(),
905    };
906
907    let raw = RawEvent::from_str(json_str);
908
909    match handle.bus.ingest_raw(raw) {
910        Ok(_) => NetError::Success.into(),
911        Err(_) => NetError::IngestionFailed.into(),
912    }
913}
914
915/// Ingest multiple raw JSON strings (fastest batch path).
916///
917/// # Parameters
918///
919/// - `handle`: Event bus handle.
920/// - `jsons`: Array of pointers to JSON strings.
921/// - `lens`: Array of lengths for each JSON string.
922/// - `count`: Number of events in the arrays.
923///
924/// # Returns
925///
926/// Number of successfully ingested events, or negative error code.
927#[unsafe(no_mangle)]
928pub unsafe extern "C" fn net_ingest_raw_batch(
929    handle: *mut NetHandle,
930    jsons: *const *const c_char,
931    lens: *const usize,
932    count: usize,
933) -> c_int {
934    if !handle_is_valid(handle) || jsons.is_null() || lens.is_null() {
935        return NetError::NullPointer.into();
936    }
937    if count == 0 {
938        return 0;
939    }
940
941    let handle = unsafe { &*handle };
942    let _guard = match enter_ffi_op(handle) {
943        Ok(g) => g,
944        Err(err) => return err,
945    };
946    let mut events = Vec::with_capacity(count);
947    // Track per-entry drops so the caller's accounting can
948    // reconcile the returned count against the input count.
949    // Pre-fix per-entry rejects (null pointer, oversized length,
950    // invalid UTF-8) were silently `continue`-d and the caller
951    // saw `count - drops` accepted events without any signal as
952    // to which input indices were dropped. A binding that
953    // attributed the drop to back-pressure and retried got the
954    // wrong indices and double-published the good ones.
955    //
956    // The C-API contract is "returns count of accepted events";
957    // expanding it to take an out-param of dropped indices is
958    // an API addition, not a fix-in-place. Emit `tracing::warn!`
959    // with the offending index AND reason so operators
960    // observing the bus can correlate drop counts to specific
961    // inputs without changing the C surface. For high-volume
962    // bindings this should still be sized at one log line per
963    // dropped entry; if that ever matters in practice the
964    // `*_ex` follow-up can return the indices structurally.
965    let mut dropped_null = 0usize;
966    let mut dropped_oversize = 0usize;
967    let mut dropped_invalid_utf8 = 0usize;
968
969    for i in 0..count {
970        let json_ptr = unsafe { *jsons.add(i) };
971        let len = unsafe { *lens.add(i) };
972
973        if json_ptr.is_null() {
974            tracing::warn!(
975                index = i,
976                "net_ingest_raw_batch: dropping entry with null pointer"
977            );
978            dropped_null += 1;
979            continue;
980        }
981
982        // `slice::from_raw_parts` requires `len <= isize::MAX`.
983        // Skip pathological per-entry lengths rather than UB.
984        if len > isize::MAX as usize {
985            tracing::warn!(
986                index = i,
987                len,
988                "net_ingest_raw_batch: dropping entry with len > isize::MAX"
989            );
990            dropped_oversize += 1;
991            continue;
992        }
993        let json_bytes = unsafe { std::slice::from_raw_parts(json_ptr as *const u8, len) };
994        match std::str::from_utf8(json_bytes) {
995            Ok(json_str) => events.push(RawEvent::from_str(json_str)),
996            Err(_) => {
997                tracing::warn!(
998                    index = i,
999                    "net_ingest_raw_batch: dropping entry with invalid UTF-8"
1000                );
1001                dropped_invalid_utf8 += 1;
1002            }
1003        }
1004    }
1005    let total_dropped = dropped_null + dropped_oversize + dropped_invalid_utf8;
1006    if total_dropped > 0 {
1007        // Aggregate summary for log-pipeline filters that fold
1008        // per-index lines.
1009        tracing::warn!(
1010            input_count = count,
1011            dropped_null,
1012            dropped_oversize,
1013            dropped_invalid_utf8,
1014            "net_ingest_raw_batch: {} of {} entries dropped before ingest",
1015            total_dropped,
1016            count,
1017        );
1018    }
1019
1020    let count = handle.bus.ingest_raw_batch(events);
1021    // Returning `c_int::MAX` on overflow would be ambiguous with a real
1022    // `INT_MAX` ingest. Signal overflow explicitly so callers doing
1023    // accounting in high-throughput paths do not silently miscount.
1024    c_int::try_from(count).unwrap_or_else(|_| NetError::IntOverflow.into())
1025}
1026
1027/// Ingest multiple events.
1028///
1029/// # Parameters
1030///
1031/// - `handle`: Event bus handle.
1032/// - `events_json`: JSON array of events (UTF-8, null-terminated).
1033///
1034/// # Returns
1035///
1036/// Number of successfully ingested events, or negative error code.
1037#[unsafe(no_mangle)]
1038pub unsafe extern "C" fn net_ingest_batch(
1039    handle: *mut NetHandle,
1040    events_json: *const c_char,
1041) -> c_int {
1042    if !handle_is_valid(handle) || events_json.is_null() {
1043        return NetError::NullPointer.into();
1044    }
1045
1046    let handle = unsafe { &*handle };
1047    let _guard = match enter_ffi_op(handle) {
1048        Ok(g) => g,
1049        Err(err) => return err,
1050    };
1051
1052    let json_str = match unsafe { CStr::from_ptr(events_json) }.to_str() {
1053        Ok(s) => s,
1054        Err(_) => return NetError::InvalidUtf8.into(),
1055    };
1056
1057    // Parse as JSON array
1058    let array: Vec<serde_json::Value> = match serde_json::from_str(json_str) {
1059        Ok(a) => a,
1060        Err(_) => return NetError::InvalidJson.into(),
1061    };
1062
1063    let events: Vec<Event> = array.into_iter().map(Event::new).collect();
1064    let count = handle.bus.ingest_batch(events);
1065
1066    // Returning `c_int::MAX` on overflow would be ambiguous with a real
1067    // `INT_MAX` ingest. Signal overflow explicitly — matches the
1068    // `net_ingest_raw_batch` contract.
1069    c_int::try_from(count).unwrap_or_else(|_| NetError::IntOverflow.into())
1070}
1071
1072/// Parse the JSON request body passed to `net_poll` into a
1073/// `ConsumeRequest`. Returns the negative `NetError` code on parse
1074/// failure so the caller can surface it back across FFI. Both `limit`
1075/// and `cursor` are optional, but if either key is present with the
1076/// wrong JSON type it is an explicit error — silently falling back to
1077/// the default would hide caller bugs (e.g. the Go binding that
1078/// previously serialized `cursor` but had it dropped server-side).
1079fn parse_poll_request_json(json_str: &str) -> Result<ConsumeRequest, c_int> {
1080    let value: serde_json::Value =
1081        serde_json::from_str(json_str).map_err(|_| c_int::from(NetError::InvalidJson))?;
1082
1083    let limit = match value.get("limit") {
1084        None | Some(serde_json::Value::Null) => 100usize,
1085        Some(v) => match v.as_u64() {
1086            // `as usize` would silently truncate on 32-bit targets for
1087            // values above `usize::MAX`. Reject such inputs explicitly
1088            // so a caller asking for e.g. 2^33 events on a wasm32
1089            // build gets `InvalidJson` instead of a tiny wrap-around.
1090            Some(n) => usize::try_from(n).map_err(|_| c_int::from(NetError::InvalidJson))?,
1091            None => return Err(NetError::InvalidJson.into()),
1092        },
1093    };
1094    let cursor = match value.get("cursor") {
1095        None | Some(serde_json::Value::Null) => None,
1096        Some(v) => match v.as_str() {
1097            Some(s) => Some(s.to_owned()),
1098            None => return Err(NetError::InvalidJson.into()),
1099        },
1100    };
1101    let mut req = ConsumeRequest::new(limit);
1102    req.from_id = cursor;
1103    Ok(req)
1104}
1105
1106/// Build the JSON envelope `net_poll` writes into the caller's buffer.
1107///
1108/// **PERF_AUDIT §1.6.** Pre-fix every event was deserialized into a
1109/// full `serde_json::Value` tree (per-event hashmap + recursive
1110/// allocations + UTF-8 re-validation) and then re-serialized when the
1111/// envelope was emitted. Per event that's ~1-5 µs/KB plus an
1112/// unbounded chain of small allocations for the tree shape — a real
1113/// load on the FFI/SDK consume hot path.
1114///
1115/// The new path uses `from_slice::<&RawValue>`: validates the bytes
1116/// as well-formed JSON, hands back a borrowed view into the original
1117/// buffer, and lets the outer serialize splice those bytes verbatim
1118/// into the envelope. Zero per-event allocation on the parse-OK fast
1119/// path; the rare invalid-UTF-8 / invalid-JSON fallback still emits
1120/// the bytes as a JSON string so the caller can see what got skipped.
1121/// Events whose bytes are not even valid UTF-8 are skipped entirely
1122/// (only `parse_errors` records them) — same as the pre-fix path.
1123fn build_poll_envelope_json(response: &ConsumeResponse) -> Result<String, serde_json::Error> {
1124    use serde_json::value::RawValue;
1125    #[derive(serde::Serialize)]
1126    #[serde(untagged)]
1127    enum EventOut<'a> {
1128        Raw(&'a RawValue),
1129        Fallback(String),
1130    }
1131    let mut events_out: Vec<EventOut<'_>> = Vec::with_capacity(response.events.len());
1132    let mut parse_errors: usize = 0;
1133    for e in &response.events {
1134        match serde_json::from_slice::<&RawValue>(&e.raw) {
1135            Ok(rv) => events_out.push(EventOut::Raw(rv)),
1136            Err(_) => {
1137                parse_errors += 1;
1138                // Include the raw bytes as a string so the caller doesn't silently lose events
1139                if let Ok(raw) = e.raw_str() {
1140                    events_out.push(EventOut::Fallback(raw.to_string()));
1141                }
1142            }
1143        }
1144    }
1145    #[derive(serde::Serialize)]
1146    struct EnvelopeBorrowed<'a> {
1147        events: &'a [EventOut<'a>],
1148        next_id: Option<&'a str>,
1149        has_more: bool,
1150        count: usize,
1151        parse_errors: usize,
1152    }
1153    serde_json::to_string(&EnvelopeBorrowed {
1154        events: &events_out,
1155        next_id: response.next_id.as_deref(),
1156        has_more: response.has_more,
1157        count: events_out.len(),
1158        parse_errors,
1159    })
1160}
1161
1162/// Poll events from the bus.
1163///
1164/// # Parameters
1165///
1166/// - `handle`: Event bus handle.
1167/// - `request_json`: JSON request string (UTF-8, null-terminated).
1168///   Example: `{"limit": 100, "ordering": "InsertionTs"}`
1169/// - `out_buffer`: Output buffer for JSON response.
1170/// - `buffer_len`: Size of the output buffer.
1171///
1172/// # Returns
1173///
1174/// - Number of bytes written to buffer on success
1175/// - Negative error code on failure
1176#[unsafe(no_mangle)]
1177pub unsafe extern "C" fn net_poll(
1178    handle: *mut NetHandle,
1179    request_json: *const c_char,
1180    out_buffer: *mut c_char,
1181    buffer_len: usize,
1182) -> c_int {
1183    if !handle_is_valid(handle) || out_buffer.is_null() {
1184        return NetError::NullPointer.into();
1185    }
1186
1187    let handle = unsafe { &*handle };
1188    let _guard = match enter_ffi_op(handle) {
1189        Ok(g) => g,
1190        Err(err) => return err,
1191    };
1192
1193    // Parse request
1194    let request = if request_json.is_null() {
1195        ConsumeRequest::new(100)
1196    } else {
1197        let json_str = match unsafe { CStr::from_ptr(request_json) }.to_str() {
1198            Ok(s) => s,
1199            Err(_) => return NetError::InvalidUtf8.into(),
1200        };
1201        match parse_poll_request_json(json_str) {
1202            Ok(req) => req,
1203            Err(code) => return code,
1204        }
1205    };
1206
1207    // Reject buffers too small to even hold an empty-response
1208    // JSON envelope. This catches the degenerate "tiny buffer"
1209    // case before we hit the adapter — `BufferTooSmall` returned
1210    // here means "no work was done, caller's cursor is unchanged."
1211    // 256 bytes comfortably fits the empty-response JSON below
1212    // even with a long echoed `next_id` cursor.
1213    const MIN_RESPONSE_BUFFER: usize = 256;
1214    if buffer_len < MIN_RESPONSE_BUFFER {
1215        return NetError::BufferTooSmall.into();
1216    }
1217
1218    // Stash the cursor before moving `request` into `poll()` so
1219    // the post-poll fallback can echo it back to the caller. On
1220    // overflow we write a minimal "no events delivered, cursor
1221    // unchanged" response so the caller's next poll re-fetches
1222    // the same range — events are not lost on idempotent
1223    // adapters (Redis XRANGE, JetStream direct_get).
1224    let cursor_snapshot = request.from_id.clone();
1225
1226    // Poll
1227    let response = match handle.runtime.block_on(handle.bus.poll(request)) {
1228        Ok(r) => r,
1229        Err(_) => return NetError::PollFailed.into(),
1230    };
1231
1232    // Serialize response. Events that fail to parse are included as raw
1233    // strings so the caller can see all events and detect parse failures.
1234    let total_events = response.events.len();
1235    let response_json = match build_poll_envelope_json(&response) {
1236        Ok(s) => s,
1237        Err(_) => return NetError::Unknown.into(),
1238    };
1239
1240    // Buffer overflow: emit a minimal fallback response that echoes
1241    // the caller's original cursor as `next_id`. The caller's next
1242    // poll runs against the same range and re-delivers the events
1243    // (idempotent on Redis XRANGE / JetStream direct_get). Without
1244    // this, a caller that trusts `next_id` blindly would advance
1245    // past the unread batch.
1246    if response_json.len() + 1 > buffer_len {
1247        let fallback = serde_json::to_string(&serde_json::json!({
1248            "events": [],
1249            "next_id": cursor_snapshot,
1250            "has_more": true,
1251            "count": 0,
1252            "parse_errors": 0,
1253            "buffer_too_small": true,
1254            "events_dropped": total_events,
1255        }))
1256        .unwrap_or_else(|_| String::from(
1257            r#"{"events":[],"next_id":null,"has_more":true,"count":0,"parse_errors":0,"buffer_too_small":true}"#
1258        ));
1259        if fallback.len() < buffer_len {
1260            unsafe {
1261                ptr::copy_nonoverlapping(
1262                    fallback.as_ptr() as *const c_char,
1263                    out_buffer,
1264                    fallback.len(),
1265                );
1266                *out_buffer.add(fallback.len()) = 0;
1267            }
1268        }
1269        return NetError::BufferTooSmall.into();
1270    }
1271
1272    // Copy to output buffer
1273    unsafe {
1274        ptr::copy_nonoverlapping(
1275            response_json.as_ptr() as *const c_char,
1276            out_buffer,
1277            response_json.len(),
1278        );
1279        *out_buffer.add(response_json.len()) = 0; // Null terminate
1280    }
1281
1282    // Data was already copied into the caller's buffer; a
1283    // `c_int` overflow here means the byte count exceeds c_int's
1284    // range, NOT that the buffer was too small. Returning
1285    // `BufferTooSmall` would tell the caller to "resize and retry"
1286    // when retrying can't fix the actual condition. `IntOverflow`
1287    // is the documented variant for this case.
1288    match c_int::try_from(response_json.len()) {
1289        Ok(n) => n,
1290        Err(_) => NetError::IntOverflow.into(),
1291    }
1292}
1293
1294/// Get event bus statistics.
1295///
1296/// # Parameters
1297///
1298/// - `handle`: Event bus handle.
1299/// - `out_buffer`: Output buffer for JSON statistics.
1300/// - `buffer_len`: Size of the output buffer.
1301///
1302/// # Returns
1303///
1304/// Number of bytes written, or negative error code.
1305#[unsafe(no_mangle)]
1306pub unsafe extern "C" fn net_stats(
1307    handle: *mut NetHandle,
1308    out_buffer: *mut c_char,
1309    buffer_len: usize,
1310) -> c_int {
1311    if !handle_is_valid(handle) || out_buffer.is_null() {
1312        return NetError::NullPointer.into();
1313    }
1314
1315    let handle = unsafe { &*handle };
1316    let _guard = match enter_ffi_op(handle) {
1317        Ok(g) => g,
1318        Err(err) => return err,
1319    };
1320    let stats = handle.bus.stats();
1321    let shard_stats = handle.bus.shard_stats();
1322
1323    let stats_json = match serde_json::to_string(&serde_json::json!({
1324        "events_ingested": stats.events_ingested.load(std::sync::atomic::Ordering::Relaxed),
1325        "events_dropped": stats.events_dropped.load(std::sync::atomic::Ordering::Relaxed),
1326        "batches_dispatched": stats.batches_dispatched.load(std::sync::atomic::Ordering::Relaxed),
1327        "shard_events_ingested": shard_stats.events_ingested,
1328        "shard_events_dropped": shard_stats.events_dropped,
1329        "shard_batches_dispatched": shard_stats.batches_dispatched,
1330    })) {
1331        Ok(s) => s,
1332        Err(_) => return NetError::Unknown.into(),
1333    };
1334
1335    if stats_json.len() + 1 > buffer_len {
1336        return NetError::BufferTooSmall.into();
1337    }
1338
1339    unsafe {
1340        ptr::copy_nonoverlapping(
1341            stats_json.as_ptr() as *const c_char,
1342            out_buffer,
1343            stats_json.len(),
1344        );
1345        *out_buffer.add(stats_json.len()) = 0;
1346    }
1347
1348    // See net_poll above — the data was already copied, so an
1349    // overflowing length is `IntOverflow`, not `BufferTooSmall`.
1350    match c_int::try_from(stats_json.len()) {
1351        Ok(n) => n,
1352        Err(_) => NetError::IntOverflow.into(),
1353    }
1354}
1355
1356/// Flush all pending batches to the adapter.
1357///
1358/// # Parameters
1359///
1360/// - `handle`: Event bus handle.
1361///
1362/// # Returns
1363///
1364/// - `0` on success
1365/// - Negative error code on failure
1366#[unsafe(no_mangle)]
1367pub unsafe extern "C" fn net_flush(handle: *mut NetHandle) -> c_int {
1368    if !handle_is_valid(handle) {
1369        return NetError::NullPointer.into();
1370    }
1371
1372    let handle = unsafe { &*handle };
1373    let _guard = match enter_ffi_op(handle) {
1374        Ok(g) => g,
1375        Err(err) => return err,
1376    };
1377
1378    match handle.runtime.block_on(handle.bus.flush()) {
1379        Ok(_) => NetError::Success.into(),
1380        Err(_) => NetError::Unknown.into(),
1381    }
1382}
1383
1384/// Shut down the event bus and free resources.
1385///
1386/// # Parameters
1387///
1388/// - `handle`: Event bus handle. After this call, the handle is invalid.
1389///
1390/// # Returns
1391///
1392/// - `0` on success
1393/// - Negative error code on failure (including `Unknown` if the
1394///   bounded wait for in-flight FFI operations expired before the bus
1395///   could be shut down cleanly)
1396///
1397/// # Notes
1398///
1399/// The handle's storage is intentionally leaked: the box is never
1400/// returned to the allocator. See `NetHandle`'s docs for why. This is
1401/// a one-time cost per shutdown — typically per-process, since most C
1402/// callers initialize the bus once and shut down once.
1403#[unsafe(no_mangle)]
1404pub unsafe extern "C" fn net_shutdown(handle: *mut NetHandle) -> c_int {
1405    if !handle_is_valid(handle) {
1406        return NetError::NullPointer.into();
1407    }
1408
1409    // Scope the `&NetHandle` borrow into an inner block so it is
1410    // verifiably out of scope before the
1411    // `ManuallyDrop::take(&mut (*handle).bus)` calls below.
1412    // Holding an `&NetHandle` in scope for the whole function
1413    // while taking a raw `&mut (*handle).bus` later would rely on
1414    // NLL ending the immutable borrow before the mutable take —
1415    // a pattern fragile under stacked/tree borrow models. The
1416    // block-scoped borrow makes the lifetime constraint explicit
1417    // and obvious to both the compiler and any future maintainer.
1418    let drained_and_taken = {
1419        // SAFETY: The C contract guarantees `handle` is valid here and that
1420        // `net_shutdown` is not called concurrently with itself. Future
1421        // dereferences of the box from concurrent FFI ops on other threads
1422        // are also sound because we never free the box (see below).
1423        let handle_ref = unsafe { &*handle };
1424
1425        // Signal shutdown so concurrent FFI calls bail before touching
1426        // `bus`/`runtime`. SeqCst pairs with `FfiOpGuard::try_enter`.
1427        handle_ref
1428            .shutting_down
1429            .store(true, std::sync::atomic::Ordering::SeqCst);
1430
1431        // Bounded wait for in-flight ops to drain. Without a deadline, a
1432        // hung concurrent operation (e.g. `net_flush` against a stalled
1433        // adapter) would pin a CPU at 100% inside this loop forever.
1434        //
1435        // `std::hint::spin_loop()` is a CPU pause hint, not a yield. On
1436        // a single-threaded executor (or any configuration where the FFI
1437        // caller's thread is the same one that needs to make progress on
1438        // the in-flight async work) the tight spin starves the very tokio
1439        // worker we're waiting for, *causing* the deadline to expire when
1440        // it otherwise wouldn't. `thread::yield_now` lets the OS schedule
1441        // whatever's blocked, and a 1ms `thread::sleep` between yields
1442        // prevents the loop from saturating a CPU on platforms where
1443        // `yield_now` is a
1444        // near-no-op under low contention. The drain we expect to take
1445        // milliseconds, so a millisecond-granularity poll is fine.
1446        let deadline = std::time::Instant::now() + FFI_SHUTDOWN_DEADLINE;
1447        let mut drained = false;
1448        loop {
1449            if handle_ref
1450                .active_ops
1451                .load(std::sync::atomic::Ordering::SeqCst)
1452                == 0
1453            {
1454                drained = true;
1455                break;
1456            }
1457            if std::time::Instant::now() >= deadline {
1458                break;
1459            }
1460            std::thread::yield_now();
1461            std::thread::sleep(std::time::Duration::from_millis(1));
1462        }
1463
1464        if !drained {
1465            // In-flight ops may still be reading `bus`/`runtime`; reading
1466            // them out via `ManuallyDrop::take` would race those readers.
1467            // Leak both fields along with the box. Future ops still see
1468            // `shutting_down=true` and bail before touching either field,
1469            // so the leaked memory is never read again.
1470            return NetError::Unknown.into();
1471        }
1472
1473        // Idempotent shutdown: if a previous `net_shutdown` already
1474        // moved out the bus/runtime, do not call `ManuallyDrop::take`
1475        // a second time (that would be UB). The first call may still
1476        // be inside `runtime.block_on(bus.shutdown())` though — pre-
1477        // fix the second caller observed `bus_taken == true` and
1478        // returned `Success` immediately, falsely signaling
1479        // completion of an in-progress shutdown. Spin on
1480        // `shutdown_completed` (set by the first caller AFTER
1481        // `bus.shutdown()` returns) so subsequent callers wait for
1482        // the actual completion.
1483        if handle_ref
1484            .bus_taken
1485            .swap(true, std::sync::atomic::Ordering::SeqCst)
1486        {
1487            // Wait for the first caller to actually finish.
1488            // Bounded by the same FFI_SHUTDOWN_DEADLINE as the
1489            // `active_ops` drain — if the first caller is wedged
1490            // longer than that, we surface a Transient error rather
1491            // than block forever.
1492            let inner_deadline = std::time::Instant::now() + FFI_SHUTDOWN_DEADLINE;
1493            while !handle_ref
1494                .shutdown_completed
1495                .load(std::sync::atomic::Ordering::Acquire)
1496            {
1497                if std::time::Instant::now() >= inner_deadline {
1498                    return NetError::Unknown.into();
1499                }
1500                std::thread::yield_now();
1501                std::thread::sleep(std::time::Duration::from_millis(1));
1502            }
1503            return NetError::Success.into();
1504        }
1505        drained
1506    };
1507    let _ = drained_and_taken;
1508
1509    // SAFETY: `active_ops` reached zero with `shutting_down=true`, so:
1510    //   - Every FFI op that started before shutdown has fully
1511    //     completed (decremented `active_ops` on guard drop).
1512    //   - Any future FFI op will observe `shutting_down=true` and
1513    //     bail in `try_enter` before touching `bus` / `runtime`.
1514    // Plus, `bus_taken` was just CAS'd from false → true, so no other
1515    // shutdown is concurrently moving the same fields out. The
1516    // immutable `handle_ref` borrow above has been dropped (block
1517    // scope ended), so the `&mut`-via-raw-pointer below is the
1518    // only live access — no stacked/tree-borrow race.
1519    //
1520    // We deliberately do NOT call `Box::from_raw` here. The box's
1521    // `shutting_down` / `active_ops` / `bus_taken` atomics must remain
1522    // valid memory because future FFI ops still dereference the
1523    // C-side pointer to check them. Leaking the box is the
1524    // correctness fix for the previous use-after-free; the per-handle
1525    // storage cost is a one-time overhead.
1526    let bus = unsafe { std::mem::ManuallyDrop::take(&mut (*handle).bus) };
1527    let runtime = unsafe { std::mem::ManuallyDrop::take(&mut (*handle).runtime) };
1528
1529    // Flush pending batches and gracefully shut down the adapter
1530    // before dropping the runtime. Without this, pending events in
1531    // ring buffers and batch workers would be silently lost.
1532    let result = runtime.block_on(bus.shutdown());
1533
1534    // `bus` and `runtime` go out of scope here and are dropped.
1535    // The leaked box keeps the atomics alive for any straggler ops.
1536
1537    // Signal completion to any second/third caller spinning on
1538    // `shutdown_completed` in the idempotent path above. Done
1539    // AFTER `bus.shutdown()` returns and AFTER the bus / runtime
1540    // drop, so subsequent callers can rely on this flag as a
1541    // hard "shutdown is fully done" barrier.
1542    unsafe { &*handle }
1543        .shutdown_completed
1544        .store(true, std::sync::atomic::Ordering::Release);
1545
1546    match result {
1547        Ok(()) => NetError::Success.into(),
1548        Err(_) => NetError::Unknown.into(),
1549    }
1550}
1551
1552/// Get the number of shards.
1553///
1554/// # Parameters
1555///
1556/// - `handle`: Event bus handle.
1557///
1558/// # Returns
1559///
1560/// Number of shards, or 0 if handle is null.
1561#[unsafe(no_mangle)]
1562pub unsafe extern "C" fn net_num_shards(handle: *mut NetHandle) -> u16 {
1563    if !handle_is_valid(handle) {
1564        return 0;
1565    }
1566    let handle = unsafe { &*handle };
1567    let _guard = match enter_ffi_op(handle) {
1568        Ok(g) => g,
1569        Err(_) => return 0,
1570    };
1571    handle.bus.num_shards()
1572}
1573
1574/// Get the library version.
1575///
1576/// # Returns
1577///
1578/// Version string (static, do not free).
1579#[unsafe(no_mangle)]
1580pub unsafe extern "C" fn net_version() -> *const c_char {
1581    static VERSION: &[u8] = b"0.8.0\0";
1582    VERSION.as_ptr() as *const c_char
1583}
1584
1585/// Generate a new Net keypair.
1586///
1587/// # Returns
1588///
1589/// JSON string with hex-encoded public_key and secret_key.
1590/// The caller must free the returned string with `net_free_string`.
1591/// Returns NULL if Net feature is not enabled.
1592#[cfg(feature = "net")]
1593#[unsafe(no_mangle)]
1594pub unsafe extern "C" fn net_generate_keypair() -> *mut c_char {
1595    let keypair = StaticKeypair::generate();
1596    let json = serde_json::json!({
1597        "public_key": hex::encode(keypair.public_key()),
1598        "secret_key": hex::encode(keypair.secret_key()),
1599    });
1600
1601    match CString::new(json.to_string()) {
1602        Ok(s) => s.into_raw(),
1603        Err(_) => ptr::null_mut(),
1604    }
1605}
1606
1607/// Free a string returned by Net functions.
1608///
1609/// # Parameters
1610///
1611/// - `s`: String pointer returned by `net_generate_keypair` or similar.
1612#[cfg(feature = "net")]
1613#[unsafe(no_mangle)]
1614pub unsafe extern "C" fn net_free_string(s: *mut c_char) {
1615    if !s.is_null() {
1616        unsafe {
1617            drop(CString::from_raw(s));
1618        }
1619    }
1620}
1621
1622// `net.h` declares both `net_generate_keypair` and
1623// `net_free_string` unconditionally — a consumer linking against
1624// a cdylib built without the `net` feature would otherwise hit
1625// a load-time missing-symbol error despite the header advertising
1626// the symbol. Provide always-empty stubs so the symbol is
1627// resolvable on every build configuration. Mirrors the
1628// `nat-traversal` cfg pattern in `mesh.rs`.
1629
1630/// Stub for builds without the `net` feature.
1631///
1632/// `net.h` declares `net_generate_keypair` unconditionally, so
1633/// the symbol must be resolvable on every build configuration.
1634/// Returns NULL since keypair generation requires the net feature.
1635#[cfg(not(feature = "net"))]
1636#[unsafe(no_mangle)]
1637pub unsafe extern "C" fn net_generate_keypair() -> *mut c_char {
1638    ptr::null_mut()
1639}
1640
1641/// Stub for builds without the `net` feature.
1642///
1643/// Mirrors the always-on signature in `net.h`. Reclaims a
1644/// CString-allocated pointer if non-null.
1645#[cfg(not(feature = "net"))]
1646#[unsafe(no_mangle)]
1647pub unsafe extern "C" fn net_free_string(s: *mut c_char) {
1648    if !s.is_null() {
1649        unsafe {
1650            drop(std::ffi::CString::from_raw(s));
1651        }
1652    }
1653}
1654
1655// =========================================================================
1656// Structured (non-JSON) API — _ex variants
1657// =========================================================================
1658
1659/// Ingestion receipt for C consumers.
1660#[repr(C)]
1661pub struct NetReceipt {
1662    /// Shard the event was assigned to.
1663    pub shard_id: u16,
1664    /// Insertion timestamp (nanoseconds).
1665    pub timestamp: u64,
1666}
1667
1668// Pin layout invariants for `NetReceipt`. `#[repr(C)]` already
1669// gives C ABI compatibility per platform, but doesn't catch a
1670// future field-reorder or field-add — both would silently break
1671// any C/Go/Python binding that hard-codes the struct layout.
1672// Static asserts on 64-bit targets (the production deployment
1673// shape) trip CI before such a change reaches a binary release.
1674//
1675// 64-bit: `u16 (2) + 6 pad + u64 (8)` = 16 bytes, alignment 8.
1676#[cfg(target_pointer_width = "64")]
1677const _: () = assert!(
1678    std::mem::size_of::<NetReceipt>() == 16,
1679    "NetReceipt size changed on 64-bit; bindings hard-code 16. \
1680     If the change is intentional, bump the binding versions and \
1681     update this assertion."
1682);
1683#[cfg(target_pointer_width = "64")]
1684const _: () = assert!(
1685    std::mem::align_of::<NetReceipt>() == 8,
1686    "NetReceipt alignment changed on 64-bit; bindings expect 8."
1687);
1688
1689/// A single stored event for C consumers.
1690///
1691/// # Safety contract for callers
1692///
1693/// `id`/`id_len` and `raw`/`raw_len` are produced by Rust as a
1694/// `Box<[u8]>` whose fat-pointer length is reconstructed at free
1695/// time from `id_len` / `raw_len`. The fields are `pub` because
1696/// `#[repr(C)]` exposes them to C, **but they must be treated as
1697/// read-only** between the `net_poll_*` call that produced them
1698/// and the `net_free_poll_result` that consumes them.
1699///
1700/// Mutating `id_len` or `raw_len` (or copying the struct, replacing
1701/// the pointer, and then freeing) causes
1702/// `Box::from_raw(slice_from_raw_parts_mut(ptr, wrong_len))` to be
1703/// undefined behavior on free — the allocator records the
1704/// allocation size and any mismatch is UB.
1705#[repr(C)]
1706pub struct NetEvent {
1707    /// Event ID (not null-terminated, use `id_len`).
1708    /// Read-only after `net_poll_*`; do not mutate.
1709    pub id: *const c_char,
1710    /// Length of the event ID. Read-only after `net_poll_*`; do not
1711    /// mutate (mutation causes UB on free).
1712    pub id_len: usize,
1713    /// Raw JSON payload (not null-terminated, use `raw_len`).
1714    /// Read-only after `net_poll_*`; do not mutate.
1715    pub raw: *const c_char,
1716    /// Length of the raw JSON payload. Read-only after
1717    /// `net_poll_*`; do not mutate (mutation causes UB on free).
1718    pub raw_len: usize,
1719    /// Insertion timestamp (nanoseconds).
1720    pub insertion_ts: u64,
1721    /// Shard ID.
1722    pub shard_id: u16,
1723}
1724
1725// Pin layout invariants for `NetEvent`. See `NetReceipt`'s
1726// asserts for rationale. Bindings (C, Go, Python, Node) hard-
1727// code 48 bytes on 64-bit; an accidental reorder or new field
1728// would silently shift every offset.
1729//
1730// 64-bit: `4 × 8 (ptrs/usize) + u64 (8) + u16 (2) + 6 trail` = 48.
1731#[cfg(target_pointer_width = "64")]
1732const _: () = assert!(
1733    std::mem::size_of::<NetEvent>() == 48,
1734    "NetEvent size changed on 64-bit; bindings hard-code 48. \
1735     If the change is intentional, bump the binding versions and \
1736     update this assertion."
1737);
1738#[cfg(target_pointer_width = "64")]
1739const _: () = assert!(
1740    std::mem::align_of::<NetEvent>() == 8,
1741    "NetEvent alignment changed on 64-bit; bindings expect 8."
1742);
1743
1744/// Poll result for C consumers.
1745#[repr(C)]
1746pub struct NetPollResult {
1747    /// Array of events. Free with `net_free_poll_result`.
1748    pub events: *mut NetEvent,
1749    /// Number of events in the array.
1750    pub count: usize,
1751    /// Cursor for the next poll (null-terminated). NULL if no more.
1752    pub next_id: *mut c_char,
1753    /// 1 if more events are available, 0 otherwise.
1754    pub has_more: c_int,
1755}
1756
1757/// Stats for C consumers.
1758#[repr(C)]
1759pub struct NetStats {
1760    /// Total events ingested.
1761    pub events_ingested: u64,
1762    /// Events dropped due to backpressure.
1763    pub events_dropped: u64,
1764    /// Batches dispatched to the adapter.
1765    pub batches_dispatched: u64,
1766}
1767
1768/// Ingest raw JSON with structured receipt.
1769#[unsafe(no_mangle)]
1770pub unsafe extern "C" fn net_ingest_raw_ex(
1771    handle: *mut NetHandle,
1772    json: *const c_char,
1773    len: usize,
1774    out: *mut NetReceipt,
1775) -> c_int {
1776    if !handle_is_valid(handle) || json.is_null() {
1777        return NetError::NullPointer.into();
1778    }
1779
1780    let handle = unsafe { &*handle };
1781    let _guard = match enter_ffi_op(handle) {
1782        Ok(g) => g,
1783        Err(err) => return err,
1784    };
1785
1786    // `slice::from_raw_parts` requires `len <= isize::MAX`.
1787    if len > isize::MAX as usize {
1788        return NetError::InvalidJson.into();
1789    }
1790    let json_bytes = unsafe { std::slice::from_raw_parts(json as *const u8, len) };
1791    let json_str = match std::str::from_utf8(json_bytes) {
1792        Ok(s) => s,
1793        Err(_) => return NetError::InvalidUtf8.into(),
1794    };
1795
1796    let raw = RawEvent::from_str(json_str);
1797
1798    match handle.bus.ingest_raw(raw) {
1799        Ok((shard_id, timestamp)) => {
1800            if !out.is_null() {
1801                unsafe {
1802                    (*out).shard_id = shard_id;
1803                    (*out).timestamp = timestamp;
1804                }
1805            }
1806            NetError::Success.into()
1807        }
1808        Err(_) => NetError::IngestionFailed.into(),
1809    }
1810}
1811
1812/// Poll events with structured result (no JSON overhead).
1813///
1814/// The caller must free the result with `net_free_poll_result`.
1815#[unsafe(no_mangle)]
1816pub unsafe extern "C" fn net_poll_ex(
1817    handle: *mut NetHandle,
1818    limit: usize,
1819    cursor: *const c_char,
1820    out: *mut NetPollResult,
1821) -> c_int {
1822    if !handle_is_valid(handle) || out.is_null() {
1823        return NetError::NullPointer.into();
1824    }
1825
1826    // Pre-validate `limit` BEFORE calling `bus.poll` — the bus
1827    // advances the consumer cursor before returning, so any
1828    // post-poll allocation failure (e.g. `Layout::array::<NetEvent>`
1829    // overflow on a pathological `count`, or `std::alloc::alloc`
1830    // returning null under memory pressure) would drop the response
1831    // and lose every event the cursor just stepped past. Reject
1832    // requests whose `count * size_of::<NetEvent>` would overflow
1833    // `isize::MAX` (the `Layout::array` cap) up front, so the
1834    // failure happens before the cursor moves.
1835    if limit > 0
1836        && (std::mem::size_of::<NetEvent>())
1837            .checked_mul(limit)
1838            .is_none_or(|v| v > isize::MAX as usize)
1839    {
1840        return NetError::IntOverflow.into();
1841    }
1842
1843    let handle = unsafe { &*handle };
1844    let _guard = match enter_ffi_op(handle) {
1845        Ok(g) => g,
1846        Err(err) => return err,
1847    };
1848
1849    let mut request = ConsumeRequest::new(limit);
1850    if !cursor.is_null() {
1851        if let Ok(s) = unsafe { CStr::from_ptr(cursor) }.to_str() {
1852            if !s.is_empty() {
1853                request = request.from(s);
1854            }
1855        }
1856    }
1857
1858    let response = match handle.runtime.block_on(handle.bus.poll(request)) {
1859        Ok(r) => r,
1860        Err(_) => return NetError::PollFailed.into(),
1861    };
1862
1863    let count = response.events.len();
1864
1865    // Allocate events array.
1866    //
1867    // Each iteration allocates two boxed byte slices via
1868    // `Vec::to_vec().into_boxed_slice()`, which panic on OOM in
1869    // the global allocator. A panic across this `extern "C"`
1870    // body is UB — under the cgo/N-API/cffi unwind model the
1871    // panic propagates into a frame that doesn't expect it. Wrap
1872    // the per-event build in `catch_unwind`, track how many
1873    // events we've fully written, and on panic / mid-loop
1874    // failure free the partial array via `free_events_array`
1875    // so neither UB nor the partial allocations leak.
1876    let events_ptr = if count > 0 {
1877        let layout = match std::alloc::Layout::array::<NetEvent>(count) {
1878            Ok(l) => l,
1879            Err(_) => return NetError::Unknown.into(),
1880        };
1881        let ptr = unsafe { std::alloc::alloc(layout) as *mut NetEvent };
1882        if ptr.is_null() {
1883            return NetError::Unknown.into();
1884        }
1885
1886        // Shared counter so the outer scope can clean up partial
1887        // writes if any iteration panics.
1888        let completed = std::cell::Cell::new(0usize);
1889        let build_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1890            for (i, event) in response.events.iter().enumerate() {
1891                let id_bytes = event.id.as_bytes().to_vec().into_boxed_slice();
1892                let id_len = id_bytes.len();
1893                let id_ptr = Box::into_raw(id_bytes) as *const c_char;
1894
1895                let raw_bytes = event.raw.to_vec().into_boxed_slice();
1896                let raw_len = raw_bytes.len();
1897                let raw_ptr = Box::into_raw(raw_bytes) as *const c_char;
1898
1899                unsafe {
1900                    ptr.add(i).write(NetEvent {
1901                        id: id_ptr,
1902                        id_len,
1903                        raw: raw_ptr,
1904                        raw_len,
1905                        insertion_ts: event.insertion_ts,
1906                        shard_id: event.shard_id,
1907                    });
1908                }
1909                completed.set(i + 1);
1910            }
1911        }));
1912        if build_result.is_err() {
1913            // A panic landed mid-loop. Free fully-written events
1914            // (those past `completed.get()` were never written, so
1915            // the inner `id`/`raw` pointers aren't valid). The
1916            // events array was allocated for `count` NetEvent
1917            // slots, so the dealloc must use that same layout.
1918            free_events_array_partial(ptr, completed.get(), count);
1919            return NetError::Unknown.into();
1920        }
1921        ptr
1922    } else {
1923        ptr::null_mut()
1924    };
1925
1926    // Leak next_id if present.
1927    let next_id_ptr = match response.next_id {
1928        Some(ref s) => match std::ffi::CString::new(s.as_str()) {
1929            Ok(c) => c.into_raw(),
1930            Err(_) => {
1931                // Free already-allocated events before returning
1932                // error. `s.as_str()` is valid UTF-8 by `String`
1933                // invariant, so this is the interior-NUL path —
1934                // an upstream cursor id that contains `\0` cannot
1935                // round-trip through a C string. Pre-fix this
1936                // returned `InvalidUtf8`, which mis-described
1937                // the cause; bindings now see the more accurate
1938                // `InteriorNul`.
1939                free_events_array(events_ptr, count);
1940                return NetError::InteriorNul.into();
1941            }
1942        },
1943        None => ptr::null_mut(),
1944    };
1945
1946    unsafe {
1947        (*out).events = events_ptr;
1948        (*out).count = count;
1949        (*out).next_id = next_id_ptr;
1950        (*out).has_more = if response.has_more { 1 } else { 0 };
1951    }
1952
1953    NetError::Success.into()
1954}
1955
1956/// Free an events array and all its id/raw allocations.
1957///
1958/// `count` is the number of fully-written events (those whose
1959/// inner `id` / `raw` boxed slices were initialized). It must
1960/// also match the `Layout::array::<NetEvent>` used at allocation
1961/// time — every existing caller writes exactly `count` events
1962/// before invoking this function. For partial-cleanup paths
1963/// (e.g. panic mid-build), use [`free_events_array_partial`].
1964fn free_events_array(events: *mut NetEvent, count: usize) {
1965    free_events_array_partial(events, count, count);
1966}
1967
1968/// Free an events array where only `walk_count` entries have
1969/// fully-initialized `id`/`raw` allocations, but the array
1970/// itself was allocated for `alloc_count` slots. Per-event
1971/// boxes are freed for `0..walk_count`; the array is then
1972/// deallocated with the original `Layout::array::<NetEvent>(alloc_count)`
1973/// to match the allocation. Used by `net_poll_ex`'s panic-mid-loop
1974/// recovery path.
1975fn free_events_array_partial(events: *mut NetEvent, walk_count: usize, alloc_count: usize) {
1976    if events.is_null() || alloc_count == 0 {
1977        return;
1978    }
1979    for i in 0..walk_count {
1980        let event = unsafe { &*events.add(i) };
1981        if !event.id.is_null() {
1982            unsafe {
1983                let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(
1984                    event.id as *mut u8,
1985                    event.id_len,
1986                ));
1987            }
1988        }
1989        if !event.raw.is_null() {
1990            unsafe {
1991                let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(
1992                    event.raw as *mut u8,
1993                    event.raw_len,
1994                ));
1995            }
1996        }
1997    }
1998    if let Ok(layout) = std::alloc::Layout::array::<NetEvent>(alloc_count) {
1999        unsafe {
2000            std::alloc::dealloc(events as *mut u8, layout);
2001        }
2002    }
2003}
2004
2005/// Free the internal allocations of a poll result returned by `net_poll_ex`.
2006///
2007/// This frees the events array (including each event's `id` and `raw` buffers)
2008/// and the `next_id` string. It does **not** free the `NetPollResult` struct
2009/// itself, which is caller-provided (typically stack-allocated or managed by
2010/// the caller).
2011#[unsafe(no_mangle)]
2012pub unsafe extern "C" fn net_free_poll_result(result: *mut NetPollResult) {
2013    if result.is_null() {
2014        return;
2015    }
2016
2017    let result = unsafe { &mut *result };
2018
2019    // Free events array and all id/raw allocations.
2020    free_events_array(result.events, result.count);
2021
2022    // Free next_id.
2023    if !result.next_id.is_null() {
2024        unsafe {
2025            drop(std::ffi::CString::from_raw(result.next_id));
2026        }
2027    }
2028
2029    // Null the fields so a second `net_free_poll_result` on the
2030    // same struct is a safe no-op rather than a double-free. The
2031    // C header's contract just says "free a poll result"; without
2032    // this clear, a defensive caller calling free twice (or two
2033    // wrappers each calling free in their destructor) would
2034    // re-`Box::from_raw` an already-freed pointer.
2035    result.events = std::ptr::null_mut();
2036    result.count = 0;
2037    result.next_id = std::ptr::null_mut();
2038    result.has_more = 0;
2039}
2040
2041/// Get stats without JSON serialization.
2042#[unsafe(no_mangle)]
2043pub unsafe extern "C" fn net_stats_ex(handle: *mut NetHandle, out: *mut NetStats) -> c_int {
2044    if !handle_is_valid(handle) || out.is_null() {
2045        return NetError::NullPointer.into();
2046    }
2047
2048    let handle = unsafe { &*handle };
2049    let _guard = match enter_ffi_op(handle) {
2050        Ok(g) => g,
2051        Err(err) => return err,
2052    };
2053    let stats = handle.bus.stats();
2054
2055    unsafe {
2056        (*out).events_ingested = stats
2057            .events_ingested
2058            .load(std::sync::atomic::Ordering::Relaxed);
2059        (*out).events_dropped = stats
2060            .events_dropped
2061            .load(std::sync::atomic::Ordering::Relaxed);
2062        (*out).batches_dispatched = stats
2063            .batches_dispatched
2064            .load(std::sync::atomic::Ordering::Relaxed);
2065    }
2066
2067    NetError::Success.into()
2068}
2069
2070#[cfg(test)]
2071mod tests {
2072    use super::*;
2073
2074    /// Build a `ConsumeResponse` with the given raw event payloads —
2075    /// shared fixture for the PERF_AUDIT §1.6 envelope tests.
2076    fn poll_response_with_raw(raws: Vec<bytes::Bytes>) -> ConsumeResponse {
2077        let events = raws
2078            .into_iter()
2079            .enumerate()
2080            .map(|(i, raw)| crate::event::StoredEvent::new(format!("ev-{i}"), raw, i as u64, 0))
2081            .collect();
2082        ConsumeResponse {
2083            events,
2084            next_id: Some("cursor-42".to_string()),
2085            has_more: true,
2086            truncated_at_per_shard_cap: false,
2087            stalled_shards: Vec::new(),
2088            failed_shards: Vec::new(),
2089        }
2090    }
2091
2092    /// PERF_AUDIT §1.6 — the poll envelope must splice valid event
2093    /// bytes through verbatim (no `Value` round-trip). Key order and
2094    /// number formatting are the two observable fingerprints of the
2095    /// old parse→re-serialize path: a BTreeMap-backed `Value` would
2096    /// re-order `z` before `a` alphabetically and could rewrite
2097    /// `1.0`. The envelope's shape fields must match the pre-fix
2098    /// `json!` output: same keys, same value types.
2099    #[test]
2100    fn poll_envelope_splices_raw_event_bytes_verbatim() {
2101        let raw = br#"{"z":1.0,"a":2}"#;
2102        let response = poll_response_with_raw(vec![bytes::Bytes::from_static(raw)]);
2103        let json = build_poll_envelope_json(&response).unwrap();
2104
2105        assert!(
2106            json.contains(std::str::from_utf8(raw).unwrap()),
2107            "event bytes must appear verbatim in the envelope; got {json}"
2108        );
2109        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
2110        assert_eq!(v["count"], 1);
2111        assert_eq!(v["parse_errors"], 0);
2112        assert_eq!(v["has_more"], true);
2113        assert_eq!(v["next_id"], "cursor-42");
2114        assert_eq!(v["events"][0]["z"], 1.0);
2115        assert_eq!(v["events"][0]["a"], 2);
2116    }
2117
2118    /// PERF_AUDIT §1.6 — invalid JSON (but valid UTF-8) must fail
2119    /// closed into the string fallback: the event shows up as a JSON
2120    /// string (caller can see what got skipped), `parse_errors`
2121    /// counts it, and the envelope is still valid JSON. No panic, no
2122    /// silent loss.
2123    #[test]
2124    fn poll_envelope_invalid_json_falls_back_to_string() {
2125        let response = poll_response_with_raw(vec![
2126            bytes::Bytes::from_static(br#"{"ok":true}"#),
2127            bytes::Bytes::from_static(b"not valid json"),
2128        ]);
2129        let json = build_poll_envelope_json(&response).unwrap();
2130        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
2131
2132        assert_eq!(v["count"], 2, "fallback event still counts toward count");
2133        assert_eq!(v["parse_errors"], 1);
2134        assert_eq!(v["events"][0]["ok"], true);
2135        assert_eq!(
2136            v["events"][1], "not valid json",
2137            "invalid-JSON event must be emitted as a JSON string"
2138        );
2139    }
2140
2141    /// PERF_AUDIT §1.6 — invalid UTF-8 can't be emitted even as a
2142    /// string: the event is skipped from `events` (so `count`
2143    /// excludes it) while `parse_errors` still records it. Mirrors
2144    /// the pre-fix `e.parse()` + `raw_str()` behavior exactly.
2145    #[test]
2146    fn poll_envelope_invalid_utf8_is_skipped_but_counted_as_error() {
2147        let response = poll_response_with_raw(vec![
2148            bytes::Bytes::from_static(&[0xFF, 0xFE, 0xFD]),
2149            bytes::Bytes::from_static(br#"{"ok":1}"#),
2150        ]);
2151        let json = build_poll_envelope_json(&response).unwrap();
2152        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
2153
2154        assert_eq!(
2155            v["count"], 1,
2156            "invalid-UTF-8 event is skipped from the events array"
2157        );
2158        assert_eq!(v["parse_errors"], 1);
2159        assert_eq!(v["events"].as_array().unwrap().len(), 1);
2160        assert_eq!(v["events"][0]["ok"], 1);
2161    }
2162
2163    #[test]
2164    fn test_parse_config_valid() {
2165        let config = parse_config_json(r#"{"num_shards": 8}"#);
2166        assert!(config.is_some());
2167    }
2168
2169    #[test]
2170    fn test_parse_config_num_shards_overflow() {
2171        // u16::MAX is 65535, so 65536 should fail
2172        let config = parse_config_json(r#"{"num_shards": 65536}"#);
2173        assert!(
2174            config.is_none(),
2175            "num_shards exceeding u16::MAX should fail"
2176        );
2177
2178        // Much larger value should also fail
2179        let config = parse_config_json(r#"{"num_shards": 100000}"#);
2180        assert!(
2181            config.is_none(),
2182            "num_shards exceeding u16::MAX should fail"
2183        );
2184    }
2185
2186    #[test]
2187    fn test_parse_config_num_shards_max_valid() {
2188        // u16::MAX (65535) should be valid
2189        let config = parse_config_json(r#"{"num_shards": 65535}"#);
2190        assert!(config.is_some(), "num_shards at u16::MAX should be valid");
2191    }
2192
2193    #[test]
2194    fn test_parse_config_invalid_json() {
2195        let config = parse_config_json(r#"{"num_shards": invalid}"#);
2196        assert!(config.is_none());
2197    }
2198
2199    #[test]
2200    fn test_parse_config_empty() {
2201        let config = parse_config_json(r#"{}"#);
2202        assert!(config.is_some(), "empty config should use defaults");
2203    }
2204
2205    /// Pin: known `backpressure_mode` strings round-trip; an
2206    /// unknown value (typo) is rejected with `None`, not silently
2207    /// downgraded to `DropNewest`. Pre-fix a deploy-time typo
2208    /// like `"DropOldset"` swapped the operator's intended
2209    /// durability for `DropNewest` with no diagnostic.
2210    #[test]
2211    fn parse_config_rejects_unknown_backpressure_mode() {
2212        // Known values still parse.
2213        for s in [
2214            "DropNewest",
2215            "drop_newest",
2216            "DropOldest",
2217            "drop_oldest",
2218            "FailProducer",
2219            "fail_producer",
2220        ] {
2221            let cfg = parse_config_json(&format!(r#"{{"backpressure_mode": "{}"}}"#, s));
2222            assert!(cfg.is_some(), "known mode `{}` must parse", s);
2223        }
2224
2225        // Typos must fail.
2226        for s in ["DropOldset", "FailProduce", "drop_oldst", "garbage", ""] {
2227            let cfg = parse_config_json(&format!(r#"{{"backpressure_mode": "{}"}}"#, s));
2228            assert!(
2229                cfg.is_none(),
2230                "unknown mode `{}` must reject (pre-fix this silently \
2231                 fell through to DropNewest)",
2232                s,
2233            );
2234        }
2235
2236        // Wrong JSON type also fails — pre-fix this hit the
2237        // `and_then(|v| v.as_str())` short-circuit and was
2238        // ignored entirely.
2239        let cfg = parse_config_json(r#"{"backpressure_mode": 42}"#);
2240        assert!(
2241            cfg.is_none(),
2242            "non-string non-object backpressure_mode must reject"
2243        );
2244        let cfg = parse_config_json(r#"{"backpressure_mode": true}"#);
2245        assert!(cfg.is_none(), "boolean backpressure_mode must reject");
2246    }
2247
2248    /// Pin: the `Sample { rate }` mode is reachable from JSON
2249    /// via `{"backpressure_mode": {"Sample": {"rate": N}}}`,
2250    /// and a zero rate is rejected (validator already rejects
2251    /// it; the parser must too, so the surface is consistent).
2252    #[test]
2253    fn parse_config_supports_sample_mode_with_validation() {
2254        let cfg = parse_config_json(r#"{"backpressure_mode": {"Sample": {"rate": 10}}}"#);
2255        assert!(cfg.is_some(), "Sample with non-zero rate must parse");
2256
2257        let cfg = parse_config_json(r#"{"backpressure_mode": {"Sample": {"rate": 0}}}"#);
2258        assert!(cfg.is_none(), "Sample with rate=0 must reject");
2259
2260        let cfg = parse_config_json(r#"{"backpressure_mode": {"Sample": {}}}"#);
2261        assert!(cfg.is_none(), "Sample missing rate must reject");
2262    }
2263
2264    // Regression: the Go binding's `Poll(limit, cursor)` serializes a
2265    // `"cursor"` field that the FFI JSON path previously ignored —
2266    // cross-shard pagination silently broke. `parse_poll_request_json`
2267    // must round-trip the cursor into `ConsumeRequest.from_id`.
2268    #[test]
2269    fn test_parse_poll_request_preserves_cursor() {
2270        let req = parse_poll_request_json(r#"{"limit": 50, "cursor": "abc:123"}"#).unwrap();
2271        assert_eq!(req.limit, 50);
2272        assert_eq!(req.from_id.as_deref(), Some("abc:123"));
2273    }
2274
2275    #[test]
2276    fn test_parse_poll_request_no_cursor_defaults_to_none() {
2277        let req = parse_poll_request_json(r#"{"limit": 10}"#).unwrap();
2278        assert_eq!(req.limit, 10);
2279        assert_eq!(req.from_id, None);
2280    }
2281
2282    #[test]
2283    fn test_parse_poll_request_empty_uses_default_limit() {
2284        let req = parse_poll_request_json(r#"{}"#).unwrap();
2285        assert_eq!(req.limit, 100);
2286        assert_eq!(req.from_id, None);
2287    }
2288
2289    // Regression: a wrong-typed `"limit"` previously hit
2290    // `.as_u64().unwrap_or(100)` and silently defaulted. Caller bugs
2291    // (e.g. sending a string or a negative number) must surface as
2292    // `InvalidJson` instead.
2293    #[test]
2294    fn test_parse_poll_request_wrong_type_limit_errors() {
2295        let err = parse_poll_request_json(r#"{"limit": "50"}"#).unwrap_err();
2296        assert_eq!(err, c_int::from(NetError::InvalidJson));
2297        let err = parse_poll_request_json(r#"{"limit": -1}"#).unwrap_err();
2298        assert_eq!(err, c_int::from(NetError::InvalidJson));
2299    }
2300
2301    #[test]
2302    fn test_parse_poll_request_wrong_type_cursor_errors() {
2303        let err = parse_poll_request_json(r#"{"cursor": 123}"#).unwrap_err();
2304        assert_eq!(err, c_int::from(NetError::InvalidJson));
2305    }
2306
2307    #[test]
2308    fn test_parse_poll_request_null_fields_use_defaults() {
2309        let req = parse_poll_request_json(r#"{"limit": null, "cursor": null}"#).unwrap();
2310        assert_eq!(req.limit, 100);
2311        assert_eq!(req.from_id, None);
2312    }
2313
2314    /// `usize::MAX` is always a valid usize regardless of target
2315    /// pointer width, so it must parse successfully on both 32- and
2316    /// 64-bit builds. This pins the boundary case.
2317    #[test]
2318    fn test_parse_poll_request_limit_at_usize_max() {
2319        let json = format!(r#"{{"limit": {}}}"#, usize::MAX);
2320        let req = parse_poll_request_json(&json).unwrap();
2321        assert_eq!(req.limit, usize::MAX);
2322    }
2323
2324    /// Regression: `as usize` silently truncates on 32-bit targets
2325    /// for `u64` values above `usize::MAX`. The parser must return
2326    /// `InvalidJson` instead of wrapping. We only run this on 32-bit
2327    /// targets because on 64-bit `usize::MAX == u64::MAX`, leaving
2328    /// nothing that fits in u64 but not usize.
2329    #[cfg(target_pointer_width = "32")]
2330    #[test]
2331    fn test_parse_poll_request_limit_overflows_usize_on_32bit() {
2332        // 2^33 — fits in u64, but exceeds usize::MAX on a 32-bit build.
2333        let err = parse_poll_request_json(r#"{"limit": 8589934592}"#).unwrap_err();
2334        assert_eq!(err, c_int::from(NetError::InvalidJson));
2335    }
2336
2337    /// CR-22: pin parity between the Rust-side `NetError` enum and
2338    /// the two C-header copies. The Rust enum is the source of
2339    /// truth; C / Go consumers `errors.Is` against the named codes.
2340    /// Pre-CR-22 the headers were missing `-9` (IntOverflow) and
2341    /// `-10` (MismatchedHandles); a consumer receiving those values
2342    /// would fall into the unknown-code branch and lose actionable
2343    /// distinction.
2344    ///
2345    /// We extract every integer literal that appears as the
2346    /// right-hand side of an `= ` token in the file and check
2347    /// that each Rust-side value is present in BOTH headers. The
2348    /// test does NOT verify symbolic names; the sealing
2349    /// constraint is the numeric value alone.
2350    ///
2351    /// Both `include_str!` paths point inside `net/crates/net/`.
2352    /// `include/net.go.h` is a manually-synced mirror of the
2353    /// repo-root `go/net.h`. Reaching outside the crate root
2354    /// (`include_str!("../../../../../go/net.h")`) breaks
2355    /// `cargo publish` and any out-of-repo vendoring of this
2356    /// crate, so the in-crate copy is the supported source. A
2357    /// drift between the two surfaces here as a parity-test
2358    /// failure: one of them will be missing the new value.
2359    #[test]
2360    fn cr22_c_header_parity_with_rust_neterror() {
2361        let primary = include_str!("../../include/net.h");
2362        let go_copy = include_str!("../../include/net.go.h");
2363
2364        // `ALL_NET_ERRORS` is generated by the same `net_error_codes!`
2365        // invocation that declares the enum, so a variant cannot exist
2366        // without appearing here. See that macro for why neither a
2367        // hand-written list nor an exhaustive match was sufficient.
2368        //
2369        // The `#[repr(C)]` discriminant is what the headers actually
2370        // mirror, so confirm the table's declared value matches it
2371        // rather than assuming the macro was fed correctly.
2372        for &(e, declared) in ALL_NET_ERRORS {
2373            assert_eq!(
2374                c_int::from(e),
2375                declared,
2376                "ALL_NET_ERRORS declares {declared} but the #[repr(C)] \
2377                 discriminant is {}",
2378                c_int::from(e)
2379            );
2380        }
2381        let rust_values: Vec<i32> = ALL_NET_ERRORS.iter().map(|&(_, v)| v).collect();
2382        let rust_values: &[i32] = &rust_values;
2383
2384        // Pull every numeric literal that looks like an enum-value
2385        // assignment (`= <number>` followed by `,` or whitespace).
2386        // Whitespace-tolerant: skips `= 0`, `=  0`, `= -10`, etc.
2387        fn extract_assigned_values(src: &str) -> Vec<i32> {
2388            let mut out = Vec::new();
2389            let mut chars = src.chars().peekable();
2390            while let Some(c) = chars.next() {
2391                if c != '=' {
2392                    continue;
2393                }
2394                // Skip whitespace.
2395                while let Some(&peek) = chars.peek() {
2396                    if peek == ' ' || peek == '\t' {
2397                        chars.next();
2398                    } else {
2399                        break;
2400                    }
2401                }
2402                // Optional sign.
2403                let mut buf = String::new();
2404                if let Some(&peek) = chars.peek() {
2405                    if peek == '-' || peek == '+' {
2406                        buf.push(peek);
2407                        chars.next();
2408                    }
2409                }
2410                // Digits.
2411                let mut had_digit = false;
2412                while let Some(&peek) = chars.peek() {
2413                    if peek.is_ascii_digit() {
2414                        buf.push(peek);
2415                        chars.next();
2416                        had_digit = true;
2417                    } else {
2418                        break;
2419                    }
2420                }
2421                if had_digit {
2422                    if let Ok(v) = buf.parse::<i32>() {
2423                        out.push(v);
2424                    }
2425                }
2426            }
2427            out
2428        }
2429
2430        let primary_vals = extract_assigned_values(primary);
2431        let go_vals = extract_assigned_values(go_copy);
2432
2433        for &v in rust_values {
2434            assert!(
2435                primary_vals.contains(&v),
2436                "CR-22 regression: include/net.h is missing the value {} \
2437                 (Rust NetError defines it). Add the matching `NET_ERR_*` \
2438                 enumerator before merging.",
2439                v
2440            );
2441            assert!(
2442                go_vals.contains(&v),
2443                "CR-22 regression: bindings/go/net/net.h is missing the value {} \
2444                 (Rust NetError defines it).",
2445                v
2446            );
2447        }
2448    }
2449
2450    /// CR-5: pin that `examples/capability.c` does not double-include
2451    /// `net.h` and `net.go.h`. Both files use the `NET_SDK_H` include
2452    /// guard, so when both are included in one TU the second is
2453    /// silently skipped — every `net_validate_capabilities` /
2454    /// `net_predicate_*` call the example makes becomes an
2455    /// implicit-declaration error on GCC 14+/Clang 16+, and a silent
2456    /// `int`-return miscompile on older toolchains. The deeper fix
2457    /// (renaming one guard so they compose cleanly) is tracked as
2458    /// CR-28; this test catches the example-level regression.
2459    #[test]
2460    fn cr5_example_does_not_double_include_net_headers() {
2461        let example = include_str!("../../examples/capability.c");
2462        let net_h_included = example.contains("#include \"../include/net.h\"");
2463        let net_go_h_included = example.contains("#include \"../include/net.go.h\"");
2464        assert!(
2465            net_go_h_included,
2466            "examples/capability.c must include net.go.h to declare \
2467             net_validate_capabilities + net_predicate_* symbols"
2468        );
2469        assert!(
2470            !net_h_included,
2471            "examples/capability.c must NOT also include net.h: \
2472             both headers share the NET_SDK_H guard, so the second \
2473             include is silently skipped, leaving the example's \
2474             net_predicate_* calls implicitly declared. Drop the \
2475             redundant include — net.go.h is a superset."
2476        );
2477    }
2478
2479    /// `handle_is_valid` rejects null and any pointer not aligned for
2480    /// `NetHandle`. A foreign caller producing a misaligned pointer
2481    /// (e.g. via an over-eager `void *` cast on a packed struct) hits
2482    /// `&*handle` UB before any other check fires; this gate is the
2483    /// pre-deref discriminator.
2484    #[test]
2485    fn handle_is_valid_rejects_null_and_misaligned() {
2486        // Null is rejected.
2487        assert!(
2488            !handle_is_valid(std::ptr::null::<NetHandle>()),
2489            "null pointer must not be considered a valid handle"
2490        );
2491
2492        // Aligned but non-null is accepted (we use a small backing
2493        // buffer to materialize a pointer without dereferencing it).
2494        // `align_of::<NetHandle>()` is the alignment we must match.
2495        let align = std::mem::align_of::<NetHandle>();
2496        let buf = vec![0u8; align * 2];
2497        let base = buf.as_ptr() as usize;
2498        let aligned = (base + align - 1) & !(align - 1);
2499        let aligned_ptr = aligned as *const NetHandle;
2500        assert!(
2501            handle_is_valid(aligned_ptr),
2502            "aligned non-null pointer must validate (align={align}, ptr={aligned_ptr:p})"
2503        );
2504
2505        // A pointer one byte past `aligned_ptr` is misaligned for any
2506        // type with align > 1, and `NetHandle` (containing `AtomicU32`,
2507        // `AtomicBool`, ManuallyDrop'd EventBus + Runtime) easily
2508        // exceeds 1.
2509        if align > 1 {
2510            let misaligned_ptr = (aligned + 1) as *const NetHandle;
2511            assert!(
2512                !handle_is_valid(misaligned_ptr),
2513                "misaligned pointer must be rejected (align={align}, ptr={misaligned_ptr:p})"
2514            );
2515        }
2516    }
2517
2518    /// Pin: zero values for `heartbeat_interval_ms` and
2519    /// `session_timeout_ms` must reject the entire config (parser
2520    /// returns `None`). Pre-fix the parser threaded `0` through
2521    /// to `Duration::from_millis(0)`, which on the Net adapter's
2522    /// heartbeat path results in a busy-loop that pegs a CPU and
2523    /// produces no diagnostic — the FFI caller saw a successful
2524    /// `net_init` followed by a hung daemon. The validator-level
2525    /// guard for cooldown / metrics_window has no equivalent on
2526    /// the Net-adapter side, so the parser is the only place that
2527    /// can refuse the build.
2528    #[cfg(feature = "net")]
2529    #[test]
2530    fn parse_config_rejects_zero_heartbeat_and_session_timeout() {
2531        // 32-byte hex strings (64 chars) so `hex::decode` produces
2532        // exactly the [u8; 32] the parser requires for `psk` and
2533        // `peer_public_key`.
2534        let psk = "0".repeat(64);
2535        let peer_pk = "1".repeat(64);
2536
2537        // Sanity: a config with both fields *non-zero* must parse
2538        // successfully — proves the rejection in the negative
2539        // cases below is caused by the zero, not a missing
2540        // required field on the surrounding `net` block.
2541        let baseline = format!(
2542            r#"{{"net":{{"bind_addr":"127.0.0.1:9000","peer_addr":"127.0.0.1:9001",
2543                "psk":"{psk}","peer_public_key":"{peer_pk}",
2544                "heartbeat_interval_ms":1000,"session_timeout_ms":30000}}}}"#
2545        );
2546        assert!(
2547            parse_config_json(&baseline).is_some(),
2548            "baseline net config with non-zero heartbeat/session_timeout must parse"
2549        );
2550
2551        // heartbeat_interval_ms = 0 → reject.
2552        let zero_hb = format!(
2553            r#"{{"net":{{"bind_addr":"127.0.0.1:9000","peer_addr":"127.0.0.1:9001",
2554                "psk":"{psk}","peer_public_key":"{peer_pk}",
2555                "heartbeat_interval_ms":0,"session_timeout_ms":30000}}}}"#
2556        );
2557        assert!(
2558            parse_config_json(&zero_hb).is_none(),
2559            "heartbeat_interval_ms=0 must reject (pre-fix this produced a CPU-pegging busy loop)"
2560        );
2561
2562        // session_timeout_ms = 0 → reject.
2563        let zero_to = format!(
2564            r#"{{"net":{{"bind_addr":"127.0.0.1:9000","peer_addr":"127.0.0.1:9001",
2565                "psk":"{psk}","peer_public_key":"{peer_pk}",
2566                "heartbeat_interval_ms":1000,"session_timeout_ms":0}}}}"#
2567        );
2568        assert!(
2569            parse_config_json(&zero_to).is_none(),
2570            "session_timeout_ms=0 must reject"
2571        );
2572    }
2573}