From 15c366bfe0143294d2f5573a151234884f2b70d9 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 20:05:47 +0000 Subject: [PATCH 1/2] fix(Godot): resolve crash at shutdown due to dangling signal callbacks Godot was crashing with EXC_BAD_ACCESS during headless shutdown because several scripts connected to Autoload singleton signals or global node signals (like Viewport or SceneTree root) but never disconnected them. This led to use-after-free errors when the engine attempted to call back into partially or fully destroyed script instances during singleton teardown. Changes: - Added `_exit_tree()` overrides to 22 scripts to explicitly disconnect from Autoloads (EventBus, EntityLifecycle, BurdenManager, etc.) and globals (SafeZoneManager, InputRouter, Viewport). - Implemented `is_connected()` checks in `_exit_tree()` to ensure robust disconnection logic. - Converted anonymous lambda connections to named methods in `VersionLabel` to facilitate reliable disconnection. - Verified clean shutdown with exit code 0 via `godot --headless --quit`. This fix unblocks CI pipelines and headless test suite execution. Co-authored-by: niyazmft <9331133+niyazmft@users.noreply.github.com> --- scripts/autoload/ambient_narrator.gd | 6 +++++ scripts/autoload/burden_caption_driver.gd | 6 +++++ scripts/autoload/burden_event_coordinator.gd | 8 +++++++ scripts/autoload/burden_shader_manager.gd | 5 ++++ scripts/autoload/level_up_manager.gd | 9 +++++++ scripts/autoload/secret_room_trigger.gd | 11 +++++++++ scripts/autoload/toast_manager.gd | 7 ++++++ scripts/combat/turn_manager.gd | 5 ++++ scripts/core/combat_room.gd | 6 +++++ scripts/entities/apparition_renderer.gd | 8 +++++++ scripts/entities/burden_visuals.gd | 6 +++++ scripts/state_machine/run_manager.gd | 5 ++++ scripts/ui/combat_hud.gd | 25 ++++++++++++++++++++ scripts/ui/hotbar.gd | 10 ++++++++ scripts/ui/main_menu.gd | 7 ++++++ scripts/ui/pause_menu.gd | 7 ++++++ scripts/ui/remap_panel.gd | 5 ++++ scripts/ui/settings_panel.gd | 5 ++++ scripts/ui/turn_banner.gd | 9 +++++++ scripts/ui/ui_root.gd | 5 ++++ scripts/version_label.gd | 12 +++++++++- scripts/visual/entity_visual_proxy.gd | 4 ++++ 22 files changed, 170 insertions(+), 1 deletion(-) diff --git a/scripts/autoload/ambient_narrator.gd b/scripts/autoload/ambient_narrator.gd index 55c3296f..03a1badc 100644 --- a/scripts/autoload/ambient_narrator.gd +++ b/scripts/autoload/ambient_narrator.gd @@ -11,6 +11,12 @@ func _ready() -> void: _connect_signals() +func _exit_tree() -> void: + var eb: _EventBus = AutoloadHelper.event_bus() + if eb and eb.room_entered.is_connected(_on_room_entered): + eb.room_entered.disconnect(_on_room_entered) + + func _connect_signals() -> void: var eb: _EventBus = AutoloadHelper.event_bus() if eb: diff --git a/scripts/autoload/burden_caption_driver.gd b/scripts/autoload/burden_caption_driver.gd index ae8905b7..1a4c352f 100644 --- a/scripts/autoload/burden_caption_driver.gd +++ b/scripts/autoload/burden_caption_driver.gd @@ -23,6 +23,12 @@ func _ready() -> void: _print_debug("BurdenCaptionDriver ready") +func _exit_tree() -> void: + var am: _AudioMiddleware = AutoloadHelper.get_autoload("AudioMiddleware") as _AudioMiddleware + if am != null and am.is_connected("stem_event_detected", _on_stem_event): + am.stem_event_detected.disconnect(_on_stem_event) + + func _process(delta: float) -> void: if _cooldowns.is_empty(): return diff --git a/scripts/autoload/burden_event_coordinator.gd b/scripts/autoload/burden_event_coordinator.gd index f9d3cc5a..c25e5acf 100644 --- a/scripts/autoload/burden_event_coordinator.gd +++ b/scripts/autoload/burden_event_coordinator.gd @@ -14,6 +14,14 @@ func _ready() -> void: _print_debug("BurdenEventCoordinator ready") +func _exit_tree() -> void: + if BurdenManager: + if BurdenManager.is_connected("burden_event_triggered", _on_burden_event_triggered): + BurdenManager.disconnect("burden_event_triggered", _on_burden_event_triggered) + if BurdenManager.is_connected("burden_active_changed", _on_burden_active_changed): + BurdenManager.disconnect("burden_active_changed", _on_burden_active_changed) + + # ── Internal ──────────────────────────────────────────────────────────────── diff --git a/scripts/autoload/burden_shader_manager.gd b/scripts/autoload/burden_shader_manager.gd index a83f3fd1..672deec4 100644 --- a/scripts/autoload/burden_shader_manager.gd +++ b/scripts/autoload/burden_shader_manager.gd @@ -22,6 +22,11 @@ func _ready() -> void: call_deferred("_on_burden_active_changed", BurdenManager.burden_active) +func _exit_tree() -> void: + if BurdenManager and BurdenManager.is_connected("burden_active_changed", _on_burden_active_changed): + BurdenManager.disconnect("burden_active_changed", _on_burden_active_changed) + + func _allocate_static_pool() -> void: # Pre-allocate the 512 KB VRAM scratch buffer at boot (Static pool allocation) var img: Image = Image.create( diff --git a/scripts/autoload/level_up_manager.gd b/scripts/autoload/level_up_manager.gd index 28356093..b17254bc 100644 --- a/scripts/autoload/level_up_manager.gd +++ b/scripts/autoload/level_up_manager.gd @@ -30,6 +30,15 @@ func _connect_signals() -> void: event_bus.biome_echo_triggered.connect(_on_biome_echo_triggered) +func _exit_tree() -> void: + var event_bus: _EventBus = AutoloadHelper.event_bus() + if event_bus: + if event_bus.spare_or_execute.is_connected(_on_spare_or_execute): + event_bus.spare_or_execute.disconnect(_on_spare_or_execute) + if event_bus.biome_echo_triggered.is_connected(_on_biome_echo_triggered): + event_bus.biome_echo_triggered.disconnect(_on_biome_echo_triggered) + + func grant_experience(entity: Entity, amount: int, reason: String = "") -> void: if amount <= 0: return diff --git a/scripts/autoload/secret_room_trigger.gd b/scripts/autoload/secret_room_trigger.gd index b69ec266..0018cde1 100644 --- a/scripts/autoload/secret_room_trigger.gd +++ b/scripts/autoload/secret_room_trigger.gd @@ -16,6 +16,17 @@ func _ready() -> void: _connect_signals() +func _exit_tree() -> void: + var eb: _EventBus = AutoloadHelper.event_bus() + if eb: + if eb.room_entered.is_connected(_on_room_entered): + eb.room_entered.disconnect(_on_room_entered) + if eb.combat_resolved_signal.is_connected(_on_combat_resolved): + eb.combat_resolved_signal.disconnect(_on_combat_resolved) + if eb.spare_or_execute.is_connected(_on_spare_or_execute): + eb.spare_or_execute.disconnect(_on_spare_or_execute) + + func _connect_signals() -> void: var eb: _EventBus = AutoloadHelper.event_bus() if eb: diff --git a/scripts/autoload/toast_manager.gd b/scripts/autoload/toast_manager.gd index 6c5195c7..16ba2ab6 100644 --- a/scripts/autoload/toast_manager.gd +++ b/scripts/autoload/toast_manager.gd @@ -18,6 +18,13 @@ func _ready() -> void: LayerManager.modal_closed.connect(_on_modal_closed) +func _exit_tree() -> void: + if LayerManager.modal_opened.is_connected(_on_modal_opened): + LayerManager.modal_opened.disconnect(_on_modal_opened) + if LayerManager.modal_closed.is_connected(_on_modal_closed): + LayerManager.modal_closed.disconnect(_on_modal_closed) + + func show_toast(text_key: String, type: ToastType = ToastType.T_01) -> void: _queue.append({"key": text_key, "type": type}) _process_queue() diff --git a/scripts/combat/turn_manager.gd b/scripts/combat/turn_manager.gd index f266e69e..ac61dfdc 100644 --- a/scripts/combat/turn_manager.gd +++ b/scripts/combat/turn_manager.gd @@ -34,6 +34,11 @@ func _ready() -> void: _lifecycle.connect("entity_state_changed", _on_entity_state_changed) +func _exit_tree() -> void: + if _lifecycle and _lifecycle.is_connected("entity_state_changed", _on_entity_state_changed): + _lifecycle.disconnect("entity_state_changed", _on_entity_state_changed) + + # ── Public API ────────────────────────────────────────────────────── func start_combat(p_player: Node2D, p_enemies: Array[Node2D]) -> void: _player = p_player diff --git a/scripts/core/combat_room.gd b/scripts/core/combat_room.gd index 12f19f51..bdef331e 100644 --- a/scripts/core/combat_room.gd +++ b/scripts/core/combat_room.gd @@ -40,6 +40,12 @@ func _ready() -> void: _setup_camera() +func _exit_tree() -> void: + var run_manager := AutoloadHelper.run_manager() + if run_manager and run_manager.room_entered.is_connected(_on_room_entered): + run_manager.room_entered.disconnect(_on_room_entered) + + func _on_room_entered(_room_index: int, room_data: Dictionary) -> void: # Clear existing entities if any for child in entity_container.get_children(): diff --git a/scripts/entities/apparition_renderer.gd b/scripts/entities/apparition_renderer.gd index f502f51b..66212533 100644 --- a/scripts/entities/apparition_renderer.gd +++ b/scripts/entities/apparition_renderer.gd @@ -95,6 +95,14 @@ func _ready() -> void: _refresh_stack() +func _exit_tree() -> void: + if BurdenManager: + if BurdenManager.kill_history_changed.is_connected(_on_kill_history_changed): + BurdenManager.kill_history_changed.disconnect(_on_kill_history_changed) + if BurdenManager.burden_active_changed.is_connected(_on_burden_active_changed): + BurdenManager.burden_active_changed.disconnect(_on_burden_active_changed) + + func _process(delta: float) -> void: if state_machine: state_machine.update(delta) diff --git a/scripts/entities/burden_visuals.gd b/scripts/entities/burden_visuals.gd index 6fd5dacc..80694c39 100644 --- a/scripts/entities/burden_visuals.gd +++ b/scripts/entities/burden_visuals.gd @@ -10,6 +10,12 @@ func _ready() -> void: BurdenManager.burden_event_triggered.connect(on_entity_burden_triggered) +func _exit_tree() -> void: + if BurdenManager: + if BurdenManager.burden_event_triggered.is_connected(on_entity_burden_triggered): + BurdenManager.burden_event_triggered.disconnect(on_entity_burden_triggered) + + func on_entity_burden_triggered(_result: RefCounted) -> void: # Wire router reset or other logic here if needed. # For DON-223, this is a requested hook. diff --git a/scripts/state_machine/run_manager.gd b/scripts/state_machine/run_manager.gd index e916ae69..f41ac163 100644 --- a/scripts/state_machine/run_manager.gd +++ b/scripts/state_machine/run_manager.gd @@ -63,6 +63,11 @@ func _ready() -> void: _entity_lifecycle.connect("mwt_reached", _on_mwt_reached) +func _exit_tree() -> void: + if _entity_lifecycle and _entity_lifecycle.is_connected("mwt_reached", _on_mwt_reached): + _entity_lifecycle.disconnect("mwt_reached", _on_mwt_reached) + + func _on_mwt_reached(_moral_flag: int, _remaining: int) -> void: # When MWT is reached, the Burden Event system takes over. _moral_eval_waiting_burden = true diff --git a/scripts/ui/combat_hud.gd b/scripts/ui/combat_hud.gd index 13630f40..d36c78c2 100644 --- a/scripts/ui/combat_hud.gd +++ b/scripts/ui/combat_hud.gd @@ -42,6 +42,31 @@ func _ready() -> void: end_turn_button.pressed.connect(_on_end_turn_pressed) +func _exit_tree() -> void: + if SafeZoneManager.safe_area_changed.is_connected(_on_safe_area_changed): + SafeZoneManager.safe_area_changed.disconnect(_on_safe_area_changed) + if SafeZoneManager.aspect_ratio_changed.is_connected(_on_aspect_ratio_changed): + SafeZoneManager.aspect_ratio_changed.disconnect(_on_aspect_ratio_changed) + + if _player_entity: + if _player_entity.hp_changed.is_connected(_on_hp_changed): + _player_entity.hp_changed.disconnect(_on_hp_changed) + if _player_entity.ap_changed.is_connected(_on_ap_changed): + _player_entity.ap_changed.disconnect(_on_ap_changed) + + if _turn_manager: + if _turn_manager.turn_started.is_connected(_on_turn_started): + _turn_manager.turn_started.disconnect(_on_turn_started) + if _turn_manager.round_started.is_connected(_on_round_started): + _turn_manager.round_started.disconnect(_on_round_started) + + if _combat_input: + if _combat_input.targeting_started.is_connected(_on_targeting_started): + _combat_input.targeting_started.disconnect(_on_targeting_started) + if _combat_input.attack_executed.is_connected(_on_attack_executed): + _combat_input.attack_executed.disconnect(_on_attack_executed) + + func setup(player_entity: Entity, turn_manager: TurnManager, combat_input: CombatInput) -> void: _player_entity = player_entity _turn_manager = turn_manager diff --git a/scripts/ui/hotbar.gd b/scripts/ui/hotbar.gd index f6aa25ee..c4fe6c06 100644 --- a/scripts/ui/hotbar.gd +++ b/scripts/ui/hotbar.gd @@ -23,6 +23,16 @@ func _ready() -> void: _refresh_hotbar() +func _exit_tree() -> void: + if get_tree() and get_tree().root: + if get_tree().root.size_changed.is_connected(_on_viewport_resized): + get_tree().root.size_changed.disconnect(_on_viewport_resized) + + var eb: _EventBus = AutoloadHelper.event_bus() + if eb and eb.run_started.is_connected(_on_run_started): + eb.run_started.disconnect(_on_run_started) + + func _on_run_started(_seed: int) -> void: _refresh_hotbar() diff --git a/scripts/ui/main_menu.gd b/scripts/ui/main_menu.gd index 04dbf457..8460eeb0 100644 --- a/scripts/ui/main_menu.gd +++ b/scripts/ui/main_menu.gd @@ -36,6 +36,13 @@ func _on_safe_area_changed(_rect: Rect2) -> void: _apply_safe_area() +func _exit_tree() -> void: + if InputRouter.device_changed.is_connected(_on_device_changed): + InputRouter.device_changed.disconnect(_on_device_changed) + if SafeZoneManager.safe_area_changed.is_connected(_on_safe_area_changed): + SafeZoneManager.safe_area_changed.disconnect(_on_safe_area_changed) + + func _apply_safe_area() -> void: var margins: Dictionary = SafeZoneManager.get_safe_margins() as Dictionary margin_container.add_theme_constant_override("margin_left", int(margins.get("left", 0))) diff --git a/scripts/ui/pause_menu.gd b/scripts/ui/pause_menu.gd index 36805881..c8cfedbc 100644 --- a/scripts/ui/pause_menu.gd +++ b/scripts/ui/pause_menu.gd @@ -28,6 +28,13 @@ func _ready() -> void: _apply_safe_area() +func _exit_tree() -> void: + if InputRouter.device_changed.is_connected(_on_device_changed): + InputRouter.device_changed.disconnect(_on_device_changed) + if SafeZoneManager.safe_area_changed.is_connected(_on_safe_area_changed): + SafeZoneManager.safe_area_changed.disconnect(_on_safe_area_changed) + + func _on_safe_area_changed(_rect: Rect2) -> void: _apply_safe_area() diff --git a/scripts/ui/remap_panel.gd b/scripts/ui/remap_panel.gd index 12222ec6..5eff4d87 100644 --- a/scripts/ui/remap_panel.gd +++ b/scripts/ui/remap_panel.gd @@ -19,6 +19,11 @@ func _ready() -> void: call_deferred("_focus_first_item") +func _exit_tree() -> void: + if InputRouter.has_signal("device_changed") and InputRouter.device_changed.is_connected(_on_device_changed): + InputRouter.device_changed.disconnect(_on_device_changed) + + func _focus_first_item() -> void: if not action_list: return diff --git a/scripts/ui/settings_panel.gd b/scripts/ui/settings_panel.gd index e129aec9..e1b9a717 100644 --- a/scripts/ui/settings_panel.gd +++ b/scripts/ui/settings_panel.gd @@ -114,6 +114,11 @@ func _load_ui_from_settings() -> void: _remap_panel.call("refresh") +func _exit_tree() -> void: + if SafeZoneManager.safe_area_changed.is_connected(_on_safe_area_changed): + SafeZoneManager.safe_area_changed.disconnect(_on_safe_area_changed) + + func _connect_signals() -> void: _master_slider.value_changed.connect(_on_audio_changed.bind("master_volume")) _music_slider.value_changed.connect(_on_audio_changed.bind("music_volume")) diff --git a/scripts/ui/turn_banner.gd b/scripts/ui/turn_banner.gd index 82d5e329..04faf3c5 100644 --- a/scripts/ui/turn_banner.gd +++ b/scripts/ui/turn_banner.gd @@ -28,6 +28,15 @@ func _ready() -> void: eb.turn_started.connect(_on_turn_started) +func _exit_tree() -> void: + var eb: _EventBus = AutoloadHelper.event_bus() + if eb: + if eb.room_entered.is_connected(_on_combat_started): + eb.room_entered.disconnect(_on_combat_started) + if eb.turn_started.is_connected(_on_turn_started): + eb.turn_started.disconnect(_on_turn_started) + + func display_message(p_text: String, p_color: Color = Color.WHITE) -> void: turn_label.text = p_text turn_label.modulate = p_color diff --git a/scripts/ui/ui_root.gd b/scripts/ui/ui_root.gd index df8064cd..49d7bd5e 100644 --- a/scripts/ui/ui_root.gd +++ b/scripts/ui/ui_root.gd @@ -23,6 +23,11 @@ func _ready() -> void: _settings_panel.hide() +func _exit_tree() -> void: + if get_viewport() and get_viewport().size_changed.is_connected(_apply_safe_area): + get_viewport().size_changed.disconnect(_apply_safe_area) + + func _input(event: InputEvent) -> void: if event.is_action_pressed("ui_cancel"): if _settings_panel.visible: diff --git a/scripts/version_label.gd b/scripts/version_label.gd index 06c42f5a..083622e9 100644 --- a/scripts/version_label.gd +++ b/scripts/version_label.gd @@ -6,7 +6,17 @@ extends Label func _ready() -> void: text = "v0.1.0-sprint1" _apply_notch_offset() - SafeZoneManager.safe_area_changed.connect(func(_r: Rect2) -> void: _apply_notch_offset()) + if not SafeZoneManager.safe_area_changed.is_connected(_on_safe_area_changed): + SafeZoneManager.safe_area_changed.connect(_on_safe_area_changed) + + +func _exit_tree() -> void: + if SafeZoneManager.safe_area_changed.is_connected(_on_safe_area_changed): + SafeZoneManager.safe_area_changed.disconnect(_on_safe_area_changed) + + +func _on_safe_area_changed(_r: Rect2) -> void: + _apply_notch_offset() func _apply_notch_offset() -> void: diff --git a/scripts/visual/entity_visual_proxy.gd b/scripts/visual/entity_visual_proxy.gd index 635a4e1d..18997abf 100644 --- a/scripts/visual/entity_visual_proxy.gd +++ b/scripts/visual/entity_visual_proxy.gd @@ -49,6 +49,10 @@ func _ready() -> void: global_position = _target_position # Snap initially +func _exit_tree() -> void: + _disconnect_entity_signals() + + func _process(delta: float) -> void: if global_position.distance_to(_target_position) > 0.1: var weight: float = minf(delta * lerp_speed, 1.0) From a61527d7c7a23338093b28aebc61df20a2f477cf Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 20:12:23 +0000 Subject: [PATCH 2/2] fix(Godot): resolve crash at shutdown due to dangling signal callbacks Godot was crashing with EXC_BAD_ACCESS during headless shutdown because several scripts connected to Autoload singleton signals or global node signals (like Viewport or SceneTree root) but never disconnected them. This led to use-after-free errors when the engine attempted to call back into partially or fully destroyed script instances during singleton teardown. Changes: - Added `_exit_tree()` overrides to 22 scripts to explicitly disconnect from Autoloads (EventBus, EntityLifecycle, BurdenManager, etc.) and globals (SafeZoneManager, InputRouter, Viewport). - Implemented `is_connected()` checks in `_exit_tree()` to ensure robust disconnection logic. - Converted anonymous lambda connections to named methods in `VersionLabel` to facilitate reliable disconnection. - Fixed GDScript formatting issues in `scripts/ui/remap_panel.gd` and `scripts/autoload/burden_shader_manager.gd` to satisfy CI checks. - Verified clean shutdown with exit code 0 via `godot --headless --quit`. This fix unblocks CI pipelines and headless test suite execution. Co-authored-by: niyazmft <9331133+niyazmft@users.noreply.github.com> --- scripts/autoload/burden_shader_manager.gd | 5 ++++- scripts/ui/remap_panel.gd | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/autoload/burden_shader_manager.gd b/scripts/autoload/burden_shader_manager.gd index 672deec4..0d7201d3 100644 --- a/scripts/autoload/burden_shader_manager.gd +++ b/scripts/autoload/burden_shader_manager.gd @@ -23,7 +23,10 @@ func _ready() -> void: func _exit_tree() -> void: - if BurdenManager and BurdenManager.is_connected("burden_active_changed", _on_burden_active_changed): + if ( + BurdenManager + and BurdenManager.is_connected("burden_active_changed", _on_burden_active_changed) + ): BurdenManager.disconnect("burden_active_changed", _on_burden_active_changed) diff --git a/scripts/ui/remap_panel.gd b/scripts/ui/remap_panel.gd index 5eff4d87..d0f6b3be 100644 --- a/scripts/ui/remap_panel.gd +++ b/scripts/ui/remap_panel.gd @@ -20,7 +20,10 @@ func _ready() -> void: func _exit_tree() -> void: - if InputRouter.has_signal("device_changed") and InputRouter.device_changed.is_connected(_on_device_changed): + if ( + InputRouter.has_signal("device_changed") + and InputRouter.device_changed.is_connected(_on_device_changed) + ): InputRouter.device_changed.disconnect(_on_device_changed)