Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions arduino-mcp-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion arduino-mcp-extension/src/common/mcp-tool-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion arduino-mcp-extension/src/common/mcp-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
132 changes: 131 additions & 1 deletion arduino-mcp-extension/src/node/mcp-serial-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -203,6 +255,8 @@ export class MCPSerialManager {
bufferStartOffset: 0,
lineRemainder: '',
waiters: [],
events: [],
crashPendingDetail: null,
};

ws.on('open', () => {
Expand Down Expand Up @@ -283,6 +337,8 @@ export class MCPSerialManager {
line: string,
lineEndCursor: number
): void {
this.classifyLine(connection, line, lineEndCursor);

if (!connection.waiters.length) {
return;
}
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -373,6 +494,7 @@ export class MCPSerialManager {
cursor: end,
dropped: 0,
has_more: false,
events: [...connection.events],
};
}

Expand Down Expand Up @@ -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),
};
}

Expand Down Expand Up @@ -514,6 +638,8 @@ export class MCPSerialManager {
c.bufferStartOffset += c.buffer.length;
c.buffer = '';
c.lineRemainder = '';
c.events = [];
c.crashPendingDetail = null;
}
}

Expand All @@ -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 {
Expand All @@ -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,
};
}

Expand Down