[GDExtension] Crash on editor exit after hot-reload: CallableCustomExtension calls freed function pointer

Godot Version

4.6 stable

godot-cpp Version

4.5(build godot-cpp on godot 4.5.1)

Question

Problem Description:
When I hot-reload my GDExtension DLL and then exit to the project list (or close the editor), Godot crashes with:

0x00007FFFBF755A40: Access violation executing location 0x00007FFFBF755A40

The crash occurs in gdextension_interface.cpp:

~CallableCustomExtension() {
    if (free_func != nullptr) {
        free_func(userdata);  // <-- crashes here
    }
}

This is my cpp script:

#ifndef TIME_SCRUBBER_H
#define TIME_SCRUBBER_H

#include "../utils.h"
#include <godot_cpp/classes/font.hpp>
#include <godot_cpp/classes/input.hpp>
#include <godot_cpp/classes/input_event_mouse_button.hpp>
#include <godot_cpp/classes/input_event_mouse_motion.hpp>
#include <godot_cpp/classes/line_edit.hpp>
#include <godot_cpp/classes/range.hpp>
#include <godot_cpp/classes/style_box.hpp>
#include <godot_cpp/classes/text_line.hpp>
#include <godot_cpp/classes/text_server.hpp>
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/variant/utility_functions.hpp>

namespace godot {
    class TimeScrubber : public Range {
        GDCLASS(TimeScrubber, Range)

    private:
        LineEdit* line_edit = nullptr;

        bool dragging = false;
        double value_at_start = 0.0;
        Vector2 mouse_start_pos;

        Ref<TextLine> _configure_text_line(const String& p_text) const;
        void _on_line_edit_editing_toggled(const bool toggled_on);
        void _set_value(const double value);

    protected:
        static void _bind_methods();
        void _notification(int p_what);

    public:
        TimeScrubber();

        float scrub_speed = 1.0;
        enum TimeFormat {
            TIME_FORMAT_HHMMSS_MSS,
            TIME_FORMAT_MMSS,
        };
        TimeFormat time_format = TIME_FORMAT_HHMMSS_MSS;
        bool editable = true;
        TextServer::OverrunBehavior overrun_behavior = TextServer::OVERRUN_NO_TRIMMING;

        virtual void _gui_input(const Ref<InputEvent>& p_event) override;
        virtual Size2 _get_minimum_size() const override;
        LineEdit* get_line_edit();

        void set_scrub_speed(float p_speed);
        float get_scrub_speed() const;

        void set_time_format(TimeFormat p_time_format);
        int get_time_format() const;
        
        void set_editable(bool p_editable);
        bool get_editable() const;

        void set_overrun_behavior(TextServer::OverrunBehavior p_behavior);
        TextServer::OverrunBehavior get_overrun_behavior() const;
    };
}

VARIANT_ENUM_CAST(TimeScrubber::TimeFormat);

#endif // !TIME_SCRUBBER_H
#include "time_scrubber.h"

namespace godot {

