Quick answer: Capture SynchronizationContext.Current on the main thread; Post back to it after a Task.Run. await ToSignal(...) is already main-thread-safe; mixing in plain Task.Run requires explicit marshalling.

You await Task.Run for an HTTP call. Continuation reads node properties. Crash — the continuation ran on a thread pool thread that can’t touch nodes.

The Symptom

Crash or assertion when accessing a Godot Node from after-await code. Specifically “Cannot call X on a non-main thread.”

The Fix

using System.Threading;
using System.Threading.Tasks;

public partial class Loader : Node
{
    private SynchronizationContext _main;

    public override void _Ready()
    {
        _main = SynchronizationContext.Current;
    }

    public async Task Load()
    {
        var data = await Task.Run(DownloadBlocking);

        _main.Post(_ => {
            // Safe: this lambda runs on the main thread
            statusLabel.Text = data;
        }, null);
    }
}

Capture the main-thread context in _Ready. After background work, Post the UI update to it.

ToSignal

Awaiting Godot signals is already main-thread-safe:

await ToSignal(GetTree().CreateTimer(1.0), "timeout");
// continues on main thread
GetNode<Label>("%Status").Text = "Done";

Don’t Use ConfigureAwait(false)

In Godot you usually want to stay on (or return to) the main thread. ConfigureAwait(false) on a Godot-internal await would let it resume anywhere, which is rarely what you want.

Verifying

Add System.Threading.Thread.CurrentThread.ManagedThreadId prints before and after await. The post-await ID should match the main thread ID. If different, you’re on a worker.

“Capture SynchronizationContext. Post back. ToSignal stays main.”

Related Issues

For C# CallDeferred, see CallDeferred. For C# disposed wrapper, see disposed wrapper.

Main context. Post back. Nodes touched safely.