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.