[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:
- 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.
isBenignUncaughtExceptionError regex omits EADDRNOTAVAIL — the same code path that catches ENETUNREACH lets EADDRNOTAVAIL through.
- 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
- 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)
- 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).
- 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: #77298 — consecutiveErrors 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:
-
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
-
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.
-
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).
-
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.
[Bug] Gateway crashes with EADDRNOTAVAIL on IPv6
api.anthropic.comresolution — pinned-lookup round-robin emits unhandled TLSSocket 'error' bypassing uncaughtException benign-listHeads 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 aTLSSocketinstance witherrno: -99 EADDRNOTAVAIL(or-101 ENETUNREACHdepending on whether the kernel has IPv6 disabled).Root cause is a combination of three issues:
createPinnedLookupretains 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 likeapi.anthropic.com.isBenignUncaughtExceptionErrorregex omitsEADDRNOTAVAIL— the same code path that catchesENETUNREACHletsEADDRNOTAVAILthrough.TLSSocket'error'event fromlookupAndConnectdoesn't reach OpenClaw'suncaughtExceptionhandler — the crash output has no[openclaw] Uncaught exception:prefix, so the registered handler isn't being invoked. Process is terminated by Node's default EventEmitternode:events:487throw before OpenClaw can intercept.This is a regression from #60515 / #67944 / #71529 — those fixes addressed the Telegram code path and added
isTransientNetworkErrorto the global handler, but neither covers the SSRF guard's TLSSocket emission path nor addsEADDRNOTAVAILto the benign list.api.anthropic.comis an unusually reliable trigger because every OpenClaw agent run hits it (built-in Anthropic provider) and it's dual-stacked with2607:6bc0::10.Environment
2026.5.7 (eeef486)(current latest at time of report)v24.15.0Linux 5.15.167.4-microsoft-standard-WSL22.39-0ubuntu8.7openclaw-gateway.servicefe80::/64link-local oneth0). DNS resolves AAAA records normally.Steps to reproduce
dig AAAA api.anthropic.com +shortreturns2607:6bc0::10)ip -6 routeshows no default;curl -6 -s --max-time 3 https://api.anthropic.com >/dev/null; echo $?returns non-zero)gpt-5.5/claude-*and makes ≥2 outbound API calls (Daily News built from the schedule skill works).160.79.104.10). The second connection (pinned-lookup index 1 → IPv62607:6bc0::10) emits an unhandledTLSSocket'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: #77298 —consecutiveErrorscounter inflates on these gateway-restart interruptions).Stack trace (journalctl, redacted)
systemd then logs
Main process exited, code=exited, status=1/FAILURE. Crucially, there is no[openclaw] Uncaught exception:line preceding this, indicating the registereduncaughtExceptionhandler indist/index.jsdid not fire.(With kernel IPv6 still enabled, the
codeisENETUNREACH (-101)instead ofEADDRNOTAVAIL (-99). Same crash path otherwise.)Root cause analysis
Issue 1 —
createPinnedLookupround-robins across families without reachability/preferencedist/ssrf-B5bGsnx-.js:185-208(line numbers from a built bundle, search forcreatePinnedLookup):dedupeAndPreferIpv4is run upstream sorecords[0]is IPv4 — butindexincrements across calls, so the 2nd call returns the IPv6 entry. There's no:recordsFor 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 reachrecords[], eliminates them entirely — strong evidence the AAAA retention is the trigger).Issue 2 —
BENIGN_UNCAUGHT_EXCEPTION_NETWORK_MESSAGE_CODE_REomitsEADDRNOTAVAILdist/unhandled-rejections--a3kG4I0.js:90:EADDRNOTAVAILis missing. So even if the error reaches OpenClaw'suncaughtExceptionhandler (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.jsis:When the gateway crashes from this code path, the journal output contains the raw
node:events:487 throw er;trace andNode.js v24.15.0footer — never the[openclaw]prefixes from the lines above. This means theuncaughtExceptionhandler isn't invoked at all on this path. Best guess: the synchronousthrowinside the EventEmitter's error handling is happening in a context whereprocess.on('uncaughtException')isn't reached, or there's an earlier listener callingprocess.exitfirst. Either way, the protective layer above (isBenignUncaughtExceptionError) is never consulted.Workaround that works in production
This invokes glibc 2.36+'s
no-aaaaresolver option, which suppresses AAAA queries entirely. Node'sdns.lookupthen receives only A records, socreatePinnedLookup'srecordsarray contains only IPv4 entries — round-robin can never produce an IPv6 address.Verified locally: a previously crash-looping
Daily Newscron withconsecutiveErrors: 4ran 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):
net.ipv6.conf.all.disable_ipv6 = 1) — only changeserrno: -101 → -99, same crash.NODE_OPTIONS=--no-network-family-autoselection— disables Happy Eyeballs innet.connectbut 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:
Add
EADDRNOTAVAILto the benign regex (dist/unhandled-rejections-*.js) — one-character fix, low risk:Investigate why TLSSocket
'error'bypassesuncaughtException— even with (1), if the handler isn't called, the benign list is irrelevant. Likely needs an earliersocket.on('error', ...)registration in the SSRF guard's connect wrapper, or aprocess.on('uncaughtException', ...)registered with the right priority.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 usenet.getDefaultAutoSelectFamilyAttemptTimeoutsemantics) 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).Add
RES_OPTIONS=no-aaaa(or equivalent) to docs as a documented escape hatch for IPv6-unreachable environments, until 1–3 land.Related issues
uncaughtExceptionlacksisTransientNetworkErrorcheck (closed; fix incomplete on this path)consecutiveErrorsincrements on gateway-restart interruptions (open; the symptom that surfaces this bug)ENETUNREACH-storm class)That's everything I've found. Hope it's useful.