Skip to content

Keep the fully-read marker monotonic - #565

Open
ruka-hamanasu wants to merge 3 commits into
matrix-construct:devfrom
ruka-hamanasu:fully-read-monotonic
Open

Keep the fully-read marker monotonic#565
ruka-hamanasu wants to merge 3 commits into
matrix-construct:devfrom
ruka-hamanasu:fully-read-monotonic

Conversation

@ruka-hamanasu

@ruka-hamanasu ruka-hamanasu commented Aug 28, 2026

Copy link
Copy Markdown

What does this PR do?

Unlike m.read and m.read.private receipts, which already have monotonic gates, the m.fully_read marker accepted any position, including backwards. In a multi-device setup, any client acting on a stale view (e.g. reposting its last-known position on startup, or a "mark as read" action on an old notification) silently regressed the marker that another device had already advanced. Users saw the unread line stuck in place or jumping backwards between syncs, and no amount of re-reading could be trusted to fix it, since whichever device was stalest would pull it back again. There is also a Matrix Rust SDK quirk that can cause older private read receipts to take precedence over fully-read if they're on events the client doesn't have locally, like when using an existing account on a new client.

Fix: before storing, resolve both the stored and the incoming marker event to their timeline positions and skip the write unless it advances, the same semantics as the existing receipt gates (position_advances): an unresolvable position on either side accepts the write. Backwards writes are accepted-but-ignored (200 OK) so the endpoints stay idempotent. Applies to both POST /read_markers and POST /receipt (m.fully_read). As a side effect, storage errors in /read_markers now propagate instead of being silently swallowed.

The spec doesn't say anything about whether fully-read can or should be allowed to go backward, but there isn't an obvious use case for it, and I'm not sure it can really be implemented coherently since I think it should be order-independent, which it can't be if it's not monotonic.

Testing: new HTTP-level integration test (fully_read_monotonic) — forward write stores; backwards writes via /read_markers (all three fields, as clients send them) and via /receipt leave the marker untouched. Verified to fail without the guard. Also verified live against a production server with a deterministic repro script (included in a separate commit, drop if unwanted).

I currently have this running on matrix.agiadn.org along with #564, and along with some FluffyChat fixes that were preventing it from updating the read marker in some cases, FluffyChat now marks rooms as read when I read them in all cases.

Found and fixed another case (possibly a regression?) for sliding sync clients: the monotonicity check was causing duplicate receipts to be rejected entirely with no new EDU. But the only case where we should be seeing these is if a client thinks it's reading unread messages. So we now send a new EDU in these cases so that those clients (Shildichat Next is the one I've been testing with) mark the room as read.

Checklist

  • Code is formatted with nightly cargo fmt and satisfies clippy and
    rustc lints; any allowed lint is justified by an obvious reason or a
    comment.
  • Complement compliance changes (new passes or new failures), if any,
    are noted in the description above.
  • [n/a] Config option changes were made in src/core/config/mod.rs doc
    comments and the regenerated tuwunel-example.toml is committed.
  • [n/a] User-facing changes are reflected in docs/.
  • I agree that my changes may be licensed under the Apache-2.0 licence
    and my conduct is in line with the Contributor's Covenant and
    Tuwunel's Code of Conduct.

Ruka Hamanasu added 2 commits August 28, 2026 16:34
A backwards m.fully_read write was accepted verbatim, so any stale
device or client could regress the marker another one already
advanced, leaving the unread line stuck or jumping backwards between
syncs.

Resolve both the stored and the incoming marker event to their
timeline positions and skip the write unless it advances, matching
the existing monotonic gates for m.read and m.read.private. An
unresolvable position on either side accepts the write, as for public
receipts. Backwards writes are silently accepted-but-ignored to keep
the endpoints idempotent. Applies to both /read_markers and /receipt;
storage errors in /read_markers now propagate instead of being
swallowed.

Adds an HTTP-level integration test (fully_read_monotonic) covering
forward writes, backwards writes via /read_markers, and backwards
writes via /receipt.
repro-fully-read-regression.sh verifies the monotonic fully-read
guard against a running server (forward write stores, backwards
writes via /read_markers and /receipt are ignored). probe-read-state.sh
dumps the server-side signals a client's unread state derives from
(notification counts, own receipts, m.fully_read, marked-unread) for
a given account and room.

Both default to a test server and test accounts; they are diagnostic
tooling, not general-purpose tests.
A receipt naming the currently stored event was rejected without
allocating a stream position, producing no EDU and no sync traffic at
all. A client which missed the original receipt (e.g. read on another
device while the room was outside its sliding-sync window) could never
repair its derived unread state in a quiet room: re-reading the same
position stayed silent, and no new events arrived to force a
recomputation. Element X / SchildiChat Next showed such rooms as
unread (grey client-side count) forever while other clients correctly
showed them read.

Identical receipts now rewrite the row under a fresh stream position
so the receipt EDU is re-emitted to sync (v3 ephemeral and the MSC4186
receipts extension), but still report no advance: notification counts
are not reset and the last-notification-read stamp is untouched, so
legitimate unread accumulated after the receipted event is preserved.
Strictly older receipts remain rejected (monotonicity, matrix-construct#516). The
private read marker path re-announces identically through its sync
gate; unannounced identical markers (own-send path) stay a no-op.
@jevolk
jevolk force-pushed the dev branch 3 times, most recently from 6ae89f9 to 93a663e Compare September 4, 2026 06:52
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.

1 participant