feat: harden official sync scheduling and integrity

This commit is contained in:
2026-07-11 01:06:02 +08:00
parent 2bdc55a1be
commit 264e78c440
11 changed files with 474 additions and 99 deletions
+145 -5
View File
@@ -7,12 +7,16 @@ use serde::Serialize;
use std::env;
use std::path::PathBuf;
use std::thread;
use std::time::{Duration, Instant};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
const EXIT_ERROR: i32 = 1;
const EXIT_LOCKED: i32 = 75;
const DEFAULT_WATCH_INTERVAL_SECONDS: u64 = 60 * 60;
const DEFAULT_ERROR_RETRY_SECONDS: u64 = 60;
const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
const BEIJING_UTC_OFFSET_SECONDS: u64 = 8 * 60 * 60;
const DAILY_FORCED_REFRESH_LOCAL_SECONDS: [u64; 3] = [3 * 60 * 60, 16 * 60 * 60, 18 * 60 * 60];
const DAILY_FORCED_REFRESH_LABEL: &str = "UTC+8 03:00, 16:00, 18:00";
const STARTUP_BANNER: &str = r#"
============================================================
____ _ _ _ _ _____ _ _ _ _
@@ -120,25 +124,64 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
}
let service = OfficialUpdateService::new();
let mut logger = ProgressLogger::new(options.progress);
let mut next_forced_refresh_at = next_forced_refresh_at_or_after(SystemTime::now());
let mut pending_scheduled_force = false;
logger.log_text(
"watch",
format!(
"daily forced refresh schedule: {DAILY_FORCED_REFRESH_LABEL}; next forced refresh in {}",
format_duration(duration_until(next_forced_refresh_at, SystemTime::now()))
),
);
loop {
let now = SystemTime::now();
if now >= next_forced_refresh_at {
pending_scheduled_force = true;
logger.log_text(
"watch",
format!("scheduled forced refresh triggered at {DAILY_FORCED_REFRESH_LABEL}"),
);
next_forced_refresh_at = next_forced_refresh_after(now);
}
let mut iteration_config = options.config.clone();
if pending_scheduled_force {
iteration_config.force = true;
}
let mut sleep_for = options.interval;
logger.log_text("watch", "starting watch iteration");
match service.run_with_progress(&options.config, |event| logger.log(event)) {
logger.log_text(
"watch",
if pending_scheduled_force {
"starting watch iteration with scheduled force refresh"
} else {
"starting watch iteration"
},
);
match service.run_with_progress(&iteration_config, |event| logger.log(event)) {
Ok(report) => {
if pending_scheduled_force {
pending_scheduled_force = false;
}
if should_print_status(report.update_status, options.quiet_up_to_date) {
println!("{}", serde_json::to_string_pretty(&report)?);
}
sleep_for =
sleep_for.min(duration_until(next_forced_refresh_at, SystemTime::now()));
logger.log_text(
"watch",
format!(
"iteration finished with status={}; next check in {}",
"iteration finished with status={}; next check in {}; next scheduled force refresh in {}",
report.update_status.as_str(),
format_duration(sleep_for)
format_duration(sleep_for),
format_duration(duration_until(next_forced_refresh_at, SystemTime::now()))
),
);
}
Err(error) => {
sleep_for = options.error_retry_interval;
sleep_for =
sleep_for.min(duration_until(next_forced_refresh_at, SystemTime::now()));
logger.log_text(
"watch",
format!(
@@ -161,6 +204,62 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
}
}
fn next_forced_refresh_at_or_after(now: SystemTime) -> SystemTime {
forced_refresh_time(now, ForcedRefreshBoundary::AtOrAfter)
}
fn next_forced_refresh_after(now: SystemTime) -> SystemTime {
forced_refresh_time(now, ForcedRefreshBoundary::After)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ForcedRefreshBoundary {
AtOrAfter,
After,
}
fn forced_refresh_time(now: SystemTime, boundary: ForcedRefreshBoundary) -> SystemTime {
let local_seconds = beijing_local_seconds_since_epoch(now);
let local_day = local_seconds / SECONDS_PER_DAY;
let local_second_of_day = local_seconds % SECONDS_PER_DAY;
for scheduled_second in DAILY_FORCED_REFRESH_LOCAL_SECONDS {
let matches_boundary = match boundary {
ForcedRefreshBoundary::AtOrAfter => scheduled_second >= local_second_of_day,
ForcedRefreshBoundary::After => scheduled_second > local_second_of_day,
};
if matches_boundary {
return system_time_from_beijing_local_seconds(
local_day
.saturating_mul(SECONDS_PER_DAY)
.saturating_add(scheduled_second),
);
}
}
system_time_from_beijing_local_seconds(
local_day
.saturating_add(1)
.saturating_mul(SECONDS_PER_DAY)
.saturating_add(DAILY_FORCED_REFRESH_LOCAL_SECONDS[0]),
)
}
fn beijing_local_seconds_since_epoch(time: SystemTime) -> u64 {
time.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.saturating_add(BEIJING_UTC_OFFSET_SECONDS)
}
fn system_time_from_beijing_local_seconds(local_seconds: u64) -> SystemTime {
UNIX_EPOCH + Duration::from_secs(local_seconds.saturating_sub(BEIJING_UTC_OFFSET_SECONDS))
}
fn duration_until(deadline: SystemTime, now: SystemTime) -> Duration {
deadline.duration_since(now).unwrap_or_default()
}
#[derive(Debug, Clone)]
struct ProgressLogger {
enabled: bool,
@@ -365,6 +464,9 @@ fn print_usage(binary: &str) {
[--curl curl] [--unzip unzip] [--dry-run] [--plan] [--force] [--audit-local|--no-audit-local] [--repair|--no-repair] \
[--watch] [--interval 1h|60m|3600s] [--error-retry 60s] [--quiet-up-to-date|--no-quiet-up-to-date] [--progress|--no-progress] [--banner|--no-banner]"
);
eprintln!(
"watch mode also runs scheduled force refreshes every day at {DAILY_FORCED_REFRESH_LABEL}"
);
}
fn parse_duration(value: &str) -> anyhow::Result<Duration> {
@@ -616,6 +718,35 @@ mod tests {
assert_eq!(parse_duration("3600").unwrap(), Duration::from_secs(3600));
}
#[test]
fn scheduled_forced_refresh_uses_beijing_local_times() {
let day = 20_000;
assert_eq!(
next_forced_refresh_at_or_after(beijing_time(day, 2, 30, 0)),
beijing_time(day, 3, 0, 0)
);
assert_eq!(
next_forced_refresh_at_or_after(beijing_time(day, 3, 0, 0)),
beijing_time(day, 3, 0, 0)
);
assert_eq!(
next_forced_refresh_after(beijing_time(day, 3, 0, 0)),
beijing_time(day, 16, 0, 0)
);
assert_eq!(
next_forced_refresh_after(beijing_time(day, 18, 0, 0)),
beijing_time(day + 1, 3, 0, 0)
);
assert_eq!(
duration_until(
next_forced_refresh_at_or_after(beijing_time(day, 17, 45, 0)),
beijing_time(day, 17, 45, 0)
),
Duration::from_secs(15 * 60)
);
}
#[test]
fn formats_progress_durations() {
assert_eq!(format_duration(Duration::from_millis(7)), "00:00.007");
@@ -642,4 +773,13 @@ mod tests {
);
assert_eq!(OfficialUpdateStatus::Downloaded.as_str(), "downloaded");
}
fn beijing_time(day: u64, hour: u64, minute: u64, second: u64) -> SystemTime {
system_time_from_beijing_local_seconds(
day.saturating_mul(SECONDS_PER_DAY)
.saturating_add(hour.saturating_mul(60 * 60))
.saturating_add(minute.saturating_mul(60))
.saturating_add(second),
)
}
}