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