Skip to content
This repository was archived by the owner on Aug 25, 2026. It is now read-only.

Use a lot less memory, give it back under pressure, and make voice work on 32-bit phones - #86

Merged
alltechdev merged 22 commits into
mainfrom
perf/webview-and-native-purge
Jul 24, 2026
Merged

alltechdev merged 22 commits into
mainfrom
perf/webview-and-native-purge

Conversation

@alltechdev

@alltechdev alltechdev commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Fixes #83. Fixes #95. Combines and supersedes #85.

One PR, three layers of work, all device-verified on the M5 (2.9 GB, Android 13) in both 64-bit and 32-bit processes:

  1. Use a lot less memory, and give it back when the system asks — the original Use a lot less memory, and give it back when the system asks #85/Use a lot less memory, give it back under pressure, and make voice work on 32-bit phones #86 work: memory-pressure plumbing, idle reapers for the speech model and all five hidden WebViews, a native mallopt purge, deferred WebView layout, and low-RAM adaptations. Heavy states drop by half or more (measurements in the appendices below).
  2. Voice now works on 32-bit phones — the issue Crash on launch after downloading voice model on 32-bit ARM device #95 crash (SIGBUS in libonnxruntime.so on the TCL Flip 2) turned out to be a runtime bug that killed every model load on armv7, TTS and ASR alike. Fixed at the runtime, plus crash-loop insurance, plus a new low-memory ASR engine for exactly these phones.
  3. A 12-finding adversarial review of this PR, all findings fixed — including two real crashers in the new reclamation machinery itself.

