Skip to content

Commit 13259ff

Browse files
authored
fix(client): šŸ› prevent stale poll from overwriting a completed masked write (#70)
- capture an RMW generation before each poll's device I/O and skip keys an RMW committed after that snapshot when merging, so an in-flight poll cannot restore a stale packed word and undo a just-written field - keep the generation monotonic across close() so a pre-close poll cannot outrank a post-reconnect RMW; reset cache and per-key stamps under the cache lock - recompute derived keys (filtration_speed_state) from the post-merge cache when their guarded source word is skipped - add regression tests for the merge, capture-before-I/O, multi-key, catch-up, cross-close, and derived-key paths
1 parent 93341b3 commit 13259ff

2 files changed

Lines changed: 386 additions & 11 deletions

File tree

ā€Žsrc/neopool_modbus/client.pyā€Ž

Lines changed: 85 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,17 @@
155155
)
156156

157157

158+
# Derived keys computed from an RMW-guarded raw word. When a poll's stale
159+
# snapshot is skipped for the raw word, its derived keys must be recomputed from
160+
# the authoritative post-merge cache rather than merged from the snapshot,
161+
# otherwise the poll's transient derived view could regress a completed RMW.
162+
_RMW_DERIVED_KEYS: dict[str, dict[str, Callable[[dict[str, Any]], Any]]] = {
163+
"MBF_PAR_FILTRATION_CONF": {
164+
"filtration_speed_state": compute_filtration_speed_state,
165+
},
166+
}
167+
168+
158169
def _collapse_u32_register_pairs(result: dict[str, Any]) -> None:
159170
"""Replace each known LOW/HIGH register pair with a single combined entry."""
160171
for combined, low_key, high_key in _U32_REGISTER_PAIRS:
@@ -196,6 +207,13 @@ def __init__(self, config: Mapping[str, Any]) -> None:
196207
# _client_lock (connection management) to avoid reentrant deadlock: the
197208
# RMW write path re-enters _client_lock via get_client().
198209
self._cache_lock = asyncio.Lock()
210+
# RMW freshness guard: the poll builds its device snapshot outside the
211+
# lock, so a completed RMW could still be overwritten by an in-flight
212+
# poll that read the old word before the RMW ran. Each RMW bumps a
213+
# monotonic generation and stamps the key it committed; the poll skips
214+
# any key committed after the generation it captured at its start.
215+
self._rmw_generation: int = 0
216+
self._rmw_key_generation: dict[str, int] = {}
199217

200218
# Connection retry parameters
201219
self._connection_attempts = 0
@@ -492,12 +510,20 @@ async def close(self) -> None:
492510
self._connection_attempts = 0
493511
self._consecutive_errors = 0
494512
self._backoff_until = None
495-
# Reset notification polling state so the next connect starts with a full read
496-
self._cached_result = {}
497513
self._polls_since_full_read = _FULL_READ_INTERVAL
498514
self._last_notification = 0
499515
self._last_was_full_read = True
500516
self._cached_timers = {}
517+
# Reset the cache and per-key RMW stamps under _cache_lock so the reset
518+
# is ordered against an in-flight poll's _merge_poll_result rather than
519+
# racing it. Keep _rmw_generation MONOTONIC across the close (bump, never
520+
# reset): a poll that captured a high generation before the close must
521+
# still compare as older than any post-reconnect RMW, so a stale snapshot
522+
# cannot overwrite a fresh write after a reconnect.
523+
async with self._cache_lock:
524+
self._cached_result = {}
525+
self._rmw_key_generation = {}
526+
self._rmw_generation += 1
501527

