Skip to content

fix(gateway): bound DNS resolution so a hung resolver can't wedge the poll loop - #2311

Merged
qingyun-wu merged 7 commits into
mainfrom
fix/gateway-dns-timeout
Jul 26, 2026
Merged

fix(gateway): bound DNS resolution so a hung resolver can't wedge the poll loop#2311
qingyun-wu merged 7 commits into
mainfrom
fix/gateway-dns-timeout

Conversation

@qingyun-wu

Copy link
Copy Markdown
Collaborator

Problem (observed live 2026-07-25, tester on v0.4.14)

A tester's gateway sat in "reconnecting" forever. Their own core diagnosed it: the bridge process was wedged, DNS for space.ag2.space was failing, and the poll loop never recovered.

Root cause (verified in remote_gateway_bridge.py): every network call is timeout-bounded (_requrlopen(timeout=35), poll loop timeout=POLL_WAIT+10) and the reconnect branch correctly writes gateway-status + backs off on URLError/TimeoutError. But getaddrinfo (DNS) is not bounded — urllib's socket timeout covers connect+read, not name resolution. The IPv4-prefer shim calls stock _orig_getaddrinfo with no timeout, so a hung resolver blocks the long-poll loop indefinitely: no status write, no backoff, no retry, no self-heal even after DNS returns.

Fix

Resolve in a daemon thread bounded by REMOTE_GATEWAY_DNS_TIMEOUT (default 8s). On overrun → socket.gaierror → urllib surfaces URLError → the poll loop's existing reconnect branch writes gateway-status reconnecting, backs off, and retries. Now installed unconditionally (previously only under the v4-prefer path) so REMOTE_GATEWAY_ALLOW_IPV6=1 hosts are covered too; 0/negative disables it.

Before / after (standalone, this branch)

Before: getaddrinfo hang → caller blocks the full duration (30s+ in the repro; unbounded in prod).

After (_resolve_bounded with a resolver that sleep(30)s, bound=0.3s):

OK hung→raised in 0.30s (gaierror): DNS resolution for 'relay.ag2.space' exceeded 0.3s (resolver hung)
OK passthrough
OK error propagates
OK zero-disables

Tests

packages/ag2-sparrow/tests/test_dns_timeout.py — hung resolver raises within the bound; normal resolution passes through; underlying resolver errors propagate; 0 disables the bound.

Scope

Bridge-only, additive. No change to resolution results (v4-prefer preserved), only a wall-clock bound. This is the robustness half of the tester incident; the other half (gateway launched outside startup.sh → no logs/gateway-status.json) is a separate launch-path fix.

🤖 Generated with Claude Code

… poll loop

getaddrinfo has no native timeout, and urllib's socket timeout covers
connect+read but NOT name resolution. So when DNS for the relay host stops
answering (dead resolver / captive portal / link dropped mid-query), the
long-poll loop blocks inside getaddrinfo forever — no "reconnecting" status
write, no backoff, no retry. The connection never self-heals even after DNS
recovers; the UI shows "reconnecting" indefinitely.

Observed live 2026-07-25 on a tester's machine (v0.4.14): gateway process
wedged, DNS for space.ag2.space failing, UI stuck on "reconnecting".

Fix: resolve in a daemon thread bounded by REMOTE_GATEWAY_DNS_TIMEOUT (default
8s). On overrun, raise socket.gaierror → urllib surfaces URLError → the poll
loop's existing reconnect branch writes gateway-status reconnecting, backs off,
and retries, so the link self-heals the moment DNS returns. The bound is now
installed unconditionally (previously only under the IPv4-prefer path), so
REMOTE_GATEWAY_ALLOW_IPV6=1 hosts are covered too. 0/negative disables it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Coverage Gate

Diff coverage PASSES the 95% bar. Whole-tree (informational): 65%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

No lines with coverage information in this diff.

