Negative DNS cache entries become permanent under `CURLOPT_DNS_CACHE_TIMEOUT=-1`
I did this
experiment
| I did this | libcurl did this |
|---|---|
set CURLOPT_DNS_CACHE_TIMEOUT to -1 |
|
resolved a name that got NXDOMAIN |
queried DNS twice, failed, wrote negative entry |
| made the name resolvable in DNS | |
| waited 2.5 seconds | |
| resolved that same name again | did not query DNS, failed, "Negative DNS entry" |
reproduction
- Clone the following repo: https://github.com/SeanPMiller/curl-issue-negdns-permanence
- Execute the following commands:
cd curl-issue-negdns-permanence # or wherever you put the clone docker build -t curl-negdns . docker run --rm curl-negdns - Observe exit code
3, which is the failed test-case count.
AI disclosure
I used Claude Code to evaluate timeout -1 as DNS-outage resilience for a
daemon I maintain that uses libcurl. Claude found this behavior as a side
effect.
I verified the finding against the source and wrote the reproduction harness to prove it. The patch is mine, too, but Claude reviewed and tested it.
employer disclosure
My employer authorized me to publish the reproduction and to submit the patch.
I expected the following
https://curl.se/libcurl/c/CURLOPT_DNS_CACHE_TIMEOUT.html
The docs state that -1 keeps all entries forever. They also state that, since
8.16.0, negative entries live for half the set timeout. Further, they state that
caching is disabled at zero. Thus,
| timeout | positive entry | negative entry |
|---|---|---|
| T > 0 | lives for T | lives for T/2 |
| T = 0 | not cached | not cached |
| T = -1 | lives forever[^1] | ??? |
[^1]: I would question this, too, but it is out of scope for this report.
Filling in the gap: I expect that negative DNS-cache entries should not become
permanent under timeout -1.
relevance
A user who asks for -1 wants to keep names that work. That user does not want
to remember forever a name that did not resolve.
Suppose we have two easy handles sharing a DNS cache. The first has timeout 2,
while the second has timeout -1. The first handle writes a negative entry and
is cleaned up. We wait 2.5 seconds. The second handle tries to resolve the same
name and finds a negative entry, now permanent, whose documented lifetime was
only one second. The harness's test case #6 does exactly this.
mechanism
fetch_addr()skips the staleness check when the timeout is-1:- Due to the
fetch_addr()skip,dnscache_entry_is_stale()'s doubled aging rate never runs: Curl_dnscache_prune()returns before aging anything when the timeout is-1:- Negative-entry insertion never checks any timeout.
proposed fix
Check negative-entry staleness against some fixed fallback using the existing double-rate aging. 60 seconds seems a good fallback choice because everyone who never set the option already uses it.
The fix, as applied to file lib/vdns/dnscache.c:
- Define a fallback of 60000 milliseconds.
- In
dnscache_entry_is_stale():- Copy the maximum age (i.e., timeout) into a local.
- If the timeout is
-1:- If the entry is positive, return
FALSE(never stale, i.e., pinned as the docs promise). - Otherwise, overwrite the timeout local with the fallback.
- If the entry is positive, return
- If the entry is negative, double its age.
- In the staleness check, compare against the timeout local.
- In
Curl_dnscache_prune():- Remove the
timeout_ms == -1condition.
- Remove the
- In
fetch_entry()(namedfetch_addr()before #22900):- Remove the
(data->set.dns_cache_timeout_ms != -1)condition.
- Remove the
- Side effect: The cache-size cap now also applies when a handle prunes
the cache under timeout
-1.
To test this proposed fix, run the harness against the 2026-09-15 snapshot with
envar BASE_DNS_CACHE_TIMEOUT_SEC set as shown, then observe the following
results:
BASE_DNS_CACHE_TIMEOUT_SEC |
conformances | violations | inconclusives | meaning |
|---|---|---|---|---|
| 30 | 6 | 0 | 0 | negatives expired at 30 s (fixed) |
| 15 | 3 | 3 | 0 | negatives still alive at 15 s (expected: bounded, not disabled) |
Positive entries under timeout -1 continue to never age out. I verified that
a positive entry is still served five seconds past the 60-second fallback.
I considered not caching negative entries under -1, but that turns a fleet of
resolvers into a thundering herd that tramples the DNS service.
--- a/lib/vdns/dnscache.c 2026-09-14 17:31:08
+++ b/lib/vdns/dnscache.c 2026-09-16 12:17:35
@@ -60,6 +60,8 @@
#define MAX_DNS_CACHE_SIZE 29999
+#define DNSCACHE_NEG_FALLBACK_MS 60000
+
struct dnsc_id {
struct Curl_str name;
uint16_t port;
@@ -132,23 +134,35 @@
struct Curl_dns_entry *dns = (struct Curl_dns_entry *)hc;
if(!dns->permanent) {
- /* get age in milliseconds */
- timediff_t age_ms = curlx_ptimediff_ms(prune->pnow, &dns->added);
+ timediff_t age_ms;
+ timediff_t max_age_ms = prune->max_age_ms;
+
+ /* is the entry negative? */
+ bool negative = FALSE;
switch(dns->type) {
case CURL_DNST_ADDR:
- if(!dns->addr)
- age_ms *= 2; /* negative entries age twice as fast */
+ negative = !dns->addr;
break;
#ifdef USE_HTTPSRR
case CURL_DNST_HTTPS:
- if(!dns->hinfo)
- age_ms *= 2; /* negative entries age twice as fast */
+ negative = !dns->hinfo;
break;
#endif
default:
break;
}
- if(age_ms >= prune->max_age_ms)
+
+ if(max_age_ms == -1) {
+ if(!negative)
+ return FALSE; /* positive under -1: pinned, as documented */
+ max_age_ms = DNSCACHE_NEG_FALLBACK_MS; /* negative under -1: bounded */
+ }
+
+ /* get age in milliseconds */
+ age_ms = curlx_ptimediff_ms(prune->pnow, &dns->added);
+ if(negative)
+ age_ms *= 2; /* negative entries age twice as fast */
+ if(age_ms >= max_age_ms)
return TRUE;
if(age_ms > prune->oldest_ms)
prune->oldest_ms = age_ms;
@@ -207,10 +221,9 @@
void Curl_dnscache_prune(struct Curl_easy *data, const struct curltime *pnow)
{
struct Curl_dnscache *dnscache = dnscache_get(data);
- /* the timeout may be set -1 (forever) */
timediff_t timeout_ms = data->set.dns_cache_timeout_ms;
- if(!dnscache || (timeout_ms == -1))
+ if(!dnscache)
/* NULL hostcache means we cannot do it */
return;
@@ -280,7 +293,7 @@
dns = Curl_hash_pick(&dnscache->entries, key.data, key.len);
}
- if(dns && (data->set.dns_cache_timeout_ms != -1)) {
+ if(dns) {
/* See whether the returned entry is stale. Done before we release lock */
struct dnscache_prune_data user;
curl/libcurl version
- released versions: 8.17.0 - 8.22.0
- daily snapshots: 8.22.0-20260903, 8.22.1-20260910, 8.22.1-20260915
operating system
- macOS (macOS 26.6, Apple clang 21, c-ares 1.34.8, dnsmasq 2.93)
- Ubuntu (Ubuntu 24.04, gcc 13.3, c-ares 1.27.0, dnsmasq 2.91)
Source: curl/curl