Skip to content

feat(hugepages): a declared pool ceiling caps the grow-only write - #399

Merged
VijitSingh97 merged 2 commits into
developfrom
fix/398-hugepages-ceiling
Aug 23, 2026
Merged

feat(hugepages): a declared pool ceiling caps the grow-only write#399
VijitSingh97 merged 2 commits into
developfrom
fix/398-hugepages-ceiling

Conversation

@VijitSingh97

@VijitSingh97 VijitSingh97 commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

fix(hugepages): a pool ceiling stops the co-resident write from double-counting

Closes #398

Branch: fix/398-hugepages-ceiling
SHA: b01b187a611346a35243e9c252ca231f81da8c8d

Root cause (re-derived from the code, not copied from #398 forward)

rigforge.sh:1494-1503 _ensure_hugepages, the grow-only runtime write:

if avail < required: sysctl -w vm.nr_hugepages=$((current + required - avail))
  • required (util/proposed-grub.sh:100-101, the fallback branch — the one that actually runs on
    the appliance, since it never gets 1G pages) = 1168*NUMA_NODES + THREADS + 50 + EXTRA_2MB_PAGES, where EXTRA_2MB_PAGES is the declared hugepages_reserve_extra_mb (the
    co-resident stack's headroom) converted to 2MB pages.
  • avail (rigforge.sh:1487-1492) = HugePages_Free + _miner_held_hugepages. Pages a different
    consumer (a co-hosted Pithead stack's p2pool/monerod) already holds are neither free nor
    RigForge-miner-held, so they never appear in avail.

Write both out symbolically (M = miner's own base need, E = declared headroom, S = pages the
stack actually holds, H = pages the RigForge miner holds — 0 on first run — and current = HugePages_Free + S + H, true whenever nothing else touches the pool):

required = M + E
avail    = current - S
new_pool = current + required - avail = M + E + S

E exists to declare "how much to leave for the stack," i.e. E ≈ S. So new_pool ≈ M + 2S: the
stack's reservation is counted once inside required (as E) and a second time via the current − avail cancellation, because avail gives zero credit for pages a different consumer holds.

8 GB worked example (NUMA_NODES=1, THREADS=4, RESERVE_EXTRA_MB=5120, current=2560
pages, stack holding ~2336 → HugePages_Free=224, miner not yet running):

  1. EXTRA_2MB_PAGES = (5120+1)/2 = 2560 (util/proposed-grub.sh:87)
  2. required = 1168 + 4 + 50 + 2560 = 3782 pages (util/proposed-grub.sh:101) — not the 3870
    both Grow-only HugePages write double-counts a co-resident stack's declared reservation #398 and pithead#1103/#1306 quote; that number conflates proposed-grub.sh's two 2MB
    formula branches (128+threads+10 from the 1G-active branch, 1168*numa+...+50 from the
    fallback) into one call that never actually happens. The fallback formula is the one this box
    runs.
  3. avail = 224 + 0 = 224 (rigforge.sh:1487-1492)
  4. new_pool = 2560 + 3782 - 224 = 6118 pages → sudo sysctl -w vm.nr_hugepages=6118
    6118 * 2 MiB ≈ 11.95 GiB requested on a box reporting MemTotal: 8133520 kB (≈7.76 GiB).

The conclusion (the pool blows past the box's RAM) matches #398/pithead#1103/#1306; the
intermediate page count doesn't — 6118, not 6206, because the real fallback formula's terms are
+50 and no separate 128+10 on top of 1168, not both formulas' terms summed. Confirmed by
running the actual code against the fixture (this PR's new tests), not by hand-checking the prior
write-up.

The honest total (the stack's reservation counted once) is M + E = required = 3782 pages
(≈7.39 GiB) — no value of hugepages_reserve_extra_mb gets there, because the double-counted term
is S (the stack's actual held pages), which E cannot cancel no matter what it's set to.

The fix (#398's Option 3, from pithead#1103)

A new config key, hugepages_pool_ceiling_mb (default 0 = no ceiling), caps the write
instead of feeding more headroom into the requirement:

  • rigforge.sh:574-590 (parse_config) — parses and validates the key the same way as
    hugepages_reserve_extra_mb (whole MB, 0-65536).
  • rigforge.sh:686 (_warn_unknown_config_keys's known list) — added, so a typo warns instead of
    silently no-op'ing.
  • rigforge.sh:1509-1535 (_ensure_hugepages) — when HUGEPAGES_POOL_CEILING_MB > 0 and the
    computed target exceeds the ceiling: write the ceiling instead (still grow-only relative to
    current) if the ceiling is above current; otherwise leave the pool untouched and warn (a
    ceiling never shrinks someone else's reservation — it only stops RigForge from growing past
    it). ceiling_pages = HUGEPAGES_POOL_CEILING_MB / 2, floored — a cap must round toward less
    memory, never more (see "Security review round-trip" below; this is the fixed form).
  • config.reference.json — new key + _docs entry.
  • CHANGELOG.md[Unreleased] / Fixed entry.

Conservative activation: with the key absent or 0 (every rig today), _ensure_hugepages's
arithmetic and its sysctl -w call are byte-for-byte what they were before this PR — proved by a
regression test using the same 8 GB fixture as the double-count reproduction, not a synthetic
one.

Tests run (bash tests/run.sh)

New unit coverage (tests/run.sh, both echoed as their own sections):

  • == unit: parse_config hugepages_pool_ceiling_mb (#398) == — parses, defaults to 0, rejects
    negative and oversized values (mirrors the existing hugepages_reserve_extra_mb tests).
  • == unit: hugepages pool ceiling bounds the grow-only write (#398) ==_ensure_hugepages
    exercised directly (not through tune_kernel/proposed-grub.sh, which are already covered
    elsewhere) against the 8 GB fixture:
    • no ceiling declared → unchanged pre-fix arithmetic (vm.nr_hugepages=6118) — the regression pin.
    • ceiling above current but below the uncapped target (6400 MB) → write capped at 3200 pages,
      6118 absent, capping logged.
    • ceiling already met by current (5120 MB, the real reduced-tier number) → no write at all, a
      WARN naming the ceiling.
    • odd declared ceiling (5121 MB) floors to the same 2560 pages as the even 5120 MB case →
      no write, not vm.nr_hugepages=2561 — the fixture that killed the rounding bug caught in
      security review (below).
    • no headroom and no ceiling (the plain tune_kernel's runtime HugePages write shrinks another consumer's pool — reserve grow-only #328 fixture) → unchanged vm.nr_hugepages=200.

Mutation kill, stated explicitly and verified by hand: reverted _ensure_hugepages back to its
pre-fix body (sudo sysctl -w vm.nr_hugepages=$((current + required - avail)), no ceiling clamp)
and re-ran the suite. Exactly the 5 assertions that depend on the ceiling clamp went red (the other
6118-fixture assertion and the no-ceiling regression test stayed green, as expected):

✗ ceiling above current caps the write, not the double count (#398)
    [-w vm.nr_hugepages=6118] missing [vm.nr_hugepages=3200]
✗ capped write is no longer the double-counted 6118 (#398 mutation kill)
    [-w vm.nr_hugepages=6118] unexpectedly contains [6118]
✗ capping is logged with the ceiling reason (#398)
    [] missing [capping the write]
✗ ceiling already met -> no write at all (#398)
    [-w vm.nr_hugepages=6118] unexpectedly contains [vm.nr_hugepages]
✗ ceiling-already-met is a WARN naming the ceiling (#398)
    [] missing [already at its declared ceiling]

Then restored the fix and confirmed the suite is green again.

Security review round-trip

An earlier version of this branch (SHA d45891481fe5ce27e509b6be4ad00797b653e24f) computed
ceiling_pages = (HUGEPAGES_POOL_CEILING_MB + 1) / 2 — the same round-up rule
EXTRA_2MB_PAGES uses elsewhere for headroom being added, which is correct there but wrong for
a cap. Security review reproduced it: an odd declared ceiling (5121 MB) rounded up to 2561 pages
(5122 MB) — one page past the declared ceiling, contradicting "never grown past the ceiling."
All three original fixtures used even MB values, so the suite couldn't see it.

Fixed by flooring instead (ceiling_pages = HUGEPAGES_POOL_CEILING_MB / 2 — bash integer division
already truncates toward zero for positive operands, so dropping the + 1 is the whole fix), plus:

  • A new odd-MB fixture (5121 MB, same 8 GB scenario) asserts the floor: 2560 pages, no write (the
    pool is already at-or-past the floored ceiling). Mutation kill, verified by hand: restoring
    the + 1 flips the code to the CAP branch instead of the already-met branch and writes
    vm.nr_hugepages=2561 (5122 MB) — both new assertions go red.
  • The in-code comments at rigforge.sh:585-587 (parse_config) and rigforge.sh:1525-1528
    (_ensure_hugepages) now state the floor behavior explicitly, and the CHANGELOG entry says "an
    odd declared MB value floors to the 2MB page below rather than rounding up past it."
  • config.reference.json's _docs entry for hugepages_pool_ceiling_mb states the same.

Re-ran the ceiling test blocks and make lint after the floor fix — both clean (below).

Full suite, RUN: bash tests/run.sh1818 passed, 1 failed (2 more passing than the prior
SHA — the new odd-ceiling fixture). The 1 failure is
msr-apply: missing wrmsr warns, never fails the unit (#140) — the known pre-existing host wrmsr
flake, unrelated to this change (present identically before this branch's first commit, on
origin/develop).

make lint, RUN: shellcheck (--severity=warning) + shfmt (-i 4 -d) over the full script and
test set — clean, exit 0.

make lint-md, RUN: markdownlint over all docs incl. CHANGELOG.md — 0 errors.

What was NOT run

  • No live RigForge process against a live co-hosted Pithead stack (no bench access in this task;
    per the operator's hard rules, no bench/miners were touched). The fix is proven at the
    _ensure_hugepages/parse_config unit level, same tier as the #328/#305 tests it extends.
  • No pithead-side change. Pithead's own reduced-tier boot logic does not declare
    hugepages_reserve_extra_mb at all after pithead#1306 shipped, so this fix is inert on the
    appliance until pithead#1103 is revisited (see Sequencing below).

Sequencing (cross-repo)

  1. pithead#1306 (shipped mitigation, unaffected by this PR): refuses to co-locate the built-in
    RigForge miner on the appliance's reduced HugePages tier — the safety net stays in place
    regardless of this fix.
  2. This PR: fixes the RigForge-side contract so that if a co-resident declaration is made
    again on a constrained box, the pool write cannot exceed an explicitly declared ceiling. Ships
    inert (no config sets hugepages_pool_ceiling_mb today).
  3. Follow-up, pithead-side, out of scope here: once a RigForge release carrying this ceiling
    exists, pithead#1103 can be revisited — the reduced tier could declare both
    hugepages_reserve_extra_mb (headroom, as today) and hugepages_pool_ceiling_mb (the tier's own
    honest budget, e.g. 5120 MB) and re-enable co-location instead of pithead#1306's outright
    refusal. That decision and any pithead code change belongs to pithead#1103.

…e-counting

The grow-only runtime write (_ensure_hugepages) computes
current + required - avail, where required already includes any declared
hugepages_reserve_extra_mb (the co-resident stack's headroom) but avail gives
no credit for pages that same stack already holds — only free pages and pages
the RigForge-managed miner itself holds. The stack's reservation therefore
lands in the write twice: once via required's declared headroom, once again
because avail doesn't cancel it back out. Re-derived independently (not from
the write-up that first reported it) against an 8 GiB reduced-tier appliance
box: the write comes out to 6118 pages (~12 GiB) instead of the honest 3782
(rigforge#398, pithead#1103).

hugepages_reserve_extra_mb can't fix this — no declared value cancels a
double count in the arithmetic that combines it. A new config key,
hugepages_pool_ceiling_mb (default 0, inert), caps the WRITE itself instead:
when declared, vm.nr_hugepages is never grown past the ceiling regardless of
what required/avail compute. Absent, the write is byte-for-byte unchanged
(proved by a regression test against the same 8 GB fixture).

Closes #398
Security review on fix/398-hugepages-ceiling found that ceiling_pages was
computed as ceil(HUGEPAGES_POOL_CEILING_MB / 2) — the same rounding rule
EXTRA_2MB_PAGES uses for headroom, which is right for a value being ADDED but
wrong for a cap: an odd declared ceiling (5121 MB) rounded up to 2561 pages
(5122 MB), one page past the declared ceiling, contradicting the "never grown
past the ceiling" contract. All three existing fixtures used even MB values
so the suite couldn't see it.

Floor instead (bash integer division already truncates toward zero for
positive operands, so dropping the "+ 1" is the whole fix). Added an odd-MB
fixture (5121 -> 2560 pages) that kills the ceil mutation: restoring "+ 1"
flips it to write vm.nr_hugepages=2561 and drops the "already at its declared
ceiling" WARN, confirmed by hand before this commit. Updated the two
in-code comments and the CHANGELOG entry to state the floor behavior.
@VijitSingh97
VijitSingh97 merged commit 2c08f59 into develop Aug 23, 2026
9 checks passed
@VijitSingh97
VijitSingh97 deleted the fix/398-hugepages-ceiling branch August 23, 2026 03:20
VijitSingh97 added a commit that referenced this pull request Aug 23, 2026
Restart-free control-apply fast path (#397/#381) and the HugePages pool-ceiling
contract that closes the co-resident double-count (#399/#398, pithead#1103).
Recovers the topology guard, e2e-dashboard leg, and msr-test PATH fix that had
landed on main only (back-merge restoring the main-ancestor invariant).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U189N8GtbdUVtm8UtVNcDw
@VijitSingh97 VijitSingh97 mentioned this pull request Aug 23, 2026
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.

Grow-only HugePages write double-counts a co-resident stack's declared reservation

1 participant