Private
Public Access
0
0

fix(gui): correct __getattr__ to not silently return None for missing ui_ attrs

PR1 follow-up (the actual IM_ASSERT root cause fix).

The IM_ASSERT in 'MainDockSpace' was triggered by the
render_approve_script_modal function (gui_2.py:4895) calling
imgui.checkbox with a None value for app.ui_approve_modal_preview.

The chain of bugs:

1. AppController.__getattr__ returned None for ANY ui_ attribute
   (line 1237-1238). This was intended as a safety net for ui_*
   flags defined in __init__ but it was too généreux: it returned
   None for ui_ attrs that were NEVER set.

2. The pattern in render_approve_script_modal:
      if not hasattr(app, 'ui_approve_modal_preview'):
          app.ui_approve_modal_preview = False
      _, app.ui_approve_modal_preview = imgui.checkbox(..., app.ui_approve_modal_preview)
   relied on hasattr() returning False for unset attrs to trigger
   the initialization. But the App.__setattr__ checks
   hasattr(self.controller, name) to decide where to route
   assignments. The controller's __getattr__ returned None for
   ui_approve_modal_preview, so hasattr() returned True. The
   App.__setattr__ routed the assignment to the controller.
   The controller's __getattr__ then returned None on read,
   silently dropping the False value.

3. The next line called imgui.checkbox with None, which raised
   a TypeError. The TypeError propagated out of
   render_approve_script_modal without closing the modal,
   leaving the ImGui scope stack unbalanced. The unbalanced
   scope triggered IM_ASSERT(Missing End()) on the next frame.

Fix: AppController.__getattr__ now only returns None for an
EXPLICIT allowlist of ui_ attrs that are defined in __init__.
For any other missing attribute (including the case
'hasattr() should return False'), it raises AttributeError.

The App.__getattr__ was also fixed (per the test) to check
hasattr(controller, name) before delegating. This is defense in
depth in case other __getattr__ patterns are added.

Test verification (TDD red → green):
- 1/1 test_app_getattr_hasattr_bug PASSES (verifies hasattr
  returns False for unset attrs via App.__getattr__)
- 1/1 test_app_controller_getattr_ui_bug PASSES (verifies hasattr
  returns False for unset ui_ attrs on controller)

Live verification:
- 4 sims + test_live_workflow + 2 markdown tests: 7/7 PASS in 83.15s
- Previously failed at 200s+ with 'cannot schedule new futures after
  shutdown' / 121s with 'GUI is degraded before test starts'
- Now passes cleanly. The IM_ASSERT no longer fires.

13/13 related unit tests pass (app_controller_* + app_run_* +
app_getattr_*). No regressions in 51/51 io_pool/warmup/sigint/etc.
unit tests.
This commit is contained in:
2026-06-08 23:45:25 -04:00
parent 999fdea467
commit bcdc26d0bd
5 changed files with 207 additions and 5 deletions
+24 -4
View File
@@ -1231,10 +1231,30 @@ class AppController:
"__reduce__", "__reduce_ex__", "__getnewargs__",
):
raise AttributeError(name)
# Only return a default for the UI flag pattern (set in init_state).
# Other lazy attributes (e.g. persona_manager set in
# _load_active_project) should raise so hasattr() returns False.
if name.startswith("ui_") or name == "rag_engine":
# Only return a default for the explicit UI flag list (those defined
# in __init__ as defaults). The previous implementation returned None for
# ANY ui_ attribute, which broke hasattr() and the App.__setattr__
# routing logic. For any other missing attribute, raise AttributeError
# so hasattr() returns False correctly.
_UI_FLAG_DEFAULTS = {
"ui_active_tool_preset", "ui_active_bias_profile",
"ui_ai_input", "ui_disc_new_name_input", "ui_disc_new_role_input",
"ui_epic_input", "ui_new_track_name", "ui_new_track_desc",
"ui_new_track_type", "ui_project_conductor_dir",
"ui_conductor_setup_summary", "ui_last_script_text",
"ui_last_script_output", "ui_new_ticket_id", "ui_new_ticket_desc",
"ui_new_ticket_target", "ui_new_ticket_deps", "ui_output_dir",
"ui_files_base_dir", "ui_shots_base_dir", "ui_project_git_dir",
"ui_project_system_prompt", "ui_project_execution_mode",
"ui_gemini_cli_path", "ui_word_wrap", "ui_auto_add_history",
"ui_separate_message_panel", "ui_separate_response_panel",
"ui_separate_tool_calls_panel", "ui_global_system_prompt",
"ui_base_system_prompt", "ui_use_default_base_prompt",
"ui_project_context_marker", "ui_agent_tools", "ui_manual_approve",
"ui_disc_truncate_pairs", "ui_auto_scroll_comms",
"ui_auto_scroll_tool_calls", "ui_focus_agent", "ui_active_persona",
}
if name in _UI_FLAG_DEFAULTS or name == "rag_engine":
return None
raise AttributeError(name)
+3 -1
View File
@@ -688,7 +688,9 @@ class App:
def __getattr__(self, name: str) -> Any:
if name == 'controller':
raise AttributeError(name)
return getattr(self.controller, name)
if hasattr(self, 'controller') and hasattr(self.controller, name):
return getattr(self.controller, name)
raise AttributeError(name)
def __setattr__(self, name: str, value: Any) -> None:
if name != 'controller' and hasattr(self, 'controller') and hasattr(self.controller, name):