返回 DeepSeek-TUI-2026
retry_status.rs
根目录 / crates / tui / src / retry_status.rs
1 //! Process-wide retry-state surface (#499).
2 //!
3 //! The HTTP retry path in `client::send_with_retry` already times its
4 //! waits and knows the error category. This module gives the TUI a way
5 //! to observe that state — `start`, `succeeded`, and `failed` flip a
6 //! global `RetryState` that the footer / status panel reads each frame.
7 //!
8 //! Why a process-wide global: the user-facing TUI runs as one engine
9 //! per process, and the only retry state we want to surface is the one
10 //! the user is staring at. Sub-agent retries in background tasks
11 //! deliberately do **not** light up the foreground banner — they're
12 //! supposed to be invisible. If a future feature ever needs per-engine
13 //! retry surfaces, swap this for an `Arc<RwLock<...>>` carried on the
14 //! `EngineHandle`; the public API stays the same.
15
16 use std::sync::{Mutex, OnceLock};
17 use std::time::{Duration, Instant};
18
19 /// One in-flight retry attempt. `deadline` is the wall-clock time the
20 /// next request will fire — the UI subtracts `Instant::now()` from it
21 /// to render a live countdown.
22 #[derive(Debug, Clone)]
23 pub struct RetryBanner {
24 /// 1-indexed retry attempt number (the first retry is attempt 1).
25 pub attempt: u32,
26 /// Time at which the next request will be sent.
27 pub deadline: Instant,
28 /// Short human-readable reason ("rate limited", "server error", …).
29 pub reason: String,
30 }
31
32 /// Snapshot of the retry surface for the UI to render.
33 #[derive(Debug, Clone, Default)]
34 pub enum RetryState {
35 /// No retry in flight. Banner hidden.
36 #[default]
37 Idle,
38 /// A request is sleeping before retrying. Show countdown banner.
39 Active(RetryBanner),
40 /// All retries exhausted; show failure row until the next turn
41 /// starts. `since` records when the row was set so a future polish
42 /// pass can age it out automatically; today the engine clears it on
43 /// `TurnStarted`.
44 Failed {
45 reason: String,
46 #[allow(dead_code)]
47 since: Instant,
48 },
49 }
50
51 impl RetryState {
52 /// Wall-clock seconds remaining on the active banner, or `None` if
53 /// not active. Saturates at zero — the renderer should treat any
54 /// negative remaining as "firing now".
55 #[must_use]
56 pub fn seconds_remaining(&self) -> Option<u64> {
57 match self {
58 Self::Active(banner) => Some(
59 banner
60 .deadline
61 .saturating_duration_since(Instant::now())
62 .as_secs(),
63 ),
64 _ => None,
65 }
66 }
67
68 /// Whether the failure row should still be shown. Mirrors the
69 /// "until next turn" rule in the issue spec; the engine clears it
70 /// explicitly via [`clear`] on `TurnStarted`.
71 #[cfg(test)]
72 #[must_use]
73 pub fn is_failed(&self) -> bool {
74 matches!(self, Self::Failed { .. })
75 }
76 }
77
78 /// Lazy-init the cell on first read so callers don't have to initialize
79 /// process-wide state at boot.
80 fn cell() -> &'static Mutex<RetryState> {
81 static STATE: OnceLock<Mutex<RetryState>> = OnceLock::new();
82 STATE.get_or_init(|| Mutex::new(RetryState::Idle))
83 }
84
85 /// Public read snapshot for renderers.
86 #[must_use]
87 pub fn snapshot() -> RetryState {
88 cell().lock().map(|s| s.clone()).unwrap_or(RetryState::Idle)
89 }
90
91 /// Mark an in-flight retry. `attempt` is the number of the *upcoming*
92 /// retry (1 for the first); `delay` is how long the client will sleep
93 /// before firing.
94 pub fn start(attempt: u32, delay: Duration, reason: impl Into<String>) {
95 let banner = RetryBanner {
96 attempt,
97 deadline: Instant::now() + delay,
98 reason: reason.into(),
99 };
100 if let Ok(mut s) = cell().lock() {
101 *s = RetryState::Active(banner);
102 }
103 }
104
105 /// Mark the retry chain as having succeeded. Hides the banner.
106 pub fn succeeded() {
107 if let Ok(mut s) = cell().lock() {
108 *s = RetryState::Idle;
109 }
110 }
111
112 /// Mark the retry chain as having exhausted retries. The renderer keeps
113 /// the failure row until [`clear`] (typically called on `TurnStarted`).
114 pub fn failed(reason: impl Into<String>) {
115 if let Ok(mut s) = cell().lock() {
116 *s = RetryState::Failed {
117 reason: reason.into(),
118 since: Instant::now(),
119 };
120 }
121 }
122
123 /// Reset to idle. Called on `TurnStarted` so the previous turn's
124 /// failure row doesn't bleed into the next turn.
125 pub fn clear() {
126 if let Ok(mut s) = cell().lock() {
127 *s = RetryState::Idle;
128 }
129 }
130
131 /// Test helper: serialize tests that touch the global state so cargo's
132 /// parallel runner can't observe a torn read. The guard is exported so
133 /// tests in *other* modules (e.g. footer rendering tests) can hold the
134 /// same lock as the ones in `retry_status::tests`.
135 #[cfg(test)]
136 pub fn test_guard() -> std::sync::MutexGuard<'static, ()> {
137 static GUARD: Mutex<()> = Mutex::new(());
138 GUARD.lock().unwrap_or_else(|e| e.into_inner())
139 }
140
141 #[cfg(test)]
142 mod tests {
143 use super::*;
144
145 /// Acquire the cross-module test guard from [`super::test_guard`] and
146 /// reset state to `Idle` before yielding to the test body.
147 fn setup() -> std::sync::MutexGuard<'static, ()> {
148 let g = test_guard();
149 clear();
150 g
151 }
152
153 #[test]
154 fn idle_by_default_after_clear() {
155 let _g = setup();
156 assert!(matches!(snapshot(), RetryState::Idle));
157 assert_eq!(snapshot().seconds_remaining(), None);
158 }
159
160 #[test]
161 fn start_then_succeeded_returns_to_idle() {
162 let _g = setup();
163 start(1, Duration::from_secs(5), "rate limited");
164 let s = snapshot();
165 assert!(matches!(s, RetryState::Active(_)));
166 let remaining = s.seconds_remaining().unwrap();
167 assert!(remaining <= 5, "{remaining}");
168 succeeded();
169 assert!(matches!(snapshot(), RetryState::Idle));
170 }
171
172 #[test]
173 fn failed_persists_until_clear() {
174 let _g = setup();
175 failed("upstream 500");
176 let s = snapshot();
177 assert!(s.is_failed());
178 if let RetryState::Failed { reason, .. } = s {
179 assert_eq!(reason, "upstream 500");
180 } else {
181 panic!("expected Failed");
182 }
183 clear();
184 assert!(matches!(snapshot(), RetryState::Idle));
185 }
186
187 #[test]
188 fn deadline_in_past_yields_zero_remaining() {
189 let _g = setup();
190 // Bypass `start` so we can plant a deadline already in the past.
191 if let Ok(mut s) = cell().lock() {
192 *s = RetryState::Active(RetryBanner {
193 attempt: 2,
194 deadline: Instant::now() - Duration::from_secs(1),
195 reason: "test".into(),
196 });
197 }
198 assert_eq!(snapshot().seconds_remaining(), Some(0));
199 clear();
200 }
201 }
202
202 lines RUST