Skip to content

Synchronous DNS resolution can wedge a crawl past --max-time#613

Merged
xroche merged 2 commits into
masterfrom
fix-606-dns-timeout
Jul 17, 2026
Merged

Synchronous DNS resolution can wedge a crawl past --max-time#613
xroche merged 2 commits into
masterfrom
fix-606-dns-timeout

Conversation

@xroche

@xroche xroche commented Jul 16, 2026

Copy link
Copy Markdown
Owner

host_wait() is a stub returning 1, so a STATUS_WAIT_DNS slot resolves synchronously through getaddrinfo() on the engine thread while holding opt->state.lock. A slow resolver stalls the crawl past --max-time and --timeout, and blocks hts_request_stop() behind it. The OS resolver imposes its own cap, which is why this usually reads as a pause rather than a hang: Windows around 10s, Linux's resolv.conf defaults (5s, two attempts, per nameserver) up to roughly 40s. Neither cap is --timeout, and neither is ours to set.

A cache miss now resolves on a worker thread bounded by opt->timeout, with the lock held only for the cache lookup and the store. The awkward part is the abandoned resolve: getaddrinfo has no portable cancellation, so a timed-out worker keeps running and has to be safe to leave behind. The job is refcounted, and the worker writes only the job, never opt (freed before the thread wait at exit) or the DNS cache. A timeout is reported as "does not resolve" but deliberately not cached, so a transient stall does not write the host off for the whole crawl.

This bounds the wedge rather than closing #606, in two ways. Resolution is still synchronous, so the engine thread still stalls other slots for up to --timeout per resolve; only driving STATUS_WAIT_DNS from the poll loop fixes that. And an abandoned worker still counts as a live thread, so htsthread_wait() at exit waits out the original resolve: the crawl carries on meanwhile and exit is never later than before, but a resolve returning in 1s does not mean the process leaves in 1s. At the default --timeout 120 neither is easy to hit in practice.

New -#test=dnstimeout drives a mock resolver that blocks 3s past a 1s --timeout; five of its six checks fail on master.

xroche and others added 2 commits July 16, 2026 19:10
host_wait() is a stub returning 1, so a STATUS_WAIT_DNS slot resolves
synchronously through hts_dns_resolve_all() -> getaddrinfo() on the engine
thread. Nothing bounded that call, and it ran holding opt->state.lock, so a
black-hole resolver stalled the crawl past --max-time and --timeout and blocked
hts_request_stop()/hts_has_stopped() with it. resolv.conf caps getaddrinfo on
POSIX and hides this most of the time; Windows has no equivalent.

Resolve a cache miss on a worker thread and wait at most opt->timeout for it,
with the lock held only for the cache lookup and the store. A timed-out resolve
is abandoned rather than cancelled (getaddrinfo has no portable cancellation),
so the job is refcounted and the worker touches nothing but it. A timeout is
reported as "does not resolve" but never cached, so the host is retried instead
of written off for the rest of the crawl.

This bounds the wedge; it does not make resolution asynchronous. The engine
thread still stalls other slots for up to --timeout per resolve, which only
driving STATUS_WAIT_DNS from the poll loop would fix.

New -#test=dnstimeout self-test drives a mock resolver that blocks for 3s past
a 1s --timeout; all five of its checks fail on master.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
…kers

The self-test's ceiling came from the mock's sleep (MOCK_SLOW_MS - 500), not
from opt->timeout: a resolve using a hardcoded 2400ms deadline that ignored
opt->timeout entirely still passed it. Derive the bound from opt->timeout so
the assertion tests the contract instead of the fixture.

The scaffolding also raced its own abandoned workers. mock_host.calls was read
while a worker was still writing it, and tearing the backend down at the end
raced the worker's freeaddrinfo read, ordered only by a sleep. Serialize the
counters under a mutex, wait for the backend calls to actually return instead
of guessing, and leave the backend installed, since an abandoned worker still
reads it. Drops the drain sleep: 6.0s -> 4.0s.

Also: --timeout now bounds resolution, so say so in the help text (the man page
is generated from it), and factor the three address-copy loops into a helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
@xroche
xroche merged commit 7be61d3 into master Jul 17, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant