@@ -2176,19 +2176,32 @@ pub fn build_fallback_specs(
21762176 . collect ( )
21772177}
21782178
2179- /// Process-wide fallback providers, set once at startup (like the primary, these are config).
2180- /// Empty until set, so failover is simply inert when unconfigured or in tests.
2181- static FALLBACK_PROVIDERS : std:: sync:: OnceLock < Vec < FallbackProviderSpec > > = std:: sync:: OnceLock :: new ( ) ;
2182-
2183- /// Install the fallback provider list. Call once at startup, after the primary is built; a second
2184- /// call is ignored (OnceLock). **Do not call from tests** — the `OnceLock` can't be reset, so it
2185- /// would pollute every other test in the binary. Tests drive [`try_fallbacks`] directly instead.
2179+ /// Process-wide fallback providers (config, like the primary). Empty until set, so failover is
2180+ /// simply inert when unconfigured. Stored behind an `RwLock` rather than a `OnceLock` so an embedder
2181+ /// that rebuilds its runtime — e.g. a desktop app that re-bootstraps on a provider/model switch —
2182+ /// can refresh the list, keeping the fallbacks consistent with the current primary instead of
2183+ /// frozen at the first call.
2184+ static FALLBACK_PROVIDERS : std:: sync:: RwLock < Vec < FallbackProviderSpec > > =
2185+ std:: sync:: RwLock :: new ( Vec :: new ( ) ) ;
2186+
2187+ /// Install (or replace) the fallback provider list. Safe to call repeatedly — each call replaces the
2188+ /// previous list; pass an empty vec to disable failover.
2189+ ///
2190+ /// **Don't mutate this from tests.** It's process-global and the reasoning-loop tests read it (via
2191+ /// `chat_with_retry` on primary exhaustion), so setting it from a test races the rest of the binary
2192+ /// under Cargo's parallel runner. Drive [`try_fallbacks`] directly instead, as the failover tests do.
21862193pub fn set_fallback_providers ( specs : Vec < FallbackProviderSpec > ) {
2187- let _ = FALLBACK_PROVIDERS . set ( specs) ;
2194+ // Recover from a poisoned lock: we replace the list wholesale, so any state a panicking writer
2195+ // left behind is irrelevant — honoring the poison would instead wedge failover config for the
2196+ // rest of the process.
2197+ let mut guard = FALLBACK_PROVIDERS . write ( ) . unwrap_or_else ( |e| e. into_inner ( ) ) ;
2198+ * guard = specs;
21882199}
21892200
2190- fn fallback_providers ( ) -> & ' static [ FallbackProviderSpec ] {
2191- FALLBACK_PROVIDERS . get ( ) . map ( Vec :: as_slice) . unwrap_or ( & [ ] )
2201+ /// Snapshot of the current fallback list. Returns an owned clone so the lock isn't held across the
2202+ /// (async) failover chat calls. Recovers a poisoned read lock so failover keeps working.
2203+ fn fallback_providers ( ) -> Vec < FallbackProviderSpec > {
2204+ FALLBACK_PROVIDERS . read ( ) . unwrap_or_else ( |e| e. into_inner ( ) ) . clone ( )
21922205}
21932206
21942207/// Result of attempting the configured fallback providers.
@@ -2355,8 +2368,9 @@ async fn chat_with_retry(
23552368 // Primary exhausted. Before surfacing a failure, try each configured fallback provider once, so
23562369 // a transient outage / key rotation / model deprecation on the primary doesn't drop a long
23572370 // unattended turn. The primary stays the active provider — failover is per-call.
2371+ let fallbacks = fallback_providers ( ) ;
23582372 match try_fallbacks (
2359- fallback_providers ( ) ,
2373+ & fallbacks ,
23602374 |s| {
23612375 crate :: provider:: create_provider ( & s. provider_name , & s. base_url , & s. api_key , & s. model_name )
23622376 } ,
0 commit comments