Skip to main content

ferroui_headless/
headless_unit_test_session.rs

1//! Port of `HeadlessUnitTestSession.cs`: the session that runs the tests of
2//! a test assembly on a dispatcher thread of its own.
3//!
4//! What differs from the original, and why:
5//!
6//! - An entry point is a function that builds the application
7//!   (`fn() -> AppBuilder`), where the original takes a type and finds its
8//!   builder method through reflection. An assembly with its two attributes
9//!   is a [`FerroTestAssembly`] value: there are no assembly attributes.
10//! - An exception is a panic. The outcome of a dispatch is a
11//!   [`HeadlessUnitTestTask`], which a thread other than the session thread
12//!   waits for; a panic of the dispatched action, or of the cleanup after
13//!   it, is raised again in the thread that observes the task.
14//! - The application builder is not `Send` (it holds `Rc`s): it is created
15//!   on the session thread, as in the original, and stays there, in the
16//!   state of the consumer loop, where the original keeps it in a field of
17//!   the session. The synchronization context of the shared application is
18//!   kept in the same place.
19//! - The session thread is isolated as a unit test thread
20//!   ([`Dispatcher::unit_test_scope`]): the dispatcher of the session is the
21//!   dispatcher of its thread, and resetting it does not touch the tests of
22//!   the process that run on threads of their own. The original resets the
23//!   one UI thread dispatcher of the process.
24//! - There is no execution context to capture and to run a work item in.
25//! - A work item that is still queued when the session is disposed is
26//!   dropped, and its task is cancelled; the original leaves such a task
27//!   incomplete for ever.
28//! - The cleanup after a test of a shared application also processes the
29//!   composition batches the test left in flight
30//!   ([`finish_composition_in_flight`]), so that they do not hold back the
31//!   commits of the next test.
32
33use crate::{
34    FerroHeadlessPlatform, FerroHeadlessPlatformExtensions, FerroHeadlessPlatformOptions, FerroTestApplicationAttribute,
35    FerroTestIsolationAttribute, FerroTestIsolationLevel,
36};
37use ferroui_base::media::{FontManager, MediaContext};
38use ferroui_base::reactive::IDisposable;
39use ferroui_base::threading::{
40    CancellationToken, CancellationTokenSource, Dispatcher, DispatcherFrame, FerroSynchronizationContext,
41    OperationCanceledError,
42};
43use ferroui_base::{FerroLocator, LocatorExtensions};
44use ferroui_controls::{AppBuilder, Application, Control, IToolTipService};
45use ferroui_harfbuzz::HarfBuzzApplicationExtensions;
46use std::any::Any;
47use std::cell::RefCell;
48use std::collections::{HashMap, VecDeque};
49use std::future::Future;
50use std::panic::{catch_unwind, resume_unwind, AssertUnwindSafe};
51use std::sync::mpsc;
52use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock, PoisonError};
53use std::thread::{self, JoinHandle};
54use std::time::Duration;
55
56/// A panic payload: what the original carries as an exception.
57type Exception = Box<dyn Any + Send + 'static>;
58
59/// A test assembly as the session sees it: its name, by which
60/// [`HeadlessUnitTestSession::get_or_start_for_assembly`] keeps one session
61/// per assembly, and the two assembly attributes of the original. A test
62/// crate declares one as a `static`.
63#[derive(Debug)]
64pub struct FerroTestAssembly {
65    /// The name of the assembly: the name of the test crate, or of a part of
66    /// it that has an application and an isolation level of its own.
67    pub name: &'static str,
68    /// The application of the tests; an empty application when `None`.
69    pub test_application: Option<FerroTestApplicationAttribute>,
70    /// The isolation of the tests; [`FerroTestIsolationLevel::PerTest`] when
71    /// `None`.
72    pub test_isolation: Option<FerroTestIsolationAttribute>,
73}
74
75// --- the task of a dispatch ----------------------------------------------------------------------
76
77enum TaskState<T> {
78    Running,
79    RanToCompletion(Option<T>),
80    Faulted(Option<Exception>),
81    Canceled,
82}
83
84struct TaskShared<T> {
85    state: Mutex<TaskState<T>>,
86    finished: Condvar,
87}
88
89impl<T> TaskShared<T> {
90    fn lock(&self) -> MutexGuard<'_, TaskState<T>> {
91        self.state.lock().unwrap_or_else(PoisonError::into_inner)
92    }
93
94    fn try_set(&self, state: TaskState<T>) -> bool {
95        let mut current = self.lock();
96        if !matches!(*current, TaskState::Running) {
97            return false;
98        }
99        *current = state;
100        self.finished.notify_all();
101        true
102    }
103}
104
105/// The completion source of a [`HeadlessUnitTestTask`]. A source that is
106/// dropped without having completed its task cancels it.
107struct TaskCompletionSource<T>(Arc<TaskShared<T>>);
108
109impl<T> TaskCompletionSource<T> {
110    fn new() -> (Self, HeadlessUnitTestTask<T>) {
111        let shared = Arc::new(TaskShared { state: Mutex::new(TaskState::Running), finished: Condvar::new() });
112        (Self(shared.clone()), HeadlessUnitTestTask { shared })
113    }
114
115    fn try_set_result(&self, result: T) -> bool {
116        self.0.try_set(TaskState::RanToCompletion(Some(result)))
117    }
118
119    fn try_set_exception(&self, exception: Exception) -> bool {
120        self.0.try_set(TaskState::Faulted(Some(exception)))
121    }
122
123    fn try_set_canceled(&self) -> bool {
124        self.0.try_set(TaskState::Canceled)
125    }
126}
127
128impl<T> Drop for TaskCompletionSource<T> {
129    fn drop(&mut self) {
130        self.try_set_canceled();
131    }
132}
133
134/// The outcome of an action dispatched to a [`HeadlessUnitTestSession`];
135/// what the original returns as a task object.
136pub struct HeadlessUnitTestTask<T> {
137    shared: Arc<TaskShared<T>>,
138}
139
140impl<T> HeadlessUnitTestTask<T> {
141    /// Whether the task has finished: completed, failed or cancelled.
142    pub fn is_completed(&self) -> bool {
143        !matches!(*self.shared.lock(), TaskState::Running)
144    }
145
146    /// Blocks until the task has finished and takes its result
147    /// (`GetAwaiter().GetResult()`). Not to be called on the session thread,
148    /// which is the thread that has to make progress.
149    ///
150    /// # Panics
151    /// Raises the panic of the dispatched action, or of the cleanup after
152    /// it, again. Panics when the outcome has already been taken.
153    pub fn wait(self) -> Result<T, OperationCanceledError> {
154        let mut state = self.shared.lock();
155        while matches!(*state, TaskState::Running) {
156            state = self.shared.finished.wait(state).unwrap_or_else(PoisonError::into_inner);
157        }
158        Self::take(&mut state)
159    }
160
161    /// Blocks until the task has finished or `timeout` has passed
162    /// (`WaitAsync(TimeSpan)`).
163    ///
164    /// # Panics
165    /// Panics when the task has not finished in time (`TimeoutException`);
166    /// otherwise as [`wait`](Self::wait).
167    pub fn wait_timeout(self, timeout: Duration) -> Result<T, OperationCanceledError> {
168        let state = self.shared.lock();
169        let (mut state, _) = self
170            .shared
171            .finished
172            .wait_timeout_while(state, timeout, |state| matches!(*state, TaskState::Running))
173            .unwrap_or_else(PoisonError::into_inner);
174        if matches!(*state, TaskState::Running) {
175            drop(state);
176            panic!("The operation has timed out.");
177        }
178        Self::take(&mut state)
179    }
180
181    fn take(state: &mut TaskState<T>) -> Result<T, OperationCanceledError> {
182        match state {
183            TaskState::RanToCompletion(result) => match result.take() {
184                Some(result) => Ok(result),
185                None => panic!("The result of the task has already been taken."),
186            },
187            TaskState::Faulted(exception) => match exception.take() {
188                Some(exception) => resume_unwind(exception),
189                None => panic!("The failure of the task has already been observed."),
190            },
191            TaskState::Canceled => Err(OperationCanceledError),
192            TaskState::Running => unreachable!("the task has finished"),
193        }
194    }
195}
196
197// --- the queue of the session --------------------------------------------------------------------
198
199/// What the consumer loop owns and a work item runs with: the fields of the
200/// original session that cannot leave the session thread.
201struct SessionThread {
202    app_builder: AppBuilder,
203    /// Only set and used with PerAssembly isolation
204    shared_context: RefCell<Option<Arc<FerroSynchronizationContext>>>,
205}
206
207type WorkItem = Box<dyn FnOnce(&SessionThread) + Send + 'static>;
208
209#[derive(Default)]
210struct QueueState {
211    items: VecDeque<WorkItem>,
212    adding_completed: bool,
213}
214
215/// `BlockingCollection` of the work items.
216#[derive(Default)]
217struct BlockingQueue {
218    state: Mutex<QueueState>,
219    changed: Condvar,
220}
221
222impl BlockingQueue {
223    fn lock(&self) -> MutexGuard<'_, QueueState> {
224        self.state.lock().unwrap_or_else(PoisonError::into_inner)
225    }
226
227    /// # Panics
228    /// Panics when the adding was completed (`InvalidOperationException`).
229    fn add(&self, item: WorkItem) {
230        let mut state = self.lock();
231        if state.adding_completed {
232            drop(state);
233            panic!("The collection has been marked as complete with regards to additions.");
234        }
235        state.items.push_back(item);
236        self.changed.notify_all();
237    }
238
239    /// `Take(CancellationToken)`: `None` is the cancellation of the token.
240    fn take(&self, cancellation_token_source: &CancellationTokenSource) -> Option<WorkItem> {
241        let mut state = self.lock();
242        loop {
243            if cancellation_token_source.is_cancellation_requested() {
244                return None;
245            }
246            if let Some(item) = state.items.pop_front() {
247                return Some(item);
248            }
249            state = self.changed.wait(state).unwrap_or_else(PoisonError::into_inner);
250        }
251    }
252
253    /// Wakes the consumer so that it sees a cancellation.
254    fn wake(&self) {
255        let _state = self.lock();
256        self.changed.notify_all();
257    }
258
259    fn complete_adding(&self) {
260        let dropped = {
261            let mut state = self.lock();
262            state.adding_completed = true;
263            std::mem::take(&mut state.items)
264        };
265        // Outside of the lock: dropping a work item cancels its task.
266        drop(dropped);
267    }
268}
269
270// --- the session ---------------------------------------------------------------------------------
271
272/// Headless unit test session that needs to be used by the actual testing
273/// framework. All UI tests are supposed to be executed from one of the
274/// `dispatch` methods to keep execution flow on the UI thread. Disposing
275/// unit test session stops internal dispatcher loop.
276pub struct HeadlessUnitTestSession {
277    cancellation_token_source: CancellationTokenSource,
278    queue: Arc<BlockingQueue>,
279    dispatch_task: Mutex<Option<JoinHandle<()>>>,
280    isolated: bool,
281}
282
283fn sessions() -> &'static Mutex<HashMap<&'static str, Arc<HeadlessUnitTestSession>>> {
284    static SESSIONS: OnceLock<Mutex<HashMap<&'static str, Arc<HeadlessUnitTestSession>>>> = OnceLock::new();
285    SESSIONS.get_or_init(Default::default)
286}
287
288/// The assembly of the session itself, which stands for an assembly that is
289/// not given.
290static HEADLESS_ASSEMBLY: FerroTestAssembly =
291    FerroTestAssembly { name: env!("CARGO_PKG_NAME"), test_application: None, test_isolation: None };
292
293/// Cancels the source when dropped: when the future it is a local of has
294/// run to its end, has panicked or was dropped.
295struct CancelOnDrop(CancellationTokenSource);
296
297impl Drop for CancelOnDrop {
298    fn drop(&mut self) {
299        self.0.cancel();
300    }
301}
302
303/// Drops the compositor of the headless platform when the thread of a
304/// session ends (see `FerroHeadlessPlatform::release_compositor`).
305struct ReleaseCompositorOnDrop;
306
307impl Drop for ReleaseCompositorOnDrop {
308    fn drop(&mut self) {
309        crate::ferro_headless_platform::FerroHeadlessPlatform::release_compositor();
310    }
311}
312
313impl HeadlessUnitTestSession {
314    /// Queues an action on the dispatcher thread; see
315    /// [`dispatch_task`](Self::dispatch_task).
316    pub fn dispatch(
317        &self,
318        action: impl FnOnce() + Send + 'static,
319        cancellation_token: CancellationToken,
320    ) -> HeadlessUnitTestTask<()> {
321        self.dispatch_core(
322            move || {
323                action();
324                std::future::ready(())
325            },
326            cancellation_token,
327        )
328    }
329
330    /// Queues an action with a result on the dispatcher thread; see
331    /// [`dispatch_task`](Self::dispatch_task).
332    pub fn dispatch_result<TResult: Send + 'static>(
333        &self,
334        action: impl FnOnce() -> TResult + Send + 'static,
335        cancellation_token: CancellationToken,
336    ) -> HeadlessUnitTestTask<TResult> {
337        self.dispatch_core(move || std::future::ready(action()), cancellation_token)
338    }
339
340    /// Dispatch method queues an async operation on the dispatcher thread,
341    /// creates a new application instance, setting the application services,
342    /// and runs `action` parameter.
343    ///
344    /// `action` is the action to execute on the dispatcher thread with the
345    /// services; the future it returns never leaves the dispatcher thread.
346    /// `cancellation_token` is the cancellation token to cancel execution.
347    ///
348    /// # Panics
349    /// If global session was already cancelled and thread killed, it's not
350    /// possible to dispatch any actions again (`ObjectDisposedException`).
351    pub fn dispatch_task<F, Fut>(&self, action: F, cancellation_token: CancellationToken) -> HeadlessUnitTestTask<Fut::Output>
352    where
353        F: FnOnce() -> Fut + Send + 'static,
354        Fut: Future + 'static,
355        Fut::Output: Send + 'static,
356    {
357        self.dispatch_core(action, cancellation_token)
358    }
359
360    fn dispatch_core<F, Fut>(&self, action: F, cancellation_token: CancellationToken) -> HeadlessUnitTestTask<Fut::Output>
361    where
362        F: FnOnce() -> Fut + Send + 'static,
363        Fut: Future + 'static,
364        Fut::Output: Send + 'static,
365    {
366        if self.cancellation_token_source.is_cancellation_requested() {
367            panic!("Cannot access a disposed object.\nObject name: 'Session was already disposed.'.");
368        }
369
370        let token = self.cancellation_token_source.token();
371        let isolated = self.isolated;
372
373        let (tcs, task) = TaskCompletionSource::<Fut::Output>::new();
374        self.queue.add(Box::new(move |session: &SessionThread| {
375            let cts = CancellationTokenSource::new();
376            let global_cts = {
377                let cts = cts.clone();
378                token.register(move || cts.cancel())
379            };
380            let local_cts = {
381                let cts = cts.clone();
382                cancellation_token.register(move || cts.cancel())
383            };
384
385            let application = match catch_unwind(AssertUnwindSafe(|| {
386                if isolated {
387                    session.ensure_isolated_application()
388                } else {
389                    session.ensure_shared_application()
390                }
391            })) {
392                Ok(application) => application,
393                Err(ex) => {
394                    tcs.try_set_exception(ex);
395                    local_cts.dispose();
396                    global_cts.dispose();
397                    return; // Exit the dispatcher action if application initialization fails
398                }
399            };
400
401            let mut should_cancel = false;
402            let mut caught: Option<Exception> = None;
403            let mut result: Option<Fut::Output> = None;
404
405            match catch_unwind(AssertUnwindSafe(|| {
406                // The future is started as a call of an asynchronous method is: it runs to its
407                // first pending await before `start_local` returns. The guard is the continuation
408                // of the original, which cancels the source when the task has finished.
409                let future = action();
410                let guard = CancelOnDrop(cts.clone());
411                let task = Dispatcher::ui_thread().to_task_scheduler().start_local(async move {
412                    let _guard = guard;
413                    future.await
414                });
415                if task.is_completed() {
416                    return match task.result() {
417                        Ok(result) => Some(result),
418                        Err(canceled) => std::panic::panic_any(canceled),
419                    };
420                }
421
422                if cts.is_cancellation_requested() {
423                    should_cancel = true;
424                    None
425                } else {
426                    let frame = DispatcherFrame::new();
427                    let inner_cts = {
428                        let frame = frame.clone();
429                        cts.token().register(move || frame.set_continue(false))
430                    };
431                    Dispatcher::ui_thread().push_frame(&frame);
432                    inner_cts.dispose();
433                    match task.result() {
434                        Ok(result) => Some(result),
435                        Err(canceled) => std::panic::panic_any(canceled),
436                    }
437                }
438            })) {
439                Ok(value) => result = value,
440                Err(ex) => caught = Some(ex),
441            }
442
443            if let Err(ex) = catch_unwind(AssertUnwindSafe(|| application.dispose())) {
444                // Cleanup runs before the TCS is completed, so its failure must be
445                // reported by this work item instead of escaping the consumer loop.
446                caught = Some(ex);
447            }
448
449            local_cts.dispose();
450            global_cts.dispose();
451
452            if let Some(caught) = caught {
453                tcs.try_set_exception(caught);
454            } else if should_cancel {
455                tcs.try_set_canceled();
456            } else if let Some(result) = result {
457                tcs.try_set_result(result);
458            }
459        }));
460        task
461    }
462
463    pub fn dispose(&self) {
464        self.cancellation_token_source.cancel();
465        self.queue.wake();
466        let dispatch_task = self.dispatch_task.lock().unwrap_or_else(PoisonError::into_inner).take();
467        if let Some(dispatch_task) = dispatch_task {
468            // `_dispatchTask.Wait()`.
469            if let Err(ex) = dispatch_task.join() {
470                resume_unwind(ex);
471            }
472        }
473        self.queue.complete_adding();
474    }
475
476    /// The asynchronous form of [`dispose`](Self::dispose). The dispatch
477    /// thread is joined before the future is returned to its first poll:
478    /// there is no task to await in its place.
479    pub async fn dispose_async(&self) {
480        self.dispose();
481    }
482
483    /// Creates instance of [`HeadlessUnitTestSession`] with the
484    /// [`FerroTestIsolationLevel::PerTest`] isolation.
485    ///
486    /// `entry_point_type` is the parameter from which [`AppBuilder`] should
487    /// be created: the function that builds the application
488    /// (`AppBuilder::configure::<TApp>` for an application class).
489    pub fn start_new(entry_point_type: fn() -> AppBuilder) -> Arc<HeadlessUnitTestSession> {
490        Self::start_new_with_isolation(entry_point_type, FerroTestIsolationLevel::PerTest)
491    }
492
493    /// Creates instance of [`HeadlessUnitTestSession`].
494    ///
495    /// `entry_point_type` is the parameter from which [`AppBuilder`] should
496    /// be created. `isolation_level` defines the isolation level for
497    /// headless unit tests.
498    ///
499    /// # Panics
500    /// Raises the panic of the entry point again.
501    pub fn start_new_with_isolation(
502        entry_point_type: fn() -> AppBuilder,
503        isolation_level: FerroTestIsolationLevel,
504    ) -> Arc<HeadlessUnitTestSession> {
505        let (tcs, started) = mpsc::channel::<Result<(), Exception>>();
506        let cancellation_token_source = CancellationTokenSource::new();
507        let queue = Arc::new(BlockingQueue::default());
508
509        let task = {
510            let cancellation_token_source = cancellation_token_source.clone();
511            let queue = queue.clone();
512            thread::Builder::new()
513                .name("HeadlessUnitTestSession".to_owned())
514                .spawn(move || {
515                    // See the module documentation: the dispatcher of the session is the
516                    // dispatcher of this thread.
517                    let _dispatcher = Dispatcher::unit_test_scope();
518                    // Dropped before the scope of the dispatcher, after the session.
519                    let _compositor = ReleaseCompositorOnDrop;
520                    let session = match catch_unwind(|| {
521                        let mut app_builder = entry_point_type();
522
523                        // If windowing subsystem wasn't initialized by user, force headless with default parameters.
524                        if app_builder.windowing_subsystem_name().as_deref() != Some("Headless") {
525                            app_builder = app_builder.use_headless(FerroHeadlessPlatformOptions::default());
526                        }
527
528                        if app_builder.text_shaping_subsystem_initializer().is_none() {
529                            app_builder = app_builder.use_harfbuzz();
530                        }
531
532                        SessionThread { app_builder, shared_context: RefCell::new(None) }
533                    }) {
534                        Ok(session) => {
535                            let _ = tcs.send(Ok(()));
536                            session
537                        }
538                        Err(e) => {
539                            let _ = tcs.send(Err(e));
540                            return;
541                        }
542                    };
543
544                    while !cancellation_token_source.is_cancellation_requested() {
545                        // `None` is the `OperationCanceledException` of the original.
546                        if let Some(action) = queue.take(&cancellation_token_source) {
547                            action(&session);
548                        }
549                    }
550                })
551                .expect("the thread of the headless unit test session")
552        };
553
554        match started.recv() {
555            Ok(Ok(())) => {}
556            Ok(Err(e)) => resume_unwind(e),
557            Err(_) => panic!("The thread of the headless unit test session ended before the session was created."),
558        }
559
560        let run_isolated = isolation_level == FerroTestIsolationLevel::PerTest;
561        Arc::new(HeadlessUnitTestSession {
562            cancellation_token_source,
563            queue,
564            dispatch_task: Mutex::new(Some(task)),
565            isolated: run_isolated,
566        })
567    }
568
569    /// Creates a session from the test application attribute of the
570    /// assembly or reuses any existing. If the attribute doesn't exist,
571    /// empty application is used.
572    ///
573    /// `None` is the assembly of the session itself.
574    pub fn get_or_start_for_assembly(assembly: Option<&FerroTestAssembly>) -> Arc<HeadlessUnitTestSession> {
575        let assembly = assembly.unwrap_or(&HEADLESS_ASSEMBLY);
576
577        let mut sessions = sessions().lock().unwrap_or_else(PoisonError::into_inner);
578        if let Some(session) = sessions.get(assembly.name) {
579            return session.clone();
580        }
581
582        let app_builder_entry_point_type =
583            assembly.test_application.map(|attribute| attribute.app_builder_entry_point_type());
584
585        let isolation_level = assembly
586            .test_isolation
587            .map_or(FerroTestIsolationLevel::PerTest, |attribute| attribute.isolation_level());
588
589        let session = match app_builder_entry_point_type {
590            Some(app_builder_entry_point_type) => {
591                Self::start_new_with_isolation(app_builder_entry_point_type, isolation_level)
592            }
593            None => Self::start_new_with_isolation(AppBuilder::configure::<Application>, isolation_level),
594        };
595
596        sessions.insert(assembly.name, session.clone());
597
598        session
599    }
600}
601
602impl IDisposable for HeadlessUnitTestSession {
603    fn dispose(&self) {
604        HeadlessUnitTestSession::dispose(self)
605    }
606}
607
608/// Processes the composition batches the test has left in flight, so that
609/// the next test of a shared application starts with none.
610///
611/// Not in the original (DEVIATIONS.md, Headless platform), whose cleanup of
612/// a shared application only runs the jobs. A batch is processed by a tick
613/// of the render timer, and the timer of the headless platform ticks on the
614/// dispatcher of the session, which does not run between two dispatches: a
615/// batch the jobs after a test commit (a test that closes a window ends so:
616/// the window hides before its renderer is disposed, which asks for a
617/// commit) stays in flight into the next test, whose commits the media
618/// context then holds back until a tick. A window the next test shows is
619/// not composed by its jobs, and its composition visual is not attached to
620/// its target (the snapshot test of the rendering tests failed so, in the
621/// order of tests in which the leak test ran before it). An isolated
622/// application is set up afresh for every test and has nothing in flight.
623///
624/// A tick processes the batches and posts their completion; the jobs after
625/// it run the completion and the render pass it schedules, which may commit
626/// once more. As many rounds as the headless window extensions render at
627/// most in one go.
628fn finish_composition_in_flight() {
629    let Some(media_context) = FerroLocator::current().get_service::<MediaContext>() else {
630        return;
631    };
632    for _ in 0..10 {
633        if !media_context.has_composition_batches_in_flight() {
634            return;
635        }
636        FerroHeadlessPlatform::force_render_timer_tick(1);
637        Dispatcher::ui_thread().run_jobs(None);
638    }
639}
640
641impl SessionThread {
642    fn ensure_shared_application(&self) -> Box<dyn IDisposable> {
643        let old_context = FerroSynchronizationContext::current();
644        if Application::current().is_none() {
645            self.app_builder.setup_unsafe();
646            *self.shared_context.borrow_mut() = FerroSynchronizationContext::current();
647        } else {
648            FerroSynchronizationContext::set_current(self.shared_context.borrow().clone());
649        }
650
651        Box::new(Cleanup::new(move || {
652            let jobs = catch_unwind(|| {
653                Dispatcher::ui_thread().run_jobs(None);
654                finish_composition_in_flight();
655            });
656            FerroSynchronizationContext::set_current(old_context);
657            if let Err(ex) = jobs {
658                resume_unwind(ex);
659            }
660        }))
661    }
662
663    fn ensure_isolated_application(&self) -> Box<dyn IDisposable> {
664        let scope = FerroLocator::enter_scope();
665        let old_context = FerroSynchronizationContext::current();
666        if let Err(ex) = catch_unwind(AssertUnwindSafe(|| {
667            Dispatcher::reset_before_unit_tests();
668            self.app_builder.setup_unsafe();
669        })) {
670            scope.dispose();
671            resume_unwind(ex);
672        }
673
674        Box::new(Cleanup::new(move || {
675            let services = catch_unwind(|| {
676                let tool_tip_service = FerroLocator::current().get_service::<dyn IToolTipService>();
677                if let Some(tool_tip_service) =
678                    tool_tip_service.as_ref().and_then(|service| service.as_tool_tip_service())
679                {
680                    tool_tip_service.dispose();
681                }
682                if let Some(font_manager) = FerroLocator::current().get_service::<FontManager>() {
683                    font_manager.dispose();
684                }
685                Dispatcher::reset_for_unit_tests();
686            });
687
688            // Cleanup jobs can throw, but the ambient state still belongs to this dispatch.
689            let scope = catch_unwind(AssertUnwindSafe(|| scope.dispose()));
690            Dispatcher::reset_before_unit_tests();
691            FerroSynchronizationContext::set_current(old_context);
692            // Not in the original: the queue of the controls that wait for their loaded event is
693            // a static of the thread, which the reset dispatcher will not run (as the scope of
694            // the unit test application of the controls does).
695            Control::reset_loaded_queue_for_unit_tests();
696
697            // An exception of the inner `finally` replaces the one that was in flight.
698            if let Err(ex) = scope {
699                resume_unwind(ex);
700            }
701            if let Err(ex) = services {
702                resume_unwind(ex);
703            }
704        }))
705    }
706}
707
708/// `Disposable.Create`.
709struct Cleanup(RefCell<Option<Box<dyn FnOnce()>>>);
710
711impl Cleanup {
712    fn new(action: impl FnOnce() + 'static) -> Self {
713        Self(RefCell::new(Some(Box::new(action))))
714    }
715}
716
717impl IDisposable for Cleanup {
718    fn dispose(&self) {
719        let action = self.0.borrow_mut().take();
720        if let Some(action) = action {
721            action();
722        }
723    }
724}