1 // Copyright 2023 The Pigweed Authors
2 //
3 // Licensed under the Apache License, Version 2.0 (the "License"); you may not
4 // use this file except in compliance with the License. You may obtain a copy of
5 // the License at
6 //
7 // https://www.apache.org/licenses/LICENSE-2.0
8 //
9 // Unless required by applicable law or agreed to in writing, software
10 // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11 // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12 // License for the specific language governing permissions and limitations under
13 // the License.
14
15 #include "pw_bluetooth_sapphire/internal/host/gap/low_energy_discovery_manager.h"
16
17 #include <lib/fit/function.h>
18 #include <pw_assert/check.h>
19
20 #include <algorithm>
21 #include <vector>
22
23 #include "pw_bluetooth_sapphire/internal/host/gap/peer.h"
24 #include "pw_bluetooth_sapphire/internal/host/gap/peer_cache.h"
25 #include "pw_bluetooth_sapphire/internal/host/hci/discovery_filter.h"
26 #include "pw_bluetooth_sapphire/internal/host/hci/low_energy_scanner.h"
27
28 namespace bt::gap {
29
30 constexpr uint16_t kLEActiveScanInterval = 80; // 50ms
31 constexpr uint16_t kLEActiveScanWindow = 24; // 15ms
32 constexpr uint16_t kLEPassiveScanInterval = kLEScanSlowInterval1;
33 constexpr uint16_t kLEPassiveScanWindow = kLEScanSlowWindow1;
34
35 const char* kInspectPausedCountPropertyName = "paused";
36 const char* kInspectStatePropertyName = "state";
37 const char* kInspectFailedCountPropertyName = "failed_count";
38 const char* kInspectScanIntervalPropertyName = "scan_interval_ms";
39 const char* kInspectScanWindowPropertyName = "scan_window_ms";
40
LowEnergyDiscoverySession(uint16_t scan_id,bool active,std::vector<hci::DiscoveryFilter> filters,PeerCache & peer_cache,pw::async::Dispatcher & dispatcher,fit::function<void (LowEnergyDiscoverySession *)> on_stop_cb,fit::function<const std::unordered_set<PeerId> & ()> cached_scan_results_fn)41 LowEnergyDiscoverySession::LowEnergyDiscoverySession(
42 uint16_t scan_id,
43 bool active,
44 std::vector<hci::DiscoveryFilter> filters,
45 PeerCache& peer_cache,
46 pw::async::Dispatcher& dispatcher,
47 fit::function<void(LowEnergyDiscoverySession*)> on_stop_cb,
48 fit::function<const std::unordered_set<PeerId>&()> cached_scan_results_fn)
49 : WeakSelf(this),
50 scan_id_(scan_id),
51 active_(active),
52 filters_(std::move(filters)),
53 peer_cache_(peer_cache),
54 heap_dispatcher_(dispatcher),
55 on_stop_cb_(std::move(on_stop_cb)),
56 cached_scan_results_fn_(std::move(cached_scan_results_fn)) {}
57
~LowEnergyDiscoverySession()58 LowEnergyDiscoverySession::~LowEnergyDiscoverySession() {
59 if (alive_ && on_stop_cb_) {
60 on_stop_cb_(this);
61 }
62 }
63
SetResultCallback(PeerFoundFunction callback)64 void LowEnergyDiscoverySession::SetResultCallback(PeerFoundFunction callback) {
65 if (!alive_) {
66 return;
67 }
68 peer_found_fn_ = std::move(callback);
69
70 // Post NotifyDiscoveryResult(), which calls peer_found_fn_, to avoid client
71 // bugs (e.g. deadlock) when peer_found_fn_ is called in SetResultCallback().
72 pw::Status post_status = heap_dispatcher_.Post([self = GetWeakPtr()](
73 pw::async::Context,
74 pw::Status status) {
75 if (!status.ok() || !self.is_alive()) {
76 return;
77 }
78 for (PeerId cached_peer_id : self->cached_scan_results_fn_()) {
79 auto peer = self->peer_cache_.FindById(cached_peer_id);
80 // Ignore peers that have since been removed from the peer cache.
81 if (!peer) {
82 bt_log(
83 TRACE,
84 "gap",
85 "Ignoring cached scan result for peer %s missing from peer cache",
86 bt_str(cached_peer_id));
87 continue;
88 }
89 self->NotifyDiscoveryResult(*peer);
90 }
91 });
92 PW_CHECK(post_status.ok());
93 }
94
NotifyDiscoveryResult(const Peer & peer) const95 void LowEnergyDiscoverySession::NotifyDiscoveryResult(const Peer& peer) const {
96 PW_CHECK(peer.le());
97
98 if (!alive_ || !peer_found_fn_) {
99 return;
100 }
101
102 if (filters_.empty()) {
103 peer_found_fn_(peer);
104 return;
105 }
106
107 if (std::any_of(filters_.begin(),
108 filters_.end(),
109 [&peer](const hci::DiscoveryFilter& filter) {
110 return filter.MatchLowEnergyResult(
111 peer.le()->parsed_advertising_data(),
112 peer.connectable(),
113 peer.rssi());
114 })) {
115 peer_found_fn_(peer);
116 }
117 }
118
NotifyError()119 void LowEnergyDiscoverySession::NotifyError() {
120 alive_ = false;
121 if (error_cb_) {
122 error_cb_();
123 }
124 }
125
Stop()126 void LowEnergyDiscoverySession::Stop() {
127 PW_DCHECK(alive_);
128 on_stop_cb_(this);
129 alive_ = false;
130 }
131
LowEnergyDiscoveryManager(hci::LowEnergyScanner * scanner,PeerCache * peer_cache,const hci::LowEnergyScanner::PacketFilterConfig & packet_filter_config,pw::async::Dispatcher & dispatcher)132 LowEnergyDiscoveryManager::LowEnergyDiscoveryManager(
133 hci::LowEnergyScanner* scanner,
134 PeerCache* peer_cache,
135 const hci::LowEnergyScanner::PacketFilterConfig& packet_filter_config,
136 pw::async::Dispatcher& dispatcher)
137 : WeakSelf(this),
138 dispatcher_(dispatcher),
139 state_(State::kIdle, StateToString),
140 peer_cache_(peer_cache),
141 packet_filter_config_(packet_filter_config),
142 paused_count_(0),
143 scanner_(scanner) {
144 PW_DCHECK(peer_cache_);
145 PW_DCHECK(scanner_);
146
147 scanner_->set_delegate(this);
148 }
149
~LowEnergyDiscoveryManager()150 LowEnergyDiscoveryManager::~LowEnergyDiscoveryManager() {
151 scanner_->set_delegate(nullptr);
152
153 DeactivateAndNotifySessions();
154 }
155
StartDiscovery(bool active,std::vector<hci::DiscoveryFilter> discovery_filters,SessionCallback callback)156 void LowEnergyDiscoveryManager::StartDiscovery(
157 bool active,
158 std::vector<hci::DiscoveryFilter> discovery_filters,
159 SessionCallback callback) {
160 PW_CHECK(callback);
161 bt_log(INFO, "gap-le", "start %s discovery", active ? "active" : "passive");
162
163 // If a request to start or stop is currently pending then this one will
164 // become pending until the HCI request completes. This does NOT include
165 // the state in which we are stopping and restarting scan in between scan
166 // periods, in which case session_ will not be empty.
167 //
168 // If the scan needs to be upgraded to an active scan, it will be handled
169 // in OnScanStatus() when the HCI request completes.
170 if (!pending_.empty() ||
171 (scanner_->state() == hci::LowEnergyScanner::State::kStopping &&
172 sessions_.empty())) {
173 PW_CHECK(!scanner_->IsScanning());
174 pending_.push_back(DiscoveryRequest{.active = active,
175 .filters = std::move(discovery_filters),
176 .callback = std::move(callback)});
177 return;
178 }
179
180 // If a peer scan is already in progress, then the request succeeds (this
181 // includes the state in which we are stopping and restarting scan in
182 // between scan periods).
183 if (!sessions_.empty()) {
184 if (active) {
185 // If this is the first active session, stop scanning and wait for
186 // OnScanStatus() to initiate active scan.
187 if (!std::any_of(sessions_.begin(), sessions_.end(), [](auto s) {
188 return s->active();
189 })) {
190 StopScan();
191 }
192 }
193
194 auto session = AddSession(active, std::move(discovery_filters));
195 // Post the callback instead of calling it synchronously to avoid bugs
196 // caused by client code not expecting this.
197 (void)heap_dispatcher_.Post(
198 [cb = std::move(callback), discovery_session = std::move(session)](
199 pw::async::Context /*ctx*/, pw::Status status) mutable {
200 if (status.ok()) {
201 cb(std::move(discovery_session));
202 }
203 });
204 return;
205 }
206
207 pending_.push_back({.active = active,
208 .filters = std::move(discovery_filters),
209 .callback = std::move(callback)});
210
211 if (paused()) {
212 return;
213 }
214
215 // If the scanner is not idle, it is starting/stopping, and the
216 // appropriate scanning will be initiated in OnScanStatus().
217 if (scanner_->IsIdle()) {
218 StartScan(active);
219 }
220 }
221
222 LowEnergyDiscoveryManager::PauseToken
PauseDiscovery()223 LowEnergyDiscoveryManager::PauseDiscovery() {
224 if (!paused()) {
225 bt_log(TRACE, "gap-le", "Pausing discovery");
226 StopScan();
227 }
228
229 paused_count_.Set(*paused_count_ + 1);
230
231 return PauseToken([this, self = GetWeakPtr()]() {
232 if (!self.is_alive()) {
233 return;
234 }
235
236 PW_CHECK(paused());
237 paused_count_.Set(*paused_count_ - 1);
238 if (*paused_count_ == 0) {
239 ResumeDiscovery();
240 }
241 });
242 }
243
discovering() const244 bool LowEnergyDiscoveryManager::discovering() const {
245 return std::any_of(
246 sessions_.begin(), sessions_.end(), [](auto& s) { return s->active(); });
247 }
248
AttachInspect(inspect::Node & parent,std::string name)249 void LowEnergyDiscoveryManager::AttachInspect(inspect::Node& parent,
250 std::string name) {
251 inspect_.node = parent.CreateChild(name);
252 paused_count_.AttachInspect(inspect_.node, kInspectPausedCountPropertyName);
253 state_.AttachInspect(inspect_.node, kInspectStatePropertyName);
254 inspect_.failed_count =
255 inspect_.node.CreateUint(kInspectFailedCountPropertyName, 0);
256 inspect_.scan_interval_ms =
257 inspect_.node.CreateDouble(kInspectScanIntervalPropertyName, 0);
258 inspect_.scan_window_ms =
259 inspect_.node.CreateDouble(kInspectScanWindowPropertyName, 0);
260 }
261
StateToString(State state)262 std::string LowEnergyDiscoveryManager::StateToString(State state) {
263 switch (state) {
264 case State::kIdle:
265 return "Idle";
266 case State::kStarting:
267 return "Starting";
268 case State::kActive:
269 return "Active";
270 case State::kPassive:
271 return "Passive";
272 case State::kStopping:
273 return "Stopping";
274 }
275 }
276
277 std::unique_ptr<LowEnergyDiscoverySession>
AddSession(bool active,std::vector<hci::DiscoveryFilter> discovery_filters)278 LowEnergyDiscoveryManager::AddSession(
279 bool active, std::vector<hci::DiscoveryFilter> discovery_filters) {
280 auto on_stop_cb = [this](LowEnergyDiscoverySession* session_to_remove) {
281 RemoveSession(session_to_remove);
282 };
283 auto cached_scan_results_fn =
284 [this]() -> const decltype(cached_scan_results_)& {
285 return this->cached_scan_results_;
286 };
287 auto session = std::make_unique<LowEnergyDiscoverySession>(
288 next_scan_id_++,
289 active,
290 std::move(discovery_filters),
291 *peer_cache_,
292 dispatcher_,
293 std::move(on_stop_cb),
294 std::move(cached_scan_results_fn));
295 sessions_.push_back(session.get());
296 return session;
297 }
298
RemoveSession(LowEnergyDiscoverySession * session)299 void LowEnergyDiscoveryManager::RemoveSession(
300 LowEnergyDiscoverySession* session) {
301 PW_CHECK(session);
302
303 // Only alive sessions are allowed to call this method. If there is at
304 // least one alive session object out there, then we MUST be scanning.
305 PW_CHECK(session->alive());
306
307 auto iter = std::find(sessions_.begin(), sessions_.end(), session);
308 PW_CHECK(iter != sessions_.end());
309
310 bool active = session->active();
311
312 sessions_.erase(iter);
313
314 bool last_active =
315 active && std::none_of(sessions_.begin(), sessions_.end(), [](auto& s) {
316 return s->active();
317 });
318
319 // Stop scanning if the session count has dropped to zero or the scan type
320 // needs to be downgraded to passive.
321 if (sessions_.empty() || last_active) {
322 bt_log(TRACE,
323 "gap-le",
324 "Last %sdiscovery session removed, stopping scan (sessions: %zu)",
325 last_active ? "active " : "",
326 sessions_.size());
327 StopScan();
328 return;
329 }
330 }
331
OnPeerFound(const hci::LowEnergyScanResult & result)332 void LowEnergyDiscoveryManager::OnPeerFound(
333 const hci::LowEnergyScanResult& result) {
334 bt_log(DEBUG,
335 "gap-le",
336 "peer found (address: %s, connectable: %d)",
337 bt_str(result.address()),
338 result.connectable());
339
340 auto peer = peer_cache_->FindByAddress(result.address());
341 if (peer && peer->connectable() && peer->le() && connectable_cb_) {
342 bt_log(TRACE,
343 "gap-le",
344 "found connectable peer (id: %s)",
345 bt_str(peer->identifier()));
346 connectable_cb_(peer);
347 }
348
349 // Don't notify sessions of unknown LE peers during passive scan.
350 if (scanner_->IsPassiveScanning() && (!peer || !peer->le())) {
351 return;
352 }
353
354 // Create a new entry if we found the device during general discovery.
355 if (!peer) {
356 peer = peer_cache_->NewPeer(result.address(), result.connectable());
357 PW_CHECK(peer);
358 } else if (!peer->connectable() && result.connectable()) {
359 bt_log(DEBUG,
360 "gap-le",
361 "received connectable advertisement from previously "
362 "non-connectable "
363 "peer (address: %s, "
364 "peer: %s)",
365 bt_str(result.address()),
366 bt_str(peer->identifier()));
367 peer->set_connectable(true);
368 }
369
370 peer->MutLe().SetAdvertisingData(
371 result.rssi(), result.data(), dispatcher_.now());
372
373 cached_scan_results_.insert(peer->identifier());
374
375 for (auto iter = sessions_.begin(); iter != sessions_.end();) {
376 // The session may be erased by the result handler, so we need to get
377 // the next iterator before iter is invalidated.
378 auto next = std::next(iter);
379 auto session = *iter;
380 session->NotifyDiscoveryResult(*peer);
381 iter = next;
382 }
383 }
384
OnDirectedAdvertisement(const hci::LowEnergyScanResult & result)385 void LowEnergyDiscoveryManager::OnDirectedAdvertisement(
386 const hci::LowEnergyScanResult& result) {
387 bt_log(TRACE,
388 "gap-le",
389 "Received directed advertisement (address: %s, %s)",
390 result.address().ToString().c_str(),
391 (result.resolved() ? "resolved" : "not resolved"));
392
393 auto peer = peer_cache_->FindByAddress(result.address());
394 if (!peer) {
395 bt_log(DEBUG,
396 "gap-le",
397 "ignoring connection request from unknown peripheral: %s",
398 result.address().ToString().c_str());
399 return;
400 }
401
402 if (!peer->le()) {
403 bt_log(DEBUG,
404 "gap-le",
405 "rejecting connection request from non-LE peripheral: %s",
406 result.address().ToString().c_str());
407 return;
408 }
409
410 if (peer->connectable() && connectable_cb_) {
411 connectable_cb_(peer);
412 }
413
414 // Only notify passive sessions.
415 for (auto iter = sessions_.begin(); iter != sessions_.end();) {
416 // The session may be erased by the result handler, so we need to get
417 // the next iterator before iter is invalidated.
418 auto next = std::next(iter);
419 auto session = *iter;
420 if (!session->active()) {
421 session->NotifyDiscoveryResult(*peer);
422 }
423 iter = next;
424 }
425 }
426
OnScanStatus(hci::LowEnergyScanner::ScanStatus status)427 void LowEnergyDiscoveryManager::OnScanStatus(
428 hci::LowEnergyScanner::ScanStatus status) {
429 switch (status) {
430 case hci::LowEnergyScanner::ScanStatus::kFailed:
431 OnScanFailed();
432 return;
433 case hci::LowEnergyScanner::ScanStatus::kPassive:
434 OnPassiveScanStarted();
435 return;
436 case hci::LowEnergyScanner::ScanStatus::kActive:
437 OnActiveScanStarted();
438 return;
439 case hci::LowEnergyScanner::ScanStatus::kStopped:
440 OnScanStopped();
441 return;
442 case hci::LowEnergyScanner::ScanStatus::kComplete:
443 OnScanComplete();
444 return;
445 }
446 }
447
OnScanFailed()448 void LowEnergyDiscoveryManager::OnScanFailed() {
449 bt_log(ERROR, "gap-le", "failed to initiate scan!");
450
451 inspect_.failed_count.Add(1);
452 DeactivateAndNotifySessions();
453
454 // Report failure on all currently pending requests. If any of the
455 // callbacks issue a retry the new requests will get re-queued and
456 // notified of failure in the same loop here.
457 while (!pending_.empty()) {
458 auto request = std::move(pending_.back());
459 pending_.pop_back();
460 request.callback(nullptr);
461 }
462
463 state_.Set(State::kIdle);
464 }
465
OnPassiveScanStarted()466 void LowEnergyDiscoveryManager::OnPassiveScanStarted() {
467 bt_log(TRACE, "gap-le", "passive scan started");
468
469 state_.Set(State::kPassive);
470
471 // Stop the passive scan if an active scan was requested while the scan
472 // was starting. The active scan will start in OnScanStopped() once the
473 // passive scan stops.
474 if (std::any_of(sessions_.begin(),
475 sessions_.end(),
476 [](auto& s) { return s->active(); }) ||
477 std::any_of(
478 pending_.begin(), pending_.end(), [](auto& p) { return p.active; })) {
479 bt_log(TRACE,
480 "gap-le",
481 "active scan requested while passive scan was starting");
482 StopScan();
483 return;
484 }
485
486 NotifyPending();
487 }
488
OnActiveScanStarted()489 void LowEnergyDiscoveryManager::OnActiveScanStarted() {
490 bt_log(TRACE, "gap-le", "active scan started");
491 state_.Set(State::kActive);
492 NotifyPending();
493 }
494
OnScanStopped()495 void LowEnergyDiscoveryManager::OnScanStopped() {
496 bt_log(DEBUG,
497 "gap-le",
498 "stopped scanning (paused: %d, pending: %zu, sessions: %zu)",
499 paused(),
500 pending_.size(),
501 sessions_.size());
502
503 state_.Set(State::kIdle);
504 cached_scan_results_.clear();
505
506 if (paused()) {
507 return;
508 }
509
510 if (!sessions_.empty()) {
511 bt_log(DEBUG, "gap-le", "initiating scanning");
512 bool active = std::any_of(sessions_.begin(), sessions_.end(), [](auto& s) {
513 return s->active();
514 });
515 StartScan(active);
516 return;
517 }
518
519 // Some clients might have requested to start scanning while we were
520 // waiting for it to stop. Restart scanning if that is the case.
521 if (!pending_.empty()) {
522 bt_log(DEBUG, "gap-le", "initiating scanning");
523 bool active = std::any_of(
524 pending_.begin(), pending_.end(), [](auto& p) { return p.active; });
525 StartScan(active);
526 return;
527 }
528 }
529
OnScanComplete()530 void LowEnergyDiscoveryManager::OnScanComplete() {
531 bt_log(TRACE, "gap-le", "end of scan period");
532
533 state_.Set(State::kIdle);
534 cached_scan_results_.clear();
535
536 if (paused()) {
537 return;
538 }
539
540 // If |sessions_| is empty this is because sessions were stopped while the
541 // scanner was shutting down after the end of the scan period. Restart the
542 // scan as long as clients are waiting for it.
543 ResumeDiscovery();
544 }
545
NotifyPending()546 void LowEnergyDiscoveryManager::NotifyPending() {
547 // Create and register all sessions before notifying the clients. We do
548 // this so that the reference count is incremented for all new sessions
549 // before the callbacks execute, to prevent a potential case in which a
550 // callback stops its session immediately which could cause the reference
551 // count to drop the zero before all clients receive their session object.
552 if (!pending_.empty()) {
553 size_t count = pending_.size();
554 std::vector<std::unique_ptr<LowEnergyDiscoverySession>> new_sessions(count);
555 std::generate(
556 new_sessions.begin(), new_sessions.end(), [this, i = 0]() mutable {
557 bool active = pending_[i].active;
558 std::vector<hci::DiscoveryFilter> filters =
559 std::move(pending_[i].filters);
560 i++;
561 return AddSession(active, filters);
562 });
563
564 for (size_t i = count - 1; i < count; i--) {
565 auto cb = std::move(pending_.back().callback);
566 pending_.pop_back();
567 cb(std::move(new_sessions[i]));
568 }
569 }
570 PW_CHECK(pending_.empty());
571 }
572
StartScan(bool active)573 void LowEnergyDiscoveryManager::StartScan(bool active) {
574 auto cb = [self = GetWeakPtr()](auto status) {
575 if (self.is_alive())
576 self->OnScanStatus(status);
577 };
578
579 // TODO(armansito): A client that is interested in scanning nearby beacons
580 // and calculating proximity based on RSSI changes may want to disable
581 // duplicate filtering. We generally shouldn't allow this unless a client
582 // has the capability for it. Processing all HCI events containing
583 // advertising reports will both generate a lot of bus traffic and
584 // performing duplicate filtering on the host will take away CPU cycles
585 // from other things. It's a valid use case but needs proper management.
586 // For now we always make the controller filter duplicate reports.
587 hci::LowEnergyScanner::ScanOptions options{
588 .active = active,
589 .filter_duplicates = true,
590 .filter_policy =
591 pw::bluetooth::emboss::LEScanFilterPolicy::BASIC_UNFILTERED,
592 .period = scan_period_,
593 .scan_response_timeout = kLEScanResponseTimeout,
594 };
595
596 // See Vol 3, Part C, 9.3.11 "Connection Establishment Timing Parameters".
597 if (active) {
598 options.interval = kLEActiveScanInterval;
599 options.window = kLEActiveScanWindow;
600 } else {
601 options.interval = kLEPassiveScanInterval;
602 options.window = kLEPassiveScanWindow;
603 // TODO(armansito): Use the controller filter accept policy to filter
604 // advertisements.
605 }
606
607 // Since we use duplicate filtering, we stop and start the scan
608 // periodically to re-process advertisements. We use the minimum required
609 // scan period for general discovery (by default; |scan_period_| can be
610 // modified, e.g. by unit tests).
611 state_.Set(State::kStarting);
612 scanner_->StartScan(options, std::move(cb));
613
614 inspect_.scan_interval_ms.Set(HciScanIntervalToMs(options.interval));
615 inspect_.scan_window_ms.Set(HciScanWindowToMs(options.window));
616 }
617
StopScan()618 void LowEnergyDiscoveryManager::StopScan() {
619 state_.Set(State::kStopping);
620 scanner_->StopScan();
621 }
622
ResumeDiscovery()623 void LowEnergyDiscoveryManager::ResumeDiscovery() {
624 PW_CHECK(!paused());
625
626 if (!scanner_->IsIdle()) {
627 bt_log(TRACE, "gap-le", "attempt to resume discovery when it is not idle");
628 return;
629 }
630
631 if (!sessions_.empty()) {
632 bt_log(TRACE, "gap-le", "resuming scan");
633 bool active = std::any_of(sessions_.begin(), sessions_.end(), [](auto& s) {
634 return s->active();
635 });
636 StartScan(active);
637 return;
638 }
639
640 if (!pending_.empty()) {
641 bt_log(TRACE, "gap-le", "starting scan");
642 bool active = std::any_of(
643 pending_.begin(), pending_.end(), [](auto& s) { return s.active; });
644 StartScan(active);
645 return;
646 }
647 }
648
DeactivateAndNotifySessions()649 void LowEnergyDiscoveryManager::DeactivateAndNotifySessions() {
650 // If there are any active sessions we invalidate by notifying of an
651 // error.
652
653 // We move the initial set and notify those, if any error callbacks create
654 // additional sessions they will be added to pending_
655 auto sessions = std::move(sessions_);
656 for (const auto& session : sessions) {
657 if (session->alive()) {
658 session->NotifyError();
659 }
660 }
661
662 // Due to the move, sessions_ should be empty before the loop and any
663 // callbacks will add sessions to pending_ so it should be empty
664 // afterwards as well.
665 PW_CHECK(sessions_.empty());
666 }
667
668 } // namespace bt::gap
669