Skip to content

Fix loop.close() hanging on in-flight DNS resolution - #783

Open
JoshCLWren wants to merge 6 commits into
MagicStack:masterfrom
JoshCLWren:test/issue-777-dns-close-hang
Open

JoshCLWren wants to merge 6 commits into
MagicStack:masterfrom
JoshCLWren:test/issue-777-dns-close-hang

Conversation

@JoshCLWren

@JoshCLWren JoshCLWren commented Oct 19, 2026 •

Copy link
Copy Markdown

Fixes #777.

loop.close() currently waits for executing libuv DNS requests, so a slow or stuck resolver can block shutdown indefinitely. This revision keeps DNS on libuv's existing resolver pool and makes close return without waiting for executing lookups. It replaces the earlier executor-based implementation, which regressed throughput and shared the application's executor queue.

DNS requests are tracked with intrusive C links. Close cancels queued requests, drains handle close callbacks without blocking, and hands the native loop to a daemon cleanup thread only if executing DNS remains. That thread retains the loop until native completion; closed-loop callbacks discard results and free returned address data. Normal DNS uses the original native request and callback path without Python executor submissions, locks, or additional Python allocations. Closed-loop time() avoids accessing the native loop during cleanup.

An executing libc resolver cannot be interrupted: a lookup that never returns retains its native loop and cleanup thread. This change makes synchronous loop close nonblocking; it does not promise to interrupt DNS or change process-exit behavior.

Validation includes deterministic Linux subprocess tests for forward/reverse resolution, successful and failed late results, queued cancellation, idempotent close, retained loop lifetime and eventual collection, plus independence from a saturated application executor. Python 3.9 and 3.10 DNS suites pass locally. The Python 3.13 native debug build passes all 535 locally runnable tests (32 existing skips); the original blocked-resolver reproducer returns from close in 1 ms while native DNS sleeps for 10 seconds. A free-threaded debug build passed 180 repeated shutdown/lifetime cases with debug allocation enabled. All 18 Linux/macOS configurations and the aggregate Regression Tests check pass on cdbfbb4 (CI run), covering Python 3.9–3.15 and free-threaded 3.14/3.15, normal/debug builds, and asyncio debug mode on/off. Locally, IPv6 UDP sends to ::1 fail with EPERM even through a plain socket, so two unrelated full-suite tests cannot run successfully in this environment.

The reproducible benchmark is examples/bench/dns.py. Matching optimized uvloop and libuv builds, 15 alternating process pairs, and fixed CPU affinity give native numeric forward throughput changes of -0.54% sequential / +0.08% concurrent, and reverse changes of -1.29% / +2.14%. All paired bootstrap 95% intervals include parity. Localhost concurrent throughput changes by -0.31%; DNS still completes in about 2 ms with the application executor saturated. These are local measurements, not a guarantee for every workload. The revised implementation is ready for review.

@JoshCLWren JoshCLWren changed the title test: reproduce loop.close() hang during native DNS resolution Fix loop.close() hanging on in-flight DNS resolution Oct 19, 2026
@JoshCLWren
JoshCLWren marked this pull request as ready for review October 19, 2026 13:01
@JoshCLWren

JoshCLWren commented Oct 20, 2026 •

Copy link
Copy Markdown
Author

Superseded: The executor implementation measured below has been replaced by native DNS with deferred shutdown cleanup. See the new optimized-build measurements. Original measurements are preserved below.

Performance follow-up: these measurements expose substantial regressions in the current executor-backed design. I am returning this PR to draft; passing the correctness/compatibility matrix is insufficient to recommend merging it as implemented.

Compared baseline 07b10a4 with PR 0230c10 on Linux x86_64, CPython 3.13.5, glibc 2.41, 12 reported CPUs, Cython 3.3.0, and the same bundled libuv 1.52.1 archive. Both were separately built with matching optimized -O2 settings and verified to have UVLOOP_DEBUG disabled. Medians of seven fresh subprocess samples per build/case, with build order alternated. UV_THREADPOOL_SIZE=4; Python executor sizing is left at its default except in the explicit contention case.

Case Baseline PR PR / baseline
Forward localhost, sequential (lookups/s) 2,549 2,822 1.11×
Forward localhost, concurrency 32 (lookups/s) 10,484 5,355 0.51×
Numeric reverse lookup, sequential (lookups/s) 18,770 2,473 0.13×
Numeric reverse lookup, concurrency 32 (lookups/s) 36,927 3,531 0.10×
Numeric forward fast path (lookups/s) 391,423 380,968 0.97×
DNS latency with all executor workers busy (ms) 2.28 202.63 88.85×

The contention case explicitly configures four executor workers, occupies all four with application work waiting on an event, and releases that event after 200 ms. The baseline's libuv DNS pool remains independent; the PR queues DNS behind that application work. This is a deliberate saturation demonstration, not a claim about typical application latency.

