找回密码
立即注册
搜索
发回帖 发新帖

6250

积分

0

好友

792

主题
发表于 2026-4-15 01:57:00 | 查看: 277| 回复: 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;
}

然后,在 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 的内部机制。




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

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

GMT+8, 2026-10-13 16:22 , Processed in 0.075921 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

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