Skip to content

Off-main-thread instantiation crash on macOS hot-reload builds: _create_instance_func skips _constructing_mutex (unguarded write + unbalanced unlock) #1990

Description

@reforia

Summary

On macOS editor/debug builds with hot reload enabled (MACOS_ENABLED && HOT_RELOAD_ENABLED), ClassDB::_create_instance_func races on the shared construction state and performs an unbalanced mutex unlock, causing intermittent (~50%) SIGABRT during off-main-thread instantiation of GDExtension objects (e.g. ResourceLoader.load_threaded_request() of an extension resource, or any construction on a WorkerThreadPool thread).

The crash signature is:

BUG: Godot Object created without binding callbacks. Did you forget to use memnew()?
  (src/classes/wrapped.cpp, Wrapped::Wrapped(const StringName &))

…often followed by downstream container/type corruption (e.g. a wrong-typed pointer landing in a TypedArray<T>).

Root cause

On this build configuration the construction state is shared across threads, not thread_local, and is guarded by Wrapped::_constructing_mutex:

// include/godot_cpp/classes/wrapped.hpp
#if defined(MACOS_ENABLED) && defined(HOT_RELOAD_ENABLED)
#define _GODOT_CPP_AVOID_THREAD_LOCAL
#define _GODOT_CPP_THREAD_LOCAL          // -> not thread_local
#else
#define _GODOT_CPP_THREAD_LOCAL thread_local
#endif

_GODOT_CPP_THREAD_LOCAL static const StringName *_constructing_extension_class_name;
_GODOT_CPP_THREAD_LOCAL static const GDExtensionInstanceBindingCallbacks *_constructing_class_binding_callbacks;

The locking contract is:

  • _pre_initialize<T>() (the memnew path) locks _constructing_mutex, then sets the construct info.
  • Wrapped::Wrapped(const StringName &) consumes the shared globals and nulls them.
  • _postinitialize() unlocks _constructing_mutex.

So the critical section is meant to span the entire construction, and memnew (_pre_initialize + _post_initialize) honors it. _create_instance_func does not:

// include/godot_cpp/core/class_db.hpp  (Godot 4.4+ path)
template <typename T>
static GDExtensionObjectPtr _create_instance_func(void *data, GDExtensionBool p_notify_postinitialize) {
    if constexpr (!std::is_abstract_v<T>) {
        Wrapped::_set_construct_info<T>();   // (a) writes the SHARED globals with NO lock
        T *new_object = new ("", "") T;      //     Wrapped ctor consumes them
        if (p_notify_postinitialize) {
            new_object->_postinitialize();   // (b) UNLOCKS a mutex this path never locked
        }
        return new_object->_owner;
    } else {
        return nullptr;
    }
}

Two defects under concurrency on the _GODOT_CPP_AVOID_THREAD_LOCAL build:

  1. Unguarded write. It calls _set_construct_info<T>() directly instead of _pre_initialize<T>(), so it never takes _constructing_mutex. A concurrent construction on another thread can clobber _constructing_class_binding_callbacks between this thread setting it and the Wrapped ctor consuming it → ctor sees nullptrCRASH_NOW_MSG.
  2. Unbalanced unlock. When p_notify_postinitialize is true, _postinitialize() calls _constructing_mutex.unlock() on a recursive_mutex this path never locked — UB, and it tears down a critical section legitimately held by another thread, letting two threads mutate the shared globals and the instance-binding table simultaneously (the source of the downstream TypedArray corruption).

_recreate_instance_func is not affected — it uses an explicit std::lock_guard<std::recursive_mutex>, so it's balanced. Only the create path is broken.

How it got here (for context)

_create_instance_func was changed from memnew(T) to manual _set_construct_info + placement-new in #1568, intentionally — create_instance2 / GDExtensionClassCreationInfo4 introduced p_notify_postinitialize to let the engine defer the POSTINITIALIZE notification, which memnew (always post-initializes) can't express. The macOS shared-global mutex workaround (#1594) landed between #1568's authoring and its merge, so the create path lost the lock that memnew's _pre_initialize had been providing, while keeping the unlock in _postinitialize. There's no record of the mutex being considered in #1568 — it appears to be a merge-ordering oversight rather than a deliberate change.

Why the symptoms match

  • Intermittent (~50%), timing-dependent → race in a sub-microsecond unlocked window. ✔
  • Only on threaded loads; synchronous main-thread load() never crashes → single-threaded means no interleaving, and a stray unlock() on an otherwise-uncontended recursive_mutex is benign. ✔
  • macOS-specific → the shared-global branch is MACOS_ENABLED && HOT_RELOAD_ENABLED only; release/export-template builds use real thread_local (the #else branch) and do not crash. ✔

Proposed fix

Mirror _pre_initialize/_postinitialize in the create path: lock before _set_construct_info, and balance the unlock in the deferred (no-postinit) branch.

template <typename T>
static GDExtensionObjectPtr _create_instance_func(void *data, GDExtensionBool p_notify_postinitialize) {
    if constexpr (!std::is_abstract_v<T>) {
#ifdef _GODOT_CPP_AVOID_THREAD_LOCAL
        Wrapped::_constructing_mutex.lock();
#endif
        Wrapped::_set_construct_info<T>();
        T *new_object = new ("", "") T;
        if (p_notify_postinitialize) {
            new_object->_postinitialize();   // releases the lock
        }
#ifdef _GODOT_CPP_AVOID_THREAD_LOCAL
        else {
            Wrapped::_constructing_mutex.unlock();   // balance when post-init is deferred
        }
#endif
        return new_object->_owner;
    } else {
        return nullptr;
    }
}

This serializes the create path on the same mutex as memnew on the affected build, closing the unguarded-write window and the unbalanced unlock. Non-macOS / release builds are unaffected (both #ifdef blocks compile out). Nested memnew during a T constructor remains safe because _constructing_mutex is recursive. Builds clean against master's test extension on platform=macos target=editor dev_build=yes (the _GODOT_CPP_AVOID_THREAD_LOCAL config).

Happy to open a PR if this looks right.

System / build

  • Platform: macOS
  • Build: target=editor/template_debug with hot reload (HOT_RELOAD_ENABLED) — i.e. the _GODOT_CPP_AVOID_THREAD_LOCAL path
  • godot-cpp: current master (verified the create path still acquires no mutex)

Steps to reproduce

  1. Register a custom GDExtension Resource subclass and create several .tres instances of it.
  2. On a macOS editor/debug build, issue ResourceLoader.load_threaded_request() for all of them concurrently (so deserialization/construction runs on WorkerThreadPool threads).
  3. Boot headless repeatedly. Observe intermittent SIGABRT with the without binding callbacks signature; building a release/export template makes it go away.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions