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.
Running a builtin city forward crashes the WASM engine with
RuntimeError: memory access out of bounds. It reproduces onkobeand (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()allocatesSimSpritewithmallocand then assigns to itsnamemember, which was never constructed.Environment
main@2894fe4apps/micropolis/src/lib/(no rebuild needed)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.mjsattachment, and the extension matters — it's what makes Node treat the script's top-levelawaitas an ES module), drop it in the repo root, and run:It takes
<city> <turns>and exits non-zero on a trap, so the intermittency is easy to measure:Measured on
main:node repro.mjs deadwood 240×12node repro.mjs kobe 30×10FREEZE_CLOCK=1 node repro.mjs kobe 30×5Why 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()callsrandomlySeedRandom()(random.cpp), which seeds fromgettimeofday(); this happens during bothinit()andloadCity(). ThedoSimInit()mapScans that follow run the zone simulation over the freshly loaded city, drawinggetRandom()and mutating map tiles. Every process therefore starts from a slightly different world, so a latent bad access is hit on only some runs. CallingseedRandom()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(). FreezingDate.nowacross setup pins that seed and makes the crash fire every run — that's whatFREEZE_CLOCK=1does 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=2turns the raw offsets into a precise chain:In
packages/micropolis-engine/src/sprite.cpp,Micropolis::newSprite():SimSpritehas astd::string namemember (micropolis.h), but the object is allocated with rawmallocand never constructed, sooperator=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::callbackis never initialized — not by the constructor, not byinit()— yetsetCallback()doesif (callback != NULL) delete callback. The firstMicropolisworks only because a fresh WASM heap is zeroed; a second instance allocated over freed memory deletes a stale pointer and traps withRuntimeError: 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.