Skip to content

Commit b552ba1

Browse files
authored
feat: improved reliability for failing fetches through forc-pkg (#7377)
## Description closes #7376 and TOOL-681 **Problem**: When registry package fetching fails, the created directory remains in ~/.forc/registry/, causing subsequent builds to fail because forc assumes the directory contains a valid package. To mitigate this: - *Moved directory creation:* Directory creation now happens after validating the package exists in the registry index. - *Added cleanup guard*: Used scopeguard::guard to ensure automatic cleanup if fetch fails. - *Implemented proper error handling*: On successful fetch, the guard is defused; on failure, the directory is automatically removed. ### Testing - `test_fetch_directory_cleanup_on_failure` validates that failed fetches properly clean up directories
1 parent 6981d3d commit b552ba1

File tree

3 files changed

+104
-5
lines changed

3 files changed

+104
-5
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

forc-pkg/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ git2 = { workspace = true, features = ["vendored-libgit2", "vendored-openssl"] }
2525
gix-url = { workspace = true, features = ["serde"] }
2626
hex.workspace = true
2727
ipfs-api-backend-hyper = { workspace = true, features = ["with-builder", "with-send-sync"] }
28+
once_cell.workspace = true
2829
petgraph = { workspace = true, features = ["serde-1"] }
2930
reqwest.workspace = true
3031
scopeguard.workspace = true

forc-pkg/src/source/reg/mod.rs

Lines changed: 102 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -241,10 +241,32 @@ impl Display for Source {
241241
write!(f, "{}+{}", self.name, self.version)
242242
}
243243
}
244+
#[cfg(not(test))]
244245
fn registry_dir() -> PathBuf {
245246
forc_util::user_forc_directory().join(REG_DIR_NAME)
246247
}
247248

