ReactNative iOSでBlobを含むfetchを実行すると稀にクラッシュする


ReactNativeのBlobについて

Blobの種類

ReactNativeのBlobは保持しているデータの種類(文字列, Array, バイナリデータ)によって3種類に分かれていると考えてよい
クラッシュが発生するのはバイナリデータを保持しているもので, 以下でBlobと書いている場合はバイナリデータを保持しているものを指す

どのようにバイナリデータを管理しているのか

実はBlobはバイナリデータを直接は保持しておらず, BlobIdというフィールドを保持しているだけである
バイナリデータはNative側のBlobManagerというクラスで保持されており, BlobIdというのはBlobManagerからバイナリデータを取り出すキーに当たる
JS側がNative側に処理を依頼する際, Native側にはBlobIdだけがパラメータとして受け渡され, Native側で必要に応じてBlobManagerからバイナリデータを取り出して処理を行っている

本題

正常時の流れ

  1. JS側: Blobを含むfetchを実行する
  2. Native側: fetch要求がキューに入れられる
  3. Native側: fetch要求がキューから取り出される
  4. Native側: fetch要求がBlobIdを含んでいる場合, BlobIdをキーとしてBlobManagerからバイナリデータを取り出す
  5. Native側: バイナリデータをNSURLSessionで送信する

クラッシュに至る流れ

  1. JS側: Blobを含むfetchを実行する
  2. Native側: fetch要求がキューに入れられる
  3. JS側: GCが実行されBlobがGCされる
  4. Native側: JS側でBlobがGCされたことを受け, BlobIdをキーとしてBlobManagerのバイナリデータを解放する
  5. Native側: fetch要求がキューから取り出される
  6. 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バイトのリクエストボディを送信したことになる