Skip to content

fix(cameras): contain a wedged camera read — bounded locks, first-frame deadline - #74

Open
Mokuroh54 wants to merge 5 commits into
mainfrom
fix/camera-wedge-containment
Open

fix(cameras): contain a wedged camera read — bounded locks, first-frame deadline#74
Mokuroh54 wants to merge 5 commits into
mainfrom
fix/camera-wedge-containment

Conversation

@Mokuroh54

@Mokuroh54 Mokuroh54 commented Aug 8, 2026

Copy link
Copy Markdown

Important

Stacked PR — layer 2 of 2 on #70 (fix/camera-hotplug-pump). Merge #70 first, then retarget this to main (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.

  • Every lock acquisition that contends with a reader is bounded (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.
  • A stream's first read gets a deadline (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).
  • Because a wedged entry is deregistered, a retry after the pump heals the device gets a fresh capture and succeeds — the pump makes recovery possible, this layer makes the retry reachable without a hang.

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 --check clean; pytest tests/test_camera_preview.py tests/test_camera_identity.py44 passed (23 preview + 21 identity, all hardware mocked; verified under both random and fixed test ordering); scoped pre-commit (typos, bandit, mypy et al.) fully green.

test_wedge_does_not_fast_fail_a_different_camera_at_the_same_index is 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

🤖 Generated with Claude Code

…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>
@Mokuroh54
Mokuroh54 marked this pull request as ready for review August 9, 2026 01:12
@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
@IsaacSinn

Copy link
Copy Markdown

1. Only the first read is protected — a camera that wedges later still hangs shutdown. The read loop is if first_frame: _read_first_frame(entry) (watchdog) else: entry.cap.read() — the else branch is a raw, unbounded OpenCV call made while holding the per-camera lock. So: open a preview, see frames, unplug and replug in the same port. The next read wedges with no deadline, the thread sits in OpenCV forever holding the lock, and the MJPEG response never completes. A --reload or graceful shutdown still hangs until you force-kill — which is the exact failure this PR's own commit message says it contains. The added test covers a blocking first read, not a blocking later one. This one is pre-existing, but it blocks because the PR claims to close it.

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. BackendCameraStream.tsx caps the retry interval at 12s but never caps the attempt count. Leave the tile open on a genuinely dead camera and threads and handles accumulate until restart. Introduced: the deregister-and-reopen behavior is new, and it turns the existing retry loop into an unbounded leak. (The good news: an abandoned read writes only into its own stale local list, so it can't inject a garbage frame into the new owner.)

3. The 2s lock timeout undercuts the 5s deadline. LOCK_ACQUIRE_TIMEOUT = 2.0, FIRST_FRAME_TIMEOUT = 5.0. A real webcam can legitimately take 2–3s for its first frame. Open the same preview in two tabs: the first gets 5s, the second waits only 2s for the shared lock, declares a perfectly healthy camera wedged, and tells that user to restart the app — possibly moments before the first request succeeds. Then the entry stays flagged wedged, so everyone after fails fast too.

Mokuroh54 and others added 3 commits August 10, 2026 22:04
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>
@Mokuroh54
Mokuroh54 force-pushed the fix/camera-wedge-containment branch from 33ff61e to c7180a0 Compare August 11, 2026 14:27
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).
@Mokuroh54

Copy link
Copy Markdown
Author

All three confirmed and fixed in 551289e1 — thank you, these were all reachable exactly as described, and each one now has a regression test. I reproduced each against the branch before touching anything, so the notes below are measurements rather than readings.

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 (_acquire, _release, stop_all) were already bounded, so a late wedge never froze Start or stacked new clients. What survived was specifically the stream generator itself, which is the one that holds shutdown hostage. Narrower than "the containment doesn't work", but you're right that it's the headline case.

_read_first_frame is now _read_with_deadline(entry, timeout) and every read goes through it — FIRST_FRAME_TIMEOUT (5s) for the first, a new READ_TIMEOUT (2s, ~30x a healthy read at 15fps) for the rest. I benchmarked the watchdog-per-frame cost before committing to it: 25us to start and join, ~0.04% of one core per camera at TARGET_FPS, which is noise next to the JPEG encode already in that loop. It's also the only way to bound an uncancellable blocking call in CPython.

2. Retries leak a thread and a capture. Confirmed, and the mechanism is slightly worse than "the deregister-and-reopen behavior is new": _release deletes the entry from _captures before the wedge flag is set, so the flag landed on an object nobody could look up again. _acquire's fast-fail check and the class docstring's promise that "new clients get a fast CameraOpenError" were both already there — they were simply unreachable, because the entry was always gone before the next client arrived.

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 (_mark_wedged, guarded so it can't overwrite a live entry that has since taken the key). The cost is now capped at one thread, one capture and one lock per physically wedged camera, no matter how long the tile retries. stop_all also skips tombstones now — otherwise it re-waited LOCK_ACQUIRE_TIMEOUT on each of them on every recording/inference Start, to re-learn what it already knew.

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 BackendCameraStream's unbounded retry is deliberate: most preview failures are transient (a recording session holding the cameras, a restarting server), and a blind attempt cap would leave the tile dead after a long recording. If you'd still rather bound it, I'd want it conditional on the unrecoverable reason rather than on attempt count, which needs a machine-readable signal the endpoint doesn't currently emit. Happy to do that as a follow-up if you disagree.

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 entry.wedged is set on a working device.

LOCK_ACQUIRE_TIMEOUT is now derived rather than picked:

LOCK_ACQUIRE_TIMEOUT = max(FIRST_FRAME_TIMEOUT, READ_TIMEOUT) + 1.0

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

  • stop_all's worst case on the Start path goes 2s -> 6s, but only when a camera is genuinely mid-first-frame or wedged. That's the price of removing the false positives, and with tombstones a false wedge is now permanent-until-restart, so removing them stopped being optional.
  • The test fixture that shrinks these timeouts now preserves the production ordering rather than setting them equal, so it can still tell a slow read from a wedge.

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 tests/test_camera_preview.py: mid-stream wedge ends the response; 10 retries open one capture and strand no extra threads; a slow-but-healthy first read is not mistaken for a wedge by a second client; the timeout ordering invariant; stop_all skips tombstones; and the two existing wedge tests updated to assert the tombstone instead of deregistration.

Full suite: 1276 passed. The 7 test_models.py failures are pre-existing on this base (real-user-state leakage, fixed separately by #76) — I verified them against a pristine checkout of the branch tip.

Base automatically changed from fix/camera-hotplug-pump to main August 13, 2026 07:08
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