Add last_failure to worker state
The `WorkerSnapshot` now includes `last_failure`, which tracks the time of the last recorded error. This allows the UI to display a more accurate status, especially when a failure occurs after a recent success. The `pick_footer_status` function has been updated to prioritize `last_failure`. If a failure occurred more recently than the last success, the footer will show `Offline`, regardless of the success age. This addresses scenarios where the server might have temporarily failed, and the UI should reflect that immediately. The `run_loop` function now updates `last_failure` upon transient or terminal upload errors and poll failures. This ensures that the new state information is correctly propagated.
This commit is contained in:
@@ -524,7 +524,8 @@ impl DoctateApp {
|
||||
return;
|
||||
};
|
||||
let snap = worker_rx.borrow().clone();
|
||||
let last_age = snap.last_success.map(|i| i.elapsed());
|
||||
let last_success_age = snap.last_success.map(|i| i.elapsed());
|
||||
let last_failure_age = snap.last_failure.map(|i| i.elapsed());
|
||||
let threshold = self
|
||||
.config
|
||||
.as_ref()
|
||||
@@ -535,7 +536,8 @@ impl DoctateApp {
|
||||
self.finalizing.is_some(),
|
||||
snap.state,
|
||||
snap.queue_len,
|
||||
last_age,
|
||||
last_success_age,
|
||||
last_failure_age,
|
||||
threshold,
|
||||
);
|
||||
|
||||
|
||||
+113
-38
@@ -61,28 +61,45 @@ pub enum FooterStatus {
|
||||
/// Decide which footer status to show. Pure function — no side effects,
|
||||
/// fully table-testable. Inputs chosen to match what `DoctateApp` can
|
||||
/// observe cheaply per egui frame.
|
||||
///
|
||||
/// Decision rules for the `Idle` branch:
|
||||
/// - A failure younger than the last success (or a failure with no
|
||||
/// prior success at all) wins → `Offline`.
|
||||
/// - Otherwise fall back to success-age vs. threshold (classic staleness).
|
||||
///
|
||||
/// `last_*_age` arguments are `Option<Duration>` = "seconds since that
|
||||
/// event, or `None` if it never happened". Smaller Duration = more recent.
|
||||
pub fn pick_footer_status(
|
||||
finalizing: bool,
|
||||
worker_state: WorkerState,
|
||||
queue_len: usize,
|
||||
last_success_age: Option<Duration>,
|
||||
last_failure_age: Option<Duration>,
|
||||
offline_threshold: Duration,
|
||||
) -> FooterStatus {
|
||||
if finalizing {
|
||||
return FooterStatus::Finalizing;
|
||||
}
|
||||
if queue_len > 0 || worker_state == WorkerState::Uploading {
|
||||
return FooterStatus::Uploading {
|
||||
match worker_state {
|
||||
WorkerState::Uploading => FooterStatus::Uploading {
|
||||
count: queue_len.max(1),
|
||||
};
|
||||
}
|
||||
if worker_state == WorkerState::Polling {
|
||||
return FooterStatus::Synchronizing;
|
||||
}
|
||||
match last_success_age {
|
||||
None => FooterStatus::NeverConnected,
|
||||
Some(age) if age > offline_threshold => FooterStatus::Offline,
|
||||
Some(_) => FooterStatus::Online,
|
||||
},
|
||||
WorkerState::Polling => FooterStatus::Synchronizing,
|
||||
WorkerState::Idle => {
|
||||
let failure_more_recent = match (last_success_age, last_failure_age) {
|
||||
(Some(s), Some(f)) => f < s, // smaller age = younger
|
||||
(None, Some(_)) => true, // only a failure on record
|
||||
_ => false,
|
||||
};
|
||||
if failure_more_recent {
|
||||
return FooterStatus::Offline;
|
||||
}
|
||||
match last_success_age {
|
||||
None => FooterStatus::NeverConnected,
|
||||
Some(age) if age > offline_threshold => FooterStatus::Offline,
|
||||
Some(_) => FooterStatus::Online,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +109,16 @@ mod tests {
|
||||
|
||||
const THRESHOLD: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Convenience: most tests don't care about the failure field.
|
||||
fn status(
|
||||
finalizing: bool,
|
||||
worker_state: WorkerState,
|
||||
queue_len: usize,
|
||||
last_success_age: Option<Duration>,
|
||||
) -> FooterStatus {
|
||||
pick_footer_status(finalizing, worker_state, queue_len, last_success_age, None, THRESHOLD)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finalizing_wins_over_everything() {
|
||||
let s = pick_footer_status(
|
||||
@@ -99,6 +126,7 @@ mod tests {
|
||||
WorkerState::Uploading,
|
||||
5,
|
||||
Some(Duration::from_millis(1)),
|
||||
Some(Duration::from_millis(1)),
|
||||
THRESHOLD,
|
||||
);
|
||||
assert_eq!(s, FooterStatus::Finalizing);
|
||||
@@ -106,84 +134,131 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn uploading_when_queue_non_empty() {
|
||||
let s = pick_footer_status(
|
||||
let s = status(
|
||||
false,
|
||||
WorkerState::Uploading,
|
||||
3,
|
||||
Some(Duration::from_millis(1)),
|
||||
THRESHOLD,
|
||||
);
|
||||
assert_eq!(s, FooterStatus::Uploading { count: 3 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uploading_reports_at_least_one_even_if_scan_raced() {
|
||||
// Edge case: WorkerState says Uploading, but scan returned 0
|
||||
// (just finished a file and the next scan hasn't happened yet).
|
||||
// We still want to show "Uploading" to avoid a flicker back to
|
||||
// Online right before the next file starts.
|
||||
let s = pick_footer_status(
|
||||
let s = status(
|
||||
false,
|
||||
WorkerState::Uploading,
|
||||
0,
|
||||
Some(Duration::from_millis(1)),
|
||||
THRESHOLD,
|
||||
);
|
||||
assert_eq!(s, FooterStatus::Uploading { count: 1 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synchronizing_when_polling_and_no_queue() {
|
||||
let s = pick_footer_status(
|
||||
false,
|
||||
WorkerState::Polling,
|
||||
0,
|
||||
Some(Duration::from_millis(1)),
|
||||
THRESHOLD,
|
||||
);
|
||||
let s = status(false, WorkerState::Polling, 0, Some(Duration::from_millis(1)));
|
||||
assert_eq!(s, FooterStatus::Synchronizing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_connected_without_last_success() {
|
||||
let s = pick_footer_status(false, WorkerState::Idle, 0, None, THRESHOLD);
|
||||
let s = status(false, WorkerState::Idle, 0, None);
|
||||
assert_eq!(s, FooterStatus::NeverConnected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_when_last_success_is_stale() {
|
||||
let s = pick_footer_status(
|
||||
let s = status(
|
||||
false,
|
||||
WorkerState::Idle,
|
||||
0,
|
||||
Some(Duration::from_secs(120)),
|
||||
THRESHOLD,
|
||||
);
|
||||
assert_eq!(s, FooterStatus::Offline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn online_when_last_success_is_fresh() {
|
||||
let s = pick_footer_status(
|
||||
false,
|
||||
WorkerState::Idle,
|
||||
0,
|
||||
Some(Duration::from_secs(5)),
|
||||
THRESHOLD,
|
||||
);
|
||||
let s = status(false, WorkerState::Idle, 0, Some(Duration::from_secs(5)));
|
||||
assert_eq!(s, FooterStatus::Online);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_threshold_is_inclusive_of_equal_age_as_online() {
|
||||
// Exactly at threshold: still online (strict ">" in logic).
|
||||
let s = status(false, WorkerState::Idle, 0, Some(THRESHOLD));
|
||||
assert_eq!(s, FooterStatus::Online);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_while_queue_has_items_but_worker_backing_off() {
|
||||
let s = status(
|
||||
false,
|
||||
WorkerState::Idle,
|
||||
3,
|
||||
Some(Duration::from_secs(120)),
|
||||
);
|
||||
assert_eq!(s, FooterStatus::Offline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn online_while_queue_has_items_but_worker_idle_and_recent_success() {
|
||||
let s = status(false, WorkerState::Idle, 3, Some(Duration::from_secs(2)));
|
||||
assert_eq!(s, FooterStatus::Online);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_connected_while_queue_has_items_and_no_success_ever() {
|
||||
let s = status(false, WorkerState::Idle, 3, None);
|
||||
assert_eq!(s, FooterStatus::NeverConnected);
|
||||
}
|
||||
|
||||
// --- last_failure-driven scenarios ---
|
||||
|
||||
/// Core regression this fix addresses: server was reachable (fresh
|
||||
/// last_success), then went offline mid-session; a single failed
|
||||
/// upload must flip the footer to Offline immediately, without
|
||||
/// waiting for the success-age threshold.
|
||||
#[test]
|
||||
fn failure_younger_than_success_forces_offline() {
|
||||
let s = pick_footer_status(
|
||||
false,
|
||||
WorkerState::Idle,
|
||||
1,
|
||||
Some(Duration::from_secs(10)), // was online 10s ago
|
||||
Some(Duration::from_secs(1)), // but failed 1s ago
|
||||
THRESHOLD,
|
||||
);
|
||||
assert_eq!(s, FooterStatus::Offline);
|
||||
}
|
||||
|
||||
/// Complement: success younger than the last failure → the server
|
||||
/// recovered. Show Online.
|
||||
#[test]
|
||||
fn success_younger_than_failure_wins_back_online() {
|
||||
let s = pick_footer_status(
|
||||
false,
|
||||
WorkerState::Idle,
|
||||
0,
|
||||
Some(THRESHOLD),
|
||||
Some(Duration::from_secs(1)), // succeeded 1s ago
|
||||
Some(Duration::from_secs(10)), // previous failure 10s ago
|
||||
THRESHOLD,
|
||||
);
|
||||
assert_eq!(s, FooterStatus::Online);
|
||||
}
|
||||
|
||||
/// Only a failure ever, no success: treat as Offline, not
|
||||
/// NeverConnected — we tried and got rejected, which is a stronger
|
||||
/// signal than "silent cold start".
|
||||
#[test]
|
||||
fn only_failure_no_success_is_offline() {
|
||||
let s = pick_footer_status(
|
||||
false,
|
||||
WorkerState::Idle,
|
||||
0,
|
||||
None,
|
||||
Some(Duration::from_secs(5)),
|
||||
THRESHOLD,
|
||||
);
|
||||
assert_eq!(s, FooterStatus::Offline);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user