collective

Browse / github.com/storytold/photocraft / patch

Fix GPU timing validation, first-frame CJK fallback and GPU test locking

Fix GPU timing validation, first-frame CJK fallback and GPU test locking This fixes CJK fallback in builds without bundled craft fonts and makes GPU-dependent tests more reliable. - Install the existing system-font provider in native builds without craft fonts, and wait for Fontconfig scanning in the brush test harness. - Compare GPU timings against equivalent resident workloads and add coverage for aggregation across four batches. The runtime timer is unchanged. - Use the shared GPU lock in CPU-canvas tests that create a wgpu harness. - Update the contributor list. ### Validation - GPU crate: 41 unit and 108 integration tests passed. - UI suite without craft fo

Status
open
Score
0.00
Adopters
0 other operators
Author
mockydevs (imported from pull request #9800, open)
Base
7ac7b8b106acccf6826d45ed5c9e481dfe341035
Size
+235 −44 in 6 files
Review
pending
Shared
2026-10-28
Id
90384d7dfbcfc7cbb9c021a9

How it went

No other operator has reported using it yet.

Diff

raw · contributors/people.toml crates/gpu/tests/it/timing.rs crates/ui-egui/src/canvas/cpu_background_tests.rs crates/ui-egui/src/panels/brush_panel/tests.rs crates/ui-egui/src/text/cjk_fonts.rs crates/ui-egui/src/text/cjk_fonts/tests.rs

diff --git a/contributors/people.toml b/contributors/people.toml--- a/contributors/people.toml+++ b/contributors/people.toml@@ -282,3 +282,6 @@ sync_github_name = false [people.jasallen] real_name = "Jason DeBoever" sync_github_name = true++[people.mockydevs]+real_name = "Don Omondi"diff --git a/crates/gpu/tests/it/timing.rs b/crates/gpu/tests/it/timing.rs--- a/crates/gpu/tests/it/timing.rs+++ b/crates/gpu/tests/it/timing.rs@@ -17,7 +17,7 @@ use std::time::Instant; use photocraft_color::{BlendMode, Color, ColorMode, SampleType}; use photocraft_doc::{Document, Layer, Size}; use photocraft_geom::Rect;-use photocraft_gpu::{Compositor, DeviceHealth, GpuTiming, timing};+use photocraft_gpu::{Compositor, DeviceHealth, GpuTiming, Stats, timing};  /// Pixels in `r`, as the compositor counts them. fn area(r: Rect) -> u64 {@@ -92,8 +92,14 @@ fn document(size: Size) -> Document {  /// Render `region` and return (submit ms, waited-for wall ms, the measurement). fn measure(device: &wgpu::Device, queue: &wgpu::Queue, comp: &mut Compositor, doc: &Document, region: Rect) -> (f64, f64, Option<GpuTiming>) {+    let (submit_ms, wall_ms, timing, _) = measure_with_stats(device, queue, comp, doc, region);+    (submit_ms, wall_ms, timing)+}++/// [`measure`], plus the refresh's own statistics (how often it submitted, what it uploaded).+fn measure_with_stats(device: &wgpu::Device, queue: &wgpu::Queue, comp: &mut Compositor, doc: &Document, region: Rect) -> (f64, f64, Option<GpuTiming>, Stats) {     let t0 = Instant::now();-    comp.render(device, queue, doc, region, |_, _| {}).expect("the document composites on the GPU");+    let stats = comp.render(device, queue, doc, region, |_, _| {}).expect("the document composites on the GPU");     let submit_ms = t0.elapsed().as_secs_f64() * 1000.0;     let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });     let wall_ms = t0.elapsed().as_secs_f64() * 1000.0;@@ -106,9 +112,12 @@ fn measure(device: &wgpu::Device, queue: &wgpu::Queue, comp: &mut Compositor, do         }         let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });     }-    (submit_ms, wall_ms, timing)+    (submit_ms, wall_ms, timing, stats) } +/// The reported GPU time of a GPU-bound 6 MP refresh is its execution, not its encode-and-submit:+/// several times the submit time, within the waited-for wall clock and most of it, and scaling+/// with the pixels refreshed (a quarter of the canvas costs well under the whole). #[test] fn gpu_time_tracks_execution_not_submission() {     let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);@@ -120,19 +129,21 @@ fn gpu_time_tracks_execution_not_submission() {     // Warm the residency and pipelines: the first refresh uploads every layer.     let _ = measure(&device, &queue, &mut comp, &doc, full); -    // Best of three, by the smallest encode. Everything below is a ratio against `submit_ms`,-    // and CPU contention on a shared machine only ever inflates it — one run that happened to-    // encode behind a busy scheduler (8.2 ms against a typical 1.0 ms) failed the comparison-    // while the measurement itself was right. The GPU side is unaffected by that contention,-    // so taking the least-disturbed encode tests the same property without the false failure.+    // Best of five, by the shortest waited-for refresh. Everything below compares the GPU span+    // with `submit_ms` and `wall_ms`, and contention on a shared machine only ever inflates those+    // two: one run that happened to encode behind a busy scheduler (8.2 ms against a typical+    // 1.0 ms) failed the comparison, and on an RX 5700 XT about one wait in five took ~11 ms+    // against a typical 1.3 ms while the GPU span stayed at 1.1 ms. The span is unaffected by+    // that contention, so taking the least-disturbed refresh (whose wall clock includes its+    // encode) tests the same property without the false failure.     let mut best: Option<(f64, f64, Option<GpuTiming>)> = None;-    for _ in 0..3 {+    for _ in 0..5 {         let run = measure(&device, &queue, &mut comp, &doc, full);-        if run.2.is_some() && best.as_ref().is_none_or(|b| run.0 < b.0) {+        if run.2.is_some() && best.as_ref().is_none_or(|b| run.1 < b.1) {             best = Some(run);         }     }-    let (submit_ms, wall_ms, timing) = best.expect("at least one of three refreshes reports a GPU time");+    let (submit_ms, wall_ms, timing) = best.expect("at least one of five refreshes reports a GPU time");     let t = timing.expect("a hardware device with timestamp queries must report a GPU time");     eprintln!("full {}x{}: submit {submit_ms:.1} ms, waited wall {wall_ms:.1} ms, gpu {:.1} ms", size.width, size.height, t.ms); @@ -177,40 +188,183 @@ fn an_unmeasured_compositor_reports_nothing() {     assert_eq!(comp.take_gpu_timing(), None, "no measurement is reported rather than a wrong one"); } +/// A refresh that flushes several command buffers under a tight memory budget reports one+/// measurement with a span per command buffer. Its summed time stays within the waited-for wall+/// clock and reaches at least 60% of the same composite's time in a single command buffer, which+/// timing only the last command buffer would miss. #[test] fn a_refresh_that_submits_several_times_is_one_measurement() {     let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);-    let Some((device, queue, mut comp)) = gpu() else { return };+    let Some((device, queue, mut batched)) = gpu() else { return };     // A budget far below the document's layer pages makes the refresh evict as it goes, which is     // what makes it submit in batches: the measurement must span all of them, not just the last.-    comp.set_memory_budget(24 << 20);+    batched.set_memory_budget(24 << 20);+    // The same composite with every page resident, in one command buffer: the reference for the+    // GPU work the batched refresh's spans must add up to.+    let mut whole = Compositor::new(&device);+    assert!(whole.measure_gpu_time(&device, &queue), "the device has the features, so the timer must build");+    // Both draw every pass: a reused lower-stack composite would make either do less work.+    batched.set_composite_cache_budget(0);+    whole.set_composite_cache_budget(0);     let size = Size::new(3000, 2000);     let doc = document(size);     let full = Rect::from_xywh(0, 0, size.width, size.height); -    let _ = measure(&device, &queue, &mut comp, &doc, full);-    let (submit_ms, wall_ms, timing) = measure(&device, &queue, &mut comp, &doc, full);-    let t = timing.expect("a batched refresh is measured like any other");-    eprintln!("batched: {} command buffers, gpu {:.1} ms, submit {submit_ms:.1} ms, waited wall {wall_ms:.1} ms", t.spans, t.ms);-    assert_eq!(t.px, area(full));+    let _ = measure(&device, &queue, &mut whole, &doc, full);+    let _ = measure(&device, &queue, &mut batched, &doc, full);+    // Interleaved, best of six each: GPU clocks ramp and settle between refreshes, and the+    // fastest run of each is the one at full clock, so the two are compared like for like. The+    // order alternates by sample, so neither side always runs on clocks and caches the other has+    // just warmed; an even count gives each order three samples.+    let (mut one, mut sum) = (f64::INFINITY, None::<(GpuTiming, f64, f64)>);+    for sample in 0..6 {+        let resident_first = sample % 2 == 0;+        let mut resident = if resident_first { measure(&device, &queue, &mut whole, &doc, full).2 } else { None };+        let (submit_ms, wall_ms, timing, stats) = measure_with_stats(&device, &queue, &mut batched, &doc, full);+        if !resident_first {+            resident = measure(&device, &queue, &mut whole, &doc, full).2;+        }+        if let Some(r) = &resident {+            assert_eq!(r.spans, 1, "a resident refresh of this size submits once");+            one = one.min(r.ms);+        }+        let t = timing.expect("a batched refresh is measured like any other");+        assert_eq!(t.px, area(full));+        // Structure: every command buffer the refresh submitted is one span of the measurement.+        assert_eq!(t.spans as usize, stats.flushes + 1, "{} flushes but {} timed command buffers", stats.flushes, t.spans);+        // Upper bound: the spans run inside the refresh, so they can't exceed its waited-for+        // wall clock.+        assert!(t.ms <= wall_ms * 1.25, "summed gpu time {:.1} ms exceeds the whole waited-for refresh {wall_ms:.1} ms", t.ms);+        eprintln!(+            "sample {sample} ({} first): resident {}; batched {} command buffers, gpu {:.3} ms, submit {submit_ms:.1} ms, waited wall {wall_ms:.1} ms, {} MB uploaded",+            if resident_first { "resident" } else { "batched" },+            resident.as_ref().map_or("not reported".to_owned(), |r| format!("gpu {:.3} ms", r.ms)),+            t.spans,+            t.ms,+            stats.bytes_uploaded >> 20+        );+        if sum.as_ref().is_none_or(|(b, ..)| t.ms < b.ms) {+            sum = Some((t, submit_ms, wall_ms));+        }+    }+    let (t, submit_ms, wall_ms) = sum.expect("six batched refreshes were measured");+    assert!(one.is_finite(), "the resident refresh reports a GPU time");+    eprintln!("best: batched {:.2} ms in {} command buffers, resident {one:.2} ms in one", t.ms, t.spans);     if t.spans < 2 {         eprintln!("SKIP the sum: this refresh still fit one command buffer");         return;     }-    assert!(t.ms <= wall_ms * 1.25, "summed gpu time {:.1} ms exceeds the whole waited-for refresh {wall_ms:.1} ms", t.ms);-    // What this bound excludes is summing only the last command buffer. A correct sum measures-    // ~85 % of the waited-for GPU time here; the flush thresholds split on accumulated work, so-    // no single buffer of the several carries much over half of it, and timing only the last-    // would land near 40 %. 0.6 sits between the two with room on both sides for a machine-    // whose split is less even than this one's.+    // Lower bound. The batched refresh draws the same passes over the same pixels as the+    // resident one, so its spans together carry at least that GPU work; timing only the last+    // command buffer would report a fraction of it. (`a_split_measurement_sums_every_command_buffer`+    // proves the sum itself with equal, known batches.)+    //+    // Not against the wall clock: a batched refresh evicts and re-uploads its pages, and+    // `Queue::write_texture` uploads run in wgpu's own command buffer outside the spans by design+    // (see `timing.rs`). On an integrated GPU the composite dominates the wait, but on a discrete+    // one (RX 5700 XT) the composite took ~2 ms of a ~25 ms wait that was PCIe uploads and fence+    // latency, so a `wall - submit` bound fails a correct sum there.     assert!(-        t.ms >= (wall_ms - submit_ms) * 0.6,-        "summed gpu time {:.1} ms is far below the {:.1} ms the refresh waited: is only the last command buffer timed?",+        t.ms >= one * 0.6,+        "summed gpu time {:.2} ms over {} command buffers is well below the {one:.2} ms the same composite takes in one: is only the last command buffer timed? ({submit_ms:.1} ms submit, {wall_ms:.1} ms wall)",         t.ms,-        wall_ms - submit_ms+        t.spans     ); } +/// The sum itself, with known work: the same GPU work recorded into one command buffer, or split+/// over several that are submitted one after the other (as a flushing refresh does), measures+/// the same. Timing only the first or the last of four equal batches would report a quarter.+#[test]+fn a_split_measurement_sums_every_command_buffer() {+    let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);+    let Some((device, queue, _)) = gpu() else { return };+    const BATCHES: usize = 4;+    // One batch: a 64 MiB buffer copied four times (well inside every adapter's default limits),+    // a few milliseconds on an integrated GPU and about one on a discrete one.+    const BYTES: u64 = 64 << 20;+    let buffer = |usage| device.create_buffer(&wgpu::BufferDescriptor { label: None, size: BYTES, usage, mapped_at_creation: false });+    let (src, dst) = (buffer(wgpu::BufferUsages::COPY_SRC), buffer(wgpu::BufferUsages::COPY_DST));+    let batch = |encoder: &mut wgpu::CommandEncoder| {+        for _ in 0..4 {+            encoder.copy_buffer_to_buffer(&src, 0, &dst, 0, BYTES);+        }+    };+    let mut timer = timing::GpuTimer::new(&device, &queue).expect("the device has timestamp queries");+    let new_encoder = || device.create_command_encoder(&Default::default());+    let land = |timer: &mut timing::GpuTimer| {+        let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });+        (0..8)+            .find_map(|_| {+                let t = timer.take();+                if t.is_none() {+                    let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });+                }+                t+            })+            .expect("the measurement lands")+    };+    let one_buffer = |timer: &mut timing::GpuTimer| {+        let mut encoder = new_encoder();+        timer.start(&mut encoder, 1);+        for _ in 0..BATCHES {+            batch(&mut encoder);+        }+        timer.finish(&mut encoder);+        queue.submit([encoder.finish()]);+        timer.submitted();+        land(timer)+    };+    let split = |timer: &mut timing::GpuTimer| {+        let mut encoder = new_encoder();+        timer.start(&mut encoder, 1);+        for i in 0..BATCHES {+            batch(&mut encoder);+            if i + 1 < BATCHES {+                let mut next = new_encoder();+                timer.split(&mut encoder, &mut next);+                queue.submit([std::mem::replace(&mut encoder, next).finish()]);+            }+        }+        timer.finish(&mut encoder);+        queue.submit([encoder.finish()]);+        timer.submitted();+        land(timer)+    };++    let _ = one_buffer(&mut timer);+    let _ = split(&mut timer);+    // Interleaved, best of six each, alternating which runs first by sample: neither side always+    // runs on clocks and caches the other has just warmed, and each order gets three samples.+    let (mut one, mut sum) = (f64::INFINITY, f64::INFINITY);+    for sample in 0..6 {+        let one_first = sample % 2 == 0;+        let (one_ms, t) = if one_first {+            let one_ms = one_buffer(&mut timer).ms;+            (one_ms, split(&mut timer))+        } else {+            let t = split(&mut timer);+            (one_buffer(&mut timer).ms, t)+        };+        eprintln!(+            "sample {sample} ({} first): one command buffer {one_ms:.3} ms, split over {} {:.3} ms",+            if one_first { "one buffer" } else { "split" },+            t.spans,+            t.ms+        );+        assert_eq!(t.spans as usize, BATCHES, "every command buffer is one span");+        one = one.min(one_ms);+        sum = sum.min(t.ms);+    }+    eprintln!("{BATCHES} batches: one command buffer {one:.2} ms, split over {BATCHES} {sum:.2} ms");+    assert!(one > 0.0, "the copies took measurable GPU time");+    // Equal batches: losing one of four spans reports about 75% of the work even when the+    // span count is still four. Keep the lower bound above that failure, with 15% room for+    // clock variation between the interleaved single-buffer and split measurements.+    assert!(sum >= one * 0.85, "split over {BATCHES} command buffers measured {sum:.2} ms against {one:.2} ms in one: not every span is summed");+    assert!(sum <= one * 1.5, "split over {BATCHES} command buffers measured {sum:.2} ms against {one:.2} ms in one: spans counted twice?");+}+ #[test] fn refreshes_faster_than_the_readback_stay_valid() {     let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);diff --git a/crates/ui-egui/src/canvas/cpu_background_tests.rs b/crates/ui-egui/src/canvas/cpu_background_tests.rs--- a/crates/ui-egui/src/canvas/cpu_background_tests.rs+++ b/crates/ui-egui/src/canvas/cpu_background_tests.rs@@ -67,6 +67,9 @@ type Harness = egui_kittest::Harness<'static, PhotocraftApp>; /// The CPU-canvas fixture on the offscreen software adapter, or `None` when this machine has no /// graphics adapter. Only creating the renderer may be skipped: a failing fixture command or a /// panic in the first frames must fail the test, not pass it as "no adapter".+///+/// Callers hold [`photocraft_testkit::gpu_lock`] for the whole test, like every other GPU harness:+/// building wgpu devices on parallel test threads segfaults on some drivers (testkit `gpu.rs`). fn harness(size: [u32; 2], background: &str) -> Option<Harness> {     let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());     // app.gpu remains None: compose on the CPU, use the harness's software window adapter.@@ -117,8 +120,12 @@ fn fixture_failures_are_not_skipped_as_a_missing_adapter() {     let _ = harness([0, 0], "white"); } +/// Skipping the transparency checker behind an opaque CPU canvas renders the same pixels as+/// drawing it, across rotation, horizontal flip and pattern preview. Set+/// `PHOTOCRAFT_CPU_PIXEL_OUTPUT` to a directory to save both frames and a difference report. #[test] fn omitting_an_occluded_checker_preserves_cpu_canvas_pixels() {+    let _gpu = photocraft_testkit::gpu_lock();     let Some(mut h) = harness([320, 240], "white") else {         eprintln!("skipping: no offscreen graphics adapter");         return;@@ -157,8 +164,12 @@ fn omitting_an_occluded_checker_preserves_cpu_canvas_pixels() {     } } +/// With pattern preview on, the opaque CPU canvas still submits the same checker meshes as the+/// reference path for the document and each visible tiled copy, rotated or flipped; with it off,+/// the hidden checker is omitted. #[test] fn pattern_preview_keeps_the_legacy_checker_for_every_copy() {+    let _gpu = photocraft_testkit::gpu_lock();     let Some(mut h) = harness([320, 240], "white") else {         eprintln!("skipping: no offscreen graphics adapter");         return;@@ -195,10 +206,15 @@ fn pattern_preview_keeps_the_legacy_checker_for_every_copy() {     assert!(checker_meshes(&h).is_empty(), "ordinary opaque CPU canvases still omit the hidden checker"); } +/// Frame time of a 24 MP CPU canvas with and without the occluded checker, for an opaque and a+/// transparent document: 24 alternating-order samples per path, reported as p50/p95 JSON on+/// stdout (and in `PHOTOCRAFT_CPU_BENCH_OUTPUT`, with before/after frames, when set). Run in+/// release with `--ignored`; it also asserts both paths render the same pixels. #[test] #[ignore = "manual release benchmark: 24 MP CPU canvas on the offscreen software adapter"] fn cpu_canvas_background_benchmark() {     use std::time::Instant;+    let _gpu = photocraft_testkit::gpu_lock();     let mut results = Vec::new();     let output = std::env::var_os("PHOTOCRAFT_CPU_BENCH_OUTPUT").map(std::path::PathBuf::from);     for background in ["white", "transparent"] {diff --git a/crates/ui-egui/src/panels/brush_panel/tests.rs b/crates/ui-egui/src/panels/brush_panel/tests.rs--- a/crates/ui-egui/src/panels/brush_panel/tests.rs+++ b/crates/ui-egui/src/panels/brush_panel/tests.rs@@ -1338,7 +1338,16 @@ fn app_harness() -> Harness<'static, PhotocraftApp> {     app_harness_with_language(crate::i18n::Lang::EN) } +/// [`app_harness`] with its UI drawn in `lang`, under kittest's strict missing-glyph policy, so+/// every label of the Brush tool's options bar, settings window and preset picker must shape in+/// that language from the first frame. fn app_harness_with_language(lang: crate::i18n::Lang) -> Harness<'static, PhotocraftApp> {+    // Kittest's strict missing-glyph policy panics in the first frame that draws a CJK label. The+    // font provider serves it then, but on Linux it may wait for the background Fontconfig lookup+    // when no known font path has the script, so let that finish first (as the other strict+    // all-language tests do).+    #[cfg(target_os = "linux")]+    assert!(crate::cjk_fonts::wait_for_fontconfig_cjk_scan(), "Fontconfig CJK lookup did not finish within 10 seconds");     let mut app = app();     app.ui.tool = crate::state::Tool::Brush;     let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_ui_state(diff --git a/crates/ui-egui/src/text/cjk_fonts.rs b/crates/ui-egui/src/text/cjk_fonts.rs--- a/crates/ui-egui/src/text/cjk_fonts.rs+++ b/crates/ui-egui/src/text/cjk_fonts.rs@@ -30,12 +30,13 @@ //! Builds made with the optional craft-fonts input (`CRAFT_FONTS_DIR`, //! [`photocraft_text::craft_fonts`]) carry Japanese, Chinese, Korean and other faces. A //! per-character [`FontProvider`] serves them, and the system font a locale defers to, in the-//! first frame; the lazy loader remains the later fallback. The provider has no lifetime read-//! cap. It retains at most [`MAX_SYSTEM_CACHE_BYTES`] of unique font-file blobs, charging a-//! collection (TTC) once however many faces it selects; a face that cannot be retained is-//! returned transiently for the current request, so a later Fontconfig candidate is never-//! skipped merely because earlier characters read other fonts.-//! Without craft-fonts (and on the web, which never embeds them) nothing changes.+//! first frame; the lazy loader remains the later fallback. Without craft-fonts the same+//! provider serves the system fonts alone, so the first frame needs no embedded face either.+//! The provider has no lifetime read cap. It retains at most [`MAX_SYSTEM_CACHE_BYTES`] of+//! unique font-file blobs, charging a collection (TTC) once however many faces it selects; a+//! face that cannot be retained is returned transiently for the current request, so a later+//! Fontconfig candidate is never skipped merely because earlier characters read other fonts.+//! The web, which never embeds craft-fonts and has no system font files, installs no provider.  use egui::epaint::text::{FontInsert, FontPriority, InsertFontFamily}; use egui::{FallbackRequest, FontData, FontFamily, FontId, FontProvider, Shape};@@ -81,10 +82,11 @@ pub struct Sources {     /// provider and lazy loader both use this injection (tests can therefore pin craft-font     /// selection without depending on the build input).     pub embedded: fn(&'static str) -> Vec<&'static CraftFont>,-    /// Install the per-character craft-font provider, so a headless first frame can shape CJK-    /// and other scripts before the lazy loader gets a second pass (tests panic on a missing-    /// glyph). It selects a face for the character's script instead of putting a broad face into-    /// the family stack up front for every later character.+    /// Install the per-character provider (craft faces when the build has them, then system+    /// faces), so a headless first frame can shape CJK and other scripts before the lazy loader+    /// gets a second pass (tests panic on a missing glyph). It selects a face for the+    /// character's script instead of putting a broad face into the family stack up front for+    /// every later character.     pub eager_craft_fonts: bool, } @@ -977,6 +979,10 @@ pub fn install(ctx: &egui::Context) {     install_with(ctx, Sources::system()); } +/// [`install`] with injected [`Sources`]: (re)installs the lazy fallback loader plugin, resetting+/// one already present, and, when `sources.eager_craft_fonts` is set, the per-character provider+/// that serves craft faces (if the build has them) and system faces in the first frame. Tests use+/// it to pin locale, files and coverage without depending on the machine's fonts. pub fn install_with(ctx: &egui::Context, sources: Sources) {     // egui keeps the first plugin of a type. set_fonts removes loaded fallbacks, so reset the     // existing loader too; otherwise its tried/loaded sets prevent fonts being added again.@@ -991,9 +997,11 @@ pub fn install_with(ctx: &egui::Context, sources: Sources) {     {         ctx.add_plugin(CjkFontPlugin(fallback));     }-    // The provider resolves a craft face for each missing character in the first frame, before the-    // lazy loader's next-pass system fallback. Without it a headless frame panics on CJK text.-    if sources.eager_craft_fonts && !craft_fonts::CRAFT_FONTS.is_empty() {+    // The provider resolves a face for each missing character in the first frame, before the lazy+    // loader's next-pass system fallback. Without it a headless frame panics on CJK text. It is+    // installed without craft-fonts too: it then serves the system faces alone, exactly the files+    // the lazy loader would read one frame later, so a strict first frame needs no embedded fonts.+    if sources.eager_craft_fonts {         let provider = std::sync::Arc::new(CraftFontProvider::new(&sources, crate::theme::ui_font_scale(ctx)));         let as_provider: std::sync::Arc<dyn FontProvider> = provider.clone();         ctx.data_mut(|d| d.insert_temp(craft_provider_id(), provider));diff --git a/crates/ui-egui/src/text/cjk_fonts/tests.rs b/crates/ui-egui/src/text/cjk_fonts/tests.rs--- a/crates/ui-egui/src/text/cjk_fonts/tests.rs+++ b/crates/ui-egui/src/text/cjk_fonts/tests.rs@@ -1366,12 +1366,13 @@ fn pending_scan_recovers_through_real_layout_and_the_lazy_reload() {     PENDING_READY.store(false, std::sync::atomic::Ordering::Relaxed); } +/// Under the panicking missing-glyph policy, the first frame after a finished scan draws a+/// Traditional Chinese label from the system face alone: the eager provider is installed and+/// serves it even with no embedded craft fonts. #[test] fn strict_policy_first_frame_after_the_scan_is_accepted() {-    if craft_fonts::CRAFT_FONTS.is_empty() {-        eprintln!("skipping: the eager provider is only installed with craft-fonts");-        return;-    }+    // No craft-font skip: the sources embed nothing, and the provider must serve the system face+    // alone, with or without craft-fonts in the build.     let ctx = egui::Context::default();     ctx.set_missing_glyph_policy(egui::MissingGlyphPolicy::Panic);     let sources = Sources {