Skip to main content

vllm_cpp/
request.rs

1use std::any::Any;
2use std::cell::Cell;
3use std::ffi::CStr;
4use std::marker::PhantomData;
5use std::os::raw::{c_char, c_void};
6use std::panic::{catch_unwind, AssertUnwindSafe};
7use std::ptr::{self, NonNull};
8use std::sync::{mpsc, Arc, Mutex, OnceLock};
9use std::thread::{self, ThreadId};
10
11use vllm_cpp_sys as ffi;
12
13use crate::callback::{StreamControl, StreamEvent};
14use crate::engine::{Engine, EngineInner};
15use crate::error::{status_result, Error};
16use crate::params::{to_cstring, LogitsProcessorRegistration, SamplingParams};
17
18/// How a successfully waited non-blocking request ended.
19///
20/// This is a Rust-side classification because the native ABI does not expose its
21/// cancellation flag. Callback panic/error takes precedence, followed by an
22/// explicit callback stop, an observed terminal callback, and cancellation.
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
24#[non_exhaustive]
25pub enum RequestOutcome {
26    /// Native generation delivered its terminal callback.
27    Completed,
28    /// The Rust callback returned [`StreamControl::Stop`].
29    ///
30    /// ABI v10 treats this as an explicit stop even for the terminal event.
31    StoppedByCallback,
32    /// Rust requested cancellation before completion was observable.
33    Cancelled,
34}
35
36/// An owned non-blocking streaming request.
37///
38/// A request keeps its parent [`Engine`] alive. Lifecycle methods require mutable
39/// access, and the request is intentionally `Send` but not `Sync`.
40pub struct Request {
41    raw: Option<NonNull<ffi::vllm_request>>,
42    callback: Option<Box<AsyncCallbackState>>,
43    logits_processor: Option<LogitsProcessorRegistration>,
44    engine: Option<Arc<EngineInner>>,
45    cancellation_requested: bool,
46    _not_sync: PhantomData<Cell<()>>,
47}
48
49impl std::fmt::Debug for Request {
50    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        formatter
52            .debug_struct("Request")
53            .field("raw", &self.raw)
54            .field("cancellation_requested", &self.cancellation_requested)
55            .finish_non_exhaustive()
56    }
57}
58
59impl Engine {
60    /// Submits a non-blocking streaming completion to the shared engine.
61    ///
62    /// The callback runs on a native delivery thread and receives an owned UTF-8
63    /// copy of each delta. A callback panic is contained and later reported by
64    /// [`Request::wait`] as [`Error::CallbackPanicked`].
65    pub fn submit<F>(
66        &self,
67        prompt: &str,
68        params: &SamplingParams,
69        callback: F,
70    ) -> Result<Request, Error>
71    where
72        F: FnMut(StreamEvent) -> StreamControl + Send + 'static,
73    {
74        cleanup_sender()?;
75        let prompt = to_cstring(prompt, "prompt")?;
76        let mut params = params.marshal()?;
77        let mut callback = Box::new(AsyncCallbackState::new(callback));
78        let mut output = ptr::null_mut();
79        // SAFETY: the engine is retained by the returned Request, native code
80        // copies prompt/parameters before returning, callback has a stable boxed
81        // address, and callback state remains live until request_free joins.
82        let status = unsafe {
83            ffi::vllm_request_submit(
84                self.inner.raw.as_ptr(),
85                prompt.as_ptr(),
86                params.raw(),
87                Some(async_callback_trampoline),
88                ptr::from_mut(&mut *callback).cast(),
89                &mut output,
90            )
91        };
92        if status != ffi::vllm_status_VLLM_OK {
93            status_result(status)?;
94            unreachable!("non-OK native status unexpectedly succeeded");
95        }
96        let raw = match NonNull::new(output) {
97            Some(raw) => raw,
98            None => {
99                return Err(Error::Runtime {
100                    message: "vllm_request_submit succeeded without a request handle".to_owned(),
101                });
102            }
103        };
104        Ok(Request {
105            raw: Some(raw),
106            callback: Some(callback),
107            logits_processor: params.take_logits_processor(),
108            engine: Some(Arc::clone(&self.inner)),
109            cancellation_requested: false,
110            _not_sync: PhantomData,
111        })
112    }
113}
114
115impl Request {
116    /// Returns whether native callback delivery has finished.
117    #[must_use]
118    pub fn is_done(&self) -> bool {
119        self.native_done()
120    }
121
122    /// Requests cancellation.
123    ///
124    /// Cancellation is idempotent. The ABI does not report whether this call
125    /// changed native state, so [`wait`](Self::wait) returns
126    /// [`RequestOutcome::Cancelled`] when cancellation succeeded after a false
127    /// completion probe and no terminal callback was subsequently observed. A
128    /// terminal callback wins that race unless it stops or panics.
129    pub fn cancel(&mut self) -> Result<(), Error> {
130        let was_done = self.is_done();
131        // SAFETY: mutable access serializes safe lifecycle calls and raw is live.
132        let status = unsafe { ffi::vllm_request_cancel(self.raw().as_ptr()) };
133        status_result(status)?;
134        self.cancellation_requested |= !was_done;
135        Ok(())
136    }
137
138    /// Waits for callback delivery to finish and returns its terminal outcome.
139    ///
140    /// Calling this from this request's own callback returns
141    /// [`Error::RequestCallbackThread`] without entering native code.
142    pub fn wait(&mut self) -> Result<RequestOutcome, Error> {
143        if self.is_native_callback_thread() {
144            return Err(Error::RequestCallbackThread { operation: "wait" });
145        }
146        // SAFETY: mutable access serializes safe lifecycle calls, raw is live,
147        // and the delivery-thread case was rejected before the FFI call.
148        let status = unsafe { ffi::vllm_request_wait(self.raw().as_ptr()) };
149        let native_result = status_result(status);
150        if let Some(error) = self.logits_processor_error() {
151            return Err(error);
152        }
153        let callback_result = self.callback().result(self.cancellation_requested);
154        match callback_result {
155            Err(error) => Err(error),
156            Ok(Some(outcome)) => native_result.map(|()| outcome),
157            Ok(None) => {
158                native_result?;
159                Err(Error::Runtime {
160                    message: "request completed without a terminal callback or locally observable stop/cancellation"
161                        .to_owned(),
162                })
163            }
164        }
165    }
166
167    /// Copies the native request diagnostic after completion.
168    ///
169    /// Returns `None` while the request is running or when it completed without
170    /// a native diagnostic. Callback panics are reported by [`wait`](Self::wait)
171    /// rather than through this native string.
172    pub fn native_error(&self) -> Result<Option<String>, Error> {
173        if !self.is_done() {
174            return Ok(None);
175        }
176        // SAFETY: done has acquired native publication of the request-owned error
177        // string, and raw remains live for this copy.
178        let pointer = unsafe { ffi::vllm_request_error(self.raw().as_ptr()) };
179        if pointer.is_null() {
180            return Err(Error::Runtime {
181                message: "vllm_request_error returned a null pointer".to_owned(),
182            });
183        }
184        // SAFETY: the C contract promises a NUL-terminated string valid until
185        // request_free; this method copies it before returning.
186        let error = unsafe { CStr::from_ptr(pointer) }
187            .to_str()
188            .map_err(|_| Error::InvalidUtf8 {
189                field: "request error",
190            })?
191            .to_owned();
192        Ok((!error.is_empty()).then_some(error))
193    }
194
195    fn raw(&self) -> NonNull<ffi::vllm_request> {
196        self.raw.expect("live Request always has a native handle")
197    }
198
199    fn native_done(&self) -> bool {
200        // SAFETY: raw remains a live request handle until Drop, and the native
201        // completion probe is atomic and accepts concurrent callback delivery.
202        unsafe { ffi::vllm_request_done(self.raw().as_ptr()) }
203    }
204
205    fn callback(&self) -> &AsyncCallbackState {
206        self.callback
207            .as_deref()
208            .expect("live Request always has callback state")
209    }
210
211    fn logits_processor_error(&self) -> Option<Error> {
212        self.logits_processor
213            .as_ref()
214            .and_then(LogitsProcessorRegistration::error)
215    }
216
217    fn is_native_callback_thread(&self) -> bool {
218        self.callback().is_delivery_thread()
219            || self
220                .logits_processor
221                .as_ref()
222                .is_some_and(LogitsProcessorRegistration::is_active_on_current_thread)
223    }
224}
225
226impl Drop for Request {
227    fn drop(&mut self) {
228        let parts = (
229            self.raw.take(),
230            self.callback.take(),
231            self.logits_processor.take(),
232            self.engine.take(),
233        );
234        match parts {
235            (Some(raw), Some(callback), logits_processor, Some(engine)) => {
236                CleanupJob::new(raw, callback, logits_processor, engine).run();
237            }
238            parts => {
239                // A partial Request would make either freeing or dropping its
240                // Rust owners unsafe. This private invariant cannot fail without
241                // an implementation bug, so preserve everything before aborting.
242                std::mem::forget(parts);
243                std::process::abort();
244            }
245        }
246    }
247}
248
249// Moving exclusive request ownership between threads is valid under the native
250// request contract. Callback state is Send, EngineInner is Send + Sync, and
251// wait/free explicitly reject or defer the one prohibited delivery-thread case.
252unsafe impl Send for Request {}
253
254struct CallbackOutcome {
255    stopped: bool,
256    saw_finished: bool,
257    error: Option<Error>,
258    panic: Option<Box<dyn Any + Send>>,
259    delivery_thread: Option<ThreadId>,
260}
261
262struct AsyncCallbackState {
263    callback: Mutex<Box<dyn FnMut(StreamEvent) -> StreamControl + Send + 'static>>,
264    outcome: Mutex<CallbackOutcome>,
265}
266
267impl AsyncCallbackState {
268    fn new<F>(callback: F) -> Self
269    where
270        F: FnMut(StreamEvent) -> StreamControl + Send + 'static,
271    {
272        Self {
273            callback: Mutex::new(Box::new(callback)),
274            outcome: Mutex::new(CallbackOutcome {
275                stopped: false,
276                saw_finished: false,
277                error: None,
278                panic: None,
279                delivery_thread: None,
280            }),
281        }
282    }
283
284    fn record_delivery_thread(&self) {
285        // ABI v10 invokes user_data only from this request's single library-owned
286        // delivery thread. Retain its ID through cleanup instead of marking only
287        // an active trampoline, so every possible Rust re-entry from that thread
288        // remains ineligible for synchronous wait/free.
289        lock_unpoisoned(&self.outcome).delivery_thread = Some(thread::current().id());
290    }
291
292    fn is_delivery_thread(&self) -> bool {
293        lock_unpoisoned(&self.outcome)
294            .delivery_thread
295            .as_ref()
296            .is_some_and(|id| *id == thread::current().id())
297    }
298
299    fn record_error(&self, error: Error) {
300        let mut outcome = lock_unpoisoned(&self.outcome);
301        outcome.error = Some(error);
302        outcome.stopped = true;
303    }
304
305    fn record_result(
306        &self,
307        result: Result<StreamControl, Box<dyn Any + Send>>,
308        finished: bool,
309    ) -> bool {
310        let mut outcome = lock_unpoisoned(&self.outcome);
311        outcome.saw_finished |= finished;
312        match result {
313            Ok(StreamControl::Continue) => true,
314            Ok(StreamControl::Stop) => {
315                outcome.stopped = true;
316                false
317            }
318            Err(payload) => {
319                outcome.panic = Some(payload);
320                outcome.stopped = true;
321                false
322            }
323        }
324    }
325
326    fn result(&self, cancellation_requested: bool) -> Result<Option<RequestOutcome>, Error> {
327        let outcome = lock_unpoisoned(&self.outcome);
328        if outcome.panic.is_some() {
329            return Err(Error::CallbackPanicked);
330        }
331        if let Some(error) = &outcome.error {
332            return Err(error.clone());
333        }
334        if outcome.stopped {
335            return Ok(Some(RequestOutcome::StoppedByCallback));
336        }
337        if outcome.saw_finished {
338            return Ok(Some(RequestOutcome::Completed));
339        }
340        if cancellation_requested {
341            return Ok(Some(RequestOutcome::Cancelled));
342        }
343        Ok(None)
344    }
345}
346
347unsafe extern "C" fn async_callback_trampoline(
348    delta_text: *const c_char,
349    finished: bool,
350    user_data: *mut c_void,
351) -> bool {
352    // SAFETY: submit passes a stable boxed AsyncCallbackState, and request_free
353    // joins this delivery before the box can be destroyed.
354    let state = unsafe { &*user_data.cast::<AsyncCallbackState>() };
355    state.record_delivery_thread();
356    if delta_text.is_null() {
357        state.record_error(Error::InvalidUtf8 {
358            field: "stream delta",
359        });
360        return false;
361    }
362    // SAFETY: native code lends a NUL-terminated string for this invocation.
363    let delta = match unsafe { CStr::from_ptr(delta_text) }.to_str() {
364        Ok(delta) => delta.to_owned(),
365        Err(_) => {
366            state.record_error(Error::InvalidUtf8 {
367                field: "stream delta",
368            });
369            return false;
370        }
371    };
372    let event = StreamEvent { delta, finished };
373    let result = catch_unwind(AssertUnwindSafe(|| {
374        let mut callback = lock_unpoisoned(&state.callback);
375        callback(event)
376    }));
377    state.record_result(result, finished)
378}
379
380fn lock_unpoisoned<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
381    mutex
382        .lock()
383        .unwrap_or_else(std::sync::PoisonError::into_inner)
384}
385
386struct CleanupJob {
387    state: CleanupState,
388    context: CleanupContext,
389}
390
391enum CleanupContext {
392    Caller,
393    Reaper,
394}
395
396enum CleanupState {
397    Armed {
398        raw: NonNull<ffi::vllm_request>,
399        callback: Box<AsyncCallbackState>,
400        logits_processor: Option<LogitsProcessorRegistration>,
401        engine: Arc<EngineInner>,
402    },
403    Disarmed,
404}
405
406impl CleanupJob {
407    fn new(
408        raw: NonNull<ffi::vllm_request>,
409        callback: Box<AsyncCallbackState>,
410        logits_processor: Option<LogitsProcessorRegistration>,
411        engine: Arc<EngineInner>,
412    ) -> Self {
413        Self {
414            state: CleanupState::Armed {
415                raw,
416                callback,
417                logits_processor,
418                engine,
419            },
420            context: CleanupContext::Caller,
421        }
422    }
423
424    fn run(mut self) {
425        self.finish();
426    }
427
428    fn finish(&mut self) {
429        if matches!(self.state, CleanupState::Disarmed) {
430            return;
431        }
432        let needs_deferral = match self.context {
433            CleanupContext::Caller => match &self.state {
434                CleanupState::Armed {
435                    callback,
436                    logits_processor,
437                    ..
438                } => {
439                    callback.is_delivery_thread()
440                        || logits_processor
441                            .as_ref()
442                            .is_some_and(LogitsProcessorRegistration::is_active_on_current_thread)
443                }
444                CleanupState::Disarmed => return,
445            },
446            // A successfully sent job is owned only by the prestarted Rust reaper,
447            // so it cannot be running in the native request callback.
448            CleanupContext::Reaper => false,
449        };
450        if needs_deferral {
451            self.defer_to_reaper();
452        } else if let Err(payload) = catch_unwind(AssertUnwindSafe(|| self.cleanup_now())) {
453            // Retrying free after a Rust unwind could double-free an opaque void
454            // native operation. Disarm and leak the unknown remainder instead.
455            self.leak_armed();
456            std::mem::forget(payload);
457        }
458    }
459
460    fn cleanup_now(&mut self) {
461        let state = std::mem::replace(&mut self.state, CleanupState::Disarmed);
462        let CleanupState::Armed {
463            raw,
464            callback,
465            logits_processor,
466            engine,
467        } = state
468        else {
469            return;
470        };
471        // If this function unwinds, forget every owner before CleanupJob::Drop can
472        // run. Repeating an opaque void free could double-free, while releasing
473        // callback/engine without a known join would be unsafe.
474        let mut owners = std::mem::ManuallyDrop::new((callback, logits_processor, engine));
475        // ABI coupling: output delivery records its permanent thread ID, while
476        // logits calls record their active thread. This path cannot free from either
477        // callback context. New native user_data entrypoints must join this tracking.
478        //
479        // SAFETY: this job owns the request once and retains its callback and engine.
480        // Native free cancels the request and joins output delivery before the logits
481        // processor registration is removed.
482        unsafe { ffi::vllm_request_free(raw.as_ptr()) };
483        // SAFETY: output delivery and native request teardown are complete.
484        let (callback, logits_processor, engine) =
485            unsafe { std::mem::ManuallyDrop::take(&mut owners) };
486        // User callback captures and a stored panic payload can have arbitrary
487        // destructors. Never let them unwind out of cleanup.
488        if let Err(payload) = catch_unwind(AssertUnwindSafe(|| drop(callback))) {
489            std::mem::forget(payload);
490        }
491        if let Err(payload) = catch_unwind(AssertUnwindSafe(|| drop(logits_processor))) {
492            std::mem::forget(payload);
493        }
494        drop(engine);
495    }
496
497    fn leak_armed(&mut self) {
498        let state = std::mem::replace(&mut self.state, CleanupState::Disarmed);
499        std::mem::forget(state);
500    }
501
502    fn defer_to_reaper(&mut self) {
503        let sender = match CLEANUP_REAPER.get() {
504            Some(Ok(sender)) => sender,
505            // Submission starts the process-lifetime reaper before native code
506            // can create a request. Without it, self-thread cleanup is impossible.
507            _ => std::process::abort(),
508        };
509        let job = Self {
510            state: std::mem::replace(&mut self.state, CleanupState::Disarmed),
511            context: CleanupContext::Reaper,
512        };
513        if let Err(error) = sender.send(job) {
514            // SendError owns the still-live native handle. Its Drop would recurse
515            // here on the callback thread and eventually release callback/engine
516            // before native join, so leak it and terminate instead.
517            std::mem::forget(error);
518            std::process::abort();
519        }
520    }
521}
522
523impl Drop for CleanupJob {
524    fn drop(&mut self) {
525        // This is the ownership backstop: every caller-side armed drop either
526        // frees and joins or transfers all owners to the reaper; a reaper-owned
527        // drop always completes cleanup locally, so send failure cannot recurse.
528        self.finish();
529    }
530}
531
532// The job transfers unique native-handle ownership to the reaper. Its callback
533// is Send and its retained engine is Send + Sync; no aliases perform lifecycle
534// operations while the job owns the handle.
535unsafe impl Send for CleanupJob {}
536
537static CLEANUP_REAPER: OnceLock<Result<mpsc::Sender<CleanupJob>, String>> = OnceLock::new();
538
539fn cleanup_sender() -> Result<&'static mpsc::Sender<CleanupJob>, Error> {
540    match CLEANUP_REAPER.get_or_init(|| {
541        let (sender, receiver) = mpsc::channel::<CleanupJob>();
542        thread::Builder::new()
543            .name("vllm-request-reaper".to_owned())
544            .spawn(move || {
545                while let Ok(job) = receiver.recv() {
546                    job.run();
547                }
548            })
549            .map(|_| sender)
550            .map_err(|error| error.to_string())
551    }) {
552        Ok(sender) => Ok(sender),
553        Err(message) => Err(Error::Runtime {
554            message: format!("failed to start request cleanup reaper: {message}"),
555        }),
556    }
557}