macltok design decisions
the contract the working tree actually implements. if the code disagrees with this, one of them is lying and it should be fixed
two ways in, one way out
state changes are commands. ClientHandle::send queues one and hands back a
Pending, execute queues and waits, and there’s an awaited convenience method
for every command. each command answers exactly once with
Result<CommandOutput, Error>. drop the Pending if you don’t care, the
command still runs
pure reads skip the engine and ask the sdk directly through the handle. rooms, members, power levels, account data, devices, push notification resolution, and media bytes all live here
the event stream carries state and only state. timelines, history, typing, rooms, connection, encryption, verification, upload progress, session. no command results ride on it. drain it from its own task, it’s bounded and the engine waits for you
one error type for everything, Error { kind, message, challenge }. match on
kind. Interrupted means the engine stopped, the room closed, or the task
doing the work died before it could answer. challenge turns up with
InteractiveAuthRequired
engine
the run loop routes and does nothing slow. every task it spawns is registered
with a context. a command task that panics costs its caller an Interrupted
and gets logged. a watcher or a room actor that panics takes the engine down,
emitting Failed then Stopped. natively, anyway. on wasm a panic traps the
task mid poll and nothing is caught. the rest keeps running in an undefined
state and goes silent, no Failed and no Stopped, so the host hears of it
through on_panic, a free function since a panic hook belongs to the whole
instance rather than to one engine
sync restarts itself with backoff after an error or an unexpected stop, and
ConnectionChanged shows Offline meanwhile. a run that drops again inside
thirty seconds counts as a failure too, or a server that answers /versions
but refuses sync would bounce us between online and offline forever. every return to Online re-arms
the send queues, since the sdk parks a room’s queue on any send failure and
never unparks it. a recoverable failure while online gets the room another go
after a few seconds
logout and a rejected token both stop the engine. run resolves to a
Termination naming which of those it was, or a shutdown, or every handle
dropping, so the host never works it out from the events. Shutdown answers once
Stopped has gone out, every one of them if several were queued
Engine::run hands back a boxed future. the real one nests deep enough that
every crate spawning it would trip the compiler’s query depth
rooms
one actor per open room. the engine holds a slot per room, Active with a
sender or Closing with a fifo of opens and closes that arrived while it wound
down. those replay in order once the actor exits. a room you leave, or get
kicked or banned from, closes itself. it tells the engine first, and anything
already queued to it comes back to be answered or replayed, so an open racing a
leave still gets a fresh actor
however an actor ends, short of the engine stopping, its typing goes out empty
and each of its timelines sends TimelineClosed. so does a closed thread. a
timeline that never sent a reset never sends a close either
opening sends TimelineReset on the stream before it answers. opening an
already open room sends a fresh reset, so a second frontend gets a snapshot
sends, edits and replies run on the actor in order, so they reach the send queue in the order they were asked for. redactions, reactions and read receipts are network calls, so they run beside the actor and a slow one doesn’t queue the room behind it. closing lets them finish rather than lie
threads belong to their room’s actor. commands for a thread that’s still opening wait behind the open, up to the actor’s queue capacity
pagination runs one request at a time per timeline and emits HistoryChanged
around each. the sdk remembers where history starts, we don’t, because a gappy
sync can unload it again
the actor’s watchers are supervised, and a panic in any of them panics the
actor. its command work isn’t, a panicking redaction just answers Interrupted
and gets logged
timelines
TimelineDiff::try_apply refuses a diff whose indices don’t fit your snapshot
and leaves the snapshot alone. try_apply_all does the same for a whole batch,
checking every diff against lengths alone first, since that’s all a diff’s fit
depends on. snapshots are imbl::Vectors so keeping one is a pointer copy.
stop applying, resync, and carry on from the next TimelineReset. resync
kills the old watcher before the new reset goes out, so no stale diff can land
after it
TimelineId tells a room from a thread by its root event
every item is an event, a day divider, the read marker or the start. an event
carries its sender with avatar and name ambiguity, and a TimelineContent
saying what it actually is. messages keep their msgtype, membership and profile
changes and the common room state changes get their own variants, and anything
else is OtherState or Other with its type. nothing is flattened into english
in a body field
thread receipts for a thread that isn’t open go straight to the room, so marking a thread read works the same whether its room is on screen or not
replies carry the replied-to event once it’s loaded. the watcher asks the sdk for any it hasn’t got, once per event, since the sdk won’t on its own
edit, redact and react take an EventRef, so your own message can be edited or
reacted to before the server has echoed it back. redacting an unsent one just
cancels it
mutations
membership, moderation, power levels, room creation and account data writes go
through one keyed fifo scheduler. same room, same alias, same dm partner or
same account data type run in order, everything else runs at once.
create_direct hands back the existing dm when there is one
notifications and encryption each get one serial worker. the encryption worker owns the verification controller outright
power level changes build one state event from the local mapping, since the sdk’s own patcher is private
media
media is a read. ClientHandle::media takes a MediaId a retained timeline
handed you, mxc_media takes any plain mxc uri. both share one bounded
download path with the byte cap the sdk’s own download lacks, an hour’s total
deadline instead of the sdk client’s thirty seconds, and a minute’s patience
for a stalled stream. concurrent asks for the same bytes share one flight.
a thumbnail the server won’t make falls back to the file itself.
MediaId parses from a string, since a web client has to round trip it
the cache holds plaintext, keyed like the sdk keys it, so the sdk and macltok can read each other’s entries. that’s how your own encrypted uploads come back. the sdk keys plain and encrypted sources alike, so a plain event pointing at someone’s encrypted file can leave ciphertext in the cache. an encrypted read checks the cached bytes against the file’s hash and decrypts them if they match
media ids are registered as timelines render and forgotten when their timeline closes, least recently seen first once the cap is hit
uploads are commands that answer once the send queue has them. progress only
goes out on a room’s own send queue channel, so each room that uploads gets a
forwarder, subscribed before the upload is queued and restarted for uploads the
sdk resumes from last time. everything but progress reaches the client-wide
channel, so one watcher there follows every attachment in the queue and ends
each with one MediaUploadFinished. sent, cancelled, its room forgotten, or
missed when that channel overflowed at the wrong moment, since then there’s no
knowing which. a failure isn’t an ending, a wedged upload can still be retried.
uploads don’t belong to a room’s actor, so closing the room leaves them be
the forwarders hand their progress to that watcher rather than the stream, so
it’s the only thing speaking about uploads. anything that ever showed progress
or a failure is followed until it gets exactly one MediaUploadFinished, and
nothing about it goes out after. each forwarder relays its room’s sends and
cancels behind its progress, so an end never overtakes the ticks before it. an
upload ended from anywhere else is remembered until its forwarder’s own end
turns up, since until then there may be progress still queued for it. the
engine stopping ends nothing, Stopped covers it
sessions and secrets
Session::login_password, begin_oauth and restore share one client builder
with an encrypted store, refresh tokens and threads. the store is sqlite
natively and indexeddb in a browser. secret-bearing inputs don’t derive Clone
a SessionStore owns one login’s record and its store. login saves the first
record, restore loads it, and the engine a Session builds saves every token
rotation after that, since a refresh token works once and a host that missed one
save has lost the login. the session changes are subscribed the moment the
client exists, because the sdk can refresh during restore before any engine
does. a save runs off the event stream, so a full stream never holds it back,
and SessionTokensChanged only goes out once it has landed, or
SessionSaveFailed if it didn’t. natively the sdk’s save callback runs inside
the refresh, before the new token is used, and save_blocking writes there and
then. in a browser there’s only the async save, so a tab that dies between a
refresh and its save wakes up holding a spent token. without_persistence
hands the job back to the host. EncryptedSessionStore seals the record with
the sdk’s StoreCipher under the store’s passphrase and replaces it by rename,
so a crash mid save leaves the old one. it keeps the record out of the sdk’s
state store because sqlite writes custom values there in the clear, and a
client can’t open its store before it knows the homeserver the record holds.
discard deletes the record, then the store, since a record without its store
would restore into empty crypto. the host follows Termination::retain.
Nothing discards. Store calls soft_log_out, which keeps the store and
swaps the record for one holding the homeserver, user, device and oauth client
id with no tokens. restore hands that back as Restore::SoftLoggedOut, so a
restart still knows which device to log in again as, and the login overwrites
it. one store directory per login
room lists, spaces and profiles
RoomSummary has what a room list row needs without a second query. topic,
alias, member counts, heroes for nameless rooms, the inviter, where an upgraded
room went, a latest event preview, the joined spaces listing it, and for a
space the children it lists
parents come from one lazily built SpaceService shared by every handle.
children come straight from the space’s local state, joined or not.
space_hierarchy pages through the server’s view for browsing rooms you
haven’t joined, and holds its place between pages
own_profile and user_profile read profiles. name and avatar changes are
commands, and uploading an avatar gets the same byte cap as any upload
notifications and room data
the notification client is built lazily and shares the engine’s sync service. sdk ui types stay behind the boundary
rooms normalises on demand with bounded concurrency and sorts by latest event,
then name, then id. RoomsChanged is an invalidation, coalesced so one sync
touching fifty rooms is one event. a left room stays in the list with its
membership until it’s forgotten. push rule changes never touch room info, so
NotificationSettingsChanged brings a RoomsChanged along for the modes in
the summaries
verification
Accept, Confirm, Reject and Cancel. a request another device sends
shows up as Incoming and waits for Accept, or Cancel to turn it down. one
is only taken up while nothing else is in flight. reject tells the other side
the emoji didn’t match. the watchers subscribe before they look at the current
state, since the sdk only reports what changes after you ask. the watcher and
the action paths claim the terminal event, so a racing
cancel can’t turn Done into Cancelled
mirror
the mirror is a consumer like any other, behind the mirror feature, and it
takes any stream of events rather than the EventStream itself so a host keeps
the raw events without a second channel. state, its sequence number and the
broadcast send all move under one lock, which is how subscribe gets a
snapshot and a receiver with nothing between them. a lagged subscriber starts
over from a fresh state under the same lock
nothing on the draining path waits for an engine answer. a resync runs on its
own task, retrying with backoff until a reset or a close for its timeline
aborts it. rooms are fetched on a task too, one at a time with one more owed at
most. the only thing it does wait for is SessionStore::save, since a refresh
token spent before the save lands loses the login. SessionTokensChanged
never becomes an update, a lagging subscriber would lose it
public model
owned ruma ids and typed data wherever there’s a stable shape, Raw where
arbitrary content has to survive. anything expected to grow is
#[non_exhaustive]. Engine::from_client stays public for people who built
their own client