1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
use std::thread;
use std::time::Duration;
use tui_pane::ToastId;
use tui_pane::ToastStyle::Warning;
use crate::constants::SERVICE_RETRY_SECS;
use crate::constants::SERVICE_UNAVAILABLE_GRACE;
use crate::http::GithubAuthGap;
use crate::http::ServiceKind;
use crate::http::ServiceSignal;
use crate::scan;
use crate::scan::BackgroundMsg;
use crate::tui::app::App;
use crate::tui::app::phase_state::FailureReason;
use crate::tui::state::AvailabilityStatus;
use crate::tui::state::RecoveryOutcome;
impl App {
/// One-shot startup check: when `gh auth token` yielded no token,
/// every authenticated GitHub call silently no-ops (see
/// [`crate::http::HttpClient`]), so CI runs and rate-limit buckets
/// never load. Mark GitHub unavailable — the git-pane rate-limit rows
/// read this to surface the remediation hint — and push a one-time
/// persistent warning toast whose copy depends on whether `gh` is
/// missing (install it) or merely logged out (`gh auth login`).
///
/// Skipped under `cfg(test)`: the gap comes from a real `gh auth
/// token` subprocess, so honoring it would make toast and render
/// state depend on the host's gh login.
pub fn warn_if_github_unauthenticated(&mut self) {
if cfg!(test) {
return;
}
let Some(gap) = self.net.http_client.github_auth_gap() else {
return;
};
match gap {
GithubAuthGap::NotInstalled => {
self.net
.availability_for(ServiceKind::GitHub)
.mark_not_installed();
self.framework.toasts.push_persistent(
"GitHub CLI not found",
"Install gh (https://cli.github.com), run `gh auth login`, then restart cargo-port. CI runs and rate limits are unavailable.",
Warning,
None,
1,
);
},
GithubAuthGap::Unauthenticated => {
self.net
.availability_for(ServiceKind::GitHub)
.mark_unauthenticated();
self.framework.toasts.push_persistent(
"GitHub not authenticated",
"CI runs and rate limits are unavailable. Run `gh auth login`, then restart cargo-port.",
Warning,
None,
1,
);
},
}
}
pub(super) fn apply_service_signal(&mut self, signal: ServiceSignal) {
match signal {
ServiceSignal::Reachable(service) => self.handle_service_reachable(service),
ServiceSignal::Unreachable(service) => {
self.apply_unavailability(service, AvailabilityKind::Unreachable);
},
ServiceSignal::RateLimited(service) => {
self.apply_unavailability(service, AvailabilityKind::RateLimited);
},
}
}
/// A successful request is authoritative evidence the service
/// works; treat it as recovery. Previously `Reachable` was a
/// no-op to avoid flicker, but that left the persistent
/// unavailability toast stuck whenever the retry probe couldn't
/// complete (tight 1s timeout, graphql quota quirks, etc.). The
/// recovery work fires only on the actual state transition, so
/// steady-state success signals stay silent. With the grace
/// window in place, an `unavailable_toast` id is only set after
/// the confirm handler fires — so a Reachable signal *inside*
/// the grace window finds `unavailable_toast == None` and
/// silently clears state without flashing a "back online" toast,
/// while still triggering the missing-data refetch.
pub(super) fn handle_service_reachable(&mut self, service: ServiceKind) {
let outcome = self.net.availability_for(service).mark_reachable();
self.apply_recovery_outcome(service, outcome);
}
/// Record the unavailability transition and spawn the retry
/// thread. The user-visible toast is **not** pushed here — it's
/// deferred to the [`Self::confirm_service_unreachable`] handler
/// which only fires after the [`SERVICE_UNAVAILABLE_GRACE`]
/// window elapses without recovery. Single transient timeouts
/// in a sea of successful fetches never reach the UI.
pub(super) fn apply_unavailability(&mut self, service: ServiceKind, kind: AvailabilityKind) {
let spawn_retry = {
let avail = self.net.availability_for(service);
match kind {
AvailabilityKind::Unreachable => avail.mark_unreachable(),
AvailabilityKind::RateLimited => avail.mark_rate_limited(),
}
};
if spawn_retry {
self.spawn_service_retry(service);
}
}
/// Surface the persistent "service unavailable" toast. Called
/// from the dispatch path when [`BackgroundMsg::ServiceUnreachableConfirmed`]
/// arrives — i.e. after the retry thread waited
/// [`SERVICE_UNAVAILABLE_GRACE`] and confirmed the service is
/// still down. No-op if the state has flipped back to reachable
/// during the grace window (a real fetch landed) or a live toast
/// is already showing.
pub(super) fn confirm_service_unreachable(&mut self, service: ServiceKind) {
let (kind, prior_toast) = {
let avail = self.net.availability_for(service);
let kind = match avail.status() {
AvailabilityStatus::Unreachable => AvailabilityKind::Unreachable,
AvailabilityStatus::RateLimited => AvailabilityKind::RateLimited,
// No GitHub token (logged out or `gh` missing) never spawns
// a retry — the token is fixed for the process — so this
// confirm path can't reach those states.
AvailabilityStatus::Reachable
| AvailabilityStatus::Unauthenticated
| AvailabilityStatus::NotInstalled => return,
};
(kind, avail.toast_id())
};
let alive = prior_toast.is_some_and(|id| self.framework.toasts.is_alive(id));
if alive {
return;
}
let toast_id = self.push_service_unavailable_toast(service, kind);
self.net.availability_for(service).set_toast(toast_id);
// A confirmed-down GitHub means startup repo fetches will never
// complete; fail the startup panel's repo row so it finishes
// instead of waiting out the timeout. The toast above names the
// reason, so the row failure adds none of its own.
if service == ServiceKind::GitHub {
let reason = match kind {
AvailabilityKind::RateLimited => FailureReason::RateLimited,
AvailabilityKind::Unreachable => FailureReason::FetchError,
};
self.fail_startup_repo_phase(reason);
}
}
pub(super) fn push_service_unavailable_toast(
&mut self,
service: ServiceKind,
kind: AvailabilityKind,
) -> ToastId {
let (title, body) = service_unavailable_message(service, kind);
self.framework
.toasts
.push_persistent(title, body, Warning, None, 1)
}
/// Spawn the retry / grace probe thread.
///
/// The thread sleeps for [`SERVICE_UNAVAILABLE_GRACE`] before its
/// first probe. If the service has recovered by then, emit a
/// silent recovery (no "back online" toast — none was pushed).
/// Otherwise emit [`BackgroundMsg::ServiceUnreachableConfirmed`]
/// to push the user-visible toast, then enter the 1Hz retry loop
/// until probe succeeds.
pub(super) fn spawn_service_retry(&self, service: ServiceKind) {
#[cfg(test)]
if !self.scan.retry_spawn_mode().is_enabled() {
return;
}
let tx = self.background.background_sender();
let client = self.net.http_client();
thread::spawn(move || {
thread::sleep(SERVICE_UNAVAILABLE_GRACE);
if client.probe_service(service) {
scan::emit_service_recovered(&tx, service);
return;
}
let _ = tx.send(BackgroundMsg::ServiceUnreachableConfirmed { service });
loop {
if client.probe_service(service) {
scan::emit_service_recovered(&tx, service);
break;
}
thread::sleep(Duration::from_secs(SERVICE_RETRY_SECS));
}
});
}
/// Apply a `ServiceRecovered` message from the retry probe.
/// Routes through the shared [`Self::apply_recovery_outcome`]
/// helper so the toast handling and refetch hook stay in lockstep
/// with the `handle_service_reachable` path.
pub(super) fn mark_service_recovered(&mut self, service: ServiceKind) {
let outcome = self.net.availability_for(service).mark_recovered();
self.apply_recovery_outcome(service, outcome);
}
/// Unified post-recovery dispatch: dismiss / push the back-online
/// toast on the `WithToast` variant, then fire
/// [`Self::refetch_missing_after_recovery`] on every transition
/// (silent or not) so rows that failed to fetch during the outage
/// fill in once the service is reachable again.
fn apply_recovery_outcome(&mut self, service: ServiceKind, outcome: RecoveryOutcome) {
match outcome {
RecoveryOutcome::NoTransition => return,
RecoveryOutcome::Silent => {},
RecoveryOutcome::WithToast(toast_id) => {
self.framework.toasts.dismiss(toast_id);
let (title, body) = service_recovered_message(service);
self.show_timed_toast(title, body);
},
}
self.refetch_missing_after_recovery(service);
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum AvailabilityKind {
Unreachable,
RateLimited,
}
const fn service_unavailable_message(
service: ServiceKind,
kind: AvailabilityKind,
) -> (&'static str, &'static str) {
match (service, kind) {
(ServiceKind::GitHub, AvailabilityKind::Unreachable) => (
"GitHub unreachable",
"Rate limits and CI data are unavailable until GitHub recovers.",
),
(ServiceKind::GitHub, AvailabilityKind::RateLimited) => (
"GitHub rate-limited",
"CI data is paused until the rate-limit bucket refills.",
),
(ServiceKind::CratesIo, AvailabilityKind::Unreachable) => (
"crates.io unreachable",
"Crate metadata is unavailable until crates.io recovers.",
),
(ServiceKind::CratesIo, AvailabilityKind::RateLimited) => (
"crates.io rate-limited",
"Crate metadata is paused until the rate-limit bucket refills.",
),
}
}
const fn service_recovered_message(service: ServiceKind) -> (&'static str, &'static str) {
match service {
ServiceKind::GitHub => ("GitHub available", "Back online."),
ServiceKind::CratesIo => ("crates.io available", "Back online."),
}
}