Node.js HTTP Client 內(nèi)存泄露問題
最近社區(qū)有一個開發(fā)者提交了一個關于 HTTP 模塊內(nèi)存泄露的問題(issue),這個問題影響的是 Node.js HTTP 客戶端,但是是比較特殊的場景,一般不會出現(xiàn),除非服務端惡意攻擊客戶端。最近提交了一個 PR 修復了這個問題,本文簡單介紹下這個問題和修復方案。
例子
先看下復現(xiàn)的代碼。
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();上面的代碼邏輯很簡單,首先發(fā)起一個 HTTP,然后拿到一個響應,特殊的地方在于服務器返回了兩個響應,從而導致了 request 對象不會被釋放,引起內(nèi)存泄露問題。
HTTP 響應解析過程
下面來分析下原因,分析這個問題需要對 Node.js HTTP 協(xié)議解析過程有一些了解。簡單來說,Node.js 收到數(shù)據(jù)后,會調(diào) parser.execute(data) 進行 HTTP 協(xié)議的解析。
// socket 收到數(shù)據(jù)時執(zhí)行
function socketOnData(d) {
const socket = this;
const req = this._httpMessage;
const parser = this.parser;
// 解析 HTTP 響應
const ret = parser.execute(d);
// 響應解析完成,做一些清除操作,釋放相關對象內(nèi)存
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;
}在解析過程中會執(zhí)行多個鉤子函數(shù)。
// 解析 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 響應時,這些鉤子函數(shù)的邏輯。
解析到 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);
}接著回調(diào) onIncoming 函數(shù)。
function parserOnIncomingClient(res, shouldKeepAlive) {
const socket = this.socket;
const req = socket._httpMessage;
if (req.res) {
    // 收到了多個響應
    socket.destroy();
    return0;
  }
// 觸發(fā) 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;
    // 標記流結(jié)束
    stream.push(null);
  }
}分析問題
了解了大概的流程后看一下為啥會出現(xiàn)內(nèi)存泄露問題,當通過 parser.execute(data) 解析響應時,因為服務器返回了兩個響應,第一次解析完 HTTP 響應 header 時執(zhí)行以下代碼。
const incoming = parser.incoming = new IncomingMessage(socket);
return parser.onIncoming(incoming, shouldKeepAlive);onIncoming 會觸發(fā) response 事件,這是正常的流程,緊接著又解析完第二個響應的 header 時問題就來了,這時同樣會執(zhí)行上面的代碼,并且注意 parser.incoming 指向了新的 IncomingMessage 對象,接著看這時 onIncoming 的邏輯。
// 已經(jīng)收到了一個響應了,忽略并銷毀 socket
if (req.res) {
  socket.destroy();
  return 0;
}Node.js 這里做了判斷,直接銷毀 socket 并返回,最終 parser.execute(data) 執(zhí)行結(jié)束,相關代碼如下。
// 解析 HTTP 響應
const ret = parser.execute(d);
// 響應是否解析完成
if (parser.incoming?.complete) {
  // 做一些清除操作,釋放相關對象內(nèi)存
  freeParser(parser, req, socket);
}因為 parser.incoming 這時候指向的是第二個響應,其 complete 字段的值是 false,所以導致沒有執(zhí)行清除操作,引起內(nèi)存泄露。
修復方案
修復方案有兩個,一是在解析到第二個響應時,以下代碼返回 -1 表示解析出錯。
if (req.res) {
  socket.destroy();
  return -1;
}但是這種方式有一個問題是,因為解析完第一個響應時已經(jīng)觸發(fā)了 response 事件,然后這里如果又觸發(fā) error 事件會比較奇怪,讓用戶側(cè)不好處理。第二種方案是忽略第二個響應。最終選擇的是第二種方案,改動如下。
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 執(zhí)行第一個響應,并且設置丟棄后續(xù)所有數(shù)據(jù)標記,然后在后續(xù)解析過程中忽略收到的數(shù)據(jù),否則后續(xù)的數(shù)據(jù)會干擾第一個響應。
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















 
 
 








 
 
 
 