Skip to main content

proxima_primitives/pipe/
isolate.rs

1use bytes::Bytes;
2use std::future::Future;
3use std::panic::AssertUnwindSafe;
4use std::pin::Pin;
5use std::time::Duration;
6
7use bon::Builder;
8use conflaguration::Settings;
9use futures::FutureExt;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13use crate::pipe::ProximaError;
14use crate::pipe::SendPipe;
15use crate::pipe::handler::{Handler, PipeHandle, ThreadLocalPipeHandle, into_handle};
16use crate::pipe::pipe_factory::PipeFactory;
17use crate::pipe::primitives::Pipe;
18use crate::pipe::request::{Request, Response};
19use crate::pipe::telemetry_surface::Labels;
20
21const METRIC_TIMEOUT: &str = "proxima.isolate.timeout_total";
22const METRIC_PANIC: &str = "proxima.isolate.panic_total";
23
24/// blast-radius barrier around an inner Pipe. enforces:
25/// - **time budget** — `proxima_core::time::timeout` drops the inner future and
26///   returns 503 when exceeded; the rest of the chain stays healthy.
27/// - **panic barrier** — `catch_unwind` converts a panic in the inner
28///   future into a 500 response instead of unwinding through the chain.
29///
30/// either guard is independently optional; with both off this is a
31/// pass-through.
32///
33/// generic over the inner handle so the same wrapper composes a `Send`
34/// inner (`PipeHandle`, the default — yields `impl Handler`) or a
35/// per-thread `!Send`-tolerant inner (`ThreadLocalPipeHandle` — yields
36/// `impl ThreadLocalHandler`). The Send variant participates in
37/// cross-thread bootstrap; the per-thread variant lets DPDK / per-core
38/// runtimes hold `Rc`/`RefCell`/`!Send` impl state.
39pub struct Isolate<Inner = PipeHandle> {
40    pub inner: Inner,
41    pub timeout: Option<Duration>,
42    pub panic_barrier: bool,
43}
44
45impl<Inner> Isolate<Inner> {
46    #[must_use]
47    pub fn new(inner: Inner) -> Self {
48        Self {
49            inner,
50            timeout: None,
51            panic_barrier: false,
52        }
53    }
54
55    #[must_use]
56    pub fn with_timeout(mut self, timeout: Duration) -> Self {
57        self.timeout = Some(timeout);
58        self
59    }
60
61    #[must_use]
62    pub fn with_panic_barrier(mut self, enabled: bool) -> Self {
63        self.panic_barrier = enabled;
64        self
65    }
66}
67
68impl Isolate<PipeHandle> {
69    pub fn from_spec(inner: PipeHandle, value: &Value) -> Result<Self, ProximaError> {
70        let config: IsolateConfig = serde_json::from_value(value.clone())
71            .map_err(|err| ProximaError::Config(format!("isolate config: {err}")))?;
72        Ok(config.into_isolate(inner))
73    }
74}
75
76/// Typed config surface for the `isolate` middleware — a blast-radius barrier
77/// with an optional time budget and panic barrier around an inner pipe.
78#[derive(Debug, Clone, Default, Builder, Deserialize, Serialize, Settings)]
79#[settings(prefix = "PROXIMA_ISOLATE")]
80#[builder(derive(Clone, Debug))]
81pub struct IsolateConfig {
82    /// Time budget in ms; the inner future is dropped and a 503 returned when
83    /// exceeded. `None` disables the timeout guard.
84    #[setting(default)]
85    #[serde(default)]
86    pub timeout_ms: Option<u64>,
87
88    /// Convert an inner panic into a 500 instead of unwinding the chain.
89    #[setting(default)]
90    #[serde(default)]
91    #[builder(default)]
92    pub panic_barrier: bool,
93}
94
95impl IsolateConfig {
96    /// Materialise the isolate middleware around `inner`.
97    #[must_use]
98    pub fn into_isolate(self, inner: PipeHandle) -> Isolate<PipeHandle> {
99        Isolate {
100            inner,
101            timeout: self.timeout_ms.map(Duration::from_millis),
102            panic_barrier: self.panic_barrier,
103        }
104    }
105}
106
107impl<Inner> SendPipe for Isolate<Inner>
108where
109    Inner: Handler + Clone,
110{
111    type In = Request<Bytes>;
112    type Out = Response<Bytes>;
113    type Err = ProximaError;
114
115    fn call(
116        &self,
117        request: Request<Bytes>,
118    ) -> impl Future<Output = Result<Response<Bytes>, ProximaError>> + Send {
119        let inner = self.inner.clone();
120        let timeout = self.timeout;
121        let panic_barrier = self.panic_barrier;
122        let telemetry = request.context.telemetry.clone();
123        let labels = request.context.metric_labels(&[]);
124        async move {
125            let guarded = run_with_panic_barrier(inner, request, panic_barrier);
126            let outcome = match timeout {
127                Some(budget) => match proxima_core::time::timeout(budget, guarded).await {
128                    Ok(outcome) => outcome.unwrap_or_else(|reason| panic_response(&reason)),
129                    Err(_elapsed) => {
130                        telemetry.counter_inc(METRIC_TIMEOUT, &labels, 1);
131                        Ok(timeout_response(budget))
132                    }
133                },
134                None => guarded
135                    .await
136                    .unwrap_or_else(|reason| panic_response(&reason)),
137            };
138            outcome.map(|response| record_panic_metric(response, &telemetry, &labels))
139        }
140    }
141}
142
143impl Pipe for Isolate<ThreadLocalPipeHandle> {
144    type In = Request<Bytes>;
145    type Out = Response<Bytes>;
146    type Err = ProximaError;
147
148    fn call(
149        &self,
150        request: Request<Bytes>,
151    ) -> impl Future<Output = Result<Response<Bytes>, ProximaError>> {
152        let inner = self.inner.clone();
153        let timeout = self.timeout;
154        let panic_barrier = self.panic_barrier;
155        let telemetry = request.context.telemetry.clone();
156        let labels = request.context.metric_labels(&[]);
157        async move {
158            let guarded = run_with_panic_barrier_tls(inner, request, panic_barrier);
159            let outcome = match timeout {
160                Some(budget) => match proxima_core::time::timeout(budget, guarded).await {
161                    Ok(outcome) => outcome.unwrap_or_else(|reason| panic_response(&reason)),
162                    Err(_elapsed) => {
163                        telemetry.counter_inc(METRIC_TIMEOUT, &labels, 1);
164                        Ok(timeout_response(budget))
165                    }
166                },
167                None => guarded
168                    .await
169                    .unwrap_or_else(|reason| panic_response(&reason)),
170            };
171            outcome.map(|response| record_panic_metric(response, &telemetry, &labels))
172        }
173    }
174}
175
176/// run inner.call with optional catch_unwind. returns Ok(outcome) when no
177/// panic was caught, Err(reason) when the inner future panicked. the outer
178/// caller then decides whether the panic gets a timeout-style mapping or
179/// the direct panic_response.
180async fn run_with_panic_barrier<Inner: Handler + Clone>(
181    inner: Inner,
182    request: Request<Bytes>,
183    enabled: bool,
184) -> Result<Result<Response<Bytes>, ProximaError>, String> {
185    if !enabled {
186        return Ok(SendPipe::call(&inner, request).await);
187    }
188    let future = AssertUnwindSafe(SendPipe::call(&inner, request));
189    match future.catch_unwind().await {
190        Ok(outcome) => Ok(outcome),
191        Err(panic) => Err(describe_panic(panic.as_ref())),
192    }
193}
194
195async fn run_with_panic_barrier_tls(
196    inner: ThreadLocalPipeHandle,
197    request: Request<Bytes>,
198    enabled: bool,
199) -> Result<Result<Response<Bytes>, ProximaError>, String> {
200    if !enabled {
201        return Ok(Pipe::call(&inner, request).await);
202    }
203    let future = AssertUnwindSafe(Pipe::call(&inner, request));
204    match future.catch_unwind().await {
205        Ok(outcome) => Ok(outcome),
206        Err(panic) => Err(describe_panic(panic.as_ref())),
207    }
208}
209
210fn describe_panic(payload: &(dyn std::any::Any + Send)) -> String {
211    if let Some(message) = payload.downcast_ref::<&'static str>() {
212        (*message).to_string()
213    } else if let Some(message) = payload.downcast_ref::<String>() {
214        message.clone()
215    } else {
216        "unknown panic payload".to_string()
217    }
218}
219
220fn panic_response(reason: &str) -> Result<Response<Bytes>, ProximaError> {
221    Ok(Response::new(500)
222        .with_header("x-proxima-isolated", "panic")
223        .with_body(bytes::Bytes::from(format!(
224            "isolated pipe panicked: {reason}"
225        ))))
226}
227
228fn timeout_response(budget: Duration) -> Response<Bytes> {
229    Response::new(503)
230        .with_header("x-proxima-isolated", "timeout")
231        .with_body(bytes::Bytes::from(format!(
232            "isolated pipe exceeded {}ms budget",
233            budget.as_millis()
234        )))
235}
236
237/// look at the outgoing response for our own panic header so we can bump
238/// the metric exactly once, regardless of whether the panic was caught
239/// directly or by way of the timeout wrapper.
240fn record_panic_metric(
241    response: Response<Bytes>,
242    telemetry: &crate::pipe::telemetry_surface::TelemetryHandle,
243    labels: &Labels,
244) -> Response<Bytes> {
245    if response.metadata.iter().any(|(name, value)| {
246        name.eq_ignore_ascii_case(b"x-proxima-isolated") && value.as_ref() == b"panic"
247    }) {
248        telemetry.counter_inc(METRIC_PANIC, labels, 1);
249    }
250    response
251}
252
253pub struct IsolateFactory;
254
255impl PipeFactory for IsolateFactory {
256    fn name(&self) -> &str {
257        "isolate"
258    }
259
260    fn build(
261        &self,
262        spec: &Value,
263        inner: Option<PipeHandle>,
264    ) -> Pin<Box<dyn Future<Output = Result<PipeHandle, ProximaError>> + Send + '_>> {
265        let spec = spec.clone();
266        Box::pin(async move {
267            let inner = inner
268                .ok_or_else(|| ProximaError::Config("isolate requires an inner pipe".into()))?;
269            let isolate = Isolate::from_spec(inner, &spec)?;
270            Ok(into_handle(isolate))
271        })
272    }
273}
274
275// `#[proxima::test]` and inline `tokio::task::{LocalSet, spawn_local}` pull
276// in the `proxima` / `tokio` dev-dependencies, which the loom build keeps
277// out of the graph (see `[target.'cfg(not(loom))'.dev-dependencies]` in
278// Cargo.toml); these tests are unrelated to the Notify/watch loom protocol.
279#[cfg(all(test, not(loom)))]
280#[allow(clippy::unwrap_used, clippy::expect_used)]
281mod tests {
282    use super::*;
283    use crate::pipe::telemetry_surface::{Telemetry, TelemetryHandle};
284    use std::collections::HashMap;
285    use std::sync::Arc;
286    use std::sync::Mutex;
287    use std::sync::atomic::{AtomicBool, Ordering};
288
289    #[derive(Default)]
290    struct CounterRecorder {
291        counters: Mutex<HashMap<String, u64>>,
292    }
293
294    impl Telemetry for CounterRecorder {
295        fn counter_inc(&self, metric: &str, _labels: &Labels, by: u64) {
296            *self
297                .counters
298                .lock()
299                .unwrap()
300                .entry(metric.to_string())
301                .or_insert(0) += by;
302        }
303        fn gauge_set(&self, _: &str, _: &Labels, _: i64) {}
304        fn histogram_record(&self, _: &str, _: &Labels, _: f64) {}
305    }
306
307    impl CounterRecorder {
308        fn counter(&self, metric: &str) -> Option<u64> {
309            self.counters.lock().unwrap().get(metric).copied()
310        }
311    }
312
313    struct SleepingPipe {
314        millis: u64,
315    }
316
317    impl SendPipe for SleepingPipe {
318        type In = Request<Bytes>;
319        type Out = Response<Bytes>;
320        type Err = ProximaError;
321
322        fn call(
323            &self,
324            _request: Request<Bytes>,
325        ) -> impl Future<Output = Result<Response<Bytes>, ProximaError>> + Send {
326            let millis = self.millis;
327            async move {
328                proxima_core::time::sleep(Duration::from_millis(millis)).await;
329                Ok(Response::ok("done"))
330            }
331        }
332    }
333
334    struct PanickingPipe;
335
336    impl SendPipe for PanickingPipe {
337        type In = Request<Bytes>;
338        type Out = Response<Bytes>;
339        type Err = ProximaError;
340
341        fn call(
342            &self,
343            _request: Request<Bytes>,
344        ) -> impl Future<Output = Result<Response<Bytes>, ProximaError>> + Send {
345            async { panic!("intentional test panic") }
346        }
347    }
348
349    fn request_with_metrics() -> (Request<Bytes>, Arc<CounterRecorder>) {
350        let metrics: Arc<CounterRecorder> = Arc::new(CounterRecorder::default());
351        let telemetry: TelemetryHandle = metrics.clone();
352        let request = Request::builder()
353            .method("GET")
354            .path("/x")
355            .body("")
356            .telemetry(telemetry)
357            .build()
358            .expect("builder");
359        (request, metrics)
360    }
361
362    // principle-4 parity: the fluent builder and the config value must lower to
363    // identical Isolate state (timeout + panic barrier).
364    #[test]
365    fn parity_fluent_builder_and_config_value_match() {
366        let from_value: IsolateConfig =
367            serde_json::from_value(serde_json::json!({"timeout_ms": 250, "panic_barrier": true}))
368                .expect("from_value");
369        let from_value = from_value.into_isolate(into_handle(SleepingPipe { millis: 0 }));
370
371        let from_builder = IsolateConfig::builder()
372            .timeout_ms(250)
373            .panic_barrier(true)
374            .build()
375            .into_isolate(into_handle(SleepingPipe { millis: 0 }));
376
377        assert_eq!(from_value.timeout, from_builder.timeout);
378        assert_eq!(from_value.timeout, Some(Duration::from_millis(250)));
379        assert_eq!(from_value.panic_barrier, from_builder.panic_barrier);
380    }
381
382    #[proxima::test]
383    async fn passthrough_when_no_guards_enabled() {
384        let inner = into_handle(SleepingPipe { millis: 0 });
385        let isolate = Isolate::new(inner);
386        let (request, _metrics) = request_with_metrics();
387        let response = SendPipe::call(&isolate, request).await.expect("call");
388        assert_eq!(response.status, 200);
389    }
390
391    #[proxima::test]
392    async fn timeout_returns_503_and_increments_metric() {
393        // real-time test — 10ms budget vs 1s sleep is plenty of headroom
394        // without bringing in tokio's `test-util` feature for pause/advance.
395        let inner = into_handle(SleepingPipe { millis: 1_000 });
396        let isolate = Isolate::new(inner).with_timeout(Duration::from_millis(10));
397        let (request, metrics) = request_with_metrics();
398        let response = SendPipe::call(&isolate, request).await.expect("call");
399        assert_eq!(response.status, 503);
400        let isolated = response
401            .metadata
402            .iter()
403            .find(|(name, _)| name.eq_ignore_ascii_case(b"x-proxima-isolated"))
404            .expect("isolated header");
405        assert_eq!(isolated.1.as_ref(), b"timeout");
406        let count = metrics.counter(METRIC_TIMEOUT).expect("timeout counter");
407        assert_eq!(count, 1);
408    }
409
410    #[proxima::test]
411    async fn panic_barrier_converts_panic_to_500_and_increments_metric() {
412        let inner = into_handle(PanickingPipe);
413        let isolate = Isolate::new(inner).with_panic_barrier(true);
414        let (request, metrics) = request_with_metrics();
415        let response = SendPipe::call(&isolate, request).await.expect("call");
416        assert_eq!(response.status, 500);
417        let isolated = response
418            .metadata
419            .iter()
420            .find(|(name, _)| name.eq_ignore_ascii_case(b"x-proxima-isolated"))
421            .expect("isolated header");
422        assert_eq!(isolated.1.as_ref(), b"panic");
423        let count = metrics.counter(METRIC_PANIC).expect("panic counter");
424        assert_eq!(count, 1);
425    }
426
427    #[proxima::test(runtime = "tokio")]
428    async fn panic_without_barrier_propagates() {
429        let inner = into_handle(PanickingPipe);
430        let isolate = Isolate::new(inner); // barrier disabled
431        let (request, _metrics) = request_with_metrics();
432        // panic propagates through .await — catch via spawn_local's JoinError.
433        // Handler::call returns ?Send (per-core only); must spawn on LocalSet.
434        let outcome = tokio::task::LocalSet::new()
435            .run_until(async move {
436                tokio::task::spawn_local(async move { SendPipe::call(&isolate, request).await })
437                    .await
438            })
439            .await;
440        assert!(outcome.is_err(), "panic must propagate without barrier");
441    }
442
443    #[proxima::test]
444    async fn timeout_does_not_fire_for_fast_inner() {
445        let inner = into_handle(SleepingPipe { millis: 0 });
446        let isolate = Isolate::new(inner).with_timeout(Duration::from_millis(100));
447        let (request, _metrics) = request_with_metrics();
448        let response = SendPipe::call(&isolate, request).await.expect("call");
449        assert_eq!(response.status, 200);
450    }
451
452    #[proxima::test]
453    async fn neighbor_request_unaffected_after_inner_panic() {
454        // proves the "rest of chain unaffected" acceptance: build a chain
455        // where one call panics (caught by barrier), then a second call
456        // through the same Isolate succeeds.
457        let toggled = Arc::new(AtomicBool::new(false));
458        struct OnceFail {
459            toggled: Arc<AtomicBool>,
460        }
461
462        impl SendPipe for OnceFail {
463            type In = Request<Bytes>;
464            type Out = Response<Bytes>;
465            type Err = ProximaError;
466
467            fn call(
468                &self,
469                _request: Request<Bytes>,
470            ) -> impl Future<Output = Result<Response<Bytes>, ProximaError>> + Send {
471                let toggled = self.toggled.clone();
472                async move {
473                    if toggled.swap(true, Ordering::SeqCst) {
474                        Ok(Response::ok("recovered"))
475                    } else {
476                        panic!("first-call panic")
477                    }
478                }
479            }
480        }
481
482        let inner = into_handle(OnceFail {
483            toggled: toggled.clone(),
484        });
485        let isolate = Isolate::new(inner).with_panic_barrier(true);
486        let (request_one, _m1) = request_with_metrics();
487        let first = SendPipe::call(&isolate, request_one).await.expect("first");
488        assert_eq!(first.status, 500);
489        let (request_two, _m2) = request_with_metrics();
490        let second = SendPipe::call(&isolate, request_two).await.expect("second");
491        assert_eq!(second.status, 200);
492    }
493}