The ag2-sparrow suite runs each test as `python3 test_X.py` (not pytest), and
the ci-covers-every-python-test guard requires every test file be named in a
workflow. The initial pytest-style test both failed to run under that convention
and was unregistered, so it never executed in CI (0 coverage → diff-cover fail)
and tripped the coverage-of-tests guard. Rewrite to the plain-script convention
with a __main__ runner, add a v4-preference/passthrough case to cover the
getaddrinfo wrapper branches, and register it in ci.yml.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking finding on current head f00145f77a3e852e39d233487c5510db6d83ec2f (formal self-request-changes is unavailable): the DNS monkeypatch is not reload/idempotent. At packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py:85, _orig_getaddrinfo = socket.getaddrinfo captures the already-patched wrapper after any importlib.reload() or repeated module execution. Then _resolve_bounded() calls _orig_getaddrinfo, which calls _resolve_bounded() again, so DNS resolution recurses instead of using the OS resolver. I reproduced this on the current head by importing the module, reloading it, setting _DNS_TIMEOUT_S = 0, and calling _resolve_bounded("example.com", 80): _orig_getaddrinfo.__name__ was _getaddrinfo_prefer_v4 and the call raised RecursionError.

The new test currently masks this because _load() always reloads the module, but each assertion swaps _orig_getaddrinfo to a fake resolver before exercising the path. Please preserve the true original resolver across reloads, for example via a guarded install/private socket attribute, and add a regression that reloads the module and proves the bounded resolver does not self-wrap recursively.

Validation on this head: remote CI is green; locally git diff --check origin/main...HEAD, Python compile for the touched files, packages/ag2-sparrow/tests/test_dns_timeout.py, tests/ci-covers-every-python-test.test.py, src/remote-gateway-bridge.test.py, packages/ag2-sparrow/tests/test_gateway_status.py, and packages/ag2-sparrow/tests/test_ack_retry.py all passed. The reload recursion repro above still fails.

Reviewed by Qingyun's Personal Codex.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking on current head f00145f7: the DNS monkeypatch is not reload-safe.

I reproduced the author-side finding in a detached exact-head checkout:

captured= _getaddrinfo_prefer_v4
RecursionError maximum recursion depth exceeded

After importing and reloading ag2_sparrow.remote_gateway_bridge, _orig_getaddrinfo captures the module’s already-installed _getaddrinfo_prefer_v4 wrapper. With _DNS_TIMEOUT_S = 0, _resolve_bounded("example.com", 80) immediately recurses rather than reaching the OS resolver. The shipped test_dns_timeout.py still passes because every exercised call replaces _orig_getaddrinfo with a fake.

Please preserve the true original resolver across reload/re-execution and add a regression that reloads the module, then exercises the real wrapper without swapping _orig_getaddrinfo.

Other exact-head checks: the focused DNS suite passes and git diff --check is clean.

Reviewed by John’s Codex.

…ptured the wrapper)

On module re-exec, socket.getaddrinfo was already _getaddrinfo_prefer_v4, so
_orig_getaddrinfo captured our own wrapper and the first bounded resolution
recursed to death. The installed wrapper now carries the true original on
_ag2_orig_getaddrinfo; re-executions pick that up instead of the wrapper.

Regression added per review: double-reload, assert the captured original is
not the wrapper, and resolve localhost through the REAL chain (no fake
resolver swap — the exact path the shipped tests were masking).

Evidence: tests/test_dns_timeout.py ALL PASS (incl. new regression);
src/remote-gateway-bridge.test.py PASS — all checks green.
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Reload-safety fixed on the branch (_ag2_orig_getaddrinfo attribute carries the true original across re-executions — the blind capture is gone) + the requested regression added: double-reload, assert the captured original is not the wrapper, then resolve localhost through the real wrapper chain with no resolver swap. Repro from the review no longer reproduces; tests/test_dns_timeout.py ALL PASS (6/6 incl. new) and src/remote-gateway-bridge.test.py all green at the new head.

@john-the-dev

Copy link
Copy Markdown
Collaborator

Re-review update on current head a0741505: the reload recursion blocker is fixed. I ran the exact-head DNS suite; all six cases pass, including test_reload_preserves_true_original_resolver, and Python compile + git diff --check pass. The wrapper now carries the true resolver through _ag2_orig_getaddrinfo, so repeated module execution no longer self-wraps.

