Skip to main content

burn_train/renderer/tui/
renderer.rs

1use crate::logger::{EvaluationProgressLogger, ProgressSnapshot, TrainingProgressLogger};
2use crate::metric::{MetricAttributes, MetricDefinition, MetricId};
3use crate::renderer::tui::TuiSplit;
4use crate::renderer::{EvaluationName, MetricState, MetricsRenderer, MetricsRendererEvaluation};
5use crate::renderer::{MetricsRendererTraining, tui::NumericMetricsState};
6use crate::{Interrupter, LearnerSummary};
7use burn_core::data::dataloader::Progress;
8use ratatui::{
9    Terminal,
10    crossterm::{
11        event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
12        execute,
13        terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
14    },
15    prelude::*,
16};
17use std::collections::HashMap;
18use std::panic::{set_hook, take_hook};
19use std::sync::Once;
20use std::sync::atomic::{AtomicBool, Ordering};
21use std::sync::mpsc::{Receiver, Sender};
22use std::sync::{Arc, Mutex, mpsc};
23use std::thread::JoinHandle;
24use std::{
25    error::Error,
26    io::{self, Stdout},
27    time::{Duration, Instant},
28};
29
30use super::{
31    Callback, CallbackFn, ControlsView, MetricsView, PopupState, ProgressBarState, StatusState,
32    TextEventOutcome, TextMetricsState, TuiGroup, TuiTag,
33};
34
35/// The current terminal backend.
36pub(crate) type TerminalBackend = CrosstermBackend<Stdout>;
37/// The current terminal frame.
38pub(crate) type TerminalFrame<'a> = ratatui::Frame<'a>;
39
40type PanicHook = Box<dyn Fn(&std::panic::PanicHookInfo<'_>) + 'static + Sync + Send>;
41
42const MAX_REFRESH_RATE_MILLIS: u64 = 100;
43
44static USER_KILL_REQUESTED: AtomicBool = AtomicBool::new(false);
45// `Once` to have a single error message while subsequent threads silently unwind
46static USER_KILL_NOTIFICATION: Once = Once::new();
47
48enum TuiRendererEvent {
49    MetricRegistration(MetricDefinition),
50    MetricsUpdate((TuiSplit, TuiGroup, MetricState)),
51    StatusUpdateTrain((TuiSplit, ProgressSnapshot)),
52    StatusUpdateTest(ProgressSnapshot),
53    ProcessEnd {
54        summary: Option<LearnerSummary>,
55        /// Interrupter reset.
56        reset: bool,
57    },
58    CounterUpdate(String),
59    LabelUpdate(Option<String>),
60    SplitEnd,
61    ManualClose,
62    Close,
63    Persistent,
64}
65
66/// The terminal UI metrics renderer.
67pub struct TuiMetricsRendererWrapper {
68    sender: mpsc::Sender<TuiRendererEvent>,
69    interrupter: Interrupter,
70    handle_join: Option<JoinHandle<()>>,
71    kill_signal: Arc<Mutex<Receiver<()>>>,
72    current_split: TuiSplit,
73    training_progress: ProgressSnapshot,
74    eval_progress: ProgressSnapshot,
75}
76
77impl TuiMetricsRendererWrapper {
78    /// Create a new terminal UI renderer.
79    pub fn new(interrupter: Interrupter, checkpoint: Option<usize>) -> Self {
80        let (sender, receiver) = mpsc::channel();
81        let (kill_signal_sender, kill_signal_receiver) = mpsc::channel();
82
83        let interrupter_clone = interrupter.clone();
84        let handle_join = std::thread::Builder::new()
85            .name("train-renderer".into())
86            .spawn(move || {
87                let mut renderer =
88                    TuiMetricsRenderer::new(interrupter_clone, checkpoint, kill_signal_sender);
89
90                let tick_rate = Duration::from_millis(MAX_REFRESH_RATE_MILLIS);
91                loop {
92                    let remaining_time = tick_rate.saturating_sub(renderer.last_update.elapsed());
93                    match receiver.recv_timeout(remaining_time) {
94                        Ok(event) => renderer.handle_event(event),
95                        Err(mpsc::RecvTimeoutError::Timeout) => (),
96                        Err(mpsc::RecvTimeoutError::Disconnected) => {
97                            log::error!("Renderer thread disconnected.");
98                            break;
99                        }
100                    }
101
102                    // Render
103                    if renderer.last_update.elapsed() >= tick_rate
104                        && let Err(err) = renderer.render()
105                    {
106                        log::error!("Render error: {err}");
107                        break;
108                    }
109
110                    if (renderer.manual_close && renderer.interrupter.should_stop())
111                        || renderer.close
112                    {
113                        break;
114                    }
115                }
116            })
117            .unwrap();
118
119        let init = Progress::new(0, 0, None);
120        Self {
121            sender,
122            interrupter,
123            handle_join: Some(handle_join),
124            kill_signal: Arc::new(Mutex::new(kill_signal_receiver)),
125            current_split: TuiSplit::Train,
126            training_progress: ProgressSnapshot::new(init.clone(), init.clone()),
127            eval_progress: ProgressSnapshot::new(init.clone(), init),
128        }
129    }
130
131    fn send_event(&self, event: TuiRendererEvent) {
132        if self.kill_signal.lock().unwrap().try_recv().is_ok()
133            || USER_KILL_REQUESTED.load(Ordering::Relaxed)
134        {
135            USER_KILL_REQUESTED.store(true, Ordering::Relaxed);
136            panic!("Killing training from user input.");
137        }
138
139        if let Err(e) = self.sender.send(event) {
140            // Ignore send errors if we are already in the middle of killing
141            if !USER_KILL_REQUESTED.load(Ordering::Relaxed) {
142                log::warn!("Failed to send TUI event: {e}");
143            }
144        }
145    }
146
147    /// Set the renderer to persistent mode.
148    pub fn persistent(self) -> Self {
149        self.send_event(TuiRendererEvent::Persistent);
150        self
151    }
152
153    /// Joins the render thread if it hasn't already been joined.
154    ///
155    /// Both `manual_close` and `drop` can trigger this, so the handle may already have been
156    /// taken by the time either one runs. Safe to call multiple times: only the first call
157    /// actually joins the thread, subsequent calls are a no-op.
158    fn join_render_thread(&mut self) {
159        if let Some(handle) = self.handle_join.take() {
160            let _ = handle.join();
161        }
162    }
163}
164
165struct TuiMetricsRenderer {
166    terminal: Terminal<TerminalBackend>,
167    last_update: std::time::Instant,
168    progress: ProgressBarState,
169    metric_definitions: HashMap<MetricId, MetricDefinition>,
170    metrics_numeric: NumericMetricsState,
171    metrics_text: TextMetricsState,
172    status: StatusState,
173    interrupter: Interrupter,
174    popup: PopupState,
175    previous_panic_hook: Option<Arc<PanicHook>>,
176    persistent: bool,
177    manual_close: bool,
178    close: bool,
179    summary: Option<LearnerSummary>,
180    kill_signal: Sender<()>,
181}
182
183impl MetricsRendererEvaluation for TuiMetricsRendererWrapper {
184    fn update_test(&mut self, name: EvaluationName, state: MetricState) {
185        self.send_event(TuiRendererEvent::MetricsUpdate((
186            TuiSplit::Test,
187            TuiGroup::Named(name.name),
188            state,
189        )));
190    }
191
192    fn on_test_end(&mut self, summary: Option<LearnerSummary>) -> Result<(), Box<dyn Error>> {
193        // Update the summary
194        self.send_event(TuiRendererEvent::ProcessEnd {
195            summary,
196            reset: false,
197        });
198        Ok(())
199    }
200}
201
202impl MetricsRenderer for TuiMetricsRendererWrapper {
203    fn manual_close(&mut self) {
204        self.send_event(TuiRendererEvent::ManualClose);
205        self.join_render_thread();
206    }
207
208    fn register_metric(&mut self, definition: MetricDefinition) {
209        self.send_event(TuiRendererEvent::MetricRegistration(definition));
210    }
211}
212
213impl MetricsRendererTraining for TuiMetricsRendererWrapper {
214    fn update_train(&mut self, state: MetricState) {
215        self.send_event(TuiRendererEvent::MetricsUpdate((
216            TuiSplit::Train,
217            TuiGroup::Default,
218            state,
219        )));
220    }
221
222    fn update_valid(&mut self, state: MetricState) {
223        self.send_event(TuiRendererEvent::MetricsUpdate((
224            TuiSplit::Valid,
225            TuiGroup::Default,
226            state,
227        )));
228    }
229
230    fn on_train_end(&mut self, summary: Option<LearnerSummary>) -> Result<(), Box<dyn Error>> {
231        // Reset for following steps.
232        self.interrupter.reset();
233        // Update the summary
234        self.send_event(TuiRendererEvent::ProcessEnd {
235            summary,
236            reset: true,
237        });
238        Ok(())
239    }
240}
241
242impl TrainingProgressLogger for TuiMetricsRendererWrapper {
243    fn start(
244        &mut self,
245        total_epochs: usize,
246        starting_epoch: usize,
247        total_items: Option<usize>,
248        label: Option<&str>,
249    ) {
250        self.send_event(TuiRendererEvent::LabelUpdate(label.map(str::to_string)));
251        self.training_progress.global =
252            Progress::new(starting_epoch, total_epochs, Some("epochs".to_string()));
253        if let Some(items) = total_items {
254            self.training_progress.split = Progress::new(0, items, Some("items".to_string()));
255        }
256    }
257
258    fn update_epoch(&mut self, epoch: usize) {
259        let total = self.training_progress.global.items_total;
260        let unit = self.training_progress.global.unit.clone();
261        self.training_progress.global = Progress::new(epoch + 1, total, unit);
262    }
263
264    fn start_split(&mut self, split: &str, total_items: usize) {
265        self.training_progress.split = Progress::new(0, total_items, Some("items".to_string()));
266        self.current_split = if split == "train" {
267            TuiSplit::Train
268        } else {
269            TuiSplit::Valid
270        };
271    }
272
273    fn update_split(&mut self, items_processed: usize) {
274        let total = self.training_progress.split.items_total;
275        let unit = self.training_progress.split.unit.clone();
276        self.training_progress.split = Progress::new(items_processed, total, unit);
277        if self.training_progress.global.items_total == 0 {
278            self.training_progress.global = self.training_progress.split.clone();
279        }
280        self.send_event(TuiRendererEvent::StatusUpdateTrain((
281            self.current_split,
282            self.training_progress.clone(),
283        )));
284    }
285
286    fn end_split(&mut self) {
287        self.send_event(TuiRendererEvent::SplitEnd);
288        self.current_split = TuiSplit::Train;
289    }
290
291    fn end(&mut self) {}
292
293    fn log_event_training(&mut self, event: String) {
294        self.send_event(TuiRendererEvent::CounterUpdate(event));
295    }
296}
297
298impl EvaluationProgressLogger for TuiMetricsRendererWrapper {
299    fn start_global_progress(&mut self, total_tests: usize) {
300        self.eval_progress.global = Progress::new(0, total_tests, Some("tests".to_string()));
301    }
302
303    fn start_test(&mut self, _name: &str, total_items: usize) {
304        let current = self.eval_progress.global.items_processed + 1;
305        let total = self.eval_progress.global.items_total;
306        self.eval_progress.global = Progress::new(current, total, Some("tests".to_string()));
307        self.eval_progress.split = Progress::new(0, total_items, Some("items".to_string()));
308    }
309
310    fn update_test_progress(&mut self, items_processed: usize) {
311        let total = self.eval_progress.split.items_total;
312        let unit = self.eval_progress.split.unit.clone();
313        self.eval_progress.split = Progress::new(items_processed, total, unit);
314        self.send_event(TuiRendererEvent::StatusUpdateTest(
315            self.eval_progress.clone(),
316        ));
317    }
318
319    fn end_test(&mut self) {
320        self.send_event(TuiRendererEvent::SplitEnd);
321    }
322
323    fn end_global_progress(&mut self) {}
324
325    fn log_event_evaluation(&mut self, event: String) {
326        self.send_event(TuiRendererEvent::CounterUpdate(event));
327    }
328}
329
330impl Drop for TuiMetricsRendererWrapper {
331    fn drop(&mut self) {
332        if !std::thread::panicking() {
333            self.send_event(TuiRendererEvent::Close);
334            self.join_render_thread();
335        }
336    }
337}
338
339impl TuiMetricsRenderer {
340    fn update_metric(&mut self, split: TuiSplit, group: TuiGroup, state: MetricState) {
341        match state {
342            MetricState::Generic(entry) => {
343                let name = self
344                    .metric_definitions
345                    .get(&entry.metric_id)
346                    .unwrap()
347                    .name
348                    .clone();
349                self.metrics_text.update(split, group, entry, name);
350            }
351            MetricState::Numeric(entry, value) => {
352                let name: Arc<String> = self
353                    .metric_definitions
354                    .get(&entry.metric_id)
355                    .unwrap()
356                    .name
357                    .clone();
358                self.metrics_numeric
359                    .push(TuiTag::new(split, group.clone()), name.clone(), value);
360                self.metrics_text.update(split, group, entry, name);
361            }
362        };
363    }
364
365    pub fn new(
366        interrupter: Interrupter,
367        checkpoint: Option<usize>,
368        kill_signal: Sender<()>,
369    ) -> Self {
370        let mut stdout = io::stdout();
371        execute!(stdout, EnterAlternateScreen, EnableMouseCapture).unwrap();
372        enable_raw_mode().unwrap();
373        let terminal = Terminal::new(CrosstermBackend::new(stdout)).unwrap();
374
375        // Reset the terminal to raw mode on panic before running the panic handler
376        // This prevents that the panic message is not visible for the user.
377        let previous_panic_hook = Arc::new(take_hook());
378        set_hook(Box::new({
379            let previous_panic_hook = previous_panic_hook.clone();
380            move |panic_info| {
381                let _ = disable_raw_mode();
382                let _ = execute!(io::stdout(), DisableMouseCapture, LeaveAlternateScreen);
383
384                if USER_KILL_REQUESTED.load(Ordering::Relaxed) {
385                    USER_KILL_NOTIFICATION.call_once(|| {
386                        log::warn!("Training killed by user.");
387                        eprintln!("\nTraining killed by user.");
388                    });
389                    // A user-requested kill causes several threads (main, renderer, worker, ...) to panic.
390                    // Suppress the normal panic hook so the user sees a single message instead of a
391                    // cascade of panic reports.
392                    return;
393                }
394
395                previous_panic_hook(panic_info);
396            }
397        }));
398
399        Self {
400            terminal,
401            last_update: Instant::now(),
402            progress: ProgressBarState::new(checkpoint),
403            metric_definitions: HashMap::default(),
404            metrics_numeric: NumericMetricsState::default(),
405            metrics_text: TextMetricsState::default(),
406            status: StatusState::default(),
407            interrupter,
408            popup: PopupState::Empty,
409            previous_panic_hook: Some(previous_panic_hook),
410            persistent: false,
411            manual_close: false,
412            close: false,
413            summary: None,
414            kill_signal,
415        }
416    }
417
418    fn handle_event(&mut self, event: TuiRendererEvent) {
419        match event {
420            TuiRendererEvent::MetricRegistration(definition) => {
421                if let MetricAttributes::Numeric(_) = &definition.attributes {
422                    // Register numeric metrics so even epoch-level metrics already appear
423                    // in the registry (i.e., TUI tabs) before a value is actually pushed.
424                    self.metrics_numeric.register(definition.name.clone());
425                }
426                self.metric_definitions
427                    .insert(definition.metric_id.clone(), definition);
428            }
429            TuiRendererEvent::MetricsUpdate((split, group, state)) => {
430                self.update_metric(split, group, state);
431            }
432            TuiRendererEvent::StatusUpdateTrain((split, item)) => match split {
433                TuiSplit::Train => {
434                    self.progress.update_train(&item);
435                    self.metrics_numeric.update_progress_train(&item);
436                    self.status.update_train(&item);
437                }
438                TuiSplit::Valid => {
439                    self.progress.update_valid(&item);
440                    self.metrics_numeric.update_progress_valid(&item);
441                    self.status.update_valid(&item);
442                }
443                _ => (),
444            },
445            TuiRendererEvent::StatusUpdateTest(item) => {
446                self.progress.update_test(&item);
447                self.metrics_numeric.update_progress_test(&item);
448                self.status.update_test(&item);
449            }
450            TuiRendererEvent::ProcessEnd { summary, reset } => {
451                match (self.summary.take(), summary) {
452                    (None, Some(summary)) => {
453                        self.summary = Some(summary);
454                    }
455                    (Some(current), Some(other)) => self.summary = Some(current.merge(other)),
456                    (_, _) => { /* nothing to update */ }
457                }
458
459                if reset {
460                    self.interrupter.reset();
461                }
462            }
463            TuiRendererEvent::CounterUpdate(event) => {
464                self.status.update_counter(event);
465            }
466            TuiRendererEvent::LabelUpdate(label) => self.status.update_label(label.as_deref()),
467            TuiRendererEvent::SplitEnd => {
468                self.status.reset_counters();
469            }
470            TuiRendererEvent::ManualClose => self.manual_close = true,
471            TuiRendererEvent::Persistent => self.persistent = true,
472            TuiRendererEvent::Close => self.close = true,
473        }
474    }
475
476    fn render(&mut self) -> Result<(), Box<dyn Error>> {
477        self.draw()?;
478        self.handle_user_input()?;
479
480        self.last_update = Instant::now();
481
482        Ok(())
483    }
484
485    fn draw(&mut self) -> Result<(), Box<dyn Error>> {
486        self.terminal.draw(|frame| {
487            let size = frame.area();
488
489            match self.popup.view() {
490                Some(view) => view.render(frame, size),
491                None => {
492                    let view = MetricsView::new(
493                        self.metrics_numeric.view(),
494                        self.metrics_text.view(),
495                        self.progress.view(),
496                        ControlsView,
497                        self.status.view(),
498                    );
499
500                    view.render(frame, size);
501                }
502            };
503        })?;
504
505        Ok(())
506    }
507
508    /// Dispatch a single user event to the popup / numeric / text components.
509    /// Returns `true` when something visible changed and a redraw is warranted.
510    /// The training loop ignores this (it is tick-gated); the post-training loop
511    /// uses it to skip redraws on inert events like mouse jitter.
512    fn dispatch_user_event(&mut self, event: &Event) -> bool {
513        let mut redraw = self.popup.on_event(event);
514        if self.popup.is_empty() {
515            redraw |= self.metrics_numeric.on_event(event);
516            redraw |= match self.metrics_text.on_event(event) {
517                TextEventOutcome::Clicked(name) => {
518                    self.metrics_numeric.select_by_name(&name);
519                    true
520                }
521                TextEventOutcome::HoverChanged => true,
522                TextEventOutcome::Ignored => false,
523            };
524        }
525        redraw
526    }
527
528    fn handle_user_input(&mut self) -> Result<(), Box<dyn Error>> {
529        while event::poll(Duration::from_secs(0))? {
530            let event = event::read()?;
531            let _ = self.dispatch_user_event(&event);
532
533            if self.popup.is_empty()
534                && let Event::Key(key) = event
535                && let KeyCode::Char('q') = key.code
536            {
537                self.popup = PopupState::Full(
538                    "Quit".to_string(),
539                    vec![
540                        Callback::new(
541                            "Stop the training.",
542                            "Stop the training immediately. This will break from the \
543                                 training loop, but any remaining code after the loop will be \
544                                 executed.",
545                            's',
546                            QuitPopupAccept(self.interrupter.clone()),
547                        ),
548                        Callback::new(
549                            "Stop the training immediately.",
550                            "Kill the program. This will abort the current training instantly. \
551                                 Any code following the training won't be executed.",
552                            'k',
553                            KillPopupAccept(self.kill_signal.clone()),
554                        ),
555                        Callback::new(
556                            "Cancel",
557                            "Cancel the action, continue the training.",
558                            'c',
559                            PopupCancel,
560                        ),
561                    ],
562                );
563            }
564        }
565
566        Ok(())
567    }
568
569    fn handle_post_training(&mut self) -> Result<(), Box<dyn Error>> {
570        self.popup = PopupState::Full(
571            "Training is done".to_string(),
572            vec![Callback::new(
573                "Training Done",
574                "Press 'x' to close this popup.  Press 'q' to exit the application after the \
575                popup is closed.",
576                'x',
577                PopupCancel,
578            )],
579        );
580
581        self.draw().ok();
582
583        loop {
584            if let Ok(true) = event::poll(Duration::from_millis(MAX_REFRESH_RATE_MILLIS)) {
585                match event::read() {
586                    Ok(event @ Event::Key(key)) => {
587                        let redraw = self.dispatch_user_event(&event);
588                        if self.popup.is_empty()
589                            && let KeyCode::Char('q') = key.code
590                        {
591                            break;
592                        }
593                        if redraw {
594                            self.draw().ok();
595                        }
596                    }
597
598                    Ok(event @ Event::Mouse(_)) => {
599                        if self.dispatch_user_event(&event) {
600                            self.draw().ok();
601                        }
602                    }
603
604                    Ok(Event::Resize(..)) => {
605                        self.draw().ok();
606                    }
607                    Err(err) => {
608                        eprintln!("Error reading event: {err}");
609                        break;
610                    }
611                    _ => continue,
612                }
613            }
614        }
615        Ok(())
616    }
617
618    // Reset the terminal back to raw mode.
619    fn reset(&mut self) -> Result<(), Box<dyn Error>> {
620        // If previous panic hook has already been re-instated, then the terminal was already reset.
621        if self.previous_panic_hook.is_some() {
622            if self.persistent
623                && let Err(err) = self.handle_post_training()
624            {
625                eprintln!("Error in post-training handling: {err}");
626            }
627
628            disable_raw_mode()?;
629            execute!(
630                self.terminal.backend_mut(),
631                DisableMouseCapture,
632                LeaveAlternateScreen
633            )?;
634            self.terminal.show_cursor()?;
635
636            // Reinstall the previous panic hook
637            let _ = take_hook();
638            if let Some(previous_panic_hook) =
639                Arc::into_inner(self.previous_panic_hook.take().unwrap())
640            {
641                set_hook(previous_panic_hook);
642            }
643        }
644        Ok(())
645    }
646}
647
648struct QuitPopupAccept(Interrupter);
649struct KillPopupAccept(Sender<()>);
650struct PopupCancel;
651
652impl CallbackFn for KillPopupAccept {
653    fn call(&self) -> bool {
654        USER_KILL_REQUESTED.store(true, Ordering::Relaxed);
655        self.0.send(()).unwrap();
656        panic!("Killing training from user input.");
657    }
658}
659
660impl CallbackFn for QuitPopupAccept {
661    fn call(&self) -> bool {
662        self.0.stop(Some("Stopping training from user input."));
663        true
664    }
665}
666
667impl CallbackFn for PopupCancel {
668    fn call(&self) -> bool {
669        true
670    }
671}
672
673impl Drop for TuiMetricsRenderer {
674    fn drop(&mut self) {
675        // Reset the terminal back to raw mode. This can be skipped during
676        // panicking because the panic hook has already reset the terminal
677        if !std::thread::panicking() {
678            self.reset().unwrap();
679
680            if let Some(summary) = &self.summary {
681                println!("{summary}");
682                log::info!("{summary}");
683            }
684        }
685    }
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691
692    // `TuiMetricsRendererWrapper::new` spawns a `TuiMetricsRenderer`, which requires a real
693    // terminal (raw mode + alternate screen), unavailable in CI. Build the wrapper directly
694    // instead, with a plain no-op thread standing in for the render thread, to exercise the
695    // `handle_join` shutdown logic on its own.
696    fn wrapper_with_dummy_thread() -> TuiMetricsRendererWrapper {
697        let (sender, _receiver) = mpsc::channel();
698        let (_kill_signal_sender, kill_signal_receiver) = mpsc::channel();
699        let handle_join = std::thread::spawn(|| {});
700
701        let init = Progress::new(0, 0, None);
702        TuiMetricsRendererWrapper {
703            sender,
704            interrupter: Interrupter::new(),
705            handle_join: Some(handle_join),
706            kill_signal: Arc::new(Mutex::new(kill_signal_receiver)),
707            current_split: TuiSplit::Train,
708            training_progress: ProgressSnapshot::new(init.clone(), init.clone()),
709            eval_progress: ProgressSnapshot::new(init.clone(), init),
710        }
711    }
712
713    #[test]
714    fn manual_close_twice_does_not_panic() {
715        let mut renderer = wrapper_with_dummy_thread();
716
717        // The first call takes and joins the render thread's handle.
718        renderer.manual_close();
719        // Regression test for #4793: a second call used to panic on
720        // `handle_join.take().unwrap()` since the handle was already taken.
721        renderer.manual_close();
722    }
723
724    #[test]
725    fn drop_after_manual_close_does_not_panic() {
726        let mut renderer = wrapper_with_dummy_thread();
727
728        renderer.manual_close();
729        drop(renderer);
730    }
731}