
close 说的是消息流结束
给 Node HTTP 服务加断开监控时,很容易把 req.on('close') 当成「客户端挂了」。但 IncomingMessage 的 close 事件描述的是这条请求消息对应的流已经结束,不专指异常断连。请求体正常收完,close 也会来;如果只用它计数,正常请求也会被记成断线。
这个区别值得单独验证,因为旧代码里常把 close、end 当成同一件事。end 表示可读流没有更多数据,完整请求通常先有 end,再有 close。请求没收完整时,Node 会发出 aborted,随后也会有 close。两种结果都能走到 close,光看事件名不足以判断请求是否完整。
两种请求都收到了 close
验证脚本在本机启动只绑定 127.0.0.1 的 HTTP 服务。一条 POST 发完四个字节;另一条用原始 TCP 连接声明十个字节,只发送三个字节就关闭连接。服务端消费请求流,并在事件处理器里记录 req.complete 与 aborted。没有访问外网,也没有依赖真实用户请求。
runtime: v26.8.1
server: 127.0.0.1:57516 (loopback only)
full request events: end, close, complete=true, aborted=false
partial request events: aborted, close, complete=false, aborted=true
assertion: PASS (close fires for both; complete distinguishes body completion; aborted marks truncation)
完整请求的事件顺序是 end、close,此时 req.complete 为 true。被截断的请求则先触发 aborted,随后触发 close,req.complete 为 false。因此,close 确实发生在两种路径上;区别是 HTTP 消息有没有完整解析,而不是连接最后有没有关闭。连接无论正常收尾还是异常中断,最终都要关闭,这也是单凭 close 作判断会失真的原因。
这个复现特意用原始 TCP 发送第二个请求。若用普通 HTTP 客户端并立即销毁请求对象,客户端的取消时机与服务端实际收到的正文长度会混在一起,不容易确认究竟是服务端处理逻辑还是测试代码造成了中断。这里固定了请求头里的 Content-Length,并只发送一部分正文,观察点就落在服务端解析到的消息完整性上。脚本还断言两条请求的状态,事件顺序若变了或 complete 判断不符,会直接失败,而不是只打印一段看起来合理的日志。
记录断开要看完整性
req.complete 在 HTTP 消息完整接收并解析后为真。要统计客户端中途断开,可以把 aborted 作为异常信号,同时在最终状态检查 complete。处理顺序也有实际影响:事件回调可能分开到达,若在第一个 close 回调里只看另一个尚未更新的自维护标志,监控容易记出短暂的错误状态。将请求结束状态集中在一次收尾逻辑里,比散落在多个计数器里更容易核对。
这里的 complete 也不是业务成功标记。客户端把整个 JSON 发完再断开,服务器仍可能在解析后发现字段不对;反过来,消息体没收全时,应用层还没机会验证任何业务字段。日志最好把传输是否完整与处理结果分开写,免得一条 close 被拿去解释两类故障。aborted 适合作为传输异常证据,不能直接推导出用户取消了操作,连接可能经过代理,断开来源并不一定能从后端请求对象还原。
function watchRequest(req, record) {
let aborted = false;
req.once('aborted', () => { aborted = true; });
req.once('close', () => {
record({ complete: req.complete, aborted });
});
}
示例只展示信号读取,不代表所有代理链路都会把客户端断开原样传给应用。反向代理可能先接完上游请求,再与后端保持另一条连接;后端看到的状态取决于代理怎样转发和缓冲。这部分没有在本机验证,不能把直连实验外推成代理行为保证。
我会保留 close 作为资源收尾的观测点,不会把它直接写成客户端断开告警。完整性由 req.complete 判断,异常中止再看 aborted。如果监控关心的是 TCP socket 生命周期,也应另查连接层的信号,不要把请求对象上的 close 当成 socket 探针。