Skip to content

Commit 92b20d0

Browse files
committed
fix: global hard stop coverage, 1s save task, exhaustion detail logging
1 parent c7e860c commit 92b20d0

5 files changed

Lines changed: 89 additions & 15 deletions

File tree

src/bin/ui.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1224,17 +1224,19 @@ impl eframe::App for App {
12241224

12251225
// Use quota-tracked daily capacity when available, fall back to
12261226
// the free-tier default for display purposes.
1227+
// quota_used: total relay() calls (exit node + Apps Script combined)
1228+
// so "fetches today" reflects all proxied traffic, not just Apps Script.
12271229
let (quota_cap, quota_used, _quota_remaining, any_exhausted, global_stop) =
12281230
if let Some(q) = &quota_state {
12291231
(
12301232
q.daily_capacity_total.max(1),
1231-
q.requests_used_total,
1233+
s.total_relay_calls,
12321234
q.requests_remaining_total,
12331235
q.exhausted_count > 0,
12341236
q.global_hard_stop,
12351237
)
12361238
} else {
1237-
(20_000u64, s.today_calls, 20_000u64.saturating_sub(s.today_calls), false, false)
1239+
(20_000u64, s.total_relay_calls, 20_000u64.saturating_sub(s.total_relay_calls), false, false)
12381240
};
12391241

12401242
let pct = (quota_used as f64 / quota_cap as f64) * 100.0;

src/domain_fronter.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,11 @@ pub struct DomainFronter {
365365
/// strike state is per-deployment health bookkeeping, not the
366366
/// permanent ban list.
367367
script_timeouts: Arc<std::sync::Mutex<HashMap<String, (Instant, u32)>>>,
368+
/// Every call to `relay()` increments this — exit node AND Apps Script.
369+
/// Use this for UI "fetches today" display. Distinct from `relay_calls`
370+
/// (Apps-Script-direct only) and from the quota tracker's `requests_used`
371+
/// (also Apps-Script-only).
372+
total_relay_calls: AtomicU64,
368373
relay_calls: AtomicU64,
369374
relay_failures: AtomicU64,
370375
bytes_relayed: AtomicU64,
@@ -633,6 +638,7 @@ impl DomainFronter {
633638
coalesced: AtomicU64::new(0),
634639
blacklist: Arc::new(std::sync::Mutex::new(HashMap::new())),
635640
script_timeouts: Arc::new(std::sync::Mutex::new(HashMap::new())),
641+
total_relay_calls: AtomicU64::new(0),
636642
relay_calls: AtomicU64::new(0),
637643
relay_failures: AtomicU64::new(0),
638644
bytes_relayed: AtomicU64::new(0),
@@ -775,6 +781,7 @@ impl DomainFronter {
775781
guard.clone()
776782
};
777783
StatsSnapshot {
784+
total_relay_calls: self.total_relay_calls.load(Ordering::Relaxed),
778785
relay_calls: self.relay_calls.load(Ordering::Relaxed),
779786
relay_failures: self.relay_failures.load(Ordering::Relaxed),
780787
coalesced: self.coalesced.load(Ordering::Relaxed),
@@ -1784,6 +1791,23 @@ impl DomainFronter {
17841791
headers: &[(String, String)],
17851792
body: &[u8],
17861793
) -> Vec<u8> {
1794+
self.total_relay_calls.fetch_add(1, Ordering::Relaxed);
1795+
1796+
// Block ALL relay paths (exit node + Apps Script) when every account
1797+
// bucket is quota-exhausted. Checked here so the exit node short-circuit
1798+
// below can't bypass the global hard stop.
1799+
if self.quota_tracker.is_globally_hard_stopped() {
1800+
self.relay_failures.fetch_add(1, Ordering::Relaxed);
1801+
tracing::error!(
1802+
"[quota] global hard stop active — all Apps Script account buckets exhausted"
1803+
);
1804+
return error_response(
1805+
502,
1806+
"All Apps Script accounts quota exhausted; hard stop active. \
1807+
Quota resets on a rolling 24-hour window per account.",
1808+
);
1809+
}
1810+
17871811
// Optional URL rewrite for X/Twitter GraphQL (issue #16). Applied
17881812
// here, at the top of relay(), so it affects BOTH the cache key
17891813
// (so matching requests collapse into one entry) AND the URL that
@@ -4907,6 +4931,9 @@ fn decode_js_string_escapes(s: &str) -> Option<String> {
49074931

49084932
#[derive(Debug, Clone)]
49094933
pub struct StatsSnapshot {
4934+
/// Total calls to `relay()` — all traffic through this fronter including
4935+
/// exit node and Apps Script. Use for "fetches today" display.
4936+
pub total_relay_calls: u64,
49104937
pub relay_calls: u64,
49114938
pub relay_failures: u64,
49124939
pub coalesced: u64,

src/main.rs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -357,11 +357,6 @@ async fn main() -> ExitCode {
357357
}
358358
};
359359

360-
// Log quota state on startup so the user knows where their daily budget stands.
361-
if let Some(summary) = server.quota_startup_summary() {
362-
tracing::info!("{}", summary);
363-
}
364-
365360
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
366361

367362
let run = server.run(shutdown_rx);

src/proxy_server.rs

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,9 @@ impl ProxyServer {
564564
"Listening SOCKS5 on {} — xray / Telegram / app-level SOCKS5 clients use this.",
565565
socks_addr
566566
);
567+
if let Some(summary) = self.quota_startup_summary() {
568+
tracing::info!("{}", summary);
569+
}
567570
// Pre-warm the outbound connection pool so the user's first request
568571
// doesn't pay a fresh TLS handshake to Google edge. Best-effort;
569572
// failures are logged and ignored. Skipped in `direct` mode —
@@ -616,22 +619,25 @@ impl ProxyServer {
616619

617620
let stats_task = if let Some(stats_fronter) = self.fronter.clone() {
618621
tokio::spawn(async move {
619-
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60));
622+
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(15));
620623
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
621-
ticker.tick().await;
624+
let mut was_hard_stopped = false;
622625
loop {
623626
ticker.tick().await;
624627
let s = stats_fronter.snapshot_stats();
625628
if s.relay_calls > 0 || s.cache_hits > 0 {
626629
tracing::info!("{}", s.fmt_line());
627630
}
628-
// Log quota warnings and flush persisted state.
629631
let q = &s.quota;
630632
if q.global_hard_stop {
631633
tracing::error!(
632634
"[quota] GLOBAL HARD STOP — all {} account(s) exhausted",
633635
q.account_count
634636
);
637+
// Log per-account reasons only on the first tick after transition.
638+
if !was_hard_stopped {
639+
stats_fronter.quota_tracker().log_exhaustion_details();
640+
}
635641
} else if q.exhausted_count > 0 {
636642
tracing::warn!(
637643
"[quota] {}/{} account(s) exhausted used={}/{} remaining={}",
@@ -642,13 +648,29 @@ impl ProxyServer {
642648
q.requests_remaining_total,
643649
);
644650
}
651+
was_hard_stopped = q.global_hard_stop;
645652
// Roll any expired 24-hour windows so idle accounts come
646653
// back online even without inbound traffic.
647654
stats_fronter.quota_tracker().roll_expired_windows();
648-
// Always flush so the file is up-to-date even when idle.
649-
// save_if_needed() skips the write when dirty_count == 0,
650-
// which means zero-traffic sessions never update the file.
651-
stats_fronter.quota_tracker().save();
655+
// Safety-net flush for idle sessions (1s save task handles
656+
// active-traffic saves; this covers the zero-traffic case).
657+
stats_fronter.quota_tracker().save_if_needed();
658+
}
659+
})
660+
} else {
661+
tokio::spawn(async move { std::future::pending::<()>().await })
662+
};
663+
664+
// Flush quota state to disk every second so the JSON file stays within
665+
// ~1s of in-memory state. The Mutex-backed in-memory state is always
666+
// real-time; this just keeps the on-disk snapshot current.
667+
let save_task = if let Some(save_fronter) = self.fronter.clone() {
668+
tokio::spawn(async move {
669+
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(1));
670+
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
671+
loop {
672+
ticker.tick().await;
673+
save_fronter.quota_tracker().save_if_needed();
652674
}
653675
})
654676
} else {
@@ -744,6 +766,7 @@ impl ProxyServer {
744766
biased;
745767
_ = &mut shutdown_rx => {
746768
tracing::info!("Shutdown signal received, stopping listeners");
769+
save_task.abort();
747770
stats_task.abort();
748771
keepalive_task.abort();
749772
refill_task.abort();

src/quota_tracker.rs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,21 +487,48 @@ impl QuotaTracker {
487487
}
488488

489489
/// Build a human-readable startup summary line.
490+
/// Log the masked ID and exhaustion reason for every hard-stopped bucket.
491+
/// Called once when global hard stop transitions from false to true.
492+
pub fn log_exhaustion_details(&self) {
493+
let st = self.state.lock().unwrap();
494+
for sid in &self.script_ids {
495+
let Some(b) = st.buckets.get(sid) else { continue };
496+
if b.hard_stopped {
497+
let reason = b.exhaustion_reason.as_deref().unwrap_or("no reason recorded");
498+
tracing::warn!("[quota] {} exhausted: {}", b.masked_id, reason);
499+
}
500+
}
501+
}
502+
490503
pub fn startup_summary(&self) -> String {
491504
let s = self.summary();
492505
let now = now_unix();
493506
let reset_str = s.next_reset_at.map(|r| {
494507
let secs = r.saturating_sub(now);
495508
format!(" next_reset=in {}h {}m", secs / 3600, (secs / 60) % 60)
496509
}).unwrap_or_default();
510+
let stop_suffix = if s.global_hard_stop {
511+
format!(" exhausted={}/{} HARD-STOP", s.exhausted_count, s.account_count)
512+
} else if s.exhausted_count > 0 {
513+
format!(" exhausted={}/{}", s.exhausted_count, s.account_count)
514+
} else {
515+
String::new()
516+
};
497517

498518
format!(
499-
"[quota] {} account(s) capacity={}/day used={} remaining={}{}",
519+
"[quota] {} account(s) capacity={}/day used={} remaining={}{}{}",
500520
s.account_count,
501521
s.daily_capacity_total,
502522
s.requests_used_total,
503523
s.requests_remaining_total,
504524
reset_str,
525+
stop_suffix,
505526
)
506527
}
507528
}
529+
530+
impl Drop for QuotaTracker {
531+
fn drop(&mut self) {
532+
self.save();
533+
}
534+
}

0 commit comments

Comments
 (0)