Skip to content

fix(connection): only retry idempotent queries after mid-send transport loss - #131

Merged
GreenGrassBlueOcean merged 1 commit into
masterfrom
fix/non-query-no-retry
Oct 19, 2026
Merged

GreenGrassBlueOcean merged 1 commit into
masterfrom
fix/non-query-no-retry

Conversation

@GreenGrassBlueOcean

@GreenGrassBlueOcean GreenGrassBlueOcean commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

What is the problem

In OWNCommandSession._locked_send() (OWNd/connection/command_session.py), when a socket closed mid-send (ConnectionResetError, asyncio.IncompleteReadError, asyncio.LimitOverrunError, or OSError) before the gateway's final *#*1## (ACK) / *#*0## (NACK) was read, the transport-loss retry branch checked only if is_status_request:.

Because callers such as MyHOME's LightingResyncManager or declarative expose engine may pass is_status_request=True to suppress NACK retries on fire-and-forget writes, checking is_status_request alone could replay a non-idempotent action command (*1*1*WHERE##, *2*1*WHERE##), a dimension write (*#WHO*WHERE*#DIM*VAL##), or a dimension response injection (*#WHO*WHERE*DIM*VAL##) that the gateway had already forwarded onto the physical SCS bus before the TCP socket dropped.


How is it solved

  • Added _is_idempotent_query_frame(message) in OWNd/connection/command_session.py, which parses the outgoing frame via OWNMessage.parse(raw) and returns True strictly when parsed._mode in (OWNMessageMode.STATUS_REQUEST, OWNMessageMode.DIMENSION_REQUEST) (*#WHO*WHERE## or *#WHO*WHERE*DIM##).
  • Updated the mid-send transport-loss recovery guard in OWNCommandSession._locked_send() to require if is_status_request and _is_idempotent_query_frame(message): before reconnecting and re-transmitting the frame.
  • Non-query frames (COMMAND, DIMENSION_WRITE, DIMENSION_RESPONSE) never replay after mid-send transport loss once bytes have been written to the socket.

How is it tested

  • tests/test_connection.py:
    • Verifies that mid-send ConnectionResetError / IncompleteReadError retries idempotent status/dimension queries (*#1*0##, *#4*1*0##) when is_status_request=True, and never retries action commands (*1*1*21##), dimension writes (*#4*1*#14*0215*1##), or dimension response injections (*#4*1*0*0215##) even when is_status_request=True.
  • Full OWNd test suite passing with 100.0% coverage on OWNd/connection/command_session.py, mypy OWNd (0 issues), and ruff check clean.

State Diagram

stateDiagram-v2
    [*] --> WriteFrame : _locked_send(message, is_status_request)

    state "Write Frame to TCP/Serial Stream" as WriteFrame
    state "Await Gateway Reply (*#*1## / *#*0##)" as AwaitReply
    state "Mid-Send Transport Loss\n(ConnectionResetError / IncompleteReadError / OSError)" as TransportLoss
    state "Check Guard:\nis_status_request AND _is_idempotent_query_frame(message)" as RetryGuard
    state "Reconnect Once & Replay Query\n(*#WHO*WHERE## or *#WHO*WHERE*DIM##)" as ReplayQuery
    state "Close Socket & Return None / Raise OWNConnectionError\n(Never Replay Action / Write / Expose Frame)" as AbortNoReplay

    WriteFrame --> AwaitReply : writer.drain()
    AwaitReply --> [*] : ACK (*#*1##) / Collected Replies
    AwaitReply --> TransportLoss : Socket drops before ACK/NACK
    TransportLoss --> RetryGuard
    RetryGuard --> ReplayQuery : True (STATUS_REQUEST or DIMENSION_REQUEST)
    RetryGuard --> AbortNoReplay : False (COMMAND, DIMENSION_WRITE, or DIMENSION_RESPONSE)
    ReplayQuery --> [*]
    AbortNoReplay --> [*]
Loading

Where are the traces

  • Golden Conformance Corpus & Firmware Oracle (tests/golden/corpus.json, tests/golden/firmware_oracle.json):
  • Live Physical BTicino F461 (2.0.11) Captures (2026-10-18):
    • MyHOME/tests/fixtures/traces/f461_live_2026-10-18/myhome_trace_F461_live_2026-10-18.json (live F461 command and status request traffic across WHO=1, WHO=2, and WHO=4).

What does the Oracle / Encyclopedia say

  1. Firmware Oracle (own-firmware-oracle / tests/golden/firmware_oracle.json):
    • In gateway daemons (bt_luci, bt_fls, bt_termo, coso), an incoming command or dimension write is transmitted onto the 2-wire SCS bus ($03... / $003...) before the gateway writes the final *#*1## (ACK) or *#*0## (NACK) back to the TCP command socket.
    • If the TCP connection drops while the gateway is waiting for the physical bus ACK (~90 ms on populated addresses, ~2.05 s on unpopulated addresses on F460/F461/MyHomeServer1), the SCS bus frame has already been emitted; replaying a non-query frame can double-toggle an impulse relay (*1*1*WHERE##), double-step a shutter, or duplicate a bus write.
  2. OpenWebNet Encyclopedia (protocol/frame-grammar.md):
    • OpenWebNet distinguishes read-only queries (*#WHO*WHERE## status request and *#WHO*WHERE*DIM## dimension request) from state-mutating frames (*WHO*WHAT*WHERE## command, *#WHO*WHERE*#DIM*VAL## dimension write, and *#WHO*WHERE*DIM*VAL## dimension response/expose). Only the two read-only query forms are idempotent on the SCS bus.

@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@fedem95 fedem95 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good. The retry guard now distinguishes idempotent queries from state-changing frames, and the tests cover both paths. CI is passing. Approved.

@GreenGrassBlueOcean
GreenGrassBlueOcean merged commit a25024b into master Oct 19, 2026
16 checks passed
@GreenGrassBlueOcean
GreenGrassBlueOcean deleted the fix/non-query-no-retry branch October 19, 2026 20:40
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.

3 participants