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}