Android の CI テストがたまに落ちるので戦った
前々回と前回の記事です。
結局 travis でも CircleCI でも確率的に落ちるじゃん的な話でした。
travis だと 1/2、 CircleCI だと 1/4 ぐらいで落ちてしまいます。さすがにこれだけ落ちると鬱陶しいのでなんとか直したいところ。
ということで直すために頑張った話です。
状況
com.android.builder.testing.api.DeviceException: com.android.ddmlib.InstallException: Unable to upload some APKs
や
com.android.builder.testing.api.DeviceException: com.android.ddmlib.InstallException
といったもので、その元凶は
com.android.builder.testing.api.TestException: com.android.builder.testing.api.DeviceException: com.android.ddmlib.ShellCommandUnresponsiveException
です。
その1 - ADB_INSTALL_TIMEOUT
を伸ばす (効果: 弱)
ADB_INSTALL_TIMEOUT
は、APK をインストールときの shell の最大の待ち時間を決定する環境変数です。下のようにテスト時によばれています。
String installTimeout = System.getenv("ADB_INSTALL_TIMEOUT");
ddmlib/src/main/java/com/android/ddmlib/Device.java - platform/tools/base - Git at Google
ShellCommandUnresponsiveException
で検索すると大抵コレを伸ばせと主張されているのですが、そもそも自分のアプリは 3MB 程度のためインストールする時間が足りないわけではなさそうです。なのでそれをやってもあんまり変わりませんでした。
その2 - エミュレーターに積むメモリを増やす (効果: 無)
スペックが悪くて落ちてるのかと思ってエミュレーターにメモリを多く積むようにしてみましたが、全く関係ありませんでした。
その3 - sleep
する (効果: 中)
さらに調べているとどうやら Android のテストの内部で呼ばれている getDeviceConfig()
というのが失敗している模様。
getDeviceConfig()
でも shell の最大の待ち時間が設定されているわけですが、この待ち時間がテストのコードにハードコーディングされていました。
try { executeShellCommand("am get-config", receiver, 5, TimeUnit.SECONDS); return DeviceConfig.Builder.parse(output); } catch (Exception e) { throw new DeviceException(e); }
issue はこちらです。
この待ち時間が5秒と短いため、動作の遅い arm のエミュレータではよく落ちる、ということでした。
blame してみると Android Gradle Plugin 1.2.0 頃から追加されたもののようです。そこまでバージョンを戻すわけにもいかないので、対策として wait-for-boot
の後に
sleep 30
をして起動後しばらく落ち着くのを待つようにしてみたとろ、fail するのが 1/4 から 1/7 ぐらいになりました!!ちなみに sleep
を 90 ぐらいにしたら 1/10 ぐらいになりました。
なんとも言えない方法ですね...
その4 - Android Gradle Plugin
のバージョンを下げてみる (効果: 大)
1.3.1 を使っていたのですが、上記の辺りのメソッドの呼び出し方が 1.2.3 と 1.3.0 で変化していたので試しに 1.2.3 にバージョンを戻してみました。
その結果... 1/10 ぐらいだったのが 1/20 くらいになりました!!
最近 Android Gradle Plugin 1.5.0 が出たとのことですがまだそれは試していません。
その5 - リトライする (効果: 大)
といった感じで 1/20 ぐらいまで減らせたわけですが、そのために古い Android Gradle Plugin 使うのは嫌だし、sleep 90
するのも時間かかるからあまりよくない。
最終的にはリトライして fail を減らすことにしました*2。
そして苦闘の末できた test.sh
がこちらです。
#! /usr/bin/env bash c=0 n=0 port=5554 until [ $n -ge 3 ] do c=0 case $CIRCLE_NODE_INDEX in 0) export ANDROID_VERSION=22 ;; 1) export ANDROID_VERSION=19 ;; 2) export ANDROID_VERSION=17 ;; 3) ANDROID_VERSION=15 ;; esac nohup bash -c "$ANDROID_HOME/tools/emulator -avd test$ANDROID_VERSION -no-skin -no-boot-anim -no-audio -no-window -port $port &" circle-android wait-for-boot sleep 30 n=$[$n+1] echo $n test starting... ./gradlew --info --stacktrace clean :app:connectedAndroidTest && break c=$? echo $n test errored. adb emu kill echo kill | nc -w 2 localhost $port port=$[$port+2] sleep 10 done echo tested $n times. exit $c
(shell初心者なのでアレアレです...)
ポイント
until [ $n -ge 3 ]
回すごとにnを足していき合計 2回ループするようにしています。
nohup bash -c "$ANDROID_HOME/tools/emulator -avd test$ANDROID_VERSION -no-skin -no-boot-anim -no-audio -no-window -port $port &"
バックグラウンドでエミュレータを起動しています。同じエミュレータを使うとうまくいかないことが多いので失敗するごとに毎回作りなおしています。
./gradlew --info --stacktrace clean :app:connectedAndroidTest && break
成功したら break
してループを抜けるという次第です。
adb emu kill; echo kill | nc -w 2 localhost $port
adb emu kill
でエミュレータを list of devices
から殺せるのですが、それでもプロセスは残って CPU を食いまくるので echo kill | nc -w 2 localhost $port
で直接エミュレータに接続して kill
しています*3。後半だけでも十分かもしれない。
$port
一回一つのポートで起動するともう一回同じポートで起動できないので*4、毎回ポート番号を +2 しています。
exit $c
until
の中の exit code
は実際にループ抜けるときには反映されないので $c
に結果を格納して返しています。
...という非常にあたたかみのあるシェルスクリプトになりました。これで 2回ループしたところ失敗するのは劇的に減りました。まあ当たり前ですけど...
実際に 2回ループして最後に成功してるテストがこちらになります(job 3 の ./test.sh
の欄を見ていただくとわかると思います。ログが多くて重いので注意)。
https://circleci.com/gh/nonylene/PhotoLinkViewer/120
結論
上のように対策を組み合わせるとそれなりに落ちる確率を減らすことはできますが、それでも落ちるのは仕方ないです。
いろいろやってた間 API 22 以外では理不尽な fail は特になかったので、通常はこだわらず API 19 とかを使えばいいと思いました((