Skip to content

Add a timer for scan responses#947

Merged
h2zero merged 1 commit intomasterfrom
scan-response-timer
Mar 24, 2026
Merged

Add a timer for scan responses#947
h2zero merged 1 commit intomasterfrom
scan-response-timer

Conversation

@h2zero
Copy link
Copy Markdown
Owner

@h2zero h2zero commented Apr 25, 2025

This ensures that the callback will be called within the configured time (in ms) when devices fail to respond to a scan response request within that time.

  • Also adds stats when debug logging to help tune the scan response timeout/scan parameters.

Closes #591

Summary by CodeRabbit

  • New Features

    • Configurable scan-response timeout to force delivery of pending scan results.
    • Runtime scan diagnostics with formatted metrics and per-device scan-response timing.
  • Bug Fixes

    • Ensure pending scan-response results are delivered on timeout and at scan end.
    • Stop and clear timeout timers and pending entries when scans stop, results are erased, or new scans start.
  • Behavior

    • FIFO waiting-list handling and legacy active-scan ordering improved to reduce missed responses and preserve timeout semantics.

@h2zero h2zero force-pushed the scan-response-timer branch 4 times, most recently from 64bae68 to f47363e Compare April 26, 2025 00:38
@h2zero h2zero marked this pull request as ready for review April 26, 2025 00:40
@h2zero h2zero force-pushed the scan-response-timer branch 3 times, most recently from 96d0ef0 to b8438ac Compare May 1, 2025 01:03
@h2zero h2zero force-pushed the scan-response-timer branch from b8438ac to bdb4021 Compare May 6, 2025 00:29
@h2zero h2zero requested a review from Copilot March 17, 2026 19:25
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an optional (and default-enabled) scan-response timeout to ensure active scanning still produces onResult callbacks even when a peripheral never sends a scan response.

Changes:

  • Introduces CONFIG_NIMBLE_CPP_SCAN_RSP_TIMEOUT configuration (default 30ms, 0 to disable).
  • Adds a host-queue callout timer to trigger a “dummy scan response” path when the timeout elapses.
  • Extends scan state tracking by storing a per-device timestamp used by the timeout logic.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.

File Description
src/nimconfig.h Adds user-facing config and default for scan response timeout.
src/NimBLEScan.h Adds scan-response timeout timer callback declaration and callout member.
src/NimBLEScan.cpp Implements timer logic and enqueues a dummy scan response event to complete results.
src/NimBLEAdvertisedDevice.h Adds per-device timestamp storage used for timeout scheduling.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

@h2zero h2zero force-pushed the scan-response-timer branch from bdb4021 to 1f164c2 Compare March 21, 2026 16:09
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 21, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Arrr — Added scan-response timeout handling, FIFO waiting list for scannable devices, per-device timestamp and intrusive-list pointer (m_time, m_pNextWaiting), forced-result callbacks on timeout, runtime SR stats, and a public setScanResponseTimeout(uint32_t) API; constructor now initializes m_pNextWaiting to self.

Changes

Cohort / File(s) Summary
Advertised Device (private state)
src/NimBLEAdvertisedDevice.h, src/NimBLEAdvertisedDevice.cpp
Added ble_npl_time_t m_time{} and intrusive-list node NimBLEAdvertisedDevice* m_pNextWaiting{}; constructor initializes m_pNextWaiting = this as sentinel. Minor alignment/formatting in header/cpp.
Scan: timeout, waiting list & timers
src/NimBLEScan.h, src/NimBLEScan.cpp
Introduced scan-response timeout machinery (callout/timer m_srTimer, timeout ticks, host event), FIFO waiting-list management (m_pWaitingListHead, m_pWaitingListTail, addWaitingDevice, removeWaitingDevice, clearWaitingList, resetWaitingTimer), and integrated lifecycle into start/stop/erase/clear and GAP event handling (handleGapEvent updates, sr timeout handling, forceResultCallback).
Scan: stats & API
src/NimBLEScan.h, src/NimBLEScan.cpp
Added conditional m_stats struct for SR metrics, getStatsString() and setScanResponseTimeout(uint32_t) API, formatted stats output (added <cinttypes>, <cstdio>). Logging/helpers compiled out when log level low.

Sequence Diagram(s)

sequenceDiagram
    participant GAP as GAP Event
    participant Scanner as NimBLEScan
    participant Timer as SR Timer
    participant Device as AdvertisedDevice

    GAP->>Scanner: advertisement received
    Scanner->>Device: create/update (set m_time)
    alt active scan & device scannable
        Scanner->>Scanner: addWaitingDevice(Device)
        Scanner->>Timer: ensure timer running for head
    end

    alt scan response received
        GAP->>Scanner: scan response
        Scanner->>Scanner: recordSrTime(Device)
        Scanner->>Scanner: removeWaitingDevice(Device)
        Scanner->>Scanner: onResult(Device)
    else timeout expires
        Timer->>Scanner: srTimerCb()
        Scanner->>Scanner: select overdue head device
        Scanner->>Scanner: schedule forceResultCallback(Device)
        Scanner->>Scanner: force onResult(Device) (mark responded)
    end

    GAP->>Scanner: BLE_GAP_EVENT_DISC_COMPLETE
    Scanner->>Timer: stop callout
    Scanner->>Scanner: force remaining results, clear waiting list
    Scanner->>Scanner: onScanEnd()
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

Yarrr — a timer keeps the watch upon the tide,
Waiting-list buccaneers queue each scannable name,
When responses ghost away, forced callbacks bring 'em inside,
Counters note the misses, orphans, and the game,
Avast — the scanner stands its ground across the frame.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 47.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add a timer for scan responses' clearly summarizes the main objective of the changeset—introducing scan-response timeout handling with associated timer and statistics infrastructure.
Linked Issues check ✅ Passed The PR adds scan-response timeout functionality that ensures callbacks are invoked when devices fail to respond, directly addressing issue #591's concern about potential memory leaks from unprocessed scan responses.
Out of Scope Changes check ✅ Passed All changes focus on scan-response timeout handling, waiting-list management, and statistics tracking—all directly aligned with the PR objectives. No extraneous modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch scan-response-timer

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/NimBLEScan.cpp (2)

76-114: The timer callback logic be sound, but there be a minor navigation error!

The algorithm fer findin' the oldest device awaitin' a scan response and schedulin' the next timeout be correct. The unsigned arithmetic fer time comparison handles wrap-around properly - good seamanship!

However, I be spottin' a wee formatting issue that might make future crew members scratch their heads:

🔧 Minor spacing fix at line 84

The comparison logic and timer restart be correct - the elapsed time calculation using unsigned subtraction handles wrap-around properly. Just a tiny style nit:

