Unyablog.

のにれんのブログ

Android の CI テストがたまに落ちるので戦った

前々回と前回の記事です。

結局 travis でも CircleCI でも確率的に落ちるじゃん的な話でした。

travis だと 1/2、 CircleCI だと 1/4 ぐらいで落ちてしまいます。さすがにこれだけ落ちると鬱陶しいのでなんとか直したいところ。

ということで直すために頑張った話です。

状況

android-22 のみ落ち続けていて*1、エラー内容は

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);
}

build-system/builder/src/main/java/com/android/builder/testing/ConnectedDevice.java - platform/tools/base - Git at Google

issue はこちらです。

Issue 189764 - android - com.android.builder.testing.ConnectedDevice.getDeviceConfig has a hard coded timeout value that is too low - Android Open Source Project - Issue Tracker - Google Project Hosting

この待ち時間が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。後半だけでも十分かもしれない。

Issue 21021 - android - 'adb emu kill' command doesn't work on Windows - Android Open Source Project - Issue Tracker - Google Project Hosting

  • $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 とかを使えばいいと思いました((

*1:andorid-23はそもそも wait-for-boot が終わらないので使っていません

*2:つらい

*3:あんまりよくないけど、まあ使い捨てコンテナだし

*4:/tmp にいろいろファイルが残ってました