Skip to content

fix(anvil): survive OOM-truncated state + bound memory growth - #11

Merged
thiras merged 2 commits into
mainfrom
fix/anvil-oom-state-resilience
Jun 7, 2026
Merged

thiras merged 2 commits into
mainfrom
fix/anvil-oom-state-resilience

Conversation

@thiras

@thiras thiras commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

What happened

The internal anvil devnet behind rpc-dev.decdn.org was returning HTTP 502 for every authenticated RPC call. Caddy, TLS, and basic-auth were all healthy — anvil itself was down.

anvil.service was crash-looping (~4,900 restarts). Root cause, in sequence:

  1. anvil keeps chain history in RAM; with a fixed --block-time 2 it grows without bound.
  2. On the ~3.8 GB / no-swap VPS it reached anon-rss ~3.7 GB and the global OOM-killer killed it at 09:47:48 — mid --state-interval snapshot.
  3. That truncated /var/lib/anvil/state.json to 0 bytes.
  4. anvil's --state refuses to parse an empty file (EOF while parsing a value at line 1 column 0, exit 2) → systemd restarts → same failure → permanent loop.

No backup existed and the file was already empty, so the chain state was unrecoverable regardless (acceptable for a devnet).

The fix — three layers in the anvil role

Layer Change Prevents
Recover anvil-state-guard.sh as ExecStartPre — quarantines an empty/corrupt state.json (moves it aside) so anvil starts a fresh chain instead of wedging The crash-loop itself; any future truncation self-heals
Prevent --prune-history (default 5000) bounds in-memory history; --transaction-block-keeper exposed too (off by default) The OOM in the first place
Contain Optional cgroup MemoryHigh/MemoryMax, set for rpc-dev in group_vars (2G/2560M) A runaway taking out Caddy/sshd via the global OOM-killer

All knobs default to safe, generic values in roles/anvil/defaults/main.yml; the host-specific memory ceilings live in inventory/group_vars/anvil_devnet.yml with the incident noted.

Verification

  • Template renders to valid systemd with resilience flags on and off (no stray continuations).
  • ansible-lint (production profile) ✅ · yamllint ✅ · shellcheck ✅ · KICS 0 critical / 0 high ✅
  • molecule converge+verify ✅ — boots the new unit: anvil up with --prune-history, chain-id 0x7a69, loopback-only binding, 401 auth gate intact.

Out of scope / ops follow-up

  • Immediate production recovery (clear the empty state.json + restart on rpc-dev) was applied separately by the operator — this PR is the durable prevention.
  • Consider adding swap to the VPS as an additional cushion (host/baseline concern, not in this PR).
  • Note: inventory currently points both decdn-node-1 and anvil-vps at the same rpc-dev.decdn.org, which contradicts the "keep on a separate host" guidance — flagging separately.

🤖 Generated with Claude Code

The internal anvil devnet (rpc-dev) crash-looped ~4900 times until Caddy
served only 502s. Root cause: with a fixed block-time anvil's in-memory
chain history grows without bound; on the ~3.8 GB / no-swap VPS it reached
~3.7 GB and the global OOM-killer killed it mid state-snapshot, truncating
/var/lib/anvil/state.json to 0 bytes. anvil's --state then refuses to parse
the empty file ("EOF while parsing a value at line 1 column 0", exit 2),
wedging the unit permanently.

Three layered fixes in the anvil role:

- anvil-state-guard.sh as ExecStartPre: quarantines an empty/corrupt
  state.json (moves it aside) so a truncated snapshot self-heals to a fresh
  chain instead of crash-looping. A devnet can lose state; it can't be down.
- --prune-history (default 5000) bounds in-memory history so the box can't
  OOM in the first place. --transaction-block-keeper is exposed too (off).
- Optional cgroup MemoryHigh/MemoryMax in the unit, set for the rpc-dev VPS
  in group_vars (2G/2560M), so a runaway is confined to its own slice rather
  than letting the global OOM-killer pick Caddy or sshd.

Verified: template renders to valid systemd for flags on/off; ansible-lint
(production), yamllint, shellcheck, KICS (0 high/critical) all pass; molecule
converge+verify boots the new unit (anvil up with --prune-history, chain-id
0x7a69, loopback-only, 401 gate intact).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 7, 2026 13:05

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces memory limits and a state-resilience guard for the Anvil service to prevent and recover from OOM crashes. Feedback on the state-guard script highlights that using jq to validate large state files on resource-constrained hosts could trigger further OOM events, suggesting a lightweight trailing-character check instead. Additionally, it is recommended to use a static filename for corrupt state files rather than timestamps to prevent disk space exhaustion during repeated crash loops.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread ansible/roles/anvil/templates/anvil-state-guard.sh.j2
Comment thread ansible/roles/anvil/templates/anvil-state-guard.sh.j2 Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR hardens the anvil Ansible role to prevent a devnet outage mode where an OOM-killed anvil leaves an empty/partial state.json, causing --state to fail and systemd to crash-loop indefinitely. It adds a pre-start guard to self-heal from truncated snapshots, bounds in-memory growth, and optionally contains memory usage via systemd cgroup limits (with host-specific ceilings applied for the devnet VPS).

Changes:

  • Add an ExecStartPre guard script that quarantines empty/invalid state.json so anvil can start a fresh chain instead of crash-looping.
  • Enable bounded history retention via --prune-history by default, with an optional --transaction-block-keeper knob.
  • Support optional systemd MemoryHigh / MemoryMax limits (enabled for anvil_devnet) to avoid global OOM impacts.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.

Show a summary per file
File Description
ansible/roles/anvil/templates/anvil.service.j2 Adds ExecStartPre state guard, optional --prune-history/--transaction-block-keeper, and optional cgroup memory ceilings.
ansible/roles/anvil/templates/anvil-state-guard.sh.j2 New guard script to detect/quarantine empty or invalid JSON snapshots before anvil starts.
ansible/roles/anvil/tasks/main.yml Installs the guard script before the unit so ExecStartPre always has a valid target, and restarts anvil when it changes.
ansible/roles/anvil/defaults/main.yml Introduces defaults for pruning/history knobs, memory ceiling knobs, and the guard script path.
ansible/inventory/group_vars/anvil_devnet.yml Sets concrete MemoryHigh/MemoryMax ceilings for the constrained devnet VPS and documents the incident context.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Address review on the ExecStartPre guard. It runs inside the unit's cgroup, so
it is subject to the same MemoryMax as anvil — validating a large snapshot with
jq could itself be OOM-killed and re-wedge startup, the exact failure the guard
exists to prevent. Replace the jq parse with an O(1) check: file is non-empty
and its last non-whitespace byte is a JSON close token (} or ]); a truncated
write won't end that way. This also drops the jq dependency.

Quarantine to a static '.corrupt' name instead of a timestamped one so a
repeated crash/OOM loop overwrites a single file rather than accumulating
snapshots and exhausting disk on the constrained VPS; the latest sample still
suffices for forensics. Drops the date dependency too.

Verified: shellcheck clean; behavioural test covers no-file/empty/truncated/
valid (valid snapshot preserved, empty and truncated quarantined).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thiras
thiras merged commit 7e943a8 into main Jun 7, 2026
7 checks passed
@thiras
thiras deleted the fix/anvil-oom-state-resilience branch June 7, 2026 13:19
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.

2 participants