fix(cameras): contain a wedged camera read — bounded locks, first-frame deadline - #74
fix(cameras): contain a wedged camera read — bounded locks, first-frame deadline#74Mokuroh54 wants to merge 5 commits into
Conversation
…view tile releases on remove
- BackendCameraStream: key={attempt} remounts orphaned the mount-captured
unmount cleanup, so removing a tile after any retry left the live MJPEG
connection open and the server holding the camera. A callback ref clears
src on every element detach (retry remounts and final unmount alike).
- camera_identity: pump_avfoundation_runloop() — AVFoundation queues its
device-cache updates on the main dispatch queue, which only the MAIN
thread's runloop drains; a background-thread NSRunLoop verifiably does
nothing (three-arm hardware experiment, camera_runloop_experiment.py,
committed alongside). Pumping ~50ms every 0.5s keeps the in-process
snapshot live: cameras plugged after startup become visible and a
same-port replug recovers on its own — the "restart MakerMods Lab"
paths are no longer reachable on macOS. Hardware-verified end-to-end.
- server: start the pump from an async startup handler (async = it runs
on the main-thread event loop, which the pump requires); cancel it in
shutdown_event so --reload restarts stay clean.
Changing which port a camera occupies while the server runs remains
unsupported: uniqueID is the port path, so identical cameras swapped
pairwise are undetectable in principle.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
1. Only the first read is protected — a camera that wedges later still hangs shutdown. The read loop is 2. Retries leak a thread and a capture, without bound. After a 5s wedge the code abandons a daemon thread plus its capture and deregisters the index, so the frontend retries — opening another capture that can wedge another thread. 3. The 2s lock timeout undercuts the 5s deadline. |
The runloop pump this PR adds makes the in-process AVFoundation device list live, and a live list is what lets indices renumber at runtime. CameraPreviewManager._captures is keyed by the bare cv2 index, so the identity /camera-preview has just resolved is discarded one call later: attach a camera that sorts ahead of one already streaming, and the new camera's client is handed the capture still bound to the old device. The user then configures and names one camera while watching another's picture, and that name lands in a robot record. Before the pump the aliasing was unreachable — a frozen device list makes uniqueID -> index constant for the process lifetime, so the index was a sound stand-in for identity. It stops being one here, which is why the fix belongs in this PR rather than behind it. - camera_identity: identify_cv2_index() returns (index to open, identity key), backfilling the uniqueID when the caller supplied none — the frontend's uniqueId is optional, and without the backfill two clients would key the same device differently and open it twice. - camera_preview: registry keyed by identity, falling back to the index only where identity is unavailable (non-macOS, PyObjC missing, query failure) — platforms whose device list is not live-refreshed anyway. - list_cameras_in_process: every "could not ask" path now returns None explicitly — a framework that fails to load, no device-type constant resolving, a discovery session answering nothing. Each previously fell through to an empty list, indistinguishable from a real empty machine. Callers depend on that difference: None means trust the index, [] means the device is verifiably absent and we must fail loudly. - server: the preview 503 names macOS camera-permission denial alongside attached-after-startup, since a denied process enumerates truthfully and a restart cannot help it. Does NOT address a same-port replug (the uniqueID survives it, so a dead handle is still returned for the right device) or two identical cameras swapped between ports (uniqueID is a port path, so it swaps with them).
…me deadline The pump (previous commit) keeps the device list truthful, but it cannot retract a handle cv2 has already issued: a capture held open across a same-port replug stays bound to the dead device — isOpened() stays True and the next cap.read() blocks forever while holding the per-index lock (verified live 2026-08-07). One wedged read then stacks every preview client, freezes recording/inference start (stop_all), and holds uvicorn's graceful shutdown hostage via the in-flight MJPEG response. Containment bounds every lock acquisition that contends with a reader (LOCK_ACQUIRE_TIMEOUT = 2.0): a timeout means "the reader is wedged" — leak the capture, log loudly, deregister the entry, move on. Leaking is deliberate: releasing a capture while a read may be in flight segfaults cv2. A first-frame deadline (FIRST_FRAME_TIMEOUT = 5.0) turns a wedged device into a visible stream error instead of an eternal blank tile; the wedge signal is the watchdog thread still being alive after the join — a read that raises is a fast failure and releases normally. Because a wedged entry is deregistered, a retry after the pump has healed the device gets a fresh capture and succeeds: the pump makes recovery possible, the containment makes the retry reachable without a hang. Known cost: each wedged attempt against a genuinely dead device leaks one capture and one daemon thread, bounded by the frontend's capped retries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t I8 The I10 section's untested-scope list names --reload restarts as an open risk for the teleop stop path. A --reload shutdown hang WAS then seen in the real world — but the cause was a camera preview thread wedged on a stale same-port-replug device, holding _SharedCapture.lock so graceful shutdown never completed. Point future debuggers at wedged camera threads (now contained by the previous commit) before suspecting I8's stop wiring. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
33ff61e to
c7180a0
Compare
Addresses the three findings on PR #74. All three reproduce against the branch as it stood; each now has a regression test. 1. Only the first read was watchdogged; later reads were a raw cap.read() under the entry lock. A device dying mid-stream left the generator blocked inside cv2 forever, so the MJPEG response never completed and uvicorn's graceful shutdown waited on it — the exact hang this PR says it contains. _read_first_frame becomes _read_with_deadline and every read goes through it (READ_TIMEOUT for the steady state, the existing FIRST_FRAME_TIMEOUT for the first). A watchdog thread per frame costs ~25us, ~0.04% of a core at TARGET_FPS. 2. A wedge deregistered its entry, so the next client built a fresh one and opened a fresh capture on the same dead device. The preview tile retries forever by design, so that leaked a thread and a handle every 12s: measured 10 captures and 10 stranded threads over 10 retries. The entry is now kept as a tombstone (_mark_wedged), which is what the class docstring already claimed and what identity keying made safe. Capped at one leaked thread and one capture per wedged camera. stop_all skips tombstones rather than re-waiting the lock on each Start. 3. LOCK_ACQUIRE_TIMEOUT (2s) sat below FIRST_FRAME_TIMEOUT (5s), so a contender gave up sooner than a healthy camera is allowed to spend on a first read: a second tile on a camera with a 3s warm-up refused it and marked it wedged. The contention timeout is now derived to sit above every read deadline, which is also what makes "lock still held => the reader is wedged" a sound inference for the first time. Full suite: 1276 passed; the 7 test_models failures are pre-existing on this base (real-user-state leakage, fixed separately by PR #76).
|
All three confirmed and fixed in 1. Only the first read was protected. Confirmed. Reproduced with a capture that streams one frame and then blocks: the generator never returns, so the MJPEG response never completes and graceful shutdown waits on it forever — the hang the commit message claimed to contain. One scoping note in the PR's favour: the lock acquisitions on the other three paths (
2. Retries leak a thread and a capture. Confirmed, and the mechanism is slightly worse than "the deregister-and-reopen behavior is new": Measured on the unfixed branch: 10 retries against a dead camera opened 10 captures and stranded 10 threads. Fixed by keeping the entry as a tombstone ( I did not add the frontend attempt cap, and I want to flag that rather than quietly skip it. Once the leak is fixed at the source, a retry is an instant 503 with no allocation and no log line, so the cap would be cosmetic — and 3. The 2s lock timeout undercuts the 5s deadline. Confirmed, and this one was the most interesting. Reproduced with a healthy camera that takes 3s for its first frame (well inside the 5s budget): the second client is refused with "Restart MakerMods Lab", and
LOCK_ACQUIRE_TIMEOUT = max(FIRST_FRAME_TIMEOUT, READ_TIMEOUT) + 1.0The derivation is really the point. That timeout is the sole evidence for "the holder is wedged", and the inference only holds if no healthy reader can still hold the lock when it expires — i.e. if it sits strictly above every read deadline. At 2.0 it sat below the 5.0 first-frame budget, so a warming-up camera and a dead one were indistinguishable. There's now a test asserting the ordering directly, since inverting it silently re-arms this. Two consequences worth naming:
Known limit, unchanged: the tombstone makes recovery restart-only, which is what every message on this path already says. #70's runloop pump means a replugged camera could in principle revive in-process, but telling "it came back" from "still dead" needs a re-enumeration signal — the uniqueID reads identically either way. Worth a follow-up, not this PR. Tests added/updated in Full suite: 1276 passed. The 7 |
Important
Stacked PR — layer 2 of 2 on #70 (
fix/camera-hotplug-pump). Merge #70 first, then retarget this tomain(automatic if #70's branch is deleted on merge). Do not squash-merge #70 while this is open — that duplicates its commits into this diff and forces a manual rebase. Merge order: #70 → this.What
Defense-in-depth for the same-port-replug camera wedge. #70's runloop pump prevents stale devices (the list heals, replug self-recovers). This layer survives the case the pump cannot reach: a
cap.read()already bound to a dead handle —isOpened()stays True, the read blocks forever while holding the per-index lock (verified live 2026-08-07), and one wedged read then stacks every preview client, freezes recording/inference start, and holds uvicorn's graceful shutdown hostage via the in-flight MJPEG response.LOCK_ACQUIRE_TIMEOUT = 2.0s). Timeout ⇒ the reader is wedged: leak the capture, log loudly, deregister, move on. Leaking is deliberate — releasing a capture under an in-flight read segfaults cv2.FIRST_FRAME_TIMEOUT = 5.0s), so a wedged device becomes a visible stream error instead of an eternal blank tile. The wedge signal is precise: the watchdog thread still alive after the join. A read that raises is a fast failure and releases normally (tested, mutation-verified).Known tradeoff (deliberate)
Each wedged attempt against a genuinely dead device leaks one
cv2.VideoCapture+ one daemon thread. Retries are bounded by the frontend's capped backoff; a leak beats a process-wide hang, and the only true fix for an already-wedged handle is a restart.Rebased onto #70's identity-keyed base
#70 now keys the preview capture registry by camera uniqueID rather than by cv2 index, so this layer's wedge flag is scoped to the device that wedged instead of to an index slot — previously a renumber left a wedged entry fast-failing whatever camera had since taken that index, while the genuinely dead device answered fresh under its old number. The bounded-lock and first-frame-deadline logic is unchanged; every registry lookup now goes through the identity key, and the "per-index lock" is a per-camera lock throughout.
Checks
ruff check+ruff format --checkclean;pytest tests/test_camera_preview.py tests/test_camera_identity.py→ 44 passed (23 preview + 21 identity, all hardware mocked; verified under both random and fixed test ordering); scopedpre-commit(typos, bandit, mypy et al.) fully green.test_wedge_does_not_fast_fail_a_different_camera_at_the_same_indexis the one that earns its place here: it passes only if the containment and the identity keying are both present and interacting correctly.Deferred / out of scope
camera_runloop_experiment.pyat repo root is fix(cameras): hotplug/replug self-recovers via main-runloop pump; preview tile releases on remove #70's, untouched here.camera_identity.pychange is docstring-only: documents the residual hole this layer contains, without weakening fix(cameras): hotplug/replug self-recovers via main-runloop pump; preview tile releases on remove #70's pump claims.🤖 Generated with Claude Code