Android HTTPUrlConnection EOFException 历史 BUG

目录 Linux, 开源, 无线和移动

这是一个影响 Android 4.1-4.3 版本的 HTTPUrlConnection 库 BUG,但只会在特定条件下触发。

我们有一个 Android App,通过多个并发 POST 连接上传数据到服务器,没有加入单个请求重试机制。在某些 Android 机型上发现一个诡异的 bug,在使用中频繁出现上传失败的情况,但是在其它机型上并不能复现。

经过较长时间的排查,我们找到了上传失败出现的规律,并认为它跟 HTTP Keepalive 持久化连接机制有关。具体的规律是:当 App 上传一轮数据后,等待超过服务端 Nginx keepalive_timeout 时间后,再次尝试上传数据,就会出现上传失败,抛出 EOFException 异常。

更准确的特征可以通过连上手机的 adb shell 观察 netstat:当 App 上传一轮数据后,可以观察到有 N 个到服务器的连接处于 ESTABLISHED 状态;当等待超过服务端 Nginx keepalive_timeout 时间后,可以观察到这 N 个到服务器的连接处于 CLOSE_WAIT 状态;当上传失败后,发现部分 CLOSE_WAIT 状态的连接消失。

Java 的 HTTP Keepalive 机制,一直是由底层实现的,理论上来讲,不需要应用层关心。但从上面的 BUG 来看,对于 stale connection 的复用,在部分 Android 机型上是有问题的。为此我稍微研究了一下 Android 的源代码,的确发现了一些问题。

在 2011年12月15日,Android 开发者提交了这样一个 Commit,Commit Message 这样写到:

Change the way we cope with stale pooled connections.

Previously we'd attempt to detect if a connection was stale by
probing it. This was expensive (it relied on catching a 1-millisecond
read timing out with a SocketTimeoutException), and racy. If the
recycled connection was stale, the application would have to catch
the failure and retry.

The new approach is to try the stale connection directly and to recover
if that connection fails. This is simpler and avoids the isStale
heuristics.

This fixes some flakiness in URLConnectionTest tests like
testServerShutdownOutput and testServerClosesOutput.

Bug: http://b/2974888
Change-Id: I1f1711c0a6855f99e6ff9c348790740117c7ffb9

简单来说,这次 commit 做了一件事:在修改前,是在 TCP 连接池获取连接时,做 connection isStale 的探测。Android 开发者认为这样会在获取每个 connection 时都有 1ms 的 overhead,所以改成了在应用层发生异常时,再重试请求。 但是这个重试有个前提,就是用户的请求不能是 ChunkedStreamingMode,不能是 FixedLengthStreamingMode,这两种模式下,底层无法重试。很不幸地是,我们正好使用到了 FixedLengthStreamingMode 带来的特性。

