Skip to content

Commit c65357a

Browse files
committed
feat: add automatic fallback for GitHub downloads
1 parent 9f1e6c4 commit c65357a

8 files changed

Lines changed: 578 additions & 142 deletions

File tree

‎src-tauri/src/core.rs‎

Lines changed: 127 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const MAX_SYSTEM_NODES: usize = 100;
3939
const CATALOG_MAX_BYTES: usize = 262_144;
4040
const NODE_TEST_CONCURRENCY: usize = 4;
4141
const AUTO_POOL_NODE_TARGET: usize = 10;
42+
const DOWNLOAD_BATCH_SIZE: usize = 4;
4243
const HEALTH_CHECK_INTERVALS: [u32; 6] = [
4344
0,
4445
60,
@@ -380,54 +381,49 @@ impl AppCore {
380381
})
381382
}
382383

383-
pub fn prepare_download_excluding(
384+
pub fn prepare_download_batch(
384385
&self,
385386
original_url: &str,
386387
excluded_node_ids: &[String],
387-
) -> Result<DownloadTarget, String> {
388+
) -> Result<(Vec<DownloadTarget>, bool), String> {
388389
let settings = self.settings()?;
389-
let pairs: Vec<_> = self
390+
let mut pairs: Vec<_> = self
390391
.node_pairs()?
391392
.into_iter()
392-
.filter(|(node, _)| !excluded_node_ids.contains(&node.id))
393+
.filter(|(node, health)| {
394+
!excluded_node_ids.contains(&node.id)
395+
&& node.enabled
396+
&& health.in_auto_pool
397+
&& is_usable_health(health)
398+
})
393399
.collect();
394-
let current = settings.current_node_id.as_deref().and_then(|id| {
395-
pairs
396-
.iter()
397-
.find(|(node, health)| {
398-
node.id == id && node.enabled && health.in_auto_pool && is_usable_health(health)
399-
})
400-
.map(|(node, _)| node)
401-
});
402-
let preferred = current.or_else(|| git::choose_node(&pairs));
403-
let node = preferred.ok_or_else(|| {
404-
if excluded_node_ids.is_empty() {
400+
let mut targets = Vec::new();
401+
while targets.len() < DOWNLOAD_BATCH_SIZE {
402+
let selected = if targets.is_empty() {
403+
settings
404+
.current_node_id
405+
.as_deref()
406+
.and_then(|id| pairs.iter().find(|(node, _)| node.id == id))
407+
.map(|(node, _)| node)
408+
.or_else(|| git::choose_node(&pairs))
409+
} else {
410+
git::choose_node(&pairs)
411+
};
412+
let Some(node) = selected else {
413+
break;
414+
};
415+
targets.push(downloads::prepare_target(original_url, node)?);
416+
let selected_id = node.id.clone();
417+
pairs.retain(|(node, _)| node.id != selected_id);
418+
}
419+
if targets.is_empty() {
420+
return Err(if excluded_node_ids.is_empty() {
405421
"自动线路池中没有可用的下载节点".to_string()
406422
} else {
407423
"自动线路池中没有其他可用的下载线路".to_string()
408-
}
409-
})?;
410-
downloads::prepare_target(original_url, node)
411-
}
412-
413-
pub fn prepare_download_with_node(
414-
&self,
415-
original_url: &str,
416-
node_id: &str,
417-
) -> Result<DownloadTarget, String> {
418-
let health = self.health()?;
419-
let nodes = self.nodes()?;
420-
let node = nodes
421-
.iter()
422-
.find(|node| {
423-
node.id == node_id
424-
&& node.enabled
425-
&& health
426-
.get(&node.id)
427-
.is_some_and(|summary| summary.in_auto_pool && is_usable_health(summary))
428-
})
429-
.ok_or_else(|| "下载线路不在自动线路池中".to_string())?;
430-
downloads::prepare_target(original_url, node)
424+
});
425+
}
426+
Ok((targets, git::choose_node(&pairs).is_some()))
431427
}
432428

433429
fn node_entries(&self) -> Result<Vec<NodeEntry>, String> {
@@ -3049,7 +3045,7 @@ mod tests {
30493045
let directory = tempfile::tempdir().unwrap();
30503046
let core = AppCore::new(directory.path().to_path_buf()).unwrap();
30513047
let error = core
3052-
.prepare_download_excluding(
3048+
.prepare_download_batch(
30533049
"https://github.com/ollama/ollama/releases/download/v1/OllamaSetup.exe",
30543050
&[],
30553051
)
@@ -3058,7 +3054,7 @@ mod tests {
30583054
}
30593055

30603056
#[test]
3061-
fn prepares_download_with_the_next_available_node_without_changing_settings() {
3057+
fn prepares_download_batch_without_changing_settings() {
30623058
let directory = tempfile::tempdir().unwrap();
30633059
let core = AppCore::new(directory.path().to_path_buf()).unwrap();
30643060
core.import_nodes("https://one.example\nhttps://two.example")
@@ -3089,20 +3085,23 @@ mod tests {
30893085
};
30903086
atomic_write_json(&core.paths.settings, &settings).unwrap();
30913087

3092-
let target = core
3093-
.prepare_download_excluding(
3088+
let (targets, has_remaining) = core
3089+
.prepare_download_batch(
30943090
"https://github.com/DiscoverBox/gitboost/archive/refs/heads/main.zip",
3095-
std::slice::from_ref(&first.id),
3091+
&[],
30963092
)
30973093
.unwrap();
30983094

3099-
assert_eq!(target.node_id, second.id);
3095+
assert_eq!(targets.len(), 2);
3096+
assert_eq!(targets[0].node_id, first.id);
3097+
assert_eq!(targets[1].node_id, second.id);
3098+
assert!(!has_remaining);
31003099
assert_eq!(
31013100
core.settings().unwrap().current_node_id,
31023101
Some(first.id.clone())
31033102
);
31043103
assert_eq!(
3105-
core.prepare_download_excluding(
3104+
core.prepare_download_batch(
31063105
"https://github.com/DiscoverBox/gitboost/archive/refs/heads/main.zip",
31073106
&[first.id.clone(), second.id.clone()],
31083107
)
@@ -3111,6 +3110,45 @@ mod tests {
31113110
);
31123111
}
31133112

3113+
#[test]
3114+
fn download_batch_keeps_unattempted_nodes_for_manual_retry() {
3115+
let directory = tempfile::tempdir().unwrap();
3116+
let core = AppCore::new(directory.path().to_path_buf()).unwrap();
3117+
let nodes = (1..=5)
3118+
.map(|index| {
3119+
custom_node(
3120+
&format!("node-{index}"),
3121+
&format!("Node {index}"),
3122+
&format!("https://{index}.example/https://github.com/"),
3123+
)
3124+
})
3125+
.collect::<Vec<_>>();
3126+
configure_automatic_nodes(&core, &nodes, &nodes[0].id);
3127+
3128+
let (first_batch, has_remaining) = core
3129+
.prepare_download_batch(
3130+
"https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.sh",
3131+
&[],
3132+
)
3133+
.unwrap();
3134+
assert_eq!(first_batch.len(), DOWNLOAD_BATCH_SIZE);
3135+
assert!(has_remaining);
3136+
3137+
let excluded = first_batch
3138+
.iter()
3139+
.map(|target| target.node_id.clone())
3140+
.collect::<Vec<_>>();
3141+
let (second_batch, has_remaining) = core
3142+
.prepare_download_batch(
3143+
"https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.sh",
3144+
&excluded,
3145+
)
3146+
.unwrap();
3147+
assert_eq!(second_batch.len(), 1);
3148+
assert_eq!(second_batch[0].node_id, nodes[4].id);
3149+
assert!(!has_remaining);
3150+
}
3151+
31143152
#[test]
31153153
fn usage_audit_records_route_and_removes_credentials() {
31163154
let directory = tempfile::tempdir().unwrap();
@@ -3555,6 +3593,50 @@ mod tests {
35553593
.unwrap();
35563594
}
35573595

3596+
#[test]
3597+
#[ignore = "requires live HTTPS and GitHub proxy access"]
3598+
fn live_download_batch_falls_back_after_a_real_http_failure() {
3599+
let directory = tempfile::tempdir().unwrap();
3600+
let core = AppCore::new(directory.path().to_path_buf()).unwrap();
3601+
let unavailable = custom_node(
3602+
"real-http-failure",
3603+
"Real HTTP failure",
3604+
"https://example.com/https://github.com/",
3605+
);
3606+
let fallback = NodeDefinition::fastgit();
3607+
configure_automatic_nodes(
3608+
&core,
3609+
&[unavailable.clone(), fallback.clone()],
3610+
&unavailable.id,
3611+
);
3612+
3613+
let (targets, has_remaining) = core
3614+
.prepare_download_batch(
3615+
"https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.sh",
3616+
&[],
3617+
)
3618+
.unwrap();
3619+
assert_eq!(
3620+
targets
3621+
.iter()
3622+
.map(|target| target.node_id.as_str())
3623+
.collect::<Vec<_>>(),
3624+
vec![unavailable.id.as_str(), fallback.id.as_str()]
3625+
);
3626+
assert!(!has_remaining);
3627+
3628+
let (winner, attempted_node_ids) = downloads::probe_first_available(targets).unwrap();
3629+
assert_eq!(winner.node_id, fallback.id);
3630+
assert_eq!(
3631+
attempted_node_ids,
3632+
vec![unavailable.id.clone(), fallback.id.clone()]
3633+
);
3634+
assert_eq!(
3635+
core.settings().unwrap().current_node_id,
3636+
Some(unavailable.id)
3637+
);
3638+
}
3639+
35583640
#[test]
35593641
#[ignore = "requires live system-node and GitHub access"]
35603642
fn core_workflow_applies_verifies_persists_and_restores_git_routing() {

0 commit comments

Comments
 (0)