Skip to main content

libobs_wrapper/
runtime.rs

1//! Runtime management for serialized OBS access.
2//!
3//! libobs has process-global state and a number of thread-affine operations.  The
4//! default runtime therefore treats OBS as an actor: callers submit bounded work to
5//! one dedicated thread while native resource destruction uses a separate cleanup
6//! queue.  The cleanup queue is intentionally unbounded because Rust destructors must
7//! never deadlock behind normal application work.
8
9use std::ffi::CStr;
10use std::fmt::Debug;
11use std::ptr;
12use std::rc::Rc;
13use std::sync::Arc;
14use std::thread;
15#[cfg(feature = "enable_runtime")]
16use std::thread::JoinHandle;
17
18#[cfg(feature = "enable_runtime")]
19use crossbeam_channel::{bounded, select, unbounded, Sender, TrySendError};
20#[cfg(feature = "enable_runtime")]
21use std::{
22    panic::{catch_unwind, AssertUnwindSafe},
23    sync::{
24        atomic::{AtomicU8, Ordering},
25        Mutex,
26    },
27};
28
29use crate::context::ObsContext;
30use crate::crash_handler::main_crash_handler;
31use crate::enums::{ObsLogLevel, ObsResetVideoStatus};
32use crate::logger::{extern_log_callback, internal_log_global, LOGGER};
33#[cfg(target_os = "linux")]
34use crate::run_with_obs;
35use crate::unsafe_send::NativeObjectRegistry;
36use crate::utils::initialization::{platform_specific_setup, PlatformSpecificGuard};
37use crate::utils::{ObsError, ObsModules, ObsString};
38use crate::{
39    context::{
40        activate_runtime_slot, begin_runtime_shutdown, cancel_runtime_start, release_runtime_slot,
41        reserve_runtime_slot,
42    },
43    utils::StartupInfo,
44};
45
46#[cfg(feature = "enable_runtime")]
47const RUNTIME_QUEUE_CAPACITY: usize = 128;
48#[cfg(feature = "enable_runtime")]
49const RUNTIME_RUNNING: u8 = 0;
50#[cfg(feature = "enable_runtime")]
51const RUNTIME_SHUTTING_DOWN: u8 = 1;
52#[cfg(feature = "enable_runtime")]
53const RUNTIME_PANICKED: u8 = 2;
54#[cfg(feature = "enable_runtime")]
55const RUNTIME_STOPPED: u8 = 3;
56
57#[cfg(feature = "enable_runtime")]
58type RuntimeTask = Box<dyn FnOnce() + Send + 'static>;
59
60#[cfg(feature = "enable_runtime")]
61enum ObsCommand {
62    Execute(RuntimeTask),
63}
64
65#[cfg(feature = "enable_runtime")]
66fn execute_command(command: ObsCommand) {
67    match command {
68        ObsCommand::Execute(task) => task(),
69    }
70}
71
72#[cfg(feature = "enable_runtime")]
73#[derive(Clone, Copy, Debug, PartialEq, Eq)]
74enum QueueSubmitError {
75    Full,
76    Disconnected,
77}
78
79#[cfg(feature = "enable_runtime")]
80fn try_submit_command(
81    sender: &Sender<ObsCommand>,
82    command: ObsCommand,
83) -> Result<(), QueueSubmitError> {
84    match sender.try_send(command) {
85        Ok(()) => Ok(()),
86        Err(TrySendError::Full(_)) => Err(QueueSubmitError::Full),
87        Err(TrySendError::Disconnected(_)) => Err(QueueSubmitError::Disconnected),
88    }
89}
90
91#[cfg(feature = "enable_runtime")]
92fn run_actor_work<F>(work: F) -> bool
93where
94    F: FnOnce(),
95{
96    catch_unwind(AssertUnwindSafe(work)).is_ok()
97}
98
99struct InitializationRollback {
100    armed: bool,
101    obs_started: bool,
102}
103
104impl InitializationRollback {
105    fn new() -> Self {
106        Self {
107            armed: true,
108            obs_started: false,
109        }
110    }
111
112    fn disarm(&mut self) {
113        self.armed = false;
114    }
115}
116
117#[allow(unknown_lints)]
118#[allow(ensure_obs_call_in_runtime)]
119impl Drop for InitializationRollback {
120    fn drop(&mut self) {
121        if !self.armed {
122            return;
123        }
124
125        log::warn!("Rolling back incomplete OBS initialization");
126        if self.obs_started {
127            unsafe {
128                // SAFETY: Initialization and rollback occur on the owning OBS thread.
129                libobs::obs_shutdown();
130            }
131        }
132        unsafe {
133            // SAFETY: These process-global handlers were installed by initialize_inner.
134            libobs::base_set_crash_handler(None, std::ptr::null_mut());
135            libobs::base_set_log_handler(None, std::ptr::null_mut());
136        }
137        let _ = release_runtime_slot();
138    }
139}
140
141/// Core runtime that serializes access to libobs.
142#[derive(Clone)]
143pub struct ObsRuntime {
144    #[cfg(feature = "enable_runtime")]
145    command_sender: Arc<Sender<ObsCommand>>,
146    #[cfg(feature = "enable_runtime")]
147    cleanup_sender: Arc<Sender<RuntimeTask>>,
148    #[cfg(feature = "enable_runtime")]
149    health: Arc<AtomicU8>,
150    native_registry: Arc<NativeObjectRegistry>,
151    thread_id: std::thread::ThreadId,
152    _guard: Arc<_ObsRuntimeGuard>,
153
154    #[cfg(not(feature = "enable_runtime"))]
155    _platform_specific: Option<Rc<PlatformSpecificGuard>>,
156}
157
158impl Debug for ObsRuntime {
159    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        f.debug_struct("ObsRuntime")
161            .field("thread_id", &self.thread_id)
162            .field("native_objects", &self.native_registry.len())
163            .finish_non_exhaustive()
164    }
165}
166
167impl ObsRuntime {
168    pub(crate) fn startup(
169        options: StartupInfo,
170    ) -> Result<(ObsRuntime, ObsModules, StartupInfo), ObsError> {
171        reserve_runtime_slot()?;
172        log::trace!("Initializing OBS context");
173        match Self::init(options) {
174            Ok(initialized) => Ok(initialized),
175            Err(err) => {
176                // Only undo a reservation that never became active. initialize_inner
177                // owns rollback after it activates the process-global slot.
178                cancel_runtime_start();
179                Err(err)
180            }
181        }
182    }
183
184    #[cfg(not(feature = "enable_runtime"))]
185    fn init(info: StartupInfo) -> Result<(ObsRuntime, ObsModules, StartupInfo), ObsError> {
186        let (startup, mut modules, platform_specific) = unsafe { Self::initialize_inner(info)? };
187        let runtime = Self {
188            native_registry: Arc::new(NativeObjectRegistry::default()),
189            thread_id: thread::current().id(),
190            _guard: Arc::new(_ObsRuntimeGuard {}),
191            _platform_specific: platform_specific,
192        };
193        modules.runtime = Some(runtime.clone());
194        Ok((runtime, modules, startup))
195    }
196
197    #[cfg(feature = "enable_runtime")]
198    #[allow(unknown_lints)]
199    #[allow(ensure_obs_call_in_runtime)]
200    fn init(info: StartupInfo) -> Result<(ObsRuntime, ObsModules, StartupInfo), ObsError> {
201        static RUNTIME_THREAD_NAME: &str = "libobs-wrapper-obs-runtime";
202
203        let health = Arc::new(AtomicU8::new(RUNTIME_RUNNING));
204        let actor_health = health.clone();
205        let (command_sender, command_receiver) = bounded(RUNTIME_QUEUE_CAPACITY);
206        let (cleanup_sender, cleanup_receiver) = unbounded::<RuntimeTask>();
207        let (shutdown_sender, shutdown_receiver) = unbounded::<()>();
208        let (init_tx, init_rx) = bounded(1);
209
210        let handle = std::thread::Builder::new()
211            .name(RUNTIME_THREAD_NAME.to_string())
212            .spawn(move || {
213                log::trace!("Starting OBS actor thread");
214                // SAFETY: This closure is the dedicated OBS actor thread and owns the
215                // complete initialize/use/shutdown sequence for libobs. Catch Rust panics
216                // as well as typed initialization failures so the process-global slot
217                // cannot be stranded in Starting/Active.
218                let initialized = catch_unwind(AssertUnwindSafe(|| {
219                    // SAFETY: This closure is executing on the dedicated OBS actor that
220                    // owns initialization for the complete process-global OBS lifetime.
221                    unsafe { Self::initialize_inner(info) }
222                }));
223
224                let (info, modules, platform_specific_guard) = match initialized {
225                    Ok(Ok(value)) => value,
226                    Ok(Err(err)) => {
227                        let _ = init_tx.send(Err(err));
228                        return;
229                    }
230                    Err(_) => {
231                        actor_health.store(RUNTIME_PANICKED, Ordering::Release);
232                        // SAFETY: We are still on the owning OBS actor and initialization
233                        // panicked before control could be returned to another thread.
234                        // SAFETY: Shutdown recovery runs on the owning OBS actor after
235                        // normal work has stopped; clearing process-global callbacks is safe.
236                        unsafe {
237                            libobs::base_set_crash_handler(None, std::ptr::null_mut());
238                            libobs::base_set_log_handler(None, std::ptr::null_mut());
239                        }
240                        let _ = release_runtime_slot();
241                        let _ = init_tx.send(Err(ObsError::RuntimePanicked));
242                        return;
243                    }
244                };
245
246                if init_tx.send(Ok((modules, info))).is_err() {
247                    log::error!("OBS runtime initializer was dropped before startup completed");
248                    // SAFETY: Initialization and this cleanup both run on the same OBS actor thread.
249                    let _ = unsafe { Self::shutdown_inner() };
250                    return;
251                }
252
253                // Keep platform-specific thread-affine state alive for the complete OBS lifetime.
254                let _platform_specific_guard = platform_specific_guard;
255
256                let actor_survived = run_actor_work(|| {
257                    'runtime: loop {
258                        select! {
259                            recv(cleanup_receiver) -> cleanup => match cleanup {
260                                Ok(task) => task(),
261                                Err(_) => break 'runtime,
262                            },
263                            recv(command_receiver) -> command => match command {
264                                Ok(command) => execute_command(command),
265                                Err(_) => break 'runtime,
266                            },
267                            recv(shutdown_receiver) -> _ => break 'runtime,
268                        }
269                    }
270
271                    // A normal last-runtime drop may leave fire-and-forget work and deferred
272                    // destruction. Preserve FIFO ordering before obs_shutdown().
273                    while let Ok(command) = command_receiver.try_recv() {
274                        execute_command(command);
275                    }
276                    while let Ok(cleanup) = cleanup_receiver.try_recv() {
277                        cleanup();
278                    }
279                });
280
281                if !actor_survived {
282                    actor_health.store(RUNTIME_PANICKED, Ordering::Release);
283                    log::error!("OBS actor command panicked; rejecting pending work and shutting down safely");
284
285                    // Pending normal commands must not execute after an arbitrary command panic.
286                    // Dropping them disconnects any synchronous result channels. Native cleanup
287                    // is still attempted before shutdown so owned OBS references are released.
288                    while command_receiver.try_recv().is_ok() {}
289                    while let Ok(cleanup) = cleanup_receiver.try_recv() {
290                        if !run_actor_work(cleanup) {
291                            log::error!("A deferred OBS cleanup task also panicked during actor recovery");
292                        }
293                    }
294                }
295
296                let shutdown_result = catch_unwind(AssertUnwindSafe(|| unsafe {
297                    // SAFETY: The actor owns the complete libobs lifetime and shutdown
298                    // runs on the same thread that initialized it.
299                    Self::shutdown_inner()
300                }));
301                match shutdown_result {
302                    Ok(Ok(())) => {}
303                    Ok(Err(err)) => log::error!("Failed to shut down OBS context: {err:?}"),
304                    Err(_) => {
305                        log::error!("OBS shutdown panicked; clearing handlers and force-releasing the global runtime slot");
306                        // SAFETY: This recovery branch still executes on the owning OBS actor
307                        // after normal work has stopped; clearing global callbacks is safe here.
308                        unsafe {
309                            libobs::base_set_crash_handler(None, std::ptr::null_mut());
310                            libobs::base_set_log_handler(None, std::ptr::null_mut());
311                        }
312                        let _ = release_runtime_slot();
313                    }
314                }
315                if actor_health.load(Ordering::Acquire) != RUNTIME_PANICKED {
316                    actor_health.store(RUNTIME_STOPPED, Ordering::Release);
317                }
318            })
319            .map_err(|_| ObsError::ThreadFailure)?;
320
321        let (mut modules, info) = init_rx.recv().map_err(|_| {
322            ObsError::RuntimeChannelError("OBS actor exited during initialization".to_string())
323        })??;
324
325        let thread_id = handle.thread().id();
326        let command_sender = Arc::new(command_sender);
327        let cleanup_sender = Arc::new(cleanup_sender);
328        let shutdown_sender = Arc::new(shutdown_sender);
329        let runtime = Self {
330            command_sender: command_sender.clone(),
331            cleanup_sender: cleanup_sender.clone(),
332            health: health.clone(),
333            native_registry: Arc::new(NativeObjectRegistry::default()),
334            thread_id,
335            _guard: Arc::new(_ObsRuntimeGuard {
336                handle: Mutex::new(Some(handle)),
337                shutdown_sender,
338                health,
339            }),
340        };
341
342        modules.runtime = Some(runtime.clone());
343        Ok((runtime, modules, info))
344    }
345
346    /// Returns true when called from the dedicated OBS actor thread.
347    pub fn is_actor_thread(&self) -> bool {
348        std::thread::current().id() == self.thread_id
349    }
350
351    /// Returns true when both values belong to the same process-global OBS runtime.
352    pub fn same_instance(&self, other: &Self) -> bool {
353        self.native_registry.runtime_id() == other.native_registry.runtime_id()
354    }
355
356    pub(crate) fn ensure_same_runtime(&self, other: &Self) -> Result<(), ObsError> {
357        if self.same_instance(other) {
358            Ok(())
359        } else {
360            Err(ObsError::RuntimeMismatch)
361        }
362    }
363
364    #[cfg(feature = "enable_runtime")]
365    fn unavailable_error(&self) -> ObsError {
366        match self.health.load(Ordering::Acquire) {
367            RUNTIME_PANICKED => ObsError::RuntimePanicked,
368            _ => ObsError::RuntimeChannelError("OBS actor is shutting down or stopped".to_string()),
369        }
370    }
371
372    /// Dispatches work without waiting for completion.
373    ///
374    /// The regular actor queue is bounded.  If it is full this returns
375    /// [`ObsError::RuntimeQueueFull`] instead of growing memory without bound.
376    #[cfg(feature = "enable_runtime")]
377    pub fn run_with_obs_no_block<F>(&self, operation: F) -> Result<(), ObsError>
378    where
379        F: FnOnce() + Send + 'static,
380    {
381        if self.is_actor_thread() {
382            operation();
383            return Ok(());
384        }
385        if self.health.load(Ordering::Acquire) != RUNTIME_RUNNING {
386            return Err(self.unavailable_error());
387        }
388
389        match try_submit_command(
390            &self.command_sender,
391            ObsCommand::Execute(Box::new(operation)),
392        ) {
393            Ok(()) => Ok(()),
394            Err(QueueSubmitError::Full) => Err(ObsError::RuntimeQueueFull {
395                capacity: RUNTIME_QUEUE_CAPACITY,
396            }),
397            Err(QueueSubmitError::Disconnected) => Err(self.unavailable_error()),
398        }
399    }
400
401    #[cfg(not(feature = "enable_runtime"))]
402    pub fn run_with_obs_no_block<F>(&self, operation: F) -> Result<(), ObsError>
403    where
404        F: FnOnce() + 'static,
405    {
406        self.run_with_obs_result(operation)
407    }
408
409    /// Runs work on the OBS actor and returns its typed result.
410    ///
411    /// Unlike the old implementation this does not erase the result into `Any`, so
412    /// there is no runtime downcast or corresponding impossible error path.
413    #[cfg(feature = "enable_runtime")]
414    pub fn run_with_obs_result<F, T>(&self, operation: F) -> Result<T, ObsError>
415    where
416        F: FnOnce() -> T + Send + 'static,
417        T: Send + 'static,
418    {
419        if self.is_actor_thread() {
420            return Ok(operation());
421        }
422        if self.health.load(Ordering::Acquire) != RUNTIME_RUNNING {
423            return Err(self.unavailable_error());
424        }
425
426        let (result_tx, result_rx) = bounded(1);
427        let task = move || {
428            let result = operation();
429            let _ = result_tx.send(result);
430        };
431
432        match try_submit_command(&self.command_sender, ObsCommand::Execute(Box::new(task))) {
433            Ok(()) => {}
434            Err(QueueSubmitError::Full) => {
435                return Err(ObsError::RuntimeQueueFull {
436                    capacity: RUNTIME_QUEUE_CAPACITY,
437                });
438            }
439            Err(QueueSubmitError::Disconnected) => return Err(self.unavailable_error()),
440        }
441
442        result_rx.recv().map_err(|_| self.unavailable_error())
443    }
444
445    #[cfg(not(feature = "enable_runtime"))]
446    pub fn run_with_obs_result<F, T>(&self, operation: F) -> Result<T, ObsError>
447    where
448        F: FnOnce() -> T,
449    {
450        if std::thread::current().id() != self.thread_id {
451            return Err(ObsError::RuntimeOutsideThread);
452        }
453        Ok(operation())
454    }
455
456    pub(crate) fn native_registry(&self) -> Arc<NativeObjectRegistry> {
457        self.native_registry.clone()
458    }
459
460    /// Queue native destruction without making a Rust `Drop` implementation wait for
461    /// the normal actor queue.  This is public only so exported wrapper macros can use
462    /// it from companion crates; application code normally has no reason to call it.
463    #[doc(hidden)]
464    #[cfg(feature = "enable_runtime")]
465    pub fn defer_obs_cleanup<F>(&self, cleanup: F)
466    where
467        F: FnOnce() + Send + 'static,
468    {
469        if std::thread::current().id() == self.thread_id {
470            cleanup();
471            return;
472        }
473
474        if self.cleanup_sender.send(Box::new(cleanup)).is_err() {
475            // We must never panic from Drop.  At this point the actor is gone, so the
476            // only safe option is to report the leak rather than call libobs off-thread.
477            log::error!("OBS actor stopped before deferred native cleanup could be queued");
478        }
479    }
480
481    #[doc(hidden)]
482    #[cfg(not(feature = "enable_runtime"))]
483    pub fn defer_obs_cleanup<F>(&self, cleanup: F)
484    where
485        F: FnOnce() + 'static,
486    {
487        if std::thread::current().id() == self.thread_id {
488            cleanup();
489        } else {
490            log::error!("Native OBS cleanup requested from outside the owning thread");
491        }
492    }
493
494    #[allow(unknown_lints)]
495    #[allow(ensure_obs_call_in_runtime)]
496    unsafe fn initialize_inner(
497        mut info: StartupInfo,
498    ) -> Result<(StartupInfo, ObsModules, Option<Rc<PlatformSpecificGuard>>), ObsError> {
499        // `startup` reserved the process-global slot before spawning this actor.
500        // Transition that reservation to the concrete owning thread.
501        activate_runtime_slot(thread::current().id())?;
502
503        // Install DLL blocklist hook here
504
505        #[cfg(windows)]
506        unsafe {
507            // Safety: We are in the OBS thread, so it's safe to call this here.
508            libobs::obs_init_win32_crash_handler();
509        }
510
511        // Set logger, load debug privileges and crash handler
512        unsafe {
513            // Safety: We are in the OBS thread, so it's safe to call this here.
514            libobs::base_set_crash_handler(Some(main_crash_handler), std::ptr::null_mut());
515        }
516
517        let native = match unsafe {
518            // Safety: We are in the OBS thread and the nix_display can only be set here.
519            platform_specific_setup(info.nix_display.clone())
520        } {
521            Ok(native) => native,
522            Err(err) => {
523                unsafe {
524                    libobs::base_set_crash_handler(None, std::ptr::null_mut());
525                }
526                let _ = release_runtime_slot();
527                return Err(err);
528            }
529        };
530        let mut rollback = InitializationRollback::new();
531        unsafe {
532            // Safety: We are in the OBS thread, so it's safe to call this here.
533            libobs::base_set_log_handler(Some(extern_log_callback), std::ptr::null_mut());
534        }
535
536        let mut log_callback = LOGGER.lock().map_err(|_e| ObsError::MutexFailure)?;
537
538        *log_callback = info.logger.take().ok_or_else(|| {
539            ObsError::InvalidOperation("startup logger was already consumed".to_string())
540        })?;
541        drop(log_callback);
542
543        // Locale will only be used internally by
544        // libobs for logging purposes, making it
545        // unnecessary to support other languages.
546        let locale_str = ObsString::new("en-US");
547        let module_config_path = info
548            .module_config_path
549            .as_ref()
550            .map_or(ptr::null(), |path| path.as_ptr().0);
551        internal_log_global(
552            ObsLogLevel::Info,
553            format!(
554                "[libobs-wrapper]: Module config path: {}",
555                info.module_config_path
556                    .as_ref()
557                    .map(ToString::to_string)
558                    .unwrap_or_else(|| "<unset>".to_string())
559            ),
560        );
561        let startup_status = unsafe {
562            // Safety: Both strings are owned by this startup frame and remain valid
563            // for the duration of obs_startup. A null module config path preserves
564            // libobs's documented "no configuration directory" behavior.
565            libobs::obs_startup(locale_str.as_ptr().0, module_config_path, ptr::null_mut())
566        };
567        if !startup_status {
568            return Err(ObsError::Failure);
569        }
570        // From this exact point onward any Rust error/panic must run obs_shutdown().
571        rollback.obs_started = true;
572
573        // SAFETY: libobs startup succeeded on this actor; the returned pointer is either
574        // null or a libobs-owned NUL-terminated version string.
575        let version = unsafe { libobs::obs_get_version_string() };
576        let version_str = if version.is_null() {
577            "unknown".to_string()
578        } else {
579            // SAFETY: `version` was checked non-null immediately above and libobs owns
580            // the NUL-terminated string for the process lifetime.
581            let version_cstr = unsafe { CStr::from_ptr(version) };
582            version_cstr.to_string_lossy().into_owned()
583        };
584
585        internal_log_global(ObsLogLevel::Info, format!("OBS {}", version_str));
586
587        // Check version compatibility
588        if !ObsContext::check_version_compatibility() {
589            internal_log_global(
590                ObsLogLevel::Warning,
591                "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!".to_string(),
592            );
593            internal_log_global(
594                ObsLogLevel::Warning,
595                format!(
596                    "OBS major version mismatch: installed version is {}, but expected major version {}. Expect crashes or bugs!!",
597                    version_str,
598                    libobs::LIBOBS_API_MAJOR_VER
599                ),
600            );
601            internal_log_global(
602                ObsLogLevel::Warning,
603                "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!".to_string(),
604            );
605        }
606
607        internal_log_global(
608            ObsLogLevel::Info,
609            "---------------------------------".to_string(),
610        );
611
612        let mut obs_modules = unsafe {
613            // Safety: This is running in the OBS thread, so it's safe to call this here.
614            ObsModules::add_paths(&info.startup_paths)
615        };
616
617        // Note that audio is meant to only be reset
618        // once. See the link below for information.
619        //
620        // https://docs.obsproject.com/frontends
621        let audio_ready = unsafe {
622            // Safety: The audio_info pointer is valid here.
623            libobs::obs_reset_audio2(info.obs_audio_info.as_ptr().0)
624        };
625        if !audio_ready {
626            return Err(ObsError::ResetAudioFailure);
627        }
628
629        // Resets the video context. Note that this
630        // is similar to Self::reset_video, but it
631        // does not call that function because the
632        // ObsContext struct is not created yet,
633        // and also because there is no need to free
634        // anything tied to the OBS context.
635        let reset_video_status = num_traits::FromPrimitive::from_i32(unsafe {
636            // Safety: The video_info pointer is valid here.
637            libobs::obs_reset_video(info.obs_video_info.as_ptr())
638        });
639
640        let reset_video_status = match reset_video_status {
641            Some(x) => x,
642            None => ObsResetVideoStatus::Failure,
643        };
644
645        if reset_video_status != ObsResetVideoStatus::Success {
646            return Err(ObsError::ResetVideoFailure(reset_video_status));
647        }
648
649        let sdr_info = info.obs_video_info.get_sdr_info();
650        unsafe {
651            // Safety: These are just numbers, so it's safe to call this here. Also graphics are initialized, so we can call this.
652            libobs::obs_set_video_levels(sdr_info.sdr_white_level, sdr_info.hdr_nominal_peak_level);
653        }
654
655        unsafe {
656            obs_modules.load_modules();
657        }
658
659        internal_log_global(
660            ObsLogLevel::Info,
661            "==== Startup complete ===============================================".to_string(),
662        );
663
664        rollback.disarm();
665        Ok((info, obs_modules, native))
666    }
667
668    /// Shuts down the OBS context and cleans up resources
669    ///
670    /// This method performs a clean shutdown of OBS, including:
671    /// - Removing sources from output channels
672    /// - Calling `obs_shutdown` to clean up OBS resources
673    /// - Removing log and crash handlers
674    /// - Checking for memory leaks
675    ///
676    /// Safety: Always run this in the OBS runtime context.
677    #[allow(unknown_lints)]
678    #[allow(ensure_obs_call_in_runtime)]
679    unsafe fn shutdown_inner() -> Result<(), ObsError> {
680        // Clean up sources
681        for i in 0..libobs::MAX_CHANNELS {
682            unsafe { libobs::obs_set_output_source(i, ptr::null_mut()) };
683        }
684
685        unsafe {
686            // Safety: We are in the OBS thread, so it's safe to call this here. Also by this time, we _should_ have dropped all OBS resources.
687            libobs::obs_shutdown()
688        }
689
690        let r = LOGGER.lock();
691        match r {
692            Ok(mut logger) => {
693                logger.log(ObsLogLevel::Info, "OBS context shutdown.".to_string());
694                let allocs = unsafe {
695                    // Safety: Can always be called because it just returns a number.
696                    libobs::bnum_allocs()
697                };
698
699                // Increasing this to 1 because of whats described below
700                let mut notice = "";
701                let level = if allocs > 1 {
702                    ObsLogLevel::Error
703                } else {
704                    notice = " (this is an issue in the OBS source code that cannot be fixed)";
705                    ObsLogLevel::Info
706                };
707                // One memory leak is expected here because OBS does not free array elements of the obs_data_path when calling obs_add_data_path
708                // even when obs_remove_data_path is called. This is a bug in OBS.
709                logger.log(
710                    level,
711                    format!("Number of memory leaks: {}{}", allocs, notice),
712                );
713
714                #[cfg(any(feature = "__test_environment", test))]
715                if allocs != 1 {
716                    log::error!("OBS leak check expected 1 allocation, observed {allocs}");
717                }
718            }
719            Err(_) => {
720                println!("OBS context shutdown. (but couldn't lock logger)");
721            }
722        }
723
724        unsafe {
725            // Safety: We are in the OBS thread, so it's safe to call this here.
726            // Clean up log and crash handler
727            libobs::base_set_crash_handler(None, std::ptr::null_mut());
728            libobs::base_set_log_handler(None, std::ptr::null_mut());
729        }
730
731        release_runtime_slot()?;
732        Ok(())
733    }
734
735    #[cfg(target_os = "linux")]
736    pub fn get_platform(&self) -> Result<crate::utils::initialization::PlatformType, ObsError> {
737        run_with_obs!(self, || {
738            let raw_platform = unsafe {
739                // Safety: This is safe to call as long as OBS is initialized.
740                libobs::obs_get_nix_platform()
741            };
742
743            match raw_platform {
744                libobs::obs_nix_platform_type_OBS_NIX_PLATFORM_X11_EGL => {
745                    crate::utils::initialization::PlatformType::X11
746                }
747                libobs::obs_nix_platform_type_OBS_NIX_PLATFORM_WAYLAND => {
748                    crate::utils::initialization::PlatformType::Wayland
749                }
750                _ => crate::utils::initialization::PlatformType::Invalid,
751            }
752        })
753    }
754}
755
756/// Guard for the process-global actor lifetime.
757#[derive(Debug)]
758pub struct _ObsRuntimeGuard {
759    #[cfg(feature = "enable_runtime")]
760    handle: Mutex<Option<JoinHandle<()>>>,
761    #[cfg(feature = "enable_runtime")]
762    shutdown_sender: Arc<Sender<()>>,
763    #[cfg(feature = "enable_runtime")]
764    health: Arc<AtomicU8>,
765}
766
767#[cfg(feature = "enable_runtime")]
768impl Drop for _ObsRuntimeGuard {
769    fn drop(&mut self) {
770        log::trace!("Last ObsRuntime dropped; requesting actor shutdown");
771        begin_runtime_shutdown();
772        self.health.store(RUNTIME_SHUTTING_DOWN, Ordering::Release);
773        if self.shutdown_sender.send(()).is_err() {
774            let _ = release_runtime_slot();
775        }
776
777        // Never wait for native execution from a production destructor.  Dropping a
778        // JoinHandle detaches the worker, which owns everything it needs to finish the
779        // queued cleanup and call obs_shutdown safely.
780        let handle = match self.handle.get_mut() {
781            Ok(handle) => handle.take(),
782            Err(poisoned) => poisoned.into_inner().take(),
783        };
784
785        #[cfg(any(test, feature = "__test_environment"))]
786        if let Some(handle) = handle {
787            // Integration tests need deterministic shutdown before the next test starts.
788            if handle.thread().id() != std::thread::current().id() && handle.join().is_err() {
789                log::error!("OBS actor panicked during test shutdown");
790            }
791        }
792
793        #[cfg(not(any(test, feature = "__test_environment")))]
794        drop(handle);
795    }
796}
797
798#[cfg(not(feature = "enable_runtime"))]
799impl Drop for _ObsRuntimeGuard {
800    fn drop(&mut self) {
801        log::trace!("Last local ObsRuntime dropped; shutting down OBS");
802        begin_runtime_shutdown();
803        if let Err(err) = unsafe { ObsRuntime::shutdown_inner() } {
804            log::error!("Failed to shut down OBS context: {err:?}");
805        }
806    }
807}
808
809#[cfg(all(test, feature = "enable_runtime"))]
810mod tests {
811    use super::*;
812
813    #[test]
814    fn bounded_submission_reports_full_without_blocking() {
815        let (sender, _receiver) = bounded(1);
816        try_submit_command(&sender, ObsCommand::Execute(Box::new(|| {})))
817            .expect("first command fits");
818        assert_eq!(
819            try_submit_command(&sender, ObsCommand::Execute(Box::new(|| {}))),
820            Err(QueueSubmitError::Full)
821        );
822    }
823
824    #[test]
825    fn bounded_submission_reports_disconnect() {
826        let (sender, receiver) = bounded(1);
827        drop(receiver);
828        assert_eq!(
829            try_submit_command(&sender, ObsCommand::Execute(Box::new(|| {}))),
830            Err(QueueSubmitError::Disconnected)
831        );
832    }
833
834    #[test]
835    fn actor_work_contains_command_panics() {
836        assert!(!run_actor_work(|| panic!("synthetic actor command panic")));
837        assert!(run_actor_work(|| {}));
838    }
839}