At line 256 (in the handleGapEvent function, not shown in this range but related), there be a missing space:

} else if (isLegacyAdv && advertisedDevice->isScannable() &&!ble_npl_callout_is_active(&pScan->m_srTimer)) {

Should have a space before the !:

} else if (isLegacyAdv && advertisedDevice->isScannable() && !ble_npl_callout_is_active(&pScan->m_srTimer)) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/NimBLEScan.cpp` around lines 76 - 114, Fix the minor spacing nit in the
conditional inside handleGapEvent: add a space before the negation operator in
the condition that references advertisedDevice->isScannable() and
!ble_npl_callout_is_active(&pScan->m_srTimer) so it reads "... &&
advertisedDevice->isScannable() &&
!ble_npl_callout_is_active(&pScan->m_srTimer))". Locate the condition in the
handleGapEvent function (related to NimBLEScan and m_srTimer / advertisedDevice)
and adjust the spacing for readability only.

255-259: Startin' the timer when we spot a scannable device!

This be the trigger that starts the whole timeout mechanism. Only startin' the timer when it ain't already active prevents unnecessary resets.

However, there be a wee formatting barnacle on line 256:

🔧 Missing space before negation operator
-            } else if (isLegacyAdv && advertisedDevice->isScannable() &&!ble_npl_callout_is_active(&pScan->m_srTimer)) {
+            } else if (isLegacyAdv && advertisedDevice->isScannable() && !ble_npl_callout_is_active(&pScan->m_srTimer)) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/NimBLEScan.cpp` around lines 255 - 259, The condition in NimBLEScan.cpp
uses "&&!ble_npl_callout_is_active(&pScan->m_srTimer)" which lacks a space
before the negation operator; update the conditional in the block that starts
the scan-response timer (the check involving isLegacyAdv,
advertisedDevice->isScannable(), and
ble_npl_callout_is_active(&pScan->m_srTimer)) to use "&&
!ble_npl_callout_is_active(...)" for correct/consistent formatting.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/NimBLEScan.cpp`:
- Around line 76-114: Fix the minor spacing nit in the conditional inside
handleGapEvent: add a space before the negation operator in the condition that
references advertisedDevice->isScannable() and
!ble_npl_callout_is_active(&pScan->m_srTimer) so it reads "... &&
advertisedDevice->isScannable() &&
!ble_npl_callout_is_active(&pScan->m_srTimer))". Locate the condition in the
handleGapEvent function (related to NimBLEScan and m_srTimer / advertisedDevice)
and adjust the spacing for readability only.
- Around line 255-259: The condition in NimBLEScan.cpp uses
"&&!ble_npl_callout_is_active(&pScan->m_srTimer)" which lacks a space before the
negation operator; update the conditional in the block that starts the
scan-response timer (the check involving isLegacyAdv,
advertisedDevice->isScannable(), and
ble_npl_callout_is_active(&pScan->m_srTimer)) to use "&&
!ble_npl_callout_is_active(...)" for correct/consistent formatting.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9adbc458-9132-4c41-b106-1f6e9dd365e8

📥 Commits

Reviewing files that changed from the base of the PR and between b5110a2 and 1f164c2.

📒 Files selected for processing (6)
  • src/NimBLEAdvertisedDevice.cpp
  • src/NimBLEAdvertisedDevice.h
  • src/NimBLEScan.cpp
  • src/NimBLEScan.h
  • src/nimconfig.h
  • src/syscfg/syscfg.h

@h2zero h2zero force-pushed the scan-response-timer branch from 1f164c2 to 542fd41 Compare March 21, 2026 16:21
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/NimBLEScan.h (1)

99-111: Arrr, this timeout hook changes the onResult() contract.

With the synthetic scan-response path in src/NimBLEScan.cpp, callers can now see onResult() without any real scan-response payload. Please note that in the public callback docs so mates don’t assume onResult() always means “complete data.”

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/NimBLEScan.h` around lines 99 - 111, The public callback contract for
NimBLEScanCallbacks::onResult must be updated to state that the synthetic
scan-response path in NimBLEScan.cpp (triggered by NimBLEScan::srTimerCb /
ble_npl_callout m_srTimer) can invoke onResult() without a real scan-response
payload; update the onResult() documentation/comments in the NimBLEScanCallbacks
declaration and any public API docs to explicitly say callers cannot assume
onResult() always carries complete scan-response data and must handle
missing/empty scan-response fields.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/NimBLEScan.cpp`:
- Around line 103-106: The synthetic EXT_ADV lookup fails because only the
address is copied into dummyDesc when a scan response times out; on BLE_EXT_ADV
builds matching uses both address and disc.sid. Update the timeout handling so
that after memcpy(&dummyDesc.addr, curDev->getAddress().getBase(), ...) you also
set dummyDesc.sid to the advertising SID from the discovered device (use the
existing field/source used elsewhere, e.g. the discovered record's disc.sid or
the SID accessor on curDev) so the subsequent lookup (the code that matches by
address and disc.sid) finds the existing entry and avoids the "scan response
without advertisement" path.
- Around line 82-119: In NimBLEScan::srTimerCb, avoid enqueueing a dummy
scan-response for curDev unless its timeout has actually elapsed: compute
elapsed = now - curDev->m_time and only memcpy to dummyDesc.addr, log and
ble_npl_eventq_put(&dummySrTimerEvent) when elapsed >= srTimeoutTicks; otherwise
calculate remaining = srTimeoutTicks - elapsed and call
ble_npl_callout_reset(&pScan->m_srTimer, remaining) (similarly handle the case
where remaining==0 set to 1), and keep the existing nextDev logic to schedule
the next expiry using m_time and srTimeoutTicks.

---

Nitpick comments:
In `@src/NimBLEScan.h`:
- Around line 99-111: The public callback contract for
NimBLEScanCallbacks::onResult must be updated to state that the synthetic
scan-response path in NimBLEScan.cpp (triggered by NimBLEScan::srTimerCb /
ble_npl_callout m_srTimer) can invoke onResult() without a real scan-response
payload; update the onResult() documentation/comments in the NimBLEScanCallbacks
declaration and any public API docs to explicitly say callers cannot assume
onResult() always carries complete scan-response data and must handle
missing/empty scan-response fields.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0737c67e-0af2-4017-b273-93c6e765d1bb

📥 Commits

Reviewing files that changed from the base of the PR and between 1f164c2 and 542fd41.

📒 Files selected for processing (6)
  • src/NimBLEAdvertisedDevice.cpp
  • src/NimBLEAdvertisedDevice.h
  • src/NimBLEScan.cpp
  • src/NimBLEScan.h
  • src/nimconfig.h
  • src/syscfg/syscfg.h
✅ Files skipped from review due to trivial changes (2)
  • src/nimconfig.h
  • src/syscfg/syscfg.h
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/NimBLEAdvertisedDevice.h
  • src/NimBLEAdvertisedDevice.cpp

@h2zero h2zero force-pushed the scan-response-timer branch from 542fd41 to 6420308 Compare March 21, 2026 16:45
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/NimBLEScan.cpp (1)

