Skip to content

Full libsession Client support - #150

Draft
jagerman wants to merge 434 commits into
devfrom
client
Draft

Full libsession Client support#150
jagerman wants to merge 434 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
…enewal deferral

- rotating_seed doc (C++ and C): "as of now" instead of "contains now";
  drop the epoch-computation narration and the redundant naming rationale.
- pro_renewal_target: promote the boundary defer / min-validity magic
  values to documented constants -- PRO_RENEWAL_BOUNDARY_DEFER (reduced
  2min -> 1min) and PRO_RENEWAL_BOUNDARY_MIN_VALIDITY (5min) -- and reword
  the collision comment to just say a collision is resolved by config
  resolution rather than (incorrectly) describing how config resolves it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the C-style utf16_count_truncated_to_codepoints (span in, code-unit
index out) with utf16_truncate(u16string_view) -> u16string_view returning the
truncated prefix directly, and take utf16_count on a u16string_view. Remove the
assert()s on the surrogate state (invalid UTF-16 is documented-but-UB *input*,
not a programming invariant, so asserting on it is wrong) and the now-dead
surrogate helpers. Rename utf8_truncate's `n` to `max_bytes`. Tests use native
u"..." literals instead of a UTF-8->UTF-16 round-trip UDL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The utf8/16 variants forced libsession to validate and codepoint-count the
message text, but every client already counts codepoints natively. Take the
count directly via pro_features_for_message(size_t codepoint_count) and keep
only the policy libsession should own: the character-limit thresholds and the
count->flag mapping.

Drops the simdutf validation/count (and the simdutf include, its only user in
this TU), the redundant codepoint_count output field, and the now-impossible
UTFDecodingError status.

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

Same doc correction as dev: the 7-day rotating_seed quantization is not a
"weekly rotation period" -- rotation cadence is the backend-issued proof expiry
(min of subscription remaining, 30 days), and the 7-day bucket only makes every
device agree on the derived seed.

Also adds a TODO in pro_renewal_target to investigate device-count-aware renewal
jitter (skewed so the first-order statistic is uniform, using PFS's multi-device
account info) so the public renewal-time distribution can't leak the device
count -- the dev branch can't (count unknown there) and accepts the lesser
backend-only leak.

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

pro_renewal_target returned `now` (fetch immediately) for *any* missing or
expired proof, which told a non-Pro account -- and a lapsed one -- to hammer the
backend indefinitely. Split the two cases:
  - no proof at all: fetch only if a purchase is in flight (the prepaid marker),
    else nullopt. An entitled account carries its proof in synced config `s`, so
    genuinely having none means the account isn't Pro.
  - present-but-expired proof: always re-check. The subscription may have
    auto-renewed without this device's knowledge (a stale cached access expiry
    can't be trusted), and on an authoritative not-Pro the client clears the
    credential -- which terminates the loop.

Also corrects a stray "weekly rotation" mischaracterization in a test comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
generate_pro_proof responses now carry account_expiry_ts -- the account's true,
grace-inclusive entitlement end (the same value get_pro_status returns), distinct
from the proof's own clamped <=30d expiry_ts. It rides the response so a proof
fetch also refreshes the client's cached access expiry. Exposed on
GenerateProProofResponse (C++, optional) and session_pro_backend_pro_proof_response
(C, 0 when absent).

Advisory and unsigned: never fed into signature verification -- M is reconstructed
from version/revocation_tag/rotating_pkey/expiry_ts only (pro-wire-protocol.md
§2.2). Required on a successful proof; also read top-level off a
subscription_expired failure so an expired-sub client can refresh its horizon
without a separate get_pro_status; absent on not_subscribed / revoked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
jagerman and others added 29 commits September 2, 2026 13:59
encrypt_device_data() had no caller, so no group was ever visible to
another device.  The seqno comes back with the bytes because the message
is built from a snapshot: confirming the push against a seqno read
afterwards would mark clean a change that went out in no message.
A retrieve is capped, so one round need not exhaust a namespace, but
"more" was never read and is_final was hardcoded true: a truncated batch
was announced as complete and handlers fired on a partial view.  Rounds
continue against the same node, since the cursor is stored per
(namespace, node).

An empty batch is now dispatched too.  A namespace that answered with
nothing is telling us something -- it is what lets a handler know a fetch
happened -- while one that failed still reports nothing at all.  Configs
opt out: there is no state there an empty poll settles.
Without one it lands in Unknown with a free-form string, which every
other client then shows as an unrecognised device.

The two decoders had the mapping written out twice and a fourth value
would have made that three places disagreeing; they now share the
inverse of encoded_type().
The four payload-carrying handlers took a const reference to a value
Client had read for that one delivery and destroys straight afterwards,
so every handler that kept what it was given -- which is what this
header asks them to do -- copied it, strings and attachment vector
included.  Passing it by value and moving out of the emit job removes
that copy rather than relocating it: nothing else holds the value, and
each job runs exactly once.

_emit_lists_replaced was paying the same cost from the other side.  Its
lambda was not mutable, so handing the captures to the by-value list
handlers copy-constructed both vectors on every replacement.

A handler written against the old signature still compiles, since a
callable taking a const reference binds to a by-value std::function
parameter, so existing callers see one fewer copy rather than a change
they have to make.  Only code naming the std::function types themselves
needs touching.
A by-value parameter on the std::function forces a T into existence in
operator()'s frame before the handler is reached, so a handler that only
reads still pays for one; and it lets a call site omit the move and copy
silently, which is what this was meant to stop.  An rvalue reference
costs nothing for a const&-taking handler, still permits a by-value one,
and makes a missing move a compile error.

The Core device handlers get the same treatment, which is why the
dispatch loop stops being const.  Handlers that borrow keep const& --
message_decrypt_failed's argument is one of the caller's own elements,
identifiable by pointer, and configs_changed's span is a view.
Four of the six take an id nobody else keeps: the _emit job that carries
it runs once and calls one handler.  ConversationId holds a std::string
of 33 raw bytes for a dm or group, past the small-string threshold, so
each of those was an allocation a handler could not avoid.

The two progress handlers keep const&: they report repeatedly against one
captured id, so a handler that moved from it would empty what the next
report needs.
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`
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