macltok review, 2026-09-24
Second full review, taken after the restructure and Tier 0 landed. It covers correctness, races, robustness, duplication, antipatterns and how easy the crate is to build a client on, then folds in the API gaps reported by Malice, the first real client built on macltok.
Every finding carries an id so fixes can reference it. Status starts as OPEN.
SDK claims cite matrix-sdk 0.18 source under ~/.cargo/registry and were
checked against the code rather than recalled.
Baseline when written. cargo fmt --check clean, all default-feature tests
pass. The sqlite and clippy runs are recorded at the end.
C. Correctness
C1. Verify(Reject) never rejects. done
VerificationController::reject (src/encryption.rs:339-389) validates the
SAS state twice and returns Ok(()) without ever calling sas.mismatch(). The
peer is never told the emoji did not match, the local flow stays in
comparison, and the command reports success. A user who presses “they don’t
match” gets no protection from the one action meant to provide it. No test
exercises Reject.
C2. Sync dies permanently after SyncState::Error. done
The connection watcher maps SyncState::Error to ConnectionState::Offline
(src/engine.rs:1338-1341) and nothing ever calls SyncService::start again.
In the SDK the supervisor breaks out of its loop on error, and even offline
mode ends in Error when its reachability check fails
(matrix-sdk-ui sync_service.rs:313-339). start() is the only way back
(sync_service.rs:629-649). The client sees “Offline” forever while the engine
still reports itself healthy. SyncState::Terminated has the same problem, it
becomes ConnectionState::Stopped while the engine keeps running, so
Stopped now means two different things.
Fix direction. Restart with backoff on Error and Terminated, and report
Stopped only from engine shutdown.
C3. A room’s send queue stays disabled after any send failure. done
The SDK disables a room’s queue after every send error, recoverable or not
(matrix-sdk send_queue/mod.rs:1011-1019), and nothing in matrix-sdk-ui turns
it back on. macltok re-enables only inside RetrySend
(src/engine.rs:2178), and does so globally. One network blip therefore
pauses every later message in that room until the user explicitly retries a
specific transaction. Element X re-enables queues when sync returns to
running.
Fix direction. Re-enable on each transition to Online, and keep
RetrySend for unwedging a single item.
C4. Own encrypted uploads cannot be fetched, and the media cache is split between two formats. done, with a round trip test through the sdk cache
macltok writes ciphertext into the media store under the Encrypted source key
and decrypts on every read (src/engine.rs:2799-2821, 2864-2874). The SDK
stores plaintext under that same key, both in Media::get_media_content
(matrix-sdk media.rs:451-480, 522-528) and in the send queue. After an
upload the send queue renames its plaintext cache entry to the remote
Encrypted source (send_queue/upload.rs:928-953). So fetching your own sent
image in an encrypted room reads plaintext, runs it through
AttachmentDecryptor, fails the hash check and returns MediaUnavailable. The
reverse also holds. Any SDK path that reads a macltok-written entry gets
ciphertext as if it were the image. The media tests force plaintext rooms
(tests/media.rs:766), which is why this passed.
Fix direction. Cache plaintext, matching the SDK, and keep the byte bound by decrypting in a streaming fashion before the write.
C5. Media single-flight is poisoned by a leader panic. folded into the media query rewrite under q1
In spawn_media_fetch (src/engine.rs:1870-1911) followers push their id and
return at once, trusting the leader to answer for them. If fetch_media
panics, the leader’s own id gets “command task panicked”, the followers get
nothing, and the (MediaId, MediaSize) entry is never removed. Every later
fetch of that media then becomes a follower of a dead leader and never
completes. Largely superseded if media fetch becomes a direct query (see
M2), but the fix is the same either way. Use a drop guard or a shared future
so the entry always clears.
C6. Back pagination can stick at “start reached” forever. done, the sdk already remembers
reached_start is a cached AtomicBool that is only ever set from a
pagination result (src/engine.rs:4060-4071, 4097). A limited sync with a
gap makes the event cache shrink to the last chunk
(event_cache/caches/room/state.rs:917-924), which the timeline applies as
VectorDiff::Clear. History is unloaded again but the flag stays true, so
every later Paginate emits StartReached without fetching anything. The
room scrolls to a stub and stops.
Fix direction. Drop the flag and follow Timeline::live_back_pagination_status
(matrix-sdk-ui timeline/pagination.rs:130-138), which is authoritative.
C7. Soft logout destroys the store. done, SessionAccessRejected { soft_logout } and a same-device login
SessionChange::UnknownToken carries soft_logout
(matrix-sdk client/mod.rs:166), but the watcher collapses it into a
payload-free SessionAccessRejected (src/engine.rs:1314). The README and the
event’s doc comment both tell the client to delete the saved record and the
store. On a soft logout the correct response is to re-authenticate and keep
the store, so following the documentation throws away every E2EE key for
nothing.
C8. Logout and rejected access leave the engine running. done
spawn_logout emits LoggedOut and returns (src/engine.rs:825-852). The
sync service, watchers and room actors keep running against a dead session,
and every later command fails with an authentication error. Neither logout nor
a hard UnknownToken shuts the engine down. The engine should stop itself,
emitting LoggedOut or the rejection before Stopped.
C9. MarkRead can move the read marker backwards and costs two requests. done, open rooms go through the timeline so stale receipts get dropped
mark_read (src/engine.rs:2072-2095) calls Room::send_single_receipt
twice. That call does no ordering check
(matrix-sdk room/mod.rs:2167-2201), so a client that marks an older event
drags both the public receipt and the fully-read marker back. The timeline API
refuses stale receipts (timeline/controller/mod.rs:1621-1690), and
Room::send_multiple_receipts sets fully read and read in one
/read_markers call (room/mod.rs:2213-2230). Private read receipts are not
offered at all.
Fix direction. When the room is open, route through the actor and use
Timeline::send_multiple_receipts. Otherwise use a single
Room::send_multiple_receipts.
C10. create_direct always creates a new room. done
Client::create_dm never reuses an existing DM
(matrix-sdk client/mod.rs:1830-1849). Starting a chat with someone you
already talk to creates a duplicate every time. Client::get_dm_room exists
for the lookup (client/mod.rs:1852).
C11. Unregistered tasks swallow panics, contradicting the design doc. folded into q1 and the actor restructure
The design doc says a watcher panic is terminal. But the timeline watcher
(src/engine.rs:589), pagination worker (446), pagination result task
(472) and verification watcher (src/encryption.rs:276) are bare
tokio::spawns. A panic in a timeline watcher freezes that timeline in
silence while commands keep succeeding. The reverse mistake also exists. The
account-data writers (src/engine.rs:1111, 1141) and the per-room media
upload watcher (1945) are registered as TaskContext::Watcher, so a panic
in a single write kills the whole engine.
C12. The room actor blocks on network requests. done, redact reactions and receipts run beside the actor. replies stay serial so sends keep their order
Each room actor runs its commands serially and awaits each one. Most sends
only enqueue, but Timeline::redact on a remote event is a direct HTTP call
(timeline/mod.rs:630), and so is removing a reaction
(controller/mod.rs:595). Room::make_reply_event can fetch the replied-to
event (room/reply.rs:108). A slow homeserver therefore stalls every later
send, paginate and thread command in that room behind one redaction.
C13. Media registry evicts media that is still on screen. done, lru by stamp and released on close
MediaRegistry::insert_all (src/engine.rs:358-370) evicts in first-insertion
order and does not refresh position on re-registration. One registry of 2048
entries is shared by every room. In a long-lived session, the first messages
loaded in a room that is still open are the first evicted, and fetching them
then fails with MediaUnknown. Closed timelines never release their entries,
so they compete with open ones.
C14. Smaller correctness issues. mostly done. open_room on an open room waits for q1 snapshots, the lagged watcher stays as is
CommandResultError::Failedrenders itsDisplaythroughDebug(src/transport.rs:840), so users see a struct dump.download_boundedbuilds a freshreqwest::Clientper fetch just to build a request (src/engine.rs:3181). That reloads the TLS roots every time.http_client.get(...)is enough. On a 401 it refreshes the token and then fails rather than retrying once.- On
Lagged, the session watcher assumes the dropped message was a token refresh (src/engine.rs:1315). A droppedUnknownTokenis lost. - Leaving a room does not close its open actor, so its timeline, typing subscription and room subscription outlive the membership.
open_roomon a room that is already open succeeds with noTimelineReset(src/engine.rs:1608). A second frontend sharing the engine gets success and no snapshot.- On shutdown, in-flight commands never get a terminal result. That is
acceptable, but the docs should say that
Stoppedfails everything still outstanding. - A store path reused after a login whose record was never persisted holds a crypto account for another device, and the SDK refuses it. Document one store directory per login.
A. API shape and client-builder friendliness
A1. Three completion mechanisms, and the useful one is rationed. OPEN
A client must know which of three patterns each operation uses.
sendreturns aCommandId. The client correlates it by hand againstCommandSucceeded,CommandSucceededWithRoomorCommandFailedon a shared stream.- Five direct methods (
join_room,create_room,create_direct,delete_devices, and the internal room-result path) await aoneshotand returnCommandResultError. These emit nothing on the stream. - The account-data writes await a
oneshotbut returnClientQueryError, with noCommandIdat all.
The headless example is mostly bookkeeping of ids. Malice gap 4 comes straight from this. The one pattern a web backend can use, an awaited result per request, is not offered for most operations.
A2. Pure reads modelled as commands, against the doc’s own rule. OPEN
The design doc says operations are split by whether they change state. Media
fetch changes no state, yet it is a MediaCommand whose bytes arrive as a
MediaReady broadcast on the shared stream. Every frontend then receives every
other frontend’s image bytes, up to 20 MiB each, and the requester has to
filter by MediaId.
A3. Every timeline item is a Message, with presentation strings in body. OPEN
normalise_timeline_item (src/normalise/timeline.rs) flattens every event
into TimelineMessage. The consequences for a client are these.
- Membership changes, profile changes and room state changes arrive as
messages whose body reads
[m.room.member]or similar. - Redacted and undecryptable events carry hard-coded English bodies,
<message redacted>and<unable to decrypt message>. Only UTD has a flag. A redaction cannot be told apart from a user who typed that text. msgtypeis dropped, so emotes and notices cannot be rendered differently from text.- No sender avatar, though the SDK profile has one.
display_name_ambiguousis ignored, so two “Alice“s look identical. - Reply references hold no preview content, and reply details are never
fetched. The SDK leaves them
Unavailableuntil the app callsTimeline::fetch_details_for_event(timeline/event_item/content/reply.rs:38), so replies to anything outside the loaded window never get a sender. TimelineItemis not#[non_exhaustive], so adding a state-event variant later is a break.
This needs a content enum, for example Message { msgtype, ... }, Redacted,
UnableToDecrypt, Membership, ProfileChange, RoomState, Poll and
Other { event_type }. It is the largest single rendering gap.
A4. Timeline commands cannot target local echoes. OPEN
Edit, Redact and ToggleReaction take an OwnedEventId and wrap it in
TimelineEventItemId::EventId. The SDK accepts transaction ids for all three,
but a client cannot edit, redact or react to a message it sent until the
server echo arrives. The commands should take the identifier that the model
already exposes.
A5. RoomSummary is too thin for a room list. OPEN
Missing fields, all cheap SDK reads. Topic, canonical alias, joined and invited member counts, heroes, tombstone and successor, inviter for invites, a latest event preview, and space parents and children (Malice gap 3).
A6. No incoming verification. OPEN
Only outgoing StartVerification exists. A request started from another
device, which is the normal “verify this new login” flow, is never observed.
macltok registers no handler for it, and the SDK has no stream, so the app
pattern is an event handler plus Encryption::get_verification_request
(encryption/mod.rs:1060).
A7. Missing #[non_exhaustive]. OPEN
Adding the attribute later is itself a breaking change, so it belongs now. Output types that will
grow are TimelineItem, CommandKind, RoomMembership, VerificationState,
HistoryState, KeyBackupState and RecoveryState. Input structs that will
grow need a constructor plus #[non_exhaustive]. These are
CreateRoomOptions, PasswordLogin, OAuthLogin and UploadThumbnail.
A8. Smaller API issues. OPEN
MediaCommand::UploadduplicatesUploadAttachment, and the handle exposes bothupload_mediaandupload_attachment.- Convenience methods cover room and account commands but not timeline send, edit, paginate, mark read, typing, recovery or verification, so a client uses two styles side by side.
HistoryState::Failed(String)andRoomPowerLevels::events: Vec<(String, i64)>are stringly typed where typed values exist.rooms_filteredre-implements the search half ofRoomListFilter::matches, which itself lowercases the needle once per room.RoomsChangedis sent once per notable room-info update with no coalescing at source. A sync touching fifty rooms queues fifty invalidations, each of which prompts a fullrooms()rebuild in a naive client.- The encryption defaults (
auto_enable_backups,OneShotbackup download) are hard-coded with no override.
D. Duplication and antipatterns
Line references are from the mechanical inventory taken during this review.
RoomActor::runis about 700 lines. The “queue while the thread is opening, else QueueFull” block appears four times (src/engine.rs:3367-3402,3518-3549,3625-3644), and emit-then-break 'actorabout twenty times. A thread registry type with oneroute(id, root, command)method removes most of it.- Six
spawn_*helpers share one shape of clone, spawn, run, emit (src/engine.rs:825-960).spawn_rename_devicehand-rolls the same thing withoutemit_command_result.CommandFailureis built by hand in nine places. The reply-or-event branch appears nine times. Onespawn_command(id, kind, future)plus one completion sink covers all of them, and A1 removes the branch entirely. media_http_request(src/engine.rs:2991-3175) is six near-identical branches that differ by endpoint type and token mode.- Twenty-one
client.get_room(...)lookups with hand-writtenRoomUnknownerrors, ten of them consecutive match arms inexecute_room_command. media_from_messageandmedia_registrationwalkMessageTypetwice for the same item. The descriptor can be derived from the registration.- The verification controller repeats its “done, then cancelled, then error”
terminal check eight times. It also carries an unused
_clientparameter, and the encryption worker builds a throwawayVerificationControllerto runRecover.EncryptionWorkis a single-variant enum. catch_unwindwith a “… panicked” string appears six times.- Test helpers
receive_until,event_messages,diff_items,start_engineandstopare copied across up to five test files. A sharedtests/support/mod.rsremoves them. model.rshas its test module in the middle of the file, with types defined after it.
T. Test coverage
No test touches the whole encryption domain (recovery, backups, verification),
all notification commands, invite, kick, ban and unban, power levels,
MarkRead, SetTyping, Redact, ToggleReaction, CloseThread, Logout,
RenameDevice, DeleteDevices, resolve_push_notification or
rooms_filtered. No test uses an encrypted room, which is how C4 got through.
M. Malice gaps, synthesised
Malice reported four gaps. Each one traces back to a finding above, and fixing the underlying finding closes the gap without special-casing it.
| Malice gap | Root cause | Proposed shape |
|---|---|---|
| Own profile | Not exposed at all | ClientHandle::own_profile(), a direct read via Account::fetch_user_profile (account.rs:353), returning a Profile { user_id, display_name, avatar }. Add user_profile(user_id) alongside. Setting the name and avatar become commands. |
| MXC avatar download | Media is reachable only through registered MediaIds (A2) | ClientHandle::mxc_media(uri, MediaRequest) as a bounded direct query, sharing the download, bound and cache code with timeline media. |
| Space children | RoomSummary gap (A5) | Wrap matrix_sdk_ui::spaces::SpaceService (spaces/mod.rs), which offers top-level joined spaces, joined_parents_of_child and a paginated /hierarchy SpaceRoomList. |
| Timeline media bytes per request | A1 and A2 | ClientHandle::media(media_id, size) as an awaited direct query. Remove MediaCommand::Fetch and Event::MediaReady. |
Malice’s rule, that no matrix-sdk use remains in the app, holds once these
land. None of the four needs an escape hatch.
Discussion items
These are genuine trade-offs rather than fixes, each with a recommendation.
Q1. How far to take awaited results
Option 1. Keep send and add handle.execute(command).await -> Result<CommandOutput, CommandFailure>
by carrying an optional oneshot in CommandEnvelope. Stream result events
continue for send. This is additive, but it leaves two mechanisms and every
engine path still branches on reply versus event.
Option 2. Make awaited results the only completion path. Remove CommandId,
CommandSucceeded*, CommandFailed and InteractiveAuthRequired from the
stream, which then carries only state changes. Fire-and-forget callers spawn a
task. Every engine path produces one Result and the nine reply-or-event
branches disappear.
The cost of option 2 is ordering. Today a TimelineReset is guaranteed to
precede the CommandSucceeded for its Open or Resync on the same stream.
Across a stream and a oneshot the guarantee weakens to “enqueued before”, so
a client draining the stream in another task can see the future resolve first.
Either Open and Resync return the snapshot in their result and stop
emitting TimelineReset, which then needs an epoch on TimelineChanged so
stale diffs from the old watcher can be discarded, or the contract states that
the snapshot arrives on the stream at some point after success. A second
frontend sharing the engine also stops seeing the other’s outcomes, though it
still sees every resulting state change.
Recommendation. Option 2 with snapshot-in-result and an epoch. It is the larger break, but it is the shape every client ends up wrapping the current API into, and breakage is free now.
Q2. Space children source
SpaceService gives the full graph, including unjoined children, over the
network via /hierarchy. Local m.space.child state is instant but lists only
what sync delivered and cannot show rooms the user has not joined.
Recommendation. Own a SpaceService in the engine, as with SyncService. Put
parent_spaces and child_rooms on RoomSummary from local state for the
sidebar, and add a paginated hierarchy query for browsing.
Q3. Timeline content model depth
A3 can be closed with a coarse enum (message, redacted, UTD, state, other) or
with typed variants for membership and profile changes, room name and topic,
polls and calls. Coarse is quick but pushes raw event parsing onto every
client. Recommendation. Typed variants for membership, profile, name, topic,
avatar and encryption changes, which cover what Element shows inline, plus
Other { event_type } as the escape.
Verification results
cargo fmt -- --checkclean.cargo test --all-targets49 passed, none failed.