Visitar URL original
fix(http): stop per-socket error listener from retaining the installing request's context (memory leak) by v-dev-cl · Pull Request #11091 · axios/axios · GitHub
Skip to content

fix(http): stop per-socket error listener from retaining the installing request's context (memory leak) - #11091

Merged
jasonsaayman merged 3 commits into
axios:v1.xfrom
v-dev-cl:fix/socket-error-listener-context-retention
Aug 4, 2026
Merged

jasonsaayman merged 3 commits into
axios:v1.xfrom
v-dev-cl:fix/socket-error-listener-context-retention

Conversation

@v-dev-cl

@v-dev-cl v-dev-cl commented Jul 21, 2026 •

Copy link
Copy Markdown
Contributor

🏄

Summary

Since v1.15.2, the http adapter installs a once-per-socket 'error' listener (handleSocketError, the fix for #10780's listener accumulation). That listener is an inline closure created inside dispatchHttpRequest and is never removed — so via its scope chain it retains the entire adapter scope of whichever request first touched the socket for the socket's whole lifetime: the settled response promise (reactions_or_result → the full response body), the buffered body chunks, boundSockets, timers.

With keep-alive agent pools that stay warm (long-running services), every pooled socket permanently pins one complete response (~body size × 2: string on-heap + chunk buffers off-heap). On our scraper fleet (agentkeepalive pools up to 600 sockets, ~3 MB HTML responses) this retained multiple GB per process and OOM-killed pods continuously. The leak grows with socket churn and is invisible to handle counts — heap and external memory grow ~1:1 while active handles stay flat, and a full GC reclaims nothing.

Affects v1.15.2 through current v1.x head (verified in 1.16.0, 1.17.0, 1.18.1 and this branch).

Evidence (heap snapshot from production, axios 1.17.0)

Retainer path for a 3.2 MB retained response string:

TCP handle (GC root, live pooled socket)
 → Socket → _events.error[…] = closure handleSocketError
 → closure context chain (adapter scope of the FIRST request on this socket)
 → context:reject → _reject → Promise → reactions_or_result
 → response object → .data → <3.2 MB response body string>

A snapshot of one process showed 1046 Socket/TLSSocket objects in heap vs ~295 live socket handles, 766 retained IncomingMessages, and hundreds of MB of response strings + JSArrayBufferData, all rooted in handleSocketError closures on pooled or dead sockets.

Repro

Standalone script (node --expose-gc repro.js) — measures post-GC memory while the pool is warm
const http = require('http');
const axios = require('axios');
const Agent = require('agentkeepalive'); // plain http.Agent({keepAlive:true}) works too

const BIG = '<!doctype html>' + 'x'.repeat(3 * 1024 * 1024);
const server = http.createServer((req, res) => {
  if (!req.url.startsWith('/final')) { res.writeHead(302, {location: `/final${req.url}`}); res.end(); return; }
  res.writeHead(200, {'content-type': 'text/html'}); res.end(BIG);
});

server.listen(0, () => {
  const port = server.address().port;
  const agent = new Agent({ keepAlive: true, maxSockets: 30, maxFreeSockets: 30, freeSocketTimeout: 4000 });
  const inst = axios.create({ timeout: 600, maxRedirects: 1 });
  const url = () => `http://127.0.0.1:${port}/p${Math.floor(Math.random() * 1000)}`;
  const t0 = Date.now();
  let inflight = 0;
  const pump = () => {
    while (inflight < 30 && Date.now() - t0 < 45000) {
      inflight++;
      inst.get(url(), { httpAgent: agent }).catch(() => {}).finally(() => { inflight--; setImmediate(pump); });
    }
  };
  pump();
  setInterval(() => {
    global.gc(); global.gc();
    const m = process.memoryUsage();
    console.log(`heapUsed=${(m.heapUsed / 1048576) | 0}Mi external=${(m.external / 1048576) | 0}Mi`);
    if (Date.now() - t0 > 47000) process.exit(0);
  }, 5000);
});

Results (30-socket warm pool, 3 MB bodies, post-GC):

heapUsed external
v1.17.0 stock 73–116 Mi retained 70–111 Mi retained
this fix 12–16 Mi flat 4–17 Mi flat

The retained memory frees only when the sockets die — confirming the retainer is the per-socket listener, not the requests.

Fix

Hoist the listener to a module-scope function that reads the active request via this[kAxiosCurrentReq] (EventEmitter invokes listeners with this bound to the emitter, i.e. the socket). It captures no per-request context; the once-per-socket install guard, the kAxiosCurrentReq swap, and the error semantics (destroy the currently-active request) are unchanged.

🤖 Generated with Claude Code


Summary by cubic

Fixes a memory leak in the Node http adapter by switching the per-socket 'error' listener to a shared module-scope function. Behavior is unchanged; socket errors still destroy only the active request.

Description

  • Summary of changes
    • Hoisted the socket 'error' handler to a module-scope handleSocketError and installed it once per socket.
    • The handler reads this[kAxiosCurrentReq] and calls destroy(err).
    • Kept the kAxiosSocketListener guard and existing error semantics.
  • Reasoning
    • The prior inline closure retained the first request’s adapter scope for each pooled socket, causing memory growth with keep-alive agents.
  • Additional context
    • Verified flat memory under load; added a unit test to validate the shared listener behavior.

Docs

  • Add a note in /docs/ under the Node http adapter about the keep-alive memory retention fix in v1.x; no user action is required.
  • Ensure the changelog entry reflects the fix for pooled socket context retention.

Testing

  • Added a unit test that:
    • Reuses one module-scope 'error' listener across different sockets.
    • Ensures a socket error destroys only the currently active request and propagates the correct error code.
  • No additional tests needed.

Semantic version impact

  • Patch: bug fix with no API or behavior changes.

Written for commit 4b13a79. Summary will update on new commits.

Review in cubic

…ng request's context

The once-per-socket 'error' listener introduced in v1.15.2 (axios#10780 fix) is
an inline closure created inside dispatchHttpRequest. Because it is never
removed, its scope chain pins the entire adapter scope of whichever request
first touched the socket — including the settled response promise (full
response body) and buffered chunks — for the lifetime of every pooled
keep-alive socket. On busy services with warm agent pools this retains
multiple MB per socket (GBs at fleet scale), growing with socket churn.

Hoist the listener to a module-scope function that reads the active request
via this[kAxiosCurrentReq] (EventEmitter binds `this` to the socket), so it
captures no per-request context. Error semantics are unchanged.
@v-dev-cl
v-dev-cl requested a review from jasonsaayman as a code owner July 21, 2026 20:23

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 1 file

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Re-trigger cubic

@jasonsaayman jasonsaayman added the commit::fix The PR is related to a bugfix label Jul 29, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread lib/adapters/http.js
@greptile-apps

greptile-apps Bot commented Aug 4, 2026

Copy link
Copy Markdown

Greptile Summary

This change updates the Node HTTP adapter so pooled keep-alive sockets use one module-scoped error listener while each socket tracks its currently active request. Focused runtime checks confirmed that socket errors are delivered only to the active request after pooled-socket reassignment, and that separate sockets share the listener without cross-request failure handling. The two targeted Node HTTP adapter regression tests passed.

Confidence Score: 5/5

Reviews (1): Last reviewed commit: "fix(http): validate shared socket error ..." | Re-trigger Greptile

@jasonsaayman
jasonsaayman merged commit e8147e6 into axios:v1.x Aug 4, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

commit::fix The PR is related to a bugfix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants