Quick answer: Disconnect signals in _ExitTree before the node is freed, or guard handlers with GodotObject.IsInstanceValid(this) for cases where disconnection order isn’t guaranteed.
An enemy connects to the player’s HealthChanged signal so it can adjust aggression. The player dies, the player node is freed, then the enemy receives the next HealthChanged emission and crashes with ObjectDisposedException: Cannot access a disposed object. The enemy survived but its reference to a dead player wrapper became a landmine.
What Actually Goes Wrong
In Godot 4’s C# binding, each native Object has a matching managed wrapper. When you call QueueFree() the native side is destroyed; the C# wrapper is marked disposed shortly after. Signal connections on other emitters that point at methods on the disposed wrapper remain in place — the emitter doesn’t know its listener died. Next emission throws because the runtime tries to invoke a method on a disposed object.
Fix 1: Disconnect in _ExitTree
using Godot;
public partial class Enemy : Node
{
[Export] public Node Player;
public override void _Ready()
{
Player.Connect("HealthChanged", Callable.From<int>(OnPlayerHealthChanged));
}
public override void _ExitTree()
{
if (GodotObject.IsInstanceValid(Player))
Player.Disconnect("HealthChanged", Callable.From<int>(OnPlayerHealthChanged));
}
void OnPlayerHealthChanged(int newHealth) { /* ... */ }
}
_ExitTree runs before the wrapper is disposed, so the disconnect succeeds. The IsInstanceValid guard handles the case where the player was freed first (e.g., level shutdown).
Fix 2: TreeExiting Event of the Emitter
Hook the emitter’s TreeExiting signal so when the player is about to leave, listeners self-disconnect:
Player.TreeExiting += () =>
{
if (Player.IsConnected("HealthChanged", Callable.From<int>(OnPlayerHealthChanged)))
Player.Disconnect("HealthChanged", Callable.From<int>(OnPlayerHealthChanged));
};
This puts disconnect responsibility on the side of the relationship that’s being torn down first, which is sometimes harder to predict than the listener’s lifetime.
Fix 3: Guard the Handler with IsInstanceValid
If you can’t guarantee disconnect order — for example, in a scene with many cross-references — defensively check inside the handler:
void OnPlayerHealthChanged(int newHealth)
{
if (!GodotObject.IsInstanceValid(this)) return;
// safe to use member state
}
The check is cheap (one bool comparison) and short-circuits before any disposed-state access.
Fix 4: Use C# Events Instead of Godot Signals
For purely-C# logic between nodes, the typed C# event pattern avoids the marshalling entirely:
public class Player : Node
{
public event System.Action<int> HealthChanged;
}
// in Enemy:
Player.HealthChanged += OnPlayerHealthChanged;
public override void _ExitTree()
{
Player.HealthChanged -= OnPlayerHealthChanged;
}
C# events still leak references if you forget to -=, but they don’t produce ObjectDisposedException — instead a missed unsubscribe keeps the listener alive longer than expected.
Verifying
Run with --verbose in a terminal and reproduce the freeing sequence. Before fix: stack trace includes ObjectDisposedException. After fix: clean log, signal stops firing on the dead listener.
“Godot signals don’t auto-cleanup on listener death. Disconnect in _ExitTree, or guard with IsInstanceValid.”
Use a project-wide convention: every Connect call has a matching Disconnect in _ExitTree. Future cross-scene bugs disappear.