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
This commit is contained in:
@@ -55,6 +55,21 @@ class SymbolLibraryManager:
|
|||||||
self.libraries: Dict[str, str] = {} # nickname -> path mapping
|
self.libraries: Dict[str, str] = {} # nickname -> path mapping
|
||||||
self.symbol_cache: Dict[str, List[SymbolInfo]] = {} # library -> [SymbolInfo]
|
self.symbol_cache: Dict[str, List[SymbolInfo]] = {} # library -> [SymbolInfo]
|
||||||
self._load_libraries()
|
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:
|
def _load_libraries(self) -> None:
|
||||||
"""Load libraries from sym-lib-table files"""
|
"""Load libraries from sym-lib-table files"""
|
||||||
|
|||||||
@@ -170,7 +170,8 @@ if not USE_IPC_BACKEND and KICAD_BACKEND != "ipc":
|
|||||||
import pcbnew # type: ignore
|
import pcbnew # type: ignore
|
||||||
|
|
||||||
logger.info(f"Successfully imported pcbnew module from: {pcbnew.__file__}")
|
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")
|
logger.warning("Using SWIG backend - changes require manual reload in KiCAD UI")
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
logger.error(f"Failed to import pcbnew module: {e}")
|
logger.error(f"Failed to import pcbnew module: {e}")
|
||||||
@@ -460,6 +461,8 @@ class KiCADInterface:
|
|||||||
# UI/Process management commands
|
# UI/Process management commands
|
||||||
"check_kicad_ui": self._handle_check_kicad_ui,
|
"check_kicad_ui": self._handle_check_kicad_ui,
|
||||||
"launch_kicad_ui": self._handle_launch_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)
|
# IPC-specific commands (real-time operations)
|
||||||
"get_backend_info": self._handle_get_backend_info,
|
"get_backend_info": self._handle_get_backend_info,
|
||||||
"ipc_add_track": self._handle_ipc_add_track,
|
"ipc_add_track": self._handle_ipc_add_track,
|
||||||
@@ -5610,6 +5613,35 @@ print("ok")
|
|||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Legacy IPC command handlers (explicit ipc_* commands)
|
# 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]:
|
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...")
|
logger.info("Starting KiCAD interface...")
|
||||||
interface = KiCADInterface()
|
interface = KiCADInterface()
|
||||||
|
# Signal to the TypeScript server that the stdin loop is live.
|
||||||
|
_write_response(_response_fd, {"type": "ready"})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info("Processing commands from stdin...")
|
logger.info("Processing commands from stdin...")
|
||||||
|
|||||||
140
src/server.ts
140
src/server.ts
@@ -177,6 +177,15 @@ export class KiCADMcpServer {
|
|||||||
timeoutHandle: NodeJS.Timeout;
|
timeoutHandle: NodeJS.Timeout;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
|
|
||||||
|
/** Resolved when Python prints {"type":"ready"} — stdin loop is live. */
|
||||||
|
private readyPromise: Promise<void>;
|
||||||
|
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
|
* Constructor for the KiCAD MCP Server
|
||||||
* @param kicadScriptPath Path to the Python KiCAD interface script
|
* @param kicadScriptPath Path to the Python KiCAD interface script
|
||||||
@@ -198,6 +207,11 @@ export class KiCADMcpServer {
|
|||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
description: "MCP server for KiCAD PCB design operations",
|
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
|
// Initialize STDIO transport
|
||||||
this.stdioTransport = new StdioServerTransport();
|
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) {
|
if (this.pythonProcess.stdout) {
|
||||||
this.pythonProcess.stdout.on("data", (data: Buffer) => {
|
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...");
|
logger.info("Connecting MCP server to STDIO transport...");
|
||||||
try {
|
try {
|
||||||
await this.server.connect(this.stdioTransport);
|
await this.server.connect(this.stdioTransport);
|
||||||
@@ -469,6 +519,7 @@ export class KiCADMcpServer {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Write a ready message to stderr (for debugging)
|
// Write a ready message to stderr (for debugging)
|
||||||
process.stderr.write("KiCAD MCP SERVER READY\n");
|
process.stderr.write("KiCAD MCP SERVER READY\n");
|
||||||
|
|
||||||
@@ -494,6 +545,89 @@ export class KiCADMcpServer {
|
|||||||
logger.info("KiCAD MCP server stopped");
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
return new Promise<void>((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
|
* Call the KiCAD scripting interface to execute commands
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user