性能調査で足した診断ログが多すぎたので整理した。 そのとき
[TLS][READEND] TIMEOUT
という失敗ログまで一緒に消した。
結果、「失敗・エラーしているのにログが出ない」状態を作った。
冗長な情報ログと失敗ログを同列に扱ったのが誤り。
以下は常にログを出す。整理・削除の対象にしない。
_ => で握り潰す前)失敗ログには判断に必要な値を必ず添える (経過時間、上限値、対象のホスト/パス、件数など)。 「失敗しました」だけでは次の一手が決められない。
以下は禁止。どうしても続行する場合はログを出してから続行する。
let _ = fs.save_file(...); // ← 失敗が消える
result.ok(); // ← 同上
result.unwrap_or_default(); // ← 失敗と「空」が区別できない過去に let _ = fs.save_file(...)
でキャッシュ保存の失敗が
握り潰され、「非同期取得は完了しているのにキャッシュが効かない」
原因の特定に時間を要した(spec/resource_loading.md)。
毎フレーム・毎ノード・毎リクエストで出るログは、
spec/resource_loading.md)成功パスは黙る。必要なら集計して節目で 1 回出す。
一時的な切り分けログには、いつ・何のために足したかを書く。
// 【2026-08-01計測】paint の 53% がどのプリミティブにも計上されない。
// 原因が判明したら消す。目的が達成されたら消す。ただし L-1 に該当するものは消さない。
一括で消さない。1 件ずつ次を判定する。
| 種別 | 扱い |
|---|---|
| 失敗・エラー | 残す(L-1) |
| 上限到達・異常な状態 | 残す |
| 正常終了の記録(EOF 等) | 頻度次第。毎回出るなら消す |
| 成功時の値の記録 | 消す |
| 一時的な計測 | 消す(L-4) |
grep
でまとめて消す前に、フォーマット文字列を目で見て分類する。
test_tcp_socket_table_exhaustion_and_reuse確かめたい性質は 「テーブルが満杯なら
tcp_connect_real が
Err("tcp socket table full") を返す」。
ところが試験は 40 本の OS スレッドを一斉に起動し、 「SYN タイムアウト(6 秒)で先行分が抜ける前に全員が揃う」ことに賭けていた。 ホスト負荷が高いと起動が遅れて満杯を観測できず、偽陰性で落ちる。 過去に 5 回「フレーキー解消」の修正が入っており(リトライ 3 回化など)、 どれも賭けの勝率を上げただけで賭け自体は残っていた。
賭けの部分を状態の直接構築に置き換える。
let occupied = tcp_fill_socket_table_for_test(); // スレッド 0 本
assert_eq!(tcp_connect_real(...), Err("tcp socket table full"));
tcp_clear_socket_table_for_test();#[cfg(test)] の試験用ヘルパを 2 本足しただけ。
T-1: 試験が「AがBより先に起きる」ことに依存していたら、 その順序は検証対象か? 違うなら状態を直接作る。
T-2: 同じ試験に「フレーキー解消」の注釈が 2 回以上付いていたら、 賭け率の調整ではなく構造を疑う。
T-3: 純粋モジュール側で決定論的に検証済みの性質を、 実機側で重ねて確かめない。実機側は 「その戻り値が呼び出し元まで伝わるか」だけに絞る。
結果: 偽陰性が消え、試験時間も 22.1 秒 → 15.1 秒。
シリアルログの行が混ざり、機械的に読めなくなっていた。
[NET][DNSSEND]CgQ hoBnstw7HFyzSD-As=fonts.gstatic.com attempt=1
ont Awesome 7 Brands[WARN ] [NET][DNSSEND] host=fonts.gstatic.com ...
リソース取得を 4 並列にしてから頻発した。 診断の土台が壊れると、他のすべての調査が信用できなくなる。
| # | 原因 | 対処 |
|---|---|---|
| 1 | log! が write_fmt を 3
回(レベル・本文・改行)素で呼ぶ。UART へは 1
文字ずつ書くので途中で切り替わる |
行を組み立ててから 1 回で出す(emit_line) |
| 2 | print! がロックを通らず、ログ行の途中へ割り込む |
同じロックを通す(emit_raw) |
| 3 | println! が本文と改行で print! を 2
回呼ぶ。1 回ずつロックしても隙間に割り込まれる |
format_args! の入れ子で改行まで 1 回にまとめる |
最初は回数(10 万回)でスピンを切ったが短すぎた。 115200 baud では 1 文字 ≈ 87µs、100 文字の行で ≈ 9ms かかる。 10 万回のスピンは 1ms にも満たないので、先行する行を書き終える前に 諦めて混線が残っていた。
tick 基準(20 tick ≈ 200ms)にすれば CPU 速度に依らず 「行 1 本ぶんは待つ」が保証できる。tick が進まない場合の保険として 回数の上限も併せて置く。
String を組むとアロケータのロックを取る。
確保まわりの不具合を調べるためのログ自身が、その経路に依存してしまう。
スタック上の固定長バッファ(kernel/uart_line_buf.rs、512
バイト)で組む。 入り切らない分は文字境界まで戻して ...
を付ける (不正な UTF-8 を UART へ流さない)。
L-6: ログ 1 行は不可分に出力する。 出力を複数回の書き込みに分けない。分けるなら全体をロックで囲う。
| 混線行 / 総ログ行 | |
|---|---|
| 修正前 | 2 / 399 |
emit_line + emit_raw 後 |
1 / 417 |
println! を 1 回化した後 |
0 / 435 |
単体試験は tests/src/test_line_buf.rs に 9 件
(溢れ・UTF-8 境界・ちょうど収まる場合の off-by-one)。
単体試験がまれに 1
件だけ落ちる(845 passed; 1 failed)。 3
回続けて再現せず、落ちた回もテスト名を採れていなかった。
再現を待つのではなく、失敗するまで回して丸ごと保存することで捕捉した。
for i in 1 2 3 4 5 6 7 8; do
make test > tmp/test_run.log 2>&1
if grep -qa "test result: FAILED" tmp/test_run.log; then
grep -a -A 12 "^failures:" tmp/test_run.log; break
fi
done4 回目で捕捉:
test_rule_index::state_masks::状態を使わなければゼロ
left: None right: Some(0)
この session で私自身が追加した試験だった。
active_states_of はグローバルの
ACTIVE(spin::Mutex<Option<RuleIndex>>)
を見るが、試験は並列に走るので、 別の試験の
clear_active() が挟まると None
が返る。
同じ file の少し上に、自分で書いた教訓がある。
T-1: 試験が「AがBより先に起きる」ことに依存していたら、 その順序は検証対象か? 違うなら状態を直接作る。
守れていなかった。
問い合わせ本体を RuleIndex::states_of(&self, ..)
へ移し、 active_states_of はそれに委譲するだけにした。
試験は索引を直接組んで問い合わせる。グローバルを一切使わない。
10 回連続で成功(従来は 8 回に 1 回程度落ちていた)。
T-4: 再現しないフレーキーは「待つ」のではなく 失敗するまで回して出力を丸ごと保存する。 15 秒で回る試験なら 10 回でも 3 分。推測より速い。
T-5: 教訓を書いても、次に自分が書くコードで破る。 新しい試験を足したら、その場で T-1〜T-3 に照らすこと。
T-5(新しい試験を足したら既存の規準に照らす)に従い、
css/rule_index.rs の ACTIVE
に依存する試験を全部数えた。
| 試験 | 状況 |
|---|---|
test_rule_index::state_masks(今回追加) |
active_states_of 経由。8 回に 1
回落ちていた → グローバルを使わない形へ修正済み |
test_sugi_lab_cascade(24 件) |
style_tree 経由。style_tree
が呼び出しの前後で ACTIVE を設定・解除する |
後者は落ちてはいなかったが、2 本の style_tree
が重なれば
相手の索引を上書きし合い、間違った候補ルールで照合する。
落ちれば気付けるが、たまたま通ってしまうと
「通ったのに検証できていない」状態になる方が怖い。
style_tree は本体の設計がグローバル前提なので、
こちらは呼び出しを直列化した(style_tree_serialized)。
10 回連続で成功。
layout は Core 0
の単一スレッドからしか走らないため、この競合は起きない。 ただしこれは
get_fs()
と同じ種類の「運用上の不変条件」であり、
型システムが保証するものではない。 レイアウトを並列化するときは、先に
ACTIVE を引数へ移すこと (この session で
get_fs() の同じ申し送りを見落として
ファイルシステムを壊した。二度目は避ける)。
T-6: 「落ちていない」は「競合が無い」ではない。 グローバル状態に触る試験は、落ちる前に洗い出して直列化するか、 グローバルを使わない形へ直す。
draw() に計測用ガードを 2
つ足したらブラウザが起動しなくなり、
外したら直ったので「計装が原因」と結論して L-7/L-8 を書いた。
コードを一切変えずに再実行したら正常に動いた(total=13 ok=13)。
ブラウザが起動しない現象は前から断続的に起きており (この session で
total=3 や Loading Secure Page... のまま等、
異なるコード状態で何度も観測している)、
私の変更とは無関係だった。
「外したら直った」は1 回の観測にすぎない。 自分で書いた R-7(見た目 1 枚で原因を決めつけない)を、 また破った。戻して直ったように見えても、それは 1 標本。
撮影スクリプトは [LAYOUT][SRC] load_html が出れば
「ブラウザが起動した」と判定していた。
だがブラウザは起動時に空のホームページを読むので、
目的の URL へ行っていなくてもこの印は出る。
結果、ブラウザが起動しないまま「成功」と判定して撮影し、 端末の画面を保存していた。しかも再送も走らない。
目的のホストへ実際に接続したこと([NET][DNSSEND] host=<host>)を
確かめるようにした。行っていなければ再送し、3
回で諦めて明示的に報告する。
L-7: 「変更を戻したら直った」は1 標本。 断続的な失敗のある環境では、戻さずにもう一度実行して 再現するかを先に確かめる。実機 1 回が数分でも、それが最短。
L-8:
判定に使う印は、目的の状態に固有でなければならない。
load_html のように「似た状況でも出る」印を使うと、
失敗を成功と誤判定する。
「ブラウザが起動した」の判定に [LAYOUT][SRC] load_html
を使っていた。
だがブラウザは起動時に空のホームページを読むので、
目的の URL へ行っていなくてもこの印は出る。
結果、ブラウザが起動しないまま「成功」と判定して端末の画面を保存し、 再送も走らなかった。失敗が失敗として見えていなかった。
判定を
host=<host>(名前解決・接続まで)に変えたところ、
そこまで 60 秒では届かない回があり、
起動中のブラウザへもう一度キーを送ってしまった。
送ったキーはブラウザが吸うので、かえって壊れる。 3
回連続で起動失敗した。
| 目的 | 印 | 失敗時 |
|---|---|---|
| コマンドが届いたか | [LAYOUT][SRC] load_html(速い) |
再送する(最大 3 回) |
| 目的地へ着いたか | host=<host>(遅い) |
再送しない。警告を出すだけ |
再送してよいのは「そもそも届いていない」ときだけ。 着いたかどうかは、再送では直らない。
L-9: 再送の判定と、成否の判定は別の印を使う。 遅い印で再送を決めると、処理中の相手に追い打ちをかける。
L-10: 「失敗を検出できるようにする」変更は、 それ自体が新しい失敗を作りうる。 検出を足したら、正常系が壊れていないかを必ず確かめる。
ブラウザが起動しない回が頻発(一時は 4 回連続)。 端末にはコマンドのエコーすら出ず、シリアルログはログイン直後で止まる。 OS 側のコードを戻しても直らず、環境要因かと思われた。
待ちループが Get-Content -Raw でログ全体を 500ms
ごとに読み直していた。
cmd.exe
のリダイレクト)の書き込みが待たされるsend_string
の中で待つので、 OS 全体が止まる待ちを増やした(host=<host>
の確認を足した)ときに悪化したのは、
読み直しの回数がそのぶん増えたため。計測のための待ちが、
計測対象を止めていた。
Read-SerialFrom)FileShare::ReadWrite で開き、QEMU
の書き込みを妨げない2 回連続で成功(total=13 ok=13 /
total=10 ok=10)。
L-11: 観測が対象を止めていないか疑う。 ログを読む頻度・量も負荷であり、 シリアルのように詰まると相手が止まる経路では致命的になる。
L-12: 「環境要因だろう」で片付けない。 今回それで 4 回の失敗を見送りかけたが、原因は自分のスクリプトにあった。
役目を終えた計装を撤去した(L-4)。
| 撤去 | 呼ばれ方 |
|---|---|
受信ループの recv呼出 / 受信sleep |
1 パケットごと(実測 8118 / 4758 回) |
USB 送信の TX成功 / TX失敗 |
1 フレームごと(実測 40068 回) |
撤去前: 受信待ち=11647/25416
撤去後: 受信待ち= 5473/25057 ← 呼び出し回数はほぼ同じ
受信待ちが半減。 計装が受信ループの半分を占めていた。
perf::add は「アトミック 1 回だけ」のつもりだったが、
実際は get_ticks() を2
回(start と add)呼ぶ。 1
パケットごとに 4 回のタイマ読みは、 1 秒あたり数千回の経路では効く。
L-13: 計装は呼ばれる頻度で分類する。 - 節目(1 ページに数回)… 常設してよい - 1 パケット・1 フレーム・1 要素ごと … 調査中だけ。 目的を果たしたら必ず外し、外した状態で測り直す
perf.rs
の冒頭に「熱経路に置いても影響が小さい」と書いていたが、
これは誤りだった。訂正済み。
同じ考えで draw_helpers と kernel/draw.rs
の 1 呼び出しごとの計測(TickTimer / AccTimer
/ Guard、17 行)も外した。
撤去前: total=17461 relayout=6650 frames=70 → 描画部 154 tick/frame
撤去後: total=17601 relayout=6741 frames=72 → 描画部 151 tick/frame
有意差なし。
| 呼ばれる回数 | 撤去の効果 | |
|---|---|---|
| 受信ループ | 1 パケットごと(約 13000 回) | 受信待ちが半減 |
| 描画の原始操作 | 矩形塗り=11 tick と元々軽い |
差なし |
「1 呼び出しごと」でも、呼ばれる回数が桁違いだと結論が変わる。 L-13 は「頻度で分類する」だが、 その頻度は推測ではなく実測の回数で判断すること。
撤去自体は正しい後始末(役目を終えた計測を残さない)。 ただし速くなると期待して外すなら、回数を先に見る。