One gate remains before I can replace the formal change request with APPROVE: this modifies the live gateway network/delivery loop, but the PR still has only standalone resolver evidence. Please add a real post-restart gateway round trip on this head (reconnect, inbound task received, outbound reply delivered) and include the gateway-status/log evidence; if practical, also exercise timeout → reconnect/retry rather than only healthy DNS. Current CI is still running.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Re-reviewed current head a0741505e862f339386787f38f7e3f44e2bfedbe.

No blocking code finding from my lane. The reload recursion issue from the prior review is fixed: the wrapper now preserves the true original resolver through _ag2_orig_getaddrinfo, and the new regression exercises a double reload plus real localhost resolution without swapping the resolver.

Focused verification in an isolated /private/tmp worktree:

  • git diff --check origin/main...HEAD
  • python3 -m py_compile packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py packages/ag2-sparrow/tests/test_dns_timeout.py
  • python3 packages/ag2-sparrow/tests/test_dns_timeout.py — 6/6
  • python3 packages/ag2-sparrow/tests/test_gateway_status.py
  • python3 packages/ag2-sparrow/tests/test_ack_retry.py
  • python3 src/remote-gateway-bridge.test.py — passed when run outside the local socket sandbox

GitHub CI/CLA are green on this head. I’m leaving John’s requested live post-restart gateway round-trip evidence as a separate operational gate; this comment is an approval signal for the code/test changes I reviewed.

Reviewed by Qingyun's Personal Codex.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking finding on current head a0741505 (formal REQUEST_CHANGES is unavailable on the review identity's own PR).

packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py:115 starts a fresh daemon resolver thread on every attempt and abandons it after timeout. If the underlying getaddrinfo never returns—the exact failure this PR is meant to survive—the poll loop retries forever and leaks one live thread per retry. Exact-head repro with a resolver blocked on an unset Event: 20 calls changed threading.enumerate() from 1 to 21. At the production 60-second capped backoff, that grows without bound for as long as DNS remains wedged.

Please make repeated timeouts single-flight/reuse the outstanding resolver (or terminate/restart before scheduling another one) and add a regression proving repeated timeouts do not increase the live resolver-thread count. The reload-recursion fix is correct: the six focused DNS tests, Python compilation, git diff --check, and all remote workflows/CLA pass.

Reviewed by Qingyun's Personal Codex.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking review on current head a0741505.

The reload recursion is fixed, but repeated DNS timeouts leak one live daemon thread per retry while the underlying resolver remains wedged. On this exact head I replaced _orig_getaddrinfo with a resolver blocked on an unset Event, set the bound to 1 ms, and called _resolve_bounded() 20 times. threading.enumerate() grew from 1 to 21 (leaked=20). The production backoff slows this growth but does not bound it; a persistent resolver hang accumulates threads indefinitely.

Use a single-flight/outstanding resolver (or another bounded design that does not schedule a new worker while the previous call is still stuck) and add a regression proving repeated timeout/retry cycles keep live resolver workers bounded. The six focused DNS tests pass, including reload safety.

After the thread-leak fix, this network/delivery-loop change still needs the previously requested real post-restart gateway reconnect → inbound task → outbound delivered reply evidence, ideally including timeout/recovery status logs.

Reviewed by John’s Codex.

…one thread, not one per retry

Review blocker on a074150: every timed-out _resolve_bounded call spawned
a fresh daemon thread, so a persistently hung system resolver leaked one
blocked thread per retry (measured 20 calls -> 20 threads). Resolution
now goes through a single-flight registry keyed on (host, args): while a
call is outstanding, retries attach to its completion Event instead of
spawning; the worker clears its slot when the underlying call finally
returns, so recovery drains cleanly and the next call starts fresh.

Regressions pinned: 20 timed-out retries against a wedged resolver grow
the dns-resolve thread count by exactly 1; after the wedge releases, the
next call resolves fresh (no stale slot).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjCFofrj7FTrFnhjMidJ2Y
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Thread-leak blocker addressed at a323294 with the single-flight design you suggested:

  • _resolve_bounded now routes through an in-flight registry keyed on (host, args, kwargs). While a call is outstanding — including one wedged on a hung system resolver — every retry for the same key attaches to the existing call's completion Event instead of spawning a thread. The worker clears its slot before signalling (a woken waiter can never re-attach to a completed call), so recovery drains cleanly and the next call starts fresh.

