Skip to content

Circular dependency detection does not fail the build and silently generates stack-overflowing code #5

Closed

Description

@mark-dropbear

Describe the bug

When a circular dependency is introduced in the dependency injection graph, the code generator detects the cycle but fails to halt the compilation. The build_runner step completes with a successful exit code (0) and writes out an invalid .inject.dart implementation.

Furthermore, the severe warning message is logged to a detached Logger instance that is not piped to the builder's buildStep.logger, meaning the circular dependency warning is completely hidden from the stdout.

When the application runs, resolving any dependency involved in the cycle immediately crashes with a Stack Overflow.


To Reproduce

  1. Define two classes that circularly depend on each other:

    @provide
    class A {
      final B b;
      A(this.b);
    }
    
    @provide
    class B {
      final A a;
      B(this.a);
    }
    
    @Injector()
    abstract class CycleInjector {
      static final create = g.CycleInjector$Injector.create;
    
      @provide
      A get a;
    }
  2. Run the build runner:

    dart run build_runner build
  3. Observe that the build completes successfully:

    Built with build_runner/aot in 39s; wrote 2 outputs.
    

    (No cycles or warnings are logged to the terminal).

  4. Run the application entry point:

    dart run lib/main.dart
  5. Observe the runtime crash:

    Unhandled exception:
    Stack Overflow
    #0      CycleInjector$Injector._createB (package:cycle_demo/cycle_demo.inject.dart:19:3)
    #1      CycleInjector$Injector._createA (package:cycle_demo/cycle_demo.inject.dart:17:29)
    #2      CycleInjector$Injector._createB (package:cycle_demo/cycle_demo.inject.dart:19:29)
    ...
    

Emitted Code Analysis

The generator silently writes out invalid mutual recursion:

class CycleInjector$Injector extends _i1.CycleInjector {
  // ...
  @override
  _i1.A get a => _createA();

  _i1.A _createA() => _i1.A(_createB());

  _i1.B _createB() => _i1.B(_createA());
}

Context / Code Reference

In package/inject_compile_generator/lib/src/graph/injector_graph_resolver.dart, the _detectCycles function logs the cycle to _logger.severe, but does not throw an exception to fail the build step:

  void _detectCycles(Map<LookupKey, ResolvedDependency> merged) {
    final checked = <LookupKey>{};
    final cycles = <_Cycle>{};

    for (final key in merged.keys) {
      if (checked.contains(key)) continue;

      final chain = <LookupKey>[];
      void visit(LookupKey current) {
        final index = chain.indexOf(current);
        if (index != -1) {
          final cycle = _Cycle(chain.sublist(index)..add(current));
          if (cycles.add(cycle)) {
            // Logs to a detached logger, so it's not shown in stdout
            _logger.severe('Detected dependency cycle:\n${cycle.format()}');
          }
          return;
        }

        chain.add(current);
        final dep = merged[current];
        if (dep != null) {
          for (final next in dep.dependencies) {
            visit(next.lookupKey);
          }
        }
        chain.removeLast();
        checked.add(current);
      }

      visit(key);
    }
  }

Suggested Solution

Modify _detectCycles to accumulate detected cycles and throw a StateError (or InvalidGenerationSourceError from source_gen) if any circular dependency is found. This halts the build runner immediately and fails local compilation and CI/CD runs:

   void _detectCycles(Map<LookupKey, ResolvedDependency> merged) {
     final checked = <LookupKey>{};
     final cycles = <_Cycle>{};
 
     for (final key in merged.keys) {
       if (checked.contains(key)) continue;
 
       final chain = <LookupKey>[];
       void visit(LookupKey current) {
         final index = chain.indexOf(current);
         if (index != -1) {
           final cycle = _Cycle(chain.sublist(index)..add(current));
-          if (cycles.add(cycle)) {
-            _logger.severe('Detected dependency cycle:\n${cycle.format()}');
-          }
+          cycles.add(cycle);
           return;
         }
 
         chain.add(current);
         final dep = merged[current];
         if (dep != null) {
           for (final next in dep.dependencies) {
             visit(next.lookupKey);
           }
         }
         chain.removeLast();
         checked.add(current);
       }
 
       visit(key);
     }
+
+    if (cycles.isNotEmpty) {
+      final message = StringBuffer('Detected circular dependencies:\n');
+      for (final cycle in cycles) {
+        message.writeln(cycle.format());
+      }
+      throw StateError(message.toString());
+    }
   }

Activity

  1. added a commit that references this issue on Jul 15, 2026
    45e8d8c
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions