From 275fc7c5fa9bcb9410b999ca9f85c0ddf410373b Mon Sep 17 00:00:00 2001 From: ZouzouWP Date: Fri, 17 Jul 2026 17:10:48 +0200 Subject: [PATCH] fix: Windows stability issues (console encoding, camera enumeration, video encoder, error visibility) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four separate crashes that only show up on Windows with real hardware attached. Grouped in one PR because they were all found the same week, not because they're related — happy to split if preferred. ## 1. Console encoding crash (cp1252) **Symptom:** an emoji in a log/print line could raise an unhandled `UnicodeEncodeError` on a Windows console using the legacy cp1252 code page, turning an endpoint into a blank 500 with no useful message reaching the frontend. **Fix:** `stdout`/`stderr` are reconfigured with `errors="replace"` at import time, so unencodable characters degrade instead of crashing the process. ## 2. Camera list sometimes empty **Symptom:** `/available-cameras` would intermittently return an empty or wrong list, depending on which FastAPI worker thread handled the request. **Root cause:** `pygrabber` enumerates cameras via COM, which must be initialized per-thread. FastAPI's thread pool never did that, so enumeration silently failed on threads that hadn't. **Fix:** explicitly initialize/uninitialize COM around the enumeration call. ## 3. Recording crashes with "no status packet" **Symptom:** recording sessions aborted mid-way with a serial `ConnectionError`, non-deterministically, on otherwise-healthy hardware. **Root cause:** the default video encoder (libsvtav1) is CPU-heavy enough that two parallel encode streams could starve the motor-bus read loop of CPU time. **Fix:** default to h264/ultrafast, measured ~11x faster to encode on this hardware. ## 4. Real errors hidden behind "check logs" **Symptom:** any recording failure showed the same generic message to the user — the real exception (exactly what let us diagnose #2 and #3 above) never left the server logs. **Fix:** `/recording-status` now includes the actual exception message. ## Testing Each fix corresponds to a crash reproduced and captured in server logs beforehand, then confirmed resolved after the change. --- lelab/record.py | 19 ++++++++++++++++--- lelab/server.py | 24 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/lelab/record.py b/lelab/record.py index f3a54b09..9159aace 100644 --- a/lelab/record.py +++ b/lelab/record.py @@ -23,6 +23,7 @@ from pydantic import BaseModel from lerobot.configs.dataset import DatasetRecordConfig +from lerobot.configs.video import RGBEncoderConfig from lerobot.datasets import LeRobotDataset from lerobot.robots.so_follower import SO101FollowerConfig @@ -183,6 +184,10 @@ def create_record_config(request: RecordingRequest) -> RecordConfig: tags=with_lelab_tag(request.tags) if request.push_to_hub else None, private=request.private, streaming_encoding=request.streaming_encoding, + # libsvtav1 (lerobot's default) is CPU-heavy enough on this machine to starve + # the motor bus read loop mid-recording ("no status packet" ConnectionError, + # see 2026-07-15 crashes). h264/ultrafast measured ~11x faster to encode here. + rgb_encoder=RGBEncoderConfig(vcodec="h264", preset="ultrafast"), ) # Create the main record config @@ -309,11 +314,15 @@ def recording_worker(): "robot_type": getattr(dataset.meta, "robot_type", "Unknown robot"), } except Exception as e: - logger.exception("Recording session failed") + logger.exception("Recording session failed: %r", e) current_phase = "error" if recording_start_time: session_end_elapsed_seconds = int(time.time() - recording_start_time) - last_recording_info = {"success": False, "error": str(e)} + last_recording_info = { + "success": False, + "error": str(e) or repr(e), + "dataset_repo_id": request.dataset_repo_id, + } finally: if current_phase != "error": current_phase = "completed" @@ -419,7 +428,11 @@ def handle_recording_status() -> dict[str, Any]: "rerecord_episode": recording_active and current_phase == "recording", # Only during recording phase }, - "message": "Recording session failed with error - check logs" + "message": ( + f"Recording failed: {last_recording_info['error']}" + if last_recording_info and last_recording_info.get("error") + else "Recording session failed with error - check logs" + ) if current_phase == "error" else ( "Recording session has ended - stop polling" diff --git a/lelab/server.py b/lelab/server.py index 519e0c72..9e56de6a 100644 --- a/lelab/server.py +++ b/lelab/server.py @@ -36,6 +36,15 @@ from starlette.responses import Response from starlette.types import Scope +# Windows consoles often use a legacy code page (cp1252) that can't encode the +# emoji used in log/print output; an unhandled UnicodeEncodeError inside an +# endpoint then turns into a 500 and hides the real error from the frontend. +# Replace unencodable characters instead of crashing. +for _stream in (sys.stdout, sys.stderr): + if _stream is not None and hasattr(_stream, "reconfigure"): + with contextlib.suppress(Exception): + _stream.reconfigure(errors="replace") + from . import datasets as dataset_browser # Import our custom calibration functionality @@ -998,6 +1007,18 @@ def _windows_cameras() -> list[dict[str, Any]]: frontend match each index to the browser's ``MediaDeviceInfo.label`` for the live preview. Falls back to generic names if pygrabber is unavailable. """ + # pygrabber uses COM, which must be initialized per-thread. FastAPI serves + # sync endpoints from a thread pool whose threads never call CoInitialize, + # so enumeration fails intermittently depending on which thread handles the + # request. Initialize COM here and balance with CoUninitialize. + import ctypes + + com_initialized = False + try: + hr = ctypes.windll.ole32.CoInitialize(None) + com_initialized = hr in (0, 1) # S_OK or S_FALSE (already initialized) + except Exception: + pass try: from pygrabber.dshow_graph import FilterGraph @@ -1007,6 +1028,9 @@ def _windows_cameras() -> list[dict[str, Any]]: import cv2 return _generic_cv2_cameras(cv2.CAP_DSHOW) + finally: + if com_initialized: + ctypes.windll.ole32.CoUninitialize() return [{"index": i, "name": name, "available": True} for i, name in enumerate(names)]