From 7887b549b2a33cbbdf59f5ffe7b818884e57a7f2 Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 7 Apr 2026 21:43:59 +0900 Subject: [PATCH 1/2] fix(cg): unify Stylo test locks to prevent cascading PoisonError failures The `html` and `htmlcss` test modules each had their own mutex guarding Stylo's process-global DOM slot. Since both locks protected the same shared state, tests from the two modules could run concurrently and corrupt Stylo, causing panics that poisoned the htmlcss lock (which used `.unwrap()`) and cascaded to all 22 htmlcss tests. Consolidate into a single `stylo_test::lock()` in lib.rs with poison recovery via `unwrap_or_else(|e| e.into_inner())`. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/grida-canvas/src/html/mod.rs | 79 ++++++++++---------------- crates/grida-canvas/src/htmlcss/mod.rs | 49 +++++++--------- crates/grida-canvas/src/lib.rs | 19 +++++++ 3 files changed, 72 insertions(+), 75 deletions(-) diff --git a/crates/grida-canvas/src/html/mod.rs b/crates/grida-canvas/src/html/mod.rs index 6f2ac5557..cb851c964 100644 --- a/crates/grida-canvas/src/html/mod.rs +++ b/crates/grida-canvas/src/html/mod.rs @@ -1392,23 +1392,6 @@ mod tests { use crate::layout::ComputedLayout; use crate::node::schema::Scene; use std::collections::HashMap; - use std::sync::Mutex; - - /// Global mutex to serialize HTML tests. - /// - /// The CSS cascade adapter uses a process-global `DEMO_DOM` static, so - /// concurrent `from_html_str` calls race on that shared slot and cause - /// Stylo `debug_assert` panics ("Why are we here?"). A mutex ensures - /// only one test touches the global DOM at a time. - /// - /// We use `lock().unwrap_or_else(|e| e.into_inner())` to recover from - /// poison so that a single test failure doesn't cascade to all others. - static HTML_TEST_LOCK: Mutex<()> = Mutex::new(()); - - /// Lock the HTML test mutex, clearing poison if a prior test panicked. - fn lock_html() -> std::sync::MutexGuard<'static, ()> { - HTML_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()) - } /// Parse HTML and run the layout engine, returning the scene and a /// map of every node's computed layout. @@ -1455,7 +1438,7 @@ mod tests { #[test] fn smoke_test_basic_html() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#" @@ -1483,7 +1466,7 @@ mod tests { #[test] fn test_inline_style_attribute() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#" @@ -1496,7 +1479,7 @@ mod tests { #[test] fn test_borders_and_shadows() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#" @@ -1514,7 +1497,7 @@ mod tests { #[test] fn test_flex_alignment() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#" @@ -1535,7 +1518,7 @@ mod tests { #[test] fn test_gradient_backgrounds() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#" @@ -1560,7 +1543,7 @@ mod tests { /// 3 fixed-size divs in a flex row with gap. #[test] fn test_flex_row_positions() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#"
@@ -1597,7 +1580,7 @@ mod tests { /// 3 fixed-size divs in a flex column with gap. #[test] fn test_flex_column_positions() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#"
@@ -1630,7 +1613,7 @@ mod tests { /// justify-content: center with 2 fixed children. #[test] fn test_flex_justify_center() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#"
@@ -1662,7 +1645,7 @@ mod tests { /// justify-content: space-between with 3 fixed children. #[test] fn test_flex_justify_space_between() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#"
@@ -1694,7 +1677,7 @@ mod tests { /// align-items: center with a single child shorter than container. #[test] fn test_flex_align_center() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#"
@@ -1722,7 +1705,7 @@ mod tests { /// flex-grow: second child fills remaining space. #[test] fn test_flex_grow() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#"
@@ -1758,7 +1741,7 @@ mod tests { /// Container padding offsets children. #[test] fn test_flex_padding() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#"
@@ -1787,7 +1770,7 @@ mod tests { /// Flex column gap direction is correct (gap applies vertically). #[test] fn test_flex_gap_column_direction() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#"
@@ -1818,7 +1801,7 @@ mod tests { /// Nested flex: outer row, inner column with children. #[test] fn test_nested_flex() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#"
@@ -1869,7 +1852,7 @@ mod tests { /// Explicit width/height dimensions are preserved. #[test] fn test_explicit_dimensions() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#"
@@ -1896,7 +1879,7 @@ mod tests { /// flex-wrap: children that overflow wrap to the next line. #[test] fn test_flex_wrap() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#"
@@ -1944,7 +1927,7 @@ mod tests { /// text-shadow maps to drop-shadow effects on the TextSpan node. #[test] fn test_text_shadow() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#"
Hello
@@ -1982,7 +1965,7 @@ mod tests { /// Multiple text-shadows produce multiple DropShadow effects. #[test] fn test_text_shadow_multiple() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#"
Multi
@@ -2009,7 +1992,7 @@ mod tests { /// box-shadow (inset + outer) maps to InnerShadow + DropShadow. #[test] fn test_box_shadow() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#"
@@ -2044,7 +2027,7 @@ mod tests { /// filter: blur() maps to FeLayerBlur on a rectangle. #[test] fn test_filter_blur() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#"
@@ -2075,7 +2058,7 @@ mod tests { /// filter: drop-shadow() maps to DropShadow effect on a rectangle. #[test] fn test_filter_drop_shadow() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#"
@@ -2115,7 +2098,7 @@ mod tests { /// override is available. This test verifies the pipeline doesn't crash. #[test] fn test_backdrop_filter_blur() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#"
@@ -2145,7 +2128,7 @@ mod tests { /// mix-blend-mode: multiply maps to LayerBlendMode::Blend(BlendMode::Multiply). #[test] fn test_mix_blend_mode() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#"
@@ -2174,7 +2157,7 @@ mod tests { /// mix-blend-mode: normal stays as PassThrough (default). #[test] fn test_mix_blend_mode_normal() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#"
@@ -2200,7 +2183,7 @@ mod tests { /// Effects on containers: filter + blend mode on a flex container. #[test] fn test_container_effects() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#"
@@ -2241,7 +2224,7 @@ mod tests { /// text-decoration-color maps to TextDecorationRec.text_decoration_color. #[test] fn test_text_decoration_color() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#"
Red underline
@@ -2280,7 +2263,7 @@ mod tests { /// text-decoration-style maps to TextDecorationRec.text_decoration_style. #[test] fn test_text_decoration_style() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#"
Wavy
@@ -2315,7 +2298,7 @@ mod tests { /// Combined: text-decoration with color + style + line. #[test] fn test_text_decoration_combined() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#"
Combo
@@ -2359,7 +2342,7 @@ mod tests { /// h1 margin should merge into padding (no extra wrapper container). #[test] fn test_h1_margin_no_double_wrap() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#"

Hello

@@ -2409,7 +2392,7 @@ mod tests { /// Inline elements (, , ) should merge into a single AttributedText. #[test] fn test_inline_elements_merge_to_attributed_text() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#"

Hello world!

@@ -2470,7 +2453,7 @@ mod tests { /// Whitespace between inline elements must be preserved. #[test] fn test_inline_whitespace_preserved() { - let _guard = lock_html(); + let _guard = crate::stylo_test::lock(); let html = r#"

Default red and green text.

diff --git a/crates/grida-canvas/src/htmlcss/mod.rs b/crates/grida-canvas/src/htmlcss/mod.rs index 08cf029f6..250b8d493 100644 --- a/crates/grida-canvas/src/htmlcss/mod.rs +++ b/crates/grida-canvas/src/htmlcss/mod.rs @@ -82,18 +82,13 @@ mod tests { use crate::resources::ByteStore; use std::sync::{Arc, Mutex}; - /// Stylo uses a process-global DOM slot that is not thread-safe. - /// All htmlcss tests must be serialized to avoid concurrent access. - /// We also share this with the `html` module's tests via crate-level visibility. - static TEST_LOCK: Mutex<()> = Mutex::new(()); - fn test_fonts() -> FontRepository { FontRepository::new(Arc::new(Mutex::new(ByteStore::new()))) } #[test] fn test_render_empty() { - let _guard = TEST_LOCK.lock().unwrap(); + let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); let pic = render("", 400.0, 300.0, &fonts); assert!(pic.is_ok()); @@ -101,7 +96,7 @@ mod tests { #[test] fn test_render_heading() { - let _guard = TEST_LOCK.lock().unwrap(); + let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); let pic = render("

Hello

", 400.0, 300.0, &fonts).unwrap(); assert!(pic.cull_rect().width() > 0.0); @@ -109,7 +104,7 @@ mod tests { #[test] fn test_render_with_style_block() { - let _guard = TEST_LOCK.lock().unwrap(); + let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); let pic = render( "

Blue

", @@ -122,7 +117,7 @@ mod tests { #[test] fn test_render_table() { - let _guard = TEST_LOCK.lock().unwrap(); + let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); let pic = render( "
AB
", @@ -135,7 +130,7 @@ mod tests { #[test] fn test_render_flex() { - let _guard = TEST_LOCK.lock().unwrap(); + let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); let pic = render( r#"
A
B
"#, @@ -149,7 +144,7 @@ mod tests { /// Verify grid properties are collected and layout produces columns. #[test] fn test_grid_layout_columns() { - let _guard = TEST_LOCK.lock().unwrap(); + let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); let html = r#"
@@ -222,7 +217,7 @@ mod tests { #[test] fn test_render_grid_basic() { - let _guard = TEST_LOCK.lock().unwrap(); + let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); let pic = render( r#"
@@ -237,7 +232,7 @@ mod tests { #[test] fn test_render_grid_fr() { - let _guard = TEST_LOCK.lock().unwrap(); + let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); let pic = render( r#"
@@ -252,7 +247,7 @@ mod tests { #[test] fn test_render_grid_repeat() { - let _guard = TEST_LOCK.lock().unwrap(); + let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); let pic = render( r#"
@@ -267,7 +262,7 @@ mod tests { #[test] fn test_render_grid_span() { - let _guard = TEST_LOCK.lock().unwrap(); + let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); let pic = render( r#"
@@ -284,7 +279,7 @@ mod tests { #[test] fn test_render_grid_auto_flow_dense() { - let _guard = TEST_LOCK.lock().unwrap(); + let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); let pic = render( r#"
@@ -302,7 +297,7 @@ mod tests { #[test] fn test_render_box_shadow_outer() { - let _guard = TEST_LOCK.lock().unwrap(); + let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); let pic = render( r#"
shadow
"#, @@ -315,7 +310,7 @@ mod tests { #[test] fn test_render_box_shadow_inset() { - let _guard = TEST_LOCK.lock().unwrap(); + let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); let pic = render( r#"
inset
"#, @@ -328,7 +323,7 @@ mod tests { #[test] fn test_render_box_shadow_combined() { - let _guard = TEST_LOCK.lock().unwrap(); + let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); let pic = render( r#"
both
"#, @@ -342,7 +337,7 @@ mod tests { /// Verify box-shadow properties are collected from Stylo. #[test] fn test_box_shadow_collection() { - let _guard = TEST_LOCK.lock().unwrap(); + let _guard = crate::stylo_test::lock(); let html = r#"
shadow
"#; let root = collect::collect_styled_tree(html).unwrap().unwrap(); @@ -372,7 +367,7 @@ mod tests { #[test] fn test_render_opacity() { - let _guard = TEST_LOCK.lock().unwrap(); + let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); let pic = render( r#"

Semi-transparent

"#, @@ -442,7 +437,7 @@ mod tests { #[test] fn test_measure_height() { - let _guard = TEST_LOCK.lock().unwrap(); + let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); let h = measure_content_height("

Hello

", 400.0, &fonts).unwrap(); assert!(h > 0.0, "Content height should be positive, got {h}"); @@ -450,7 +445,7 @@ mod tests { #[test] fn test_head_hidden() { - let _guard = TEST_LOCK.lock().unwrap(); + let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); let pic = render( r#"

V

"#, @@ -467,7 +462,7 @@ mod tests { #[test] fn test_markdown_heading() { - let _guard = TEST_LOCK.lock().unwrap(); + let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); let html = markdown_to_styled_html("# Hello World"); let pic = render(&html, 400.0, 300.0, &fonts); @@ -477,7 +472,7 @@ mod tests { #[test] fn test_markdown_mixed_content() { - let _guard = TEST_LOCK.lock().unwrap(); + let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); let md = r#"# Title @@ -509,7 +504,7 @@ code block #[test] fn test_markdown_table() { - let _guard = TEST_LOCK.lock().unwrap(); + let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); let md = r#" | Name | Age | City | @@ -524,7 +519,7 @@ code block #[test] fn test_markdown_empty() { - let _guard = TEST_LOCK.lock().unwrap(); + let _guard = crate::stylo_test::lock(); let fonts = test_fonts(); let html = markdown_to_styled_html(""); let pic = render(&html, 400.0, 300.0, &fonts); diff --git a/crates/grida-canvas/src/lib.rs b/crates/grida-canvas/src/lib.rs index bf483db08..53f3ae250 100644 --- a/crates/grida-canvas/src/lib.rs +++ b/crates/grida-canvas/src/lib.rs @@ -27,3 +27,22 @@ pub mod text_edit; pub mod text_edit_session; pub mod vectornetwork; pub mod window; + +/// Shared test lock for Stylo's process-global DOM slot. +/// +/// Both `html` and `htmlcss` modules use Stylo which is **not** thread-safe. +/// All tests that call into Stylo must hold this lock. We use +/// `unwrap_or_else(|e| e.into_inner())` to recover from poison so a single +/// test panic does not cascade to every other Stylo test. +#[cfg(test)] +pub(crate) mod stylo_test { + use std::sync::{Mutex, MutexGuard}; + + static STYLO_TEST_LOCK: Mutex<()> = Mutex::new(()); + + pub fn lock() -> MutexGuard<'static, ()> { + STYLO_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()) + } +} From 7dd831c095edec586b5e9c22d1694fce804e32eb Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 7 Apr 2026 21:49:19 +0900 Subject: [PATCH 2/2] style: cargo fmt Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/grida-canvas/src/lib.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/grida-canvas/src/lib.rs b/crates/grida-canvas/src/lib.rs index 53f3ae250..ee5e18497 100644 --- a/crates/grida-canvas/src/lib.rs +++ b/crates/grida-canvas/src/lib.rs @@ -41,8 +41,6 @@ pub(crate) mod stylo_test { static STYLO_TEST_LOCK: Mutex<()> = Mutex::new(()); pub fn lock() -> MutexGuard<'static, ()> { - STYLO_TEST_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()) + STYLO_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()) } }