Skip to content

fetch() does not implement Happy Eyeballs — hangs on IPv6 blackhole instead of falling back to IPv4 #36279

Description

@stack72

Summary

fetch() does not implement Happy Eyeballs (RFC 8305 / RFC 6555). When a hostname resolves to both IPv4 and IPv6 addresses and the IPv6 path is blackholed (TCP SYN accepted but no data returned), fetch() connects to the IPv6 address and waits for the full timeout — it never falls back to IPv4.

Deno.connect and Deno.connectTls were fixed in #25661 / #31726 (Deno 2.9), but fetch() still uses a code path that does not race connections across address families.

Reproduction

Self-contained script — starts an IPv4 HTTP server and an IPv6 TCP blackhole on the same port, then fetches http://localhost:<port>/:

const PORT = 19371;

// IPv4: real HTTP server that responds immediately
const ipv4Server = Deno.serve({
  hostname: "127.0.0.1",
  port: PORT,
  onListen: () => console.log(`[ipv4] listening on 127.0.0.1:${PORT}`),
}, () => new Response("ok from ipv4"));

// IPv6: accepts TCP but never sends data (simulates blackhole)
const ipv6Listener = Deno.listen({ hostname: "::1", port: PORT, transport: "tcp" });
console.log(`[ipv6] blackhole on [::1]:${PORT}`);
const conns: Deno.Conn[] = [];
const acceptLoop = (async () => {
  try {
    for await (const conn of ipv6Listener) {
      console.log("[ipv6] accepted connection (blackholing)");
      conns.push(conn);
    }
  } catch { /* closed */ }
})();

await new Promise((r) => setTimeout(r, 200));

// Test: fetch localhost (resolves to both ::1 and 127.0.0.1)
console.log("\nfetch('http://localhost:PORT/') with 5s timeout...");
const start = performance.now();
try {
  const resp = await fetch(`http://localhost:${PORT}/`, {
    signal: AbortSignal.timeout(5_000),
  });
  const body = await resp.text();
  console.log(`"${body}" in ${(performance.now() - start).toFixed(0)}ms`);
} catch (e) {
  console.log(`failed in ${(performance.now() - start).toFixed(0)}ms: ${(e as Error).message}`);
}

// Control: fetch directly to IPv4
console.log("\nfetch('http://127.0.0.1:PORT/') (control)...");
const start2 = performance.now();
const resp2 = await fetch(`http://127.0.0.1:${PORT}/`);
console.log(`"${await resp2.text()}" in ${(performance.now() - start2).toFixed(0)}ms`);

// Cleanup
for (const c of conns) try { c.close(); } catch { /* */ }
ipv6Listener.close();
await ipv4Server.shutdown();
$ deno run --allow-net repro.ts
[ipv4] listening on 127.0.0.1:19371
[ipv6] blackhole on [::1]:19371

fetch('http://localhost:PORT/') with 5s timeout...
[ipv6] accepted connection (blackholing)
failed in 5002ms: The operation was aborted due to timeout

fetch('http://127.0.0.1:PORT/') (control)...
"ok from ipv4" in 6ms

Expected

fetch() should race connections across address families per RFC 8305 — after ~250–300ms with no response from [::1], start a parallel connection to 127.0.0.1 and use whichever connects first. The request should succeed in ~300ms, not hang for the full timeout.

Actual

fetch() connects to [::1] (IPv6), waits for the full 5-second timeout, and fails. 127.0.0.1 is never attempted.

Context

Versions tested

  • Deno 2.8.3: broken
  • Deno 2.9.4: broken

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