embassy-rs/embassy · critical

heartbeat from esp32 stopped

Error message

heartbeat from esp32 stopped

What it means

embassy-net-esp-hosted panics in its main run loop when the ESP32 co-processor stops sending heartbeat responses within HEARTBEAT_MAX_GAP. The driver polls the esp32 (via ioctl control events) and extends the deadline while the chip is initializing; if it is not initializing and the deadline expires, the firmware link is considered dead and the driver aborts, because it cannot recover the radio link. This is a fatal, unrecoverable condition for the driver by design.

Solutions

  1. Verify the ESP32 is running a compatible esp-hosted-ng firmware version flashed for the correct chip (esp32, esp32s3, c3) and that its status LED/boot logs show it is up
  2. Check power supply stability and decoupling capacitors; ESP32 Wi-Fi TX current spikes commonly cause brown-outs and resets
  3. Inspect SPI wiring (MOSI/MISO/CS/CLK/handshake pins) and confirm they match the embassy-net-esp-hosted Config pin settings
  4. Ensure the esp32 reset sequence is honored: reset the chip (or power-cycle the board) before/with driver init so ControlState transitions are observed
  5. Catch the panic in a supervisor task and restart the whole driver + esp32 reset, since the run loop cannot recover once the heartbeat is lost

Example fix

// before: driver started immediately at boot while esp32 still resetting
let device = embassy_net_esp_hosted::new(mio, spi, ctrl_pin, rng, config);
// after: hold esp32 in reset, then release and wait before init
delay.delay(100.millis()); // let esp32 boot fully / recover from reset
let device = embassy_net_esp_hosted::new(mio, spi, ctrl_pin, rng, config);
Defensive patterns

Strategy: retry

Validate before calling

// before driver init: verify esp32 responds and is flashed
// (embedded: no pre-call check possible; validate at startup)
fn validate_esp32(ctrl: &mut Control) {
    match ctrl.init() {
        Ok(_) => info!("esp32 alive"),
        Err(e) => panic!("esp32 not responding, check power/spi/firmware: {:?}", e),
    }
}

Try / catch

// Rust panics cannot be caught in no_std; wrap in a supervisor task
// that resets the esp32 and re-creates the driver on watchdog expiry
loop {
    spawn_network_task();
    if WATCHDOG.timed_out() {
        esp32_reset_pin.set_low(); // hard reset co-processor
        Timer::after(100.millis()).await;
        esp32_reset_pin.set_high();
    }
}

Prevention

When it happens

Trigger: The run() loop receives EitherMany::Fourth(()) (heartbeat tick) while shared.state() is neither ControlState::Reboot nor ControlState::WaitingForInit, and the time since the last heartbeat from the ESP32 exceeded HEARTBEAT_MAX_GAP. Practically: the esp32 stopped answering init/control/ioctl exchanges (chip crashed, reset, brown-out, or SPI link failure).

Common situations: ESP32 firmware flashed with a mismatched or corrupt esp-hosted firmware image; insufficient power supply causing the esp32 to brown out and reset mid-operation; SPI wiring/soldering issues on custom boards; the esp32 being externally reset (e.g., by another driver or a watchdog); driver started before the esp32 finished booting and it then hung.

Understand the failure class

Background: "This is a bug, please report it": internal invariant violations, unreachable panics, and SNH errors explained — this error's family across 47 libraries.

Related errors


AI-assisted analysis of embassy-rs/embassy@463a07b963 (2026-09-10). Data as JSON: /api/errors/9ea88d41edfb9741. Report an issue: GitHub.

Appendix: source

Thrown at embassy-net-esp-hosted/src/lib.rs:324

                        header.copy(&mut buffer);
                        tx_len = PayloadHeader::SIZE + packet_len;
                    } else {
                        // Backend doesn't support the requested interface type. Drop the
                        // packet and send nothing this iteration.
                        buffer[..PayloadHeader::SIZE].fill(0);
                    }
                }
                EitherMany::Third(()) => {
                    buffer[..PayloadHeader::SIZE].fill(0);
                    tx_len = 0;
                }
                EitherMany::Fourth(()) => {
                    // Extend the deadline if initializing
                    if let ioctl::ControlState::Reboot | ioctl::ControlState::WaitingForInit = self.shared.state() {
                        self.heartbeat_deadline = Instant::now() + HEARTBEAT_MAX_GAP;
                        continue;
                    }
                    panic!("heartbeat from esp32 stopped")
                }

                // Bluetooth HCI packet queued by the host stack.
                #[cfg(feature = "bluetooth")]
                EitherMany::Fifth(slot) => {
                    if let Some(if_type_and_num) = self.backend.encode_iface_type(InterfaceType::Hci) {
                        // `slot.buf[0]` is the H4 packet type indicator; it travels in the
                        // payload header's `hci_priv_packet_type` field, and the remaining
                        // bytes are the HCI packet body.
                        let pkt_type = slot.buf[0];
                        let body = &slot.buf[1..slot.len];
                        buffer[PayloadHeader::SIZE..][..body.len()].copy_from_slice(body);

                        let header = PayloadHeader {
                            if_type_and_num,
                            len: body.len() as _,
                            offset: PayloadHeader::SIZE as _,
                            seq_num: self.next_seq,

View on GitHub (pinned to 463a07b963)