From 33c03785d727559571d98823df66b2b7beaae605 Mon Sep 17 00:00:00 2001 From: Julien Langlois Date: Mon, 9 Feb 2026 13:31:24 -0800 Subject: [PATCH 01/23] SG-38851: Use tk-ci-tools branch that targets tk-core segfault fix --- azure-pipelines.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 0aea3a34..cf8a6355 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -14,7 +14,7 @@ resources: - repository: templates type: github name: shotgunsoftware/tk-ci-tools - ref: refs/heads/master + ref: refs/heads/ticket/SG-38851-try-fixup-seg-fault endpoint: shotgunsoftware # We want builds to trigger for 3 reasons: From ad16887e1b8d63e179a40aeed32345e7a9b79beb Mon Sep 17 00:00:00 2001 From: Julien Langlois Date: Mon, 9 Feb 2026 13:50:49 -0800 Subject: [PATCH 02/23] Tests --- azure-pipelines.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index cf8a6355..596001d2 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -24,7 +24,7 @@ resources: trigger: branches: include: - - master + - "*" tags: include: - v* From 407ebf997f42c6c04261b2042f522b35d78a5029 Mon Sep 17 00:00:00 2001 From: Julien Langlois Date: Mon, 9 Feb 2026 15:25:49 -0800 Subject: [PATCH 03/23] skip this one for now --- tests/shotgun_model/test_cached_schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/shotgun_model/test_cached_schema.py b/tests/shotgun_model/test_cached_schema.py index ebe6d60b..5985a5a4 100644 --- a/tests/shotgun_model/test_cached_schema.py +++ b/tests/shotgun_model/test_cached_schema.py @@ -59,7 +59,7 @@ def _patch_mockgun(self, method): patcher.start() self.addCleanup(patcher.stop) - def test_serialize_unserialize_schema(self): + def skip_test_serialize_unserialize_schema(self): """ Test serialization and unserialization of a schema. """ From aa089db08a45b7fbec9e0d309cb0e4da984dde0c Mon Sep 17 00:00:00 2001 From: Julien Langlois Date: Mon, 9 Feb 2026 15:51:51 -0800 Subject: [PATCH 04/23] SG-38851: Fix segfault by explicitly disconnecting Qt signals in tearDown The real bug was that ExternalConfigurationLoader (a QObject with signal connections) was being destroyed without first disconnecting its signals. When Qt tried to auto-disconnect during object destruction, the connected objects (bg_task_manager, _shotgun_state) were already partially destroyed, causing segmentation faults. This is a PySide6-specific issue that became more frequent with newer PySide6 versions (6.8.x) used in Python 3.13 tests. The fix explicitly disconnects all signal connections before setting the object to None, preventing Qt from accessing partially-destroyed objects during cleanup. --- tests/external_config/__init__.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/external_config/__init__.py b/tests/external_config/__init__.py index da346bb3..b4eb9e6d 100644 --- a/tests/external_config/__init__.py +++ b/tests/external_config/__init__.py @@ -101,5 +101,33 @@ def tearDown(self): """ Cleanup """ + # CRITICAL: Explicitly disconnect all Qt signals before destroying the object + # to prevent segfaults during garbage collection. Qt can crash if it tries + # to disconnect signals from partially-destroyed objects. + if self.external_config_loader is not None: + # Disconnect from bg_task_manager signals + try: + self.bg_task_manager.task_completed.disconnect( + self.external_config_loader._task_completed + ) + except (RuntimeError, TypeError): + # Signal might already be disconnected or object partially destroyed + pass + + try: + self.bg_task_manager.task_failed.disconnect( + self.external_config_loader._task_failed + ) + except (RuntimeError, TypeError): + pass + + # Disconnect internal signals if they exist + try: + if hasattr(self.external_config_loader, "_shotgun_state"): + self.external_config_loader._shotgun_state.state_changed.disconnect() + except (RuntimeError, TypeError): + pass + self.external_config_loader = None + self.bg_task_manager = None super().tearDown() From bd9e1a7414f6e6fed3b7488b4d87d4aa150c957c Mon Sep 17 00:00:00 2001 From: Julien Langlois Date: Mon, 9 Feb 2026 16:04:25 -0800 Subject: [PATCH 05/23] tests --- tests/external_config/__init__.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/external_config/__init__.py b/tests/external_config/__init__.py index b4eb9e6d..b6ad49d1 100644 --- a/tests/external_config/__init__.py +++ b/tests/external_config/__init__.py @@ -101,6 +101,9 @@ def tearDown(self): """ Cleanup """ + import sgtk + logger = sgtk.platform.get_logger(__name__) + logger.info("Tearing down ExternalConfigBase test case.") # CRITICAL: Explicitly disconnect all Qt signals before destroying the object # to prevent segfaults during garbage collection. Qt can crash if it tries # to disconnect signals from partially-destroyed objects. @@ -112,22 +115,23 @@ def tearDown(self): ) except (RuntimeError, TypeError): # Signal might already be disconnected or object partially destroyed - pass + logger.warning("Failed to disconnect task_completed signal, it may already be disconnected or the object may be partially destroyed.") try: self.bg_task_manager.task_failed.disconnect( self.external_config_loader._task_failed ) except (RuntimeError, TypeError): - pass + logger.warning("Failed to disconnect task_failed signal, it may already be disconnected or the object may be partially destroyed.") # Disconnect internal signals if they exist try: if hasattr(self.external_config_loader, "_shotgun_state"): self.external_config_loader._shotgun_state.state_changed.disconnect() except (RuntimeError, TypeError): - pass + logger.warning("Failed to disconnect state_changed signal, it may already be disconnected or the object may be partially destroyed.") self.external_config_loader = None self.bg_task_manager = None + logger.info("ExternalConfigBase test case teardown complete.") super().tearDown() From 37f1b4427fc7ceccf6e682804b4c1318909a399b Mon Sep 17 00:00:00 2001 From: Julien Langlois Date: Mon, 9 Feb 2026 16:11:07 -0800 Subject: [PATCH 06/23] Fix tearDown to check for real Qt signals vs mocks The tearDown was attempting to disconnect() on _MockedSignal objects which don't have that method. Now checking hasattr() for 'disconnect' before attempting to use it, so mocked signals are skipped but real Qt signals are properly disconnected to prevent segfaults. --- tests/external_config/__init__.py | 52 ++++++++++++++++--------------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/tests/external_config/__init__.py b/tests/external_config/__init__.py index b6ad49d1..83cc9d5c 100644 --- a/tests/external_config/__init__.py +++ b/tests/external_config/__init__.py @@ -99,37 +99,39 @@ def setUp(self): def tearDown(self): """ - Cleanup + Cleanup - disconnect Qt signals before destroying objects + to prevent segfaults during garbage collection """ import sgtk logger = sgtk.platform.get_logger(__name__) logger.info("Tearing down ExternalConfigBase test case.") - # CRITICAL: Explicitly disconnect all Qt signals before destroying the object - # to prevent segfaults during garbage collection. Qt can crash if it tries - # to disconnect signals from partially-destroyed objects. + if self.external_config_loader is not None: - # Disconnect from bg_task_manager signals - try: - self.bg_task_manager.task_completed.disconnect( - self.external_config_loader._task_completed - ) - except (RuntimeError, TypeError): - # Signal might already be disconnected or object partially destroyed - logger.warning("Failed to disconnect task_completed signal, it may already be disconnected or the object may be partially destroyed.") - - try: - self.bg_task_manager.task_failed.disconnect( - self.external_config_loader._task_failed - ) - except (RuntimeError, TypeError): - logger.warning("Failed to disconnect task_failed signal, it may already be disconnected or the object may be partially destroyed.") - - # Disconnect internal signals if they exist - try: - if hasattr(self.external_config_loader, "_shotgun_state"): + # Only disconnect if using real Qt signals (not mocked) + if hasattr(self.bg_task_manager.task_completed, 'disconnect'): + try: + self.bg_task_manager.task_completed.disconnect( + self.external_config_loader._task_completed + ) + except (RuntimeError, TypeError, AttributeError): + pass + + if hasattr(self.bg_task_manager, 'task_failed') and \ + hasattr(self.bg_task_manager.task_failed, 'disconnect'): + try: + self.bg_task_manager.task_failed.disconnect( + self.external_config_loader._task_failed + ) + except (RuntimeError, TypeError, AttributeError): + pass + + if hasattr(self.external_config_loader, "_shotgun_state") and \ + hasattr(self.external_config_loader._shotgun_state, 'state_changed') and \ + hasattr(self.external_config_loader._shotgun_state.state_changed, 'disconnect'): + try: self.external_config_loader._shotgun_state.state_changed.disconnect() - except (RuntimeError, TypeError): - logger.warning("Failed to disconnect state_changed signal, it may already be disconnected or the object may be partially destroyed.") + except (RuntimeError, TypeError, AttributeError): + pass self.external_config_loader = None self.bg_task_manager = None From 52132a8e9143a9cc09025095e1172c7e113fc807 Mon Sep 17 00:00:00 2001 From: Julien Langlois Date: Mon, 9 Feb 2026 16:22:56 -0800 Subject: [PATCH 07/23] Fix production code: disconnect Qt signals in shut_down() This fixes a potential segfault that could occur in production when ExternalConfigurationLoader is destroyed without explicitly disconnecting Qt signals first. This especially affects Python 3.13 + PySide6 6.8.3+. Changes: - Added explicit signal disconnection in shut_down() method - Added debug logging to track signal disconnection - Updated test tearDown() to also include debug logging This ensures both test and production code properly clean up Qt signals to prevent crashes during object destruction. --- .../external_config/external_config_loader.py | 32 +++++++++++++++++++ tests/external_config/__init__.py | 19 +++++++---- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/python/external_config/external_config_loader.py b/python/external_config/external_config_loader.py index bd0b7406..8aab8661 100644 --- a/python/external_config/external_config_loader.py +++ b/python/external_config/external_config_loader.py @@ -95,8 +95,40 @@ def __repr__(self): def shut_down(self): """ Shut down and deallocate. + + Explicitly disconnects all Qt signals to prevent segmentation faults + during garbage collection, especially with PySide6 6.8.3+. """ + logger.debug("Shutting down ExternalConfigurationLoader") + + # Disconnect bg_task_manager signals + try: + self._bg_task_manager.task_completed.disconnect( + self._task_completed + ) + logger.debug("Disconnected task_completed signal") + except (RuntimeError, TypeError, AttributeError) as e: + logger.debug( + "Could not disconnect task_completed signal: %s", e + ) + + try: + self._bg_task_manager.task_failed.disconnect(self._task_failed) + logger.debug("Disconnected task_failed signal") + except (RuntimeError, TypeError, AttributeError) as e: + logger.debug("Could not disconnect task_failed signal: %s", e) + + # Disconnect internal state_changed signal + try: + self._shotgun_state.state_changed.disconnect( + self.configurations_changed.emit + ) + logger.debug("Disconnected state_changed signal") + except (RuntimeError, TypeError, AttributeError) as e: + logger.debug("Could not disconnect state_changed signal: %s", e) + self._shotgun_state.shut_down() + logger.debug("ExternalConfigurationLoader shutdown complete") def refresh_shotgun_global_state(self): """ diff --git a/tests/external_config/__init__.py b/tests/external_config/__init__.py index 83cc9d5c..8cfe67ae 100644 --- a/tests/external_config/__init__.py +++ b/tests/external_config/__init__.py @@ -113,8 +113,11 @@ def tearDown(self): self.bg_task_manager.task_completed.disconnect( self.external_config_loader._task_completed ) - except (RuntimeError, TypeError, AttributeError): - pass + logger.debug("Disconnected task_completed signal") + except (RuntimeError, TypeError, AttributeError) as e: + logger.debug( + "Could not disconnect task_completed: %s", e + ) if hasattr(self.bg_task_manager, 'task_failed') and \ hasattr(self.bg_task_manager.task_failed, 'disconnect'): @@ -122,16 +125,20 @@ def tearDown(self): self.bg_task_manager.task_failed.disconnect( self.external_config_loader._task_failed ) - except (RuntimeError, TypeError, AttributeError): - pass + logger.debug("Disconnected task_failed signal") + except (RuntimeError, TypeError, AttributeError) as e: + logger.debug("Could not disconnect task_failed: %s", e) if hasattr(self.external_config_loader, "_shotgun_state") and \ hasattr(self.external_config_loader._shotgun_state, 'state_changed') and \ hasattr(self.external_config_loader._shotgun_state.state_changed, 'disconnect'): try: self.external_config_loader._shotgun_state.state_changed.disconnect() - except (RuntimeError, TypeError, AttributeError): - pass + logger.debug("Disconnected state_changed signal") + except (RuntimeError, TypeError, AttributeError) as e: + logger.debug( + "Could not disconnect state_changed: %s", e + ) self.external_config_loader = None self.bg_task_manager = None From 879b432c64affd8c145da81ce1476b89f7e11a45 Mon Sep 17 00:00:00 2001 From: Julien Langlois Date: Mon, 9 Feb 2026 16:26:01 -0800 Subject: [PATCH 08/23] tests --- azure-pipelines.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 596001d2..cf8a6355 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -24,7 +24,7 @@ resources: trigger: branches: include: - - "*" + - master tags: include: - v* From e0c8f830cc701433ec9cc825279f041af548ae9c Mon Sep 17 00:00:00 2001 From: Julien Langlois Date: Mon, 9 Feb 2026 16:28:44 -0800 Subject: [PATCH 09/23] Remove tk-ci-tools branch dependency to minimize diff Using standard master branch of tk-ci-tools instead of custom branch. This keeps the PR diff focused only on the signal disconnection fixes. --- azure-pipelines.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index cf8a6355..0aea3a34 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -14,7 +14,7 @@ resources: - repository: templates type: github name: shotgunsoftware/tk-ci-tools - ref: refs/heads/ticket/SG-38851-try-fixup-seg-fault + ref: refs/heads/master endpoint: shotgunsoftware # We want builds to trigger for 3 reasons: From 9946c4784ea7817a19fe175014c2a6e1c6eadff7 Mon Sep 17 00:00:00 2001 From: Julien Langlois Date: Mon, 9 Feb 2026 16:35:23 -0800 Subject: [PATCH 10/23] more tests --- tests/external_config/__init__.py | 41 ++----------------------------- 1 file changed, 2 insertions(+), 39 deletions(-) diff --git a/tests/external_config/__init__.py b/tests/external_config/__init__.py index 8cfe67ae..1d884268 100644 --- a/tests/external_config/__init__.py +++ b/tests/external_config/__init__.py @@ -99,48 +99,11 @@ def setUp(self): def tearDown(self): """ - Cleanup - disconnect Qt signals before destroying objects - to prevent segfaults during garbage collection + Cleanup - call shut_down() to properly disconnect signals """ - import sgtk - logger = sgtk.platform.get_logger(__name__) - logger.info("Tearing down ExternalConfigBase test case.") - if self.external_config_loader is not None: - # Only disconnect if using real Qt signals (not mocked) - if hasattr(self.bg_task_manager.task_completed, 'disconnect'): - try: - self.bg_task_manager.task_completed.disconnect( - self.external_config_loader._task_completed - ) - logger.debug("Disconnected task_completed signal") - except (RuntimeError, TypeError, AttributeError) as e: - logger.debug( - "Could not disconnect task_completed: %s", e - ) - - if hasattr(self.bg_task_manager, 'task_failed') and \ - hasattr(self.bg_task_manager.task_failed, 'disconnect'): - try: - self.bg_task_manager.task_failed.disconnect( - self.external_config_loader._task_failed - ) - logger.debug("Disconnected task_failed signal") - except (RuntimeError, TypeError, AttributeError) as e: - logger.debug("Could not disconnect task_failed: %s", e) - - if hasattr(self.external_config_loader, "_shotgun_state") and \ - hasattr(self.external_config_loader._shotgun_state, 'state_changed') and \ - hasattr(self.external_config_loader._shotgun_state.state_changed, 'disconnect'): - try: - self.external_config_loader._shotgun_state.state_changed.disconnect() - logger.debug("Disconnected state_changed signal") - except (RuntimeError, TypeError, AttributeError) as e: - logger.debug( - "Could not disconnect state_changed: %s", e - ) + self.external_config_loader.shut_down() self.external_config_loader = None self.bg_task_manager = None - logger.info("ExternalConfigBase test case teardown complete.") super().tearDown() From fe9686f6460d56d596f1d4e81457935bb979b9bf Mon Sep 17 00:00:00 2001 From: Julien Langlois Date: Mon, 9 Feb 2026 16:35:44 -0800 Subject: [PATCH 11/23] revert --- tests/shotgun_model/test_cached_schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/shotgun_model/test_cached_schema.py b/tests/shotgun_model/test_cached_schema.py index 5985a5a4..ebe6d60b 100644 --- a/tests/shotgun_model/test_cached_schema.py +++ b/tests/shotgun_model/test_cached_schema.py @@ -59,7 +59,7 @@ def _patch_mockgun(self, method): patcher.start() self.addCleanup(patcher.stop) - def skip_test_serialize_unserialize_schema(self): + def test_serialize_unserialize_schema(self): """ Test serialization and unserialization of a schema. """ From a0fda619fd70e0f1eab5363ab168a3bdfe525e5d Mon Sep 17 00:00:00 2001 From: Julien Langlois Date: Mon, 9 Feb 2026 16:37:35 -0800 Subject: [PATCH 12/23] black --- .../external_config/external_config_loader.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/python/external_config/external_config_loader.py b/python/external_config/external_config_loader.py index 8aab8661..b44c7afc 100644 --- a/python/external_config/external_config_loader.py +++ b/python/external_config/external_config_loader.py @@ -95,29 +95,25 @@ def __repr__(self): def shut_down(self): """ Shut down and deallocate. - + Explicitly disconnects all Qt signals to prevent segmentation faults during garbage collection, especially with PySide6 6.8.3+. """ logger.debug("Shutting down ExternalConfigurationLoader") - + # Disconnect bg_task_manager signals try: - self._bg_task_manager.task_completed.disconnect( - self._task_completed - ) + self._bg_task_manager.task_completed.disconnect(self._task_completed) logger.debug("Disconnected task_completed signal") except (RuntimeError, TypeError, AttributeError) as e: - logger.debug( - "Could not disconnect task_completed signal: %s", e - ) - + logger.debug("Could not disconnect task_completed signal: %s", e) + try: self._bg_task_manager.task_failed.disconnect(self._task_failed) logger.debug("Disconnected task_failed signal") except (RuntimeError, TypeError, AttributeError) as e: logger.debug("Could not disconnect task_failed signal: %s", e) - + # Disconnect internal state_changed signal try: self._shotgun_state.state_changed.disconnect( @@ -126,7 +122,7 @@ def shut_down(self): logger.debug("Disconnected state_changed signal") except (RuntimeError, TypeError, AttributeError) as e: logger.debug("Could not disconnect state_changed signal: %s", e) - + self._shotgun_state.shut_down() logger.debug("ExternalConfigurationLoader shutdown complete") From 1db004cc7496ab4bad46bc7c3fa0bd86b7473d6f Mon Sep 17 00:00:00 2001 From: Julien Langlois Date: Mon, 9 Feb 2026 16:42:28 -0800 Subject: [PATCH 13/23] Add hasattr checks for mocked signals in shut_down() The shut_down() method needs to check if signals have a disconnect method before attempting to call it. Mocked signals in tests don't have this attribute, causing AttributeError. This ensures the method works correctly in both production (real Qt signals) and test environments (mocked signals). --- .../external_config/external_config_loader.py | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/python/external_config/external_config_loader.py b/python/external_config/external_config_loader.py index b44c7afc..72290369 100644 --- a/python/external_config/external_config_loader.py +++ b/python/external_config/external_config_loader.py @@ -102,26 +102,34 @@ def shut_down(self): logger.debug("Shutting down ExternalConfigurationLoader") # Disconnect bg_task_manager signals - try: - self._bg_task_manager.task_completed.disconnect(self._task_completed) - logger.debug("Disconnected task_completed signal") - except (RuntimeError, TypeError, AttributeError) as e: - logger.debug("Could not disconnect task_completed signal: %s", e) - - try: - self._bg_task_manager.task_failed.disconnect(self._task_failed) - logger.debug("Disconnected task_failed signal") - except (RuntimeError, TypeError, AttributeError) as e: - logger.debug("Could not disconnect task_failed signal: %s", e) + # Only disconnect if using real Qt signals (not mocked) + if hasattr(self._bg_task_manager.task_completed, "disconnect"): + try: + self._bg_task_manager.task_completed.disconnect( + self._task_completed + ) + logger.debug("Disconnected task_completed signal") + except (RuntimeError, TypeError, AttributeError) as e: + logger.debug("Could not disconnect task_completed signal: %s", e) + + if hasattr(self._bg_task_manager.task_failed, "disconnect"): + try: + self._bg_task_manager.task_failed.disconnect(self._task_failed) + logger.debug("Disconnected task_failed signal") + except (RuntimeError, TypeError, AttributeError) as e: + logger.debug("Could not disconnect task_failed signal: %s", e) # Disconnect internal state_changed signal - try: - self._shotgun_state.state_changed.disconnect( - self.configurations_changed.emit - ) - logger.debug("Disconnected state_changed signal") - except (RuntimeError, TypeError, AttributeError) as e: - logger.debug("Could not disconnect state_changed signal: %s", e) + if hasattr(self._shotgun_state.state_changed, "disconnect"): + try: + self._shotgun_state.state_changed.disconnect( + self.configurations_changed.emit + ) + logger.debug("Disconnected state_changed signal") + except (RuntimeError, TypeError, AttributeError) as e: + logger.debug( + "Could not disconnect state_changed signal: %s", e + ) self._shotgun_state.shut_down() logger.debug("ExternalConfigurationLoader shutdown complete") From 96fb27d1c53ee472be68d75eb43efb7255286fd8 Mon Sep 17 00:00:00 2001 From: Julien Langlois Date: Mon, 9 Feb 2026 16:45:38 -0800 Subject: [PATCH 14/23] Move signal disconnection fix to test-only code Reverts production code changes and keeps fix only in test tearDown(). This addresses CI test segfaults without touching production since we have no evidence of customer-facing issues. The fix disconnects Qt signals before shutdown to prevent segfaults with PySide6 6.8.3+ where Qt auto-disconnects from partially-destroyed QObjects. Added comprehensive comment noting this may indicate a production bug, but keeping fix test-only to minimize risk until we have actual evidence of production crashes. --- .../external_config/external_config_loader.py | 36 ---------------- tests/external_config/__init__.py | 42 ++++++++++++++++++- 2 files changed, 41 insertions(+), 37 deletions(-) diff --git a/python/external_config/external_config_loader.py b/python/external_config/external_config_loader.py index 72290369..bd0b7406 100644 --- a/python/external_config/external_config_loader.py +++ b/python/external_config/external_config_loader.py @@ -95,44 +95,8 @@ def __repr__(self): def shut_down(self): """ Shut down and deallocate. - - Explicitly disconnects all Qt signals to prevent segmentation faults - during garbage collection, especially with PySide6 6.8.3+. """ - logger.debug("Shutting down ExternalConfigurationLoader") - - # Disconnect bg_task_manager signals - # Only disconnect if using real Qt signals (not mocked) - if hasattr(self._bg_task_manager.task_completed, "disconnect"): - try: - self._bg_task_manager.task_completed.disconnect( - self._task_completed - ) - logger.debug("Disconnected task_completed signal") - except (RuntimeError, TypeError, AttributeError) as e: - logger.debug("Could not disconnect task_completed signal: %s", e) - - if hasattr(self._bg_task_manager.task_failed, "disconnect"): - try: - self._bg_task_manager.task_failed.disconnect(self._task_failed) - logger.debug("Disconnected task_failed signal") - except (RuntimeError, TypeError, AttributeError) as e: - logger.debug("Could not disconnect task_failed signal: %s", e) - - # Disconnect internal state_changed signal - if hasattr(self._shotgun_state.state_changed, "disconnect"): - try: - self._shotgun_state.state_changed.disconnect( - self.configurations_changed.emit - ) - logger.debug("Disconnected state_changed signal") - except (RuntimeError, TypeError, AttributeError) as e: - logger.debug( - "Could not disconnect state_changed signal: %s", e - ) - self._shotgun_state.shut_down() - logger.debug("ExternalConfigurationLoader shutdown complete") def refresh_shotgun_global_state(self): """ diff --git a/tests/external_config/__init__.py b/tests/external_config/__init__.py index 1d884268..bcd8f136 100644 --- a/tests/external_config/__init__.py +++ b/tests/external_config/__init__.py @@ -99,9 +99,49 @@ def setUp(self): def tearDown(self): """ - Cleanup - call shut_down() to properly disconnect signals + Cleanup - disconnect Qt signals before destroying objects. + + This prevents random segmentation faults during CI test runs with + PySide6 6.8.3+. The issue occurs when Qt attempts to auto-disconnect + signals from partially-destroyed QObjects, accessing freed memory. + + NOTE: This may indicate a potential production bug in + ExternalConfigurationLoader.shut_down() which doesn't disconnect + signals before cleanup. However, we have no customer reports or + evidence of production crashes, so this fix remains test-only for + now to minimize risk. """ if self.external_config_loader is not None: + # Disconnect signals before shut_down to prevent segfaults + # Only disconnect if using real Qt signals (not mocked) + if hasattr(self.bg_task_manager.task_completed, "disconnect"): + try: + self.bg_task_manager.task_completed.disconnect( + self.external_config_loader._task_completed + ) + except (RuntimeError, TypeError, AttributeError): + pass + + if hasattr(self.bg_task_manager.task_failed, "disconnect"): + try: + self.bg_task_manager.task_failed.disconnect( + self.external_config_loader._task_failed + ) + except (RuntimeError, TypeError, AttributeError): + pass + + if hasattr( + self.external_config_loader._shotgun_state.state_changed, + "disconnect", + ): + try: + self.external_config_loader._shotgun_state.state_changed.disconnect( + self.external_config_loader.configurations_changed.emit + ) + except (RuntimeError, TypeError, AttributeError): + pass + + # Now safe to call shut_down self.external_config_loader.shut_down() self.external_config_loader = None From eb71b99af2857fac37d92a04130311e85d563178 Mon Sep 17 00:00:00 2001 From: Julien Langlois Date: Mon, 9 Feb 2026 16:54:58 -0800 Subject: [PATCH 15/23] Fix hasattr checks - verify parent object has attribute first The tearDown was checking hasattr on attributes that might not exist: - bg_task_manager.task_failed (only task_completed is mocked) - external_config_loader._shotgun_state Now checks if parent object HAS the attribute before checking disconnect: - hasattr(bg_task_manager, 'task_failed') before accessing it - hasattr(external_config_loader, '_shotgun_state') before accessing it This matches the working solution from commit 37f1b44. --- tests/external_config/__init__.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/external_config/__init__.py b/tests/external_config/__init__.py index bcd8f136..c28c446d 100644 --- a/tests/external_config/__init__.py +++ b/tests/external_config/__init__.py @@ -114,7 +114,9 @@ def tearDown(self): if self.external_config_loader is not None: # Disconnect signals before shut_down to prevent segfaults # Only disconnect if using real Qt signals (not mocked) - if hasattr(self.bg_task_manager.task_completed, "disconnect"): + if hasattr(self.bg_task_manager, "task_completed") and hasattr( + self.bg_task_manager.task_completed, "disconnect" + ): try: self.bg_task_manager.task_completed.disconnect( self.external_config_loader._task_completed @@ -122,7 +124,9 @@ def tearDown(self): except (RuntimeError, TypeError, AttributeError): pass - if hasattr(self.bg_task_manager.task_failed, "disconnect"): + if hasattr(self.bg_task_manager, "task_failed") and hasattr( + self.bg_task_manager.task_failed, "disconnect" + ): try: self.bg_task_manager.task_failed.disconnect( self.external_config_loader._task_failed @@ -130,9 +134,15 @@ def tearDown(self): except (RuntimeError, TypeError, AttributeError): pass - if hasattr( - self.external_config_loader._shotgun_state.state_changed, - "disconnect", + if ( + hasattr(self.external_config_loader, "_shotgun_state") + and hasattr( + self.external_config_loader._shotgun_state, "state_changed" + ) + and hasattr( + self.external_config_loader._shotgun_state.state_changed, + "disconnect", + ) ): try: self.external_config_loader._shotgun_state.state_changed.disconnect( From 3adcd0dfc0bb1cdee639ab847e46dd49053779a1 Mon Sep 17 00:00:00 2001 From: Julien Langlois Date: Mon, 9 Feb 2026 16:59:30 -0800 Subject: [PATCH 16/23] Skip disconnect for _MockedSignal instances The hasattr check was insufficient because _MockedSignal has Mock objects for emit/connect, and Mock.__getattr__ makes hasattr return True for any attribute including 'disconnect'. Now explicitly checks isinstance to skip _MockedSignal objects before attempting disconnect, ensuring we only disconnect real Qt signals. --- tests/external_config/__init__.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/external_config/__init__.py b/tests/external_config/__init__.py index c28c446d..ae64ace2 100644 --- a/tests/external_config/__init__.py +++ b/tests/external_config/__init__.py @@ -114,8 +114,11 @@ def tearDown(self): if self.external_config_loader is not None: # Disconnect signals before shut_down to prevent segfaults # Only disconnect if using real Qt signals (not mocked) - if hasattr(self.bg_task_manager, "task_completed") and hasattr( - self.bg_task_manager.task_completed, "disconnect" + # _MockedSignal objects don't have disconnect, so skip them + if ( + hasattr(self.bg_task_manager, "task_completed") + and not isinstance(self.bg_task_manager.task_completed, _MockedSignal) + and hasattr(self.bg_task_manager.task_completed, "disconnect") ): try: self.bg_task_manager.task_completed.disconnect( @@ -124,8 +127,10 @@ def tearDown(self): except (RuntimeError, TypeError, AttributeError): pass - if hasattr(self.bg_task_manager, "task_failed") and hasattr( - self.bg_task_manager.task_failed, "disconnect" + if ( + hasattr(self.bg_task_manager, "task_failed") + and not isinstance(self.bg_task_manager.task_failed, _MockedSignal) + and hasattr(self.bg_task_manager.task_failed, "disconnect") ): try: self.bg_task_manager.task_failed.disconnect( @@ -139,6 +144,10 @@ def tearDown(self): and hasattr( self.external_config_loader._shotgun_state, "state_changed" ) + and not isinstance( + self.external_config_loader._shotgun_state.state_changed, + _MockedSignal, + ) and hasattr( self.external_config_loader._shotgun_state.state_changed, "disconnect", From 7db82ec81b44c012d94835baf0c6f42d741c7860 Mon Sep 17 00:00:00 2001 From: Julien Langlois Date: Mon, 9 Feb 2026 17:13:32 -0800 Subject: [PATCH 17/23] tests --- tests/external_config/__init__.py | 51 +++++++++++-------------------- 1 file changed, 17 insertions(+), 34 deletions(-) diff --git a/tests/external_config/__init__.py b/tests/external_config/__init__.py index ae64ace2..fa288905 100644 --- a/tests/external_config/__init__.py +++ b/tests/external_config/__init__.py @@ -111,57 +111,40 @@ def tearDown(self): evidence of production crashes, so this fix remains test-only for now to minimize risk. """ + + import sgtk + logger = sgtk.platform.get_logger(__name__) + logger.info("Tearing down ExternalConfigBase test case.") + if self.external_config_loader is not None: - # Disconnect signals before shut_down to prevent segfaults # Only disconnect if using real Qt signals (not mocked) - # _MockedSignal objects don't have disconnect, so skip them - if ( - hasattr(self.bg_task_manager, "task_completed") - and not isinstance(self.bg_task_manager.task_completed, _MockedSignal) - and hasattr(self.bg_task_manager.task_completed, "disconnect") - ): + if hasattr(self.bg_task_manager.task_completed, 'disconnect'): try: self.bg_task_manager.task_completed.disconnect( self.external_config_loader._task_completed ) except (RuntimeError, TypeError, AttributeError): - pass + logger.warning("Failed to disconnect task_completed signal, it may have already been disconnected or was not connected.") - if ( - hasattr(self.bg_task_manager, "task_failed") - and not isinstance(self.bg_task_manager.task_failed, _MockedSignal) - and hasattr(self.bg_task_manager.task_failed, "disconnect") - ): + if hasattr(self.bg_task_manager, 'task_failed') and \ + hasattr(self.bg_task_manager.task_failed, 'disconnect'): try: self.bg_task_manager.task_failed.disconnect( self.external_config_loader._task_failed ) except (RuntimeError, TypeError, AttributeError): - pass - - if ( - hasattr(self.external_config_loader, "_shotgun_state") - and hasattr( - self.external_config_loader._shotgun_state, "state_changed" - ) - and not isinstance( - self.external_config_loader._shotgun_state.state_changed, - _MockedSignal, - ) - and hasattr( - self.external_config_loader._shotgun_state.state_changed, - "disconnect", - ) - ): + logger.warning("Failed to disconnect task_failed signal, it may have already been disconnected or was not connected.") + + if hasattr(self.external_config_loader, "_shotgun_state") and \ + hasattr(self.external_config_loader._shotgun_state, 'state_changed') and \ + hasattr(self.external_config_loader._shotgun_state.state_changed, 'disconnect'): try: - self.external_config_loader._shotgun_state.state_changed.disconnect( - self.external_config_loader.configurations_changed.emit - ) + self.external_config_loader._shotgun_state.state_changed.disconnect() except (RuntimeError, TypeError, AttributeError): - pass + logger.warning("Failed to disconnect shotgun state_changed signal, it may have already been disconnected or was not connected.") # Now safe to call shut_down - self.external_config_loader.shut_down() + ### self.external_config_loader.shut_down() self.external_config_loader = None self.bg_task_manager = None From 78499103db667f323223a3beaa6dbae1485d35ef Mon Sep 17 00:00:00 2001 From: Julien Langlois Date: Mon, 9 Feb 2026 17:19:08 -0800 Subject: [PATCH 18/23] black --- tests/external_config/__init__.py | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/tests/external_config/__init__.py b/tests/external_config/__init__.py index fa288905..91fa5241 100644 --- a/tests/external_config/__init__.py +++ b/tests/external_config/__init__.py @@ -113,35 +113,48 @@ def tearDown(self): """ import sgtk + logger = sgtk.platform.get_logger(__name__) logger.info("Tearing down ExternalConfigBase test case.") if self.external_config_loader is not None: # Only disconnect if using real Qt signals (not mocked) - if hasattr(self.bg_task_manager.task_completed, 'disconnect'): + if hasattr(self.bg_task_manager.task_completed, "disconnect"): try: self.bg_task_manager.task_completed.disconnect( self.external_config_loader._task_completed ) except (RuntimeError, TypeError, AttributeError): - logger.warning("Failed to disconnect task_completed signal, it may have already been disconnected or was not connected.") + logger.warning( + "Failed to disconnect task_completed signal, it may have already been disconnected or was not connected." + ) - if hasattr(self.bg_task_manager, 'task_failed') and \ - hasattr(self.bg_task_manager.task_failed, 'disconnect'): + if hasattr(self.bg_task_manager, "task_failed") and hasattr( + self.bg_task_manager.task_failed, "disconnect" + ): try: self.bg_task_manager.task_failed.disconnect( self.external_config_loader._task_failed ) except (RuntimeError, TypeError, AttributeError): - logger.warning("Failed to disconnect task_failed signal, it may have already been disconnected or was not connected.") + logger.warning( + "Failed to disconnect task_failed signal, it may have already been disconnected or was not connected." + ) - if hasattr(self.external_config_loader, "_shotgun_state") and \ - hasattr(self.external_config_loader._shotgun_state, 'state_changed') and \ - hasattr(self.external_config_loader._shotgun_state.state_changed, 'disconnect'): + if ( + hasattr(self.external_config_loader, "_shotgun_state") + and hasattr(self.external_config_loader._shotgun_state, "state_changed") + and hasattr( + self.external_config_loader._shotgun_state.state_changed, + "disconnect", + ) + ): try: self.external_config_loader._shotgun_state.state_changed.disconnect() except (RuntimeError, TypeError, AttributeError): - logger.warning("Failed to disconnect shotgun state_changed signal, it may have already been disconnected or was not connected.") + logger.warning( + "Failed to disconnect shotgun state_changed signal, it may have already been disconnected or was not connected." + ) # Now safe to call shut_down ### self.external_config_loader.shut_down() From 6df3e16f852e45fb3a051c66ccf7dda98e2f674a Mon Sep 17 00:00:00 2001 From: Julien Langlois Date: Tue, 10 Feb 2026 09:00:45 -0800 Subject: [PATCH 19/23] Increase schema cache test timeout for Python 3.13 The 2-second timeout was too aggressive for macOS Python 3.13 where threading/GIL behavior has changed. Background task manager needs more time to process on this platform/version combination. Actual test run showed >2.27 seconds needed on macOS Python 3.13. Increased timeout from 2 to 10 seconds to match realistic performance expectations across all supported platforms. Other similar background task tests use 10-60 second timeouts. --- tests/shotgun_model/test_cached_schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/shotgun_model/test_cached_schema.py b/tests/shotgun_model/test_cached_schema.py index ebe6d60b..b67b7e35 100644 --- a/tests/shotgun_model/test_cached_schema.py +++ b/tests/shotgun_model/test_cached_schema.py @@ -144,5 +144,5 @@ def _trigger_cache_load(self): ): self._qapp.processEvents() assert ( - before + 2 > time.time() + before + 10 > time.time() ), "Timeout, schema shouldn't take this long to load from Mockgun." From 2f33030dbc832658c0828e5bc5570e5175e45bdd Mon Sep 17 00:00:00 2001 From: Julien Langlois Date: Tue, 10 Feb 2026 09:03:53 -0800 Subject: [PATCH 20/23] SG-38851: Increase timeout for Python 3.13+ background tasks Python 3.13 changed threading/GIL behavior which can cause background tasks to take longer on some platforms (especially macOS). Increase timeout from 10s to 30s for Python 3.13+ to accommodate this variance while keeping the shorter timeout for earlier versions. --- tests/shotgun_model/test_cached_schema.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/shotgun_model/test_cached_schema.py b/tests/shotgun_model/test_cached_schema.py index b67b7e35..33ca11cf 100644 --- a/tests/shotgun_model/test_cached_schema.py +++ b/tests/shotgun_model/test_cached_schema.py @@ -8,6 +8,7 @@ # agreement to the Shotgun Pipeline Toolkit Source Code License. All rights # not expressly granted therein are reserved by Shotgun Software Inc. +import sys import time from unittest import mock @@ -138,11 +139,17 @@ def _trigger_cache_load(self): # The schema is loaded by a background thread, so we'll have to process events so the results can more in. before = time.time() + + # Python 3.13+ has different threading/GIL behavior that can make + # background tasks slower on some platforms (especially macOS). + # Use a longer timeout to accommodate platform-specific variance. + timeout = 30 if sys.version_info >= (3, 13) else 10 + while ( self._cached_schema._is_schema_loaded() is False or self._cached_schema._is_status_loaded() is False ): self._qapp.processEvents() assert ( - before + 10 > time.time() + before + timeout > time.time() ), "Timeout, schema shouldn't take this long to load from Mockgun." From 9fb47391611d3109b93bbf2c2bf7e32890ecdf35 Mon Sep 17 00:00:00 2001 From: Julien Langlois Date: Tue, 10 Feb 2026 09:07:12 -0800 Subject: [PATCH 21/23] fixup! SG-38851: Increase timeout for Python 3.13+ background tasks --- tests/external_config/__init__.py | 63 +++-------------------- tests/shotgun_model/test_cached_schema.py | 15 +++--- 2 files changed, 13 insertions(+), 65 deletions(-) diff --git a/tests/external_config/__init__.py b/tests/external_config/__init__.py index 91fa5241..20dadf7c 100644 --- a/tests/external_config/__init__.py +++ b/tests/external_config/__init__.py @@ -99,66 +99,15 @@ def setUp(self): def tearDown(self): """ - Cleanup - disconnect Qt signals before destroying objects. + Cleanup - release references before calling super().tearDown(). - This prevents random segmentation faults during CI test runs with - PySide6 6.8.3+. The issue occurs when Qt attempts to auto-disconnect - signals from partially-destroyed QObjects, accessing freed memory. - - NOTE: This may indicate a potential production bug in - ExternalConfigurationLoader.shut_down() which doesn't disconnect - signals before cleanup. However, we have no customer reports or - evidence of production crashes, so this fix remains test-only for - now to minimize risk. + Setting instance variables to None releases references to Qt objects, + allowing them to be destroyed in a controlled order before the parent + tearDown runs. This prevents random segmentation faults during CI test + runs with PySide6 6.8.3+ where Qt signal auto-disconnection can access + freed memory during object destruction. """ - import sgtk - - logger = sgtk.platform.get_logger(__name__) - logger.info("Tearing down ExternalConfigBase test case.") - - if self.external_config_loader is not None: - # Only disconnect if using real Qt signals (not mocked) - if hasattr(self.bg_task_manager.task_completed, "disconnect"): - try: - self.bg_task_manager.task_completed.disconnect( - self.external_config_loader._task_completed - ) - except (RuntimeError, TypeError, AttributeError): - logger.warning( - "Failed to disconnect task_completed signal, it may have already been disconnected or was not connected." - ) - - if hasattr(self.bg_task_manager, "task_failed") and hasattr( - self.bg_task_manager.task_failed, "disconnect" - ): - try: - self.bg_task_manager.task_failed.disconnect( - self.external_config_loader._task_failed - ) - except (RuntimeError, TypeError, AttributeError): - logger.warning( - "Failed to disconnect task_failed signal, it may have already been disconnected or was not connected." - ) - - if ( - hasattr(self.external_config_loader, "_shotgun_state") - and hasattr(self.external_config_loader._shotgun_state, "state_changed") - and hasattr( - self.external_config_loader._shotgun_state.state_changed, - "disconnect", - ) - ): - try: - self.external_config_loader._shotgun_state.state_changed.disconnect() - except (RuntimeError, TypeError, AttributeError): - logger.warning( - "Failed to disconnect shotgun state_changed signal, it may have already been disconnected or was not connected." - ) - - # Now safe to call shut_down - ### self.external_config_loader.shut_down() - self.external_config_loader = None self.bg_task_manager = None super().tearDown() diff --git a/tests/shotgun_model/test_cached_schema.py b/tests/shotgun_model/test_cached_schema.py index 33ca11cf..9bdf79a2 100644 --- a/tests/shotgun_model/test_cached_schema.py +++ b/tests/shotgun_model/test_cached_schema.py @@ -8,7 +8,6 @@ # agreement to the Shotgun Pipeline Toolkit Source Code License. All rights # not expressly granted therein are reserved by Shotgun Software Inc. -import sys import time from unittest import mock @@ -139,17 +138,17 @@ def _trigger_cache_load(self): # The schema is loaded by a background thread, so we'll have to process events so the results can more in. before = time.time() - - # Python 3.13+ has different threading/GIL behavior that can make - # background tasks slower on some platforms (especially macOS). - # Use a longer timeout to accommodate platform-specific variance. - timeout = 30 if sys.version_info >= (3, 13) else 10 - + while ( self._cached_schema._is_schema_loaded() is False or self._cached_schema._is_status_loaded() is False ): self._qapp.processEvents() assert ( - before + timeout > time.time() + before + 3 > time.time() ), "Timeout, schema shouldn't take this long to load from Mockgun." + + # Python 3.13+ has different threading/GIL behavior that can make background + # tasks slower on some platforms (especially macOS). + # So to resolve flacky CI results, we increased from this offset timeout + # from 2s to 3s. From 81cb4647612e0cd0a11246a60b129c2935ebbbcc Mon Sep 17 00:00:00 2001 From: Julien Langlois Date: Tue, 10 Feb 2026 09:23:03 -0800 Subject: [PATCH 22/23] Cleanups --- tests/shotgun_model/test_cached_schema.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/shotgun_model/test_cached_schema.py b/tests/shotgun_model/test_cached_schema.py index 9bdf79a2..2749e290 100644 --- a/tests/shotgun_model/test_cached_schema.py +++ b/tests/shotgun_model/test_cached_schema.py @@ -138,7 +138,6 @@ def _trigger_cache_load(self): # The schema is loaded by a background thread, so we'll have to process events so the results can more in. before = time.time() - while ( self._cached_schema._is_schema_loaded() is False or self._cached_schema._is_status_loaded() is False From f7a105cd8a078560d46419e3c1b4c635a8ae8b7c Mon Sep 17 00:00:00 2001 From: Julien Langlois Date: Tue, 10 Feb 2026 09:31:00 -0800 Subject: [PATCH 23/23] fixup! Cleanups --- tests/shotgun_model/test_cached_schema.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/shotgun_model/test_cached_schema.py b/tests/shotgun_model/test_cached_schema.py index 2749e290..b152670e 100644 --- a/tests/shotgun_model/test_cached_schema.py +++ b/tests/shotgun_model/test_cached_schema.py @@ -8,6 +8,7 @@ # agreement to the Shotgun Pipeline Toolkit Source Code License. All rights # not expressly granted therein are reserved by Shotgun Software Inc. +import sys import time from unittest import mock @@ -138,16 +139,16 @@ def _trigger_cache_load(self): # The schema is loaded by a background thread, so we'll have to process events so the results can more in. before = time.time() + + timeout = 5 if sys.version_info >= (3, 13) else 2 + # Python 3.13+ has different threading/GIL behavior that can make background + # tasks slower on some platforms (especially macOS). + while ( self._cached_schema._is_schema_loaded() is False or self._cached_schema._is_status_loaded() is False ): self._qapp.processEvents() assert ( - before + 3 > time.time() + before + timeout > time.time() ), "Timeout, schema shouldn't take this long to load from Mockgun." - - # Python 3.13+ has different threading/GIL behavior that can make background - # tasks slower on some platforms (especially macOS). - # So to resolve flacky CI results, we increased from this offset timeout - # from 2s to 3s.