Files
RealSense-D435i-G1/TODO_camera_auto_recovery.md
T
OliviaCandClaude Opus 4.7 ede70a21b3 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>
2026-05-25 12:15:41 +02:00

380 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.