Skip to content

net: socket.setTimeout() from a nextTick fires the old timeout at once and drops the new one (regression from #64254) #67047

Open

Description

@frimodig

Version

v24.19.0, v24.22.1, v26.12.1. Last good: v24.18.1.

Platform

Linux 6.10.14-linuxkit aarch64 (official Docker images). We first saw it on Linux in production.

Subsystem

timers, net

What steps will reproduce the bug?

'use strict';
const net = require('net');
const server = net.createServer(() => {}).listen(0, () => {
  const socket = net.connect(server.address().port, () => {
    let rearmedAt = 0;
    socket.on('timeout', () => {
      console.log(`${process.version}: 'timeout' ${Date.now() - rearmedAt} ms after re-arm (socket.timeout=${socket.timeout})`);
    });
    setTimeout(() => process.nextTick(() => { socket.setTimeout(1500); rearmedAt = Date.now(); }), 300);
    socket.setTimeout(300);
    setTimeout(() => { socket.destroy(); server.close(); }, 2500);
  });
});

How often does it reproduce? Is there a required condition?

Every time. The socket's timeout has to be due in the same timer pass as the timer just before it in the same list, and that timer re-arms the socket from a nextTick or microtask. A synchronous re-arm, or one from setImmediate, works.

What is the expected behavior? Why is that the expected behavior?

v24.18.1: 'timeout' 1509 ms after re-arm (socket.timeout=1500)

The re-arm replaces the 300 ms timeout, so only the new one should fire.

What do you see instead?

v24.22.1: 'timeout' 1 ms after re-arm (socket.timeout=1500)
v26.12.1: 'timeout' 0 ms after re-arm (socket.timeout=1500)

The old timeout fires at once, and the new one never fires.

Additional information

#64254 (in 24.19.0 and 26.5.0) made setStreamTimeout reuse the socket's Timeout object. listOnTimeout checks that the timer is due, runs the next tick queue, then fires it (timers.js#L623-L642). The re-arm in that tick reuses the same object (timers.js#L458-L480, called from stream_base_commons.js#L258-L267), so it still runs. Before, the cleared object was skipped.

Re-checking the timer after the ticks run fixes the repro for us:

if (ranAtLeastOneTimer) {
  runNextTicks();
  if (L.peek(list) !== timer || now - timer._idleStart < msecs)
    continue;
}

We hit this through HTTP keep-alive: a request started from a 5 s timer, on a socket idle for exactly the global agent's 5 s timeout, with req.setTimeout(), timed out at once. Passing the timeout option instead of calling req.setTimeout() avoids it.

This was investigated with a coding agent, which ran the repro on each version listed, in the official Docker images, several times each.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions