Skip to content

Introduce a generic rust async runtime integration #1228

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

Open
wants to merge 20 commits into
base: master
Choose a base branch
from

Conversation

AllenDang
Copy link

@AllenDang AllenDang commented Jul 7, 2025

Hi, all contributors of gdext

This PR introduces a new macro #[async_func] which could expose rust async function with return value to gdscript, and allow gdscript could await the execution, like below demonstrated.

Note: this PR only works with experimental-multithread feature is enabled.

#[derive(GodotClass)]
#[class(init, base=RefCounted)]
struct AsyncTestClass;

#[godot_api]
impl AsyncTestClass {
    #[async_func]
    async fn async_vector2_multiply(input: Vector2) -> Vector2 {
        // Use real tokio sleep to test tokio runtime integration
        tokio::time::sleep(Duration::from_millis(10)).await;
        Vector2::new(input.x * 2.0, input.y * 2.0)
    }
}
var result = await AsyncTestClass.async_vector2_multiply(Vector2(3.0, 4.0))

I raised a PR before, that at that time, I coupled tokio into gdext, after discuss with you guys, I decided to create a generic async runtime interface which could let user plug any async runtime like async-std or smol.

So it comes with it this time, user could plug async runtime like this

use godot::task::AsyncRuntimeIntegration;
use std::any::Any;

/// Minimal tokio runtime integration for gdext
///
/// Users need to implement both `create_runtime()` and `with_context()` for the integration.
pub struct TokioIntegration;

impl AsyncRuntimeIntegration for TokioIntegration {
    type Handle = tokio::runtime::Handle;

    fn create_runtime() -> Result<(Box<dyn Any + Send + Sync>, Self::Handle), String> {
        // Create a multi-threaded runtime with proper configuration
        let runtime = tokio::runtime::Builder::new_multi_thread()
            .enable_all()
            .thread_name("gdext-tokio")
            .worker_threads(2)
            .build()
            .map_err(|e| format!("Failed to create tokio runtime: {e}"))?;

        let handle = runtime.handle().clone();

        // Return both - gdext manages the lifecycle automatically
        Ok((Box::new(runtime), handle))
    }

    fn with_context<R>(handle: &Self::Handle, f: impl FnOnce() -> R) -> R {
        // Enter the tokio runtime context to make it current
        let _guard = handle.enter();
        f()
    }
}

And in lib.rs, do

// Import the async runtime integration
use async_runtimes::TokioIntegration;
use godot::task::register_runtime;

// ----------------------------------------------------------------------------------------------------------------------------------------------
// Entry point

#[gdextension(entry_symbol = itest_init)]
unsafe impl ExtensionLibrary for framework::IntegrationTests {
    fn on_level_init(level: InitLevel) {
        // Show which initialization level is being processed
        println!("📍 gdext initialization: level = {level:?}");

        // Register the async runtime early in the initialization process
        // This is the proper way to integrate async runtimes with gdext
        if level == InitLevel::Scene {
            register_runtime::<TokioIntegration>().expect("Failed to register tokio runtime");
        }
}

This PR is kind of huge, according to the complexity nature of async future management, but it comes out finally.

Hope contributors could spent sometime to help reviewing the code, and hopefully could merge this into gdext.

Thanks!

@Bromeon Bromeon added feature Adds functionality to the library c: async Everything related to async tasks/signals labels Jul 7, 2025
@Yarwin
Copy link
Contributor

Yarwin commented Jul 8, 2025

I scrolled through it and ehhhhhhhhhh. I haven't been looking to hard (5 mins total), but it looks like something written on stroke.

  • No prior discussion about design, API and whatnot.
  • All the docstrings are broken, have you even run ./check.sh?
  • At least one current test is broken.
  • Unnecessary, noisy printing and sending HTTP requests (SIC!) in tests. You should NEVER let any LLM do something that you can't verify for yourself – and by extension, you should write all the tests by yourself.
  • Multiple REVOLUTIONARY TESTS 🚀 which are doing the same thing – async_func_registration, async_runtime_class_registration, async_func_signature_validation, and stuff such as test_async_infrastructure (returns true) which does nothing and is never called.
  • New dependencies added with no explanation what they are used for.
  • It is not gated behind feature
  • // *** Added: Prompts leftovers that haven't been cleared! Ultra DISRESPECTFUL 💀 💀 💀 ***
  • It messes with few very brittle things (experimental-threads), introducing insane overhead
  • Static functions avoid complexity of instance state what the hell does it even mean.
  • …I'm not sure how one should call async_func method from inside the gdext (Rust-to-Rust). It could be an interesting angle for PR, but should be first consulted with people who use async, checking various codebases etc.

All of it can be done 100% in user space, with macro and #[godot_api(secondary)]. Author (?) even admits so:

    fn on_level_init(level: InitLevel) {
        // Show which initialization level is being processed
        println!("📍 gdext initialization: level = {level:?}");

        // Register the async runtime early in the initialization process
        // This is the proper way to integrate async runtimes with gdext
        if level == InitLevel::Scene {
            register_runtime::<TokioIntegration>().expect("Failed to register tokio runtime");
        }
    }

In general – I don't see why it would be a part of the core and it doesn't live up to standards in the core. Additionally limiting it only to static functions only is silly – like, what if I have something that implements Send+Sync on my struct and I want to use it with async? In such cases (which is, I argue, most of the cases) this feature is next to useless.

@Yarwin Yarwin closed this Jul 8, 2025
@AllenDang
Copy link
Author

@Yarwin Well written! Did you enjoy venting your anger? As someone who is contributing to gdext with my own ideas, I'm honestly shocked. What exactly made you so angry? You completely dismissed this PR as worthless—how exactly did you come to the conclusion that this PR has no value at all?

I clearly stated at the beginning that my intention was simply to expose Tokio-based async functions to GDScript, allowing the use of GDScript's await syntax. It's as straightforward as that.

Even though currently only static methods are supported, this already meets our practical needs. Our game project frequently interacts with servers via HTTP requests, and this PR allows us to write this logic in Rust. It works well for us, and it's certainly not meaningless or useless as you described.

I'm currently working on supporting instance methods as well, and I've already completed testing. This will further enhance async support.

If the PR quality isn't good enough, I'm open to criticism and willing to improve it.
If there's a better way to implement this PR, I'm eager to learn and adopt that better approach.

But is it really appropriate to treat PR contributors with such an attitude?

@Bromeon Bromeon reopened this Jul 8, 2025
@Bromeon
Copy link
Member

Bromeon commented Jul 8, 2025

@AllenDang Thank you for your contribution, and I apologize for the heated response here.

The use of AI-generated code can make it look like no effort went into the PR. However, our responsibility as maintainers is to provide constructive feedback, so that things can be improved. In particular, it was me who suggested to come up with a runtime-agnostic async integration, so "why should it be part of core" is on me, not the author.

I'll have a deeper look today evening, let's please keep a nice tone.
Maybe @TitanNano, who is more knowledgeable than me in async, can also give this a look.

@AllenDang
Copy link
Author

@Bromeon Deep thank, I'm keep improving it, and really open to any criticism and willing to improve it.

@TitanNano
Copy link
Contributor

Maybe @TitanNano, who is more knowledgeable than me in async, can also give this a look.

Yes, I will try to make some time to take a look at this PR.

@GodotRust
Copy link

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

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.

So, I had a high-level look at the code.
Here's some feedback 🙂

  1. The diff is currently at +1,851 -221 LoC.

    This is too much code to review at once, especially for a feature of this complexity. I know async runtimes are complex by their nature, but I wonder if we can start with a minimal foundation and add more features over time. Maybe parts can be offloaded to concrete runtimes like tokio (outside godot-rust), maybe some parts we don't need at all. Or maybe there are lightweight crates (no tokio, syn etc.) that could support us here? We can then consider feature-gating this.

  2. It's a bit unclear how everything works together. You show some examples that highlight the AsyncRuntimeIntegration trait, which is nice. But the code adds a lot of other public symbols. While they are individually documented, I'm missing the architecture connecting them (and justifying their existence). Maybe related to 1, some can be omitted in a first version?

  3. Regarding CI, you can test locally by running check.sh, see also book. This script isn't perfect -- if you have a fork of the repo on GitHub, you can also enable GitHub Actions and run the whole suite there (maybe separate branch -- and update this one once ready). That would speed up review time, as you don't have to wait for us manually approving CI.

  4. Do we need thread-local storage? Can we not start by only allowing async on the main thread? Or how does Godot interact with async on other threads? A disadvantage is also that the whole runtime needs to be re-instantiated for each thread (with CPU + memory implications) -- something that's not obvious to the user.

  5. @TitanNano already implemented async functionality based on the "Godot runtime". This is a very nice and lightweight option for people who want to reuse Godot's idle frame time to run async functionality. It currently supports signals, and may or may not be extended. But the PR seems to overwrite all existing functionality in a breaking way, and expect an external async runtime instead. I believe it should be possible to stay with the Godot runtime, even at limited functionality.

  6. There are some code style rules in the book. Not everything is specified, it's usually best to look at existing code. Some parts:

    • Comment line length 120-145 characters.
    • Full sentences for comments, capitalized first word and ending in period.
    • ...

I hope that helps! We can also gladly discuss the approach a bit more before jumping too much into implementation. Especially ideas regarding a simpler foundation would be very welcome 😊

TLDR: massively simplify (much smaller diff, minimal API) + reuse existing ASYNC_RUNTIME 👍

Comment on lines 165 to 190
/// Errors that can occur during async runtime operations
#[derive(Debug, Clone)]
pub enum AsyncRuntimeError {
/// Runtime has been deinitialized (during engine shutdown)
RuntimeDeinitialized,
/// Task was canceled while being polled
TaskCanceled { task_id: u64 },
/// Task panicked during polling
TaskPanicked { task_id: u64, message: String },
/// Task slot is in an invalid state
InvalidTaskState {
task_id: u64,
expected_state: String,
},
/// No async runtime has been registered
NoRuntimeRegistered,
/// Task spawning failed
TaskSpawningFailed { reason: String },
/// Signal emission failed
SignalEmissionFailed { task_id: u64, reason: String },
/// Thread safety violation
ThreadSafetyViolation {
expected_thread: ThreadId,
actual_thread: ThreadId,
},
}
Copy link
Member

Choose a reason for hiding this comment

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

This is a very detailed error API. I would probably keep this private in the beginning -- and wrapped by a public-facing struct:

pub struct AsyncRuntimeError {
     inner: AsyncInnerError, // your enum
}

Then all the traits (Display etc.) can appear on the public-facing type. This reduces API complexity quite a bit. If people need to differentiate those programmatically, we can always gradually expose the details, but then we start simple.

Comment on lines 352 to 355
// Check if runtime is registered
if !is_runtime_registered() {
panic!("No async runtime has been registered. Call gdext::task::register_runtime() before using async functions.");
}
Copy link
Member

Choose a reason for hiding this comment

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

This is a breaking change.

So far, could use the "Godot runtime" as a default. That one supports some features (signal awaiting) but not all (async fn).

Do you think it's possible to still provide that as a default, and later let the user explicitly select which one to use?

Comment on lines 867 to 219
enum FutureSlotState<T> {
/// Slot is currently empty.
Empty,
/// Slot was previously occupied but the future has been canceled or the slot reused.
Gone,
/// Slot contains a pending future.
Pending(T),
/// Slot contains a future which is currently being polled.
Polling,
}
Copy link
Member

Choose a reason for hiding this comment

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

If we keep supporting the Godot runtime, we can probably not just remove these 2 states, can we?

Comment on lines +907 to +914
/// Separated concerns for better architecture
///
/// Task limits and backpressure configuration
#[derive(Debug, Clone)]
pub struct TaskLimits {
/// Maximum number of concurrent tasks allowed
pub max_concurrent_tasks: usize,
}
Copy link
Member

Choose a reason for hiding this comment

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

Is task management something that needs to be done in godot-rust?

Comment on lines +26 to +52
#[async_func]
async fn async_vector2_multiply(input: Vector2) -> Vector2 {
// Use real tokio sleep to test tokio runtime integration
time::sleep(Duration::from_millis(10)).await;
Vector2::new(input.x * 2.0, input.y * 2.0)
}

#[async_func]
async fn async_compute_sum(a: i32, b: i32) -> i32 {
// Use real tokio sleep to test tokio runtime integration
time::sleep(Duration::from_millis(12)).await;
a + b
}

#[async_func]
async fn async_get_magic_number() -> i32 {
// Test with a short tokio sleep
time::sleep(Duration::from_millis(15)).await;
42
}

#[async_func]
async fn async_get_message() -> StringName {
// Test async with string return
time::sleep(Duration::from_millis(20)).await;
StringName::from("async message")
}
Copy link
Member

Choose a reason for hiding this comment

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

These tests do the same thing. The sum is enough and the other 3 can be removed, without losing coverage.

@TitanNano
Copy link
Contributor

I agree with @Bromeon s last comment and would highly recommend following a three-step plan like this:

