Summary
_dispatch_coap hands an OBSERVE notification's payload straight to on_notification without looking at its Block2 option (dtls_session.py:629-643). When a notification's representation spans more than one block, only the first block reaches the callback, and the consumer decodes a partial CBOR buffer.
One-shot get() is not affected: it already runs a token-stable Block2 reassembly loop (dtls_session.py:649+). The gap is specific to the notification path.
Symptom
The consumer sees a CBOR decode failure on a large observed resource, most often /mode/vs/0. Two independent reports, same path, same error shape:
The error message belongs to the consumer, but the truncation happens here.
Why
RFC 7959 §2.6 covers blockwise notifications. When sending a 2.05 notification the server sends only the first block of the representation, and the client retrieves the rest "as if it had caused this first response by a GET request." This library never does that second step for notifications, so anything past the first block is lost.
Design constraints (learned on these devices)
Anyone fixing this has to account for the device behaviour already documented in the dtls_session.py docstring and oven-findings.md:
- Block2 transfers must reuse one token across every block. A fresh token per block is silently dropped.
- A fresh token has to begin its transfer at block 0. Samsung keys per-transfer state on the token, so a request that opens with Block2 NUM=1 under a token the server has not seen gets no reply at all.
- The continuation cannot borrow the notification's token. RFC 7959 §3.4 is explicit that requests for additional blocks cannot use the token of the observation relationship, and in this codebase it would also make
_dispatch_coap resolve real notifications into the transfer's pending waiter.
- OBSERVE registrations use 1-byte tokens; Samsung silently drops TKL>1 registrations. One-shot GETs use 4-byte tokens.
- The resource can change mid-transfer. ETag validation exists for exactly that race.
- Requests are paced at 5/s. A notification storm that triggers a fetch per notification could cross the firmware ceiling.
The fix
The two constraints above leave one shape: when a notification arrives carrying Block2 with the M bit set, discard the partial and re-read the whole resource from block 0 on a fresh 4-byte one-shot token. That is an ordinary GET, which is what §2.6 asks for, and it reuses the token-stable reassembly get() already performs. It costs one redundant block against a transfer that would otherwise be lost entirely.
The re-read has to be ETag-guarded against mid-transfer change and rate-limited so a notification storm cannot exceed the 5/s pacing ceiling. Deliver the reassembled representation to on_notification only once it is complete. get() already does token-stable Block2 reassembly (dtls_session.py:649+); factor that loop into a shared helper and reuse it here. This can land independently, on current main, and gives the codebase the shared Block2 primitive #36 was reaching for instead of waiting on it.
Two things need care:
- The re-read runs on the reader thread's callback path, and
get() blocks on an event only the reader thread can set. It has to be handed to a worker, not called inline.
- When the re-read fails or has not finished (device silent, ETag mismatch mid-transfer, pacing ceiling hit), drop the notification and log at debug rather than hand the first block to the callback.
Summary
_dispatch_coaphands an OBSERVE notification's payload straight toon_notificationwithout looking at its Block2 option (dtls_session.py:629-643). When a notification's representation spans more than one block, only the first block reaches the callback, and the consumer decodes a partial CBOR buffer.One-shot
get()is not affected: it already runs a token-stable Block2 reassembly loop (dtls_session.py:649+). The gap is specific to the notification path.Symptom
The consumer sees a CBOR decode failure on a large observed resource, most often
/mode/vs/0. Two independent reports, same path, same error shape:observe /mode/vs/0: cbor decode failed: premature end of stream (expected to read 5880 bytes, got 555 instead)poll cbor decode: premature end of stream (expected to read 1 bytes, got 0 instead)on/mode/vs/0The error message belongs to the consumer, but the truncation happens here.
Why
RFC 7959 §2.6 covers blockwise notifications. When sending a 2.05 notification the server sends only the first block of the representation, and the client retrieves the rest "as if it had caused this first response by a GET request." This library never does that second step for notifications, so anything past the first block is lost.
Design constraints (learned on these devices)
Anyone fixing this has to account for the device behaviour already documented in the
dtls_session.pydocstring andoven-findings.md:_dispatch_coapresolve real notifications into the transfer's pending waiter.The fix
The two constraints above leave one shape: when a notification arrives carrying Block2 with the M bit set, discard the partial and re-read the whole resource from block 0 on a fresh 4-byte one-shot token. That is an ordinary GET, which is what §2.6 asks for, and it reuses the token-stable reassembly
get()already performs. It costs one redundant block against a transfer that would otherwise be lost entirely.The re-read has to be ETag-guarded against mid-transfer change and rate-limited so a notification storm cannot exceed the 5/s pacing ceiling. Deliver the reassembled representation to
on_notificationonly once it is complete.get()already does token-stable Block2 reassembly (dtls_session.py:649+); factor that loop into a shared helper and reuse it here. This can land independently, on currentmain, and gives the codebase the shared Block2 primitive #36 was reaching for instead of waiting on it.Two things need care:
get()blocks on an event only the reader thread can set. It has to be handed to a worker, not called inline.