#22975·curl

Negative DNS cache entries become permanent under `CURLOPT_DNS_CACHE_TIMEOUT=-1`

Author: SeanPMillerCreated Sep 17, 2026Updated Sep 17, 2026
Labelsname lookup

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

  1. Clone the following repo: https://github.com/SeanPMiller/curl-issue-negdns-permanence
  2. Execute the following commands:
    bash
    cd curl-issue-negdns-permanence # or wherever you put the clone
    docker build -t curl-negdns .
    docker run --rm curl-negdns
  3. 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

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 negative, double its age.
    • In the staleness check, compare against the timeout local.
  • In Curl_dnscache_prune():
    • Remove the timeout_ms == -1 condition.
  • In fetch_entry() (named fetch_addr() before #22900):
    • Remove the (data->set.dns_cache_timeout_ms != -1) condition.
  • 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.

Proposed diff vs. 2026-09-15 daily snapshot
diff
--- 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)