diff --git a/doc/mcp.md b/doc/mcp.md index 52e89fda2..af4d5eb7f 100644 --- a/doc/mcp.md +++ b/doc/mcp.md @@ -137,12 +137,13 @@ Runs tests in a Dart or Flutter project. "platform": "chrome | vm | android | ios", "dart-define": "foo=bar", "dart-define-from-file": "config.json", - "test_randomize_ordering_seed": "random" + "test_randomize_ordering_seed": "random", + "timeout_seconds": 120 } } ``` -All parameters are optional. When `optimization` is not specified, `--no-optimization` is applied by default. +All parameters are optional. When `optimization` is not specified, `--no-optimization` is applied by default. When `timeout_seconds` is not specified, the test run is killed after 120 seconds. ### `packages_get` diff --git a/lib/src/mcp/mcp_server.dart b/lib/src/mcp/mcp_server.dart index 544ade81e..46da8502d 100644 --- a/lib/src/mcp/mcp_server.dart +++ b/lib/src/mcp/mcp_server.dart @@ -1,5 +1,5 @@ import 'dart:async'; -import 'dart:io' show stderr; +import 'dart:io' show Process, stderr; import 'package:args/command_runner.dart'; import 'package:dart_mcp/server.dart'; @@ -221,6 +221,13 @@ Only one value can be selected. '(e.g. // coverage:ignore-line). ' 'Only applies to Dart tests (dart: true).', ), + 'timeout_seconds': IntegerSchema( + description: + 'Maximum seconds to wait for the test run before killing ' + 'the Flutter test process. Flutter tests can hang ' + 'indefinitely when pumpAndSettle() is called without a ' + 'timeout argument (default is 10 minutes). Defaults to 120.', + ), }, ), ), @@ -425,8 +432,44 @@ Only one value can be selected. Future _handleTest(CallToolRequest request) async { final args = request.arguments ?? {}; + final timeoutSeconds = (args['timeout_seconds'] as num?)?.toInt() ?? 120; final cliArgs = _parseTest(args); - return _runToolCommand(cliArgs, toolName: 'test'); + try { + return await _runToolCommand( + cliArgs, + toolName: 'test', + ).timeout(Duration(seconds: timeoutSeconds)); + } on TimeoutException { + stderr.writeln( + '[very_good_mcp] Test run timed out after ${timeoutSeconds}s. ' + 'Killing flutter_tester processes.', + ); + // OS-level SIGKILL is the only reliable way to stop a hung + // flutter_tester. package:test timeouts fire internally but the + // Flutter process keeps running regardless. + unawaited( + Process.run( + 'pkill', + ['-KILL', '-f', 'flutter_tester'], + runInShell: true, + ), + ); + return CallToolResult( + content: [ + TextContent( + text: + 'Test run timed out after ${timeoutSeconds}s. ' + 'A Flutter test was hanging — likely an unbounded ' + 'pumpAndSettle() call (default timeout is 10 minutes). ' + 'The flutter_tester process was killed.\n' + 'Fix: replace tester.pumpAndSettle() with ' + 'tester.pump(Duration(milliseconds: 500)) or ' + 'tester.pumpAndSettle(timeout: Duration(seconds: 5)).', + ), + ], + isError: true, + ); + } } Future _handlePackagesGet(CallToolRequest request) async { diff --git a/test/src/mcp/mcp_server_test.dart b/test/src/mcp/mcp_server_test.dart index a8630348a..d43c605f7 100644 --- a/test/src/mcp/mcp_server_test.dart +++ b/test/src/mcp/mcp_server_test.dart @@ -420,6 +420,44 @@ void main() { contains('"test" failed with exit code'), ); }); + + test( + 'returns timeout error and kills flutter_tester when test hangs', + () async { + // Simulate a hung test — the future never completes. + final hangCompleter = Completer(); + when( + () => mockCommandRunner.run(any()), + ).thenAnswer((_) => hangCompleter.future); + + final response = await sendRequest( + CallToolRequest.methodName, + _params( + CallToolRequest( + name: 'test', + arguments: {'timeout_seconds': 1}, + ), + ), + ); + + expect(response['error'], isNull); + final result = CallToolResult.fromMap( + response['result'] as Map, + ); + expect(result.isError, isTrue); + expect( + (result.content.first as TextContent).text, + allOf([ + contains('timed out after 1s'), + contains('pumpAndSettle'), + ]), + ); + + // Resolve the dangling future so it doesn't outlive the test. + hangCompleter.complete(0); + }, + timeout: const Timeout(Duration(seconds: 5)), + ); }); group('Tool: packages_get', () {