From ef6c135cda360733db75ebd873c99249a660fbba Mon Sep 17 00:00:00 2001 From: Chaitanya Malhotra Date: Sat, 23 May 2026 05:39:24 +0530 Subject: [PATCH] fix: macOS wxApp warm-up + symbol cache eager loading (#195) Three-phase MCP server startup: wait for Python READY handshake, send _warmup command (pcbnew.BOARD() triggers wxApp init on macOS), connect to MCP transport only after warm-up completes. Also pre-populate symbol library cache during SymbolLibraryManager init so the first search_symbols call doesn't parse 241 .kicad_sym files from disk (30-120s). Both warm-up and cache population happen before tools are registered with the MCP client. Fixes #195 --- python/commands/library_symbol.py | 15 ++++ python/kicad_interface.py | 36 +++++++- src/server.ts | 140 +++++++++++++++++++++++++++++- 3 files changed, 187 insertions(+), 4 deletions(-) diff --git a/python/commands/library_symbol.py b/python/commands/library_symbol.py index 9c7630b..39bae26 100644 --- a/python/commands/library_symbol.py +++ b/python/commands/library_symbol.py @@ -55,6 +55,21 @@ class SymbolLibraryManager: self.libraries: Dict[str, str] = {} # nickname -> path mapping self.symbol_cache: Dict[str, List[SymbolInfo]] = {} # library -> [SymbolInfo] self._load_libraries() + self._warm_cache() + + def _warm_cache(self) -> None: + """Pre-parse all symbol libraries so the first search is fast. + + Without this, the first ``search_symbols`` call parses every + .kicad_sym file on demand, which can take 30-120 s across + 200+ libraries. By populating the cache here (during startup, + before the READY handshake) the cost is paid once. + """ + for nickname in list(self.libraries.keys()): + try: + self.list_symbols(nickname) + except Exception: + logger.debug("Skipping unparseable library: %s", nickname) def _load_libraries(self) -> None: """Load libraries from sym-lib-table files""" diff --git a/python/kicad_interface.py b/python/kicad_interface.py index d197068..1ef2a3d 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -170,7 +170,8 @@ if not USE_IPC_BACKEND and KICAD_BACKEND != "ipc": import pcbnew # type: ignore logger.info(f"Successfully imported pcbnew module from: {pcbnew.__file__}") - logger.info(f"pcbnew version: {pcbnew.GetBuildVersion()}") + # Deferred — GetBuildVersion() triggers 55-65 s wxApp init on macOS. + # The _warmup handler pays this cost during startup (not on first tool call). logger.warning("Using SWIG backend - changes require manual reload in KiCAD UI") except ImportError as e: logger.error(f"Failed to import pcbnew module: {e}") @@ -460,6 +461,8 @@ class KiCADInterface: # UI/Process management commands "check_kicad_ui": self._handle_check_kicad_ui, "launch_kicad_ui": self._handle_launch_kicad_ui, + # Internal warm-up (pays wxApp init cost during startup) + "_warmup": self._handle_warmup, # IPC-specific commands (real-time operations) "get_backend_info": self._handle_get_backend_info, "ipc_add_track": self._handle_ipc_add_track, @@ -5610,6 +5613,35 @@ print("ok") # ========================================================================= # Legacy IPC command handlers (explicit ipc_* commands) + + def _handle_warmup(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Force full pcbnew/wxApp initialisation. + + On macOS the wxApp singleton is created lazily on the first + pcbnew operation that needs it (not on ``import pcbnew``). + That first call can take 55-65 s outside the KiCad GUI, which + exceeds the 30 s default MCP-client tool-call timeout. + + This handler is called by the TypeScript server during startup + (with a 120 s timeout) so the cost is paid before any user + tools are registered with the MCP client. + """ + import time + start = time.monotonic() + try: + # pcbnew.BOARD() triggers wxApp creation on macOS. + # GetBuildVersion() alone is too cheap — it doesn't + # force the wxWidgets event loop to materialise. + board = pcbnew.BOARD() + del board + ver = pcbnew.GetBuildVersion() + elapsed = time.monotonic() - start + logger.info(f"Warm-up complete: pcbnew {ver} ({elapsed:.1f}s)") + return {"success": True, "version": ver, "elapsed_s": round(elapsed, 1)} + except Exception as exc: + elapsed = time.monotonic() - start + logger.error(f"Warm-up failed after {elapsed:.1f}s: {exc}") + return {"success": False, "message": str(exc), "elapsed_s": round(elapsed, 1)} # ========================================================================= def _handle_get_backend_info(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -5984,6 +6016,8 @@ def main() -> None: logger.info("Starting KiCAD interface...") interface = KiCADInterface() + # Signal to the TypeScript server that the stdin loop is live. + _write_response(_response_fd, {"type": "ready"}) try: logger.info("Processing commands from stdin...") diff --git a/src/server.ts b/src/server.ts index a3a4a6e..95ed169 100644 --- a/src/server.ts +++ b/src/server.ts @@ -177,6 +177,15 @@ export class KiCADMcpServer { timeoutHandle: NodeJS.Timeout; } | null = null; + /** Resolved when Python prints {"type":"ready"} — stdin loop is live. */ + private readyPromise: Promise; + private resolveReady!: () => void; + private rejectReady!: (err: Error) => void; + /** Accumulates stdout until the READY marker is seen. */ + private startupBuffer: string = ""; + /** True after READY marker detected; persistent handler takes over. */ + private readyDetected: boolean = false; + /** * Constructor for the KiCAD MCP Server * @param kicadScriptPath Path to the Python KiCAD interface script @@ -198,6 +207,11 @@ export class KiCADMcpServer { version: "1.0.0", description: "MCP server for KiCAD PCB design operations", }); + // Create the ready promise (resolved when Python sends {"type":"ready"}) + this.readyPromise = new Promise((resolve, reject) => { + this.resolveReady = resolve; + this.rejectReady = reject; + }); // Initialize STDIO transport this.stdioTransport = new StdioServerTransport(); @@ -452,14 +466,50 @@ export class KiCADMcpServer { }); } - // Set up persistent stdout handler (instead of adding/removing per request) + // ——— Phase 1: stdout handler that detects the READY marker ——— + // Before Python reaches main() it may spend 55-65 s on wxApp init. + // The stdin loop is only live after main() prints {"type":"ready"}. + // Until then we buffer everything and scan for that exact JSON line. if (this.pythonProcess.stdout) { this.pythonProcess.stdout.on("data", (data: Buffer) => { - this.handlePythonResponse(data); + if (this.readyDetected) { + // Persistent handler (post-warm-up) + this.handlePythonResponse(data); + } else { + this.startupBuffer += data.toString(); + const lines = this.startupBuffer.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + try { + const obj = JSON.parse(line); + if (obj.type === "ready") { + logger.info("Python process READY — stdin loop is live"); + this.readyDetected = true; + // Replay any remaining buffered lines through the persistent handler + const remaining = lines.slice(i + 1).join("\n"); + if (remaining.trim()) { + this.handlePythonResponse(Buffer.from(remaining)); + } + this.resolveReady(); + return; + } + } catch { + // Not valid JSON yet; keep buffering + } + } + } }); } - // Connect server to STDIO transport + // ——— Phase 2: wait for Python READY, then send warm-up ——— + logger.info("Waiting for Python process to be ready..."); + await this.waitForReady(120_000); + logger.info("Python process is ready. Sending warm-up command..."); + await this.runWarmup(120_000); + logger.info("Warm-up complete — pcbnew/wxApp initialised"); + + // ——— Phase 3: only now connect to MCP transport ——— logger.info("Connecting MCP server to STDIO transport..."); try { await this.server.connect(this.stdioTransport); @@ -469,6 +519,7 @@ export class KiCADMcpServer { throw error; } + // Write a ready message to stderr (for debugging) process.stderr.write("KiCAD MCP SERVER READY\n"); @@ -494,6 +545,89 @@ export class KiCADMcpServer { logger.info("KiCAD MCP server stopped"); } + /** + * Wait for the Python process to print {"type":"ready"} on stdout, + * signalling that the stdin loop is live and the process can accept + * commands. + */ + private async waitForReady(timeoutMs: number): Promise { + return new Promise((_resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error( + `Python process did not send READY within ${timeoutMs / 1000} s` + )); + }, timeoutMs); + this.readyPromise.then(() => { + clearTimeout(timeout); + _resolve(); + }).catch(reject); + }); + } + + /** + * Send a _warmup command to the Python process to force full + * pcbnew/wxApp initialisation. On macOS this can take 55-65 s; + * we use a generous timeout so the cost is paid during startup + * rather than on the first user tool call. + * + * Wires into the existing request infrastructure so the persistent + * stdout handler (already active post-READY) processes the response. + */ + private async runWarmup(timeoutMs: number): Promise { + return new Promise((resolve) => { + if (!this.pythonProcess || !this.pythonProcess.stdin) { + logger.warn("Python process not running — skipping warm-up"); + resolve(); + return; + } + + const requestStr = JSON.stringify({ command: "_warmup", params: {} }); + this.responseBuffer = ""; + + const timeoutHandle = setTimeout(() => { + logger.warn( + `Warm-up timed out after ${timeoutMs / 1000} s — ` + + "continuing without full initialisation" + ); + this.responseBuffer = ""; + this.processingRequest = false; + this.currentRequestHandler = null; + resolve(); + }, timeoutMs); + + // Use the existing request infrastructure to avoid race conditions + // with the persistent stdout handler. + this.processingRequest = true; + this.currentRequestHandler = { + resolve: (result: any) => { + clearTimeout(timeoutHandle); + this.processingRequest = false; + this.currentRequestHandler = null; + if (result?.success) { + logger.info( + `Warm-up succeeded: pcbnew ${result.version} (${result.elapsed_s}s)` + ); + } else { + logger.warn( + `Warm-up returned failure: ${result?.message || "unknown"} — continuing` + ); + } + resolve(); + }, + reject: (err: Error) => { + clearTimeout(timeoutHandle); + this.processingRequest = false; + this.currentRequestHandler = null; + logger.warn(`Warm-up failed: ${err.message} — continuing`); + resolve(); // don't fail the whole server + }, + timeoutHandle, + }; + + this.pythonProcess.stdin.write(requestStr + "\n"); + }); + } + /** * Call the KiCAD scripting interface to execute commands *