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
24pub 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#[derive(Debug, Clone, Default, Builder, Deserialize, Serialize, Settings)]
79#[settings(prefix = "PROXIMA_ISOLATE")]
80#[builder(derive(Clone, Debug))]
81pub struct IsolateConfig {
82 #[setting(default)]
85 #[serde(default)]
86 pub timeout_ms: Option<u64>,
87
88 #[setting(default)]
90 #[serde(default)]
91 #[builder(default)]
92 pub panic_barrier: bool,
93}
94
95impl IsolateConfig {
96 #[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
176async 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
237fn 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#[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 #[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 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); let (request, _metrics) = request_with_metrics();
432 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 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}