@@ -1208,7 +1208,42 @@ def restore_primary_runtime(agent) -> bool:
12081208 api_mode = rt .get ("compressor_api_mode" , "" ),
12091209 )
12101210
1211- # ── Re-select from the credential pool if one is available ──
1211+ # ── Rebind and re-select the primary credential pool ──
1212+ # A cross-provider fallback attaches the fallback provider's pool. The
1213+ # runtime fields above restore the primary, but leaving that pool in
1214+ # place makes the next primary 401/429 hit the provider-mismatch guard
1215+ # and disables credential rotation. Reload the primary pool first; if
1216+ # auth storage is temporarily unreadable, clear the mismatched pool.
1217+ primary_provider = str (rt .get ("provider" ) or "" ).strip ().lower ()
1218+ pool = getattr (agent , "_credential_pool" , None )
1219+ pool_provider = str (getattr (pool , "provider" , "" ) or "" ).strip ().lower ()
1220+ pool_matches_primary = pool_provider == primary_provider
1221+ if (
1222+ primary_provider == "custom"
1223+ and pool_provider .startswith ("custom:" )
1224+ ):
1225+ try :
1226+ from agent .credential_pool import get_custom_provider_pool_key
1227+
1228+ primary_key = (
1229+ get_custom_provider_pool_key (str (rt .get ("base_url" ) or "" )) or ""
1230+ ).strip ().lower ()
1231+ pool_matches_primary = bool (primary_key ) and primary_key == pool_provider
1232+ except Exception :
1233+ pool_matches_primary = False
1234+ if pool is not None and pool_provider and not pool_matches_primary :
1235+ agent ._credential_pool = None
1236+ try :
1237+ from agent .credential_pool import load_pool
1238+
1239+ agent ._credential_pool = load_pool (primary_provider )
1240+ except Exception as exc :
1241+ logger .warning (
1242+ "Restore could not reload primary credential pool for %s: %s" ,
1243+ primary_provider ,
1244+ exc ,
1245+ )
1246+
12121247 # The snapshot's api_key was captured at construction time. Across
12131248 # turns the pool may have rotated (token revocation, billing/rate-limit
12141249 # exhaustion, cooldown), leaving the snapshot key stale. Restoring it
@@ -1222,7 +1257,6 @@ def restore_primary_runtime(agent) -> bool:
12221257 entry = pool .select ()
12231258 if entry is not None :
12241259 entry_provider = str (getattr (entry , "provider" , "" ) or "" ).strip ().lower ()
1225- primary_provider = str (rt .get ("provider" ) or "" ).strip ().lower ()
12261260 entry_matches_primary = entry_provider == primary_provider
12271261 # Custom endpoints all carry the generic ``custom`` provider on
12281262 # the agent while the pool entry is keyed ``custom:<name>`` (see
@@ -1858,6 +1892,9 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
18581892 old_norm = (old_provider or "" ).strip ().lower ()
18591893 new_norm = (new_provider or "" ).strip ().lower ()
18601894 if old_norm != new_norm or getattr (agent , "_credential_pool" , None ) is None :
1895+ # A pool bound to the old provider is worse than no pool: the
1896+ # recovery guard rejects it and every later 401/429 skips rotation.
1897+ agent ._credential_pool = None
18611898 try :
18621899 from agent .credential_pool import load_pool
18631900 agent ._credential_pool = load_pool (new_provider )
@@ -3161,20 +3198,21 @@ def apply_pending_steer_to_tool_results(agent, messages: list, num_tool_msgs: in
31613198
31623199
31633200
3164- def force_close_tcp_sockets (client : Any , * , release_fds : bool = False ) -> int :
3165- """Abort in-flight TCP I/O by shutting down pool sockets.
3201+ def force_close_tcp_sockets (client : Any ) -> int :
3202+ """Abort in-flight TCP I/O by shutting down sockets WITHOUT closing FDs .
31663203
31673204 When a provider drops a connection mid-stream — or the user issues an
31683205 interrupt — we want to unblock httpx's reader/writer immediately rather
31693206 than waiting for the kernel's per-connection timeout. ``shutdown(SHUT_RDWR)``
31703207 achieves that: it sends FIN, breaks any pending ``recv``/``send`` with EOF
3171- or ``EPIPE``.
3208+ or ``EPIPE``, but does NOT release the file descriptor .
31723209
3173- By default (``release_fds=False``) this helper does **not** call
3174- ``socket.close()`` / release the FD. That default is load-bearing for
3175- cross-thread abort paths (#29507):
3210+ Historically this helper also called ``socket.close()`` so the FD got
3211+ released immediately, but that's unsafe when (as is the case for both the
3212+ interrupt-abort path and stale-call kill path) the helper runs on a
3213+ different thread than the one driving the request:
31763214
3177- * The Python ``socket.socket`` we close is the SAME object held by
3215+ * The Python ``socket.socket`` we close here is the SAME object held by
31783216 httpx's pool, so closing it via Python sets its ``_fd`` to -1 and
31793217 future operations on that Python object fail safely.
31803218 * BUT the SSL wrapper (``ssl.SSLSocket``'s underlying OpenSSL ``BIO``)
@@ -3186,20 +3224,15 @@ def force_close_tcp_sockets(client: Any, *, release_fds: bool = False) -> int:
31863224 wrong file (issue #29507: 24-byte TLS application-data record
31873225 clobbering SQLite header bytes 5..28).
31883226
3189- ``shutdown()`` from any thread is FD-safe; ``close()`` is not when a
3190- stranger thread still has the BIO holding the raw FD.
3227+ The fix is to let the owning thread own the close. ``shutdown()`` from any
3228+ thread is FD-safe; ``close()`` is not. The httpx connection's own close
3229+ path — which runs from the worker thread when it unwinds — will release
3230+ the FD via the same ``socket.socket`` object, and because Python's socket
3231+ close atomically swaps ``_fd`` to -1 *before* issuing ``os.close``, there
3232+ is no FD-aliasing window when only one thread closes.
31913233
3192- When the **owning** thread is disposing of a client that is no longer
3193- shared (``_close_openai_client`` after replace / request-complete), pass
3194- ``release_fds=True``. httpx's own ``client.close()`` does not reliably
3195- ``os.close()`` sockets that were already ``shutdown()``'d, so without an
3196- explicit ``sock.close()`` those FDs stay in kernel CLOSED state forever
3197- and accumulate under long-lived gateways (issue #61979 — ~1 CLOSED fd
3198- per ~6 minutes through a local proxy path).
3199-
3200- Returns the number of sockets shut down (and optionally closed). Field
3201- kept as ``tcp_force_closed=N`` in log lines for backwards-compatible
3202- parsing.
3234+ Returns the number of sockets shut down. (Field kept as
3235+ ``tcp_force_closed=N`` in the log line for backwards-compatible parsing.)
32033236 """
32043237 import socket as _socket
32053238
@@ -3211,13 +3244,7 @@ def force_close_tcp_sockets(client: Any, *, release_fds: bool = False) -> int:
32113244 except OSError :
32123245 # Already shut down / not connected / FD invalid — all benign.
32133246 pass
3214- # IMPORTANT (#29507): never release FDs from stranger-thread
3215- # abort paths. Only the owning-thread close path may opt in.
3216- if release_fds :
3217- try :
3218- sock .close ()
3219- except OSError :
3220- pass
3247+ # IMPORTANT (#29507): do NOT call sock.close() here. See docstring.
32213248 shutdown_count += 1
32223249 except Exception as exc :
32233250 _ra ().logger .debug ("Force-close TCP sockets sweep error: %s" , exc )
0 commit comments