How we debugged a production crash at HashCube that only happened when Android silently killed our Cocos2d-x game in the background. A deep dive into mobile lifecycle edge cases.
In late 2019, while working at HashCube on mobile puzzle games, we encountered one of those frustrating production crashes that every mobile engineer dreads: random, hard-to-reproduce native crashes that only seemed to happen in the wild.
What started as a mysterious JSAbortIfWrongThread crash turned into a fascinating investigation that revealed fundamental gaps in how Cocos2d-x handles Android lifecycle events.
Our mobile game, built with Cocos2d-x using JavaScript bindings, was experiencing native crashes in production. The symptoms were maddening:
JSAbortIfWrongThread deep inside the JS engineThe crash appeared to be coming from somewhere deep in the JavaScript engine, which initially led us down the wrong path entirely.
Our first instinct was to rely on Crashlytics for insights. Unfortunately, the native crash reports were frustratingly vague:
Fatal Exception: JSAbortIfWrongThread
at [native code]
Multiple attempts to reproduce the crash locally failed. We initially suspected rendering issues, which led to optimization work that, while beneficial for performance, didn't solve the actual problem.
My teammate Ashish suggested integrating Bugsnag to get richer event context. This decision proved crucial. Bugsnag's breadcrumb feature started showing us patterns that Crashlytics missed.
The key insight came from repeatedly seeing this in the logs just before crashes:
onActivitySaveInstanceState
This breadcrumb appeared consistently before crashes, often without any user input. Something was triggering the Android activity lifecycle without user interaction.
Digging deeper into the logs, we noticed a suspicious pattern:
onActivitySaveInstanceState is calledThe critical detail: users weren't manually switching apps or restarting. Something else was causing the activity to restart.
The breakthrough came when I enabled "Limit background processes" in Android Developer Options and set it to 1 process maximum.
Boom. Instant reproduction.
This setting forces Android to aggressively kill background apps to reclaim memory. When our game returned to the foreground, Android would recreate the activity, but Cocos2d-x wasn't properly handling this lifecycle transition.
Our game had been ported from Game Closure's DevKit to Cocos2d-x-js, so we had a perfect reference point. We tested the original Game Closure version under the same conditions:
The Game Closure version handled this perfectly - it cleaned up properly and restarted fresh. We also checked other apps and games - they all either cleaned up properly or restarted completely. Our Cocos2d-x-js port was the outlier, trying to resurrect from an invalid state.
Here's what was actually happening:
The JSAbortIfWrongThread error was just a symptom – the real issue was that Cocos2d-x wasn't designed to handle process death and revival gracefully when using JavaScript bindings.
Our investigation led us to critical community resources that confirmed the broader pattern:
GitHub Issue #20466: "Android killed activity may cause JS engine crash"
This issue was opened by the maintainer after I reported the problem. It documented the crash in Cocos2d-x v3.17.1 JavaScript branch:
malformed UTF-8 character sequence at offset 0js_abortifwrongthread (MOZ_CRASH)The issue helped document the problem for others facing the same crash.
Forum Discussion: Crash on device language change
Hatim, who later implemented our fix, had reported in this forum thread that the crash wasn't limited to memory pressure scenarios:
The Common Thread: Whether Android kills your activity due to memory pressure or configuration changes, the result is the same – Cocos2d-x's JavaScript engine can't handle the activity recreation, leading to native crashes when the engine tries to execute JS code in an invalid state.
My first instincts were to work around the issue rather than fix it:
android:launchMode="singleTask"onCreateWhile these might have masked the problem, they weren't addressing the root cause.
The breakthrough came when Ramprasad (our CTO) suggested looking into cocos2d-x-lite for inspiration. Following this lead, Hatim implemented the proper fix – cleanup in the activity's onDestroy method:
@Override
protected void onDestroy() {
Cocos2dxAudioFocusManager.unregisterAudioFocusListener(this);
CAAgent.onDestroy(); // analytics cleanup
super.onDestroy();
if (mGLSurfaceView != null) {
Cocos2dxHelper.terminateProcess(); // Critical: terminates native engine safely
}
}
The key line is Cocos2dxHelper.terminateProcess(), which ensures the native engine shuts down properly instead of leaving it in a corrupted state when Android kills the process. This was a much cleaner solution than any of the workarounds I'd been considering.
This bug revealed a fundamental gap in how Cocos2d-x handles Android lifecycle events when JavaScript bindings are involved. The engine assumed process continuity that Android doesn't guarantee on memory-constrained devices.
If you're working with Cocos2d-x on Android, you can test for this issue:
If you see crashes on app resume, you're likely hitting the same lifecycle management issue.
This debugging experience reinforced my belief that the most interesting bugs often hide behind misleading error messages. Sometimes the crash you see isn't the problem you need to solve.