From e45fdd71547cc62faacd8e77cc5f8883a1bdfa85 Mon Sep 17 00:00:00 2001 From: Robert Magnusson Date: Thu, 5 Feb 2026 12:40:24 +0100 Subject: [PATCH] feat: add circular dependency detection with PodCycleError Detect cycles during provider resolution and throw a descriptive PodCycleError with the full dependency chain. Providers can now include an optional debugName for clearer diagnostics; unnamed providers display as Provider in error messages. Changes: - Add PodCycleError with cycle chain and human-readable toString - Add debugName parameter and debugLabel getter to Provider - Track resolution stack in Pod to detect cycles - Add tests for cycle detection and concurrent async resolves Co-authored-by: Cursor --- CHANGELOG.md | 5 ++++ lib/dartypod.dart | 1 + lib/src/errors.dart | 18 ++++++++++++ lib/src/pod.dart | 38 ++++++++++++++++++++++--- lib/src/provider.dart | 15 ++++++++++ test/pod_test.dart | 65 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 lib/src/errors.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 77a16d8..e63f64e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Circular dependency detection with `PodCycleError` during resolution +- Optional `debugName` for providers to improve diagnostics + ## [0.1.1] - 2026-02-04 ### Added diff --git a/lib/dartypod.dart b/lib/dartypod.dart index 11619bd..54cf6f0 100644 --- a/lib/dartypod.dart +++ b/lib/dartypod.dart @@ -29,6 +29,7 @@ library; export 'src/disposable.dart'; +export 'src/errors.dart'; export 'src/pod.dart'; export 'src/pod_resolver.dart'; export 'src/provider.dart'; diff --git a/lib/src/errors.dart b/lib/src/errors.dart new file mode 100644 index 0000000..358a505 --- /dev/null +++ b/lib/src/errors.dart @@ -0,0 +1,18 @@ +import 'provider.dart'; + +/// Error thrown when a circular dependency is detected during resolution. +class PodCycleError implements Exception { + /// The cycle chain, including the provider that closed the loop. + final List> cycle; + + PodCycleError(List> cycle) + : cycle = List>.unmodifiable(cycle); + + /// Human-readable provider names in the cycle chain. + List get chain => + cycle.map((provider) => provider.debugLabel).toList(growable: false); + + @override + String toString() => + 'PodCycleError: Circular dependency detected: ${chain.join(' -> ')}'; +} diff --git a/lib/src/pod.dart b/lib/src/pod.dart index c972331..de1be09 100644 --- a/lib/src/pod.dart +++ b/lib/src/pod.dart @@ -1,3 +1,4 @@ +import 'errors.dart'; import 'pod_resolver.dart'; import 'provider.dart'; import 'scope.dart'; @@ -6,12 +7,16 @@ import 'scope.dart'; class Pod implements PodResolver { final Map, Object?> _cache = {}; final Map, Object? Function(Pod)> _overrides = {}; + final List> _resolvingStack = []; + final Set> _resolvingSet = {}; /// Resolves an instance from the given provider. /// /// For [SingletonScope] providers, the instance is cached and reused. /// For [TransientScope] providers, a new instance is created each time. /// For [CustomScope] providers, instances are cached per scope. + /// + /// Throws [PodCycleError] if a circular dependency is detected. @override T resolve(Provider provider) { // Check for override first @@ -19,19 +24,19 @@ class Pod implements PodResolver { final builder = _overrides[provider]!; // Overrides follow the same scoping rules if (provider.scope is TransientScope) { - return builder(this) as T; + return _buildWithCycleCheck(provider, () => builder(this) as T); } if (_cache.containsKey(provider)) { return _cache[provider] as T; } - final instance = builder(this) as T; + final instance = _buildWithCycleCheck(provider, () => builder(this) as T); _cache[provider] = instance; return instance; } // Transient scope always creates new instance if (provider.scope is TransientScope) { - return provider.build(this); + return _buildWithCycleCheck(provider, () => provider.build(this)); } // Check cache for singleton and custom scopes @@ -40,11 +45,36 @@ class Pod implements PodResolver { } // Create and cache new instance - final instance = provider.build(this); + final instance = _buildWithCycleCheck(provider, () => provider.build(this)); _cache[provider] = instance; return instance; } + T _buildWithCycleCheck(Provider provider, T Function() build) { + final resolvingProvider = provider as Provider; + if (_resolvingSet.contains(resolvingProvider)) { + throw _createCycleError(resolvingProvider); + } + + _resolvingSet.add(resolvingProvider); + _resolvingStack.add(resolvingProvider); + + try { + return build(); + } finally { + _resolvingStack.removeLast(); + _resolvingSet.remove(resolvingProvider); + } + } + + PodCycleError _createCycleError(Provider provider) { + final startIndex = _resolvingStack.indexOf(provider); + if (startIndex == -1) { + return PodCycleError([..._resolvingStack, provider]); + } + return PodCycleError([..._resolvingStack.sublist(startIndex), provider]); + } + /// Overrides a provider with a custom builder for testing. void overrideProvider(Provider provider, T Function(Pod) builder) { // Clear any cached instance when overriding diff --git a/lib/src/provider.dart b/lib/src/provider.dart index 511acf4..fa206d9 100644 --- a/lib/src/provider.dart +++ b/lib/src/provider.dart @@ -4,6 +4,8 @@ import 'scope.dart'; /// A provider that knows how to create instances of type [T]. class Provider { + /// Optional debug-friendly name for diagnostics. + final String? debugName; final T Function(PodResolver pod) _builder; final void Function(T instance)? _dispose; final ProviderScope scope; @@ -12,16 +14,29 @@ class Provider { /// /// [builder] - Function that creates instances of [T], receiving the Pod for /// resolving dependencies. + /// [debugName] - Optional name used in diagnostics (e.g. cycle errors). /// [scope] - Controls instance lifecycle. Defaults to [SingletonScope]. /// [dispose] - Optional custom dispose function. If not provided and the /// instance implements [Disposable], its dispose method will be called. Provider( T Function(PodResolver pod) builder, { + this.debugName, this.scope = const SingletonScope(), void Function(T)? dispose, }) : _builder = builder, _dispose = dispose; + /// Debug-friendly label for this provider. + /// + /// Returns [debugName] if set, otherwise falls back to `Provider`. + String get debugLabel { + final name = debugName; + if (name != null && name.isNotEmpty) { + return name; + } + return 'Provider<$T>'; + } + /// Builds a new instance using the provided pod for dependency resolution. T build(PodResolver pod) => _builder(pod); diff --git a/test/pod_test.dart b/test/pod_test.dart index 14c366c..b1227a2 100644 --- a/test/pod_test.dart +++ b/test/pod_test.dart @@ -106,6 +106,71 @@ void main() { expect(identical(dependent1.dependency, dependent2.dependency), isTrue); expect(SimpleService.instanceCount, equals(1)); }); + + test('throws PodCycleError on circular dependencies', () { + final pod = Pod(); + late Provider providerA; + late Provider providerB; + late Provider providerC; + + providerA = Provider( + (pod) => pod.resolve(providerB), + debugName: 'A', + ); + providerB = Provider( + (pod) => pod.resolve(providerC), + // No debugName - tests the Provider fallback format + ); + providerC = Provider( + (pod) => pod.resolve(providerA), + debugName: 'C', + ); + + expect( + () => pod.resolve(providerA), + throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains('A -> Provider -> C -> A'), + ), + ), + ); + }); + + test('does not treat concurrent async resolves as a cycle', () async { + final pod = Pod(); + final providerD = Provider( + (pod) => 'D', + debugName: 'D', + ); + final providerC = Provider( + (pod) => pod.resolve(providerD), + debugName: 'C', + ); + final providerB = Provider( + (pod) => pod.resolve(providerC), + debugName: 'B', + ); + final providerA = Provider>( + (pod) async { + final value = pod.resolve(providerB); + await Future.delayed(Duration.zero); + return 'A$value'; + }, + debugName: 'A', + ); + final providerE = Provider>( + (pod) => pod.resolve(providerA), + debugName: 'E', + ); + + final futureA = pod.resolve(providerA); + final futureE = pod.resolve(providerE); + + expect(identical(futureA, futureE), isTrue); + expect(await futureE, equals('AD')); + }); }); group('Pod.overrideProvider', () {