try/catch では捕まえられないクラッシュ — OSS オーディオプラグインをフォークして根治するまで
| 開発記録 | オーディオ
タグ: #iOS #オーディオ #OSS #Swift #個人開発
楽器系アプリで SIGSEGV のネイティブクラッシュが多発しました。犯人は OSS プラグインが iOS の構成変更通知の中で「親切に」オーディオエンジンを再起動していたことです。フォークを決めて、直列化キューと 500ms の隔離期間と panic CC で根治するまでの記録です。
Dart でも Swift でも捕まえられないクラッシュ
運用中の楽器系アプリで、あるバージョンからネイティブクラッシュが多発しはじめました。レポートに残るのは、オーディオ基盤のネイティブ層(C++)での SIGSEGV だけ。ログを開いた瞬間、これはしんどいやつだと思いました。
Dart の try/catch は当然きません。Swift 側でエラーハンドリングを足しても意味がありません。例外機構より下の層、C++ のメモリ破壊で落ちているからです。
音源再生には OSS の Flutter プラグインを使っていました。調べていくと、原因はプラグイン内部の「善意の実装」にありました。
犯人は「親切なリカバリ処理」だった
iOS はイヤホンの抜き差しやサンプルレート変更で、AVAudioEngine の構成変更通知を発火します。このとき OS は内部のディスパッチキューで Stop → Uninitialize → リソース解放を進めます。
問題のプラグインは、この通知を受け取ったハンドラの中で、メインスレッドから engine.start() を呼んで復旧を試みる実装でした。親切心です。でも OS が破棄処理を進めている最中に初期化をぶつけます。タイミング次第でサンプラーの内部状態が壊れて SIGSEGV になる。通知ハンドラ内でオーディオグラフを触ってはいけない、という iOS オーディオの鉄則に真正面から反していました。
upstream への修正提案も考えました。ただ変更規模が大きく、自アプリでの検証を先にしたかった。フォークして根本から作り直すことにしました。
三段構えの対策
1. 専用シリアルキューへの直列化。 メソッドチャネルの全呼び出しとエンジン操作を 1 本の serial queue に集約しました。メインスレッドと通知ハンドラがエンジン上で並走することを、物理的に不可能にします。
2. 隔離期間(quarantine)。 構成変更通知が来たら「今から 500ms は OS が暴れている時間」とみなします。その間は一切グラフを触りません。通知ハンドラでは絶対に start() しない。復旧はクールダウン後に予約実行します。クラッシュをがんばって避けるのではなく、危険な時間帯には何もしないという割り切りです。
3. 復帰時の辻褄合わせ。 再起動は 0→50→150→400→800→1500ms の段階的バックオフでリトライします。再初期化でノート状態が失われて音が鳴りっぱなしになる問題も出ました。これは全チャンネルへ MIDI の All Sound Off (CC#120) と All Notes Off (CC#123)、いわゆる panic を送って解消しています。
地味に効いたのが値のクランプです。Swift の UInt8(Int) は範囲外の値で即 trap(クラッシュ)します。外部から来る MIDI 値を 0〜127 に丸める 1 行のヘルパーを足したら、もう一系統のネイティブクラッシュがそのまま消えました。
フォークの維持コストについて
フォークは根治の代償として保守を背負い込みます。だから今回は、影響範囲を iOS の 1 ファイルと Dart の API 追加に絞りました。Android 側は upstream のまま無改変です。
パッケージ名とバージョンも upstream に合わせてあります。本体アプリからは path 参照を切り替えるだけで済みますし、upstream に修正が入れば差分を取り込んで再評価できます。「全部自分のものにする」のではなく「直したい層だけ薄く上書きする」。このくらいのフォークなら、個人開発でも維持できます。
教訓
例外で握れないクラッシュは、ハンドリングではなく構造で潰すしかありません。直列化・隔離・冪等な復帰。この 3 つでやっと止まりました。
そして OSS の「親切なリカバリ」は疑ってかかる価値があります。善意こそが race の温床でした。次に同じ症状を見たら、まずリカバリ処理を探します。