Skip to content

resolved illegal threading issue; close #32#38

Merged
iamtekson merged 30 commits intomainfrom
dev
Jan 5, 2026
Merged

resolved illegal threading issue; close #32#38
iamtekson merged 30 commits intomainfrom
dev

Conversation

@iamtekson
Copy link
Copy Markdown
Owner

No description provided.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR resolves an illegal threading issue (#32) by replacing the old callback-based threading mechanism with a new unified MainThreadRunner approach for executing QGIS operations safely on the main Qt thread.

Key changes:

  • Introduced MainThreadRunner class with BlockingQueuedConnection to replace multiple dispatcher classes
  • Added @qgis_main_thread decorator for seamless main-thread execution of tool functions
  • Removed specialized dispatchers (RefreshDispatcher, ProjectLoadDispatcher, LayerRemovalDispatcher) in favor of the unified runner

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
utils/project_loader.py Deleted - specialized project loading dispatcher removed
utils/layer_operations.py Deleted - specialized layer removal dispatcher removed
utils/canvas_refresh.py Replaced old RefreshDispatcher with new MainThreadRunner class and execute_on_main_thread function; added @qgis_main_thread decorator
tools/io.py Applied @qgis_main_thread decorator to all tool functions; replaced callback-based operations with direct QGIS API calls
tools/filters.py Applied @qgis_main_thread decorator and replaced refresh_map_canvas() with direct canvas refresh
tools/geoprocessing.py Removed TODO comment
tools/init.py Changed from explicit imports to wildcard import for geoprocessing module
geo_agent.py Removed old dispatcher initialization and callbacks; added MainThreadRunner initialization and global setup

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tools/io.py
Comment on lines +401 to +411

# main logic to save project
try:
project.setFileName(path)
project.write(path)
_logger.info(f"Successfully created project at '{path}'")
return f"**Success:** Created new project at **{path}**"
except Exception as e:
_logger.error(f"Error saving project on main thread: {str(e)}", exc_info=True)
return f"**Error:** creating project: {str(e)}"

Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nested exception handling creates redundant error paths. The inner try-except on lines 403-410 catches exceptions and returns an error message, but this return happens inside the outer try block (starting at line 377), so if the inner exception handler doesn't catch something, the outer handler on lines 412-414 will also catch it and return a different error message format. Consider removing the nested try-except and using a single exception handler for cleaner error handling.

Suggested change
# main logic to save project
try:
project.setFileName(path)
project.write(path)
_logger.info(f"Successfully created project at '{path}'")
return f"**Success:** Created new project at **{path}**"
except Exception as e:
_logger.error(f"Error saving project on main thread: {str(e)}", exc_info=True)
return f"**Error:** creating project: {str(e)}"
# main logic to save project
project.setFileName(path)
project.write(path)
_logger.info(f"Successfully created project at '{path}'")
return f"**Success:** Created new project at **{path}**"

Copilot uses AI. Check for mistakes.
Comment thread utils/canvas_refresh.py
Comment on lines +50 to +61
runner = _global_main_runner

# This is the magic part: invokeMethod with BlockingQueuedConnection
QMetaObject.invokeMethod(
runner,
"run_task",
Qt.BlockingQueuedConnection,
Q_ARG(object, func),
Q_ARG(list, list(args)),
Q_ARG(dict, kwargs)
)

Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing null check for _global_main_runner. If execute_on_main_thread is called before set_main_runner is invoked (e.g., if a tool is called before the plugin is fully initialized), this will cause an AttributeError when trying to invoke methods on None. Add a validation check and raise an informative error message.

Suggested change
runner = _global_main_runner
# This is the magic part: invokeMethod with BlockingQueuedConnection
QMetaObject.invokeMethod(
runner,
"run_task",
Qt.BlockingQueuedConnection,
Q_ARG(object, func),
Q_ARG(list, list(args)),
Q_ARG(dict, kwargs)
)
runner = _global_main_runner
if runner is None:
raise RuntimeError(
"MainThreadRunner is not set. Call set_main_runner(...) during plugin "
"initialization before calling execute_on_main_thread."
)
# This is the magic part: invokeMethod with BlockingQueuedConnection
QMetaObject.invokeMethod(
runner,
"run_task",
Qt.BlockingQueuedConnection,
Q_ARG(object, func),
Q_ARG(list, list(args)),
Q_ARG(dict, kwargs)
)

Copilot uses AI. Check for mistakes.
Comment thread tools/io.py Outdated
Comment on lines +506 to +510
return {"success": success, "error": None if success else f"Failed to load project from '{path}'. The file may be corrupted or incompatible."}

except Exception as e:
_logger.error(f"Error loading project on main thread: {str(e)}", exc_info=True)
return {"success": False, "error": str(e)}
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent return type in load_qgis_project function. The function signature declares it returns str, but lines 506 and 510 return dict objects with 'success' and 'error' keys, while lines 492, 499, and 514 correctly return strings. This inconsistency will cause type errors for callers expecting a string return value. All return statements should return strings matching the function signature.

Copilot uses AI. Check for mistakes.
Comment thread tools/__init__.py Outdated
find_processing_algorithm,
)
# for easy importing geoprocessing tools later
from .geoprocessing import *
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wildcard import using 'from .geoprocessing import *' is a bad practice as it makes it unclear which names are being imported and can lead to namespace pollution and naming conflicts. Explicitly import the required functions (execute_processing, list_processing_algorithms, get_algorithm_parameters, find_processing_algorithm) as was done in the removed code.

