Skip to content

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

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());
+    }
   }

Metadata

Metadata

Assignees

No one assigned

    Type

    Fields

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions