Add GuestHeap size-classed reuse pool + oversized-allocation free-list

GuestHeap previously bump-allocated any request >64KiB and never
reclaimed it on free() - a real, sustained gameplay session eventually
exhausted the arena (traced live to two separate crashes: onCreate
never completing, and a NULL-vector dereference in sub_4BA588 caused
by a failed ~175KB allocation going unchecked by the real EA code).

Widen the existing O(1) size-class free list (already used for <=64KiB
requests) to cover the whole arena instead of adding a second reuse
mechanism next to it - oversized allocations now pool and reclaim the
same way small ones already did. Add matching desktop tests
(guest_heap_test.cpp, run via run_heap_tests.sh) covering the exact
failing size from the crash log and a sustained alloc/free cycle in an
arena too small to survive without reclaim.

Verified live on device: the crash is gone across a full 10-minute
session with sustained GLES rendering throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 14:12:32 +03:00
co-authored by Claude Sonnet 5
parent 74ee49881d
commit 9a5736bf3f
4 changed files with 497 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Desktop-only GuestHeap test harness (see guest_heap_test.cpp's own top
# comment) - no Android, no Unicorn, no APK, no device. Compiles
# guest_heap.cpp directly against the host's own C++ compiler and runs the
# resulting binary. Fast feedback loop for changes to the guest heap
# allocator before ever touching a device.
set -euo pipefail
cd "$(dirname "$0")/.."
SRC_DIR="src/main/cpp/emu"
OUT="${TMPDIR:-/tmp}/guest_heap_test"
c++ -std=c++17 -O0 -g -Wall \
"$SRC_DIR/guest_heap.cpp" \
"$SRC_DIR/tests/guest_heap_test.cpp" \
-I"$SRC_DIR" \
-o "$OUT"
"$OUT"
+175
View File
@@ -0,0 +1,175 @@
#include "guest_heap.h"
#include "../util/util.h"
#include <atomic>
#include <cstring>
#include <time.h>
// Temporary sanity check (2026-09-06) - confirm the new size-class free
// list actually IS O(1) per call now, independent of whether it explains
// the overall fread-rate mystery (first measurement showed it did NOT move
// the needle - see ARM64_TRANSLATION_LAYER.md). Remove once confirmed.
namespace {
std::atomic<uint64_t> g_allocCalls2{0};
uint64_t NowNs2() {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (uint64_t)ts.tv_sec * 1000000000ull + ts.tv_nsec;
}
} // namespace
void GuestHeap::Init(uint8_t* hostBase, GuestAddr arenaStart, uint32_t arenaSize) {
host_base_ = hostBase;
arena_start_ = arenaStart;
arena_end_ = arenaStart + arenaSize;
free_cursor_ = arenaStart;
for (auto& head : free_list_heads_) head = 0;
}
int GuestHeap::SizeClassFor(uint32_t payload) {
if (payload > kMaxSizeClassBytes) return -1;
uint32_t classBytes = kAlign;
for (int i = 0; i < kNumSizeClasses; i++) {
if (payload <= classBytes) return i;
classBytes <<= 1;
}
return -1; // unreachable given the check above, but safe
}
GuestAddr GuestHeap::Alloc(uint32_t size) {
std::lock_guard<std::mutex> lock(mutex_);
if (!host_base_ || size == 0) return 0;
uint64_t callNo = g_allocCalls2.fetch_add(1, std::memory_order_relaxed) + 1;
uint64_t t0 = NowNs2();
uint32_t payload = AlignUp(size, kAlign);
int classIdx = SizeClassFor(payload);
auto logIfSampled = [&](const char* how) {
if (callNo <= 5 || callNo % 20000 == 0) {
Log("GuestHeap::Alloc: call #%llu (%s), took %lluns",
(unsigned long long)callNo, how, (unsigned long long)(NowNs2() - t0));
}
};
if (classIdx >= 0) {
uint32_t classBytes = SizeClassBytes(classIdx);
GuestAddr freeAddr = free_list_heads_[classIdx];
if (freeAddr) {
// Pop the free-list head - the "next" pointer lives in the
// block's own (now-unused) payload, always at least kAlign=8
// bytes so a 32-bit guest address always fits.
uint32_t next = 0;
memcpy(&next, host_base_ + freeAddr, 4);
free_list_heads_[classIdx] = next;
HeaderAt(freeAddr)->free = 0;
std::memset(host_base_ + freeAddr, 0, classBytes);
logIfSampled("pool-reuse");
return freeAddr;
}
// No free block of this class yet - bump-allocate a new one at the
// CLASS size (not the raw requested payload), so any future free()
// of a different-sized request that rounds to this same class can
// still reuse it exactly.
uint32_t needed = sizeof(BlockHeader) + classBytes;
if (free_cursor_ + needed > arena_end_) {
// Loud on purpose (2026-09-18) - since the size-class widening,
// this is now the path any oversized allocation's exhaustion
// actually takes (the old "large path" fallback below is only
// reached past 512MiB, effectively unreachable in practice) -
// losing this class's own copy of the 2026-09-17 "HEAP
// EXHAUSTED" loud-logging fix would silently regress that
// diagnostic for the exact case it was added for.
Log("GuestHeap::Alloc: HEAP EXHAUSTED - class=%d classBytes=%u needed=%u but only "
"%u bytes remain (free_cursor_=0x%x arena_end_=0x%x) - returning 0 (null)",
classIdx, classBytes, needed,
(free_cursor_ <= arena_end_) ? (arena_end_ - free_cursor_) : 0,
free_cursor_, arena_end_);
return 0;
}
BlockHeader* h = reinterpret_cast<BlockHeader*>(host_base_ + free_cursor_);
h->magic = kMagic;
h->size = classBytes;
h->free = 0;
GuestAddr dataAddr = free_cursor_ + sizeof(BlockHeader);
free_cursor_ += needed;
std::memset(host_base_ + dataAddr, 0, classBytes);
logIfSampled("pool-bump-new");
return dataAddr;
}
// Fallback for a request bigger than even the largest size class
// (kMaxSizeClassBytes, now 512MiB as of the 2026-09-18 widening - see
// guest_heap.h's own comment) - genuinely unreachable in practice for
// this codebase's real allocation sizes, so still just bump-allocates
// fresh and never reuses on free, same as every size WAS before that
// widening. Kept only as a defensive ceiling, not the routine path it
// used to be.
uint32_t needed = sizeof(BlockHeader) + payload;
// Diagnostic (2026-09-17, ARM64_TRANSLATION_LAYER.md - the 0x1e0
// wild-jump chase; kept live after the 2026-09-18 size-class widening
// since this branch, though now rare, still never reclaims). Logging
// every allocation that lands here (>512MiB, past even the widened
// size-class ceiling), and LOUDLY logging outright exhaustion (which a
// silent `return 0` would let downstream shims like Shim_aeabi_memcpy's
// own null-check quietly swallow instead of failing loud).
if (payload > 1024 * 1024) {
Log("GuestHeap::Alloc: request past the widened size-class ceiling, payload=%u "
"(%.1fMB) - free_cursor_=0x%x arena_end_=0x%x headroom=%u bytes",
payload, payload / (1024.0 * 1024.0), free_cursor_, arena_end_,
(free_cursor_ <= arena_end_) ? (arena_end_ - free_cursor_) : 0);
}
if (free_cursor_ + needed > arena_end_) {
Log("GuestHeap::Alloc: HEAP EXHAUSTED - payload=%u needed=%u but only %u bytes "
"remain (free_cursor_=0x%x arena_end_=0x%x) - returning 0 (null)",
payload, needed, (free_cursor_ <= arena_end_) ? (arena_end_ - free_cursor_) : 0,
free_cursor_, arena_end_);
return 0;
}
BlockHeader* h = reinterpret_cast<BlockHeader*>(host_base_ + free_cursor_);
h->magic = kMagic;
h->size = payload;
h->free = 0;
GuestAddr dataAddr = free_cursor_ + sizeof(BlockHeader);
free_cursor_ += needed;
std::memset(host_base_ + dataAddr, 0, payload);
logIfSampled("large-path");
return dataAddr;
}
bool GuestHeap::IsValidLiveBlock(GuestAddr addr) const {
if (!host_base_ || addr < arena_start_ + sizeof(BlockHeader) || addr >= free_cursor_) return false;
BlockHeader* h = HeaderAt(addr);
if (h->magic != kMagic) return false; // not a real header - wrong/stale address
if (h->free) return false; // already free - reject double-free
// Payload must fit inside the arena and not run past the bump cursor -
// catches a header whose magic happens to match by coincidence but
// whose size field is nonsense (e.g. a wild write landed exactly 8
// bytes before some unrelated valid-looking magic value).
if (addr + h->size > free_cursor_) return false;
return true;
}
void GuestHeap::Free(GuestAddr addr) {
if (addr == 0) return; // free(NULL) is legal and common - silent, not a corruption signal
std::lock_guard<std::mutex> lock(mutex_);
if (!IsValidLiveBlock(addr)) {
Log("GuestHeap::Free: rejected addr=0x%x - not a valid live block (wrong pointer, double-free, "
"or heap corruption - see guest_heap.h's own class comment)", addr);
return;
}
BlockHeader* h = HeaderAt(addr);
h->free = 1;
// Pooled (size-classed) blocks always have h->size exactly equal to
// their class's byte size (Alloc() only ever bump-allocates a NEW block
// at the class size, never the raw request) - push it onto that class's
// free list for O(1) reuse. As of the 2026-09-18 widening (see
// guest_heap.h's own comment) this now covers every size up to 512MiB,
// not just the original <=64KiB range - only a request bigger than
// that (h->size > kMaxSizeClassBytes, SizeClassFor returns -1) still
// falls outside every class and is simply left free without joining
// any list, same tradeoff Alloc() documents for that now-rare fallback.
int classIdx = SizeClassFor(h->size);
if (classIdx >= 0 && SizeClassBytes(classIdx) == h->size) {
uint32_t next = free_list_heads_[classIdx];
memcpy(host_base_ + addr, &next, 4);
free_list_heads_[classIdx] = addr;
}
}
+135
View File
@@ -0,0 +1,135 @@
#pragma once
#include "guest_types.h"
#include <mutex>
// Minimal first-fit heap allocator carved out of a fixed-size arena that
// lives INSIDE the same flat guest/host region as the loaded libapp.so
// image (see GuestEngine). Backs the malloc/free/calloc import shims, so
// that any object the guest code allocates at runtime (e.g. InjectSyntheticEvent's
// RaceEvent/CashReward/FakeActor - see lan_event_injection.h) gets a real
// guest address in the SAME identity-offset numbering as every static
// OFFSET macro in this codebase, not an arbitrary host heap pointer outside
// the mapped region.
//
// 2026-09-06: replaced the original first-fit-scan-the-whole-arena design
// with a size-class-segregated free list, after directly measuring the old
// one as the real (confirmed, quantified) root cause of the OBB-index-build
// performance cliff investigated in ARM64_TRANSLATION_LAYER.md - the old
// Alloc() scanned every block ever carved, free or in-use, on every call,
// so cost grew with total historical allocation count (measured: 0 blocks
// scanned at call #1, 9294 at call #40000, ~30us/call once the arena's
// free/used population reached its steady-state size). Requested sizes are
// now rounded up to one of a small set of power-of-two size classes
// (kAlign=8 .. kMaxSizeClassBytes, see kNumSizeClasses's own comment for
// its current ceiling); each class keeps its own singly-linked free list
// (the "next" pointer for a free block lives in the block's own now-unused
// payload - always at least kAlign=8 bytes, enough for one 32-bit guest
// address), making both Alloc() and Free() O(1) instead of O(n). Only a
// request bigger than the largest class - now a genuinely rare, near-
// arena-sized edge case rather than the routine "anything over 64KiB" path
// it used to be (see kNumSizeClasses's own 2026-09-18 comment) - still
// bump-allocates fresh and is simply never reused on free.
//
// This heap backs BOTH the guest program's own malloc/free/calloc/realloc
// AND short-lived JNI marshaling buffers (see import_shims.cpp/jni_shim.cpp)
// - i.e. arbitrary, uncontrolled usage from millions of lines of real ARM32
// game code sharing one arena. A single wrong free() anywhere in that code
// (or a buffer overflow into a neighboring block) can otherwise silently
// corrupt any other live allocation - confirmed live this session, tracked
// down to the permanently-cached guest JNIEnv structure getting overwritten
// this way. Two structural responses: (1) permanent, safety-critical
// structures (guest JNIEnv/JavaVM) no longer live in this heap at all - see
// GuestEngine::AllocPermanent's own comment; (2) this heap now validates a
// magic value on every Free() so a wrong/stale address is rejected and
// logged instead of silently corrupting whatever bytes precede it. Doesn't
// stop a wild WRITE (nothing but a full guard-page/MMU scheme would), but
// catches the specific failure mode actually observed: a bad free().
class GuestHeap {
public:
// hostBase: host pointer corresponding to guest address 0 (i.e. the
// engine's G2H(0)). arenaStart/arenaSize: guest-address range this heap
// is allowed to hand out (must not overlap the loaded image or the
// guest stack).
void Init(uint8_t* hostBase, GuestAddr arenaStart, uint32_t arenaSize);
// Returns 0 (a guest NULL) on failure, matching malloc's own contract.
// Thread-safe (mutex-guarded) - now that real guest threads exist (see
// emu/pthread_shim.h), any of them can call malloc/free concurrently.
GuestAddr Alloc(uint32_t size);
// No-ops (logged) if `addr` doesn't point at a real, currently-allocated
// block's payload - see class comment.
void Free(GuestAddr addr);
private:
struct BlockHeader {
uint32_t magic; // kMagic if this is a real header GuestHeap itself wrote - see class comment
uint32_t size; // payload size, not including this header
uint32_t free; // 1 = free, 0 = in use
};
static constexpr uint32_t kMagic = 0x47484B21; // "GuestHeap blocK!" - arbitrary but distinctive
static constexpr uint32_t kAlign = 8;
static uint32_t AlignUp(uint32_t v, uint32_t a) { return (v + a - 1) & ~(a - 1); }
// Size classes: kAlign(8), 16, 32, ... up to kAlign << (kNumSizeClasses-1).
// 27 classes tops out at 8 << 26 = 512MiB.
//
// 2026-09-18 (ARM64_TRANSLATION_LAYER.md - the sub_4BA588/"loadNodeUncached"
// chase, ROOT CAUSE): widened from 14 classes (64KiB ceiling) to 27
// (512MiB ceiling) - the >64KiB "oversized" path used to bump-allocate
// fresh on every call and NEVER reuse freed memory (see the old
// GuestHeap::Alloc's own now-removed comment), which was root-caused
// live on the Pixel 6a as the single cause of BOTH the "onCreate never
// completes" heap-exhaustion crash AND a separate, later-discovered
// crash in sub_4BA588 ("loadNodeUncached"): a real ~175KB M3G resource
// buffer allocation failed because the 256MB arena was already full of
// permanently-stranded freed-but-unreclaimed oversized blocks from
// earlier in the same session, the real EA code doesn't check malloc's
// result for null (reasonably, since real hardware's own heap
// essentially never fails a request this size), and the null flows
// downstream into a reader that legitimately-but-incorrectly reports
// "0 bytes available" forever. Real hardware never hits this because
// its heap never actually runs out. Simplest complete fix: there is no
// structural difference between a "small" and a "large" allocation from
// this allocator's own point of view - the SAME O(1) size-classed
// free-list scheme that already correctly reuses freed <=64KiB blocks
// (see the 2026-09-06 comment two paragraphs up) works identically at
// any size, so widening it removes the separate never-reclaimed path
// entirely rather than adding a second, differently-shaped one next to
// it. Trades up to ~2x internal fragmentation per oversized object
// (worst case: a request just over a class boundary rounds up to
// double) for a hard bound on total arena growth - a real win given the
// failure mode being fixed is fully UNBOUNDED growth, not excess
// fragmentation. Does not coalesce adjacent free blocks (consistent
// with this allocator's existing choice not to do so even for small
// blocks - see the class comment) - not needed for this fix: reuse
// alone converts "grows forever" into "reaches a steady state," which
// is the actual guarantee being restored here.
static constexpr int kNumSizeClasses = 27;
static constexpr uint32_t kMaxSizeClassBytes = kAlign << (kNumSizeClasses - 1);
// Returns the class index for `payload`, or -1 if it's larger than the
// biggest class (caller falls back to a fresh, never-reused allocation).
static int SizeClassFor(uint32_t payload);
static uint32_t SizeClassBytes(int classIdx) { return kAlign << classIdx; }
BlockHeader* HeaderAt(GuestAddr addr) const {
return reinterpret_cast<BlockHeader*>(host_base_ + addr - sizeof(BlockHeader));
}
GuestAddr AddrOfHeader(BlockHeader* h) const {
return static_cast<GuestAddr>(reinterpret_cast<uint8_t*>(h) - host_base_);
}
// True if `addr` is in-range AND the bytes immediately before it look
// like a real header this allocator wrote (magic matches) AND that
// block is currently marked in-use (double-free protection too).
bool IsValidLiveBlock(GuestAddr addr) const;
uint8_t* host_base_ = nullptr;
GuestAddr arena_start_ = 0;
GuestAddr arena_end_ = 0; // one-past-the-end of the arena
GuestAddr free_cursor_ = 0; // next never-yet-used byte (bump pointer for the "no free block fits" case)
// Head of each size class's free list (a block's data address, or 0 =
// empty) - see class comment. Zero-initialized; Init() also resets it
// explicitly in case an instance is ever re-Init()'d.
GuestAddr free_list_heads_[kNumSizeClasses] = {};
std::mutex mutex_;
};
@@ -0,0 +1,168 @@
// Desktop-only test harness for GuestHeap (see /ARM64_TRANSLATION_LAYER.md
// and guest_heap.h's own class comment) - no Unicorn, no Android, no APK.
// Compiles and links ONLY guest_heap.cpp directly against a host-side stub
// of util.h's Log() below (guest_heap.cpp is otherwise fully portable - see
// guest_types.h). Run via mpcore/scripts/run_heap_tests.sh.
//
// Exercises exactly the failure mode a real on-device crash was traced to
// this session: GuestHeap::Free() with a wrong/stale address silently
// corrupting an unrelated live allocation (which, in the real crash, was
// the permanently-cached guest JNIEnv). Confirms the canary added in
// response catches it instead of letting it through.
#include "../guest_heap.h"
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <vector>
// util.h declares this; guest_heap.cpp calls it on rejected frees/
// exhaustion. Desktop stand-in - prints to stdout instead of
// __android_log_write (an NDK-only API, not available on the host).
void Log(const char* fmt, ...) {
va_list args;
va_start(args, fmt);
vprintf(fmt, args);
va_end(args);
printf("\n");
}
namespace {
int g_checks = 0;
int g_failures = 0;
void Check(bool cond, const char* what) {
g_checks++;
if (!cond) {
g_failures++;
printf(" FAIL: %s\n", what);
} else {
printf(" ok: %s\n", what);
}
}
} // namespace
int main() {
constexpr uint32_t kArenaSize = 64 * 1024;
constexpr GuestAddr kArenaStart = 0x1000; // nonzero, matches real usage (heap never starts at guest 0)
// Backing buffer is addressed the same way GuestEngine::G2H does
// (hostBase + guestAddr) - kArenaStart headroom below the arena itself
// so that convention holds without a separate translation helper here.
std::vector<uint8_t> backing(kArenaStart + kArenaSize, 0);
printf("test: basic alloc/free round trip\n");
{
GuestHeap heap;
heap.Init(backing.data(), kArenaStart, kArenaSize);
GuestAddr a = heap.Alloc(64);
Check(a != 0, "Alloc(64) succeeds");
heap.Free(a);
GuestAddr b = heap.Alloc(64);
Check(b == a, "Alloc after Free reuses the freed block (first-fit)");
}
printf("test: free(0) is a silent no-op, matching free(NULL)\n");
{
GuestHeap heap;
heap.Init(backing.data(), kArenaStart, kArenaSize);
heap.Free(0); // must not crash, must not touch anything
GuestAddr a = heap.Alloc(16);
Check(a != 0, "heap still works normally after Free(0)");
}
printf("test: double-free is rejected, not silently accepted\n");
{
GuestHeap heap;
heap.Init(backing.data(), kArenaStart, kArenaSize);
GuestAddr a = heap.Alloc(32);
heap.Free(a);
heap.Free(a); // second free of the same address - must be rejected, not corrupt bookkeeping
GuestAddr b = heap.Alloc(32);
Check(b == a, "block is still consistently reusable after an attempted double-free");
}
printf("test: a wrong/stale address passed to Free() does not corrupt a live neighbor\n");
{
GuestHeap heap;
heap.Init(backing.data(), kArenaStart, kArenaSize);
GuestAddr victim = heap.Alloc(16);
Check(victim != 0, "victim allocation succeeds");
memset(backing.data() + victim, 0xAB, 16); // sentinel payload
// NOT a real block's payload start - exactly the shape of bug this
// session traced a real crash to (a miscalculated/stale guest
// address handed to Free()).
GuestAddr wrongAddr = victim + 5;
heap.Free(wrongAddr); // must be rejected
bool intact = true;
for (int i = 0; i < 16; i++) {
if (backing.data()[victim + i] != 0xAB) intact = false;
}
Check(intact, "victim block's payload is untouched after a rejected wrong-address free");
// The victim should still be considered in-use - first-fit must
// skip it (not reuse it) for a fresh allocation.
GuestAddr other = heap.Alloc(16);
Check(other != victim, "victim block was NOT freed by the rejected wrong-address free");
}
printf("test: oversized (>64KiB) alloc/free round trip reuses the freed block\n");
{
// Separate, larger arena - the default 64KiB test arena above can't
// fit even one >64KiB allocation.
constexpr uint32_t kBigArenaSize = 4 * 1024 * 1024; // 4MiB
std::vector<uint8_t> bigBacking(kArenaStart + kBigArenaSize, 0);
GuestHeap heap;
heap.Init(bigBacking.data(), kArenaStart, kBigArenaSize);
GuestAddr a = heap.Alloc(175760); // real size from the sub_4BA588 crash log
Check(a != 0, "Alloc(175760) succeeds");
heap.Free(a);
GuestAddr b = heap.Alloc(175760);
Check(b == a, "oversized Alloc after Free reuses the freed block, same as small sizes");
}
printf("test: repeated oversized alloc/free does NOT grow the arena unboundedly "
"(regression test for the sub_4BA588/loadNodeUncached heap-exhaustion crash, "
"ARM64_TRANSLATION_LAYER.md 2026-09-18)\n");
{
// Small enough that the OLD never-reclaim behavior would exhaust
// this arena in well under 100 iterations (100 * (175760+12) ~=
// 17.6MB > this 1MB arena), but large enough that a handful of
// real, simultaneously-live oversized objects still fit alongside
// the reused ones - a tight, realistic regression bound instead of
// an arbitrarily generous one.
constexpr uint32_t kBigArenaSize = 1 * 1024 * 1024; // 1MiB
std::vector<uint8_t> bigBacking(kArenaStart + kBigArenaSize, 0);
GuestHeap heap;
heap.Init(bigBacking.data(), kArenaStart, kBigArenaSize);
bool allSucceeded = true;
for (int i = 0; i < 500; i++) {
GuestAddr a = heap.Alloc(175760);
if (a == 0) { allSucceeded = false; break; }
heap.Free(a);
}
Check(allSucceeded, "500 repeated alloc/free cycles of a real oversized size all "
"succeed in a 1MiB arena (would have exhausted after ~5 cycles "
"under the old never-reclaimed behavior)");
}
printf("test: exhaustion returns 0 (guest NULL), not garbage or a crash\n");
{
GuestHeap heap;
heap.Init(backing.data(), kArenaStart, kArenaSize);
int count = 0;
while (heap.Alloc(256) != 0) {
count++;
if (count > 100000) break; // safety valve
}
Check(count > 0, "at least one allocation succeeded before exhaustion");
GuestAddr overflow = heap.Alloc(256);
Check(overflow == 0, "allocation after exhaustion returns 0");
}
printf("\n%d checks, %d failures\n", g_checks, g_failures);
return g_failures == 0 ? 0 : 1;
}