Skip to content

Full libsession Client support - #150

Draft
jagerman wants to merge 440 commits into
devfrom
client
Draft

Full libsession Client support#150
jagerman wants to merge 440 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 July 31, 2026 03:00
The Pro backend (>= 0.3.0) sizes the padding on its per-account proof-expiry
grid around exactly this 1h renewal lead, so `expiry_ts - PRO_RENEWAL_LEAD`
lands just after the subscription's grace-inclusive true end. Increasing the
lead (renewing earlier than 1h before expiry) would reach the upstream store
before its final chance to report a renewal and get a spurious
subscription_expired, so it now requires a coordinated backend change. Comment
only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(PFS) Pro: atomic credential storage; key rotation & renewal logic; add_pro_payment renewal
The backend no longer reports a redeemed timestamp: no client uses it and
it exposes an internal payment-lifecycle detail with no purpose here.

The "unredeemed" status string is likewise gone: anything that could
return it redeems the payment before returning, so the value was never
actually observable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(PFS) Drop redeemed_at/redeemed_ts and the "unredeemed" status
The backend renamed the out-of-band grant provider from "rangeproof" to
"stf", reflecting that such issuances come from the Session Technology
Foundation, not the inactive Rangeproof dev house. Follow the wire/slug
value ("stf") and the C/C++ constant names to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(PFS) Rename payment provider rangeproof -> stf
Forward-port of #109 (dev commits c3f6951..b08630b) onto pfs.

session_id_matches_blinded_id() read blinded_id[1] before checking the
value's length, and never checked that it was hex.  It also used the
predicate `blinded_id[1] != '5' && (blinded_id[0] != '1' || blinded_id[0] !=
'2')`, whose right-hand side is always true (a char cannot differ from both
'1' and '2' only when it equals one of them, so the || is tautological),
leaving the check as just `blinded_id[1] != '5'` -- so any X5-prefixed value,
including a plain 05 session id, was accepted as a blinded id.

Validate length and hex before indexing, and replace the prefix tests with
starts_with("15")/starts_with("25").

Applies to pfs unmodified: line-for-line identical to the dev change.
Records dev up to 4a47113 as merged, keeping pfs's tree unchanged (-s ours).
Everything in the range is already applied to pfs:

- #104 (refund-requested-config) via #106 refund-requested-config-pfs
- #107 (drop-redeemed-at)        via #108 drop-redeemed-at-pfs
- #110 (rename-rp-to-stf)        via #111 rename-rp-to-stf-pfs
- #109 (fix-blinded-id-validation) forward-ported in the preceding commit

The first three were rewritten for pfs and so have different patch-ids, which
is why `git cherry` still reports their commits as absent; the content was
verified present by comparing the identifiers each PR added or removed across
both branches.
The backend is dropping this field and libsession follows. It carried no
actionable information: a single count of "some number of backend errors
happened at some unspecified past time", with no what, no when, and
nothing a client could do in response. Parsing it only added a way for
the response to fail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the manual skip_until + consume_string_view + explicit size
checks with require<>/require_span<>/maybe<>, which fold presence and
size/type validation into the accessor. Build the verification buffer
with reserve+insert instead of resize+memcpy (identical bytes; also
drops the spurious -Wstringop-overflow warning on the memcpy).
(PFS) Drop error_report from get_pro_status
Records dev up to 947d72a as merged, keeping pfs's tree unchanged (-s ours).
The only PR in the range, #112 (drop-error-report), is already applied to pfs
via #113 drop-error-report-pfs; nothing needed forward-porting.

#113 is a faithful adaptation rather than a straight pick, so its commits have
different patch-ids: it carries the same six files, differing only in pfs's
unsigned char -> std::byte types, the resulting drop of an ed_pk.first<32>()
now that require_span is fixed-extent, and each branch removing its own
pre-existing form of the two size-error messages.  Verified by outcome:
error_report is absent from both branches, both parse decrypt_group_message
through the bt_dict_consumer require/maybe helpers, and seed_payment.py is
identical on both.
The proof/status/payment/revocation parsers threaded a
std::vector<std::string> of errors through every helper and made the
caller check it afterwards -- C-style error handling in C++. Replace it
with a `parse_error` exception (new, public in pro_backend.hpp): the JSON
helpers and each parser throw on a malformed reply, while a well-formed
backend *failure* (envelope status fail/error) is still returned normally
with status/error_code set. The C entry points catch once and translate
to the invalid_response header, now carrying the real diagnostic rather
than a fixed "out-of-memory" string.

Along the way:
- json_require<double> accepts any JSON number (is_number(), since an
  integer is a valid float value) rather than is_number_float(); the
  now-redundant json_require_number helper is removed.
- json_require<integral> uses is_number_integer() so a fractional wire
  value is rejected rather than silently truncated by get_to.
- rename json_require_fixed_bytes_from_hex -> json_require_hex.
- C-layer allocations use make_unique + release() rather than a raw new
  with a manual delete in the catch; this also closes a leak in the
  error-translation path where a throw between the new and the pointer
  assignment orphaned the object.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pro_renewal_target returned nullopt ("never (re)fetch") whenever there was
no proof credential unless a prepaid purchase marker was set. But `s` (the
credential) and `E` (the access expiry) are independent config keys, so an
account can be genuinely entitled -- `E` still in the future -- while
holding no proof (e.g. `s` was dropped or merge-lost). That state should
fetch a proof, not sit idle forever. Return `now` when the access expiry is
still in the future and there's no proof.

Also rename the local `pro` -> `pro_config`: it is the full ProConfig
credential (rotating key + proof), not a boolean "are we pro", and the old
name misled at least one reader into misdiagnosing this very path.

Relies on the client keeping `E` synced to the backend's reported horizon
and clearing it on not_subscribed (E does not self-age); communicated
separately to the clients.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(PFS) pro_backend: throw on parse errors instead of an error vector
(PFS) user_profile: fetch a proof when entitled but holding none
Forward-port of #120 (dev commit b63b4b0) onto pfs; applies unmodified.

GetProRevocationsCResponse / GetPaymentDetailsCResponse are aggregates built
by the *_parse functions via std::make_unique from a parsed base response.
make_unique initializes with parentheses, which only aggregate-initializes
under P0960R3 (C++20): GCC implements it, the Apple Clang on the macOS CI
runners does not, so it looks for a constructor and finds none.  Give each an
explicit base-slice constructor; call sites are unchanged.

pfs has the same structs and the same make_unique call sites, so it has the
same latent macOS break.
Forward-port of #114 (dev commit 89c6acb) onto pfs.

Most of #114 is already here: the byte refactor (7662405) had independently
given the binary API fixed-extent spans, so sign/verify/pubkey already reject
wrong sizes at compile time, the string overloads already validate, and the
blinding / group-keys call sites already pass sized views.  Two things were
missing.

First, pfs's verify(string_view) returned false for a wrong-sized signature or
pubkey where #114 throws std::invalid_argument, so it conflated "you passed a
malformed argument" with "this signature does not verify".  Align with dev:
letting pfs keep return-false would silently revert #114's behaviour when pfs
eventually lands on dev.  Nothing in the tree calls the string overloads --
every call site uses the span overloads -- and the return-false came in
incidentally with 7662405 rather than as a considered choice.

Second, port #114's regression test for the rejected sizes.

The three string overloads all validated a length and then narrowed to a fixed
span; that is now one require_bytes<N> helper, which also gives the arguments
#114's exception wording.
Records dev up to 2b27d27 as merged, keeping pfs's tree unchanged (-s ours).
All four PRs in the range are now applied to pfs:

- #116 (this-is-not-c)                 via #115 this-is-not-c-pfs
- #118 (renewal-target-no-proof-fetch) via #119 renewal-target-no-proof-fetch-pfs
- #120 (macOS C response holders)      forward-ported in e682e6b
- #114 (fixed-size XEd25519 spans)     forward-ported in b03709d

Verified for the two that were already applied: #119's user_profile.cpp change
is line-identical to #118's, and for #116/#115 the `errs` error-vector is gone
from both branches with identical parse_error usage.
Clients sometimes need to know whether a Pro subscription is terminal or
auto-renewing (e.g. "renews on X" vs "expires on X"). Store the backend's
`auto_renewing` (from get_pro_status) as a presence-only config flag `A`:
1 when auto-renewing, absent otherwise (terminal / unknown / not Pro).

Deliberately not tri-state: unlike blinded_msgreqs `M`, this is backend-
derived fact, not a defaulted client preference, so there's no upgrade-
default edge case that a distinct "unset" would guard. And no t/T bump --
it's synced pro state like E/I/R, not a user-initiated profile edit.

Exposes get_/set_pro_auto_renewing (C++ bool; C 0/1) with unit + C-API
coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Forward-port of #125 (dev commit d9406eb) onto pfs, where the same code was
present verbatim.

encrypt_for_multiple_simple's decoy padding was sized from the plaintext
rather than the ciphertext, so a padding entry was
encrypt_multiple_message_overhead bytes shorter than every real entry and
could be picked out by length alone -- defeating the point of padding the
list to a fixed count.

The `if (int pad_size = ...)` form also made an empty first message pad to
zero, i.e. falsy, which skipped the padding loop entirely: a list of empty
messages got no decoys at all.  Test both, the sizes and the count.
pfs has broken a fair bit of the API (the std::byte span refactor, the crypto
header reorganization, the C API changes), and together with core and the
coming Client code this is a substantially different release, so it takes a
major bump rather than following dev's 1.x line.

This deliberately diverges from dev, which is at 1.8.0 (#128): pfs has been on
its own version line since before 1.7.0, and the successive `-s ours` merges
have been quietly masking dev's bumps.  Setting 2.0.0 makes pfs's version
unambiguously ahead, so a future merge into dev carries the version forwards
rather than dragging it back.
I kept dev's line wrapping when porting #125, but pfs's
encryption::XCHACHA20_ABYTES is shorter than dev's
crypto_aead_xchacha20poly1305_ietf_ABYTES, so the CHECK now fits on one line
and clang-format joins it.  Missed because I did not run utils/format.sh
before committing.
Forward-port of #126 (dev commit 4143882) onto pfs; the five files dev
touches are line-for-line identical to dev's change.

A 421 means the account we asked about is not in the queried node's swarm, so
recovery requires re-resolving *that account's* swarm.  The old code used
failed_node.swarm_pubkey(), which was compute_x25519_pubkey(remote_pubkey) --
the X25519 pubkey of the node we had just been rejected by, not of the account
-- so it re-resolved the wrong swarm entirely.  Requests now carry the account
they address in Request::swarm_pubkey (swarm_pubkey_hex in the C params), the
421 path uses that, and a request without one is failed rather than
misdirected again.  service_node::swarm_pubkey() goes with it: its only caller
was that wrong line, and its name invited exactly this mistake.

Beyond dev's diff: dev only populates the field in the C API, but pfs has
three internal swarm-addressed request sites in core.cpp (the namespace batch
poll, the PFS-key prefetch, and the DM store) which dev does not.  Left unset
those would now fail on their first 421 instead of recovering, so all three go
through a swarm_request() helper that carries the pubkey alongside the node --
one place to get right, and a fourth site cannot silently omit it.
Forward-port of #127 (dev commits 724939a..4b582a5) onto pfs.

A refresh can succeed and still yield too few nodes -- most easily when the
multi-request intersection finds little in common -- and the result was
written over the existing cache unconditionally, leaving nothing to route
through until the next refresh landed.  An empty result wiped it entirely.
Treat an under-sized result as a failed refresh instead: keep the cache we
have and retry after the usual backoff.

The retry path already existed for an unparseable response, so it is lifted
out of the catch block into a discard_and_retry lambda holding a weak self ref
and reused for both cases.

Also carried along from the same branch: seed_payment.py inlines the
day-rounding it needs, since the backend moved
base.round_datetime_to_next_day into its own test scaffolding.

pfs adaptations: `_hexbytes` -> `_hex_b`; the new test helper is static, as
pfs builds with -Werror=missing-declarations; and the byte cast when copying
the remote key is dropped, view_remote_key() already returning std::byte here.
Forward-port of dev commit 0a23f70; applies unmodified.

Aligns the revocationTag column and corrects expiryUnixTs's comment, which
claimed milliseconds where the field carries seconds.
jagerman and others added 23 commits September 9, 2026 14:54
It has not compiled since send_message collapsed into OutgoingMessage
and the synchronous message() grew its wait_t; testLive is outside
testAll, so only a full build says so.
The lint pipeline has been failing on client for a while, on code that predates any one branch.
This is `./utils/format.sh` and nothing else: no include was added or removed, and outside the
reordering clang-format's SortIncludes does, not a token changed.

jsonnetfmt leaves .drone.jsonnet alone already.
The elements of a braced-init-list are evaluated in order, so testing
`it->second` in the second element was testing a moved-from Info.  It
gave the right answer only because `state` is an enum and a member-wise
move leaves scalars intact, so it would have started lying the moment
that field gained a destructive move.
set_poll_interval assigned _poll_interval, stopped the ticker and built
a new one on the caller's thread, while the loop that owns both ran.
Creating or stopping a libevent event off the loop races it -- libquic's
own call_later goes out of its way to avoid exactly that -- so the doc's
"safe to call at any time" was true only for a caller that happened to
call before a network was attached.

Marshalled through Loop::call, which runs inline when already on the
loop, and the ticker rebuild handed to _update_polling rather than
written out a second time.  Documents what the interval trades away, and
that nothing serialises polls: an interval short enough to overlap gets
duplicate requests, which is wasteful but not incorrect.
Two comments described a Client that no longer exists.

The constructor's said core::callbacks could be passed and would be
forwarded "as for a bare Core", which the static_assert below it
forbids outright.  An application cannot supply any, so a Core event
with no client::callbacks equivalent is a gap to fill here rather than
something a caller can reach past Client for.

The accessors' said reading off-thread "deadlocks rather than merely
racing".  It does not: the pool hands each thread its own connection and
WAL lets a reader run beside the loop's writer.  The reasons to go
through the loop are cost and consistency -- a second encrypted
connection per reading thread, contention for the single write lock, and
a snapshot with no defined relationship to the callbacks already
delivered -- so say those instead of a hazard that sends a reader
hunting for a deadlock that cannot happen.
last_message was a std::string, so a row could not tell "no messages"
from "the latest carries only attachments" -- both arrived empty, and a
conversation that had just moved to the top of the list appeared to say
nothing.

Replaced by an optional MessagePreview: unset means there is nothing to
preview, so an empty body on one that is set means the message has no
text.  Body and attachment count are independent fields rather than
alternatives, because a message can carry both and a row wants both
halves -- which is why there is no kind enum to switch on.  Attachments
are summarised to a count plus whether they are a voice message and
whether they are all images: enough vocabulary for a row, without the
filenames and content types that are a message view's business.

Costs two queries for a list of any size: the body and sender come from
a join on one index seek per conversation, and the attachment aggregate
is batched over every previewed message at once, as a page of history
already does.
A device's state never goes on the wire -- it is inferred from which
message the record arrived in -- so a state change moves no field that
the record's seqno versions.  upsert_device_info guarded on
`excluded.seqno > seqno`, which therefore discarded exactly the
transitions it was there to decide: an applicant is stored Pending at
seqno 1, the accepting device pushes the identical record as Registered
at seqno 1, every device compares 1 > 1 and stays Pending.  Registration
could not complete.

State values are renumbered least to most authoritative so the stored
integer is a rank, and the guard compares (state, seqno) as a row value.
That subsumes the special cases: equal rank falls back to the seqno, an
acceptance outranks a newer link request, and a kick outranks everything.
Rank only increases and the order is total, so a merged result is the
maximum over everything received regardless of arrival order.

Kicked becomes a state of its own rather than a second job for
Unregistered, which had been covering both "removed from the group" and
"never in it" -- two answers that the merge and the payload writer each
had to separate by a different ad-hoc test.  A schema CHECK ties it to
kicked_timestamp so the coupling is enforced rather than remembered, and
the ungated kick update becomes an upsert: an update alone matched
nothing for a device that joined after the removal, silently storing no
tombstone and leaving that device free to accept the removed one back.

Also drops kicked_timestamp from upsert_device_info, where it only ever
wrote the NULL it was inserted with; the rank guard is now what keeps a
record from overwriting a tombstone.

No migration: these tables have no rows anywhere yet.
Picks up session-sqlite's bind_each, which is what a runtime-sized
parameter list needs to go through bind_oneshot at all, and covers it
with tests here since session-sqlite has no suite of its own.

The tests that matter are the composing ones: a sequence between two
ordinary values, and two sequences in one call.  A single sequence on
its own passes whether or not the parameter counter advances correctly,
so it cannot tell a working implementation from one that numbers
parameters by argument position.
A row saying "invoice.pdf" is worth considerably more than one saying
"1 file", and the count comes along for free as the vector's length.

One entry per attachment, in the order the sender listed them, so the
entries line up with Attachment::index and the length is an exact count.
An entry is empty where the sender omitted the name, which it may: the
names cannot stand in for the attachments, and a row needs a fallback
for the empty ones.

The query becomes a row per attachment rather than an aggregate, since
the names are wanted individually; the three summary fields are folded
from those same rows instead of being asked for separately.
Three build fixes merged into the two dependency repositories, and this is
what brings them in.

session-deps (dbda1a9a..30b20092):
  - libevent is built --with-pic, so its archive can be linked into a shared
    object. Without it an x86-64 link fails with a R_X86_64_PC32 relocation
    against event_base_loop.
  - CMake-based dependencies are configured with the cross toolchain file
    rather than the compiler binary alone. A cross compiler invoked without
    its target builds for the host, and the archive links against the wrong
    platform's standard library - on Android, libstdc++ symbols that are not
    there.
  - session_deps_version 1.7 -> 1.8, which is session-deps' own major-version
    compatibility marker; the major is unchanged.

session-router (2e0b7dbb..054d3b9f):
  - target_architecture() recognises arm64 on macOS instead of calling
    FATAL_ERROR on it, so configuring for Apple silicon works.

All three were carried as out-of-tree patches by a consumer until now.
Nothing here changes what libsession-util builds; the .gitmodules URLs are
unchanged and both commits are on their repository's dev branch.
Bump session-deps and session-router for the cross-build fixes
Every client draws a path screen naming the country of each hop, and each
of them currently ships and maintains its own geo database to do it.  This
puts the lookup upstream: session::ip_country::lookup(ipv4) returns an ISO
3166-1 alpha-2 code, with available(), attribution() and database_version()
alongside it.

It is off by default.  With WITH_IP_GEOLOCATION off the compiled-in database
is an empty one rather than absent, so every lookup misses and a client needs
no #ifdef of its own; available() is a link-time fact, not a macro, so nothing
about the option reaches a header.  data.cpp and no_data.cpp define the same
accessors and the lookup itself is identical either way -- only the table it
searches differs.

DB-IP Lite is the source, chosen over MaxMind's GeoLite2 on licensing rather
than accuracy: CC BY 4.0 permits redistribution and has no clause requiring a
copy to stay current, which is what makes a bundled snapshot viable at all.
Its IPv4 rows already tile the address space with no gaps and no mergeable
neighbours, so a range needs only its first address -- the next range's start
ends it.  That leaves parallel arrays of 357k ipv4 starts and uint8_t country
indices plus a 246-entry code table: 5 bytes a range, 1.79MB, of which a
binary search touches only the 1.43MB of starts.

The codes are numbered by descending range count.  The countries holding the
most ranges get the shortest indices, which is worth ~0.4MB of generated
source, and the rare countries -- the ones that come and go between releases
-- land at the end where nothing follows them to renumber.  Numbering them
alphabetically instead rewrote 86% of the table across the Aug->Sep refresh,
where two mid-alphabet countries disappeared; this ordering rewrote 9%.

The generated table is not committed: utils/update-ip-country-db.py downloads
a release and generates it, and cmake refuses to configure with the option on
until it has been run.  Nothing downloads during a build.  The tests run in
both configurations and check the mechanism -- table invariants, range
boundaries, the unknown path -- rather than pinning countries, save for one
anchor commented as expected to move when the snapshot does.
POSIX declares `::wait` in <sys/wait.h>, so a translation unit that does
`using namespace session::client;` and pulls in that header -- which
macOS and glibc both make easy -- finds two `wait`s and can use neither:

    error: reference to 'wait' is ambiguous
    note: candidate found by name lookup is 'wait'
    note: candidate found by name lookup is 'session::client::wait'

There is no fix for that at namespace scope.  A using-declaration next to
the using-directive is rejected outright ("target of using declaration
conflicts with declaration already in scope"), and a using-directive in an
inner namespace does not help, since it injects the name into the nearest
namespace enclosing both -- the global one, where `::wait` already is.
What is left is a block-scope using-declaration in every function that
wants it, or qualifying every call site, and neither is something to ask
of a client.

So the tag becomes `block_t`/`block`, and its doc block says why, so that
the next reader does not rename it back.  The reason it is not a variable
problem in the first place is that `wait` is an object: a function of that
name would merge into an overload set with `::wait(int*)` and resolve by
arity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rename the blocking-call tag from `wait` to `block`
Add an optional IP-to-country lookup behind a cmake option
`client` branched off `pfs` and was meant to sit on top of it, but the two
ran side by side for a month: 192 commits here, 38 there. This closes that,
and `pfs` is retired -- future work happens on `client`.

It also fixes CI. The Pro backend dropped `"version": 0` from the proof
response on 2026-08-17 (session-pro-backend 2ce104a), and PR #130 answered it
the same day by deleting the field outright -- a proof's format is fixed by
the endpoint that issued it and bound into `sig` by the domain prefix, so the
only party that has to discover a version is an offline peer, which reads it
from the protobuf envelope. That went to `dev` and to `pfs` (PR #131) and
never reached `client`, whose [pro_live] job has been failing on
`Key 'version' is missing` ever since.

Three resolutions needed a decision rather than a pick:

- generate_download_url: `client` gave it a `stream_encrypted` parameter and
  `sr=` fragments; `pfs` gave it the port suffix. Both are wanted, so the
  signature is client's and the port handling pfs's. The url tests move to
  the port-bearing expectations, since `example.com:123` on http states a
  port now.

- proto/debug_print.cpp exists only on `client`, so it merged untouched while
  the schema under it lost `ProProof.version`. Its print block goes; an old
  proof still carrying field 1 falls to `unknown_fields`, which that function
  already prints.

- "Configs: what a skipped seqno costs afterwards" pinned the spurious seqno
  increment as observed behaviour, and ba569b2 fixes it -- as the test's own
  closing note anticipated. Rewritten to assert what now holds: the duplicate
  is adopted at its own seqno, so the peer's next change lands cleanly and
  owes no conflict push.

testAll: 392 cases pass. [pro_live] against session-pro-backend dev: 3 cases,
100 assertions, all pass.
Forward-port of #139 (dev commit 24bd95c) onto client.

Only the url half: 24bd95c also bumps 1.9.0 -> 1.9.1, which does not apply
here.  dev carries the 1.x line and client the in-development 2.x, so dev's
bumps are never taken.

Nothing pins these urls in a test, on either branch.
Forward-port of dev commit fea5acc; line-for-line identical to dev's, as
client's pipeline list still had Static iOS in the position dev moved it from.
First dev->client marker merge, replacing the dev->pfs ones now that pfs is
retired.  61f9529 brought all of pfs into client, so dev was already recorded
merged through cd2aca9 and only #139 remained.

Records dev up to 1d565e3 as merged, keeping client's tree unchanged
(-s ours):

- #139's url fix (24bd95c) forward-ported in 6aa2608.
- fea5acc (CI: run static iOS first) forward-ported in a8805d8.

24bd95c's other half bumps 1.9.0 -> 1.9.1 and is deliberately not taken: dev
carries the 1.x line, client the in-development 2.x, so dev's version bumps are
never applied here.
`block` already means something else in this library: `set_blocked(bool
blocked, block_t)` had one word doing two unrelated jobs in a single
declaration.

`await` is free of both hazards the name has to clear -- it is not a
keyword (`co_await` is), and nothing libc or POSIX declares at global
scope shadows it, so it survives a `using namespace session::client;` in
application code the way `wait` would not.
Rename the blocking-call tag to `await`
Bilb and others added 6 commits September 11, 2026 14:18
add_logger built a sink around the caller's callback and discarded the only
reference to it, so the callback could never be retired: clear_loggers drops
every logger in the process, including ones this caller does not own.

A consumer whose callback captures something shorter-lived than the process is
then left with a registered callback pointing at freed memory. That is what
session-app hit - a bridge that can be closed and reopened, logging from
threads its teardown cannot join - and it had to hold the bridge weakly to work
around it.

add_logger now returns a handle and remove_logger takes it. The handle is a
shared_ptr to a forward-declared formatted_callback_sink: spdlog stays out of
the public header, and the handle still names a type, so an unrelated
shared_ptr cannot be passed as one. Logging is serialised against removal, so
once remove_logger returns the callback is neither running nor reachable.

The C API is unchanged; it never exposed removal and adding it needs an opaque
handle of its own.
Conversation::messages passes `limit` straight through to SQLite as `LIMIT ?`,
where a negative value means no limit at all and zero means the end of the
history. So a caller asking for -1 messages gets every message in the
conversation loaded into memory, and one asking for 0 gets an empty page that
looks like the end of the history rather than an error.

The parameter was undocumented, so neither reading was wrong from the caller's
side: the header explains `before` and `include_deleted` at length and said
nothing about the range of `limit`.

_require_page joins the other _require_ guards on the calling thread, where a
caller bug surfaces at the call site with a stack still under it rather than as
a string in an error leg. The two terminal overloads are the only callers of
_messages, so guarding them covers all eight public spellings; the defaulted
ones pass 50 and are unaffected.
Refuse a page size that is not a page
Let a logger be taken back off
Without this we could try to use a system-installed 1.2.1 that doesn't
have the needed sink removal code.
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