最近,云栈社区的一位开发者在 Node.js 官方仓库提交了一个关于 HTTP 模块内存泄露的问题。这个 Bug 涉及 Node.js HTTP 客户端,但场景比较特殊:通常只在服务端恶意返回多个响应时才会触发。对应的修复 PR 已经提交,本文将带大家深入分析问题的根源和具体的修复方案。
问题复现
我们先来看一段能够复现问题的代码。
const http = require('http');
const gcTrackerMap = new WeakMap();
const gcTrackerTag = 'NODE_TEST_COMMON_GC_TRACKER';
function onGC(obj, gcListener) {
const async_hooks = require('async_hooks');
const onGcAsyncHook = async_hooks.createHook({
init: function(id, type) {
if (this.trackedId === undefined) {
this.trackedId = id;
}
},
destroy(id) {
if (id === this.trackedId) {
this.gcListener.ongc();
onGcAsyncHook.disable();
}
},
}).enable();
onGcAsyncHook.gcListener = gcListener;
gcTrackerMap.set(obj, new async_hooks.AsyncResource(gcTrackerTag));
obj = null;
}
function createServer() {
const server = http.createServer((req, res) => {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ hello: 'world' }));
req.socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
});
return new Promise((resolve) => {
server.listen(0, () => {
resolve(server);
});
});
}
async function main() {
const server = await createServer();
const req = http.get({
port: server.address().port,
}, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c), 1);
res.on('end', () => {
console.log(Buffer.concat(chunks).toString('utf8'));
});
});
const timer = setInterval(global.gc, 300);
onGC(req, {
ongc: () => {
clearInterval(timer);
server.close();
}
});
}
main();
这段代码的逻辑并不复杂:客户端发起一个 HTTP 请求,服务端先返回一个正常的 JSON 响应,但紧接着又通过 socket 直接写入了一个错误的 HTTP 响应报文。这个“双重响应”的异常场景,导致了客户端的 request 对象无法被垃圾回收,从而引发了内存泄露。
Node.js HTTP 响应解析流程
要理解这个 Bug,我们需要对 Node.js HTTP 协议的解析过程有个基本了解。简单来说,当 socket 收到数据时,会调用 parser.execute(data) 进行解析。
// socket 收到数据时执行
function socketOnData(d) {
const socket = this;
const req = this._httpMessage;
const parser = this.parser;
// 解析 HTTP 响应
const ret = parser.execute(d);
// 响应解析完成,做一些清除操作,释放相关对象内存
if (parser.incoming?.complete) {
socket.removeListener('data', socketOnData);
socket.removeListener('end', socketOnEnd);
socket.removeListener('drain', ondrain);
freeParser(parser, req, socket);
}
}
function freeParser(parser, req, socket) {
if (parser) {
cleanParser(parser);
parser.remove();
if (parsers.free(parser) === false) {
// function closeParserInstance(parser) { parser.close(); }
setImmediate(closeParserInstance, parser);
} else {
parser.free();
}
}
if (req) {
req.parser = null;
}
if (socket) {
socket.parser = null;
}
}
function cleanParser(parser) {
parser.socket = null;
parser.incoming = null;
parser.outgoing = null;
parser[kOnMessageBegin] = null;
parser[kOnExecute] = null;
parser[kOnTimeout] = null;
parser.onIncoming = null;
}
解析过程中会依次触发几个关键的回调钩子:
// 解析 header 时
const kOnHeaders = HTTPParser.kOnHeaders | 0;
// 解析 header 完成时
const kOnHeadersComplete = HTTPParser.kOnHeadersComplete | 0;
// 解析 HTTP body 时
const kOnBody = HTTPParser.kOnBody | 0;
// 解析完一个 HTTP 报文时
const kOnMessageComplete = HTTPParser.kOnMessageComplete | 0;
对于客户端响应,这些钩子的核心逻辑如下:
-
解析到响应头 (parserOnHeaders):收集头部信息。
-
解析完响应头 (parserOnHeadersComplete):创建 IncomingMessage 对象并触发 onIncoming 回调。
function parserOnHeadersComplete(versionMajor, versionMinor, headers, method,
url, statusCode, statusMessage, upgrade,
shouldKeepAlive) {
const parser = this;
const { socket } = parser;
const incoming = parser.incoming = new IncomingMessage(socket);
return parser.onIncoming(incoming, shouldKeepAlive);
}
-
触发 onIncoming 回调 (parserOnIncomingClient):这里会检查是否已收到过响应。
function parserOnIncomingClient(res, shouldKeepAlive) {
const socket = this.socket;
const req = socket._httpMessage;
if (req.res) {
// 收到了多个响应
socket.destroy();
return 0;
}
// 触发 response 事件
if (req.aborted || !req.emit('response', res)) {
// ...
}
return 0; // No special treatment.
}
-
解析响应体 (parserOnBody):将数据块推入响应流。
function parserOnBody(b) {
const stream = this.incoming;
// If the stream has already been removed, then drop it.
if (stream === null)
return;
// 把 body push 到响应对象中
if (!stream._dumped) {
const ret = stream.push(b);
if (!ret)
readStop(this.socket);
}
}
-
解析完成 (parserOnMessageComplete):标记响应完成并结束流。
function parserOnMessageComplete() {
const parser = this;
const stream = parser.incoming;
// stream 就是上面的 IncomingMessage 对象
if (stream !== null) {
// 标记响应对象解析完成
stream.complete = true;
// 标记流结束
stream.push(null);
}
}
问题根源分析
了解了基本流程,我们来看看内存泄露究竟是怎么发生的。
当服务器连续发送两个响应时:
- 解析第一个响应的头部后,会创建一个
IncomingMessage 对象(记为 res1),parser.incoming 指向它,并成功触发 response 事件。
- 紧接着解析第二个响应的头部,关键点来了:
parser.onHeadersComplete 会再次执行,parser.incoming 被重新赋值为一个新的 IncomingMessage 对象(res2)。
- 当执行到
parserOnIncomingClient 时,代码发现 req.res 已存在(即第一个响应),于是立刻销毁 socket 并返回 0。
问题就出在返回值之后。解析函数 parser.execute(d) 执行完毕,代码会检查 parser.incoming?.complete,以决定是否执行清理函数 freeParser。此时 parser.incoming 指向的是第二个响应对象 res2,而它的 complete 字段为 false(因为第二个响应的 parserOnMessageComplete 从未被调用)。因此,freeParser 被跳过,与第一个请求 (req) 和解析器 (parser) 相关的关键引用没有被清除,导致了内存泄露。
修复方案
修复的思路主要有两种。第一种是在发现重复响应时,让 parserOnIncomingClient 返回 -1 表示解析错误。但这样会在触发 response 事件后再触发 error 事件,给使用者造成困惑。
最终采用的是第二种更优雅的方案:忽略第二个及后续的所有响应。具体改动如下:
首先,在发现重复响应时,除了销毁 socket,还将 parser.incoming 重新指向第一个响应对象 (req.res),并设置一个跳过标记。
if (req.res) {
socket.destroy();
if (socket.parser) {
// Now, parser.incoming is pointed to the new IncomingMessage,
// we need to rewrite it to the first one and skip all the pending IncomingMessage
socket.parser.incoming = req.res;
socket.parser.incoming[kSkipPendingData] = true;
}
return 0;
}
然后,在 parserOnBody 和 parserOnMessageComplete 钩子中,检查并忽略带有跳过标记的响应数据。
function parserOnBody(b) {
const stream = this.incoming;
if (stream === null || stream[kSkipPendingData])
return;
if (!stream._dumped) {
const ret = stream.push(b);
if (!ret)
readStop(this.socket);
}
}
function parserOnMessageComplete() {
const parser = this;
const stream = parser.incoming;
if (stream !== null && !stream[kSkipPendingData]) {
stream.complete = true;
stream.push(null);
}
}
这样,parser.incoming 始终指向第一个已完成的响应对象 (res1),其 complete 字段为 true。当 parser.execute(d) 结束后,检查条件 parser.incoming?.complete 成立,freeParser 得以正确执行,从而释放了内存。这个方案既解决了内存泄露,又对用户透明,不影响现有的HTTP/HTTPS客户端逻辑。
相关链接
这个案例提醒我们,在处理网络协议这类底层计算机基础组件时,需要格外注意异常分支下的资源管理。希望这次深入源码的分析,能帮助你更好地理解 Node.js 的内部机制。