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>
380 lines
14 KiB
Markdown
380 lines
14 KiB
Markdown
# 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.
|