atmos/os_lib/web_engine/fetch_limit.rs
1//! リソース取得の同時実行を**種別横断で**制御する。
2//!
3//! 仕様は `spec/resource_loading.md`(実装順序の 4「キューを 1 本に統合し、
4//! 種別ごとの優先度を持たせる」に相当する最小実装)。
5//!
6//! # なぜ要るのか
7//!
8//! 【2026-08-05】フォントと画像でワーカー上限を**別々に**持っていたため、
9//! 実際には最大 8 本が同時に TLS 接続を張っていた。
10//! この自作 TCP/TLS スタックは同時接続に弱いことが既知で
11//! (`spec/TODO.md` の「接続回数依存の劣化」)、
12//! CSS/JS も非同期化したところ**メイン HTML が読み込めなくなった**
13//! (`[NET][STATS] total=3`、正常時は 13)。
14//!
15//! 上限は種別ごとではなく**全体で**持たないと意味がない。
16//! さらに、メイン HTML はページの他の何よりも優先される
17//! (それが無ければ描くものが無い)。
18//!
19//! # 方針
20//!
21//! - 同時に走るリソース取得ワーカーの総数に上限を設ける
22//! - メイン HTML の取得中はリソース取得を新たに始めない
23//!
24//! グローバル状態を持つが、判定そのものは純粋関数へ切り出してあり
25//! 単体試験で固定してある(`tests/src/test_fetch_limit.rs`)。
26
27use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
28
29/// 同時に走らせるリソース取得ワーカーの総数上限。
30///
31/// 【2026-08-05】最初 4 にしたが**背景写真が届かなくなった**
32/// (`[NET][STATS] total=12`、`[IMGPERF]` が出ない。正常時は 13)。
33/// 3.7MB の画像は最も重く、並列度を半分にすると 200 秒に間に合わない。
34///
35/// 観測された不具合の原因は「同時数が 8 だから」ではなく
36/// **メイン HTML と競合したから**だった(`set_main_load_in_flight` を参照)。
37/// そちらだけを直し、総数は現状(フォント 4 + 画像 4)を維持する。
38/// ここは種別ごとの上限を横断で押さえる**安全網**として置く。
39///
40/// 増減させるときは `[NET][STATS]` の total と `[IMGPERF]` の有無で
41/// 必ず実測すること。同時接続に弱いスタックなので上げるのも危険。
42pub const MAX_TOTAL_FETCH_WORKERS: usize = 8;
43
44/// 現在走っているリソース取得ワーカーの総数。
45static ACTIVE: AtomicUsize = AtomicUsize::new(0);
46
47/// メイン HTML を取得中か。
48static MAIN_LOAD_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
49
50/// リソースの種別。値が小さいほど優先。
51///
52/// 【2026-08-05】CSS を非同期取得にしたとき、CSS がフォント 8 本・画像と
53/// 同じ枠を奪い合って**到着が遅すぎ**、スタイル付きのレイアウトが
54/// 実行時間内に始まらなかった(ログの行番号で確認。
55/// 「カスケード開始 ルール数=2403」の後に完了行が出ない)。
56///
57/// CSS はレイアウトに必須なので最優先。
58/// フォントは文字の見た目に直結するので次。
59/// 画像は無くても文章が読める。JS は最後。
60#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
61pub enum Kind {
62 /// スタイルシート。レイアウトに必須。
63 Style = 0,
64 /// Web フォント。
65 Font = 1,
66 /// 画像。
67 Image = 2,
68 /// スクリプト。
69 Script = 3,
70}
71
72impl Kind {
73 pub fn priority(self) -> u8 {
74 self as u8
75 }
76}
77
78/// 優先度の高い順に取り出すべき添字を返す(純粋関数)。
79///
80/// 同じ優先度なら**先に積まれた方**を選ぶ(先入れ先出し)。
81/// 空なら `None`。
82///
83/// 計算量: **O(N)**。キューは高々数十件なので十分。
84pub fn pick_highest_priority(prios: &[u8]) -> Option<usize> {
85 let mut best: Option<usize> = None;
86 for (i, &p) in prios.iter().enumerate() {
87 match best {
88 None => best = Some(i),
89 Some(b) => {
90 if p < prios[b] {
91 best = Some(i);
92 }
93 }
94 }
95 }
96 best
97}
98
99/// 低優先のワーカーを起こしてよいか(純粋関数)。
100///
101/// より優先度の高いリソースが待っている間は起こさない。
102/// これが無いと、CSS が画像 4 本の後ろに並んで到着が遅れる。
103pub fn may_spawn_lower(
104 main_in_flight: bool,
105 active: usize,
106 queued: usize,
107 higher_priority_pending: usize,
108) -> bool {
109 if higher_priority_pending > 0 {
110 return false;
111 }
112 may_spawn(main_in_flight, active, queued)
113}
114
115/// 新しいワーカーを起こしてよいかの判定(純粋関数)。
116///
117/// - `main_in_flight` … メイン HTML の取得中か
118/// - `active` … 現在走っているワーカー総数
119/// - `queued` … そのキューに溜まっている件数
120///
121/// メイン HTML 取得中は新規に起こさない。
122/// 上限に達していても起こさない。
123/// 積むものが無ければ起こさない。
124pub fn may_spawn(main_in_flight: bool, active: usize, queued: usize) -> bool {
125 if main_in_flight {
126 return false;
127 }
128 if active >= MAX_TOTAL_FETCH_WORKERS {
129 return false;
130 }
131 queued > 0
132}
133
134/// メイン HTML の取得開始/終了を記録する。
135pub fn set_main_load_in_flight(v: bool) {
136 MAIN_LOAD_IN_FLIGHT.store(v, Ordering::Release);
137}
138
139pub fn main_load_in_flight() -> bool {
140 MAIN_LOAD_IN_FLIGHT.load(Ordering::Acquire)
141}
142
143/// 保留中・実行中の <script> があるか(`WebEngine::run_pending_scripts`。2026-10-05)。
144/// 最初の画面の後にスクリプトを回すようにしたので、待機状態の表示ではこれも「読込中」に数える。
145static SCRIPTS_PENDING: AtomicBool = AtomicBool::new(false);
146
147pub fn set_scripts_pending(v: bool) {
148 SCRIPTS_PENDING.store(v, Ordering::Release);
149}
150
151/// 現在のワーカー総数。
152pub fn active() -> usize {
153 ACTIVE.load(Ordering::Acquire)
154}
155
156/// ワーカーを 1 本起こしてよいか判定し、よければ枠を確保する。
157///
158/// 確保できたら `true`。呼び出し側は**必ず** `release()` と対にすること。
159pub fn try_acquire(queued: usize) -> bool {
160 if !may_spawn(main_load_in_flight(), active(), queued) {
161 return false;
162 }
163 ACTIVE.fetch_add(1, Ordering::AcqRel);
164 true
165}
166
167/// ワーカーの枠を返す。
168pub fn release() {
169 // 0 を下回らせない(二重解放しても壊さない)。
170 let _ = ACTIVE.fetch_update(Ordering::AcqRel, Ordering::Acquire, |v| {
171 Some(v.saturating_sub(1))
172 });
173}
174
175// ───────────── パニックしたワーカーの後始末 ─────────────
176//
177// 【2026-08-25 バグ修正】ワーカーがパニックすると
178// `#[panic_handler]` が `scheduler::exit()` を呼んでその場で殺す。
179// 巻き戻しが無いので `release()` は**実行されない**。
180//
181// これは机上の話ではない。`os_lib/webp` は
182// 「カーネルの panic 禁止ポリシーの**対象外**」と明記された
183// ベンダリング済みデコーダで、まさにこのワーカーの中で回る。
184// 壊れた WebP が 8 枚あれば枠を全部食い潰し、以後
185// **フォントも画像も二度と読み込まれない**(黙って止まる)。
186//
187// パニックハンドラの中でロックを取ってはいけない。
188// そのロックを握ったまま死んだ場合に、回収そのものが固まる。
189// だから所有者の記録は**原子変数の配列**にし、
190// 実際の後始末は毎フレーム走る `pump()` に任せる。
191
192/// 枠の所有者 PID。0 は空き。
193static OWNER_PID: [AtomicUsize; MAX_TOTAL_FETCH_WORKERS] =
194 [const { AtomicUsize::new(0) }; MAX_TOTAL_FETCH_WORKERS];
195
196/// 死んだフォントワーカーの数(`font_queue::pump` が回収する)。
197pub static FONT_WORKERS_DIED: AtomicUsize = AtomicUsize::new(0);
198/// 死んだ画像ワーカーの数(`image::pump` が回収する)。
199pub static IMAGE_WORKERS_DIED: AtomicUsize = AtomicUsize::new(0);
200
201/// 種別: フォントワーカー。
202pub const KIND_FONT: usize = 1;
203/// 種別: 画像ワーカー。
204pub const KIND_IMAGE: usize = 2;
205
206/// 所有者の記録。`pid` の下位ビットに種別を混ぜず、別配列で持つ。
207static OWNER_KIND: [AtomicUsize; MAX_TOTAL_FETCH_WORKERS] =
208 [const { AtomicUsize::new(0) }; MAX_TOTAL_FETCH_WORKERS];
209
210/// ワーカーが自分の PID で枠の所有を宣言する。
211///
212/// 枠自体は親(`pump`)が `try_acquire` で確保済み。
213/// ここで結び付けておかないと、死んだときに誰の枠か分からない。
214pub fn claim(pid: usize, kind: usize) {
215 if pid == 0 {
216 return;
217 }
218 for i in 0..MAX_TOTAL_FETCH_WORKERS {
219 if OWNER_PID[i]
220 .compare_exchange(0, pid, Ordering::AcqRel, Ordering::Acquire)
221 .is_ok()
222 {
223 OWNER_KIND[i].store(kind, Ordering::Release);
224 return;
225 }
226 }
227}
228
229/// 正常終了時に所有を外す(`release()` と対で呼ぶ)。
230pub fn unclaim(pid: usize) {
231 if pid == 0 {
232 return;
233 }
234 for i in 0..MAX_TOTAL_FETCH_WORKERS {
235 if OWNER_PID[i]
236 .compare_exchange(pid, 0, Ordering::AcqRel, Ordering::Acquire)
237 .is_ok()
238 {
239 OWNER_KIND[i].store(0, Ordering::Release);
240 return;
241 }
242 }
243}
244
245/// パニックハンドラから呼ぶ。**ロックを取らない**。
246///
247/// `pid` が枠を持っていたら解放し、種別ごとの死亡数を 1 増やす。
248/// 種別の数え上げは `pump()` が `active_workers` の補正に使う。
249pub fn on_process_died(pid: usize) {
250 if pid == 0 {
251 return;
252 }
253 for i in 0..MAX_TOTAL_FETCH_WORKERS {
254 if OWNER_PID[i].load(Ordering::Acquire) != pid {
255 continue;
256 }
257 let kind = OWNER_KIND[i].swap(0, Ordering::AcqRel);
258 OWNER_PID[i].store(0, Ordering::Release);
259 release();
260 match kind {
261 KIND_FONT => {
262 FONT_WORKERS_DIED.fetch_add(1, Ordering::AcqRel);
263 }
264 KIND_IMAGE => {
265 IMAGE_WORKERS_DIED.fetch_add(1, Ordering::AcqRel);
266 }
267 _ => {}
268 }
269 return;
270 }
271}
272
273/// 死んだフォントワーカー数を取り出して 0 に戻す。
274pub fn take_font_deaths() -> usize {
275 FONT_WORKERS_DIED.swap(0, Ordering::AcqRel)
276}
277
278/// 死んだ画像ワーカー数を取り出して 0 に戻す。
279pub fn take_image_deaths() -> usize {
280 IMAGE_WORKERS_DIED.swap(0, Ordering::AcqRel)
281}
282
283/// ブラウザの `draw()` がどの段にいるか。
284///
285/// 【なぜ必要か】停滞時、`draw()` から戻ってこないことまでは分かったが
286/// **どこで戻らないか**が分からない。提示ループ(`[IDLE]` を出す側)は
287/// 停滞中も動き続けるので、そちらから覗ける場所に段を置く。
288/// 0=描画外 1=入口 2=フォント取り込み 3=再レイアウト 4=描画 5=出口直前
289pub static DRAW_STAGE: AtomicUsize = AtomicUsize::new(0);
290
291pub fn set_draw_stage(s: usize) {
292 DRAW_STAGE.store(s, Ordering::Relaxed);
293}
294
295/// 取得の待機状態を 1 行で出す。
296///
297/// 【2026-08-25】撮影時期を決めるための**明示的な待機状態**。
298///
299/// 採取スクリプトはこれまでログの断片から「もう終わったか」を
300/// 推し量っていた(`after-resp total=` が伸びない、`resolved=` と
301/// `SUCCESS` の対応を数える等)。どれも外れた。接続を使い回すと
302/// `resolved=` は出ないし、数 MB の転送中は何も出ない。
303/// まだ取得中なのに「静止した」と判断して撮っていた。
304///
305/// 当てるのをやめ、**OS が自分の状態を明示する**。
306/// 全部ゼロなら取得は残っていない。
307///
308/// **呼び出し元は止まらないループであること。**
309/// 最初はブラウザの `draw()` から出していたが、待機に入ると
310/// `draw()` 自体が止まるため、最後の行が古い標本のまま残り
311/// 「まだ取得中」に見え続けた。画面の提示ループから呼ぶ。
312pub fn idle_line() {
313 crate::warn!(
314 "[IDLE] font_pending={} img_pending={} active={} main={} fw={} iw={} ff={} 描画段={}",
315 super::font_queue::pending_len(),
316 super::image::pending_count(),
317 active(),
318 main_load_in_flight() || SCRIPTS_PENDING.load(Ordering::Acquire),
319 // 種別ごとの稼働本数。全体の枠に余裕があっても種別の上限に
320 // 達していれば起こせないので、止まったとき内訳が要る。
321 super::font_queue::worker_count(),
322 super::image::worker_count(),
323 // 取得の中に居るフォントワーカー数。停止点の切り分けに使う。
324 super::font_queue::in_fetch_count(),
325 // `draw()` がどこで止まっているか。
326 DRAW_STAGE.load(Ordering::Relaxed),
327 );
328}
329