103-121: ⚠️ Potential issue | 🔴 Critical

Arrr, re-arm curDev when its timeout has not actually elapsed.

If a duplicate advertisement refreshes curDev->m_time, Line 103 can be false even though curDev is still the oldest pending device. This branch then schedules nextDev instead, or nothing at all, so the oldest advertiser never gets its timeout callback and m_maxResults == 0 can start leaking again.

🏴‍☠️ Proposed fix
-    // Add the event to the host queue
-    if (curDev && now - curDev->m_time >= srTimeoutTicks) {
+    // Add the event to the host queue.
+    if (curDev) {
+        auto elapsed = now - curDev->m_time;
+        if (elapsed < srTimeoutTicks) {
+            ble_npl_callout_reset(&pScan->m_srTimer, srTimeoutTicks - elapsed);
+            return;
+        }
 #  if MYNEWT_VAL(BLE_EXT_ADV)
         dummyDesc.sid = curDev->getSetId();
 #  endif
         memcpy(&dummyDesc.addr, curDev->getAddress().getBase(), sizeof(dummyDesc.addr));
         NIMBLE_LOGI(LOG_TAG, "Scan response timeout for: %s", curDev->getAddress().toString().c_str());
         ble_npl_eventq_put(nimble_port_get_dflt_eventq(), &dummySrTimerEvent);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/NimBLEScan.cpp` around lines 103 - 121, The current logic only fires
dummySrTimerEvent when curDev has actually timed out and otherwise schedules
nextDev, which lets curDev slip if its m_time was refreshed; change the branch
for curDev to re-arm its timeout when now - curDev->m_time < srTimeoutTicks by
computing remaining = srTimeoutTicks - (now - curDev->m_time) (clamp to >=1) and
calling ble_npl_callout_reset(&pScan->m_srTimer, remaining) (or otherwise
schedule dummySrTimerEvent for that remaining delay) so the oldest pending
device (curDev) always gets a future timeout even if its timestamp was updated;
use the existing symbols curDev, m_time, srTimeoutTicks, dummySrTimerEvent and
pScan->m_srTimer to implement this.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/NimBLEScan.cpp`:
- Around line 88-99: srTimerCb() currently ignores devices once m_callbackSent
>= 2 even if a fresh legacy advertisement only updates m_time, so devices never
re-enter the “waiting for scan response” path; when the legacy-advertisement
handling code that updates dev->m_time (the block that stores a fresh legacy
advertisement) runs, also reset or decrement dev->m_callbackSent (e.g., set to 0
or to a value <2) for that device so srTimerCb() will reconsider it and re-arm
the scan-response timeout and eventual onResult() callout; update the
legacy-advertisement update site and ensure you only reset m_callbackSent for
devices that truly started a new advertisement cycle (don’t touch devices that
legitimately completed two full callbacks).

---

Duplicate comments:
In `@src/NimBLEScan.cpp`:
- Around line 103-121: The current logic only fires dummySrTimerEvent when
curDev has actually timed out and otherwise schedules nextDev, which lets curDev
slip if its m_time was refreshed; change the branch for curDev to re-arm its
timeout when now - curDev->m_time < srTimeoutTicks by computing remaining =
srTimeoutTicks - (now - curDev->m_time) (clamp to >=1) and calling
ble_npl_callout_reset(&pScan->m_srTimer, remaining) (or otherwise schedule
dummySrTimerEvent for that remaining delay) so the oldest pending device
(curDev) always gets a future timeout even if its timestamp was updated; use the
existing symbols curDev, m_time, srTimeoutTicks, dummySrTimerEvent and
pScan->m_srTimer to implement this.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a50b5f99-a57d-423d-82fb-efaad4b6af74

📥 Commits

Reviewing files that changed from the base of the PR and between 542fd41 and 6420308.

📒 Files selected for processing (6)
  • src/NimBLEAdvertisedDevice.cpp
  • src/NimBLEAdvertisedDevice.h
  • src/NimBLEScan.cpp
  • src/NimBLEScan.h
  • src/nimconfig.h
  • src/syscfg/syscfg.h
✅ Files skipped from review due to trivial changes (1)
  • src/nimconfig.h
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/NimBLEScan.h
  • src/syscfg/syscfg.h
  • src/NimBLEAdvertisedDevice.h
  • src/NimBLEAdvertisedDevice.cpp

@h2zero h2zero force-pushed the scan-response-timer branch from 6420308 to 0c61601 Compare March 21, 2026 16:57
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
src/NimBLEScan.cpp (2)

97-117: ⚠️ Potential issue | 🔴 Critical

Arrr, re-arm the timeout for curDev before ye look at nextDev.

When Lines 98-116 see the oldest pending device but it has not actually reached m_srTimeoutTicks yet, the callback falls through without resetting m_srTimer for that device. That can leave the timer idle after a refreshed advertisement or after the previously oldest device already completed, so onResult() never fires for the still-pending entry.

🏴‍☠️ Proposed fix
-    // Add the event to the host queue
-    if (curDev && now - curDev->m_time >= pScan->m_srTimeoutTicks) {
+    if (curDev) {
+        auto elapsed = now - curDev->m_time;
+        if (elapsed < pScan->m_srTimeoutTicks) {
+            auto remaining = pScan->m_srTimeoutTicks - elapsed;
+            ble_npl_callout_reset(&pScan->m_srTimer, remaining ? remaining : 1);
+            return;
+        }
+
+        // Add the event to the host queue
 #  if MYNEWT_VAL(BLE_EXT_ADV)
         dummyDesc.sid = curDev->getSetId();
 #  endif
         memcpy(&dummyDesc.addr, curDev->getAddress().getBase(), sizeof(dummyDesc.addr));
         NIMBLE_LOGI(LOG_TAG, "Scan response timeout for: %s", curDev->getAddress().toString().c_str());
         ble_npl_eventq_put(nimble_port_get_dflt_eventq(), &dummySrTimerEvent);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/NimBLEScan.cpp` around lines 97 - 117, The timer re-arm logic fails to
reset pScan->m_srTimer for curDev when curDev is still pending, causing
onResult() to never fire for that entry; fix by moving/resetting the sr timer
for curDev before evaluating nextDev: when curDev exists and has not yet reached
pScan->m_srTimeoutTicks, compute the remaining timeout and call
ble_npl_callout_reset(&pScan->m_srTimer, remaining) (using curDev->m_time and
pScan->m_srTimeoutTicks) so the dummySrTimerEvent/dummyDesc handling still posts
the event when it elapses, then proceed to compute/reset for nextDev as
currently implemented.

83-95: ⚠️ Potential issue | 🟠 Major

Arrr, keep one explicit “waiting for scan response” state.

Lines 83-95 treat any m_callbackSent < 2 device as timer-eligible, while Lines 223-229 reopen duplicates by zeroing m_callbackSent. That includes EXT_ADV entries that bail out at Lines 235-237 with m_callbackSent == 0, and it also makes repeated legacy advertisements fire onDiscovered() again. Track only m_callbackSent == 1 in srTimerCb(), and reopen completed active-scannable devices back to 1, not 0.

🏴‍☠️ Proposed fix
-        if (dev->m_callbackSent < 2 && dev->isScannable()) {
+        if (dev->m_callbackSent == 1 && dev->isScannable()) {
             if (!curDev || (now - dev->m_time > now - curDev->m_time)) {
                 nextDev = curDev;
                 curDev  = dev;
                 continue;
             }
@@
                         NIMBLE_LOGI(LOG_TAG, "Duplicate; updated: %s", advertisedAddress.toString().c_str());
                         // Restart scan-response timeout when we see a new non-scan-response
                         // legacy advertisement during active scanning for a scannable device.
-                        advertisedDevice->m_time = ble_npl_time_get();
-                        advertisedDevice->m_callbackSent = 0;
-                        if (advertisedDevice->isScannable() && !ble_npl_callout_is_active(&pScan->m_srTimer)) {
-                            ble_npl_callout_reset(&pScan->m_srTimer, pScan->m_srTimeoutTicks);
+                        if (!pScan->m_scanParams.passive && advertisedDevice->isScannable()) {
+                            advertisedDevice->m_time = ble_npl_time_get();
+                            if (advertisedDevice->m_callbackSent > 1) {
+                                advertisedDevice->m_callbackSent = 1;
+                            }
+                            if (!ble_npl_callout_is_active(&pScan->m_srTimer)) {
+                                ble_npl_callout_reset(&pScan->m_srTimer, pScan->m_srTimeoutTicks);
+                            }
                         }

Also applies to: 223-229

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/NimBLEScan.cpp` around lines 83 - 95, In srTimerCb() (the loop iterating
pScan->m_scanResults.m_deviceVec) change the eligibility check from
"m_callbackSent < 2 && dev->isScannable()" to only consider devices with
m_callbackSent == 1 and dev->isScannable(), and update the reopen logic where
completed active-scannable devices are currently reset to 0 (the code around the
block that zeroes m_callbackSent) so that such devices are reopened by setting
m_callbackSent back to 1 instead of 0; this ensures only the explicit "waiting
for scan response" state (m_callbackSent == 1) is scheduled by srTimerCb and
prevents legacy/EXT_ADV entries from being retriggered incorrectly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/NimBLEScan.cpp`:
- Around line 97-117: The timer re-arm logic fails to reset pScan->m_srTimer for
curDev when curDev is still pending, causing onResult() to never fire for that
entry; fix by moving/resetting the sr timer for curDev before evaluating
nextDev: when curDev exists and has not yet reached pScan->m_srTimeoutTicks,
compute the remaining timeout and call ble_npl_callout_reset(&pScan->m_srTimer,
remaining) (using curDev->m_time and pScan->m_srTimeoutTicks) so the
dummySrTimerEvent/dummyDesc handling still posts the event when it elapses, then
proceed to compute/reset for nextDev as currently implemented.
- Around line 83-95: In srTimerCb() (the loop iterating
pScan->m_scanResults.m_deviceVec) change the eligibility check from
"m_callbackSent < 2 && dev->isScannable()" to only consider devices with
m_callbackSent == 1 and dev->isScannable(), and update the reopen logic where
completed active-scannable devices are currently reset to 0 (the code around the
block that zeroes m_callbackSent) so that such devices are reopened by setting
m_callbackSent back to 1 instead of 0; this ensures only the explicit "waiting
for scan response" state (m_callbackSent == 1) is scheduled by srTimerCb and
prevents legacy/EXT_ADV entries from being retriggered incorrectly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9ebc4748-bd8a-4938-b806-58918d86f28b

📥 Commits

Reviewing files that changed from the base of the PR and between 0c61601 and 0504a9e.

📒 Files selected for processing (4)
  • src/NimBLEAdvertisedDevice.h
  • src/NimBLEScan.cpp
  • src/NimBLEScan.h
  • src/nimconfig.h
✅ Files skipped from review due to trivial changes (1)
  • src/nimconfig.h
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/NimBLEAdvertisedDevice.h

@h2zero h2zero force-pushed the scan-response-timer branch from 32724c5 to ca46c60 Compare March 22, 2026 14:49
@h2zero
Copy link
Copy Markdown
Owner Author

h2zero commented Mar 22, 2026

@CodeRabbit review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 22, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/NimBLEScan.cpp (1)

142-144: Arr, deinitializin' a static from an instance be a bit peculiar, matey.

dummySrTimerEvent be a module-level static, but it's bein' deinitialized in the instance destructor. While this be safe in practice since NimBLEScan be effectively a singleton (created only by NimBLEDevice), and ble_npl_event_deinit be a no-op on FreeRTOS, it be architecturally unusual. If the singleton assumption ever changes, this could cause issues.

Consider movin' the event initialization/deinitialization to match the static's lifetime, or document the singleton assumption.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/NimBLEScan.cpp` around lines 142 - 144, The destructor in NimBLEScan
currently calls ble_npl_event_deinit(&dummySrTimerEvent) even though
dummySrTimerEvent is a module-level static; instead, either move the init/deinit
of dummySrTimerEvent out of the NimBLEScan instance lifecycle to module scope
(e.g., perform ble_npl_event_init/ble_npl_event_deinit in a module-level
init/cleanup pair or a singleton manager), or remove the instance deinit and
document the singleton assumption clearly; update references around m_srTimer
and ble_npl_callout_deinit so only instance-owned resources are deinitialized in
the NimBLEScan destructor and module-owned dummySrTimerEvent is handled by
module-level code (or a static refcount) to avoid tearing down a static from an
instance.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/NimBLEScan.h`:
- Around line 126-148: In toString() the std::string is pre-sized to 400 then
populated via snprintf leaving the string length at 400 with embedded nulls; fix
by capturing snprintf's return (the written/required length) and then resize the
std::string to that actual character count (handling negative return as zero and
clamp to buffer size if it equals/exceeds the buffer) so the returned string
contains only the formatted text.

---

Nitpick comments:
In `@src/NimBLEScan.cpp`:
- Around line 142-144: The destructor in NimBLEScan currently calls
ble_npl_event_deinit(&dummySrTimerEvent) even though dummySrTimerEvent is a
module-level static; instead, either move the init/deinit of dummySrTimerEvent
out of the NimBLEScan instance lifecycle to module scope (e.g., perform
ble_npl_event_init/ble_npl_event_deinit in a module-level init/cleanup pair or a
singleton manager), or remove the instance deinit and document the singleton
assumption clearly; update references around m_srTimer and
ble_npl_callout_deinit so only instance-owned resources are deinitialized in the
NimBLEScan destructor and module-owned dummySrTimerEvent is handled by
module-level code (or a static refcount) to avoid tearing down a static from an
instance.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a437b2f1-e9d4-47bd-b44e-7651e133f582

📥 Commits

Reviewing files that changed from the base of the PR and between 0c61601 and 359b47d.

📒 Files selected for processing (4)
  • src/NimBLEAdvertisedDevice.cpp
  • src/NimBLEAdvertisedDevice.h
  • src/NimBLEScan.cpp
  • src/NimBLEScan.h
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/NimBLEAdvertisedDevice.cpp
  • src/NimBLEAdvertisedDevice.h

@h2zero
Copy link
Copy Markdown
Owner Author

h2zero commented Mar 22, 2026

@CodeRabbit review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 22, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
src/NimBLEScan.cpp (1)

205-212: ⚠️ Potential issue | 🟠 Major

Blimey! This be causin' duplicate onDiscovered callbacks, matey!

Resettin' m_callbackSent = 0 at line 208 means the check at line 224 (if (!advertisedDevice->m_callbackSent)) will pass again, causin' onDiscovered() to be called multiple times for the same device on each duplicate advertisement.

If the intent be to allow the SR timeout path to work again, consider settin' it to 1 instead of 0, so only the SR timeout/result callback can fire again without re-triggerin' the discovery callback:

🏴‍☠️ Proposed fix to prevent duplicate onDiscovered calls
                         // Restart scan-response timeout when we see a new non-scan-response
                         // legacy advertisement during active scanning for a scannable device.
                         advertisedDevice->m_time         = ble_npl_time_get();
-                        advertisedDevice->m_callbackSent = 0;
+                        advertisedDevice->m_callbackSent = 1;
                         if (pScan->m_srTimeoutTicks && advertisedDevice->isScannable() &&
                             !ble_npl_callout_is_active(&pScan->m_srTimer)) {
                             ble_npl_callout_reset(&pScan->m_srTimer, pScan->m_srTimeoutTicks);
                         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/NimBLEScan.cpp` around lines 205 - 212, The code resets
advertisedDevice->m_callbackSent to 0 which allows the later check
(!advertisedDevice->m_callbackSent) to re-fire onDiscovered for duplicate
adverts; change the reset to set advertisedDevice->m_callbackSent = 1 so the
discovery callback won't be re-triggered while still restarting the
scan-response timer (keep the ble_npl_callout_reset(&pScan->m_srTimer,
pScan->m_srTimeoutTicks) logic intact) and ensure this change is applied where
advertisedDevice->m_time and the SR timer handling occur (references:
advertisedDevice->m_time, advertisedDevice->m_callbackSent,
advertisedDevice->isScannable(), pScan->m_srTimeoutTicks, pScan->m_srTimer).
🧹 Nitpick comments (1)
src/NimBLEScan.cpp (1)

434-436: A minor navigation concern, captain!

The stats be reset at the very start of start(), even if the scan be already in progress and restart be false. This means ye might lose stats from an ongoing scan even when not actually restarting it.

Consider movin' the reset to after the restart check, or only resettin' when !isContinue:

🏴‍☠️ Suggested adjustment
 bool NimBLEScan::start(uint32_t duration, bool isContinue, bool restart) {
-    m_stats.reset();
-
     NIMBLE_LOGD(LOG_TAG, ">> start: duration=%" PRIu32, duration);
     if (isScanning()) {
         if (restart) {
             NIMBLE_LOGI(LOG_TAG, "Scan already in progress, restarting it");
             if (!stop()) {
                 return false;
             }
 
             if (!isContinue) {
                 clearResults();
+                m_stats.reset();
             }
         }
     } else { // Don't clear results while scanning is active
         if (!isContinue) {
             clearResults();
+            m_stats.reset();
         }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/NimBLEScan.cpp` around lines 434 - 436, In NimBLEScan::start,
m_stats.reset() is called unconditionally at the top which clears stats even
when a scan is already running and restart == false; move or conditionally
execute the reset so stats are only cleared when restarting or starting a new
non-continuous scan (e.g., after the restart check or guarded by if (restart ||
!isContinue))—update the logic around NimBLEScan::start and references to
m_stats.reset to preserve ongoing scan statistics when restart is false.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/NimBLEScan.cpp`:
- Around line 205-212: The code resets advertisedDevice->m_callbackSent to 0
which allows the later check (!advertisedDevice->m_callbackSent) to re-fire
onDiscovered for duplicate adverts; change the reset to set
advertisedDevice->m_callbackSent = 1 so the discovery callback won't be
re-triggered while still restarting the scan-response timer (keep the
ble_npl_callout_reset(&pScan->m_srTimer, pScan->m_srTimeoutTicks) logic intact)
and ensure this change is applied where advertisedDevice->m_time and the SR
timer handling occur (references: advertisedDevice->m_time,
advertisedDevice->m_callbackSent, advertisedDevice->isScannable(),
pScan->m_srTimeoutTicks, pScan->m_srTimer).

---

Nitpick comments:
In `@src/NimBLEScan.cpp`:
- Around line 434-436: In NimBLEScan::start, m_stats.reset() is called
unconditionally at the top which clears stats even when a scan is already
running and restart == false; move or conditionally execute the reset so stats
are only cleared when restarting or starting a new non-continuous scan (e.g.,
after the restart check or guarded by if (restart || !isContinue))—update the
logic around NimBLEScan::start and references to m_stats.reset to preserve
ongoing scan statistics when restart is false.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 200d34f6-d189-4d57-944c-da260800bfa0

📥 Commits

Reviewing files that changed from the base of the PR and between 359b47d and 11530a8.

📒 Files selected for processing (4)
  • src/NimBLEAdvertisedDevice.cpp
  • src/NimBLEAdvertisedDevice.h
  • src/NimBLEScan.cpp
  • src/NimBLEScan.h
💤 Files with no reviewable changes (1)
  • src/NimBLEAdvertisedDevice.cpp
✅ Files skipped from review due to trivial changes (1)
  • src/NimBLEAdvertisedDevice.h

@h2zero h2zero force-pushed the scan-response-timer branch from 11530a8 to b4dc2d4 Compare March 22, 2026 16:07
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 22, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/NimBLEAdvertisedDevice.h`:
- Line 167: The field m_pNextWaiting must be initialized to the "not in list"
sentinel (this) for every NimBLEAdvertisedDevice construction because
addWaitingDevice() and removeWaitingDevice() rely on m_pNextWaiting == this;
update every constructor of class NimBLEAdvertisedDevice (including the public
default constructor) to set m_pNextWaiting = this in their initializer or body,
or alternatively remove/delete the default constructor if default-constructed
instances must be disallowed; ensure changes reference m_pNextWaiting, the
NimBLEAdvertisedDevice constructors, and the functions
addWaitingDevice/removeWaitingDevice so the invariant holds everywhere.

In `@src/NimBLEScan.cpp`:
- Around line 338-342: The loop over pScan->m_scanResults.m_deviceVec calls
pScan->m_pScanCallbacks->onResult(dev) which can mutate/clear m_scanResults and
invalidate the iterator; fix by snapshotting the pending devices before invoking
callbacks (e.g. build a small vector of pointers/refs from
pScan->m_scanResults.m_deviceVec for devices where dev->isScannable() &&
dev->m_callbackSent < 2), update pScan->m_stats.incMissedSrCount() while
building the snapshot, then after the iteration over the snapshot call
m_pScanCallbacks->onResult(dev) for each saved device so the internal container
is not mutated during traversal.
- Around line 117-195: The waiting-list (m_pWaitingListHead, m_pWaitingListTail,
and each device's m_pNextWaiting) is mutated from multiple contexts
(addWaitingDevice, removeWaitingDevice, clearWaitingList, resetWaitingTimer) and
races with srTimerCb/handleGapEvent and public callers like
erase()/clearResults; serialize these mutations onto one task or add a
protection primitive around all accesses (e.g., a mutex or run-on-host-queue
wrapper). Concretely, ensure every call-site that reads or writes the waiting
list (addWaitingDevice, removeWaitingDevice, clearWaitingList, resetWaitingTimer
and the srTimerCb/handleGapEvent callbacks) acquires the same lock or posts the
operation to the NimBLE host queue before touching
m_pWaitingListHead/m_pWaitingListTail/m_pNextWaiting, and release/return only
after the list work is complete so the intrusive list cannot be concurrently
modified.

In `@src/NimBLEScan.h`:
- Around line 126-147: The toString() formatter prints srMinMs/srMaxMs as
UINT32_MAX when srCount == 0; update toString() to clamp the displayed SR
min/max/avg values when srCount is zero (e.g., compute displayMin = srCount ?
srMinMs : 0, displayMax = srCount ? srMaxMs : 0, displayAvg = srCount ?
srTotalMs / srCount : 0) and pass those display* variables into snprintf instead
of srMinMs/srMaxMs/srTotalMs so the output shows 0 (or another chosen sentinel)
rather than 4294967295 when there are no samples.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f4f80ad1-1f1f-4577-baca-a8430f1bbfa5

📥 Commits

Reviewing files that changed from the base of the PR and between 11530a8 and 0527f1f.

📒 Files selected for processing (4)
  • src/NimBLEAdvertisedDevice.cpp
  • src/NimBLEAdvertisedDevice.h
  • src/NimBLEScan.cpp
  • src/NimBLEScan.h
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/NimBLEAdvertisedDevice.cpp

@h2zero h2zero force-pushed the scan-response-timer branch from 0527f1f to 84f557f Compare March 23, 2026 15:01
@h2zero
Copy link
Copy Markdown
Owner Author

h2zero commented Mar 23, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 23, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (5)
src/NimBLEScan.cpp (3)

57-74: ⚠️ Potential issue | 🔴 Critical

Arrr, the timeout can still fire against the wrong head.

If the current head gets a real scan response at Lines 269-273, ye remove it from the FIFO but leave the old callout armed. When that deadline hits, srTimerCb() at Lines 57-74 times out whatever device is now at the head without checking its own elapsed wait. Re-arm on head removal here, or guard srTimerCb() with now - curDev->m_time >= m_srTimeoutTicks.

🏴‍☠️ Possible patch
 void NimBLEScan::srTimerCb(ble_npl_event* event) {
     auto* pScan  = NimBLEDevice::getScan();
     auto  curDev = pScan->m_pWaitingListHead;

     if (curDev == nullptr) {
         ble_npl_callout_stop(&pScan->m_srTimer);
         return;
     }
+
+    ble_npl_time_t elapsed = ble_npl_time_get() - curDev->m_time;
+    if (elapsed < pScan->m_srTimeoutTicks) {
+        ble_npl_time_t remaining = pScan->m_srTimeoutTicks - elapsed;
+        ble_npl_callout_reset(&pScan->m_srTimer, remaining == 0 ? 1 : remaining);
+        return;
+    }

     NIMBLE_LOGI(LOG_TAG, "Scan response timeout for: %s", curDev->getAddress().toString().c_str());

@@
                     if (event_type == BLE_HCI_ADV_RPT_EVTYPE_SCAN_RSP) {
                         pScan->m_stats.recordSrTime(ble_npl_time_get() - advertisedDevice->m_time);
                         NIMBLE_LOGI(LOG_TAG, "Scan response from: %s", advertisedAddress.toString().c_str());
-                        pScan->removeWaitingDevice(advertisedDevice);
+                        bool wasHead = pScan->m_pWaitingListHead == advertisedDevice;
+                        pScan->removeWaitingDevice(advertisedDevice);
+                        if (wasHead) {
+                            pScan->resetWaitingTimer();
+                        }
                     } else {

Also applies to: 269-273

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/NimBLEScan.cpp` around lines 57 - 74, srTimerCb can fire for the wrong
device because the head may have been removed after a real scan response but the
callout remained armed; update srTimerCb (which reads m_pWaitingListHead) to
verify the timed-out device actually exceeded m_srTimeoutTicks by comparing now
- curDev->m_time >= m_srTimeoutTicks before treating it as timed out, and/or
ensure removeWaitingDevice/resetWaitingTimer re-arms or stops the
m_srTimer/callout when the head is removed so the stale deadline cannot fire for
a new head (refer to NimBLEScan::srTimerCb, NimBLEScan::removeWaitingDevice,
NimBLEScan::resetWaitingTimer, member m_srTimeoutTicks and device m_time).

332-337: ⚠️ Potential issue | 🟠 Major

Arrr, this scan-end fallback still calls user code while sailin' m_deviceVec.

Line 333 iterates the internal vector and Line 336 calls onResult(), so a callback that erases or clears results blows up the loop. It also leaves m_callbackSent unchanged, so the same stale entry can be emitted again on later start(..., true) scans. Snapshot the pending devices first, mark each one complete, then invoke callbacks from the snapshot.

🏴‍☠️ Possible patch
         case BLE_GAP_EVENT_DISC_COMPLETE: {
             pScan->clearWaitingList();
-            for (const auto& dev : pScan->m_scanResults.m_deviceVec) {
-                if (dev->isScannable() && dev->m_callbackSent < 2) {
-                    pScan->m_stats.incMissedSrCount();
-                    pScan->m_pScanCallbacks->onResult(dev);
-                }
-            }
+            std::vector<NimBLEAdvertisedDevice*> pending{};
+            pending.reserve(pScan->m_scanResults.m_deviceVec.size());
+            for (const auto& dev : pScan->m_scanResults.m_deviceVec) {
+                if (dev->isScannable() && dev->m_callbackSent < 2) {
+                    pScan->m_stats.incMissedSrCount();
+                    dev->m_callbackSent = 2;
+                    pending.push_back(dev);
+                }
+            }
+            for (const auto& dev : pending) {
+                pScan->m_pScanCallbacks->onResult(dev);
+            }

             NIMBLE_LOGD(LOG_TAG, "discovery complete; reason=%d", event->disc_complete.reason);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/NimBLEScan.cpp` around lines 332 - 337, The loop in NimBLEScan.cpp
iterates pScan->m_scanResults.m_deviceVec and invokes
pScan->m_pScanCallbacks->onResult(dev) directly which can mutate/clear the
vector and re-emit stale entries; instead, first snapshot the pending devices
(filter devices where isScannable() && m_callbackSent < 2) into a local list,
then for each snapshot entry set its m_callbackSent to the completed value
(e.g., mark as sent/2) and increment stats via
pScan->m_stats.incMissedSrCount(), and only after marking/incrementing invoke
pScan->m_pScanCallbacks->onResult(...) on the snapshot so the original
m_scanResults.m_deviceVec is not mutated during iteration and entries won’t be
re-emitted on later start(..., true) calls.

108-191: ⚠️ Potential issue | 🔴 Critical

Arrr, the waiting FIFO still be touched from multiple contexts without a guard.

handleGapEvent() and srTimerCb() mutate m_pWaitingListHead / m_pWaitingListTail on the host queue, while Lines 597, 619-620, 635-636, and 689-695 let public user-callable paths mutate the same intrusive list. Ye already wrap the result-vector swap in a critical section at Lines 693-695, but the waiting FIFO itself still has none. A concurrent erase() / clearResults() can unlink or free the current head while the host task is walking it.

Also applies to: 597-597, 619-620, 635-636, 689-695

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/NimBLEScan.cpp` around lines 108 - 191, The waiting FIFO
(m_pWaitingListHead / m_pWaitingListTail) is accessed from host callbacks
(handleGapEvent, srTimerCb) and public methods (addWaitingDevice,
removeWaitingDevice, clearWaitingList, resetWaitingTimer, erase(),
clearResults()) without synchronization; wrap every mutation and traversal of
the intrusive list in the same critical section used for the results-vector swap
(i.e., the existing critical lock used around the result swap) to prevent
concurrent unlink/free races — specifically, add enter/exit critical section
calls at the start/end of addWaitingDevice, removeWaitingDevice,
clearWaitingList, resetWaitingTimer and around any code paths in erase() /
clearResults() that touch m_pWaitingListHead or m_pWaitingListTail, ensuring
sentinel updates (pDev->m_pNextWaiting = pDev) occur while holding the lock.
src/NimBLEScan.h (1)

126-147: ⚠️ Potential issue | 🟡 Minor

Arrr, zero matched scan responses still print a bogus minimum.

When srCount == 0, Line 141 formats srMinMs while it still holds UINT32_MAX, so the stats string says min=4294967295. Clamp the displayed minimum to 0 or n/a when there be no samples.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/NimBLEScan.h` around lines 126 - 147, In NimBLEScan::toString() the SR
min value prints srMinMs even when srCount == 0 (srMinMs still UINT32_MAX);
update the formatting logic in toString() to conditionally use a safe display
value (e.g., 0 or "n/a") when srCount is zero instead of srMinMs, and ensure the
same conditional approach used for avg (srCount ? srTotalMs / srCount : 0) is
applied to the min (and max if desired) so the snprintf call uses the computed
safe value(s) rather than raw srMinMs.
src/NimBLEAdvertisedDevice.h (1)

161-167: ⚠️ Potential issue | 🟡 Minor

Arrr, the self-pointer sentinel still ain't guaranteed for default-constructed devices.

Line 167 says this be the “not in list” state, but Line 47 still default-constructs m_pNextWaiting{} to null. addWaitingDevice() and removeWaitingDevice() both key off m_pNextWaiting == this, so a default-constructed NimBLEAdvertisedDevice starts in the wrong state. Please initialize the sentinel in every constructor, or delete the default constructor if that state be unsupported.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/NimBLEAdvertisedDevice.h` around lines 161 - 167, The sentinel pointer
m_pNextWaiting must be initialized to point to the object itself so
addWaitingDevice() and removeWaitingDevice() can detect the "not in list" state;
update NimBLEAdvertisedDevice so that every constructor (including the default
constructor) sets m_pNextWaiting = this (or change the member initializer to
m_pNextWaiting{this}) or remove the default constructor if that state is
unsupported, ensuring existing code paths in addWaitingDevice() and
removeWaitingDevice() behave correctly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/NimBLEAdvertisedDevice.h`:
- Around line 161-167: The sentinel pointer m_pNextWaiting must be initialized
to point to the object itself so addWaitingDevice() and removeWaitingDevice()
can detect the "not in list" state; update NimBLEAdvertisedDevice so that every
constructor (including the default constructor) sets m_pNextWaiting = this (or
change the member initializer to m_pNextWaiting{this}) or remove the default
constructor if that state is unsupported, ensuring existing code paths in
addWaitingDevice() and removeWaitingDevice() behave correctly.

In `@src/NimBLEScan.cpp`:
- Around line 57-74: srTimerCb can fire for the wrong device because the head
may have been removed after a real scan response but the callout remained armed;
update srTimerCb (which reads m_pWaitingListHead) to verify the timed-out device
actually exceeded m_srTimeoutTicks by comparing now - curDev->m_time >=
m_srTimeoutTicks before treating it as timed out, and/or ensure
removeWaitingDevice/resetWaitingTimer re-arms or stops the m_srTimer/callout
when the head is removed so the stale deadline cannot fire for a new head (refer
to NimBLEScan::srTimerCb, NimBLEScan::removeWaitingDevice,
NimBLEScan::resetWaitingTimer, member m_srTimeoutTicks and device m_time).
- Around line 332-337: The loop in NimBLEScan.cpp iterates
pScan->m_scanResults.m_deviceVec and invokes
pScan->m_pScanCallbacks->onResult(dev) directly which can mutate/clear the
vector and re-emit stale entries; instead, first snapshot the pending devices
(filter devices where isScannable() && m_callbackSent < 2) into a local list,
then for each snapshot entry set its m_callbackSent to the completed value
(e.g., mark as sent/2) and increment stats via
pScan->m_stats.incMissedSrCount(), and only after marking/incrementing invoke
pScan->m_pScanCallbacks->onResult(...) on the snapshot so the original
m_scanResults.m_deviceVec is not mutated during iteration and entries won’t be
re-emitted on later start(..., true) calls.
- Around line 108-191: The waiting FIFO (m_pWaitingListHead /
m_pWaitingListTail) is accessed from host callbacks (handleGapEvent, srTimerCb)
and public methods (addWaitingDevice, removeWaitingDevice, clearWaitingList,
resetWaitingTimer, erase(), clearResults()) without synchronization; wrap every
mutation and traversal of the intrusive list in the same critical section used
for the results-vector swap (i.e., the existing critical lock used around the
result swap) to prevent concurrent unlink/free races — specifically, add
enter/exit critical section calls at the start/end of addWaitingDevice,
removeWaitingDevice, clearWaitingList, resetWaitingTimer and around any code
paths in erase() / clearResults() that touch m_pWaitingListHead or
m_pWaitingListTail, ensuring sentinel updates (pDev->m_pNextWaiting = pDev)
occur while holding the lock.

In `@src/NimBLEScan.h`:
- Around line 126-147: In NimBLEScan::toString() the SR min value prints srMinMs
even when srCount == 0 (srMinMs still UINT32_MAX); update the formatting logic
in toString() to conditionally use a safe display value (e.g., 0 or "n/a") when
srCount is zero instead of srMinMs, and ensure the same conditional approach
used for avg (srCount ? srTotalMs / srCount : 0) is applied to the min (and max
if desired) so the snprintf call uses the computed safe value(s) rather than raw
srMinMs.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8e4e73b5-3b4c-4890-a347-f4c5edcd265e

📥 Commits

Reviewing files that changed from the base of the PR and between 0527f1f and 84f557f.

📒 Files selected for processing (4)
  • src/NimBLEAdvertisedDevice.cpp
  • src/NimBLEAdvertisedDevice.h
  • src/NimBLEScan.cpp
  • src/NimBLEScan.h
✅ Files skipped from review due to trivial changes (1)
  • src/NimBLEAdvertisedDevice.cpp

@h2zero h2zero force-pushed the scan-response-timer branch from 84f557f to 0c11810 Compare March 23, 2026 16:11
@h2zero
Copy link
Copy Markdown
Owner Author

h2zero commented Mar 23, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 23, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
src/NimBLEScan.h (1)

126-147: ⚠️ Potential issue | 🟡 Minor

Arrr, zero scan responses still print a bogus minimum, ye scallywag!

When srCount == 0, the formatter prints srMinMs as UINT32_MAX (4294967295) which be mighty confusin' for sailors tryin' to tune their scan parameters. The average be handled with a ternary, but the min/max fields be not.

🏴‍☠️ Proposed fix to clamp min/max when no samples exist
             snprintf(out.data(),
                      out.size(),
                      "Scan stats:\n"
                      "  Devices seen      : %" PRIu32 "\n"
                      "  Duplicate advs    : %" PRIu32 "\n"
                      "  Scan responses    : %" PRIu32 "\n"
                      "  SR timing (ms)    : min=%" PRIu32 ", max=%" PRIu32 ", avg=%" PRIu64 "\n"
                      "  Orphaned SR       : %" PRIu32 "\n"
                      "  Missed SR         : %" PRIu32 "\n",
                      devCount,
                      dupCount,
                      srCount,
-                     srMinMs,
-                     srMaxMs,
+                     srCount ? srMinMs : 0,
+                     srCount ? srMaxMs : 0,
                      srCount ? srTotalMs / srCount : 0,
                      orphanedSrCount,
                      missedSrCount);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/NimBLEScan.h` around lines 126 - 147, The toString() formatter prints
srMinMs/srMaxMs even when srCount==0, showing UINT32_MAX; update the snprintf
arguments in NimBLEScan::toString() to use conditional/clamped values for the
min and max (e.g., use srCount ? srMinMs : 0 and srCount ? srMaxMs : 0) and keep
the existing ternary for average (srCount ? srTotalMs / srCount : 0) so
min/max/avg are sensible when srCount is zero; ensure the order of arguments
passed to snprintf matches the format placeholders (devCount, dupCount, srCount,
minVal, maxVal, avgVal, orphanedSrCount, missedSrCount).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/NimBLEScan.h`:
- Around line 126-147: The toString() formatter prints srMinMs/srMaxMs even when
srCount==0, showing UINT32_MAX; update the snprintf arguments in
NimBLEScan::toString() to use conditional/clamped values for the min and max
(e.g., use srCount ? srMinMs : 0 and srCount ? srMaxMs : 0) and keep the
existing ternary for average (srCount ? srTotalMs / srCount : 0) so min/max/avg
are sensible when srCount is zero; ensure the order of arguments passed to
snprintf matches the format placeholders (devCount, dupCount, srCount, minVal,
maxVal, avgVal, orphanedSrCount, missedSrCount).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ce089f21-4caa-444a-812f-03bb983bf59d

📥 Commits

Reviewing files that changed from the base of the PR and between 84f557f and 0c11810.

📒 Files selected for processing (4)
  • src/NimBLEAdvertisedDevice.cpp
  • src/NimBLEAdvertisedDevice.h
  • src/NimBLEScan.cpp
  • src/NimBLEScan.h
✅ Files skipped from review due to trivial changes (1)
  • src/NimBLEAdvertisedDevice.cpp

@h2zero h2zero force-pushed the scan-response-timer branch 2 times, most recently from 11b6053 to cb43e11 Compare March 23, 2026 18:32
@h2zero
Copy link
Copy Markdown
Owner Author

h2zero commented Mar 23, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 23, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds scan-response timeout handling to the BLE scanning flow so onResult() is delivered within a bounded time even when devices don’t respond to active scan requests, and it adds debug-only scan diagnostics to help tune scanning parameters.

Changes:

  • Introduces a per-scan FIFO “waiting list” and a callout timer to force-deliver pending scan results when scan responses don’t arrive in time.
  • Adds debug-level scan statistics (device counts, duplicate advs, scan-response timing, missed/orphaned scan responses).
  • Extends NimBLEAdvertisedDevice with timestamp and intrusive waiting-list linkage used by the scan-response timeout mechanism.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.

File Description
src/NimBLEScan.h Adds scan-response timeout API, debug stats struct/string formatting, and new timer/waiting-list members.
src/NimBLEScan.cpp Implements timeout callout + waiting-list management and integrates it into GAP scan event handling.
src/NimBLEAdvertisedDevice.h Adds time and intrusive-list pointer fields used by the scan-response timeout workflow.
src/NimBLEAdvertisedDevice.cpp Initializes the intrusive-list sentinel pointer in the adv-device constructor.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@h2zero h2zero force-pushed the scan-response-timer branch 5 times, most recently from e627df2 to eab5e5d Compare March 24, 2026 17:08
This ensures that the callback will be called within the configured time (in ms) when devices fail to respond to a scan response request within that time.

* Adds stats when debug logging to help tune the scan response timeout/scan parameters.
@h2zero h2zero force-pushed the scan-response-timer branch from eab5e5d to a5e88fc Compare March 24, 2026 17:56
@h2zero h2zero merged commit 9b2cadc into master Mar 24, 2026
42 checks passed
@h2zero h2zero deleted the scan-response-timer branch March 24, 2026 18:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

NimBLE_Scan_Continuous.ino possible heap memory leak?

2 participants