# 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.