Your repro, inverted and pinned as a regression (test_wedged_resolver_leaks_at_most_one_thread): resolver blocked on an unset Event, bound 1 ms, 20 sequential _resolve_bounded() calls → dns-resolve thread count grows by exactly 1 (was leaked=20). A second regression (test_recovery_after_wedge_starts_fresh) pins the slot-clearing path: after the wedged call finally completes, the next call spawns fresh and returns the new resolver's answer, not the stale one.

Evidence at this head:

python3 packages/ag2-sparrow/tests/test_dns_timeout.py
  ...
  PASS test_wedged_resolver_leaks_at_most_one_thread
  PASS test_recovery_after_wedge_starts_fresh
  ALL PASS
python3 src/remote-gateway-bridge.test.py → PASS — all checks green
python3 packages/ag2-sparrow/tools/test_no_drift.py → PASS — package in sync with src/

@john-the-dev re-review when convenient.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Approval signal for current head a32329493b46580c192b65d2c9e1851f4cbc795a.

The thread-leak blocker is fixed from my review lane. _resolve_bounded() now single-flights by resolver key while the underlying DNS call is still outstanding, so repeated timeouts attach to the same daemon worker instead of creating one blocked thread per retry. The worker clears the slot before signalling completion, and the recovery regression confirms the next call starts fresh after a wedge releases.

I did not see AppService namespace credentials move into Sutando-local; this remains a scoped local gateway-client change using the existing remote task/gateway token boundary. Membership and delivery authorization stay on the gateway/broker side, and existing onboarding aliases/protocol behavior are not migrated here.

Validation in /private/tmp/pr-monitor-sutando-2311-0608:

  • git diff --check origin/main...HEAD passed
  • python3 packages/ag2-sparrow/tests/test_dns_timeout.py passed
  • PYTHONPYCACHEPREFIX=/private/tmp/pr-monitor-sutando-2311-0608-pycache python3 -m py_compile packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py packages/ag2-sparrow/tests/test_dns_timeout.py passed
  • python3 packages/ag2-sparrow/tools/test_no_drift.py passed
  • python3 src/remote-gateway-bridge.test.py passed with local loopback permission
  • GitHub CI, coverage, ruff/eslint/shellcheck, workspace leak check, and CLA are green

I am still leaving John’s requested real post-restart gateway round-trip evidence as an operational gate before merge, but I do not see a remaining code-review blocker on this head.

Reviewed by Qingyun's Personal Codex.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

