From 9c3c6ef85bc7d9eb34df25f1fd482644215e40cc Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Wed, 23 Sep 2026 18:55:54 -0700 Subject: [PATCH] fix(net): honor ipv4first dns result order in dual-stack lookup Fixes: https://github.com/microsoft/playwright/issues/42799 --- packages/utils/network.ts | 6 ++++-- tests/library/network-timeout.spec.ts | 31 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/utils/network.ts b/packages/utils/network.ts index 3ba25fbb4d180..d121ffda9e9f8 100644 --- a/packages/utils/network.ts +++ b/packages/utils/network.ts @@ -167,11 +167,13 @@ export function createProxyAgent(proxy?: ProxySettings, forUrl?: URL) { // ::1 is served. Separate family: 4 and family: 6 lookups do not have these problems. Native // Happy Eyeballs (autoSelectFamily) then races connection attempts across the families. const dualStackLookup: net.LookupFunction = (hostname, options, callback) => { - const families = options.family === 4 || options.family === 6 ? [options.family] : [6, 4]; + // Honor --dns-result-order=ipv4first, e.g. on networks where IPv6 is configured but not routed. + const defaultFamilies = dns.getDefaultResultOrder() === 'ipv4first' ? [4, 6] : [6, 4]; + const families = options.family === 4 || options.family === 6 ? [options.family] : defaultFamilies; void Promise.allSettled(families.map(family => dns.promises.lookup(hostname, { all: true, family }))).then(results => { const perFamily = results.map(result => result.status === 'fulfilled' ? result.value : []); const addresses: dns.LookupAddress[] = []; - // Alternate IPv6 and IPv4 addresses per RFC 8305 (prefer IPv6 first). + // Alternate address families per RFC 8305. for (let i = 0; i < Math.max(...perFamily.map(list => list.length)); i++) { for (const list of perFamily) { if (list[i]) diff --git a/tests/library/network-timeout.spec.ts b/tests/library/network-timeout.spec.ts index c73e388fefee3..0f120eca91355 100644 --- a/tests/library/network-timeout.spec.ts +++ b/tests/library/network-timeout.spec.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import dns from 'dns'; import http from 'http'; import { browserTest as test, expect } from '../config/browserTest'; import { utils } from '../../packages/playwright-core/lib/coreBundle.js'; @@ -64,3 +65,33 @@ test('httpRequest should honor socketTimeout for stalled responses', async () => server.close(); } }); + +test('httpRequest should honor ipv4first result order', async () => { + const server = http.createServer((req, res) => res.end('ok')); + await new Promise(f => server.listen(0, '127.0.0.1', () => f())); + const port = (server.address() as import('node:net').AddressInfo).port; + const originalLookup = dns.promises.lookup; + const originalOrder = dns.getDefaultResultOrder(); + // Emulate a host whose IPv6 address is not routable: connecting to it stalls. + (dns.promises as any).lookup = async (hostname: string, options: dns.LookupOptions) => { + if (hostname !== 'dual-stack.test') + return originalLookup(hostname, options as dns.LookupAllOptions); + return options.family === 6 ? [{ address: '203.0.113.1', family: 4 }] : [{ address: '127.0.0.1', family: 4 }]; + }; + dns.setDefaultResultOrder('ipv4first'); + try { + const start = Date.now(); + const status = await new Promise(resolve => { + utils.httpRequest({ + url: `http://dual-stack.test:${port}/`, + socketTimeout: 30000, + }, response => resolve(response.statusCode!), resolve); + }); + expect(status).toBe(200); + expect(Date.now() - start).toBeLessThan(2000); + } finally { + dns.setDefaultResultOrder(originalOrder); + dns.promises.lookup = originalLookup; + server.close(); + } +});