Node.js HTTP Client 內存泄露問題
最近社區有一個開發者提交了一個關于 HTTP 模塊內存泄露的問題(issue),這個問題影響的是 Node.js HTTP 客戶端,但是是比較特殊的場景,一般不會出現,除非服務端惡意攻擊客戶端。最近提交了一個 PR 修復了這個問題,本文簡單介紹下這個問題和修復方案。
例子
先看下復現的代碼。
const http = require('http');
const gcTrackerMap = newWeakMap();
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');
});
returnnewPromise((resolve) => {
server.listen(0, () => {
resolve(server);
});
});
}
asyncfunction 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,然后拿到一個響應,特殊的地方在于服務器返回了兩個響應,從而導致了 request 對象不會被釋放,引起內存泄露問題。
HTTP 響應解析過程
下面來分析下原因,分析這個問題需要對 Node.js HTTP 協議解析過程有一些了解。簡單來說,Node.js 收到數據后,會調 parser.execute(data) 進行 HTTP 協議的解析。
// 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;接著看 Node.js 在處理 HTTP 響應時,這些鉤子函數的邏輯。
解析到 header。
function parserOnHeaders(headers, url) {
// Once we exceeded headers limit - stop collecting them
if (this.maxHeaderPairs <= 0 ||
this._headers.length < this.maxHeaderPairs) {
this._headers.push(...headers);
}
this._url += url;
}解析完 header。
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 函數。
function parserOnIncomingClient(res, shouldKeepAlive) {
const socket = this.socket;
const req = socket._httpMessage;
if (req.res) {
// 收到了多個響應
socket.destroy();
return0;
}
// 觸發 response 事件
if (req.aborted || !req.emit('response', res)) {
// ...
}
return0; // No special treatment.
}解析 HTTP 響應 body。
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);
}
}解析完 HTTP 響應。
function parserOnMessageComplete() {
const parser = this;
const stream = parser.incoming;
// stream 就是上面的 IncomingMessage 對象
if (stream !== null) {
// 標記響應對象解析完成
stream.complete = true;
// 標記流結束
stream.push(null);
}
}分析問題
了解了大概的流程后看一下為啥會出現內存泄露問題,當通過 parser.execute(data) 解析響應時,因為服務器返回了兩個響應,第一次解析完 HTTP 響應 header 時執行以下代碼。
const incoming = parser.incoming = new IncomingMessage(socket);
return parser.onIncoming(incoming, shouldKeepAlive);onIncoming 會觸發 response 事件,這是正常的流程,緊接著又解析完第二個響應的 header 時問題就來了,這時同樣會執行上面的代碼,并且注意 parser.incoming 指向了新的 IncomingMessage 對象,接著看這時 onIncoming 的邏輯。
// 已經收到了一個響應了,忽略并銷毀 socket
if (req.res) {
socket.destroy();
return 0;
}Node.js 這里做了判斷,直接銷毀 socket 并返回,最終 parser.execute(data) 執行結束,相關代碼如下。
// 解析 HTTP 響應
const ret = parser.execute(d);
// 響應是否解析完成
if (parser.incoming?.complete) {
// 做一些清除操作,釋放相關對象內存
freeParser(parser, req, socket);
}因為 parser.incoming 這時候指向的是第二個響應,其 complete 字段的值是 false,所以導致沒有執行清除操作,引起內存泄露。
修復方案
修復方案有兩個,一是在解析到第二個響應時,以下代碼返回 -1 表示解析出錯。
if (req.res) {
socket.destroy();
return -1;
}但是這種方式有一個問題是,因為解析完第一個響應時已經觸發了 response 事件,然后這里如果又觸發 error 事件會比較奇怪,讓用戶側不好處理。第二種方案是忽略第二個響應。最終選擇的是第二種方案,改動如下。
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;
}首先讓 parser.incoming 執行第一個響應,并且設置丟棄后續所有數據標記,然后在后續解析過程中忽略收到的數據,否則后續的數據會干擾第一個響應。
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);
}
}1. issue:https://github.com/nodejs/node/issues/60025
2. PR:https://github.com/nodejs/node/pull/60062


























