From 8c3404fc87cdb056c963528887248c24cc94a0b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=B3=D0=B4=D0=B0=20=D0=9C=2E=D0=93=2E?= Date: Wed, 14 Oct 2026 20:35:43 +0200 Subject: [PATCH] Fonts: falling back to the default face no longer deadlocks cascade's last fallback searched the loaded faces and, finding none that draws the cluster, called `first` while the search's read guard was still alive (a temporary in the match arm lives to the end of the `let`). `first` looks up the default family, which loads it through add_fonts, and add_fonts takes the faces' write lock even when there is nothing to add: the thread waited on itself, and every other thread then waited on the loading lock it held. It shows when a run's chain is empty and nothing loaded draws the character before the default family has been looked up, so it depends on which test (or document) gets there first: deckcraft-text's tests hung in 4 of 10 parallel runs here (all threads parked in system_fallback / try_family, one in add_fonts' write), and in 0 of 15 with this change. The search now finishes before `first` runs. New test falling_back_to_the_default_face_does_not_deadlock reaches that path on a fresh database; it times out on main and passes with the fix. --- crates/fonts/src/fallback.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/fonts/src/fallback.rs b/crates/fonts/src/fallback.rs index 563e0337..de4b2b0a 100644 --- a/crates/fonts/src/fallback.rs +++ b/crates/fonts/src/fallback.rs @@ -188,7 +188,12 @@ impl FontDb { // A letter with marks the run's font lacks (stacked combining marks): a loaded plain // face that draws the whole cluster, as PowerPoint draws the marks, before the run's // font with missing-glyph boxes. - _ => self.read_faces().iter().find(|f| !f.is_color() && covers_cluster(f, cluster)).cloned().unwrap_or_else(first), + _ => { + // Searched before `first` runs: it may load the default family, which needs the + // faces' write lock, so the read lock must not be held across it. + let found = self.read_faces().iter().find(|f| !f.is_color() && covers_cluster(f, cluster)).cloned(); + found.unwrap_or_else(first) + } }; // A pictograph that defaults to text presentation (⚠ ♥ ⬆ ▶) whose first fallback is the // colour emoji font: PowerPoint draws it from a plain symbol font. @@ -277,6 +282,22 @@ mod tests { } } + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn falling_back_to_the_default_face_does_not_deadlock() { + // An empty chain and a character no loaded face draws: the loaded faces are searched, + // then the default family is looked up, which loads it (taking the faces' write lock). + // Holding the search's read lock across that lookup hung the thread. + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + let _ = tx.send(db.cascade(&['\u{10FFFD}'], &[], "Regular").family.clone()); + }); + let family = rx.recv_timeout(std::time::Duration::from_secs(30)).expect("cascade did not return"); + assert!(!family.is_empty()); + } + #[test] fn ligature_carets_never_fail() { let face = FontDb::global().face(crate::DEFAULT_FAMILY, "Regular");