Skip to content

http: fix keylog listener setup on existing agent sockets - #65066

Open
shani-singh1 wants to merge 1 commit into
nodejs:mainfrom
shani-singh1:http-agent-keylog-existing-sockets
Open

http: fix keylog listener setup on existing agent sockets#65066
shani-singh1 wants to merge 1 commit into
nodejs:mainfrom
shani-singh1:http-agent-keylog-existing-sockets

Conversation

@shani-singh1

Copy link
Copy Markdown

maybeEnableKeylog() runs as the agent's 'newListener' handler and attaches the agent's keylog handler to the sockets the agent already owns:

// Existing sockets will start listening on keylog now.
const sockets = ObjectValues(this.sockets);
for (let i = 0; i < sockets.length; i++) {
  sockets[i].on('keylog', this[kOnKeylog]);
}

agent.sockets maps a name to an array of sockets, so ObjectValues() yields arrays, not sockets, and .on() is called on an array. Agent.prototype.destroy() in the same file gets this right with a nested walk over both maps.

Two things follow. Adding a 'keylog' listener to an agent that already owns a socket throws, and because the throw happens inside the 'newListener' handler it propagates out of agent.on() before the listener is stored, so the caller gets an exception and no listener. Separately, agent.freeSockets is never visited, so idle keep-alive sockets never start listening even once the crash is out of the way.

Reproduction

const http = require('http');

const server = http.createServer((req, res) => setTimeout(() => res.end('ok'), 300));

server.listen(0, '127.0.0.1', () => {
  const agent = new http.Agent({ keepAlive: true });
  const req = http.get({ host: '127.0.0.1', port: server.address().port, agent }, (res) => res.resume());
  req.on('error', () => {});
  req.on('socket', () => setImmediate(() => {
    console.log('bucket is an Array :', Array.isArray(Object.values(agent.sockets)[0]));
    try {
      agent.on('keylog', () => {});
      console.log('agent.on("keylog") : OK');
    } catch (err) {
      console.log('agent.on("keylog") : THREW ' + err.constructor.name + ': ' + err.message);
    }
    console.log('keylog listeners   :', agent.listenerCount('keylog'), '(expected 1)');
    agent.destroy();
    server.close();
    process.exit(0);
  }));
});

On v24.11.1:

bucket is an Array : true
agent.on("keylog") : THREW TypeError: sockets[i].on is not a function
keylog listeners   : 0 (expected 1)

The documented 'keylog' event on https.Agent is therefore unusable on any agent that has already opened a socket, which is the normal case for a long-lived agent. The existing coverage in test/parallel/test-https-agent-keylog.js registers the listener on https.globalAgent before any request is made, so agent.sockets is empty and the loop body never runs.

Change

Walk both maps the same way Agent.prototype.destroy() does.

Verification

Running the old and new loop bodies against a real http.Agent holding one idle socket and one in-flight socket:

sockets in freeSockets : 1
sockets in sockets     : 1
total existing sockets : 2
OLD loop               : THREW TypeError: sockets[i].on is not a function
NEW loop               : no throw
sockets wired by NEW   : 2 / 2  (expected all)

The added test uses two servers so the sockets get different names and one stays parked in freeSockets rather than being reused. It fails on main with the TypeError above at the agent.on('keylog', ...) line, and passes with this change.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/http
  • @nodejs/net

@nodejs-github-bot nodejs-github-bot added http Issues or PRs related to the http subsystem. needs-ci PRs that need a full CI run. labels Aug 6, 2026
@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.29%. Comparing base (51c0d94) to head (898e685).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #65066      +/-   ##
==========================================
- Coverage   90.30%   90.29%   -0.02%     
==========================================
  Files         759      759              
  Lines      247644   247651       +7     
  Branches    46687    46688       +1     
==========================================
- Hits       223645   223608      -37     
- Misses      15474    15514      +40     
- Partials     8525     8529       +4     
Files with missing lines Coverage Δ
lib/_http_agent.js 96.59% <100.00%> (+0.46%) ⬆️

... and 32 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@pimterry pimterry left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nice work, thanks for fixing this @shani-singh1!

Commit linting is failing as there's no sign-off trailer, so you'll need to git commit --amend -s and then force push to update it.

`maybeEnableKeylog()` runs as the agent's `'newListener'` handler and
attaches the agent's keylog handler to the sockets the agent already
owns. `agent.sockets` maps a name to an array of sockets, but the loop
treated those arrays as sockets and called `.on()` on them.

Adding a `'keylog'` listener to an agent that already owned a socket
therefore threw `TypeError: sockets[i].on is not a function` out of
`agent.on('keylog', ...)`. Since the throw happened inside the
`'newListener'` handler it propagated before the listener was stored,
so the caller got an exception and no listener. Sockets parked in
`agent.freeSockets` were never visited at all.

Walk both maps the way `Agent.prototype.destroy()` does.

Signed-off-by: Shani Singh <teamdeveloperworld@gmail.com>
@shani-singh1
shani-singh1 force-pushed the http-agent-keylog-existing-sockets branch from 898e685 to 9058cd0 Compare August 6, 2026 11:18
@shani-singh1

Copy link
Copy Markdown
Author

Thanks for the review!

Amended with -s and force pushed, so the head is now 9058cd0 and carries the sign-off trailer. I applied the same to my three other open PRs (#65064, #65065, #65067) since they would have hit the same lint failure once CI ran on them.

@pimterry pimterry added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

http Issues or PRs related to the http subsystem. needs-ci PRs that need a full CI run. request-ci Add this label to start a Jenkins CI on a PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants