fix(swig): detect and recover from BOARD proxy dehydration (#173)
KiCAD nightly builds occasionally return a SwigPyObject from pcbnew.LoadBoard, and SaveBoard can leave self.board with no method dispatch as a side-effect of certain sequences (delete_trace + auto-save is the one users have hit in the wild). Before this fix, open_project would keep reporting "Opened project: foo.kicad_pcb" while every subsequent board operation failed with AttributeError on GetDesignSettings / GetBoardEdgesBoundingBox / GetCurrentViaSize, with no path to recovery short of restarting the MCP server. Add two helpers in KiCADInterface: * _is_board_healthy(board=None) probes for stable BOARD methods (GetDesignSettings, GetBoardEdgesBoundingBox, GetFileName) — these are missing on a dehydrated SwigPyObject, so hasattr() catches the state without segfaulting. * _safe_load_board(path) wraps pcbnew.LoadBoard, checks health, and on dehydration reloads the pcbnew module via importlib.reload and retries once. Returns None when recovery is impossible so callers surface real failure rather than fake success. Wire the helpers in: * handle_command's open_project / create_project path validates the loaded board and either recovers (with a warnings[] entry) or returns success=False with an explicit "restart the MCP server" errorDetails — never claims success when the board is unusable. * _auto_save_board now detects dehydration introduced by SaveBoard itself and reloads from disk so the next command sees a usable proxy. This is the post-delete_trace failure mode users hit. * _handle_place_component, _handle_sync_schematic_to_board, _handle_import_svg_logo and _handle_refill_zones all go through _safe_load_board instead of bare LoadBoard, surfacing real errors consistently. Also fix two adjacent issues observed in the same incident: * _handle_check_kicad_ui used to call manager.is_running() and manager.get_process_info() separately, with different detection methods. They could disagree, producing the confusing running=True, processes=[] state users hit after manually quitting KiCAD. processes is now the single source of truth and running is derived from len(). * run_drc accepts a timeoutSec param (default 600s, clamped to [10, 1800]) so callers with smaller MCP transport budgets can bound the kicad-cli subprocess. Same timeout is applied to the optional report-generation subprocess. Error message names the actual timeout that fired. Tests: tests/test_swig_dehydration.py adds 17 unit tests covering detection, recovery, the open_project surfacing path, the auto-save post-recovery path, the check_kicad_ui consistency, and the run_drc timeout clamping. Full suite: same 12 pre-existing failures both before and after this change, +17 new tests passing. Note: the SWIG dehydration is fundamentally a pcbnew memory bug exposed by repeated LoadBoard calls in a single Python process; this PR is a defensive recovery layer, not a fix to the underlying binding. Complementary to PR #151 (auto-save-guard), which expands the LoadBoard call rate by refusing saves on external file change and forcing re-open_project cycles.
This commit is contained in:
@@ -187,6 +187,13 @@ class DesignRuleCommands:
|
||||
}
|
||||
|
||||
report_path = params.get("reportPath")
|
||||
# Caller-overridable timeout (seconds). Defaults to 600s for big boards
|
||||
# but smaller MCP transport budgets (e.g. 120s) can lower it explicitly.
|
||||
try:
|
||||
timeout_sec = int(params.get("timeoutSec", 600))
|
||||
except (TypeError, ValueError):
|
||||
timeout_sec = 600
|
||||
timeout_sec = max(10, min(timeout_sec, 1800)) # clamp to [10, 1800]
|
||||
|
||||
# Get the board file path
|
||||
board_file = self.board.GetFileName()
|
||||
@@ -225,14 +232,14 @@ class DesignRuleCommands:
|
||||
board_file,
|
||||
]
|
||||
|
||||
logger.info(f"Running DRC command: {' '.join(cmd)}")
|
||||
logger.info(f"Running DRC command (timeout={timeout_sec}s): {' '.join(cmd)}")
|
||||
|
||||
# Run DRC
|
||||
# Run DRC. subprocess.run kills the child on TimeoutExpired.
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600, # 10 minute timeout for large boards (21MB PCB needs time)
|
||||
timeout=timeout_sec,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
@@ -318,7 +325,7 @@ class DesignRuleCommands:
|
||||
"mm",
|
||||
board_file,
|
||||
]
|
||||
subprocess.run(cmd_report, capture_output=True, timeout=600)
|
||||
subprocess.run(cmd_report, capture_output=True, timeout=timeout_sec)
|
||||
|
||||
# Return summary only (not full violations list)
|
||||
return {
|
||||
@@ -339,11 +346,14 @@ class DesignRuleCommands:
|
||||
os.unlink(json_output)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("DRC command timed out")
|
||||
logger.error(f"DRC command timed out after {timeout_sec}s")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "DRC command timed out",
|
||||
"errorDetails": "Command took longer than 600 seconds (10 minutes)",
|
||||
"errorDetails": (
|
||||
f"Command took longer than {timeout_sec} seconds; "
|
||||
"raise timeoutSec param for very large boards"
|
||||
),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error running DRC: {str(e)}")
|
||||
|
||||
Reference in New Issue
Block a user