🐥
ReactNative iOSでBlobを含むfetchを実行すると稀にクラッシュする
ReactNativeのBlobについて
Blobの種類
ReactNativeのBlobは保持しているデータの種類(文字列, Array, バイナリデータ)によって3種類に分かれていると考えてよい
クラッシュが発生するのはバイナリデータを保持しているもので, 以下でBlobと書いている場合はバイナリデータを保持しているものを指す
どのようにバイナリデータを管理しているのか
実はBlobはバイナリデータを直接は保持しておらず, BlobIdというフィールドを保持しているだけである
バイナリデータはNative側のBlobManagerというクラスで保持されており, BlobIdというのはBlobManagerからバイナリデータを取り出すキーに当たる
JS側がNative側に処理を依頼する際, Native側にはBlobIdだけがパラメータとして受け渡され, Native側で必要に応じてBlobManagerからバイナリデータを取り出して処理を行っている
本題
正常時の流れ
- JS側: Blobを含むfetchを実行する
- Native側: fetch要求がキューに入れられる
- Native側: fetch要求がキューから取り出される
- Native側: fetch要求がBlobIdを含んでいる場合, BlobIdをキーとしてBlobManagerからバイナリデータを取り出す
- Native側: バイナリデータをNSURLSessionで送信する
クラッシュに至る流れ
- JS側: Blobを含むfetchを実行する
- Native側: fetch要求がキューに入れられる
- JS側: GCが実行されBlobがGCされる
- Native側: JS側でBlobがGCされたことを受け, BlobIdをキーとしてBlobManagerのバイナリデータを解放する
- Native側: fetch要求がキューから取り出される
- Native側: fetch要求がBlobIdを含んでいる場合, BlobIdをキーとしてBlobManagerからバイナリデータを取り出そうとするが, 既に解放されているためnilが返ってくる. 取り出そうとした側はnilが返ってくることを予期しておらずクラッシュする
ステップ3のような微妙なタイミングでGCが発生すると, アプリケーションの文脈上はBlobがまだ使用中にも関わらずバイナリデータが解放されている, という状態になってしまう
BlobのライフタイムはJS側とNative側で一致していなければならず, 本来ステップ4でバイナリデータを解放してはいけないのだが, GCがJS側のライフタイムしか意識していないため, それができていないのである
ワークアラウンド
これは簡単である
応答が返ってくるまで(Blobの送信が完了するまで)JS側でBlobがGCされないようにすればいい
// 駄目な例
function postImageNG(localImageUri: string) {
const blob = await (fetch(localImageUri)).blob();
return await fetch('画像アップロードAPIなど', {
method: 'POST',
body: blob,
});
}
// 良い例
function postImageOK(localImageUri: string) {
const blob = await (fetch(localImageUri)).blob();
const res = await fetch('画像アップロードAPIなど', {
method: 'POST',
body: blob,
});
console.log(blob); // 送信が完了するまで参照を解放しないためのダミー出力
return res;
}
補足と感想
- この不具合は0.59の頃に発見しており, 0.67でも発生する
- GCが頻発している状態でもなければ, よほど運が悪くない限り踏まない不具合だと思われる
- この不具合の調査のためにBlob周りのソースコードはかなり読み込んだのだが, 後付けのためか色々とこなれていない面が見られ, 頼り過ぎるべきではないなと感じた
- Androidではクラッシュはしないが, 0バイトのリクエストボディを送信したことになる
Discussion