Skip to content

Report a live account missing from by_name as shared-memory corruption - #179

Merged
On1x merged 7 commits into
masterfrom
pm
Oct 15, 2026
Merged

On1x merged 7 commits into
masterfrom
pm

Conversation

@On1x

@On1x On1x commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

Commits in this PR

pm is the working branch, so this pull request carries the whole batch (same shape as #177, #175, #171):

Commit Date What
f2517168 15.10 this change — report a live account missing from by_name as shared-memory corruption
d7e2ad1e 11.10 agent-access: reject an over-long agent_name instead of truncating it silently
9e30eb9f 11.10 docs(agent-access): correct what an agent key signs; drop nonexistent sign_data

To review only the corruption fix: commit f2517168.

Problem

A node that loses a live account from the by_name index does not recover: it stops applying
blocks and sits behind head until an operator intervenes. That is what happened in the 14.10
incident — block 84284542 died in get_account() with a bare unknown key (out_of_range
from chainbase), and _push_block / the p2p layer rejected the block exactly as if it were
merely invalid.

The node already has the machinery for the opposite behaviour: shared_memory_corruption_exception
is caught in four places (chain::plugin::accept_block / accept_transaction,
p2p_plugin::accept_block, validator) and funnels into attempt_auto_recovery(), which pauses
P2P, wipes the database and reloads the newest snapshot (under a 3-attempt / 5-minute guard).
Those handlers cannot fire for this failure, because the block-application path never throws
that type.

Change

In database::_apply_transaction, when a required authority is missing from by_name,
discriminate the two cases the bare exception conflates:

  • account reachable in by_id but not by name — the name index lost a live entry: log the
    diagnostics (head, trx, expiration, account_index_size) and throw
    shared_memory_corruption_exception, so the existing handlers start recovery.
  • account absent from both indexes — a block naming an account that does not exist: fall through
    to get_account() and reject exactly as before.

by_id is an independent ordered index (also a tree, not a flat vector). A live object
reachable by id but not by name proves an index inconsistency; this does not assume that
the id index is immune to corruption.

Locking

This path already runs under an exclusive write lock (push_block / push_transaction hold it for
the whole application), so a missing name cannot be a racing writer. A nested
with_strong_read_lock is not available here either: read_write_mutex is a non-recursive
boost::shared_mutex, so a second lock from the same thread would deadlock. The re-read is
therefore done under the lock already held, which is the strongest available form of the check.

Cost

The by_id scan runs only after a name lookup has already failed (a block naming a nonexistent
account — tens per hour on mainnet) and walks ~14k entries.

Deployment

Measured on prod (docker top vizd): the running command line is

/usr/local/bin/vizd --rpc-endpoint=0.0.0.0:8090 --p2p-endpoint=0.0.0.0:2001 --data-dir=/var/lib/vizd

--auto-recover-from-snapshot is not on it, and it does not need to be: the option is
bool_switch()->default_value(true) (plugins/chain/plugin.cpp:411, added by 88bc7768, in
master since 28.04.2026 — long before the deployed 01.10 image was built). So the live binary
already has self-recovery enabled; the only thing missing is the exception type that reaches it,
which is what this change supplies. Container RestartPolicy=no is unchanged and remains correct:
attempt_auto_recovery() either recovers or calls appbase::app().quit() deliberately.

Verification

  • Build for a PR (vizd:pm image) — success; Tests — success (both on f2517168).
  • Local single-TU -fsyntax-only on database.cpp — clean.
  • A regression test deliberately invalidates name-index ordering in an isolated simulation database,
    restores the original keys before teardown, and checks that a live name-index miss throws the
    typed corruption exception while an account absent from both indexes remains an ordinary rejection.

Audit follow-up

Preserve shared_memory_corruption_exception across the four block-application catch sites
that otherwise consume it as an ordinary failure: fork application, its schedule-relaxed retry,
_push_next cascade application, and pending transaction selection during block generation.
Each now rethrows before the generic fc::exception handler, so chain/P2P/validator recovery
can receive the original type. Ordinary invalid-block and invalid-transaction handling is unchanged.

The agent-name regression also checks legacy byte parity at lengths 1 and 32, plus
wire round-trip preservation and rejection of a 33-byte name.

Pending/popped transaction restoration also propagated neither exception: it lived in a
noexcept destructor and swallowed per-transaction failures. Restoration is now an explicit
call after callback completion (including ordinary callback failure), with typed corruption
rethrows. An already detected corruption skips reapplication. Regression coverage exercises
both pending and popped restoration and preservation of an ordinary callback exception.

Local audit verification (0441489)

  • Single-TU syntax checks: database.cpp, database_hardfork.cpp, changed test TUs — clean.
  • Protocol agent tests: 13 passed; simulated-node smoke tests: 4 passed; agent chain tests: 23 passed;
    equivocation seed_deadbeef scenario: passed.
  • Local BUILD_TESTNET build used -O0 for database.cpp only (repository build settings unchanged);
    harness ran with ASan/UBSan and leak detection disabled. Full release validation is the new CI run.
  • No production deployment or master merge was performed.

On1x added 4 commits October 11, 2026 12:51
… sign_data

The old sentence claimed an agent key "cannot pass passwordless site auth or sign_data". There is
no `sign_data` anywhere in the chain (the name occurred only in that line), and the claim mixed two
layers: consensus sees operations only, while a `domain:auth:account:regular:time:nonce` signature
is off-chain. The verifying libraries (VIZ\Auth in viz-php-lib, viz.auth in viz-js-lib) accept an
agent for such a signature when the agent row carries the site's addon - there the addon, not the
operation list, is the grant (decision q#2152=A). Both layers stated in en and @l10n/ru.
… silently

The label lived in an `account_name_type` (32 bytes) whose constructor TRUNCATES: a string longer
than 32 bytes was memcpy'd down to its first 32, with no error anywhere. validate() only checked
emptiness and the character set, so the declared "1..32" bound was never enforced.

The consequence is not cosmetic. Two labels sharing the first 32 bytes collapse into the same
(principal, agent_name) key of `by_permission_account`, so the second grant REPLACES the first row
- rotating its key, operation list and expiration - while the caller believes it added an agent.
The label is an agent's identity, so silently renaming one is the wrong failure mode.

Fix: the op carries the label as a plain string and validate() refuses anything longer than
CHAIN_MAX_ACCOUNT_NAME_LENGTH (32) while the raw length is still visible. The evaluator compares
through the string form too, so the name check cannot pass through the truncating conversion.

Wire/JSON compatible: a fixed_string packs and reflects exactly as a string does (proved: bytes
and JSON form identical for one and the same label), so no transaction changes shape and nothing
has to be re-signed or re-snapshotted.

Test: pm_agent_access pins the boundary - 33 bytes refused, 32 bytes accepted. Reverting only the
fix (test kept) makes the new check fail, so it is not a test that cannot fail.
The block-application path resolved required authorities through
get_account(), which throws a generic chainbase out_of_range ("unknown
key") when a name is absent. That type lands in the generic fc::exception
branch of the p2p / chain / validator handlers, which merely rejects the
block and leaves the node sitting behind head — exactly the 14.10 incident.

Discriminate the two cases the bare exception conflates. This path already
holds the strong write lock (push_block / push_transaction take it for the
whole application), so a miss cannot be a racing writer, and a nested
with_strong_read_lock is not an option: read_write_mutex is a non-recursive
boost::shared_mutex, so a second lock from this thread would deadlock.

The discriminator is the index that does not break: by_id is a flat vector,
by_name is the red-black tree. An account reachable only by id means the
name index lost a live entry — log the diagnostics and throw
shared_memory_corruption_exception, which the existing handlers turn into
attempt_auto_recovery(). An account absent from both indexes is a block
naming a nonexistent account: ordinary rejection, behaviour unchanged.
@@ -6065,6 +6075,39 @@ namespace graphene { namespace chain {
const validator_schedule_object &consensus = get_validator_schedule_object();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This discriminates corruption only for accounts that are required signers of the transaction (required comes from trx.get_required_authorities(...)). database::get_account() is called directly, with no such guard, in ~115 other places across the evaluators — including the most common one of all:

// transfer_evaluator::do_apply, chain_evaluator.cpp:887
const auto &from_account = _db.get_account(o.from);  // signer -> covered by this check
const auto &to_account   = _db.get_account(o.to);     // not a signer -> NOT covered

o.to never appears in required (receiving a transfer needs no signature), so if the index damage happens to make the receiver's name unreachable rather than the sender's, get_account(o.to) still throws the bare fc::assert_exception — not shared_memory_corruption_exception — and the node falls right back into the stuck-behind-head failure this PR is meant to fix. Same hole in escrow_transfer_evaluator (o.to, o.agent at lines 720), benefactor accounts in award_evaluator/fixed_award_evaluator, invite_evaluator's receiver, etc.

The PR description doesn't say which account died in get_account() during the 14.10 incident (block 84284542). If it wasn't a required signer, this patch wouldn't have caught the incident it's written to fix. Pushing the by_id discrimination down into get_account()/find_account itself (so every one of the ~115 call sites inherits it for free) would close the gap without duplicating this block at every caller.

@On1x On1x Oct 15, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks. Fixed in 4446e11: the by_id discrimination now lives in database::get_account() itself, so all evaluator call sites (transfer to, escrow to/agent, award beneficiaries, invite receiver, …) inherit it. It is gated by a thread-local apply depth raised in _apply_block/_apply_transaction, so only the write-lock holder can turn a by_name miss into shared_memory_corruption_exception; lockless readers on other threads (API, block-production hints) keep the old behaviour and can't trigger a false recovery. find_account() is unchanged (existence checks stay O(log n)); the O(accounts) scan runs only on a miss, which already rejects the op. The duplicated block in _apply_transaction is removed. #180 (P2P routing) is cherry-picked on top as 8d723cf.

On1x and others added 2 commits October 15, 2026 11:43
Review of #179 (chiliec): the by_id discrimination only covered required
signers, while ~115 evaluator call sites use get_account() directly (e.g.
the transfer receiver). Move the check into database::get_account(),
gated by a thread-local apply depth raised in _apply_block and
_apply_transaction, so only the write-lock holder can turn a miss into
shared_memory_corruption_exception; lockless readers on other threads
keep the old behaviour.
@On1x
On1x merged commit abee329 into master Oct 15, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants