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}