502528
async def async_read_register(
503529
self,
@@ -665,6 +691,12 @@ async def _read_register_ranges(
665691

666692
async def _perform_read_all(self) -> dict[str, Any]:
667693
result: dict[str, Any] = {}
694+
# Capture the RMW generation before any device I/O. Any key an RMW
695+
# commits after this point must not be clobbered by our stale snapshot.
696+
# Read outside the lock: a slightly older value only makes the poll more
697+
# conservative (skip a key it could have merged), never the reverse; the
698+
# authoritative comparison happens under _cache_lock at write time.
699+
poll_generation = self._rmw_generation
668700

669701
@overload
670702
def get_safe(regs: list[int], idx: int) -> int | None: ...
@@ -1187,13 +1219,44 @@ def get_safe(
11871219
# Update cache after fixup and derived fields so partial reads
11881220
# start from consistent values including derived flags. Hold
11891221
# _cache_lock only around the write so a concurrent RMW cannot observe
1190-
# a torn cache; the slow I/O above stays outside the lock.
1191-
async with self._cache_lock:
1192-
self._cached_result.update(result)
1222+
# a torn cache; the slow I/O above stays outside the lock. Skip any key
1223+
# an RMW committed after our snapshot so a stale word cannot undo it.
1224+
await self._merge_poll_result(result, poll_generation)
11931225

11941226
# _LOGGER.debug("All Results: %s", result)
11951227
return result
11961228

1229+
async def _merge_poll_result(
1230+
self, result: dict[str, Any], poll_generation: int
1231+
) -> None:
1232+
"""Merge a poll's device snapshot into the cache under _cache_lock.
1233+
1234+
Skips any key an RMW committed after *poll_generation* (the generation
1235+
captured before this poll's device I/O began), so a stale in-flight
1236+
snapshot cannot undo a completed RMW write.
1237+
1238+
Derived keys computed from a guarded raw word (e.g.
1239+
``filtration_speed_state`` from ``MBF_PAR_FILTRATION_CONF``) are the
1240+
poll's own transient view of the stale word, so when a guarded raw key
1241+
is skipped its derived keys are recomputed from the authoritative
1242+
post-merge cache rather than merged from the snapshot.
1243+
"""
1244+
async with self._cache_lock:
1245+
skipped: set[str] = set()
1246+
for key, value in result.items():
1247+
if self._rmw_key_generation.get(key, 0) > poll_generation:
1248+
skipped.add(key)
1249+
continue
1250+
self._cached_result[key] = value
1251+
# Recompute derived keys whose guarded source was skipped, so the
1252+
# poll's stale derived view cannot regress a completed RMW.
1253+
for source, derived in _RMW_DERIVED_KEYS.items():
1254+
if source in skipped:
1255+
for derived_key, recompute in derived.items():
1256+
self._cached_result[derived_key] = recompute(
1257+
self._cached_result
1258+
)
1259+
11971260
async def async_write_register(
11981261
self, address: int, value: int | list[int], apply: bool = False
11991262
) -> dict[str, Any] | None:
@@ -1247,8 +1310,9 @@ async def async_set_filtration_speed(
12471310
change (before the next poll) starts from the updated word.
12481311
12491312
The read-compute-write-back is serialized against concurrent polls
1250-
and other RMW writes, so a poll cannot restore a stale packed word
1251-
between the cache read and the write-back.
1313+
and other RMW writes, and stamps the committed key with a monotonic
1314+
generation so a poll that snapshotted the old word before this write
1315+
cannot restore it afterwards.
12521316
"""
12531317
encoded = encode_filtration_speed(speed)
12541318
# Serialize the read-compute-write-back against the poll so a read_all
@@ -1266,6 +1330,8 @@ async def async_set_filtration_speed(
12661330
FILTRATION_CONF_REGISTER, new_value, apply=apply
12671331
)
12681332
self._cached_result["MBF_PAR_FILTRATION_CONF"] = new_value
1333+
self._rmw_generation += 1
1334+
self._rmw_key_generation["MBF_PAR_FILTRATION_CONF"] = self._rmw_generation
12691335
return result
12701336

12711337
async def async_start_backwash(self, apply: bool = False) -> dict[str, Any] | None:
@@ -1444,8 +1510,9 @@ async def async_set_masked_register(
14441510
from the updated word.
14451511
14461512
The read-compute-write-back is serialized against concurrent polls
1447-
and other RMW writes, so a poll cannot restore a stale packed word
1448-
between the cache read and the write-back.
1513+
and other RMW writes, and stamps the committed key with a monotonic
1514+
generation so a poll that snapshotted the old word before this write
1515+
cannot restore it afterwards.
14491516
14501517
Returns an optimistic-update dict of the coordinator-data key
14511518
the caller can merge into its own cache without knowing the
@@ -1459,6 +1526,8 @@ async def async_set_masked_register(
14591526
new_value = (current & ~mask) | ((value << shift) & mask)
14601527
await self.async_write_register(register, new_value, apply=True)
14611528
self._cached_result[data_key] = new_value
1529+
self._rmw_generation += 1
1530+
self._rmw_key_generation[data_key] = self._rmw_generation
14621531
_LOGGER.debug("Masked flag %s written: %s", flag.name, value)
14631532
return {data_key: new_value}
14641533

@@ -1620,8 +1689,9 @@ async def async_set_bitmask_flag(
16201689
(before the next poll) starts from the updated word.
16211690
16221691
The read-compute-write-back is serialized against concurrent polls
1623-
and other RMW writes, so a poll cannot restore a stale packed word
1624-
between the cache read and the write-back.
1692+
and other RMW writes, and stamps the committed key with a monotonic
1693+
generation so a poll that snapshotted the old word before this write
1694+
cannot restore it afterwards.
16251695
16261696
Returns an optimistic-update dict of the
16271697
``MBF_PAR_HIDRO_COVER_ENABLE`` coordinator-data key with the new
@@ -1637,6 +1707,10 @@ async def async_set_bitmask_flag(
16371707
HIDRO_COVER_ENABLE_REGISTER, new_value, apply=True
16381708
)
16391709
self._cached_result["MBF_PAR_HIDRO_COVER_ENABLE"] = new_value
1710+
self._rmw_generation += 1
1711+
self._rmw_key_generation["MBF_PAR_HIDRO_COVER_ENABLE"] = (
1712+
self._rmw_generation
1713+
)
16401714
_LOGGER.debug("Bitmask flag %s set to %s", flag.name, on)
16411715
return {"MBF_PAR_HIDRO_COVER_ENABLE": new_value}
16421716

0 commit comments

Comments
Ā (0)