Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix: Ensure class servers methods are loaded after hot reload #636

Merged
merged 1 commit into from
Mar 3, 2024

Conversation

kang-sw
Copy link
Contributor

@kang-sw kang-sw commented Mar 2, 2024

This pull request addresses issue #629.

Background

Based on a preliminary analysis, the following observations were made (note that these findings may not be entirely accurate):

  1. Upon the initial load of the DLL, gdext_on_level_init is invoked in the order of InitLevel::Servers followed by InitLevel::Scene.
  2. When the DLL is rebuilt, the editor detects the updated DLL and initiates a hot reload.
  3. Before hot reloading, all nodes created within the editor are removed (as verified by calling exit_tree on instances of classes registered through gdext).
  4. Upon loading the new DLL, gdext_on_level_init(InitLevel::Scene) is immediately called for the new DLL memory space, skipping the call to InitLevel::Servers.
  5. As a result, attempting to reference class_servers_api in the editor plugin after a hot reload leads to a panic due to the missed InitLevel::Servers call.

Workaround

The core issue appears to stem from upstream not invoking the initialization function after hot reloading. The ideal solution would involve upstream sequentially calling class initialization functions following a hot reload. However, as a temporary workaround, we ensure that the class servers API is loaded at least once by the library, even if InitLevel::Server is skipped.

Questions

  • Following a hot reload, the call to crate::auto_register_classes(sys::ClassApiLevel::Core) is omitted. Should a similar lazy loading approach be applied to InitLevel::Core?
  • gdext_on_level_init seems to be guaranteed to be called on the main thread. To prevent re-entry, is it acceptable to reduce overhead by using unsafe static mut or AtomicBool instead of std::sync::Mutex?

Given my limited understanding of the internal architecture, I am uncertain about the potential side effects this may entail. Your review and feedback would be greatly appreciated.

@kang-sw kang-sw marked this pull request as ready for review March 2, 2024 04:28
@Bromeon Bromeon added bug c: ffi Low-level components and interaction with GDExtension API labels Mar 2, 2024
@GodotRust
Copy link

API docs are being generated and will be shortly available at: https://godot-rust.github.io/docs/gdext/pr-636

@Bromeon
Copy link
Member

Bromeon commented Mar 2, 2024

Thanks a lot! Yesterday I tried your MRE from #629 and could reproduce the crash.

I added logging to ExtensionLibrary::on_[de]init():

#[gdextension]
unsafe impl ExtensionLibrary for MyExtension {
    fn on_level_init(level: InitLevel) {
        println!("on_level_init: {:?}", level);
    }

    fn on_level_deinit(level: InitLevel) {
        println!("on_level_deinit: {:?}", level);
    }
 }

With that, I observed the following:

<initial load>
Initialize godot-rust (API v4.2.stable.official, runtime v4.3.dev.custom_build)
on_level_init: Servers
on_level_init: Scene
on_level_init: Editor
<run editor>
on_level_deinit: Editor
on_level_deinit: Scene
on_level_deinit: Servers
on_level_deinit: Core
<hot reload>
Initialize godot-rust (API v4.2.stable.official, runtime v4.3.dev.custom_build)
on_level_init: Scene
on_level_init: Editor

So as you say, there is a discrepancy that Servers/Core levels are unloaded, but not loaded again.

I also looked at the "minimum level": it determines from which level Godot has to reload. I tried to lower the level to Servers:

#[gdextension]
unsafe impl ExtensionLibrary for MyExtension {
    fn min_level() -> InitLevel {
        InitLevel::Servers
    }

    ...
 }

And while it no longer crashes, it simply doesn't reload, not even the print statements are executed anymore. The C header documents:

	/* Minimum initialization level required.
	 * If Core or Servers, the extension needs editor or game restart to take effect */
	GDExtensionInitializationLevel minimum_initialization_level;

so hot reload will not work with any level below Scene.

In conclusion, I think it is a Godot bug that all levels are unloaded but only some reloaded. Would you like to report it upstream in the Godot issues and link to #629? Let me know if I can help somehow.

I'll write more about a workaround in a separate response.

Copy link
Member

@Bromeon Bromeon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Workaround

I agree it makes sense to solve this problem already, so people can use Godot 4.2 properly.


gdext_on_level_init seems to be guaranteed to be called on the main thread. To prevent re-entry, is it acceptable to reduce overhead by using unsafe static mut or AtomicBool instead of std::sync::Mutex?

You can definitely use AtomicBool, Mutex<bool> has no advantages here.

I would not go for anything unsafe, as this is not a hot path (hot reload doesn't happen 1000s of times per frame), and any other logic involved takes orders of magnitude more time.


Following a hot reload, the call to crate::auto_register_classes(sys::ClassApiLevel::Core) is omitted. Should a similar lazy loading approach be applied to InitLevel::Core?

Yes, that would be great! There is currently no class table loaded in Core, but we might add other logic and it would be cool if we didn't have to fix it again.

I wonder if we should change the logic, so we have

static LEVEL_SERVERS_CORE_LOADED: AtomicBool = AtomicBool::new(false);

fn try_load_level<E: ExtensionLibrary>(level: InitLevel) {
    // Workaround for https://github.com/godot-rust/gdext/issues/629:
    // When using editor plugins, Godot may unload all levels but only reload from Scene upward.
    // Manually run initialization of lower levels.
    if level == InitLevel::Scene {
        let lower_levels_loaded = LEVEL_SERVERS_CORE_LOADED.swap(true);
        if !lower_levels_loaded {
            try_load_level::<E>(InitLevel::Servers);
            try_load_level::<E>(InitLevel::Core);
        }
    }

    // Regular initialization routine (including user callback):
    gdext_on_level_init(level);
    E::on_level_init(level);
}

And unset the bool on unload again. Not sure if individual AtomicBool instances are easier, but I think this problem is specific enough to handle it in the above way.

godot-core/src/init/mod.rs Outdated Show resolved Hide resolved
@kang-sw
Copy link
Contributor Author

kang-sw commented Mar 2, 2024

Committed bcc82de.

  • Remove Mutex usage, replaced into AtomicU8
  • Since it's workaround for upstream behavior, add one more bulletproof logic, instead of binding cores/servers into single boolean flag:
    • Split global initialization levels into 3 steps => nothing loaded, core loaded, servers loaded.
    • When Scene level is initialized, it checks for Servers. For Servers, it checks for Core. (recursively)
    • Assume Editor level never be initialized before Scene level is initialized

godot-core/src/init/mod.rs Outdated Show resolved Hide resolved
godot-core/src/init/mod.rs Outdated Show resolved Hide resolved
Copy link
Member

@Bromeon Bromeon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

About the logic -- do we gain anything by having 3 states (core, servers, scene) instead of 2 (core+servers, scene)? Because it seems like this bug explicitly skips both core+server states, not just either of them.

So we should maybe make the code embrace this specific case rather than general "forgot to init" scenarios? That would also signal us if there's another initialization bug, and allow to report it to Godot.

Then we could also use AtomicBool -- see my previous code snippet 🤔

@kang-sw
Copy link
Contributor Author

kang-sw commented Mar 2, 2024

So we should maybe make the code embrace this specific case rather than general "forgot to init" scenarios? That would also signal us if there's another initialization bug, and allow to report it to Godot.

Ah, now I got that point. Agree that this is a bug should be resolved from upstream. I'll update the code.

@kang-sw kang-sw force-pushed the fix/629-hot-reload-servers-api branch from bcc82de to 08951b6 Compare March 2, 2024 16:07
Copy link
Member

@Bromeon Bromeon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Looks mostly good, one comment left 🙂
Could you also squash the commits?

godot-core/src/init/mod.rs Outdated Show resolved Hide resolved
Class servers methods are guaranteed to be loaded after hot reloading.

[wip]
@kang-sw kang-sw force-pushed the fix/629-hot-reload-servers-api branch from 08951b6 to 30c0f23 Compare March 3, 2024 01:17
Copy link
Member

@Bromeon Bromeon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot for your contribution! ❤️

@Bromeon Bromeon added this pull request to the merge queue Mar 3, 2024
Merged via the queue into godot-rust:master with commit d2de819 Mar 3, 2024
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug c: ffi Low-level components and interaction with GDExtension API
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants