use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::mpsc::{Receiver, RecvTimeoutError};
use std::time::Duration;
use rapid_structure_rs::{
DEFAULT_CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT, DownloadBudget, DownloadError, DownloadObserver,
ModelFileSpec, ModelSet, RapidOcrError, download_model_set_observed,
};
use super::error::ServeError;
use super::jobs::DownloadProgress;
use super::limits::ServeConfigError;
use super::model_plan::PendingDownload;
use super::server::ServeShared;
pub(super) const DOWNLOAD_QUEUE_CAPACITY: usize = 4;
const POLL_INTERVAL: Duration = Duration::from_millis(200);
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct DownloadCommand {
pub job_id: String,
pub set_id: String,
}
pub(super) struct DownloadJob<'a> {
pub set: &'a ModelSet,
pub root: &'a Path,
pub budget_bytes: u64,
pub connect_timeout: Duration,
pub read_timeout: Duration,
pub allowed_hosts: Vec<String>,
}
impl DownloadJob<'_> {
pub fn initial_progress(&self, pending: PendingDownload) -> DownloadProgress {
DownloadProgress::planned(pending.files, pending.bytes)
}
}
pub(super) trait DownloadSink {
fn file_started(
&mut self,
file: &ModelFileSpec,
index: usize,
total: usize,
declared_bytes: Option<u64>,
) -> bool;
fn bytes_written(&mut self, written_bytes: u64);
fn file_finished(&mut self, file: &ModelFileSpec, index: usize, bytes: u64);
}
pub(super) trait ModelDownloader: Send {
fn download(
&mut self,
job: &DownloadJob<'_>,
sink: &mut dyn DownloadSink,
) -> Result<Vec<PathBuf>, RapidOcrError>;
}
pub(super) type DownloaderFactory = Arc<dyn Fn() -> Box<dyn ModelDownloader> + Send + Sync>;
pub(super) fn real_downloader_factory() -> DownloaderFactory {
Arc::new(|| Box::new(RealDownloader))
}
struct RealDownloader;
impl ModelDownloader for RealDownloader {
fn download(
&mut self,
job: &DownloadJob<'_>,
sink: &mut dyn DownloadSink,
) -> Result<Vec<PathBuf>, RapidOcrError> {
let hosts: Vec<&str> = job.allowed_hosts.iter().map(String::as_str).collect();
let mut budget = DownloadBudget::new(job.budget_bytes);
let mut observer = SinkObserver { sink };
download_model_set_observed(
job.set,
job.root,
&mut budget,
job.connect_timeout,
job.read_timeout,
&hosts,
&mut observer,
)
}
}
struct SinkObserver<'a> {
sink: &'a mut dyn DownloadSink,
}
impl DownloadObserver for SinkObserver<'_> {
fn file_started(
&mut self,
file: &ModelFileSpec,
index: usize,
total: usize,
declared_bytes: Option<u64>,
) -> bool {
self.sink.file_started(file, index, total, declared_bytes)
}
fn bytes_written(&mut self, written_bytes: u64) {
self.sink.bytes_written(written_bytes);
}
fn file_finished(&mut self, file: &ModelFileSpec, index: usize, bytes: u64) {
self.sink.file_finished(file, index, bytes);
}
}
pub(super) fn validate_extra_hosts(hosts: &[String]) -> Result<Vec<String>, ServeConfigError> {
let mut out: Vec<String> = Vec::with_capacity(hosts.len());
for host in hosts {
let trimmed = host.trim();
let reject = |reason: &str| ServeConfigError::new("--allow-download-host", host, reason);
if trimmed.is_empty() {
return Err(reject("the host must not be empty"));
}
if trimmed.len() != host.len() {
return Err(reject("the host must not contain surrounding whitespace"));
}
if trimmed.contains(|c: char| {
c.is_whitespace() || matches!(c, '/' | '\\' | ':' | '@' | '?' | '#' | '*' | ',')
}) {
return Err(reject(
"the host must be a bare host name (no scheme, port, path, userinfo or wildcard)",
));
}
if !trimmed
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
{
return Err(reject("the host must be an ASCII host name"));
}
if trimmed.starts_with('.') || trimmed.ends_with('.') || trimmed.starts_with('-') {
return Err(reject(
"the host must not start or end with a dot or a dash",
));
}
if !out
.iter()
.any(|existing| existing.eq_ignore_ascii_case(trimmed))
{
out.push(trimmed.to_string());
}
}
Ok(out)
}
pub(super) fn worker(
runtime: Arc<ServeShared>,
inbox: Receiver<DownloadCommand>,
factory: DownloaderFactory,
) {
let mut downloader = factory();
loop {
match inbox.recv_timeout(POLL_INTERVAL) {
Ok(command) => run_one(&runtime, &command, downloader.as_mut()),
Err(RecvTimeoutError::Timeout) => {
if runtime.is_shutting_down() {
return;
}
}
Err(RecvTimeoutError::Disconnected) => return,
}
}
}
fn run_one(
runtime: &Arc<ServeShared>,
command: &DownloadCommand,
downloader: &mut dyn ModelDownloader,
) {
if runtime.is_shutting_down() {
runtime.abandon_download(&command.job_id);
return;
}
if runtime.begin_download(&command.job_id).is_err() {
return;
}
let Some(job) = runtime.download_job(&command.set_id) else {
runtime.finish_download_failed(
&command.job_id,
ServeError::ModelSetNotFound {
set_id: command.set_id.clone(),
known: runtime.known_set_ids(),
},
);
return;
};
let pending = runtime
.pending_download(&command.set_id)
.unwrap_or(PendingDownload {
files: job.set.files.len(),
bytes: None,
});
let mut sink = JobSink::new(Arc::clone(runtime), command.job_id.clone(), &job, pending);
match downloader.download(&job, &mut sink) {
Ok(_paths) => {
if runtime.is_cancel_requested(&command.job_id) {
runtime.finish_download_cancelled(&command.job_id);
} else {
runtime.finish_download_ok(&command.job_id);
}
}
Err(RapidOcrError::Download(DownloadError::Cancelled)) => {
runtime.finish_download_cancelled(&command.job_id);
}
Err(error) => runtime.finish_download_failed(&command.job_id, ServeError::from(error)),
}
}
struct JobSink {
runtime: Arc<ServeShared>,
job_id: String,
progress: DownloadProgress,
completed_bytes: u64,
current_bytes: u64,
}
impl JobSink {
fn new(
runtime: Arc<ServeShared>,
job_id: String,
job: &DownloadJob<'_>,
pending: PendingDownload,
) -> Self {
Self {
runtime,
job_id,
progress: job.initial_progress(pending),
completed_bytes: 0,
current_bytes: 0,
}
}
fn push(&self) {
let mut progress = self.progress.clone();
progress.bytes_done = self.completed_bytes.saturating_add(self.current_bytes);
self.runtime.set_download_progress(&self.job_id, progress);
}
}
impl DownloadSink for JobSink {
fn file_started(
&mut self,
file: &ModelFileSpec,
_index: usize,
total: usize,
_declared_bytes: Option<u64>,
) -> bool {
if self.runtime.is_cancel_requested(&self.job_id) {
return false;
}
self.progress.files_total = total;
self.progress.current_file = Some(file.name.clone());
self.current_bytes = 0;
self.push();
true
}
fn bytes_written(&mut self, written_bytes: u64) {
self.current_bytes = written_bytes;
self.push();
}
fn file_finished(&mut self, _file: &ModelFileSpec, index: usize, bytes: u64) {
self.progress.files_done = index;
self.progress.current_file = None;
self.completed_bytes = self.completed_bytes.saturating_add(bytes);
self.current_bytes = 0;
self.push();
}
}
pub(super) fn default_timeouts() -> (Duration, Duration) {
(DEFAULT_CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT)
}
#[cfg(test)]
mod tests {
use super::validate_extra_hosts;
#[test]
fn only_bare_hosts_may_extend_the_allow_list() {
let ok = validate_extra_hosts(&[
"example.com".to_string(),
"mirror.example.com".to_string(),
" example.org ".to_string(),
]);
assert!(ok.is_err(), "{ok:?}");
let good = validate_extra_hosts(&[
"example.com".to_string(),
"mirror.example.com".to_string(),
"EXAMPLE.com".to_string(),
])
.expect("bare hosts are valid");
assert_eq!(
good,
vec!["example.com".to_string(), "mirror.example.com".to_string()],
"case-insensitive duplicates are folded"
);
for bad in [
"",
" ",
"https://example.com",
"example.com:443",
"user@example.com",
"example.com/models",
"*.example.com",
"exa mple.com",
".example.com",
"-example.com",
"例子.中国",
] {
let error = validate_extra_hosts(&[bad.to_string()])
.expect_err(&format!("`{bad}` must be rejected"));
assert_eq!(error.field(), "--allow-download-host");
assert!(
error.to_string().contains("--allow-download-host"),
"the error must locate the flag: {error}"
);
}
}
}