Description
On iOS, the app can crash during shutdown when the audio worker thread calls AudioCache::invokingLoadCallbacks() while cc::Engine::destroy() / AudioEngine::end() is tearing down the engine on the main thread.
Observed on Cocos Creator 3.8.8 (versions 3.8.x native audio stack) across multiple Crashlytics sessions when the app receives applicationWillTerminate or exits.
Stack traces (representative)
Direct crash in audio callback dispatch:
Crashed: audio worker thread
0 cc::AudioCache::invokingLoadCallbacks()
1 cc::AudioCache::readDataTask(unsigned int)
2 cc::AudioEngine::AudioEngineThreadPool::threadFunc()
Main thread concurrently:
0 std::thread::join()
1 cc::AudioEngine::AudioEngineThreadPool::~AudioEngineThreadPool()
2 cc::AudioEngine::end()
3 cc::Engine::destroy()
Scheduler / UAF variant:
Crashed: com.apple.root.default-qos
0 ??? (invalid address)
Main thread: Engine::destroy() / ScriptEngine::cleanup() / AudioEngineImpl dtor
Root cause
Shutdown race
applicationWillTerminate
└── Engine::destroy()
└── AudioEngine::end()
├── delete sThreadPool → join() blocks until audio thread finishes
│ audio thread may be in:
│ readDataTask() → invokingLoadCallbacks()
│ └── CC_CURRENT_ENGINE()->getScheduler() ← crash
└── delete sAudioEngineImpl (engine/scheduler already invalid)
AudioCache may still be alive (*_isDestroyed == false) while the engine is being destroyed, so the destroyed-flag check does not protect this path.
Bug 1: CC_CURRENT_ENGINE() is unsafe during app teardown
File: native/cocos/audio/apple/AudioCache.mm — invokingLoadCallbacks()
Current code:
auto scheduler = CC_CURRENT_ENGINE()->getScheduler();
scheduler->performFunctionInCocosThread([&, isDestroyed]() { ... });
CC_CURRENT_ENGINE() expands to something like ApplicationManager::getCurrentAppSafe()->getEngine().
- In Release, when
_currentApp is expired, getCurrentAppSafe() can return nullptr (assert is no-op).
- Then
nullptr->getEngine() is a virtual call on null → crash before any null check on the engine pointer is possible.
A null check on CC_CURRENT_ENGINE() does not fix this; the macro must not be used on the audio thread during shutdown.
Suggested approach: use ApplicationManager::getInstance()->getCurrentApp() (returns nullptr if expired without dereferencing), then null-check app, engine, and scheduler before scheduling work.
Also:
- Capture
_state by value in the lambda (avoid cross-thread read of _state while the audio thread writes it).
- Keep
_loadCallbacks main-thread-only invariant (AudioCache.h design).
Bug 2: AudioEngineThreadPool::threadFunc stop/wait logic
File: native/cocos/audio/AudioEngine.cpp
The worker loop can wake from wait without re-checking _stop in a single predicate, and pending tasks are not cleared in the destructor before join(), prolonging shutdown and allowing more readDataTask work during teardown.
Suggested improvements:
- In dtor: set
_stop = true, drain _taskQueue, then notify_all() before join().
- Use
wait(lk, [this]{ return _stop || !_taskQueue.empty(); }) then break if _stop.
Suggested patch (summary)
AudioCache.mm — invokingLoadCallbacks()
auto app = ApplicationManager::getInstance()->getCurrentApp();
if (!app) return;
auto engine = app->getEngine();
if (!engine) return;
auto scheduler = engine->getScheduler();
if (!scheduler) return;
auto isDestroyed = _isDestroyed;
auto state = _state;
scheduler->performFunctionInCocosThread([this, state, isDestroyed]() {
if (*isDestroyed) return;
for (auto &&cb : _loadCallbacks) {
cb(state == State::READY);
}
_loadCallbacks.clear();
});
AudioEngine.cpp — thread pool
// dtor: clear queue after _stop = true
// threadFunc: wait with predicate, then if (_stop) break;
Reproduction
- iOS build with native audio (Apple backend).
- Preload several audio clips (
preload / play with async read).
- Force-quit or background-kill the app while loads are in flight.
- Crash in
invokingLoadCallbacks or invalid scheduler access during AudioEngine::end().
Environment
- Cocos Creator: 3.8.8
- Platform: iOS
- Files:
native/cocos/audio/apple/AudioCache.mm, native/cocos/audio/AudioEngine.cpp
Related (not fixed by above)
Separate watchdog kills when main thread blocks in alcMakeContextCurrent(nullptr) / AURemoteIO::Stop() during OpenAL teardown — likely needs AudioSession suspend / context suspend before destroy. Can file separately if useful.
Verification
After fix: no crashes in invokingLoadCallbacks during Engine::destroy() / thread pool join() on forced app exit with in-flight audio preloads.
Description
On iOS, the app can crash during shutdown when the audio worker thread calls
AudioCache::invokingLoadCallbacks()whilecc::Engine::destroy()/AudioEngine::end()is tearing down the engine on the main thread.Observed on Cocos Creator 3.8.8 (versions 3.8.x native audio stack) across multiple Crashlytics sessions when the app receives
applicationWillTerminateor exits.Stack traces (representative)
Direct crash in audio callback dispatch:
Scheduler / UAF variant:
Root cause
Shutdown race
AudioCachemay still be alive (*_isDestroyed == false) while the engine is being destroyed, so the destroyed-flag check does not protect this path.Bug 1:
CC_CURRENT_ENGINE()is unsafe during app teardownFile:
native/cocos/audio/apple/AudioCache.mm—invokingLoadCallbacks()Current code:
CC_CURRENT_ENGINE()expands to something likeApplicationManager::getCurrentAppSafe()->getEngine()._currentAppis expired,getCurrentAppSafe()can return nullptr (assert is no-op).nullptr->getEngine()is a virtual call on null → crash before any null check on the engine pointer is possible.A null check on
CC_CURRENT_ENGINE()does not fix this; the macro must not be used on the audio thread during shutdown.Suggested approach: use
ApplicationManager::getInstance()->getCurrentApp()(returns nullptr if expired without dereferencing), then null-checkapp,engine, andschedulerbefore scheduling work.Also:
_stateby value in the lambda (avoid cross-thread read of_statewhile the audio thread writes it)._loadCallbacksmain-thread-only invariant (AudioCache.hdesign).Bug 2:
AudioEngineThreadPool::threadFuncstop/wait logicFile:
native/cocos/audio/AudioEngine.cppThe worker loop can wake from
waitwithout re-checking_stopin a single predicate, and pending tasks are not cleared in the destructor beforejoin(), prolonging shutdown and allowing morereadDataTaskwork during teardown.Suggested improvements:
_stop = true, drain_taskQueue, thennotify_all()beforejoin().wait(lk, [this]{ return _stop || !_taskQueue.empty(); })then break if_stop.Suggested patch (summary)
AudioCache.mm—invokingLoadCallbacks()AudioEngine.cpp— thread poolReproduction
preload/playwith async read).invokingLoadCallbacksor invalid scheduler access duringAudioEngine::end().Environment
native/cocos/audio/apple/AudioCache.mm,native/cocos/audio/AudioEngine.cppRelated (not fixed by above)
Separate watchdog kills when main thread blocks in
alcMakeContextCurrent(nullptr)/AURemoteIO::Stop()during OpenAL teardown — likely needs AudioSession suspend / context suspend before destroy. Can file separately if useful.Verification
After fix: no crashes in
invokingLoadCallbacksduringEngine::destroy()/ thread pooljoin()on forced app exit with in-flight audio preloads.