Wrapping callback soup with async/await

Godot Version

v4.5.1.stable.mono.arch_linux

Question

I’m trying to avoid all of the callback hell that so many languages have had before C# introduced async/await to the world (thank you Anders). I’m still learning Godot but I’m a very experienced C# developer (15+ years), and right away when following some of the tutorials out there I noticed that there was a lots of callback soup going on.

So I’m trying to avoid that by using TaskCompletionSources and returning tasks that I can manually resolve with a single level callback. I’ll give an example below:

	public Task Shake(Node2D? obj, float strength, float durationSeconds = 0.8f)
	{
		if (obj == null)
		{
			return Task.CompletedTask;
		}

		TaskCompletionSource tcs = new();

		var originalPosition = obj.Position;
		var shakeCount = 10;
		var tween = obj.CreateTween();

		// Do some stuff with the tween to shake the object here

		tween.Finished += () =>
		{
			obj.Position = originalPosition;
			tcs.SetResult(); // Once the tween is done, complete the task so we can pick back up in calling code
		};

		return tcs.Task; // Give back the task so the calling code can await it
	}

With this, I should, in theory, be able to call this with an await and have whatever code comes next happen afterward. For example..

public async Task TakeDamage(int damage)
{
	Sprite2D.Material = WhiteSpriteMaterial; // Turn the sprite white

	Stats.TakeDamage(damage); // Apply the damage
	await Shaker.Instance.Shake(this, 16, 0.15f); // Shake, and wait for it to complete..

	Sprite2D.Material = null; // Then put the sprite back to normal
}

However I’m running into the wonderful issues of trying to call Godot APIs from other threads. My understanding of this code, though, is that none of it should run on any other threads - I’m not calling .ConfigureAwait(false) so I should end up back on the same thread and context, the code in the actual Shake call gets called synchronously, immediately, and then when the callback inside there (tween.Finished) happens, it should get called on the main thread as well. Clearly I’m missing something though. (Everything works but my debug window lights up with lots of messages)

Has anyone had any success in wrapping the callback soup APIs with async/await APIs?

Some of the messages from debug window:

E 0:01:41:970   NativeCalls.cs:495 @ void Godot.NativeCalls.godot_icall_1_56(nint, nint, nint): Caller thread can't call this function in this node (/root/Battle/Sprite2D). Use call_deferred() or call_thread_group() instead.
  <C++ Error>   Condition "!is_accessible_from_caller_thread()" is true.
  <C++ Source>  scene/main/canvas_item.cpp:1170 @ set_material()
  <Stack Trace> NativeCalls.cs:495 @ void Godot.NativeCalls.godot_icall_1_56(nint, nint, nint)
                CanvasItem.cs:1239 @ void Godot.CanvasItem.SetMaterial(Godot.Material)
                CanvasItem.cs:348 @ void Godot.CanvasItem.set_Material(Godot.Material)

Interacting with Godot’s scene tree is not thread safe. So when doing that, you shouldn’t use any C# facilities that can run your code in a thread. Try using deferred calls as the error message suggests but I’m not sure this will be an option in all cases.

Right, I understand that. I ended up figuring it out, I had a Task.WhenAll which was ending up on another thread. I ended up fixing this with two things - a custom SynchronizationContext that runs the awaited tasks/actions on the main thread:

public class GodotSynchronizationContext : SynchronizationContext
{
    private readonly ConcurrentQueue<(SendOrPostCallback, object)> _queue = new();

    public override void Post(SendOrPostCallback d, object state)
    {
        // Queue up the continuation for execution on the main thread
        _queue.Enqueue((d, state));
    }

    public void Update()
    {
        while (_queue.TryDequeue(out var item))
            item.Item1(item.Item2);
    }
}
...
public partial class GodotSynchronizationContextNode : Node
{
    private static GodotSynchronizationContext _context = null!;

    public override void _Ready()
    {
        _context = new GodotSynchronizationContext();
        SynchronizationContext.SetSynchronizationContext(_context);
    }

    public override void _Process(double delta)
    {
        _context?.Update();
    }
}

And then doubling down on the Task.WhenAll to force capturing the current context:

public override async Task Execute(IEnumerable<Node> targets)
{
    var tasks = targets.Select(t => {
        return t switch {
            null => Task.CompletedTask,
            Enemy enemy => enemy.TakeDamage(Amount),
            Player player => player.TakeDamage(Amount)
        }
    });
    await Task.WhenAll(tasks).ConfigureAwait(true); // <- This was the culprit before
    SoundPlayer.Effects.Play(Sound);
}

So now I can have really nice code that reads linearly and as I step through it it just does those things and never leaves the main thread:

await MoveToAsync(end, 0.4f);
await damageEffect.Execute(targets);
await Task.Delay(350);
await damageEffect.Execute(targets);
await Task.Delay(250);
await MoveToAsync(start, 0.4f);