|
8 | 8 |
|
9 | 9 | import asyncio |
10 | 10 | import atexit |
| 11 | +import concurrent.futures |
11 | 12 | import logging |
12 | 13 | import threading |
13 | 14 | from typing import Any |
@@ -102,6 +103,9 @@ def __init__(self, config: GrpcConfig): |
102 | 103 | # map each asyncio loop to its async channel and stub dict |
103 | 104 | self._channels_async: dict[asyncio.AbstractEventLoop, Any] = {} |
104 | 105 | self._stubs_async_map: dict[asyncio.AbstractEventLoop, dict[type[Any], Any]] = {} |
| 106 | + # Guards close() / close_sync() against running twice. The atexit |
| 107 | + # handler always fires, so an explicit close must leave it a no-op. |
| 108 | + self._closed = False |
105 | 109 | # default loop for sync API |
106 | 110 | self._default_loop = asyncio.new_event_loop() |
107 | 111 | atexit.register(self.close_sync) |
@@ -160,21 +164,47 @@ def get_stub(self, stub_class: type[Any]) -> Any: |
160 | 164 | return stubs[stub_class] |
161 | 165 |
|
162 | 166 | def close_sync(self): |
163 | | - """Close the sync channel and all async channels.""" |
| 167 | + """Close the sync channel and all async channels. Idempotent.""" |
| 168 | + if self._closed: |
| 169 | + return |
| 170 | + self._closed = True |
164 | 171 | try: |
165 | | - for ch in self._channels_async.values(): |
166 | | - asyncio.run_coroutine_threadsafe(ch.close(), self._default_loop).result() |
167 | | - self._default_loop.call_soon_threadsafe(self._default_loop.stop) |
| 172 | + # Only drive the loop if it's still running; submitting a coroutine |
| 173 | + # to a stopped loop never resolves and would hang on .result(). |
| 174 | + if self._default_loop.is_running(): |
| 175 | + for ch in self._channels_async.values(): |
| 176 | + asyncio.run_coroutine_threadsafe(ch.close(), self._default_loop).result( |
| 177 | + timeout=5.0 |
| 178 | + ) |
| 179 | + self._default_loop.call_soon_threadsafe(self._default_loop.stop) |
168 | 180 | self._default_loop_thread.join(timeout=1.0) |
169 | | - except ValueError: |
| 181 | + except (ValueError, RuntimeError, concurrent.futures.TimeoutError): |
170 | 182 | ... |
| 183 | + finally: |
| 184 | + self._release_channels() |
171 | 185 |
|
172 | 186 | async def close(self): |
173 | | - """Close sync and async channels and stop the default loop.""" |
| 187 | + """Close sync and async channels and stop the default loop. Idempotent.""" |
| 188 | + if self._closed: |
| 189 | + return |
| 190 | + self._closed = True |
174 | 191 | for ch in self._channels_async.values(): |
175 | 192 | await ch.close() |
176 | 193 | self._default_loop.call_soon_threadsafe(self._default_loop.stop) |
177 | 194 | self._default_loop_thread.join(timeout=1.0) |
| 195 | + self._release_channels() |
| 196 | + |
| 197 | + def _release_channels(self): |
| 198 | + """Drop references to the closed channels and stubs. |
| 199 | +
|
| 200 | + The gRPC C-core defers a channel's resource release until the Python |
| 201 | + object is destroyed, not merely closed. Holding the channels in these |
| 202 | + maps keeps them alive until interpreter finalization, which races the |
| 203 | + C-core's own exit-time shutdown ("grpc_wait_for_shutdown_with_timeout() |
| 204 | + timed out"). Clearing the maps lets the channels be collected promptly. |
| 205 | + """ |
| 206 | + self._channels_async.clear() |
| 207 | + self._stubs_async_map.clear() |
178 | 208 |
|
179 | 209 | async def __aenter__(self): |
180 | 210 | return self |
|
0 commit comments