feat(client): re-resolve the server hostnames at the DNS TTL

hbc, hbc_mini.py and hbc_mini.c resolved the server once at startup, so an
address change needed a client restart. They now re-resolve every TTL seconds
(smallest A/AAAA TTL, floor 30s, 300s when unknown) and re-point, add or drop
connections to follow the new addresses; a failed lookup keeps the current ones.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 04:23:55 -04:00
co-authored by Claude Sonnet 5.5
parent e8d9c4a958
commit 885d5dc937
9 changed files with 887 additions and 1 deletions
+72
View File
@@ -0,0 +1,72 @@
"""TTL-driven re-resolution in the standalone scripts/hbc_mini.py."""
import asyncio
import importlib.util
import pathlib
import socket
import struct
import sys
_PATH = pathlib.Path(__file__).resolve().parents[1] / "scripts" / "hbc_mini.py"
_SPEC = importlib.util.spec_from_file_location("hbc_mini", _PATH)
mini = importlib.util.module_from_spec(_SPEC)
sys.modules["hbc_mini"] = mini
_SPEC.loader.exec_module(mini)
V4, V6 = socket.AF_INET, socket.AF_INET6
def _rr(rtype, ttl, rdata):
return b"\xc0\x0c" + struct.pack("!HHIH", rtype, 1, ttl, len(rdata)) + rdata
def _response(qid, qtype, answers, rcode=0):
q = mini._dns_build_query("hbd.example.com", qtype, qid)[12:]
return struct.pack("!HHHHHH", qid, 0x8180 | rcode, 1, len(answers), 0, 0) + q + b"".join(answers)
def test_min_ttl_covers_cname_and_address():
resp = _response(7, 1, [_rr(5, 60, b"\x01x\x00"), _rr(1, 300, b"\x01\x02\x03\x04")])
assert mini._dns_min_answer_ttl(resp, 7, 1) == 60
def test_unknown_ttl_cases():
assert mini._dns_min_answer_ttl(_response(7, 28, []), 7, 28) is None
assert mini._dns_min_answer_ttl(_response(7, 1, [_rr(1, 9, b"1234")], rcode=3), 7, 1) is None
assert mini._dns_min_answer_ttl(b"\x00\x07\x81", 7, 1) is None
assert mini._dns_lookup_ttl("192.0.2.1") is None
def test_refresh_delay():
assert mini._dns_refresh_delay([300, 120, None]) == 120
assert mini._dns_refresh_delay([0]) == mini._DNS_MIN_TTL
assert mini._dns_refresh_delay([None]) == mini._DNS_DEFAULT_TTL
def test_refresher_follows_a_change_and_adds_a_family(monkeypatch):
answers = [[(V4, "9.9.9.9"), (V6, "2001:db8::9")], None, [(V4, "9.9.9.9")]]
monkeypatch.setattr(mini, "_dns_resolve", lambda *a: answers.pop(0) if len(answers) > 1 else answers[0])
monkeypatch.setattr(mini, "_dns_lookup_ttl", lambda *a: None)
monkeypatch.setattr(mini, "_DNS_DEFAULT_TTL", 0.01)
monkeypatch.setattr(mini, "_DNS_FAILED_RETRY", 0.01)
async def idle(*a):
await asyncio.sleep(10)
monkeypatch.setattr(mini, "_heartbeat_sender", idle)
async def go():
mini._running = True
mini._shutdown_event = asyncio.Event()
mini._active_tasks = []
conns = [mini.AsyncConnection(1, "1.1.1.1", 5, V4, "me")]
task = asyncio.create_task(mini._dns_refresher(conns, ["h"], 5, 0, "me", 10, None, 2))
await asyncio.sleep(0.2)
mini._running = False
mini._shutdown_event.set()
await task
for t in mini._active_tasks:
t.cancel()
return conns
conns = asyncio.run(go())
assert [(c.conn_id, c.addr) for c in conns] == [(1, "9.9.9.9")]
assert conns[0].af == V4