Skip to content

fix(protocol): retransmit and fail fast on the write path - #54

Open
mbillow wants to merge 3 commits into
QuiteYellow:mainfrom
mbillow:claude/smartthings-local-write-retry-i4kea9
Open

fix(protocol): retransmit and fail fast on the write path#54
mbillow wants to merge 3 commits into
QuiteYellow:mainfrom
mbillow:claude/smartthings-local-write-retry-i4kea9

Conversation

@mbillow

@mbillow mbillow commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Picks up the half of mbillow/localthings#384 that was left open after #51 claimed the pacing half.

The asymmetry

post() sends its datagram exactly once:

self._send_dgram(datagram)
if not ev.wait(timeout):
    raise SessionTimeoutError()

get() goes through _exchange_block, which retransmits each block up to _BLOCK_MAX_ATTEMPTS at _BLOCK_ACK_TIMEOUT per attempt. So one lost datagram — request or ACK — is an unrecoverable write, while a read absorbs the identical loss silently. That matches the report in #384: reads keep working, three unrelated resources (/power/vs/0, /temperature/desired/0, /wind/direction/vs/0) intermittently don't.

The bare ev.wait() is a second, separate gap: it skips the _wait_for_block liveness slicing get() uses, so a reader thread dying mid-write burns the full 8 s and reports a device timeout for what is actually a dead session.

What this does

Liveness (active). _wait_for_block becomes _wait_live, and post() waits through it. A reader death mid-write now raises SessionClosedError within one liveness poll instead of at the end of the caller's timeout.

Retransmission (off by default). post() retransmits the CON up to write_max_attempts times inside the caller's deadline, with §4.2 backoff, pacing each retransmit, and the final attempt taking whatever budget is left.

The datagram is built once and resent verbatim. That MID reuse is the load-bearing part: a server implementing §4.5 recognises the duplicate and answers from its dedupe cache instead of re-running the write. A caller-side retry can't offer that — post() mints a fresh MID and token per call, so a retry from above is a genuinely new request the device has no way to dedupe.

It defaults to 1 attempt — byte-for-byte today's behaviour — per @QuiteYellow's ordering caution on #384: retransmitting into a device that's already dropping under load turns one lost write into several, and §4.5 dedupe is unverified on RT-OCF, which doesn't reliably emit RST either. With #51 landed we can see whether writes are still being lost before turning this on, and the flag is then a one-line change rather than a new feature.

Bare-frame matching (active). The empty ACK ("separate response coming", §5.2.2) and RST both answer a request without carrying a token, so _pending — keyed by token — could never match either. The empty ACK was dropped on the floor and RST had no branch at all (TYPE_RST was imported and unused). Both now resolve through a MID registry, registered before the send so a stray frame for an unknown MID can't grow it:

  • empty ACK stops retransmission and waits out the caller's budget for the separate CON
  • RST surfaces as SessionError — a rejection, not the timeout it looked like

