Skip to main content

proxima_core/
lib.rs

1//! Foundation primitives for the proxima workspace — the layer everything else
2//! is built out of, and the one crate that cannot itself be expressed as a pipe
3//! (`proxima_primitives::Pipe` is defined one level up, on top of this).
4//!
5//! Every module carries the finest tier it can reach, so a bare-metal consumer
6//! takes a subset rather than a fork:
7//!
8//! - **unconditional, down to no_std + no-alloc:** [`arch`], [`datagram_batch`],
9//!   [`factory::Named`], [`markers`], [`per_core`], [`ring`], [`time`]. The
10//!   buffered ones ([`datagram_batch`], [`per_core`], [`ring`]) carry an inline
11//!   fixed-cap form sized by `build.rs` alongside the heap form behind `alloc`.
12//!   CI builds both `--features alloc` and bare `--no-default-features` for
13//!   `thumbv7m-none-eabi` (`scripts/thumbv7m-cliff-gate.sh`).
14//! - **`alloc`:** [`arena`], [`batch`]
15//! - **`std`:** `buffer`, `signal`, and the [`factory`] registry (`registry`
16//!   / `config`) and `live`, which ride their own default-off features
17//! - **orthogonal gates:** `io` (`io-async`) is no-alloc for the traits and
18//!   `alloc` for `Prepend`; `park` (`park`) is no_std but needs an OS futex;
19//!   [`histogram`] needs 64-bit atomics, so it is absent on cortex-m.
20//!
21//! [`ProximaError`] is the shared error type. It carries two surfaces: the
22//! `String`-payload variants (`Decode(String)`, `Config(String)`, …) that most
23//! of the workspace still constructs, and the typed `*Kind` variants
24//! (`DecodeKind(DecodeError)`, `ConfigKind(ConfigError)`, …) that let a caller
25//! match on the failure without parsing a message. **Typed is the convention**;
26//! this crate constructs only typed kinds. The `String` variants remain until
27//! their owning crates migrate, and render identically either way.
28//!
29//! Typed sources are erased through
30//! `Box<dyn core::error::Error + Send + Sync + 'static>` so the sub-enum surface
31//! is uniform regardless of whether the underlying source is `std::io::Error`,
32//! `serde_json::Error`, `FromUtf8Error`, … `core::error::Error` is stable since
33//! Rust 1.81 and the workspace is edition 2024 (1.85+), so it is always
34//! available.
35
36#![cfg_attr(not(feature = "std"), no_std)]
37
38#[cfg(feature = "alloc")]
39extern crate alloc;
40
41#[cfg(feature = "alloc")]
42pub mod accept_rule;
43pub mod arch;
44#[cfg(feature = "alloc")]
45pub mod arena;
46pub mod kv_decision;
47pub mod ring;
48#[cfg(feature = "alloc")]
49pub mod serving_state;
50// per-worker BytesMut reservoir; std-only (thread_local has no no_std
51// analog). folded in from the former proxima-lib satellite crate.
52#[cfg(feature = "alloc")]
53pub mod batch;
54#[cfg(feature = "std")]
55pub mod buffer;
56pub mod datagram_batch;
57pub mod factory;
58// histogram accumulates count/sum/buckets in AtomicU64; targets without
59// 64-bit atomics (cortex-m thumbv7m/em) can't back it lock-free, so gate it out
60// there — no_std consumers with 64-bit atomics still get it.
61#[cfg(target_has_atomic = "64")]
62pub mod histogram;
63#[cfg(feature = "io-async")]
64pub mod io;
65pub mod markers;
66#[cfg(feature = "park")]
67pub mod park;
68pub mod per_core;
69// std tier only for now: the waiter store is a std Mutex. the no_std
70// tier needs a bounded waiter table over SlotPark — deliberate follow-on.
71#[cfg(feature = "std")]
72pub mod signal;
73// runtime-agnostic timer primitives (folded from the former proxima-time
74// crate). Unconditional: compiles at every tier, falling back to a
75// panics-on-use driver when no `time-driver-*` feature/profile is active.
76pub mod time;
77// lock-free live-swappable cell (arc-swap); std-tier, opt-in via `live`.
78#[cfg(feature = "live")]
79pub mod live;
80
81/// Build-time sizing constants emitted by `build.rs` from `proxima-core.toml`
82/// (principle 12). Only the `no_std + no-alloc` tier is bounded by these; the
83/// alloc tier grows dynamically.
84pub mod sized {
85    include!(concat!(env!("OUT_DIR"), "/proxima_core_sized.rs"));
86}
87
88#[cfg(feature = "registry")]
89pub use factory::FactoryRegistry;
90pub use factory::Named;
91#[cfg(feature = "config")]
92pub use factory::{Composition, Factory, FactorySpec};
93#[cfg(feature = "alloc")]
94pub use serving_state::{ServingFsmError, ServingState};
95
96#[cfg(feature = "alloc")]
97use alloc::boxed::Box;
98#[cfg(feature = "alloc")]
99use alloc::string::String;
100
101use core::time::Duration;
102
103use thiserror::Error;
104
105/// Erased source for typed sub-enum variants. Concrete error types
106/// (`std::io::Error`, `serde_json::Error`, `FromUtf8Error`, `ParseIntError`,
107/// etc.) all impl `core::error::Error` and box cleanly through this alias.
108#[cfg(feature = "alloc")]
109pub type ErrorSource = Box<dyn core::error::Error + Send + Sync + 'static>;
110
111// ---------------------------------------------------------------------------
112// Typed sub-enums (alongside the legacy String variants)
113// ---------------------------------------------------------------------------
114
115/// Decode failure during wire / format parsing.
116#[derive(Debug, Error)]
117#[cfg(feature = "alloc")]
118pub enum DecodeError {
119    #[error("missing header terminator")]
120    MissingHeaderTerminator,
121    #[error("empty status line")]
122    EmptyStatusLine,
123    #[error("malformed status line: {raw}")]
124    MalformedStatusLine { raw: String },
125    #[error("chunked: size CRLF missing")]
126    ChunkedSizeMissing,
127    #[error("chunked: payload short")]
128    ChunkedPayloadShort,
129    #[error("not utf-8 ({context}): {source}")]
130    NotUtf8 {
131        context: &'static str,
132        source: ErrorSource,
133    },
134    #[error("parse {context}: {source}")]
135    Parse {
136        context: &'static str,
137        source: ErrorSource,
138    },
139    #[error("missing required field: {field}")]
140    MissingField { field: &'static str },
141    #[error("decode ({context}): {source}")]
142    Other {
143        context: &'static str,
144        source: ErrorSource,
145    },
146}
147
148/// Encode failure during wire / format serialization.
149#[derive(Debug, Error)]
150#[cfg(feature = "alloc")]
151pub enum EncodeError {
152    #[error("json encode: {source}")]
153    Json { source: ErrorSource },
154    #[error("format convert ({context}): {source}")]
155    FormatConvert {
156        context: &'static str,
157        source: ErrorSource,
158    },
159    #[error("render ({context}): {source}")]
160    Render {
161        context: &'static str,
162        source: ErrorSource,
163    },
164    #[error("base64: {source}")]
165    Base64 { source: ErrorSource },
166    #[error("requires base64 string input")]
167    RequiresBase64Input,
168    #[error("encode ({context}): {source}")]
169    Other {
170        context: &'static str,
171        source: ErrorSource,
172    },
173}
174
175/// Upstream / I/O-side failure.
176#[derive(Debug, Error)]
177#[cfg(feature = "alloc")]
178pub enum UpstreamError {
179    #[error("io ({operation}): {source}")]
180    Io {
181        operation: &'static str,
182        source: ErrorSource,
183    },
184    #[error("upstream returned status {status}")]
185    BadStatus { status: u16 },
186    #[error("upstream cancelled")]
187    Cancelled,
188    #[error("protocol ({context}): {source}")]
189    Protocol {
190        context: &'static str,
191        source: ErrorSource,
192    },
193    #[error("connect ({context}): {source}")]
194    Connect {
195        context: &'static str,
196        source: ErrorSource,
197    },
198    #[error("upstream ({context}): {source}")]
199    Other {
200        context: &'static str,
201        source: ErrorSource,
202    },
203}
204
205/// Configuration / spec error.
206#[derive(Debug, Error)]
207#[cfg(feature = "alloc")]
208pub enum ConfigError {
209    #[error("{component}: requires field '{field}'")]
210    RequiredField {
211        component: &'static str,
212        field: &'static str,
213    },
214    #[error("{component}: requires an inner pipe")]
215    MissingInnerPipe { component: &'static str },
216    #[error("no upstreams configured")]
217    NoUpstreams,
218    #[error("parse ({context}): {source}")]
219    Parse {
220        context: &'static str,
221        source: ErrorSource,
222    },
223    #[error("spawn failed ({context}): {source}")]
224    SpawnFailed {
225        context: &'static str,
226        source: ErrorSource,
227    },
228    #[error("config ({context}): {source}")]
229    Other {
230        context: &'static str,
231        source: ErrorSource,
232    },
233}
234
235/// Body-stream / worker-side failure.
236#[derive(Debug, Error)]
237#[cfg(feature = "alloc")]
238pub enum BodyError {
239    #[error("body cancelled")]
240    Cancelled,
241    #[error("worker dropped sender ({context})")]
242    WorkerDropped { context: &'static str },
243    #[error("worker panicked ({context})")]
244    WorkerPanic { context: &'static str },
245    #[error("serialize ({context}): {source}")]
246    Serialize {
247        context: &'static str,
248        source: ErrorSource,
249    },
250    #[error("body ({context}): {source}")]
251    Other {
252        context: &'static str,
253        source: ErrorSource,
254    },
255}
256
257/// Resource-not-found error.
258#[derive(Debug, Error)]
259#[cfg(feature = "alloc")]
260pub enum NotFoundError {
261    #[error("no pipe registered as '{name}'")]
262    Pipe { name: String },
263    #[error("{kind} '{name}' not found")]
264    Resource { kind: &'static str, name: String },
265}
266
267/// Registry-level error (factories, listen protocols, codecs).
268#[derive(Debug, Error)]
269#[cfg(feature = "alloc")]
270pub enum RegistryError {
271    #[error("no {kind} named '{name}'")]
272    NotRegistered { kind: &'static str, name: String },
273    #[error("{kind} '{name}' already registered")]
274    AlreadyRegistered { kind: &'static str, name: String },
275}
276
277/// Recording / replay sink-side error.
278#[derive(Debug, Error)]
279#[cfg(feature = "alloc")]
280pub enum RecordError {
281    #[error("recording io ({operation}): {source}")]
282    Io {
283        operation: &'static str,
284        source: ErrorSource,
285    },
286    #[error("recording codec ({operation}): {source}")]
287    Codec {
288        operation: &'static str,
289        source: ErrorSource,
290    },
291    #[error("recording frame exceeds u32::MAX bytes")]
292    FrameTooLarge,
293    #[error("recording version mismatch: expected {expected}, got {got}")]
294    VersionMismatch { expected: u8, got: u8 },
295    #[error("recording buffer full at capacity {capacity}")]
296    BufferFull { capacity: usize },
297}
298
299// ---------------------------------------------------------------------------
300// ProximaError — legacy String-payload variants + new typed variants
301// ---------------------------------------------------------------------------
302
303#[derive(Debug, Error)]
304pub enum ProximaError {
305    /// Legacy String-payload variants. Construct via `ProximaError::Decode(...)`
306    /// today; migrate to `ProximaError::DecodeKind(DecodeError::...)` over time.
307    #[error("upstream error: {0}")]
308    #[cfg(feature = "alloc")]
309    Upstream(String),
310
311    #[error("timeout after {0:?}")]
312    Timeout(Duration),
313
314    #[error("rate limited")]
315    RateLimited,
316
317    #[error("no upstream had data")]
318    NoData,
319
320    #[error("config: {0}")]
321    #[cfg(feature = "alloc")]
322    Config(String),
323
324    #[error("io: {0}")]
325    #[cfg(feature = "std")]
326    Io(#[from] std::io::Error),
327
328    #[error("decode: {0}")]
329    #[cfg(feature = "alloc")]
330    Decode(String),
331
332    #[error("encode: {0}")]
333    #[cfg(feature = "alloc")]
334    Encode(String),
335
336    #[error("body stream: {0}")]
337    #[cfg(feature = "alloc")]
338    Body(String),
339
340    #[error("not found: {0}")]
341    #[cfg(feature = "alloc")]
342    NotFound(String),
343
344    /// A deliberate refusal, not a failure — the payload is the response
345    /// body verbatim (e.g. a `filter` predicate's `RejectMode::Drop`, which
346    /// renders as 403 with this payload as the body).
347    #[error("forbidden: {0}")]
348    #[cfg(feature = "alloc")]
349    Forbidden(String),
350
351    #[error("registry: {0}")]
352    #[cfg(feature = "alloc")]
353    Registry(String),
354
355    #[error("retries exhausted after {attempts} attempts: {last}")]
356    #[cfg(feature = "alloc")]
357    RetriesExhausted { attempts: usize, last: String },
358
359    #[error("recording: {0}")]
360    #[cfg(feature = "alloc")]
361    Record(String),
362
363    #[error("replay miss: {fingerprint}")]
364    #[cfg(feature = "alloc")]
365    ReplayMiss { fingerprint: String },
366
367    /// Typed payload variants. Carry sub-enum kinds with concrete fields
368    /// (e.g. `Decode(format!("missing field 'name'"))` becomes
369    /// `DecodeKind(DecodeError::MissingField { field: "name" })`). Match
370    /// callers can now inspect the kind without parsing strings.
371    #[error("decode: {0}")]
372    #[cfg(feature = "alloc")]
373    DecodeKind(#[from] DecodeError),
374
375    #[error("encode: {0}")]
376    #[cfg(feature = "alloc")]
377    EncodeKind(#[from] EncodeError),
378
379    #[error("upstream: {0}")]
380    #[cfg(feature = "alloc")]
381    UpstreamKind(#[from] UpstreamError),
382
383    #[error("config: {0}")]
384    #[cfg(feature = "alloc")]
385    ConfigKind(#[from] ConfigError),
386
387    #[error("body: {0}")]
388    #[cfg(feature = "alloc")]
389    BodyKind(#[from] BodyError),
390
391    #[error("not found: {0}")]
392    #[cfg(feature = "alloc")]
393    NotFoundKind(#[from] NotFoundError),
394
395    #[error("registry: {0}")]
396    #[cfg(feature = "alloc")]
397    RegistryKind(#[from] RegistryError),
398
399    #[error("recording: {0}")]
400    #[cfg(feature = "alloc")]
401    RecordKind(#[from] RecordError),
402}
403
404impl ProximaError {
405    /// Whether this error class is worth retrying. Covers both legacy and
406    /// typed variants — the heuristic matches `Upstream*` (transient I/O),
407    /// `Timeout`, `Body*` (worker churn), and (under std) `Io`.
408    #[must_use]
409    pub fn is_retryable(&self) -> bool {
410        match self {
411            Self::Timeout(_) => true,
412            Self::RateLimited => false,
413            #[cfg(feature = "alloc")]
414            Self::Upstream(_) | Self::Body(_) => true,
415            #[cfg(feature = "std")]
416            Self::Io(_) => true,
417            #[cfg(feature = "alloc")]
418            Self::UpstreamKind(kind) => !matches!(kind, UpstreamError::BadStatus { .. }),
419            #[cfg(feature = "alloc")]
420            Self::BodyKind(kind) => matches!(
421                kind,
422                BodyError::WorkerDropped { .. } | BodyError::WorkerPanic { .. }
423            ),
424            _ => false,
425        }
426    }
427}
428
429pub type ProximaResult<T> = Result<T, ProximaError>;
430
431#[cfg(test)]
432#[cfg(feature = "alloc")]
433#[allow(clippy::unwrap_used, clippy::expect_used)]
434mod tests {
435    use super::*;
436    use alloc::format;
437
438    #[test]
439    fn timeout_is_retryable() {
440        assert!(ProximaError::Timeout(Duration::from_secs(1)).is_retryable());
441    }
442
443    #[test]
444    fn rate_limited_is_not_retryable() {
445        assert!(!ProximaError::RateLimited.is_retryable());
446    }
447
448    #[test]
449    fn legacy_upstream_is_retryable() {
450        assert!(ProximaError::Upstream("transient".into()).is_retryable());
451    }
452
453    #[test]
454    fn typed_upstream_io_is_retryable_but_bad_status_is_not() {
455        let io = UpstreamError::Io {
456            operation: "read",
457            source: Box::new(LightweightTestError("transient")),
458        };
459        assert!(ProximaError::from(io).is_retryable());
460
461        let bad = UpstreamError::BadStatus { status: 500 };
462        assert!(!ProximaError::from(bad).is_retryable());
463    }
464
465    #[test]
466    fn decode_kind_carries_typed_payload() {
467        let err = DecodeError::MissingField {
468            field: "pipeline_id",
469        };
470        let display = format!("{}", err);
471        assert!(display.contains("pipeline_id"));
472    }
473
474    #[test]
475    fn from_decode_error_to_proxima_error_via_from_impl() {
476        let err: ProximaError = DecodeError::EmptyStatusLine.into();
477        assert!(matches!(
478            err,
479            ProximaError::DecodeKind(DecodeError::EmptyStatusLine)
480        ));
481    }
482
483    #[test]
484    fn config_required_field_renders_component_and_field() {
485        let err = ConfigError::RequiredField {
486            component: "auth",
487            field: "allow",
488        };
489        let display = format!("{}", err);
490        assert!(display.contains("auth"));
491        assert!(display.contains("allow"));
492    }
493
494    #[test]
495    fn registry_not_registered_uses_kind_and_name() {
496        let err = RegistryError::NotRegistered {
497            kind: "codec factory",
498            name: "json".into(),
499        };
500        let display = format!("{}", err);
501        assert!(display.contains("codec factory"));
502        assert!(display.contains("json"));
503    }
504
505    #[test]
506    fn record_version_mismatch_carries_numbers() {
507        let err = RecordError::VersionMismatch {
508            expected: 3,
509            got: 2,
510        };
511        let display = format!("{}", err);
512        assert!(display.contains('3'));
513        assert!(display.contains('2'));
514    }
515
516    #[derive(Debug)]
517    struct LightweightTestError(&'static str);
518    impl core::fmt::Display for LightweightTestError {
519        fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
520            f.write_str(self.0)
521        }
522    }
523    impl core::error::Error for LightweightTestError {}
524}