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/// 現在のワーカー総数。
144pub fn active() -> usize {
145 ACTIVE.load(Ordering::Acquire)
146}
147
148/// ワーカーを 1 本起こしてよいか判定し、よければ枠を確保する。
149///
150/// 確保できたら `true`。呼び出し側は**必ず** `release()` と対にすること。
151pub fn try_acquire(queued: usize) -> bool {
152 if !may_spawn(main_load_in_flight(), active(), queued) {
153 return false;
154 }
155 ACTIVE.fetch_add(1, Ordering::AcqRel);
156 true
157}
158
159/// ワーカーの枠を返す。
160pub fn release() {
161 // 0 を下回らせない(二重解放しても壊さない)。
162 let _ = ACTIVE.fetch_update(Ordering::AcqRel, Ordering::Acquire, |v| {
163 Some(v.saturating_sub(1))
164 });
165}
166
167// ───────────── パニックしたワーカーの後始末 ─────────────
168//
169// 【2026-08-25 バグ修正】ワーカーがパニックすると
170// `#[panic_handler]` が `scheduler::exit()` を呼んでその場で殺す。
171// 巻き戻しが無いので `release()` は**実行されない**。
172//
173// これは机上の話ではない。`os_lib/webp` は
174// 「カーネルの panic 禁止ポリシーの**対象外**」と明記された
175// ベンダリング済みデコーダで、まさにこのワーカーの中で回る。
176// 壊れた WebP が 8 枚あれば枠を全部食い潰し、以後
177// **フォントも画像も二度と読み込まれない**(黙って止まる)。
178//
179// パニックハンドラの中でロックを取ってはいけない。
180// そのロックを握ったまま死んだ場合に、回収そのものが固まる。
181// だから所有者の記録は**原子変数の配列**にし、
182// 実際の後始末は毎フレーム走る `pump()` に任せる。
183
184/// 枠の所有者 PID。0 は空き。
185static OWNER_PID: [AtomicUsize; MAX_TOTAL_FETCH_WORKERS] =
186 [const { AtomicUsize::new(0) }; MAX_TOTAL_FETCH_WORKERS];
187
188/// 死んだフォントワーカーの数(`font_queue::pump` が回収する)。
189pub static FONT_WORKERS_DIED: AtomicUsize = AtomicUsize::new(0);
190/// 死んだ画像ワーカーの数(`image::pump` が回収する)。
191pub static IMAGE_WORKERS_DIED: AtomicUsize = AtomicUsize::new(0);
192
193/// 種別: フォントワーカー。
194pub const KIND_FONT: usize = 1;
195/// 種別: 画像ワーカー。
196pub const KIND_IMAGE: usize = 2;
197
198/// 所有者の記録。`pid` の下位ビットに種別を混ぜず、別配列で持つ。
199static OWNER_KIND: [AtomicUsize; MAX_TOTAL_FETCH_WORKERS] =
200 [const { AtomicUsize::new(0) }; MAX_TOTAL_FETCH_WORKERS];
201
202/// ワーカーが自分の PID で枠の所有を宣言する。
203///
204/// 枠自体は親(`pump`)が `try_acquire` で確保済み。
205/// ここで結び付けておかないと、死んだときに誰の枠か分からない。
206pub fn claim(pid: usize, kind: usize) {
207 if pid == 0 {
208 return;
209 }
210 for i in 0..MAX_TOTAL_FETCH_WORKERS {
211 if OWNER_PID[i]
212 .compare_exchange(0, pid, Ordering::AcqRel, Ordering::Acquire)
213 .is_ok()
214 {
215 OWNER_KIND[i].store(kind, Ordering::Release);
216 return;
217 }
218 }
219}
220
221/// 正常終了時に所有を外す(`release()` と対で呼ぶ)。
222pub fn unclaim(pid: usize) {
223 if pid == 0 {
224 return;
225 }
226 for i in 0..MAX_TOTAL_FETCH_WORKERS {
227 if OWNER_PID[i]
228 .compare_exchange(pid, 0, Ordering::AcqRel, Ordering::Acquire)
229 .is_ok()
230 {
231 OWNER_KIND[i].store(0, Ordering::Release);
232 return;
233 }
234 }
235}
236
237/// パニックハンドラから呼ぶ。**ロックを取らない**。
238///
239/// `pid` が枠を持っていたら解放し、種別ごとの死亡数を 1 増やす。
240/// 種別の数え上げは `pump()` が `active_workers` の補正に使う。
241pub fn on_process_died(pid: usize) {
242 if pid == 0 {
243 return;
244 }
245 for i in 0..MAX_TOTAL_FETCH_WORKERS {
246 if OWNER_PID[i].load(Ordering::Acquire) != pid {
247 continue;
248 }
249 let kind = OWNER_KIND[i].swap(0, Ordering::AcqRel);
250 OWNER_PID[i].store(0, Ordering::Release);
251 release();
252 match kind {
253 KIND_FONT => {
254 FONT_WORKERS_DIED.fetch_add(1, Ordering::AcqRel);
255 }
256 KIND_IMAGE => {
257 IMAGE_WORKERS_DIED.fetch_add(1, Ordering::AcqRel);
258 }
259 _ => {}
260 }
261 return;
262 }
263}
264
265/// 死んだフォントワーカー数を取り出して 0 に戻す。
266pub fn take_font_deaths() -> usize {
267 FONT_WORKERS_DIED.swap(0, Ordering::AcqRel)
268}
269
270/// 死んだ画像ワーカー数を取り出して 0 に戻す。
271pub fn take_image_deaths() -> usize {
272 IMAGE_WORKERS_DIED.swap(0, Ordering::AcqRel)
273}
274
275/// ブラウザの `draw()` がどの段にいるか。
276///
277/// 【なぜ必要か】停滞時、`draw()` から戻ってこないことまでは分かったが
278/// **どこで戻らないか**が分からない。提示ループ(`[IDLE]` を出す側)は
279/// 停滞中も動き続けるので、そちらから覗ける場所に段を置く。
280/// 0=描画外 1=入口 2=フォント取り込み 3=再レイアウト 4=描画 5=出口直前
281pub static DRAW_STAGE: AtomicUsize = AtomicUsize::new(0);
282
283pub fn set_draw_stage(s: usize) {
284 DRAW_STAGE.store(s, Ordering::Relaxed);
285}
286
287/// 取得の待機状態を 1 行で出す。
288///
289/// 【2026-08-25】撮影時期を決めるための**明示的な待機状態**。
290///
291/// 採取スクリプトはこれまでログの断片から「もう終わったか」を
292/// 推し量っていた(`after-resp total=` が伸びない、`resolved=` と
293/// `SUCCESS` の対応を数える等)。どれも外れた。接続を使い回すと
294/// `resolved=` は出ないし、数 MB の転送中は何も出ない。
295/// まだ取得中なのに「静止した」と判断して撮っていた。
296///
297/// 当てるのをやめ、**OS が自分の状態を明示する**。
298/// 全部ゼロなら取得は残っていない。
299///
300/// **呼び出し元は止まらないループであること。**
301/// 最初はブラウザの `draw()` から出していたが、待機に入ると
302/// `draw()` 自体が止まるため、最後の行が古い標本のまま残り
303/// 「まだ取得中」に見え続けた。画面の提示ループから呼ぶ。
304pub fn idle_line() {
305 crate::warn!(
306 "[IDLE] font_pending={} img_pending={} active={} main={} fw={} iw={} ff={} 描画段={}",
307 super::font_queue::pending_len(),
308 super::image::pending_count(),
309 active(),
310 main_load_in_flight(),
311 // 種別ごとの稼働本数。全体の枠に余裕があっても種別の上限に
312 // 達していれば起こせないので、止まったとき内訳が要る。
313 super::font_queue::worker_count(),
314 super::image::worker_count(),
315 // 取得の中に居るフォントワーカー数。停止点の切り分けに使う。
316 super::font_queue::in_fetch_count(),
317 // `draw()` がどこで止まっているか。
318 DRAW_STAGE.load(Ordering::Relaxed),
319 );
320}
321