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:
1 parent
ece2171678
commit
ede70a21b3
2 files changed
+414
-3
No files matched your search
@@ -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
@@ -40,11 +40,43 @@ services:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
# autoheal monitora SOLO i container con questa label = true
|
||||
autoheal: "true"
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8878/api/health')"]
|
||||
interval: 10s
|
||||
# Healthcheck "vero": fallisce se camera_open != true.
|
||||
# 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
|
||||
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-dev:
|
||||
|
||||
Reference in new issue
Block a user