diff --git a/build_resolvers/CHANGELOG.md b/build_resolvers/CHANGELOG.md index d6e95d2bc..70f4cadda 100644 --- a/build_resolvers/CHANGELOG.md +++ b/build_resolvers/CHANGELOG.md @@ -1,3 +1,8 @@ +## 2.4.5-wip + +- Switch `BuildAssetUriResolver` dependency crawl to an iterative + algorithm, preventing stack overflows. + ## 2.4.4 - Refactor `BuildAssetUriResolver` into `AnalysisDriverModel` and diff --git a/build_resolvers/lib/src/build_asset_uri_resolver.dart b/build_resolvers/lib/src/build_asset_uri_resolver.dart index c5a57bd4f..d346540c1 100644 --- a/build_resolvers/lib/src/build_asset_uri_resolver.dart +++ b/build_resolvers/lib/src/build_asset_uri_resolver.dart @@ -13,11 +13,11 @@ import 'package:analyzer/src/clients/build_resolvers/build_resolvers.dart'; import 'package:build/build.dart' show AssetId, BuildStep; import 'package:collection/collection.dart'; import 'package:crypto/crypto.dart'; -import 'package:graphs/graphs.dart'; import 'package:path/path.dart' as p; import 'analysis_driver_filesystem.dart'; import 'analysis_driver_model.dart'; +import 'crawl_async.dart'; const _ignoredSchemes = ['dart', 'dart-ext']; diff --git a/build_resolvers/lib/src/crawl_async.dart b/build_resolvers/lib/src/crawl_async.dart new file mode 100644 index 000000000..c442a5310 --- /dev/null +++ b/build_resolvers/lib/src/crawl_async.dart @@ -0,0 +1,82 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:collection'; + +/// Finds and returns every node in a graph who's nodes and edges are +/// asynchronously resolved. +/// +/// Cycles are allowed. If this is an undirected graph the [edges] function +/// may be symmetric. In this case the [roots] may be any node in each connected +/// graph. +/// +/// [V] is the type of values in the graph nodes. [K] must be a type suitable +/// for using as a Map or Set key. [edges] should return the next reachable +/// nodes. +/// +/// There are no ordering guarantees. This is useful for ensuring some work is +/// performed at every node in an asynchronous graph, but does not give +/// guarantees that the work is done in topological order. +/// +/// If either [readNode] or [edges] throws the error will be forwarded +/// through the result stream and no further nodes will be crawled, though some +/// work may have already been started. +/// +/// Crawling is eager, so calls to [edges] may overlap with other calls that +/// have not completed. If the [edges] callback needs to be limited or throttled +/// that must be done by wrapping it before calling [crawlAsync]. +/// +/// This is a fork of the `package:graph` algorithm changed from recursive to +/// iterative; it is mostly for benchmarking, as `AnalysisDriverModel` will +/// replace the use of this method entirely. +Stream crawlAsync( + Iterable roots, + FutureOr Function(K) readNode, + FutureOr> Function(K, V) edges, +) { + final crawl = _CrawlAsync(roots, readNode, edges)..run(); + return crawl.result.stream; +} + +class _CrawlAsync { + final result = StreamController(); + + final FutureOr Function(K) readNode; + final FutureOr> Function(K, V) edges; + final Iterable roots; + + final _seen = HashSet(); + var _next = []; + + _CrawlAsync(this.roots, this.readNode, this.edges); + + /// Add all nodes in the graph to [result] and return a Future which fires + /// after all nodes have been seen. + Future run() async { + try { + _next.addAll(roots); + while (_next.isNotEmpty) { + // Take everything from `_next`, await crawling it in parallel. + final next = _next; + _next = []; + await Future.wait(next.map(_crawlNext), eagerError: true); + } + await result.close(); + } catch (e, st) { + result.addError(e, st); + await result.close(); + } + } + + /// Process [key], queue up any of its its edges that haven't been seen. + Future _crawlNext(K key) async { + final value = await readNode(key); + if (result.isClosed) return; + result.add(value); + for (final edge in await edges(key, value)) { + if (_seen.add(edge)) _next.add(edge); + } + } +} diff --git a/build_resolvers/test/crawl_async_test.dart b/build_resolvers/test/crawl_async_test.dart new file mode 100644 index 000000000..991f0f561 --- /dev/null +++ b/build_resolvers/test/crawl_async_test.dart @@ -0,0 +1,129 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:build_resolvers/src/crawl_async.dart'; +import 'package:test/test.dart'; + +// This is a fork of the `package:graph` test of the same name, there are +// no changes. TODO(davidmorgan): remove when `crawlAsync` is removed +// in favor of the new `AnalysisDriverModel`. + +void main() { + group('asyncCrawl', () { + Future> crawl( + Map?> g, + Iterable roots, + ) { + final graph = AsyncGraph(g); + return crawlAsync(roots, graph.readNode, graph.edges).toList(); + } + + test('empty result for empty graph', () async { + final result = await crawl({}, []); + expect(result, isEmpty); + }); + + test('single item for a single node', () async { + final result = await crawl({'a': []}, ['a']); + expect(result, ['a']); + }); + + test('hits every node in a graph', () async { + final result = await crawl({ + 'a': ['b', 'c'], + 'b': ['c'], + 'c': ['d'], + 'd': [], + }, [ + 'a', + ]); + expect(result, hasLength(4)); + expect( + result, + allOf(contains('a'), contains('b'), contains('c'), contains('d')), + ); + }); + + test('handles cycles', () async { + final result = await crawl({ + 'a': ['b'], + 'b': ['c'], + 'c': ['b'], + }, [ + 'a', + ]); + expect(result, hasLength(3)); + expect(result, allOf(contains('a'), contains('b'), contains('c'))); + }); + + test('handles self cycles', () async { + final result = await crawl({ + 'a': ['b'], + 'b': ['b'], + }, [ + 'a', + ]); + expect(result, hasLength(2)); + expect(result, allOf(contains('a'), contains('b'))); + }); + + test('allows null edges', () async { + final result = await crawl({ + 'a': ['b'], + 'b': null, + }, [ + 'a', + ]); + expect(result, hasLength(2)); + expect(result, allOf(contains('a'), contains('b'))); + }); + + test('allows null nodes', () async { + final result = await crawl({ + 'a': ['b'], + }, [ + 'a', + ]); + expect(result, ['a', null]); + }); + + test('surfaces exceptions for crawling edges', () { + final graph = { + 'a': ['b'], + }; + final nodes = crawlAsync( + ['a'], + (n) => n, + (k, n) => k == 'b' ? throw ArgumentError() : graph[k] ?? [], + ); + expect(nodes, emitsThrough(emitsError(isArgumentError))); + }); + + test('surfaces exceptions for resolving keys', () { + final graph = { + 'a': ['b'], + }; + final nodes = crawlAsync( + ['a'], + (n) => n == 'b' ? throw ArgumentError() : n, + (k, n) => graph[k] ?? [], + ); + expect(nodes, emitsThrough(emitsError(isArgumentError))); + }); + }); +} + +/// A representation of a Graph where keys can asynchronously be resolved to +/// real values or to edges. +class AsyncGraph { + final Map?> graph; + + AsyncGraph(this.graph); + + Future readNode(String node) async => + graph.containsKey(node) ? node : null; + + Future> edges(String key, String? node) async => + graph[key] ?? []; +} diff --git a/build_resolvers/test/resolver_test.dart b/build_resolvers/test/resolver_test.dart index e93046507..0849fc424 100644 --- a/build_resolvers/test/resolver_test.dart +++ b/build_resolvers/test/resolver_test.dart @@ -82,6 +82,24 @@ void runTests(ResolversFactory resolversFactory) { }, resolvers: createResolvers()); }); + test('does not stack overflow on long import chain', () { + return resolveSources( + { + 'a|web/main.dart': ''' + import 'lib0.dart'; + + main() { + } ''', + for (var i = 0; i != 750; ++i) + 'a|web/lib$i.dart': i == 749 ? '' : 'import "lib${i + 1}.dart";', + }, + (resolver) async { + await resolver.libraryFor(entryPoint); + }, + resolvers: createResolvers(), + ); + }); + test('should follow package imports', () { return resolveSources({ 'a|web/main.dart': '''