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 に修正が入れば差分を取り込んで再評価できます。「全部自分のものにする」のではなく「直したい層だけ薄く上書きする」フォークなら、個人開発でも十分維持できる規模に収まります。

教訓

例外で握れないクラッシュは、ハンドリングではなく構造で潰すしかありません。直列化・隔離・冪等な復帰。そして OSS の「親切なリカバリ」は疑ってかかる価値があります。善意こそが race の温床でした。