From d0eca6fa90b625ba9f867294e6b80d8a0e948f0c Mon Sep 17 00:00:00 2001 From: Stephen Boddy Date: Sun, 12 Jul 2026 21:52:09 +0100 Subject: [PATCH] Fix segfault closing autosave recovery dialog Reported: Blender segfaults when clicking Cancel on the "newer autosave found" recovery popup shown by LoadProject at startup. Root cause: LoadProject.execute()/invoke() triggered the recovery popup via bpy.ops.bim.load_autosaved_recovery_popup("INVOKE_DEFAULT", ...) and returned that call's result ({'RUNNING_MODAL'}) as their own return value, without LoadProject itself ever calling modal_handler_add(). Blender's window manager takes a RUNNING_MODAL return as a promise the operator registered its own modal handler; since it hadn't, the WM's operator bookkeeping was left corrupted - silently, since this is heap/state corruption rather than an immediate crash. It only surfaced later, when the real modal operator (the popup) closed and the WM reconciled its modal stack, which lines up with the crash occurring specifically on dialog close regardless of which button was pressed. check_autosave_recovery() now returns a plain bool and fires the popup fire-and-forget; LoadProject reports its own honest {"FINISHED"}. Also hardened, as defense in depth: LoadAutosavedRecoveryPopup's execute()/cancel() call back into bim.load_project(...), which (with should_start_fresh_session) calls wm.read_homefile() and tears down the window manager/screens. Doing that synchronously from inside this popup's own execute()/cancel() - itself invoked from deep inside Blender's modal handling for the popup's button click - risks the same class of use-after-free as the timer bug fixed in the previous commit. The reload is now deferred by one timer tick so it runs after the popup's modal handling has fully unwound, and the deferred callback closes over plain values rather than `self`, since the operator instance may not survive past cancel()/execute() returning. This defer-only change was tried and tested first, on the (incorrect) assumption it was the root cause: it produced a byte-for-byte identical crash backtrace on retest, which is what pointed at the RUNNING_MODAL bug above as the actual cause - the defer change alone was insufficient because the corruption happens when the popup is first shown, not when it's closed. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/project/operator.py | 65 ++++++++++++++----- 1 file changed, 49 insertions(+), 16 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index 41e7d49c5d..032fa3e40c 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -1044,13 +1044,19 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): return tooltip - def check_autosave_recovery(self, context: bpy.types.Context) -> set["rna_enums.OperatorReturnItems"] | None: + def check_autosave_recovery(self, context: bpy.types.Context) -> bool: if self.skip_autosave_recovery: - return None + return False autosaved_filepath = tool.Autosave.get_newer_autosaved_path(self.get_filepath_abs()) if not autosaved_filepath: - return None - return bpy.ops.bim.load_autosaved_recovery_popup( + return False + # Fire-and-forget: don't propagate this popup's own RUNNING_MODAL + # return value up as if *this* operator were running modally too - + # we never call modal_handler_add() on ourselves, so the window + # manager would be left tracking a modal operator with no handler, + # corrupting its operator bookkeeping until it crashes later when + # the (real) popup modal handler is closed. + bpy.ops.bim.load_autosaved_recovery_popup( "INVOKE_DEFAULT", original_filepath=str(self.get_filepath_abs()), autosaved_filepath=autosaved_filepath, @@ -1059,10 +1065,11 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): should_start_fresh_session=self.should_start_fresh_session, import_without_ifc_data=self.import_without_ifc_data, ) + return True def execute(self, context): - if recovery := self.check_autosave_recovery(context): - return recovery + if self.check_autosave_recovery(context): + return {"FINISHED"} if ( tool.Blender.get_addon_preferences().save_metadata_blend_file @@ -1177,8 +1184,8 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): def invoke(self, context, event): if self.filepath: - if recovery := self.check_autosave_recovery(context): - return recovery + if self.check_autosave_recovery(context): + return {"FINISHED"} return self.execute(context) return ImportHelper.invoke(self, context, event) @@ -2182,8 +2189,8 @@ class LoadAutosavedRecoveryPopup(bpy.types.Operator): self, width=420, title="Recover Autosaved File", confirm_text="Yes" ) - def _load(self, filepath: str, skip_recent: bool) -> set["rna_enums.OperatorReturnItems"]: - return bpy.ops.bim.load_project( + def _load_kwargs(self, filepath: str, skip_recent: bool) -> dict: + return dict( filepath=filepath, skip_autosave_recovery=True, # Prevent infinite loop is_advanced=self.is_advanced, @@ -2193,16 +2200,42 @@ class LoadAutosavedRecoveryPopup(bpy.types.Operator): skip_recent=skip_recent, ) + @staticmethod + def _defer(callback) -> None: + def on_timer() -> None: + callback() + return None + + # bim.load_project (with should_start_fresh_session, our default) + # calls wm.read_homefile(), which tears down the window + # manager/screens/regions. Calling that synchronously from this + # dialog's execute()/cancel() - themselves invoked from deep inside + # Blender's modal handling for this popup's button click - frees + # data that the still-on-stack caller dereferences once we return, + # segfaulting Blender. Deferring by one timer tick runs the reload + # after the popup's own modal handling has fully unwound. The + # callback only closes over plain values (not `self`), since the + # operator instance itself may no longer be valid by the time the + # timer fires. + bpy.app.timers.register(on_timer, first_interval=0.0) + def execute(self, context): - result = self._load(self.autosaved_filepath, skip_recent=True) - # Re-point tracking at the original path so future saves write back - # to it, not "_autosaved.ifc". - tool.Ifc.set_path(self.original_filepath) - return result + kwargs = self._load_kwargs(self.autosaved_filepath, skip_recent=True) + original_filepath = self.original_filepath + + def load_and_repoint() -> None: + bpy.ops.bim.load_project(**kwargs) + # Re-point tracking at the original path so future saves write + # back to it, not "_autosaved.ifc". + tool.Ifc.set_path(original_filepath) + + self._defer(load_and_repoint) + return {"FINISHED"} def cancel(self, context): # Also reached via Escape or a click outside the dialog, not just Cancel. - self._load(self.original_filepath, skip_recent=False) + kwargs = self._load_kwargs(self.original_filepath, skip_recent=False) + self._defer(lambda: bpy.ops.bim.load_project(**kwargs)) class AutosavePrompt(bpy.types.Operator):