zeroclaw-labs/zeroclaw · error
Serial response skip limit exceeded (maximum {MAX_SKIPPED_RE
Error message
Serial response skip limit exceeded (maximum {MAX_SKIPPED_RESPONSE_FRAMES} frames) What it means
send_request writes a JSON request with a unique numeric id, then reads newline-terminated frames and returns the first frame that is valid JSON with a matching id. Any frame that fails to parse or carries a different id counts toward MAX_SKIPPED_RESPONSE_FRAMES = 16; when the 17th non-matching frame arrives, the request fails instead of skipping further. The whole exchange is also bounded by a 5-second deadline (SERIAL_TIMEOUT_SECS) that unsolicited frames cannot extend.
Source
Thrown at crates/zeroclaw-hardware/src/peripherals/serial.rs:66
loop {
let mut buf = Vec::new();
let mut byte = [0u8; 1];
loop {
port.read_exact(&mut byte).await?;
if byte[0] == b'\n' {
break;
}
buf.push(byte[0]);
}
if let Ok(resp) = serde_json::from_slice::<Value>(&buf)
&& resp.get("id").and_then(Value::as_str) == Some(id_str.as_str())
{
return Ok(resp);
}
if skipped_frames == MAX_SKIPPED_RESPONSE_FRAMES {
anyhow::bail!(
"Serial response skip limit exceeded (maximum {MAX_SKIPPED_RESPONSE_FRAMES} frames)"
);
}
skipped_frames += 1;
}
}
/// Shared serial transport for tools. Pub(crate) for capabilities tool.
pub struct SerialTransport {
port: Mutex<SerialStream>,
}
impl SerialTransport {
pub(crate) async fn request(&self, cmd: &str, args: Value) -> anyhow::Result<ToolResult> {
let mut port = self.port.lock().await;
// One timeout covers the request and every skipped frame, so stale or
// malformed input cannot restart the deadline.
let resp = tokio::time::timeout(View on GitHub (pinned to 88bb9c8533)
Solutions
- Flash/verify the board runs ZeroClaw firmware, which only emits id-tagged responses
- Stop other clients on the port (serial monitor, second zeroclaw instance) and retry — the stream recovers on the next request
- If the device emits telemetry, disable it or move it off the protocol link; the 16-frame skip cap is a hard constant
- After a timed-out request, issue a single ping to drain its late response instead of bursting retries
Defensive patterns
Strategy: retry
Validate before calling
// health probe before real work; a failing ping drains stale frames
let healthy = transport.request("ping", serde_json::json!({})).await.is_ok();
if !healthy {
anyhow::bail!("serial link not healthy; check firmware and other clients on the port");
} Try / catch
match transport.request(cmd, args).await {
Err(e) if format!("{e}").contains("skip limit exceeded") => {
// stream recovers on the next request: single ping to drain, then retry once
let _ = transport.request("ping", serde_json::json!({})).await;
transport.request(cmd, args).await
}
rest => rest,
} Prevention
- Keep exactly one client on the serial port — no monitors, no second runtime
- Flash ZeroClaw firmware so responses carry the request id and nothing else is emitted
- Never burst parallel retries after a timeout; late stale responses eat the next request's 16-frame skip budget
When it happens
Trigger: The device streams unsolicited telemetry/status frames faster than responses; a previous request timed out and its late response is still buffered, so the next request inherits stale frames; another client (serial monitor, second runtime) is talking on the same port; line noise produces JSON frames with foreign ids.
Common situations: Firmware logging status on the same serial link as the request protocol; sharing /dev/ttyACM0 with a monitor; rapid retries after a timeout piling up stale responses; non-ZeroClaw firmware that does not tag responses with the request id.
Related errors
- ping handshake failed after reconnect on {port_path} — firmw
- Serial path not allowed: {}. Allowed: {}
AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23).
Data as JSON: /api/errors/db3f7a27227700a0.
Report an issue: GitHub.