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