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

14 KiB
Raw Blame History

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):

$ 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 del backend service:

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 ha l'auto-open al startup:

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

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:

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:

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

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:

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:

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:

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):

# /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:

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:

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):

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

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 — healthcheck + servizio autoheal (Opzione A) oppure niente (Opzione B).
  • 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 — overview del container.
  • 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:


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.