fix: prevent pcbnew stdout noise from causing sync_schematic_to_board timeouts

The TS<->Python communication channel uses stdout for JSON responses.
pcbnew's C++ SWIG layer can write warnings and diagnostics directly to
C-level stdout (fd 1), corrupting the JSON framing. The TS parser then
never sees valid JSON and the command times out after 30 seconds.

Three changes fix this:

1. Python stdout redirect: In main(), save the original stdout fd for
   exclusive JSON response use, then redirect fd 1 to stderr so all
   pcbnew C++ output goes to logs instead of the response pipe.

2. Robust TS JSON parser: tryParseResponse() now uses newline-delimited
   parsing as a fallback. The Python side writes single-line JSON
   terminated by \n; the parser uses this as the completion signal
   instead of brace-matching, which prevents premature resolution of
   truncated chunked responses. Non-JSON preamble lines are logged
   and stripped.

3. Fix stray print() calls: Converted print() to logger in
   component_schematic.py and library_schematic.py so they don't
   leak to stdout during normal operations.

Also adds sync_schematic_to_board to the longRunningCommands list for
an appropriate timeout value.
This commit is contained in:
William Viana
2026-04-03 11:24:42 -07:00
parent f79a3d6435
commit 0bc00b1451
4 changed files with 137 additions and 50 deletions

View File

@@ -516,7 +516,13 @@ export class KiCADMcpServer {
// Determine timeout based on command type
// DRC and export operations need longer timeouts for large boards
let commandTimeout = 30000; // Default 30 seconds
const longRunningCommands = ["run_drc", "export_gerber", "export_pdf", "export_3d"];
const longRunningCommands = [
"run_drc",
"export_gerber",
"export_pdf",
"export_3d",
"sync_schematic_to_board",
];
if (longRunningCommands.includes(command)) {
commandTimeout = 600000; // 10 minutes for long operations
logger.info(`Using extended timeout (${commandTimeout / 1000}s) for command: ${command}`);
@@ -550,7 +556,21 @@ export class KiCADMcpServer {
}
/**
* Try to parse a complete JSON response from the buffer
* Try to parse a complete JSON response from the buffer.
*
* Responses from the Python side are single-line JSON terminated by '\n'
* (written via _write_response). The buffer may also contain non-JSON
* preamble lines (e.g. C-level warnings from pcbnew that leaked to the
* response fd before the redirect took effect).
*
* Strategy:
* 1. Fast path: JSON.parse(buffer) — works for clean, complete responses
* (JSON.parse tolerates trailing whitespace/newlines).
* 2. If that fails and the buffer has no '\n' yet, the response line is
* still arriving in chunks — keep collecting.
* 3. If the buffer has '\n', split into lines and search from the END for
* a parseable JSON line. This avoids prematurely resolving with a
* truncated JSON object when a large response is still chunking in.
*/
private tryParseResponse(): void {
if (!this.currentRequestHandler) {
@@ -564,37 +584,83 @@ export class KiCADMcpServer {
return;
}
let result: any;
// Fast path: try to parse the response as JSON. Handles the common
// case of a clean, complete JSON response (possibly with trailing \n).
try {
// Try to parse the response as JSON
const result = JSON.parse(this.responseBuffer);
// If we get here, we have a valid JSON response
logger.debug(
`Completed KiCAD command with result: ${result.success ? "success" : "failure"}`,
);
// Clear the timeout since we got a response
if (this.currentRequestHandler.timeoutHandle) {
clearTimeout(this.currentRequestHandler.timeoutHandle);
result = JSON.parse(this.responseBuffer);
} catch {
// Direct parse failed. Either the response is still arriving in
// chunks, or the buffer has non-JSON preamble from pcbnew.
//
// The Python side writes each response as a single line of JSON
// terminated by \n. We use the newline as the completion signal:
// if there is no \n in the buffer yet, the JSON line is still
// being assembled from chunks — keep collecting.
if (!this.responseBuffer.includes("\n")) {
return;
}
// Get the handler before clearing
const handler = this.currentRequestHandler;
// Buffer contains newline(s). Split into lines and look for a
// complete JSON object, searching from the END so that preamble
// lines (which may themselves contain '{') are skipped.
const lines = this.responseBuffer.split("\n");
let jsonLineIndex = -1;
// Clear state
this.responseBuffer = "";
this.currentRequestHandler = null;
this.processingRequest = false;
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i].trim();
if (line.length === 0) continue;
if (!line.startsWith("{")) continue;
// Resolve the promise with the result
handler.resolve(result);
try {
result = JSON.parse(line);
jsonLineIndex = i;
break;
} catch {
// Looks like JSON but doesn't parse — could be an incomplete
// final line still being chunked. Keep collecting.
continue;
}
}
// Process next request if any
setTimeout(() => this.processNextRequest(), 0);
} catch (e) {
// Not a complete JSON yet, keep collecting data
// This is normal for large responses that come in chunks
if (jsonLineIndex < 0) {
// No parseable JSON line found yet. Either only preamble has
// arrived, or the JSON line is split across the last \n boundary
// and is still incomplete. Keep collecting.
return;
}
// Log any preceding non-JSON lines as preamble
const preambleLines = lines.slice(0, jsonLineIndex).filter((l) => l.trim().length > 0);
if (preambleLines.length > 0) {
logger.warn(
`Stripped non-JSON preamble from Python response: ${preambleLines.join(" | ")}`,
);
}
}
// If we get here, we have a valid JSON response
logger.debug(`Completed KiCAD command with result: ${result.success ? "success" : "failure"}`);
// Clear the timeout since we got a response
if (this.currentRequestHandler.timeoutHandle) {
clearTimeout(this.currentRequestHandler.timeoutHandle);
}
// Get the handler before clearing
const handler = this.currentRequestHandler;
// Clear state
this.responseBuffer = "";
this.currentRequestHandler = null;
this.processingRequest = false;
// Resolve the promise with the result
handler.resolve(result);
// Process next request if any
setTimeout(() => this.processNextRequest(), 0);
}
/**