Compare commits

...

12 Commits

Author SHA1 Message Date
Nicholas Dudfield
6238cb7109 test(amendment): cover unsupported ledger jump shutdown 2026-09-07 18:49:31 +07:00
Richard Holland
0cd4f6aae4 test cases and more useful message 2026-09-07 19:40:52 +10:00
Richard Holland
a4b0a90998 Merge branch 'dev' into fail-fast-amendment-blocking 2026-09-07 13:25:17 +10:00
Nicholas Dudfield
6981b5b06b refactor(app): colocate amendment receipt helpers 2026-09-02 16:35:50 +07:00
Nicholas Dudfield
d4a681430a fix(amendment): persist blocked shutdown receipt 2026-09-02 13:44:53 +07:00
Nicholas Dudfield
3760e1fb41 fix: begin unsupported amendment shutdown five minutes early 2026-09-01 13:06:13 +07:00
Nicholas Dudfield
6442e0990b Merge remote-tracking branch 'origin/dev' into fail-fast-amendment-blocking 2026-09-01 09:41:41 +07:00
Nicholas Dudfield
1e6cda4d64 fix: add fatal log on amendment-blocked shutdown 2026-06-19 09:35:39 +07:00
Nicholas Dudfield
c146f15247 fix: rethrow runtime_error if not amendment blocked 2026-06-19 09:35:39 +07:00
Nicholas Dudfield
fb5081d1f4 fix: narrow catch to std::runtime_error in switchLastClosedLedger 2026-06-19 09:35:39 +07:00
Nicholas Dudfield
d4bec012a2 fix: skip signalStop in standalone mode for test compatibility 2026-06-19 09:35:39 +07:00
Nicholas Dudfield
f7187ba94f fix: fail fast when amendment blocked instead of zombie state
- signalStop() for graceful shutdown when unsupported amendment activates
- early shutdown ~1 minute before expected activation to avoid race
- try/catch in switchLastClosedLedger to survive unknown field crashes
  during shutdown window
- show amendment warning to all RPC users, not just admin

Fixes: #706
2026-06-19 09:35:39 +07:00
8 changed files with 856 additions and 7 deletions

View File

