Androidの常駐サービスは、クラッシュせずに死ぬことがある
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
Android のポーリングループは pollOnce() の呼び出しだけを例外から守っていても、delay やバックオフ計算側で想定外の例外が出ると while(true) ごと静かに抜け、アプリはクラッシュせずに常駐サービスの中身だけが停止する。
結論
サービスは生きているのに、中身だけ死ぬことがある。
pollOnce() の呼び出しだけを例外から守っても足りない。delay() やバックオフ時間の計算で想定外の例外が出ると、while (true) ごと静かに抜けてループを回していたコルーチンが終了する。プロセスは生きていて、フォアグラウンド通知も出たままなので、クラッシュレポートには何も残らない。
対策は2段構え。ループ本体をまるごと例外から守って抜けられなくするのと、直近の成功時刻を見張ってプロセスの生死とは別に「仕事が止まったこと」を検知するウォッチドッグを足した。
症状
常時稼働のキオスク端末で、設定配信のポーリングだけが止まる。
- 端末本体は動いている。画面も出ている
- クラッシュログは無い。強制終了も再起動もしていない
- **同じ環境の端末なのに、一部だけ無音化する。**全滅ではないぶん気づきにくい
- 症状が出るタイミングも端末も読めない
Android の標準的な監視はプロセスが落ちたかどうかしか見ない。今回のようにプロセスは生きたまま、中の処理ループだけが終了するケースは、通常のクラッシュ検知の外側にある。
原因
ConfigPoller の定期ループは、pollOnce() の呼び出しだけを try/catch で囲っていた。
while (true) {
delay(nextDelayMs(failureStreak, lastResult))
lastResult = try {
pollOnce()
} catch (e: Throwable) {
// ここは守られている
}
failureStreak = updateStreak(failureStreak, lastResult)
}
pollOnce() から投げられる例外は拾える。だが**delay() の引数を計算する nextDelayMs() や updateStreak() 側で想定外の例外が出たときは無防備**だった。ここで例外が起きると try/catch の外側を突き抜け、while (true) を丸ごと抜けてループのコルーチンはそのまま終了する。
コルーチンが1つ終わっただけでは、Android はアプリをクラッシュとして扱わない。フォアグラウンドサービスの通知は残り、プロセスも生きている。ポーリングという「仕事」だけが、誰にも気づかれずに止まる。
直し方
3つの層で直した。
1. ループ本体をまるごと例外から守る
while (true) {
try {
delay(nextDelayMs(failureStreak, lastResult))
lastResult = try {
pollOnce()
} catch (e: Throwable) {
if (e is CancellationException) throw e
// ...
}
recordIfSuccess(lastResult)
failureStreak = updateStreak(failureStreak, lastResult)
} catch (c: CancellationException) {
throw c
} catch (e: Throwable) {
Log.w(TAG, "poll loop iteration error (continuing)", e)
}
}
delay からバックオフ計算まで、ループ1周分をまるごと try/catch に入れる。CancellationException だけはコルーチンの正常な停止信号なので再送出し、それ以外は握りつぶしてログに残すだけでループを続ける。while (true) は、明示的なキャンセル以外では二度と抜けなくなる。
2. 「生きているか」をプロセスの有無ではなく処理の成功時刻で測る
ループが成功するたびに、時刻を書く1行を足した。
private fun recordIfSuccess(result: PollResult) {
if (result == PollResult.SUCCESS) {
runCatching { Config.setLastPollSuccessMs(context, System.currentTimeMillis()) }
}
}
これを AlarmManager ベースのウォッチドッグが15分ごとに見る。setExactAndAllowWhileIdle を使うため Doze 中でも発火する。
if (pollStaleMs > STALE_THRESHOLD_MS &&
now - Config.lastForceRestartMs(context) > RESTART_COOLDOWN_MS
) {
Log.w(TAG, "poll stale ${pollStaleMs / 60_000}min -> force restart service")
Config.setLastForceRestartMs(context, now)
BleService.forceRestart(context)
}
**「サービスが存在するか」ではなく「直近20分でポーリングが成功したか」を見ている。**このため、1で直しきれなかった別の停止経路(OOM Killer・OEM の省電力機能など)が原因でループが止まった場合も、同じ仕組みで拾える。強制再生成には10分のクールダウンを入れ、再起動ループにはならないようにしてある。
3. 実際にクラッシュしたときの保険
上の2つは「クラッシュせずに止まる」経路への対策で、「クラッシュして落ちる」側にも別に手を入れた。Application に全スレッド共通の未捕捉例外ハンドラを仕込み、プロセスが落ちる直前に3秒後の復帰アラームを AlarmManager へ登録してから、既定のクラッシュ処理に委譲する。
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
runCatching {
Watchdog.scheduleRestart(this, 3000L)
}
val prev = previous
if (prev != null) prev.uncaughtException(thread, throwable)
else {
android.os.Process.killProcess(android.os.Process.myPid())
exitProcess(10)
}
}
AlarmManager に登録したアラームはプロセスが死んでも OS 側に残るため、プロセスがクラッシュで消えたあとにアラームが発火してサービスを起こし直す。加えて AlarmManager の正確アラームを OEM の省電力機能が握りつぶす端末もあるため、WorkManager の15分周期ジョブを独立した第2経路として同じ tick 処理に接続してある。
まとめ
止まる経路は3つあった。**①ループが例外で静かに抜ける、②プロセスごとクラッシュする、③そのどちらでもなく OS に殺される。**①はループ全体を守ることで塞ぎ、②と③は「クラッシュ後に自動復帰するアラーム」と「定期的な生存確認」の二重の網で受けている。
共通しているのは、Android の「サービスが生きている」という状態は「仕事をしている」を保証しないという前提で設計したことだ。プロセスの有無ではなく直近の処理成功時刻を見るようにして初めて、クラッシュしない停止も検知できるようになる。
同じ端末を長期運用したときの別の消灯パターンは FLAG_KEEP_SCREEN_ON を入れても画面が黒くなるのはスクリーンセーバ にまとめている。
よくある質問
Q1クラッシュレポートには何も残らないのですか?
残りません。コルーチンが1つ終了しただけでは Android はアプリをクラッシュとして扱わないため、通常のクラッシュ収集の対象外です。検知するには、プロセスの生死ではなく処理の成功時刻を別途記録して見張る必要があります。
Q2AlarmManager だけでは不十分なのですか?
OEM の省電力機能が正確アラームを握りつぶす端末があるため、AlarmManager 単独では復帰しないことがあります。WorkManager の定期ジョブを独立した第2経路として同じ tick 処理に接続し、片方が殺されてももう片方が拾えるようにしています。
Q3強制再起動が再起動ループを起こしませんか?
起こり得ます。そのため強制再生成には10分のクールダウンを設け、直前の強制再生成から10分以内は再実行しないようにしています。クールダウン中に再びstale判定が出ても、次のクールダウン明けまで待たせます。
Q4delay() やバックオフ計算のどこで例外が起きたのですか?
根拠のコードには具体的な発生箇所までは残っていません。重要なのは発生箇所の特定ではなく、pollOnce() 以外の区間が無防備だったという構造で、ループ本体全体を try/catch で囲むことでどこで起きても同じように守れるようにしています。
この記事の根拠
- Kotlinファイル 60〜114行目コミット db5d962
- Kotlinファイル 35〜62行目コミット db5d962
- Kotlinファイル 28〜56行目コミット db5d962
- Kotlinファイル 1〜69行目コミット 53aacf0
- Kotlinファイル 1〜141行目コミット db5d962
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。