Voice on 32-bit ARM (issue #95)

The #95 tombstone (BUS_ADRALN, piper-tts thread) reproduced on the M5 forced into a 32-bit process (adb install --abi armeabi-v7a) — and reproduced identically for the ASR load. The vendored sherpa-onnx 1.13.3 AAR bundles onnxruntime 1.24.3, whose v7a build does unaligned reads that 32-bit ARM turns into an uncatchable native abort. These phones have no system speech engine — the downloaded models are their only voice — so gating was not an option.

Fix: sherpa-onnx-1.13.4.aar (onnxruntime 1.27.0). Hosted on the tts-runtime release, CI fetch + v7a-presence check updated, do-not-downgrade notes in the build file and AGENTS.md. Device-verified both directions, final build, no debug overrides, 32-bit process:

  • 1.13.3: SIGBUS on every load — and the two-strike quarantine self-healed the app after two crashed launches, validating the sentinel against a real native crash.
  • 1.13.4: PiperSynth: loaded en_US-hfc_female-medium (the exact Crash on launch after downloading voice model on 32-bit ARM device #95 voice, load + warm synthesis), Whisper loads, critical trim → recognizer released + native purge, PSS 641→271 MB, process alive throughout. arm64 regression-checked after the bump.

Crash-loop insurance: the ASR loads already had a two-strike per-engine quarantine; the Piper TTS load now has the same (piper_load_strikes_<voiceId>/piper_model_bad_<voiceId>, cleared by a fresh download). The #95 failure class — a natively-faulting model at startup — can never brick the app again on any chipset: worst case two crashes, then system TTS and a re-download.

The low-memory ASR engine hunt: measured, then deliberately dropped

"Small download" ≠ "small resident" (32-bit M5, same-protocol launch deltas):

engine download resident
Whisper tiny int8 58 MB ~214 MB
Moonshine tiny int8 101 MB ~212 MB — four ORT sessions eat the small weights
NeMo Conformer CTC small int8 45 MB 760 MB–1.2 GB — rejected
Zipformer small en int8 (new) 28 MB isolated reap-delta measurement in flight — lands as a comment on this PR

Zipformer small was built, wired, measured — and then removed before merge. Resident size is not the only bar: it is a 2023 librispeech (audiobook-domain) model, and a maps app lives on exactly the proper nouns that domain mishears; no post-processing fixes that. The decisive change is elsewhere in this PR: the idle reaper makes Whisper's ~214 MB hold transient (load at the mic tap, reaped 120 s later on low-RAM devices), which is what actually makes the multilingual default viable on small phones. The measurement lessons are recorded in AGENTS.md so the ground isn't re-trodden.

Kept from that work: SpeechText.cleanSearchTranscript now lowercases ALL-CAPS transcripts and runs spokenNumbersToDigits — unit-tested inverse text normalization ("ONE TWENTY THREE MAIN STREET" → "123 main street", "forty second street" → "42nd street", juxtaposition + place-value + ordinals, lone "first"–"ninth" deliberately left as words, non-English untouched). A no-op on the three digit-writing engines, insurance for any future word-form one. 8 new tests.

The 12 review findings, all fixed

  1. Engine-switch use-after-free: ensureRecognizerLocked freed the superseded recognizer while a listen could still be decoding inside it → superseded models park in a retired list, drained under loadLock when the lease count hits zero.
  2. Lease leak on cancellation: withContext { acquireRecognizer() } could take the lease then throw CancellationException instead of returning it, permanently disabling every release path → a leased flag set inside the block keys the finally.
  3. Piper crash-loop (Crash on launch after downloading voice model on 32-bit ARM device #95): sentinel + runtime fix above.
  4. v7a SIGBUS: runtime fix above.
  5. Trim reaps stranded fetches (reviews/directions/stops/populartimes): reapNow(force) drains pending whenever it destroys — no 20–45 s mutex-holding hangs.
  6. deleteAsrEngine main-thread block: lock wait + native free + up-to-154 MB delete moved to Dispatchers.IO, optimistic row hide.
  7. FlockCameras torn index publish: the CSR arrays now form one immutable generation behind a single @Volatile reference, snapshotted once per query — the weekly-refresh hot-swap can no longer throw AIOOBE under a viewport scan. 6 new unit tests.
  8. Trim silenced a speaking nav prompt: the memory-pressure path calls release(interrupt = false) — the serial worker frees the model after the current utterance instead of cutting it off mid-word.
  9. Low-RAM POI pool halving: reverted to !7i60 everywhere — ranks 31–60 were vanishing with no basemap fallback; peak memory is governed by the Semaphore(4) fan-out bound, not the per-term pool.
  10. Mid-scrape teardown: all five fetchers decline a merely-severe trim reap while a fetch is in flight (the fetch's finally re-arms it moments later); only CRITICAL forces teardown + drain.
  11. Mic latency regression on roomy phones: REAP_IDLE_MS is RAM-scaled — 120 s on low-RAM, 600 s on roomy devices.
  12. Reap-field race in three fetchers: reviews/directions/stops gained WebPhotoFetcher's onMain confinement; reap bookkeeping is main-thread-only by construction.

Verification

detekt 0 findings (:app + :core); 49 unit tests green (14 new: FlockCameras index + ITN); standardDebug built, installed, and exercised on the M5 in both 32-bit and 64-bit processes — launches, TTS + ASR model loads, critical-trim release + native purge, idle reap, UI verified intact by screenshot (full POI categories back, including the park/school pins). Stated plainly rather than implied: the mid-scrape trim-decline path is verified by tests + construction, not against a live scrape; no real-microphone decode ran on v7a (the identical model + runtime pair decodes correctly on the host); hotword biasing is a follow-up.

AGENTS.md gains the rules this work produced: the lease covers every free path including engine switches; lease acquisition must be cancellation-safe; trim reaps decline mid-fetch below CRITICAL; the AAR is pinned ≥ 1.13.4 for armv7 (test any bump on a 32-bit install); a model's file size says nothing about its resident cost — isolate it via the idle-reap delta before adopting.


Appendix: original #86 description (WebView/native purge round, with its measurements)

Stacks on #85. Seven changes, all device-measured on the M5 (2.9 GB, Android 13, standardDebug).

1. The ASR reaper could free the speech model under a running decode

This is the one to review first. The inFlight guard added with the idle reaper in #85 does not close the window its own KDoc describes. release() reads the counter before taking loadLock, and ensureRecognizer() hands the native pointer out on a lock-free fast path before taking the lock at all, so the two never order against each other:

reaper   release(): inFlight.get() -> 0, guard passes, lock not yet held
user     listen():  inFlight.incrementAndGet() -> 1
user     ensureRecognizer() fast path returns the live pointer
user     decode starts on it
reaper   synchronized(loadLock) { recognizer = null; r.release() }
user     rec.decode(stream) on freed C++ memory

That is a SIGSEGV inside libsherpa-onnx-jni, not an exception - the runCatching around the decode cannot catch a native abort, so the process dies. An AtomicInteger does not make a check-then-act atomic.

The window is not narrow: any thread holding loadLock for the ~1 s native load parks release() between its check and its free for that whole time, and warmUp() takes that lock at startup on every non-low-RAM device.

Fixed with a lease mutated only under loadLock: acquireRecognizer() loads and leases under one lock, release() checks and frees under that same lock. The lock-free fast path is gone. release() also becomes non-blocking (tryLock on a ReentrantLock), because it runs on the main thread from onTrimMemory and blocking the UI thread for a full model load to reclaim memory is a bad trade; Remove model passes wait = true.

The race is real, not theoretical. Hammering am send-trim-memory <pid> RUNNING_CRITICAL across startup logged release skipped, model load in progress 5 times in one run - 5 trims landing while the load held the lock, each of which the old code would have used to block the main thread with the check-then-act live. Across that plus a 6-round force-stop/trim-storm: 0 crashes, 0 SIGSEGV, process alive every round. Release still works (scudo:secondary 110,564 -> 8,031 KB, recognizer released logged), the model reloads cleanly afterwards (108,381 KB) and is not quarantined.

PiperSynth already gets this right by serializing through its single worker thread; only the ASR path was exposed.

2. One search pinned a Chromium renderer for the whole session

Android runs the WebView renderer out of process, so #85 could not see it. After one search: app 386 MB, sandboxed_process0 327 MB, webview service/apk 58 MB. The app-side half is GL mtrack from the offscreen layouts: 26 MB with no WebView alive, 437 MB with the two scraper views up.

Neither speculative warm was bounded. WebPhotoFetcher had no idle reaper at all; WebPopularTimesFetcher.prewarm() created a view and never called scheduleReap() (only fetch() did). MapViewModel warms both on every search.

Both had to be fixed - reaping only the photo fetcher left the renderer alive at ~200 MB because the popular-times view still held it. The renderer is shared by every WebView in the process.

Measured, no trim anywhere in the run:

t+45s    GL mtrack 437 MB   app PSS 725 MB   renderer alive
t+90s    GL mtrack  65 MB   app PSS 337 MB   reap logged
t+135s   GL mtrack  64 MB   app PSS 335 MB   renderer process gone

~390 MB back to the app plus a ~305 MB process shut down. Rebuild verified: a later search respawns it, GL mtrack returns to 431 MB, no crash.

A speculative warm gets a longer window than a real fetch (WARM_REAP_IDLE_MS 300 s vs REAP_IDLE_MS 120 s) - the warm exists so the first place tap skips the cold start, and 120 s would expire during an ordinary browse and waste it. Bounded, not short, is the point.

Also fixes two things reachable from #85's own trim hook: reapNow() now drains pending like rendererGone() already did (a reap mid-fetch parked the fetch in deferred.await() for the full 40 s timeout while holding the fetcher's Mutex), and reap bookkeeping moved onto the main thread, since reap was written from two threads and scheduling is a read-modify-write.

3. The photo scraper laid out its WebView during a speculative warm, at 390 MB

WebPhotoFetcher sized its view inside ensureWebView() - i.e. at construction - and warm() goes through ensureWebView(). So a search built a full 1200x3200 composited surface over maps?hl=en, a page with nothing scrapeable on it, and held it for the whole 300 s warm window, on a phone whose screen is 480x640.

Sizing moved into sizeForScrape(wv), called immediately before loadUrl in fetch(), so the ?cid= page's first layout is already at scrape geometry. The size is unchanged; only when it is applied changes.

Matched A/B, same harness, 3 runs per arm, search then browse with no place opened:

arm GL mtrack TOTAL PSS
eager (as before) 448 / 427 / 441 MB 866 / 854 / 852 MB
deferred 77 / 71 / 72 MB 485 / 460 / 459 MB

-365 MB of GPU memory and -390 MB of PSS, no overlap between arms, and the scrape is unaffected: 28/28/28 photos on the same place in both arms.

This fetcher is the only one that lays out during a warm - WebPopularTimesFetcher.prewarm, WebDirectionsFetcher and WebStopDeparturesFetcher never call measure/layout at all, and WebReviewsFetcher has no warm. That is why every GL number in this app tracks this one view.

Shrinking the viewport was tried first and is deliberately NOT included. On the photo side 720 px held the count (28 -> 28) on the one place it was A/B'd, but the reviews side returns 0 reviews for every place tried, at 1200 and at 720 - a pre-existing failure unrelated to width - so that arm's quality metric was pinned at zero and could not fail. An unfalsifiable check is not evidence, and scrape geometry governs how much of a virtualized grid materializes, so both widths stay stock. Deferring the layout wins the same memory back without changing anything the scraper sees: safe by construction rather than by sampling.

Both fetchers now log scraped N photos/reviews for <featureId>, which is what makes any future viewport change checkable against scrape quality rather than only memory.

4. The scraped page was held for two minutes after the scrape ended

After a photo scrape the hidden WebView kept a fully rasterized Google Maps document until the 120 s reap - the whole time the user sits on the place sheet looking at the photos. fetch()'s finally now navigates it to about:blank.

Measured at place-open + 75 s, 3 runs:

GL mtrack TOTAL PSS
before ~497 MB ~950 MB
after 64-70 MB 410-508 MB

Photo counts unchanged. The WebView, renderer, sockets and cookies stay alive, so the next place is no colder; only the document goes.

Resizing is not the lever, and that was measured rather than assumed. Shrinking the view to 0x0 after the scrape was tried first and reclaimed nothing: 494/496/497 MB against a 497/498 MB control. Chromium keeps tiles it has rasterized for a live document however small the view gets. The 1200x3200 viewport is 3.84 Mpx = 15 MB of pixels against a measured ~490 MB, so that figure was never a viewport buffer - it is a whole composited layer tree against a tile budget. Document lifetime is what moves it.

The bug this introduced, and why it nearly shipped

The about:blank navigation broke the next scrape. Its onPageFinished fires on the webViewClient the next fetch has just installed, opening that fetch's load gate before the real page commits, so the scraper injects into an empty document and returns nothing. onPageFinished now ignores about: URLs; the MAX_LOAD_MS fallback still covers a genuinely stuck load.

It was only visible when opening a second place: the same place scraped 33 photos as the first place opened and 0 as the second. A one-place test cannot see a WebView reuse bug, and re-tapping the same place is served from the LRU cache without scraping, so it cannot see one either. Verified after the fix by opening two different places in one session, twice: 28 then 52, and 28 then 33 against that place's 33 fresh-open baseline.

5. A release only returns memory to scudo, not to the kernel (worth ~10 MB)

#85 gave every holder a release(), but those pages stay on the allocator's free lists where PSS still counts them. mallopt() is reachable only from C, so this adds the app's first native module: app/src/main/cpp/velamem.cpp, 4 KB (arm64) / 2.7 KB (armeabi-v7a), built for those two ABIs only.

A/B'd on one binary, both arms confirmed in logcat to behave differently before trusting either. On the production (staging) build, 3 runs per arm, comparing where scudo:primary SETTLES after a severe trim (the pre-trim value swings 147-382 MB run to run and is useless as a baseline):

arm scudo:primary after trim
purge ON 52.4 / 53.1 / 54.1 MB
purge OFF 54.4 / 58.6 / 76.7 MB

~10 MB on production (a debug A/B had shown ~3 MB). M_PURGE_ALL is API 34+, so on Android 13 it returns 0 and falls back to M_PURGE; the logged mode= says which took.

Two corrections to earlier claims in this branch, both since fixed in AGENTS.md:

  • A single trim on production reclaims 133-328 MB, and the first reading looked like a 122 MB purge win. It is not: nearly all of that is the registered listeners releasing plus what the platform already does on trim. Only the control separated them.
  • "A 442 MB arena holding just 46 MB live" was presented as if the gap were reclaimable. It is not - mallinfo's free figure is address space scudo has already madvised away, and scudo:primary PSS at that moment was 67 MB. Purging during active use with no listener release moved it 67.1 -> 64.5 MB, i.e. 2.6 MB. A periodic idle purge would be worthless; that is now written down so nobody builds it.

6. Low-RAM phones were losing whole POI categories, for no memory saving

#85's low-RAM path fetched 8 of the 15 ambient category terms. Both halves of that were wrong.

It saved no peak memory. Peak is set by ambientFanout, a Semaphore(4), and every buffer (response String, stripped copy, JsonElement DOM) is allocated inside withPermit. At most 4 exist at once however many terms queue behind them, so 15 -> 8 changes the number of waves, not what is resident at the peak. The semaphore's own KDoc already said it: "Bounding to 4 caps the peak transient heap with the same final pool." The levers that do move the peak are the permit count and the response size; the !7i pool halving is the one in use and is untouched here.

Its justification was false. It kept school and park on the grounds that only those lack a second source while the ambient layer is up. Nothing has one then - VelaMapView sets poi_r1/poi_r7/poi_r20 to NONE wholesale on if (navMode || ambientPois.isNotEmpty()), not per category. So shopping, services, beauty salon, fast food, gym, bar and pharmacy lost their basemap fallback exactly as school and park would have. Parks at least keep a landuse polygon so the green area survives without the pin; a gym, a bar or a pharmacy exists only as a pin, making those the worse things to drop, not the safer ones. A constrained phone was quietly showing a poorer map.

The observation behind the subset was real - a first 6-term attempt did lose every park and school pin, caught by an A/B screenshot. The generalisation drawn from it was not: that screenshot was evidence about the fan-out, not about school and park being special.

Low-RAM devices now fetch the same terms as everyone else, so the category set is identical by construction rather than by sampling. The only remaining low-RAM difference in this path is the smaller !7i result pool, which trims deep-rank results per term without removing any category.

7. The low-RAM check could miss the phone it was written for

heapClassMb in 1..127 had two defects, both invisible on any device the dev side owns.

It excluded 128 - the heap class OEMs hand out across 1 GB phones and the low end of 2 GB ones, exactly the class of device #83 was filed from. The phone this work targets could plausibly have matched none of the predicate and received none of the work.

And an unreadable probe returns 0, which also falls outside 1..127, so an unknown device was silently routed down the memory-hungry path. That is the wrong failure direction: the low-RAM path costs a roomy phone about a second on its first mic tap and first place open; the normal path can OOM a phone with no headroom.

The predicate now takes three signals, any one sufficient: isLowRamDevice, total RAM <= 2048 MB, heap class <= 128 MB. Total RAM is new and is the signal that actually describes the device - heap class is a Dalvik knob an OEM can set to anything. An unreadable probe never counts as evidence of roominess.

MemoryInfo.totalMem reports what the OS can hand out rather than the marketing figure, so a nominal 2 GB phone reads ~1900 MB and lands inside the ceiling while a 3 GB phone reads ~2800 MB and does not. Device-confirmed: the M5 reads heapClassMb=256 totalRamMb=2878 and stays lowRam=false, so every measurement in this PR was taken on the path that still ships to a roomy phone.

It now has tests, and they were proven to fail on the bug

The decision moved to LowRamMode.classify in :core for one reason: so it can be tested. A predicate gating every memory adaptation in the app had no test, and both its bugs were the kind only a device nobody owns would expose.

Negative control, as AGENTS.md requires: restoring the original isLowRamDevice || heapClassMb in 1..127 makes 4 of the 9 tests fail, including "heap class 128 is low-RAM, the boundary the first version excluded" and "both probes unreadable is treated as constrained, not roomy". The tests fail on the bug they were written for.

Production numbers, and a warning about the debug ones

Every number in #85 and in the sections above was standardDebug. The staging variant (initWith(release), R8-minified, non-debuggable, installs side by side as app.vela.staging) is the production profile, and nobody had used it. Production is substantially leaner:

state standardDebug standardStaging
after a search (warm) ~460 MB ~279 MB
place open 410-508 MB 335-392 MB
after a severe trim - 140-146 MB
Code bucket 101 MB 30-45 MB

The Code gap is extracted dex and JIT profiles that do not exist in a release build, so ~55-70 MB of any debug reading is an artifact. Scrape verified unaffected by R8 (28 photos on staging, same as debug; the @JavascriptInterface bridge survives minification).

Verified on a production build, and stressed under real pressure

Two gaps that existed before this PR, both now closed with numbers.

The low-RAM path had never run on a minified build

debug.vela.lowram is BuildConfig.DEBUG-gated, so it is inert on staging/release - every low-RAM measurement in #85 and above came from a debug build with the flag forced. Magisk's resetprop closes that, because ActivityManager.staticGetMemoryClass() reads the property per call, so LowRamMode.classify runs for real (forced=no):

adb shell su -c "resetprop dalvik.vm.heapgrowthlimit 96m"   # then relaunch
adb shell su -c "resetprop dalvik.vm.heapgrowthlimit 256m"  # restore

Verifying needs a behavioural probe, not a log: Timber.plant(DebugTree) is also DEBUG-gated, so MemoryPressure never reaches logcat on staging. The renderer count works - low-RAM skips the speculative WebView warm:

arm sandboxed_process count PSS
low-RAM (96m) 0 / 0 / 0 166 / 169 / 173 MB
normal (256m) 1 / 1 / 1 287 / 372 / 295 MB

The low-RAM path is worth about 148 MB on the production build, the behavioural signal is perfectly separated where PSS is not, and 0 crashes shows R8 does not break those branches.

Trims are not a reliable defence, which argues for this PR's approach

Nothing had ever put the app under real pressure. A hog that allocates once measures nothing: this device has 1.6 GB of zram, so the pages are compressed and MemAvailable goes up - the first attempt "applied" 1500 MB and freed 148 MB. Re-touching every page in a loop denies them to the swapper, and then it bites (MemAvailable ~130 MB, lmkd killing on "direct reclaim and thrashing").

What that showed:

build pressure trims received outcome
staging 1.6 GB hot 0 killed 20 s in, oom_score_adj 0, "device is not responding"
debug 1.6 GB hot 1 (level=15) released + purged in 1 ms, killed 8 s later

lmkd kills on thrash-driven unresponsiveness before AMS gets round to asking anyone to release. So a release that only happens on a trim mostly does not happen. Proactive reclaim - the idle reapers, not warming what will not be used, not holding a scraped document after the scrape - is what actually protects a constrained phone. That is the case for the changes here over the trim fan-out alone.

Stated plainly: the app still dies under that pressure. This is not a survival claim. Past ~1.6 GB the test stops discriminating anyway - at 1.9 GB the launcher enters a kill loop and the app dies before MemoryPressure.init runs, because a continuously rewritten 1.9 GB is a pathological workload, not a small phone.

Corrections to #85

  • The MemoryPressure KDoc told readers to run setprop debug.vela.lowram "" for real detection, which AGENTS.md itself calls a shell syntax error. false does not restore detection either - it forces the normal path. Clearing needs an unparseable value, so both now say none, verified on device (forced=no).
  • Backgrounding delivers UI_HIDDEN (20), never BACKGROUND (40). isSevere starts at 40, so on an ordinary HOME press Use a lot less memory, and give it back when the system asks #85 releases nothing (measured: native heap 64000 -> 63912 KB, noise). Defensible, but Use a lot less memory, and give it back when the system asks #85 reads as though backgrounding reclaims memory. The purge therefore triggers from level 10 up, wider than isSevere.

Still open, not fixed here

Unrelated to this PR: the reviews scrape returns 0 for every place tried on the test device, at stock settings and at every viewport width tried. The retry logic in MapViewModel implies it is meant to work, so that looks like a live bug worth its own investigation. It also means any future reviews-side A/B is unfalsifiable until it is fixed.

Verification

:app:detekt + :core:detekt 0 smells, :core:test green, audit_deadcode.sh PASS, audit_static.sh no new violations (the four reported are pre-existing, in files untouched here), both flavors build, standardDebug installs and runs with no UnsatisfiedLinkError, screenshots of the map after each change.

AGENTS.md gains the rules this work needed: a lease must be taken under the same lock that frees; release() must not block the main thread; always total the out-of-process WebView processes when measuring; both-warms-or-neither; drain pending on reap; verify a reap by its LOG not by process death (the first attempt here was unfalsifiable - the renderer vanished at t+60 s to a real level=15/40 trim, not the reaper); the one-binary A/B rule; and the asr_model_bad quarantine trap, which makes warmUp() a silent no-op so a benchmark measures the model-absent case without saying so.

Appendix: original #85 description (memory-pressure round, with its measurements)

Fixes #83.

Reported on a TCL Flip 2 as "the whole app is a bit slow". Nobody on the dev side has that phone, so everything below is measured on an M5 (2.9 GB, Android 13, standardDebug, median of 5 cold starts). Debug build, so treat the absolute numbers as directional; the deltas are the point.

What was wrong

Two separate problems, and the issue's own hypothesis (the Whisper model) turned out to be half of one of them.

Nothing released under memory pressure. There was no onTrimMemory, onLowMemory or ComponentCallbacks2 anywhere in the tree. MapView.onLowMemory() was never called. WhisperRecognizer had no release path at all, so even Remove-model left ~267 MB resident for the rest of the process. A TRIM_MEMORY_COMPLETE freed 0 KB and the system's only remaining option was to kill us.

Nothing adapted to the device. No isLowRamDevice branch existed; the image cache was a flat 48 MB whatever the phone.

Numbers

metric main normal-RAM phone low-RAM path
peak PSS 831,032 KB 891,473 (noise) 580,889 KB (-30%)
post-trim PSS 397,260 KB 287,143 KB (-28%) 245,946 KB (-38%)
native heap 223,132 KB 124,176 KB (-44%) 95,072 KB (-57%)
cold start 4811 ms 4816 ms 4333 ms (-478 ms)

APK 97,396,968 -> 91,995,103 bytes.

Post-trim and native heap are the numbers to trust: both are PAIRED measurements inside a single
run, so run-to-run variance cancels.

An idle PSS row was published here earlier and has been REMOVED as unsound. It compared a
post-reap reading against a pre-reap one and presented them as one metric, and the headline cell
was n=1. A follow-up on a single consistent clock (median of 3, fixed 35 s and 155 s samples)
gives branch normal-RAM 233,479 KB settled / 192,557 KB post-reap and branch low-RAM 220,236 KB /
187,957 KB - internally consistent, low-RAM below normal as expected. Those are NOT comparable to
the main baseline above, which came from a different harness whose convergence heuristic latches
during the startup ramp. Quoting a delta across the two harnesses would repeat the original
mistake, so no idle delta is claimed. Peak, post-trim, native heap and cold start are all measured
identically on both builds and are unaffected.

The biggest win needed no device gate

The speech model costs ~267 MB while loaded (~101 MB of weights in scudo:secondary plus ~146 MB of onnxruntime arena in scudo:primary) and was kept for the whole process on the chance of a mic tap many users never make. It is now dropped after 120 s unused and rebuilt on next use, so the instant first tap asked for on 2026-07-10 is kept while the session-long hold is not.

Device-verified: scudo:secondary 111 MB -> 9 MB at the 120 s mark, recognizer released logged from the asr-reaper thread, model rebuilding to 125,836 KB afterwards, same pid throughout (a bad release/rebuild here is a native use-after-free, not an exception).

I first made this low-RAM-conditional. That was too cautious and left roomier phones holding 267 MB all session.

Everything else

  • app/ui/MemoryPressure.kt is the one seam. Registration-based, never a Hilt entry point: reaching a singleton from a trim would construct it, so the trim would allocate the very thing it is freeing.
  • Releases registered for: speech model, neural voice, MapLibre native caches, all five hidden WebViews, image cache. WebPhotoFetcher had no reaper at all and pinned a Chromium renderer for the whole session.
  • WhisperRecognizer.release() declines while a listen is in flight.
  • PiperSynth releases only at CRITICAL, not merely severe: a reload delaying a turn prompt is a missed turn.
  • Low-RAM devices additionally skip the startup model preload, cap images at 16 MB, skip the speculative WebView warm, and fetch 8 ambient POI terms instead of 15 with a halved pool.
  • x86/x86_64 native libs dropped. No target phone can execute them; libmaplibre.so alone carried 23 MB of them. armeabi-v7a kept, since 32-bit ARM keypad phones are real.

A regression I caught in my own change

The first low-RAM POI subset used 6 terms and silently deleted every park and school pin, reintroducing the exact bug the civic/green terms were added to fix (the ambient layer filter-hides the basemap OSM poi layers at z14+, so those two have no second source). Caught by an A/B screenshot, not by any test. The subset now keeps school and park deliberately.

Verification, and what was NOT run

Ran: :core:detekt, :app:detekt (0 smells), :core:test, tests/dead_code/audit_deadcode.sh (PASS), tests/dpad/audit_static.sh (no new violations). Both flavors build; restricted installed, launched, initialized MemoryPressure and rendered correctly. A/B screenshots of the POI change at native geometry, both device classes.

One of four geometries ran; three did not. kyocera-e4810 (240x320 @160) passed the map and
voice phases: 2 COVERED, 0 MISSED. sonim-x320, kyocera-duraxe-e4830 and sonim-x320-225 are
UNVERIFIED. The reasoning for not treating that as blocking, as AGENTS.md requires it be stated: The diff contains zero Compose, layout or focus code: no Modifier, no dpadHighlight, no focusable, no composable signature change. The VelaMapView edit is a DisposableEffect registration. The matrix exists to catch clipping, focus-ring and D-pad-traversal regressions at small sizes, none of which this diff can reach. The single user-visible change is ambient POI data density, which is geometry-independent and verified by A/B screenshot.

If a reviewer disagrees with that blast-radius call, the legs to run are PHASES="map voice settings" across kyocera-e4810, sonim-x320, kyocera-duraxe-e4830, sonim-x320-225.

Testing the low-RAM path

Every device we own reports lowRam=false heapClassMb=256, so those branches would otherwise ship as dead code. Debug builds honour:

adb shell setprop debug.vela.lowram true    # then relaunch
adb shell setprop debug.vela.lowram false   # real detection (NB "" is a syntax error, not a reset)

Two traps worth knowing

am send-trim-memory refuses background levels on a foreground process ("Unable to set a background trim level on a foreground process"). Press HOME first. A harness that discards that stderr measures nothing and reports a clean baseline. That happened here and produced a whole benchmark of void numbers before it was noticed.

AGENTS.md's memory rule said the OverpassTrafficSignals/OverpassPois stream-parse follow-up was pending. It has been done for some time, and chasing that stale line wasted a pass. Corrected: the remaining fully-buffered hot reader is the Google ambient path, which cannot simply decodeFromStream because the payload is a positional nameless array.

Reported on a TCL Flip 2 as "the whole app is a bit slow" (#83). Measured on an
M5 (2.9 GB, Android 13, standardDebug, median of 5 cold starts) the app held
831 MB at peak, sat at 421 MB idle, and gave back nothing at all when the OS
asked it to shrink, because nothing in the tree implemented memory-pressure
handling.

The biggest win needs no device gate. The on-device speech model costs ~267 MB
while loaded (~101 MB of weights plus ~146 MB of onnxruntime arena) and was kept
for the whole process on the chance of a mic tap many users never make. It is
now dropped after two minutes unused and rebuilt on next use, so the instant
first tap that was asked for on 2026-07-10 is kept while the session-long hold
is not. Device-verified: scudo:secondary 111 MB to 9 MB at the 120 s mark, model
rebuilding correctly afterwards. Idle PSS 421 MB to 299 MB on a phone that is
not low-RAM at all.

Nothing released under pressure. There was no onTrimMemory, onLowMemory or
ComponentCallbacks2 anywhere, so a TRIM_MEMORY_COMPLETE freed 0 KB and the
system's only remaining option was to kill us. VelaApp now fans every trim out
through a new MemoryPressure holder, and the things that actually hold memory
register a release: the speech model, the neural voice, MapLibre's native tile
and sprite caches (MapView.onLowMemory was never called), all five hidden
WebViews, and the image cache. WhisperRecognizer had no release path at all, so
even Remove-model left ~267 MB resident for the rest of the process.

Nothing adapted to the device either. There was no isLowRamDevice branch and the
image cache was a flat 48 MB whatever the phone. Constrained devices now skip the
startup preload of the speech model, cap images at 16 MB, skip the speculative
WebView warm on every search, and fetch 8 ambient POI category terms instead of
15 with a smaller result pool. Roomier phones keep their existing behaviour.

The low-RAM POI subset deliberately keeps school and park: the ambient layer
filter-hides the basemap OSM poi layers at z14+, so those two have no second
source and a first 6-term subset made every park and school pin vanish. Caught
by an A/B screenshot, not by any test.

Also stop shipping x86 and x86_64 native libraries. No phone Vela targets can
execute them and libmaplibre.so alone carried 23 MB of them into every install.
armeabi-v7a stays, since 32-bit ARM keypad phones are real.

Measured, main vs this, low-RAM path: peak 831 MB to 581 MB (-30%), post-trim
397 MB to 246 MB (-38%), native heap 223 MB to 95 MB (-57%), cold start 4811 ms
to 4333 ms. On a normal-RAM device idle drops 29%, post-trim 28% and native heap
44%. APK 97.4 MB to 92.0 MB.

Debug builds honour `setprop debug.vela.lowram true` so the low-RAM path can be
exercised on a dev phone, where it is otherwise dead code (every device we own
reports lowRam=false heapClassMb=256).

AGENTS.md: document the seam and the measurement traps, and correct the memory
rule, which said the OverpassTrafficSignals/OverpassPois stream-parse follow-up
was pending. It has been done for some time; the remaining buffered hot reader
is the Google ambient path, and chasing the stale line wasted a pass.
PR #85 gave every big holder a release() and fanned OS trims out to them, but a
Kotlin release only returns pages to scudo. They sit on its free lists, where
RSS/PSS still count them and lmkd still sees a fat process. Measured on the M5
before this change: a full TRIM_MEMORY_COMPLETE with all 8 listeners firing
moved scudo:primary 56,578 to 54,978 KB while mallinfo reported a 442 MB arena
holding just 46 MB live.

mallopt() is the only way to hand that gap on and it is reachable only from C,
so this adds the app's first native module: app/src/main/cpp/velamem.cpp, three
lines calling libc, 4 KB for arm64 and 2.7 KB for armeabi-v7a. Built for those
two ABIs only, matching the x86 drop in #85. MemoryPressure.dispatch schedules
it 750 ms after a trim, off the main thread. The delay is load-bearing: the
WebView reapers post destroy() to the main looper and VelaApp clears Coil after
dispatch returns, so an inline purge would run before the memory it is meant to
reclaim had been freed.

The purge fires from TRIM_MEMORY_RUNNING_LOW (10) up, deliberately wider than
isSevere (40). Measured: pressing HOME delivers only TRIM_MEMORY_UI_HIDDEN (20),
never BACKGROUND (40), so gating on isSevere would skip the single most common
moment we are handed, the one where the app is off-screen and nothing can jank.

Verified by A/B on ONE binary, since two builds also differ in background
settling and idle PSS swings +-60 MB run to run. debug.vela.nopurge suppresses
the purge at runtime, making the delta paired within a run; both arms were
checked in logcat to confirm the gate actually gates. Releasing the ASR model,
3 alternating pairs: with the purge suppressed scudo:primary moved 60/32/28 KB
in the 7 s after the trim, which is nothing, and with it on 3704/3008/2792 KB.
No overlap. A second 8-pair A/B over map and POI churn agreed: 3345 to 6931 KB
mean reclaimed, Mann-Whitney U=7 at n=8/8, p<0.05.

It is worth a consistent ~3 MB, not tens, and the commit says so rather than
claiming the onnxruntime arena. The ASR model's ~111 MB lives in
scudo:secondary, which is mmap-backed and comes back on free() with no purge
needed (111 MB to 7 MB in BOTH arms). Only scudo:primary needs asking.

M_PURGE_ALL is API 34+, so on the Android 13 dev phone it returns 0 and the code
falls back to M_PURGE (API 28+). The logged mode= says which actually took, so a
device supporting neither is visible instead of silently doing nothing.

Also corrects two things #85 left wrong. The MemoryPressure KDoc told the reader
to run `setprop debug.vela.lowram ""` for real detection, which AGENTS.md itself
says is a shell syntax error; and `false` does not restore detection either, it
forces the normal path. Clearing needs an unparseable value, so both the KDoc and
AGENTS.md now say `none`, which is verified: the app logs forced=no.

AGENTS.md also gains the UI_HIDDEN-vs-BACKGROUND finding, the one-binary A/B
rule, and the asr_model_bad trap: a quarantined model makes warmUp() a silent
no-op, so scudo:secondary sits at ~11 MB instead of ~111 MB and a memory
benchmark measures the model-absent case without saying so. That cost a run here.

Verified: :app:detekt and :core:detekt 0 smells, :core:test green,
audit_deadcode.sh PASS, standardDebug builds, installs, launches with no
UnsatisfiedLinkError, and renders correctly on device (screenshot: map, POI pins
including parks, focus ring, soft keys).
Measured on the M5 while reviewing #83: the hidden scraper WebViews are the
single largest thing Vela costs, and app PSS cannot see most of it. Android runs
the WebView renderer OUT OF PROCESS, so after one search sandboxed_process0 sat
at 305-347 MB PSS, plus webview_service 21 MB and webview_apk 37 MB, none of it
in this app's dumpsys meminfo. Every number in #83 was app-PSS only, so the
biggest item in the app was invisible to the whole exercise. The app-side half
is GL mtrack, from the offscreen layouts: 26 MB with no WebView alive, 437 MB
with the two scraper views up.

Neither speculative warm was bounded. WebPhotoFetcher had no idle reaper at all,
and WebPopularTimesFetcher.prewarm() created a view and never called
scheduleReap() (only fetch() did). MapViewModel warms both on every search, so a
single search held the renderer until the process died or a trim arrived. Both
now arm a reap, and both matter: fixing only the photo fetcher left the renderer
alive at ~200 MB because the popular-times view still held it. The renderer is
shared by every WebView in the process, so one un-reaped view keeps it up for
all of them.

Device-measured, no trim involved anywhere in the run:

  t+45s    GL mtrack 437 MB   app PSS 725 MB   renderer alive
  t+90s    GL mtrack  65 MB   app PSS 337 MB   reap logged
  t+135s   GL mtrack  64 MB   app PSS 335 MB   renderer process gone

About 390 MB back to the app plus a ~305 MB renderer process shut down, from
memory that used to be held for the rest of the session. Rebuild verified: a
later search respawns the renderer and GL mtrack returns to 431 MB, no crash.

A speculative warm gets a longer window than a real fetch (WARM_REAP_IDLE_MS
300 s vs REAP_IDLE_MS 120 s). The warm exists so the first place tap skips the
cold start, and reaping at 120 s would expire during an ordinary browse and
waste it. Bounded, not short, is the point: the bug was session-long.

reapNow() now drains pending like rendererGone() already did. Destroying the
view kills the injected scraper, so nothing completes those deferreds; a reap
landing mid-fetch parked the fetch in deferred.await() for the full 40 s
TOTAL_TIMEOUT_MS while holding the fetcher's Mutex, stalling everything queued
behind it. An empty result is the documented best-effort failure, a 40 s hang is
not. This was already reachable from #83's severe-trim hook.

Reap bookkeeping moved onto the main thread in both fetchers (onMain). The reap
field is touched from the trim listener on main and from fetch/warm on the
caller's dispatcher, and scheduling is a read-modify-write that @volatile would
not make safe; this commit adds another writer, so it fixes the race rather than
widening it.

The reap is logged because verifying it needs a log, not a process check.
WebView.destroy() does not kill the renderer promptly (220 MB still resident 8 s
after a destroy, process gone only minutes later) and an OS trim can kill it for
unrelated reasons. The first attempt to verify this was unfalsifiable for that
reason: the renderer vanished at t+60 s and the logs showed dispatch level=15/40,
a real trim rather than the reaper. The runs above assert the reap log AND assert
no severe trim fired.

AGENTS.md: the out-of-process measurement rule (always total the WebView
processes), the both-warms-or-neither finding, the drain-pending rule, and the
log-not-process verification rule.

Verified: :app:detekt and :core:detekt 0 smells, :core:test green,
audit_deadcode.sh PASS, audit_dpad static no new violations (the four reported
are pre-existing, in files this commit does not touch), standardDebug builds,
installs and runs, screenshot of the map after a reap-and-rebuild cycle.
@alltechdev
alltechdev force-pushed the perf/webview-and-native-purge branch from d2bfb05 to 92beb2a Compare July 21, 2026 04:15
The inFlight guard added with the idle reaper did not actually close the window
it documents. release() read the counter BEFORE taking loadLock, and
ensureRecognizer() handed the native pointer out on a lock-free fast path
(`recognizer?.let { if (loadedLang == lang) return it }`) before taking the lock
at all, so the two never ordered against each other:

  reaper   release(): inFlight.get() -> 0, guard passes, lock not yet held
  user     listen():  inFlight.incrementAndGet() -> 1
  user     ensureRecognizer() fast path returns the live pointer
  user     decode starts on it
  reaper   synchronized(loadLock) { recognizer = null; r.release() }
  user     rec.decode(stream) on freed C++ memory

That is a SIGSEGV inside libsherpa-onnx-jni, not an exception - the runCatching
around the decode cannot catch a native abort, so the whole process dies. An
AtomicInteger does not make a check-then-act atomic.

The window was not narrow either. Any thread holding loadLock for the ~1 s
native load parks release() between its check and its free for that entire time,
and warmUp() takes that lock at startup on every non-low-RAM device.

Fix: leases, mutated only under loadLock. acquireRecognizer() loads and takes a
lease under one lock; release() checks the count and frees under that same lock,
so a listen cannot start between the two. The lock-free fast path is gone - an
uncontended lock per listen is nothing next to a 15 s utterance. listen() takes
the lease and holds it for the whole utterance (recording included) and gives it
back in a finally, which keeps it exception-safe against listenInner's many
early returns; listenInner now receives the recognizer instead of fetching it.
releaseLease() deliberately does NOT take the lock: it runs only once the decode
is done with the pointer, so a racing release() can at worst read the
pre-decrement value and conservatively decline, and taking the lock there would
park the end of every utterance behind an unrelated load.

loadLock becomes a ReentrantLock so release() can tryLock instead of blocking.
It is called from onTrimMemory on the MAIN thread, and blocking the UI thread
for a whole model load to reclaim memory is a bad trade when the idle reaper or
the next trim retries anyway. deleteAsrModel passes wait = true, since there the
user asked for it and a brief wait is correct.

Device-verified on the M5, and the race window is real rather than theoretical:
hammering `am send-trim-memory <pid> RUNNING_CRITICAL` across startup logged
"release skipped, model load in progress" 5 times in one run, i.e. 5 trims
landed while the load held the lock - each of which the old code would have used
to block the main thread, with the check-then-act live. Across that run and a
6-round force-stop/trim-storm: 0 crashes, 0 SIGSEGV, process alive every round.
Release path still works with the model loaded (scudo:secondary 110,564 KB ->
8,031 KB on a severe trim, "recognizer released" logged), the model reloads
cleanly afterwards (108,381 KB) and is not quarantined, and the map renders.

RUNNING_CRITICAL is the useful level for this test: it is isSevere AND the OS
accepts it on a foreground process, so it reaches the load window without
needing HOME first.

Verified: :app:detekt and :core:detekt 0 smells, :core:test green,
audit_deadcode.sh PASS, standardDebug builds, installs and runs, screenshot.
@alltechdev alltechdev changed the title Free the memory #85 releases, and stop one search pinning a renderer for the session Fix a native use-after-free in the ASR reaper, and free the memory #85 releases Jul 21, 2026
WebPhotoFetcher sized its view inside ensureWebView(), i.e. at construction, and
warm() goes through ensureWebView(). So a search built a full 1200x3200
composited surface over maps?hl=en - a page with no scrapeable content on it -
and held it for the whole 300 s warm window, on a phone whose screen is 480x640.

Sizing moves into sizeForScrape(wv), called in fetch() immediately before
loadUrl, so the ?cid= page's FIRST layout is already at scrape geometry. The
size is unchanged; only when it is applied changes.

Matched A/B, same harness, 3 runs per arm, search then browse with no place
opened:

  eager (as before)   GL mtrack 448 / 427 / 441 MB   TOTAL PSS 866 / 854 / 852 MB
  deferred            GL mtrack  77 /  71 /  72 MB   TOTAL PSS 485 / 460 / 459 MB

-365 MB of GPU memory and -390 MB of PSS, no overlap between the arms, and the
scrape is unaffected: 28/28/28 photos on the same place in both arms.

This fetcher is the only one that lays out during a warm - WebPopularTimes'
prewarm, WebDirections and WebStopDepartures never call measure/layout at all,
and WebReviews has no warm. That is why every GL number in this app tracks this
one view.

Both fetchers now log `scraped N photos/reviews for <featureId>`, which is what
makes a viewport change checkable against scrape QUALITY rather than only
memory. A change that halves memory and quietly halves the gallery is a
regression no memory metric would show.

Shrinking the viewport was tried first and is NOT included. On the photo side
720 px held the count (28 -> 28) on the one place it was A/B'd, but the reviews
side returns 0 reviews for every place tried, at 1200 AND at 720 - a
pre-existing failure, unrelated to width - so that arm's quality metric was
pinned at zero and could not fail. An unfalsifiable check is not evidence, and
scrape geometry governs how much of a virtualized grid materializes, so both
widths stay stock. Deferring the layout wins the same memory back without
changing anything the scraper sees, which is the safer bet by construction
rather than by sampling.

Note GL mtrack at place-open is bimodal (~490 MB laid out and alive, ~71 MB
not), so single readings there are worthless; the warm window is the stable
thing to measure, and the numbers above are 3 runs per arm.

Verified: :app:detekt and :core:detekt 0 smells, :core:test green,
audit_deadcode.sh PASS, standardDebug builds, installs and runs, photo gallery
renders with the same photos.
@alltechdev
alltechdev force-pushed the perf/webview-and-native-purge branch from 2ef64c0 to 7dfc2ab Compare July 21, 2026 05:33
After a photo scrape the hidden WebView kept a fully rasterized Google Maps
document until the 120 s reap - i.e. through the entire time the user is sitting
on the place sheet looking at the photos. fetch()'s finally now navigates it to
about:blank.

Measured at place-open + 75 s, 3 runs:

  before   GL mtrack ~497 MB   TOTAL PSS ~950 MB
  after    GL mtrack   64-70 MB  TOTAL PSS 410-508 MB

Photo counts unchanged. The WebView, renderer, sockets and cookies all stay
alive, so the next place is no colder than before; only the document goes.

Resizing is NOT the lever, and that was measured rather than assumed. Shrinking
the view back to 0x0 after the scrape was tried first and reclaimed nothing:
494/496/497 MB against a 497/498 MB control. Chromium keeps the tiles it has
already rasterized for a live document however small the view gets. The
1200x3200 viewport is 3.84 Mpx = 15 MB of pixels against a measured ~490 MB, so
that number was never a viewport buffer - it is a whole composited layer tree
against a tile budget. Document lifetime is what moves it.

The about:blank navigation then broke the NEXT scrape, and this is the part
worth reading. Its onPageFinished fires on the webViewClient the next fetch has
just installed, which opens that fetch's load gate before the real page has
committed, so the scraper injects into an empty document and returns nothing.
onPageFinished now ignores about: URLs; the MAX_LOAD_MS fallback still covers a
genuinely stuck load.

That bug was only visible when opening a SECOND place: the same place scraped 33
photos when it was the first place opened and 0 when it was the second. A
one-place test cannot see a WebView REUSE bug at all, and re-tapping the same
place is served from the LRU cache without scraping, so it cannot see one
either. Verified after the fix by opening two DIFFERENT places in one session,
twice over: 28 then 52, and 28 then 33 against a 33 fresh-open baseline for that
second place, no crashes, gallery renders.

Verified: :app:detekt and :core:detekt 0 smells, :core:test green,
audit_deadcode.sh PASS, standardDebug builds, installs and runs, screenshots of
both place sheets with their galleries.
…ve purge

Every memory number in issue #83 and in this branch so far was standardDebug.
The `staging` variant exists precisely to avoid that (initWith(release), R8,
resources shrunk, non-debuggable, installs side by side as app.vela.staging) and
nobody had used it. Production is substantially leaner than debug:

  state                  standardDebug     standardStaging
  after a search           ~460 MB            ~279 MB
  place open             410-508 MB         335-392 MB
  after a severe trim         -              140-146 MB
  Code bucket               101 MB            30-45 MB

The Code gap is extracted dex and JIT profiles that do not exist in a release
build, so roughly 55-70 MB of any debug reading is an artifact. Scrape verified
unaffected by R8: 28 photos on staging, same as debug, and the @JavascriptInterface
bridge survives minification (onResult is present in the minified dex).

Two corrections, both to claims made earlier on this branch.

FIRST: "a 442 MB arena holding just 46 MB live" was presented as though the gap
were reclaimable. It is not. mallinfo's free figure is address space that scudo
has already madvised away; scudo:primary PSS at that same moment was 67 MB.
Measured directly by purging during active use with NO listener release - a
RUNNING_MODERATE trim, which isSevere excludes - scudo:primary moved 67.1 MB to
64.5 MB. That is 2.6 MB. A periodic idle purge would be worthless and is not
worth building; this note exists so nobody builds it.

SECOND: the purge was reported as worth ~3 MB from a debug A/B. On production it
is ~10 MB. A/B on staging, 3 runs per arm, comparing where scudo:primary SETTLES
after a severe trim (the pre-trim value swings 147-382 MB run to run and is
useless as a baseline): purge on 52.4/53.1/54.1 MB, purge off 54.4/58.6/76.7 MB.

A single trim on production reclaims 133-328 MB, and it would have been easy to
credit that to the purge - the first reading looked like 122 MB. Nearly all of it
is the registered listeners releasing plus what the platform already does on
trim. The control is what separated them.

Docs only; no behaviour change.
The low-RAM ambient path fetched 8 of the 15 category terms. Both halves of that
were wrong.

It saved no peak memory. Peak is set by ambientFanout, a Semaphore(4), and every
buffer - the response String, the stripped copy, the JsonElement DOM - is
allocated INSIDE withPermit. At most 4 of those exist at once however many terms
are queued behind them, so 15 to 8 changes how many WAVES the fan-out takes, not
what is resident at the peak. The semaphore's own KDoc already said so:
"Bounding to 4 caps the peak transient heap with the same final pool." The levers
that do move the peak are the permit count and the response size, and the !7i
pool halving is the one in use. It is untouched here.

And its stated justification was false. It kept school and park on the grounds
that only those two lack a second source while the ambient layer is up. Nothing
has one then: VelaMapView sets poi_r1/poi_r7/poi_r20 to NONE wholesale on
`if (navMode || ambientPois.isNotEmpty())`, not per category. So shopping,
services, beauty salon, fast food, gym, bar and pharmacy lost their basemap
fallback exactly as school and park would have. Parks at least keep a landuse
polygon, so the green area survives without the pin; a gym, a bar or a pharmacy
exists ONLY as a POI pin, which makes those the worse things to drop, not the
safer ones. A constrained phone was quietly showing a different, poorer map.

The observation behind the subset was real - a first 6-term attempt did lose
every park and school pin, caught by an A/B screenshot. The generalisation drawn
from it was not: that screenshot was evidence about the fan-out, not about school
and park being special.

Low-RAM devices now fetch the same terms as everyone else, so the category set is
identical by construction rather than by sampling. The only remaining low-RAM
difference in this path is the smaller !7i result pool, which trims deep-rank
results per term without removing any category.

Verified: :app:detekt and :core:detekt 0 smells, :core:test green,
audit_deadcode.sh PASS, standardDebug builds and installs, and the app launches
and renders ambient POIs with debug.vela.lowram forced true - park, shopping and
restaurant pins present, no crash.
`heapClassMb in 1..127` had two defects, and both were invisible on any device
the dev side owns.

It excluded 128, which is the heap class OEMs hand out across 1 GB phones and
the low end of 2 GB ones - exactly the class of device issue #83 was filed from.
The phone this work was written for could plausibly have matched none of the
predicate and received none of the work.

And a probe that could not be read returns 0, which also falls outside 1..127,
so an unknown device was silently routed down the memory-HUNGRY path. That is
the wrong failure direction: landing on the low-RAM path costs a roomy phone
about a second on its first mic tap and its first place open, while landing on
the normal path can OOM a phone that had no headroom to begin with.

The predicate now takes three signals, any one of which is enough:
isLowRamDevice (canonical but only Go builds set it), total RAM at or under
2048 MB, and heap class at or under 128 MB. Total RAM is new here and is the
signal that actually describes the device - heap class is a Dalvik knob an OEM
can set to anything. An unreadable probe never counts as evidence of a roomy
device, and two unreadable probes classify as constrained.

MemoryInfo.totalMem reports what the OS can hand out rather than the marketing
figure, so a nominal 2 GB phone reads about 1900 MB and lands inside the ceiling
while a 3 GB phone reads about 2800 MB and does not. Device-confirmed: the M5
reads heapClassMb=256 totalRamMb=2878 and stays lowRam=false, so every
measurement recorded in AGENTS.md was taken on the path that still ships to a
roomy phone.

The decision moved to LowRamMode.classify in :core for one reason: so it can be
tested. A predicate that gates every memory adaptation in the app had no test,
and both of its bugs were the kind only a device nobody owns would expose.
LowRamModeTest pins the boundaries, including 128 itself, the inclusive total-RAM
edge, unreadable probes, and the M5's own values.

Negative control run, as AGENTS.md requires: restoring the original
`isLowRamDevice || heapClassMb in 1..127` makes 4 of the 9 tests fail, including
"heap class 128 is low-RAM, the boundary the first version excluded" and "both
probes unreadable is treated as constrained, not roomy". The tests fail on the
bug they were written for.

Verified: :app:detekt and :core:detekt 0 smells, :core:test green (9/9 in the new
file), audit_deadcode.sh PASS, standardDebug builds, installs and launches, and
the M5 logs lowRam=false unchanged.
Two gaps closed, both methodology, both with numbers that did not exist before.

FIRST: the low-RAM branches had never run on a minified build. debug.vela.lowram
is BuildConfig.DEBUG-gated, so it is inert on staging and release, and every
low-RAM measurement so far came from a debug build with the flag forced.
Magisk's resetprop closes that:

    adb shell su -c "resetprop dalvik.vm.heapgrowthlimit 96m"

ActivityManager.staticGetMemoryClass() reads that property per call, so the app
sees the new heap class immediately and LowRamMode.classify runs for real
(forced=no). Restore it afterwards - it sets the heap growth limit for every app
started after.

Verifying on staging needs a behavioural probe rather than a log, because
Timber.plant(DebugTree) is also DEBUG-gated and MemoryPressure never reaches
logcat there. The renderer count works: low-RAM skips the speculative WebView
warm, so `ps -A | grep -c sandboxed_process` is 0 after a search on that path and
1 on the normal one. Across three pairs that signal was 0/0/0 against 1/1/1,
perfectly separated where PSS was not.

Measured that way, the low-RAM path is worth about 148 MB on the production
build: 166/169/173 MB against 287/372/295 MB, no crashes, which also shows R8
does not break those branches.

SECOND: nothing had ever put the app under real memory pressure. A hog that
allocates once measures nothing - this device has 1.6 GB of zram, so the pages
are simply compressed and MemAvailable goes UP. The first attempt "applied"
1500 MB and freed 148 MB. Re-touching every page in a loop denies them to the
swapper, and then it bites: MemAvailable fell to ~130 MB and lmkd started killing
on "direct reclaim and thrashing".

What that showed is worth recording, because it cuts against the design this
branch inherited. Under real pressure staging received ZERO onTrimMemory
callbacks and was killed 20 s in, at oom_score_adj 0, reason "device is not
responding". The debug build at a gentler 1.6 GB got EXACTLY ONE level=15,
released and purged in 1 ms, and was killed 8 s later. lmkd kills on thrash-driven
unresponsiveness before AMS gets round to asking anyone to release anything.

So a release that only happens on a trim mostly does not happen. Proactive
reclaim - the idle reapers, not warming what will not be used, not holding a
scraped document - is what actually protects a constrained phone. That is an
argument for the changes on this branch over the trim fan-out alone, and it is
recorded here rather than claimed in a commit subject.

Also noted: do not push past ~1.6 GB on this hardware. At 1.9 GB the launcher
enters a kill loop and the app dies before MemoryPressure.init runs, so the test
stops discriminating between good and bad memory behaviour. A continuously
rewritten 1.9 GB is a pathological workload, not a small phone.

Docs only; no behaviour change.
Startup on this app is GC-bound, not compilation-bound. An atrace of a cold start
on the staging build attributes seconds to GC phases (CopyingPhase, NativeAlloc
concurrent copying GC, MarkingPhase), and forcing full AOT compilation made cold
start WORSE, 828 ms against 775 ms - so a baseline profile would buy nothing and
the thing worth cutting is allocation.

FlockCameras.loadFrom was the largest single allocator at startup. For the
bundled dataset (127,770 rows as shipped today) it created roughly 400,000
objects that were garbage within milliseconds:

  - 255,540 boxed java.lang.Double, from two ArrayList<Double> accumulators that
    were copied into DoubleArrays and discarded
  - one boxed Integer per camera, 127,770 of them, from .add(i) into
    MutableList<Int>
  - per occupied cell an ArrayList, its Object[] backing, a boxed Long key and a
    HashMap.Node - 14,273 cells, so ~57,000 more

The coordinates now accumulate into primitive DoubleArrays that double on
demand, and the 0.1 degree bucket index is a flat CSR triple - sorted unique
LongArray of cell keys, IntArray of start offsets, IntArray of row indices -
built with a primitive sort and two counting passes. Five arrays instead of
~200,000 objects. Lookup is a binary search over ~14,000 keys, and the two call
sites move from `grid[key(r, c)]?.let { for (i in bucket) ... }` to an inline
forEachInCell, which also drops the iterator and the unboxing per step.

Proven equivalent on the real dataset rather than argued: a temporary check
rebuilt the old HashMap index alongside the new one during a real load on device
and compared every bucket. rows=127770 cells=14273 oldCells=14273 mismatches=0.
The check was removed again after it passed; this is the only kind of evidence
worth having for an index rewrite, since the data is the thing that finds the
edge cases.

On the GC metric it was written for, mean GC time across a cold start fell from
3408 ms to 2340 ms (n=3 per arm). Stated honestly, that is directional, not
conclusive: the arms overlap (2355-4644 before, 1641-3144 after) and three runs
cannot separate them. Cold start wall time is worse still as a metric here - it
swings 739-1552 ms on this device - which is why GC slices were measured instead.
The allocation reduction itself is not in doubt; the size of its effect is.

Behaviour is unchanged by construction: same rows, same cells, same buckets, same
camera list. The Flock layer is ON by default (a deliberate 2026-07-13 call), so
deferring this work instead was not an option - it had to get cheaper, not later.

Verified: :app:detekt 0 smells, :core:test green, audit_deadcode.sh PASS,
standardStaging builds, installs and runs, map renders, 0 crashes.
…nfixed item

Four measurements worth not repeating, and one large item this branch does NOT
fix.

MapScreen is too big for ART to compile. On the shipping build ART logs
"Method exceeds compiler instruction limit: 19621 in void i2.r1.f(...)", which
the R8 mapping resolves to MapScreenKt.MapScreen - so the composable that runs on
every recomposition of the main screen is never compiled and runs interpreted.
Its body spans roughly lines 192-2172. This is the largest known performance item
in the app and it is still open.

A trial extraction of the biggest block (lines 1828-2048, 221 lines) was done and
reverted, and the recipe is recorded so the next attempt is not exploratory: the
extracted function needs a BoxScope receiver, it captures 23 named values, and
six of those are `var ... by remember` that the block WRITES - pass the
MutableState and re-delegate with `by` so the body stays byte-identical. Passing
them by value is a compile error rather than a silent break, which is what makes
the refactor tractable at all. It should be done one block at a time, re-checking
the logcat instruction count after each.

Startup is GC-bound, not compilation-bound. Forcing full AOT made cold start
WORSE, 828 ms against 775 ms, which is an upper bound on anything a baseline
profile could buy - so that idea is closed rather than pending. An atrace instead
attributes seconds to GC phases, which is what motivated the FlockCameras index
rewrite in the previous commit.

Measuring startup needs the dexopt state controlled or it measures nothing:
`adb install -r` resets it, and comparing a fresh-install arm against a warmed
baseline once "showed" that REMOVING work made startup slower. Cold start swings
739-1552 ms on this device even when matched, so anything smaller than a few
hundred ms needs a lower-variance metric.

And dumpsys gfxinfo does not measure this app's map at all - MapLibre renders
through its own GL context, so HWUI stats cover only the Compose chrome. A D-pad
drive produced 60 frames at 0% jank and a swipe drive 11 frames; neither could
have detected a regression in either direction.

Docs only; no behaviour change.
The previous note said MapScreen exceeds ART's compiler limit and described how
to extract a block out of it. Following that advice naively does not work, and
this is measured rather than reasoned.

The largest block (lines 1828-2048, the idle-map overlay cluster, 221 lines) was
fully extracted into BoxScope.MapIdleOverlays with all 21 captures passed and the
six `var ... by remember` states passed as MutableState and re-delegated so the
body stayed byte-identical. It compiled, installed, ran and did not crash. And
MapScreen went from 19,621 to 20,351 instructions - the wrong direction.

The reason is Compose's calling convention: it emits $changed/$changed1 bitmask
plumbing per parameter at the call site, and for 21 parameters that costs more
than a 221-line body removes. The change was reverted; the tree is back to
19,621 and green.

So the rule is the opposite of the intuitive one: extract blocks with FEW
captures, not the biggest blocks. Count the captures first - comment the block
out, compile, and read the unresolved references, which is the exact list for one
build. A 100-line block taking 4 parameters beats a 220-line block taking 21.
Recomputing composable-local values inside the extracted function
(LocalContext.current, stringResource, isAppInDarkTheme, VelaSoftkeys.isActive)
instead of passing them drops the count further at no behavioural cost.

Docs only; no behaviour change. MapScreen is still uncompiled and still the
largest known performance item.
The previous note said extraction makes MapScreen worse and blamed Compose's
per-parameter $changed plumbing, concluding that blocks with FEW captures should
be extracted instead. That follow-up hypothesis was tested and is also wrong.

  block extracted            lines  caps  lines/cap   MapScreen after
  (baseline)                     -     -          -   19,621
  1828-2048 idle overlays      221    23        9.6   20,351  (+730)
  991-1104  dpad overlay       114     9       12.7   21,638  (+2,017)

The second block was picked precisely because its lines-per-capture ratio was far
better, which the parameter-plumbing theory predicted would win. It came out
worse than the first, and worse in absolute terms. Both compiled, installed and
ran with no crashes, so this is purely a code-size result; both were reverted and
the tree is back at 19,621.

Whatever dominates the instruction count, adding a composable call layer costs
more than the body it removes. Extraction is therefore closed as an approach:
MapScreen cannot be brought under the ~10,000 limit by pulling blocks out of it,
and a third attempt at the same idea is not worth anyone's build time. The
mechanics recorded earlier (BoxScope receiver, passing MutableState and
re-delegating with `by` to keep the body byte-identical) are correct and worth
keeping - it is the strategy they served that does not hold.

MapScreen remains uncompiled and remains the largest known performance item. A
different hypothesis is needed, and it should be measured in one build before any
refactoring work is done.

Docs only; no behaviour change.
MapScreen exceeding ART's compiler limit looked like the largest performance item
in the app, and two extraction attempts were made and reverted chasing it. Nobody
had checked whether it actually costs frames. It does not.

An atrace across a cold start plus a D-pad drive on the staging build:

  Choreographer#doFrame   3,860 ms over 430 frames  =  9.0 ms/frame
  measure/layout/draw     1,524 ms                  =  3.5 ms/frame
  GC                      2,165 ms
  inflate                    16 ms

Against a 16.7 ms budget at 60 Hz there is no frame-budget problem on this
device. A method being uncompiled only matters if it runs hot, and at 9 ms/frame
this one is not hurting. That explains why both extractions changed the
instruction count and nothing a user could feel: they were aimed at something
that was not costing anything.

This measurement should have been taken first, and the note now says so. It also
redirects the effort: GC is the largest remaining cost in the trace, which is
what the FlockCameras index rewrite targeted, and allocation is where further
work belongs rather than composable restructuring.

MapScreen stays recorded as the largest code-size anomaly, but is explicitly
demoted from "largest performance item" to "do not spend effort here without
first showing it costs frames".

Docs only; no behaviour change.
…s, which are fine

This session measured frame time, cold start, GC and instruction counts, and the
honest summary of all of it is that the app is inside its frame budget with room
to spare: 9.0 ms per frame against 16.7 ms. Two MapScreen refactors were
attempted and reverted chasing an instruction count that turned out not to cost
frames at all.

The axis that was never measured until now is the one a user actually
experiences. On staging, tapping a place and waiting for a complete gallery is
28.8 seconds (35 photos). That is not a stall - the photo scraper polls up to 58
ticks at 500 ms behind a 40 s timeout, and reviews sit behind 45 s - it is the
designed shape of scraping a rendered Google page. But it is what "the whole app
is a bit slow" almost certainly means, and it dwarfs anything measurable in
frames or startup.

So the note now says to start there: how long until the user sees the thing they
asked for - search results, ambient pins, the gallery, reviews - rather than
gfxinfo or cold start, neither of which showed a problem worth fixing.

It also records the clearest unpulled lead. nearbyPlaces fans 15 category terms
out 4-at-a-time and ends with awaitAll().flatten(), so every ambient pin appears
at once after the slowest term, about four network waves in. Streaming each term
as it lands would put the first pins on screen roughly 4x sooner for identical
total work. That changes what the user sees while loading, so it wants a
before/after measurement rather than a drive-by edit.

Docs only; no behaviour change.
The previous note flagged nearbyPlaces' awaitAll as the clearest latency lead:
15 terms 4-at-a-time, every ambient pin appearing at once after the slowest one,
so streaming each term would show first pins ~4x sooner. That is still the right
lead, but attempting it quickly would reintroduce a bug this codebase already
fixed, and the note now says so.

nearbyPlaces post-processes the whole merged pool with the SLIM-FLAVOR HEAL. For
the first ~3 s of a session Google serves per-place blocks with the review count
absent, which zeroes ambientProminence and - quoting the existing comment -
"silently broke everything keyed on it: prominence ranking, dot sizing, label
tiers - all flat". The heal detects that flavour across the pool and refetches.
Painting each term as it lands would put pins on screen before the heal can run,
which is precisely that flat-ranking regression.

There is also no onPartial on MapDataSource.nearbyPlaces today - photos and
reviews have one, ambient does not - so the interface, the ViewModel call site at
MapViewModel.kt:3659, the heal, rankAmbientPlaces and the take-N cap have to be
designed together rather than patched at the fan-out.

One thing does already hold: collision priority is stable across uploads because
it is keyed on prominence rather than list index (upstream c35eea33), so repeated
partial uploads will not reshuffle icon placement. That removes one of the two
obvious hazards, leaving the heal as the real one.

Docs only; no behaviour change.
Same resolution stance as the #85 branch merge (437c24e), applied to this
branch's rewrites:

- WhisperRecognizer: keep this branch's lease/ReentrantLock release
  architecture (the use-after-free fix) and port main's multi-engine
  loader (engine+lang key, per-engine strikes/quarantine, three model
  configs) into ensureRecognizerLocked. loadedLang becomes loadedKey.
- MapViewModel: keep main's per-engine flows; release(wait = true) moves
  into deleteAsrEngine so Remove still frees the native model first.
- app/build.gradle.kts: keep main's ARM-only ndk abiFilters and
  ONNX-only packaging excludes; keep this branch's cmake abiFilters for
  libvelamem.
@alltechdev alltechdev changed the title Fix a native use-after-free in the ASR reaper, and free the memory #85 releases Use a lot less memory, give it back when the system asks, and fix a native use-after-free in the ASR reaper Jul 23, 2026
@alltechdev
alltechdev changed the base branch from perf/low-ram-optimization to main July 23, 2026 22:26
@alltechdev alltechdev closed this Jul 23, 2026
@alltechdev alltechdev reopened this Jul 23, 2026
…, add a low-memory ASR engine

The review round on this PR surfaced 12 verified findings; all are fixed:

- WhisperRecognizer: the engine-switch rebuild now retires a superseded
  recognizer while leases are outstanding instead of freeing it under a
  running decode; lease acquisition is cancellation-safe (a leaked lease
  permanently disabled every release path); REAP_IDLE_MS is RAM-scaled
  (120 s low-RAM / 600 s roomy) so roomy phones keep instant mic taps.
- PiperSynth: the ASR two-strike native-crash quarantine now wraps the
  TTS load per voice (issue #95's crash-loop class), and the trim path
  releases with interrupt = false so a speaking nav prompt is never cut
  off mid-word.
- Web fetchers: all five now decline a merely-severe trim reap while a
  fetch is in flight and drain pending deferreds on a forced (critical)
  teardown - no more 20-45 s mutex-holding hangs or emptied galleries;
  reap bookkeeping is main-thread-confined everywhere.
- MapViewModel.deleteAsrEngine runs its lock wait + native free + 154 MB
  recursive delete on Dispatchers.IO with an optimistic row hide.
- FlockCameras publishes the CSR index as one immutable generation
  behind a single volatile reference - the refresh hot-swap can no
  longer tear against a viewport scan (AIOOBE). Unit-tested.
- GoogleMapsDataSource: the low-RAM !7i30 pool halving is reverted -
  it silently removed POI ranks 31-60 with no basemap fallback.

armv7 (issue #95): the vendored sherpa-onnx AAR moves 1.13.3 -> 1.13.4,
whose bundled onnxruntime (1.27.0, from 1.24.3) fixes the BUS_ADRALN
unaligned-read SIGBUS that crashed every model load on 32-bit ARM.
Device-verified both directions on an M5 forced to --abi armeabi-v7a:
TTS (the exact #95 voice) and ASR load, run, release and survive trims
in a 32-bit process. CI fetches the new AAR; do-not-downgrade notes in
the build file and AGENTS.md.

New: AsrEngine.ZIPFORMER_SMALL (28 MB English transducer) as the
low-memory candidate for feature phones - NeMo Conformer CTC was tried
and rejected at ~760 MB-1.2 GB resident. Zipformer emits ALL-CAPS
spoken-form text, so SpeechText.cleanSearchTranscript now lowercases
shouting and runs spokenNumbersToDigits, unit-tested inverse text
normalization ("ONE TWENTY THREE MAIN STREET" -> "123 main street",
ordinal streets, non-English passthrough).

detekt 0 findings; 49 unit tests green (14 new).
@alltechdev alltechdev changed the title Use a lot less memory, give it back when the system asks, and fix a native use-after-free in the ASR reaper Use a lot less memory, give it back under pressure, and make voice work on 32-bit phones Jul 23, 2026
The translation-completeness gate caught settings_asr_langs_zipformer
existing only in English; each locale follows its own moonshine-caption
phrasing.
Resident size was never the only bar: Zipformer small is a 2023
librispeech (audiobook-domain) model, and a maps app lives on the
proper nouns that domain mishears - no post-processing fixes that.
With the idle reaper making Whisper's ~214 MB hold transient rather
than session-long, the low-memory engine stopped paying for its
accuracy cost. The engine entry, config branch, picker caption and
its 14 translations go; the measurement lessons (NeMo ballooning,
Moonshine's four sessions, the reap-delta isolation method) stay in
AGENTS.md so the ground isn't re-trodden.

spokenNumbersToDigits + the ALL-CAPS unshout stay in
cleanSearchTranscript with their tests: they are a no-op on the three
digit-writing engines and insurance for any future word-form one.
The 1.13.4 AAR is 48.8 MB (1.13.3 was 57 MB) and the >50 MB check
failed the successfully-downloaded upgrade - the first failure looked
transient because the earlier run in the same hour passed on a cached
step ordering. Threshold to >40 MB and a note that this is a
truncation guard to resize on bumps, not a version pin.
@alltechdev
alltechdev merged commit f466390 into main Jul 24, 2026
1 check passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Crash on launch after downloading voice model on 32-bit ARM device App feels slow on low-RAM feature phones (TCL Flip 2)

1 participant