Skip to content

Full libsession Client support - #150

Draft
jagerman wants to merge 549 commits into
devfrom
client
Draft

jagerman wants to merge 549 commits into
devfrom
client

Conversation

@jagerman

Copy link
Copy Markdown
Member

This draft PR tracks the full client support being developed in libsession, to serve as the logic core of future versions of Session. This builds on top of PFS+PQ support (PR #103), and adds a ton of new features and capabilities needed to build a full Session client. The most notable starting point here is the Client class which is the entry point for an active programmable Session client.

This branch is not intended for review, but rather merely tracks the progress of the ongoing client branch (which will eventually become the dev branch).

jagerman and others added 30 commits August 14, 2026 22:24
Three things, which together are the other half of attachments.

An arriving message no longer has to have text.  _on_message_received
dropped anything with an empty body, so a message of nothing but files
was discarded outright -- it never became a message at all.  What makes
something not history is having no content of *either* kind, which is
also how session-ios spells the same rule.

Everything the sender listed gets a row, including a pointer too
malformed to fetch.  Dropping the unusable ones would make a message of
three files look like a message of two, and the count and ordering are
what a reader is shown; a row that cannot be saved is a smaller lie than
a row that is not there.  It also keeps the indices meaning what the
sender meant by them.

save_attachment fetches one and writes it to a caller-chosen path.
Nothing is downloaded until asked: an arriving message records where its
files are and stops, because whether a file is worth the bandwidth is the
application's decision and sometimes the user's.  Which of the two
encryptions applies is read from the url, so a caller neither chooses nor
needs to know.  The file lands at a temporary name and is renamed only
once decrypted and verified, so an interrupted save leaves no half-file
that looks finished, and the size ceiling is enforced against bytes
actually arriving rather than against anything claimed.

Progress reports with the same signature as send_message's on_upload, at
the cli agent's request: a display built for sending then works unchanged
in the other direction.
A message had no identity on the wire, and the content hash that stood in
for one could never have been it.  A one-to-one message is stored on both
participants' swarms and the two copies do not have the same bytes: the
copy we keep for our own devices carries syncTarget, naming who the
message was for, and that field is inside the Content protobuf, which is
what the hash was taken over.  So the sender and the recipient know the
same message by different hashes, and neither can derive the other's --
protobuf has no canonical serialisation, so parsing, dropping the field
and re-serialising is not guaranteed to reproduce another implementation's
bytes.  The column's comment claimed the opposite, that the hash was
"identical for every copy of a message"; that is true of encryption and
false of syncTarget.

So Content gains msgId: 8 random bytes, set before the copies diverge, so
every copy of a message carries the same one.  A message is identified by
(conversation, timestamp, msgId) -- always both, never the id alone,
which is sized only to separate messages within one sender-millisecond.
The alternative of identifying by timestamp is what the rest of Session
does, and it is already broken in the field: pasting several messages
stamps them identically, and clients keying on that treat them as one and
silently drop the rest.

sfixed64 rather than bytes: 8 bytes on the wire with no length prefix,
and signed, which is what every implementation can hold losslessly --
SQLite has no unsigned 64-bit integer, and our binding layer deletes the
uint64_t overload rather than let one be silently clamped.

That also replaces what the hash was doing locally, which was dedupe, and
does it better: being the same on both copies, our own message returning
from our swarm is recognised directly rather than by two copies happening
to hash alike, which they did not.  What is lost meanwhile is narrow: a
sender who retried a store that had actually succeeded shows twice, but
only from clients that set no msgId, and those clients drop
same-millisecond messages themselves -- so working around it here buys
correctness their own other devices and other recipients will not share.
A visible duplicate is the failure to prefer over a lost message.
Every message in the protocol that refers to another message did so by
the referenced message's timestamp, which does not identify it: a client
sending several messages at once stamps them identically, and matching on
that picks one of them arbitrarily.  Now that Content carries a msgId,
each of those references can carry one too.

Four are changed; each keeps its timestamp field and gains an optional
msgId beside it, so a client that sets neither still resolves the way it
always did.

Reaction and Quote also have their timestamp field renamed from `id`,
which was never an id -- the comment on Reaction's said "// Message
timestamp" and it was the only thing that said so.  Leaving it would have
put a field called `id` that is a timestamp next to a field called msgId
that is the identifier, which is worse than the original naming: a reader
picks the wrong one and nothing catches it.

UnsendRequest is the one that matters most.  It is the same defect, but
resolving it wrongly deletes a message the user did not ask to delete,
and the messages most likely to share a millisecond -- several pasted at
once -- are exactly the ones someone then thinks better of.  Its comment
says to leave a request unresolved rather than guess.

DataExtractionNotification's `timestamp` is deprecated outright rather
than kept as a fallback.  It was meant to be the referenced message's
timestamp, but nothing said so and the three clients disagreed:
session-ios set the message's, session-android set the current time, and
session-desktop set the message's, the server's, the received-at time or
zero depending on the path.  A receiver cannot tell which it was given,
so the value cannot be interpreted at all -- not even to test against
messages it holds.  It also gains attIndex, for which attachment.

ReceiptMessage is deliberately untouched: it references a *batch* of
messages, so it needs a shape decision rather than another optional
field, and that is still open.
ReceiptMessage acknowledges a batch, so unlike the other references it
gets a parallel array rather than a single optional field: msgId entry i
belongs to timestamp i.  Two rules make that safe.  A batch can mix
messages that carry a msgId with ones that do not, so entries are never
skipped and 0 means "this one has none" -- a value a real msgId takes
with probability 2^-64.  And a receiver finding the two arrays of
different lengths ignores the msgIds entirely rather than pairing up the
prefix, since a sender that got the alignment wrong has no correct prefix
to salvage.  That rule also covers the ordinary case of a sender too old
to set any: zero against N is a mismatch, so it falls back to timestamps
without needing to be a separate case.  Packed, since nothing parses it
yet and a batch of them is otherwise a tag per entry.

attIndex says what it means rather than leaving it to absence: -1 is all
of a message's attachments saved together, which is what saving from a
gallery view does, and absence now means only "not stated" -- a
SCREENSHOT, or a sender predating the field.  Neither is "the first one".
sint32 because -1 is a common value here and proto2 sign-extends a
negative int32 to ten bytes, where zigzag takes one.

KeyPair goes.  It carried a group's private key to its members back when
closed groups had a single shared keypair, and has been unreferenced
since closedGroupControlMessage was retired -- reserved a few lines
further down in this same file.  It survived as generated code nobody
called, in our copy and in session-ios's, reading like we broadcast
private keys.
save_attachment gains notify_sender, defaulting true because that is what
Session's other clients do and so what a sender expects.  Passing false
keeps the save private -- worth a setting in a UI, since whether fetching
someone's photo should be reported back to them is a privacy question
rather than a technical one.

Sent only after the file is down, decrypted and renamed into place: what
it claims is that we have their file, which is not true until we do.  And
only for a message we received -- saving from our own notifies nobody,
which covers a note to self, since that is outgoing.

The notification carries the pair that identifies a message, msgTimestamp
and msgId, plus attIndex for which file.  msgId is left unset when the
saved message carries none rather than sent as some placeholder: half of
an identifier is not a weaker match, it is an ambiguous one, and the
receiving side is meant to leave a reference it cannot resolve alone.

Nothing waits on the send, so its id goes in _quiet_sends and its
statuses are dropped as they arrive.  Without that they would collect in
_early_status against an id no message will ever claim -- the same leak
_sync_sends exists to avoid.
save_attachment had no test at all: the decryptors have known-answer
vectors, but nothing exercised download, decrypt, write and notify
together, which is where a wiring mistake would live.

Network::download becomes virtual so MockNetwork can serve one, matching
get_swarm and send_request, which are virtual for exactly this.  It was
not non-virtual by intent -- a method meant not to be overridden would be
private -- only because nothing had needed it.

The stubbed download hands the file over in chunks rather than in one
piece, since a decryptor that only works when given the whole thing is
one of the bugs this is for.  What it serves is a real attachment,
encrypted by attachment::encrypt, so the bytes are the ones a sender
would actually produce, padding and chunk boundaries included.

Covered: the file arrives byte-identical with its padding gone, nothing
is fetched until asked, progress reports open with 0/0 and end with
exactly one result, and the sender is told.  Then the cases worth more
than the happy path -- asked not to notify, we do not, and the file still
lands; and a file that fails to authenticate leaves neither a corrupt
file at the destination nor a .part beside it, and tells nobody a save
happened.

Both halves were checked by breaking them: suppressing the notification
fails the first, and renaming into place on failure fails the second.
Two gaps, both closed offline.

The first is that nothing exercised sending and saving *together*.  Each
half was tested against fixtures of its own making, so the two could
disagree about the url, its `d` fragment, the key or the size and both
still pass.  MockNetwork grows the part of a file server that matters
here: upload_file encrypts as the routers do and keeps the bytes, and
download serves them back by the id in the url.  A message sent with a
file can then be saved back through the same object, and a disagreement
between the halves shows up as bytes that do not match.  upload_file
joins download in being virtual -- a method meant not to be overridden
would be private; these were merely never needed.

The second is that legacy decryption had never run through the download
plumbing.  It has known-answer vectors, but those test the decryptor, not
the path that feeds it -- picking the scheme from the url, checking the
key length, holding the whole ciphertext because the MAC covers all of
it, trimming to the sender's size.  The blob is hard-coded rather than
generated: libsession has no legacy encryptor, deliberately, so producing
one here would mean writing the thing we chose not to have and then
testing it against itself.

Both were checked by breaking what they cover: generating the url without
its fragment fails the round trip, and ignoring the sender's size fails
the legacy save.

An older test asserted attachments were *not* uploaded, which was only
true because nothing could upload them.  With a file server present that
state is unreachable, so it now asserts what it was really about -- that
the recorded size is the file's own, not the padded ciphertext's.
…d field

A ProProof is version 0 by its type, not by a value it carries. Scope
ProProofVersion (enum class : uint8_t) and rename ProProof -> ProProof_v0 with
`using ProProof = ProProof_v0` (a note explains the alias becomes a variant or
virtual base when a v1 arrives). The version field is dropped from the C++ and C
proof structs and from operator==, and config no longer sets it.

fill_proof also stops reading a JSON `version` off the generate_pro_proof
response: the endpoint fixes the format and the proof's version is bound into its
signature via the personalisation, so there is nothing for the client to read or
gate on -- a future format is a new endpoint returning a new ProProof_vN, not a
version bump on this response. The wire version survives only where it must: the
protobuf-embedded proof a peer decodes with no endpoint context, where it selects
the layout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…the message

A received Pro proof whose wire version we don't recognize used to fail the
parse, discarding the entire message -- the recipient silently never saw it.
That's backwards for a forward-compat field: a newer proof format should cost the
sender their Pro affordances on old clients, not the whole message.

parse_pro_message now treats an unknown (or missing) version as a graceful
degrade: it flags the proof ProStatus::UnsupportedVersion (C
SESSION_PROTOCOL_PRO_STATUS_UNSUPPORTED_VERSION) and returns a non-pro message,
so the caller skips signature evaluation and delivers it as an ordinary message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
session::json::require<T>/maybe<T> now accept a scoped enum T, validated as its
underlying integer (nlohmann converts through the underlying type in extract), so
an enum-typed field can be requested directly rather than cast at the call site.
No current caller needs it -- it is a zero-cost `if constexpr` branch that only
instantiates when asked -- but it rounds out the generic helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The inner connection's UDP payload size was derived from the tunnel's
suggested_mtu, which cannot be made to mean what it needs to.  That
suggestion is the outer *endpoint's* configured max_udp_payload minus a
fixed overhead -- a policy knob rather than a measurement, unset in the
default configuration, so the subtraction does not even run there.  Where
it does run, on a client that pinned the outer, it produces an inner MTU
derived from someone's cap rather than from the path.

The two values that are real are no better suited.  A connection's
max_datagram_size is deliberately the size reachable *by splitting a
packet in two* -- get_max_datagram_size() is literally
get_max_datagram_piece() * (splitting ? 2 : 1) -- so sizing the inner
from it guarantees every inner packet splits.  The per-piece size that
would avoid splitting belongs to a connection this layer does not hold,
and knowing it once would not be enough anyway: it moves when the first
hop changes, and nothing here would hear about that.

So the inner is pinned to the one size QUIC guarantees every path
carries.  libquic splits datagrams that do not fit, so this is a
throughput question rather than a correctness one: a larger value would
win when the outer path is roomy and cost a split per datagram when it is
not, and we cannot tell which we have.  The outer connection still
discovers its own path MTU, which is where the gain actually is, and an
application whose discovery misbehaves can still pin it through
opt::quic_max_udp_payload.
An application that can save an attachment then wants to show that it
did: without it, "save" is offered forever and saving twice writes two
copies.  Every client faces that, so it belongs in the data model rather
than in each of them -- and Client is the only thing that knows a save
completed, since save_attachment is where it happens.

One nullable timestamp, not a path.  Where a file went is the
application's business: it chose the location and can move the file
afterwards, so a path recorded here would be wrong as soon as it did.
What is actually being asked is whether offering "save" again is
pointless, and a timestamp answers that.  It records an event rather than
describing the filesystem, so "this was saved, then" stays true after the
file is deleted.

The column means the same thing in both directions: when the *recipient*
of the message last saved this attachment.  For one we received that is
us; for one we sent it is them, from their notification.  Nothing has to
consult `outgoing` to read it, which is the trap the content-hash split
just cost us an afternoon of.

That second half needed the receive side of DataExtractionNotification,
which did not exist -- _on_message_received returned early on anything
without a DataMessage.  It resolves the message by (timestamp, msgId),
both required: the timestamp alone cannot identify a message sent in the
same millisecond as another, and msgId alone is far too small.  It also
requires the message to be one of ours in the conversation with the
sender, so a peer cannot make claims about a message they were not sent.
attIndex names one attachment, -1 means all of them saved together, and
absence means neither -- never "the first one".

The saved time is the notification's own timestamp, not msgTimestamp,
which identifies the message being talked about and is generally older;
the test fails if the two are confused, which is how the mistake was
caught while writing it.

Unset means "not known to have been saved" and never "not saved": the
outgoing half depends entirely on the other end volunteering a
notification, which no released client does in a form we can read.
A node that does not participate in session routing has no relay contact,
so nothing can be carried to it.  Until now the request that discovered
that simply failed: _fail_tunnel reported it to the caller, and the node
was struck out of the pool only *afterwards*.  So each such node cost one
dead request -- a send surfaced as a failed message, a poll as a round
that quietly returned nothing.

The swarm is not in question when this happens, only one member of it, so
recovery is to keep the swarm and move to the next member.  That is the
opposite of the 421 path, which fires when the swarm information itself
is wrong and recovers by throwing it away and re-resolving; the two rhyme
but must not be shared.  Hence a set of failed nodes rather than another
counter: "once per node" cannot be written as a number, since choosing
the next one has to know which are already spent.  Selection walks the
swarm in order and stops when nothing is left, reporting the original
failure rather than one of its own.

Request::retry_count becomes retry_421_count.  It only ever counted 421
redirects -- bounded by a config knob actually named redirect_retry_count
-- and a generically named field with one specific meaning is how the
next person makes the mistake this commit nearly made.

Timeouts come down to match: 10s per attempt where it was 20s, with a 60s
budget across the whole operation.  A node that has not answered in ten
seconds is better abandoned than waited on, which is the same conclusion
session-ios reaches from the other direction -- it spends its budget on
many short attempts rather than one patient one.  Each attempt gets the
per-request timeout or whatever is left of the overall budget, whichever
is shorter, so walking a swarm of unreachable members cannot outlive what
the caller asked for; below a two second remainder it gives up rather
than start something that cannot finish.

The upload path also gets the tunnel callbacks the download path already
had.  Without them an upload took the local port and sent into a mapping
whose session might not be up, so the handshake was dropped and retried
on QUIC's own timer, and an unreachable relay was discovered by waiting
out the request rather than being told.
Network's own logic sat above routing and below MockNetwork, which
overrides send_request wholesale -- so nothing exercised it.  Neither the
node walk nor _handle_421_retry, which has never had a test at all.

Two seams open it up, both following the pattern already used for Core:
TestHelper becomes a friend of Network and SnodePool.  Substituting the
router lets a test script how each node answers; seeding the swarm cache
lets get_swarm resolve at all, which it otherwise cannot in a test -- a
pool with no seed nodes has no swarms, which is what those "Cannot
refresh cache" lines in every run have been saying.  FakeRouter is
otherwise trivial: eight no-ops and one method that answers from a table.

Writing it corrected the implementation.  The comment claimed the swarm
comes back in a fixed order, so the walk would try members in the same
order every time; get_swarm actually shuffles before partitioning by
strike count, so what it really gives is least-struck-first in a random
order among equals.  That is the better preference anyway, and the walk
only needs a member never to be chosen twice -- but the comment asserted
something untrue and the tests now assert what is.

Covered: reaching a working member without spending any twice, trying
every member exactly once before giving up, reporting the original
failure rather than an invented one, not walking on a failure that is the
request's fault rather than the node's, not walking a request that names
no swarm, and the budget shortening retries and refusing to start one it
cannot finish.  The order-sensitive parts are asserted as distinctness
rather than sequence, and the suite was run five times over, since a
shuffle makes a flaky test easy to write by accident.

Verified by breaking the selection to ignore failed nodes, which fails
the two tests that exist to catch exactly that.
The user configs carry two different kinds of state and the schema
already had the seam for it: what an account *is* goes on accounts, and
what a conversation with it is like goes on conversations.  contact_info
mixes both, and base_group_info carries the conversation half again for
groups and communities -- the same fields meaning the same things -- so
those become one set of columns here rather than three.

Contact-ness becomes a row in `contacts` rather than a column on
accounts.  A flag would restate something the database already knows with
nothing keeping the two in agreement; existence cannot disagree with
itself.  It also puts each field where it means something: a name and a
picture belong to the account, because we learn those for people who are
not contacts at all, while a nickname and the approval pair need a
relationship before they mean anything.  A contact removed on another
device is then a row that goes away while the account stays, which is
what we want -- we have still seen that person.

The Pro proof lands on accounts even though ConvoInfoVolatile carries it
per conversation.  It describes the peer rather than the conversation,
and session-ios already resolves it into its per-account Profile record
at both ends -- nobody wants it per conversation, that is just where the
sync mechanism puts it.  Per-account also generalises where
per-conversation cannot: convo::group has no pro_base, so a group
member's proof has nowhere to go in the config at all, and can still be
cached locally.

Time units are per column now, matching whatever the config carries,
which is not uniform: ConvoInfoVolatile's last_read is milliseconds while
contact_info's created, profile_updated and mute_until are seconds.  One
rule produces both -- a value compared against a message timestamp is
milliseconds, everything else is seconds.  last_read qualifies because
messages.timestamp is the other side of that comparison, and at second
resolution reading a message stamped mid-second forces a choice between
leaving it unread and marking the rest of that second read.  Nothing else
needs to know the sub-second moment a conversation was created or a mute
expires, so conversations.created moves to seconds.  Every mirrored
column then round-trips with no conversion at all, so none of them can
dirty a config by being written back slightly differently.

The migrations go with it.  No released version has a client schema --
v1.8.0 has no src/client/schema at all -- so all six existed to upgrade
databases that cannot exist, which is why src/core/schema has been
full_schema alone all along.  Their comments were checked before
deleting: everything in 001-004 and 006 is already reproduced in the
column and table comments, and what is only in 005 argues about a column
that no longer exists.

That leaves the history check nothing to walk, since a schema change
without a migration is exactly the break it looks for.  SCHEMA_FLOOR
names the last revision behind that break; everything after it is still
walked, so consecutive commits must still upgrade cleanly and only the
cutoff itself is exempt.

Not done here: the conversation list still reads accounts.name rather
than preferring a nickname, because nothing populates contacts until the
config reconciliation lands.  Communities and blinded identities wait on
the same work -- both need servers split from rooms, which is a change to
tables that have no rows yet.
Core gains a Configs component holding the five config objects, and the
poll gains the four namespaces they live in.  Nothing yet interprets what
they contain -- that is Client's work -- but a config now arrives, merges
against what we hold, and survives a restart.

Core owns them because everything mechanical about a config is Core's
work already: retrieving from a namespace, merging, keeping the dump,
pushing what changed.  None of it needs to know what a contact is.  That
does mean core.hpp names config types, so core links config publicly
rather than privately now.

The dumps table is keyed by encryption_domain() rather than by storage
namespace.  The two coincide for four of the five configs and diverge
exactly where it matters: Local has no namespace of its own and reports
UserProfile's as a stand-in, so a namespace key would collide.  A domain
is already required to be unique per config type and can never change,
since changing it would break decryption of everything written under it.
for_namespace() maps by hand for the same reason -- searching over
storage_namespace() would find two configs for namespace 2 and answer
according to whatever order it looked in.

The configs are built on first use rather than in init(), because they
are encrypted to the account key and a Core opened with defer_account has
none yet.  Nothing can reach them before an account exists in any case: a
network cannot be attached without one, so neither polling nor pushing
runs.

Batch is the other half of the dumping story.  A config is dumped when it
changes, which is right when nobody knows any better -- but a caller
working through a batch of messages does know better, and dumping between
them writes intermediate states nobody reads.  One poll carrying four
config namespaces now dumps once rather than four times.  It nests, so
merge() can hold one itself without defeating a caller that already has.
It also fixes what a debounce timer can only approximate: debouncing is a
guess at where a batch ended, and the guess is only needed where nothing
knows.

Local is left out of the pushable set even though it already reports
needs_push() as false unconditionally.  That override is not what should
be keeping it off a swarm: the push path asks which configs have a
destination, and answering by trusting each config to decline makes "has
nowhere to go" indistinguishable from "has nothing to say".

The poll tests were positional -- three namespaces, Devices at index 1 --
so they broke on contact with a longer list and would break again on the
next one.  They now find a subrequest by the namespace it asks for, and
the mock response is built from the request so that it lines up by
construction, which is what the storage server does anyway.

Not here: pushing.  needs_push() reports it and nothing acts on it, so a
local change is held and dumped but never leaves.  The per-pubkey
debounce and the sequence request are the next piece.
A dirty config now reaches the swarm.  Everything owed goes in a single
sequence: a store per config message, then one delete naming every hash
those stores obsolete.  The delete goes last because a sequence stops at
its first failure, so nothing is removed before what replaces it has
landed -- which is the whole reason this is a sequence and not a batch.

Only this account's configs go in that request.  A group's live under a
different pubkey and are pushed separately even when the swarm turns out
to be the same one: a request carrying both would tell that swarm the
account and the group belong to the same person, and swarms are assigned
per pubkey, so the collision is ordinary rather than unlikely.  The
component is per-account, so its timer is already per-pubkey and a group
would get its own instance rather than a shared map.

Confirmation is per config and all-or-nothing across the messages it
split into.  Confirming a partial push would drop the parts that did land
from the obsolete list while leaving the config believing it is clean, so
the part that failed would never be sent again.

The debounce is the one from the design discussion: no sooner than two
seconds after the last change, no later than ten after the first.  Both
bounds are recomputed from timestamps on each firing rather than tracked,
so a re-arm cannot drift past the deadline the first change set.  A
steady trickle of changes therefore cannot defer a push indefinitely.

What arms it is releasing the outermost Batch, unconditionally rather
than only after a merge -- so the batch a poll already holds doubles as a
sweep, and a config changed locally without one is noticed within a poll
interval instead of sitting unpushed forever.

Deferred work is handed to the event loop, which outlives this component
and cannot be told to forget a call already scheduled.  Callbacks hold a
weak reference and do nothing once it has expired, which is what stops a
pending push firing into a destroyed Core.

swarm_request.hpp is the namespace rules, the timeouts, and the signed
values, moved out of core.cpp because more than one part of Core now
needs them and transcribing them twice is how one copy ends up
disagreeing with the storage server.  delete is the odd one out and gains
its own helper: it signs the hashes it names and carries neither a
namespace nor a timestamp.

The debounce is tested by driving the decision rather than waiting out
real intervals -- the timer fires on the event loop while a test reads
from its own thread, so a test that waited would be both slow and racy.
Verified by removing the cap and the in-flight guard, each of which fails
exactly the test written for it.
Merging updated the configs and dumped them, and nothing above Core was
told, so nothing could reconcile.  configs_changed now fires with the
namespaces a merge altered.

It carries namespaces rather than a diff.  A config diff describes a
transition between config *states*, and the reconciling layer's currency
is not a config state and is not recorded anywhere: a merge routinely
lands several updates forward at once, `<` only reaches back
config_lags, and a crash between merging and reconciling leaves the
database behind by an amount nothing measured.  Comparing against its own
stored rows is what makes reconciliation self-correcting, and a diff
would silently skip whatever those cases left behind -- permanently,
since nothing would revisit it.

Namespace is an unambiguous identifier *here* specifically because only
merging configs can appear, and Local never merges -- so the collision
with UserProfile's namespace, which forced for_namespace() to map by
hand, cannot arise on this path.

Firing is once per batch, after the dumps, so a handler never reads state
that is not yet on disk and never sees a poll half-applied.  Only merges
are reported: a config the application changed itself is not news to it,
and reporting it would invite reconciling its own write back over itself.

ConfigBase gains a seqno() accessor, which is what makes "did that merge
do anything?" answerable.  The hash set merge() returns does not answer
it -- a hash counts as parsed whether or not it turned out to be useful,
so a stale config that changed nothing still comes back in it.  The seqno
moves on a local change, on adopting a higher config, and on resolving a
conflict, and stays put when the merge was a no-op.

Verified by reporting unconditionally, which fails the case that merges
the same message twice.
The watermarks decide when the producer thread -- the loop reading and
encrypting the file -- is paused and resumed against the stream's unsent
buffer.  Resuming at 128KB left very little to send while that thread was
waking up and doing its next read, and that read is blocking file I/O, so
the stream could run dry waiting on the disk rather than on the network.
Raising the pair to 1MB/512KB leaves half a megabyte still queued at the
moment the producer is asked for more.

The comment on the loop records why that matters, since `next()` looking
like a plain iterator call is exactly what makes the blocking easy to
miss.

Also drops an <fstream> include nothing used.
configs_changed decides "did this merge change anything" by comparing the
seqno either side of it, which is only sound if a merge cannot alter the
data while leaving the seqno alone.  That was argued rather than checked,
and the argument had a hole in it.

The worry is a conflict at our *own* seqno: if resolving two
same-numbered configs could adopt one of them in place, the seqno would
sit still while the contents changed, and the comparison would miss it
silently and permanently -- nothing revisits a config it decided was
unchanged.

Four combinations, because two things vary independently.  Our own config
is either Dirty, meaning the change has not been serialised into a
message and nothing else can have built on it, or Waiting, meaning a
message exists and has gone to the swarm where another device may have
seen it -- and _merge takes a different path for each, since it passes
`_state == ConfigState::Dirty` to make_config_message.  Crossed with the
incoming config either diverging from ours or containing ours outright,
the latter being where "adopt the superset" would be tempting if the
superset were chosen on data rather than on the diff chain.

It is not: two distinct messages at one seqno are a conflict whatever
their contents, and a conflict resolves to one past the highest.  All
four report.

ConfigBase::seqno() being public is what makes the precondition
assertable at all -- the test checks the two configs really are at the
same seqno before merging, so it cannot pass by failing to set up the
case it claims to cover.
Two devices making the same change from the same starting point is the
case where a naive merge could leave us dirty with nothing to say, and
push an update carrying no changes.  It does not: the result settles
clean against the other device's message, and owes no push.

That is what the test pins down.  It deliberately does not pin the seqno,
which advances despite the data not changing -- merging while dirty
builds a MutableConfigMessage, and that constructor increments
unconditionally by design, while _merge's unwind for the spurious
increment only applies when the winning config is our own.  Here it is
the incoming one, deliberately: a hash-less local copy is placed last so
that an identical incoming message wins and we adopt its hash rather than
having none.

So we end clean at a seqno the swarm has never seen, and that is a real
cost rather than a curiosity.  The "within N" rule drops any config more
than config_lags behind the highest seqno, and that window is counted in
seqno values rather than in actual updates -- so a seqno consumed without
publishing anything shortens it in real terms, and a device holding an
unpushed change has it discarded sooner than it should be.  Two devices
making the same change is ordinary enough (marking the same conversation
read, approving the same contact) for this to add up.

Not asserted, then, because it is a wart to be fixed rather than a
contract: pinning it would make the fix look like a regression.

It also does not assert whether this reported to the application.  It
currently does, since the seqno moved, so the reconciling layer walks and
finds nothing to do: idempotent, and the safe direction to be wrong in.
Were the merge taught to recognise an identical config, this would stop
reporting and nothing here would need to change.  The direction that must
never happen -- data changing without a report -- is covered separately.
The spurious increment from merging an identical config does not stop at
one wasted number.  Two devices agree at seqno 1; the identical merge
leaves us clean at 2 while the swarm's newest is still 1.  The other
device, correctly at 1, then makes an ordinary change of its own -- which
lands on 2, the value we already consumed.  What should have been a clean
adoption is a conflict against our phantom, so it resolves to 3 and
leaves us dirty, owing a push we would otherwise not have made.

One real change, two seqnos, and an extra round trip.  It settles once
the other device adopts ours, so it does not run away, but the cost falls
on the "within N" window: two of its five spent carrying one change means
a device holding an unpushed change is discarded that much sooner.

The test asserts only that no data is lost through the collision -- both
changes survive -- since the seqno arithmetic here is the defect and
pinning it would make a fix look like a regression.  The reasoning is
recorded alongside so the fix does not have to rediscover it.
Ported from the dev-side change (Audric Ackermann, f0b9ab2) that better aligns
the proof with the protobuf. Rather than the proof carrying a version selector,
its format is bound entirely by the signing domain prefix (the `_v0` in
BUILD_PROOF_DOMAIN is part of the signed bytes), and a future format arrives as
its own protobuf field/message -- not a version bump on this one.

- Drop the `version` field from the protobuf ProProof (regenerated pb.cc/pb.h),
  renumbering so field 1 stays reserved.
- Remove the ProProofVersion enum; rename ProProof_v0 back to ProProof and drop
  the alias (a new format is a new type, not a vN of this one).
- Replace ProStatus::UnsupportedVersion with the general ProStatus::Invalid: a
  proof we cannot read (sender attached none, or it is in a format we don't
  know, so to us it simply isn't present) is unusable, with no reason-specific
  status for callers to learn. Such a message is still delivered as non-pro
  rather than dropped.
- Detect that via `!pro_msg.has_proof()` instead of a version check.
- The json scoped-enum helper support stays (now caller-less), noted as such.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The first config that does anything.  A merged UserProfile now reaches
the tables that answer queries: our own name and picture land on our
accounts row, and note to self gets its priority and disappearing timer.

It runs the opposite way round to the ones that follow.  UserProfile is
authoritative and the accounts row is a projection of it, because the
config holds structure the row does not model: a second picture slot
carrying the same image at a fresh URL, so a linked device that already
has those bytes can skip fetching them again.  Re-deriving the config
from the row would flatten the two and destroy another device's reupload.

What decides whether a conversation exists is the config, not whether
there are messages in it.  Emptying a conversation does not end it, and
an account reimported onto a new client has nothing but its configs to
rebuild the conversation list from -- so anything that existed only where
messages had been would quietly disappear.

For a contact that statement is the presence of an entry in Contacts,
with priority saying separately whether it is shown.  Note to self has no
entry to be present or absent, because UserProfile exists from the moment
the account does: it holds the account's own name.  So priority carries
both there, and a negative value is the only way that config can say
"there is no note-to-self conversation".  Hence a new account writes one
-- an unset value reads back as 0, which would otherwise be
indistinguishable from someone deliberately choosing to show it, and
would make the conversation appear on every device on the account.  And
hence writing a note moves it back, since that is how "there is one now"
gets recorded.  Restoring an account is left alone: its real values are
about to arrive from the swarm, and a local one would compete with them.

Revealing it reconciles the whole config rather than just the priority.
A timer set on another device has been waiting with nowhere to attach,
and the moment the conversation exists is when it can land; nothing else
would deliver it, because a local change is not a merge and reports
nothing back to us.

Creating the row is guarded by find_conversation rather than calling
ensure_conversation outright: that helper bumps last_activity on a row
that already exists, so unguarded it would shove note to self back to the
top of the list on every config merge.

nts_expiry carries a duration and no mode, because only one mode means
anything when the reader is also the writer; it is stored as after-send.

The test helpers build the other device from *our* dump rather than from
nothing, since a device invented alongside us lands on the same seqno as
the account defaults and has to be merged with them, which is a different
scenario.
A change notification only arrives for state that changes after someone
is listening, which leaves three ways for the tables to fall behind with
nothing to announce it: a config merged by a version that did not yet
know how to reconcile it, a crash between merging and reconciling, and a
dump restored from an older state.  In each the database is behind by an
amount nothing recorded, and no further notification is owed -- a contact
list that has stopped changing would stay unreconciled indefinitely.

Core already polls and merges all four config namespaces, so this is not
hypothetical: everything arriving for the three that have no reconciler
yet is held in the configs and dumped, and would sit there unread even
after a reconciler was written for it.

Running the walk at startup makes all three self-healing rather than
merely self-correcting, and it is safe to run when nothing has changed
because reconciliation compares rather than replays: it costs a pass and
writes nothing.

Guarded on have_account(), since a Core opened with defer_account has no
key to decrypt the configs with -- and nothing is missed, because an
account that does not exist has no configs to have fallen behind, and
whatever arrives once it does comes through a merge, which reports
itself.
A safety catch for working against a real account: with push_enabled
false, nothing this device does can reach the account's other devices or
overwrite what they hold.

Checked at the point of sending rather than at scheduling, so everything
short of the wire still happens -- changes are held, dumped, and reported
by needs_push().  It is deliberately not a pretence that the push
succeeded: the state reads as unpublished rather than as settled, and
switching it back on lets the next push carry everything accumulated
since.
Groups have had `delete_before` and `delete_attach_before` since they
existed: an instruction that messages (or just attachments) older than a
timestamp are to be deleted, and arriving ones older than it dropped.
One-to-one conversations never got them, which is why clearing a 1:1 is
local-only while clearing a group as admin propagates, and why hiding one
has to be faked from config *timestamps* instead.

That fake is worth spelling out, because it is what this replaces.  With
nothing recording when a conversation was cleared, a client compares an
incoming message's timestamp against the newest config message it has
processed -- minus two minutes of slack for clock skew -- and drops the
message if the config looks newer.  So a conversation cleared at noon and
some unrelated setting changed at five o'clock is treated as having been
cleared at five: every message in between is discarded, permanently,
because the swarm cursor moves past them.  The imprecision is in the
format rather than in any client -- no config records when a field
changed -- so recording the instruction is the only fix.

`d` and `D` in the contact dict, deliberately the same letters the group
info config uses, and seconds to match it: these two will be read side by
side forever by code asking "what is the delete-before for this
conversation", and one unit across both is worth more than the second of
precision milliseconds would buy.

Note to self gets its own pair in UserProfile rather than a contacts
entry.  It has no contacts entry to carry them: UserProfile exists from
the moment the account does, so there is nothing there to be present or
absent.  Keying one under our own pubkey was considered and rejected --
every client already carries a "self is not a contact" carve-out, and
android has five contact iterations with no such filter, one of which
would put us in the contact picker for group creation.

`delete_attach_before` is only stored while it says something
`delete_before` does not.  Deleting a message takes its attachments with
it, so setting either of the pair drops the attachment value when the
message value already covers it, rather than leaving an instruction that
means nothing.
`path` records where a file went or came from -- closer to a symlink than
a handle.  The file at the far end is the user's in both directions:
theirs to attach, or theirs to save wherever they chose.  So nothing here
may unlink it, and the comment now says so rather than only hinting at it
by mentioning that the file might vanish.

Worth stating now because a delete-attachments-before instruction is
coming, and the obvious reading of it -- delete the files -- would be
deleting the user's own originals on the instruction of a config merge.
What it will actually be entitled to remove is a cache of our own:
attachments kept encrypted so they outlive the file server.  That is a
third thing at a path we picked, and it wants its own column; sharing
this one would leave the delete path unable to tell our copy from the
user's, and the safe implementation would then be the useless one.

Comment-only: the schema fingerprint strips comments, so nothing to
migrate.
Both directions at once, because they are not separable: the destructive
rule that a contact absent from the merged config was deleted elsewhere is
only safe while a locally-created contact is written into the config as it
is created.  Ship the reading half alone and the first merge eats every
conversation we made ourselves.

Each entry lands on three tables, because it carries three kinds of fact.
Who someone is goes on accounts, which anyone we have merely *seen* also
has.  The relationship goes on contacts, where the row existing is what
being a contact means.  How the conversation behaves goes on
conversations, which is also what brings that conversation into being: a
conversation is not defined by having messages in it, or emptying one
would lose it and reimporting an account would lose every empty one.

Whose name wins is decided by profile_updated rather than by who spoke
last -- the config's name is applied only when its stamp is at least as
new as what we hold -- so a profile carried on a message that arrived out
of order cannot displace a newer one.  Out of order is the normal case,
since a profile is observed from group messages as readily as from a DM.

The reverse direction re-derives an entry from the rows rather than being
applied alongside each change.  The mapping then lives in one place and
cannot drift from the tables it describes; a caller has to remember to
call it, but cannot remember to call it wrongly.  Presence follows the
row too: no contacts row means no entry, so removing a contact is "delete
the row and sync" rather than a second place that must remember to erase.

Which is worth nothing unless the two directions agree exactly, so that
is asserted: apply a config to the tables, derive one back, and the config
must still be *clean*.  Anything lost, rounded or defaulted in transit
shows up as a dirty config -- and a mapping that dirties on every pass
would push a pointless update after every merge, forever.  Verified by
perturbing one field by one second, which fails it.

A nickname now wins over the name a contact chose for themselves, which
is what the accounts/contacts split was for and had no effect until
something populated it.

Not here: deletion.  A contact absent from the config is not yet acted
on, so this pass only ever adds and updates.  That lands next, on top of
the round-trip property rather than before it.
A contact we hold that the merged config does not mention was removed
elsewhere, and removing a contact takes the conversation and its history
with it -- deliberately: someone who deletes a conversation means it
deleted, not hidden on one device.  Merely hiding is a negative priority
and arrives as an ordinary settings change, so an absent entry can only
mean the stronger thing.

The account row stays.  We may have seen that person in a group or a
community and need their profile to render it; what is deleted is the
relationship, not the person.  Messages, raw content and attachments go
with the conversation by cascade, and the files those attachments name are
the user's -- theirs to attach or theirs to have saved -- so nothing is
unlinked.

_reconcile_all now derives outward before reconciling inward, and the
order is load-bearing.  Creating a contact commits the row and updates the
config in memory, but the dump is written later, so a crash in between
leaves a row the config has never heard of -- indistinguishable, inward,
from one deleted elsewhere, and destroyed with its history.  Publishing
what we hold first tells those two apart.  It is safe to run because
assigning a config field its existing value does not dirty it, so the
outward pass writes only where the dump was actually stale; a contact
deleted elsewhere is still in our config at that point, so it is not
resurrected and the deletion arrives and wins on its own merits.

Both are tested, and the removal test had to be fixed before it tested
anything: built from a fresh config, the second device is a rival at the
same seqno and merges to the *union* of the two, so nothing is ever
absent.  It now descends from what we published, which is what a device
that has seen our config actually is.

Recorded but not built: once attachments are cached, freeing them cannot
be done by deleting files alongside the rows that name them, because
cascade takes those rows with no code of ours running.  It wants the same
treatment as everything else here -- list the paths still referenced, list
the directory, unlink the difference -- which survives a cascade and also
collects what a crash mid-download left behind.
jagerman and others added 30 commits September 23, 2026 13:59
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.
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.
One migration per merged change: dropping the caption column is part of
what this branch does to message_attachments, so it belongs in 005 with
the rest rather than in a 006 of its own.
`unavailable` was recorded across every row naming the url, on the
reasoning that a url is a hash of the encrypted body and so names one
file read one way.  That holds for an honest sender and no one else:
nothing ties a pointer's key, digest or size to its url, so anyone can
send a pointer to a file they have seen with a key of their own making.
Fetching it fails to authenticate, and that failure was then written to
every copy of the file in every conversation -- permanently, and with
the message that asking for a resend would not help.

A 404 genuinely is the server's answer about the url, and stays
url-wide.  An authentication or size failure is about the claim, so it
now marks only rows making the same claim; and a resend, which is
evidence the server holds the file again, clears only the former, since
the resent bytes would reproduce the latter.

The same poisoning was reachable without the database, through joining:
transfers were keyed by url, so a display asking for a file while a bad
pointer's transfer ran would take that transfer's answer.  They are now
keyed by the whole claim, which honest copies share, so those still
share one download; whether anything is fetching a file is still asked
of the url.

The struct carrying url, key, digest and size is renamed RemoteFile
while it is being threaded through more places: it was called a
pointer after the protobuf message it comes from, which is not what the
word means in C++.
A fetch that landed marked every row naming the url as cached and left
`unavailable` as it was, so a row could say both at once -- the file is
here, and the file cannot be had.  Reachable whenever a not_found was
the server's passing trouble and a later attempt worked, or when one
row's key failed and another's did not.

A file in the cache is served to every row naming it, whatever key that
row carries, so none of them can honestly keep a verdict that it is
unobtainable.  Clear it in the same statement that marks them cached.
`_download_decrypted` throws before sending anything when there is no
network, the url is not a download url, or the key or digest is the
wrong length.  `_fetch_cached` had already registered the transfer and
reported it as `fetching`, and the completion that would have removed
it never runs -- so the file said `fetching` for the life of the
process, and every later request for it joined a transfer that had
never been sent and waited for ever.

Register, try to start, and report `fetching` only once it has; on a
throw, remove the entry and tell its waiters.  Told here rather than
rethrown, because not every caller still holds its handler by then:
`profile_picture` moves it in.

Two more of the same shape.  An auto-download that throws -- a pointer
with no url is enough -- escaped the arrival path before the message
was announced, so it was stored and never reported; it is now caught
per attachment.  And a save that cannot start left its temporary file
beside the destination.
Both callers already held one and spelled out all four of its fields to
make the call; the function now takes it whole.
`Attachment` said two things about its file: `availability` -- cached,
fetching or absent -- and `unavailable`, what the last attempt found.
A display has one thing to decide, what to draw, and had to rank the
two itself with nothing in the header saying how.  `absent` in
particular meant "go and look at the other field": fetchable if that
was unset, not worth offering if it was.

There is a ranking, and it is total: a cached file is what to draw
whatever an earlier attempt found, a transfer running is about to
settle it, and only with neither does the verdict matter.  So say it
once, as `not_found` and `unreadable` alongside the other three, and
`absent` goes back to meaning only that the decision is the user's.

The stored codes keep their own private type.  The numbers are what is
on disk, and the public enum's values are not meant to be anything in
particular; tying the two together would let renumbering one silently
change the meaning of the other.

A verdict is also a fact about the last attempt rather than about the
disk, so it is reported with no cache configured, where the other
states all read as absent.
Its preamble described `name` as the hashed base url that a row could be
matched against a message_attachments.url by, which is what keying the
hash and adding the `cached` reference abolished: the hash does not run
backwards, and nothing finds an entry by recomputing it.  It also said
the sweep adopts files that have no row, where it deletes them.

message_raw_content's comment is put back above its own table; folding
002 into this file had left the cache's block between them, with a
stray marker where the join was.
Every other migration here is bare SQL, with the explanation living in
full_schema.sql beside the thing it explains; 005 had grown a copy of
most of that prose, plus an account of how the branch arrived at it.
Cut it to the statements and the one thing only a migration needs to
say, that it throws the cache away.
A few this branch added describe what something used to be, or argue
against an arrangement nobody would now propose, instead of saying why
it is the way it is.
Keeping a copy of a file we sent reads it back and encrypts it whole, on
the loop -- the same work caching a download does.  A download is
refused past MAX_REGULAR_SIZE, so that work is bounded there; an upload
is not, and the only other check was the auto-download size limit,
which is optional.  With none set, sending a large file stalled the
loop for as long as reading and encrypting it took.

Apply the download's own ceiling.  The cost is that a file past it
cannot be drawn from the sender's own transcript -- but no recipient
using this library can fetch it either, so that is where such a file
already stands everywhere else.
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.
Brings liboxenquic 1.9.0, whose JobQueue owns the timers it runs, and
the router's per-path stats.

Loop::call_every is deprecated there, so every timer moves to
JobQueue::add_timer: Core's poll, renew and probe timers onto Core's
queue, and QuicFileClient's idle and upload progress timers onto a queue
of its own, along with the jobs that reach the client.  A queue cancels
its timers when it stops, so Core no longer relies on declaring its poll
timer last, and dropping a subscription no longer has to defer freeing a
ticker it may be running inside.  The progress timer is still removed on
every exit from the upload, since its callback borrows the request.

Stream::get_stats() gains a retained count, and a session's path is now
reported with its stats rather than as a bare list of hops.
A subdirectory whose cmake_minimum_required predates 3.9 has CMP0069
OLD, so CMake ignores CMAKE_INTERPROCEDURAL_OPTIMIZATION there.
protobuf's is 3.5, which made protobuf-lite the one library in an LTO
build compiled without -flto, and a link that pulls its archive members
in late -- testLogging's -- then fails on the inline std:: functions
they reference.  Default the policy to NEW beside the other settings
that keep USE_LTO the single switch.
A failed download told its requester download_failed whatever went
wrong, told its progress listener an integer status, and told every
message quoting the file a separate verdict -- three vocabularies for
one classification, and the one the requester got said nothing.

Every failure now carries an Error code named after the object that
failed: message.not_found and attachment.not_found for a request naming
nothing, file.not_found and file.unreadable for the two verdicts
AttachmentAvailability also shows, file.download_failed for an attempt
that says nothing about the file, file.upload_failed,
attachment.file_missing and file.save_failed for the rest, and
network.unavailable when there is no network.  AttachmentProgress and
the other progress callbacks end with the same Expected<void> the
requester gets, in place of a status integer, which retires the public
ATTACHMENT_* constants; the verdict's on-disk values are Unavailable's
own.  Codes nothing emitted are gone.

Inside, _download_decrypted's completion takes a DownloadResult and a
reason, from which both the public Error and the recorded verdict are
derived.  An exception from the consumer of the bytes is failed rather
than unreadable: it is our side failing, and recording it against the
file would tell every message quoting it not to try again.

Failures that used to escape as internal.exception now say what they
are.  A claim no download could satisfy -- no url, a url that is not
one, a key or digest of the wrong length -- is unreadable, and one
that reaches the download is recorded as a verdict without anything
being sent; an incoming attachment with no url shows as unreadable.
A request for a file that already has a verdict answers from it
without downloading, since only a resend changes it.
Both want a transfer that fans each chunk out to its consumers and a cache writer that encrypts as it goes, which is a change of its own.
Attachment availability: say what we hold of a file, and tell everyone showing it
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.

4 participants