Skip to content

fix: propagate application write rejections as ATT Error Responses - #442

Open
jie-meng wants to merge 1 commit into
h2zero:masterfrom
jie-meng:fix/write-error-propagation-3x
Open

fix: propagate application write rejections as ATT Error Responses#442
jie-meng wants to merge 1 commit into
h2zero:masterfrom
jie-meng:fix/write-error-propagation-3x

Conversation

@jie-meng

@jie-meng jie-meng commented Aug 30, 2026

Copy link
Copy Markdown

Description

Closes #441.

handleGattEvent's write branch hardcodes return 0, so an application that rejects a write inside onWrite() (busy, validation failure, ...) can never tell the client — the client always receives a successful Write Response. The NimBLE C stack supports ATT Error Responses from the access callback, and Write Requests (opcode 0x12) support them per spec; the capability is only lost in this wrapper.

Because NimBLELocalValueAttribute::writeEvent is a pure virtual void (overridden by both NimBLECharacteristic and NimBLEDescriptor), the error cannot be threaded through the existing signatures without a breaking API change. This PR therefore uses a per-write error slot on the characteristic — fully backwards compatible:

  • void NimBLECharacteristic::setWriteError(uint8_t attError) — called by the application inside onWrite(); 0 accepts, vendor range 0x800x9F for application-defined error codes.
  • int NimBLECharacteristic::getWriteError() const — read by handleGattEvent, which returns the slot value on the OP_WRITE_CHR path and 0 on the OP_WRITE_DSC path.
  • writeEvent() resets the slot to 0 before invoking onWrite().

Changes

File Change
src/NimBLECharacteristic.h public setWriteError / getWriteError, private int m_writeError = 0
src/NimBLECharacteristic.cpp reset slot in writeEvent before onWrite; two method implementations
src/NimBLEServer.cpp write branch returns the slot via an op-guarded cast (DSC/CHR share one case block; the guard keeps descriptor writes succeeding)

No existing signature, virtual, or behavior changes for applications that don't call setWriteError.

Client-visible behavior

  • Write Request (opcode 0x12) with a non-zero slot → the stack sends an ATT Error Response with that code. Android surfaces it as a non-GATT_SUCCESS status in onCharacteristicWrite; iOS as an error in peripheral:didWriteValueForCharacteristic:error:.
  • Write Command (0x52, no response) has no response to carry the error — unchanged (documented in the header comment).

Known pre-existing behavior (unchanged, for the record)

  • writeEvent applies setValue(val, len) before invoking onWrite, so a rejected write still updates the locally stored attribute value. This predates this PR; an ATT Error Response tells the client to treat the value as unchanged, and a rollback pass is left out of scope here.

Validation

  • The identical patch, backported to the 2.5.0 branch, is compiled and shipping in a downstream ESP-IDF 5.5.4 / ESP32-S3 product (component-manager build + 727 host-side unit tests, including new tests for the reject/accept paths).
  • This branch applies the same patch to master (identical code context); it has not been compiled against 3.0.0-dev independently.

Summary by CodeRabbit

  • New Features
    • Added write-validation error handling for characteristic callbacks.
    • Applications can now report an ATT error when a characteristic write is rejected.
    • Write errors reset for each new write, preventing stale errors from affecting later requests.
    • Write Commands continue to be accepted without returning an ATT error response.

…ror slot

handleGattEvent's write branch always returned 0, so application-layer
write rejections could never reach the client as ATT Error Responses.
Add a per-write error slot on NimBLECharacteristic (set by the onWrite
callback, reset before each write) and return it from handleGattEvent.
No existing signatures change; writeEvent remains a void override.
Vendor error range 0x80-0x9F is recommended for application codes.
@coderabbitai

coderabbitai Bot commented Aug 30, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6be70472-4627-48c4-8010-6798b2c68038

📥 Commits

Reviewing files that changed from the base of the PR and between f04e1ef and f637748.

📒 Files selected for processing (3)
  • src/NimBLECharacteristic.cpp
  • src/NimBLECharacteristic.h
  • src/NimBLEServer.cpp

Included review availability: Your plan provides up to 2 included reviews per hour; 1 remains after this review.


📝 Walkthrough

Walkthrough

NimBLECharacteristic now lets onWrite callbacks set an ATT error code. The write path clears the code for each operation, and NimBLEServer returns it for characteristic writes. Descriptor writes retain the successful result.

Changes

Write Error Propagation

Layer / File(s) Summary
Characteristic write-error API
src/NimBLECharacteristic.h, src/NimBLECharacteristic.cpp
NimBLECharacteristic adds setWriteError(uint8_t) and getWriteError() const. Each write clears the stored error before onWrite runs.
Server write-error return
src/NimBLEServer.cpp
Characteristic writes return the stored ATT error. Descriptor writes continue to return zero, arrr.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: 🔵 Low · up to f6377

This change lets applications return ATT errors for rejected characteristic writes. It is mergeable with owner awareness that overlapping or reentrant write callbacks could cause the shared error state to be attributed to the wrong client request.

Poem

A callback marks the write with care
An ATT error sails through the air
Each new write clears its chart
Descriptors keep their steady part
The server returns the code, yarrr

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 62.50% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 3 files. 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 clearly identifies the main change: propagating application write rejections as ATT Error Responses. Arrr, it is concise and specific.
Linked Issues check ✅ Passed The changes satisfy issue #441. The PR adds a per-write error slot, resets it before onWrite(), returns characteristic write errors, preserves descriptor success, and keeps existing void APIs unchange…
Out of Scope Changes check ✅ Passed The changes remain within the linked issue scope. The added API, state reset, and characteristic write-path propagation directly support application-defined ATT Error Responses. No unrelated code chan…
Full details: Linked Issues check

Explanation

The changes satisfy issue #441. The PR adds a per-write error slot, resets it before onWrite(), returns characteristic write errors, preserves descriptor success, and keeps existing void APIs unchanged.

Full details: Out of Scope Changes check

Explanation

The changes remain within the linked issue scope. The added API, state reset, and characteristic write-path propagation directly support application-defined ATT Error Responses. No unrelated code changes are identified.

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@jie-meng

Copy link
Copy Markdown
Author

One question on release strategy: would you also want this backported to the release/2.5 branch (e.g. as part of a 2.5.1 patch release)? Asking because a downstream project of ours is currently pinned to the 2.5.x line and is consuming this fix from a fork in the meantime — knowing your backport plans would tell us when we can switch back to the upstream package. No rush either way; happy to send a release/2.5 PR if you'd prefer that.

@jie-meng

Copy link
Copy Markdown
Author

@coderabbitai The Docstring Coverage check reports 62.50% (5/8 functions documented). Could you list exactly which 3 functions are counted as missing docstrings, so I can add them? Thanks!

@coderabbitai

coderabbitai Bot commented Aug 30, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== PR `#442` changed files and patch ==='
gh pr diff 442 --repo h2zero/esp-nimble-cpp -- src/NimBLECharacteristic.cpp src/NimBLECharacteristic.h src/NimBLEServer.cpp || true

echo
echo '=== Relevant source outlines ==='
ast-grep outline src/NimBLECharacteristic.h --items all --view expanded || true
ast-grep outline src/NimBLECharacteristic.cpp --items all --view expanded || true
ast-grep outline src/NimBLEServer.cpp --items all --view expanded || true

echo
echo '=== Docstring coverage configuration and workflow references ==='
rg -n -i -C 3 'docstring|documentation coverage|doxygen|doxypress' \
  -g '!**/node_modules/**' -g '!**/dist/**' -g '!**/build/**' .github . 2>/dev/null || true

echo
echo '=== Recent check runs for PR head ==='
gh pr checks 442 --repo h2zero/esp-nimble-cpp || true

Length of output: 109655


🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo="h2zero/esp-nimble-cpp"
pr="442"

echo '=== PR file patches ==='
gh api "repos/$repo/pulls/$pr/files?per_page=100" \
  --jq '.[] | select(.filename == "src/NimBLECharacteristic.cpp" or .filename == "src/NimBLECharacteristic.h" or .filename == "src/NimBLEServer.cpp") | "FILE: \(.filename)\n\(.patch // "(no patch returned)")\n"' \
  || true

echo
echo '=== Changed declaration and definition regions ==='
nl -ba src/NimBLECharacteristic.h | sed -n '220,290p'
nl -ba src/NimBLECharacteristic.cpp | sed -n '415,470p'
nl -ba src/NimBLEServer.cpp | sed -n '715,785p'

echo
echo '=== PR comments and reviews that mention documentation coverage ==='
{
  gh api "repos/$repo/issues/$pr/comments?per_page=100" --paginate --jq '.[] | select((.body // "") | test("docstring|documentation coverage|62\\.50"; "i")) | "ISSUE COMMENT by \(.user.login):\n\(.body)\n"'
  gh api "repos/$repo/pulls/$pr/comments?per_page=100" --paginate --jq '.[] | select((.body // "") | test("docstring|documentation coverage|62\\.50"; "i")) | "REVIEW COMMENT by \(.user.login):\n\(.body)\n"'
  gh api "repos/$repo/pulls/$pr/reviews?per_page=100" --paginate --jq '.[] | select((.body // "") | test("docstring|documentation coverage|62\\.50"; "i")) | "REVIEW by \(.user.login):\n\(.body)\n"'
} || true

echo
echo '=== All GitHub check runs on the PR head ==='
head_sha=$(gh api "repos/$repo/pulls/$pr" --jq '.head.sha')
gh api "repos/$repo/commits/$head_sha/check-runs?per_page=100" \
  --jq '.check_runs[] | {name, conclusion, details_url, output}' \
  || true

Length of output: 5372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo="h2zero/esp-nimble-cpp"
pr="442"

show_lines() {
  local file="$1" start="$2" end="$3"
  awk -v start="$start" -v end="$end" \
    'NR >= start && NR <= end { printf "%6d  %s\n", NR, $0 }' "$file"
}

echo '=== Documentation near modified functions ==='
show_lines src/NimBLECharacteristic.h 55 105
show_lines src/NimBLECharacteristic.cpp 420 470
show_lines src/NimBLEServer.cpp 720 785

echo
echo '=== All PR conversation comments from CodeRabbit or mentioning coverage ==='
gh api "repos/$repo/issues/$pr/comments?per_page=100" --paginate \
  --jq '.[] | select(.user.login == "coderabbitai" or ((.body // "") | test("docstring|coverage|document"; "i"))) | "ISSUE COMMENT by \(.user.login) at \(.created_at):\n\(.body)\n---"' \
  || true

echo
echo '=== All PR review comments from CodeRabbit or mentioning coverage ==='
gh api "repos/$repo/pulls/$pr/comments?per_page=100" --paginate \
  --jq '.[] | select(.user.login == "coderabbitai" or ((.body // "") | test("docstring|coverage|document"; "i"))) | "REVIEW COMMENT by \(.user.login) at \(.created_at), \(.path):\n\(.body)\n---"' \
  || true

echo
echo '=== All PR reviews from CodeRabbit or mentioning coverage ==='
gh api "repos/$repo/pulls/$pr/reviews?per_page=100" --paginate \
  --jq '.[] | select(.user.login == "coderabbitai" or ((.body // "") | test("docstring|coverage|document"; "i"))) | "REVIEW by \(.user.login) at \(.submitted_at):\n\(.body)\n---"' \
  || true

echo
echo '=== Check-run metadata ==='
head_sha=$(gh api "repos/$repo/pulls/$pr" --jq '.head.sha')
gh api "repos/$repo/commits/$head_sha/check-runs?per_page=100" \
  --jq '.check_runs[] | "CHECK: \(.name) | conclusion=\(.conclusion) | details=\(.details_url)\nTITLE: \(.output.title // "")\nSUMMARY: \(.output.summary // "")\nTEXT: \(.output.text // "")\n---"' \
  || true

Length of output: 19202


@jie-meng, I cannot list three actual missing docstrings from this check result. The check output reports only 5/8; it does not identify the eight functions.

I inspected the PR patch. These are the functions that the diff visibly touches:

  1. NimBLECharacteristic::writeEvent(...) — documented in src/NimBLECharacteristic.cpp.
  2. NimBLECharacteristic::setWriteError(uint8_t) — documented in src/NimBLECharacteristic.h and src/NimBLECharacteristic.cpp.
  3. NimBLECharacteristic::getWriteError() const — documented in src/NimBLECharacteristic.h and src/NimBLECharacteristic.cpp.
  4. NimBLEServer::handleGattEvent(...) — documented in src/NimBLEServer.cpp.

The diff contains six touched declarations or definitions, not eight. Each has a preceding Doxygen block. The reported 5/8 result is therefore inconsistent with the source. Arrr, I recommend treating this Docstring Coverage warning as a false positive rather than adding speculative docstrings.

You are interacting with an AI system.

@jie-meng

Copy link
Copy Markdown
Author

Thanks for the analysis — agreed, treating the Docstring Coverage warning as a false positive. Every touched declaration/definition in this diff (setWriteError/getWriteError in both header and implementation, writeEvent, handleGattEvent) already carries a preceding Doxygen block, so I'll leave the code as is rather than add speculative docstrings.

jie-meng added a commit to jie-meng/esp-nimble-cpp that referenced this pull request Sep 3, 2026
…ackport to 2.5)

Backport of h2zero#442 to the release/2.5 branch.

- NimBLECharacteristic: Added setWriteError(uint8_t) and getWriteError() const
- NimBLECharacteristic::writeEvent(): Resets error slot before each write
- NimBLEServer::handleGattEvent(): Returns write error for characteristic writes

Co-authored-by: Jie Meng <jmengxy@gmail.com>
@jie-meng

jie-meng commented Sep 3, 2026

Copy link
Copy Markdown
Author

@h2zero - ping on this PR. We've been using this fix in production via our fork and it's working well. Would appreciate a review/merge when you have cycles. Also opened backport #444 for release/2.5.

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.

GATT server write path cannot propagate application errors to the client (handleGattEvent always returns 0)

1 participant