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:
Matthew Runo
2026-05-22 14:38:59 -07:00
committed by GitHub
parent fa6cdcc0cd
commit f65eaf2798
3 changed files with 567 additions and 24 deletions

View File

@@ -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)}")