Rust製x86エミュレータのXにicewmとDilloを載せたらChromeが6分で落ちた — V8のダンプからwasm JITの関数テーブル漏れまで【rustx86 グラフィックス編 #4】

Rust

連載案内: Rust + WebAssembly で書いている x86 エミュレータ rustx86 の開発記。グラフィックス編は #1 VGA mode 13h#2 Linux のフレームバッファ (efifb)#3 PS/2 マウスと X と来て、今回は X の上に載せるものと、載せたら出てきた 3 つの謎。他の連載は ネットワーク編 #1ディスク編 #1JIT編 #1

前回、ブラウザの canvas に twm の xterm が開いた。今回はその上に icewm とブラウザ 3 種と日本語フォントを載せて「使える顔」にする — つもりが、しばらく使うと Chrome のタブが落ちる。日本語は四角になる。マウスは相対だから使いづらい。3 つとも実物が教えてくれる形で片づいたので、順に書く。いちばん長いのは真ん中の、エラーコード 5 の謎解きである。

icewm のデスクトップ — xterm、メニュー、タスクバーの時計。全部ブラウザの canvas

X に載せるものを整理する

前回の X 入りディスクは twm + xterm の最小構成だった。役割ごとに揃え直す。

役割 入れた物
WM icewm (タスクバー・メニュー・時計。古典デスクトップの絵)
端末 xterm
ブラウザ dillo (GUI/FLTK) / w3m / links + ca-certificates (NIC を挿して wsslirp 経由なら https)
画像・ファイラ feh / xfe
小物 xclock / xeyes / xcalc / xmessage
ゲーム xboard + gnuchess
フォント DejaVu + IPAex (13MB)

Alpine の x86 の apk を Docker の中で取り、squashfs に焼く流儀は gcc ディスクと同じ。最初の焼きは gz で 112MB になった。犯人は mupdf の 51MB と、前回捨てたはずの mesa が 別名 (libgallium、46MB) で戻ってきていたこと。mupdf を外し gallium を捨てて gz 60MB。

小物 — xclock / xeyes / xcalc、xterm には uname -a (6.18.35 i686)。全部ソフトウェア描画

icewm のメニューは /etc/icewm/menu に役割別で書いた。ここで小さな罠を 1 つ踏んだ。メニューが空になる。icewm はメニュー項目の実行ファイルを PATH で探し、無い項目を黙って隠す。init から起きたシェルは PATH を export していなかったので、startx で export して解決。こういう「黙って隠す」は、原因が分かれば 1 行だが、分かるまでは icewm の設定を疑い続けることになる。

しばらく使うと Chrome が落ちる

X 入りを 6〜8 分動かすとタブが「エラーコード: 5」で落ちる。Dillo を起動すると 1 分で再現する。

最初の見立ては外した。「1024×768×4 = 3MB のフレームを 30fps で投げっぱなしにしている。メインが追いつかなければ postMessage の行列に溜まる」— 筋は通っていて、ack が戻るまで次を送らない背圧を入れた。それでも落ちた。筋が通っているだけでは足りない。測っていないからである。

クラッシュダンプを読む

