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