bevyengine/bevy · error · DiGraphToposortError

self-loop detected at node

Error message

self-loop detected at node `{0:?}`

What it means

Bevy's schedule builder turns ordering constraints (.before/.after/.chain) and set membership into edges of a directed graph, then topologically sorts it to pick a run order. This error (DiGraphToposortError::Loop) means a single node - a system or system set - has an edge pointing directly at itself, so no valid ordering exists. It is produced while the schedule graph is topologically sorted during schedule initialization.

Solutions

  1. Map the node id printed in the error back to the system/set it names, then inspect its .before()/.after()/.in_set() constraints and delete the one that points the node at itself.
  2. If two different systems were intended, order against a distinct function or wrap the target in a named SystemSet and order against that set.
  3. Add a startup test that calls app.update() once (or schedule.initialize(&mut world)) so graph errors fail in CI instead of at first frame.

Example fix

// before
app.configure_sets(Update, Physics.in_set(Physics)); // set is a member of itself -> self-loop

// after
app.configure_sets(Update, Physics);
Defensive patterns

Strategy: validation

Validate before calling

// Validate the schedule graph at startup instead of first run
#[test]
fn schedule_graph_is_valid() {
    let mut app = App::new();
    // register systems/sets exactly as the real app does
    app.update(); // panics (with the offending node named) if a self-loop exists
}

Try / catch

if let Err(e) = schedule.initialize(&mut world) {
    // formatted against the graph: names the self-looped node
    log::error!("schedule build failed: {}", e.to_string(schedule.graph(), world));
}

Prevention

When it happens

Trigger: An ordering or membership constraint that resolves to the node itself: system fn ordered against itself (move_player.before(move_player) resolves the function name to that same system node), a set configured into itself (Physics.in_set(Physics)), or generated/config-driven code that emits a constraint whose source and target are the same node.

Common situations: Copy-pasted ordering constraints where the target name was not updated; sets accidentally nested into themselves during refactors; macro-generated plugin code that applies a user-supplied ordering twice onto the same node; Bevy upgrades where trivially self-referential ordering stopped being silently ignored.

Related errors


AI-assisted analysis of bevyengine/bevy@396ca72708 (2026-08-20). Data as JSON: /api/errors/15cfa4acb763233d. Report an issue: GitHub.

Appendix: source

Thrown at crates/bevy_ecs/src/schedule/graph/graph_map.rs:523

            // divide remainder into smaller SCCs
            sccs.extend(subgraph.iter_sccs().filter(|scc| scc.len() > 1));
        }

        cycles
    }

    /// Iterate over all *Strongly Connected Components* in this graph.
    pub(crate) fn iter_sccs(&self) -> impl Iterator<Item = SmallVec<[N; 4]>> + '_ {
        super::tarjan_scc::new_tarjan_scc(self)
    }
}

/// Error returned when topologically sorting a directed graph fails.
#[derive(Error, Debug)]
pub enum DiGraphToposortError<N: GraphNodeId> {
    /// A self-loop was detected.
    #[error("self-loop detected at node `{0:?}`")]
    Loop(N),
    /// Cycles were detected.
    #[error("cycles detected: {0:?}")]
    Cycle(Vec<Vec<N>>),
}

/// Edge direction.
#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Ord, Eq, Hash)]
#[repr(u8)]
pub enum Direction {
    /// An `Outgoing` edge is an outward edge *from* the current node.
    Outgoing = 0,
    /// An `Incoming` edge is an inbound edge *to* the current node.
    Incoming = 1,
}

impl Direction {
    /// Return the opposite `Direction`.

View on GitHub (pinned to 396ca72708)