Safe Mode, crash reports, and a watched main loop

Every build now records at boot which version runs and, after a crash
restart, which one crashed (even across a Rollback). The core dump
summary (task, PC, reason, backtrace) is printed and raised as a
Notification; `crash` shows it later. After 3 crash restarts in a row
the firmware starts in Safe Mode: clock, Wi-Fi, Update Service and Debug
Console only (SafeMode, 2 host tests). A normal restart or a minute up
resets the count.

The main loop is now on the task watchdog (enableLoopWDT): Arduino only
watched core 0's idle task, so a stuck loop hung the device for good.
The Update Service restarts into an installed update by itself if the
main loop hasn't after 90 s.

Debug Builds: `coredump get` and `reset` are answered by the console's
own task; rdbg.py crash decodes the backtrace and rdbg.py coredump runs
esp-coredump, against ELFs archived by version and digest in .pio/elves.

The StorageService mutex is now made in the constructor: Safe Mode never
starts that Service, and `info` crashed on the null mutex, 29 times in a
row before the fix was pushed into Safe Mode over Wi-Fi.

Verified on the device: crash report and full core dump decoded over
Wi-Fi; Safe Mode at exactly 3 crashes, left by `reboot`; a hung loop
caught by the watchdog in 5 s; `reset` from the console task. ADR 0005.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-04 03:07:56 +02:00
co-authored by Claude Opus 5.5
parent 0fb7f4e9d5
commit 14ff13f634
16 changed files with 452 additions and 21 deletions
+5
View File
@@ -120,6 +120,10 @@ _Avoid_: trial, test mode
Returning automatically to the previous firmware when new firmware resets or crashes during Probation. Returning automatically to the previous firmware when new firmware resets or crashes during Probation.
_Avoid_: revert, downgrade (a downgrade is installing an older version on purpose) _Avoid_: revert, downgrade (a downgrade is installing an older version on purpose)
**Safe Mode**:
What the firmware starts instead of everything else after 3 crash restarts in a row: Wi-Fi and Firmware Updates (and the Debug Console in a Debug Build), so it can be fixed without a cable. A normal restart leaves it.
_Avoid_: recovery mode, failsafe
**Debug Build**: **Debug Build**:
A firmware built with the remote debugging aids compiled in (`+debug` in its version). Release builds have none of them. A firmware built with the remote debugging aids compiled in (`+debug` in its version). Release builds have none of them.
_Avoid_: dev build, test build (a test build is one made to fail on purpose, such as a crashing update) _Avoid_: dev build, test build (a test build is one made to fail on purpose, such as a crashing update)
@@ -139,6 +143,7 @@ _Avoid_: telnet, remote shell
- Every transmission is bounded by the **Region** and its **Duty Cycle Budget**. - Every transmission is bounded by the **Region** and its **Duty Cycle Budget**.
- Past 90% SD usage, **Logs** stop being written; the remaining space is kept for **Captures**. Nothing is deleted without the user's confirmation. - Past 90% SD usage, **Logs** stop being written; the remaining space is kept for **Captures**. Nothing is deleted without the user's confirmation.
- A **Firmware Update** installs an **Update File**; the new firmware runs on **Probation**, and fails back by **Rollback**. - A **Firmware Update** installs an **Update File**; the new firmware runs on **Probation**, and fails back by **Rollback**.
- **Rollback** covers new firmware; **Safe Mode** covers confirmed firmware that keeps crashing.
- A **Node** may be in several **Channels**. A **Direct Message** targets exactly one **Node**. - A **Node** may be in several **Channels**. A **Direct Message** targets exactly one **Node**.
## Flagged ambiguities ## Flagged ambiguities
+14 -1
View File
@@ -74,6 +74,9 @@ To install from the SD card instead, copy the `.ota` file from `.pio/build/cardp
| `tasks` | FreeRTOS tasks: state, priority, lowest free stack, CPU share | | `tasks` | FreeRTOS tasks: state, priority, lowest free stack, CPU share |
| `reboot` / `boot other` | Restart, or restart into the other app slot (a manual Rollback) | | `reboot` / `boot other` | Restart, or restart into the other app slot (a manual Rollback) |
| `log level <0-5>` | ESP-IDF log level | | `log level <0-5>` | ESP-IDF log level |
| `crash` | The last crash: which firmware, why, task, PC and backtrace (from the core dump in flash) |
| `coredump erase` | Forgets the core dump |
| `crash abort` / `crash wdt` | Debug Builds: crash on purpose, or hang the main loop until the watchdog fires |
| `help` | Lists the commands | | `help` | Lists the commands |
`scripts/flash.sh` stops a running serial log first, since it would hold the port. `scripts/flash.sh` stops a running serial log first, since it would hold the port.
@@ -88,4 +91,14 @@ scripts/rdbg.py info # one command and its reply
scripts/rdbg.py -b tasks # the same, after the backlog (boot messages and so on) scripts/rdbg.py -b tasks # the same, after the backlog (boot messages and so on)
``` ```
Every command above works there too. The token is in `~/.config/roro9stack/debug-token`, made by the first build; keep developing on Debug Builds, so the firmware a Rollback returns to always has the console. Every command above works there too, plus a few handled by the PC side or the console's own task:
```sh
scripts/rdbg.py crash # the last crash, its backtrace decoded against that exact build's ELF
scripts/rdbg.py coredump # fetch the core dump and decode it all (registers, every task) with esp-coredump
scripts/rdbg.py reset # restart at once, even if the main loop is stuck
```
Every build keeps its ELF in `.pio/elves/` (version and digest in the name) for that; `scripts/decode_backtrace.sh <version|digest> <addresses>` decodes any backtrace by hand.
After 3 crash restarts in a row the firmware starts in **Safe Mode** (ADR 0005): only Wi-Fi, Firmware Updates and the Debug Console, so a fix can be pushed as usual. `reboot` leaves it. The token is in `~/.config/roro9stack/debug-token`, made by the first build; keep developing on Debug Builds, so the firmware a Rollback returns to always has the console.
@@ -0,0 +1,13 @@
# Safe Mode, crash reports and a watched main loop, in every build
Rollback protects against new firmware that fails Probation. It does nothing for firmware that was confirmed and crashes later: a corrupt setting, a server that sends something unexpected, a bug that takes an hour to show. Without a cable, such a device would restart forever. Three measures, in release and Debug Builds alike, keep it reachable:
- **Safe Mode.** The firmware counts starts that follow a crash (panic or watchdog) in NVS, first thing at boot. After 3 in a row, it starts only the clock, Wi-Fi, the Update Service and, in a Debug Build, the Debug Console: no Apps, no IRC, no SD card, and a screen that says so with the address to push an update to. Any normal restart (a `reboot`, an update), or a minute of uptime, resets the count.
- **Crash reports.** The same boot record keeps which version was running, so after a crash the firmware knows which one crashed, even when a Rollback has switched slots since. ESP-IDF already writes a core dump to its flash partition on a panic; after the restart the firmware prints its summary (task, PC, reason, backtrace) and raises a Notification. The `crash` command shows it again later. In a Debug Build, `scripts/rdbg.py crash` decodes the backtrace and `scripts/rdbg.py coredump` fetches the whole dump for `esp-coredump`, against the ELF of that exact build (`.pio/elves/`, named by version and ELF digest).
- **The main loop is watched.** Arduino-ESP32 subscribes only core 0's idle task to the task watchdog, and the main loop runs on core 1: a stuck loop used to hang the device for good, with the screen frozen and the Debug Console unable to run commands. `enableLoopWDT()` makes a loop stuck for 5 s a panic, with a core dump, counted towards Safe Mode. And an installed update no longer depends on the main loop: the Update Service restarts into it by itself after 90 s.
## Consequences
- Nothing in the main loop may block for 5 s. Network and card work already run on their own tasks.
- Safe Mode can't help when Wi-Fi or the Update Service itself is what crashes; that still needs USB.
- Three crashes within a minute of each restart are needed to reach Safe Mode, so a crash loop costs about half a minute before the device becomes reachable.
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include <cstdint>
namespace roro {
// Safe Mode (CONTEXT.md): after kCrashLimit starts in a row that ended in a crash, the firmware
// starts only what it takes to be fixed over the air (Wi-Fi, Firmware Updates, the Debug Console).
// Rollback covers new firmware; this covers firmware that was confirmed and crashes anyway.
struct SafeMode {
static constexpr int kCrashLimit = 3;
static constexpr uint32_t kStableAfterMs = 60000; // then the count starts over
// Called first thing at boot with the count so far; returns the new count.
static int countAtBoot(bool lastStartWasCrash, int crashesBefore) {
return lastStartWasCrash ? crashesBefore + 1 : 0;
}
static bool active(int crashes) { return crashes >= kCrashLimit; }
};
} // namespace roro
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Turns crash addresses into functions and source lines, using the archived ELF of the build that
# crashed (.pio/elves/, kept by scripts/version.py).
# Usage: scripts/decode_backtrace.sh <version or ELF sha256 prefix> <address>...
set -euo pipefail
source "$(dirname "$0")/_docker.sh"
[ $# -ge 2 ] || { echo "Usage: scripts/decode_backtrace.sh <version|sha> <address>..." >&2; exit 1; }
ELF="$(ls -t "$ROOT"/.pio/elves/*"$1"*.elf 2>/dev/null | head -1 || true)"
[ -n "$ELF" ] || { echo "No archived ELF matches '$1' in .pio/elves/" >&2; exit 1; }
echo "using ${ELF#$ROOT/}" >&2
DOCKER_EXTRA=()
run_in_container /pio/tools/toolchain-xtensa-esp-elf/bin/xtensa-esp32s3-elf-addr2line -pfiaC -e "/work/${ELF#$ROOT/}" "${@:2}"
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Full post-mortem of a core dump fetched with `scripts/rdbg.py coredump`: every task's backtrace,
# registers and the crashed task's stack, via esp-coredump and GDB in the container.
# Usage: scripts/decode_coredump.sh <core.bin> <version or ELF sha256 prefix>
set -euo pipefail
source "$(dirname "$0")/_docker.sh"
[ $# -eq 2 ] || { echo "Usage: scripts/decode_coredump.sh <core.bin> <version|sha>" >&2; exit 1; }
CORE="$(realpath "$1")"
ELF="$(ls -t "$ROOT"/.pio/elves/*"$2"*.elf 2>/dev/null | head -1 || true)"
[ -n "$ELF" ] || { echo "No archived ELF matches '$2' in .pio/elves/" >&2; exit 1; }
echo "using ${ELF#$ROOT/}" >&2
DOCKER_EXTRA=(-v "$(dirname "$CORE"):/core:ro")
run_in_container /pio/penv/bin/esp-coredump --chip esp32s3 info_corefile -t raw \
-g /pio/tools/tool-xtensa-esp-elf-gdb/bin/xtensa-esp32s3-elf-gdb \
-c "/core/$(basename "$CORE")" "/work/${ELF#$ROOT/}"
+99 -12
View File
@@ -6,10 +6,16 @@ Usage: scripts/rdbg.py [-H host] [-b] [command ...]
command runs it and prints what follows, until the console has been quiet for a moment command runs it and prints what follows, until the console has been quiet for a moment
-H host the device's IP (Settings > Firmware), default $RORO_OTA_HOST -H host the device's IP (Settings > Firmware), default $RORO_OTA_HOST
-b also print the backlog the device sends on connecting (boot messages and so on) -b also print the backlog the device sends on connecting (boot messages and so on)
Commands handled here as well as on the device:
crash the last crash, with its backtrace decoded (scripts/decode_backtrace.sh)
coredump [file] fetch the core dump (default core-<date>.bin) and decode it with esp-coredump
The token is read from ~/.config/roro9stack/debug-token (made by the first build). The token is read from ~/.config/roro9stack/debug-token (made by the first build).
""" """
import os import os
import re
import select import select
import subprocess
import socket import socket
import sys import sys
import time import time
@@ -48,6 +54,86 @@ def read_until_quiet(sock, quiet, out):
out.flush() out.flush()
SCRIPTS = os.path.dirname(os.path.abspath(__file__))
def run(sock, command, out=sys.stdout):
"""Sends one command and returns its reply (also copied to `out`)."""
sock.sendall((command + "\n").encode())
# The main loop echoes "> command" when it runs it; the reply follows.
echoed = read_until(sock, f"> {command}\n".encode(), 15)
if echoed is None:
sys.exit("device: the command never ran")
reply = echoed.decode(errors="replace").split(f"> {command}\n", 1)[1]
class Tee:
def write(self, text):
nonlocal reply
reply += text
if out:
out.write(text)
def flush(self):
if out:
out.flush()
if out:
out.write(reply)
try:
read_until_quiet(sock, 1.5, Tee())
except BrokenPipeError: # e.g. piped into head
pass
return reply
def crash_firmware(reply):
"""The archived-ELF key for the crashed firmware: its ELF digest, or else its version."""
sha = re.search(r"elf sha256 ([0-9a-f]{8,})", reply)
if sha:
return sha.group(1)
version = re.search(r"last one in (\S+)", reply)
return version.group(1) if version else None
def crash(sock):
reply = run(sock, "crash")
trace = re.search(r"backtrace((?: 0x[0-9a-f]+)+)", reply)
key = crash_firmware(reply)
if trace and key:
print()
subprocess.call([os.path.join(SCRIPTS, "decode_backtrace.sh"), key] + trace.group(1).split())
def coredump(sock, path):
info = run(sock, "crash", out=None)
sock.sendall(b"coredump get\n")
# One buffer throughout: the header, the size and the first bytes often share a packet.
buf = b""
sock.settimeout(15)
while b"coredump: none" not in buf and not re.search(rb"coredump: data (\d+)\n", buf):
chunk = sock.recv(65536)
if not chunk:
sys.exit("device: closed the connection")
buf += chunk
header = re.search(rb"coredump: data (\d+)\n", buf)
if not header:
sys.exit("device: no core dump in flash")
size = int(header.group(1))
data = buf[header.end():]
while len(data) < size:
chunk = sock.recv(65536)
if not chunk:
sys.exit(f"device: the connection closed after {len(data)} of {size} bytes")
data += chunk
data = data[:size]
path = path or time.strftime("core-%Y%m%d-%H%M%S.bin")
open(path, "wb").write(data)
print(f"saved {path} ({size} bytes)")
key = crash_firmware(info)
if key:
subprocess.call([os.path.join(SCRIPTS, "decode_coredump.sh"), path, key])
def interactive(sock): def interactive(sock):
sock.setblocking(False) sock.setblocking(False)
while True: while True:
@@ -93,18 +179,19 @@ def main():
read_until_quiet(sock, 0.5, show) # the backlog read_until_quiet(sock, 0.5, show) # the backlog
if not args: if not args:
return interactive(sock) return interactive(sock)
command = " ".join(args) if args == ["crash"]:
sock.sendall((command + "\n").encode()) return crash(sock)
# The main loop echoes "> command" when it runs it; the reply follows. if args == ["reset"]: # answered by the console's own task, not the main loop
echoed = read_until(sock, f"> {command}\n".encode(), 15) sock.sendall(b"reset\n")
if echoed is None: print((read_until(sock, b"restarting now\n", 10) or b"device: no answer").decode().strip().splitlines()[-1])
sys.exit("device: the command never ran") return
sys.stdout.write(echoed.decode(errors="replace").split(f"> {command}\n", 1)[1]) if args[0] == "coredump" and args[1:2] != ["erase"]:
try: return coredump(sock, args[1] if len(args) > 1 else None)
read_until_quiet(sock, 1.5, sys.stdout) run(sock, " ".join(args))
except BrokenPipeError: # e.g. piped into head
pass
if __name__ == "__main__": if __name__ == "__main__":
main() try:
main()
except (ConnectionResetError, BrokenPipeError):
sys.exit("device: the connection dropped (restarting?)")
+17
View File
@@ -14,3 +14,20 @@ if env["PIOENV"].endswith("-debug"): # noqa: F821
version += "+debug" # a Debug Build says so wherever the version shows version += "+debug" # a Debug Build says so wherever the version shows
env.Append(CPPDEFINES=[("RORO_VERSION", '\\"%s\\"' % version)]) # noqa: F821 env.Append(CPPDEFINES=[("RORO_VERSION", '\\"%s\\"' % version)]) # noqa: F821
# Keep every build's ELF, named by version and the first 16 hex digits of its SHA-256 (the core dump
# names the crashed firmware by the same digest), so a crash can be decoded after later builds.
def archive_elf(source, target, env):
import hashlib
import os
import shutil
elf = str(target[0])
digest = hashlib.sha256(open(elf, "rb").read()).hexdigest()[:16]
folder = os.path.join(env.subst("$PROJECT_DIR"), ".pio", "elves")
os.makedirs(folder, exist_ok=True)
shutil.copy(elf, os.path.join(folder, "%s.%s.elf" % (version, digest)))
env.AddPostAction("$BUILD_DIR/${PROGNAME}.elf", archive_elf) # noqa: F821
+98 -6
View File
@@ -16,6 +16,7 @@
#include "file_receiver.h" #include "file_receiver.h"
#include "key_mapper.h" #include "key_mapper.h"
#include "platform/console.h" #include "platform/console.h"
#include "platform/crash_report.h"
#include "platform/nvs_store.h" #include "platform/nvs_store.h"
#include "platform/system_info.h" #include "platform/system_info.h"
#include "service_manager.h" #include "service_manager.h"
@@ -28,6 +29,7 @@
#include "services/storage_service.h" #include "services/storage_service.h"
#include "services/update_service.h" #include "services/update_service.h"
#include "services/wifi_service.h" #include "services/wifi_service.h"
#include "safe_mode.h"
#include "settings.h" #include "settings.h"
#include "storage_paths.h" #include "storage_paths.h"
#include "ui/notifier.h" #include "ui/notifier.h"
@@ -58,6 +60,7 @@ static DebugConsole* debugConsole;
static Notifier* notifier; static Notifier* notifier;
static LauncherApp launcher; static LauncherApp launcher;
static AppManager* apps; static AppManager* apps;
static bool safeMode = false; // see SafeMode: only what it takes to be fixed over the air
static RawKeys readKeys() { static RawKeys readKeys() {
auto state = M5Cardputer.Keyboard.keysState(); auto state = M5Cardputer.Keyboard.keysState();
@@ -104,9 +107,13 @@ static StatusInfo currentStatus() {
// (UpdateService::tick) decides instead, and an unconfirmed image stays PENDING_VERIFY. // (UpdateService::tick) decides instead, and an unconfirmed image stays PENDING_VERIFY.
extern "C" bool verifyRollbackLater() { return true; } // C linkage, or the weak default wins extern "C" bool verifyRollbackLater() { return true; } // C linkage, or the weak default wins
static void setupSafeMode(int crashes);
void setup() { void setup() {
nvs.begin(); nvs.begin();
UpdateService::bootGuard(nvs); // first: before anything that could crash on new firmware UpdateService::bootGuard(nvs); // first: before anything that could crash on new firmware
int crashes = crash_report::noteBoot(nvs);
safeMode = SafeMode::active(crashes);
Serial.setRxBufferSize(2 * FileReceiver::kChunk); // before the port opens; sd put sends 1 chunk at a time Serial.setRxBufferSize(2 * FileReceiver::kChunk); // before the port opens; sd put sends 1 chunk at a time
Serial.setTxBufferSize(2048); // the console skips Serial when it's full: room for a burst like `tasks` Serial.setTxBufferSize(2048); // the console skips Serial when it's full: room for a burst like `tasks`
@@ -116,6 +123,7 @@ void setup() {
Serial.begin(115200); Serial.begin(115200);
settings.load(); settings.load();
if (safeMode) return setupSafeMode(crashes);
battery = new BatteryService(bus); battery = new BatteryService(bus);
storageService = new StorageService(bus); storageService = new StorageService(bus);
@@ -157,6 +165,39 @@ void setup() {
if (!settings.getBool(Setting::SetupDone)) apps->openModal("setup"); if (!settings.getBool(Setting::SetupDone)) apps->openModal("setup");
console.printf("%s %s ready, free heap %u, last start: %s\n", kProductName, versionString(), ESP.getFreeHeap(), console.printf("%s %s ready, free heap %u, last start: %s\n", kProductName, versionString(), ESP.getFreeHeap(),
system_info::resetReason()); system_info::resetReason());
if (system_info::resetWasCrash()) {
crash_report::print(console, nvs);
bus.publish(Event::withText(EventType::Notification, crash_report::headline().c_str(),
static_cast<int32_t>(NotificationLevel::Warning)));
}
// A main loop stuck for 5 s panics (and leaves a core dump) instead of hanging forever: Arduino
// only watches the idle task on core 0, and the loop runs on core 1.
enableLoopWDT();
}
// Safe Mode: Wi-Fi, the clock, Firmware Updates and (in a Debug Build) the Debug Console. No Apps,
// no IRC, no SD card: whatever crashed three times in a row is most likely among them.
static void setupSafeMode(int crashes) {
clockService = new ClockService(settings, bus);
savedNetworks = new SavedNetworks(nvs);
savedNetworks->load();
wifi = new WifiService(settings, *savedNetworks, *clockService);
storageService = new StorageService(bus); // never started: no card access
update = new UpdateService(nvs, *wifi, *savedNetworks, *storageService, bus, settings);
services.add(*clockService);
services.add(*wifi);
services.add(*update);
#ifdef RORO_DEBUG
debugConsole = new DebugConsole(*wifi);
services.add(*debugConsole);
#endif
bus.subscribe(EventType::Notification, [](const Event& e) { console.printf("notification: %s\n", e.text); });
screen.begin();
services.startAll(millis());
console.printf("%s %s in SAFE MODE: %d crash restarts in a row. Only Wi-Fi and Firmware Updates run.\n",
kProductName, versionString(), crashes);
crash_report::print(console, nvs);
enableLoopWDT();
} }
// Dev aid: commands to drive the UI without the keyboard, from the serial port or the Debug Console. // Dev aid: commands to drive the UI without the keyboard, from the serial port or the Debug Console.
@@ -276,19 +317,31 @@ static const char* const kHelp =
"reboot restart\n" "reboot restart\n"
"boot other restart into the other app slot (manual Rollback)\n" "boot other restart into the other app slot (manual Rollback)\n"
"log level <0-5> ESP-IDF log level (0 none ... 5 verbose)\n" "log level <0-5> ESP-IDF log level (0 none ... 5 verbose)\n"
"crash the last crash: firmware, reason, task, backtrace\n"
"coredump erase forget the core dump in flash\n"
"key <name|char> press a key: up down left right select back home, or one character\n" "key <name|char> press a key: up down left right select back home, or one character\n"
"wifi status | wifi add <ssid><TAB><password>\n" "wifi status | wifi add <ssid><TAB><password>\n"
"irc start | irc dump | irc say <buffer> <text>\n" "irc start | irc dump | irc say <buffer> <text>\n"
"sd list | cat <path> | log <text> | burst | sound on|off | short | normal\n" "sd list | cat <path> | log <text> | burst | sound on|off | short | normal\n"
#ifdef RORO_DEBUG #ifdef RORO_DEBUG
"crash abort|wdt crash on purpose (to test crash reports and Safe Mode)\n" "crash abort|wdt crash on purpose (to test crash reports and Safe Mode)\n"
"coredump get (Debug Console only) send the raw core dump: use scripts/rdbg.py coredump\n"
"reset (Debug Console only) restart at once, even if the main loop is stuck\n"
"quit close the Debug Console connection\n" "quit close the Debug Console connection\n"
#endif #endif
; ;
// Commands that only touch what Safe Mode starts.
static bool safeModeCommand(const String& line) {
return line == "help" || line == "info" || line == "tasks" || line == "reboot" || line == "boot other" ||
line.startsWith("log level ") || line.startsWith("crash") || line.startsWith("coredump") ||
line == "wifi status" || line.startsWith("wifi add ");
}
static void runCommand(String line) { static void runCommand(String line) {
line.trim(); line.trim();
if (line.isEmpty()) return; if (line.isEmpty()) return;
if (safeMode && !safeModeCommand(line)) return (void)console.println("not available in Safe Mode");
if (line == "help") console.print(kHelp); if (line == "help") console.print(kHelp);
if (line == "info") { if (line == "info") {
system_info::printSystem(console); system_info::printSystem(console);
@@ -309,6 +362,8 @@ static void runCommand(String line) {
esp_log_level_set("*", static_cast<esp_log_level_t>(constrain(level, 0, 5))); esp_log_level_set("*", static_cast<esp_log_level_t>(constrain(level, 0, 5)));
console.printf("log level: %d\n", constrain(level, 0, 5)); console.printf("log level: %d\n", constrain(level, 0, 5));
} }
if (line == "crash") crash_report::print(console, nvs);
if (line == "coredump erase") console.println(crash_report::erase() ? "coredump: erased" : "coredump: nothing to erase");
#ifdef RORO_DEBUG #ifdef RORO_DEBUG
if (line == "crash abort") abort(); if (line == "crash abort") abort();
if (line == "crash wdt") if (line == "crash wdt")
@@ -410,7 +465,48 @@ static void serialCommands() {
} }
} }
static void remoteCommands() {
#ifdef RORO_DEBUG
for (std::string remote; debugConsole->takeCommand(remote);) {
console.printf("> %s\n", remote.c_str()); // so the transcript reads the same on both ends
runCommand(remote.c_str());
}
#endif
}
// After a minute up, the crash streak is over (SafeMode counts starts that crash in a row).
static void noteStableOnce(uint32_t now) {
static bool noted = false;
if (noted || now < SafeMode::kStableAfterMs) return;
noted = true;
crash_report::noteStable(nvs);
}
static void loopSafeMode() {
uint32_t now = millis();
serialCommands();
remoteCommands();
services.tick(now);
bus.dispatch();
noteStableOnce(now);
if (update->phase() == UpdateService::Phase::Installed) {
screen.renderUpdate("Restarting", "into " + update->incomingVersion(), -1);
delay(800);
ESP.restart();
}
static uint32_t lastDraw = 0;
if (update->phase() == UpdateService::Phase::Receiving) {
screen.renderUpdate("Firmware update", "Receiving " + update->incomingVersion(), update->percent());
} else if (now - lastDraw > 2000 || !lastDraw) {
lastDraw = now;
bool up = wifi->state() == WifiController::State::Connected;
screen.renderUpdate("Safe Mode", up ? "Updates: " + wifi->ip() + ":3232" : "Waiting for Wi-Fi", -1);
}
delay(10);
}
void loop() { void loop() {
if (safeMode) return loopSafeMode();
#ifdef RORO_TEST_CRASH #ifdef RORO_TEST_CRASH
// Test builds only (never in a release): crash during Probation to exercise Rollback. // Test builds only (never in a release): crash during Probation to exercise Rollback.
if (millis() > 5000) abort(); if (millis() > 5000) abort();
@@ -420,12 +516,8 @@ void loop() {
uint32_t now = millis(); uint32_t now = millis();
serialCommands(); serialCommands();
#ifdef RORO_DEBUG remoteCommands();
for (std::string remote; debugConsole->takeCommand(remote);) { noteStableOnce(now);
console.printf("> %s\n", remote.c_str()); // so the transcript reads the same on both ends
runCommand(remote.c_str());
}
#endif
uploadStep(); uploadStep();
printListingWhenReady(); printListingWhenReady();
M5Cardputer.update(); M5Cardputer.update();
+66
View File
@@ -0,0 +1,66 @@
#include "platform/crash_report.h"
#include <esp_core_dump.h>
#include "platform/system_info.h"
#include "safe_mode.h"
#include "version.h"
namespace roro::crash_report {
int noteBoot(KeyValueStore& store) {
bool crashed = system_info::resetWasCrash();
std::string last;
store.getString("run_ver", last);
if (crashed) {
store.putString("crash_ver", last.empty() ? versionString() : last);
store.putString("crash_why", system_info::resetReason());
}
if (last != versionString()) store.putString("run_ver", versionString());
int32_t before = 0;
store.getInt("crash_boots", before);
int now = SafeMode::countAtBoot(crashed, before);
if (now != before) store.putInt("crash_boots", now);
return now;
}
void noteStable(KeyValueStore& store) {
int32_t n = 0;
if (store.getInt("crash_boots", n) && n != 0) store.putInt("crash_boots", 0);
}
void print(Print& out, KeyValueStore& store) {
std::string version, why;
store.getString("crash_ver", version);
store.getString("crash_why", why);
if (version.empty()) out.println("crash: none recorded");
else out.printf("crash: last one in %s (%s)\n", version.c_str(), why.c_str());
esp_core_dump_summary_t sum;
if (esp_core_dump_image_check() != ESP_OK || esp_core_dump_get_summary(&sum) != ESP_OK) {
out.println("crash: no core dump in flash");
return;
}
char reason[160] = "";
esp_core_dump_get_panic_reason(reason, sizeof reason);
out.printf("crash: task %s, pc 0x%08lx, cause %lu, address 0x%08lx\n", sum.exc_task, (unsigned long)sum.exc_pc,
(unsigned long)sum.ex_info.exc_cause, (unsigned long)sum.ex_info.exc_vaddr);
if (reason[0]) out.printf("crash: reason: %s\n", reason);
out.print("crash: backtrace");
for (uint32_t i = 0; i < sum.exc_bt_info.depth && i < 16; i++) out.printf(" 0x%08lx", (unsigned long)sum.exc_bt_info.bt[i]);
out.println(sum.exc_bt_info.corrupted ? " (corrupted)" : "");
out.printf("crash: elf sha256 %s\n", reinterpret_cast<const char*>(sum.app_elf_sha256));
}
std::string headline() {
esp_core_dump_summary_t sum;
std::string where = "";
if (esp_core_dump_image_check() == ESP_OK && esp_core_dump_get_summary(&sum) == ESP_OK)
where = std::string(" in ") + sum.exc_task;
return std::string("Restarted after a ") + system_info::resetReason() + where;
}
bool erase() { return esp_core_dump_image_erase() == ESP_OK; }
} // namespace roro::crash_report
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <Print.h>
#include <string>
#include "key_value_store.h"
namespace roro::crash_report {
// First thing at boot: remembers which version runs, and after a crash restart, which version
// crashed (the one that ran last time, even if a Rollback has switched slots since). Returns how
// many starts in a row ended in a crash, for Safe Mode.
int noteBoot(KeyValueStore& store);
// The device has been up long enough: the crash streak is over.
void noteStable(KeyValueStore& store);
// The last crash: which firmware, why, and the task, PC and backtrace from the core dump in flash.
// Decode the backtrace on the PC with scripts/decode_backtrace.sh <version> <addresses>.
void print(Print& out, KeyValueStore& store);
// One line for a Notification after a crash restart, e.g. "Restarted after a crash in loopTask".
std::string headline();
// Forgets the core dump, so the next crash's is the one kept.
bool erase();
} // namespace roro::crash_report
+33
View File
@@ -4,6 +4,10 @@
#include <WiFi.h> #include <WiFi.h>
#include <algorithm>
#include <esp_core_dump.h>
#include <esp_flash.h>
#include "platform/console.h" #include "platform/console.h"
#include "version.h" #include "version.h"
@@ -43,6 +47,24 @@ bool readLine(NetworkClient& c, std::string& line, uint32_t timeoutMs) {
return false; return false;
} }
// The raw core dump partition contents, as esp-coredump reads them ("-t raw").
void sendCoreDump(NetworkClient& client) {
size_t addr = 0, size = 0;
if (esp_core_dump_image_check() != ESP_OK || esp_core_dump_image_get(&addr, &size) != ESP_OK) {
client.print("coredump: none\n");
return;
}
client.printf("coredump: data %u\n", (unsigned)size);
uint8_t buf[1024];
for (size_t off = 0; off < size;) {
size_t n = std::min(sizeof buf, size - off);
if (esp_flash_read(nullptr, buf, addr + off, n) != ESP_OK) break; // the host sees a short file
if (client.write(buf, n) != n) return;
off += n;
}
client.print("coredump: end\n");
}
} // namespace } // namespace
DebugConsole::DebugConsole(WifiService& wifi) : wifi_(wifi), lock_(xSemaphoreCreateMutex()) {} DebugConsole::DebugConsole(WifiService& wifi) : wifi_(wifi), lock_(xSemaphoreCreateMutex()) {}
@@ -125,6 +147,17 @@ void DebugConsole::serve(NetworkClient& client) {
client.stop(); client.stop();
break; break;
} }
if (line == "reset") { // answered here: works even when the main loop is stuck
client.print("debug: restarting now\n");
client.flush();
delay(200);
esp_restart();
}
if (line == "coredump get") { // binary: answered here, not by the main loop
sendCoreDump(client);
line.clear();
continue;
}
xSemaphoreTake(lock_, portMAX_DELAY); xSemaphoreTake(lock_, portMAX_DELAY);
bool full = commands_.size() >= kMaxQueued; bool full = commands_.size() >= kMaxQueued;
if (!full && !line.empty()) commands_.push_back(line); if (!full && !line.empty()) commands_.push_back(line);
-1
View File
@@ -14,7 +14,6 @@ namespace roro {
void StorageService::start() { void StorageService::start() {
if (task_) return; if (task_) return;
lock_ = xSemaphoreCreateMutex();
xTaskCreate(taskEntry, "storage", 10240, this, 1, &task_); // room for a signature check xTaskCreate(taskEntry, "storage", 10240, this, 1, &task_); // room for a signature check
} }
+2 -1
View File
@@ -20,7 +20,8 @@ namespace roro {
// queued Log lines in batches, lists files for Storage Clean-up, deletes, and formats. // queued Log lines in batches, lists files for Storage Clean-up, deletes, and formats.
class StorageService : public Service { class StorageService : public Service {
public: public:
explicit StorageService(EventBus& bus) : monitor_(bus), bus_(bus) {} // The lock exists from the start, so a Service that's never started (Safe Mode) can still be asked.
explicit StorageService(EventBus& bus) : monitor_(bus), bus_(bus), lock_(xSemaphoreCreateMutex()) {}
const char* name() const override { return "storage"; } const char* name() const override { return "storage"; }
void start() override; void start() override;
void stop() override; void stop() override;
+11
View File
@@ -28,6 +28,7 @@ class UpdateSource {
namespace { namespace {
constexpr uint32_t kStallMs = 10000; constexpr uint32_t kStallMs = 10000;
constexpr uint32_t kForceRestartMs = 90000; // the main loop waits up to 60 s for someone typing
constexpr size_t kChunk = 4096; constexpr size_t kChunk = 4096;
class NetSource : public UpdateSource { class NetSource : public UpdateSource {
@@ -190,6 +191,7 @@ void UpdateService::taskEntry(void* self) { static_cast<UpdateService*>(self)->l
void UpdateService::listen() { void UpdateService::listen() {
NetworkServer server(kPort); NetworkServer server(kPort);
bool listening = false; bool listening = false;
uint32_t installedAt = 0;
for (;;) { for (;;) {
bool connected = wifi_.state() == WifiController::State::Connected; bool connected = wifi_.state() == WifiController::State::Connected;
if (connected && !listening) { if (connected && !listening) {
@@ -201,6 +203,15 @@ void UpdateService::listen() {
MDNS.end(); MDNS.end();
listening = false; listening = false;
} }
// The main loop restarts into an installed update when it's safe. If it never does (stuck,
// or waiting on someone typing for too long), restart from here: the update must not wait.
if (phase_ == Phase::Installed) {
if (!installedAt) installedAt = millis();
if (millis() - installedAt > kForceRestartMs) {
ESP_LOGW("update", "the main loop never restarted into the update: restarting");
esp_restart();
}
}
if (listening && phase_ == Phase::Idle) { if (listening && phase_ == Phase::Idle) {
NetworkClient client = server.accept(); NetworkClient client = server.accept();
if (client) { if (client) {
+20
View File
@@ -5,6 +5,7 @@
#include <vector> #include <vector>
#include "probation.h" #include "probation.h"
#include "safe_mode.h"
#include "sha256.h" #include "sha256.h"
#include "update_parser.h" #include "update_parser.h"
#include "version_compare.h" #include "version_compare.h"
@@ -284,6 +285,23 @@ void test_rollback_at_boot_after_an_unconfirmed_start() {
TEST_ASSERT_FALSE(Probation::rollBackAtBoot(false, 3)); // confirmed firmware: never TEST_ASSERT_FALSE(Probation::rollBackAtBoot(false, 3)); // confirmed firmware: never
} }
void test_safe_mode_after_three_crashes_in_a_row() {
int crashes = 0;
crashes = SafeMode::countAtBoot(true, crashes);
crashes = SafeMode::countAtBoot(true, crashes);
TEST_ASSERT_FALSE(SafeMode::active(crashes));
crashes = SafeMode::countAtBoot(true, crashes);
TEST_ASSERT_TRUE(SafeMode::active(crashes));
}
void test_safe_mode_count_restarts_after_a_normal_start() {
int crashes = SafeMode::countAtBoot(true, 2);
TEST_ASSERT_TRUE(SafeMode::active(crashes));
crashes = SafeMode::countAtBoot(false, crashes); // e.g. `reboot` from the Debug Console
TEST_ASSERT_EQUAL(0, crashes);
TEST_ASSERT_FALSE(SafeMode::active(crashes));
}
int main() { int main() {
UNITY_BEGIN(); UNITY_BEGIN();
RUN_TEST(test_sha256_known_vectors); RUN_TEST(test_sha256_known_vectors);
@@ -305,5 +323,7 @@ int main() {
RUN_TEST(test_probation_needs_wifi_when_it_is_configured); RUN_TEST(test_probation_needs_wifi_when_it_is_configured);
RUN_TEST(test_probation_rolls_back_when_wifi_never_comes); RUN_TEST(test_probation_rolls_back_when_wifi_never_comes);
RUN_TEST(test_rollback_at_boot_after_an_unconfirmed_start); RUN_TEST(test_rollback_at_boot_after_an_unconfirmed_start);
RUN_TEST(test_safe_mode_after_three_crashes_in_a_row);
RUN_TEST(test_safe_mode_count_restarts_after_a_normal_start);
return UNITY_END(); return UNITY_END();
} }