| // Copyright (c) 2017, 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'; | |
| import 'dart:io'; | |
| import 'package:bazel_worker/bazel_worker.dart'; | |
| import 'package:bazel_worker/driver.dart'; | |
| import 'package:test/test.dart'; | |
| void main() { | |
| BazelWorkerDriver? driver; | |
| final disconnectedResponse = WorkResponse( | |
| exitCode: EXIT_CODE_BROKEN_PIPE, | |
| output: 'Connection closed', | |
| )..freeze(); | |
| group('basic driver', () { | |
| test('can run a single request', () async { | |
| await _doRequests(count: 1); | |
| await _doRequests(count: 1); | |
| }); | |
| test( | |
| 'can run multiple batches of requests through multiple workers', | |
| () async { | |
| var maxWorkers = 4; | |
| var maxIdleWorkers = 2; | |
| driver = BazelWorkerDriver( | |
| MockWorker.spawn, | |
| maxWorkers: maxWorkers, | |
| maxIdleWorkers: maxIdleWorkers, | |
| ); | |
| for (var i = 0; i < 10; i++) { | |
| await _doRequests(driver: driver); | |
| expect(MockWorker.liveWorkers.length, maxIdleWorkers); | |
| // No workers should be killed while there is ongoing work, but they | |
| // should be cleaned up once there isn't any more work to do. | |
| expect( | |
| MockWorker.deadWorkers.length, | |
| (maxWorkers - maxIdleWorkers) * (i + 1), | |
| ); | |
| } | |
| }, | |
| ); | |
| test('can run multiple requests through one worker', () async { | |
| var maxWorkers = 1; | |
| var maxIdleWorkers = 1; | |
| driver = BazelWorkerDriver( | |
| MockWorker.spawn, | |
| maxWorkers: maxWorkers, | |
| maxIdleWorkers: maxIdleWorkers, | |
| ); | |
| for (var i = 0; i < 10; i++) { | |
| await _doRequests(driver: driver); | |
| expect(MockWorker.liveWorkers.length, 1); | |
| expect(MockWorker.deadWorkers.length, 0); | |
| } | |
| }); | |
| test('can run one request through multiple workers', () async { | |
| driver = BazelWorkerDriver( | |
| MockWorker.spawn, | |
| maxWorkers: 4, | |
| maxIdleWorkers: 4, | |
| ); | |
| for (var i = 0; i < 10; i++) { | |
| await _doRequests(driver: driver, count: 1); | |
| expect(MockWorker.liveWorkers.length, 1); | |
| expect(MockWorker.deadWorkers.length, 0); | |
| } | |
| }); | |
| test('can run with maxIdleWorkers == 0', () async { | |
| var maxWorkers = 4; | |
| driver = BazelWorkerDriver( | |
| MockWorker.spawn, | |
| maxWorkers: maxWorkers, | |
| maxIdleWorkers: 0, | |
| ); | |
| for (var i = 0; i < 10; i++) { | |
| await _doRequests(driver: driver); | |
| expect(MockWorker.liveWorkers.length, 0); | |
| expect(MockWorker.deadWorkers.length, maxWorkers * (i + 1)); | |
| } | |
| }); | |
| test('trackWork gets invoked when a worker is actually ready', () async { | |
| var maxWorkers = 2; | |
| driver = BazelWorkerDriver(MockWorker.spawn, maxWorkers: maxWorkers); | |
| var tracking = <Future>[]; | |
| await _doRequests( | |
| driver: driver, | |
| count: 10, | |
| trackWork: (Future response) { | |
| // We should never be tracking more than `maxWorkers` jobs at a time. | |
| expect(tracking.length, lessThan(maxWorkers)); | |
| tracking.add(response); | |
| response.then((_) => tracking.remove(response)); | |
| }, | |
| ); | |
| }); | |
| group('failing workers', () { | |
| /// A driver which spawns [numBadWorkers] failing workers and then good | |
| /// ones after that, and which will retry [maxRetries] times. | |
| void createDriver({int maxRetries = 2, int numBadWorkers = 2}) { | |
| var numSpawned = 0; | |
| driver = BazelWorkerDriver( | |
| () async => MockWorker( | |
| workerLoopFactory: (MockWorker worker) { | |
| var connection = StdAsyncWorkerConnection( | |
| inputStream: worker._stdinController.stream, | |
| outputStream: worker._stdoutController.sink, | |
| ); | |
| if (numSpawned < numBadWorkers) { | |
| numSpawned++; | |
| return ThrowingMockWorkerLoop( | |
| worker, | |
| MockWorker.responseQueue, | |
| connection, | |
| ); | |
| } else { | |
| return MockWorkerLoop( | |
| MockWorker.responseQueue, | |
| connection: connection, | |
| ); | |
| } | |
| }, | |
| ), | |
| maxRetries: maxRetries, | |
| ); | |
| } | |
| test('should retry up to maxRetries times', () async { | |
| createDriver(); | |
| var expectedResponse = WorkResponse(); | |
| MockWorker.responseQueue.addAll([ | |
| disconnectedResponse, | |
| disconnectedResponse, | |
| expectedResponse, | |
| ]); | |
| var actualResponse = await driver!.doWork(WorkRequest()); | |
| // The first 2 null responses are thrown away, and we should get the | |
| // third one. | |
| expect(actualResponse, expectedResponse); | |
| expect(MockWorker.deadWorkers.length, 2); | |
| expect(MockWorker.liveWorkers.length, 1); | |
| }); | |
| test('should fail if it exceeds maxRetries failures', () async { | |
| createDriver(maxRetries: 2, numBadWorkers: 3); | |
| MockWorker.responseQueue.addAll([ | |
| disconnectedResponse, | |
| disconnectedResponse, | |
| WorkResponse(), | |
| ]); | |
| var actualResponse = await driver!.doWork(WorkRequest()); | |
| // Should actually get a bad response. | |
| expect(actualResponse.exitCode, 15); | |
| expect( | |
| actualResponse.output, | |
| 'Invalid response from worker, this probably means it wrote ' | |
| 'invalid output or died.', | |
| ); | |
| expect(MockWorker.deadWorkers.length, 3); | |
| }); | |
| }); | |
| test('handles spawnWorker failures', () async { | |
| driver = BazelWorkerDriver( | |
| () async => throw StateError('oh no!'), | |
| maxRetries: 0, | |
| ); | |
| expect(driver!.doWork(WorkRequest()), throwsA(isA<StateError>())); | |
| }); | |
| tearDown(() async { | |
| await driver?.terminateWorkers(); | |
| expect(MockWorker.liveWorkers, isEmpty); | |
| MockWorker.deadWorkers.clear(); | |
| MockWorker.responseQueue.clear(); | |
| }); | |
| }); | |
| } | |
| /// Runs [count] of fake work requests through [driver], and asserts that they | |
| /// all completed. | |
| Future _doRequests({ | |
| BazelWorkerDriver? driver, | |
| int count = 100, | |
| void Function(Future<WorkResponse?>)? trackWork, | |
| }) async { | |
| // If we create a driver, we need to make sure and terminate it. | |
| var terminateDriver = driver == null; | |
| driver ??= BazelWorkerDriver(MockWorker.spawn); | |
| var requests = List.generate(count, (_) => WorkRequest()); | |
| var responses = List.generate(count, (_) => WorkResponse()); | |
| MockWorker.responseQueue.addAll(responses); | |
| var actualResponses = await Future.wait( | |
| requests.map((request) => driver!.doWork(request, trackWork: trackWork)), | |
| ); | |
| expect(actualResponses, unorderedEquals(responses)); | |
| if (terminateDriver) await driver.terminateWorkers(); | |
| } | |
| /// A mock worker loop that returns work responses from the provided list. | |
| /// | |
| /// Throws if it runs out of responses. | |
| class MockWorkerLoop extends AsyncWorkerLoop { | |
| final Queue<WorkResponse> _responseQueue; | |
| MockWorkerLoop(this._responseQueue, {super.connection}); | |
| @override | |
| Future<WorkResponse> performRequest(WorkRequest request) async { | |
| print('Performing request $request'); | |
| return _responseQueue.removeFirst(); | |
| } | |
| } | |
| /// A mock worker loop with a custom `run` function that throws. | |
| class ThrowingMockWorkerLoop extends MockWorkerLoop { | |
| final MockWorker _mockWorker; | |
| ThrowingMockWorkerLoop( | |
| this._mockWorker, | |
| Queue<WorkResponse> responseQueue, | |
| AsyncWorkerConnection connection, | |
| ) : super(responseQueue, connection: connection); | |
| /// Run the worker loop. The returned [Future] doesn't complete until | |
| /// [connection#readRequest] returns `null`. | |
| @override | |
| Future run() async { | |
| while (true) { | |
| var request = await connection.readRequest(); | |
| if (request == null) break; | |
| await performRequest(request); | |
| _mockWorker.kill(); | |
| } | |
| } | |
| } | |
| /// A mock worker process. | |
| /// | |
| /// Items in [responseQueue] will be returned in order based on requests. | |
| /// | |
| /// If there are no items left in [responseQueue] then it will throw. | |
| class MockWorker implements Process { | |
| /// Spawns a new [MockWorker]. | |
| static Future<MockWorker> spawn() async => MockWorker(); | |
| /// Static queue of pending responses, these are shared by all workers. | |
| /// | |
| /// If this is empty and a request is received then it will throw. | |
| static final responseQueue = Queue<WorkResponse>(); | |
| /// Static list of all live workers. | |
| static final liveWorkers = <MockWorker>[]; | |
| /// Static list of all the dead workers. | |
| static final deadWorkers = <MockWorker>[]; | |
| /// Standard constructor, creates a [WorkerLoop] from [workerLoopFactory] or | |
| /// a [MockWorkerLoop] if no factory is provided. | |
| MockWorker({WorkerLoop Function(MockWorker)? workerLoopFactory}) { | |
| liveWorkers.add(this); | |
| var workerLoop = workerLoopFactory != null | |
| ? workerLoopFactory(this) | |
| : MockWorkerLoop( | |
| responseQueue, | |
| connection: StdAsyncWorkerConnection( | |
| inputStream: _stdinController.stream, | |
| outputStream: _stdoutController.sink, | |
| ), | |
| ); | |
| workerLoop.run(); | |
| } | |
| @override | |
| Future<int> get exitCode => throw UnsupportedError('Not needed.'); | |
| @override | |
| Stream<List<int>> get stdout => _stdoutController.stream; | |
| final _stdoutController = StreamController<List<int>>(); | |
| @override | |
| Stream<List<int>> get stderr => _stderrController.stream; | |
| final _stderrController = StreamController<List<int>>(); | |
| @override | |
| late final IOSink stdin = IOSink(_stdinController.sink); | |
| final _stdinController = StreamController<List<int>>(); | |
| @override | |
| int get pid => throw UnsupportedError('Not needed.'); | |
| @override | |
| bool kill([ | |
| ProcessSignal processSignal = ProcessSignal.sigterm, | |
| int exitCode = 0, | |
| ]) { | |
| if (_killed) return false; | |
| () async { | |
| await _stdoutController.close(); | |
| await _stderrController.close(); | |
| await _stdinController.close(); | |
| }(); | |
| deadWorkers.add(this); | |
| liveWorkers.remove(this); | |
| return true; | |
| } | |
| final _killed = false; | |
| } |