From 6306ce0f80dc5097312437053325e5544910d94a Mon Sep 17 00:00:00 2001 From: Stephen Boddy Date: Sun, 12 Jul 2026 21:51:56 +0100 Subject: [PATCH 1/2] Fix autosave timer self-unregister crash risk The periodic autosave timer called reset_timer() at the end of its own callback, which unregistered the timer that was still executing (itself). Blender frees the timer's internal registry entry on that manual unregister, then frees it again when the callback returns None - a double free that corrupts the heap and can crash Blender later, once the corrupted memory is reused. Reschedule by returning the next interval from the callback instead, which is the safe, documented way to repeat a bpy.app.timers callback. External reset_timer() calls (from SaveProject, LoadProject, AutosavePrompt) are unaffected since they run from a separate call stack (UI events), not from inside the timer. Found while investigating a segfault reported when cancelling the autosave recovery popup; not itself the cause of that crash (see the following commit), but the same reentrant-unregister pattern and a real, independent latent bug in the periodic reminder path. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/tool/autosave.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/bonsai/bonsai/tool/autosave.py b/src/bonsai/bonsai/tool/autosave.py index bf4a34690f..db55b12e05 100644 --- a/src/bonsai/bonsai/tool/autosave.py +++ b/src/bonsai/bonsai/tool/autosave.py @@ -96,9 +96,16 @@ class Autosave: if not cls.is_eligible(): return - def on_timer() -> None: + def on_timer() -> Union[float, None]: cls._on_timer_expired() - return None + # Reschedule by returning the next interval rather than calling + # reset_timer(), which would unregister this timer from within + # its own callback. Blender frees the timer's internal registry + # entry on that manual unregister, then frees it again when the + # callback returns - a double free that corrupts the heap and + # crashes Blender shortly after (e.g. when the prompt dialog + # spawned below is next interacted with). + return cls.get_interval_seconds() if cls.is_eligible() else None global _timer_callback _timer_callback = on_timer @@ -120,7 +127,6 @@ class Autosave: cls.perform_backup(bpy.context) except Exception as error: print(f"Bonsai: autosave backup failed: {error}") - cls.reset_timer() @classmethod def perform_backup(cls, context: bpy.types.Context) -> None: From d0eca6fa90b625ba9f867294e6b80d8a0e948f0c Mon Sep 17 00:00:00 2001 From: Stephen Boddy Date: Sun, 12 Jul 2026 21:52:09 +0100 Subject: [PATCH 2/2] 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):