node:net Socket._unrefTimer loses the timer id, so socket timeouts can never be cleared (leaks timers and 'timeout' listeners)
What happens
In src/node/internal/internal_net.ts:
Socket.prototype._unrefTimer = function _unrefTimer(this: Socket): void {
for (let s: Socket | null = this; s != null; s = s._parentWrap) {
if (s[kTimeout] != null) {
clearTimeout(s[kTimeout] as unknown as number);
s[kTimeout] = this.setTimeout(s.timeout, (): void => {
s._onTimeout();
});
}
}
};Socket.prototype.setTimeout stores the real timer handle in this[kTimeout] and then returns this, the socket. _unrefTimer writes that return value back into s[kTimeout], which overwrites the timer handle with the socket object.
After the first _unrefTimer call:
kTimeoutholds aSocket, not a timer. Every laterclearTimeout(s[kTimeout])receives the socket and clears nothing. That includes the call in_destroy()and the one inonConnectionClosed().- Each
_unrefTimercall (on every read and write) creates a newsetTimeout(s.timeout)whose handle is immediately lost. The timers pile up and can't be cancelled. - Each call also passes a callback, so
setTimeoutadds anotheronce('timeout')listener. Any real exchange trips Node'sMaxListenersExceededWarning.
Node's implementation refreshes one existing timer instead of creating a new one per read.
Why it matters
In a Durable Object, a pending setTimeout makes the object ineligible for hibernation. A DO that opens a TLS connection, sets a socket timeout (as imapflow does by default: 300 s), reads, and then destroys the socket stays idle, non-hibernateable. It is billed for duration until eviction at 70–140 s.
We measured this with durableObjectsPeriodicGroups.activeTime:
| object | active time per minute |
|---|---|
| polls IMAP every 2 min, socket destroyed after each poll | ~33–44 s |
same class, polls over fetch() only |
~0.6 s |
Minimal reproduction
import net from 'node:net';
const socket = net.connect({ host: 'example.com', port: 80 });
socket.setTimeout(300_000);
socket.on('connect', () => socket.write('GET / HTTP/1.0\r\nHost: example.com\r\n\r\n'));
socket.on('data', () => {});
socket.on('end', () => socket.destroy());
// After destroy(), timers created by _unrefTimer are still pending, and
// socket.listenerCount('timeout') grows with the number of reads.Suggested fix
Keep the timer handle, not the return value of setTimeout, for example:
if (s[kTimeout] != null) {
clearTimeout(s[kTimeout] as unknown as number);
s[kTimeout] = setTimeout(() => s._onTimeout(), s.timeout);
}Also avoid re-registering the 'timeout' listener on every refresh.
Workaround we are using
At module scope, wrap net.Socket.prototype.setTimeout so that it always calls the original with 0. Then kTimeout stays null and _unrefTimer does nothing. The cost is that no socket in the Worker gets an inactivity timeout.
Source: cloudflare/workerd