  1. One PR to introduce a GdAsyncValue type that emits the finished result when the contained future is complete. This type could be constructed like:

    GdAsyncValue::new(async { ... });

    and would use the current Godot-based runtime to spawn a new task.

    Internally it would probably behave something like this:

    // this is skipping over many details and only serves as pseudocode
    fn new(...) -> Self {
       let inst = Gd::from_object(Self { ... });
       
       godot::task::spawn(async {
           let result = future.await;
           inst.signals().finished.emit(result);
       });
       inst
    }
  2. Second PR that allows creating GdAsyncValue with an external runtime (like tokio).

  3. Consider if we need a proc-macro for better ergonomics. If we decide the macro is desired, a third PR that adds the macro.

@KenAKAFrosty
Copy link

Maybe parts can be offloaded to concrete runtimes like tokio (outside godot-rust), maybe some parts we don't need at all. Or maybe there are lightweight crates (no tokio, syn etc.) that could support us here? We can then consider feature-gating this.

Just noting here as someone who needs to support web builds as a first-class citizen, I highly support and encourage this choice. Standing on those giants' shoulders is very helpful indeed, but I've yet to encounter one that properly plays nicely with the wasm-unknown-emscripten target. If WASM is supported at all, I've only seen wasm-unknown-unknown

@AllenDang
Copy link
Author

AllenDang commented Jul 9, 2025

截屏2025-07-09 15 47 16

@Bromeon here is the current design diagram, might be help to understand how the system works.

And thanks for you guys @TitanNano @KenAKAFrosty , I will carefully read your comments and rethink.

@Bromeon
Copy link
Member

Bromeon commented Jul 9, 2025

Thanks for the diagram! As mentioned earlier, I believe some parts could be simpler. Maybe look how the existing async runtime works and try to understand it. Then instead of replacing the entire thing, let's slowly and carefully extend it, where every extension has a clear justification and use case.

As mentioned by Yarwin, it might be worthwhile to feature-gate this behind async-external or so. This would then forbid using the async fn syntax if the feature is disabled, while retaining the current implementation for @TitanNano. Enabling the feature would not just magically change things (especially not break things), it can however introduce new APIs like AsyncRuntimeIntegration.

@AllenDang
Copy link
Author

@Bromeon Got it, I will work on it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
c: async Everything related to async tasks/signals feature Adds functionality to the library
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants