Skip to content

Commit 820716e

Browse files
Capture asyncio diagnostics via loop exception handler not warnings channel
The cycle-27 behavioural pins for the leader-probe drain (AC1) and the close_impl re-snapshot loop (CC2) used ``warnings.catch_warnings`` to assert that asyncio's "Task exception was never retrieved" / "Task was destroyed but it is pending" diagnostics did not fire. asyncio surfaces both via the loop's ``call_exception_handler`` (which routes to the asyncio logger), NOT via ``warnings.warn``. The pins captured nothing on the warnings channel, the list comprehension yielded ``[]``, and the assert silently passed against the very regression they were added to prevent. Switch the capture mechanism to ``loop.set_exception_handler`` / ``loop.get_exception_handler`` so the assertion actually observes what asyncio emits. Add a positive-control test that verifies the capture mechanism observes a synthetic unobserved-exception diagnostic — without it, a future regression on the capture path itself (e.g., asyncio changing how it surfaces the diagnostic) would silently make the negative pin pass for the wrong reason. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9c6e72b commit 820716e

2 files changed

Lines changed: 88 additions & 21 deletions

File tree

tests/test_close_impl_reaps_pending_drain_created_during_await.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
from __future__ import annotations
1717

1818
import asyncio
19-
import warnings
2019

2120
import pytest
2221

@@ -62,25 +61,36 @@ def _race_invalidate_callback() -> None:
6261
# iteration which is exactly when ``await pending`` yields.
6362
loop.call_soon(_race_invalidate_callback)
6463

65-
with warnings.catch_warnings(record=True) as captured:
66-
warnings.simplefilter("always")
64+
# asyncio surfaces the "Task was destroyed but it is pending" /
65+
# "Task exception was never retrieved" diagnostics via the loop's
66+
# exception handler, NOT via ``warnings.warn``. Capture via
67+
# ``loop.set_exception_handler`` so the assert sees what asyncio
68+
# actually emits.
69+
captured: list[dict[str, object]] = []
70+
prior_handler = loop.get_exception_handler()
71+
loop.set_exception_handler(lambda _loop, ctx: captured.append(ctx))
72+
try:
6773
await conn._close_impl()
68-
# Let the loop drain so any orphaned task surfaces as a
69-
# "Task was destroyed but it is pending" warning at GC.
74+
# Let the loop drain so any orphaned task surfaces a diagnostic
75+
# at GC.
7076
await asyncio.sleep(0.05)
77+
finally:
78+
loop.set_exception_handler(prior_handler)
7179

7280
# Both A and B must be done — the loop reaped both.
7381
assert pending_a.done()
7482
assert pending_b_holder, "race callback should have run"
7583
assert pending_b_holder[0].done(), (
7684
"fresh _pending_drain task created during await must be reaped"
7785
)
78-
# No "Task was destroyed but it is pending" warnings on the
79-
# warnings channel.
80-
asyncio_warnings = [w for w in captured if "Task was destroyed" in str(w.message)]
81-
assert not asyncio_warnings, (
82-
f"Expected no orphaned-task warnings; got {[str(w.message) for w in asyncio_warnings]}"
83-
)
86+
# No "Task was destroyed but it is pending" diagnostic was
87+
# emitted via asyncio's exception handler.
88+
asyncio_diagnostics = [
89+
ctx for ctx in captured if "Task was destroyed" in str(ctx.get("message", ""))
90+
]
91+
if asyncio_diagnostics:
92+
msgs = [ctx.get("message") for ctx in asyncio_diagnostics]
93+
raise AssertionError(f"Expected no orphaned-task diagnostics; got {msgs}")
8494

8595

8696
@pytest.mark.asyncio

tests/test_query_leader_drain_no_unobserved_task_exception.py

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
import asyncio
1919
import contextlib
2020
import inspect
21-
import warnings
2221

2322
import pytest
2423

@@ -74,11 +73,16 @@ def test_query_leader_finally_uses_observed_drain_pattern() -> None:
7473
async def test_outer_cancel_during_drain_does_not_emit_unobserved_warning() -> None:
7574
"""Behavioural pin: cancel the outer task mid-drain, let the inner
7675
task run to its TimeoutError, drain the loop, and verify NO
77-
"Task exception was never retrieved" warning fires.
76+
"Task exception was never retrieved" diagnostic fires.
7877
7978
Drives a synthetic case mirroring the production shape — a task
8079
that runs the same shielded pattern in a tight loop, with the
8180
outer cancelled mid-flight.
81+
82+
asyncio surfaces the unobserved-exception diagnostic via
83+
``loop.call_exception_handler``, NOT via ``warnings.warn``.
84+
Capture via the loop's exception handler so the assert sees what
85+
asyncio actually emits.
8286
"""
8387

8488
async def _slow() -> None:
@@ -90,21 +94,74 @@ async def _drain_under_shield() -> None:
9094
with contextlib.suppress(OSError, TimeoutError):
9195
await asyncio.shield(inner)
9296

93-
with warnings.catch_warnings(record=True) as captured:
94-
warnings.simplefilter("always")
97+
captured: list[dict[str, object]] = []
98+
loop = asyncio.get_running_loop()
99+
prior_handler = loop.get_exception_handler()
100+
loop.set_exception_handler(lambda _loop, ctx: captured.append(ctx))
101+
try:
95102
t = asyncio.create_task(_drain_under_shield())
96103
await asyncio.sleep(0.001)
97104
t.cancel()
98105
with contextlib.suppress(asyncio.CancelledError):
99106
await t
100107
# Let the inner timeout fire and finalise.
101108
await asyncio.sleep(0.1)
109+
finally:
110+
loop.set_exception_handler(prior_handler)
111+
112+
# Ensure no asyncio "Task exception was never retrieved" diagnostic
113+
# was emitted to the loop's exception handler.
114+
asyncio_diagnostics = [
115+
ctx
116+
for ctx in captured
117+
if "Task exception was never retrieved" in str(ctx.get("message", ""))
118+
]
119+
if asyncio_diagnostics:
120+
msgs = [ctx.get("message") for ctx in asyncio_diagnostics]
121+
raise AssertionError(f"Expected no unobserved-task diagnostics; got {msgs}")
122+
123+
124+
@pytest.mark.asyncio
125+
async def test_loop_exception_handler_captures_unobserved_task_exception() -> None:
126+
"""Positive control: with no done-callback, the unobserved-
127+
exception diagnostic IS emitted via ``loop.call_exception_handler``
128+
— verifying the capture mechanism works. Without this control, a
129+
future regression on the capture path itself (e.g., asyncio
130+
changing how it surfaces the diagnostic) would silently make the
131+
negative pin above pass for the wrong reason.
132+
"""
102133

103-
# Ensure no asyncio "Task exception was never retrieved" warning
104-
# was captured on the warnings channel.
105-
asyncio_warnings = [
106-
w for w in captured if "Task exception was never retrieved" in str(w.message)
134+
async def _slow() -> None:
135+
await asyncio.sleep(60)
136+
137+
captured: list[dict[str, object]] = []
138+
loop = asyncio.get_running_loop()
139+
prior_handler = loop.get_exception_handler()
140+
loop.set_exception_handler(lambda _loop, ctx: captured.append(ctx))
141+
try:
142+
# Build an inner task that resolves with TimeoutError —
143+
# crucially with NO done-callback to observe the exception.
144+
inner = asyncio.ensure_future(asyncio.wait_for(_slow(), timeout=0.001))
145+
# Wait for the timeout to fire without awaiting the inner.
146+
await asyncio.sleep(0.05)
147+
# Drop the reference so the task can be GC'd → ``__del__``
148+
# fires → ``call_exception_handler`` reports the unobserved
149+
# exception.
150+
del inner
151+
import gc
152+
153+
gc.collect()
154+
await asyncio.sleep(0.01)
155+
finally:
156+
loop.set_exception_handler(prior_handler)
157+
158+
asyncio_diagnostics = [
159+
ctx
160+
for ctx in captured
161+
if "Task exception was never retrieved" in str(ctx.get("message", ""))
107162
]
108-
assert not asyncio_warnings, (
109-
f"Expected no unobserved-task warnings; got {[str(w.message) for w in asyncio_warnings]}"
163+
assert asyncio_diagnostics, (
164+
"Capture mechanism must observe asyncio's unobserved-exception "
165+
"diagnostic; if this fails, the negative pin above is silently "
166+
"passing for the wrong reason."
110167
)

0 commit comments

Comments
 (0)