From 11d5d02b3e6066820720285bfd369336d127e4e8 Mon Sep 17 00:00:00 2001 From: wenyue Date: Sun, 2 Aug 2026 00:37:09 +0800 Subject: [PATCH 1/2] fix(server): clean up Windows machine discovery process trees --- CHANGELOG.md | 6 + .../flutter_tool_machine_discovery.dart | 88 +++++++- .../flutter_tool_machine_discovery_test.dart | 188 ++++++++++++++++++ 3 files changed, 278 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7696179..fb0047eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ +## [Unreleased] + +### Bug Fixes + +* **server:** terminate Flutter machine discovery process trees on Windows + ## [4.0.0-dev.8](https://github.com/Arenukvern/mcp_flutter/compare/v4.0.0-dev.7...v4.0.0-dev.8) (2026-07-31) diff --git a/mcp_server_dart/lib/src/shared_core/vm_connections/flutter_tool_machine_discovery.dart b/mcp_server_dart/lib/src/shared_core/vm_connections/flutter_tool_machine_discovery.dart index 777f143c..149720c7 100644 --- a/mcp_server_dart/lib/src/shared_core/vm_connections/flutter_tool_machine_discovery.dart +++ b/mcp_server_dart/lib/src/shared_core/vm_connections/flutter_tool_machine_discovery.dart @@ -9,6 +9,7 @@ import 'dart:io'; import 'package:dart_mcp/server.dart'; import 'package:flutter_mcp_toolkit_server/src/shared_core/types/core_types.dart'; +import 'package:meta/meta.dart'; final class FlutterMachineDiscoveryTarget { const FlutterMachineDiscoveryTarget({ @@ -54,6 +55,13 @@ final class FlutterMachineEventData { typedef FlutterAttachArgumentsBuilder = List Function({String? device}); typedef FlutterMachineProcessLinesProvider = Future> Function(); +typedef FlutterMachineProcessStarter = + Future Function( + String executable, + List arguments, { + String? workingDirectory, + bool runInShell, + }); /// Discovers active Flutter debug VMs by parsing `flutter attach --machine`. final class FlutterToolMachineDiscovery { @@ -62,15 +70,27 @@ final class FlutterToolMachineDiscovery { this.flutterExecutable = 'flutter', this.attachArgumentsBuilder = _defaultAttachArgumentsBuilder, this.processLinesProvider = _defaultProcessLinesProvider, + @visibleForTesting this.processStarter = _defaultProcessStarter, + @visibleForTesting this.isWindows, this.settleAfterFirstMatch = const Duration(milliseconds: 250), + @visibleForTesting this.stopTimeout = const Duration(milliseconds: 400), }); final CoreLogger logger; final String flutterExecutable; final FlutterAttachArgumentsBuilder attachArgumentsBuilder; final FlutterMachineProcessLinesProvider processLinesProvider; + @visibleForTesting + final FlutterMachineProcessStarter processStarter; + + @visibleForTesting + final bool? isWindows; + final Duration settleAfterFirstMatch; + @visibleForTesting + final Duration stopTimeout; + Future> discover({ final String? projectDir, final String? device, @@ -102,7 +122,7 @@ final class FlutterToolMachineDiscovery { logger: 'FlutterMachineDiscovery', ); - process = await Process.start( + process = await processStarter( flutterExecutable, args, workingDirectory: _normalizePath(projectDir), @@ -455,6 +475,51 @@ final class FlutterToolMachineDiscovery { .toList(growable: false); } + static Future _defaultProcessStarter( + final String executable, + final List arguments, { + final String? workingDirectory, + final bool runInShell = false, + }) => Process.start( + executable, + arguments, + workingDirectory: workingDirectory, + runInShell: runInShell, + ); + + Future _terminateWindowsProcessTree(final int pid) async { + Process taskkillProcess; + try { + taskkillProcess = await processStarter('taskkill', [ + '/PID', + '$pid', + '/T', + '/F', + ], runInShell: false); + } on Exception { + return false; + } + + unawaited(taskkillProcess.stdout.drain()); + unawaited(taskkillProcess.stderr.drain()); + + try { + return await taskkillProcess.exitCode.timeout(stopTimeout) == 0; + } on TimeoutException { + taskkillProcess.kill(); + try { + await taskkillProcess.exitCode.timeout(stopTimeout); + } on TimeoutException { + taskkillProcess.kill(ProcessSignal.sigkill); + await taskkillProcess.exitCode.timeout( + stopTimeout, + onTimeout: () => -1, + ); + } + return false; + } + } + static String _normalizeWsPath(final String path) { final rawPath = path.trim(); if (rawPath.isEmpty) { @@ -545,11 +610,26 @@ final class FlutterToolMachineDiscovery { } await process.exitCode.timeout( - const Duration(milliseconds: 400), - onTimeout: () { + stopTimeout, + onTimeout: () async { + if (isWindows ?? Platform.isWindows) { + final terminatedTree = await _terminateWindowsProcessTree( + process.pid, + ); + if (terminatedTree) { + return process.exitCode.timeout(stopTimeout, onTimeout: () => -1); + } + logger( + LoggingLevel.warning, + 'Failed to terminate Flutter machine discovery process tree; ' + 'falling back to direct process termination.', + logger: 'FlutterMachineDiscovery', + ); + } + process.kill(); return process.exitCode.timeout( - const Duration(milliseconds: 400), + stopTimeout, onTimeout: () { process.kill(ProcessSignal.sigkill); return -1; diff --git a/mcp_server_dart/test/flutter_tool_machine_discovery_test.dart b/mcp_server_dart/test/flutter_tool_machine_discovery_test.dart index d8ac7efb..40140332 100644 --- a/mcp_server_dart/test/flutter_tool_machine_discovery_test.dart +++ b/mcp_server_dart/test/flutter_tool_machine_discovery_test.dart @@ -1,3 +1,7 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + import 'package:flutter_mcp_toolkit_server/flutter_mcp_core.dart'; import 'package:test/test.dart'; @@ -63,5 +67,189 @@ void main() { ); expect(parsed?.sourceEvent, equals('process.vmServiceUri')); }); + + test( + 'uses Windows tree termination when graceful stop times out', + () async { + final process = _StubbornProcess(4242); + final taskkillProcess = _StubbornProcess(5000)..completeExit(0); + List? taskkillArguments; + addTearDown(() async { + await process.dispose(); + await taskkillProcess.dispose(); + }); + + final discovery = FlutterToolMachineDiscovery( + logger: (final level, final message, {final logger = ''}) {}, + processStarter: + ( + final executable, + final arguments, { + final workingDirectory, + final runInShell = false, + }) async { + if (executable == 'taskkill') { + taskkillArguments = List.of(arguments); + process.completeExit(-1); + return taskkillProcess; + } + return process; + }, + isWindows: true, + stopTimeout: Duration.zero, + processLinesProvider: () async => const [], + ); + + await discovery.discover(timeout: Duration.zero); + + expect(taskkillArguments, equals(['/PID', '4242', '/T', '/F'])); + expect(process.killSignals, isEmpty); + expect(process.stdinText, contains('q')); + }, + ); + + test('falls back when Windows tree termination fails', () async { + final process = _StubbornProcess(4242); + final taskkillProcess = _StubbornProcess(5000)..completeExit(1); + addTearDown(() async { + await process.dispose(); + await taskkillProcess.dispose(); + }); + + final discovery = FlutterToolMachineDiscovery( + logger: (final level, final message, {final logger = ''}) {}, + processStarter: + ( + final executable, + final arguments, { + final workingDirectory, + final runInShell = false, + }) async => executable == 'taskkill' ? taskkillProcess : process, + isWindows: true, + stopTimeout: Duration.zero, + processLinesProvider: () async => const [], + ); + + await discovery.discover(timeout: Duration.zero); + + expect( + process.killSignals, + equals([ProcessSignal.sigterm]), + ); + }); + + test('bounds Windows tree termination before falling back', () async { + final process = _StubbornProcess(2147483000); + final taskkillProcess = _StubbornProcess(4242); + addTearDown(() async { + await process.dispose(); + await taskkillProcess.dispose(); + }); + + final discovery = FlutterToolMachineDiscovery( + logger: (final level, final message, {final logger = ''}) {}, + processStarter: + ( + final executable, + final arguments, { + final workingDirectory, + final runInShell = false, + }) async => executable == 'taskkill' ? taskkillProcess : process, + isWindows: true, + stopTimeout: Duration.zero, + processLinesProvider: () async => const [], + ); + + await discovery + .discover(timeout: Duration.zero) + .timeout(const Duration(milliseconds: 100)); + + expect( + process.killSignals, + equals([ProcessSignal.sigterm]), + ); + expect( + taskkillProcess.killSignals, + equals([ProcessSignal.sigterm]), + ); + }); + + test('retains direct kill escalation outside Windows', () async { + final process = _StubbornProcess(4242, completeOnSigterm: false); + addTearDown(process.dispose); + + final discovery = FlutterToolMachineDiscovery( + logger: (final level, final message, {final logger = ''}) {}, + processStarter: + ( + final executable, + final arguments, { + final workingDirectory, + final runInShell = false, + }) async => process, + isWindows: false, + stopTimeout: Duration.zero, + processLinesProvider: () async => const [], + ); + + await discovery.discover(timeout: Duration.zero); + + expect( + process.killSignals, + equals([ProcessSignal.sigterm, ProcessSignal.sigkill]), + ); + }); }); } + +final class _StubbornProcess implements Process { + _StubbornProcess(this.pid, {this.completeOnSigterm = true}) + : _stdinController = StreamController>(sync: true), + _exitCompleter = Completer() { + _stdinController.stream.listen(stdinBytes.addAll); + stdin = IOSink(_stdinController.sink); + } + + final StreamController> _stdinController; + final Completer _exitCompleter; + final bool completeOnSigterm; + final List stdinBytes = []; + final List killSignals = []; + + String get stdinText => utf8.decode(stdinBytes); + + @override + final int pid; + + @override + late final IOSink stdin; + + @override + Stream> get stdout => const Stream>.empty(); + + @override + Stream> get stderr => const Stream>.empty(); + + @override + Future get exitCode => _exitCompleter.future; + + @override + bool kill([final ProcessSignal signal = ProcessSignal.sigterm]) { + killSignals.add(signal); + if (completeOnSigterm || signal == ProcessSignal.sigkill) { + completeExit(-1); + } + return true; + } + + void completeExit(final int code) { + if (!_exitCompleter.isCompleted) { + _exitCompleter.complete(code); + } + } + + Future dispose() async { + completeExit(-1); + await stdin.close(); + } +} From 4810a33a7c433949b7f59c994b9a43930da32768 Mon Sep 17 00:00:00 2001 From: wenyue Date: Sun, 2 Aug 2026 16:45:45 +0800 Subject: [PATCH 2/2] fix(server): harden Windows machine discovery cleanup --- CHANGELOG.md | 2 +- .../flutter_tool_machine_discovery.dart | 295 ++++++++++++++++-- .../flutter_tool_machine_discovery_test.dart | 204 ++++++++++-- 3 files changed, 446 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb0047eb..14562570 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ ### Bug Fixes -* **server:** terminate Flutter machine discovery process trees on Windows +* **server:** coalesce overlapping Flutter discovery and terminate its process trees on Windows ## [4.0.0-dev.8](https://github.com/Arenukvern/mcp_flutter/compare/v4.0.0-dev.7...v4.0.0-dev.8) (2026-07-31) diff --git a/mcp_server_dart/lib/src/shared_core/vm_connections/flutter_tool_machine_discovery.dart b/mcp_server_dart/lib/src/shared_core/vm_connections/flutter_tool_machine_discovery.dart index 149720c7..f2c743ba 100644 --- a/mcp_server_dart/lib/src/shared_core/vm_connections/flutter_tool_machine_discovery.dart +++ b/mcp_server_dart/lib/src/shared_core/vm_connections/flutter_tool_machine_discovery.dart @@ -74,6 +74,7 @@ final class FlutterToolMachineDiscovery { @visibleForTesting this.isWindows, this.settleAfterFirstMatch = const Duration(milliseconds: 250), @visibleForTesting this.stopTimeout = const Duration(milliseconds: 400), + @visibleForTesting this.windowsTreeStopTimeout = const Duration(seconds: 5), }); final CoreLogger logger; @@ -91,10 +92,53 @@ final class FlutterToolMachineDiscovery { @visibleForTesting final Duration stopTimeout; + @visibleForTesting + final Duration windowsTreeStopTimeout; + + static final Expando<_DiscoveryCoordinator> _coordinators = + Expando<_DiscoveryCoordinator>(); + Future> discover({ final String? projectDir, final String? device, final Duration timeout = const Duration(milliseconds: 2500), + }) { + final key = ( + projectDir: projectDir, + device: device?.trim(), + timeout: timeout, + ); + final coordinator = _coordinators[this] ??= _DiscoveryCoordinator(); + final pending = coordinator.pending[key]; + if (pending != null) { + return pending; + } + + final operation = coordinator.tail.then( + (_) => _runDiscovery( + projectDir: projectDir, + device: device, + timeout: timeout, + ), + ); + late final Future> tracked; + tracked = operation.whenComplete(() { + if (identical(coordinator.pending[key], tracked)) { + coordinator.pending.remove(key)?.ignore(); + } + }); + coordinator.pending[key] = tracked; + coordinator.tail = operation.then( + (_) {}, + onError: (final Object _, final StackTrace _) {}, + ); + return tracked; + } + + Future> _runDiscovery({ + final String? projectDir, + final String? device, + final Duration timeout = const Duration(milliseconds: 2500), }) async { final args = attachArgumentsBuilder(device: device?.trim()); final byWsUri = {}; @@ -115,6 +159,8 @@ final class FlutterToolMachineDiscovery { }); } + late DateTime processStartedAfter; + late DateTime processStartedBefore; try { logger( LoggingLevel.debug, @@ -122,12 +168,14 @@ final class FlutterToolMachineDiscovery { logger: 'FlutterMachineDiscovery', ); + processStartedAfter = DateTime.now().toUtc(); process = await processStarter( flutterExecutable, args, workingDirectory: _normalizePath(projectDir), runInShell: true, ); + processStartedBefore = DateTime.now().toUtc(); } on Exception catch (e) { logger( LoggingLevel.warning, @@ -206,7 +254,11 @@ final class FlutterToolMachineDiscovery { Future.delayed(timeout), ]); } finally { - await _requestStop(process); + await _requestStop( + process, + processStartedAfter: processStartedAfter, + processStartedBefore: processStartedBefore, + ); await stdoutSub.cancel(); await stderrSub.cancel(); settleTimer?.cancel(); @@ -487,31 +539,53 @@ final class FlutterToolMachineDiscovery { runInShell: runInShell, ); - Future _terminateWindowsProcessTree(final int pid) async { - Process taskkillProcess; + Future _terminateWindowsProcessTree( + final int pid, { + required final DateTime processStartedAfter, + required final DateTime processStartedBefore, + }) => _runWindowsTerminator('powershell.exe', [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-Command', + _windowsTreeTerminationScript( + pid, + processStartedAfter: processStartedAfter, + processStartedBefore: processStartedBefore, + expectedFlutterExecutable: flutterExecutable, + ), + ], timeout: windowsTreeStopTimeout); + + Future _runWindowsTerminator( + final String executable, + final List arguments, { + required final Duration timeout, + }) async { + Process terminatorProcess; try { - taskkillProcess = await processStarter('taskkill', [ - '/PID', - '$pid', - '/T', - '/F', - ], runInShell: false); + terminatorProcess = await processStarter( + executable, + arguments, + runInShell: false, + ); } on Exception { return false; } - unawaited(taskkillProcess.stdout.drain()); - unawaited(taskkillProcess.stderr.drain()); + unawaited(terminatorProcess.stdout.drain()); + unawaited(terminatorProcess.stderr.drain()); try { - return await taskkillProcess.exitCode.timeout(stopTimeout) == 0; + return await terminatorProcess.exitCode.timeout(timeout) == 0; } on TimeoutException { - taskkillProcess.kill(); + terminatorProcess.kill(); try { - await taskkillProcess.exitCode.timeout(stopTimeout); + await terminatorProcess.exitCode.timeout(stopTimeout); } on TimeoutException { - taskkillProcess.kill(ProcessSignal.sigkill); - await taskkillProcess.exitCode.timeout( + terminatorProcess.kill(ProcessSignal.sigkill); + await terminatorProcess.exitCode.timeout( stopTimeout, onTimeout: () => -1, ); @@ -520,6 +594,136 @@ final class FlutterToolMachineDiscovery { } } + static String _windowsTreeTerminationScript( + final int rootPid, { + required final DateTime processStartedAfter, + required final DateTime processStartedBefore, + required final String expectedFlutterExecutable, + }) => + r''' +$rootPid = __ROOT_PID__ +$ErrorActionPreference = 'Stop' +$startedAfter = [DateTimeOffset]::Parse('__STARTED_AFTER__').UtcDateTime +$startedBefore = [DateTimeOffset]::Parse('__STARTED_BEFORE__').UtcDateTime +$flutterPattern = [regex]::Escape('__FLUTTER_EXECUTABLE__') + + '(?:["'']?)\s+attach\s+--machine(?:\s|$)' +$all = @(Get-CimInstance Win32_Process -ErrorAction Stop) +$root = @($all | Where-Object { [int]$_.ProcessId -eq $rootPid }) | + Select-Object -First 1 +if ($null -eq $root) { exit 2 } +$rootCreated = ([DateTime]$root.CreationDate).ToUniversalTime() +$rootCommand = [string]$root.CommandLine +if ($root.Name -ne 'cmd.exe' -or + $rootCreated -lt $startedAfter -or + $rootCreated -gt $startedBefore -or + $rootCommand -notmatch $flutterPattern) { + exit 3 +} + +$rootHandle = Get-Process -Id $rootPid -ErrorAction Stop +$handleCreated = $rootHandle.StartTime.ToUniversalTime() +if ([Math]::Abs($handleCreated.Ticks - $rootCreated.Ticks) -gt 10) { + exit 4 +} +[void]$rootHandle.Handle + +$lineage = @{$rootPid = [long]$rootCreated.Ticks} +$rootStoppedBefore = $null +function Add-Lineage([object[]]$processes, [DateTime]$latestCreation) { + $processesById = @{} + foreach ($process in $processes) { + $processesById[[int]$process.ProcessId] = $process + } + $changed = $true + while ($changed) { + $changed = $false + foreach ($process in $processes) { + $processId = [int]$process.ProcessId + $parentId = [int]$process.ParentProcessId + if (-not $lineage.ContainsKey($parentId)) { + continue + } + if ($processesById.ContainsKey($parentId)) { + $parentProcess = $processesById[$parentId] + $parentCreated = ([DateTime]$parentProcess.CreationDate).ToUniversalTime() + if ([long]$parentCreated.Ticks -ne [long]$lineage[$parentId]) { + continue + } + } elseif ($parentId -ne $rootPid -or $null -eq $rootStoppedBefore) { + continue + } + $created = ([DateTime]$process.CreationDate).ToUniversalTime() + if (-not $lineage.ContainsKey($processId) -and + $created -ge $rootCreated -and + $created -le $latestCreation) { + $lineage[$processId] = [long]$created.Ticks + $changed = $true + } + } + } +} + +Add-Lineage $all ([DateTime]::UtcNow) +$rootHandle.Kill() +if (-not $rootHandle.WaitForExit(2000)) { exit 5 } +$rootStoppedBefore = $rootHandle.ExitTime.ToUniversalTime() +$deadline = [DateTime]::UtcNow.AddSeconds(2) +$quietPasses = 0 +while ([DateTime]::UtcNow -lt $deadline) { + $all = @(Get-CimInstance Win32_Process -ErrorAction Stop) + Add-Lineage $all $rootStoppedBefore + $targets = @($all | Where-Object { + $created = ([DateTime]$_.CreationDate).ToUniversalTime() + $lineage.ContainsKey([int]$_.ProcessId) -and + [long]$created.Ticks -eq [long]$lineage[[int]$_.ProcessId] -and + $_.Name -in @('dart.exe', 'dartvm.exe') -and + $created -ge $rootCreated -and + $created -le $rootStoppedBefore -and + $_.CommandLine -match 'flutter_tools\.snapshot.*attach\s+--machine(?:\s|$)' + }) + if ($targets.Count -eq 0) { + $quietPasses++ + if ($quietPasses -ge 2) { exit 0 } + Start-Sleep -Milliseconds 100 + continue + } + + $quietPasses = 0 + foreach ($target in $targets) { + try { + $targetHandle = Get-Process -Id ([int]$target.ProcessId) -ErrorAction Stop + $targetCreated = ([DateTime]$target.CreationDate).ToUniversalTime() + $targetHandleCreated = $targetHandle.StartTime.ToUniversalTime() + if ([Math]::Abs( + $targetHandleCreated.Ticks - $targetCreated.Ticks + ) -gt 10) { + continue + } + [void]$targetHandle.Handle + $targetHandle.Kill() + [void]$targetHandle.WaitForExit(500) + } catch { + continue + } + } + Start-Sleep -Milliseconds 100 +} +exit 6 +''' + .replaceAll('__ROOT_PID__', '$rootPid') + .replaceAll( + '__STARTED_AFTER__', + processStartedAfter.toUtc().toIso8601String(), + ) + .replaceAll( + '__STARTED_BEFORE__', + processStartedBefore.toUtc().toIso8601String(), + ) + .replaceAll( + '__FLUTTER_EXECUTABLE__', + expectedFlutterExecutable.replaceAll("'", "''"), + ); + static String _normalizeWsPath(final String path) { final rawPath = path.trim(); if (rawPath.isEmpty) { @@ -601,7 +805,37 @@ final class FlutterToolMachineDiscovery { return '${uri.host.toLowerCase()}:${uri.port}'; } - Future _requestStop(final Process process) async { + Future _requestStop( + final Process process, { + required final DateTime processStartedAfter, + required final DateTime processStartedBefore, + }) async { + if (isWindows ?? Platform.isWindows) { + final terminatedTree = await _terminateWindowsProcessTree( + process.pid, + processStartedAfter: processStartedAfter, + processStartedBefore: processStartedBefore, + ); + if (terminatedTree) { + await process.exitCode.timeout(stopTimeout, onTimeout: () => -1); + return; + } + logger( + LoggingLevel.warning, + 'Failed to terminate Flutter machine discovery process tree; ' + 'falling back to a graceful stdin stop.', + logger: 'FlutterMachineDiscovery', + ); + try { + process.stdin.writeln('q'); + await process.stdin.flush(); + } catch (_) { + // Ignore stdin close/write errors during the safe fallback. + } + await process.exitCode.timeout(stopTimeout, onTimeout: () => -1); + return; + } + try { process.stdin.writeln('q'); await process.stdin.flush(); @@ -611,22 +845,7 @@ final class FlutterToolMachineDiscovery { await process.exitCode.timeout( stopTimeout, - onTimeout: () async { - if (isWindows ?? Platform.isWindows) { - final terminatedTree = await _terminateWindowsProcessTree( - process.pid, - ); - if (terminatedTree) { - return process.exitCode.timeout(stopTimeout, onTimeout: () => -1); - } - logger( - LoggingLevel.warning, - 'Failed to terminate Flutter machine discovery process tree; ' - 'falling back to direct process termination.', - logger: 'FlutterMachineDiscovery', - ); - } - + onTimeout: () { process.kill(); return process.exitCode.timeout( stopTimeout, @@ -639,3 +858,13 @@ final class FlutterToolMachineDiscovery { ); } } + +final class _DiscoveryCoordinator { + final Map< + ({String? projectDir, String? device, Duration timeout}), + Future> + > + pending = {}; + + Future tail = Future.value(); +} diff --git a/mcp_server_dart/test/flutter_tool_machine_discovery_test.dart b/mcp_server_dart/test/flutter_tool_machine_discovery_test.dart index 40140332..4c5e5b20 100644 --- a/mcp_server_dart/test/flutter_tool_machine_discovery_test.dart +++ b/mcp_server_dart/test/flutter_tool_machine_discovery_test.dart @@ -2,9 +2,18 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:dart_mcp/server.dart' show LoggingLevel; import 'package:flutter_mcp_toolkit_server/flutter_mcp_core.dart'; import 'package:test/test.dart'; +void _discardLog( + final LoggingLevel level, + final String message, { + final String logger = '', +}) {} + +Future> _emptyProcessLines() async => const []; + void main() { group('FlutterToolMachineDiscovery', () { test('parseMachineEvent extracts canonical VM WS URI and DTD URI', () { @@ -69,14 +78,15 @@ void main() { }); test( - 'uses Windows tree termination when graceful stop times out', + 'uses parent-aware Windows cleanup before the shell wrapper can exit', () async { final process = _StubbornProcess(4242); - final taskkillProcess = _StubbornProcess(5000)..completeExit(0); - List? taskkillArguments; + final powershellProcess = _StubbornProcess(5000)..completeExit(0); + List? powershellArguments; + var taskkillStarted = false; addTearDown(() async { await process.dispose(); - await taskkillProcess.dispose(); + await powershellProcess.dispose(); }); final discovery = FlutterToolMachineDiscovery( @@ -88,31 +98,161 @@ void main() { final workingDirectory, final runInShell = false, }) async { - if (executable == 'taskkill') { - taskkillArguments = List.of(arguments); + if (executable == 'powershell.exe') { + powershellArguments = List.of(arguments); process.completeExit(-1); - return taskkillProcess; + return powershellProcess; + } + if (executable == 'taskkill') { + taskkillStarted = true; } return process; }, isWindows: true, stopTimeout: Duration.zero, + windowsTreeStopTimeout: Duration.zero, processLinesProvider: () async => const [], ); await discovery.discover(timeout: Duration.zero); - expect(taskkillArguments, equals(['/PID', '4242', '/T', '/F'])); + expect(powershellArguments, isNotNull); + expect(powershellArguments, contains('-Command')); + expect(powershellArguments!.last, contains('ParentProcessId')); + expect(powershellArguments!.last, contains('flutter_tools')); + expect(powershellArguments!.last, contains('CreationDate')); + expect(powershellArguments!.last, contains('WaitForExit')); + expect(powershellArguments!.last, contains('ExitTime')); + expect( + powershellArguments!.last, + contains(r"$ErrorActionPreference = 'Stop'"), + ); + expect( + powershellArguments!.last, + contains(r'[void]$rootHandle.Handle'), + ); + expect(powershellArguments!.last, contains(r'$parentId -ne $rootPid')); + expect(powershellArguments!.last, contains(r'$quietPasses')); + expect(powershellArguments!.last, contains(r'$created.Ticks')); + expect( + powershellArguments!.last, + isNot(contains(r'$lineage[$processId] = $true')), + ); + expect( + powershellArguments!.last, + isNot(contains(r'Stop-Process -Id $rootPid')), + ); + expect(taskkillStarted, isFalse); expect(process.killSignals, isEmpty); - expect(process.stdinText, contains('q')); + expect(process.stdinText, isEmpty); + }, + ); + + test('preserves the public const constructor', () { + const discovery = FlutterToolMachineDiscovery( + logger: _discardLog, + processLinesProvider: _emptyProcessLines, + ); + + expect(discovery, isA()); + }); + + test('coalesces overlapping discovery requests', () async { + final allowProcessStart = Completer(); + final processes = <_StubbornProcess>[]; + var processStartCount = 0; + addTearDown(() async { + for (final process in processes) { + await process.dispose(); + } + }); + + final discovery = FlutterToolMachineDiscovery( + logger: (final level, final message, {final logger = ''}) {}, + processStarter: + ( + final executable, + final arguments, { + final workingDirectory, + final runInShell = false, + }) async { + processStartCount++; + await allowProcessStart.future; + final process = _StubbornProcess(4200 + processStartCount); + processes.add(process); + return process; + }, + isWindows: false, + stopTimeout: Duration.zero, + processLinesProvider: () async => const [], + ); + + final first = discovery.discover(timeout: Duration.zero); + final second = discovery.discover(timeout: Duration.zero); + allowProcessStart.complete(); + + await Future.wait(>[first, second]); + + expect(processStartCount, 1); + }); + + test( + 'does not coalesce overlapping requests with different inputs', + () async { + final allowProcessStart = Completer(); + final processes = <_StubbornProcess>[]; + var processStartCount = 0; + addTearDown(() async { + for (final process in processes) { + await process.dispose(); + } + }); + + final discovery = FlutterToolMachineDiscovery( + logger: (final level, final message, {final logger = ''}) {}, + processStarter: + ( + final executable, + final arguments, { + final workingDirectory, + final runInShell = false, + }) async { + processStartCount++; + await allowProcessStart.future; + final process = _StubbornProcess(4300 + processStartCount); + processes.add(process); + return process; + }, + isWindows: false, + stopTimeout: Duration.zero, + processLinesProvider: () async => const [], + ); + + final first = discovery.discover( + device: 'windows', + timeout: Duration.zero, + ); + final second = discovery.discover( + device: 'chrome', + timeout: Duration.zero, + ); + allowProcessStart.complete(); + + await Future.wait(>[first, second]); + + expect(processStartCount, 2); }, ); test('falls back when Windows tree termination fails', () async { final process = _StubbornProcess(4242); + final powershellProcess = _StubbornProcess(5001)..completeExit(1); final taskkillProcess = _StubbornProcess(5000)..completeExit(1); + var powershellStarted = false; + var taskkillStarted = false; addTearDown(() async { await process.dispose(); + await powershellProcess.dispose(); await taskkillProcess.dispose(); }); @@ -124,25 +264,39 @@ void main() { final arguments, { final workingDirectory, final runInShell = false, - }) async => executable == 'taskkill' ? taskkillProcess : process, + }) async { + if (executable == 'powershell.exe') { + powershellStarted = true; + return powershellProcess; + } + if (executable == 'taskkill') { + taskkillStarted = true; + return taskkillProcess; + } + return process; + }, isWindows: true, stopTimeout: Duration.zero, + windowsTreeStopTimeout: Duration.zero, processLinesProvider: () async => const [], ); await discovery.discover(timeout: Duration.zero); - expect( - process.killSignals, - equals([ProcessSignal.sigterm]), - ); + expect(powershellStarted, isTrue); + expect(taskkillStarted, isFalse); + expect(process.killSignals, isEmpty); + expect(process.stdinText, contains('q')); }); test('bounds Windows tree termination before falling back', () async { final process = _StubbornProcess(2147483000); - final taskkillProcess = _StubbornProcess(4242); + final powershellProcess = _StubbornProcess(4243); + final taskkillProcess = _StubbornProcess(4242)..completeExit(1); + var taskkillStarted = false; addTearDown(() async { await process.dispose(); + await powershellProcess.dispose(); await taskkillProcess.dispose(); }); @@ -154,9 +308,18 @@ void main() { final arguments, { final workingDirectory, final runInShell = false, - }) async => executable == 'taskkill' ? taskkillProcess : process, + }) async { + if (executable == 'taskkill') { + taskkillStarted = true; + return taskkillProcess; + } + return executable == 'powershell.exe' + ? powershellProcess + : process; + }, isWindows: true, stopTimeout: Duration.zero, + windowsTreeStopTimeout: Duration.zero, processLinesProvider: () async => const [], ); @@ -164,12 +327,11 @@ void main() { .discover(timeout: Duration.zero) .timeout(const Duration(milliseconds: 100)); + expect(taskkillStarted, isFalse); + expect(process.killSignals, isEmpty); + expect(process.stdinText, contains('q')); expect( - process.killSignals, - equals([ProcessSignal.sigterm]), - ); - expect( - taskkillProcess.killSignals, + powershellProcess.killSignals, equals([ProcessSignal.sigterm]), ); });