@@ -19,15 +19,548 @@
#include <test/jtx.h>
#include <test/jtx/WSClient.h>
#include <test/jtx/envconfig.h>
#include <xrpld/app/consensus/RCLValidations.h>
#include <xrpld/app/ledger/Ledger.h>
#include <xrpld/app/ledger/LedgerMaster.h>
#include <xrpld/app/main/Application.h>
#include <xrpld/app/misc/AmendmentTable.h>
#include <xrpld/app/misc/NetworkOPs.h>
#include <xrpld/core/Config.h>
#include <xrpld/core/ConfigSections.h>
#include <xrpl/basics/FileUtilities.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/beast/utility/temp_dir.h>
#include <xrpl/protocol/BuildInfo.h>
#include <xrpl/protocol/ErrorCodes.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/digest.h>
#include <xrpl/protocol/jss.h>
#include <boost/filesystem/operations.hpp>
#include <boost/filesystem/path.hpp>
#include <chrono>
#include <cstring>
#include <memory>
#include <mutex>
#include <stdexcept>
#include <string>
namespace ripple {
class AmendmentBlocked_test : public beast::unit_test::suite
{
// An amendment id this binary will never support.
static uint256
unsupportedAmendmentId()
{
std::string const in = "AmendmentBlocked_test.unsupported";
sha256_hasher h;
using beast::hash_append;
hash_append(h, in);
auto const d = static_cast<sha256_hasher::result_type>(h);
uint256 result;
std::memcpy(result.data(), d.data(), d.size());
return result;
}
// The majority period only sets how far out activation is expected, so
// shorten it from two weeks. That keeps the ledger close-time jumps in
// these tests down to minutes, and makes the relationship to the
// five-minute shutdown lead time in LedgerMaster obvious.
static std::unique_ptr<Config>
shortMajorityConfig()
{
using namespace std::chrono_literals;
auto cfg = test::jtx::envconfig();
cfg->AMENDMENT_MAJORITY_TIME = 15min;
return cfg;
}
// Give the amendment table a majority, as of now, for an amendment we do
// not support; activation is then expected one majority period out.
// Bypasses the ReadView overload (and therefore needValidatedLedger) on
// purpose: because lastUpdateSeq_ is set to the current sequence, later
// closes within the same 256-ledger block will not recompute -- and so
// will not clear -- what we inject here.
void
injectUnsupportedMajority(test::jtx::Env& env)
{
auto const seq = env.closed()->info().seq;
majorityAmendments_t majority;
majority[unsupportedAmendmentId()] = env.now();
env.app().getAmendmentTable().doValidatedLedger(seq, {}, majority);
auto const first =
env.app().getAmendmentTable().firstUnsupportedExpected();
BEAST_EXPECT(
first &&
*first == env.now() + env.app().config().AMENDMENT_MAJORITY_TIME);
}
void
testReceiptFileHelpers()
{
testcase("amendment blocked receipt helpers");
auto const id = unsupportedAmendmentId();
{
beast::temp_dir td;
Config cfg;
cfg.CONFIG_DIR = td.path();
auto const path = amendmentBlockedFilePath(cfg);
BEAST_EXPECT(path.filename() == "README_AMENDMENT_BLOCKED");
BEAST_EXPECT(
path.parent_path() == boost::filesystem::path{td.path()});
BEAST_EXPECT(!boost::filesystem::exists(path));
// Nothing to remove yet, and that is not an error.
boost::system::error_code ec;
BEAST_EXPECT(!removeAmendmentBlockedFile(cfg, ec));
BEAST_EXPECT(!ec);
BEAST_EXPECT(!writeAmendmentBlockedFile(
cfg, {to_string(id) + " (already active)"}));
BEAST_EXPECT(boost::filesystem::exists(path));
auto const contents = getFileContents(ec, path);
BEAST_EXPECT(!ec);
BEAST_EXPECT(
contents.find("XAHAUD STOPPED: UPGRADE REQUIRED") == 0);
// When it stopped, what was running, and what it choked on.
BEAST_EXPECT(contents.find("Stopped at:") != std::string::npos);
BEAST_EXPECT(
contents.find(BuildInfo::getVersionString()) !=
std::string::npos);
BEAST_EXPECT(contents.find(to_string(id)) != std::string::npos);
BEAST_EXPECT(contents.find("already active") != std::string::npos);
BEAST_EXPECT(contents.find("Upgrade xahaud") != std::string::npos);
// The receipt is self-clearing, so it must not tell the operator
// to delete anything.
BEAST_EXPECT(
contents.find("removed automatically") != std::string::npos);
// The timestamp is rendered, not a placeholder: to_string_iso
// gives YYYY-MM-DDTHH:MM:SSZ.
auto const stampAt = contents.find("Stopped at:");
if (BEAST_EXPECT(stampAt != std::string::npos))
{
auto const eol = contents.find('\n', stampAt);
auto const line = contents.substr(stampAt, eol - stampAt);
BEAST_EXPECT(line.find("20") != std::string::npos);
BEAST_EXPECT(line.find('T') != std::string::npos);
BEAST_EXPECT(line.find('Z') != std::string::npos);
}
// Now it can be removed, and removal is reported.
ec.clear();
BEAST_EXPECT(removeAmendmentBlockedFile(cfg, ec));
BEAST_EXPECT(!ec);
BEAST_EXPECT(!boost::filesystem::exists(path));
// With no amendments to name the receipt still says something
// useful.
BEAST_EXPECT(!writeAmendmentBlockedFile(cfg, {}));
ec.clear();
auto const bare = getFileContents(ec, path);
BEAST_EXPECT(!ec);
BEAST_EXPECT(
bare.find("not supported by this build") != std::string::npos);
}
// A directory we cannot write to must surface an error rather than
// throw. The shutdown continues either way; only the receipt is lost,
// which is what happens on installs where CONFIG_DIR is read-only for
// the account xahaud runs as.
{
Config cfg;
cfg.CONFIG_DIR =
boost::filesystem::path{"/"} / "no" / "such" / "directory";
BEAST_EXPECT(!!writeAmendmentBlockedFile(cfg, {}));
boost::system::error_code ec;
BEAST_EXPECT(!removeAmendmentBlockedFile(cfg, ec));
}
}
void
testStandaloneDoesNotStop()
{
testcase("standalone does not stop or leave a receipt");
using namespace test::jtx;
beast::temp_dir td;
Env env{*this, envconfig([&](std::unique_ptr<Config> cfg) {
cfg->CONFIG_DIR = td.path();
return cfg;
})};
BEAST_EXPECT(env.app().config().standalone());
auto const path = amendmentBlockedFilePath(env.app().config());
env.app().getOPs().setAmendmentBlocked();
BEAST_EXPECT(env.app().getOPs().isAmendmentBlocked());
BEAST_EXPECT(!env.app().getOPs().isAmendmentWarned());
BEAST_EXPECT(!env.app().isStopping());
BEAST_EXPECT(!boost::filesystem::exists(path));
}
void
testShutdownOnBlock()
{
testcase("amendment blocked stops the server");
using namespace test::jtx;
// A non-standalone Env is needed to exercise shutdown rather than
// only setting the blocked flag. Consequences when editing:
// setup() arms the state timer, run() arms the deadlock detector, and
// signalStop() below releases run() to tear the application down
// concurrently with the rest of this function. Do all the assertions
// straight away and let the Env go out of scope promptly.
beast::temp_dir td;
Env env{*this, envconfig([&](std::unique_ptr<Config> config) {
config->NODE_SIZE = 0;
config->setupControl(true, true, false);
// setupControl picks a production node size for
// non-standalone; put it back to "tiny" for the test.
config->NODE_SIZE = 0;
config->CONFIG_DIR = td.path();
config->legacy("database_path", td.path());
return config;
})};
BEAST_EXPECT(!env.app().config().standalone());
auto const path = amendmentBlockedFilePath(env.app().config());
BEAST_EXPECT(path.filename() == "README_AMENDMENT_BLOCKED");
BEAST_EXPECT(!boost::filesystem::exists(path));
BEAST_EXPECT(!env.app().isStopping());
// Make the table report an unsupported amendment so the receipt has
// something concrete to name.
auto const id = unsupportedAmendmentId();
env.app().getAmendmentTable().enable(id);
BEAST_EXPECT(env.app().getAmendmentTable().hasUnsupportedEnabled());
env.app().getOPs().setAmendmentBlocked();
BEAST_EXPECT(env.app().getOPs().isAmendmentBlocked());
BEAST_EXPECT(env.app().isStopping());
BEAST_EXPECT(boost::filesystem::exists(path));
boost::system::error_code readError;
auto const contents = getFileContents(readError, path);
BEAST_EXPECT(!readError);
BEAST_EXPECT(
contents.find("XAHAUD STOPPED: UPGRADE REQUIRED") !=
std::string::npos);
BEAST_EXPECT(contents.find("Upgrade xahaud") != std::string::npos);
BEAST_EXPECT(contents.find(to_string(id)) != std::string::npos);
BEAST_EXPECT(contents.find("already active") != std::string::npos);
// Repeat calls are a no-op: Change::applyAmendment reaches this from
// the transaction apply path without an isBlocked() guard, so it must
// not rewrite the receipt or re-log once per ledger.
BEAST_EXPECT(boost::filesystem::remove(path));
env.app().getOPs().setAmendmentBlocked();
BEAST_EXPECT(!boost::filesystem::exists(path));
}
void
checkJump(bool unsupported)
{
using namespace test::jtx;
beast::temp_dir td;
Env env{*this, envconfig([&](std::unique_ptr<Config> cfg) {
cfg->setupControl(true, true, false);
cfg->NODE_SIZE = 0;
cfg->CONFIG_DIR = td.path();
cfg->legacy("database_path", td.path());
return cfg;
})};
auto& app = env.app();
auto& ops = app.getOPs();
auto& master = app.getLedgerMaster();
std::lock_guard lock(app.getMasterMutex());
auto const before = master.getClosedLedger();
// A direct child is not preferred over our LCL: consensus may be
// about to build it. Two ledgers ahead forces the JUMP path.
auto parent =
std::make_shared<Ledger>(*before, app.timeKeeper().closeTime());
parent->updateSkipList();
parent->setImmutable();
auto candidate =
std::make_shared<Ledger>(*parent, app.timeKeeper().closeTime());
candidate->updateSkipList();
if (unsupported)
{
auto const key = keylet::amendments();
auto const existing = candidate->read(key);
auto sle = existing ? std::make_shared<SLE>(*existing)
: std::make_shared<SLE>(key);
STVector256 amendments;
if (sle->isFieldPresent(sfAmendments))
amendments = sle->getFieldV256(sfAmendments);
amendments.push_back(unsupportedAmendmentId());
sle->setFieldV256(sfAmendments, amendments);
if (existing)
candidate->rawReplace(sle);
else
candidate->rawInsert(sle);
}
// A serialized uint32 with an unknown field number. Insert raw bytes
// so the real transaction parser, reached through TxQ, must throw.
BEAST_EXPECT(SField::getField(STI_UINT32, 255).isInvalid());
auto tx = std::make_shared<Serializer>();
tx->add8(0x20);
tx->add8(255);
while (tx->getDataLength() < txMinSizeBytes)
tx->add8(0);
auto meta = std::make_shared<Serializer>();
meta->add8(0xE1);
auto const txID = sha512Half(tx->slice());
candidate->rawTxInsert(txID, tx, meta);
candidate->setImmutable();
bool unknownField = false;
try
{
candidate->txRead(txID);
}
catch (std::runtime_error const& e)
{
unknownField = std::string(e.what()).starts_with("Unknown field");
}
if (!BEAST_EXPECT(unknownField))
return;
master.storeLedger(candidate);
auto const keys = randomKeyPair(KeyType::secp256k1);
auto const nodeID = calcNodeID(keys.first);
auto validation = std::make_shared<STValidation>(
app.timeKeeper().closeTime(),
keys.first,
keys.second,
nodeID,
[&](STValidation& v) {
v.setFieldH256(sfLedgerHash, candidate->info().hash);
v.setFieldU32(sfLedgerSequence, candidate->seq());
});
// Add directly so ledger acceptance does not discover the amendment
// before JUMP has a chance to inspect the candidate itself.
BEAST_EXPECT(
app.getValidations().add(nodeID, RCLValidation{validation}) ==
ValStatus::current);
if (!BEAST_EXPECT(
app.getValidations().getPreferredLCL(
RCLValidatedLedger{before, env.journal},
master.getValidLedgerIndex(),
{}) == candidate->info().hash))
return;
BEAST_EXPECT(!app.getAmendmentTable().hasUnsupportedEnabled());
BEAST_EXPECT(!app.getAmendmentTable().firstUnsupportedExpected());
BEAST_EXPECT(!ops.isAmendmentBlocked());
BEAST_EXPECT(!app.isStopping());
auto const receipt = amendmentBlockedFilePath(app.config());
BEAST_EXPECT(!boost::filesystem::exists(receipt));
bool threw = false;
try
{
ops.endConsensus({});
}
catch (std::runtime_error const& e)
{
threw = true;
BEAST_EXPECT(std::string(e.what()).starts_with("Unknown field"));
}
BEAST_EXPECT(threw == !unsupported);
BEAST_EXPECT(ops.isAmendmentBlocked() == unsupported);
BEAST_EXPECT(app.isStopping() == unsupported);
BEAST_EXPECT(boost::filesystem::exists(receipt) == unsupported);
BEAST_EXPECT(
master.getClosedLedger()->info().hash == before->info().hash);
}
void
testJumpStopsForUnsupportedAmendment()
{
testcase("JUMP to an unsupported ledger stops the server");
checkJump(/*unsupported=*/true);
}
void
testJumpRethrowsParsingError()
{
testcase("JUMP parsing errors without unsupported amendments escape");
checkJump(/*unsupported=*/false);
}
void
testWarnsOutsideShutdownWindow()
{
testcase("unsupported majority warns while activation is distant");
using namespace test::jtx;
Env env{*this, shortMajorityConfig()};
BEAST_EXPECT(!env.app().getOPs().isBlocked());
BEAST_EXPECT(env.close());
injectUnsupportedMajority(env);
BEAST_EXPECT(env.close());
// A majority period out: warn, keep running.
BEAST_EXPECT(env.app().getOPs().isAmendmentWarned());
BEAST_EXPECT(!env.app().getOPs().isAmendmentBlocked());
// The table can name the amendment, and reports it as pending rather
// than active. This is what ends up in the receipt.
auto const unsupported =
env.app().getAmendmentTable().unsupportedAmendments();
BEAST_EXPECT(unsupported.size() == 1);
if (unsupported.size() == 1)
{
BEAST_EXPECT(unsupported[0].id == unsupportedAmendmentId());
BEAST_EXPECT(
unsupported[0].expected ==
env.app().getAmendmentTable().firstUnsupportedExpected());
}
// Because firstUnsupportedExpected() is set, the warning carries the
// expected activation date.
auto const si = env.rpc("server_info")[jss::result];
BEAST_EXPECT(si.isMember(jss::info));
auto const& warnings = si[jss::info][jss::warnings];
BEAST_EXPECT(warnings.isArray() && warnings.size() == 1);
if (warnings.isArray() && warnings.size() == 1)
{
BEAST_EXPECT(
warnings[0u][jss::id].asInt() == warnRPC_UNSUPPORTED_MAJORITY);
auto const& details = warnings[0u][jss::details];
BEAST_EXPECT(details.isMember(jss::expected_date));
BEAST_EXPECT(details.isMember(jss::expected_date_UTC));
}
}
void
testUnsupportedAmendmentReporting()
{
testcase("unsupported amendments are reported for the receipt");
using namespace test::jtx;
Env env{*this, shortMajorityConfig()};
auto& table = env.app().getAmendmentTable();
BEAST_EXPECT(table.unsupportedAmendments().empty());
// Majority, not yet active -> reported with an expected time.
BEAST_EXPECT(env.close());
injectUnsupportedMajority(env);
auto pending = table.unsupportedAmendments();
BEAST_EXPECT(pending.size() == 1);
if (pending.size() == 1)
BEAST_EXPECT(pending[0].expected.has_value());
// Majority lost -> nothing to report. The list is recomputed from the
// ledger each time, so it must not accumulate.
auto const seq = env.closed()->info().seq;
table.doValidatedLedger(seq, {}, {});
BEAST_EXPECT(table.unsupportedAmendments().empty());
BEAST_EXPECT(!table.firstUnsupportedExpected());
// Active -> reported with no expected time.
BEAST_EXPECT(table.enable(unsupportedAmendmentId()));
BEAST_EXPECT(table.hasUnsupportedEnabled());
auto const active = table.unsupportedAmendments();
BEAST_EXPECT(active.size() == 1);
if (active.size() == 1)
{
BEAST_EXPECT(active[0].id == unsupportedAmendmentId());
BEAST_EXPECT(!active[0].expected);
}
}
void
testWarningVisibleWithoutAdmin()
{
testcase("unsupported majority warning is not admin only");
using namespace test::jtx;
// No closes here: ledger_accept is Role::ADMIN, server_info is
// Role::USER, which is the whole point of the test.
Env env{*this, envconfig(no_admin)};
env.app().getOPs().setAmendmentWarned();
BEAST_EXPECT(env.app().getOPs().isAmendmentWarned());
auto const si = env.rpc("server_info")[jss::result];
BEAST_EXPECT(si.isMember(jss::info));
auto const& warnings = si[jss::info][jss::warnings];
BEAST_EXPECT(warnings.isArray() && warnings.size() == 1);
if (warnings.isArray() && warnings.size() == 1)
{
BEAST_EXPECT(
warnings[0u][jss::id].asInt() == warnRPC_UNSUPPORTED_MAJORITY);
}
}
void
testShutsDownInsideShutdownWindow()
{
testcase("unsupported majority stops the server before activation");
using namespace test::jtx;
Env env{*this, shortMajorityConfig()};
BEAST_EXPECT(!env.app().getOPs().isBlocked());
BEAST_EXPECT(env.close());
injectUnsupportedMajority(env);
// First close only warns: activation is still a majority period away.
BEAST_EXPECT(env.close());
BEAST_EXPECT(env.app().getOPs().isAmendmentWarned());
BEAST_EXPECT(!env.app().getOPs().isAmendmentBlocked());
auto const expected =
*env.app().getAmendmentTable().firstUnsupportedExpected();
// Close two minutes short of the expected activation time. This is not
// a flag ledger and the warning has already been issued, so the check
// has to run on every validated ledger to see this at all.
BEAST_EXPECT(env.close(expected - NetClock::duration{120}));
BEAST_EXPECT(env.app().getOPs().isAmendmentBlocked());
BEAST_EXPECT(!env.app().getOPs().isAmendmentWarned());
// Standalone, so the flag is set but the server keeps running.
BEAST_EXPECT(!env.app().isStopping());
}
void
testShutsDownWhenActivationOverdue()
{
testcase("unsupported majority stops the server when overdue");
using namespace test::jtx;
Env env{*this, shortMajorityConfig()};
BEAST_EXPECT(!env.app().getOPs().isBlocked());
BEAST_EXPECT(env.close());
injectUnsupportedMajority(env);
auto const expected =
*env.app().getAmendmentTable().firstUnsupportedExpected();
// firstUnsupportedExpected() is a lower bound: the amendment actually
// activates at the first flag ledger at or after it, so the server can
// legitimately still be running an hour past it. That is the most
// dangerous state, not the safest, and must stop the server.
BEAST_EXPECT(env.close(expected + NetClock::duration{3600}));
BEAST_EXPECT(env.app().getOPs().isAmendmentBlocked());
BEAST_EXPECT(!env.app().getOPs().isAmendmentWarned());
}
void
testBlockedMethods()
{
@@ -250,6 +783,16 @@ public:
void
run() override
{
testReceiptFileHelpers();
testStandaloneDoesNotStop();
testShutdownOnBlock();
testJumpRethrowsParsingError();
testJumpStopsForUnsupportedAmendment();
testWarnsOutsideShutdownWindow();
testUnsupportedAmendmentReporting();
testWarningVisibleWithoutAdmin();
testShutsDownInsideShutdownWindow();
testShutsDownWhenActivationOverdue();
testBlockedMethods();
}
};

View File

@@ -289,14 +289,49 @@ LedgerMaster::setValidLedger(std::shared_ptr<Ledger const> const& l)
app_.getSHAMapStore().onLedgerClosed(getValidatedLedger());
mLedgerHistory.validatedLedger(l, consensusHash);
app_.getAmendmentTable().doValidatedLedger(l);
using namespace std::chrono_literals;
// How far ahead of the expected activation time to stop the server.
// firstUnsupportedExpected() is only a lower bound on activation: the
// amendment goes live at the first flag ledger at or after that time, so
// stopping early is harmless while stopping late risks being handed a
// ledger we cannot deserialize.
constexpr auto amendmentShutdownLeadTime = 5min;
if (!app_.getOPs().isBlocked())
{
auto const firstUnsupported =
app_.getAmendmentTable().firstUnsupportedExpected();
if (app_.getAmendmentTable().hasUnsupportedEnabled())
{
JLOG(m_journal.error()) << "One or more unsupported amendments "
"activated: server blocked.";
app_.getOPs().setAmendmentBlocked();
}
else if (
firstUnsupported &&
app_.timeKeeper().closeTime() + amendmentShutdownLeadTime >=
*firstUnsupported)
{
// Activation is imminent, or the expected time has already passed
// and we are only waiting on the next flag ledger. Shut down now,
// while we can still deserialize the ledgers we are handed.
//
// This is deliberately checked on every validated ledger rather
// than only on flag ledgers: the lead time above is much shorter
// than the flag ledger interval, so a flag-ledger-only check would
// usually skip straight over the window.
//
// The comparison must not be written as (*first - now), because
// NetClock::rep is unsigned and wraps once the expected time is in
// the past -- which is the most dangerous case, not the safest.
JLOG(m_journal.error())
<< "Unsupported amendment expected to activate at "
<< to_string(*firstUnsupported) << ". Shutting down.";
app_.getOPs().setAmendmentBlocked();
}
else if (!app_.getOPs().isAmendmentWarned() || l->isFlagLedger())
{
// Amendments can lose majority, so re-check periodically (every
@@ -308,12 +343,11 @@ LedgerMaster::setValidLedger(std::shared_ptr<Ledger const> const& l)
// this message may be logged more than once per session, because
// the node will otherwise function normally, and this gives
// operators an opportunity to see and resolve the warning.
if (auto const first =
app_.getAmendmentTable().firstUnsupportedExpected())
if (firstUnsupported)
{
JLOG(m_journal.error()) << "One or more unsupported amendments "
"reached majority. Upgrade before "
<< to_string(*first)
<< to_string(*firstUnsupported)
<< " to prevent your server from "
"becoming amendment blocked.";
app_.getOPs().setAmendmentWarned();

View File

@@ -59,6 +59,7 @@
#include <xrpld/rpc/detail/RPCHelpers.h>
#include <xrpld/shamap/NodeFamily.h>
#include <xrpl/basics/ByteUtilities.h>
#include <xrpl/basics/FileUtilities.h>
#include <xrpl/basics/ResolverAsio.h>
#include <xrpl/basics/random.h>
#include <xrpl/basics/safe_cast.h>
@@ -2372,6 +2373,65 @@ Application::Application() : beast::PropertyStream::Source("app")
//------------------------------------------------------------------------------
boost::filesystem::path
amendmentBlockedFilePath(Config const& config)
{
return config.CONFIG_DIR / "README_AMENDMENT_BLOCKED";
}
boost::system::error_code
writeAmendmentBlockedFile(
Config const& config,
std::vector<std::string> const& amendments)
{
using namespace std::chrono;
std::ostringstream ss;
ss << "XAHAUD STOPPED: UPGRADE REQUIRED\n"
<< "\n"
<< "Stopped at: "
<< to_string_iso(time_point_cast<seconds>(system_clock::now())) << "\n"
<< "This build: " << BuildInfo::getVersionString() << "\n"
<< "\n";
if (amendments.empty())
{
ss << "One or more network amendments are not supported by this "
"build.\n";
}
else
{
ss << "Amendments this build does not support:\n";
for (auto const& amendment : amendments)
ss << " " << amendment << "\n";
}
ss << "\n"
<< "The network has moved to rules this build does not implement, so "
"the\n"
<< "server stopped rather than keep serving ledgers it cannot read.\n"
<< "\n"
<< "To get back in sync:\n"
<< "1. Upgrade xahaud to a version that supports the amendments "
"above.\n"
<< "2. Start xahaud again. This file is removed automatically on "
"start.\n"
<< "\n"
<< "Starting this build again without upgrading will stop the server "
"again\n"
<< "and rewrite this file. Nothing needs to be deleted by hand.\n";
boost::system::error_code ec;
writeFileContents(ec, amendmentBlockedFilePath(config), ss.str());
return ec;
}
bool
removeAmendmentBlockedFile(Config const& config, boost::system::error_code& ec)
{
return boost::filesystem::remove(amendmentBlockedFilePath(config), ec);
}
std::unique_ptr<Application>
make_Application(
std::unique_ptr<Config> config,

View File

@@ -27,9 +27,14 @@
#include <xrpl/beast/utility/PropertyStream.h>
#include <xrpl/protocol/Protocol.h>
#include <boost/asio.hpp>
#include <boost/filesystem/path.hpp>
#include <boost/program_options.hpp>
#include <boost/system/error_code.hpp>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <vector>
namespace ripple {
@@ -279,6 +284,27 @@ make_Application(
std::unique_ptr<Logs> logs,
std::unique_ptr<TimeKeeper> timeKeeper);
/** Location of the receipt left behind when the server stops because it does
not support a network amendment. */
boost::filesystem::path
amendmentBlockedFilePath(Config const& config);
/** Write the amendment-blocked receipt: a record for the operator of when the
server stopped and which amendments it could not support. `amendments`
holds one already-rendered line per unsupported amendment, and may be
empty. Best effort -- any error is returned rather than thrown, and the
caller is expected to continue shutting down either way. */
boost::system::error_code
writeAmendmentBlockedFile(
Config const& config,
std::vector<std::string> const& amendments);
/** Remove any amendment-blocked receipt left behind by a previous run.
Returns true if a receipt was present and has been removed; sets `ec` if
removal was attempted and failed. */
bool
removeAmendmentBlockedFile(Config const& config, boost::system::error_code& ec);
} // namespace ripple
#endif

View File

@@ -809,6 +809,31 @@ run(int argc, char** argv)
// No arguments. Run server.
if (!vm.count("parameters"))
{
// Clear any receipt left by a previous amendment-blocked shutdown. It
// records why the server stopped; it is not a lock. If this build
// still does not support the amendment it will stop again and write a
// fresh one, so there is nothing for the operator to delete by hand
// and no way for a stale receipt to keep a working build down.
// Standalone does not write receipts, so it does not clear them
// either, leaving the file readable for diagnosis.
if (!config->standalone())
{
boost::system::error_code ec;
auto const blockedFile = amendmentBlockedFilePath(*config);
if (removeAmendmentBlockedFile(*config, ec))
{
JLOG(logs->journal("Application").warn())
<< "Removed amendment-blocked receipt " << blockedFile
<< " left by a previous run.";
}
else if (ec)
{
JLOG(logs->journal("Application").warn())
<< "Could not remove amendment-blocked receipt "
<< blockedFile << ": " << ec.message();
}
}
// TODO: this comment can be removed in a future release -
// say 1.7 or higher
if (config->had_trailing_comments())

View File

@@ -27,6 +27,7 @@
#include <xrpl/protocol/STValidation.h>
#include <optional>
#include <vector>
namespace ripple {
@@ -50,6 +51,17 @@ public:
VoteBehavior const vote;
};
/** An amendment seen on the network that this server has no code
support for. */
struct UnsupportedAmendment
{
uint256 id;
/** The time the amendment is expected to activate. Unset if it is
already active. */
std::optional<NetClock::time_point> expected;
};
virtual ~AmendmentTable() = default;
virtual uint256
@@ -80,6 +92,12 @@ public:
virtual std::optional<NetClock::time_point>
firstUnsupportedExpected() const = 0;
/** Amendments this server does not support that are already enabled, or
that have reached majority and are expected to activate. Ordered by
amendment id. Intended for operator-facing diagnostics. */
virtual std::vector<UnsupportedAmendment>
unsupportedAmendments() const = 0;
virtual Json::Value
getJson(bool isAdmin) const = 0;

View File

@@ -28,6 +28,7 @@
#include <xrpld/app/ledger/OpenLedger.h>
#include <xrpld/app/ledger/OrderBookDB.h>
#include <xrpld/app/ledger/TransactionMaster.h>
#include <xrpld/app/main/Application.h>
#include <xrpld/app/main/LoadManager.h>
#include <xrpld/app/misc/AmendmentTable.h>
#include <xrpld/app/misc/DeliverMax.h>
@@ -1701,11 +1702,68 @@ NetworkOPsImp::isAmendmentBlocked()
return amendmentBlocked_;
}
// Render the unsupported amendments for the operator-facing receipt. This is
// the only identification available: an amendment this build does not support
// has no name here, so the id is what the operator matches against the
// release notes.
static std::vector<std::string>
describeUnsupportedAmendments(AmendmentTable const& table)
{
std::vector<std::string> lines;
for (auto const& amendment : table.unsupportedAmendments())
{
std::ostringstream ss;
ss << to_string(amendment.id);
if (amendment.expected)
ss << " (expected to activate "
<< to_string_iso(*amendment.expected) << ")";
else
ss << " (already active)";
lines.push_back(ss.str());
}
return lines;
}
void
NetworkOPsImp::setAmendmentBlocked()
{
amendmentBlocked_ = true;
// Idempotent: this is reached from Change::applyAmendment (i.e. from the
// transaction application path, which is not guarded by isBlocked()) as
// well as from LedgerMaster::setValidLedger. Writing the receipt and
// logging once per process is enough, and it keeps the synchronous file
// write out of any subsequent ledger apply.
if (amendmentBlocked_.exchange(true))
return;
setMode(OperatingMode::CONNECTED);
if (!app_.config().standalone())
{
auto const blockedFile = amendmentBlockedFilePath(app_.config());
if (auto const ec = writeAmendmentBlockedFile(
app_.config(),
describeUnsupportedAmendments(app_.getAmendmentTable())))
{
JLOG(m_journal.fatal())
<< "Could not write amendment-blocked receipt " << blockedFile
<< ": " << ec.message();
}
else
{
JLOG(m_journal.fatal())
<< "Amendment-blocked receipt written to " << blockedFile;
}
JLOG(m_journal.fatal())
<< "This version of xahaud does not support a network amendment. "
"The amendment will activate soon or is already active. "
"The server will stop. Upgrade xahaud before you restart the "
"server.";
app_.signalStop(
"Unsupported network amendment. Upgrade xahaud before you restart "
"the server. Details: " +
blockedFile.string());
}
}
inline bool
@@ -1851,6 +1909,27 @@ NetworkOPsImp::checkLastClosedLedger(
return true;
}
// True if `view` enables an amendment this binary does not implement. Reads
// only the amendments ledger entry, so it is safe to call once transaction
// deserialization is already known to be failing.
static bool
ledgerHasUnsupportedAmendments(
AmendmentTable const& table,
ReadView const& view)
{
auto const sle = view.read(keylet::amendments());
if (!sle || !sle->isFieldPresent(sfAmendments))
return false;
for (auto const& amendment : sle->getFieldV256(sfAmendments))
{
if (!table.isSupported(amendment))
return true;
}
return false;
}
void
NetworkOPsImp::switchLastClosedLedger(
std::shared_ptr<Ledger const> const& newLCL)
@@ -1861,8 +1940,32 @@ NetworkOPsImp::switchLastClosedLedger(
clearNeedNetworkLedger();
// Update fee computations.
app_.getTxQ().processClosedLedger(app_, *newLCL, true);
// Update fee computations. May throw if the ledger contains
// transactions with fields unknown to this binary (e.g. after an
// unsupported amendment activates). Catch to allow graceful shutdown.
try
{
app_.getTxQ().processClosedLedger(app_, *newLCL, true);
}
catch (std::runtime_error const& e)
{
// Do not decide this on amendmentBlocked_ alone. A JUMP can happen
// before any validated ledger has been processed -- e.g. immediately
// after a restart, which is precisely the case that crashed -- so the
// flag may still be clear here. Ask the ledger itself instead.
// Anything else is a real bug and must propagate.
if (!amendmentBlocked_ &&
!ledgerHasUnsupportedAmendments(app_.getAmendmentTable(), *newLCL))
throw;
JLOG(m_journal.error()) << "Failed to process closed ledger "
<< newLCL->info().seq << ": " << e.what();
// No-op if we are already blocked; otherwise this starts the
// shutdown that should have been started before activation.
setAmendmentBlocked();
return;
}
// Caller must own master lock
{
@@ -2542,7 +2645,7 @@ NetworkOPsImp::getServerInfo(bool human, bool admin, bool counters)
"may be incorrectly configured or some [validator_list_sites] "
"may be unreachable.";
}
if (admin && isAmendmentWarned())
if (isAmendmentWarned())
{
Json::Value& w = warnings.append(Json::objectValue);
w[jss::id] = warnRPC_UNSUPPORTED_MAJORITY;

View File

@@ -432,6 +432,11 @@ private:
// will be enabled.
std::optional<NetClock::time_point> firstUnsupportedExpected_;
// Unsupported amendments that have reached majority, and the time each
// is expected to activate. Recomputed alongside
// firstUnsupportedExpected_, so it clears when majority is lost.
std::vector<std::pair<uint256, NetClock::time_point>> unsupportedMajority_;
beast::Journal const j_;
// Database which persists veto/unveto vote
@@ -495,6 +500,9 @@ public:
std::optional<NetClock::time_point>
firstUnsupportedExpected() const override;
std::vector<UnsupportedAmendment>
unsupportedAmendments() const override;
Json::Value
getJson(bool isAdmin) const override;
Json::Value
@@ -807,6 +815,36 @@ AmendmentTableImpl::firstUnsupportedExpected() const
return firstUnsupportedExpected_;
}
std::vector<AmendmentTable::UnsupportedAmendment>
AmendmentTableImpl::unsupportedAmendments() const
{
std::lock_guard lock(mutex_);
std::vector<UnsupportedAmendment> result;
// Already active.
for (auto const& [id, state] : amendmentMap_)
{
if (state.enabled && !state.supported)
result.push_back({id, std::nullopt});
}
// Reached majority, not yet active.
for (auto const& entry : unsupportedMajority_)
{
if (std::none_of(result.begin(), result.end(), [&entry](auto const& u) {
return u.id == entry.first;
}))
result.push_back({entry.first, entry.second});
}
std::sort(result.begin(), result.end(), [](auto const& a, auto const& b) {
return a.id < b.id;
});
return result;
}
std::vector<uint256>
AmendmentTableImpl::doValidation(std::set<uint256> const& enabled) const
{
@@ -967,6 +1005,7 @@ AmendmentTableImpl::doValidatedLedger(
// if it's currently set. If it's not set when the loop is done, then any
// prior unknown amendments have lost majority.
firstUnsupportedExpected_.reset();
unsupportedMajority_.clear();
for (auto const& [hash, time] : majority)
{
AmendmentState& s = add(hash, lock);
@@ -978,6 +1017,7 @@ AmendmentTableImpl::doValidatedLedger(
{
JLOG(j_.info()) << "Unsupported amendment " << hash
<< " reached majority at " << to_string(time);
unsupportedMajority_.emplace_back(hash, time + majorityTime_);
if (!firstUnsupportedExpected_ || firstUnsupportedExpected_ > time)
firstUnsupportedExpected_ = time;
}