fix: Ensure auto-start works correctly (#40)

* fix: Ensure auto-start works correctly

* fix: Correct auto-start functionality for Bridge

* fix: general improvements
This commit is contained in:
Sean P. Kane
2026-01-18 23:17:07 -08:00
committed by GitHub
parent 2bef958994
commit 610654ae38
6 changed files with 665 additions and 37 deletions
+120 -15
View File
@@ -1009,6 +1009,43 @@ FreeCAD can run in two modes:
1. **GUI mode**: Full graphical interface with 3D view, accessed via `FreeCAD.app` or `freecad`
1. **Headless mode**: Console-only, no GUI, accessed via `FreeCADCmd` or `freecadcmd`
### FreeCAD Workbench Addon Loading (Critical)
**CRITICAL**: For FreeCAD **workbench addons**, `Init.py` does **NOT** run at FreeCAD startup. Only `InitGui.py` module-level code runs when FreeCAD GUI starts.
| File | When It Runs | Use Case |
| ------------ | -------------------------------------------------- | ---------------------------------------- |
| `Init.py` | Only when workbench is **selected** by user | Workbench-specific initialization |
| `InitGui.py` | **Module-level code** runs at FreeCAD GUI startup | Auto-start features, status bar setup |
| `InitGui.py` | `Initialize()` method runs when workbench selected | Toolbar/menu setup, command registration |
**Why this matters for auto-start:**
If you put auto-start logic in `Init.py`, it will only run when the user manually selects the workbench - NOT when FreeCAD starts. To auto-start the MCP bridge at FreeCAD startup:
1. Put auto-start code in `InitGui.py` **module-level code** (outside any class/method)
2. Use `QTimer.singleShot()` to defer execution until GUI is fully ready
3. The `Initialize()` method is too late - it only runs when workbench is selected
**Example pattern in InitGui.py:**
```python
# Module-level code - runs at FreeCAD GUI startup
try:
from PySide2 import QtCore
except ImportError:
from PySide6 import QtCore
def _deferred_auto_start() -> None:
"""Auto-start bridge after GUI is fully ready."""
# ... auto-start logic here ...
# Schedule auto-start after GUI initializes (3 second delay)
QtCore.QTimer.singleShot(3000, _deferred_auto_start)
```
**This is different from regular Python packages** where `__init__.py` runs on import. FreeCAD workbenches have special loading behavior.
### Detecting GUI Availability
**CRITICAL**: Always use `FreeCAD.GuiUp` to check if the GUI is available. **Never** check for Qt/PySide availability as a proxy for GUI mode.
@@ -1031,7 +1068,7 @@ else:
When FreeCAD starts in GUI mode, there's a timing window where:
1. `Init.py` runs when `FreeCAD.GuiUp = False` (GUI not yet initialized)
1. `InitGui.py` module-level code runs when `FreeCAD.GuiUp = False` (GUI not yet initialized)
2. Qt/PySide is available (can import successfully)
3. The bridge starts, sees `GuiUp = False`, and starts a background thread for queue processing
4. FreeCAD GUI finishes initializing (`GuiUp` becomes `True`)
@@ -1044,28 +1081,59 @@ When FreeCAD starts in GUI mode, there's a timing window where:
- Integration tests pass initial connection, then crash on first document operation
- Thread check shows `is_main_thread: False` with `thread_name: 'MCP-QueueProcessor'` even when `gui_up: True`
**The Fix (in `Init.py`):**
**The Fix (in `InitGui.py` or `startup_bridge.py`):**
When Qt is available but `FreeCAD.GuiUp` is `False`, use a repeating timer to wait for `GuiUp` to become `True` before starting the bridge:
Use `QTimer.singleShot()` to defer bridge startup until after GUI is ready:
```python
# WRONG - starts bridge immediately, will use background thread
elif QtCore is not None:
# WRONG - starts bridge immediately, GUI may not be ready
_auto_start_bridge()
# CORRECT - wait for GUI to be ready
elif QtCore is not None:
_auto_start_timer = QtCore.QTimer()
_auto_start_timer.setSingleShot(False) # Repeating
_auto_start_timer.timeout.connect(_wait_for_gui_and_start)
_auto_start_timer.start(100) # Check every 100ms
# CORRECT - wait for GUI to be ready (use sufficient delay)
QtCore.QTimer.singleShot(3000, _deferred_auto_start)
```
**Note:** This QTimer wait pattern is **only for GUI startup scenarios**. In headless mode (`freecadcmd`), the bridge starts directly without waiting because there is no Qt event loop to wait for. The three startup paths are:
For `startup_bridge.py`, use `GuiWaiter` to poll for `FreeCAD.GuiUp`:
1. **GUI already up** (`FreeCAD.GuiUp = True`): Start bridge immediately
2. **GUI starting** (Qt available, `GuiUp = False`): Use QTimer to wait for GUI
3. **Headless** (no Qt): Start bridge immediately with background thread
```python
# Wait for GuiUp to become True before starting
from bridge_utils import GuiWaiter
_gui_waiter = GuiWaiter(callback=_start_bridge, log_prefix="Startup Bridge")
_gui_waiter.start()
```
**Note:** This QTimer wait pattern is **only for GUI startup scenarios**. In headless mode (`freecadcmd`), the bridge starts directly without waiting because there is no Qt event loop to wait for. The four startup paths are:
1. **GUI already up** (`FreeCAD.GuiUp = True`): Start bridge immediately with Qt timer
2. **True headless** (`QCoreApplication` exists but is NOT a `QApplication`): Start directly with background thread
3. **GUI starting** (Qt available but no app yet, or `QApplication` initializing): Use GuiWaiter to wait for GUI
4. **No Qt available** (unusual state): Start directly
**IMPORTANT - Detecting True Headless vs Early GUI Startup:**
Simply checking `QApplication.instance() is None` is NOT sufficient because:
- In true headless mode (`freecadcmd`): `QCoreApplication` exists but NOT `QApplication`
- In early GUI startup: No application exists yet, but GUI will be available soon
The correct detection uses `isinstance` to distinguish these cases:
```python
# Check for QApplication first
qapp = QtWidgets.QApplication.instance()
if qapp is not None:
# QApplication exists - GUI is available or starting
_has_qapp = True
else:
# No QApplication - check if QCoreApplication exists
qcore_app = QtCore.QCoreApplication.instance()
if qcore_app is not None and not isinstance(qcore_app, QtWidgets.QApplication):
# QCoreApplication exists but is NOT a QApplication = true headless
_is_true_headless = True
# If no app at all, assume early GUI startup (will use GuiWaiter)
```
This logic is implemented in both `Init.py` and `startup_bridge.py` and MUST be kept in sync.
**Testing:**
@@ -1077,6 +1145,43 @@ An integration test in `tests/integration/test_thread_safety.py` verifies that:
**Key Lesson:** Never assume Qt availability means GUI is ready. Always check `FreeCAD.GuiUp` before doing operations that depend on the Qt event loop running on the main thread.
### Keeping Startup Scripts in Sync (Critical)
**CRITICAL**: The MCP bridge can be started from THREE different entry points. They must use compatible detection logic:
| File | Purpose |
| ------------------------------------------------ | ------------------------------------------------ |
| `addon/FreecadRobustMCPBridge/InitGui.py` | Auto-start at FreeCAD GUI startup (if enabled) |
| `addon/FreecadRobustMCPBridge/Init.py` | Fallback auto-start when workbench selected |
| `addon/.../freecad_mcp_bridge/startup_bridge.py` | Manual start via `just freecad::run-gui` |
**When modifying startup logic in one file, you MUST update the others to match.**
All files must have compatible logic for:
1. **QApplication detection** - Use `QApplication.instance()` to detect GUI vs headless
2. **GuiWaiter usage** - Wait for `GuiUp` before starting in GUI mode
3. **Headless detection** - Start directly when no QApplication exists
4. **Diagnostic logging** - Log `GuiUp`, `QtCore`, and `QApp` state
5. **Already running check** - Skip start if bridge already running
**Why these files exist:**
- `InitGui.py` module-level code runs at FreeCAD GUI startup (primary auto-start location)
- `Init.py` runs when workbench is selected (fallback if InitGui didn't auto-start)
- `startup_bridge.py` is passed as a command-line argument for `just freecad::run-gui`
- All check if a bridge is already running to avoid conflicts
**Test command to verify both work:**
```bash
# Test headless mode (uses blocking_bridge.py)
just testing::integration-headless-release
# Test GUI mode (uses startup_bridge.py, InitGui.py auto-start may also be active)
just testing::integration-gui-release
```
### Why NOT to Check for PySide/Qt
**WRONG** - Do not use Qt availability to detect GUI mode:
+79 -11
View File
@@ -13,13 +13,16 @@ must run on the main thread.
from __future__ import annotations
from typing import TYPE_CHECKING, Any
# Import FreeCAD first so we can log early
import FreeCAD
FreeCAD.Console.PrintMessage("Robust MCP Bridge: Init.py loaded\n")
from typing import TYPE_CHECKING, Any # noqa: E402
if TYPE_CHECKING:
from freecad_mcp_bridge.bridge_utils import GuiWaiter
import FreeCAD
FreeCAD.Console.PrintMessage("Robust MCP Bridge: Init loaded\n")
# Global reference to GuiWaiter and auto-start timer to prevent garbage collection
@@ -83,15 +86,22 @@ def _auto_start_bridge() -> None:
except Exception as e:
FreeCAD.Console.PrintError(f"Failed to auto-start MCP Bridge: {e}\n")
import traceback
FreeCAD.Console.PrintError(f"Traceback: {traceback.format_exc()}\n")
# Schedule auto-start after FreeCAD finishes loading
# Strategy:
# - If FreeCAD.GuiUp is True: Qt event loop is running, use timer for deferred start
# - If FreeCAD.GuiUp is False but Qt is available: FreeCAD GUI is initializing.
# - If FreeCAD.GuiUp is False but QApplication exists: FreeCAD GUI is initializing.
# Use GuiWaiter to wait for GuiUp to become True before starting.
# This ensures the bridge uses Qt timer (not background thread) for queue processing.
# - If Qt is not available: Pure headless mode, start bridge directly
# - If no QApplication: True headless mode, start bridge directly
#
# IMPORTANT: We check for QApplication.instance() rather than just QtCore availability
# because FreeCAD bundles PySide even in headless mode (freecadcmd), but there's no
# Qt event loop running. Without a QApplication, Qt timers will never fire.
#
# CRITICAL: We must wait for FreeCAD.GuiUp to be True before starting the bridge
# in GUI mode. If we start when GuiUp is False, the bridge's _start_queue_processor()
@@ -100,19 +110,59 @@ def _auto_start_bridge() -> None:
try:
from preferences import get_auto_start
if get_auto_start():
# Try to import Qt
_auto_start_enabled = get_auto_start()
FreeCAD.Console.PrintMessage(
f"Robust MCP Bridge: Auto-start preference = {_auto_start_enabled}\n"
)
if _auto_start_enabled:
# Try to import Qt and check for running QApplication
import contextlib
QtCore = None
QtWidgets = None
_has_qapp = False
_is_true_headless = False
try:
from PySide2 import QtCore # type: ignore[assignment, no-redef]
from PySide2 import QtCore, QtWidgets # type: ignore[assignment, no-redef]
except ImportError:
with contextlib.suppress(ImportError):
from PySide6 import QtCore # type: ignore[assignment, no-redef]
from PySide6 import ( # type: ignore[assignment, no-redef]
QtCore,
QtWidgets,
)
# Detect GUI mode vs true headless mode
# - True headless (freecadcmd): QCoreApplication exists but NOT QApplication
# - GUI mode early startup: No app yet, or QApplication being initialized
# - GUI mode ready: FreeCAD.GuiUp is True
if QtWidgets is not None and QtCore is not None:
qapp = QtWidgets.QApplication.instance()
if qapp is not None:
_has_qapp = True
else:
# No QApplication - check if QCoreApplication exists
# If QCoreApplication exists but is NOT a QApplication, it's true headless
qcore_app = QtCore.QCoreApplication.instance()
if qcore_app is not None and not isinstance(
qcore_app, QtWidgets.QApplication
):
_is_true_headless = True
# If no app at all, assume early GUI startup (will use GuiWaiter)
FreeCAD.Console.PrintMessage(
f"Robust MCP Bridge: GuiUp={FreeCAD.GuiUp}, "
f"QtCore={'available' if QtCore else 'unavailable'}, "
f"QApp={'running' if _has_qapp else 'none'}, "
f"headless={_is_true_headless}\n"
)
if FreeCAD.GuiUp:
# GUI is already up - use timer for deferred start
FreeCAD.Console.PrintMessage(
"Robust MCP Bridge: GUI already up, scheduling deferred start...\n"
)
if QtCore is not None:
_auto_start_timer = QtCore.QTimer()
_auto_start_timer.setSingleShot(True)
@@ -121,9 +171,21 @@ try:
else:
# GUI is up but Qt import failed - start directly
_auto_start_bridge()
elif _is_true_headless:
# True headless mode - QCoreApplication exists but not QApplication
# No Qt event loop for GUI, so start bridge directly with background thread
FreeCAD.Console.PrintMessage(
"Robust MCP Bridge: True headless mode (QCoreApplication only), "
"starting directly...\n"
)
_auto_start_bridge()
elif QtCore is not None:
# GUI not ready yet, but Qt is available (FreeCAD starting in GUI mode)
# GUI not ready yet (either QApplication exists or no app yet)
# Use GuiWaiter to wait for GuiUp to become True before starting
# This ensures the bridge uses Qt timer (not background thread) for queue
FreeCAD.Console.PrintMessage(
"Robust MCP Bridge: GUI not ready, using GuiWaiter...\n"
)
from freecad_mcp_bridge.bridge_utils import GuiWaiter
_gui_waiter = GuiWaiter(
@@ -136,7 +198,13 @@ try:
)
_gui_waiter.start()
else:
# True headless mode - no Qt, no GUI
# No Qt available at all - unusual state, start directly
FreeCAD.Console.PrintMessage(
"Robust MCP Bridge: No Qt available, starting directly...\n"
)
_auto_start_bridge()
except Exception as e:
FreeCAD.Console.PrintWarning(f"Could not set up auto-start: {e}\n")
import traceback
FreeCAD.Console.PrintWarning(f"Traceback: {traceback.format_exc()}\n")
+66
View File
@@ -178,6 +178,72 @@ try:
FreeCAD.Console.PrintMessage(
"Robust MCP Bridge: Status bar sync scheduled from InitGui (2s)\n"
)
# Auto-start the MCP bridge if configured in preferences
# This runs at FreeCAD GUI startup (InitGui.py module-level code)
# Note: Init.py does NOT run at startup for workbench addons, so auto-start
# must be triggered from here instead.
def _deferred_auto_start() -> None:
"""Auto-start bridge after GUI is fully ready."""
try:
from preferences import get_auto_start
if not get_auto_start():
return
# Check if bridge is already running
from commands import is_bridge_running
if is_bridge_running():
FreeCAD.Console.PrintMessage(
"Robust MCP Bridge: Bridge already running, skipping auto-start\n"
)
return
FreeCAD.Console.PrintMessage(
"Robust MCP Bridge: Auto-starting from InitGui...\n"
)
# Import and start the bridge
from freecad_mcp_bridge.bridge_utils import register_mcp_plugin
from freecad_mcp_bridge.server import FreecadMCPPlugin
from preferences import get_socket_port, get_xmlrpc_port
xmlrpc_port = get_xmlrpc_port()
socket_port = get_socket_port()
plugin = FreecadMCPPlugin(
host="localhost",
port=socket_port,
xmlrpc_port=xmlrpc_port,
enable_xmlrpc=True,
)
plugin.start()
# Register plugin with commands module
register_mcp_plugin(plugin, xmlrpc_port, socket_port)
# Sync status bar now that bridge is running
try:
from status_widget import sync_status_with_bridge
sync_status_with_bridge()
except Exception as status_err:
FreeCAD.Console.PrintWarning(
f"Could not sync status bar after auto-start: {status_err}\n"
)
except Exception as e:
FreeCAD.Console.PrintError(f"Robust MCP Bridge: Auto-start failed: {e}\n")
import traceback
FreeCAD.Console.PrintError(traceback.format_exc())
# Schedule auto-start after a short delay to ensure GUI is fully ready
# Use a longer delay than status bar sync to avoid race conditions
QtCore.QTimer.singleShot(3000, _deferred_auto_start)
FreeCAD.Console.PrintMessage(
"Robust MCP Bridge: Auto-start scheduled from InitGui (3s)\n"
)
except Exception as e:
FreeCAD.Console.PrintWarning(
f"Robust MCP Bridge: Could not schedule status bar sync: {e}\n"
@@ -2,7 +2,7 @@
## Version 0.6.2 (2026-01-18)
No changes to the workbench code itself. This release reflects the repository restructuring.
This release fixes some auto-start issues and improves the overall startup experience across all supported modes.
### Added
@@ -11,10 +11,13 @@ No changes to the workbench code itself. This release reflects the repository re
### Changed
- **Repository restructured**: This addon now focuses solely on the MCP Bridge Workbench. Standalone macros have been moved to dedicated repositories for independent release cycles.
- **Cleaner startup messages**: Removed duplicate success messages when bridge auto-starts.
### Fixed
- No bug fixes in this release.
- **Auto-start not working at FreeCAD startup**: Fixed bug where auto-start only worked when manually selecting the workbench. For FreeCAD workbench addons, `Init.py` does NOT run at startup - only `InitGui.py` module-level code runs. Auto-start logic has been moved to `InitGui.py`.
- **Status bar not appearing after auto-start**: The status bar widget now syncs immediately after the bridge starts, instead of on a timer that ran before the bridge was ready.
- **Integration test crashes**: Fixed race condition where the bridge could start before `FreeCAD.GuiUp` was `True`, causing Qt operations to run on a background thread and crash FreeCAD.
### Note
@@ -128,31 +128,76 @@ def _start_bridge() -> None:
# Schedule bridge start after FreeCAD finishes loading
# Strategy:
# - If FreeCAD.GuiUp is True: Qt event loop is running, start bridge directly
# - If FreeCAD.GuiUp is False but Qt is available: FreeCAD GUI is initializing.
# - If FreeCAD.GuiUp is False but QApplication exists: FreeCAD GUI is initializing.
# Use GuiWaiter to wait for GuiUp to become True before starting.
# This ensures the bridge uses Qt timer (not background thread) for queue processing.
# - If Qt is not available: Pure headless mode, start bridge directly
# - If no QApplication: True headless mode, start bridge directly
#
# IMPORTANT: We check for QApplication.instance() rather than just QtCore availability
# because FreeCAD bundles PySide even in headless mode (freecadcmd), but there's no
# Qt event loop running. Without a QApplication, Qt timers will never fire.
#
# CRITICAL: We must wait for FreeCAD.GuiUp to be True before starting the bridge
# in GUI mode. If we start when GuiUp is False, the bridge's _start_queue_processor()
# will see GuiUp=False and use a background thread. Later, code executed on that
# thread will try to do Qt operations, causing crashes (SIGABRT in QCocoaWindow).
try:
# Try to import Qt
# Try to import Qt and check for running QApplication
QtCore = None
QtWidgets = None
_has_qapp = False
_is_true_headless = False
try:
from PySide2 import QtCore # type: ignore[assignment, no-redef]
from PySide2 import QtCore, QtWidgets # type: ignore[assignment, no-redef]
except ImportError:
with contextlib.suppress(ImportError):
from PySide6 import QtCore # type: ignore[assignment, no-redef]
from PySide6 import QtCore, QtWidgets # type: ignore[assignment, no-redef]
# Detect GUI mode vs true headless mode
# - True headless (freecadcmd): QCoreApplication exists but NOT QApplication
# - GUI mode early startup: No app yet, or QApplication being initialized
# - GUI mode ready: FreeCAD.GuiUp is True
if QtWidgets is not None and QtCore is not None:
qapp = QtWidgets.QApplication.instance()
if qapp is not None:
_has_qapp = True
else:
# No QApplication - check if QCoreApplication exists
# If QCoreApplication exists but is NOT a QApplication, it's true headless
qcore_app = QtCore.QCoreApplication.instance()
if qcore_app is not None and not isinstance(
qcore_app, QtWidgets.QApplication
):
_is_true_headless = True
# If no app at all, assume early GUI startup (will use GuiWaiter)
FreeCAD.Console.PrintMessage(
f"Startup Bridge: GuiUp={FreeCAD.GuiUp}, "
f"QtCore={'available' if QtCore else 'unavailable'}, "
f"QApp={'running' if _has_qapp else 'none'}, "
f"headless={_is_true_headless}\n"
)
if FreeCAD.GuiUp:
# GUI is already up - start bridge directly
FreeCAD.Console.PrintMessage("Startup Bridge: GUI already up, starting...\n")
_start_bridge()
elif _is_true_headless:
# True headless mode - QCoreApplication exists but not QApplication
# No Qt event loop for GUI, so start bridge directly with background thread
FreeCAD.Console.PrintMessage(
"Startup Bridge: True headless mode (QCoreApplication only), "
"starting directly...\n"
)
_start_bridge()
elif QtCore is not None:
# GUI not ready yet, but Qt is available (FreeCAD starting in GUI mode)
# GUI not ready yet (either QApplication exists or no app yet)
# Use GuiWaiter to wait for GuiUp to become True before starting
# This ensures the bridge uses Qt timer (not background thread) for queue
FreeCAD.Console.PrintMessage(
"Startup Bridge: GUI not ready, using GuiWaiter...\n"
)
from bridge_utils import GuiWaiter
_gui_waiter = GuiWaiter(
@@ -165,10 +210,11 @@ try:
)
_gui_waiter.start()
else:
# True headless mode - no Qt, no GUI
# No Qt available at all - unusual state, start directly
FreeCAD.Console.PrintMessage(
"Startup Bridge: Headless mode, starting directly...\n"
"Startup Bridge: No Qt available, starting directly...\n"
)
_start_bridge()
except Exception as e:
FreeCAD.Console.PrintError(f"Startup Bridge: Failed to initialize: {e}\n")
FreeCAD.Console.PrintError(traceback.format_exc())
+340
View File
@@ -0,0 +1,340 @@
"""Tests for the FreeCAD Robust MCP workbench auto-start logic.
These tests verify that the auto-start logic in Init.py is correctly
implemented with proper diagnostic logging and all code paths covered.
This test suite was added after discovering a bug where auto-start wasn't
working because the code flow wasn't properly logging which path was taken,
making it difficult to debug.
"""
import ast
from pathlib import Path
import pytest
# Get the addon directory path
ADDON_DIR = (
Path(__file__).parent.parent.parent.parent / "addon" / "FreecadRobustMCPBridge"
)
class TestAutoStartPreferences:
"""Tests for auto-start preference functions."""
@pytest.fixture
def preferences_code(self) -> str:
"""Load preferences.py content."""
return (ADDON_DIR / "preferences.py").read_text()
def test_get_auto_start_function_exists(self, preferences_code: str) -> None:
"""preferences.py should have get_auto_start function."""
assert "def get_auto_start" in preferences_code
def test_set_auto_start_function_exists(self, preferences_code: str) -> None:
"""preferences.py should have set_auto_start function."""
assert "def set_auto_start" in preferences_code
def test_auto_start_default_is_false(self, preferences_code: str) -> None:
"""Default auto-start should be False for safety."""
assert "DEFAULT_AUTO_START = False" in preferences_code
def test_auto_start_uses_param_path(self, preferences_code: str) -> None:
"""Auto-start should use FreeCAD parameter system."""
assert "PARAM_PATH" in preferences_code
assert "RobustMCPBridge" in preferences_code
def test_get_auto_start_returns_bool(self, preferences_code: str) -> None:
"""get_auto_start should return a bool."""
# Check for GetBool call
assert "GetBool" in preferences_code
# Use AST to verify return type annotation
tree = ast.parse(preferences_code)
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == "get_auto_start":
assert node.returns is not None, (
"get_auto_start should have return annotation"
)
# Check if return annotation is 'bool'
if isinstance(node.returns, ast.Name):
assert node.returns.id == "bool", "Return type should be bool"
elif isinstance(node.returns, ast.Constant):
# Python 3.9+ may use Constant for some annotations
assert node.returns.value == "bool", "Return type should be bool"
else:
pytest.fail(
f"Unexpected return annotation type: {type(node.returns)}"
)
return
pytest.fail("get_auto_start function not found in preferences.py")
class TestAutoStartInitLogic:
"""Tests for auto-start logic in Init.py."""
@pytest.fixture
def init_code(self) -> str:
"""Load Init.py content."""
return (ADDON_DIR / "Init.py").read_text()
def test_imports_get_auto_start(self, init_code: str) -> None:
"""Init.py should import get_auto_start from preferences."""
assert "from preferences import get_auto_start" in init_code
def test_checks_auto_start_preference(self, init_code: str) -> None:
"""Init.py should check the auto_start preference value."""
# Should call get_auto_start() and store/check the result
assert "get_auto_start()" in init_code
def test_logs_auto_start_preference_value(self, init_code: str) -> None:
"""Init.py should log whether auto-start is enabled.
This diagnostic logging helps debug auto-start issues by showing
what value was read from preferences.
"""
# Should log the actual preference value
assert "Auto-start preference" in init_code
def test_logs_gui_state(self, init_code: str) -> None:
"""Init.py should log GuiUp, QtCore, and QApp availability.
This diagnostic logging helps debug which code path is taken.
"""
assert "GuiUp=" in init_code
assert "QtCore=" in init_code
assert "QApp=" in init_code
def test_handles_gui_already_up_path(self, init_code: str) -> None:
"""Init.py should handle the case when GUI is already up."""
# Should check FreeCAD.GuiUp and log accordingly
assert "GUI already up" in init_code
def test_handles_gui_not_ready_path(self, init_code: str) -> None:
"""Init.py should handle the case when GUI is not yet ready."""
# Should use GuiWaiter when GUI is not ready but Qt is available
assert "GUI not ready" in init_code
assert "GuiWaiter" in init_code
def test_handles_headless_path(self, init_code: str) -> None:
"""Init.py should handle true headless mode (QCoreApplication only)."""
# Should detect and handle true headless mode
assert "True headless mode" in init_code or "headless" in init_code.lower()
# Should check QCoreApplication to detect true headless
assert "QCoreApplication" in init_code
# Should use isinstance to distinguish QCoreApplication from QApplication
assert "isinstance" in init_code
def test_imports_gui_waiter(self, init_code: str) -> None:
"""Init.py should import GuiWaiter for waiting on GUI."""
assert "from freecad_mcp_bridge.bridge_utils import GuiWaiter" in init_code
def test_uses_single_shot_timer_for_gui_up(self, init_code: str) -> None:
"""When GUI is up, should use single-shot timer for deferred start."""
assert "setSingleShot(True)" in init_code
def test_exception_handling_with_traceback(self, init_code: str) -> None:
"""Auto-start setup should catch exceptions and log traceback.
Without traceback logging, failures are silent and hard to debug.
"""
assert "except Exception" in init_code
# Should log the traceback, not just the exception message
assert "traceback" in init_code.lower()
@pytest.mark.parametrize(
"var_name",
["_auto_start_timer", "_gui_waiter"],
ids=["timer_reference", "gui_waiter_reference"],
)
def test_global_references_prevent_gc(self, init_code: str, var_name: str) -> None:
"""Global references should be stored at module level to prevent GC.
Both _auto_start_timer and _gui_waiter must be defined at module level
(not indented) to prevent garbage collection of Qt timers and callbacks.
"""
assert var_name in init_code, f"{var_name} should be present in Init.py"
# Verify module-level definition (line starts with var name, not indented)
lines = init_code.split("\n")
for line in lines:
if line.startswith(var_name):
# Found module-level definition (not indented)
return
pytest.fail(f"{var_name} should be defined at module level (not indented)")
class TestAutoStartBridgeFunction:
"""Tests for the _auto_start_bridge function."""
@pytest.fixture
def init_code(self) -> str:
"""Load Init.py content."""
return (ADDON_DIR / "Init.py").read_text()
def test_auto_start_bridge_function_exists(self, init_code: str) -> None:
"""_auto_start_bridge function should exist."""
assert "def _auto_start_bridge" in init_code
def test_auto_start_bridge_checks_preference_again(self, init_code: str) -> None:
"""_auto_start_bridge should re-check preference before starting.
This handles the case where preference changed between Init.py load
and the deferred timer firing.
"""
# Parse the function body
tree = ast.parse(init_code)
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == "_auto_start_bridge":
# Convert function body back to source for analysis
func_code = ast.unparse(node)
assert "get_auto_start" in func_code
return
pytest.fail("_auto_start_bridge function not found")
def test_auto_start_bridge_checks_if_already_running(self, init_code: str) -> None:
"""_auto_start_bridge should check if bridge is already running."""
# Should check _mcp_plugin.is_running or similar
assert "is_running" in init_code
def test_auto_start_bridge_logs_start_message(self, init_code: str) -> None:
"""_auto_start_bridge should log when auto-starting."""
assert "Auto-starting MCP Bridge" in init_code
def test_auto_start_bridge_creates_plugin(self, init_code: str) -> None:
"""_auto_start_bridge should create a FreecadMCPPlugin instance."""
assert "FreecadMCPPlugin" in init_code
def test_auto_start_bridge_registers_plugin(self, init_code: str) -> None:
"""_auto_start_bridge should register plugin with commands module."""
assert "register_mcp_plugin" in init_code
def test_auto_start_bridge_handles_exceptions(self, init_code: str) -> None:
"""_auto_start_bridge should catch and log exceptions."""
# Parse the function and check for try/except
tree = ast.parse(init_code)
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == "_auto_start_bridge":
# Check if function has try/except
for child in ast.walk(node):
if isinstance(child, ast.Try):
return # Found try/except
pytest.fail("_auto_start_bridge should have try/except for error handling")
class TestGuiWaiterUsage:
"""Tests for GuiWaiter class usage in auto-start."""
@pytest.fixture
def bridge_utils_code(self) -> str:
"""Load bridge_utils.py content."""
return (ADDON_DIR / "freecad_mcp_bridge" / "bridge_utils.py").read_text()
def test_gui_waiter_class_exists(self, bridge_utils_code: str) -> None:
"""GuiWaiter class should exist in bridge_utils.py."""
assert "class GuiWaiter" in bridge_utils_code
def test_gui_waiter_has_start_method(self, bridge_utils_code: str) -> None:
"""GuiWaiter should have a start method."""
assert "def start(self)" in bridge_utils_code
def test_gui_waiter_checks_gui_up(self, bridge_utils_code: str) -> None:
"""GuiWaiter should check FreeCAD.GuiUp."""
assert "GuiUp" in bridge_utils_code
def test_gui_waiter_uses_repeating_timer(self, bridge_utils_code: str) -> None:
"""GuiWaiter should use a repeating timer to poll GUI state."""
# setSingleShot(False) means repeating
assert "setSingleShot(False)" in bridge_utils_code
def test_gui_waiter_has_timeout(self, bridge_utils_code: str) -> None:
"""GuiWaiter should have a timeout to prevent infinite waiting."""
assert "max_retries" in bridge_utils_code or "timeout" in bridge_utils_code
def test_gui_waiter_defers_callback(self, bridge_utils_code: str) -> None:
"""GuiWaiter should defer callback after GUI is ready.
This prevents starting the bridge too early when FreeCAD is still
initializing, which could cause race conditions.
"""
# Check for specific defer-related patterns to avoid false positives
# Look for the defer timer, defer_ms parameter, or deferring log message
has_defer_timer = "_defer_timer" in bridge_utils_code
has_defer_ms = "defer_ms" in bridge_utils_code
has_deferring_message = "deferring" in bridge_utils_code.lower()
assert any([has_defer_timer, has_defer_ms, has_deferring_message]), (
"GuiWaiter should have defer functionality "
"(expected _defer_timer, defer_ms, or 'deferring' in code)"
)
def test_gui_waiter_logs_waiting_message(self, bridge_utils_code: str) -> None:
"""GuiWaiter should log when it starts waiting."""
assert "Waiting for GUI" in bridge_utils_code
def test_gui_waiter_logs_ready_message(self, bridge_utils_code: str) -> None:
"""GuiWaiter should log when GUI becomes ready."""
assert "GUI ready" in bridge_utils_code
def test_gui_waiter_logs_timeout_error(self, bridge_utils_code: str) -> None:
"""GuiWaiter should log error on timeout."""
assert "did not become ready" in bridge_utils_code
class TestAutoStartCodePaths:
"""Tests to verify all auto-start code paths are properly handled."""
@pytest.fixture
def init_code(self) -> str:
"""Load Init.py content."""
return (ADDON_DIR / "Init.py").read_text()
def test_four_startup_scenarios_documented(self, init_code: str) -> None:
"""Init.py should document the four startup scenarios.
1. GUI already up - use timer for deferred start
2. True headless (QCoreApplication only) - start directly with background thread
3. GUI not ready but Qt available - use GuiWaiter
4. No Qt available - start directly (unusual state)
"""
# Check for documentation of scenarios
assert "GuiUp is True" in init_code or "GUI is already up" in init_code
assert "QCoreApplication" in init_code # True headless detection
assert "GUI not ready" in init_code
assert "No Qt available" in init_code or "headless" in init_code.lower()
def test_all_paths_have_logging(self, init_code: str) -> None:
"""Each code path should have diagnostic logging."""
# Count distinct log messages for different paths
log_patterns = [
"GUI already up",
"True headless mode",
"GUI not ready",
"No Qt available",
]
found = sum(1 for pattern in log_patterns if pattern in init_code)
assert found >= 4, f"Expected 4 code paths with logging, found {found}"
def test_pyside_fallback_from_2_to_6(self, init_code: str) -> None:
"""Should try PySide2 first, then fall back to PySide6."""
# Check for both imports
assert "PySide2" in init_code
assert "PySide6" in init_code
# PySide2 should be tried first (common pattern)
pyside2_pos = init_code.find("PySide2")
pyside6_pos = init_code.find("PySide6")
assert pyside2_pos < pyside6_pos, "Should try PySide2 before PySide6"
def test_true_headless_detection_via_isinstance(self, init_code: str) -> None:
"""Should use isinstance to detect true headless mode.
True headless mode is when QCoreApplication exists but is NOT a
QApplication. This happens with freecadcmd (headless FreeCAD).
In GUI mode during early startup, there may be no application yet,
or a QApplication that's being initialized. We need to wait for
FreeCAD.GuiUp in those cases.
"""
# Should check QCoreApplication.instance()
assert "QCoreApplication.instance()" in init_code
# Should use isinstance to check if it's a QApplication
assert "isinstance" in init_code
# Should have a variable tracking true headless state
assert "_is_true_headless" in init_code