Skip to content

fix(cameras): release vanished-camera capture handles; route servo-bus and camera errors to honest hints - #77

Open
Mokuroh54 wants to merge 10 commits into
mainfrom
fix/camera-vanish-release-and-hints
Open

fix(cameras): release vanished-camera capture handles; route servo-bus and camera errors to honest hints#77
Mokuroh54 wants to merge 10 commits into
mainfrom
fix/camera-vanish-release-and-hints

Conversation

@Mokuroh54

Copy link
Copy Markdown

Carved from the rig integration branch. Two related fixes: one real resource leak in camera teardown, and the hint routing that made both camera and arm failures lie to the user.

(a) A vanished camera's stale capture handle poisons every later open

force_disconnect_partial releases each camera via cam.disconnect() and swallows DeviceNotConnectedError as "never opened — nothing to do". That is right for the common case, but one real failure lands there too and is not harmless.

When a camera's device vanishes mid-connect — unplugged between the cv2 open and the read thread starting — lerobot's OpenCVCamera ends up with is_connected == False (isOpened() is False on the dead device) and thread is None. Its disconnect() guard is literally if not self.is_connected and self.thread is None: raise, so it raises without releasing self.videocapture. The VideoCapture object still holds the OS capture session.

Left in place, that stale in-process session poisons every subsequent open of the camera once it is replugged — it opens "successfully" but comes up with a degraded fps profile or delivers no frames at all — and stays broken until MakerMods Lab restarts. So on exactly that path we now release the raw handle directly and null it out, with a warning if even that fails.

This is the same family as the documented macOS AVFoundation stale-device behaviour: the process-local handle outlives the physical device, and nothing else reclaims it.

(b) Two hint mis-routings

friendly_hint turns raw exception text into plain-language guidance. Two families were being answered with the wrong story:

  1. Camera failures read as network failures. lerobot raises camera-open failures as ConnectionError, and the Hub-download hint branch keyed on a bare "connectionerror" token — so an unplugged camera surfaced as "couldn't download the model". Added two camera branches, ordered ahead of the Hub branches:

    • "failed to open" + "camera" → the device is unplugged, or the index went stale after a port change.
    • the connect-time turbulence family ("failed to set fps", warmup-frame timeouts, "do not match configured") → the camera opened but came up degraded: either another app still holds it or it was just unplugged. A vanished device reports a nonsense actual_fps, which is why lerobot's own message is misleading here. This branch must precede the slow-frames branch, since "timed out waiting for frame" would otherwise be caught by "waiting for frame".
  2. Servo-bus errors read as model-download failures. lerobot raises every serial failure as ConnectionError, so a robot.connect() torque/ID failure hit the same Hub branch and surfaced as a download failure — the second conjunct of that condition was self-satisfied by the token "connectionerror" itself, which contains "connect".

    Correction worth flagging: the functional part of this fix — the txrxresult/motors-bus branch plus requiring a Hub-specific token or the in_download_step prefix — already landed on main via feat(inference): multi-episode evaluation suite — persistent runner, RTC engine, honest logs #63 (43d185b), and is therefore already present on this PR's base. What e1c5c87 contributes here is only the comment reconciling the two hint families now that the camera branches sit alongside the arm branches. Reviewers should read commit 2 as documentation, not behaviour.

Stacked on #37

Base branch is fix/record-connect-partial-teardown (#37's head), not main. The teleoperate hunk edits the cam.disconnect() / except DeviceNotConnectedError camera-release loop that #37's lineage introduced — that loop does not exist on main, so this cannot be based there. Classic branch-of-branch stack: merge #37 first, and this PR retargets to main automatically. Review the diff of the two commits below, not the full base comparison.

Tests

tests/test_teleoperate.py gains test_force_disconnect_partial_releases_vanished_cameras_capture_handle, which models OpenCVCamera's exact shape at the vanished-device point (is_connected=False, thread=None, live videocapture) with lerobot's disconnect guard reproduced verbatim, and asserts the raw handle is released, nulled, and that the bus still gets released afterwards. Existing friendly_hint tests in tests/test_rollout.py still pass unchanged — the new branches are additive and sit ahead of branches none of them exercise.

Checks: pytest tests/test_teleoperate.py tests/test_rollout.py tests/test_record.py → 324 passed. ruff check + ruff format --check on touched files → clean. pre-commit run --all-files → all 19 hooks pass.

Provenance

  • 07d5f4e — fix(teleoperate): release a vanished camera's stale capture handle; clearer camera-connect hints
  • e1c5c87 — fix(inference): route servo-bus errors to an arm hint instead of "could not download the model"

Carried forward with one adaptation: 07d5f4e predates the makerlabmakermodslab rebrand (342f4cd), so the test's from makerlab.teleoperate import ... and two prose references were updated to match the current package name. Verbatim, the test would have failed to import.

🤖 Generated with Claude Code

Mokuroh54 and others added 8 commits August 7, 2026 16:28
A transient camera failure during robot.connect() leaked the follower bus
and the already-opened cameras' background read threads for the rest of
the process, so every later recording attempt failed until makerlab was
restarted — first with the misleading "FeetechMotorsBus is already
connected", then by timing out against the leaked read threads still
holding the OS camera devices.

The retry path did call robot.disconnect() (under suppress), but that is
structurally incapable of releasing a partially-connected robot:
SOFollower.is_connected is all-or-nothing (bus.is_connected and all(
cam.is_connected ...)) and disconnect() carries @check_if_not_connected.
connect() opens the bus, then the cameras in dict order, so a camera
failure leaves every later camera unopened -> is_connected is False ->
the guard raises before releasing a single component, and suppress()
swallows it. The teardown was a no-op and the retry was guaranteed to
hit the still-open bus.

The leak also crossed sessions: the read-failure spam for OpenCVCamera(1)
precedes the first connect attempt, i.e. that thread belonged to the
previous failed session and was what made this session's camera come up
frame-dead. The existing sleep(2.0) "wait for camera resources to be
released" cannot help, because the holder is in-process.

Add teleoperate.force_disconnect_partial(): component-wise teardown with
each component independently guarded (cameras from .cameras, tolerating
DeviceNotConnectedError for never-opened ones; then buses via
_device_buses(), covering bimanual sub-arms). Safe on fully, partially,
and never-connected devices. Call it on every failed robot-connect
attempt, not just retried ones, so a terminal failure also leaves the
process clean — and on the teleop-connect failure path, so a leader
failure cannot strand the follower.

Recording is the only in-process flow affected: teleop and calibration
build follower configs with cameras == {} (bus-only is_connected, so
disconnect() works) and rollout runs as a subprocess.

Tests pin the mechanism against lerobot's real check_if_not_connected
decorator and real is_connected shape, so they fail if upstream ever
makes disconnect() tolerant of partial state.

Recording N12 (P1).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 025d4da)
…artial

bus.disconnect() disables torque internally too, but a single motor's
failed write there aborts the loop and leaves every motor after it
energized (the same failure mode force_disable_torque's docstring
already documents). force_disconnect_partial relied on disconnect()
alone for torque release; call force_disable_torque first, matching
the ordering _cleanup_after_setup_failure already uses for the same
"torque may be on after an incomplete connect" situation.
…ailure, return problems

Addresses three review findings on force_disconnect_partial:

- B: force_disable_torque ran unconditionally against every bus returned
  by _device_buses(), including one that never opened (the ordinary
  "wrong port"/"arm unplugged" case, since buses are constructed from
  config, not from a successful connect). That wrote to a closed port for
  every motor and printed the single most alarming string the system can
  emit -- "TORQUE MAY STILL BE ENABLED ... unplug its power" -- for an arm
  that was never energized, on what is the most common failure path by
  far. Fixed at the source (force_disable_torque's own per-bus loop) so
  every caller benefits, not just this one; test doubles that don't model
  connection state default to connected so existing torque-only tests are
  unaffected.

- C2: if bus.disconnect() itself raised, force_disconnect_partial just
  logged a warning and moved on, leaking the port handle for the rest of
  the process -- the one place in the codebase that gives up, when every
  other teardown path (utils/devices.py's _force_close_device_resources)
  treats a serial port as a trust boundary and force-closes past a failed
  disconnect. Added the same clearPort/is_using/closePort fallback,
  scoped per-bus so bimanual sub-arms each get their own.

- C3: the function discarded force_disable_torque's problem list and
  returned None, unlike its sibling _cleanup_after_setup_failure. Now
  returns list[str], same shape, so callers can surface a partial
  teardown instead of it vanishing into a debug log.

Also fixes five tests left importing from the pre-rename `makerlab`
module (rebase artifact -- line-based merge can't rewrite import paths
inside function bodies); the package is `makermodslab` since the
2026-08-04 rename.
The connect-retry path this branch adds was silent in two ways:

- force_disconnect_partial logs through makermodslab.teleoperate's
  logger, which wasn't in _RECORD_LOG_LOGGER_NAMES -- a real teardown
  failure ("Could not release robot bus on...") only ever reached the
  server console, invisible to an operator watching the Record page
  during a failed/retried connect. Added the teleoperate logger to the
  attach list (the existing ring-buffer mechanism already generalizes to
  any logger name, no new plumbing).

- The retry window can now run a full component-wise teardown plus up to
  three 2s backoff sleeps behind one static "Connecting arm & cameras..."
  label, with no signal anything is happening. Added a
  "reconnecting_robot" current_phase substep (mirrors the existing
  connecting_robot/connecting_teleop pattern) plus connect_retry_attempt/
  connect_retry_max on the status payload, logged at INFO through
  record's own logger so it lands in the visible panel too. Frontend
  reads the new fields to show "Camera hiccup, retrying (2/3)..."; an
  unrecognized phase string already falls back to the neutral
  "preparing" styling, so this is additive.
… force_disconnect_partial docstring

connect_retry_attempt was set to the attempt that just failed instead of
the one about to run, disagreeing with the adjacent log line's (attempt + 1)
convention that the Record page's retry UI is meant to mirror.
… on bus liveness

Move the reconnecting_robot phase/attempt-counter update before the
component-wise teardown instead of after: the teardown is the slow part
(a wedged camera's disconnect joins a read thread waiting out a frame
timeout), so bracketing only the backoff sleep left the operator staring
at a static "Connecting arm & cameras..." for the window this substep
exists to explain. Reset connect_retry_attempt to 0 once a retry succeeds
so it stays true to its "0 unless a retry is in flight" contract.

Add a bus-liveness probe (_bus_has_a_responding_motor) so
force_disable_torque only warns "TORQUE MAY STILL BE ENABLED" when a
motor actually failed to respond to the disable write, not whenever the
port opened but no motor answers a ping (unpowered/browned-out/wrong-baud
arm) - that case now gets its own accurate message instead of a
misleading rigid-arm warning. force_disconnect_partial's bus.disconnect()
call now passes disable_torque=False since force_disable_torque already
did that pass motor-by-motor, matching the pattern used elsewhere in this
codebase (rollout.py, motor_power.py, identify.py, auto_calibrate.py).
…learer camera-connect hints

Salvaged from the pre-PR#41 WIP stash (the browser-stream release gate
and pause flag it also carried are obsolete under backend previews and
were discarded):

- force_disconnect_partial: a camera whose device vanished mid-connect
  reports is_connected=False with no read thread, yet its videocapture
  still holds the OS capture session — left in place it poisons every
  later open of the replugged camera until restart. Release the raw
  handle directly on that path.
- friendly_hint: translate 'failed to open camera' (unplugged / stale
  index — ordered before the network branch that would misclassify
  lerobot's ConnectionError) and the connect-time turbulence family
  ('failed to set fps' / warmup-frame timeouts, where a vanished device
  reports a nonsense actual_fps) into held-or-unplugged guidance.

Carried forward from rig 07d5f4e, with the makerlab -> MakerMods Lab
naming from rig 342f4cd already folded in (the original text predates
that rebrand and would have imported a module that no longer exists).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ld not download the model"

lerobot raises every serial failure as ConnectionError, and the Hub-network
hint branch matched the bare token "connectionerror" (whose second conjunct
was self-satisfied by the word itself), so a robot.connect() torque/ID
failure surfaced as a model-download failure. Add a servo-bus branch keyed
on motors-bus phrasing, and require a Hub-specific token or the download-step
prefix for the download hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Mokuroh54 Mokuroh54 added bug Something isn't working area: backend FastAPI server and Python modules area: hardware Touches servos, cameras, calibration, teleop labels Aug 9, 2026
The connect-time turbulence branch keyed on "do not match configured",
which cannot reach a synchronous caller. lerobot detects a wrong native
format in _postprocess_image, whose only caller is _read_loop
(camera_opencv.py:453); connect()'s warmup goes through async_read. The
mismatch text therefore never propagates — the caller sees the dead
reader, "read thread is not running", which this branch did not match and
which fell through to no hint at all.

So the branch handled the string that cannot arrive and missed the one
that does. Add "read thread is not running"; keep "do not match
configured" as a forward guard, documented as unreachable today so it is
never again the sole marker for that failure.

Marker set now matches record.py's _is_transient_camera_error, keeping
the retry classifier and the terminal hint in sync: anything retried
there ends with advice here.

Propagation path established by j-ctang in #38; verified against the
pinned lerobot v0.6.0 build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three collisions, all cases where this branch and main fixed the same
thing independently while neither could see the other.

force_disable_torque's liveness probe. Both sides added
_bus_has_a_responding_motor to stop reporting "TORQUE MAY STILL BE
ENABLED — unplug its power" on an arm that has no power. This branch
probed first and skipped the torque pass on a silent bus; main attempts
every write and consults the probe only afterwards, to pick the wording.
Took main's: a degraded-but-recoverable bus can fail every zero-retry
ping while the retried (num_retry=5) write still lands, so gating the
write on the probe would leave a genuinely energized arm rigid. Main
also ships the regression test for exactly that case
(test_force_disable_torque_still_writes_when_the_probe_fails_but_the_bus_is_alive),
and the _FakeConnectableBus ping_dead flag it needs.

friendly_hint's "failed to set fps" marker. Merge base had neither side;
both invented a rule for it, meaning opposite things — this branch read
it as camera-session turbulence (vanished device reports a nonsense
actual_fps), main as a permanent misconfiguration (an operator typed an
fps the device can't do). Git kept both branches, and the turbulence one
shadowed main's, making its hint dead code and failing
test_every_retryable_camera_marker_that_can_be_permanent_has_a_hint.
Dropped the marker from the turbulence set: nothing in the string
separates the two causes, "click Auto" is actionable for the
misconfiguration and harmless for a vanished device, and the reverse is
not. No test on this branch pinned the turbulence reading. The markers
that stay there cannot be produced by a settings mistake.

The vanished-camera capture release in force_disconnect_partial — this
branch's actual subject — is kept as-is against main's `pass`, with its
test.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: backend FastAPI server and Python modules area: hardware Touches servos, cameras, calibration, teleop bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants