fix: Bound and offload per-connection subscription cleanup

This commit is contained in:
Bart
2026-07-16 16:24:06 -04:00
committed by Ayaz Salikhov
parent 5ab95748d4
commit 1dcaf4b54e
12 changed files with 1301 additions and 214 deletions

View File

@@ -488,6 +488,17 @@
# Must be a number between 100 and 1000, defaults to 250
#
#
# [max_subscriptions_per_connection]
#
# Maximum number of account, real-time account, and account-history
# subscriptions a single client connection may hold at once. Bounds the
# per-connection state torn down when the connection disconnects. Book
# subscriptions are tracked separately and are not counted here.
#
# Defaults to 100000 if not set; large enough for legitimate power users
# such as block explorers.
#
#
# [overlay]
#
# Controls settings related to the peer to peer overlay.

View File

@@ -25,6 +25,7 @@ struct Sections
static constexpr auto kLedgerHistory = "ledger_history";
static constexpr auto kLedgerReplay = "ledger_replay";
static constexpr auto kLedgerTxTables = "ledger_tx_tables";
static constexpr auto kMaxSubscriptionsPerConnection = "max_subscriptions_per_connection";
static constexpr auto kMaxTransactions = "max_transactions";
static constexpr auto kNetworkId = "network_id";
static constexpr auto kNetworkQuorum = "network_quorum";

View File

