Quick answer: ToSignal(node, signalName) never completes if the source is freed before firing. Add a timeout via SceneTree.CreateTimer().Timeout race or check IsInstanceValid before continuing the await chain.
Here is how to fix Godot 4 C# async/await on signals that hang forever when the source disappears. The SignalAwaiter has no built-in cancellation; you provide it via a timer race.
The Symptom
You await ToSignal(player, “FinishedAttack”) inside an enemy AI. Player is freed mid-attack. Await never returns.
What Causes This
Source freed. No signal fires; awaiter sits forever.
No cancellation primitive. SignalAwaiter does not respect CancellationToken.
The Fix
Step 1: Race against a timeout timer.
public async Task<bool> WaitForSignalOrTimeout(GodotObject src, string sig, double timeoutSec)
{
Task signal = src.ToSignal(src, sig);
Task timer = ToSignal(GetTree().CreateTimer(timeoutSec), Timer.SignalName.Timeout);
Task winner = await Task.WhenAny(signal, timer);
return winner == signal;
}
WhenAny returns whichever completes first.
Step 2: Validate before continuing.
await WaitForSignalOrTimeout(player, "FinishedAttack", 5.0);
if (!IsInstanceValid(this) || !IsInstanceValid(player))
return;
Step 3: For repeated awaits, build a helper.
public async Task WaitFrames(int count)
{
for (int i = 0; i < count; i++)
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
}
Idiomatic frame-based await for animations.
Step 4: Avoid awaiting on non-Node objects. ToSignal on Resources or freed objects is unstable. Stick to Node sources.
Step 5: For long-running work, use Task.Run carefully. Task.Run leaves the main thread; returning to update Godot nodes requires await on a SceneTree-bound task.
“Race ToSignal vs Timer.Timeout. Validate before resuming. Frame-await helpers for animation flow.”
Related Issues
For C# disposed errors, see GodotObject Disposed. For C# signal disconnect, see Signal Disconnect.
Timer race for timeout. IsInstanceValid checks. Frame-await helpers. No hangs.