	void TimeScrubber::_bind_methods() {
		BIND_ENUM_CONSTANT(TIME_FORMAT_HHMMSS_MSS);
		BIND_ENUM_CONSTANT(TIME_FORMAT_MMSS);

		ClassDB::bind_method(D_METHOD("_on_line_edit_editing_toggled"), &TimeScrubber::_on_line_edit_editing_toggled);

		ClassDB::bind_method(D_METHOD("get_line_edit"), &TimeScrubber::get_line_edit);

		ClassDB::bind_method(D_METHOD("set_scrub_speed", "speed"), &TimeScrubber::set_scrub_speed);
		ClassDB::bind_method(D_METHOD("get_scrub_speed"), &TimeScrubber::get_scrub_speed);

		ClassDB::bind_method(D_METHOD("set_time_format", "time_format"), &TimeScrubber::set_time_format);
		ClassDB::bind_method(D_METHOD("get_time_format"), &TimeScrubber::get_time_format);

		ClassDB::bind_method(D_METHOD("set_editable", "editable"), &TimeScrubber::set_editable);
		ClassDB::bind_method(D_METHOD("get_editable"), &TimeScrubber::get_editable);

		ClassDB::bind_method(D_METHOD("set_overrun_behavior", "overrun_behavior"), &TimeScrubber::set_overrun_behavior);
		ClassDB::bind_method(D_METHOD("get_overrun_behavior"), &TimeScrubber::get_overrun_behavior);

		ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "scrub_speed"), "set_scrub_speed", "get_scrub_speed");
		ADD_PROPERTY(PropertyInfo(Variant::INT, "time_format", PROPERTY_HINT_ENUM, "HHMMSS.mss,MMSS"), "set_time_format", "get_time_format");
		ADD_PROPERTY(PropertyInfo(Variant::BOOL, "editable"), "set_editable", "get_editable");
		ADD_PROPERTY(PropertyInfo(Variant::INT, "text_overrun_behavior", PROPERTY_HINT_ENUM, "No Trimming,Trim Char,Trim Word,Ellipsis"), "set_overrun_behavior", "get_overrun_behavior");
	}

	Ref<TextLine> TimeScrubber::_configure_text_line(const String& p_text) const {
		Ref<TextLine> text_line;
		text_line.instantiate();

		Ref<Font> font = get_theme_font("font", "LineEdit");
		if (font.is_null()) font = get_theme_font("font");
		int font_size = get_theme_constant("font_size", "LineEdit");
		if (font_size <= 0) font_size = 16;

		text_line->set_direction(TextServer::DIRECTION_LTR);
		text_line->set_horizontal_alignment(HORIZONTAL_ALIGNMENT_CENTER);

		float width = (overrun_behavior == TextServer::OVERRUN_NO_TRIMMING) ? -1 : get_size().x;
		text_line->set_width(width);

		text_line->add_string(p_text, font, font_size);
		return text_line;
	}

	void TimeScrubber::_on_line_edit_editing_toggled(const bool toggled_on) {
		if (toggled_on) return;
		String text = line_edit->get_text();
		if (time_format == TIME_FORMAT_HHMMSS_MSS) {
			_set_value(Utils::hms_to_seconds(text));
		}
		else {
			_set_value(Utils::ms_to_seconds(text));
		}
		line_edit->set_visible(false);
	}

	void TimeScrubber::_set_value(const double value) {
		set_value(value);
		if (overrun_behavior == TextServer::OVERRUN_NO_TRIMMING) {
			update_minimum_size();
		}
		queue_redraw();
	}

	TimeScrubber::TimeScrubber() {
		set_clip_contents(true);
		set_focus_mode(FOCUS_ALL);
		set_mouse_filter(MOUSE_FILTER_STOP);
		set_step(0.001);
		set_page(0.001);

		line_edit = memnew(LineEdit);
		line_edit->set_visible(false);
		line_edit->set_horizontal_alignment(HORIZONTAL_ALIGNMENT_CENTER);
		line_edit->set_h_size_flags(SIZE_EXPAND_FILL);
		line_edit->set_v_size_flags(SIZE_EXPAND_FILL);
		line_edit->set_anchors_preset(Control::PRESET_FULL_RECT);
		line_edit->set_offsets_preset(Control::PRESET_FULL_RECT);
		add_child(line_edit, false, INTERNAL_MODE_FRONT);

		line_edit->connect("editing_toggled", Callable(this, "_on_line_edit_editing_toggled"));
	}

	void TimeScrubber::_notification(int p_what) {
		switch (p_what) {
		case NOTIFICATION_DRAW: {
			if (!this) return;
			if (!line_edit) return;
			if (is_queued_for_deletion()) return;
			if (line_edit && line_edit->is_inside_tree() && line_edit->is_visible()) break;
			RID canvas_rid = get_canvas_item();
			Size2 current_size = get_size();
			String txt = (time_format == TIME_FORMAT_HHMMSS_MSS) ? Utils::seconds_to_hms(get_value()) : Utils::seconds_to_ms(get_value());
			Ref<TextLine> text_line = _configure_text_line(txt);
			if (text_line.is_null()) return;
			float text_h = text_line->get_size().y;
			Vector2 draw_pos(0, (current_size.y - text_h) / 2.0f);
			if (overrun_behavior == TextServer::OVERRUN_NO_TRIMMING) {
				float text_w = text_line->get_size().x;
				draw_pos.x = (current_size.x - text_w) / 2.0f;
			}
			text_line->draw(canvas_rid, draw_pos, editable ? get_theme_color("font_color", "LineEdit") : get_theme_color("font_uneditable_color", "LineEdit"));
		} break;
		}
	}

	void TimeScrubber::_gui_input(const Ref<InputEvent>& p_event) {
		if (editable) {
			Ref<InputEventMouseButton> mb = p_event;
			if (mb.is_valid() && mb->get_button_index() == MOUSE_BUTTON_LEFT) {
				if (mb->is_pressed()) {
					// Open LineEdit
					if (mb->is_double_click() || mb->is_command_or_control_pressed()) {
						line_edit->set_text(time_format == TIME_FORMAT_HHMMSS_MSS ? 
							Utils::seconds_to_hms(get_value()) : 
							Utils::seconds_to_ms(get_value()));
						line_edit->set_visible(true);
						line_edit->grab_focus();
						line_edit->select_all();
						queue_redraw();
					}
					else {
						// Start drag
						dragging = true;
						value_at_start = get_value();
						mouse_start_pos = mb->get_position();
						Input::get_singleton()->set_mouse_mode(Input::MOUSE_MODE_CAPTURED);
						queue_redraw();
					}
				}
				else {
					// Stop drag
					if (dragging) {
						dragging = false;
						Input::get_singleton()->set_mouse_mode(Input::MOUSE_MODE_VISIBLE);
						Input::get_singleton()->warp_mouse(get_global_position() + mouse_start_pos);
						queue_redraw();
					}
				}
			}
		}
		Ref<InputEventMouseMotion> mm = p_event;
		if (mm.is_valid() && dragging) {
			float diff = mm->get_relative().x;
			if (mm->is_shift_pressed() && mm->is_command_or_control_pressed()) {
				diff *= 0.001;
			}
			else if (mm->is_command_or_control_pressed()) {
				diff *= 0.01;
			}
			else if (mm->is_shift_pressed()) {
				diff *= 0.1;
			}
			double new_val = get_value() + diff * scrub_speed * (time_format == TIME_FORMAT_HHMMSS_MSS ? 1000.0 : 1.0) * get_step();
			_set_value(new_val);
		}
	}

	Size2 TimeScrubber::_get_minimum_size() const {
		String placeholder = (time_format == TIME_FORMAT_HHMMSS_MSS) ? "00:00:00.000" : "00:00";
		Ref<TextLine> tyl = _configure_text_line(placeholder);
		return tyl->get_size() + Size2(20, 10);
	}

	LineEdit* TimeScrubber::get_line_edit() {
		return line_edit;
	}

	void TimeScrubber::set_scrub_speed(float p_speed) {
		scrub_speed = p_speed;
	}

	float TimeScrubber::get_scrub_speed() const {
		return scrub_speed;
	}

	void TimeScrubber::set_time_format(TimeFormat p_time_format) {
		time_format = p_time_format;
		if (time_format == TIME_FORMAT_HHMMSS_MSS) {
			set_step(0.001);
			set_page(0.001);
		}
		else {
			set_step(1.0);
			set_page(1.0);
		}
		queue_redraw();
	}

	int TimeScrubber::get_time_format() const {
		return time_format;
	}

	void TimeScrubber::set_editable(bool p_editable) {
		editable = p_editable;
		queue_redraw();
	}

	bool TimeScrubber::get_editable() const {
		return editable;
	}

	void TimeScrubber::set_overrun_behavior(TextServer::OverrunBehavior p_behavior) {
		if (overrun_behavior == p_behavior) return;
		overrun_behavior = p_behavior;
		update_minimum_size();
		queue_redraw();
	}

	TextServer::OverrunBehavior TimeScrubber::get_overrun_behavior() const {
		return overrun_behavior;
	}
}

What I’ve Tried:

  1. Using callable_mp to connect signals:
line_edit->connect("editing_toggled", callable_mp(this, &TimeScrubber::_on_line_edit_editing_toggled));

Result: Crash on hot-reload or exit.

  1. Using Callable with string name:
line_edit->connect("editing_toggled", Callable(this, "_on_line_edit_editing_toggled"));

Result: Same crash on exit to project list.

Is this a known issue with CallableCustomExtension lifecycle management?
Are there any workarounds to prevent callable_mp from creating CallableCustomExtension objects?
Should GDExtension manually clean up all Callables before DLL unload?

I think it’s better to search for / take issues like this to the github repo, as most users will likely not be able to help you here, especially if it’s an engine issue.

Also I’m not entirely sure how it works with GDExtension, but wouldn’t the mismatch versions be a possible issue? Since in your post you mentioned that your godot version is 4.6, but godot-cpp version is 4.5