17字节锁死Netty解析线程:一个assert如何变成永远不返回
CVE-2026-63202
不靠超大请求吃光内存,也不需要把服务器塞满连接。它利用一个极小的Binary HTTP消息,让解析循环既无法归零,也无法前进,最终把Netty事件循环线程钉在100% CPU。

先给结论。
受影响组件是Netty孵化项目中的Binary HTTP编解码器:
io.netty.incubator:netty-incubator-codec-bhttp
漏洞编号是CVE-2026-63202。
GitHub编号是GHSA-4899-mpch-38p3。
受影响版本为0.0.22.Final及更早版本。
首个修复版本为0.0.23.Final。
CVSS 3.1基础分是7.5。
影响集中在可用性,不是数据读取或篡改。
这项漏洞进入GitHub全局公告流的时间是2026年8月20日18:43 UTC。
换算成北京时间,是8月21日02:43左右。
但补丁和0.0.23.Final标签在7月1日已经存在。
所以今天的准确说法是“漏洞进入全局公告流”,不是“今天刚修复”。
攻击者还需要让恶意Binary HTTP内容到达这个解析器。
如果系统没有使用相关BHTTP/OHTTP组件,或者不接收不可信的BHTTP输入,就不在同一攻击面上。
可是在OHTTP网关场景里,这个前提并不等于“攻击者先要有账号”。
OHTTP网关本来就会发布用于加密请求的公钥配置。
任意客户端都可以使用公钥构造一个密码学上合法的OHTTP请求。
网关解密后,内部的BHTTP明文仍然由客户端控制。
研究公告展示的测试消息只有约17字节。
它没有申请巨量内存。
没有发送几GB数据。
没有等待超时。
它只是让解析器掉进一个永远不满足退出条件、每一轮又不消耗任何新字节的循环。
这才是整项漏洞最值得研究的地方:
任何处理不可信输入的循环,都必须证明两件事——状态会向终点前进,异常时会在有限步骤内失败。
这个解析器两件事都没有做到。
一、先别把两个相邻的死循环混成一个
北京时间8月21日,同一组件有多份安全公告几乎同时进入GitHub全局信息流。
其中至少有两个都与BinaryHttpParser的无限循环有关。
一个是本文的CVE-2026-63202。
它对应损坏的field section长度、负数剩余计数和零进度循环。
另一个是CVE-2026-63124。
它对应“一个完整字段恰好在已知长度field section边界结束”的边界判断问题。
两个漏洞都能让解析线程不返回。
两个漏洞都与生产JVM默认不启用assert有关。
两个漏洞都在0.0.23.Final得到修复。
但它们不是同一个输入条件。
本文标题里的“17字节”,来自CVE-2026-63202修复提交新增的回归测试。
测试把17个字节包装进ByteBuf,期望解析器抛出CorruptedFrameException。
它验证的是损坏field section应该快速失败,而不是精确边界上的合法字段应该正常解析。
为什么要先分清?
因为安全修复不是只看“有没有从死循环里出来”。
一个输入本来合法,就应该被正确接受。
另一个输入在长度关系上损坏,就应该被明确拒绝。
如果把两者混为一谈,很容易用“一律报错”掩盖解析器边界仍然不正确。
二、OHTTP、BHTTP和Netty分别站在哪一层
先用三句话建立位置关系。
OHTTP,也就是Oblivious HTTP,解决的是HTTP消息经过中继和网关转发时的隐私分离问题。
Binary HTTP,也就是BHTTP,是RFC 9292定义的HTTP消息二进制表示。
Netty则是Java生态里广泛使用的异步网络框架;这个孵化项目为Netty提供OHTTP和BHTTP编解码能力。
一条OHTTP请求并不是简单地把普通HTTP文本直接丢进加密算法。
按照RFC 9458描述的结构,客户端会构造封装后的请求,使用网关公开的HPKE配置保护消息。
中继看到客户端连接,却不必知道最终消息内容。
网关解密内容,再处理内部请求。
在这个Netty实现里,OHTTP解密后的内容会进入BHTTP解析器。
因此,外层加密与内层输入安全是两件不同的事。
加密能够保证内容在传输过程中不被中间人随意看到或修改。
它不能保证发送者构造的明文本身符合解析器所有长度关系。
事实上,OHTTP允许陌生客户端使用公钥加密,正是协议可用性的一部分。
如果网关只接受自己事先知道的几个客户端,很多隐私代理场景就失去了意义。
所以“HPKE解密成功”只说明密文结构和密码学处理通过。
它不等于“里面的BHTTP一定善良”。
三、攻击输入怎样从外层密文走到内层解析器
公告不仅指出了有问题的循环,还把可达路径追到了OHTTP编解码器。
在OHttpRequestResponseContext.ContentDecoder#decodeChunk中,代码先分配一个decryptedChunk。
随后调用解密逻辑。
解密后的数据被累积进binaryHttpCumulation。
紧接着,代码调用:
binaryHttpParser.parse(binaryHttpCumulation, completeBodyReceived)
这条路径中间没有业务控制器帮忙重新解释字段长度。
也没有应用层路由先判断“这个用户能不能访问某个资源”。
解析发生在业务逻辑之前。
因此,漏洞影响可以概括为:
一个不要求进入业务授权阶段的远程可用性问题。
攻击者首先构造一个能够被网关公钥正确解封的OHTTP请求。
网关完成HPKE处理。
随后,攻击者控制的BHTTP明文进入解析器。
解析器根据framing indicator进入已知长度请求头状态。
读完request control data后,它进入field section解析。
死循环就发生在那里。
四、Binary HTTP为什么需要“field section长度”
传统HTTP/1.x报文里,读者最熟悉的是一行行文本头。
Binary HTTP不是把这些文本换个字体。
它用二进制结构表达控制数据、字段区和内容。
RFC 9292为请求和响应定义了已知长度与不定长度等不同framing方式。
在已知长度消息中,解析器会先知道某一段占多少字节。
field section可以理解为一组HTTP字段的二进制集合。
每条field line又包含:
字段名长度。
字段名。
字段值长度。
字段值。
解析器需要同时处理两个尺度。
外层告诉它:整个field section声明有多少字节。
内层每解析一条field line,会实际消耗若干字节。
正常情况下,所有字段行消耗的总字节数,应该恰好等于外层声明的field section长度。
于是可以维护一个“剩余长度”:
初始值等于声明长度。
每成功读完一条field line,就减去本轮实际消耗。
剩余长度归零,循环结束。
这套思路本身没有问题。
问题在于代码如何处理三个异常:
实际字段比声明长度更长。
剩余数据不足以组成完整字段。
解析函数一轮没有消耗任何字节。
旧代码把这些异常交给了两条assert。
而生产环境里,那两条assert通常根本不存在。
五、旧循环只有一个退出条件:必须“恰好等于0”
0.0.22.Final的核心循环可以用伪代码表达:
while (fieldSectionLength != 0)
进入循环后,它先记录当前可读字节数。
然后调用readFieldLine()。
回来后,再用调用前后的可读字节数差值,计算本轮消耗了多少字节。
最后从fieldSectionLength中减去消耗值。
正常数据的轨迹可能是:
剩余8字节。
读掉4字节。
剩余4字节。
再读4字节。
剩余0字节。
退出。
恶意输入的第一层破坏,是让声明长度小于一条实际字段消耗的长度。
例如,外层声称“这一段只剩1字节”。
内层字段行却成功消耗了更多字节。
相减后,剩余长度不再是0。
它会变成负数。
而循环条件写的是“不等于0”。
负数当然也不等于0。
所以循环不会因为越界而停下。
如果条件只是> 0,负数至少会离开循环。
但仅改成> 0也不够安全。
因为“实际消耗超过声明长度”本身就说明消息损坏。
正确做法不是静默接受越界后继续解析。
而是抛出受控的协议错误。
六、负数计数还不是死循环,真正锁死的是“零进度”
剩余长度变成负数,只能解释为什么退出条件不成立。
它还没有解释为什么循环不能继续消耗数据,最终走到其他错误。
第二个缺陷在readFieldLine()。
当剩余缓冲区不足以组成一条完整field line时,这个函数会返回null。
在这些返回路径上,它不会推进readerIndex。
也就是不会消费任何字节。
外层循环再次计算前后可读字节差。
得到0。
于是它从一个已经为负数的fieldSectionLength里减去0。
结果仍然是同一个负数。
下一轮开始时:
输入缓冲区没变。
readerIndex没变。
剩余长度没变。
解析状态没变。
readFieldLine()再次看到同一段不完整输入。
再次返回null。
再次消耗0字节。
这就构成了严格意义上的固定点。
系统每一轮都执行代码、消耗CPU,却没有任何状态向前移动。

七、两条assert看见了问题,却没有保护生产环境
旧循环里其实存在两个断言。
第一条断言要求lastType != null。
第二条断言要求本轮read > 0。
这说明原作者知道一个成功循环迭代应该读出字段类型,并且必须消耗字节。
在测试环境里,如果启用Java assertions,恶意输入可能迅速触发AssertionError。
这会让开发者误以为“异常已经被发现”。
但Java的assert不是默认开启的运行时输入校验。
普通生产JVM如果没有使用-ea等选项,断言表达式不会提供那道保护。
外部不可信输入却不会因为生产环境关闭assert而变得更规整。
于是测试中的“立刻报错”,变成生产环境的“永远循环”。
这是一个很常见、也很危险的边界错位:
断言适合表达开发者认为内部永远成立的不变量,并帮助开发阶段暴露编程错误。
协议解析器面对的是攻击者可以主动破坏的不变量。
这种条件必须由普通运行时代码检查。
失败时应该抛出受控、可分类、可观测的解码异常。
不能依赖一个默认关闭的开发辅助机制。
更不能把开启-ea当成正式修复。
开启assert可能让这条具体输入不再死循环。
但它抛出的是AssertionError,并不等同于经过设计的协议错误处理。
它也没有解决声明长度和实际消费必须一致的完整校验问题。
八、为什么17字节比17MB更麻烦
很多拒绝服务防护围绕“大”来设计。
限制请求体大小。
限制Header数量。
限制连接并发。
限制单IP带宽。
限制内存分配。
这个漏洞偏偏很小。
修复提交中的回归输入只有17字节。
公告里的受控测试显示,解析线程在数秒观察窗口内持续运行,CPU时间接近墙上时间。
这说明它不是在等锁、等网络或等磁盘。
它在忙循环。
一个极小请求穿过常见的大小限制几乎没有压力。
8KB的maxFieldSectionSize也拦不住它。
因为解析器先检查的是攻击者声明的field section长度。
该值可以非常小。
CPU消耗发生在一个固定的小缓冲区上。
不会因为内存增长触发上限。
请求越小,还越容易隐藏在普通流量中。
传统带宽图可能几乎没有异常。
入口QPS可能也不高。
但少量事件循环线程会一个接一个停止处理其他channel。
九、为什么一个线程卡死,会拖住许多连接
Netty的event loop不是“每个连接一个线程”。
一个事件循环线程通常负责一组channel上的I/O事件和处理任务。
这是Netty高并发能力的重要来源。
线程不用在海量连接之间频繁创建和销毁。
但这也带来一条硬规则:
事件循环上的处理必须快速返回。
如果某个解码器在event loop线程中永远不返回,这条线程负责的其他连接也无法获得正常调度。
它们可能表现为:
已经建立连接却没有响应。
读写事件延迟不断上升。
健康检查超时。
上游开始重试。
重试又给剩余线程带来更多压力。
负载均衡器继续把流量送给一个进程仍在、端口仍开、但工作能力快速丧失的实例。
Netty事件循环组的线程数量有限,具体由实现和配置决定。
攻击者不需要制造与正常并发同规模的请求。
只要能逐个占住有限的关键线程,就可能让网关整体离线。
公告把这一影响评为可用性高。
没有把保密性和完整性列为已证实影响。
十、为什么普通超时未必能把它救出来
有人会想到请求超时。
例如,五秒没处理完就关闭channel。
问题是:谁来执行超时回调?
如果超时任务本身需要同一个event loop线程调度,而该线程已经困在解析循环里,超时逻辑就没有机会运行。
这与异步系统里“设置了超时”不等于“任何CPU死循环都能被超时中断”是同一个问题。
外部看门狗、进程级健康检查或独立调度线程可能发现实例失去响应。
它们可以触发摘流或重启。
但重启只是恢复手段。
攻击输入仍然可以再次到达新实例。
如果所有副本使用同一易受影响版本,自动重启甚至可能形成抖动:
实例上线。
接收恶意请求。
线程被锁死。
健康检查失败。
实例重启。
再次上线。
因此,修复解析器仍然是根本措施。
十一、为什么速率限制也不是单独答案
速率限制当然有价值。
它可以限制单个来源的请求频率,减缓重复触发。
但这项漏洞的单请求成本极不对称。
客户端只发送极小消息。
服务端线程却可能永久忙循环,直到进程重启。
即使一个来源每分钟只允许少量请求,也可能慢慢占满事件循环组。
攻击流量还可能来自多个地址或经过中继。
OHTTP场景的架构目标本身也可能降低网关对原始客户端身份的可见度。
所以速率限制应该是补偿控制。
它不能替代版本升级。
更不能替代解析器内部的进度不变量。
十二、为什么WAF很难看见内层那17字节
OHTTP外层内容经过HPKE保护。
位于解密之前的普通Web应用防火墙看到的是OHTTP封装和密文,而不是里面的BHTTP字段关系。
它可以检查总长度、Content-Type、请求频率和一些外层协议条件。
但它不能在不知道解密上下文的情况下判断:
field section声明长度是否与实际字段消费一致。
最后一个字段是否被截断。
本轮解析是否会零进度返回。
如果把检查放到网关解密之后,就已经接近受影响解析器所在位置。
此时最可靠的修复仍然是让解析器对损坏输入快速抛出受控异常。
这项漏洞再次说明:
加密边界内仍然需要完整的输入验证。
“密文来自公钥加密”不是可信来源标签。
十三、0.0.23.Final到底改了什么
修复提交是89d6cfc67e034a92e6eef9ccb9829b6e358a91ea。
提交信息直接说明:
当assertions关闭并解析损坏field section时,会出现无限循环。
修复目标是检测损坏field section并抛出CorruptedFrameException。
代码没有只把!= 0机械替换为> 0。
它把两个断言升级成了普通运行时检查。
第一层检查:
如果readFieldLine()返回null,或者本轮实际读取字节数小于等于0,立即抛出CorruptedFrameException。
这保证循环不能在状态完全不变时继续旋转。
第二层检查:
如果本轮实际读取字节数大于剩余的field section声明长度,立即抛出CorruptedFrameException。
这保证解析器不会把“越过声明边界”静默当成普通进展。
只有两层都通过,才执行:
fieldSectionLength -= read
于是循环每次成功迭代都满足两个条件:
至少消耗1字节。
不会消费超过剩余边界。
剩余长度因此严格向0靠近。
这就是一个可以证明终止的循环。
十四、为什么修复后仍保留while != 0
看到修复代码后,有人会问:
循环条件为什么仍然是fieldSectionLength != 0?
因为新加入的运行时检查已经阻止剩余长度变成负数。
设循环开始时剩余长度是非负数。
每轮读取量必须大于0。
读取量又不能大于剩余长度。
所以相减后的结果仍然非负。
并且严格小于上一轮。
有限整数不可能无限严格下降而又永远大于0。
最终只能到0。
如果输入无法提供一条完整字段,第一层检查会抛异常。
如果字段越过声明边界,第二层检查会抛异常。
因此,!= 0不再有机会面对一个负数固定点。
这比单纯更换循环操作符更完整。
它不仅让程序离开死循环,还拒绝了不一致的消息。
十五、17字节回归测试证明了什么
修复提交为BinaryHttpParserTest增加了一个测试。
测试构造17字节输入。
随后调用BinaryHttpParser(8192).parse(...)。
断言结果不是“最终返回null”。
也不是“测试跑完就算成功”。
它明确要求抛出CorruptedFrameException。
这验证了三件事。
第一,已知的最小问题输入进入了真实解析器,而不是只测试一个抽象工具函数。
第二,解析器不会因为生产环境关闭assert而无限旋转。
第三,损坏输入得到的是协议解码错误,而不是开发断言错误。
测试没有证明所有可能的BHTTP异常组合都被覆盖。
但它把已知失败模式固定成了以后版本不能倒退的安全性质。
好的安全回归测试不是“这个输入不再复现”。
它应该说明正确行为是什么。
这里的正确行为就是:有限时间内,以受控异常结束。
十六、精确边界漏洞为什么要单独修
同版本中另一个提交88a244eeb278...处理了精确边界问题。
旧readFieldLine()在判断字段值是否完整时,使用了近似“总字节数大于或等于可读字节数就返回null”的条件。
当一条合法字段恰好用完field section最后一个字节时,等号成立。
函数误以为还缺数据,返回null。
外层循环又没有进度,于是出现另一个死循环。
对应修复把最终边界判断从>=调整为>,允许完整字段正好贴着边界结束。
并增加testExactBoundary()回归测试。
这与本文17字节损坏输入的处理形成一组很好的对照:
合法且完整的边界输入,应当成功解析。
声明长度与实际字段不一致的输入,应当抛出损坏帧异常。
两者都不能进入零进度循环。
解析器的健壮性,不是“对所有奇怪输入都拒绝”。
而是正确区分完整、待补充和损坏三种状态。
十七、团队怎样确认自己是否受影响
1. 查依赖坐标,不只查“有没有Netty”
重点组件是:
io.netty.incubator:netty-incubator-codec-bhttp
以及会把解密内容送入它的OHTTP编解码器。
普通使用netty-codec-http不等于自动受此漏洞影响。
查看Maven或Gradle依赖树。
同时检查打包后的实际JAR与容器镜像。
不要只看顶层pom.xml。
传递依赖、平台BOM、阴影打包和内部SDK都可能改变最终版本。
2. 查最终运行版本
如果BHTTP组件版本为0.0.22.Final或更早,安排升级。
首个修复版本是0.0.23.Final。
实际升级时优先选择经过兼容验证的更新稳定版本,而不是为了“刚好过线”长期停在最早修复版。
3. 查输入是否可由不可信对端控制
直接BHTTP服务器、客户端、代理、网关和测试接口都可能触达解析器。
OHTTP网关尤其要确认公共密钥配置是否使任意客户端都能构造可解密请求。
这通常是协议正常设计,不是错误配置。
它只是意味着内层BHTTP解析器必须被视为公共攻击面。
4. 查解析在哪个线程运行
确认BHTTP解码是否直接运行在Netty event loop。
确认一个channel的解码阻塞会影响多少其他channel。
确认健康检查是否使用同一事件循环资源。
确认外部看门狗能否在event loop不调度时摘除实例。
十八、怎样在生产信号里识别这种死循环
这类攻击未必制造明显带宽峰值。
更有价值的是CPU和线程证据。
第一,看单核或少数核心持续100%。
如果总CPU只有一部分上升,不要马上排除CPU型拒绝服务。
一个事件循环线程锁死,往往先表现为一个核心被打满。
第二,抓取线程栈。
关注Netty event loop线程是否长时间停留在:
BinaryHttpParser.readFieldSection
BinaryHttpParser.readRequestHead
BinaryHttpParser.parse
同一个线程连续多次采样都在相同循环位置,比单次栈更有说服力。
第三,对比线程CPU时间和墙上时间。
如果线程处于RUNNABLE,CPU时间持续增加,说明它在执行而不是阻塞等待。
第四,看事件循环延迟。
监控任务排队时间、channel读写延迟、请求完成率和健康检查超时。
第五,看“小流量、大失能”的不对称。
入口字节数平稳,实例可用连接却快速下降,是这类解析死循环的重要特征。
第六,保留触发前后的版本和JAR哈希。
不要在重启后只留下“CPU高”的摘要。
没有准确制品信息,很难判断实例是否真的包含修复。
十九、升级前的临时措施,哪些有用,哪些别高估
有用:缩小不可信BHTTP入口
如果业务暂时不需要相关OHTTP/BHTTP入口,可以关闭或仅允许受控来源访问。
这是降低触发机会最直接的补偿措施。
有用:独立健康检查与快速摘流
使用不依赖同一event loop正常调度的外部探测。
发现实例失去响应后迅速从负载均衡中移除。
它能减少故障扩散时间,但不能防止新实例再次被触发。
有用:限制并发与来源速率
可以降低单来源批量占用线程的速度。
但由于单个请求很小、单次影响很长,不能把它当完整修复。
不足:只调低最大请求体
17字节远低于常见阈值。
漏洞不是依靠大内存。
不足:只调小maxFieldSectionSize
攻击者声明的长度本来就可以很小。
解析器在小缓冲上忙循环。
不足:只设置channel超时
如果超时回调依赖已经锁死的event loop,它可能没有机会执行。
不足:只打开Java assertions
它不是稳定的协议错误处理,也没有完整替代修复版的长度一致性检查。
不足:只依赖外层WAF
OHTTP内层BHTTP在解密前不可见。
二十、给解析器开发者的六条硬规则
规则一:每个循环都写出进度量
解析循环的进度可以是readerIndex、剩余长度、状态枚举或输出数量。
代码评审时要明确指出:成功迭代后,哪个量严格变化。
规则二:零进度必须是显式分支
零进度可能意味着等待更多网络数据。
也可能意味着消息已经损坏。
无论哪一种,都不应在同一输入状态下无条件重试。
规则三:长度不能只检查上限
最大长度防止内存失控。
声明长度与实际消费一致,防止解析边界失控。
两种检查解决不同问题。
规则四:不可信不变量不能依赖assert
只要攻击者能破坏条件,就用普通运行时代码检查。
抛出明确的解码异常。
规则五:区分“需要更多数据”和“数据已损坏”
如果上层明确告知消息体已经完整,仍缺字段字节,就应该失败。
如果只是分片尚未到齐,可以返回待补充状态。
不能用一个含糊的null让外层无限重试。
规则六:测试必须在生产运行参数下执行
至少有一组测试关闭assertions。
使用超时和线程CPU证据捕捉不终止行为。
同时覆盖合法精确边界、截断字段、越界字段、空字段区和最大长度附近输入。
二十一、十个常见误读
误读1:只有17字节,所以影响不大
错误。
输入大小与服务端CPU时间没有线性关系。
小输入反而更容易绕过大小和带宽阈值。
误读2:这是普通Netty主库所有应用的漏洞
不准确。
受影响坐标是Netty孵化项目中的netty-incubator-codec-bhttp,要看实际依赖与可达路径。
误读3:外层有HPKE,所以里面的消息可信
错误。
网关公钥本来就是公开给客户端使用的;解密成功不代表BHTTP长度合法。
误读4:8KB字段上限能挡住它
错误。
漏洞输入声明长度很小,CPU循环也发生在小缓冲区上。
误读5:把!= 0改成> 0就全部解决
不完整。
修复还必须拒绝零进度和实际消费越过声明边界。
误读6:代码里有assert,生产自然会报错
错误。
Java assertions在普通生产JVM中默认不启用。
误读7:打开-ea等于完成修复
错误。
正式修复将条件变成受控运行时异常,并校验越界消费。
误读8:请求超时一定会中断死循环
不一定。
如果超时任务依赖同一个已锁死event loop,就可能无法调度。
误读9:两个同日BHTTP死循环是同一个CVE
错误。
CVE-2026-63202处理损坏长度和零进度,CVE-2026-63124处理完整字段恰好贴边的判断。
误读10:公告存在PoC,说明已经发生大规模攻击
没有这种证据。
公开复现用于证明缺陷,不等于在野利用确认。
二十二、最终处置清单
第一,生成实际依赖树和SBOM。
搜索io.netty.incubator:netty-incubator-codec-bhttp。
第二,确认运行制品版本。
0.0.22.Final及更早版本进入升级范围。
第三,升级到至少0.0.23.Final,优先采用经过兼容验证的更新版本。
第四,确认OHTTP解密后的BHTTP解析器已经来自修复制品,而不是旧JAR被阴影打包进最终应用。
第五,检查不可信BHTTP输入路径。
包括服务端、客户端、代理、网关、测试和调试接口。
第六,补充线程栈、event loop延迟和单线程CPU监控。
第七,外部健康检查与摘流机制不要依赖同一事件循环。
第八,把零进度循环检测加入同类协议解析器审计。
第九,回归测试同时覆盖17字节损坏输入和合法精确边界输入。
第十,升级前的限流、隔离和重启只能作为临时补偿控制。

结语
这项漏洞没有展示一段宏大的攻击链。
它只是把解析器里三行看似合理的代码放到生产条件下重新审视。
一个剩余长度。
一次字段读取。
两条assert。
正常输入下,它们配合得很好。
恶意输入让剩余长度越过0变成负数。
截断字段让下一轮读取0字节。
生产JVM让assert静默消失。
于是,一个17字节消息获得了比它自身大得多的时间成本。
解析器真正需要的不是“希望每次都会前进”。
而是一条可以执行、可以测试、可以证明的规则:
每次循环要么消费合法范围内的字节并逼近终点,要么立即返回等待更多数据,要么抛出受控错误。
绝不能在完全相同的输入和状态上,再试一次。
因为在事件驱动服务器里,“再试一次”如果没有终点,锁住的从来不只是一条请求。
它锁住的是这条线程背后的所有连接。