Skip to main content

vct_core/quota/
provider.rs

1//! Generic background quota worker, shared by every quota provider.
2//!
3//! Each provider gets its own thread (so one provider's slow HTTP never stalls
4//! the others), but they all run the same panic-isolated poll loop here (cadence
5//! from `config.usage.quota.refresh_interval`). A provider supplies a stateful
6//! `resolve` closure returning a [`QuotaOutcome`]; the worker applies it to the
7//! shared snapshot:
8//!
9//! - [`QuotaOutcome::Data`] — store the fresh snapshot + persist it.
10//! - [`QuotaOutcome::NeedsLogin`] — flip `needs_login` on the *current*
11//!   snapshot (keep its data), so a refresh failure never blanks out
12//!   still-valid last-known-good numbers (S3).
13//! - [`QuotaOutcome::Transient`] — keep last-known-good indefinitely, so a rate
14//!   limit or network blip never blanks a panel; the next success overwrites it.
15
16use crate::models::{
17    ClaudeQuotaSnapshot, CodexQuotaSnapshot, CopilotQuotaSnapshot, CursorQuotaSnapshot, QuotaSource,
18};
19use serde::Serialize;
20use std::panic::{AssertUnwindSafe, catch_unwind};
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::{Arc, Mutex};
23use std::thread::JoinHandle;
24use std::time::Duration;
25
26/// A normalized quota snapshot the worker can store and flag.
27pub trait QuotaSnapshot: Clone + Default + Send + Serialize + 'static {
28    /// Unix seconds when this snapshot was produced.
29    fn fetched_at(&self) -> i64;
30    /// Whether this snapshot carries anything to show (data or a login hint).
31    fn is_present(&self) -> bool;
32    /// Sets the `needs_login` flag without touching the data.
33    fn set_needs_login(&mut self, value: bool);
34}
35
36impl QuotaSnapshot for ClaudeQuotaSnapshot {
37    fn fetched_at(&self) -> i64 {
38        self.fetched_at
39    }
40    fn is_present(&self) -> bool {
41        self.five_hour.is_some()
42            || self.seven_day.is_some()
43            // Mirror the render gate: a scoped row needs both the window and its
44            // model label, so a label-less scoped window is not "present" alone.
45            || (self.scoped_weekly.is_some() && self.scoped_label.is_some())
46            || self.needs_login
47    }
48    fn set_needs_login(&mut self, value: bool) {
49        self.needs_login = value;
50    }
51}
52
53impl QuotaSnapshot for CodexQuotaSnapshot {
54    fn fetched_at(&self) -> i64 {
55        self.fetched_at
56    }
57    fn is_present(&self) -> bool {
58        self.source != QuotaSource::None || self.needs_login
59    }
60    fn set_needs_login(&mut self, value: bool) {
61        self.needs_login = value;
62    }
63}
64
65impl QuotaSnapshot for CopilotQuotaSnapshot {
66    fn fetched_at(&self) -> i64 {
67        self.fetched_at
68    }
69    fn is_present(&self) -> bool {
70        // Mirror the render gate: a premium gauge, an unlimited flag, the plan
71        // label, or a login hint all give the panel something to show. (Chat /
72        // completions unlimited flags are not rendered, so they must not gate
73        // visibility either, or the panel would show "no Copilot quota".)
74        self.premium.is_some()
75            || self.premium_unlimited
76            || self.plan_type.is_some()
77            || self.needs_login
78    }
79    fn set_needs_login(&mut self, value: bool) {
80        self.needs_login = value;
81    }
82}
83
84impl QuotaSnapshot for CursorQuotaSnapshot {
85    fn fetched_at(&self) -> i64 {
86        self.fetched_at
87    }
88    fn is_present(&self) -> bool {
89        self.total.is_some()
90            || self.auto.is_some()
91            || self.api.is_some()
92            || self.plan_type.is_some()
93            || self.needs_login
94    }
95    fn set_needs_login(&mut self, value: bool) {
96        self.needs_login = value;
97    }
98}
99
100/// The result of one provider `resolve` tick.
101pub enum QuotaOutcome<T> {
102    /// A fresh snapshot to store (may itself carry `needs_login`, e.g. Codex
103    /// session-fallback data + login hint).
104    Data(T),
105    /// Auth failed and there is no fallback data: flag the current snapshot for
106    /// re-login but keep whatever it is already showing.
107    NeedsLogin,
108    /// Transient failure (network / rate limit): keep last-known-good.
109    Transient,
110}
111
112/// Spawns a detached background worker that refreshes `shared` (and the on-disk
113/// cache via `save`) every `refresh_secs` until `shutdown` is set.
114///
115/// The worker is panic-isolated and holds the mutex only for the assignment, so
116/// it can never poison the lock. It is not joined on quit — `shutdown` is set as
117/// a courtesy and the OS reclaims the thread on process exit.
118pub fn spawn_quota_worker<T, R, S>(
119    label: &'static str,
120    shared: Arc<Mutex<T>>,
121    shutdown: Arc<AtomicBool>,
122    refresh_secs: u64,
123    mut resolve: R,
124    save: S,
125) -> JoinHandle<()>
126where
127    T: QuotaSnapshot,
128    R: FnMut() -> QuotaOutcome<T> + Send + 'static,
129    S: Fn(&T) + Send + 'static,
130{
131    std::thread::spawn(move || {
132        loop {
133            if shutdown.load(Ordering::Relaxed) {
134                break;
135            }
136            match catch_unwind(AssertUnwindSafe(&mut resolve)) {
137                Ok(QuotaOutcome::Data(snap)) => {
138                    if let Ok(mut guard) = shared.lock() {
139                        *guard = snap.clone();
140                    }
141                    save(&snap);
142                }
143                Ok(QuotaOutcome::NeedsLogin) => {
144                    let mut updated = None;
145                    if let Ok(mut guard) = shared.lock() {
146                        guard.set_needs_login(true);
147                        updated = Some(guard.clone());
148                    }
149                    if let Some(snap) = updated {
150                        save(&snap);
151                    }
152                }
153                // A transient failure (network error, 429 rate limit) leaves the
154                // last-known-good snapshot untouched — the panel keeps showing it
155                // with a growing staleness marker until the next success
156                // overwrites it, rather than blanking out.
157                Ok(QuotaOutcome::Transient) => {}
158                Err(_) => log::warn!("{label} quota worker panicked; keeping last snapshot"),
159            }
160            // Sleep in 200ms slices so shutdown stays responsive.
161            for _ in 0..(refresh_secs * 5) {
162                if shutdown.load(Ordering::Relaxed) {
163                    break;
164                }
165                std::thread::sleep(Duration::from_millis(200));
166            }
167        }
168    })
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::models::CodexQuotaSnapshot;
175
176    #[test]
177    fn needs_login_snapshot_is_present() {
178        let snap = CodexQuotaSnapshot {
179            needs_login: true,
180            ..Default::default()
181        };
182        assert!(snap.is_present());
183    }
184}