Skip to main content

car_inference/
offload.rs

1//! On-device generation offload — process isolation for local inference.
2//!
3//! Closes the second half of [Parslee-ai/car-releases#74]. A single heavy
4//! on-device generation (a large MLX Qwen prompt at a high `max_tokens`) can
5//! abort the process from the **Metal/MLX C++ side** — an allocation/OOM abort
6//! or a C++ exception crossing the FFI boundary *below* every Rust
7//! `catch_unwind` guard ([`crate::InferenceEngine`]'s `catch_mlx`,
8//! `handle_infer`'s `catch_unwind`). In the shared `car-server` daemon that
9//! takes down inference for **every** connected client at once.
10//!
11//! The fix is to run on-device generation in a **separate worker process** the
12//! daemon owns. When a `LocalGenerationOffload` is installed (via
13//! [`set_local_offload`]), [`crate::InferenceEngine`]'s on-device branch hands
14//! the fully-resolved request to it instead of running the Metal decode loop
15//! in-process. A Metal abort then kills only the worker: the offload call
16//! returns an [`InferenceError`], the daemon fails that one RPC gracefully and
17//! stays up, and the next call respawns the worker.
18//!
19//! The slot is process-wide and defaults to `None`, so in-process consumers
20//! (NAPI/PyO3/CLI, the worker itself) run on-device generation directly and
21//! are completely unaffected — only a host that explicitly installs an
22//! offloader (the daemon) gets the subprocess boundary.
23
24use crate::stream::StreamEvent;
25use crate::tasks::generate::GenerateRequest;
26use crate::{InferenceError, InferenceResult};
27use serde::{Deserialize, Serialize};
28use std::future::Future;
29use std::path::PathBuf;
30use std::sync::atomic::{AtomicBool, Ordering};
31use std::sync::{Arc, OnceLock, RwLock};
32
33#[derive(Clone, Debug)]
34struct InferenceControlScope {
35    inference_id: String,
36    termination: ControlledTerminationToken,
37}
38
39tokio::task_local! {
40    static INFERENCE_CONTROL_SCOPE: InferenceControlScope;
41}
42
43/// Request-scoped evidence that the exact isolated backend was killed and
44/// reaped. It is deliberately monotonic: only a confirmed kill + wait may set
45/// it, and every adaptive/retry boundary can cheaply observe it afterward.
46#[derive(Clone, Debug, Default)]
47pub struct ControlledTerminationToken(Arc<AtomicBool>);
48
49impl ControlledTerminationToken {
50    /// Publish exact backend termination evidence. Callers must only invoke
51    /// this after the isolated process has both accepted termination and been
52    /// waited/reaped; body drops and async kill requests do not qualify.
53    pub fn confirm_exact_backend_termination(&self) {
54        self.0.store(true, Ordering::Release);
55    }
56
57    pub fn is_confirmed(&self) -> bool {
58        self.0.load(Ordering::Acquire)
59    }
60
61    pub fn error_if_confirmed(&self) -> Result<(), InferenceError> {
62        if self.is_confirmed() {
63            Err(InferenceError::ControlledTermination)
64        } else {
65            Ok(())
66        }
67    }
68}
69
70/// Scope a server-minted lifecycle ID to one inference future without adding a
71/// serializable field to `GenerateRequest`. Direct local-offload awaits inherit
72/// this value; spawned tasks do not inherit it.
73pub async fn scope_inference_control_id<F>(inference_id: String, future: F) -> F::Output
74where
75    F: Future,
76{
77    INFERENCE_CONTROL_SCOPE
78        .scope(
79            InferenceControlScope {
80                inference_id,
81                termination: ControlledTerminationToken::default(),
82            },
83            future,
84        )
85        .await
86}
87
88/// Snapshot the lifecycle ID in the current inference task, if one was scoped.
89pub fn current_inference_control_id() -> Option<String> {
90    INFERENCE_CONTROL_SCOPE
91        .try_with(|scope| scope.inference_id.clone())
92        .ok()
93}
94
95/// Caller deadlines for in-flight inferences, keyed by the server-minted
96/// lifecycle ID (`scope_inference_control_id`). The daemon's `infer.deadline`
97/// handler arms an entry AFTER the request has started — that is the wire
98/// protocol's shape — so this is a mutable registry rather than a field on the
99/// request, and the remote retry loop re-reads it at every phase boundary so a
100/// late-armed deadline still takes effect mid-attempt (car-eyj: a 600 s
101/// deadline used to be abandoned at 549 s by transport ceilings that had never
102/// heard of it).
103static REMOTE_DEADLINES: OnceLock<RwLock<std::collections::HashMap<String, RemoteDeadline>>> =
104    OnceLock::new();
105
106/// One armed caller deadline: when it expires, and the caller's own number
107/// (`timeout_ms`) so a termination can NAME the deadline that was applied
108/// instead of reporting an anonymous transport ceiling.
109#[derive(Clone, Copy, Debug)]
110pub struct RemoteDeadline {
111    pub expires_at: std::time::Instant,
112    pub applied_ms: u64,
113}
114
115impl RemoteDeadline {
116    pub fn remaining(&self) -> std::time::Duration {
117        self.expires_at
118            .saturating_duration_since(std::time::Instant::now())
119    }
120}
121
122fn remote_deadlines() -> &'static RwLock<std::collections::HashMap<String, RemoteDeadline>> {
123    REMOTE_DEADLINES.get_or_init(|| RwLock::new(std::collections::HashMap::new()))
124}
125
126/// Arm (or re-arm) the caller's deadline for one inference. `timeout` is
127/// measured from now, matching `infer.deadline`'s `timeout_ms` semantics.
128pub fn set_remote_deadline(inference_id: &str, timeout: std::time::Duration) {
129    if let Ok(mut map) = remote_deadlines().write() {
130        map.insert(
131            inference_id.to_string(),
132            RemoteDeadline {
133                expires_at: std::time::Instant::now() + timeout,
134                applied_ms: timeout.as_millis() as u64,
135            },
136        );
137    }
138}
139
140/// Drop the deadline entry once the inference reaches ANY terminal state.
141/// Idempotent; the registry must never outlive its inference or the next
142/// request to reuse an ID would inherit a stale deadline.
143pub fn clear_remote_deadline(inference_id: &str) {
144    if let Ok(mut map) = remote_deadlines().write() {
145        map.remove(inference_id);
146    }
147}
148
149/// The armed deadline for the CURRENT inference task, if any. Reads the
150/// task-local lifecycle ID and then the registry, so a call outside a scoped
151/// inference (embeddings, health probes) sees `None` and keeps its defaults.
152pub fn current_remote_deadline() -> Option<RemoteDeadline> {
153    let id = current_inference_control_id()?;
154    remote_deadlines().read().ok()?.get(&id).copied()
155}
156
157/// Snapshot the request's controlled-termination token. Spawned backend tasks
158/// must clone this explicitly because Tokio task-local values are not inherited.
159pub fn current_controlled_termination_token() -> Option<ControlledTerminationToken> {
160    INFERENCE_CONTROL_SCOPE
161        .try_with(|scope| scope.termination.clone())
162        .ok()
163}
164
165/// Stop an adaptive retry/fallback boundary after exact backend termination.
166pub fn ensure_not_controlled_terminated() -> Result<(), InferenceError> {
167    match current_controlled_termination_token() {
168        Some(token) => token.error_if_confirmed(),
169        None => Ok(()),
170    }
171}
172
173#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
174pub struct LocalWorkerAdmission {
175    pub policy: crate::resource_policy::ResourcePolicy,
176    pub policy_generation: u64,
177    pub state_root: PathBuf,
178    pub measured_weights_bytes: u64,
179}
180
181#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
182pub struct LocalWorkerResidency {
183    pub model_id: String,
184    pub measured_weights_bytes: u64,
185}
186
187pub struct LocalOffloadResult {
188    pub result: InferenceResult,
189    pub residency: LocalWorkerResidency,
190    pub retention: crate::backend_cache::BackendRetention,
191}
192
193pub struct LocalOffloadStream {
194    pub events: tokio::sync::mpsc::Receiver<StreamEvent>,
195    pub residency: LocalWorkerResidency,
196    pub retention: crate::backend_cache::BackendRetention,
197}
198
199/// Evidence returned by an isolated generation backend after a termination
200/// request. Dropping a body/stream/future is not evidence and must remain
201/// `Unconfirmed`.
202#[derive(Clone, Copy, Debug, PartialEq, Eq)]
203pub enum InferenceTerminationAck {
204    Confirmed,
205    Unconfirmed,
206}
207
208/// Handler that runs a fully-resolved on-device [`GenerateRequest`] somewhere
209/// isolated from the calling process (the daemon's `car-server --mlx-worker`
210/// child). Implementations must run the request against a real in-process
211/// engine and return the same [`InferenceResult`] / [`StreamEvent`] stream the
212/// caller would have produced itself — the boundary is transparent except that
213/// a native abort surfaces as an `Err`/dropped stream instead of a crash.
214#[async_trait::async_trait]
215pub trait LocalGenerationOffload: Send + Sync {
216    /// Run a non-streaming generation to completion in the worker.
217    async fn generate(&self, request: GenerateRequest) -> Result<InferenceResult, InferenceError>;
218
219    /// Run a streaming generation in the worker, returning a receiver of the
220    /// same [`StreamEvent`]s the in-process path emits. The channel closes when
221    /// the worker finishes (or dies); a mid-stream worker death is observed as
222    /// the sender dropping, exactly like any other stream end.
223    async fn stream(
224        &self,
225        request: GenerateRequest,
226    ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError>;
227
228    /// Admission-aware extension. The default preserves source compatibility
229    /// for external legacy implementers and treats their result as transient;
230    /// only implementations that explicitly override this method may report
231    /// retained worker/process weights.
232    async fn generate_admitted(
233        &self,
234        request: GenerateRequest,
235        _admission: LocalWorkerAdmission,
236    ) -> Result<LocalOffloadResult, InferenceError> {
237        let model_id = request.model.clone().unwrap_or_default();
238        let result = self.generate(request).await?;
239        Ok(LocalOffloadResult {
240            result,
241            residency: LocalWorkerResidency {
242                model_id,
243                measured_weights_bytes: 0,
244            },
245            retention: crate::backend_cache::BackendRetention::Transient,
246        })
247    }
248
249    async fn stream_admitted(
250        &self,
251        request: GenerateRequest,
252        _admission: LocalWorkerAdmission,
253    ) -> Result<LocalOffloadStream, InferenceError> {
254        let model_id = request.model.clone().unwrap_or_default();
255        let events = self.stream(request).await?;
256        Ok(LocalOffloadStream {
257            events,
258            residency: LocalWorkerResidency {
259                model_id,
260                measured_weights_bytes: 0,
261            },
262            retention: crate::backend_cache::BackendRetention::Transient,
263        })
264    }
265
266    fn refresh_resource_policy(&self, _generation: u64) {}
267
268    async fn resident_models(&self) -> Vec<String> {
269        Vec::new()
270    }
271
272    /// Exact process-generation allocation owner for parent-side residency
273    /// publication. Legacy implementations may omit it and remain transient.
274    fn resident_allocation_id(&self, _model_id: &str) -> Option<String> {
275        None
276    }
277
278    /// Release worker-owned residency for one model. Success is acknowledged
279    /// only after the owning worker has exited.
280    async fn release_model(&self, _model_id: &str) -> Result<bool, InferenceError> {
281        Ok(false)
282    }
283
284    /// Terminate the exact isolated process currently executing this ID.
285    /// The compatibility default is intentionally conservative: only an exact
286    /// kill-and-wait implementation may report confirmed termination.
287    async fn terminate_inference(&self, _inference_id: &str) -> InferenceTerminationAck {
288        InferenceTerminationAck::Unconfirmed
289    }
290}
291
292fn offload_slot() -> &'static RwLock<Option<Arc<dyn LocalGenerationOffload>>> {
293    static SLOT: OnceLock<RwLock<Option<Arc<dyn LocalGenerationOffload>>>> = OnceLock::new();
294    SLOT.get_or_init(|| RwLock::new(None))
295}
296
297/// Install a process-wide on-device generation offloader. Pass `None` to
298/// clear the slot. Re-registering overwrites any previous offloader.
299pub fn set_local_offload(offload: Option<Arc<dyn LocalGenerationOffload>>) {
300    let mut guard = offload_slot().write().expect("local offload slot poisoned");
301    *guard = offload;
302}
303
304/// Snapshot the currently installed offloader (if any). Cheap `Arc` clone.
305///
306/// Returns `None` inside a worker process (`CAR_INFERENCE_WORKER` set) even if
307/// a slot were somehow installed — a hard guard against a worker offloading to
308/// itself and spawning an infinite chain of workers.
309pub fn current_local_offload() -> Option<Arc<dyn LocalGenerationOffload>> {
310    if is_offload_worker() {
311        return None;
312    }
313    offload_slot()
314        .read()
315        .expect("local offload slot poisoned")
316        .clone()
317}
318
319/// Whether this process is an on-device inference worker. Set by the daemon on
320/// the `car-server --mlx-worker` child it spawns. A worker runs generation
321/// in-process (the real Metal path), never offloads, and disables outcome
322/// persistence so it can't race the parent daemon writing the shared
323/// `~/.car` profile/ledger files.
324pub fn is_offload_worker() -> bool {
325    std::env::var_os("CAR_INFERENCE_WORKER").is_some()
326}
327
328#[cfg(test)]
329pub(crate) fn test_offload_lock() -> &'static tokio::sync::Mutex<()> {
330    static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
331    LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    struct NoopOffload;
339
340    #[tokio::test]
341    async fn legacy_or_nonisolated_offload_never_confirms_termination() {
342        let offload = NoopOffload;
343        assert_eq!(
344            offload.terminate_inference("opaque").await,
345            InferenceTerminationAck::Unconfirmed
346        );
347    }
348
349    #[async_trait::async_trait]
350    impl LocalGenerationOffload for NoopOffload {
351        async fn generate(
352            &self,
353            _request: GenerateRequest,
354        ) -> Result<InferenceResult, InferenceError> {
355            Err(InferenceError::InferenceFailed("noop".into()))
356        }
357        async fn stream(
358            &self,
359            _request: GenerateRequest,
360        ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
361            Err(InferenceError::InferenceFailed("noop".into()))
362        }
363    }
364
365    #[allow(dead_code)]
366    fn external_legacy_trait_contract_still_compiles(
367        implementation: Arc<dyn LocalGenerationOffload>,
368    ) -> Arc<dyn LocalGenerationOffload> {
369        implementation
370    }
371
372    #[tokio::test]
373    async fn slot_set_and_clear() {
374        let _guard = test_offload_lock().lock().await;
375        // Serialize against other tests touching the global slot is not
376        // needed — this is the only test that writes it, and it restores None.
377        assert!(current_local_offload().is_none());
378        set_local_offload(Some(Arc::new(NoopOffload)));
379        // Note: current_local_offload() would still return None here if
380        // CAR_INFERENCE_WORKER is set in the test env; it isn't by default.
381        assert!(current_local_offload().is_some());
382        set_local_offload(None);
383        assert!(current_local_offload().is_none());
384    }
385}