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