fix(desktop): stop a future-dated message from marking a channel read - #6063
fix(desktop): stop a future-dated message from marking a channel read#6063Chessing234 wants to merge 5 commits into
Conversation
created_at is self-asserted by the sending client, and the relay bounds it for moderation commands but not for ordinary messages. resolveChannelReadMarker took the newest timestamp verbatim, so one event dated ahead of the clock landed in the read marker and every genuinely new message afterwards failed createdAt > readAt — no badge, no divider, and since block#5983 no thread resume — until wall-clock time caught up with the bad value. Both inputs are event-derived, so the ceiling covers both: callerReadAt comes from a message's own created_at (channel open, the Esc shortcut's lastMessageAt, mark-all-read) and observedLatest from live events. A tolerance rather than a hard now ceiling: ordinary skew between two machines is seconds, and clamping that hard would leave a just-received message unread until this clock caught up. 120s is the relay's own MAX_COMMAND_SKEW_SECS. clearObserved now reports false when the marker was clamped below the observed event, so the observed refs survive — that event really is still unread, and dropping them would clear the sidebar dot this change exists to keep. Refs block#6046 Signed-off-by: Taksh <takshkothari09@gmail.com>
Five cases: a caller timestamp a year ahead, an observed event a year ahead (asserting the observed refs are kept), the end-to-end shape — poison, mark read, then a genuine message still counted unread by computeChannelUnreadMarker — plus 30s of ordinary skew passing through unclamped, and the past-timestamp paths unchanged. Reverting the clamp turns the first three red. Refs block#6046 Signed-off-by: Taksh <takshkothari09@gmail.com>
themiguelamador
left a comment
There was a problem hiding this comment.
Reviewed exact head e6bb0e9d3c386beecd10d25ae84f3c2d573de90e. I found three release-blocking gaps and fixed them on Complear:review/pr-6063-fix (d2238be590662b4f14c7fa84921bb737b80129f2).
- P1 — the clamp still hides legitimate messages for two minutes. Mapping an outlier to
now + 120manufactures a future read frontier. A correct message arriving atnow + 1still failscreatedAt > readAt. The tolerance should decide whether a timestamp is plausible; an implausible timestamp must be repaired tonow. - P1 — Mark all read bypasses the claimed funnel.
markAllChannelsReadwriteslatestByChannelRefstraight throughmarkContextRead, thenclearAll()deletes the observed evidence. A future event therefore still poisons the marker through the Esc/rail mark-all action and loses its unread dot. - P1 — persisted and synced poison remains effective. Read markers are monotonic and loaded from local storage plus encrypted NIP-RS events. This patch only constrains new local
markChannelReadcalls, so a marker written before upgrade or by another unpatched desktop remains year-ahead and unrecoverable in the UI.
The fix branch centralizes the skew policy, caps outliers to the present, retains future observed events during mark-all, drops implausible markers during local hydration and synced-state parsing, and adds regressions for each route. Verification passed: desktop typecheck; full desktop check including file-size, px-text, and pubkey-truncation gates (only the four pre-existing Biome diagnostics); focused 91-test marker/read-state suite; and the full desktop unit suite (4,963 passed). Diff checks and commit signoff also pass.
… clamp it Review finding (P1, themiguelamador on block#6063): mapping an outlier to `now + 120` manufactures a future read frontier. A correct message arriving at `now + 1` still failed `createdAt > readAt`, so the clamp went on hiding legitimate messages for two minutes — a smaller version of the bug it was meant to fix. The tolerance decides whether a timestamp is *plausible*; it is not a value to clamp to. `isPlausibleReadMarker` now names that policy, and `resolveChannelReadMarker` discards each implausible input rather than pulling it down to the ceiling, keeping any plausible input beside it — a real caller position is no longer thrown away because the observed timestamp next to it is poison. Only when nothing survives is the marker repaired to the present: the mark-read gesture is real, so it still takes effect, and just the future-dated event stays unread. The policy lives in `readState/readStateFormat.ts` because every route a marker can enter by has to share it; the other routes follow in the next two commits. Splits `resolveChannelReadMarkerUnix` out of the ISO-string wrapper so callers that already hold unix seconds can use the same funnel. The two tests that asserted `now + 120` encoded the wrong contract and now assert the repair, plus the case that motivates it: a message one second from now is still unread against a repaired marker. Signed-off-by: Taksh <takshkothari09@gmail.com>
Review finding (P1, themiguelamador on block#6063): `markAllChannelsRead` wrote `latestByChannelRef` straight through `markContextRead`, bypassing `resolveChannelReadMarker` entirely, and then called `clearAll()` — which deleted the observed evidence. A future-dated event therefore still poisoned the marker through the Esc shortcut and the community rail's mark-all action, and lost its unread dot on the way out. Mark-all now resolves each channel through `resolveChannelReadMarkerUnix`, the same funnel as `markChannelRead`, so there is one place a marker can be written and one skew policy behind it. Folding the effective timestamp and the observed timestamp with `Math.max` also subsumes the old `??` preference between them. Because a repaired marker does not cover the future-dated event, that channel's observed evidence has to survive the clear: `clearAll` takes a retain set and rebuilds both refs from it, writing the survivors back instead of wiping the bucket. With no retain set it wipes as before. Signed-off-by: Taksh <takshkothari09@gmail.com>
Review finding (P1, themiguelamador on block#6063): constraining new local `markChannelRead` calls is not enough. Read markers are monotonic and are loaded from local storage and from encrypted NIP-RS events, so a marker written before this change — or by another desktop that still lacks it — stays year-ahead and is unrecoverable from the UI. Both entry points now apply the same `isPlausibleReadMarker` policy: - `readStoredReadState` skips an implausible persisted marker. Hydration is the last place it can be disarmed, because the next write persists whatever was loaded. - `sanitizeContexts` skips one in a decrypted NIP-RS blob, alongside the malformed-value checks it already made. Both *drop* rather than clamp. Clamping to the present would assert a read position nobody reached; dropping restores the channel to unread, which the user can see and act on. Markers inside the tolerance are untouched, so ordinary cross-device skew still merges normally. Signed-off-by: Taksh <takshkothari09@gmail.com>
|
All three confirmed and fixed, one commit each ( P1 — the clamp. You're right, and it's the same bug in miniature: mapping an outlier to P1 — mark-all. Confirmed: P1 — persisted and synced poison. Confirmed, and both entry points now share the policy: The policy itself ( Verification: desktop typecheck, biome, the channels suite (311 passed), and the full desktop suite (4,968 passed). |
|
Reviewed at head The relay already bounds That matters here mainly because it bears on the tolerance constant. The mechanism looks right to me and the funnel looks genuinely complete — every desktop marker 1. The tolerance is 120s, but the relay admits ±900s — so this rejects timestamps the relay
Consequence: a message the relay accepted at 2. The hydration path deletes rather than quarantines, and the predicate it deletes on cannot This is the one I would most want changed. As a filter that is survivable. It is not a filter: The comment at the drop site — markers are monotonic, "hydration is the last chance to disarm Suggestion: quarantine or clamp on the bulk paths rather than delete. 3. It passes Worked example: Not yours, but it should be named before this merges: mobile is entirely unpatched, and a #6046 was traced on mobile — The part worth flagging explicitly: mobile's marker update is monotonic in the same way desktop's I have not run your branch — this is from reading the diff at |
Refs #6046 — the desktop half. The issue traces the mobile Dart path and says the desktop write path still needs confirming; it does the same thing, so this fixes that side.
created_atis self-asserted by the sending client, and the relay bounds it for moderation commands but not for ordinary messages.resolveChannelReadMarkertook the newest timestamp verbatim, so one event dated ahead of the clock landed in the read marker and every genuinely newer message afterwards failedcreatedAt > readAt— no unread badge, no divider, and since #5983 no thread resume — until wall-clock time caught up with the bad value.Where it enters. Both inputs to the marker are event-derived, so both are clamped:
callerReadAt— a message's owncreated_at, via channel open, the Esc shortcut'slastMessageAt(useMarkAsReadShortcuts.ts:43), and mark-all-read (AppShell.tsx:415);observedLatest— the newest live event this client has seen.resolveChannelReadMarkeris the single funnel everymarkChannelReadcall goes through, so the ceiling lands in one place.Why a tolerance and not a hard
nowceiling. Ordinary skew between two machines is seconds; clamping hard would leave a just-received message unread until this clock caught up, which is a new bug in place of the old one. 120s is the relay's ownMAX_COMMAND_SKEW_SECS(crates/buzz-relay/src/handlers/moderation_commands.rs:81), i.e. the number this codebase already uses for "clock difference we accept". NIP-AB (crates/buzz-core/src/pairing/NIP-AB.md:581) already says clients MUST NOT setcreated_atin the future at all, so nothing legitimate is above the ceiling.One behavioural detail worth calling out.
clearObservednow reportsfalsewhen the marker was clamped below the observed event. That is deliberate: the future-dated event genuinely is unread, so dropping the observed refs would clear the sidebar dot this change exists to preserve. The poisoning message itself stays unread until its own timestamp arrives — truthful, and it no longer takes every later message down with it.Tests — 5 new cases in
unreadReadMarker.test.mjs, withnowSecondsinjected (the existing house pattern fromformatDayGroupLabel):now + 120;computeChannelUnreadMarker, which is the comparison the divider actually uses;Reverting the clamp turns the first three red.
Scope: desktop only, one concern. The mobile Dart path (
channel_detail_page.dart/message_read_state.dart) is the issue's own subject and is untouched here — I have no Flutter toolchain on this machine, so I would not be able to run anything I wrote there.Verified locally in
desktop/:pnpm typecheck,pnpm check(2 warnings + 2 infos, all pre-existing on main),pnpm test4959 passed,git diff --check.