12 月 2 日になると iOS 11.0/11.1 が突然再起動を繰り返すようになった原因

何が起きていたのか?

既報の通り、12 月 2 日未明より一部の iOS デバイスが数十秒に一回再起動のような動作を繰り返し、操作不能になる OS の不具合が全世界で発生しています。Zaim もこの影響を受け原因調査と対策を実施しましたので、技術的に判明したことをこのエントリーで開発者向けに共有します。

なお、iOS 11.2 では解決していますので、まだアップデートしていない方は早急にアップデートをお願いします。

実際には OS が再起動していたわけではなく、SpringBoard と呼ばれるホーム画面などを管理している内部のアプリケーションがクラッシュを繰り返していました。
(時計アプリにも不具合があったようですが、こちらの現象は Zaim では確認できておりませんので、ここでは触れません)

なぜこのような状況になったのか?

Zaim アプリの場合 LocalNotification に「2017 年 12 月 1 日を開始日として、1 か月ごとに繰り返す通知」を登録した状態で 12 月 2 日を迎えた場合に SpringBoard がクラッシュを繰り返す状態になりました。

通知の登録手順については実にシンプルなもので、特に不自然なことはしていません。例えば「膨大な量の繰り返しを通知に組み込んだ」であったり「正常ではないデータを通知として登録した」といった、ロジック上の不備は発生していません。

具体的なサンプルコードは以下です。

let calendar = Calendar(identifier: .gregorian)
let timeZone = TimeZone.autoupdatingCurrent

guard let date = DateComponents(calendar: calendar, timeZone: timeZone, year: 2017, month: 12, day: 1).date
else { return }

let notify = UILocalNotification()
notify.alertBody = “alert”;
notify.alertAction = “view”
notify.repeatInterval = .month
notify.fireDate = date

UIApplication.shared.scheduleLocalNotification(notify)

上記のコードを iOS 11.0 または 11.1 をインストールした実機で実行すると、状況を再現できます。なお、アプリケーションを削除するか、設定から通知をオフにする以外に解決できませんのでご注意ください。

また、現象を再現するためのサンプルを作りました。以下よりお試しいただけます。

  • Github: ktakayama/NotificationCrash
    * サンプルを UserNotifications フレームワークにした方が望ましいかと存じますが、実際のプロダクションコードが UIKit のものだったので、これで説明しています。

なぜ 1 か月ごとの繰り返し通知を登録すると再起動を繰り返すのか?

アプリ開発者の方はご存知かと思いますが、App Store で提供されているサードパーティのアプリがどれほど工夫をこらしても iPhone を再起動させることは不可能です。不安定になるようなことをしたら、通常はそのアプリがクラッシュして終了です。

ではなぜ今回 、SpringBoard 自体がクラッシュする状態になったのでしょうか?

ここで、別の API の存在が重要となります。iOS 11.0, iOS11.1 では UNCalendarNotificationTrigger クラスの動作に問題があったことがわかっています。

UNCalendarNotificationTrigger は幸いパブリックな API なので、ドキュメントもあります。iOS の標準機能のひとつで、iOS 10 から使える新しい UserNotification フレームワークで通知を登録する際に利用します。

この中に、通知を予約するために「次の通知の日時はいつか」を取得する nextTriggerDate() というメソッドがあります。正常に動作している場合は日時が返却されるだけなのですが、12 月 2 日に以下のコードを実行すると、なぜか無限ループに陥ってるようで、結果が返って来なくなります。

let dateComponents = DateComponents(day: 1)
let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true)
trigger.nextTriggerDate()

以下はシミュレータで実行した場合のデバッグ情報ですが、CPU は常に 100%近くでメモリは急激に上昇していき、すぐに数 GB に到達します。モバイル端末の場合はメモリはそれほど多くないので、このコードを実行したらすぐにクラッシュしてしまいます。

推測を交えた結論

繰り返しのローカル通知は一度登録すれば、何もしなくても OS が勝手に指定期間に繰り返して通知を発行してくれます。つまり、個別のアプリでは nextTriggerDate() は実行していません。この部分は OS で担当しています。

推測ですが、SpringBoard では以下の内容で処理しているのではないでしょうか。

  1. 一定時間毎に該当時間の通知があれば通知を表示する
  2. 繰り返し通知がある場合は、次の通知の予約をするために nextTriggerDate() を実行する

すでに説明した通り、nextTriggerDate() を実行すると CPU 負荷が高まりメモリが圧迫されます。この処理は OS (正確には SpringBoard)が実行しているがために、ホーム画面がリスタートを繰り返してしまう結果になります。

なぜ nextTriggerDate がダメなのか?

これについては調査が完了していません。一部では macOS High Sierra で発生しているエラー「 Month 13 is out of bounds」に関連しているのではないかという憶測もありますが、確証には至っておりません。

単純に 1 か月後の日付を取得するだけなら Calendar クラスのメソッドを使うと正常に動作するのでもっと別の仕組みを使っているか、気付いてないだけで別の問題があるのかもしれません。何かわかれば、こちらのエントリーに追記します。

let calendar = Calendar(identifier: .gregorian)
let timeZone = TimeZone.autoupdatingCurrent
let date = DateComponents(calendar: calendar, timeZone: timeZone, year: 2017, month: 12, day: 1).date
calendar.date(byAdding: .month, value: 1, to: date!) // ← 正常に取得できる