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}