Conversation
A client with auto-download off has to decide between drawing a file and drawing something to press to fetch it, and had no way to tell which: the only question it could ask was `attachment_data`, which fetches on a miss -- so asking was indistinguishable from downloading. `Attachment::availability` answers it, as a field rather than a call, because the caller that needs it most is rendering a page of them across a language boundary and a round trip each would cost more than the whole read. It is a hint about what `attachment_data` will *do* -- read, join, or fetch -- rather than a promise about the file: a `cached` that is evicted first just means the call fetches, which is correct and merely slower. `cached` comes from the `attachment_cache` row rather than a stat, since that row exists precisely so eviction need not walk the directory, and `fetching` from `_in_flight`, checked first: a transfer under way has no cache row yet, and answering `absent` while the bytes are arriving is what puts a download button over the top of a progress bar. The message builders become members to reach it. They were file-local statics because they needed nothing but the database, which is no longer true -- the answer depends on the cache directory and on which transfers are running, and both of those are Client's. Threading it in as an argument was the alternative and a worse one: it is needed one level down, inside the reply targets a read builds with the same code, so every signature in between would have carried a parameter it does not use.
Once availability is part of the message, a change to it is a change to the message: an application holding one now holds something we know is wrong, and `message_updated` is how everything else in here says so. Fired on the three transitions a transfer makes -- `absent` to `fetching` when one starts, and back to `cached` or `absent` when it finishes or fails -- and for every message showing that file, since more than one routinely does. Not display pictures: one belongs to a conversation rather than to a message, and has its own progress handler to report itself with. The completion emit goes before the waiters are told, so that a waiter which reads a message in its callback finds the settled state rather than the one it was called about.
A cached file was named `blake2b(base_url)` -- unkeyed, unpersonalised, and so a function of information the adversary already has. That makes the cache directory a membership oracle: hash a url you are curious about, look for that filename, and you know whether this account downloaded it. Encrypting the contents does nothing about it, because the question is answered by the name before any file is opened. It applied to display pictures equally, so "does this account have a copy of that contact's avatar" was testable the same way. Which defeats the reason the cache is encrypted at all, stated in its own header: the disk is not a trusted place, it ends up in backups, in disk images, and in whoever's hands the machine does. So the name is a MAC rather than a digest, keyed on `client:cache_key` -- the secret the files are already encrypted under, which lives only in the encrypted database -- and personalised, so that one key serving two purposes is kept apart by construction rather than by the primitives happening not to meet. A listing now says how many files there are and nothing else. The key is applied in `Client::_cache_path`/`_cache_name` rather than at each call site: a caller that forgot it would silently get the guessable naming back, so the way to get a name is to ask the object that has the key. Every existing name changes, so the migration drops the cache rows: the files they name are then referenced by nothing, which is what a sweep collects. A cache is the one thing that costs nothing to lose.
Brings binding and retrieval of scoped enums, which the attachment code wants so a column whose values are a named set can be read back as that enum rather than as a bare int the caller has to recognise.
Every question the attachment cache asks of this table is "which messages show this file": which ones to report as fetching when a transfer starts, as cached when it finishes, and as absent again when the file is evicted. More than one message routinely quotes one file, since an attachment url is a hash of the encrypted body and so the same file sent twice by the same account lands at the same url. Without an index each of those answers is a scan of every attachment row in the account to touch a handful of them. Partial on url because a row without one has nothing to fetch and is never the subject of any of it.
A download that fails because the file server does not hold it, or because what arrived did not authenticate, says something about the file rather than about the attempt. Until now that was reported once, to whoever asked, and forgotten: the next thing to look at the message offered the same fetch again, and the only way to find out it could not work was to run it. Record it on the attachment instead, as `Attachment::unavailable`, so a display can say the file cannot be had rather than invite an attempt that will fail. The value says which of the two it was, because they are different things to tell a user: 404 -- which is how an expired upload answers -- means asking the sender for it again will work, and ATTACHMENT_UNREADABLE means it will not, since an attachment url is a hash of the encrypted body and a resend of the same file reproduces the same bytes. Nothing transient is recorded: a timeout or a server error says nothing about the file and the next attempt may well succeed. Set and cleared across every row naming the url, not just the row that was being fetched, since it is one file and a transcript must not show one message's copy as broken and another's as fine. Clearing happens when a new attachment row quoting that url arrives, which is the resend the user was told to ask for; the verdict has to be a cached answer rather than a permanent one, or it would block exactly the action that repairs it. Both directions emit message_updated for the messages they change.
`availability` says a download is running but not where it has reached, which leaves a conversation opened mid-transfer with a bar it cannot position: the progress reports went to whoever started the fetch, and a display that was not listening has no other way back to the figures. Carry them on the attachment alongside the state they qualify, counting the same encrypted bytes `AttachmentProgress` does, so a bar seeded from the message and then fed by reports continues rather than jumping. Also covers the availability states themselves, which went in untested: absent before anyone asks, fetching from the moment the transfer starts, cached once it finishes, and absent again when the cache gives that copy up for another file.
A message we just sent drew as something to press, and pressing it fetched back a file that had come off this disk a moment earlier. Keep what we upload in the attachment cache so an outgoing attachment answers "is this here" the same way an incoming one does. Under the rule that would have applied to the same file arriving -- shared with the auto-download path rather than restated -- plus anything on a gallery-viewable message whatever that rule says: a gallery is drawn as its pictures, so those bytes have to be here or the conversation shows placeholders over files it could read directly. The gallery rule decides that, the same rule the display applies, rather than an attachment happening to be an image: a message mixing a picture with a document draws as a list like any other. The source is re-read, since the upload streams the file rather than holding it, and only when its length still matches what was uploaded: a url names one particular encrypted body, and storing anything else under it would leave the cache answering for that url with the wrong bytes. Everything else about it is best effort -- a file that has since moved just means a download if the message is ever drawn again.
A cached file is named by a keyed hash of its url, and that hash was how
everything found it: "is this file here" hashed the url and looked the
result up. Two things follow from that, and both are worth being rid
of.
The hash becomes load-bearing for every file already downloaded rather
than only for the ones being written, so changing it cannot be done
without invalidating the lot -- which is exactly what 005 had to do.
And it does not run backwards, so eviction, which holds a file name and
needs the messages drawing that file, could not get to them at all: it
deleted the row and the file and said nothing, leaving conversations
showing pictures whose files had gone. `AttachmentAvailability` already
promises those changes are reported.
Give `attachment_cache` a surrogate key and have an attachment row
reference it, `ON DELETE SET NULL`. Nothing is duplicated -- the row
stores an integer, not a second copy of the name or the url -- and the
reference runs the way the questions do:
- availability comes back with the row, so reading a page no longer
hashes a url or looks anything up per attachment;
- finding an existing copy goes through the reference and uses the
name the entry was stored with, so the hash applies only to a file
being created and can change freely;
- eviction reads the messages off an index before the delete, and the
foreign key clears their reference on the way out.
The reconcile sweep drops its stale rows through the same path, so a
file that vanished behind our back is reported like one we deleted on
purpose. `_in_flight` moves to being keyed by url at the same time: the
hashed name is about what a directory listing reveals, which is not a
question a map in this process has.
Caching a file said nothing. On the download path that was hidden: the fetch's completion emitted right after storing, so the messages showing the file were told. An upload has no such completion, so keeping a copy of a file we sent -- which makes it drawable for every message quoting that url, and an attachment url is a hash of the encrypted body, so somebody having sent us the same file is the ordinary case -- told nobody but the message being sent. Move the emit to `_cache_attachment`, which is where a file becomes cached and is reached both ways. `store` then says whether it got that far, and the fetch reports the transfer ending only when it did not: a failure, a file that could not be written, and no cache at all are the same event to a reader, and all three leave nothing behind. The entry also has to leave `_in_flight` before any of that runs, or the report of a file arriving says it is still arriving. The test drives one file, shown by two messages, through every transition -- absent to fetching, back to absent for a transient failure, to cached, out again by eviction, to unavailable on a 404 and clear again on a resend -- and asserts which messages were told and what they were told the state was, rather than counting events. A second covers the upload case across conversations.
One cause routinely changes several messages, and reporting them one call at a time made the application reassemble what libsession had just taken apart. A file is the clearest case: it is one file, so evicting it, fetching it, or finding it unfetchable changes every message showing it at once -- and reporting a message also reports its replies, since a reply draws a preview of its target. So `message_added` and `message_updated` become `messages_added` and `messages_updated`, taking a vector. Two things are now promised that could not be before: - **ordered oldest first**, by the timestamp history is ordered on and then by id, so a handler applying a batch in order lands where a reload would put it. That is the reverse of `messages()`, which pages backwards from the newest; - **a batch may span conversations**, which is what the fan-out cases need. Nothing is lost by allowing it: `Message` already carries its own `conversation`, which is why the callbacks no longer take one. Eviction and the reconcile sweep now collect across the whole pass, so a message showing two of the files being dropped is told once rather than twice, and the ids are resolved to Messages only at the end -- one build per message, in its settled state rather than in whatever state it was in partway through the pass.
`FileTransferRequest::cancelled` was consulted on every upload path and on none of the streaming download ones, so cancelling a download only stopped us from using what arrived: the rest of the file came down the stream anyway and was dropped a chunk at a time. That is the common case rather than an unusual one. The stream scheme authenticates each chunk as it arrives, so a file that has been tampered with, or one that runs past the length its sender claimed, is known to be unusable from the bad chunk onwards -- and a large attachment can be most of the way through when that happens. Shutdown was the same: the routers cancel their active downloads and then close, with the transfers still running. Check the flag as each chunk arrives, which is the only moment this end of a transfer is given -- a download is driven by the server -- and abort the stream when it is set. A stalled stream still notices nothing, and the caller's timeouts remain what end those. The layer above needed one correction to suit it: a download we abandoned now completes with the cancellation we asked for, so the reason we abandoned it has to be preferred over the status when there is one. Otherwise a decryption failure would report itself as "download failed with status -10200", and the code that says a retry is pointless would be lost.
There was a getter for the cache limit and none for what is in it, so a client could show the ceiling but not how close to it the cache was. Summed from the cache index, in bytes on disk, which is the measure the limit is in and the one eviction compares against.
Eviction and `attachment_cache_size` were the same query written twice, which is also two copies of the answer to "why is this an optional" -- SQL sums an empty set to NULL, so an empty cache came back as no answer rather than as no bytes. Answer it once, in SQL, where the NULL is produced: `coalesce` makes the query incapable of returning one, so the C++ type stops advertising a state that cannot occur. That is how the rest of this file defaults a NULL already. The connection-taking overload is for eviction, which holds one and is about to write through it.
The schema grew over four migrations as this branch was built, one per commit that needed it, which is right while the commits have to stand on their own and wrong once they are merged: a migration is permanent, numbered and ordered, and these four describe intermediate states that have never existed anywhere but here. One migration per merged change instead. 005 now does what all four did, and the commits still show how it was arrived at. Its predecessors are exactly the databases that cannot take it -- they have some of the four recorded already -- so the branch goes on the schema-history skiplist: every commit of it but this one, since no state of it before this is something anything can have started from.
Nothing consumes a caption. It arrived on an incoming protobuf pointer, went into a column, came back out as a struct field, and went back out on the wire unchanged: no client renders it, nothing searches it, no index or constraint refers to it, and the conversation-preview query already skips it deliberately. A field that only round-trips is one more column to keep positionally correct in four statements for no behaviour. Field 11 is reserved rather than deleted: clients on the network keep sending it, and the number must never be handed to anything else. The checked-in protobuf output and the generated debug printer are regenerated to match, with protoc 3.21.12 - the version that produced the existing files.
An image attachment is a grey rectangle until its bytes arrive, and on a slow link that is most of the time the user spends looking at it. A ThumbHash rides on the pointer instead: 25 bytes at most, decodable into a blurred approximation of the picture, so the recipient draws something that resembles the image from the moment the message is listed. Carried opaquely. libsession stores the bytes, hands them back and puts them on the wire without decoding them or checking them against the file -- a client that wants a preview decodes it, and one that does not pays nothing. Like width and height beside it, encoding one needs the pixels, so it is the sender's to supply and an unset thumbhash stays unset. The 25-byte bound is the format's own ceiling: a 7x7 luminance DCT, 3x3 for each of P and Q, and 5x5 alpha. Over-long values are treated asymmetrically, as the surrounding code already treats everything else: thrown on the way out, where the value is our caller's and the mistake is theirs to see, and dropped on the way in, where it came from a remote peer and losing a placeholder must not cost the attachment it describes. Field 12, since 11 is reserved. The checked-in protobuf output and the generated debug printer are regenerated with protoc 3.21.12, the version that produced the existing files.
jagerman
force-pushed
the
attachment-availability
branch
from
September 23, 2026 21:39
5b3a786 to
9dfba1c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a
thumbhashfield to the attachment pointer, themessage_attachmentstable and bothattachment structs, so a recipient can draw an approximation of an image before the file itself
arrives.
Carried opaquely: libsession stores the bytes, hands them back and puts them on the wire without
decoding them or checking them against the file. The encoder and decoder are already upstream as
session::image::thumbhash(session-foundation#172, merged toclient), so aclient that wants the preview has both ends of it; this is only the transport.
Like
width/height, it is the sender's to supply -- encoding one needs the pixels -- and an unsetthumbhash stays unset.
25 bytes. The format's ceiling: a 7x7 luminance DCT, 3x3 for each of P and Q, and 5x5 alpha.
Over-long values follow the asymmetry the surrounding code already uses: thrown on the way out,
where the value is our caller's, and dropped on the way in, where it came from a remote peer and
losing a placeholder must not cost the attachment it describes.
Field 12, since 11 is reserved by the caption removal below it. The checked-in protobuf output and
the generated debug printer are regenerated with protoc 3.21.12, the version that produced the
existing files --
proto/debug_print.cppby hand, since the committed file is clang-formatted andthe generator's output is not.
On top of
attachment-availability-- this is based on it, not onclient, so it can go in with the rest ofthat work.
Tests
Two cases in
tests/test_client/attachments.cpp: a round trip in both directions, and the lengthcap in both of its forms plus the boundary. Full suite green (407 cases).
Note on the schema history check
tests/schema_history_check.shwas already failing onattachment-availabilitybefore this commit,at
3df4ae52(v1.8.0-408), over group-memberstateCHECK constraints -- nothing to do withattachments. Identical output with and without this change. Every revision from
916e6c5fforward,including this one, upgrades cleanly.