test_memory_profile.py raw

   1  """Memory profiling tests for the smesh WASM web app.
   2  
   3  Requires an instrumented build:
   4    MOXIEROOT=../moxie moxie build -target wasm -print-allocs "web/wasm/app" \
   5      -o web/static/app.wasm ./web/wasm/app/
   6  
   7  Run:
   8    pytest test/test_memory_profile.py --headed
   9  """
  10  
  11  import time
  12  import json
  13  import pytest
  14  
  15  
  16  def _trigger_stats(h):
  17      h.js("window.__moxie_trigger_mem_stats && window.__moxie_trigger_mem_stats()")
  18      time.sleep(0.3)
  19  
  20  
  21  def _mem(h):
  22      _trigger_stats(h)
  23      return h.js("return window.__moxie_mem_stats ? window.__moxie_mem_stats() : null") or {}
  24  
  25  
  26  def _counters(h):
  27      _trigger_stats(h)
  28      return h.js("return window.__moxie_read_alloc_counters ? window.__moxie_read_alloc_counters() : null") or {}
  29  
  30  
  31  @pytest.fixture(scope="module")
  32  def app(browser, relay, h):
  33      """Navigate to the app and wait until WASM is ready."""
  34      browser.get(relay["url"])
  35      h.wait_wasm_ready(timeout=20)
  36      # Allow initial render to settle.
  37      time.sleep(2)
  38      return browser
  39  
  40  
  41  @pytest.mark.memory
  42  class TestMemoryProfile:
  43  
  44      def test_mem_stats_available(self, app, h):
  45          """Verify instrumented WASM exports are accessible."""
  46          stats = _mem(h)
  47          assert stats is not None, "mem_stats returned None — is this an instrumented build?"
  48          assert "totalAlloc" in stats or "heapPtr" in stats, f"unexpected stats shape: {stats}"
  49  
  50      def test_baseline(self, app, h):
  51          """Record initial heap state."""
  52          stats = _mem(h)
  53          heap_mb = stats.get("heapPtr", 0) / (1024 * 1024)
  54          total_mb = stats.get("totalAlloc", 0) / (1024 * 1024)
  55          print(f"\n[baseline] heapPtr={heap_mb:.1f}MB totalAlloc={total_mb:.1f}MB mallocs={stats.get('mallocs', 0)}")
  56          # Baseline should be well under 64MB.
  57          assert heap_mb < 64, f"baseline heap {heap_mb:.1f}MB exceeds 64MB"
  58  
  59      def test_dom_handles_bounded(self, app, h):
  60          """DOM handle count must stay under 3000."""
  61          count = h.js("return window.__moxie_alloc_log ? 'logging' : 'no_log'")
  62          # If no instrumentation log, skip quantitative check.
  63          if count == "no_log":
  64              pytest.skip("__moxie_alloc_log not initialised — set globalThis.__moxie_instrument=true")
  65  
  66          # Enable instrumentation and wait for threshold events.
  67          h.js("globalThis.__moxie_instrument = true;")
  68          time.sleep(1)
  69          log = h.js("return (globalThis.__moxie_alloc_log || []).slice(-10)")
  70          if log:
  71              max_dom = max((e.get("value", 0) for e in log if e.get("source") == "dom"), default=0)
  72              assert max_dom < 3000, f"DOM handle count reached {max_dom} (limit 3000)"
  73  
  74      def test_idle_alloc_rate(self, app, h):
  75          """After subscriptions are closed, allocation rate must be < 1KB/s.
  76  
  77          This tests that the rotate-map eviction is working: a live session
  78          with no incoming events should allocate nearly nothing.
  79          """
  80          before = _mem(h)
  81          if not before.get("totalAlloc"):
  82              pytest.skip("totalAlloc not available in this build")
  83  
  84          # Close the feed subscription to achieve idle state.
  85          h.js("window.__moxie_trigger_mem_stats && window.__moxie_trigger_mem_stats()")
  86          time.sleep(30)
  87  
  88          after = _mem(h)
  89          delta_bytes = after.get("totalAlloc", 0) - before.get("totalAlloc", 0)
  90          rate_bytes_per_sec = delta_bytes / 30
  91  
  92          print(f"\n[idle_rate] delta={delta_bytes}B over 30s = {rate_bytes_per_sec:.0f}B/s")
  93          assert rate_bytes_per_sec < 1024, (
  94              f"idle alloc rate {rate_bytes_per_sec:.0f}B/s exceeds 1KB/s — "
  95              f"rotate-map may not be active or relay not quiesced"
  96          )
  97  
  98      def test_alloc_counters_present(self, app, h):
  99          """Verify per-site counters are populated when -print-allocs was used."""
 100          counters = _counters(h)
 101          if not counters or counters.get("n", 0) == 0:
 102              pytest.skip("Alloc counters not available — build without -print-allocs?")
 103  
 104          n = counters["n"]
 105          counts = counters.get("counts", [])
 106          sizes = counters.get("sizes", [])
 107  
 108          assert n > 0, "n should be positive"
 109          assert len(counts) >= n
 110          assert len(sizes) >= n
 111  
 112          total_allocs = sum(counts[:n])
 113          total_bytes = sum(sizes[:n])
 114          print(f"\n[counters] {n} sites, {total_allocs} allocs, {total_bytes}B total")
 115          # At least some allocs should have happened.
 116          assert total_allocs > 0, "no allocations recorded"
 117  
 118      def test_heap_under_restart_threshold(self, app, h):
 119          """After normal use heapPtr should be well under the 128MB restart threshold."""
 120          stats = _mem(h)
 121          heap_bytes = stats.get("heapPtr", 0)
 122          heap_mb = heap_bytes / (1024 * 1024)
 123          print(f"\n[heap_check] heapPtr={heap_mb:.1f}MB")
 124          assert heap_mb < 64, (
 125              f"heap {heap_mb:.1f}MB exceeds 64MB after normal use — "
 126              f"rotate-map may not be reducing growth"
 127          )
 128