Skip to content

[Bug] Gateway crashes with EADDRNOTAVAIL on IPv6 api.anthropic.com resolution — pinned-lookup retains AAAA records, emits unhandled TLSSocket 'error' bypassing uncaughtException benign-list #80078

Description

@takamasa-aiso

[Bug] Gateway crashes with EADDRNOTAVAIL on IPv6 api.anthropic.com resolution — pinned-lookup round-robin emits unhandled TLSSocket 'error' bypassing uncaughtException benign-list

Heads up

Reporting only — I'm not in a position to actively follow up, test patches, or respond to clarifying questions. Feel free to close as not-actionable if more info is needed; I'd rather you have the diagnostics on record than nothing. All the info I have is below.

Summary

On a host where IPv6 is resolvable via DNS but globally unreachable (default WSL2, IPv6-restricted corporate/ISP networks, IPv6-disabled hosts), the OpenClaw Gateway repeatedly crashes when calling api.anthropic.com. The crash signature is an unhandled 'error' event on a TLSSocket instance with errno: -99 EADDRNOTAVAIL (or -101 ENETUNREACH depending on whether the kernel has IPv6 disabled).

Root cause is a combination of three issues:

  1. SSRF guard's createPinnedLookup retains AAAA records without reachability checks and round-robins across families on subsequent calls — once the pinned-lookup hands out an IPv6 address (via round-robin index advancement, or via a family-specific lookup request from a downstream caller like undici), the connection fails in any IPv6-unreachable environment. Reproducible on every multi-call session against a dual-stack host like api.anthropic.com.
  2. isBenignUncaughtExceptionError regex omits EADDRNOTAVAIL — the same code path that catches ENETUNREACH lets EADDRNOTAVAIL through.
  3. The TLSSocket 'error' event from lookupAndConnect doesn't reach OpenClaw's uncaughtException handler — the crash output has no [openclaw] Uncaught exception: prefix, so the registered handler isn't being invoked. Process is terminated by Node's default EventEmitter node:events:487 throw before OpenClaw can intercept.

This is a regression from #60515 / #67944 / #71529 — those fixes addressed the Telegram code path and added isTransientNetworkError to the global handler, but neither covers the SSRF guard's TLSSocket emission path nor adds EADDRNOTAVAIL to the benign list.

api.anthropic.com is an unusually reliable trigger because every OpenClaw agent run hits it (built-in Anthropic provider) and it's dual-stacked with 2607:6bc0::10.

Environment

  • OpenClaw: 2026.5.7 (eeef486) (current latest at time of report)
  • Node: v24.15.0
  • OS: Ubuntu 24.04 on WSL2 (Windows 11), Linux 5.15.167.4-microsoft-standard-WSL2
  • glibc: 2.39-0ubuntu8.7
  • Service unit: user-level openclaw-gateway.service
  • Network: IPv6 routable to public destinations: NO (no IPv6 default route, only fe80::/64 link-local on eth0). DNS resolves AAAA records normally.

Steps to reproduce

  1. Run OpenClaw Gateway on any host where:
    • DNS returns AAAA records (verify: dig AAAA api.anthropic.com +short returns 2607:6bc0::10)
    • The host has no usable global IPv6 route (verify: ip -6 route shows no default; curl -6 -s --max-time 3 https://api.anthropic.com >/dev/null; echo $? returns non-zero)
  2. Trigger any agent run that calls Anthropic. Easiest: any cron job that uses gpt-5.5/claude-* and makes ≥2 outbound API calls (Daily News built from the schedule skill works).
  3. The first connection succeeds (pinned-lookup index 0 → IPv4 160.79.104.10). The second connection (pinned-lookup index 1 → IPv6 2607:6bc0::10) emits an unhandled TLSSocket 'error' and crashes the gateway.

100% reproducible in the env above. Gateway is then restarted by systemd, in-flight cron jobs get marked cron: job interrupted by gateway restart (related: #77298consecutiveErrors counter inflates on these gateway-restart interruptions).

Stack trace (journalctl, redacted)

node:events:487
      throw er; // Unhandled 'error' event
      ^
Error: connect EADDRNOTAVAIL 2607:6bc0::10:443 - Local (:::0)
    at internalConnect (node:net:1169:16)
    at defaultTriggerAsyncIdScope (node:internal/async_hooks:472:18)
    at emitLookup (node:net:1491:9)
    at file:///<NODE_MODULES>/openclaw/dist/ssrf-B5bGsnx-.js:207:3
    at node:net:1468:5
    at defaultTriggerAsyncIdScope (node:internal/async_hooks:472:18)
    at lookupAndConnect (node:net:1467:3)
    at Socket.connect (node:net:1344:5)
    at Object.connect (node:internal/tls/wrap:1782:13)
    at connect (<NODE_MODULES>/openclaw/node_modules/undici/lib/core/connect.js:86:20)
Emitted 'error' event on TLSSocket instance at:
    at emitErrorNT (node:internal/streams/destroy:170:8)
    at emitErrorCloseNT (node:internal/streams/destroy:129:3)
    at process.processTicksAndRejections (node:internal/process/task_queues:90:21) {
  errno: -99,
  code: 'EADDRNOTAVAIL',
  syscall: 'connect',
  address: '2607:6bc0::10',
  port: 443
}
Node.js v24.15.0

systemd then logs Main process exited, code=exited, status=1/FAILURE. Crucially, there is no [openclaw] Uncaught exception: line preceding this, indicating the registered uncaughtException handler in dist/index.js did not fire.

(With kernel IPv6 still enabled, the code is ENETUNREACH (-101) instead of EADDRNOTAVAIL (-99). Same crash path otherwise.)

Root cause analysis

Issue 1 — createPinnedLookup round-robins across families without reachability/preference

dist/ssrf-B5bGsnx-.js:185-208 (line numbers from a built bundle, search for createPinnedLookup):

const records = params.addresses.map((address) => ({
  address,
  family: address.includes(":") ? 6 : 4
}));
let index = 0;
return ((host, options, callback) => {
  // ...
  const candidates = requestedFamily === 4 || requestedFamily === 6
    ? records.filter((entry) => entry.family === requestedFamily)
    : records;
  const usable = candidates.length > 0 ? candidates : records;
  if (opts.all) { cb(null, usable); return; }
  const chosen = usable[index % usable.length];   // ← round-robin
  index += 1;
  cb(null, chosen.address, chosen.family);
});

dedupeAndPreferIpv4 is run upstream so records[0] is IPv4 — but index increments across calls, so the 2nd call returns the IPv6 entry. There's no:

  • IPv6 reachability check before adding the AAAA record to records
  • Family preference (always returning the first entry, retrying on failure)
  • Happy-Eyeballs-style fallback within the lookup itself

For any dual-stack host accessed multiple times in a session on an IPv6-unreachable network, this reliably produces periodic crashes (the workaround RES_OPTIONS=no-aaaa, which strips AAAA records before they reach records[], eliminates them entirely — strong evidence the AAAA retention is the trigger).

Issue 2 — BENIGN_UNCAUGHT_EXCEPTION_NETWORK_MESSAGE_CODE_RE omits EADDRNOTAVAIL

dist/unhandled-rejections--a3kG4I0.js:90:

const BENIGN_UNCAUGHT_EXCEPTION_NETWORK_MESSAGE_CODE_RE =
  /\b(ECONNREFUSED|EHOSTUNREACH|ENETUNREACH|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT|UND_ERR_DNS_RESOLVE_FAILED|UND_ERR_CONNECT)\b/i;

EADDRNOTAVAIL is missing. So even if the error reaches OpenClaw's uncaughtException handler (it doesn't, see Issue 3), it would still be treated as fatal.

Issue 3 — TLSSocket 'error' event bypasses the uncaughtException handler

The handler registered in dist/index.js is:

process.on("uncaughtException", (error) => {
  if (isUncaughtExceptionHandled(error)) return;
  if (isBenignUncaughtExceptionError(error)) {
    console.warn("[openclaw] Non-fatal uncaught exception (continuing):", formatUncaughtError(error));
    return;
  }
  console.error("[openclaw] Uncaught exception:", formatUncaughtError(error));
  // ... runFatalErrorHooks, restoreTerminalState ...
  process.exit(1);
});

When the gateway crashes from this code path, the journal output contains the raw node:events:487 throw er; trace and Node.js v24.15.0 footer — never the [openclaw] prefixes from the lines above. This means the uncaughtException handler isn't invoked at all on this path. Best guess: the synchronous throw inside the EventEmitter's error handling is happening in a context where process.on('uncaughtException') isn't reached, or there's an earlier listener calling process.exit first. Either way, the protective layer above (isBenignUncaughtExceptionError) is never consulted.

Workaround that works in production

# ~/.config/systemd/user/openclaw-gateway.service.d/10-env.conf
[Service]
Environment="RES_OPTIONS=no-aaaa"

This invokes glibc 2.36+'s no-aaaa resolver option, which suppresses AAAA queries entirely. Node's dns.lookup then receives only A records, so createPinnedLookup's records array contains only IPv4 entries — round-robin can never produce an IPv6 address.

Verified locally: a previously crash-looping Daily News cron with consecutiveErrors: 4 ran to completion in 149.7s (status: ok, delivered: true) on the very first attempt after applying this. Multi-day stable since.

This is not a fix — the bug above remains — but it removes the trigger.

Things that don't work (verified):

  • Disabling IPv6 at the WSL2 kernel level (net.ipv6.conf.all.disable_ipv6 = 1) — only changes errno: -101 → -99, same crash.
  • NODE_OPTIONS=--no-network-family-autoselection — disables Happy Eyeballs in net.connect but doesn't affect the SSRF guard's pre-resolved cache; the pinned lookup still hands out IPv6 on round-robin index 1.
  • NODE_OPTIONS=--dns-result-order=ipv4first — only sorts results, doesn't filter. The SSRF guard requests { all: true } and gets both families regardless.

Suggested fixes

In rough priority order:

  1. Add EADDRNOTAVAIL to the benign regex (dist/unhandled-rejections-*.js) — one-character fix, low risk:

    - /\b(ECONNREFUSED|EHOSTUNREACH|ENETUNREACH|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT|UND_ERR_DNS_RESOLVE_FAILED|UND_ERR_CONNECT)\b/i
    + /\b(ECONNREFUSED|EHOSTUNREACH|ENETUNREACH|EADDRNOTAVAIL|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT|UND_ERR_DNS_RESOLVE_FAILED|UND_ERR_CONNECT)\b/i
  2. Investigate why TLSSocket 'error' bypasses uncaughtException — even with (1), if the handler isn't called, the benign list is irrelevant. Likely needs an earlier socket.on('error', ...) registration in the SSRF guard's connect wrapper, or a process.on('uncaughtException', ...) registered with the right priority.

  3. Family-preference + reachability in createPinnedLookup — instead of round-robin across all families, prefer IPv4 by default, fall through to IPv6 only on IPv4 failure, and run a one-shot reachability probe (or use net.getDefaultAutoSelectFamilyAttemptTimeout semantics) before adding AAAA records to the pinned cache. The current round-robin is fragile on heterogeneous-reachability networks (which includes most WSL2 hosts and a non-trivial slice of corporate/ISP environments).

  4. Add RES_OPTIONS=no-aaaa (or equivalent) to docs as a documented escape hatch for IPv6-unreachable environments, until 1–3 land.

Related issues

That's everything I've found. Hope it's useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions