diff --git a/TODO_camera_auto_recovery.md b/TODO_camera_auto_recovery.md new file mode 100644 index 0000000..041b28d --- /dev/null +++ b/TODO_camera_auto_recovery.md @@ -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. diff --git a/docker-compose.yml b/docker-compose.yml index 79aba54..b1790bd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: