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 の温床でした。次に同じ症状を見たら、まずリカバリ処理を探します。