From 47fac6cbfc20b572d1aa537657ee8197eeb58743 Mon Sep 17 00:00:00 2001 From: mixelpixx Date: Mon, 27 Jul 2026 15:24:46 -0400 Subject: [PATCH] feat(serial): detect crash/reset events in board output The tooling used to watch a board crash-loop and say nothing: during the first hardware session the chip panicked and rebooted five times while serial reads cheerfully returned lines as if nothing happened - the only tell was a duplicated boot banner the caller had to notice themselves. An agent cannot distinguish "board is quiet" from "board is dead" without this. The line scanner now classifies crash signatures as it assembles lines: reset rst:0x.. (REASON) reason captured as detail panic Guru Meditation Error following Backtrace: attached as detail watchdog Task watchdog got triggered / AVR "wdt reset" brownout Brownout detector was triggered abort abort() was called Events (capped at 50) come back on every read/wait_for response - paged reads only return events newer than `since` - and get_config reports event_count/last_event. A reset, panic, brownout or abort also resolves any pending wait_for early with the event attached: the output being waited for is not coming from a board that just crashed. Watchdog warnings only record, since they can be transient. clear() drops events; cursors stay monotonic. Verified on the ESP32-S3 with a crash-on-command sketch driven over MCP: esp_restart() ended a pending wait with {type:reset, detail:RTC_SW_CPU_RST}; a null-pointer store produced {type:panic, line:"Guru Meditation Error: Core 1 panic'ed (StoreProhibited)..."} with the real backtrace attached as detail; abort() recorded and rebooted; the watchdog signature matches the task_wdt lines captured from this board's actual crash-loop, and benign lines containing the word "reset" do not false-positive. Co-Authored-By: Claude Opus 4.8 --- arduino-mcp-extension/README.md | 19 +++ .../src/common/mcp-tool-router.ts | 2 +- arduino-mcp-extension/src/common/mcp-tools.ts | 2 +- .../src/node/mcp-serial-manager.ts | 132 +++++++++++++++++- 4 files changed, 152 insertions(+), 3 deletions(-) diff --git a/arduino-mcp-extension/README.md b/arduino-mcp-extension/README.md index a94b79bba37..dff34328968 100644 --- a/arduino-mcp-extension/README.md +++ b/arduino-mcp-extension/README.md @@ -377,6 +377,25 @@ offset, a `dropped` count if the 512 KB buffer overflowed in between, and `read` returns the familiar tail snapshot. `wait_for` resolves with the matching line, or `timed_out: true` / `disconnected: true` (never an error). +**Crash detection:** the output stream is scanned for crash/reset signatures +and matches come back as `events` on every `read`/`wait_for` response (and as +`event_count`/`last_event` in `get_config`): + +| Event type | Signature | +|------------|-----------| +| `reset` | ESP32 `rst:0x… (REASON)` boot line (reason in `detail`) | +| `panic` | `Guru Meditation Error` (cause in `detail`, backtrace attached) | +| `watchdog` | `Task watchdog got triggered`, AVR `wdt reset` | +| `brownout` | `Brownout detector was triggered` (usually power supply) | +| `abort` | `abort() was called` | + +A `reset`/`panic`/`brownout`/`abort` also **ends a pending `wait_for` early** +with the event attached — the output you were waiting for is not coming from a +board that just crashed. Watchdog warnings only record (they can be +transient). Note: on native-USB boards (ESP32-S2/S3/C3 with CDC), the ROM +bootloader's `rst:` line may not appear on the USB port; app-level panics and +watchdog messages still do. + ### arduino_library | Action | Parameters | Description | diff --git a/arduino-mcp-extension/src/common/mcp-tool-router.ts b/arduino-mcp-extension/src/common/mcp-tool-router.ts index 202b63785fa..f39e8dea03e 100644 --- a/arduino-mcp-extension/src/common/mcp-tool-router.ts +++ b/arduino-mcp-extension/src/common/mcp-tool-router.ts @@ -51,7 +51,7 @@ export const TOOL_CATEGORIES: ToolCategory[] = [ description: 'Serial monitor operations - connect, read output, send data to devices.', toolNames: ['arduino_serial'], useWhen: - 'Debugging via serial output, sending commands to device, waiting for specific output (wait_for), following logs losslessly with cursor-based reads', + 'Debugging via serial output, sending commands to device, waiting for specific output (wait_for), following logs losslessly with cursor-based reads, diagnosing crashes/resets via auto-detected events', }, { name: 'library', diff --git a/arduino-mcp-extension/src/common/mcp-tools.ts b/arduino-mcp-extension/src/common/mcp-tools.ts index 4b906287051..2094214615e 100644 --- a/arduino-mcp-extension/src/common/mcp-tools.ts +++ b/arduino-mcp-extension/src/common/mcp-tools.ts @@ -277,7 +277,7 @@ export const ARDUINO_TOOLS: ToolDefinition[] = [ { name: 'arduino_serial', description: - 'Serial monitor operations - connect to a board, read its output, send data, change the baud rate. Reads are cursor-based: every read returns a `cursor`; pass it back as `since` to page through output losslessly. wait_for blocks until a line matching a pattern arrives. The connection is shared with the IDE serial monitor.', + 'Serial monitor operations - connect to a board, read its output, send data, change the baud rate. Reads are cursor-based: every read returns a `cursor`; pass it back as `since` to page through output losslessly. wait_for blocks until a line matching a pattern arrives. Crash/reset signatures in the output (ESP32 panics, watchdog, brownout, reset reasons) are detected automatically and returned as `events` - a reset or crash also ends a pending wait_for early. The connection is shared with the IDE serial monitor.', inputSchema: { type: 'object', properties: { diff --git a/arduino-mcp-extension/src/node/mcp-serial-manager.ts b/arduino-mcp-extension/src/node/mcp-serial-manager.ts index 66048a7c339..568e3985737 100644 --- a/arduino-mcp-extension/src/node/mcp-serial-manager.ts +++ b/arduino-mcp-extension/src/node/mcp-serial-manager.ts @@ -26,7 +26,52 @@ export const SUPPORTED_BAUD_RATES = [ const MAX_BUFFER_CHARS = 512 * 1024; // cap the capture buffer at 512 KB -/** Result shape for waitFor - see that method for the three resolutions. */ +/** + * A crash/reset signature detected in the board's output. Surfaced through + * read/wait_for/status so an agent can tell "the board rebooted five times" + * apart from "the board is quiet" - previously these looked identical. + */ +export interface SerialEvent { + type: 'reset' | 'panic' | 'watchdog' | 'brownout' | 'abort'; + /** The line that triggered the detection. */ + line: string; + /** Global cursor just past that line. */ + cursor: number; + timestamp: number; + /** Reset reason, panic cause, or the first backtrace line. */ + detail?: string; +} + +const MAX_EVENTS = 50; + +/** + * Line-anchored signatures for the funnel scanner. Order matters: the first + * match wins. ESP32 (esp-idf) signatures plus the classic AVR wdt marker. + */ +const EVENT_SIGNATURES: Array<{ + type: SerialEvent['type']; + pattern: RegExp; + detail?: (match: RegExpMatchArray) => string; +}> = [ + { + type: 'panic', + pattern: /Guru Meditation Error:?\s*(.*)/, + detail: (m) => m[1]?.trim() || 'panic', + }, + { type: 'brownout', pattern: /Brownout detector was triggered/ }, + { type: 'abort', pattern: /abort\(\) was called/ }, + { + type: 'watchdog', + pattern: /Task watchdog got triggered|\bwdt reset/i, + }, + { + type: 'reset', + pattern: /^rst:0x[0-9a-f]+\s*\(([^)]+)\)/i, + detail: (m) => m[1], + }, +]; + +/** Result shape for waitFor - see that method for the resolutions. */ export interface SerialWaitResult { matched: boolean; line?: string; @@ -35,6 +80,9 @@ export interface SerialWaitResult { timed_out?: boolean; disconnected?: boolean; hint?: string; + /** Set when a crash/reset ended the wait early. */ + event?: SerialEvent; + message?: string; } interface SerialWaiter { @@ -63,6 +111,10 @@ interface ActiveConnection { lineRemainder: string; /** Pending wait_for calls, resolved by the line scanner. */ waiters: SerialWaiter[]; + /** Detected crash/reset events, oldest first, capped at MAX_EVENTS. */ + events: SerialEvent[]; + /** Last panic/abort event still waiting for its Backtrace line. */ + crashPendingDetail: SerialEvent | null; } function escapeRegExp(text: string): string { @@ -203,6 +255,8 @@ export class MCPSerialManager { bufferStartOffset: 0, lineRemainder: '', waiters: [], + events: [], + crashPendingDetail: null, }; ws.on('open', () => { @@ -283,6 +337,8 @@ export class MCPSerialManager { line: string, lineEndCursor: number ): void { + this.classifyLine(connection, line, lineEndCursor); + if (!connection.waiters.length) { return; } @@ -303,6 +359,70 @@ export class MCPSerialManager { } } + /** Matches crash/reset signatures and records SerialEvents. */ + private classifyLine( + connection: ActiveConnection, + line: string, + lineEndCursor: number + ): void { + // A panic/abort is followed by its backtrace a few lines later - attach it + // as detail instead of recording a separate event. + if (connection.crashPendingDetail && /^Backtrace:/.test(line)) { + connection.crashPendingDetail.detail = line; + connection.crashPendingDetail = null; + return; + } + + for (const signature of EVENT_SIGNATURES) { + const match = line.match(signature.pattern); + if (!match) { + continue; + } + const event: SerialEvent = { + type: signature.type, + line, + cursor: lineEndCursor, + timestamp: Date.now(), + detail: signature.detail?.(match), + }; + connection.events.push(event); + if (connection.events.length > MAX_EVENTS) { + connection.events.shift(); + } + if (event.type === 'panic' || event.type === 'abort') { + connection.crashPendingDetail = event; + } + // Terminal events end pending waits early: the awaited output is not + // coming from a board that just crashed or rebooted. A watchdog warning + // can be transient (it does not always abort), so it only records. + if (event.type !== 'watchdog') { + this.resolveWaitersWithEvent(connection, event); + } + return; // first signature wins + } + } + + private resolveWaitersWithEvent( + connection: ActiveConnection, + event: SerialEvent + ): void { + const waiters = connection.waiters; + connection.waiters = []; + for (const waiter of waiters) { + clearTimeout(waiter.timer); + waiter.resolve({ + matched: false, + event, + cursor: event.cursor, + message: `Board ${ + event.type === 'reset' ? 'reset' : 'crashed' + } while waiting (${event.type}${ + event.detail ? `: ${event.detail}` : '' + })`, + }); + } + } + /** Resolves every pending waiter as disconnected (close, error, disconnect). */ private flushWaiters(connection: ActiveConnection): void { const waiters = connection.waiters; @@ -356,6 +476,7 @@ export class MCPSerialManager { cursor: number; dropped: number; has_more: boolean; + events: SerialEvent[]; } { const connection = this.requireConnection(); const end = connection.bufferStartOffset + connection.buffer.length; @@ -373,6 +494,7 @@ export class MCPSerialManager { cursor: end, dropped: 0, has_more: false, + events: [...connection.events], }; } @@ -401,6 +523,8 @@ export class MCPSerialManager { cursor: clamped + pos, dropped, has_more: region.indexOf('\n', pos) !== -1, + // Only events the caller has not seen yet. + events: connection.events.filter((e) => e.cursor > since), }; } @@ -514,6 +638,8 @@ export class MCPSerialManager { c.bufferStartOffset += c.buffer.length; c.buffer = ''; c.lineRemainder = ''; + c.events = []; + c.crashPendingDetail = null; } } @@ -524,6 +650,8 @@ export class MCPSerialManager { board: string | null; cursor: number | null; buffered_chars: number | null; + event_count: number; + last_event: SerialEvent | null; } { const c = this.connection; return { @@ -533,6 +661,8 @@ export class MCPSerialManager { board: c?.board.name ?? null, cursor: c ? c.bufferStartOffset + c.buffer.length : null, buffered_chars: c ? c.buffer.length : null, + event_count: c?.events.length ?? 0, + last_event: c?.events.length ? c.events[c.events.length - 1] : null, }; }