// Code snippet of: libcore.net.http.HttpURLConnectionImpl.java
while (true) {
  try {
    httpEngine.sendRequest();
    httpEngine.readResponse();
  } catch (IOException e) {
    /*
     * If the connection was recycled, its staleness may have caused
     * the failure. Silently retry with a different connection.
     */
    OutputStream requestBody = httpEngine.getRequestBody();
    // NOTE: FixedLengthOutputStream 和 ChunkedOutputStream
    // 不是 instance of RetryableOutputStream
    if (httpEngine.hasRecycledConnection()
        && (requestBody == null || requestBody instanceof RetryableOutputStream)) {
      httpEngine.release(false);
      httpEngine = newHttpEngine(method, rawRequestHeaders, null,
          (RetryableOutputStream) requestBody);
      continue;
    }
    httpEngineFailure = e;
    throw e;
}

由于 BUG 的根源在 Android 的核心库 libcore 中。这次改动影响了从 4.1 到 4.3 的所有 Android 版本, Android 4.4 网络库的 HTTP/HTTPS 从 libcore 切换到 okhttp,所以4.4以后的 Android 版本不受影响。

既然底层不重试,那么只有在应用层重试,所以我们在应用层增加了最多『http.maxConnections+1』次重试机制,以修复此问题。在重试的时候,尝试使用一个 stale connection 会导致 EOFException,底层也会自动关闭这个连接。『http.maxConnections+1』次重试保证即使连接池中全都是 stale connection,我们也能获得一个可用的连接。

网上应该也有人遇到过这个 BUG,所以我也在这个 StackOverflow 问题下做了回答

VirtualBox 还能这样玩

目录 Linux, 应用软件

在工作中经常遇到这样的情况:忽然发现开源界有了个新玩意儿,但是下载到自己电脑上一看,不支持我的操作系统,或者不支持这个版本的操作系统。只能老老实实下载某个版本的 Linux 安装镜像,然后开始安装配置虚拟机,等把环境都折腾得差不多了,已经忘了自己装系统来干什么了。

我曾经想过直接从网上寻找构建好的虚拟机镜像,最终发现并不容易。但我很遗憾没有早些遇见 Vagrant,因为它更进一步地满足了上述的需求。

Vagrant 能做什么呢?一句话来说,就是它用简单的命令封装了虚拟机创建、分发和配置的过程。如果把 VirtualBox 比作 dpkg、rpm,Vagrant 就是 apt-get、yum。用 Vagrant 可以非常简单地下载网上封装好的虚拟机镜像(叫做 box),然后启动起来,并且登录进去。配置虚拟机的端口转发等功能也变得非常容易,并且可以脚本化。

当然,Vagrant 也可以将配置好的虚拟机打包成 box 分发给别人。这就带来很大一个好处,那就是用它可以非常简单地实现团队开发环境的统一。创建一个 Linux 虚拟机,安装好必要的开发工具、运行环境,配置好代码仓库,打包成 box,然后再分发一个 Vagrantfile 支持启动虚拟机后执行一个脚本更新代码。新人只需要下载 Vagrantfile,然后 vagrant up。Bingo! 就可以 ssh 上去开发了。

当开发环境出现各种问题时,非常简单地用 vagrant 重新配置下即可。这大大避免了追查『在我的环境里执行没错啊!』这种问题的麻烦发生。

对于团队来说,开发环境的 box 还是自己打包更为合适,能够尽可能避免从网上下载的 box 带来的安全问题。不过对于普通用户来说,最酷的就是有各种来自官方或非官方现成的 box 可玩,不用痛苦地自己去一遍遍重装操作系统。比如以下几个:

虽然事实上 Vagrant 已经支持了 VMware 和 KVM,不过资源上就没有 VirtualBox 那么丰富了,大家有兴趣也可以尝试一下。

64位Ubuntu上使用Network Connect

目录 Linux, 应用软件

Network Connect 是 Juniper 公司出品的配合其安全硬件 VPN 解决方案的软件包,很多公司使用这个 VPN 解决方案,一般需要使用 RSA Token 动态密码登录。Network Connect 支持 Windows 和 Linux 操作系统,但很遗憾的是,它只支持 32 位 Linux。下面以 Ubuntu 为例,介绍如何

在 64 位 Linux 上安装并使用 Juniper Network Connect

1. 安装浏览器 Java 插件(64位);

$ sudo apt-get install icedtea-plugin

虽然看起来只安装了一个软件包,但实际上可能会下载依赖的 OpenJDK 的一系列软件包。这是为了在 Firefox 下能够正确启动 Network Connect 的安装过程。

2. 修改 root 密码;

$ sudo passwd

由于 Ubuntu 默认不使用 root 用户,下面自动安装 Network Connect 软件时候又必须提供 root 密码,所以这里必须先初始化 root 的密码。

3. 打开 Firefox,访问 VPN 网站。

像在 Windows 下那样,先登录进入 VPN 页面,再点击 start,启动 Network Connect 的自动安装过程。过程中会弹出一个很丑的终端,安装时需要输入 root 密码,但是最终必定是无法弹出 Network Connect 小图标,也连接不上。

4. 下载脚本 junipernc[1],并且安装到执行目录 /usr/bin 中;

$ wget http://mad-scientist.net/junipernc -O junipernc 
$ chmod 755 junipernc
$ sudo mv junipernc /usr/bin

5. 安装 Network Connect 二进制程序依赖的 32 位动态链接库;

NC 具体的可执行程序是 ~/.juniper_networks/network_connect/ncsv ,是 32 位的可执行程序。如果不安装它依赖的 32 位动态链接库[2],该程序是执行不了的。

$ sudo apt-get install libc6-i386 lib32z1 lib32nss-mdns

6. 执行 junipernc 脚本,会跳出各种对话框,对应填入各种参数;

$ junipernc --nojava

URL 就填入 VPN 网站的域名,USER 就是自己的用户名,REALM 比较麻烦,需要自己查看 VPN 网站登录页面的源代码,看对应 REALM 域实际表单提交的 value 是什么,填进去即可。

--nojava 的意思是,只执行 VPN 连接,不启动 Network Connect 小锁图标的 Java 程序。因为该 Java 程序要求 32 位 Java 环境。

连接失败会有提示;连接成功后,junipernc 会一直停在那里,终止连接可以使用 Ctrl-C 命令行,或者 sudo killall -9 ncsv。

6-1. 修改 junipernc 配置;

junipernc 有两个配置文件,一个是 ~/.vpn.default.cfg,保存着用户手工输入的配置;一个是 ~/.vpn.default.crt,这个是从网站上下载下来的证书。

这样,一般的 VPN 连接功能就实现了。如果希望启动 Network Connect 小锁图标并监控 VPN 的流量信息,就需要

在 64 位 Ubuntu 上安装 32 位 Java 环境[3]

如果不是特别需要,不建议折腾下面这套东西。

a. 到 Oracle 网站上下载 32位 Java 的 tar 包;

到这个地址:http://www.oracle.com/technetwork/java/javase/downloads/index-jsp-138363.html#javasejdk下载例如 jre-7u3-linux-i586.tar.gz 的 Linux JRE 包。重要的特征是那个 i586。

b. 解压并安装到 jvm 目录,调整默认 java 的链接到 32位 JRE。

$ tar xzvf jre-7u3-linux-i586.tar.gz
$ sudo mv jre1.7.0_03 /usr/lib/jvm
$ sudo update-alternatives --install /usr/bin/java java /usr/lib/jvm/jre1.7.0_03/bin/
java 73
$ sudo update-alternatives --config java

最后一个命令会给出一个 Java 的列表,如下所示,选择 jre1.7.0_03 对应的编号即可。

There are 2 choices for the alternative java (providing /usr/bin/java).

  Selection    Path                                      Priority   Status
------------------------------------------------------------
  0            /usr/lib/jvm/java-6-openjdk/jre/bin/java   1061      auto mode
  1            /usr/lib/jvm/java-6-openjdk/jre/bin/java   1061      manual mode
* 2            /usr/lib/jvm/jre1.7.0_03/bin/java          73        manual mode

Press enter to keep the current choice[*], or type selection number: 2

c. 验证 java 可以执行且版本正确(其实依赖上面的第 5 步);

$ java -version
java version "1.7.0_03"
Java(TM) SE Runtime Environment (build 1.7.0_03-b04)
Java HotSpot(TM) Server VM (build 22.1-b02, mixed mode)

d. 安装 32位 JRE 可能依赖的 32位动态链接库。

$ sudo apt-get install ia32-libs

这个包会依赖非常多的 32位链接库,安装过程会比较漫长。

参考文章:

[1] Using Juniper Network Connect on Ubuntu
[2] Debian6(64位)搞掂Juniper VPN
[3] Installing 32 bit java now that ia32-sun-java6-bin is not available