From 5d63356c6b40ccda396a60438da38b86602fba5c Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Thu, 25 Jun 2026 11:22:45 +0200 Subject: [PATCH 1/2] dgram: skip dns.lookup() for literal IP addresses Every unconnected send(), and the implicit bind on first send, resolved the destination through dns.lookup() even when it was already a literal IP. The address resolves to itself, so the call is redundant, and tools that instrument dns.lookup() record a lookup for every datagram sent to an IP. Skip the resolver for a literal IP of the socket's family and report it on the next tick, keeping dns.lookup()'s asynchronous contract. A custom lookup function is still consulted for every address. Refs: https://github.com/DataDog/dd-trace-js/issues/2984 Signed-off-by: Ruben Bridgewater --- benchmark/dgram/send-to-ip.js | 43 ++++++++++ doc/api/dgram.md | 2 + lib/internal/dgram.js | 20 +++-- test/parallel/test-dgram-custom-lookup.js | 14 ++-- test/parallel/test-dgram-default-lookup-ip.js | 80 +++++++++++++++++++ .../test-dgram-implicit-bind-failure.js | 13 +-- 6 files changed, 155 insertions(+), 17 deletions(-) create mode 100644 benchmark/dgram/send-to-ip.js create mode 100644 test/parallel/test-dgram-default-lookup-ip.js diff --git a/benchmark/dgram/send-to-ip.js b/benchmark/dgram/send-to-ip.js new file mode 100644 index 00000000000000..8704462cf8d8bd --- /dev/null +++ b/benchmark/dgram/send-to-ip.js @@ -0,0 +1,43 @@ +// Measure the send rate to a literal IP destination. The destination needs no +// name resolution, so this isolates the per-send overhead the default lookup +// pays before the packet reaches the socket. +'use strict'; + +const common = require('../common.js'); +const dgram = require('dgram'); +const PORT = common.PORT; + +// `n` is the number of send requests queued each round. Keep it high (>10) so +// the measurement reflects send overhead rather than event loop cycles. +const bench = common.createBenchmark(main, { + n: [100], + dur: [5], +}); + +function main({ dur, n }) { + const chunk = Buffer.allocUnsafe(1); + let sent = 0; + const socket = dgram.createSocket('udp4'); + + function onsend() { + if (sent++ % n === 0) { + setImmediate(() => { + for (let i = 0; i < n; i++) { + socket.send(chunk, PORT, '127.0.0.1', onsend); + } + }); + } + } + + socket.on('listening', () => { + bench.start(); + onsend(); + + setTimeout(() => { + bench.end(sent); + process.exit(0); + }, dur * 1000); + }); + + socket.bind(PORT); +} diff --git a/doc/api/dgram.md b/doc/api/dgram.md index 3143774fb2b183..2113cbc442269f 100644 --- a/doc/api/dgram.md +++ b/doc/api/dgram.md @@ -1039,6 +1039,8 @@ changes: * `recvBufferSize` {number} Sets the `SO_RCVBUF` socket value. * `sendBufferSize` {number} Sets the `SO_SNDBUF` socket value. * `lookup` {Function} Custom lookup function. **Default:** [`dns.lookup()`][]. + When the default is used, a literal IP address of the socket's family + resolves to itself without calling [`dns.lookup()`][]. * `signal` {AbortSignal} An AbortSignal that may be used to close a socket. * `receiveBlockList` {net.BlockList} `receiveBlockList` can be used for discarding inbound datagram to specific IP addresses, IP ranges, or IP subnets. This does not diff --git a/lib/internal/dgram.js b/lib/internal/dgram.js index bae5da1c1f0def..3e477a6a798f9c 100644 --- a/lib/internal/dgram.js +++ b/lib/internal/dgram.js @@ -8,6 +8,7 @@ const { const { codes: { ERR_SOCKET_BAD_TYPE, } } = require('internal/errors'); +const { isIP } = require('internal/net'); const { UDP } = internalBinding('udp_wrap'); const { guessHandleType } = require('internal/util'); const { @@ -28,13 +29,22 @@ function lookup6(lookup, address, callback) { return lookup(address || '::1', 6, callback); } +// A literal IP of the socket's family resolves to itself, so skip dns.lookup(). +// Defer with nextTick to keep the callback async (e.g. bind()'s 'listening'). +function defaultLookup(address, family, callback) { + if (isIP(address) === family) { + process.nextTick(callback, null, address, family); + return; + } + if (dns === undefined) { + dns = require('dns'); + } + return dns.lookup(address, family, callback); +} + function newHandle(type, lookup) { if (lookup === undefined) { - if (dns === undefined) { - dns = require('dns'); - } - - lookup = dns.lookup; + lookup = defaultLookup; } else { validateFunction(lookup, 'lookup'); } diff --git a/test/parallel/test-dgram-custom-lookup.js b/test/parallel/test-dgram-custom-lookup.js index 4f80451c526625..7a2bf92187bda8 100644 --- a/test/parallel/test-dgram-custom-lookup.js +++ b/test/parallel/test-dgram-custom-lookup.js @@ -4,10 +4,12 @@ const assert = require('assert'); const dgram = require('dgram'); const dns = require('dns'); +const originalLookup = dns.lookup; + { // Verify that the provided lookup function is called. const lookup = common.mustCall((host, family, callback) => { - dns.lookup(host, family, callback); + originalLookup(host, family, callback); }); const socket = dgram.createSocket({ type: 'udp4', lookup }); @@ -18,17 +20,17 @@ const dns = require('dns'); } { - // Verify that lookup defaults to dns.lookup(). - const originalLookup = dns.lookup; - + // Verify that the default lookup forwards host names to dns.lookup(). dns.lookup = common.mustCall((host, family, callback) => { dns.lookup = originalLookup; - originalLookup(host, family, callback); + assert.strictEqual(host, 'example.invalid'); + assert.strictEqual(family, 4); + callback(null, '127.0.0.1', 4); }); const socket = dgram.createSocket({ type: 'udp4' }); - socket.bind(common.mustCall(() => { + socket.bind(0, 'example.invalid', common.mustCall(() => { socket.close(); })); } diff --git a/test/parallel/test-dgram-default-lookup-ip.js b/test/parallel/test-dgram-default-lookup-ip.js new file mode 100644 index 00000000000000..826ac7582aac1b --- /dev/null +++ b/test/parallel/test-dgram-default-lookup-ip.js @@ -0,0 +1,80 @@ +'use strict'; + +// The default dgram lookup resolves a literal IP address of the socket's own +// family to itself, without calling dns.lookup(). Each case below stubs the +// process-global dns.lookup(), so they run sequentially to keep one case from +// observing another's stub. + +const common = require('../common'); +const assert = require('assert'); +const dgram = require('dgram'); +const dns = require('dns'); + +const originalLookup = dns.lookup; + +function ipv4SendSkipsLookup(next) { + dns.lookup = common.mustNotCall('dns.lookup() ran for an IPv4 literal'); + + const receiver = dgram.createSocket('udp4'); + const sender = dgram.createSocket('udp4'); + + receiver.on('message', common.mustCall((msg) => { + assert.strictEqual(msg.toString(), 'payload'); + dns.lookup = originalLookup; + receiver.close(); + sender.close(); + next(); + })); + + receiver.bind(0, '127.0.0.1', common.mustCall(() => { + sender.send('payload', receiver.address().port, '127.0.0.1', common.mustCall()); + })); +} + +function ipv6BindSkipsLookup(next) { + if (!common.hasIPv6) { + next(); + return; + } + + dns.lookup = common.mustNotCall('dns.lookup() ran for an IPv6 literal'); + + const socket = dgram.createSocket('udp6'); + + socket.bind(0, '::1', common.mustCall(() => { + dns.lookup = originalLookup; + socket.close(); + next(); + })); +} + +function mismatchedFamilyFallsThrough(next) { + // '::1' is not an IPv4 literal, so a udp4 socket still resolves it via + // dns.lookup() rather than short-circuiting. + dns.lookup = common.mustCall((host, family, callback) => { + dns.lookup = originalLookup; + assert.strictEqual(host, '::1'); + assert.strictEqual(family, 4); + callback(null, '127.0.0.1', 4); + }); + + const socket = dgram.createSocket('udp4'); + + socket.bind(0, '::1', common.mustCall(() => { + socket.close(); + next(); + })); +} + +const cases = [ + ipv4SendSkipsLookup, + ipv6BindSkipsLookup, + mismatchedFamilyFallsThrough, +]; + +(function runNext() { + const testCase = cases.shift(); + if (testCase !== undefined) { + testCase(runNext); + } +})(); diff --git a/test/sequential/test-dgram-implicit-bind-failure.js b/test/sequential/test-dgram-implicit-bind-failure.js index 89da00d5766fb3..ec00022c693730 100644 --- a/test/sequential/test-dgram-implicit-bind-failure.js +++ b/test/sequential/test-dgram-implicit-bind-failure.js @@ -4,19 +4,20 @@ const common = require('../common'); const assert = require('assert'); const EventEmitter = require('events'); const dgram = require('dgram'); -const dns = require('dns'); const { kStateSymbol } = require('internal/dgram'); const mockError = new Error('fake DNS'); -// Monkey patch dns.lookup() so that it always fails. -dns.lookup = function(address, family, callback) { +const socket = dgram.createSocket('udp4'); + +// Fail the implicit bind by making the handle's address resolution fail. A +// literal bind address is not passed to dns.lookup(), so patching dns.lookup() +// would not be observed here. +socket[kStateSymbol].handle.lookup = function(address, callback) { process.nextTick(() => { callback(mockError); }); }; -const socket = dgram.createSocket('udp4'); - socket.on(EventEmitter.errorMonitor, common.mustCall((err) => { - // The DNS lookup should fail since it is monkey patched. At that point in + // The bind should fail since the lookup is monkey patched. At that point in // time, the send queue should be populated with the send() operation. assert.strictEqual(err, mockError); assert(Array.isArray(socket[kStateSymbol].queue)); From 8dbffe4c2de413a8c59674210c1a558d4cc83bc0 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Thu, 25 Jun 2026 11:28:58 +0200 Subject: [PATCH 2/2] dgram: skip the custom lookup for literal IP addresses A user-supplied lookup function is no longer called when the destination is a literal IP of the socket's family; the address is used directly, matching net.connect(), which skips the lookup for a literal IP host before consulting options.lookup. This is a breaking change for a lookup that expected to be invoked for IP addresses. Refs: https://github.com/DataDog/dd-trace-js/issues/2984 Signed-off-by: Ruben Bridgewater --- doc/api/dgram.md | 4 +-- lib/internal/dgram.js | 35 ++++++++++++----------- test/parallel/test-dgram-custom-lookup.js | 18 ++++++++++-- 3 files changed, 36 insertions(+), 21 deletions(-) diff --git a/doc/api/dgram.md b/doc/api/dgram.md index 2113cbc442269f..d78d032fb55125 100644 --- a/doc/api/dgram.md +++ b/doc/api/dgram.md @@ -1039,8 +1039,8 @@ changes: * `recvBufferSize` {number} Sets the `SO_RCVBUF` socket value. * `sendBufferSize` {number} Sets the `SO_SNDBUF` socket value. * `lookup` {Function} Custom lookup function. **Default:** [`dns.lookup()`][]. - When the default is used, a literal IP address of the socket's family - resolves to itself without calling [`dns.lookup()`][]. + A literal IP address of the socket's family resolves to itself; the lookup + function is not called for it. * `signal` {AbortSignal} An AbortSignal that may be used to close a socket. * `receiveBlockList` {net.BlockList} `receiveBlockList` can be used for discarding inbound datagram to specific IP addresses, IP ranges, or IP subnets. This does not diff --git a/lib/internal/dgram.js b/lib/internal/dgram.js index 3e477a6a798f9c..a332adaca12cb9 100644 --- a/lib/internal/dgram.js +++ b/lib/internal/dgram.js @@ -19,32 +19,35 @@ const { UV_EINVAL } = internalBinding('uv'); const kStateSymbol = Symbol('state symbol'); let dns; // Lazy load for startup performance. +function lookupOrSkip(lookup, address, family, callback) { + if (isIP(address) === family) { + process.nextTick(callback, null, address, family); + return; + } + return lookup(address, family, callback); +} function lookup4(lookup, address, callback) { - return lookup(address || '127.0.0.1', 4, callback); + if (address) { + return lookupOrSkip(lookup, address, 4, callback); + } + process.nextTick(callback, null, '127.0.0.1', 4); } - function lookup6(lookup, address, callback) { - return lookup(address || '::1', 6, callback); -} - -// A literal IP of the socket's family resolves to itself, so skip dns.lookup(). -// Defer with nextTick to keep the callback async (e.g. bind()'s 'listening'). -function defaultLookup(address, family, callback) { - if (isIP(address) === family) { - process.nextTick(callback, null, address, family); - return; + if (address) { + return lookupOrSkip(lookup, address, 6, callback); } - if (dns === undefined) { - dns = require('dns'); - } - return dns.lookup(address, family, callback); + process.nextTick(callback, null, '::1', 6); } function newHandle(type, lookup) { if (lookup === undefined) { - lookup = defaultLookup; + if (dns === undefined) { + dns = require('dns'); + } + + lookup = dns.lookup; } else { validateFunction(lookup, 'lookup'); } diff --git a/test/parallel/test-dgram-custom-lookup.js b/test/parallel/test-dgram-custom-lookup.js index 7a2bf92187bda8..0dff5e22d29da5 100644 --- a/test/parallel/test-dgram-custom-lookup.js +++ b/test/parallel/test-dgram-custom-lookup.js @@ -9,18 +9,30 @@ const originalLookup = dns.lookup; { // Verify that the provided lookup function is called. const lookup = common.mustCall((host, family, callback) => { - originalLookup(host, family, callback); + assert.strictEqual(host, 'example.invalid'); + callback(null, '127.0.0.1', 4); }); const socket = dgram.createSocket({ type: 'udp4', lookup }); - socket.bind(common.mustCall(() => { + socket.bind(0, 'example.invalid', common.mustCall(() => { + socket.close(); + })); +} + +{ + // IPs resolve to themselves, so a custom lookup is not called. + const lookup = common.mustNotCall('lookup ran for a literal IP address'); + + const socket = dgram.createSocket({ type: 'udp4', lookup }); + + socket.bind(0, '127.0.0.1', common.mustCall(() => { socket.close(); })); } { - // Verify that the default lookup forwards host names to dns.lookup(). + // Verify that lookup defaults to dns.lookup(). dns.lookup = common.mustCall((host, family, callback) => { dns.lookup = originalLookup; assert.strictEqual(host, 'example.invalid');