Skip to content

Intermittent memory access out of bounds in simTick() — unconstructed std::string in SimSprite #11

Description

@fdrocha

Running a builtin city forward crashes the WASM engine with RuntimeError: memory access out of bounds. It reproduces on kobe and (depending on seeding) haight, and it's intermittent — the same command crashes on some runs and not others.

The root cause is an uninitialized std::string: Micropolis::newSprite() allocates SimSprite with malloc and then assigns to its name member, which was never constructed.

Environment

  • main @ 2894fe4
  • committed WASM artifacts in apps/micropolis/src/lib/ (no rebuild needed)
  • Node v22.23.2, macOS 14.2.1 arm64

Reproduction

I've attached repro.mjs.txt — it's self-contained and uses only the committed engine artifacts, so there's nothing to build and no dependencies to install. Rename it to repro.mjs (GitHub won't accept a bare .mjs attachment, and the extension matters — it's what makes Node treat the script's top-level await as an ES module), drop it in the repo root, and run:

node repro.mjs deadwood 240
node repro.mjs kobe 30
FREEZE_CLOCK=1 node repro.mjs kobe 30

It takes <city> <turns> and exits non-zero on a trap, so the intermittency is easy to measure:

for i in $(seq 12); do node repro.mjs deadwood 240; done

Measured on main:

Command Result
node repro.mjs deadwood 240 ×12 8 CRASH, 4 OK
node repro.mjs kobe 30 ×10 4 CRASH, 6 OK
FREEZE_CLOCK=1 node repro.mjs kobe 30 ×5 5 CRASH (deterministic)

Why it's intermittent, and how to make it deterministic

The engine seeds its RNG from the wall clock before anything JS-side can seed it. initWillStuff() calls randomlySeedRandom() (random.cpp), which seeds from gettimeofday(); this happens during both init() and loadCity(). The doSimInit() mapScans that follow run the zone simulation over the freshly loaded city, drawing getRandom() and mutating map tiles. Every process therefore starts from a slightly different world, so a latent bad access is hit on only some runs. Calling seedRandom() afterwards can't help — the divergence already happened.

The engine's only route to wall-clock time is emscripten_date_now, which the generated glue defines as () => Date.now(). Freezing Date.now across setup pins that seed and makes the crash fire every run — that's what FREEZE_CLOCK=1 does in the attached script, and it's what made this debuggable.

Root cause

Building the engine with -g -O0 -s SAFE_HEAP=1 -s ASSERTIONS=2 turns the raw offsets into a precise chain:

doSpecialZone → doAirport → generateCopter → makeSprite → newSprite
  → std::string::__assign_no_alias → segfault

In packages/micropolis-engine/src/sprite.cpp, Micropolis::newSprite():

sprite = (SimSprite *)newPtr(sizeof (SimSprite));   // newPtr == malloc
...
sprite->name = name;                                 // std::string::operator=

SimSprite has a std::string name member (micropolis.h), but the object is allocated with raw malloc and never constructed, so operator= dereferences whatever garbage the string's internal pointers hold. It only appears to work when the heap happens to be zero-filled. The path needs an airport to spawn a helicopter, which is why city maps without one never hit it.

A second, related bug

Same class of problem, worth fixing together: Micropolis::callback is never initialized — not by the constructor, not by init() — yet setCallback() does if (callback != NULL) delete callback. The first Micropolis works only because a fresh WASM heap is zeroed; a second instance allocated over freed memory deletes a stale pointer and traps with RuntimeError: table index is out of bounds. Repro: construct, setCallback, init, delete(), then repeat in the same module.

Expected vs. actual

Expected: ticking any builtin city forward completes without trapping.
Actual: RuntimeError: memory access out of bounds, on a run-dependent subset of attempts.

Fix

I have both fixes working and will open a PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions