Quick answer: Wrap the work in Callable.From(() => DoWork(arg)) and pass that to CallDeferred. Or capture the SynchronizationContext on the main thread and Post to it from your worker. Bare CallDeferred with parameters across threads marshals awkwardly.
Worker thread finishes a download, calls node.CallDeferred(MethodName.UpdateUI, data). Nothing happens. Marshalling complex parameters across the thread boundary fails silently in C#.
The Symptom
Async download/computation completes; UI doesn’t update. CallDeferred returns; the deferred method never runs. No exception in the editor console.
The Fix Patterns
Pattern 1: Callable.From with closure.
using Godot;
using System.Threading.Tasks;
public partial class Loader : Node
{
public Label statusLabel;
public async Task DownloadAndShow()
{
var data = await Task.Run(DownloadBlocking);
Callable.From(() => statusLabel.Text = data).CallDeferred();
}
private string DownloadBlocking() { /* HTTP work */ return "OK"; }
}
The lambda captures data by closure. Callable.From wraps it. CallDeferred queues the wrapper for the next main-thread tick. Cleaner than passing arbitrary parameters through the variant marshal.
Pattern 2: SynchronizationContext.
private readonly SynchronizationContext _mainCtx = SynchronizationContext.Current;
private void FromWorker(string result)
{
_mainCtx.Post(_ => statusLabel.Text = result, null);
}
Capture the SynchronizationContext on the main thread (Current), use Post from any thread.
Why Bare CallDeferred Fails Silently
CallDeferred(StringName method, params Variant[]) marshals each Variant. Some C# types convert cleanly, others (custom classes, Tasks, complex generics) don’t. The conversion fails; CallDeferred returns; nothing runs. No exception because the failure happens during marshalling.
Closures avoid this by passing only references the C# layer can hold; the actual call happens entirely in C# when the deferred dispatch fires.
Verifying
Print on entry to the main-thread method. Worker thread should trigger the print after a small delay. If the print never fires, your marshal is failing — switch to Callable.From or SynchronizationContext.
“Callable.From for closures. SynchronizationContext for full control. The main thread runs the work.”
Related Issues
For Godot C# disposed wrapper, see disposed wrapper. For C# signal listener missing, see signal listener.
Closure into Callable. Main thread runs.