Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

### Bug Fixes

* **server:** coalesce overlapping Flutter discovery and terminate its process trees on Windows
* **server:** coalesce identical overlapping Flutter discovery requests and terminate their 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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ final class FlutterToolMachineDiscovery {
@visibleForTesting
final Duration windowsTreeStopTimeout;

/// Coordination state keyed by object identity.
///
/// Note: identical const invocations of this class are canonicalized by the
/// Dart runtime into a single object, so they intentionally share one
/// coordinator — from the caller's perspective they are the same instance.
static final Expando<_DiscoveryCoordinator> _coordinators =
Expando<_DiscoveryCoordinator>();

Expand All @@ -104,7 +109,7 @@ final class FlutterToolMachineDiscovery {
final Duration timeout = const Duration(milliseconds: 2500),
}) {
final key = (
projectDir: projectDir,
projectDir: _normalizePath(projectDir),
device: device?.trim(),
timeout: timeout,
);
Expand Down Expand Up @@ -168,14 +173,18 @@ final class FlutterToolMachineDiscovery {
logger: 'FlutterMachineDiscovery',
);

processStartedAfter = DateTime.now().toUtc();
processStartedAfter = DateTime.now().toUtc().subtract(
const Duration(milliseconds: 1),
);
process = await processStarter(
flutterExecutable,
args,
workingDirectory: _normalizePath(projectDir),
runInShell: true,
);
processStartedBefore = DateTime.now().toUtc();
processStartedBefore = DateTime.now().toUtc().add(
const Duration(milliseconds: 1),
);
} on Exception catch (e) {
logger(
LoggingLevel.warning,
Expand Down Expand Up @@ -539,10 +548,19 @@ final class FlutterToolMachineDiscovery {
runInShell: runInShell,
);

Future<bool> _terminateWindowsProcessTree(
/// Exit code reported by the Windows tree-termination script when the root
/// was killed but descendant Dart processes remained after the rescan budget.
static const _windowsDescendantsRemainExitCode = 6;

/// Exit code reported when the root process already exited on its own
/// before the terminator could acquire its handle.
static const _windowsRootAlreadyExitedExitCode = 7;

Future<int> _terminateWindowsProcessTree(
final int pid, {
required final DateTime processStartedAfter,
required final DateTime processStartedBefore,
required final Duration scriptBudget,
}) => _runWindowsTerminator('powershell.exe', <String>[
'-NoLogo',
'-NoProfile',
Expand All @@ -555,10 +573,12 @@ final class FlutterToolMachineDiscovery {
processStartedAfter: processStartedAfter,
processStartedBefore: processStartedBefore,
expectedFlutterExecutable: flutterExecutable,
waitForExitMs: scriptBudget.inMilliseconds ~/ 4,
rescanSeconds: scriptBudget.inSeconds ~/ 2,
),
], timeout: windowsTreeStopTimeout);

Future<bool> _runWindowsTerminator(
Future<int> _runWindowsTerminator(
final String executable,
final List<String> arguments, {
required final Duration timeout,
Expand All @@ -571,14 +591,14 @@ final class FlutterToolMachineDiscovery {
runInShell: false,
);
} on Exception {
return false;
return -1;
}

unawaited(terminatorProcess.stdout.drain<void>());
unawaited(terminatorProcess.stderr.drain<void>());
terminatorProcess.stdout.drain<void>().ignore();
terminatorProcess.stderr.drain<void>().ignore();

try {
return await terminatorProcess.exitCode.timeout(timeout) == 0;
return await terminatorProcess.exitCode.timeout(timeout);
} on TimeoutException {
terminatorProcess.kill();
try {
Expand All @@ -590,7 +610,7 @@ final class FlutterToolMachineDiscovery {
onTimeout: () => -1,
);
}
return false;
return -1;
}
}

Expand All @@ -599,6 +619,8 @@ final class FlutterToolMachineDiscovery {
required final DateTime processStartedAfter,
required final DateTime processStartedBefore,
required final String expectedFlutterExecutable,
required final int waitForExitMs,
required final int rescanSeconds,
}) =>
r'''
$rootPid = __ROOT_PID__
Expand All @@ -620,7 +642,8 @@ if ($root.Name -ne 'cmd.exe' -or
exit 3
}

$rootHandle = Get-Process -Id $rootPid -ErrorAction Stop
$rootHandle = Get-Process -Id $rootPid -ErrorAction SilentlyContinue
if ($null -eq $rootHandle) { exit 7 }
$handleCreated = $rootHandle.StartTime.ToUniversalTime()
if ([Math]::Abs($handleCreated.Ticks - $rootCreated.Ticks) -gt 10) {
exit 4
Expand Down Expand Up @@ -665,9 +688,9 @@ function Add-Lineage([object[]]$processes, [DateTime]$latestCreation) {

Add-Lineage $all ([DateTime]::UtcNow)
$rootHandle.Kill()
if (-not $rootHandle.WaitForExit(2000)) { exit 5 }
if (-not $rootHandle.WaitForExit(__WAIT_FOR_EXIT_MS__)) { exit 5 }
$rootStoppedBefore = $rootHandle.ExitTime.ToUniversalTime()
$deadline = [DateTime]::UtcNow.AddSeconds(2)
$deadline = [DateTime]::UtcNow.AddSeconds(__RESCAN_SECONDS__)
$quietPasses = 0
while ([DateTime]::UtcNow -lt $deadline) {
$all = @(Get-CimInstance Win32_Process -ErrorAction Stop)
Expand Down Expand Up @@ -711,6 +734,8 @@ while ([DateTime]::UtcNow -lt $deadline) {
exit 6
'''
.replaceAll('__ROOT_PID__', '$rootPid')
.replaceAll('__WAIT_FOR_EXIT_MS__', '$waitForExitMs')
.replaceAll('__RESCAN_SECONDS__', '$rescanSeconds')
.replaceAll(
'__STARTED_AFTER__',
processStartedAfter.toUtc().toIso8601String(),
Expand Down Expand Up @@ -811,35 +836,70 @@ exit 6
required final DateTime processStartedBefore,
}) async {
if (isWindows ?? Platform.isWindows) {
final terminatedTree = await _terminateWindowsProcessTree(
final terminatorExitCode = await _terminateWindowsProcessTree(
process.pid,
processStartedAfter: processStartedAfter,
processStartedBefore: processStartedBefore,
scriptBudget: windowsTreeStopTimeout,
);
if (terminatedTree) {
if (terminatorExitCode == 0) {
await process.exitCode.timeout(stopTimeout, onTimeout: () => -1);
return;
}
if (terminatorExitCode == _windowsDescendantsRemainExitCode) {
// The verified wrapper was killed; stdin cannot reach a dead process.
logger(
LoggingLevel.warning,
'Flutter machine discovery wrapper (pid ${process.pid}) was '
'terminated but descendant Dart processes may remain.',
logger: 'FlutterMachineDiscovery',
);
await process.exitCode.timeout(stopTimeout, onTimeout: () => -1);
return;
}
if (terminatorExitCode == _windowsRootAlreadyExitedExitCode) {
// The wrapper exited on its own before termination; nothing to stop.
logger(
LoggingLevel.debug,
'Flutter machine discovery wrapper (pid ${process.pid}) already '
'exited before tree termination.',
logger: 'FlutterMachineDiscovery',
);
await process.exitCode.timeout(stopTimeout, onTimeout: () => -1);
return;
}
logger(
LoggingLevel.warning,
'Failed to terminate Flutter machine discovery process tree; '
'Failed to terminate Flutter machine discovery process tree '
'(exit code: $terminatorExitCode); '
'falling back to a graceful stdin stop.',
logger: 'FlutterMachineDiscovery',
);
try {
process.stdin.writeln('q');
await process.stdin.flush();
} catch (_) {
} on Exception catch (_) {
// Ignore stdin close/write errors during the safe fallback.
}
await process.exitCode.timeout(stopTimeout, onTimeout: () => -1);
final exitCode = await process.exitCode.timeout(
stopTimeout,
onTimeout: () => -1,
);
if (exitCode == -1) {
logger(
LoggingLevel.warning,
'Flutter machine discovery wrapper (pid ${process.pid}) could not '
'be stopped and remains running.',
logger: 'FlutterMachineDiscovery',
);
}
return;
}

try {
process.stdin.writeln('q');
await process.stdin.flush();
} catch (_) {
} on Exception catch (_) {
// Ignore stdin close/write errors.
}

Expand Down
Loading