refresh_observes() dereg sweep (active). Paced. Unlike the teardown dereg in close(), which wants out quickly, this one runs against a session that has to keep working afterwards, and an unpaced OBSERVE burst is what wedges an appliance (mbillow/localthings#396).

What this deliberately does not do

Pacing of the request paths. subscribe(), post(), and block zero are #51's, and a second layer at the call sites would only have to be unwound when it lands. The time.sleep(0.05) in the refresh_observes subscribe sweep is left alone for the same reason. Worth being explicit: that means the connect-time OBSERVE burst in mbillow/localthings#396 is not fixed by this PR — it still needs #51, then a release.

The read path's empty-ACK handling. Only post() registers a MID, so _exchange_block still retransmits through an empty ACK — and with a fresh MID per attempt, which defeats dedupe. That is pre-existing, and it's on the path the comment itself identifies as where RT-OCF actually uses separate responses, so it deserves its own change rather than a drive-by in a write-path PR. Happy to follow up.

Tests

tests/test_dtls_session_post_retry.py, on the existing _FakeConn/_FakeSock harness. Each was confirmed to fail against the unfixed code:

  • the default sends exactly once — no new load on a device nobody has measured yet
  • a dropped first datagram is recovered when the flag is on
  • retransmits carry the same MID and token as the original
  • a reply landing in the retry's pace window is not resent
  • a reply that beat a dying reader is still returned, not discarded as a closed session
  • an empty ACK stops retransmission; an RST raises SessionError
  • attempts stay inside the caller's deadline, including the pace before a retry
  • a reader death mid-post() raises SessionClosedError fast
  • refresh_observes paces its dereg sweep

One test-harness fix worth calling out: the stubbed _send_dgram didn't stamp _last_send_ts, so pace() read a zero timestamp and never slept — which silently voided the deadline test that was supposed to catch the pace-overrun bug.

Full suite: 280 passed.

post() sent its datagram exactly once and waited on a bare ev.wait(),
while get() retransmits every block through _exchange_block. One lost
datagram was therefore an unrecoverable write and a silent no-op for a
read -- the asymmetry behind three unrelated resources on one AC all
failing with SessionTimeoutError (LocalThings#384).

- Extract _wait_live() from _wait_for_block() and wait through it in
  post(). A reader thread dying mid-write now raises SessionClosedError
  within a liveness poll instead of burning the caller's whole timeout
  and reporting a dead session as a device timeout.

- Retransmit the CON up to write_max_attempts times inside the caller's
  deadline, reusing the same Message ID. The MID reuse is what makes
  retransmitting a non-idempotent POST safe: a server implementing
  RFC 7252 4.5 answers the duplicate from its dedupe cache rather than
  re-running the write, which a caller-side retry can never offer since
  it mints a fresh MID. Defaults to 1 attempt -- byte-for-byte today's
  behaviour -- because a device already dropping under load turns one
  lost write into several, and RT-OCF's dedupe is unverified. Enable it
  once pacing has been shown insufficient on real hardware.

- Pace the dereg sweep in refresh_observes(). Unlike the teardown dereg
  in close(), which wants out quickly, this one runs against a session
  that has to keep working afterwards, and an unpaced OBSERVE burst is
  what wedges an appliance until something forces a new session
  (LocalThings#396).

Pacing of the request paths themselves is deliberately left alone:
subscribe(), post() and block zero are QuiteYellow#51,
and a second layer at the call sites would only have to be unwound when
it lands.
- Do not resend when the answer is already in hand. A response can land
  in the pace window before a retry, and resending then puts a second
  copy of a non-idempotent write on the wire for nothing.

- Match the empty ACK on MID. "Separate response coming" (RFC 7252
  5.2.2) stops the retransmit timer, but that frame carries no token, so
  _dispatch_coap dropped it and the timer ran on -- resending a request
  the device had already taken. _separate_acks is keyed by MID and
  registered before the send, so a stray ACK for an unknown MID cannot
  grow it, and the read path is unaffected.

- Clamp the retry decision to what a pace costs. pace() sleeps up to a
  whole rate-limit interval, so deciding to retry on "any budget left"
  returned past the caller's timeout -- 1s on a 0.5s call at 1 rps.

The test session's _send_dgram stub now stamps _last_send_ts as the real
one does. Without it pace() read a zero timestamp and never slept, which
silently voided the deadline test that was meant to catch the third bug.
- Handle TYPE_RST. It was imported and never used: a device rejecting a
  POST neither resolved the token nor stopped retransmission, so the
  write was resent for the whole budget and then reported as a timeout,
  which reads as a device that went quiet rather than one that said no.
  Like the empty ACK it is a bare frame, so the MID registry now carries
  the token and both paths resolve through it.

- Check ev before _check_live() on a retry. Both can happen in the pace
  window -- the response lands and the reader exits -- and checking
  liveness first threw away a write the device had confirmed, raising
  SessionClosedError for a command that actually took.

Also narrows the empty-ACK comment, which implied more coverage than it
has: only post() registers a MID, so _exchange_block still retransmits
through an empty ACK with a fresh MID each time. That is pre-existing and
wants its own change on the read path.
@QuiteYellow

QuiteYellow commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Reviewed, and I want this. The MID-reuse argument is the right one, off-by-default is the right call for a device nobody has measured, and the refresh_observes dereg pacing is a good catch that I would have missed.

It needs a rebase first, and the conflict is semantic rather than textual, so I would rather hand it back than resolve it myself.

What changed under you

#51 merged as b3045f5. It paces the first send of every request, post() included:

try:
    self.pace()
    self._check_live()
    self._send_dgram(datagram)

Your loop paces only retransmits (if attempt:), which was correct when you wrote it, because post() did no pacing at all. Applied on top of #51 it silently drops first-send pacing for writes. Reads keep it, writes lose it, which is the asymmetry your own PR is about, pointed the other way.

Why I am not resolving it myself

I tried. Moving pace() and _check_live() above the loop breaks two of your tests, and both are right to break.

test_a_reply_during_the_pace_window_is_not_resent asserts one send. With a pace before the first attempt, your stub answers before anything goes out, the if not ev.is_set() and not acked guard suppresses the send, and it asserts 0 == 1.

test_an_answer_that_beat_a_dying_reader_is_still_returned is the one that convinced me to stop. My pre-loop _check_live() raised SessionClosedError on a write the device had already confirmed. That is exactly the bug the test exists to catch, and my resolution reintroduced it at attempt 0.

Both stubs assume the first send precedes any pace. Any resolution that paces first invalidates that assumption, so this is a question about your test structure and not a merge I should be making on your behalf.

The question

Should post() pay a pace interval before its first attempt?

My answer is yes. A write is a request, that is what #51 is for, and exempting writes puts the un-limited send back on the path most likely to be hit during a storm. But it restructures your stubs, so it is yours to make.

Release

I am cutting v0.1.9 with #51 alone rather than holding it. The reporter on #37 has a fridge that will not reconnect, the cause is the OBSERVE burst, and I have already told them the fix is written and waiting on a release. This goes in the next one.

That does mean LocalThings gets the halves separately rather than together. Given #384 is about writes and #396 is about the subscribe burst, taking the burst fix now costs you nothing you were relying on.

@QuiteYellow

Copy link
Copy Markdown
Owner

I opened #56 after the review above, and it changes one thing here.

Your _inflight_mids and #36's _pending_get_mids are the same registry from opposite ends, so I do not want to merge both. #56 asks you and @atc722 to agree one structure. Hold the rebase until that settles or you risk doing it twice.

The pacing question from my review still stands and is still yours: should post() pay a pace interval before its first attempt? That one is independent of the registry.

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.

2 participants