Version 3 (final). All review feedback resolved.
Smesh web app has runaway memory growth. WASM target uses gc_leaking (bump allocator, never frees). ~15 unbounded maps in web/wasm/app/main.mx, DOM handle leaks in jsruntime/dom.mjs, no eviction anywhere. Every allocation is permanent for the lifetime of the WASM instance.
moxie build -target wasm), NOT moxiejs. Makefile confirms all 4 modules use LLVM path./home/mleku/s/moxie (remote: ssh://git@git.smesh.lol:2222/~/moxie.git)/home/mleku/src/git.smesh.lol/moxie (remote: git@orly:moxie.git) - NOT used for smesh web appsrc/runtime/ is 100% .mx files (78 files, zero .go). Build tags work in .mx.gc_leaking - bump pointer, never frees. runtime.ReadMemStats tracks gcTotalAlloc, gcMallocs, heapptr.wasm-host.mjs runs on main page thread (has localStorage, DOM access). App WASM runs in a dedicated Worker.credentialless COEP and measureUserAgentSpecificMemory() both available.switchPage(name string) at main.mx:799. Called on every view transition.-print-allocs into the compilerFlag already exists (main.go:453, compileopts/options.go:41) but is not connected to the compiler backend.
File: /home/mleku/s/moxie/compiler/compiler.go
At each allocation site, when containing function matches PrintAllocs regex, emit LLVM IR call to runtime.logAlloc(siteID, size) after the runtime.alloc call.
Four sites:
*ssa.Alloc (line 2172) - struct/pointer heap allocation*ssa.MakeSlice (line 2430) - slice allocation*ssa.MakeMap (compiler/map.go) - map allocation*ssa.MakeChan (compiler/channel.go) - channel allocationNew file: /home/mleku/s/moxie/src/runtime/alloc_trace.mx
-print-allocs-max-sites flag (default 8192). If exceeded, compile-time warning + saturating overflow bucket at last index."web/wasm/app" scopes to ~350 sites.var allocCounters [N]uint64 // indexed by site ID, N set by compiler
var allocSizes [N]uint64 // cumulative bytes per site
var allocSiteNames [N]string // populated at init by compiler-generated code
var nextSiteID int32
func logAlloc(siteID int32, size uintptr) {
allocCounters[siteID]++
allocSizes[siteID] += uint64(size)
}
Cost: one integer increment per allocation (~1 instruction). Zero string formatting, zero JS bridge crossing during execution.
__moxie_read_alloc_counters - reads counter arrays from WASM memory, returns JSON__moxie_mem_stats - calls runtime.ReadMemStats, returns JSON {totalAlloc, mallocs, heapInuse, sys}globalThis by wasm-host.mjsGuarded by globalThis.__moxie_instrument = true:
dom.mjs: log _elements.size to globalThis.__moxie_alloc_log at thresholds (100, 500, 1000, 2000, 5000)ws.mjs: log _conns.size{ts, source, metric, value}dom.mjs addition: releaseAll(ids) batch function for bulk handle cleanup# Instrumented app WASM
MOXIEROOT=../moxie GOWORK=off ../moxie/moxie build -target wasm -print-allocs "web/wasm/app" \
-o web/static/app.wasm ./web/wasm/app/
# Instrumented relay
MOXIEROOT=../moxie ../moxie/moxie build -print-allocs ".*" -o /tmp/smesh-instrumented .
Add SMESH_TEST_HEADERS=1 env var to main.mx to emit COOP/COEP headers in test mode only.
test/conftest.pymem_stats(driver) - reads WASM-internal ReadMemStats via driver.execute_script("return window.__moxie_mem_stats()")alloc_counters(driver) - reads per-site counters via driver.execute_script("return window.__moxie_read_alloc_counters()")dom_handle_count(driver) - reads _elements.size from JS sidedriver.get_log('browser') (geckodriver doesn't support it)performance.memory (Chrome-only). Primary signal is ReadMemStats.performance.measureUserAgentSpecificMemory() available with COOP/COEP in test mode.test/test_memory_profile.pytotalAlloc and mallocsthreadEvents cleanup_elements.size < 3000 throughout all scenarios"Idle" = relay-proxy has no active subscriptions + 5s settling period since last EOSE. Test sends explicit CLOSE for all sub IDs ("feed", "ntf", etc.), waits 5s, then measures for 30s. Test relay is controlled (seeded dataset, no push after CLOSE). Assertion: idle_rate < 1024 bytes/sec.
JSON per test run with initial/final heap, delta bytes, mallocs, top allocation sites by count/bytes, DOM handle count.
Uniform pattern, no LRU data structure needed. No reflect, no generics. ~15 lines per cache group.
| Cache group | Maps | Max entries | Rotate together |
|---|---|---|---|
| Event data | eventCache, seenEvents, noteElements, noteReactionMap, noteRepostCounts, noteZapSeen, actionFetchedIDs | 2000 | Yes (event ID keyed) |
| Author metadata | authorNames, authorPics, authorContent, authorTs, authorRelays, fetchedK0, fetchedK10k | 500 | Yes (pubkey keyed) |
| Embeds | embedCallbacks, embedRequested, embedSubIDs | 200 | Yes |
| Reply cache | replyCache, replyAvatarCache, replyLineCache, replyPending, replyNeedName, replyQueue | 500 | Yes |
| Profile wrappers | profileWrappers, profileWrapOrder | 10 | Yes |
| Thread | threadEvents | N/A | Cleared in closeNoteThread() already |
Important: All *Old map vars must be initialized to empty maps at startup (not nil). On first rotation, range over nil map is safe (zero iterations) but silently drops all current handles without releasing them. Initialize alongside their primary counterparts.
func rotateEventCaches() {
// Release DOM handles from entries about to be dropped.
// Iterate noteElementsOld, release handles NOT also in current noteElements.
var ids []int32
for id, el := range noteElementsOld {
if _, ok := noteElements[id]; !ok {
ids = append(ids, el)
}
}
if len(ids) > 0 {
dom.ReleaseAll(ids) // single jsbridge call, bulk
}
// Rotate all event-keyed maps together.
eventCacheOld = eventCache
eventCache = map[string]*nostr.Event{}
noteElementsOld = noteElements
noteElements = map[string]dom.Element{}
seenEventsOld = seenEvents
seenEvents = map[string]bool{}
// ... same for reaction/repost/zap maps
eventCacheCount = 0
}
Insert teardownPage(activePage) in switchPage() before activePage = name (line 816). Each page case sends CLOSE messages to relay-proxy and trims page-specific caches.
Strategy: option (c) - tune threshold high enough that restarts are rare.
wasm-host.mjs polls __moxie_mem_stats() every 60s. If heapInuse > 128MB, save state to localStorage (main thread, directly available), worker.terminate(), startWorker(), send saved state via postMessage.activePage), scroll position, logged-in pubkey. Event caches are lost - feed re-fetches from relay on restart. This is acceptable: a restart every 10+ hours is indistinguishable from opening the tab fresh.delta_bytes < 20MBheapInuse after 5 minutes of use < 64MBtest-memory target in Makefile: builds instrumented WASM, runs memory tests.
1A (wire -print-allocs in compiler/compiler.go)
-> 1B (runtime counter array in alloc_trace.mx)
-> 1C (jsbridge export)
-> 2 (instrumented build)
-> 3 (selenium measurement)
-> 4A-4C (fixes based on data)
-> 4D (proactive restart)
-> 5 (regression gate)
1D (JS-side dom.mjs instrumentation) -- parallel with 1A-1C, independent
| File | Change |
|---|---|
/home/mleku/s/moxie/compiler/compiler.go | Wire PrintAllocs: emit logAlloc call at 4 allocation sites |
/home/mleku/s/moxie/compiler/map.go | Wire PrintAllocs for MakeMap |
/home/mleku/s/moxie/compiler/channel.go | Wire PrintAllocs for MakeChan |
/home/mleku/s/moxie/src/runtime/alloc_trace.mx | New: counter array + GetAllocCounters |
/home/mleku/s/moxie/jsruntime/dom.mjs | Threshold logging on _elements.size + releaseAll() batch function |
/home/mleku/s/smesh/web/wasm/app/main.mx | Rotate-map caches, switchPage teardown, jsbridge for mem_stats |
/home/mleku/s/smesh/web/static/wasm-host.mjs | Expose __moxie_mem_stats, __moxie_read_alloc_counters; proactive restart |
/home/mleku/s/smesh/main.mx | COOP/COEP headers in test mode |
/home/mleku/s/smesh/test/conftest.py | Memory profiling fixtures |
/home/mleku/s/smesh/test/test_memory_profile.py | New: memory measurement tests |
/home/mleku/s/smesh/Makefile | test-memory target |