@@ -11,6 +11,7 @@
#include <xrpl/server/Manifest.h>
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <functional>
#include <memory>
@@ -22,6 +23,39 @@ namespace xrpl {
// Operations that clients may wish to perform against the network
// Master operational handler, server sequencer, network tracker
/**
* Maximum number of subscriptions a single client connection may hold at once.
*
* Applies to the account, real-time account, and account-history subscriptions
* tracked on one InfoSub (the sets counted by totalSubscriptionCount), bounding
* the disconnect-time cleanup of those sets. Book subscriptions are tracked
* separately (OrderBookDB) and are not counted here. Generous enough for
* legitimate power users such as block explorers.
*/
constexpr std::size_t kMaxSubscriptionsPerConnection = 100'000;
/**
* Whether adding @p additional subscriptions to a connection already holding
* @p current would exceed the cap.
*
* Pure arithmetic split out so it can be unit-tested without a live
* connection. The first term avoids underflow in the subtraction.
*
* @param current Subscriptions already tracked on the connection.
* @param additional Subscriptions a request would add.
* @param cap The effective per-connection cap. Defaults to the
* built-in limit; callers may pass a configured override.
* @return true if the request must be rejected to stay within the cap.
*/
[[nodiscard]] constexpr bool
exceedsSubscriptionCap(
std::size_t current,
std::size_t additional,
std::size_t cap = kMaxSubscriptionsPerConnection)
{
return additional > cap || current > cap - additional;
}
class InfoSubRequest : public CountedObject<InfoSubRequest>
{
public:
@@ -44,12 +78,12 @@ public:
* map.
*
* @note Lifetime contract: every `InfoSub` instance MUST be destroyed
* before the backing `Source`. NetworkOPsImp shutdown drops all
* subscriber strong refs before its own teardown to satisfy this.
* before the backing `Source`. NetworkOPsImp shutdown drops all
* subscriber strong refs before its own teardown to satisfy this.
* @note Thread-safety: per-instance state is guarded by `lock_`. The
* destructor reads tracking sets without taking `lock_` because
* the strong-pointer ref-count is zero at destruction time, so
* no other thread can be calling the public mutators.
* destructor reads tracking sets without taking `lock_` because
* the strong-pointer ref-count is zero at destruction time, so
* no other thread can be calling the public mutators.
*/
class InfoSub : public CountedObject<InfoSub>
{
@@ -117,6 +151,34 @@ public:
AccountID const& account,
bool historyOnly) = 0;
/**
* Schedule the server-side teardown of a disconnecting connection's
* account subscriptions off the destructor thread.
*
* The implementation posts a low-priority JobQueue task that erases the
* entries in bounded chunks, so `~InfoSub` returns immediately instead
* of running the erase loop inline. The sets are taken by value so the
* job owns its copies and never references the destroyed `InfoSub`.
* Cleanup is keyed on `seq` (unique per connection), so deferring it
* cannot disturb a reconnected client reusing the same accounts.
*
* @param seq The disconnecting connection's unique subscription id.
* @param rtAccounts Real-time account subscriptions to remove.
* @param normalAccounts Normal account subscriptions to remove.
* @param historyAccounts Account-history subscriptions to remove.
*
* @note The implementing `Source` must outlive any job it posts. If the
* JobQueue is already stopping (process shutdown), the job is not
* enqueued; the cleanup is skipped because the server-side maps
* are about to be destroyed and no publishing can run.
*/
virtual void
scheduleAccountCleanup(
std::uint64_t seq,
hash_set<AccountID> rtAccounts,
hash_set<AccountID> normalAccounts,
hash_set<AccountID> historyAccounts) = 0;
// VFALCO TODO Document the bool return value
virtual bool
subLedger(ref ispListener, json::Value& jvResult) = 0;
@@ -153,12 +215,12 @@ public:
* @param ispListener The subscriber requesting removal.
* @param book The order book to unsubscribe from.
* @return true if the entry was present and removed, false if the
* subscriber was not subscribed to @p book.
* subscriber was not subscribed to @p book.
*
* @note Thread-safety: acquires subLock_ internally.
* @note Thread-safety: acquires bookLock_ internally.
* @note Do NOT call from ~InfoSub(). Use unsubBookInternal instead
* to avoid a redundant write-back to bookSubscriptions_ on a
* partially-destroyed object.
* to avoid a redundant write-back to bookSubscriptions_ on a
* partially-destroyed object.
*/
virtual bool
unsubBook(ref ispListener, Book const&) = 0;
@@ -173,9 +235,9 @@ public:
* @param uListener The sequence number of the subscriber being torn down.
* @param book The order book entry to remove.
* @return true if the entry was present and removed, false otherwise
* (e.g., already removed by a concurrent RPC unsubscribe).
* (e.g., already removed by a concurrent RPC unsubscribe).
*
* @note Thread-safety: acquires subLock_ internally.
* @note Thread-safety: acquires bookLock_ internally.
*/
virtual bool
unsubBookInternal(std::uint64_t uListener, Book const&) = 0;
@@ -221,8 +283,8 @@ public:
/**
* Journal used by InfoSub for diagnostics that occur after the
* owning subsystem (e.g. application-level Logs) is the only
* surviving sink — primarily destructor-time cleanup failures.
* owning subsystem (e.g. application-level Logs) is the only
* surviving sink — primarily destructor-time cleanup failures.
*/
[[nodiscard]] virtual beast::Journal const&
journal() const = 0;
@@ -243,6 +305,56 @@ public:
[[nodiscard]] std::uint64_t
getSeq() const;
/**
* Return the number of subscriptions currently tracked on this
* connection.
*
* The combined size of the per-connection account, real-time account, and
* account-history subscription sets. `doSubscribe` reads this to enforce
* the per-connection subscription cap before admitting more.
*
* @return The total tracked subscription count for this connection.
*
* @note Thread-safe: takes `lock_` for the read; read-only.
*/
[[nodiscard]] std::size_t
totalSubscriptionCount() const;
/**
* Enforce the cap and reserve a request's net-new accounts, atomically.
*
* Under one hold of `lock_`: count the net-new entries in the two sets,
* check the total against @p cap, and insert them only if it fits.
* All-or-nothing. Doing check and insert together stops two concurrent
* requests sharing an InfoSub (the admin subscribe-by-url path) from both
* passing the check before either records its accounts. The server-side
* maps are populated afterwards by subAccount, whose re-insert is a no-op.
*
* @param proposedAccounts Real-time (accounts_proposed) ids to reserve.
* @param normalAccounts Normal (accounts) ids to reserve.
* @param cap The effective per-connection cap.
* @return true if reserved; false if the request must be rejected.
* @note Thread-safe: takes `lock_`.
*/
[[nodiscard]] bool
tryReserveAccountSubscriptions(
hash_set<AccountID> const& proposedAccounts,
hash_set<AccountID> const& normalAccounts,
std::size_t cap);
/**
* Whether this connection already tracks an account-history for @p account.
*
* `doSubscribe` reads this to charge the cap for an account_history_tx_stream
* only when it is net-new, matching the account branches.
*
* @param account The account an account_history_tx_stream would add.
* @return true if @p account is already in the account-history set.
* @note Thread-safe: takes `lock_`; read-only.
*/
[[nodiscard]] bool
hasAccountHistorySubscription(AccountID const& account) const;
void
onSendEmpty();
@@ -302,7 +414,9 @@ public:
getApiVersion() const noexcept;
protected:
std::mutex lock_;
// Mutable so the read-only totalSubscriptionCount() accessor can lock it
// from a const method; locking semantics are otherwise unchanged.
mutable std::mutex lock_;
private:
Consumer consumer_;

View File

@@ -7,10 +7,12 @@
#include <xrpl/protocol/Book.h>
#include <xrpl/resource/Consumer.h>
#include <cstddef>
#include <cstdint>
#include <exception>
#include <memory>
#include <mutex>
#include <utility>
namespace xrpl {
@@ -64,6 +66,9 @@ InfoSub::InfoSub(Source& source, Consumer consumer)
InfoSub::~InfoSub()
{
// Stream unsubscribes are O(1): each erases this connection's single seq_
// from one stream map, so they are cheap enough to run inline on the
// disconnect thread.
// Each Source teardown call below acquires a server-side lock and
// can throw. Wrap each independent call so partial failure does not
// skip the remaining teardown steps.
@@ -79,29 +84,48 @@ InfoSub::~InfoSub()
safeUnsub(seq_, [&] { source_.unsubPeerStatus(seq_); }, j);
safeUnsub(seq_, [&] { source_.unsubConsensus(seq_); }, j);
// Use the internal unsubscribe so that it won't call
// back to us and modify its own parameter
if (!realTimeSubscriptions_.empty())
{
safeUnsub(
seq_, [&] { source_.unsubAccountInternal(seq_, realTimeSubscriptions_, true); }, j);
}
if (!normalSubscriptions_.empty())
{
safeUnsub(
seq_, [&] { source_.unsubAccountInternal(seq_, normalSubscriptions_, false); }, j);
}
for (auto const& account : accountHistorySubscriptions_)
{
safeUnsub(seq_, [&] { source_.unsubAccountHistoryInternal(seq_, account, false); }, j);
}
// Book subscriptions are torn down inline here, keyed on seq_, rather than
// through the chunked account cleanup below. The book set is not capped, so
// it can be large; but each unsubBookInternal takes bookLock_ for a single
// O(1) erase and releases it, so even a large set never holds a lock across
// the whole loop - a competing book publish can interleave between erases.
// The disconnect thread still does O(N) brief acquisitions. Use the internal
// variant so it does not write back to bookSubscriptions_ on this
// partially-destroyed object.
for (auto const& book : bookSubscriptions_)
{
safeUnsub(seq_, [&] { source_.unsubBookInternal(seq_, book); }, j);
}
// Hand the account sets off (by move) to the Source for a chunked,
// off-thread teardown keyed on seq_, instead of erasing them inline here.
// This keeps the destructor from holding the account lock across a large
// erase loop. The job never references this object, which is being
// destroyed.
//
// Moving the sets without holding lock_ is safe: the destructor runs only
// when the last shared_ptr to this InfoSub is released, so by the
// shared_ptr contract no other thread holds a reference. Subscription maps
// store weak_ptrs, so a concurrent publisher must weak_ptr::lock() first;
// that succeeds only while a strong reference exists, which cannot overlap
// with destruction. No other thread can observe the moved-from sets.
//
// Wrapped like the steps above: scheduleAccountCleanup enqueues a JobQueue
// task, which allocates and locks and so can throw. A throw out of this
// noexcept destructor would terminate the process. Skipping the cleanup on
// throw is harmless: the account/rt maps hold weak_ptrs that the next
// publish prunes once this InfoSub is gone, and any history paging job
// self-terminates when its weak sink can no longer be locked.
safeUnsub(
seq_,
[&] {
source_.scheduleAccountCleanup(
seq_,
std::move(realTimeSubscriptions_),
std::move(normalSubscriptions_),
std::move(accountHistorySubscriptions_));
},
j);
}
Resource::Consumer&
@@ -121,6 +145,53 @@ InfoSub::onSendEmpty()
{
}
std::size_t
InfoSub::totalSubscriptionCount() const
{
// Hold lock_ for the whole read so the three sets cannot be mutated
// mid-count by a concurrent (un)subscribe on this connection.
std::scoped_lock const sl(lock_);
// Combined tally the per-connection cap is enforced against.
return normalSubscriptions_.size() + realTimeSubscriptions_.size() +
accountHistorySubscriptions_.size();
}
bool
InfoSub::tryReserveAccountSubscriptions(
hash_set<AccountID> const& proposedAccounts,
hash_set<AccountID> const& normalAccounts,
std::size_t cap)
{
// One lock hold covers the count, the check and the insert.
std::scoped_lock const sl(lock_);
// Entries not already tracked; re-subscribing held accounts is not charged.
auto const countNew = [](hash_set<AccountID> const& requested,
hash_set<AccountID> const& existing) {
std::size_t fresh = 0;
for (auto const& account : requested)
{
if (!existing.contains(account))
++fresh;
}
return fresh;
};
std::size_t const additional = countNew(proposedAccounts, realTimeSubscriptions_) +
countNew(normalAccounts, normalSubscriptions_);
std::size_t const current = normalSubscriptions_.size() + realTimeSubscriptions_.size() +
accountHistorySubscriptions_.size();
if (exceedsSubscriptionCap(current, additional, cap))
return false;
realTimeSubscriptions_.insert(proposedAccounts.begin(), proposedAccounts.end());
normalSubscriptions_.insert(normalAccounts.begin(), normalAccounts.end());
return true;
}
void
InfoSub::insertSubAccountInfo(AccountID const& account, bool rt)
{
@@ -165,6 +236,13 @@ InfoSub::deleteSubAccountHistory(AccountID const& account)
accountHistorySubscriptions_.erase(account);
}
bool
InfoSub::hasAccountHistorySubscription(AccountID const& account) const
{
std::scoped_lock const sl(lock_);
return accountHistorySubscriptions_.contains(account);
}
void
InfoSub::insertBookSubscription(Book const& book)
{

View File

@@ -27,6 +27,7 @@
#include <xrpl/core/NetworkIDService.h>
#include <xrpl/json/json_value.h>
#include <xrpl/json/to_string.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/KeyType.h>
@@ -1548,6 +1549,413 @@ public:
}
}
// ----- Subscription limit / teardown verification ----------------------
//
// The helpers and tests below exercise:
// * the per-connection subscription cap + proportional charge enforced
// in doSubscribe (Subscribe.cpp), and
// * the asynchronous, chunked teardown of a disconnecting connection's
// account subscriptions (~InfoSub -> scheduleAccountCleanup -> JobQueue).
//
// The cap-exceeded error is rpcINVALID_PARAMS with the message "Too many
// subscriptions for this connection."; the tests assert that exactly.
//
// There is no public accessor for the server-side per-connection count, so
// the async cleanup is verified behaviorally: publishing still flows to a
// live subscriber, rather than by reading a count to zero.
// Build `count` distinct, valid, base58-encoded account strings cheaply by
// incrementing an AccountID. parseAccountIds dedups into a hash_set, so the
// strings MUST be distinct for the cap arithmetic to be exact; incrementing
// guarantees distinctness without deriving `count` keypairs.
static std::vector<std::string>
makeAccountStrings(std::size_t count, std::uint32_t seed = 1)
{
std::vector<std::string> out;
out.reserve(count);
// Start at `seed` so separate calls produce non-overlapping ranges,
// letting a test subscribe disjoint batches across requests.
AccountID id{static_cast<std::uint64_t>(seed)};
for (std::size_t i = 0; i < count; ++i)
{
out.push_back(toBase58(id));
++id;
}
return out;
}
// Append the given account strings as a jss::accounts array onto a fresh
// subscribe request object.
static json::Value
accountsRequest(std::vector<std::string> const& accts)
{
json::Value jv{json::ValueType::Object};
jv[jss::accounts] = json::ValueType::Array;
for (auto const& a : accts)
jv[jss::accounts].append(a);
return jv;
}
// Append the given account strings as a jss::accounts_proposed array onto a
// fresh subscribe request object.
static json::Value
accountsProposedRequest(std::vector<std::string> const& accts)
{
json::Value jv{json::ValueType::Object};
jv[jss::accounts_proposed] = json::ValueType::Array;
for (auto const& a : accts)
jv[jss::accounts_proposed].append(a);
return jv;
}
// A single, valid XRP/USD order book request, as one entry of a
// jss::books array.
static json::Value
oneBookRequest()
{
using namespace jtx;
json::Value jv{json::ValueType::Object};
jv[jss::books] = json::ValueType::Array;
json::Value& book = jv[jss::books][0u];
book[jss::taker_gets] = json::ValueType::Object;
book[jss::taker_gets][jss::currency] = "XRP";
book[jss::taker_pays] = json::ValueType::Object;
book[jss::taker_pays][jss::currency] = "USD";
book[jss::taker_pays][jss::issuer] = Account("alice").human();
return jv;
}
// A single account_history_tx_stream subscribe request for `acct`.
static json::Value
accountHistoryRequest(std::string const& acct)
{
json::Value jv{json::ValueType::Object};
jv[jss::account_history_tx_stream] = json::ValueType::Object;
jv[jss::account_history_tx_stream][jss::account] = acct;
return jv;
}
// An envconfig modifier that lowers the per-connection subscription cap to
// `cap`, so the cap logic in doSubscribe can be driven without subscribing
// the production default (100'000) entries. (Env is non-movable, so this
// returns the config modifier rather than a ready-made Env.)
static auto
cappedConfig(std::size_t cap)
{
return [cap](std::unique_ptr<Config> cfg) {
cfg->maxSubscriptionsPerConnection = cap;
return jtx::singleThreadIo(std::move(cfg));
};
}
void
testSubscriptionCapRejects()
{
// A request that alone exceeds the cap is rejected with the exact
// cap error, before any state is recorded. Baseline negative path.
testcase("subscription cap rejects an over-cap request");
using namespace jtx;
Env env{*this, envconfig(cappedConfig(5))};
auto wsc = makeWSClient(env.app().config());
// Six accounts against a cap of five: rejected.
auto const jr =
wsc->invoke("subscribe", accountsRequest(makeAccountStrings(6)))[jss::result];
BEAST_EXPECT(jr[jss::error] == "invalidParams");
BEAST_EXPECT(jr[jss::error_message] == "Too many subscriptions for this connection.");
}
void
testReSubscribeNotOvercounted()
{
// Re-subscribing accounts already held by this connection adds no new
// tracked state, so it must be admitted even at the cap. The cap check
// must count only NET-NEW accounts, not the raw request size.
testcase("re-subscribe at the cap is not over-counted");
using namespace jtx;
Env env{*this, envconfig(cappedConfig(5))};
auto wsc = makeWSClient(env.app().config());
// Fill the cap exactly with five distinct accounts.
auto const five = makeAccountStrings(5);
{
auto const r = wsc->invoke("subscribe", accountsRequest(five));
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
}
// Re-subscribe the same five: net-new is zero, so it stays within the
// cap and must succeed. (Pre-fix this was wrongly rejected.)
{
auto const r = wsc->invoke("subscribe", accountsRequest(five));
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
}
}
void
testBooksCapIndependentOfAccounts()
{
// Book subscriptions are tracked separately (OrderBookDB) and are not
// part of totalSubscriptionCount(). An account set at the cap must not
// block an unrelated book subscription.
testcase("books cap is independent of account count");
using namespace jtx;
Env env{*this, envconfig(cappedConfig(5))};
Account const alice{"alice"};
env.fund(XRP(10000), alice);
BEAST_EXPECT(env.syncClose());
auto wsc = makeWSClient(env.app().config());
// Fill the account cap exactly.
{
auto const r = wsc->invoke("subscribe", accountsRequest(makeAccountStrings(5)));
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
}
// A single book subscription must still be admitted: it does not count
// against the account cap. (Pre-fix this was wrongly rejected.)
{
auto const r = wsc->invoke("subscribe", oneBookRequest());
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
}
}
void
testMultiFieldNoPartialSubscribe()
{
// A single request mixing fields must be all-or-nothing: if a later
// field trips the cap, an earlier field must NOT have subscribed. The
// leak is detected through the cap arithmetic itself - a follow-up
// request succeeds only if no state leaked from the rejected one.
testcase("multi-field subscribe does not partially subscribe");
using namespace jtx;
Env env{*this, envconfig(cappedConfig(5))};
auto wsc = makeWSClient(env.app().config());
// accounts_proposed (3, evaluated first, would subscribe) +
// accounts (3): combined 6 exceeds the cap of 5, so the request is
// rejected. The proposed branch must not have leaked its 3 entries.
json::Value req = accountsProposedRequest(makeAccountStrings(3, 1));
for (auto const& a : makeAccountStrings(3, 100))
req[jss::accounts].append(a);
{
auto const jr = wsc->invoke("subscribe", req)[jss::result];
BEAST_EXPECT(jr[jss::error] == "invalidParams");
BEAST_EXPECT(jr[jss::error_message] == "Too many subscriptions for this connection.");
}
// If the rejected request leaked its 3 proposed subscriptions, the
// connection's count is already 3 and this 3-account request would be
// rejected (3 + 3 > 5). With no leak the count is 0 and it succeeds.
{
auto const r = wsc->invoke("subscribe", accountsRequest(makeAccountStrings(3, 200)));
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
}
}
void
testHistoryReSubscribeNotOvercounted()
{
// An account_history_tx_stream subscribe is charged against the cap only
// when it is net-new, matching the account branches. Re-subscribing an
// account-history already held on this connection adds no tracked entry,
// so it must NOT be rejected at the cap. The two rejection causes are
// told apart by their exact error_message: the cap check yields "Too
// many subscriptions for this connection."; a duplicate that gets past
// the cap and is rejected downstream by subAccountHistory yields the
// generic "Invalid parameters.".
testcase("account_history re-subscribe at the cap is not over-counted");
using namespace jtx;
Env env{*this, envconfig(cappedConfig(1))};
Account const alice{"alice"};
env.fund(XRP(10000), alice);
BEAST_EXPECT(env.syncClose());
auto wsc = makeWSClient(env.app().config());
// First account-history subscribe is net-new: charge 1 fills the cap of
// 1 exactly, so it is admitted. Positive path.
{
auto const r = wsc->invoke("subscribe", accountHistoryRequest(alice.human()));
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
}
// Re-subscribe the same account-history while sitting exactly at the
// cap. Net-new is zero, so the cap check must pass; the request is then
// rejected by subAccountHistory as a duplicate, NOT by the cap. Proven
// by the exact message: it is the duplicate error, not the cap error.
// (Pre-fix, the flat charge of 1 made the cap check reject this with the
// cap message instead.)
{
auto const jr =
wsc->invoke("subscribe", accountHistoryRequest(alice.human()))[jss::result];
BEAST_EXPECT(jr[jss::error] == "invalidParams");
BEAST_EXPECT(jr[jss::error_message] == "Invalid parameters.");
BEAST_EXPECT(jr[jss::error_message] != "Too many subscriptions for this connection.");
}
}
void
testHistoryCapRejectsNetNew()
{
// A genuinely net-new account-history subscribe on a connection already
// at the cap IS rejected, with the cap error. Negative path, and the
// counterpart to testHistoryReSubscribeNotOvercounted: it confirms the
// net-new charge still rejects when the entry really is new.
testcase("account_history net-new subscribe is rejected at the cap");
using namespace jtx;
Env env{*this, envconfig(cappedConfig(1))};
Account const alice{"alice"};
Account const bob{"bob"};
env.fund(XRP(10000), alice, bob);
BEAST_EXPECT(env.syncClose());
auto wsc = makeWSClient(env.app().config());
// Fill the cap of 1 with alice's account-history.
{
auto const r = wsc->invoke("subscribe", accountHistoryRequest(alice.human()));
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
}
// A different account-history (bob) is net-new: charge 1 over a cap of 1
// already full, so it is rejected with the cap error.
{
auto const jr =
wsc->invoke("subscribe", accountHistoryRequest(bob.human()))[jss::result];
BEAST_EXPECT(jr[jss::error] == "invalidParams");
BEAST_EXPECT(jr[jss::error_message] == "Too many subscriptions for this connection.");
}
}
void
testAsyncTeardownDoesNotStall()
{
// Test C (core regression): disconnecting a connection with many
// account subscriptions must NOT block subsequent operations or
// publishing. The teardown is now posted to a JobQueue job
// (scheduleAccountCleanup), so it runs off the disconnect thread.
testcase("async teardown does not stall publishing");
using namespace std::chrono_literals;
using namespace jtx;
Env env{*this, singleThreadIo(envconfig())};
Account const alice{"alice"};
env.fund(XRP(10000), alice);
BEAST_EXPECT(env.syncClose());
// A second, long-lived subscriber to alice that must keep receiving
// publishes after the first connection disconnects.
auto wscLive = makeWSClient(env.app().config());
{
json::Value jv{json::ValueType::Object};
jv[jss::accounts] = json::ValueType::Array;
jv[jss::accounts].append(alice.human());
auto const r = wscLive->invoke("subscribe", jv);
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
}
// A connection that subscribes to many accounts, then disconnects. A
// few thousand entries is enough to be a real teardown while still
// running fast in CI.
constexpr std::size_t kBulk = 3000;
{
auto wscBulk = makeWSClient(env.app().config());
auto const r =
wscBulk->invoke("subscribe", accountsRequest(makeAccountStrings(kBulk, 10)));
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
// Destroying the client closes the WS connection, which destroys
// the server-side InfoSub and posts the chunked async cleanup job.
// WSClient exposes no explicit close(); resetting the owning
// unique_ptr is the disconnect path.
wscBulk.reset();
}
// Immediately after the disconnect, an unrelated operation completes
// promptly (it would block for seconds with inline teardown). This is a
// cheap liveness check; the publish assertion below is the real proof.
{
auto const info = env.app().getOPs().getServerInfo(false, true, false);
BEAST_EXPECT(info.isMember(jss::server_state));
}
// The live subscriber still receives a published transaction for alice
// within a short timeout, proving account-publishing was not stalled by
// the concurrent teardown.
{
env(pay(env.master, alice, XRP(100)));
BEAST_EXPECT(env.syncClose());
BEAST_EXPECT(wscLive->findMsg(5s, [&](auto const& jv) {
return jv.isMember(jss::transaction) &&
jv[jss::transaction][jss::TransactionType] == jss::Payment &&
jv[jss::transaction][jss::Destination] == alice.human();
}));
}
wscLive->invoke("unsubscribe", accountsRequest({alice.human()}));
}
void
testResubscribeAfterDisconnect()
{
// Test D (Phase 3 correctness): connection A subscribes to account X
// and disconnects (async cleanup pending, keyed on A's seq). A new
// connection B subscribes to X and MUST still receive publishes for X -
// A's deferred, seq-keyed cleanup must not remove B's subscription.
testcase("re-subscribe after disconnect still delivers");
using namespace std::chrono_literals;
using namespace jtx;
Env env{*this, singleThreadIo(envconfig())};
Account const alice{"alice"};
env.fund(XRP(10000), alice);
BEAST_EXPECT(env.syncClose());
// Connection A subscribes to alice, then disconnects. A also subscribes
// to a bulk set so its deferred cleanup is non-trivial and races with B.
{
auto wscA = makeWSClient(env.app().config());
auto bulk = makeAccountStrings(2000, 10);
bulk.push_back(alice.human());
auto const r = wscA->invoke("subscribe", accountsRequest(bulk));
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
// Disconnect A by destroying its client (no explicit close()).
wscA.reset();
}
// Connection B (a new InfoSub with a distinct seq) subscribes to alice.
auto wscB = makeWSClient(env.app().config());
{
json::Value jv{json::ValueType::Object};
jv[jss::accounts] = json::ValueType::Array;
jv[jss::accounts].append(alice.human());
auto const r = wscB->invoke("subscribe", jv);
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
}
// A publish for alice must reach B. If A's seq-keyed cleanup had wrongly
// removed the shared alice entry, B would receive nothing.
{
env(pay(env.master, alice, XRP(100)));
BEAST_EXPECT(env.syncClose());
BEAST_EXPECT(wscB->findMsg(5s, [&](auto const& jv) {
return jv.isMember(jss::transaction) &&
jv[jss::transaction][jss::TransactionType] == jss::Payment &&
jv[jss::transaction][jss::Destination] == alice.human();
}));
}
wscB->invoke("unsubscribe", accountsRequest({alice.human()}));
}
void
run() override
{
@@ -1569,6 +1977,14 @@ public:
testSubBookChanges();
testNFToken(all);
testNFToken(all - featureNFTokenMintOffer);
testAsyncTeardownDoesNotStall();
testResubscribeAfterDisconnect();
testSubscriptionCapRejects();
testReSubscribeNotOvercounted();
testBooksCapIndependentOfAccounts();
testMultiFieldNoPartialSubscribe();
testHistoryReSubscribeNotOvercounted();
testHistoryCapRejectsNetNew();
}
};

View File

@@ -33,6 +33,7 @@ set(test_modules
shamap
tx
protocol_autogen
server
)
if(NOT WIN32)
list(APPEND test_modules net)

View File

@@ -0,0 +1,60 @@
#include <xrpl/server/InfoSub.h>
#include <gtest/gtest.h>
#include <cstddef>
#include <limits>
using namespace xrpl;
// The per-connection subscription cap is enforced by the pure predicate
// exceedsSubscriptionCap(current, additional). Testing it directly (rather than
// by subscribing the real cap through a WebSocket, which would exceed the frame
// limit and drop the connection before the check runs) lets the boundary be
// asserted exactly.
TEST(InfoSubSubscriptionCap, Boundary)
{
constexpr std::size_t cap = kMaxSubscriptionsPerConnection;
// Empty connection: anything up to the cap is admitted, cap+1 is not.
EXPECT_FALSE(exceedsSubscriptionCap(0, 0));
EXPECT_FALSE(exceedsSubscriptionCap(0, cap));
EXPECT_TRUE(exceedsSubscriptionCap(0, cap + 1));
// Exactly at the cap: zero more is fine, one more is rejected.
EXPECT_FALSE(exceedsSubscriptionCap(cap, 0));
EXPECT_TRUE(exceedsSubscriptionCap(cap, 1));
// One below the cap: exactly one more reaches the cap; two exceed it.
EXPECT_FALSE(exceedsSubscriptionCap(cap - 1, 1));
EXPECT_TRUE(exceedsSubscriptionCap(cap - 1, 2));
}
TEST(InfoSubSubscriptionCap, NoOverflow)
{
constexpr std::size_t cap = kMaxSubscriptionsPerConnection;
constexpr std::size_t max = std::numeric_limits<std::size_t>::max();
// current + additional must not wrap: a huge additional is rejected even
// when current is 0 (the additional > cap term guards the subtraction).
EXPECT_TRUE(exceedsSubscriptionCap(0, max));
EXPECT_TRUE(exceedsSubscriptionCap(cap, max));
}
TEST(InfoSubSubscriptionCap, ExplicitCap)
{
// A configured override is honored: the boundary tracks the passed cap, not
// the built-in default. This is the seam doSubscribe uses to enforce a
// per-connection cap set via [max_subscriptions_per_connection].
constexpr std::size_t cap = 5;
EXPECT_FALSE(exceedsSubscriptionCap(0, cap, cap));
EXPECT_TRUE(exceedsSubscriptionCap(0, cap + 1, cap));
EXPECT_FALSE(exceedsSubscriptionCap(cap, 0, cap));
EXPECT_TRUE(exceedsSubscriptionCap(cap, 1, cap));
EXPECT_FALSE(exceedsSubscriptionCap(cap - 1, 1, cap));
EXPECT_TRUE(exceedsSubscriptionCap(cap - 1, 2, cap));
// The overflow guard still holds with a small explicit cap.
EXPECT_TRUE(exceedsSubscriptionCap(0, std::numeric_limits<std::size_t>::max(), cap));
}

View File

@@ -57,6 +57,15 @@ public:
return transactions_.end();
}
/**
* The last accepted transaction. Precondition: size() > 0.
*/
[[nodiscard]] AcceptedLedgerTx const&
back() const
{
return *transactions_.back();
}
private:
std::shared_ptr<ReadView const> ledger_;
std::vector<std::unique_ptr<AcceptedLedgerTx>> transactions_;

File diff suppressed because it is too large Load Diff

View File

@@ -229,6 +229,12 @@ public:
static constexpr int kMaxJobQueueTx = 1000;
static constexpr int kMinJobQueueTx = 100;
// Optional override for the per-connection subscription cap. Unset means
// use the built-in default (kMaxSubscriptionsPerConnection in InfoSub.h).
// Kept as an override here, rather than the default itself, so the core
// module need not depend on the server module that owns the constant.
std::optional<std::size_t> maxSubscriptionsPerConnection;
// Amendment majority time
std::chrono::seconds amendmentMajorityTime = kDefaultAmendmentMajorityTime;

View File

@@ -677,6 +677,9 @@ Config::loadFromString(std::string const& fileContents)
if (getSingleSection(secConfig, Sections::kNetworkQuorum, strTemp, j_))
networkQuorum = beast::lexicalCastThrow<std::size_t>(strTemp);
if (getSingleSection(secConfig, Sections::kMaxSubscriptionsPerConnection, strTemp, j_))
maxSubscriptionsPerConnection = beast::lexicalCastThrow<std::size_t>(strTemp);
fees = setupFeeVote(section(Sections::kVoting));
/* [fee_default] is documented in the example config files as useful for
* things like offline transaction signing. Until that's completely

View File

@@ -7,6 +7,7 @@
#include <xrpld/rpc/detail/Tuning.h>
#include <xrpl/basics/Log.h>
#include <xrpl/basics/UnorderedContainers.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/json/json_value.h>
#include <xrpl/ledger/ReadView.h>
@@ -19,6 +20,7 @@
#include <xrpl/server/InfoSub.h>
#include <xrpl/server/NetworkOPs.h>
#include <cstddef>
#include <memory>
#include <optional>
#include <stdexcept>
@@ -26,6 +28,24 @@
namespace xrpl {
namespace {
/**
* Test whether admitting `additional` subscriptions would exceed the cap.
*
* @param ispSub The connection's InfoSub, queried for its current count.
* @param additional Number of new items this branch would add.
* @param cap The effective per-connection cap for this request.
* @return true if the request must be rejected to stay within the cap.
*/
[[nodiscard]] bool
wouldExceedSubscriptionCap(InfoSub::ref ispSub, std::size_t additional, std::size_t cap)
{
return exceedsSubscriptionCap(ispSub->totalSubscriptionCount(), additional, cap);
}
} // namespace
json::Value
doSubscribe(RPC::JsonContext& context)
{
@@ -105,6 +125,11 @@ doSubscribe(RPC::JsonContext& context)
}
ispSub->setApiVersion(context.apiVersion);
// Effective per-connection subscription cap: a configured override if set,
// otherwise the built-in default. Resolved once and reused by every branch.
std::size_t const subscriptionCap =
context.app.config().maxSubscriptionsPerConnection.value_or(kMaxSubscriptionsPerConnection);
if (context.params.isMember(jss::streams))
{
if (!context.params[jss::streams].isArray())
@@ -166,30 +191,59 @@ doSubscribe(RPC::JsonContext& context)
}
}
// Parse the proposed (real-time) and normal account sets first, then check
// the cap against their COMBINED net-new total before subscribing either.
// This keeps the account pair all-or-nothing: it never subscribes one set
// and then rejects on the other. Other fields (streams and account_history)
// are still checked and subscribed independently, as they always have been,
// so a later field can be rejected after an earlier one subscribed. The cap
// counts only NET-NEW accounts (those not already tracked on this
// connection), so re-subscribing accounts already held is never wrongly
// rejected.
auto accountsProposed = context.params.isMember(jss::accounts_proposed)
? jss::accounts_proposed
: jss::rt_accounts; // DEPRECATED
if (context.params.isMember(accountsProposed))
bool const hasProposed = context.params.isMember(accountsProposed);
bool const hasAccounts = context.params.isMember(jss::accounts);
hash_set<AccountID> proposedIds;
hash_set<AccountID> accountIds;
if (hasProposed)
{
if (!context.params[accountsProposed].isArray())
return rpcError(RpcInvalidParams);
auto ids = RPC::parseAccountIds(context.params[accountsProposed]);
if (ids.empty())
proposedIds = RPC::parseAccountIds(context.params[accountsProposed]);
if (proposedIds.empty())
return rpcError(RpcActMalformed);
context.netOps.subAccount(ispSub, ids, true);
}
if (context.params.isMember(jss::accounts))
if (hasAccounts)
{
if (!context.params[jss::accounts].isArray())
return rpcError(RpcInvalidParams);
auto ids = RPC::parseAccountIds(context.params[jss::accounts]);
if (ids.empty())
accountIds = RPC::parseAccountIds(context.params[jss::accounts]);
if (accountIds.empty())
return rpcError(RpcActMalformed);
context.netOps.subAccount(ispSub, ids, false);
JLOG(context.j.debug()) << "doSubscribe: accounts: " << ids.size();
}
if (hasProposed || hasAccounts)
{
// Atomic check-and-reserve, so two concurrent requests sharing this
// InfoSub (admin subscribe-by-url) cannot both pass the cap check.
if (!ispSub->tryReserveAccountSubscriptions(proposedIds, accountIds, subscriptionCap))
return RPC::makeParamError("Too many subscriptions for this connection.");
}
if (hasProposed)
context.netOps.subAccount(ispSub, proposedIds, true);
if (hasAccounts)
{
context.netOps.subAccount(ispSub, accountIds, false);
JLOG(context.j.debug()) << "doSubscribe: accounts: " << accountIds.size();
}
if (context.params.isMember(jss::account_history_tx_stream))
@@ -206,6 +260,13 @@ doSubscribe(RPC::JsonContext& context)
if (!id)
return rpcError(RpcInvalidParams);
// Charge the cap only when net-new, like the account branches. Not
// atomic here (subAccountHistory does its own dup-detecting insert), but
// a concurrent race adds at most one entry, so the overshoot is trivial.
std::size_t const historyCharge = ispSub->hasAccountHistorySubscription(*id) ? 0 : 1;
if (wouldExceedSubscriptionCap(ispSub, historyCharge, subscriptionCap))
return RPC::makeParamError("Too many subscriptions for this connection.");
if (auto result = context.netOps.subAccountHistory(ispSub, *id); result != RpcSuccess)
{
return rpcError(result);
@@ -222,6 +283,10 @@ doSubscribe(RPC::JsonContext& context)
if (!context.params[jss::books].isArray())
return rpcError(RpcInvalidParams);
// Book subscriptions are tracked separately (OrderBookDB) and are not
// part of totalSubscriptionCount(), so they are not gated by the
// per-connection account cap. Each book entry is validated and
// subscribed below.
for (auto& j : context.params[jss::books])
{
if (!j.isObject() || !j.isMember(jss::taker_pays) || !j.isMember(jss::taker_gets) ||