1use 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 libobs::obs_shutdown();
130 }
131 }
132 unsafe {
133 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#[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 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 let initialized = catch_unwind(AssertUnwindSafe(|| {
219 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 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 let _ = unsafe { Self::shutdown_inner() };
250 return;
251 }
252
253 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 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 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 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 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 pub fn is_actor_thread(&self) -> bool {
348 std::thread::current().id() == self.thread_id
349 }
350
351 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 #[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 #[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 #[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 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 activate_runtime_slot(thread::current().id())?;
502
503 #[cfg(windows)]
506 unsafe {
507 libobs::obs_init_win32_crash_handler();
509 }
510
511 unsafe {
513 libobs::base_set_crash_handler(Some(main_crash_handler), std::ptr::null_mut());
515 }
516
517 let native = match unsafe {
518 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 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 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 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 rollback.obs_started = true;
572
573 let version = unsafe { libobs::obs_get_version_string() };
576 let version_str = if version.is_null() {
577 "unknown".to_string()
578 } else {
579 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 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 ObsModules::add_paths(&info.startup_paths)
615 };
616
617 let audio_ready = unsafe {
622 libobs::obs_reset_audio2(info.obs_audio_info.as_ptr().0)
624 };
625 if !audio_ready {
626 return Err(ObsError::ResetAudioFailure);
627 }
628
629 let reset_video_status = num_traits::FromPrimitive::from_i32(unsafe {
636 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 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 #[allow(unknown_lints)]
678 #[allow(ensure_obs_call_in_runtime)]
679 unsafe fn shutdown_inner() -> Result<(), ObsError> {
680 for i in 0..libobs::MAX_CHANNELS {
682 unsafe { libobs::obs_set_output_source(i, ptr::null_mut()) };
683 }
684
685 unsafe {
686 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 libobs::bnum_allocs()
697 };
698
699 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 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 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 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#[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 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 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}