Skip to content

Commit 136d8e5

Browse files
authored
Show error on macOS if missing Local Network permissions (#161846)
### Background macOS Sequoia requires the user's permission to do multicast operations, which the Flutter tool does to connect to the Dart VM. If the app does not have permission to communicate with devices on the local network, the following happens: 1. Flutter tool starts a [multicast lookup](https://github.com/flutter/flutter/blob/bb2d34126cc8161dbe4a1bf23c925e48b732f670/packages/flutter_tools/lib/src/mdns_discovery.dart#L238-L241) 2. The mDNS client [sends data on the socket](https://github.com/flutter/packages/blob/973e8b59e24ba80d3c36a2bcfa914fcfd5e19943/packages/multicast_dns/lib/multicast_dns.dart#L219) 4. macOS blocks the operation. Dart's native socket implementation throws an `OSError` 5. Dart's `Socket.send` [catches the `OSError`](https://github.com/dart-lang/sdk/blob/da6dc03a15822d83d9180bd766c02d11aacdc06b/sdk/lib/_internal/vm/bin/socket_patch.dart#L1511-L1515), wraps it in a `SocketException`, and [schedules a microtask](https://github.com/dart-lang/sdk/blob/da6dc03a15822d83d9180bd766c02d11aacdc06b/sdk/lib/_internal/vm/bin/socket_patch.dart#L1513) that [reports the exception through the socket's stream](https://github.com/dart-lang/sdk/blob/95f00522676dff03f64fc715cb1835ad451faa4c/sdk/lib/_internal/vm/bin/socket_patch.dart#L3011) ([`Socket` is a `Stream`](https://api.dart.dev/dart-io/Socket-class.html)) 6. The mDNS client [does not listen to the socket stream's errors](https://github.com/flutter/packages/blob/973e8b59e24ba80d3c36a2bcfa914fcfd5e19943/packages/multicast_dns/lib/multicast_dns.dart#L155), so [the error is sent to the current `Zone`'s uncaught error handler](https://github.com/dart-lang/sdk/blob/95f00522676dff03f64fc715cb1835ad451faa4c/sdk/lib/async/stream_impl.dart#L553). ### Reproduction To reproduce this error on macOS... 1. Open System Settings > Privacy & Security > Local Network and toggle off Visual Studio Code 2. Run a Flutter app using a physical device ### Fix Ideally, we'd make `MDnsClient.lookup` throw an exception for this scenario. Unfortunately, the `MDnsClient` can have multiple lookup operations in parallel, and the `SocketException` doesn't give us enough information to match it back to a pending `MDnsClient` request. See flutter#8450 as an attempt to solve this in the `MDnsClient` layer. Instead, this fix introduces a `Zone` in the tool to catch the socket's uncaught exception. Follow-up to flutter/flutter#157638 See: flutter/flutter#150131 ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
1 parent 3baa356 commit 136d8e5

File tree

2 files changed

+145
-38
lines changed

2 files changed

+145
-38
lines changed

packages/flutter_tools/lib/src/mdns_discovery.dart

Lines changed: 78 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ class MDnsVmServiceDiscovery {
9696
final List<MDnsVmServiceDiscoveryResult> results = await _pollingVmService(
9797
_preliminaryClient ?? MDnsClient(),
9898
applicationId: applicationId,
99-
deviceVmservicePort: deviceVmservicePort,
99+
deviceVmServicePort: deviceVmservicePort,
100100
ipv6: ipv6,
101101
useDeviceIPAsHost: useDeviceIPAsHost,
102102
timeout: const Duration(seconds: 5),
@@ -192,7 +192,7 @@ class MDnsVmServiceDiscovery {
192192
final List<MDnsVmServiceDiscoveryResult> results = await _pollingVmService(
193193
client,
194194
applicationId: applicationId,
195-
deviceVmservicePort: deviceVmservicePort,
195+
deviceVmServicePort: deviceVmservicePort,
196196
deviceName: deviceName,
197197
ipv6: ipv6,
198198
useDeviceIPAsHost: useDeviceIPAsHost,
@@ -208,7 +208,74 @@ class MDnsVmServiceDiscovery {
208208
Future<List<MDnsVmServiceDiscoveryResult>> _pollingVmService(
209209
MDnsClient client, {
210210
String? applicationId,
211-
int? deviceVmservicePort,
211+
int? deviceVmServicePort,
212+
String? deviceName,
213+
bool ipv6 = false,
214+
bool useDeviceIPAsHost = false,
215+
required Duration timeout,
216+
bool quitOnFind = false,
217+
}) async {
218+
// macOS blocks mDNS unless the app has Local Network permissions.
219+
// Since the mDNS client does not handle errors from the socket's stream,
220+
// socket exceptions are routed to the current zone. Create an error zone to
221+
// catch the socket exception.
222+
// See: https://github.com/flutter/flutter/issues/150131
223+
final Completer<List<MDnsVmServiceDiscoveryResult>> completer =
224+
Completer<List<MDnsVmServiceDiscoveryResult>>();
225+
unawaited(
226+
runZonedGuarded(
227+
() async {
228+
final List<MDnsVmServiceDiscoveryResult> results = await _doPollingVmService(
229+
client,
230+
applicationId: applicationId,
231+
deviceVmServicePort: deviceVmServicePort,
232+
deviceName: deviceName,
233+
ipv6: ipv6,
234+
useDeviceIPAsHost: useDeviceIPAsHost,
235+
timeout: timeout,
236+
quitOnFind: quitOnFind,
237+
);
238+
239+
if (!completer.isCompleted) {
240+
completer.complete(results);
241+
}
242+
},
243+
(Object error, StackTrace stackTrace) {
244+
if (!completer.isCompleted) {
245+
completer.completeError(error, stackTrace);
246+
}
247+
},
248+
),
249+
);
250+
251+
try {
252+
return await completer.future;
253+
} on SocketException catch (e, stackTrace) {
254+
if (!globals.platform.isMacOS) {
255+
rethrow;
256+
}
257+
258+
_logger.printTrace(stackTrace.toString());
259+
260+
throwToolExit(
261+
'Flutter could not connect to the Dart VM service.\n'
262+
'\n'
263+
'Please ensure your IDE or terminal app has permission to access '
264+
'devices on the local network. This allows Flutter to connect to '
265+
'the Dart VM.\n'
266+
'\n'
267+
'You can grant this permission in System Settings > Privacy & '
268+
'Security > Local Network.\n'
269+
'\n'
270+
'$e',
271+
);
272+
}
273+
}
274+
275+
Future<List<MDnsVmServiceDiscoveryResult>> _doPollingVmService(
276+
MDnsClient client, {
277+
String? applicationId,
278+
int? deviceVmServicePort,
212279
String? deviceName,
213280
bool ipv6 = false,
214281
bool useDeviceIPAsHost = false,
@@ -232,27 +299,10 @@ class MDnsVmServiceDiscovery {
232299
final Set<String> uniqueDomainNamesInResults = <String>{};
233300

234301
// Listen for mDNS connections until timeout.
235-
final Stream<PtrResourceRecord> ptrResourceStream;
236-
237-
try {
238-
ptrResourceStream = client.lookup<PtrResourceRecord>(
239-
ResourceRecordQuery.serverPointer(dartVmServiceName),
240-
timeout: timeout,
241-
);
242-
} on SocketException catch (e, stacktrace) {
243-
_logger.printError(e.message);
244-
_logger.printTrace(stacktrace.toString());
245-
if (globals.platform.isMacOS) {
246-
throwToolExit(
247-
'You might be having a permissions issue with your IDE. '
248-
'Please try going to '
249-
'System Settings -> Privacy & Security -> Local Network -> '
250-
'[Find your IDE] -> Toggle ON, then restart your phone.',
251-
);
252-
} else {
253-
rethrow;
254-
}
255-
}
302+
final Stream<PtrResourceRecord> ptrResourceStream = client.lookup<PtrResourceRecord>(
303+
ResourceRecordQuery.serverPointer(dartVmServiceName),
304+
timeout: timeout,
305+
);
256306

257307
await for (final PtrResourceRecord ptr in ptrResourceStream) {
258308
uniqueDomainNames.add(ptr.domainName);
@@ -292,8 +342,8 @@ class MDnsVmServiceDiscovery {
292342
);
293343
}
294344

295-
// If deviceVmservicePort is set, only use records that match it
296-
if (deviceVmservicePort != null && srvRecord.port != deviceVmservicePort) {
345+
// If deviceVmServicePort is set, only use records that match it
346+
if (deviceVmServicePort != null && srvRecord.port != deviceVmServicePort) {
297347
continue;
298348
}
299349

@@ -355,8 +405,8 @@ class MDnsVmServiceDiscovery {
355405
// the applicationId were found but other results were found, throw an error.
356406
if (applicationId != null && quitOnFind && results.isEmpty && uniqueDomainNames.isNotEmpty) {
357407
String message = 'Did not find a Dart VM Service advertised for $applicationId';
358-
if (deviceVmservicePort != null) {
359-
message += ' on port $deviceVmservicePort';
408+
if (deviceVmServicePort != null) {
409+
message += ' on port $deviceVmServicePort';
360410
}
361411
throwToolExit('$message.');
362412
}

packages/flutter_tools/test/general.shard/mdns_discovery_test.dart

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5+
import 'dart:async';
6+
57
import 'package:file/memory.dart';
68
import 'package:flutter_tools/src/base/io.dart';
79
import 'package:flutter_tools/src/base/logger.dart';
@@ -610,13 +612,16 @@ void main() {
610612
);
611613
});
612614

615+
// On macOS, the mDNS client's socket stream creates a SocketException if
616+
// the app running the tool does not have Local Network permissions.
617+
// See: https://github.com/flutter/flutter/issues/150131
613618
test(
614-
'On macOS, throw tool exit with a helpful message when client throws a SocketException on lookup',
619+
'On macOS, tool exits with a helpful message when mDNS lookup throws a SocketException',
615620
() async {
616621
final MDnsClient client = FakeMDnsClient(
617622
<PtrResourceRecord>[],
618623
<String, List<SrvResourceRecord>>{},
619-
socketExceptionOnStart: true,
624+
socketExceptionOnLookup: true,
620625
);
621626

622627
final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery(
@@ -627,13 +632,55 @@ void main() {
627632
);
628633

629634
expect(
630-
portDiscovery.firstMatchingVmService(client),
635+
() async => portDiscovery.firstMatchingVmService(client),
631636
throwsToolExit(
632637
message:
633-
'You might be having a permissions issue with your IDE. '
634-
'Please try going to '
635-
'System Settings -> Privacy & Security -> Local Network -> '
636-
'[Find your IDE] -> Toggle ON, then restart your phone.',
638+
'Flutter could not connect to the Dart VM service.\n'
639+
'\n'
640+
'Please ensure your IDE or terminal app has permission to access '
641+
'devices on the local network. This allows Flutter to connect to '
642+
'the Dart VM.\n'
643+
'\n'
644+
'You can grant this permission in System Settings > Privacy & '
645+
'Security > Local Network.\n',
646+
),
647+
);
648+
},
649+
// [intended] This tool exit message only works for macOS
650+
skip: !globals.platform.isMacOS,
651+
);
652+
653+
// On macOS, the mDNS client's socket stream creates a SocketException if
654+
// the app running the tool does not have Local Network permissions.
655+
// See: https://github.com/flutter/flutter/issues/150131
656+
test(
657+
'On macOS, tool exits with a helpful message when mDNS lookup throws an uncaught SocketException',
658+
() async {
659+
final MDnsClient client = FakeMDnsClient(
660+
<PtrResourceRecord>[],
661+
<String, List<SrvResourceRecord>>{},
662+
uncaughtSocketExceptionOnLookup: true,
663+
);
664+
665+
final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery(
666+
mdnsClient: client,
667+
logger: BufferLogger.test(),
668+
flutterUsage: TestUsage(),
669+
analytics: const NoOpAnalytics(),
670+
);
671+
672+
expect(
673+
() async => portDiscovery.firstMatchingVmService(client),
674+
throwsToolExit(
675+
message:
676+
'Flutter could not connect to the Dart VM service.\n'
677+
'\n'
678+
'Please ensure your IDE or terminal app has permission to access '
679+
'devices on the local network. This allows Flutter to connect to '
680+
'the Dart VM.\n'
681+
'\n'
682+
'You can grant this permission in System Settings > Privacy & '
683+
'Security > Local Network.\n',
637684
),
638685
);
639686
},
@@ -1155,15 +1202,17 @@ class FakeMDnsClient extends Fake implements MDnsClient {
11551202
this.txtResponse = const <String, List<TxtResourceRecord>>{},
11561203
this.ipResponse = const <String, List<IPAddressResourceRecord>>{},
11571204
this.osErrorOnStart = false,
1158-
this.socketExceptionOnStart = false,
1205+
this.socketExceptionOnLookup = false,
1206+
this.uncaughtSocketExceptionOnLookup = false,
11591207
});
11601208

11611209
final List<PtrResourceRecord> ptrRecords;
11621210
final Map<String, List<SrvResourceRecord>> srvResponse;
11631211
final Map<String, List<TxtResourceRecord>> txtResponse;
11641212
final Map<String, List<IPAddressResourceRecord>> ipResponse;
11651213
final bool osErrorOnStart;
1166-
final bool socketExceptionOnStart;
1214+
final bool socketExceptionOnLookup;
1215+
final bool uncaughtSocketExceptionOnLookup;
11671216

11681217
@override
11691218
Future<void> start({
@@ -1182,9 +1231,17 @@ class FakeMDnsClient extends Fake implements MDnsClient {
11821231
ResourceRecordQuery query, {
11831232
Duration timeout = const Duration(seconds: 5),
11841233
}) {
1185-
if (socketExceptionOnStart) {
1234+
if (socketExceptionOnLookup) {
11861235
throw const SocketException('Socket Exception');
11871236
}
1237+
1238+
if (uncaughtSocketExceptionOnLookup) {
1239+
Zone.current.handleUncaughtError(
1240+
const SocketException('Socket Exception'),
1241+
StackTrace.current,
1242+
);
1243+
}
1244+
11881245
if (T == PtrResourceRecord &&
11891246
query.fullyQualifiedName == MDnsVmServiceDiscovery.dartVmServiceName) {
11901247
return Stream<PtrResourceRecord>.fromIterable(ptrRecords) as Stream<T>;

0 commit comments

Comments
 (0)