Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 82 additions & 5 deletions tests/python/workfiles2_test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,6 @@ def setUp(

# and start the engine
self.engine = sgtk.platform.start_engine("tk-testengine", self.tk, context)
# This ensures that the engine will always be destroyed.
self.addCleanup(self.engine.destroy)

self.app = self.engine.apps[app_instance]
self.tk_multi_workfiles = self.app.import_module("tk_multi_workfiles")
Expand All @@ -80,10 +78,89 @@ def setUp(
.import_module("task_manager")
.BackgroundTaskManager(parent=None, start_processing=True)
)
self.addCleanup(self.bg_task_manager.shut_down)
self.work_template = self.tk.templates[work_template]
self.publish_template = self.tk.templates[publish_template]

def tearDown(self):
"""
Cleanup - manually shut down and destroy objects before super().tearDown().

This prevents segmentation faults on macOS with Python 3.13+ where
the GIL changes and PySide6 6.8.3+ stricter signal auto-disconnection
can access freed memory during object destruction.

Following tk-framework-shotgunutils pattern: manually handle all
cleanup in tearDown (NOT via addCleanup) to ensure proper order.
"""
# Import Qt early so we can use it throughout
from tank.platform.qt import QtCore
import time

# Shut down background task manager first and wait for ALL threads
if hasattr(self, "bg_task_manager") and self.bg_task_manager is not None:
# Call shut_down which signals threads to stop
self.bg_task_manager.shut_down()

# CRITICAL: Wait for the results dispatcher thread to actually finish
# The dispatcher's shut_down() doesn't wait (to avoid deadlock in normal use)
# but in tearDown we MUST wait or it will try to log during engine destruction
results_dispatcher = getattr(
self.bg_task_manager, "_results_dispatcher", None
)
if results_dispatcher and results_dispatcher.isRunning():
results_dispatcher.wait(2000) # Wait up to 2 seconds
Comment thread
julien-lang marked this conversation as resolved.
Comment thread
julien-lang marked this conversation as resolved.

# Explicitly delete the task manager to break any references
del self.bg_task_manager
self.bg_task_manager = None

# CRITICAL: Destroy any test-created models/widgets BEFORE destroying engine
# This prevents segfaults on Python 3.13 with PySide6
if hasattr(self, "_model") and self._model is not None:
self._model.destroy()
del self._model
self._model = None

# Wait for metrics dispatcher threads to finish before engine.destroy()
if hasattr(self, "engine") and self.engine is not None:
metrics_dispatcher = getattr(self.engine, "_metrics_dispatcher", None)
if metrics_dispatcher and metrics_dispatcher.dispatching:
# Stop metrics dispatcher
metrics_dispatcher.stop()
# Wait for worker threads to actually complete
for worker in metrics_dispatcher.workers:
if worker.is_alive():
worker.join(timeout=2.0)
Comment thread
julien-lang marked this conversation as resolved.

# Destroy the engine
self.engine.destroy()

# CRITICAL: Aggressive Qt cleanup before pytest fixture teardown
qapp = QtCore.QCoreApplication.instance()
if qapp is not None:
# Process all events multiple times
for _ in range(10):
QtCore.QCoreApplication.processEvents()
QtCore.QCoreApplication.sendPostedEvents()
Comment thread
julien-lang marked this conversation as resolved.

# Explicitly process deferred delete events
QtCore.QCoreApplication.sendPostedEvents(None, QtCore.QEvent.DeferredDelete)
QtCore.QCoreApplication.processEvents()

# Give threads a tiny moment to fully exit
time.sleep(0.01)
Comment thread
julien-lang marked this conversation as resolved.

# Final event processing
QtCore.QCoreApplication.processEvents()
QtCore.QCoreApplication.sendPostedEvents()

# Clear engine reference after all Qt processing
if hasattr(self, "engine"):
del self.engine
self.engine = None

super().tearDown()

def create_context(self, entity, user=None):
"""
Create a context for the given entity and user.
Expand All @@ -104,7 +181,7 @@ def create_context(self, entity, user=None):
return context

@contextmanager
def wait_for(self, predicate, assert_msg_cb, timeout=2000):
def wait_for(self, predicate, assert_msg_cb, timeout=5000):
"""
Wait for a given predicate to turn True.

Expand All @@ -113,7 +190,7 @@ def wait_for(self, predicate, assert_msg_cb, timeout=2000):
:param callable predicate: Predicate to evaluate.
:param callable assert_msg_cb: On error, this callable will be invoked
to generate an error message.
:param int timeout: Timeout
:param int timeout: Timeout in milliseconds (default 5000ms) in milliseconds (default 5000ms)
"""
loop = sgtk.platform.qt.QtCore.QEventLoop()

Expand Down