Skip to main content

atmos/os_lib/web_engine/
font_queue.rs

1//! フォント取得の非同期キュー(レンダリングをブロックしない)。
2//!
3//! 仕様は `spec/resource_loading.md`。
4//!
5//! ## なぜ必要か
6//! CSS/JS/フォントは `parse_and_layout` の中から**同期的に**取得されており、
7//! 取得が終わるまでレイアウトが進まなかった。
8//! 実測で 5.3MB の Noto Sans JP のダウンロードが描画を止めていた
9//! (画像だけは既に `image_loader_thread` + `IMAGE_QUEUE` で非同期化済み)。
10//!
11//! ## 方針(不変条件 R-1〜R-5)
12//! - **R-1**: 未取得でもレンダリングは先へ進む(内蔵フォントで描く)
13//! - **R-2**: サイズを理由に打ち切らない。**最後まで取得する**
14//! - **R-3**: 取得完了は次のレイアウトでキャッシュ経由で反映される
15//! - **R-4**: 同一 URL の重複取得はキュー段階で排除する
16//! - **R-5**: 結果は `/cache/browser/` へ保存し、次回はネットワークを叩かない
17
18extern crate alloc;
19
20use alloc::collections::BTreeSet;
21use alloc::string::String;
22use alloc::vec::Vec;
23use spin::Mutex;
24
25/// 取得待ちのフォント URL。
26pub(super) struct FontQueue {
27    /// (host, path, is_https, 優先度, まとめ取得を諦めた印)。
28    ///
29    /// 5 番目は「一度まとめ取得で失敗したので次は個別に取る」印。
30    /// これが無いと、失敗 → 積み直し → また同じ便にまとめられる →
31    /// また失敗、の堂々巡りになる(実測でページが読めなくなった)。
32    pub pending: Vec<(String, String, bool, u8, bool)>,
33    /// 取得済み・取得中の URL(重複投入を防ぐ)。R-4。
34    pub seen: BTreeSet<String>,
35    /// 【2026-08-04】稼働中のワーカースレッド数。
36    ///
37    /// 以前は `thread_active: bool` で**ワーカーが 1 本だけ**だった。
38    /// 5.3MB の Noto Sans JP を 1 本取っている間、同じキューの後続は
39    /// 一切進まない。実測でギャラリー画像がフォント 21MB の後ろに
40    /// 並んで実行時間内に届かなかった。
41    ///
42    /// 通信待ちの間はスケジューラへ譲るので、複数本走らせれば
43    /// **重いリソースが他をブロックしなくなる**。
44    pub active_workers: usize,
45}
46
47/// 【2026-07-31】取得完了を描画側へ知らせるフラグ(不変条件 R-3)。
48///
49/// 非同期化とキャッシュは動いていたが、**完了を通知する経路が無かった**ため
50/// 同一ブート内で永久に反映されなかった(実測: cache hit 0 件 / 投入 14 件)。
51/// レイアウトは取得完了より先に走るので、完了時に再レイアウトを促す必要がある。
52///
53/// 画像は `IMAGE_QUEUE.results` を毎フレーム見て反映しているので、
54/// フォントも同じ考え方でフラグを立てる。
55static FONTS_UPDATED: core::sync::atomic::AtomicBool =
56    core::sync::atomic::AtomicBool::new(false);
57
58/// 前回の確認以降にフォント取得が完了したか。**確認したらフラグを下ろす**
59/// (何度も再レイアウトを繰り返さないため)。
60pub(super) fn take_fonts_updated() -> bool {
61    FONTS_UPDATED.swap(false, core::sync::atomic::Ordering::Relaxed)
62}
63
64/// 同時に走らせるフォント取得ワーカーの上限。
65///
66/// 多すぎると TCP ソケット表(32 件)を圧迫し、
67/// 少なすぎると重い 1 本が他を止める。実サイトの `@font-face` は
68/// Noto Sans JP 4 本 + Font Awesome 4 本なので、4 本並列で
69/// おおむね一巡できる。
70const MAX_FONT_WORKERS: usize = 4;
71
72/// 1 本の接続でまとめて取る最大件数。
73///
74/// 多すぎると 1 本の接続に依存しすぎ、途中で壊れたときの
75/// 積み直しが大きくなる。まずは控えめに。
76const MAX_KEEPALIVE_BATCH: usize = 4;
77
78pub(super) static FONT_QUEUE: Mutex<FontQueue> = Mutex::new(FontQueue {
79    pending: Vec::new(),
80    seen: BTreeSet::new(),
81    active_workers: 0,
82});
83
84/// フォント取得を予約する。**即座に戻る**(R-1)。
85///
86/// 既に投入済みの URL は無視する(R-4)。
87/// 待ち行列にある「指定優先度より高い」件数。
88///
89/// 画像側がこれを見て、CSS が待っている間は起こさないようにする。
90/// 【2026-08-05 計測】最初にスタイルシートを積んだ時刻(tick, 0 なら未投入)。
91///
92/// 非同期化した CSS が「投入から到着まで」「到着から反映まで」で
93/// それぞれ何 tick 失っているかを測る。
94/// 目的: 非同期化が遅い原因の切り分け。破棄条件: 特定でき次第削除。
95pub(super) static STYLE_ENQUEUE_TICK: core::sync::atomic::AtomicUsize =
96    core::sync::atomic::AtomicUsize::new(0);
97/// スタイルシートが届いた時刻(tick, 0 なら未到着)。
98pub(super) static STYLE_ARRIVE_TICK: core::sync::atomic::AtomicUsize =
99    core::sync::atomic::AtomicUsize::new(0);
100
101// 【2026-08-05 観測】まとめ取得を 6 回配線して 6 回とも
102// `[NET][STATS] total=3` で止まった。毎回「取得のしかた」を変えていたが、
103// **そもそもワーカーのループが回っているか**を一度も確かめていない。
104// 中身ではなくループそのものを数える。
105// 破棄条件: 停止の原因が特定でき次第削除。
106/// ループを回った回数。
107pub(super) static FQ_ITERS: core::sync::atomic::AtomicU32 =
108    core::sync::atomic::AtomicU32::new(0);
109/// ワーカーを起こした回数。
110pub(super) static FQ_SPAWNS: core::sync::atomic::AtomicU32 =
111    core::sync::atomic::AtomicU32::new(0);
112/// ワーカーが終了した回数。
113pub(super) static FQ_EXITS: core::sync::atomic::AtomicU32 =
114    core::sync::atomic::AtomicU32::new(0);
115/// キャッシュ命中で早期 continue した回数。
116pub(super) static FQ_CACHED: core::sync::atomic::AtomicU32 =
117    core::sync::atomic::AtomicU32::new(0);
118/// 実際に取得へ進んだ回数。
119pub(super) static FQ_FETCH: core::sync::atomic::AtomicU32 =
120    core::sync::atomic::AtomicU32::new(0);
121/// 直近に観測した `pending` の長さ。
122pub(super) static FQ_PENDING: core::sync::atomic::AtomicU32 =
123    core::sync::atomic::AtomicU32::new(0);
124
125/// 観測値を 1 行で出す。`draw()` の節目から呼ぶ。
126pub(super) fn diag_line() {
127    use core::sync::atomic::Ordering as O;
128    crate::warn!(
129        "[FQDIAG] iters={} spawn={} exit={} cached={} fetch={} pending={}",
130        FQ_ITERS.load(O::Relaxed),
131        FQ_SPAWNS.load(O::Relaxed),
132        FQ_EXITS.load(O::Relaxed),
133        FQ_CACHED.load(O::Relaxed),
134        FQ_FETCH.load(O::Relaxed),
135        FQ_PENDING.load(O::Relaxed),
136    );
137    crate::warn!(
138        "[FQDIAG2] pump不可={} 同時稼働={}",
139        FQ_PUMP_BLOCKED.load(O::Relaxed),
140        super::fetch_limit::active(),
141    );
142}
143
144/// 取得待ちのフォント/CSS/JS 件数。待機判定(`[IDLE]` 行)から使う。
145///
146/// 【2026-08-25 バグ修正】ここは `FONT_QUEUE.lock()` していた。
147///
148/// 待機判定はウィンドウマネージャの提示ループから呼ばれる。
149/// あちらは高優先度で、`spin::Mutex` は譲らずに回り続けるので、
150/// 低優先度のワーカーがこのロックを握っている間に提示ループが
151/// スピンへ入ると、**ワーカーが走れず解放もされない**。
152/// 典型的な優先度逆転で、実測では取得が 7 件やり残しのまま
153/// 900 秒経っても動かなくなった(`active=5` のまま静止)。
154///
155/// **待機判定はロックを取ってはいけない。**
156/// 正確さは、キューを変更する側(既にロックを持っている側)が
157/// 毎回 `FQ_PENDING` へ書き写すことで保つ。
158pub(super) fn pending_len() -> usize {
159    FQ_PENDING.load(core::sync::atomic::Ordering::Acquire) as usize
160}
161
162/// 稼働中のフォントワーカー本数の写し。
163///
164/// 全体の枠(`fetch_limit::active`)に余裕があっても、種別ごとの
165/// 上限に達していれば起こせない。止まったときにどちらが原因かを
166/// 分けるため、件数だけでなく**内訳**を出せるようにする。
167/// `pending_len` と同じ理由で、読み出しはロックを取らない。
168static FQ_WORKERS: core::sync::atomic::AtomicUsize =
169    core::sync::atomic::AtomicUsize::new(0);
170
171pub(super) fn worker_count() -> usize {
172    FQ_WORKERS.load(core::sync::atomic::Ordering::Acquire)
173}
174
175/// 取得(`https_get_binary_keepalive`)の中に居るワーカー数。
176///
177/// 停滞時に `worker_count` と同数なら、止まっているのは取得そのもの。
178/// 少なければ、取得は返っているのに後段(復号・登録)で詰まっている。
179static FQ_IN_FETCH: core::sync::atomic::AtomicUsize =
180    core::sync::atomic::AtomicUsize::new(0);
181
182pub(super) fn in_fetch_count() -> usize {
183    FQ_IN_FETCH.load(core::sync::atomic::Ordering::Acquire)
184}
185
186/// 稼働本数を写す。ロックを持っている側から呼ぶ。
187pub(super) fn store_worker_count(n: usize) {
188    FQ_WORKERS.store(n, core::sync::atomic::Ordering::Release);
189}
190
191pub(super) fn pending_higher_than(prio: u8) -> usize {
192    FONT_QUEUE
193        .lock()
194        .pending
195        .iter()
196        .filter(|(_, _, _, p, _)| *p < prio)
197        .count()
198}
199
200pub(super) fn enqueue_font(host: String, path: String, is_https: bool) {
201    enqueue_kind(host, path, is_https, super::fetch_limit::Kind::Font)
202}
203
204/// 積むだけで**ワーカーを起こさない**版。
205///
206/// 【2026-08-05】`@font-face` は 1 回のレイアウトで 8 本まとめて見つかるのに、
207/// 1 本目を積んだ時点でワーカーが走り出すため、
208/// **残りが待ち行列へ並ぶ前に取得が始まって**しまい、
209/// まとめ取得(`https_get_binary_keepalive`)に載る数が増えなかった
210/// (実測でハンドシェイクは 14 → 12 までしか減らなかった)。
211///
212/// 全部積んでから `spawn_workers()` で起こせば、同じホストのものが
213/// 同時に並ぶので 1 本の接続へまとめられる。
214pub(super) fn enqueue_kind_deferred(
215    host: String,
216    path: String,
217    is_https: bool,
218    kind: super::fetch_limit::Kind,
219) {
220    let key = alloc::format!("{}{}", host, path);
221    let mut q = FONT_QUEUE.lock();
222    if q.seen.contains(&key) {
223        return;
224    }
225    q.seen.insert(key);
226    if kind == super::fetch_limit::Kind::Style {
227        let _ = STYLE_ENQUEUE_TICK.compare_exchange(
228            0,
229            crate::kernel::timer::get_ticks(),
230            core::sync::atomic::Ordering::AcqRel,
231            core::sync::atomic::Ordering::Acquire,
232        );
233    }
234    q.pending.push((host, path, is_https, kind.priority(), false));
235    // 【2026-08-25】待機判定はロックを取らずに読むので、
236    // ここで**正確な長さ**を写しておく(`fetch_limit::idle_line`)。
237    FQ_PENDING.store(q.pending.len() as u32, core::sync::atomic::Ordering::Release);
238}
239
240/// 溜まっているぶんに対してワーカーを起こす。`enqueue_kind_deferred` と対で使う。
241pub(super) fn spawn_workers() {
242    let mut q = FONT_QUEUE.lock();
243    while q.active_workers < MAX_FONT_WORKERS
244        && super::fetch_limit::try_acquire(q.pending.len())
245    {
246        q.active_workers += 1;
247        store_worker_count(q.active_workers);
248        FQ_SPAWNS.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
249        // 【2026-08-05 バグ修正】`spawn` はヒープ不足で **0 を返す**。
250        // 戻り値を見ずに枠だけ確保していたため、失敗すると
251        // 「ワーカーは居ないのに枠は埋まったまま」になり、
252        // 以後**永久にワーカーを起こせなくなる**。失敗したら必ず返す。
253        let pid = crate::kernel::scheduler::spawn(
254            font_loader_thread,
255            crate::kernel::scheduler::Priority::Low,
256            "font_load",
257        );
258        if pid == 0 {
259            q.active_workers = q.active_workers.saturating_sub(1);
260            store_worker_count(q.active_workers);
261            super::fetch_limit::release();
262        }
263    }
264}
265
266/// 種別を指定して積む。取り出しは**優先度の高い順**(`fetch_limit::Kind`)。
267pub(super) fn enqueue_kind(
268    host: String,
269    path: String,
270    is_https: bool,
271    kind: super::fetch_limit::Kind,
272) {
273    let key = alloc::format!("{}{}", host, path);
274    let mut q = FONT_QUEUE.lock();
275    if q.seen.contains(&key) {
276        return;
277    }
278    q.seen.insert(key);
279    if kind == super::fetch_limit::Kind::Style {
280        let _ = STYLE_ENQUEUE_TICK.compare_exchange(
281            0,
282            crate::kernel::timer::get_ticks(),
283            core::sync::atomic::Ordering::AcqRel,
284            core::sync::atomic::Ordering::Acquire,
285        );
286    }
287    q.pending.push((host, path, is_https, kind.priority(), false));
288    // 【2026-08-25】待機判定はロックを取らずに読むので、
289    // ここで**正確な長さ**を写しておく(`fetch_limit::idle_line`)。
290    FQ_PENDING.store(q.pending.len() as u32, core::sync::atomic::Ordering::Release);
291    // 待ち行列があるうちはワーカーを増やす(上限まで)。
292    // 1 本あたり 1 リソースを担当するので、重いフォントが
293    // 他のフォントの取得を止めない。
294    // 【2026-08-05】上限は種別ごとではなく**全体で**持つ
295    // (`fetch_limit` 参照。フォント 4 + 画像 4 で 8 本張れていた)。
296    // メイン HTML の取得中は新たに起こさない。
297    if q.active_workers < MAX_FONT_WORKERS
298        && super::fetch_limit::try_acquire(q.pending.len())
299    {
300        q.active_workers += 1;
301        store_worker_count(q.active_workers);
302        FQ_SPAWNS.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
303        // 【2026-08-05 バグ修正】`spawn` はヒープ不足で **0 を返す**。
304        // 戻り値を見ずに枠だけ確保していたため、失敗すると
305        // 「ワーカーは居ないのに枠は埋まったまま」になり、
306        // 以後**永久にワーカーを起こせなくなる**。失敗したら必ず返す。
307        let pid = crate::kernel::scheduler::spawn(
308            font_loader_thread,
309            crate::kernel::scheduler::Priority::Low,
310            "font_load",
311        );
312        if pid == 0 {
313            q.active_workers = q.active_workers.saturating_sub(1);
314            store_worker_count(q.active_workers);
315            super::fetch_limit::release();
316        }
317    }
318}
319
320/// 溜まっている取得要求に対してワーカーを起こし直す。
321///
322/// 【2026-08-05】`enqueue_font` は**投入時にしか**ワーカーを起こさない。
323/// `fetch_limit` の門(メイン HTML 取得中は起こさない)を入れたことで、
324/// 門が閉じている間に投入された分が**永久に `pending` に残る**
325/// ようになった(実測: `[NET][STATS] total=3`、正常時は 13)。
326///
327/// 門が開いたら拾い直す必要がある。`draw()` から毎フレーム呼ぶ。
328/// 起こす条件は `enqueue_font` と同じなので、余分には起きない。
329/// `pump()` が「積んであるのに起こせなかった」回数。
330///
331/// 【2026-08-05】遅延投入(`enqueue_kind_deferred` + `spawn_workers`)を
332/// 試したとき `[FQDIAG] iters=0 spawn=0` でワーカーが一度も起動しなかった。
333/// `pump()` は毎フレーム呼ばれているのに起こせていない。
334/// **何が拒んでいるか**を数える。破棄条件: 特定でき次第削除。
335pub(super) static FQ_PUMP_BLOCKED: core::sync::atomic::AtomicU32 =
336    core::sync::atomic::AtomicU32::new(0);
337
338pub(super) fn pump() {
339    // 【2026-09-06 バグ修正】ここは `lock()` だった。
340    //
341    // `pump()` は `draw()` から**毎フレーム**呼ばれる。描画側は高優先度で、
342    // `spin::Mutex` は譲らずに回り続けるため、低優先度のワーカーが
343    // この錠を握っている間に入ると**ワーカーが走れなくなる**。
344    // 同じ優先度逆転は `pending_len`/`pending_count`/`image_results_ready`
345    // で既に 3 回踏んでおり、「**待機・再描画の判定はロックを取ってはいけない**」
346    // という規則になっていた。`pump()` はその規則から漏れていた。
347    //
348    // 実測(停滞時): `fw=4 ff=0 font_pending=0 exit=0` ——
349    // 取得中でもパニックでもなく、終了経路も通っていない。
350    // ワーカーが錠待ちで止まり、稼働本数が上限に張り付いていた。
351    //
352    // 毎フレーム呼ばれるので、取れなければ諦めて次フレームに任せればよい。
353    let Some(mut q) = FONT_QUEUE.try_lock() else {
354        return;
355    };
356    // 【2026-08-25】パニックで死んだワーカーぶんの枠を戻す。
357    // 死んだ本人は `active_workers` を減らせないので、
358    // 毎フレーム走るここで遅れて回収する(詳細は `fetch_limit`)。
359    let dead = super::fetch_limit::take_font_deaths();
360    if dead > 0 {
361        crate::warn!("[FONT] パニックで死んだワーカー {} 本を回収", dead);
362        q.active_workers = q.active_workers.saturating_sub(dead);
363        store_worker_count(q.active_workers);
364    }
365    if q.pending.is_empty() {
366        return;
367    }
368    if q.active_workers >= MAX_FONT_WORKERS {
369        FQ_PUMP_BLOCKED.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
370    }
371    if q.active_workers < MAX_FONT_WORKERS && super::fetch_limit::try_acquire(q.pending.len()) {
372        q.active_workers += 1;
373        store_worker_count(q.active_workers);
374        // 【2026-08-05 バグ修正】`spawn` はヒープ不足で **0 を返す**。
375        // 戻り値を見ずに枠だけ確保していたため、失敗すると
376        // 「ワーカーは居ないのに枠は埋まったまま」になり、
377        // 以後**永久にワーカーを起こせなくなる**。失敗したら必ず返す。
378        let pid = crate::kernel::scheduler::spawn(
379            font_loader_thread,
380            crate::kernel::scheduler::Priority::Low,
381            "font_load",
382        );
383        if pid == 0 {
384            q.active_workers = q.active_workers.saturating_sub(1);
385            store_worker_count(q.active_workers);
386            super::fetch_limit::release();
387        }
388    }
389}
390
391/// 背景でフォントを取得してキャッシュへ書く。
392///
393/// **サイズによる打ち切りはしない**(R-2)。時間がかかっても最後まで取る。
394/// レンダリングは待たないので、遅くても描画は進む。
395fn font_loader_thread() {
396    // 枠の所有を自分の PID で宣言する。パニックで死んだとき、
397    // パニックハンドラがこの記録を頼りに枠を返す。
398    let my_pid = crate::kernel::scheduler::get_current_process_id().unwrap_or(0);
399    super::fetch_limit::claim(my_pid, super::fetch_limit::KIND_FONT);
400    // このスレッドの稼働中に 1 本でも取得できたか。
401    // 取得のたびに通知すると再レイアウトが本数分走ってしまうので、
402    // キューを処理し終えてからまとめて 1 回だけ通知する。
403    let mut got_any = false;
404    loop {
405        FQ_ITERS.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
406        let next = {
407            let mut q = FONT_QUEUE.lock();
408            FQ_PENDING.store(q.pending.len() as u32, core::sync::atomic::Ordering::Relaxed);
409            // 【2026-08-05】以前は `pop()`(後ろから)だった。
410            // CSS はレイアウトに必須なので、種別の優先度で選ぶ。
411            let prios: alloc::vec::Vec<u8> =
412                q.pending.iter().map(|(_, _, _, p, _)| *p).collect();
413            let picked = super::fetch_limit::pick_highest_priority(&prios)
414                .map(|i| q.pending.remove(i));
415            FQ_PENDING.store(q.pending.len() as u32, core::sync::atomic::Ordering::Release);
416            // 【2026-08-05】同じホスト・同じ優先度の兄弟を、
417            // **取り出す前に判定してから**一緒に引き取る。
418            //
419            // 判定は純粋関数(`queue_batch`、単体試験 11 件)。
420            // ここで対象外なら待ち行列に一切触らないので、
421            // 「取り出したのに使わない」経路ができない。
422            let batch: alloc::vec::Vec<String> = match &picked {
423                Some((h, _, https, pr, nb))
424                    if super::queue_batch::may_start_batch(false, *https, *nb) =>
425                {
426                    let (h, pr) = (h.clone(), *pr);
427                    let cands: alloc::vec::Vec<super::queue_batch::Candidate> = q
428                        .pending
429                        .iter()
430                        .map(|e| super::queue_batch::Candidate {
431                            same_host: e.0 == h,
432                            same_priority: e.3 == pr,
433                            is_https: e.2,
434                            no_batch: e.4,
435                        })
436                        .collect();
437                    let idxs = super::queue_batch::select_batch(&cands, MAX_KEEPALIVE_BATCH);
438                    // 添字は昇順なので**後ろから**取り除く(ずれない)。
439                    let mut v = alloc::vec::Vec::with_capacity(idxs.len());
440                    for &i in idxs.iter().rev() {
441                        let (_, pp, _, _, _) = q.pending.remove(i);
442                        FQ_PENDING
443                            .store(q.pending.len() as u32, core::sync::atomic::Ordering::Release);
444                        v.push(pp);
445                    }
446                    v
447                }
448                _ => alloc::vec::Vec::new(),
449            };
450            match picked {
451                Some(v) => (v, batch),
452                None => {
453                    // 取り残しが無いことを確認してから終了する
454                    // (終了レースで永久に処理されないアイテムを作らない)。
455                    q.active_workers = q.active_workers.saturating_sub(1);
456                    store_worker_count(q.active_workers);
457                    drop(q);
458                    // 正常終了なので所有の記録を外してから枠を返す。
459                    // 外し忘れると、その PID が使い回されたときに
460                    // 無関係なスレッドの死で枠が二重に返る。
461                    super::fetch_limit::unclaim(my_pid);
462                    super::fetch_limit::release();
463                    FQ_EXITS.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
464                    if got_any {
465                        // ここで初めて描画側へ知らせる(R-3)。
466                        FONTS_UPDATED.store(true, core::sync::atomic::Ordering::Relaxed);
467                    }
468                    // 【2026-08-05 重大】ここは `return` だった。
469                    //
470                    // カーネルスレッドの入口関数から `return` してもスレッドは
471                    // 終わらず、**入口から再実行され続ける**。
472                    // 実測: ワーカーを 4 本しか起こしていないのに
473                    // この経路を **3 億回以上**通っていた
474                    // (`[FQDIAG] iters=341562104 spawn=4 exit=341562100`)。
475                    //
476                    // 空回りが CPU を食い潰し、通信も描画も遅くなる。
477                    // 他のスレッド(`async_load_thread_entry`、`main.rs` の各所)は
478                    // いずれも `scheduler::exit()` を明示的に呼んでいる。
479                    // ここだけ作法が違っていた。
480                    crate::kernel::scheduler::exit();
481                }
482            }
483        };
484        let ((host, path, is_https, _prio, _no_batch), batch) = next;
485        let is_style = _prio == super::fetch_limit::Kind::Style.priority();
486
487        if super::cache::read_cache(&host, &path).is_some() {
488            FQ_CACHED.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
489            continue;
490        }
491
492        // 【2026-08-05】同一ホストをまとめて 1 接続で取る。
493        //
494        // 動機(実測): `TLS握手=3512/14`(約 35 秒、1 回 約 250 tick)。
495        // DNS も TCP 接続も軽く、重いのは TLS 交渉そのもの。
496        // ホストは 4 種類程度なので、まとめれば大半を消せる。
497        //
498        // 6 回配線して 6 回とも `total=3` で止まったが、
499        // その原因は**まとめ取得ではなくワーカーの空回り**だった
500        // (`return` でスレッドが終わらず 3 億回転。R-17)。
501        // 空回りを直したうえで改めて入れる。
502        //
503        // `batch` は**取り出す前に判定して**集めてある(`queue_batch`)。
504        // ここは無条件に使い切る。取り出したまま使わない経路は作らない。
505        if !batch.is_empty() {
506            let mut paths = alloc::vec::Vec::with_capacity(1 + batch.len());
507            paths.push(path.clone());
508            paths.extend(batch.iter().cloned());
509            // 通信中のワーカー数を数える。停滞したとき
510            // 「種別の上限に張り付いている(fw=4)」までは分かっても、
511            // **どこで止まっているか**が分からなかった。
512            // ここが fw と同数なら、停止点は取得そのもの。
513            FQ_IN_FETCH.fetch_add(1, core::sync::atomic::Ordering::AcqRel);
514            let fetched = crate::kernel::tls::https_get_binary_keepalive(&host, &paths);
515            FQ_IN_FETCH.fetch_sub(1, core::sync::atomic::Ordering::AcqRel);
516            let results = match fetched {
517                Ok(r) => r,
518                Err(e) => {
519                    crate::warn!("[KADIAG] まとめ取得に失敗 host={} err={}", host, e);
520                    alloc::vec![None; paths.len()]
521                }
522            };
523            let mut any = false;
524            let mut retry: alloc::vec::Vec<String> = alloc::vec::Vec::new();
525            for (pp, r) in paths.iter().zip(results.into_iter()) {
526                match r {
527                    Some(b) if !b.is_empty() => {
528                        super::cache::write_cache(&host, pp, &b);
529                        any = true;
530                    }
531                    // 取れなかったぶんは印を付けて**必ず戻す**。
532                    // 印が無いと同じ便に乗り直して堂々巡りになる。
533                    _ => retry.push(pp.clone()),
534                }
535            }
536            if !retry.is_empty() {
537                let mut q = FONT_QUEUE.lock();
538                for pp in retry {
539                    // 【2026-08-05 バグ修正】ここは `is_https` を `true` に
540                    // 決め打ちしていた。HTTP で取ったものを HTTPS として
541                    // 積み直すことになり、再試行は必ず失敗する。
542                    // 元の値をそのまま使う。
543                    q.pending.push((host.clone(), pp, is_https, _prio, true));
544                    FQ_PENDING
545                        .store(q.pending.len() as u32, core::sync::atomic::Ordering::Release);
546                }
547            }
548            if any {
549                FONTS_UPDATED.store(true, core::sync::atomic::Ordering::Relaxed);
550                got_any = true;
551            }
552            crate::kernel::scheduler::yield_now();
553            continue;
554        }
555
556        FQ_FETCH.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
557        let result = if is_https {
558            crate::kernel::tls::https_get_binary(&host, &path)
559        } else {
560            crate::kernel::net::http_get_binary(&host, &path)
561        };
562        match result {
563            Ok(bytes) if !bytes.is_empty() => {
564                crate::warn!(
565                    "[FONT] 背景取得完了 host={} path={} bytes={}",
566                    host,
567                    path,
568                    bytes.len()
569                );
570                super::cache::write_cache(&host, &path, &bytes);
571                if is_style {
572                    let now = crate::kernel::timer::get_ticks();
573                    let _ = STYLE_ARRIVE_TICK.compare_exchange(
574                        0,
575                        now,
576                        core::sync::atomic::Ordering::AcqRel,
577                        core::sync::atomic::Ordering::Acquire,
578                    );
579                    crate::warn!(
580                        "[CSSTRACE] 到着 path={} 投入から={}tick",
581                        path,
582                        now.wrapping_sub(
583                            STYLE_ENQUEUE_TICK.load(core::sync::atomic::Ordering::Acquire)
584                        )
585                    );
586                }
587                // 【2026-07-31】1 本ごとに通知する。
588                //
589                // 一度「キューが空になってからまとめて通知」に変えたが、
590                // **通知が 0 回になった**。14 件投入して 8 件しか取れていない間は
591                // キューが空になる瞬間が来ないため、取得が遅いほど
592                // 永久に反映されないという逆の壊れ方をした(実測)。
593                // 届いたものから順に反映する方が R-3 の趣旨にも合う。
594                FONTS_UPDATED.store(true, core::sync::atomic::Ordering::Relaxed);
595                got_any = true;
596            }
597            Ok(_) => {
598                // 空応答はキャッシュしない(一度書くと恒久的に空が返り続ける)。
599                crate::warn!("[FONT] 背景取得が空 host={} path={}", host, path);
600            }
601            Err(e) => {
602                crate::warn!("[FONT] 背景取得失敗 host={} path={} err={}", host, path, e);
603            }
604        }
605        crate::kernel::scheduler::yield_now();
606    }
607}