写一个正确的状态机有多难

JDK 9+ Argument File bug一则

Posted by Bo on November 13, 2018

最近给JDK修了一个存在3年的bug。故事的起因是这样的,我们的一个测试在Windows下会莫名其妙地卡住,但是如果稍微把项目文件夹改个名字,增加或者减少一个字符(比如说从gradle改成gradle1或者gradl),这个测试就如丝般顺滑。

俗话说,能重现的bug都是好bug。一番调试之后,发现问题似乎出在JDK本身。

故事要从Argument File说起,这玩意的中文名是啥我也不知道,就不翻译了。大家都知道,Java世界里的一切最终都会变成java命令行的调用。在Windows上,大概长这样:

java -Xmx2g -cp "C:\\1.jar;C:\\2.jar" Main doSomething

如果你有一个很大的应用,引用了成百上千个jar包,那么你的classpath可能会特别长,长到几十K都装不下。然而,Windows万分悲痛地告诉你,不好意思,我的命令行长度是有限制的,不能超过8191个字符。

你:……

1

以前,大家对此的解决方案一般是,创建一个空jar包,然后把这个巨型的classpath写到manifest文件里,然后引用这个空jar包即可:https://docs.oracle.com/javase/tutorial/deployment/jar/downman.html 。Bazel就是这么干的

JDK 9之后,官方给了一个解决方案:Argument Files

简而言之,你不是有个巨型无敌长的classpath么?不用直接传到命令行里了,先把这个巨型的参数-cp "C:\\1.jar;C:\\2.jar;...;C:\\10000.jar"写到一个文件里,比如说叫classpath.txt,然后直接用java @classpath.txt ...就行了,java命令会自动帮你展开。要注意的是,不仅是classpath,任何被java接受的参数都可以用这种方式传递。

坏就坏在Argument File的处理上。我一步一步研究上面遇到的那个诡异bug,最终找到了一个可以复现的样例。在特定情况下,Argument File的读取会出现问题。比如说,如果你有一个Argument File,内容是:

-cp ".;C:\\app.jar" Main

我们假定Main类存在于app.jar中,因此一切正常。接下来,我们逐渐向双引号中增加.;

-cp ".;.;.;.;.;.;.;.;.;.;.;.;.;.;.;.;.;.;.;.;C:\\app.jar" Main

你会发现,当这个文件增长到一定长度(大约4100字节)时候,突然,java告诉你:

NoClassDefFoundError

怀着困惑的心情,我提交了JDK-8210810。随后,我下载了OpenJDK的源代码,连上宇宙第一IDE,开始了debug之路。

在JDK的实现中,Argument File的处理是一个状态机:

/*
       [\n\r]   +------------+                        +------------+ [\n\r]
      +---------+ IN_COMMENT +<------+                | IN_ESCAPE  +---------+
      |         +------------+       |                +------------+         |
      |    [#]       ^               |[#]                 ^     |            |
      |   +----------+               |                [\\]|     |[^\n\r]     |
      v   |                          |                    |     v            |
+------------+ [^ \t\n\r\f]  +------------+['"]>      +------------+         |
| FIND_NEXT  +-------------->+ IN_TOKEN   +-----------+ IN_QUOTE   +         |
+------------+               +------------+   <[quote]+------------+         |
  |   ^                          |                       |  ^   ^            |
  |   |               [ \t\n\r\f]|                 [\n\r]|  |   |[^ \t\n\r\f]v
  |   +--------------------------+-----------------------+  |  +--------------+
  |                       ['"]                              |  | SKIP_LEAD_WS |
  +---------------------------------------------------------+  +--------------+
*/

JDK每次从Argument File中读取4096字节的数据到缓冲区中,这个状态机就随着文件中Token的变化进行状态转换。然鹅,在某种特定情况下,这个状态机的某个状态转换会出现问题,这种特定情况是什么呢?

答案是,两个连续的转义字符\\被缓冲区分割开,即:

1

找到了原因,修复其实就是一行代码的事:

--- a/src/java.base/share/native/libjli/args.c	Fri Sep 28 13:01:28 2018 -0700

+++ b/src/java.base/share/native/libjli/args.c	Fri Sep 28 13:15:01 2018 -0700

@@ -263,6 +263,8 @@
                 }
                 JLI_List_addSubstring(pctx->parts, anchor, nextc - anchor);
                 pctx->state = IN_ESCAPE;
+                // anchor after backslash character

+                anchor = nextc + 1;

                 break;
             case '\'':
             case '"':

翻了下Mecurial日志,这个问题从2015年起就存在了,因为修bug的时候JDK11刚刚发布,所以按照Oracle的尿性,这个fix不会被backport到10上。Workaround很简单,把所有的反斜杠\换成/就行了。

这个故事告诉我们,别迷信JDK,其实他们的开发者和我们一样,也在天天写bug啊。