Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
08fa601
Notify the failure listeners we took, not the ones we erased
jagerman Sep 9, 2026
addc0e7
Refuse to replace an attached Network, and say what it would take
jagerman Sep 9, 2026
abd00d6
Give a tunnelled handshake its own timeout
jagerman Sep 9, 2026
95069d6
Carry a server's unsolicited pushes up out of the transport
jagerman Sep 9, 2026
dd04a2b
Let a caller ask whether a server can push to us
jagerman Sep 9, 2026
885eaea
Report a lost connection, not only a failed request
jagerman Sep 9, 2026
3128312
Drop a redundant access specifier
jagerman Sep 10, 2026
b8657bf
Subscribe to a swarm member instead of polling it
jagerman Sep 10, 2026
d63d06a
Probe the subscribed node for the 421 it would otherwise never send
jagerman Sep 10, 2026
09ae013
Release a subscription ticker on a later turn, not inside its own cal…
jagerman Sep 10, 2026
bef73b4
Report the route to the swarm member we are using
jagerman Sep 10, 2026
08a1e16
Ask the path selection which path, rather than guessing
jagerman Sep 10, 2026
c2c1751
Say onion_requests where that is what is meant
jagerman Sep 10, 2026
672ca97
Move swarm retry decisions out of Network and into Core
jagerman Sep 10, 2026
24dc7d2
Poll through the swarm helper, and record the member that answered
jagerman Sep 10, 2026
aa23eef
Move the remaining swarm callers onto the helper
jagerman Sep 10, 2026
2005c04
Test the swarm walk where it now lives
jagerman Sep 10, 2026
b7270c2
Poll once more after subscribing
jagerman Sep 10, 2026
495084b
Log the probe, uneventful as it is
jagerman Sep 10, 2026
3071cd1
Detach from the Network before Core is torn down
jagerman Sep 10, 2026
a3958a3
Do not try to read network state out of a bt response
jagerman Sep 10, 2026
914ba5d
Put the subscription's own callbacks on Core's queue
jagerman Sep 11, 2026
19631dd
Give a config push a category of its own
jagerman Sep 11, 2026
a07b87a
Notice a config change without being told about it
jagerman Sep 11, 2026
e7bdc8b
Do not settle a config while Core is still being built
jagerman Sep 11, 2026
f490484
Shut Core's job queue down from inside its own last job
jagerman Sep 11, 2026
f6cb65b
Refuse a nickname the config cannot hold, before storing it
jagerman Sep 11, 2026
88749f6
Prefer swarm members likely to be reachable over Session Router
jagerman Sep 14, 2026
5c2cfbf
Reformat
jagerman Sep 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions include/session/config/contacts.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -132,20 +132,68 @@ struct contact_info {

/// API: contacts/contact_info::set_name
///
/// Sets a name or nickname; this is exactly the same as assigning to .name/.nickname directly,
/// except that we throw an exception if the given name is longer than MAX_NAME_LENGTH.
/// Sets the contact's own name, as assigning to .name does, except that an over-long one is
/// put through `fixup_contact_name` rather than rejected: this is their name, arriving in their
/// profile, and refusing it would leave us unable to hold the contact at all.
///
/// Inputs:
/// - `name` -- Name to assign to the contact
void set_name(std::string name);

/// API: contacts/contact_info::set_nickname
///
/// Sets our own name for the contact, as assigning to .nickname does, except that it throws if
/// the nickname is longer than MAX_NAME_LENGTH. It throws rather than truncating because a
/// nickname is something a person here typed, and storing a prefix of what they wrote changes
/// what they said. Check it with `validate_contact_name` before calling this -- or as it is
/// typed -- and put the refusal in front of them.
///
/// Inputs:
/// - `nickname` -- Nickname to assign to the contact
void set_nickname(std::string nickname);

/// API: contacts/contact_info::set_nickname_truncated
///
/// As `set_nickname`, but truncating rather than throwing. Only for a caller that has already
/// decided truncation is acceptable for what it holds.
///
/// Inputs:
/// - `nickname` -- Nickname to assign to the contact
void set_nickname_truncated(std::string nickname);

private:
friend class Contacts;
void load(const dict& info_dict);
};

/// API: contacts/validate_contact_name
///
/// Reports what is wrong with `name` as a contact name, nickname or profile name, or nullopt when
/// nothing is. The same limits apply to all three, so one check serves them.
///
/// A free function needing no account or config, so an application can call it on each keystroke
/// while a name is being typed, rather than finding out when it tries to store one. That is the
/// intended use: anything a person typed should be refused here and corrected by them, because the
/// alternative -- keeping a prefix of what they wrote -- changes what they said.
///
/// Inputs:
/// - `name` -- the candidate name
///
/// Outputs:
/// - `std::optional<std::string>` -- what is wrong with it, or nullopt if nothing is
std::optional<std::string> validate_contact_name(std::string_view name);

/// API: contacts/fixup_contact_name
///
/// Makes `name` storable as a contact name, changing as little as it can -- today that is
/// truncating it to MAX_NAME_LENGTH on a utf8 boundary, but it is the place for whatever else
/// storing a name comes to require.
///
/// For names we are *given* rather than told: a peer's profile name has to be stored whatever they
/// set it to, and refusing it would leave us unable to hold their contact at all. Never use it on
/// something a person typed here -- see `validate_contact_name`.
void fixup_contact_name(std::string& name);

struct blinded_contact_info {
const std::string session_id() const; // in hex
std::string name;
Expand Down
165 changes: 162 additions & 3 deletions include/session/core.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
#include "core/schema/schema_registry.hpp"
#include "session/network/key_types.hpp"
#include "session/network/service_node.hpp"
#include "session/network/session_network_types.hpp"

/// The "Core" class holds a Session account's own state, in an encrypted sqlite database: its keys,
/// its device group, its configs, and the bookkeeping needed to talk to the network on its behalf.
Expand Down Expand Up @@ -205,6 +206,15 @@ namespace detail {
}
} // namespace detail

/// Thrown by `set_network` when a Network is already attached. See the TODO on that method for
/// what a replacement would have to do first.
struct network_already_attached : std::logic_error {
network_already_attached() :
std::logic_error{
"This Core already has a Network attached; replacing it is not yet "
"supported"} {}
};

/// Wraps a predefined 32-byte account seed to pass to the Core constructor, overriding any seed
/// already stored in the database. Used when restoring an existing account from a seed.
struct predefined_seed {
Expand Down Expand Up @@ -309,6 +319,10 @@ class Core {
sqlite::Database db;
friend class detail::CoreComponent;

// Friendship does not reach a component through its base, and Configs pushes to the swarm, so
// it needs `_swarm_request` by name.
friend class Configs;

core::callbacks callbacks;

// Called during the constructor: the database is opened and all members are constructed, but
Expand All @@ -332,16 +346,83 @@ class Core {
void _update_polling();
void _poll();

/// The outcome of a swarm request.
struct SwarmResponse {
bool timeout;

/// The storage server's status, or one of the negative ERROR_ values when the request did
/// not get far enough to have one. A batch whose subrequests all failed identically
/// reports that failure rather than the 200 the batch itself returned.
int16_t status_code;
std::optional<std::string> body;

/// Which member this came from: the one that answered, or the last one tried. Not
/// necessarily the one the operation started with -- a request can be re-aimed at another
/// member several times before it succeeds, and anything recorded per-node has to be
/// recorded against *this* one.
network::service_node node;

/// Whether the storage server answered, and answered with a 2xx.
bool ok() const { return !timeout && status_code >= 200 && status_code <= 299; }
explicit operator bool() const { return ok(); }
};

// Sends `endpoint` to a member of `swarm_pubkey`'s swarm, re-aiming it as needed, and reports
// which member finally answered.
//
// Re-aiming is here rather than in Network because it is a decision, not a mechanism: only the
// caller knows whether a substitution matters to it, and a substitution made below Core is
// invisible to the bookkeeping that depends on it. Two things move a request:
//
// - a 421, meaning this member does not hold the account. Network will have taken the
// corrected swarm out of the rejection by the time we see it, so re-resolving gets the new
// membership rather than the stale one that misdirected us. Bounded by
// SWARM_REDIRECT_LIMIT, since a server that keeps saying no is not going to stop.
// - an unreachable member, which says nothing about the swarm. Keep the swarm and walk to a
// member not already spent, until they are exhausted.
//
// `make_body` is given the member the attempt will use, because a body can depend on it: a
// retrieve carries that node's cursor, and sending one node's cursor to another asks the wrong
// question.
// `prefer` names a member to go back to rather than choosing afresh, for an operation that
// has to continue against the one it started with; it is dropped as soon as that member turns
// out to be wrong or unreachable.
void _swarm_request(
network::x25519_pubkey swarm_pubkey,
std::string endpoint,
std::function<std::vector<std::byte>(const network::service_node&)> make_body,
std::function<void(SwarmResponse)> on_done,
std::optional<network::service_node> prefer = std::nullopt);

struct SwarmOp;
void _swarm_attempt(std::shared_ptr<SwarmOp> op);
void _swarm_send(std::shared_ptr<SwarmOp> op, network::service_node node);

// What `_swarm_send`'s reply does, once it is back on our own queue. Split out rather than
// written inline because the network hands it to us on its loop, and everything it does --
// re-resolving a swarm, running `on_done` -- reaches Core state that only this thread may
// touch. Every swarm operation answers through here, so this is the single hop for all of
// them.
void _swarm_response(
std::shared_ptr<SwarmOp> op,
network::service_node node,
bool timeout,
int16_t status,
std::optional<std::string> body);

// Sends one round of retrieves to `node` for `namespaces`. A retrieve is capped by the storage
// server, so one round may not exhaust a namespace; `round` counts continuations and bounds
// them. Every round goes to the same node: the retrieve cursor is stored per (namespace,
// node), so continuing against a different swarm member would resume from that member's
// position.
void _send_poll(
network::Network* net,
network::service_node node,
std::vector<config::Namespace> namespaces,
int round);
int round,
std::optional<network::service_node> node);

// The batch of retrieves to send `node`, carrying that node's cursor for each namespace.
std::vector<std::byte> _build_poll_body(
const network::service_node& node, const std::vector<config::Namespace>& namespaces);
void _handle_poll_response(
network::service_node node,
std::vector<config::Namespace> namespaces,
Expand Down Expand Up @@ -371,6 +452,49 @@ class Core {
const network::ed25519_pubkey& node, std::span<const SwarmMessage> messages);
void _conclude_profile_fetch(ProfileFanOut& state);

// Swarm push subscription. All of this is touched only on the loop.
//
// Having subscribed with a swarm member, that member pushes each new message to us instead of
// our asking for them, and the poll ticker stops. The subscription belongs to the connection,
// so it does not survive one being rebuilt and there is no notice from the far end when it
// lapses -- it simply stops pushing. Hence: renew on a timer well inside the server's expiry,
// and treat losing the connection as having lost the subscription.
//
// `_sub_node` is not a preference to be restored. It is only the member we happen to be
// talking to, held for as long as its connection lasts because there is no reason to move; a
// fresh one is chosen the ordinary way -- a new `get_swarm`, whatever it hands back first --
// once this one is gone.
// The swarm member currently carrying our messages: the one being polled, or the one a
// subscription is held with. Not a preference -- each poll re-picks at random, and this only
// stops moving because a subscription stops the polling.
std::optional<network::service_node> _swarm_node;

std::optional<network::service_node> _sub_node;
bool _subscribed = false;
std::shared_ptr<oxen::quic::Ticker> _sub_ticker;
std::shared_ptr<oxen::quic::Ticker> _probe_ticker;

// Subscribes to `node` if a subscription is possible and we do not already have one. Called
// when a poll of `node` drains, which is what makes it the node we subscribe with: it has an
// established connection and its cursors are current.
void _maybe_subscribe(const network::service_node& node);
void _send_subscribe(network::Network* net, network::service_node node);

// Re-sends the subscribe, so that the server's expiry never elapses on a connection that is
// still up.
void _subscription_renew();

// Asks the subscribed node a question whose only purpose is the 421 we get if it has stopped
// holding our swarm. Nothing else would notice: a subscription that has stopped applying is
// silent, not an error. Temporary -- see the comment on the definition.
void _subscription_probe();

// Gives up the subscription and returns to polling.
void _drop_subscription(std::string_view why);

// Feeds one pushed message in as though it had been retrieved.
void _handle_server_push(std::string_view endpoint, std::span<const std::byte> body);

// Decrypts and dispatches one-to-one messages from Namespace::Default.
void _handle_direct_messages(std::span<const SwarmMessage> messages);

Expand Down Expand Up @@ -489,8 +613,28 @@ class Core {
init();
}

/// Detaches from the Network before letting anything be destroyed; see the definition.
~Core();

/// Set an optional network interface that can be used to make network requests to swarm
/// members. Ownership is taken: nothing else may hold on to the Network.
///
/// May only be called once, and only from a thread that is not Core's loop; replacing an
/// already-attached Network (including with nullptr) throws `network_already_attached`.
///
/// TODO: allow the Network to be replaced. A client that lets the user choose a routing mode
/// needs it, and so does anything that has to re-establish swarm state across the swap. Two
/// things block it today:
///
/// - This calls `_update_polling()` on the caller's thread, which creates and stops the
/// libevent poll ticker. `set_poll_interval` marshals onto the loop for exactly that reason.
/// - Tearing down a Network *invokes* the callbacks it is holding: failing the requests queued
/// in its router and transport is part of `~Network`. Those callbacks are Core's, they hold
/// a raw `Network*` (see `_poll`), and a poll continuation among them will call back into a
/// Network whose router has already been destroyed.
///
/// So a fix is not a `_loop.call` around this body: polling has to be stopped and in-flight
/// swarm work quiesced before the old Network is dropped.
void set_network(std::unique_ptr<network::Network> network);

/// Constructs the network in place and attaches it, forwarding the arguments to its
Expand Down Expand Up @@ -647,6 +791,21 @@ class Core {
/// must not keep this beyond the point where the network could be replaced or dropped.
network::Network* network() const { return _network.get(); }

/// The swarm member currently carrying our messages -- the one being polled, or the one a
/// subscription is held with -- or nullopt before there is one.
///
/// Which member that is changes on its own: each poll picks a fresh one at random, and a
/// subscription holds one only for as long as its connection lasts. Read it to show what is
/// happening now, not to depend on it.
std::optional<network::service_node> swarm_node() const { return _swarm_node; }

/// The route our traffic to `swarm_node()` is taking right now, for showing a user where it
/// goes. Nullopt when there is no member yet, no network attached, or no route to report.
///
/// A snapshot rather than a commitment: paths rotate and subscriptions move, so asking again
/// later can legitimately give a different answer.
std::optional<network::PathInfo> current_swarm_path() const;

/// The event loop this account's work runs on.
///
/// Everything Core does off the caller's thread — polling, send completion, and therefore every
Expand Down
5 changes: 5 additions & 0 deletions include/session/core/configs.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ class Configs : public detail::CoreComponent {
size_t count;
};

// Queues a flush for the next turn of the loop, once however many times it is called before
// then. Every accessor that hands out a config calls this; see the note above them.
void _schedule_settle();
bool _settle_scheduled = false;

void _schedule_push();
void _arm_push_timer(std::chrono::milliseconds delay);
void _push_if_due();
Expand Down
1 change: 0 additions & 1 deletion include/session/core/globals.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,6 @@ class Globals final : detail::CoreComponent {
void restore_account(predefined_seed seed, failable_function<void()> cb);
void restore_account(const predefined_seed& seed, await_t);

public:
// Retrieval methods. These query for the given key and, if the type matches, return the given
// value. You get back nullopt if the database key does not exist, or if it contains a value
// of some other type.
Expand Down
18 changes: 15 additions & 3 deletions include/session/network/network_config.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ struct Config {
bool increase_no_file_limit = false;
uint8_t path_length = 3;
bool enforce_subnet_diversity = true;
uint8_t redirect_retry_count = 1;
opt::retry_delay retry_delay = opt::retry_delay(200ms, 5s);
uint8_t num_nodes_to_check_for_network_offset = 3;
std::chrono::minutes min_resume_clock_resync_interval = 10min;
Expand Down Expand Up @@ -68,7 +67,20 @@ struct Config {
std::chrono::days onionreq_edge_node_cache_duration = std::chrono::days{10};

// Quic Transport Options
std::chrono::milliseconds quic_handshake_timeout{3s};

/// How long a QUIC handshake straight out to a node's own address gets: the guard node of an
/// onion request, a direct-mode destination, a connectivity check. One internet round trip and
/// a little slack.
std::chrono::milliseconds quic_handshake_timeout{5s};

/// How long a QUIC handshake gets when its packets go through a Session Router tunnel.
///
/// A separate figure because it measures something else entirely: the connection is nominally
/// to ::1, but every packet of it crosses the whole tunnel, so the budget has to cover a
/// multi-hop round trip rather than a direct one. That it currently sits at a value other
/// constants here also happen to use means nothing -- move either one on its own merits.
std::chrono::milliseconds quic_tunnel_handshake_timeout{10s};

std::chrono::seconds quic_keep_alive{10s};
std::optional<size_t> quic_max_udp_payload;

Expand Down Expand Up @@ -102,7 +114,6 @@ struct Config {
void handle_config_opt(opt::increase_no_file_limit infl);
void handle_config_opt(opt::path_length pl);
void handle_config_opt(opt::disable_subnet_diversity dsd);
void handle_config_opt(opt::redirect_retry_count rrc);
void handle_config_opt(opt::retry_delay rd);
void handle_config_opt(opt::num_nodes_to_check_for_network_offset nncno);
void handle_config_opt(opt::min_resume_clock_resync_interval mrcri);
Expand All @@ -125,6 +136,7 @@ struct Config {

// Quic transport options
void handle_config_opt(opt::quic_handshake_timeout qht);
void handle_config_opt(opt::quic_tunnel_handshake_timeout qtht);
void handle_config_opt(opt::quic_keep_alive qka);
void handle_config_opt(opt::quic_max_udp_payload qmup);

Expand Down
Loading