Suggested change
from .geoprocessing import *
from .geoprocessing import (
execute_processing,
list_processing_algorithms,
get_algorithm_parameters,
find_processing_algorithm,
)

Copilot uses AI. Check for mistakes.
Comment thread utils/canvas_refresh.py
Comment on lines +53 to +60
QMetaObject.invokeMethod(
runner,
"run_task",
Qt.BlockingQueuedConnection,
Q_ARG(object, func),
Q_ARG(list, list(args)),
Q_ARG(dict, kwargs)
)
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing error handling for QMetaObject.invokeMethod. If the invocation fails (returns False), the code continues without any indication of failure. The return value of invokeMethod should be checked, and an appropriate error should be raised if it fails.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment thread utils/canvas_refresh.py Outdated
Comment on lines +33 to +41
@pyqtSlot(object, list, dict)
def run_task(self, func, args, kwargs):
"""Internal slot to execute the function and store result."""
self._result = None
self._error = None
try:
self._result = func(*args, **kwargs)
except Exception as e:
self._error = e
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential race condition with _result and _error attributes. These instance variables are accessed by both the main thread (in run_task) and worker threads (in execute_on_main_thread checking runner._error and runner._result). If multiple worker threads call execute_on_main_thread concurrently using the same runner instance, they could overwrite each other's results. Consider using thread-local storage or a different synchronization mechanism to ensure each call has isolated result/error storage.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment thread tools/io.py Outdated
Comment on lines +501 to +510
try:
project = QgsProject.instance()
project.clear()
success = project.read(path)
_logger.info(f"Successfully loaded project from '{path}'")
return f"**Success:** Project loaded from **{path}**"
else:
_logger.error(f"Unknown error loading project from '{path}'")
return f"**Error:** Failed to load project from **{path}**."
return {"success": success, "error": None if success else f"Failed to load project from '{path}'. The file may be corrupted or incompatible."}

except Exception as e:
_logger.error(f"Error loading project on main thread: {str(e)}", exc_info=True)
return {"success": False, "error": str(e)}
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nested exception handling creates redundant error paths. The inner try-except on lines 501-510 catches exceptions and returns an error (either dict or string), but this return happens inside the outer try block (starting at line 489), so if the inner exception handler doesn't catch something, the outer handler on lines 512-514 will also catch it and return a different error format. Consider removing the nested try-except and using a single exception handler for cleaner error handling.

Copilot uses AI. Check for mistakes.
Comment thread tools/io.py Outdated
# main logic to save project
try:
project.setFileName(path)
project.write(path)
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing success check after project.write(). The write operation on line 405 returns a boolean indicating success/failure, but this return value is ignored. If the write fails, the function will still log success and return a success message. Store the return value and check if it's False before returning success.

Suggested change
project.write(path)
write_success = project.write(path)
if not write_success:
_logger.error(f"Failed to write QGIS project to '{path}'")
return f"**Error:** Failed to create project at **{path}**. Could not write project file."

Copilot uses AI. Check for mistakes.
Comment thread tools/filters.py Outdated
@@ -1,4 +1,4 @@
from ..utils.canvas_refresh import refresh_map_canvas
from ..utils.canvas_refresh import get_qgis_interface, qgis_main_thread, execute_on_main_thread
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import of 'execute_on_main_thread' is not used.

Suggested change
from ..utils.canvas_refresh import get_qgis_interface, qgis_main_thread, execute_on_main_thread
from ..utils.canvas_refresh import get_qgis_interface, qgis_main_thread

Copilot uses AI. Check for mistakes.
Comment thread tools/io.py Outdated
from ..utils.canvas_refresh import get_qgis_interface
from ..utils.layer_operations import remove_layer_on_main_thread
from ..utils.project_loader import load_project_on_main_thread
from ..utils.canvas_refresh import get_qgis_interface, execute_on_main_thread, qgis_main_thread
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import of 'execute_on_main_thread' is not used.

Suggested change
from ..utils.canvas_refresh import get_qgis_interface, execute_on_main_thread, qgis_main_thread
from ..utils.canvas_refresh import get_qgis_interface, qgis_main_thread

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Copilot AI commented Jan 5, 2026

@iamtekson I've opened a new pull request, #39, to work on those changes. Once the pull request is ready, I'll request review from you.

Copy link
Copy Markdown
Contributor

Copilot AI commented Jan 5, 2026

@iamtekson I've opened a new pull request, #40, to work on those changes. Once the pull request is ready, I'll request review from you.

Copilot AI and others added 8 commits January 5, 2026 05:06
Co-authored-by: iamtekson <39838116+iamtekson@users.noreply.github.com>
Co-authored-by: iamtekson <39838116+iamtekson@users.noreply.github.com>
Co-authored-by: iamtekson <39838116+iamtekson@users.noreply.github.com>
Add error handling for QMetaObject.invokeMethod in thread-safe execution
Fix race condition in MainThreadRunner with thread-indexed result storage
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 7 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread utils/canvas_refresh.py
raise RuntimeError("MainThreadRunner is not set. Please set it using set_main_runner().")

# Get the current thread ID to isolate results
thread_id = threading.get_ident()
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thread IDs can be reused by the operating system after a thread terminates. If a new thread gets the same ID as a previous thread whose result hasn't been popped yet, it could retrieve the wrong result. This is mitigated by the pop operation, but race conditions could still occur. Consider using a combination of thread ID and a sequence number or UUID for unique identification.

Copilot uses AI. Check for mistakes.
Comment thread utils/canvas_refresh.py
Comment on lines +90 to +99
def qgis_main_thread(func):
"""
Decorator that automatically wraps a function to run
on the QGIS main thread using the Global Runner.
"""
@wraps(func)
def wrapper(*args, **kwargs):
# We use the existing execute_on_main_thread logic
return execute_on_main_thread(func, *args, **kwargs)
return wrapper
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The decorator silently changes the execution context of all decorated functions to run on the main thread. This could lead to unexpected blocking behavior, especially if a developer is unaware that a function decorated with @qgis_main_thread will block the calling thread. Consider adding a docstring or naming the decorator more explicitly (e.g., @blocking_main_thread) to make this behavior more obvious.

Copilot uses AI. Check for mistakes.
Comment thread tools/io.py Outdated
Comment thread geo_agent.py Outdated
self._layer_removal_dispatcher = LayerRemovalDispatcher(self.iface)

# main runner
self.main_runner = MainThreadRunner()
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MainThreadRunner instance is created in the constructor but Qt objects should typically be created on the thread where they will live (the main Qt thread). Creating this QObject in init which may be called from any thread could lead to threading issues. Consider moving the MainThreadRunner instantiation to the initGui() method which is guaranteed to run on the main thread.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment thread utils/canvas_refresh.py
Comment on lines +33 to +48
self._results = {} # Dictionary to store results per thread ID
self._lock = threading.Lock() # Lock to protect the results dictionary

@pyqtSlot(object, list, dict, int)
def run_task(self, func, args, kwargs, thread_id):
"""Internal slot to execute the function and store result."""
result = None
error = None
try:
result = func(*args, **kwargs)
except Exception as e:
error = e

# Store the result/error for this specific thread
with self._lock:
self._results[thread_id] = (result, error)
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential memory leak: results stored in _results dictionary are only cleaned up when popped in execute_on_main_thread. If a thread ID is used but the result is never retrieved (e.g., due to an exception before the pop), the result will remain in the dictionary indefinitely. Consider implementing a cleanup mechanism or using WeakValueDictionary.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment thread utils/canvas_refresh.py
Copy link
Copy Markdown
Contributor

Copilot AI commented Jan 5, 2026

@iamtekson I've opened a new pull request, #41, to work on those changes. Once the pull request is ready, I'll request review from you.

Copy link
Copy Markdown
Contributor

Copilot AI commented Jan 5, 2026

@iamtekson I've opened a new pull request, #42, to work on those changes. Once the pull request is ready, I'll request review from you.

iamtekson and others added 14 commits January 4, 2026 22:32
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: iamtekson <39838116+iamtekson@users.noreply.github.com>
Co-authored-by: iamtekson <39838116+iamtekson@users.noreply.github.com>
Co-authored-by: iamtekson <39838116+iamtekson@users.noreply.github.com>
Co-authored-by: iamtekson <39838116+iamtekson@users.noreply.github.com>
Co-authored-by: iamtekson <39838116+iamtekson@users.noreply.github.com>
Co-authored-by: iamtekson <39838116+iamtekson@users.noreply.github.com>
Co-authored-by: iamtekson <39838116+iamtekson@users.noreply.github.com>
Fix Qt threading violation in MainThreadRunner initialization
Fix memory leak in MainThreadRunner result cleanup
@iamtekson iamtekson merged commit 975a69a into main Jan 5, 2026
@iamtekson iamtekson deleted the dev branch January 5, 2026 05:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants