Commit Graph
5 Commits
Author SHA1 Message Date
Michael Snoyman 9b81ac4d48 fix: make unread clears race-safe across clients 2026-09-15 17:12:36 +03:00
Michael Snoyman 927f1ca4f7 docs(rfc): document the client-side unread persistence contract
The run state consistency contract is where sidebar metadata work routes, and its
state-layer table already lists the sidebar layer, but nothing recorded the rules
the two client-side unread stores actually follow: viewed counts converge by a max
merge, completion markers are ordered by per-session tombstones with a bounded
retention window, and the storage listener repairs the store instead of only
dropping the local cache.

Write those rules down where they belong, naming the three localStorage keys and
the retention cap, and state the limitation explicitly: each save is a
read-modify-write across separate localStorage operations with no lock between
them, so the contract is convergence rather than atomicity, and two concurrent
clears can still lose a tombstone.

tests/test_webui_run_state_consistency_contract_rfc.py pins the documented keys
and the documented retention window to the constants in static/sessions.js, so
changing one without the other fails. It fails on the previous revision and passes
with this change.
2026-09-15 15:48:02 +03:00
Michael Snoyman 000b85a450 fix(sessions): prune viewed counts only the store holds
`_clearSessionViewedCount` returned early when this client's cache did not
hold the session id, so it never consulted the persisted map. A viewed count
another client acknowledged after our cache was loaded therefore survived
session deletion, leaving one entry behind per deleted session — the quota
pressure this prune path exists to avoid — and a later repair merged that
stale entry back into the store.

Read the store on every deletion and drop the key when the store holds it. The
deletion must not cost this client the acknowledgement it holds for another
session, so adopt the store's entries while keeping any higher count already
cached — the merge the repair path uses — instead of replacing the cache with
the store map. The write stays conditional, so a deletion that changes nothing
does not fire a storage event.

Behavioural two-client coverage in tests/test_unread_cross_client_sync.py:
`test_delete_prunes_a_viewed_count_only_the_store_holds` and
`test_delete_keeps_the_higher_count_this_client_holds_for_others`. Both fail on
the previous revision (the store keeps the deleted session's count, and the
repair writes it back) and pass with this change.
2026-09-15 15:18:36 +03:00
Michael Snoyman 9ffdaba4d9 fix(sessions): converge the unread stores across WebUI clients
Two WebUI clients on the same origin/profile share one localStorage, and each
caches the unread stores in module state. Max-merging viewed counts on save and
dropping the caches on a storage event was not enough: a save read the store,
merged its cache and wrote the result back, so a write landing inside another
client's read-modify-write window was still lost — and the surviving client only
forgot its own cache, so it re-read the value that had already been lost. A
viewed count could roll backwards and re-light a cleared dot. The completion
markers behind the visible dot were worse: that save was a wholesale overwrite
with no merge at all, so a stale client re-serialising its cache re-added a
marker another client had cleared.

Make both stores converge on the later fact rather than on the last writer:

- viewed counts mean "seen at least up to N messages", so they merge with a max:
  monotonic per session and additive across clients. A value that exists only in
  our cache is written back only for a session the session list still shows
  (_allSessions / _sessionListSnapshotById), so a session deleted in another
  client, already pruned from the store by _clearSessionViewedCount, is not
  resurrected.
- completion markers are add/remove and cannot be ordered by a max, so every
  clear records a tombstone (hermes-session-completion-unread-cleared,
  sid -> clearedAt) and a marker that does not postdate its clear loses. A
  genuine later completion still wins. The tombstone store never enters the
  marker map that consumers read, and is pruned by session existence and by a
  7-day cap. Both clear paths record it: _clearSessionCompletionUnread (visit /
  delete) and _clearCronSessionCompletionUnreadForInactiveProfiles (profile
  switch).
- both saves write only when the store does not already hold the merged result,
  and the storage listener runs that same merge instead of dropping our cache.
  A client that still holds an acknowledgement therefore re-asserts it after the
  other client's write, the loser sees a value it cannot beat and stops, and a
  repair converges instead of ping-ponging storage events.

tests/test_unread_cross_client_sync.py drives the real functions against a
two-client harness sharing one fake localStorage: an interleaved write inside a
read-modify-write window, a deleted session's count, a stale client's marker
write after a clear, tombstone ordering vs a later re-mark, tombstone pruning,
and the registered storage listener dispatched with synthetic events.
tests/_unread_store_helpers.py gives the neighbouring node harnesses the helpers
those persistence paths call, so they keep driving the real code.
2026-09-15 13:11:36 +03:00
Michael Snoyman ef31e3ac43 fix(sessions): stop cross-client clobber of unread state
Two WebUI clients on the same origin/profile share one localStorage but each
cache the unread stores in module state and re-serialise that cache wholesale.
With no storage listener for either key, a stale client overwrites the other's
writes: a session's viewed count can roll backwards (re-lighting a cleared
unread dot via message_count > viewed_count) and a cleared completion-unread
marker can be resurrected.

Fix the class:
- max-merge viewed counts on save so a count is monotonic and additive across
  clients, never dropping another client's entries or lowering a value;
- keep the in-cache viewed count monotonic on set;
- delete a viewed count directly from the persisted map (removals must still
  leave disk, unlike the additive set path);
- invalidate both unread caches on storage events so a concurrent write is
  re-read instead of overwritten.

Adds tests/test_unread_cross_client_sync.py (node-driven, observable behavior):
the viewed-count rollback fails before the fix and passes after; the clear path
keeps pruning; the storage-event handler prevents marker resurrection.
2026-09-15 05:31:29 +03:00