bevyengine/bevy · error · DiGraphToposortError

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

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.

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)

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.