249+
#[cfg(test)]
250+
fn registry_dir() -> PathBuf {
251+
use once_cell::sync::Lazy;
252+
use std::sync::Mutex;
253+
254+
static TEST_REGISTRY_DIR: Lazy<Mutex<Option<PathBuf>>> = Lazy::new(|| Mutex::new(None));
255+
256+
let mut dir = TEST_REGISTRY_DIR.lock().unwrap();
257+
if let Some(ref path) = *dir {
258+
path.clone()
259+
} else {
260+
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir for tests");
261+
let path = temp_dir.path().join(REG_DIR_NAME);
262+
std::fs::create_dir_all(&path).expect("Failed to create test registry dir");
263+
// Keep the temp dir alive by leaking it (only for tests)
264+
let leaked_path = temp_dir.keep().join(REG_DIR_NAME);
265+
*dir = Some(leaked_path.clone());
266+
leaked_path
267+
}
268+
}
269+
248270
fn registry_with_namespace_dir(namespace: &Namespace) -> PathBuf {
249271
let base = registry_dir();
250272
match namespace {
@@ -456,10 +478,19 @@ async fn fetch(fetch_id: u64, pinned: &Pinned, ipfs_node: &IPFSNode) -> anyhow::
456478
if path.exists() {
457479
let _ = fs::remove_dir_all(&path);
458480
}
459-
fs::create_dir_all(&path)?;
460481

461482
let cid = resolve_to_cid(&index_file, pinned)?;
462483

484+
// Create directory only after we've validated the package exists in the index
485+
fs::create_dir_all(&path)?;
486+
487+
// Use a cleanup guard to ensure directory is removed if fetch fails
488+
let cleanup_guard = scopeguard::guard(&path, |path| {
489+
if path.exists() {
490+
let _ = fs::remove_dir_all(path);
491+
}
492+
});
493+
463494
// Try IPFS first, fallback to CDN if it fails
464495
let ipfs_result = match ipfs_node {
465496
IPFSNode::Local => {
@@ -476,11 +507,24 @@ async fn fetch(fetch_id: u64, pinned: &Pinned, ipfs_node: &IPFSNode) -> anyhow::
476507
};
477508

478509
// If IPFS fails, try CDN fallback
479-
if let Err(ipfs_error) = ipfs_result {
510+
let fetch_result = if let Err(ipfs_error) = ipfs_result {
480511
println_action_green("Warning", &format!("IPFS fetch failed: {ipfs_error}"));
481512
fetch_from_s3(pinned, &path).await.with_context(|| {
482513
format!("Both IPFS and CDN fallback failed. IPFS error: {ipfs_error}")
483-
})?;
514+
})
515+
} else {
516+
Ok(())
517+
};
518+
519+
match fetch_result {
520+
Ok(()) => {
521+
// Fetch successful, defuse the cleanup guard so directory is preserved
522+
scopeguard::ScopeGuard::into_inner(cleanup_guard);
523+
}
524+
Err(e) => {
525+
// Fetch failed, cleanup guard will automatically remove the directory
526+
return Err(e);
527+
}
484528
}
485529

486530
Ok(path)
@@ -637,12 +681,16 @@ where
637681

638682
#[cfg(test)]
639683
mod tests {
640-
use super::{file_location::Namespace, resolve_to_cid, Pinned, Source};
684+
use super::{
685+
block_on_any_runtime, fetch, file_location::Namespace, registry_package_dir,
686+
resolve_to_cid, Pinned, Source,
687+
};
641688
use crate::source::{
642689
ipfs::Cid,
643690
reg::index_file::{IndexFile, PackageEntry},
691+
IPFSNode,
644692
};
645-
use std::str::FromStr;
693+
use std::{fs, str::FromStr};
646694

647695
#[test]
648696
fn parse_pinned_entry_without_namespace() {
@@ -792,4 +840,53 @@ mod tests {
792840
|| error_msg.contains("Other available versions: [1.0.0,1.1.0]")
793841
);
794842
}
843+
844+
#[test]
845+
fn test_fetch_directory_cleanup_on_failure() {
846+
// The test itself doesn't need to assert anything about the result,
847+
// the assertions inside the async block are what matter
848+
block_on_any_runtime(async {
849+
let pinned = Pinned {
850+
source: Source {
851+
name: "nonexistent_test_package".to_string(),
852+
version: semver::Version::new(1, 0, 0),
853+
namespace: Namespace::Flat,
854+
},
855+
// Valid CID format but this will fail because the package doesn't exist in the index
856+
cid: Cid::from_str("QmdMVqLqpba2mMB5AUjYCxubC6tLGevQFunpBkbC2UbrKS").unwrap(),
857+
};
858+
859+
// Get the expected package directory path
860+
let expected_path = registry_package_dir(
861+
&pinned.source.namespace,
862+
&pinned.source.name,
863+
&pinned.source.version,
864+
);
865+
866+
// Ensure the directory doesn't exist initially
867+
if expected_path.exists() {
868+
let _ = fs::remove_dir_all(&expected_path);
869+
}
870+
assert!(!expected_path.exists());
871+
872+
// Call the actual fetch function with an IPFS node that will fail
873+
// This will fail during index lookup (the package doesn't exist in registry)
874+
let fetch_id = 12345;
875+
let ipfs_node = IPFSNode::WithUrl("https://invalid-url.com".to_string());
876+
877+
let result = fetch(fetch_id, &pinned, &ipfs_node).await;
878+
879+
// Verify that fetch failed (package not found in index)
880+
assert!(result.is_err());
881+
let error_msg = result.unwrap_err().to_string();
882+
assert!(error_msg.contains("Failed to fetch nonexistent_test_package"));
883+
884+
// Most importantly, verify that no directory was created or if it was created, it got cleaned up
885+
assert!(
886+
!expected_path.exists(),
887+
"Directory should not exist after fetch failure, but it exists at: {}",
888+
expected_path.display()
889+
);
890+
});
891+
}
795892
}

0 commit comments

Comments
 (0)