wrote a small signal debug utility for Godot 4 — had no idea half my connections were stale

349 views 10 replies

Running into a weird bug yesterday where a UI element was responding to a signal twice on every emission. Spent way too long chasing it before realizing I had the same signal connected in both the editor and in _ready(). Classic.

The problem is there's no quick way to audit what's actually wired up at runtime. Object.get_signal_connection_list() exists, but you have to query each signal by name — you can't just ask a node "what are all your active connections." So I wrote a small static utility that walks a node (optionally recursive) and prints everything:

class_name SignalDebug

static func debug_signals(node: Node, recursive: bool = false, depth: int = 0) -> void:
    var prefix := "  ".repeat(depth)
    var printed_node := false
    for sig in node.get_signal_list():
        var connections := node.get_signal_connection_list(sig.name)
        if connections.is_empty():
            continue
        if not printed_node:
            print("%s[%s] %s" % [prefix, node.get_class(), node.name])
            printed_node = true
        for conn in connections:
            var target := conn.callable.get_object()
            var method := conn.callable.get_method()
            var target_label := target.name if target is Node else target.get_class()
            print("%s  .%s → %s::%s" % [prefix, sig.name, target_label, method])
    if recursive:
        for child in node.get_children():
            debug_signals(child, true, depth + 1)

Drop it in a file with class_name SignalDebug and call it anywhere:

SignalDebug.debug_signals(get_tree().root, true)

Output ends up looking like:

[CanvasLayer] HUD
  .visibility_changed → GameManager::_on_hud_visibility_changed
[Button] StartButton
  .pressed → UIController::_on_start_pressed
  .pressed → AudioManager::_on_button_press   ← this one definitely shouldn't still be here

Immediately obvious when you've got duplicate connections or leftover references from old refactors. First time I ran it on my current project I found four stale connections I didn't know existed, including one pointing to a freed node that somehow hadn't triggered an error yet.

It's become a staple in my debug builds. Curious if anyone's taken this further — an actual in-editor panel, maybe filtering to only show connections to external nodes, or hooking into the remote debugger protocol? I keep meaning to turn it into a proper plugin but never get around to it.

this would've saved me two days last month. had a singleton connecting to a scene signal, scene reloading on every level transition, nobody cleaning up old connections. by level 5 there were 5 listeners firing on the same emission and the behavior was completely baffling. same event producing multiplicatively worse outcomes the longer you played. couldn't figure out why.

two days. detective board conspiracy red string

does your tool surface connections to already-freed objects, or only currently live ones?

the double connection issue hits specifically hard in godot because the inspector and _ready() both feel like equally valid places to wire signals, and they are, but if you switch approaches halfway through a feature without cleaning up the other side, you're just waiting for the bug to surface. i've started keeping a comment block at the top of any node class that has signals, just listing what's connected where and why. very low tech but it's caught two problems for me in the last month before they became actual bugs.

The editor + _ready() double-connection is the one that gets everyone eventually. Something I started doing after the third time: any signal connection in _ready() gets an is_connected() guard by default, even when I'm completely confident it's only connected once. Slightly paranoid, but it's caught this exact class of bug multiple times, and it means you can safely reload scenes without worrying about teardown order or stale connection counts.

the double-emission case is such a classic. worst one i had was a signal connected in the editor and in _ready(), and it only doubled up under a specific scene load order so reproducing it consistently took forever. your tool would've caught it immediately.

quick question: does it surface connections across the whole scene tree or just on the node you're actively inspecting? wondering if it handles the case where node A connects to a signal on node B and then node B gets freed first.

detective magnifying glass clue
Replying to VoidLark: The editor + _ready() double-connection is the one that gets everyone eventually...

yeah the filtering by object idea is the right call imo. i'd also add a way to suppress signals during specific frames — like a mute_until_next_frame() helper. super useful when you're debugging an emission loop and you want to confirm whether a signal is firing once or a dozen times per physics tick without just scrolling through a wall of output.

Replying to ObsidianVale: the double connection issue hits specifically hard in godot because the inspecto...

tbh the autoload approach for something like this is exactly right, you don't want to have to remember to attach a debug node to every scene during development. does yours survive scene reloads cleanly? that's always where mine falls apart — the references go stale and you get phantom entries in the log that point to freed objects.

ghost typing on keyboard

Replying to VoidLark: The editor + _ready() double-connection is the one that gets everyone eventually...

yeah the deferred connection flag tripped me up too. worth noting you also want to watch out for signals connected inside _ready() on nodes that get reparented — the connection survives but your debug utility might not catch it as 'active' depending on how you're checking get_signal_connection_list(). small edge case but annoying when it bites you.

Replying to ObsidianVale: the double connection issue hits specifically hard in godot because the inspecto...

nice, one question though — does it handle one-shot connections correctly? like if you connect with CONNECT_ONE_SHOT and the signal fires, does your utility update the list in real time or does it need a manual refresh? just wondering if i'd need to hook into the signal itself to track that.

Replying to VoidLark: The editor + _ready() double-connection is the one that gets everyone eventually...

this is exactly what i needed, thank you. i was doing something similar with a custom inspector plugin but the approach you showed here is way cleaner. one edge case i ran into: if the signal has default arguments that are objects, the debug overlay sometimes throws a type mismatch on reconnect. did you hit that at all?

Replying to ObsidianVale: the double connection issue hits specifically hard in godot because the inspecto...

+1 on the autoload approach, it makes it globally accessible without polluting individual node scripts. I'd also suggest storing the signal history in a ring buffer rather than a plain array if you're logging high-frequency signals like input events, otherwise memory creep becomes noticeable over long sessions.