react-native-zeroconf 0.17.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src=".github/banner.png" alt="react-native-zeroconf: Bonjour / mDNS discovery and publishing for React Native, on iOS, Android, macOS, tvOS and Expo" width="100%">
2
+ <a href="https://zeroconf.balthazar.dev"><img src=".github/banner.png" alt="react-native-zeroconf: Bonjour / mDNS discovery and publishing for React Native, on iOS, Android, Windows, macOS, tvOS and Expo" width="100%"></a>
3
3
  </p>
4
4
 
5
5
  Find the services other devices advertise on the local network (printers, speakers, cameras, your own servers) and advertise your own, on iOS, Android, macOS, tvOS and Windows.
@@ -13,7 +13,7 @@ yarn add react-native-zeroconf
13
13
  cd ios && pod install
14
14
  ```
15
15
 
16
- On iOS, declare the service types you use in `NSBonjourServices`, see [Permissions and Setup](https://zeroconf.balthazar.dev/Permissions-and-Setup). Expo works with development builds, see [Installation](https://zeroconf.balthazar.dev/Installation).
16
+ On iOS, declare the service types you use in `NSBonjourServices`, see [Permissions and Setup](https://zeroconf.balthazar.dev/Permissions-and-Setup). Expo works with development builds, see [Installation with Expo](https://zeroconf.balthazar.dev/Installation-Expo).
17
17
 
18
18
  ## Usage
19
19
 
@@ -21,6 +21,8 @@ public class ZeroconfOptions {
21
21
  @Nullable public String networkInterface;
22
22
  /** resolveService: seconds before giving up */
23
23
  public double timeoutSeconds = 5;
24
+ /** Scan: seconds to resolve each service, retried once before a TIMEOUT error */
25
+ public double resolveTimeoutSeconds = 5;
24
26
 
25
27
  public static ZeroconfOptions from(@Nullable ReadableMap map) {
26
28
  ZeroconfOptions options = new ZeroconfOptions();
@@ -42,6 +44,9 @@ public class ZeroconfOptions {
42
44
  if (map.hasKey("timeout") && map.getType("timeout") == ReadableType.Number && map.getDouble("timeout") > 0) {
43
45
  options.timeoutSeconds = map.getDouble("timeout");
44
46
  }
47
+ if (map.hasKey("resolveTimeout") && map.getType("resolveTimeout") == ReadableType.Number && map.getDouble("resolveTimeout") > 0) {
48
+ options.resolveTimeoutSeconds = map.getDouble("resolveTimeout");
49
+ }
45
50
  return options;
46
51
  }
47
52
 
@@ -66,6 +66,7 @@ public class DnssdImpl implements Zeroconf {
66
66
  final String scanId;
67
67
  final boolean typesOnly;
68
68
  int ifIndex = DNSSD.ALL_INTERFACES;
69
+ long resolveTimeoutMs = 5000;
69
70
  DNSSDService browse;
70
71
  // A service is reported once per network interface, count them so found/remove are emitted once
71
72
  final Map<String, Integer> foundInterfaces = new HashMap<>();
@@ -87,6 +88,9 @@ public class DnssdImpl implements Zeroconf {
87
88
  int port;
88
89
  Map<String, String> txt = new LinkedHashMap<>();
89
90
  final Set<String> addresses = new LinkedHashSet<>();
91
+ // Scans: emitted once an address is known, retried once after a timeout
92
+ boolean emitted;
93
+ boolean retried;
90
94
 
91
95
  Resolve(String name) {
92
96
  this.name = name;
@@ -144,6 +148,7 @@ public class DnssdImpl implements Zeroconf {
144
148
  return;
145
149
  }
146
150
  scan.ifIndex = ifIndex;
151
+ scan.resolveTimeoutMs = (long) (options.resolveTimeoutSeconds * 1000);
147
152
  // "_ipp._tcp,_printer" browses the _printer subtype
148
153
  String regType = String.format("_%s._%s", type, protocol);
149
154
  if (options.subtype != null) {
@@ -254,10 +259,25 @@ public class DnssdImpl implements Zeroconf {
254
259
 
255
260
  // Resolve: host, port and TXT record, then the host's IPv4 and IPv6 addresses
256
261
 
257
- private void startResolve(@Nullable final Scan scan, int ifIndex, final String serviceName, String regType, String domain, @Nullable Resolve existing) {
262
+ private void startResolve(@Nullable final Scan scan, final int ifIndex, final String serviceName, final String regType, final String domain, @Nullable Resolve existing) {
258
263
  final Resolve resolve = existing != null ? existing : new Resolve(serviceName);
259
264
  if (scan != null) {
260
265
  scan.resolves.put(serviceName, resolve);
266
+ // DNSSD gives up silently after its own timeout, report it before
267
+ mainHandler.postDelayed(() -> executor.execute(() -> {
268
+ if (!isCurrent(scan, resolve) || resolve.emitted) {
269
+ return;
270
+ }
271
+ resolve.stop();
272
+ // Slow devices can time out, retry once before reporting the error
273
+ if (!resolve.retried) {
274
+ resolve.retried = true;
275
+ startResolve(scan, ifIndex, serviceName, regType, domain, resolve);
276
+ return;
277
+ }
278
+ scan.resolves.remove(serviceName);
279
+ zeroconfModule.sendError(ZeroconfModule.ERROR_DOMAIN_LIBRARY, ZeroconfModule.ERROR_CODE_TIMEOUT, "Resolving service " + serviceName + " failed: timed out", serviceName, scan.scanId);
280
+ }), scan.resolveTimeoutMs);
261
281
  }
262
282
  try {
263
283
  resolve.operations.add(dnssd.resolve(0, ifIndex, serviceName, regType, domain, new ResolveListener() {
@@ -306,6 +326,7 @@ public class DnssdImpl implements Zeroconf {
306
326
  return;
307
327
  }
308
328
  // Emitted for each address, the first one is usable right away
329
+ resolve.emitted = true;
309
330
  sendScanEvent(ZeroconfModule.EVENT_RESOLVE, resolveToMap(resolve), scan.scanId);
310
331
  });
311
332
  }
@@ -63,6 +63,7 @@ public class NsdServiceImpl implements Zeroconf {
63
63
 
64
64
  private static class NsdScan {
65
65
  final String scanId;
66
+ double resolveTimeoutSeconds = 5;
66
67
  NsdManager.DiscoveryListener discoveryListener;
67
68
  // Android 14+: services are followed with registerServiceInfoCallback, null before
68
69
  @Nullable ServiceInfoCallbacks infoCallbacks;
@@ -83,11 +84,14 @@ public class NsdServiceImpl implements Zeroconf {
83
84
  final NsdServiceInfo serviceInfo;
84
85
  // resolveService(): settled instead of emitting events
85
86
  @Nullable final Promise promise;
87
+ // Seconds before the TIMEOUT error, counted from the start of the resolve
88
+ final double timeoutSeconds;
86
89
 
87
- PendingResolve(@Nullable String scanId, NsdServiceInfo serviceInfo, @Nullable Promise promise) {
90
+ PendingResolve(@Nullable String scanId, NsdServiceInfo serviceInfo, @Nullable Promise promise, double timeoutSeconds) {
88
91
  this.scanId = scanId;
89
92
  this.serviceInfo = serviceInfo;
90
93
  this.promise = promise;
94
+ this.timeoutSeconds = timeoutSeconds;
91
95
  }
92
96
  }
93
97
 
@@ -104,6 +108,7 @@ public class NsdServiceImpl implements Zeroconf {
104
108
  this.stop(scanId);
105
109
 
106
110
  final NsdScan scan = new NsdScan(scanId);
111
+ scan.resolveTimeoutSeconds = options.resolveTimeoutSeconds;
107
112
  final boolean typesOnly = ZeroconfModule.isServiceTypesScan(type, protocol);
108
113
 
109
114
  Network network = null;
@@ -154,7 +159,8 @@ public class NsdServiceImpl implements Zeroconf {
154
159
  }
155
160
 
156
161
  if (Build.VERSION.SDK_INT >= 34) {
157
- scan.infoCallbacks = new ServiceInfoCallbacks(nsdManager, mCallbackExecutor, new ServiceInfoCallbacks.Listener() {
162
+ long timeoutMs = (long) (options.resolveTimeoutSeconds * 1000);
163
+ scan.infoCallbacks = new ServiceInfoCallbacks(nsdManager, mCallbackExecutor, mHandler, timeoutMs, new ServiceInfoCallbacks.Listener() {
158
164
  @Override
159
165
  public void onServiceUpdated(NsdServiceInfo serviceInfo) {
160
166
  if (getHostAddresses(serviceInfo).isEmpty()) {
@@ -167,6 +173,11 @@ public class NsdServiceImpl implements Zeroconf {
167
173
  public void onRegistrationFailed(NsdServiceInfo serviceInfo, int errorCode) {
168
174
  zeroconfModule.sendError(ZeroconfModule.ERROR_DOMAIN_NSD, errorCode, "Resolving service " + serviceInfo.getServiceName() + " failed: " + ZeroconfModule.describeNsdError(errorCode), serviceInfo.getServiceName(), scanId);
169
175
  }
176
+
177
+ @Override
178
+ public void onTimeout(NsdServiceInfo serviceInfo) {
179
+ sendResolveTimeout(serviceInfo.getServiceName(), scanId);
180
+ }
170
181
  });
171
182
  }
172
183
 
@@ -204,7 +215,7 @@ public class NsdServiceImpl implements Zeroconf {
204
215
  if (scan.infoCallbacks != null) {
205
216
  scan.infoCallbacks.register(serviceInfo);
206
217
  } else {
207
- enqueueResolve(scanId, serviceInfo);
218
+ enqueueResolve(scan, serviceInfo);
208
219
  }
209
220
  }
210
221
 
@@ -278,7 +289,8 @@ public class NsdServiceImpl implements Zeroconf {
278
289
  synchronized (mResolveQueue) {
279
290
  Iterator<PendingResolve> iterator = mResolveQueue.iterator();
280
291
  while (iterator.hasNext()) {
281
- if (iterator.next().scanId.equals(scanId)) {
292
+ // resolveService() entries have no scan id
293
+ if (scanId.equals(iterator.next().scanId)) {
282
294
  iterator.remove();
283
295
  }
284
296
  }
@@ -384,7 +396,7 @@ public class NsdServiceImpl implements Zeroconf {
384
396
  if (Build.VERSION.SDK_INT < 34) {
385
397
  // One resolve at a time before Android 14, shares the queue of the scans
386
398
  synchronized (mResolveQueue) {
387
- mResolveQueue.add(new PendingResolve(null, serviceInfo, promise));
399
+ mResolveQueue.add(new PendingResolve(null, serviceInfo, promise, options.timeoutSeconds));
388
400
  }
389
401
  resolveNext();
390
402
  return;
@@ -524,13 +536,19 @@ public class NsdServiceImpl implements Zeroconf {
524
536
  return reactApplicationContext;
525
537
  }
526
538
 
527
- private void enqueueResolve(String scanId, NsdServiceInfo serviceInfo) {
539
+ private void enqueueResolve(NsdScan scan, NsdServiceInfo serviceInfo) {
540
+ // A resolve can't be cancelled before Android 14, so there is no retry: waiting twice as long
541
+ // matches the total time of the other implementations, which retry once
528
542
  synchronized (mResolveQueue) {
529
- mResolveQueue.add(new PendingResolve(scanId, serviceInfo, null));
543
+ mResolveQueue.add(new PendingResolve(scan.scanId, serviceInfo, null, scan.resolveTimeoutSeconds * 2));
530
544
  }
531
545
  resolveNext();
532
546
  }
533
547
 
548
+ private void sendResolveTimeout(String serviceName, String scanId) {
549
+ zeroconfModule.sendError(ZeroconfModule.ERROR_DOMAIN_LIBRARY, ZeroconfModule.ERROR_CODE_TIMEOUT, "Resolving service " + serviceName + " failed: timed out", serviceName, scanId);
550
+ }
551
+
534
552
  private void resolveNext() {
535
553
  PendingResolve next;
536
554
  synchronized (mResolveQueue) {
@@ -540,8 +558,11 @@ public class NsdServiceImpl implements Zeroconf {
540
558
  next = mResolveQueue.poll();
541
559
  mIsResolving = true;
542
560
  }
561
+ final ZeroResolveListener listener = new ZeroResolveListener(next);
543
562
  try {
544
- getNsdManager().resolveService(next.serviceInfo, new ZeroResolveListener(next.scanId, next.promise));
563
+ getNsdManager().resolveService(next.serviceInfo, listener);
564
+ // The system keeps the resolve until it answers or fails, the queue waits for it
565
+ mHandler.postDelayed(listener::timedOut, (long) (next.timeoutSeconds * 1000));
545
566
  } catch (Throwable e) {
546
567
  Log.e(TAG, "resolveService failed", e);
547
568
  String serviceName = next.serviceInfo.getServiceName();
@@ -558,25 +579,57 @@ public class NsdServiceImpl implements Zeroconf {
558
579
  }
559
580
 
560
581
  private class ZeroResolveListener implements NsdManager.ResolveListener {
582
+ private final PendingResolve pending;
561
583
  @Nullable private final String scanId;
562
584
  @Nullable private final Promise promise;
585
+ // Settled: answered, failed or timed out. A late answer after the timeout only frees the queue
586
+ private boolean settled = false;
563
587
 
564
- ZeroResolveListener(@Nullable String scanId, @Nullable Promise promise) {
565
- this.scanId = scanId;
566
- this.promise = promise;
588
+ ZeroResolveListener(PendingResolve pending) {
589
+ this.pending = pending;
590
+ this.scanId = pending.scanId;
591
+ this.promise = pending.promise;
592
+ }
593
+
594
+ private synchronized boolean settle() {
595
+ if (settled) {
596
+ return false;
597
+ }
598
+ settled = true;
599
+ return true;
600
+ }
601
+
602
+ void timedOut() {
603
+ if (!settle()) {
604
+ return;
605
+ }
606
+ String serviceName = pending.serviceInfo.getServiceName();
607
+ if (promise != null) {
608
+ ZeroconfModule.reject(promise, ZeroconfModule.ERROR_DOMAIN_LIBRARY, ZeroconfModule.ERROR_CODE_TIMEOUT, "Resolving service " + serviceName + " failed: timed out", serviceName);
609
+ } else if (mScans.containsKey(scanId)) {
610
+ sendResolveTimeout(serviceName, scanId);
611
+ }
567
612
  }
568
613
 
569
614
  @Override
570
615
  public void onResolveFailed(NsdServiceInfo serviceInfo, int errorCode) {
571
616
  if (errorCode == NsdManager.FAILURE_ALREADY_ACTIVE) {
572
617
  // Another resolve (possibly from another app or listener) is in flight: retry shortly
618
+ if (!settle()) {
619
+ onResolveDone();
620
+ return;
621
+ }
573
622
  synchronized (mResolveQueue) {
574
- mResolveQueue.addFirst(new PendingResolve(scanId, serviceInfo, promise));
623
+ mResolveQueue.addFirst(pending);
575
624
  mIsResolving = false;
576
625
  }
577
626
  mHandler.postDelayed(NsdServiceImpl.this::resolveNext, RESOLVE_RETRY_DELAY_MS);
578
627
  return;
579
628
  }
629
+ if (!settle()) {
630
+ onResolveDone();
631
+ return;
632
+ }
580
633
  String message = "Resolving service " + serviceInfo.getServiceName() + " failed: " + ZeroconfModule.describeNsdError(errorCode);
581
634
  if (promise != null) {
582
635
  ZeroconfModule.reject(promise, ZeroconfModule.ERROR_DOMAIN_NSD, errorCode, message, serviceInfo.getServiceName());
@@ -588,6 +641,10 @@ public class NsdServiceImpl implements Zeroconf {
588
641
 
589
642
  @Override
590
643
  public void onServiceResolved(NsdServiceInfo serviceInfo) {
644
+ if (!settle()) {
645
+ onResolveDone();
646
+ return;
647
+ }
591
648
  if (promise != null) {
592
649
  promise.resolve(serviceInfoToMap(serviceInfo));
593
650
  } else if (mScans.containsKey(scanId)) {
@@ -3,6 +3,7 @@ package com.balthazargronon.RCTZeroconf.nsd;
3
3
  import android.annotation.TargetApi;
4
4
  import android.net.nsd.NsdManager;
5
5
  import android.net.nsd.NsdServiceInfo;
6
+ import android.os.Handler;
6
7
  import android.util.Log;
7
8
 
8
9
  import java.util.ArrayList;
@@ -13,6 +14,7 @@ import java.util.concurrent.Executor;
13
14
  /**
14
15
  * Follows found services with NsdManager.registerServiceInfoCallback (Android 14+) instead of one-shot resolves:
15
16
  * services can be followed concurrently, updates (addresses, TXT records) are delivered as they change.
17
+ * A service without addresses after the resolve timeout is followed again once, then reported with onTimeout.
16
18
  * Only loaded on API 34+, so older devices never touch these APIs.
17
19
  */
18
20
  @TargetApi(34)
@@ -23,34 +25,55 @@ class ServiceInfoCallbacks {
23
25
  void onServiceUpdated(NsdServiceInfo serviceInfo);
24
26
 
25
27
  void onRegistrationFailed(NsdServiceInfo serviceInfo, int errorCode);
28
+
29
+ void onTimeout(NsdServiceInfo serviceInfo);
30
+ }
31
+
32
+ private static class Followed {
33
+ NsdManager.ServiceInfoCallback callback;
34
+ Runnable timeout;
35
+ volatile boolean resolved;
26
36
  }
27
37
 
28
38
  private final NsdManager nsdManager;
29
39
  private final Executor executor;
40
+ private final Handler handler;
41
+ private final long timeoutMs;
30
42
  private final Listener listener;
31
- private final Map<String, NsdManager.ServiceInfoCallback> callbacks = new ConcurrentHashMap<>();
43
+ private final Map<String, Followed> followed = new ConcurrentHashMap<>();
32
44
 
33
- ServiceInfoCallbacks(NsdManager nsdManager, Executor executor, Listener listener) {
45
+ ServiceInfoCallbacks(NsdManager nsdManager, Executor executor, Handler handler, long timeoutMs, Listener listener) {
34
46
  this.nsdManager = nsdManager;
35
47
  this.executor = executor;
48
+ this.handler = handler;
49
+ this.timeoutMs = timeoutMs;
36
50
  this.listener = listener;
37
51
  }
38
52
 
39
53
  void register(final NsdServiceInfo serviceInfo) {
40
- final String name = serviceInfo.getServiceName();
41
- if (callbacks.containsKey(name)) {
54
+ if (followed.containsKey(serviceInfo.getServiceName())) {
42
55
  return;
43
56
  }
57
+ follow(serviceInfo, false);
58
+ }
44
59
 
45
- NsdManager.ServiceInfoCallback callback = new NsdManager.ServiceInfoCallback() {
60
+ private void follow(final NsdServiceInfo serviceInfo, final boolean retried) {
61
+ final String name = serviceInfo.getServiceName();
62
+ final Followed entry = new Followed();
63
+ entry.callback = new NsdManager.ServiceInfoCallback() {
46
64
  @Override
47
65
  public void onServiceInfoCallbackRegistrationFailed(int errorCode) {
48
- callbacks.remove(name);
49
- listener.onRegistrationFailed(serviceInfo, errorCode);
66
+ if (followed.remove(name, entry)) {
67
+ handler.removeCallbacks(entry.timeout);
68
+ listener.onRegistrationFailed(serviceInfo, errorCode);
69
+ }
50
70
  }
51
71
 
52
72
  @Override
53
73
  public void onServiceUpdated(NsdServiceInfo updated) {
74
+ if (!updated.getHostAddresses().isEmpty()) {
75
+ entry.resolved = true;
76
+ }
54
77
  listener.onServiceUpdated(updated);
55
78
  }
56
79
 
@@ -63,18 +86,32 @@ class ServiceInfoCallbacks {
63
86
  public void onServiceInfoCallbackUnregistered() {
64
87
  }
65
88
  };
66
- callbacks.put(name, callback);
89
+ entry.timeout = () -> {
90
+ if (followed.get(name) != entry || entry.resolved) {
91
+ return;
92
+ }
93
+ unregister(name);
94
+ // Slow devices can time out, follow again once before reporting the error
95
+ if (!retried) {
96
+ follow(serviceInfo, true);
97
+ } else {
98
+ listener.onTimeout(serviceInfo);
99
+ }
100
+ };
101
+ followed.put(name, entry);
67
102
  Log.d(TAG, "Following " + name + " with registerServiceInfoCallback");
68
- nsdManager.registerServiceInfoCallback(serviceInfo, executor, callback);
103
+ nsdManager.registerServiceInfoCallback(serviceInfo, executor, entry.callback);
104
+ handler.postDelayed(entry.timeout, timeoutMs);
69
105
  }
70
106
 
71
107
  void unregister(String name) {
72
- NsdManager.ServiceInfoCallback callback = callbacks.remove(name);
73
- if (callback == null) {
108
+ Followed entry = followed.remove(name);
109
+ if (entry == null) {
74
110
  return;
75
111
  }
112
+ handler.removeCallbacks(entry.timeout);
76
113
  try {
77
- nsdManager.unregisterServiceInfoCallback(callback);
114
+ nsdManager.unregisterServiceInfoCallback(entry.callback);
78
115
  } catch (IllegalArgumentException e) {
79
116
  // Registration failed or was already unregistered
80
117
  Log.w(TAG, "unregisterServiceInfoCallback failed", e);
@@ -82,7 +119,7 @@ class ServiceInfoCallbacks {
82
119
  }
83
120
 
84
121
  void unregisterAll() {
85
- for (String name : new ArrayList<>(callbacks.keySet())) {
122
+ for (String name : new ArrayList<>(followed.keySet())) {
86
123
  unregister(name);
87
124
  }
88
125
  }
package/dist/index.js CHANGED
@@ -330,6 +330,7 @@ class Zeroconf extends _events.EventEmitter {
330
330
  }
331
331
  this._scanImplType = implType;
332
332
  RNZeroconf.scan(this._scanId, type, protocol, domain, implType, nativeOptions({
333
+ resolveTimeout,
333
334
  subtype,
334
335
  networkInterface
335
336
  }));
package/index.d.ts CHANGED
@@ -36,7 +36,10 @@ export interface ScanOptions {
36
36
  domain?: string
37
37
  /** Android only, defaults to `NSD` */
38
38
  implType?: ImplType
39
- /** iOS only, seconds to try resolving a service before retrying once and giving up. Defaults to `5` */
39
+ /**
40
+ * Seconds to try resolving each service before retrying once, then emitting an `error` with code `'TIMEOUT'`.
41
+ * Defaults to `5`. Android `NSD` before Android 14 can't retry a resolve, the error comes after twice this time
42
+ */
40
43
  resolveTimeout?: number
41
44
  /** Only find services registered with this subtype, e.g. `'printer'` */
42
45
  subtype?: string
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-zeroconf",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "A Zeroconf discovery utility for react-native",
5
5
  "main": "dist",
6
6
  "types": "index.d.ts",
package/src/index.js CHANGED
@@ -329,7 +329,7 @@ export default class Zeroconf extends EventEmitter {
329
329
  protocol,
330
330
  domain,
331
331
  implType,
332
- nativeOptions({ subtype, networkInterface }),
332
+ nativeOptions({ resolveTimeout, subtype, networkInterface }),
333
333
  )
334
334
  } else {
335
335
  RNZeroconf.scan(
@@ -140,7 +140,8 @@ void RNZeroconf::Emit(std::wstring_view eventName, React::JSValueObject body) no
140
140
  }
141
141
 
142
142
  void RNZeroconf::scan(std::string scanId, std::string type, std::string protocol, std::string domain, React::JSValue options) noexcept {
143
- m_zeroconf->Scan(scanId, Wide(type), Wide(protocol), Wide(domain), StringOption(options, "subtype"), StringOption(options, "networkInterface"));
143
+ m_zeroconf->Scan(scanId, Wide(type), Wide(protocol), Wide(domain), StringOption(options, "subtype"), StringOption(options, "networkInterface"),
144
+ NumberOption(options, "resolveTimeout"));
144
145
  }
145
146
 
146
147
  void RNZeroconf::stop(React::JSValue scanId) noexcept {
@@ -38,6 +38,8 @@ struct Resolve : Operation {
38
38
  DNS_SERVICE_RESOLVE_REQUEST request{};
39
39
  DNS_SERVICE_CANCEL cancel{};
40
40
  bool done = false;
41
+ // Scans: retried once after a timeout
42
+ bool retried = false;
41
43
  ServiceCallback promiseResolve;
42
44
  ErrorCallback promiseReject;
43
45
  };
@@ -50,6 +52,7 @@ struct Browse : Operation {
50
52
  std::wstring queryName;
51
53
  bool typesOnly = false;
52
54
  ULONG interfaceIndex = 0;
55
+ DWORD resolveTimeoutMs = 5000;
53
56
  DNS_SERVICE_BROWSE_REQUEST request{};
54
57
  DNS_SERVICE_CANCEL cancel{};
55
58
  // Lower-case PTR target -> name reported to JavaScript
@@ -362,7 +365,8 @@ void Zeroconf::Scan(
362
365
  const std::wstring &protocol,
363
366
  const std::wstring &domain,
364
367
  const std::wstring &subtype,
365
- const std::wstring &networkInterface) {
368
+ const std::wstring &networkInterface,
369
+ double resolveTimeoutSeconds) {
366
370
  After after;
367
371
  {
368
372
  std::lock_guard<std::recursive_mutex> lock(mutex_);
@@ -370,6 +374,7 @@ void Zeroconf::Scan(
370
374
 
371
375
  auto browse = std::make_shared<Browse>(this);
372
376
  browse->scanId = scanId;
377
+ browse->resolveTimeoutMs = static_cast<DWORD>((resolveTimeoutSeconds > 0 ? resolveTimeoutSeconds : 5) * 1000);
373
378
  browse->regType = L"_" + type + L"._" + protocol;
374
379
  browse->domain = DomainName(domain);
375
380
  browse->typesOnly = type == L"services._dns-sd" && protocol == L"udp";
@@ -490,7 +495,7 @@ void Zeroconf::OnBrowse(const std::shared_ptr<Browse> &browse, DWORD status, PDN
490
495
  resolve->queryName = WithoutTrailingDot(record->pName);
491
496
  resolve->interfaceIndex = browse->interfaceIndex;
492
497
  browse->resolves[instanceKey] = resolve;
493
- StartResolve(resolve, after);
498
+ StartScanResolve(browse, resolve, after);
494
499
  }
495
500
  continue;
496
501
  }
@@ -552,7 +557,7 @@ void Zeroconf::OnBrowse(const std::shared_ptr<Browse> &browse, DWORD status, PDN
552
557
  resolve->queryName = target;
553
558
  resolve->interfaceIndex = browse->interfaceIndex;
554
559
  browse->resolves[key] = resolve;
555
- StartResolve(resolve, after);
560
+ StartScanResolve(browse, resolve, after);
556
561
  }
557
562
  }
558
563
  }
@@ -590,6 +595,54 @@ void Zeroconf::StartResolve(const std::shared_ptr<Resolve> &resolve, After &afte
590
595
  });
591
596
  }
592
597
 
598
+ // Resolves of a scan time out after the scan's resolveTimeout, are retried once, then reported with a TIMEOUT error
599
+ void Zeroconf::StartScanResolve(const std::shared_ptr<Browse> &browse, const std::shared_ptr<Resolve> &resolve, After &after) {
600
+ StartResolve(resolve, after);
601
+ if (resolve->done) {
602
+ return;
603
+ }
604
+ std::weak_ptr<Resolve> weak = resolve;
605
+ auto alive = alive_;
606
+ DWORD timeout = browse->resolveTimeoutMs;
607
+ std::thread([this, weak, alive, timeout] {
608
+ Sleep(timeout);
609
+ if (!*alive) return;
610
+ auto expired = weak.lock();
611
+ if (!expired) return;
612
+ After timedOut;
613
+ {
614
+ std::lock_guard<std::recursive_mutex> lock(mutex_);
615
+ auto browse = expired->browse.lock();
616
+ auto current = browse ? browses_.find(browse->scanId) : browses_.end();
617
+ if (expired->done || current == browses_.end() || current->second != browse) return;
618
+ auto entry = browse->resolves.find(expired->key);
619
+ if (entry == browse->resolves.end() || entry->second != expired) return;
620
+ CancelResolve(expired, timedOut);
621
+ browse->resolves.erase(entry);
622
+ if (!expired->retried) {
623
+ // Slow devices can time out, retry once before reporting the error
624
+ auto retry = std::make_shared<Resolve>(this);
625
+ retry->browse = browse;
626
+ retry->scanId = expired->scanId;
627
+ retry->name = expired->name;
628
+ retry->key = expired->key;
629
+ retry->queryName = expired->queryName;
630
+ retry->interfaceIndex = expired->interfaceIndex;
631
+ retry->retried = true;
632
+ browse->resolves[retry->key] = retry;
633
+ StartScanResolve(browse, retry, timedOut);
634
+ } else {
635
+ Error error = LibraryError(L"TIMEOUT", L"Resolving service " + expired->name + L" failed: timed out", expired->name);
636
+ std::string scanId = expired->scanId;
637
+ timedOut.push_back([this, scanId, error] {
638
+ if (events_.error) events_.error(scanId, error);
639
+ });
640
+ }
641
+ }
642
+ Run(timedOut);
643
+ }).detach();
644
+ }
645
+
593
646
  void Zeroconf::CancelResolve(const std::shared_ptr<Resolve> &resolve, After &after) {
594
647
  if (resolve->done) {
595
648
  return;
@@ -89,7 +89,8 @@ class Zeroconf {
89
89
  const std::wstring &protocol,
90
90
  const std::wstring &domain,
91
91
  const std::wstring &subtype,
92
- const std::wstring &networkInterface);
92
+ const std::wstring &networkInterface,
93
+ double resolveTimeoutSeconds = 5);
93
94
  void Stop(const std::string &scanId);
94
95
  void StopAll();
95
96
 
@@ -128,6 +129,7 @@ class Zeroconf {
128
129
 
129
130
  void StopLocked(const std::string &scanId, After &after);
130
131
  void StartResolve(const std::shared_ptr<Resolve> &resolve, After &after);
132
+ void StartScanResolve(const std::shared_ptr<Browse> &browse, const std::shared_ptr<Resolve> &resolve, After &after);
131
133
  void CancelResolve(const std::shared_ptr<Resolve> &resolve, After &after);
132
134
  void PublishAs(
133
135
  const std::wstring &regType,