Skip to content

Commit d93d523

Browse files
committed
fix: detect circular publish dependency and error instead of silent timeout
1 parent 7b691cd commit d93d523

2 files changed

Lines changed: 79 additions & 17 deletions

File tree

src/cargo/ops/registry/publish.rs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,13 +190,42 @@ pub fn publish(ws: &Workspace<'_>, opts: &PublishOpts<'_>) -> CargoResult<()> {
190190
// As a side effect, any given package's "effective" timeout may be much larger.
191191
let mut to_confirm = BTreeSet::new();
192192

193+
// Check for circular dependencies before entering the main loop.
194+
// We check for cycles before entering the publish loop to provide a clear error
195+
// message if the workspace cannot be published due to circular dependencies.
196+
// `has_cycles()` detects any back-edges in the dependency graph, while
197+
// `cycle_members()` identifies the specific packages involved in the cycle.
198+
if plan.has_cycles() {
199+
bail!(
200+
"circular dependency detected while publishing {}\n\
201+
help: to break a cycle between dev-dependencies \
202+
and other dependencies, remove the version field \
203+
on the dev-dependency so it will be implicitly \
204+
stripped on publish",
205+
package_list(plan.cycle_members(), "and")
206+
);
207+
}
208+
193209
while !plan.is_empty() {
194210
// There might not be any ready package, if the previous confirmations
195211
// didn't unlock a new one. For example, if `c` depends on `a` and
196212
// `b`, and we uploaded `a` and `b` but only confirmed `a`, then on
197213
// the following pass through the outer loop nothing will be ready for
198214
// upload.
199215
let mut ready = plan.take_ready();
216+
217+
if ready.is_empty() {
218+
// This situation should not be a circular dependency,
219+
// since those are caught before the loop.
220+
// It could be that waiting for confirmation timed out, or something else.
221+
return Err(crate::util::internal(format!(
222+
"no packages ready to publish but {} packages remain in plan with {} awaiting confirmation: {}",
223+
plan.len(),
224+
to_confirm.len(),
225+
package_list(plan.iter(), "and")
226+
)));
227+
}
228+
200229
while let Some(pkg_id) = ready.pop_first() {
201230
let (pkg, (_features, tarball)) = &pkg_dep_graph.packages[&pkg_id];
202231
opts.gctx.shell().status("Uploading", pkg.package_id())?;
@@ -745,6 +774,45 @@ impl PublishPlan {
745774
self.dependencies_count.len()
746775
}
747776

777+
/// Returns `true` if the dependency graph contains
778+
/// a cycle that would prevent any package from
779+
/// being published.
780+
///
781+
/// This happens when all remaining packages have
782+
/// unmet dependencies with no package having
783+
/// zero outstanding dependencies.
784+
fn has_cycles(&self) -> bool {
785+
// If the graph is not empty but no packages have 0 dependencies,
786+
// it means there's a cycle preventing any progress.
787+
!self.is_empty() && self.dependencies_count.values().all(|&weight| weight > 0)
788+
}
789+
790+
/// Returns the members of any dependency cycles in the plan.
791+
///
792+
/// This identifies the specific packages that form a circular dependency,
793+
/// allowing for more precise error reporting.
794+
fn cycle_members(&self) -> Vec<PackageId> {
795+
let mut remaining: BTreeSet<_> = self.dependencies_count.keys().copied().collect();
796+
loop {
797+
let to_remove: Vec<_> = remaining
798+
.iter()
799+
.filter(|&id| {
800+
self.dependents
801+
.edges(id)
802+
.all(|(child, _)| !remaining.contains(child))
803+
})
804+
.copied()
805+
.collect();
806+
if to_remove.is_empty() {
807+
break;
808+
}
809+
for id in to_remove {
810+
remaining.remove(&id);
811+
}
812+
}
813+
remaining.into_iter().collect()
814+
}
815+
748816
/// Returns the set of packages that are ready for publishing (i.e. have no outstanding dependencies).
749817
///
750818
/// These will not be returned in future calls.

tests/testsuite/publish.rs

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4650,8 +4650,8 @@ fn workspace_circular_publish_dependency() {
46504650
.file("bar/src/lib.rs", "")
46514651
.build();
46524652

4653-
// With current buggy code, it will silently wait for dependencies because plan.take_ready() returns empty
4654-
// but the `plan` isn't fully completed. It eventually hits a timeout with a blank string.
4653+
// The circular dependency between foo and bar (via dev-dependencies) is detected
4654+
// and reported with a helpful error message.
46554655
p.cargo("publish --workspace --no-verify -Zpublish-timeout --config publish.timeout=1")
46564656
.masquerade_as_nightly_cargo(&["publish-timeout"])
46574657
.replace_crates_io(registry.index_url())
@@ -4664,12 +4664,8 @@ fn workspace_circular_publish_dependency() {
46644664
[PACKAGING] bar v0.1.1 ([ROOT]/foo/bar)
46654665
[UPDATING] crates.io index
46664666
[PACKAGED] 4 files, [FILE_SIZE]B ([FILE_SIZE]B compressed)
4667-
[NOTE] waiting for to be available at registry `crates-io`.
4668-
2 remaining crates to be published
4669-
[WARNING] timed out waiting for to be available in registry `crates-io`
4670-
|
4671-
= [NOTE] the registry may have a backlog that is delaying making the crates available. The crates should be available soon.
4672-
[ERROR] unable to publish bar v0.1.1 and foo v0.1.1 due to a timeout while waiting for published dependencies to be available.
4667+
[ERROR] circular dependency detected while publishing bar v0.1.1 and foo v0.1.1
4668+
[HELP] to break a cycle between dev-dependencies and other dependencies, remove the version field on the dev-dependency so it will be implicitly stripped on publish
46734669
46744670
"#]])
46754671
.run();
@@ -4679,9 +4675,9 @@ fn workspace_circular_publish_dependency_with_non_cycle_package() {
46794675
// Test that when a workspace has a circular dependency, only the packages involved
46804676
// in the cycle are reported in the error message, even if other packages are
46814677
// blocked by the cycle.
4682-
// With current buggy code, all 3 crates timeout
4683-
// together with blank crate names. Only a and b
4684-
// form the cycle but c is also blocked.
4678+
// c is standalone and publishes successfully.
4679+
// a and b form a cycle (a depends on b, b dev-depends on a)
4680+
// and are detected as unable to make progress.
46854681
let registry = registry::RegistryBuilder::new()
46864682
.http_api()
46874683
.http_index()
@@ -4769,12 +4765,10 @@ fn workspace_circular_publish_dependency_with_non_cycle_package() {
47694765
[NOTE] waiting for c v1.0.1 to be available at registry `crates-io`.
47704766
2 remaining crates to be published
47714767
[PUBLISHED] c v1.0.1 at registry `crates-io`
4772-
[NOTE] waiting for to be available at registry `crates-io`.
4773-
2 remaining crates to be published
4774-
[WARNING] timed out waiting for to be available in registry `crates-io`
4775-
|
4776-
= [NOTE] the registry may have a backlog that is delaying making the crates available. The crates should be available soon.
4777-
[ERROR] unable to publish a v1.0.1 and b v1.0.1 due to a timeout while waiting for published dependencies to be available.
4768+
[ERROR] no packages ready to publish but 2 packages remain in plan with 0 awaiting confirmation: a v1.0.1 and b v1.0.1
4769+
[NOTE] this is an unexpected cargo internal error
4770+
[NOTE] we would appreciate a bug report: https://github.com/rust-lang/cargo/issues/
4771+
[NOTE] cargo [..]
47784772
47794773
"#]])
47804774
.run();

0 commit comments

Comments
 (0)