Repository navigation
fix(marionette_mcp): time out connect when the VM service stops answering #143
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
mateusz-pietras
merged 7 commits into
leancodepl:main
from
uveshm003:fix/131-connect-timeout
Oct 28, 2026
Merged
Changes from 1 commit
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
347d0ff
fix(marionette_mcp): time out connect when the VM service stops answe…
907f631
fix(marionette_mcp): keep each connect attempt's connection to itself
mateusz-pietras 0e6e4d8
fix(marionette_mcp): don't wait for connections to close in connect
mateusz-pietras 4646f72
test(marionette_mcp): cover the 5 s limit on closing a frozen VM service
mateusz-pietras ab9069e
fix(marionette_mcp): time out the whole connect tool, not only the ha…
mateusz-pietras c60efbe
fix(marionette_cli): pass --timeout to connect
mateusz-pietras 299fd8e
fix(marionette_mcp): keep a stale connect call out of the newer session
mateusz-pietras File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
203 changes: 203 additions & 0 deletions
203
packages/marionette_mcp/test/vm_service/connect_timeout_test.dart
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,203 @@ | ||
| import 'dart:async'; | ||
| import 'dart:convert'; | ||
| import 'dart:io'; | ||
|
|
||
| import 'package:marionette_mcp/src/vm_service/vm_service_connector.dart'; | ||
| import 'package:test/test.dart'; | ||
|
|
||
| /// A local stand-in for an app's VM service whose replies the test controls. | ||
| class _FakeVmService { | ||
| _FakeVmService._(this._server); | ||
|
|
||
| final HttpServer _server; | ||
| final _sockets = <WebSocket>[]; | ||
|
|
||
| /// Whether the server completes the WebSocket upgrade at all. | ||
| bool acceptUpgrade = true; | ||
|
|
||
| /// Whether the server answers JSON-RPC requests once connected. | ||
| bool answerRequests = true; | ||
|
|
||
| /// Extensions the single fake isolate reports. | ||
| List<String> extensionRPCs = ['ext.flutter.marionette.getLogs']; | ||
|
|
||
| static Future<_FakeVmService> start() async { | ||
| final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); | ||
| final fake = _FakeVmService._(server); | ||
| server.listen(fake._handle); | ||
| return fake; | ||
| } | ||
|
|
||
| String get uri => 'ws://127.0.0.1:${_server.port}/ws'; | ||
|
|
||
| Future<void> _handle(HttpRequest request) async { | ||
| // Leaving the request open simulates a VM service that accepted the | ||
| // TCP connection but never finishes the handshake. | ||
| if (!acceptUpgrade) return; | ||
| final socket = await WebSocketTransformer.upgrade(request); | ||
| _sockets.add(socket); | ||
| socket.listen((data) { | ||
| if (!answerRequests) return; | ||
| final request = jsonDecode(data as String) as Map<String, dynamic>; | ||
| socket.add(jsonEncode({ | ||
| 'jsonrpc': '2.0', | ||
| 'id': request['id'], | ||
| 'result': _result(request['method'] as String), | ||
| })); | ||
| }); | ||
| } | ||
|
|
||
| Map<String, dynamic> _result(String method) { | ||
| const isolateRef = { | ||
| 'type': '@Isolate', | ||
| 'id': 'isolates/1', | ||
| 'number': '1', | ||
| 'name': 'main', | ||
| 'isSystemIsolate': false, | ||
| }; | ||
| return switch (method) { | ||
| 'getVM' => { | ||
| 'type': 'VM', | ||
| 'name': 'vm', | ||
| 'architectureBits': 64, | ||
| 'hostCPU': '', | ||
| 'operatingSystem': '', | ||
| 'targetCPU': '', | ||
| 'version': '', | ||
| 'pid': 1, | ||
| 'startTime': 0, | ||
| 'isolates': [isolateRef], | ||
| 'isolateGroups': <Object>[], | ||
| 'systemIsolates': <Object>[], | ||
| 'systemIsolateGroups': <Object>[], | ||
| }, | ||
| 'getIsolate' => { | ||
| ...isolateRef, | ||
| 'type': 'Isolate', | ||
| 'isolateFlags': <Object>[], | ||
| 'startTime': 0, | ||
| 'runnable': true, | ||
| 'livePorts': 0, | ||
| 'pauseOnExit': false, | ||
| 'pauseEvent': {'type': 'Event', 'kind': 'Resume', 'timestamp': 0}, | ||
| 'libraries': <Object>[], | ||
| 'breakpoints': <Object>[], | ||
| 'exceptionPauseMode': 'None', | ||
| 'extensionRPCs': extensionRPCs, | ||
| }, | ||
| _ => {'type': 'Success'}, | ||
| }; | ||
| } | ||
|
|
||
| Future<void> close() async { | ||
| for (final socket in _sockets) { | ||
| await socket.close(); | ||
| } | ||
| await _server.close(force: true); | ||
| } | ||
| } | ||
|
|
||
| void main() { | ||
| late _FakeVmService vmService; | ||
| late VmServiceConnector connector; | ||
|
|
||
| setUp(() async { | ||
| vmService = await _FakeVmService.start(); | ||
| connector = VmServiceConnector(); | ||
| }); | ||
|
|
||
| tearDown(() async { | ||
| await connector.disconnect(); | ||
| await vmService.close(); | ||
| }); | ||
|
|
||
| test('connects to a responsive VM service', () async { | ||
| await connector.connect(vmService.uri); | ||
|
|
||
| expect(connector.isConnected, isTrue); | ||
| }); | ||
|
|
||
| test('times out when the VM service stops answering requests', () async { | ||
| vmService.answerRequests = false; | ||
|
|
||
| final stopwatch = Stopwatch()..start(); | ||
| await expectLater( | ||
| connector.connect( | ||
| vmService.uri, | ||
| timeout: const Duration(milliseconds: 300), | ||
| ), | ||
| throwsA( | ||
| isA<TimeoutException>().having( | ||
| (e) => e.message, | ||
| 'message', | ||
| contains( | ||
| 'Timed out connecting to the VM service at ${vmService.uri}'), | ||
| ), | ||
| ), | ||
| ); | ||
|
|
||
| expect(stopwatch.elapsed, lessThan(const Duration(seconds: 5))); | ||
| expect(connector.isConnected, isFalse); | ||
| }); | ||
|
|
||
| test('times out when the WebSocket handshake never completes', () async { | ||
| vmService.acceptUpgrade = false; | ||
|
|
||
| await expectLater( | ||
| connector.connect( | ||
| vmService.uri, | ||
| timeout: const Duration(milliseconds: 300), | ||
| ), | ||
| throwsA(isA<TimeoutException>()), | ||
| ); | ||
|
|
||
| expect(connector.isConnected, isFalse); | ||
| }); | ||
|
|
||
| test('can connect again after a timed-out attempt', () async { | ||
| vmService.answerRequests = false; | ||
| await expectLater( | ||
| connector.connect( | ||
| vmService.uri, | ||
| timeout: const Duration(milliseconds: 300), | ||
| ), | ||
| throwsA(isA<TimeoutException>()), | ||
| ); | ||
|
|
||
| vmService.answerRequests = true; | ||
| await connector.connect(vmService.uri); | ||
|
|
||
| expect(connector.isConnected, isTrue); | ||
| }); | ||
|
|
||
| test('still reports a missing marionette binding without waiting', () async { | ||
| vmService.extensionRPCs = []; | ||
|
|
||
| await expectLater( | ||
| connector.connect(vmService.uri), | ||
| throwsA( | ||
| isA<Exception>().having( | ||
| (e) => e.toString(), | ||
| 'message', | ||
| contains('No isolate found'), | ||
| ), | ||
| ), | ||
| ); | ||
|
|
||
| expect(connector.isConnected, isFalse); | ||
| }); | ||
|
|
||
| test('reconnects when the previous VM service stopped answering', () async { | ||
| await connector.connect(vmService.uri); | ||
| vmService.answerRequests = false; | ||
|
|
||
| final other = await _FakeVmService.start(); | ||
| addTearDown(other.close); | ||
|
|
||
| final stopwatch = Stopwatch()..start(); | ||
| await connector.connect(other.uri); | ||
|
|
||
| expect(connector.isConnected, isTrue); | ||
| expect(stopwatch.elapsed, lessThan(const Duration(seconds: 10))); | ||
| }); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.