找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4673

积分

0

好友

643

主题
发表于 2 小时前 | 查看: 3| 回复: 0

最近,云栈社区的一位开发者在 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;

对于客户端响应,这些钩子的核心逻辑如下:

  1. 解析到响应头 (parserOnHeaders):收集头部信息。

  2. 解析完响应头 (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);
    }
  3. 触发 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.
    }
  4. 解析响应体 (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);
      }
    }
  5. 解析完成 (parserOnMessageComplete):标记响应完成并结束流。

    function parserOnMessageComplete() {
      const parser = this;
      const stream = parser.incoming;
      // stream 就是上面的 IncomingMessage 对象
      if (stream !== null) {
        // 标记响应对象解析完成
        stream.complete = true;
        // 标记流结束
        stream.push(null);
      }
    }

问题根源分析

了解了基本流程,我们来看看内存泄露究竟是怎么发生的。

当服务器连续发送两个响应时:

  1. 解析第一个响应的头部后,会创建一个 IncomingMessage 对象(记为 res1),parser.incoming 指向它,并成功触发 response 事件。
  2. 紧接着解析第二个响应的头部,关键点来了parser.onHeadersComplete 会再次执行,parser.incoming 被重新赋值为一个新的 IncomingMessage 对象(res2)。
  3. 当执行到 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;
}

然后,在 parserOnBodyparserOnMessageComplete 钩子中,检查并忽略带有跳过标记的响应数据。

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 的内部机制。




上一篇:深入Chromium源码:以Electron为例解析浏览器进程启动流程
下一篇:Windows应急响应工具Hawkeye v1.1:Go语言开发的综合安全分析与溯源利器
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-4-15 04:44 , Processed in 0.610495 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表