feat: auto-recovery pyrealsense2 stuck via healthcheck + autoheal

Sostituisce il healthcheck banale (urlopen /api/health) con uno che
fallisce quando camera_open != true, e aggiunge il container willfarrell/autoheal
che restarta il backend quando Docker lo marca unhealthy. Recovery <1min
da SDK stuck senza intervento SSH.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
OliviaCandClaude Opus 4.7 committed 2026-05-25 12:15:41 +02:00
1 parent ece2171678
commit ede70a21b3
2 files changed
+414 -3

No files matched your search

+379
View File
@@ -0,0 +1,379 @@
# TODO — Auto-recovery del pipeline pyrealsense2 stuck
Documento di handoff per implementare auto-recovery quando la D435 SDK si pianta. Stato attuale: dopo boot di PC2 (o dopo un client che termina sporcamente), la D435 risulta `camera_open=False` e il `POST /api/open` ritorna `OPEN_FAILED`. **L'unico fix oggi è SSH a PC2 + `docker compose restart`**, che blocca chiunque sta usando il client.
## Goal
Eliminare il bisogno di intervento manuale: il container deve auto-recuperare in <1 minuto da uno stato "SDK stuck", anche se nessuno è loggato su PC2.
---
## 1. Sintomi osservati
Dopo boot PC2 o dopo terminazione sporca di un client (es. zombie Python che ha tenuto aperto un handle USB):
```bash
$ curl -s http://192.168.123.99:8878/api/health
{"success":true,"data":{"status":"ok","sdk_initialized":true,"camera_open":false,"nats_connected":false}}
# → 200 OK, "healthy" per Docker, ma camera_open=False
$ curl -s http://192.168.123.99:8878/api/status | jq .data
{"sdk_initialized":true, "camera_open":false, "color_available":false, "usb_type":null, ...}
$ curl -s -X POST http://192.168.123.99:8878/api/open
{"success":false,"data":null,"error":{"code":"OPEN_FAILED","message":"Failed to open camera"}}
$ curl -s http://192.168.123.99:8878/api/discover | jq .data.cameras
[{"index":0,"vid":"8086","pid":"0b3a","serial":"243722073572", ...}]
# USB device discoverable, ma pyrealsense2.pipeline().start() fallisce.
# Soft cycle close+open NON sblocca. Solo `docker compose restart` del container ripristina.
```
Frequenza: osservato il **2026-05-23** e il **2026-05-25** (oggi). Sospetto trigger: un client che termina senza chiudere pulito la `pipeline` (es. SIGKILL, oppure process Python che muore con un handle USB aperto). pyrealsense2 SDK lascia il device descriptor in uno stato inconsistente che solo un fresh process può risolvere.
---
## 2. Comportamento attuale del container
[`docker-compose.yml`](docker-compose.yml) del backend service:
```yaml
backend:
restart: unless-stopped
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8878/api/health')"]
interval: 10s
timeout: 5s
retries: 3
```
E [`src/backend/main.py:53-66`](src/backend/main.py) ha l'auto-open al startup:
```python
if sdk_ok and settings.camera_auto_open:
try:
devices = camera.discover()
if devices:
if camera.open(0):
logger.info(f"Auto-opened camera 0 (serial={devices[0].serial})")
else:
logger.warning("Auto-open requested but open(0) failed") # ← cade qui spesso
else:
logger.warning("Auto-open requested but no RealSense devices found")
except Exception as exc:
logger.warning(f"Auto-open failed: {exc}")
```
`camera_auto_open: bool = True` di default (vedi [`src/backend/config.py:27`](src/backend/config.py)).
**Cosa NON funziona** del comportamento attuale:
1. **Healthcheck su `/api/health`**: l'endpoint ritorna 200 anche se `camera_open=False`. Docker considera il container "healthy" anche quando la SDK è stuck → `restart: unless-stopped` non si attiva.
2. **Auto-open senza retry**: `main.py` prova `camera.open(0)` UNA volta sola. Se fallisce, log warning e basta. Nessun retry, nessun re-tentativo periodico.
3. **Nessun watchdog runtime**: se la SDK si piantava dopo che il container era già up + camera_open=True (es. il client kill un handle), il container resta vivo con stato "stuck" indefinitamente.
---
## 3. Soluzione raccomandata (Opzione A): healthcheck "vero" + autoheal
Production-grade, copre BOTH startup-failure E runtime-stuck.
### 3.1 Modifica `docker-compose.yml`
Cambia il healthcheck del `backend` per probare `camera_open=True` invece di solo `/api/health`:
```yaml
backend:
build: ...
privileged: true
ports:
- "${API_PORT:-8878}:8878"
devices:
- "/dev/bus/usb:/dev/bus/usb"
volumes:
- ./images:/app/images
- ./models:/app/models:ro
env_file: .env
depends_on:
nats:
condition: service_healthy
required: false
restart: unless-stopped
labels:
# autoheal monitorerà SOLO i container con questa label = true
autoheal: "true"
healthcheck:
test:
- CMD
- python3
- -c
- |
import urllib.request, json, sys
try:
r = urllib.request.urlopen('http://localhost:8878/api/status', timeout=3)
d = json.loads(r.read())
sys.exit(0 if d.get('data', {}).get('camera_open') else 1)
except Exception:
sys.exit(1)
interval: 15s # check ogni 15s
timeout: 5s
retries: 4 # 4 fallimenti consecutivi → unhealthy (4×15s = 1min di stato cattivo)
start_period: 30s # 30s di grace al boot per dare tempo al pipeline start
```
### 3.2 Aggiungi servizio `autoheal` al compose
Sotto la sezione `services:`, aggiungi:
```yaml
# -- Autoheal -------------------------------
# Monitora i container con label `autoheal=true` e li restarta automaticamente
# quando Docker li marca "unhealthy". Risolve il caso "SDK stuck a runtime".
autoheal:
image: willfarrell/autoheal:latest
container_name: autoheal-d435
restart: always
environment:
AUTOHEAL_CONTAINER_LABEL: autoheal
AUTOHEAL_INTERVAL: 10
AUTOHEAL_START_PERIOD: 30
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
```
### 3.3 Comportamento risultante
- **Boot PC2**: backend parte → auto-open prova → healthcheck su `camera_open` valida lo stato.
- Se camera ok: tutto verde, healthy.
- Se camera fail: 4×15s = 1min → unhealthy → autoheal restart → backend riparte da capo → auto-open di nuovo. Loop finché ok (o finché USB realmente morta).
- **Runtime stuck**: client kill, SDK si pianta, `camera_open` torna False → 4×15s di unhealthy → restart automatico.
- **Latenza max recovery**: ~1 minuto (4 retry × 15s).
### 3.4 Deploy
Su PC2:
```bash
ssh unitree@192.168.123.99
cd ~/Robot_2026/RealSense-D435 # path da STATUS.md
git pull # se è in git, altrimenti rsync il compose nuovo
sg docker -c "docker compose up -d --remove-orphans"
# verifica:
sg docker -c "docker compose ps"
sg docker -c "docker compose logs -f autoheal" & # log autoheal in background
curl -s http://localhost:8878/api/status | jq .data.camera_open
# atteso: true entro 30-60s
```
---
## 4. Soluzione fallback (Opzione B): watchdog INTERNO al backend
Se per qualche motivo NON vuoi aggiungere un container extra (autoheal), puoi inserire watchdog logic DENTRO `main.py`. Meno production-grade ma self-contained.
### 4.1 Modifica `src/backend/main.py`
Aggiungi dopo l'auto-open un thread watchdog:
```python
import threading
import os
def _camera_watchdog(camera, settings):
"""Thread daemon che monitora camera.is_open(). Se False per N cicli
consecutivi → os._exit(1) → restart_policy 'unless-stopped' rilancia
il container."""
consecutive_bad = 0
BAD_THRESHOLD = 3 # 3 cicli × 20s = 1 minuto di stato cattivo
INTERVAL_S = 20
while True:
time.sleep(INTERVAL_S)
if not camera.is_open():
consecutive_bad += 1
logger.warning(
f"Watchdog: camera_open=False ({consecutive_bad}/{BAD_THRESHOLD} consecutive). "
f"Tento auto-recovery..."
)
# Prima prova: re-open in-process
try:
if camera.open(0):
logger.info("Watchdog: re-open succeeded")
consecutive_bad = 0
continue
except Exception as exc:
logger.warning(f"Watchdog: re-open failed: {exc}")
# Se ancora cattivo dopo BAD_THRESHOLD → esce con errore →
# restart_policy rilancia il container fresh.
if consecutive_bad >= BAD_THRESHOLD:
logger.error(
f"Watchdog: camera stuck per {BAD_THRESHOLD * INTERVAL_S}s. "
f"Killing process to trigger restart_policy."
)
os._exit(1)
else:
consecutive_bad = 0
# E dopo l'auto-open (line ~66):
threading.Thread(target=_camera_watchdog, args=(camera, settings), daemon=True).start()
logger.info("Camera watchdog thread avviato (check ogni 20s)")
```
### 4.2 Aggiungi anche retry sull'auto-open iniziale
Dopo il blocco `if sdk_ok and settings.camera_auto_open:` esistente, sostituiscilo con un retry loop:
```python
if sdk_ok and settings.camera_auto_open:
MAX_RETRY = 5
RETRY_BACKOFF_S = 3
for attempt in range(1, MAX_RETRY + 1):
try:
devices = camera.discover()
if not devices:
logger.warning(f"Auto-open attempt {attempt}/{MAX_RETRY}: no devices")
time.sleep(RETRY_BACKOFF_S)
continue
if camera.open(0):
logger.info(f"Auto-opened camera 0 (serial={devices[0].serial}) at attempt {attempt}")
break
logger.warning(f"Auto-open attempt {attempt}/{MAX_RETRY}: open(0) returned False")
except Exception as exc:
logger.warning(f"Auto-open attempt {attempt}/{MAX_RETRY}: {exc}")
time.sleep(RETRY_BACKOFF_S)
else:
logger.error(f"Auto-open failed after {MAX_RETRY} attempts. Watchdog continuerà a riprovare.")
```
### 4.3 Deploy
Stesso pattern di Opzione A ma serve **rebuild dell'immagine**:
```bash
ssh unitree@192.168.123.99
cd ~/Robot_2026/RealSense-D435
git pull # o rsync di src/backend/main.py
sg docker -c "docker compose build backend"
sg docker -c "docker compose up -d"
sg docker -c "docker compose logs -f backend" # verifica le righe "Auto-opened" o "Watchdog"
```
---
## 5. Soluzioni minori (NON raccomandate, ma per completezza)
### Opzione C — Solo retry al startup, no watchdog runtime
Implementa solo il punto 4.2 di Opzione B (retry sull'auto-open iniziale). Risolve il caso "fallisce al boot PC2" ma NON il caso "si pianta a runtime dopo crash client". Da scartare a meno che il caso runtime non sia mai stato osservato (oggi lo è stato, quindi NON basta).
### Opzione D — Cron/systemd timer ESTERNO su PC2
Watchdog systemd-timer SU PC2 (non dentro container):
```ini
# /etc/systemd/system/d435-watchdog.service
[Unit]
Description=D435 camera health watchdog
[Service]
Type=oneshot
ExecStart=/bin/bash -c '/usr/bin/curl -s --max-time 5 http://localhost:8878/api/status | /usr/bin/jq -e ".data.camera_open" >/dev/null || (cd /home/unitree/Robot_2026/RealSense-D435 && /usr/bin/docker compose restart backend)'
# /etc/systemd/system/d435-watchdog.timer
[Unit]
Description=Run D435 watchdog every 5 minutes
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
[Install]
WantedBy=timers.target
```
Setup una tantum su PC2:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now d435-watchdog.timer
sudo systemctl list-timers | grep d435
```
Pro: zero modifiche al container, deploy via SSH una tantum. Contro: 5min di latenza al recovery, serve sysadmin PC2.
---
## 6. Verifica end-to-end (test del fix dopo deploy)
Test 1 — **Boot scenario**:
```bash
ssh unitree@192.168.123.99 'sudo reboot'
sleep 90 # aspetta boot + container start + auto-open
curl -s --max-time 5 http://192.168.123.99:8878/api/status | jq .data.camera_open
# atteso: true
```
Test 2 — **Stuck scenario** (simulare il problema):
```bash
# Da una qualunque shell, lancia uno script Python che apre la pipeline poi viene killato sporco:
python3 -c "
import pyrealsense2 as rs
p = rs.pipeline()
p.start()
import time; time.sleep(2)
import os; os._exit(0) # exit senza p.stop()
" &
sleep 1
# Adesso prova ad aprire dal container:
curl -s -X POST http://192.168.123.99:8878/api/open
# Probabilmente OPEN_FAILED. Aspetta il watchdog/autoheal:
for i in 1 2 3 4 5; do
sleep 20
state=$(curl -s --max-time 3 http://192.168.123.99:8878/api/status | jq -r .data.camera_open)
echo "t=+${i}*20s camera_open=$state"
done
# atteso (con Opzione A o B): "true" entro t=+3*20s = 60s.
```
Test 3 — **Healthcheck visibility**:
```bash
ssh unitree@192.168.123.99 'sg docker -c "docker inspect --format=\"{{.State.Health.Status}}\" robotserver-realsense-d435i-g1-backend-1"'
# atteso: "healthy" quando camera_open=true, "unhealthy" quando camera stuck.
```
---
## 7. Riferimenti
**File da modificare**:
- [`docker-compose.yml`](docker-compose.yml) — healthcheck + servizio autoheal (Opzione A) oppure niente (Opzione B).
- [`src/backend/main.py`](src/backend/main.py) — retry auto-open + watchdog thread (Opzione B).
- (nessuno per Opzione D, solo file su PC2 fuori repo)
**Documentazione esistente**:
- [`README.md`](README.md) — overview del container.
- [`STATUS.md`](STATUS.md) — stato funzionale + setup PC2 (gruppo docker, proxy, ecc.).
**Doc esterna correlata** (nel repo principale `/home/harry/robot/workspace/`):
- `docs/hardware/g1-KB/g1_arm_control_dds.md` — describes the dual-publisher arm_sdk pattern.
- `oneshot_servo_comparison_h1_vs_g1.md` — note sul comportamento robot quando cam fail.
**Riferimenti tecnici upstream**:
- [pyrealsense2 GitHub issue: pipeline stuck after process kill](https://github.com/IntelRealSense/librealsense/issues?q=is%3Aissue+pipeline+stuck+is%3Aclosed) — molti reports simili.
- [willfarrell/autoheal](https://github.com/willfarrell/autoheal) — container Docker per auto-restart su unhealthy.
- [Docker healthcheck docs](https://docs.docker.com/reference/dockerfile/#healthcheck) — sintassi e semantica `interval`/`retries`/`start_period`.
---
## 8. Priorità + scope
- **Opzione A** = scelta raccomandata. ~30 LoC modificate (compose), 0 di backend code change. 1 rebuild non necessario (compose change basta).
- **Opzione B** = se per qualche motivo (es. policy aziendale) non puoi aggiungere servizi a un compose esistente. ~60 LoC backend Python. Serve rebuild image.
- **Opzione D** = se accetti latenza 5min e vuoi zero modifiche al repo. Setup PC2 una tantum.
Stima effort:
- Opzione A: 1-2 ore (compose modif + test).
- Opzione B: 3-4 ore (Python modif + test + rebuild).
- Opzione D: 1 ora (script systemd su PC2).
**Vincoli operativi noti**:
- `PC2 = 192.168.123.99`, user `unitree` di solito ha permessi docker via `sg docker -c`.
- Path container sul PC2: `~/Robot_2026/RealSense-D435/` (confermato da STATUS.md).
- Network host (proxy tinyproxy `localhost:8888`) — vedi STATUS.md per dettagli build.
Buona fortuna.
+35 -3
View File
@@ -40,11 +40,43 @@ services:
condition: service_healthy condition: service_healthy
required: false required: false
restart: unless-stopped restart: unless-stopped
labels:
# autoheal monitora SOLO i container con questa label = true
autoheal: "true"
healthcheck: healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8878/api/health')"] # Healthcheck "vero": fallisce se camera_open != true.
interval: 10s # Usa /api/health (pubblico, no API key) che già ritorna camera_open.
# 4 retry × 15s ⇒ ~1 min di stato cattivo prima dell'autoheal restart.
test:
- CMD
- python3
- -c
- |
import urllib.request, json, sys
try:
r = urllib.request.urlopen('http://localhost:8878/api/health', timeout=3)
d = json.loads(r.read())
sys.exit(0 if d.get('data', {}).get('camera_open') else 1)
except Exception:
sys.exit(1)
interval: 15s
timeout: 5s timeout: 5s
retries: 3 retries: 4
start_period: 30s
# -- Autoheal -------------------------------
# Monitora i container con label `autoheal=true` e li restarta automaticamente
# quando Docker li marca "unhealthy". Risolve il caso "pyrealsense2 SDK stuck".
autoheal:
image: willfarrell/autoheal:latest
container_name: autoheal-d435
restart: always
environment:
AUTOHEAL_CONTAINER_LABEL: autoheal
AUTOHEAL_INTERVAL: 10
AUTOHEAL_START_PERIOD: 30
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
# -- Frontend React Dev (hot reload) -------- # -- Frontend React Dev (hot reload) --------
frontend-dev: frontend-dev: