From 92468387587dcc6ab62ce9b9667fb6060f8c576e Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sat, 1 Aug 2026 15:16:55 -0500 Subject: [PATCH] fix(dashboard): count the built-in miner in the mode-aware RAM floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The floor (3 base / +5 monero local / +6 tari local) predates the Both role: with local_miner.enabled the miner holds its own RandomX dataset alongside the stack's — 3 GB outright as 3x1 GB pages, ~2.4 GB plus scratchpads as 2 MB pages (bench-measured via smaps) — so a 16 GB box running both local nodes plus the miner sat under the floor's promise. The floor now adds 3 GB when local_miner.enabled, read live off the masked-config mount the dashboard already holds (no env plumb; a dashboard toggle moves the floor without a restart). Deliberately NOT the 6 GB headroom the miner's rendered config declares — that is the stack's own hugepage budget, which the container floors already count; adding it would count p2pool's dataset twice. Both floor consumers (the top-bar badge and the low_ram alert edge) route through the one shared function, and the LOW_RAM_GB flat pin still wins. Closes #820 Co-Authored-By: Claude Fable 5 --- .../mining_dashboard/config/config.py | 21 +++++++ build/dashboard/tests/config/test_config.py | 58 +++++++++++++++++++ build/dashboard/tests/web/test_views.py | 15 +++++ docs/dashboard.md | 2 +- 4 files changed, 95 insertions(+), 1 deletion(-) diff --git a/build/dashboard/mining_dashboard/config/config.py b/build/dashboard/mining_dashboard/config/config.py index eef27ce2..882883e3 100644 --- a/build/dashboard/mining_dashboard/config/config.py +++ b/build/dashboard/mining_dashboard/config/config.py @@ -32,6 +32,26 @@ LOW_RAM_BASE_GB = 3 # p2pool + proxy + tor + caddy + dashboard LOW_RAM_LOCAL_MONERO_GB = 5 LOW_RAM_LOCAL_TARI_GB = 6 +# The built-in miner's own footprint, counted only when local_miner.enabled: its RandomX +# dataset is 3 GB outright as 3×1 GB pages (the unfragmented-pool case), or ~2.4 GB as 2 MB +# pages plus per-thread scratchpads and process overhead — bench-measured via smaps while both +# sides mined. 3 GB covers the reservation either way. Deliberately NOT the 6 GB headroom the +# miner's rendered config declares (hugepages_reserve_extra_mb): that is the stack's OWN +# hugepage budget, which the container floors above already count — adding it here would count +# p2pool's dataset twice. +LOW_RAM_LOCAL_MINER_GB = 3 + + +def local_miner_enabled(path=None): + """Whether this box runs the built-in RigForge miner (config.json: local_miner.enabled), + read live off the same read-only masked-config mount as the worker descriptors below — no + env plumb, and a dashboard toggle moves the floor without a container restart. A missing or + unreadable file reads False (DIY stacks without the mount never run the built-in miner).""" + try: + with open(path or HOST_CONFIG_PATH) as f: + return json.load(f).get("local_miner", {}).get("enabled") is True + except (OSError, ValueError, AttributeError): + return False def low_ram_floor_gb(monero_local: bool, tari_local: bool) -> float: @@ -42,6 +62,7 @@ def low_ram_floor_gb(monero_local: bool, tari_local: bool) -> float: LOW_RAM_BASE_GB + (LOW_RAM_LOCAL_MONERO_GB if monero_local else 0) + (LOW_RAM_LOCAL_TARI_GB if tari_local else 0) + + (LOW_RAM_LOCAL_MINER_GB if local_miner_enabled() else 0) ) diff --git a/build/dashboard/tests/config/test_config.py b/build/dashboard/tests/config/test_config.py index 75aebdc0..6c33192a 100644 --- a/build/dashboard/tests/config/test_config.py +++ b/build/dashboard/tests/config/test_config.py @@ -398,3 +398,61 @@ def test_missing_file_reads_defaults(self, tmp_path): from mining_dashboard.config.config import load_energy_config assert load_energy_config(str(tmp_path / "absent.json")) == self.DEFAULTS + + +class TestLowRamFloorLocalMiner: + """The mode-aware floor counts the built-in miner's share when local_miner.enabled — read + live off the same read-only masked-config mount as the worker descriptors, so a dashboard + toggle moves the floor without a restart. The increment is the miner's OWN ~3 GB dataset + reservation, never the 6 GB headroom its rendered config declares (that is the stack's own + hugepage budget, which the per-container floors already count).""" + + def _write(self, tmp_path, payload): + p = tmp_path / "config.json" + p.write_text(payload if isinstance(payload, str) else json.dumps(payload)) + return str(p) + + def test_enabled_reads_true(self, tmp_path): + from mining_dashboard.config.config import local_miner_enabled + + assert local_miner_enabled(self._write(tmp_path, {"local_miner": {"enabled": True}})) + + def test_anything_else_reads_false(self, tmp_path): + # Only the literal boolean true counts; every degraded shape (off, absent, hand-edited + # junk, a non-object document) reads False — the floor must never inflate on a typo. + from mining_dashboard.config.config import local_miner_enabled + + for payload in ( + {"local_miner": {"enabled": False}}, + {"local_miner": {}}, + {}, + {"local_miner": "yes"}, + {"local_miner": {"enabled": "true"}}, + [], + "{not json", + ): + assert local_miner_enabled(self._write(tmp_path, payload)) is False, payload + assert local_miner_enabled(str(tmp_path / "absent.json")) is False + + def test_floor_adds_miner_share_only_when_enabled(self, tmp_path, monkeypatch): + import mining_dashboard.config.config as cfg + + monkeypatch.setattr( + cfg, "HOST_CONFIG_PATH", self._write(tmp_path, {"local_miner": {"enabled": True}}) + ) + # The Both role with both nodes local: 3 + 5 + 6 + 3 — a 16 GB box now warns, which is + # exactly the OOM edge the adversarial pass flagged. + assert cfg.low_ram_floor_gb(True, True) == 17 + assert cfg.low_ram_floor_gb(False, False) == 6 + # No mount (a DIY stack) or the miner off: the floors are what they always were. + monkeypatch.setattr(cfg, "HOST_CONFIG_PATH", str(tmp_path / "absent.json")) + assert cfg.low_ram_floor_gb(True, True) == 14 + + def test_low_ram_gb_env_pin_still_wins(self, tmp_path, monkeypatch): + import mining_dashboard.config.config as cfg + + monkeypatch.setattr( + cfg, "HOST_CONFIG_PATH", self._write(tmp_path, {"local_miner": {"enabled": True}}) + ) + monkeypatch.setattr(cfg, "_LOW_RAM_ENV", "9") + assert cfg.low_ram_floor_gb(True, True) == 9.0 diff --git a/build/dashboard/tests/web/test_views.py b/build/dashboard/tests/web/test_views.py index 8115c9af..1d228b16 100644 --- a/build/dashboard/tests/web/test_views.py +++ b/build/dashboard/tests/web/test_views.py @@ -698,6 +698,21 @@ def test_low_ram_badge_tracks_what_runs_locally(self, monkeypatch): out = build_badges({"system": {"memory": {"total_gb": 8}}}, _metrics(), "ok") assert not any("Low RAM" in b["text"] for b in out) + def test_low_ram_badge_counts_the_built_in_miner(self, monkeypatch): + # The Both role's risk case: both nodes local fits a 16 GB box (floor 14) — until the + # built-in miner's own dataset joins them, when the same box honestly warns (floor 17). + import mining_dashboard.config.config as cfg_mod + import mining_dashboard.web.views as views_mod + + monkeypatch.setattr(views_mod, "monero_is_local", lambda: True) + monkeypatch.setattr(views_mod, "tari_is_local", lambda: True) + state = {"system": {"memory": {"total_gb": 15.6}}} + assert not any("Low RAM" in b["text"] for b in build_badges(state, _metrics(), "ok")) + + monkeypatch.setattr(cfg_mod, "local_miner_enabled", lambda path=None: True) + out = build_badges(state, _metrics(), "ok") + assert any(b["variant"] == "warn" and "Low RAM (16 GB)" in b["text"] for b in out) + def test_no_low_ram_badge_at_or_above_threshold_or_unknown(self): # 15.6 is what a NOMINAL 16 GB machine actually reports (reserved memory, GiB-vs-GB) — # the documented minimum spec must never wear a permanent warning. Bench-reported. diff --git a/docs/dashboard.md b/docs/dashboard.md index 08d5ff9d..0a6179ad 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -114,7 +114,7 @@ The top bar also surfaces the persistent host conditions that `setup` warns abou | Badge | Means | Fix | |---|---|---| | `⚠ HugePages off` | HugePages aren't reserved — RandomX hashrate is capped. | Run setup's tuning (or edit GRUB) and reboot; the badge clears once they're reserved. | -| `⚠ Low RAM (N GB)` | Under what this machine's workload wants: ~14 GB running both nodes locally, ~8 GB with one remote, ~3 GB with both remote. Remote nodes take their memory appetite with them; a nominal 16 GB machine reports ~15 and is fine for the full stack. | Add RAM, or point a node at a machine that has it. | +| `⚠ Low RAM (N GB)` | Under what this machine's workload wants: ~14 GB running both nodes locally, ~8 GB with one remote, ~3 GB with both remote, plus ~3 GB more when the appliance's built-in miner is on (its RandomX dataset holds that much alongside the stack's own). Remote nodes take their memory appetite with them; a nominal 16 GB machine reports ~15 and is fine for the full stack — but mining on the same box as both local nodes wants more. | Add RAM, point a node at a machine that has it, or mine from a separate rig. | | `⚠ Memory pressure (N GB free)` | Live signal: under 1.5 GB actually available right now, whatever the machine's size — the next spike can OOM a container. | Check which service is growing on the System panel. | | `⚠ No AVX2` | The CPU lacks AVX2, so RandomX mining is much slower. | A hardware limit; nothing to change at runtime. | | `⚠ Payout wallet changed` | The wallet p2pool mines to changed within the last 72 hours (old → new, truncated). A confirmation if you changed it; an alarm if you didn't. | Verify `monero.wallet_address` in `config.json`; see [Operations › wallet changes](operations.md). The badge expires on its own after 72 h. |