m1k1o/neko · error

cyclical dependency detected: '%s' <-> '%s'

Error message

cyclical dependency detected: '%s' <-> '%s'

What it means

Returned by addPlugin when wiring a plugin's dependencies would create a dependency cycle. The code detects the cycle (dependsOn.findPlugin finds the starting plugin reachable), breaks the link (sets dependsOn.dependsOn = nil and deletes the entry) to keep the graph acyclic, and returns this error. The error names the two plugins forming the cycle.

Source

Thrown at server/internal/plugins/dependency.go:92

	plug.plugin = plugin
	plug.logger = d.logger
	d.deps[pluginName] = plug

	dplug, ok := plugin.(types.DependablePlugin)
	if !ok {
		return nil
	}

	for _, depName := range dplug.DependsOn() {
		dependsOn, ok := d.deps[depName]
		if !ok {
			dependsOn = &dependency{}
		} else if dependsOn.plugin != nil {
			// if there is a cyclical dependency, break it and return error
			if tdep, ok := dependsOn.findPlugin(pluginName); ok {
				dependsOn.dependsOn = nil
				delete(d.deps, pluginName)
				return fmt.Errorf("cyclical dependency detected: '%s' <-> '%s'", pluginName, tdep.plugin.Name())
			}
		}

		plug.dependsOn = append(plug.dependsOn, dependsOn)
		d.deps[depName] = dependsOn
	}

	return nil
}

func (d *dependiencies) findPlugin(name string) (*dependency, bool) {
	for _, dep := range d.deps {
		plug, ok := dep.findPlugin(name)
		if ok {
			return plug, true
		}
	}
	return nil, false

View on GitHub (pinned to b0f01cedea)

Solutions

  1. Break the cycle in plugin configuration: remove the dependency edge that makes A depend on B while B depends on A
  2. Extract shared logic into a third plugin both can depend on, restoring a DAG
  3. Fix the plugin's dependency declaration (the DependsOn/requirements list) so a plugin never lists something that already depends on it
  4. After this error, note the graph was mutated (cycle broken, entry deleted) — re-initialize the plugin manager before retrying with corrected deps

Example fix

// before
pluginA.DependsOn = ["B"]
pluginB.DependsOn = ["A"]  // cycle: A <-> B

// after
pluginA.DependsOn = ["B"]
pluginB.DependsOn = []      // cycle removed
// or: shared plugin C; A -> C, B -> C
Defensive patterns

Strategy: validation

Validate before calling

// before adding, ensure no registered plugin already (transitively) depends on p
func createsCycle(reg map[string]*dependency, p types.Plugin) bool {
    var visit func(name string) bool
    visit = func(name string) bool {
        d, ok := reg[name]
        if !ok || d.plugin == nil { return false }
        if d.plugin.Name() == p.Name() { return true }
        for _, dep := range d.dependsOn {
            if visit(dep.Name()) { return true }
        }
        return false
    }
    for _, name := range p.Dependencies() {
        if visit(name) { return true }
    }
    return false
}

Try / catch

if err := manager.plugins.addPlugin(p); err != nil {
    if strings.Contains(err.Error(), "cyclical dependency") {
        log.Error().Str("plugin", p.Name()).Err(err).Msg("fix plugin dependency graph")
    }
    return err
}

Prevention

When it happens

Trigger: Calling addPlugin(p) where p declares (via its dependency list) a dependency on a plugin that transitively already depends on p — e.g. plugin A depends on B, then adding B which depends on A.

Common situations: Refactoring plugins so two features start requiring each other; copy-pasting a DependsOn list that includes the registering plugin itself or its ancestor; config where two plugins mutually list each other as dependencies.

Related errors


AI-assisted analysis of m1k1o/neko@b0f01cedea (2026-09-01). Data as JSON: /api/errors/bf90b6509300dfac. Report an issue: GitHub.