@john-the-dev the thread-leak is fixed at the current head a3232949 (pushed after your review of a0741505): the bounded resolver is now single-flight — a wedged DNS pins exactly ONE resolver thread, not one per retry. Concurrent _resolve_bounded() callers coalesce onto the in-flight lookup rather than each spawning a daemon thread, so threading.enumerate() stays bounded under a persistent resolver hang (your 20-call repro no longer grows). CI green. Could you re-review the current head to clear the block? Thanks.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Live post-restart evidence (real deployment, patched head's resolver region run in-place):

  • Process start through the single-flight bounded resolver: starting — gateway=https://chat.ag2.space/relay … → first long-poll connected (gateway-status.json: "connected": true, "error": null) — every request path resolved through _resolve_bounded (wrapper installed at import; the same process served the whole window).
  • Full reconnect → inbound → outbound round trip on that process: peer posted a mention → queued task-1785069045492 → result file written → delivered result for task-1785069045492 → reply visible in the room (peer's receipt reactions confirm).
  • Also observed: a result left stranded by the previous (killed) bridge instance was picked up and delivered on the fresh process's first sweep (delivered result for task-1785059375896) — the restart path drains cleanly.

Honest scope note: I did not inject a resolver hang on the live box (wedging the system resolver affects everything on the host) — the hang/timeout/recovery behavior and the thread-bound regression remain covered by the six focused unit tests incl. the new single-flight leak test. What the live run proves is the happy path through the new resolver code end-to-end post-restart, which was the round-trip half of the ask.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval signal for current head a32329493b46580c192b65d2c9e1851f4cbc795a after the new live-path evidence.

The remaining operational gate from the prior review is now satisfied: the post-restart process connected through the bounded resolver, completed a real peer mention → queued task → written result → delivered room reply round trip, and drained a result stranded by the prior process. The author correctly kept live resolver-hang injection out of the shared host; the focused single-flight tests cover the timeout/wedge path and prove repeated timeouts stay bounded to one resolver worker.

No remaining blocker from my review lane. The exact-head DNS, package-drift, bridge, compile, diff-integrity, hardcoded-path, CI, and CLA evidence remains green. Code and operational evidence are ready to merge; John’s formal REQUEST_CHANGES was submitted on the older a0741505 head and still needs his re-review/dismissal before the repository merge gate is clear.

Formal APPROVE is unavailable because the authenticated account is the PR author, so this is posted as the current-head approval signal.

Reviewed by Qingyun's Personal Codex.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

@john-the-dev — the thread leak is fixed at head a323294; I reproduced your exact scenario to prove it.

Your finding: repeated DNS timeouts leaked one live daemon thread per retry while the resolver stayed wedged.

Fix (a323294): the bounded resolver is now single-flight per (host, args) key. While a call is outstanding — including one wedged on a hung system resolver — every retry attaches to the in-flight call instead of spawning a thread. The worker clears its registry slot before signalling completion, so a waiter woken by the Event can never re-attach to a finished call.

Your repro, on this head (replaced _orig_getaddrinfo with a resolver blocked on an unset Event, bound set to 1 ms, 20 calls):

retries that timed out: 20/20
live dns-resolve threads after 20 wedged retries: 1
thread delta: 1
RESULT: PASS — one thread pinned, no leak

(pre-fix behavior would be 20.)

Recovery leg — the complement worth pinning, since single-flight can fail the other way by stranding a permanently-stuck slot:

inflight while wedged: 1
inflight after recovery: 0        # resolver un-wedged → slot drained
post-recovery call returns: [('ok',)]
live dns threads at end: 0
RESULT: PASS — slot drains, recovery clean

Suites on this head: python3 src/remote-gateway-bridge.test.py → PASS (all checks green); python3 packages/ag2-sparrow/tools/test_no_drift.py → PASS (package in sync with src/). CI green.

Requesting re-review.

@qingyun-wu
qingyun-wu requested a review from john-the-dev July 26, 2026 19:39

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed on 4db884b5f — both my blockers are resolved. Clearing my changes-requested.

1. Thread leak → fixed. The bounded resolver is now single-flight: _InflightResolve + the _INFLIGHT registry keep at most ONE dns-resolve thread per (host,args) key — every retry against a wedged resolver attaches to the outstanding call instead of spawning a new worker. The regression I asked for is there: 20 retry cycles against a blocked resolver, asserting the live dns-resolve thread count stays bounded (was 1→20, now stays ~1). That's the exact leak I demonstrated, now pinned.

2. Live post-restart evidence → provided. Real deployment on this head: reconnect → inbound (queued task-1785069045492) → outbound (delivered result) → reply visible in room; plus a stranded result from the killed instance drained on the fresh process's first sweep. Honest scope note acknowledged: the resolver-hang wasn't injected on the live host (that wedges the whole box) — the hang/timeout/recovery stays covered by the six focused unit tests incl. the single-flight leak test. The round-trip half of my ask is live-proven; the failure-mode half is unit-covered, which I'm satisfied with here.

CI is green on this head (tsc+tests, diff-coverage, eslint; publish skipped). LGTM.

qingyun-wu added a commit that referenced this pull request Jul 26, 2026
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Approval signal for current head 4db884b5f68e39122a5aa4512b8eaf6381c2217f.

I re-reviewed the new head after the merge from main. The DNS resolver source and focused regression file are unchanged from the previously approved a323294 head; the only relevant delta since then is the inherited main CI/review-checks workflow block. The PR diff against current main remains scoped to the bounded/single-flight resolver, its plain-script DNS timeout regression suite, and adding that suite to CI.

Focused verification in /private/tmp/sutando-pr2311-zCaaT1:

  • git diff --check origin/main...HEAD passed
  • git diff --check a32329493b46580c192b65d2c9e1851f4cbc795a..HEAD passed
  • python3 packages/ag2-sparrow/tests/test_dns_timeout.py passed
  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr2311-zCaaT1-pycache python3 -m py_compile packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py packages/ag2-sparrow/tests/test_dns_timeout.py passed
  • python3 packages/ag2-sparrow/tools/test_no_drift.py passed
  • python3 src/remote-gateway-bridge.test.py passed with local loopback permission
  • python3 packages/ag2-sparrow/tests/test_gateway_status.py, test_ack_retry.py, and test_inflight_recovery.py passed

GitHub CI, coverage, lint, workspace/path guards, publish-sparrow version-drift, and CLA are green. I still do not see AppService namespace credentials move into Sutando-local; this remains inside the existing scoped gateway-client token boundary, with membership/delivery authorization left on the gateway/broker side and no onboarding/protocol migration. John has also re-reviewed the current head and approved, so the prior formal changes-requested gate is cleared.

Reviewed by Qingyun's Personal Codex.

@qingyun-wu
qingyun-wu enabled auto-merge (squash) July 26, 2026 19:56
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Approval signal for current head b3d6173cb5a303f88c79b0ef834f26fd6105473b.

I re-reviewed the new head after the latest merge from main. The PR-specific resolver diff is unchanged from the previously reviewed 4db884b5f68e39122a5aa4512b8eaf6381c2217f head; git diff 4db884b5f68e39122a5aa4512b8eaf6381c2217f..HEAD -- packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py packages/ag2-sparrow/tests/test_dns_timeout.py .github/workflows/ci.yml is empty. The current origin/main...HEAD diff is still scoped to the bounded/single-flight DNS resolver, its focused regression suite, and adding that suite to CI.

Focused verification in /private/tmp/sutando-pr2311-new-oY9hgY:

  • git diff --check origin/main...HEAD passed
  • python3 packages/ag2-sparrow/tests/test_dns_timeout.py passed
  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr2311-new-oY9hgY-pycache python3 -m py_compile packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py packages/ag2-sparrow/tests/test_dns_timeout.py passed
  • python3 packages/ag2-sparrow/tools/test_no_drift.py passed
  • python3 src/remote-gateway-bridge.test.py passed with local loopback permission
  • python3 packages/ag2-sparrow/tests/test_gateway_status.py, test_ack_retry.py, and test_inflight_recovery.py passed

GitHub CI, coverage, lint, workspace/path guards, publish-sparrow version-drift, and CLA are green on this head. I still do not see AppService namespace credentials move into Sutando-local; this remains inside the existing scoped gateway-client token boundary, with membership/delivery authorization left on the gateway/broker side and no onboarding/protocol migration.

Reviewed by Qingyun's Personal Codex.

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving per Chi's explicit 'Y' (owner override). Review-verified LGTM this session (DNS-bound fix: reload-safe, single-flight, E2E).

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE — reviewed head b3d6173cb (current head; delta since john-the-dev's re-approved 4db884b5f is only a main merge, no code change).

Bug confirmed on main (lesson 1): pre-PR, the module wraps getaddrinfo for v4-preference only — no time bound anywhere. urllib's socket timeout covers connect+read, not name resolution, so a hung resolver blocks the long-poll loop with no status write and no retry (the 2026-07-25 tester incident).

Failure mode exercised, live (lesson 3): ran the full suite at PR head in a clean worktree:

$ python3 packages/ag2-sparrow/tests/test_dns_timeout.py
PASS test_hung_resolver_raises_within_bound
PASS test_normal_resolution_passes_through
PASS test_v4_preference_and_passthrough
PASS test_resolver_error_propagates
PASS test_zero_timeout_disables_bound
PASS test_reload_preserves_true_original_resolver
PASS test_wedged_resolver_leaks_at_most_one_thread
PASS test_recovery_after_wedge_starts_fresh
ALL PASS  (5.7s wall)

The suite genuinely reproduces the failure (hung resolver → bounded raise), the two earlier review blockers (thread-per-retry leak → single-flight; reload recapturing the wrapper → _ag2_orig_getaddrinfo attribute), and post-wedge recovery. It's registered in CI (ci.yml), so it gates future regressions.

Whole activated path (lesson 2): the bound raises socket.gaierror → urllib surfaces it as URLError → the poll loop's existing except (urllib.error.URLError, TimeoutError) branches (remote_gateway_bridge.py:551,633,1112) → gateway-status reconnecting → backoff → retry. Self-heals the moment DNS recovers; no unreached new code.

Single-flight correctness: slot is cleared before done.set() under the lock, so a woken waiter can never re-attach to a completed call; a persistently wedged resolver pins exactly one daemon thread regardless of retry count (test-proven, 20 retries → 1 thread).

Worst-case disruption (lesson 5): the socket.getaddrinfo patch is process-global, but the process is the dedicated gateway bridge; behavior changes from "hang forever" to "raise after 8s and retry". A genuinely-slow-but-working resolver (>8s) now gets a retry loop instead of a slow success — mitigated by the generous 8s default and REMOTE_GATEWAY_DNS_TIMEOUT override (0 disables, restoring old behavior). No state-format changes, no new required config, rolling-upgrade safe.

Non-blocking nits: (a) the except TypeError guards key construction, but an unhashable kwarg value would only raise later at _INFLIGHT.get(key) — unreachable for real getaddrinfo args (ints), fine as-is; (b) a malformed REMOTE_GATEWAY_DNS_TIMEOUT (e.g. "abc") raises ValueError at import — a fallback-to-default would be gentler.

Prior-art note: open #2323 also touches remote_gateway_bridge.py; whichever merges second needs a rebase + re-run. No semantic conflict (different regions: DNS preamble vs auth recovery).

@bassilkhilo-ag2 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving — adding analysis rather than a bare LGTM, since the two subtle traps in this pattern are both already handled and that's worth recording for the next reader.

The core insight is correct and non-obvious: urllib's socket timeout covers connect and read but not name resolution, so a hung resolver wedges the loop with no status write and no self-recovery. That's a real gap and the fix targets exactly it.

Trap 1 — recursive self-wrapping on reload — is handled.

_orig_getaddrinfo = getattr(socket.getaddrinfo, "_ag2_orig_getaddrinfo", socket.getaddrinfo)

Capturing socket.getaddrinfo blindly on a module re-exec would capture the previous wrapper, giving RecursionError on the first resolve. Carrying the true original on an attribute is the right idiom. This is the kind of thing that only surfaces in a reload path nobody tests, so good catch.

Trap 2 — thread leak — is also handled, which is what I came to check. getaddrinfo can't be interrupted, so the timed-out call keeps running; the naive version of this pattern spawns one unkillable thread per retry and, against a resolver that's down for an hour with a backoff loop retrying, accumulates hundreds. The _INFLIGHT dict keyed on (host, args) with a shared Event means repeated retries join the existing flight rather than starting a new one, so a persistently wedged resolver costs one thread per distinct host, not one per attempt. Plus daemon=True so it can't hold up interpreter exit. Bounded — and the comment at the bottom of the diff shows you'd already reasoned through the leak.

Relevant live context: I hit a two-minute hang on a gateway room read on this host today, and the same call succeeded later. That's consistent with transient resolution/broker slowness of the kind this bounds, though I can't attribute my specific hang to DNS — I didn't capture the resolver state at the time, so treat that as circumstantial rather than a reproduction.

Nit

_DNS_TIMEOUT_S = float(os.environ.get("REMOTE_GATEWAY_DNS_TIMEOUT") or "8") will raise ValueError at import time on a malformed value (REMOTE_GATEWAY_DNS_TIMEOUT=abc), which fails the bridge at startup on a config typo rather than falling back to the default. Given this module's other env reads are defensive, a try/except ValueError → 8 would match the house style. Minor, but it's a startup-fatal path.

What I did not verify

Did not run packages/ag2-sparrow/tests/test_dns_timeout.py. Good to see it wired into ci.yml in the same PR rather than left to a follow-up.

@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@qingyun-wu
qingyun-wu merged commit 01b9ef3 into main Jul 26, 2026
17 checks passed
@qingyun-wu
qingyun-wu deleted the fix/gateway-dns-timeout branch July 26, 2026 20:28
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.

6 participants