Mac の Chrome のエラーコード 5 は SIGTRAP、つまり V8 が自分で止めた致命エラーで、ダンプは ~/Library/Application Support/Google/Chrome/Crashpad/completed/*.dmp に残る。strings で引くと、3 回のクラッシュ全部に同じ 2 行があった。

v8-oom-location
ExternalEntityTable::AllocateEntry
[57355:0x10c10470000]   379739 ms: Scavenge 968.4 (976.6) -> 967.2 (980.9) MB, ... allocation failure;

V8 のヒープが 960MB。そして OOM の場所が ExternalEntityTable — これは転送バッファではなく、V8 がヒープの外の実体 (Code オブジェクト等) を数える表である。フレームの行列ではなく、何かが「数えられる物」を大量に作って保持している。

本物のワーカーを Node で動かす

ブラウザのワーカーのヒープは performance.memory では見えない (別 isolate)。そこで 本物の linux-worker.js を Node の worker_threads で動かす靴べらを書いた。selfpostMessagefetch(file:) を用意し、'__heap' というお願いだけ横取りして v8.getHeapStatistics() を返す。メイン側の真似 (LFB フレームを即 ack、X→Dillo→マウス 60Hz) をして 10 秒ごとに採る。

// worker-shim.mjs — ブラウザの Worker グローバルを用意して、本物を import する
globalThis.self = globalThis;
globalThis.postMessage = (m, t) => parentPort.postMessage(m, t);
globalThis.fetch = async (u) => new Response(readFileSync(fileURLToPath(u)), { headers: { 'content-type': 'application/wasm' } });
parentPort.on('message', (e) => {
  if (e.type === '__heap') { const h = v8.getHeapStatistics(); parentPort.postMessage({ type: '__heap', used: h.used_heap_size }); return; }
  globalThis.onmessage?.({ data: e });
});
await import(process.env.WORKER_URL);

結果は白黒がはっきりした。

  • JIT off: 6.5MB で一定 (6 分、RAM 1024MB でも同じ) — フレーム経路も背圧もメインスレッドも白
  • JIT on: +0.45MB/s で伸び続ける — 黒

JIT の帳簿を横に並べると、決定的な数字が出た。

+20G heap=814MB installed=807403 baked=1997137 recycled=1189734 table=807848

installed=807403。JIT のブロック台帳は 64K 枠の直接マップなので、据え付け済みが 80 万になることはあり得ない。帳簿が壊れている

原因: 関数テーブルが伸びる一方

wasm の JIT (F1d) は、ゲストのブロックを焼いて小さな wasm モジュールにし、その export を core の __indirect_function_tabletable.set する。core は添字を関数ポインタとして call_indirect で呼ぶ。据え付けは 4096 ブロックを 1 モジュールに束ねる (モジュールのコンパイル固定費のため)。

問題はその逆向き。退去 (スロットの衝突、自己書き換えによる世代落ち) は台帳から消すだけで、テーブルの添字を返していなかった。添字が関数を掴んでいる限り、V8 はモジュールを解放できない。1 モジュールは 4096 個の Code を抱えているので、1 ブロックの生き残りが 4095 個の死体を道連れにする。さらに「語彙外・短すぎ」の負の印 (Rejected) を書くとき、据わっていた別ブロックを退去なしで上書きしていた — これが installed の帳簿を壊し、添字を行方不明にしていた本丸である。

X + Dillo は 64K の直接マップを絶えず衝突させる。30G 命令で 260 万回焼き直し、テーブルは 80 万に伸び、V8 の表が尽きた。

関数テーブル — 前は grow する一方、後は添字を返し、null にし、疎なら引退

直し方

  1. 退去で添字を free list へ返し、据え付けは assign_slots() の添字へ。JS は足りない分だけ table.grow
  2. 退去した添字は pump の頭で table.set(s, null) — 使い回されるまで待たず、その場でモジュールを GC の手に渡す
  3. 生き残りが 1/8 を切ったモジュールは残りも退去させる (次の来訪で焼き直し)。1 つのホットブロックがモジュールを永遠に留めないように
  4. Rejected も同居人を退去してから上書き
// wasm/src/jit.rs — 退去 + そのモジュールの生き残りが 1/8 を切ったら残りも引退
fn evict(&mut self, si: usize) {
    let module = self.slots[si].module;
    let was_installed = self.slots[si].state == SlotState::Installed;
    self.evict_raw(si); // installed -= 1、添字を free_slots と freed へ、module.live -= 1
    if !was_installed { return; }
    let Some(m) = self.modules.get(module as usize).copied() else { return };
    if m.live > 0 && m.live * 8 <= m.size {
        for i in 0..JSLOTS {
            let s = self.slots[i];
            if s.state == SlotState::Installed && s.module == module { self.evict_raw(i); }
        }
        self.retired += 1;
    }
}

意味論には一切触らない添字の帳簿の話なので、JIT の決定性ゲート (on/off で命令数も出力もビット同一) はそのまま通る。

ワーカーの V8 ヒープ — 修正前は伸び続け、修正後は 65MB で一定

同じ負荷で 814MB → 約 65MB で一定、テーブルは 33,314 で固定、installed ≈ 31K。

振り返ると、最初の背圧は「害は無いが原因ではない」修正だった。症状からの推測は筋が通っていても、ダンプを読み、ヒープを採り、帳簿を並べるまで原因ではない。今回の収穫は修正そのものより、ブラウザのワーカーを Node で動かして数字を採る靴べらが手元に残ったことだと思う。

日本語が四角になる

Dillo で日本語のページを開くと文字が四角 (tofu) になる。IPAex は入れてある。icewm のタイトルバーは日本語が出る (Xft)。Dillo だけ出ない。

dillorcfont_sans_serif="IPAexGothic" と名指しすると、Dillo は preferred sans-serif font "IPAexGothic" not found と言う。fontconfig は知っている (fc-match は返す) のに、Dillo の存在確認が落ちる。Dillo のソースを読むと、FLTK の font 一覧 (Fl::set_fonts) と名前を突き合わせていた。では FLTK はどう名前を作るか — FcNameUnparse の「:」の手前をそのまま使う。IPAex の家族名は 2 つ登録されているので、FLTK の名前は IPAexGothic,IPAexゴシック (並びそのもの) になる。IPAexGothic は一覧に無い。

なら並びそのものを書けばよいか。試すと、それでも not found。ここで Dillo 側を追うのをやめ、fontconfig 側で「DejaVu Sans を頼まれたら IPAexGothic を返す」ことにした。Dillo の既定は DejaVu の名指しなので存在確認は通り、開くときの XftFontOpen で差し替わる。Xft を使う全員 (dillo / icewm / xterm -fa) に効き、Latin も IPAex に入っているので困らない。

<!-- /etc/fonts/local.conf -->
<match target="pattern">
  <test name="family"><string>DejaVu Sans</string></test>
  <edit name="family" mode="prepend" binding="strong"><string>IPAexGothic</string></edit>
</match>

Dillo に日本語のローカルページ — ゴシックと明朝

ついでに分かったこと: Dillo には JavaScript が無いので、Google は最小のページしか返さず検索結果は出ない。検索は html.duckduckgo.com/html/ か、w3m / links のほうが実用的である。

マウスの捕獲をやめる

前回のマウスは「画面をクリックすると捕獲し、Ctrl+Alt+Shift+G で解放」だった。相対移動を送っているので、X が加速を掛けてホストとゲストのカーソルがすぐズレる。ズレるから閉じ込める必要があり、閉じ込めるから脱出キーが要る。連鎖の根は加速である。

そこで相対デバイスのまま「絶対位置に見せる」:

  1. X 側の加速を切る (xorg.conf の evdev に AccelerationScheme none)。送った差分 = 動く画素
  2. 枠に入った瞬間とクリックの直前に、左上の角へ画面より多く押し込む (X は 0,0 で止める) → ホスト座標ぶん進める。これで揃う
  3. 以後は差分だけ送る。PS/2 の 1 パケットは ±255 までなので、超えたら分けて送る

捕獲をやめる — 相対しか送れない PS/2 を、加速を切って絶対位置風に

捕獲なし、脱出キーなし。canvas の上にカーソルがある間だけゲストへ届き、枠を出れば自然に抜ける。Esc はずっと vi のもの。ズレても次の入り直しかクリックで消える。

検証は wasm で X のカーソル先端を画面差分で測る: 6 点へ飛ばして先端の座標を読むと全部 ±1px。最初は 2/9 だった — 「(x, y) ぶん進める」を 1 パケットで送っていて、600 が 255 に切られていた。分割を入れて 9/9。ブラウザの実経路 (ansi.js に MouseEvent を流して canvas の画素差分) でも同じ結果。

// web/ansi.js — 枠に入った / 押す直前: 角へ押し込んでから座標ぶん進める
const resync = (p, buttons) => {
  const over = Math.max(canvas.width, canvas.height) + 255;
  send(-over, -over, buttons); // X は 0,0 で止める
  send(p.x, p.y, buttons);     // ±255 ずつに分けて送る
  sent = p;
};

本物の絶対デバイス (virtio-input tablet) は台帳に寝かせた。ゲームがカーソルを自分で飛ばしてズレが気になったら、同じ UI の下に差し替える。

次回

X の上で使えるものは揃った。次は DOOM か、ISO 起動 (ATAPI + El Torito) で Tiny Core / DSL をこの画面に出すか。

今回のコードは PR #211 (X のアプリ整理・JIT の関数テーブル・fontconfig・マウス)。前回は #3 PS/2 マウスと X

コメント

タイトルとURLをコピーしました