Forward lookups use localhost (present in this machine's hosts file). Reverse lookups use 127.0.0.1 with NI_NUMERICHOST | NI_NUMERICSERV. This is an exploratory local-resolver microbenchmark, not a production-network DNS benchmark. The machine was not CPU-isolated, and there is substantial sample variation: small median differences in sequential forward lookups and the numeric fast path should not be interpreted as established improvements/regressions. The reverse-lookup slowdown is pronounced across the samples.

The current design needs revision: concurrent forward throughput has a much lower median, numeric reverse lookups are roughly 8–10× slower, and DNS inherits application-executor queueing. The executor handoffs and worker-owned loop initialization are candidates for profiling; these measurements do not isolate their individual costs. Feedback on a dedicated bounded resolver pool versus a different ownership/shutdown design would be welcome before developing this approach further.

Raw samples (lookups/s, except contention latency in ms)
forward_sequential
baseline: 2343.09, 2446.31, 2658.19, 2461.65, 2549.23, 2955.40, 2993.62
candidate: 2822.27, 2406.68, 2768.05, 2429.90, 3654.12, 3979.12, 2985.09
forward_concurrent
baseline: 11265.38, 10484.35, 10035.77, 12166.64, 11017.07, 7979.35, 9542.05
candidate: 13809.58, 6474.02, 5306.93, 5576.45, 5354.78, 5144.18, 5289.08
reverse_sequential
baseline: 23176.19, 10666.22, 27707.42, 18770.19, 16915.29, 10364.28, 19682.17
candidate: 2472.99, 2712.33, 2756.31, 2436.47, 2372.99, 2468.29, 2540.42
reverse_concurrent
baseline: 33382.78, 36926.53, 35130.10, 84912.92, 52852.59, 84423.49, 35427.71
candidate: 3575.98, 3556.82, 3539.16, 3530.97, 3515.66, 3267.79, 3296.33
numeric_fast_path
baseline: 180135.19, 378312.11, 370035.30, 392737.10, 411478.52, 391422.61, 421033.71
candidate: 423077.65, 326062.00, 401621.33, 367560.33, 354406.44, 380967.59, 419528.16
executor_saturated
baseline: 2.04, 4.25, 3.56, 2.28, 2.45, 1.94, 1.90
candidate: 202.28, 202.19, 204.07, 206.81, 202.63, 202.26, 203.17
Benchmark script

Build both checkouts in place with matching optimized build settings, save this script as benchmark-dns.py, and run:

python benchmark-dns.py --baseline /path/to/baseline --candidate /path/to/pr --samples 7 --output results.json
"""Compare DNS on two optimized uvloop builds without external DNS traffic.

Run each sample in a fresh process. The parent alternates build order and
reports medians; individual samples and build paths remain in JSON output.
"""
import argparse
import asyncio
import concurrent.futures
import json
import os
import pathlib
import platform
import socket
import statistics
import subprocess
import sys
import threading
import time


def child(args):
    sys.path.insert(0, str(pathlib.Path(args.repo).resolve()))
    import uvloop
    import uvloop.loop
    loop = uvloop.new_event_loop()
    assert not loop._debug_cc, 'benchmark requires an optimized build'
    assert pathlib.Path(uvloop.loop.__file__).is_relative_to(
        pathlib.Path(args.repo).resolve())
    asyncio.set_event_loop(loop)

    async def lookup(reverse=False, numeric=False):
        if reverse:
            return await loop.getnameinfo(
                ('127.0.0.1', 443), socket.NI_NUMERICHOST | socket.NI_NUMERICSERV)
        return await loop.getaddrinfo(
            '127.0.0.1' if numeric else 'localhost', 443,
            family=socket.AF_INET, type=socket.SOCK_STREAM)

    async def run():
        if args.case == 'executor_saturated':
            # Explicitly use four workers so this case is reproducible across
            # machines. Occupy every worker with sleeping application work.
            loop.set_default_executor(
                concurrent.futures.ThreadPoolExecutor(max_workers=4))
            release = threading.Event()
            ready = [threading.Event() for _ in range(4)]

            def application_work(event):
                event.set()
                release.wait()

            tasks = [loop.run_in_executor(None, application_work, e)
                     for e in ready]
            while not all(e.is_set() for e in ready):
                await asyncio.sleep(0.001)
            timer = loop.call_later(0.2, release.set)
            start = time.perf_counter()
            try:
                await lookup()
                elapsed = time.perf_counter() - start
            finally:
                release.set()
                timer.cancel()
                await asyncio.gather(*tasks)
            return {'dns_latency_ms': elapsed * 1000}

        reverse = args.case.startswith('reverse')
        numeric = args.case == 'numeric_fast_path'
        concurrency = 32 if args.case.endswith('concurrent') else 1
        count = 32768 if numeric else (4096 if reverse else 8192)
        for _ in range(100):
            await lookup(reverse, numeric)

        async def worker():
            for _ in range(count // concurrency):
                result = await lookup(reverse, numeric)
                assert result

        start = time.perf_counter()
        await asyncio.gather(*(worker() for _ in range(concurrency)))
        elapsed = time.perf_counter() - start
        return {'lookups_per_second': count / elapsed,
                'microseconds_per_lookup': elapsed * 1e6 / count,
                'lookups': count, 'concurrency': concurrency}

    try:
        result = loop.run_until_complete(run())
        loop.run_until_complete(loop.shutdown_default_executor())
    finally:
        loop.close()
    result.update(module=uvloop.loop.__file__, version=uvloop.__version__,
                  libuv_version=uvloop.loop.libuv_get_version())
    print(json.dumps(result))


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--repo')
    parser.add_argument('--case')
    parser.add_argument('--baseline')
    parser.add_argument('--candidate')
    parser.add_argument('--samples', type=int, default=7)
    parser.add_argument('--output')
    args = parser.parse_args()
    if args.repo:
        child(args)
        return
    cases = ('forward_sequential', 'forward_concurrent',
             'reverse_sequential', 'reverse_concurrent',
             'numeric_fast_path', 'executor_saturated')
    result = {'environment': {'python': sys.version,
                             'platform': platform.platform(),
                             'cpu_count': os.cpu_count(),
                             'uv_threadpool_size': os.getenv('UV_THREADPOOL_SIZE', 'default (4)'),
                             'samples': args.samples}, 'cases': {}}
    env = dict(os.environ)
    env.pop('PYTHONASYNCIODEBUG', None)
    env.pop('LD_PRELOAD', None)
    env['UV_THREADPOOL_SIZE'] = '4'
    for case in cases:
        samples = {name: [] for name in ('baseline', 'candidate')}
        for i in range(args.samples):
            order = ('baseline', 'candidate') if i % 2 == 0 else ('candidate', 'baseline')
            for name in order:
                output = subprocess.check_output(
                    [sys.executable, str(pathlib.Path(__file__).resolve()),
                     '--repo', getattr(args, name), '--case', case],
                    text=True, env=env, timeout=60)
                samples[name].append(json.loads(output))
        key = 'dns_latency_ms' if case == 'executor_saturated' else 'lookups_per_second'
        medians = {name: statistics.median(s[key] for s in runs)
                   for name, runs in samples.items()}
        result['cases'][case] = {'samples': samples, 'median': medians,
                                 'candidate_over_baseline': medians['candidate'] / medians['baseline']}
        print(case, json.dumps(medians), flush=True)
        pathlib.Path(args.output).write_text(json.dumps(result, indent=2) + '\n')


if __name__ == '__main__':
    main()

@JoshCLWren

JoshCLWren commented Oct 20, 2026 •

Copy link
Copy Markdown
Author

Replaced the executor implementation with native libuv DNS plus deferred cleanup of executing requests at close (cdbfbb4). The earlier performance warning applies to the superseded executor implementation.

Current comparison against 07b10a4: both uvloop and libuv built with -O2, UV_THREADPOOL_SIZE=4, 15 alternating fresh-process pairs per case, affinity to CPUs 0 and 1. Long native cases use 65,536 calls per sample; concurrent cases use 32 tasks.

Native lookup case Baseline calls/s Revision calls/s Change
Numeric forward, sequential 40,993 40,773 -0.54%
Numeric forward, concurrent 84,108 84,173 +0.08%
Reverse, sequential 29,806 29,423 -1.29%
Reverse, concurrent 65,024 66,416 +2.14%

Paired bootstrap 95% intervals for each throughput ratio include 1.0. Numeric forward uses socktype=0 and AI_NUMERICHOST to exercise native requests rather than uvloop's numeric fast path. Concurrent localhost lookup measured 13,933 vs 13,890 calls/s (-0.31%). With the application's executor occupied, native DNS latency remains about 2 ms rather than waiting for application work.

Methodology caveats: unrestricted host scheduling was noisy enough that identical-baseline A/A comparisons differed by ~12%; fixed affinity reduced this noise. Localhost sequential NSS timings remain noisy. These results show no material regression in the measured workloads, rather than proving universal performance parity. Also, the earlier executor comparison used the same unoptimized libuv archive on both sides; this new comparison corrects that build configuration and uses optimized libuv on both sides.

A reproducible harness, including A/A controls and executor saturation, is now included at examples/bench/dns.py. All 18 Linux/macOS configurations and the aggregate regression check now pass on cdbfbb4 (CI run); the PR is ready for review.

@JoshCLWren
JoshCLWren marked this pull request as ready for review October 20, 2026 04:20
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.

Loop.close() can hang indefinitely on an in-flight libuv getaddrinfo request

1 participant