Skip to main content

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