Skip to content

fix: an unverifiable write or clear is retried, not charged toward suspension - #1577

Closed
raman325 wants to merge 9 commits into
mainfrom
fix/unconfirmed-writes-retry
Closed

raman325 wants to merge 9 commits into
mainfrom
fix/unconfirmed-writes-retry

Conversation

@raman325

@raman325 raman325 commented Sep 17, 2026

Copy link
Copy Markdown
Owner

Proposed change

A smaller replacement for #1575, for the loop in #1307 (and #1397 before it).

A Z-Wave lock without the Supervision command class is verified by the driver reading the code back after each write. On a lossy link that read often times out, so the driver reports the outcome as unknown (credential_rejected_unknown, mapped to WriteResult.OPTIMISTIC) and its cache keeps showing the old state. The coordinator gave the write up at its deadline, sync charged the slot's breaker, and three laps later the slot was suspended, although the PIN worked at the keypad.

#1575 tried to work out what the lock held despite the broken read path, which took a pile of rules to get right. This PR doesn't guess:

  • A write the stack couldn't verify, and no read showed by its deadline, is unconfirmed, not failed. The coordinator reports it separately (take_unconfirmed_write). Sync doesn't charge the slot breaker for it; writes the stack did vouch for (a polled lock's service accepting it) are still charged, as before.
  • A clear the driver reports as unknown is unconfirmed too. The Z-Wave delete raises the new LockOperationUnconfirmed (a LockOperationFailed) instead of a plain failure, and sync treats it the same way. Nothing is pending for a clear, so sync reads that slot back right away (a hard refresh of the one slot on a push lock); a clear that landed settles there and then. The slot stays syncing while that read runs, so no other tick starts meanwhile, and a read that fails in any way changes nothing.
  • The slot says so. A new sync state, unconfirmed, shows on the in-sync sensor's sync_status and in the slot card ("Unconfirmed"). The sensor is off: nothing has shown the lock holds the code. Ranked just after out_of_sync when statuses fold across credentials.
  • It retries, less and less often. The wait starts at the write time to live (one minute) and doubles with each unconfirmed attempt in a row for the same target, up to an hour (UNCONFIRMED_RETRY_MAX); the exponent is bounded too, so a lock that never confirms is retried hourly indefinitely. A changed PIN or enabled state is a new target and is tried at once, and writing it ends the old wait, so switching back to the earlier PIN is tried at once too. The count resets once the slot is seen in sync.
  • A read never fails an unverifiable write. A read showing a different code is judged like an empty one for a write the stack couldn't verify: that stack may still show the slot's old code ([ISSUE] Sync attempts exceeded #1397's cache). It is waited out, and at the deadline the write ends unconfirmed. A push from the lock showing a different code still fails it, as does any read for a write the stack vouched for.
  • A believed value stays unverified until the lock says something. An optimistic write puts its PIN in the stored data before anything confirms it. If the write is given up after a failed read, no read replaces that value, so the address stays unverified until a read or push mentions it, or a clear that actually did something replaces it (the slot then reads empty). Only a value that displaced something the lock hadn't already reported counts: writing back the PIN the lock last showed leaves the slot verified. The same goes for a believed write that sync drops because the user changed or disabled the slot while it was pending: its value stays in the data, and stays unverified. A clear that found nothing to clear, or a dropped write, is not the lock's word; a push provider's confirmed write is (settle_pending). Sync never calls an unverified slot in sync, so the slot can't read in sync on the strength of the write alone, including on locks that hide their codes (where the last PIN written would otherwise match).
  • The flag carries its PIN. Sync only waits on an unconfirmed write if it is still the PIN it wants; a leftover flag from a direct write, or one for a PIN since changed, doesn't hold up the new one. A slot in sync drops a leftover flag, as it already drops a leftover failure.
  • The wait holds through a detour. It's keyed on the target and the time alone, so a lock outage in between (suspended, then back) doesn't let the retry go out early. It is forgotten once the slot is in sync.
  • A clear that found nothing to clear doesn't loop. On a slot whose value is still unverified, sync reads the slot back; if that can't settle it either, the clear waits like any unconfirmed one instead of running again on every tick.
  • In sync comes first. A slot the lock has already shown in sync isn't marked unconfirmed on the way, even if a write to it was given up moments earlier.
  • Nothing is inferred. When a read or push finally shows the code, the slot is in sync, as for any other lock.
  • The missing pending_confirmation translation for sync_status is added alongside unconfirmed.

Behaviour change: a lock whose writes land but whose reads never return used to end in a suspension and a repair. It now shows unconfirmed and keeps retrying, at most hourly, without a repair. Only Z-Wave returns an unconfirmed outcome today, and only for ERROR_UNKNOWN.

Accepted trade-off: while a slot waits, a read showing a different code doesn't cut the wait short. On this link that is often the driver's cache still showing the slot's old code, and retrying on it would bring back the loop this PR removes. A code set at the keypad on such a slot can stay for up to the current wait (at most an hour) before it is overwritten.

Also accepted: an unverified slot is settled by any read or push that mentions it, including a read from the driver's cache. A cache that still shows the slot empty (or its old code) then drives a retry, which is the point; one that shows a hidden code on a lock that masks its codes can settle the slot as in sync, which is no weaker than any read of such a lock.

Known gap, unchanged from main: once an unconfirmed write is given up, the slot reads empty, so disabling that user sends no clear, and if the write did land, the code keeps working. Fixing that needs a reliable way to address the credential when the driver's cache doesn't show it, which #1575 tried with a remembered user and couldn't get right. A follow-up could use the fixed per-slot user on User Code CC locks, if node-zwave-js guarantees one; I haven't confirmed that.

Tests:

  • End to end through the Z-Wave provider, with the driver reporting every write as unknown and its cache showing the slot empty. Over twenty minutes the slot is never suspended (on main it is, after three writes); it shows unconfirmed with three to five writes, and goes in sync, with nothing written again, once the cache shows the code.
  • The same for a clear reported as unknown while the cache shows the code.
  • Over six hours the wait between writes never shrinks, and never exceeds an hour plus the time to give a write up. After the slot is seen in sync and loses the code again, the wait starts over.
  • A changed PIN is written without waiting, and its own retries start from one minute.
  • Sync: the wait holds through an outage and is never charged; a flag for a PIN no longer wanted doesn't hold the new one; a slot seen in sync forgets the wait; a leftover flag is dropped while in sync.
  • End to end, a PIN change whose cache keeps showing the old code is retried as unconfirmed and never suspended (on main it is suspended after three writes).
  • End to end, a write whose confirmation reads all fail shows unconfirmed and keeps retrying, instead of reading in sync.
  • End to end on Z-Wave: disabling a slot whose write was never confirmed, then enabling it again, is not taken as in sync when the clear found nothing to clear; a clear that went out leaves the slot empty and in sync.
  • Sync: a lock that hides its codes is not taken as in sync after a given-up write until it reports the slot; the wait stops growing at the cap even after thousands of attempts.
  • Coordinator: a believed value given up after a failed read stays unverified (without marking the read a success) until a read, a push or a clear; a vouched-for write is judged as before. A believed write given up at the deadline (read absent, read failed, or slot left out of a read) is unconfirmed; one the stack vouched for is failed. A clear or a new write forgets an unconfirmed write.
  • A clear reported unknown is read back at once, and settles in sync with one delete when the lock shows the slot empty. Reading one slot back uses a hard refresh on a push lock and the usual read on a polled one; a failed read changes nothing. A flag for a slot already in sync is dropped without passing through unconfirmed.
  • The status-fold property covers unconfirmed; the slot card renders it.
  • Mutations: counting an unverifiable write as failed, ignoring the unconfirmed outcome in sync, dropping the wait, the doubling, the cap, the target check or either count reset, raising a plain failure for an unknown delete, not handling the unconfirmed clear, keeping the flag through a clear, not keeping a believed value unverified (or keeping a vouched one), letting a read, push or clear leave it unverified, letting a dropped write or a no-op clear verify it, a push provider's confirmed write not settling it, a real clear not replacing it (or replacing the lock's own word), not reading an unconfirmed clear back (or not storing, or not hard-refreshing, the read), acting on the flag before the in-sync check, retrying a no-op clear every tick (with or without the read-back), failing an unverifiable write on a read of another code, keeping the old wait after another target was written, keeping a flag from before the manager started, marking a same-PIN write's slot unverified (or a repeated unconfirmed PIN verified), an unbounded exponent, ignoring the flag's PIN, keeping a leftover flag while in sync, gating the wait on the state, or keeping the target after sync each fail a test.

Full suite green at 100% coverage; TypeScript tests green.

Type of change

  • Dependency upgrade
  • Bugfix (non-breaking change which fixes an issue)
  • New feature (which adds functionality)
  • Breaking change (fix/feature causing existing functionality to break)
  • Code quality improvements to existing code or addition of tests

Additional information

🤖 Generated with Claude Code

…spension

A Z-Wave lock whose verification read-back times out reports writes and
clears as unknown, and its cache keeps showing the old state. Those are
now unconfirmed rather than failed: the slot says so, retries on a
doubling wait capped at an hour, and is never suspended for it. Nothing
is inferred about what the lock holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 714b734ac436
Copilot AI lite review requested due to automatic review settings September 17, 2026 02:39

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions github-actions Bot added python Pull requests that update Python code javascript Pull requests that update javascript code bug Something isn't working labels Sep 17, 2026
@codecov

codecov Bot commented Sep 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.24%. Comparing base (dbdaa80) to head (e3c2ddc).
✅ All tests successful. No failed tests found.

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff            @@
##             main    #1577    +/-   ##
========================================
  Coverage   99.23%   99.24%            
========================================
  Files          66       66            
  Lines        8935     9038   +103     
  Branches      530      531     +1     
========================================
+ Hits         8867     8970   +103     
  Misses         68       68            
Flag Coverage Δ
python 100.00% <100.00%> (ø)
typescript 95.70% <100.00%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
custom_components/lock_code_manager/const.py 100.00% <100.00%> (ø)
...components/lock_code_manager/domain/coordinator.py 100.00% <100.00%> (ø)
..._components/lock_code_manager/domain/exceptions.py 100.00% <100.00%> (ø)
...stom_components/lock_code_manager/domain/models.py 100.00% <100.00%> (ø)
custom_components/lock_code_manager/domain/sync.py 100.00% <100.00%> (ø)
...om_components/lock_code_manager/providers/_base.py 100.00% <100.00%> (ø)
...components/lock_code_manager/providers/zwave_js.py 100.00% <100.00%> (ø)
ts/slot-card.ts 98.16% <100.00%> (+0.01%) ⬆️
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

raman325 and others added 8 commits September 16, 2026 23:04
A believed value given up after a failed read is withdrawn, so the slot
no longer reads in sync on the strength of the write alone. The
unconfirmed flag carries its PIN and is dropped while the slot is in
sync, so it cannot hold up a different PIN. The retry wait is keyed on
the target and time, so an outage in between does not skip it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 54fd0bca1fc1
…ed back

Restoring the value from before the write could restore another believed
value, and on a lock that hides its codes the old unreadable value
matched the last PIN written anyway. The address now stays unverified
until a read, push or clear speaks for it, which sync already refuses to
call in sync. The retry wait's exponent is bounded so a lock that never
confirms does not overflow it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 61e94f4a0909
…believed value

A clear that found nothing to clear, and a write sync dropped when the
target changed, both cleared the unverified mark, so re-enabling a slot
showed it in sync on the write alone. A dropped believed write now stays
unverified, a clear that changed something replaces it with empty, and a
push provider's confirmed write settles it explicitly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 3a736458ddc3
A clear the stack could not verify left nothing pending, so nothing
read it back and a clear that landed waited out the retry. Sync now
reads that slot back right away. A slot the lock has already shown in
sync is no longer marked unconfirmed on the way there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: a425a616fa5c
With the slot's value unverified and nothing for the clear to remove,
sync retried the clear on every tick. It now reads the slot back and,
if that cannot settle it, waits like any unconfirmed clear.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: acffc1286540
…ead error

The slot was marked unconfirmed before its read-back, so a slow read
could let another tick send a second clear meanwhile; it now stays
syncing until the read returns. The read-back also catches unexpected
errors, which would otherwise leave the slot stuck syncing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 76bf786c8a3e
The driver's cache can keep showing the slot's old code, so for a write
the stack could not verify that read is judged like an empty one: waited
out, then unconfirmed. Writing a different target ends the old wait, so
switching back to the earlier PIN is not held. A manager discards an
unconfirmed flag left from before it started.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 12f32561cab4
…ified

A same-PIN direct write whose confirmation read failed marked a slot the
lock had already confirmed as unverified, so it showed Unconfirmed and
was rewritten needlessly. Only a believed value that displaced something
the lock had not reported leaves the slot unverified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 38ffbe84e53d
@raman325

Copy link
Copy Markdown
Owner Author

Closing in favour of a simpler model.

This PR treats an unverifiable write as something that must eventually be resolved — a seventh SyncState, a retry clock (UNCONFIRMED_RETRY_MAX, 60s doubling to an hour), a second backoff machine alongside the circuit breaker (_unconfirmed_attempts / _unconfirmed_target / _unconfirmed_retry_at), and two new coordinator collections (_unconfirmed_writes, _unobserved) that change the meaning of is_verified.

The replacement model makes "unverifiable" a resting state rather than a pending one. A write that returns without error is recorded as believed, and belief is erased only by an observation that actually contradicts it — never by silence or by a failed read. So there is nothing to time out and nothing to back off from:

  1. observed contradicts desired → out_of_sync
  2. observed proves desired → confirmed
  3. observed opaque (lock affirms presence, not value) → optimistic
  4. believed matching desired → optimistic
  5. otherwise → unknown

in_sync reads on for both confirmed and optimistic, so Matter slots stop reporting off for the duration of a confirmation window. Confirmation becomes display-only: confirmed drives no behavior, which is what lets it exist without a clock.

That removes SyncState.UNCONFIRMED and PENDING_CONFIRMATION, PENDING_WRITE_TTL, CONFIRM_READ_INTERVAL, the confirmation timer loop, and _last_set_pin — the last of which also fixes write-only slots being rewritten once per restart.

Branch and worktree are left intact. Superseded by the optimistic/confirmed credential-state work.

@raman325 raman325 closed this Sep 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working javascript Pull requests that update javascript code python Pull requests that update Python code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants