feat(recovery): readiness-based reset policy, configurable bounds (no fixed device constants) - #21
Merged
Merged
Conversation
… fixed device constants)
The wedged-device recovery waited a fixed 4 s between the USB port reset and its
single reopen. That constant was measured on one DJI Osmo Pocket 3, so it
over-waited on every faster device and burned the one reopen on every slower one.
Worse, it declared success on a clean uvc_start_streaming() — which a still-wedged
device returns happily while delivering nothing.
Replace it with a readiness-driven policy: issue the one port reset, poll
uvc_find_devices() on a 25->200 ms micro-backoff until the device re-enumerates,
reopen, and require an ACTUAL delivered frame before calling it recovered. The
proving frame is pushed back to the queue front, so proving recovery costs no
video. Two new properties bound it: reset-settle-max-ms (default 8000) and
reset-rearm-frames (default 30), following the max-payload/transfer-buffers
pattern. The start() path's blind usleep(1000000) becomes a readiness poll too,
but only after a forced cleanup — a normal start stays single-shot so an absent
device still fails immediately.
One port reset per silence episode is unchanged: retries inside the budget are
reopens only. The reconnect=true ladder is untouched; its loop body was factored
into a shared reopen_once() with identical attempt accounting.
Three defects the mock could not surface, each found on hardware and then pinned
red-first before its fix:
- A failed reopen ended the whole recovery, discarding the remaining budget
(gave up after 6.9 s of a 30 s budget). Now retried with micro-backoff.
- The libuvc context goes stale across a re-enumeration: a freshly started
process streamed again at +14.4 s while this element, on the context it held
across the reset, could not reopen at all in 30 s. uvc_exit()+uvc_init() on
the recovery path took the measured recovery from never to 288 ms.
- The budget could be overrun. Deadline checks between steps cap it at one
in-flight libuvc call; the residual is the synchronous, uninterruptible
teardown, which is now documented honestly rather than overclaimed.
Board-measured recovery: ~280 ms fast path (277/288 ms), 25 s worst-case tail
(21880/21893 ms). The tail is teardown-bound, not policy-bound.
Board-Evidence-SHA256: 1da244d8256fdcf96bfd6dca271fa5a803579cb42910b0827173c1a0f77cbde4
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Replaces the wedged-device recovery's fixed timing with a readiness-driven, bounded policy.
RESET_SETTLE_MS 4000(a settle measured on one DJI Osmo Pocket 3) and the start-pathusleep(1000000) // Wait 1 second for USB to settle.uvc_find_devices()on a 25→200 ms micro-backoff until the device re-enumerates, reopen, and require an actual delivered frame before declaring recovery. The proving frame is returned viag_async_queue_push_front(), so proving recovery costs no video.max-payload/transfer-bufferspattern (enum +g_object_class_install_property+ locked setter/getter):reset-settle-max-ms(uint, 0..120000, default 8000)reset-rearm-frames(uint, 1..100000, default 30)tests/board/wedge-recovery.sh— real-hardware only, deliberately not registered intests/CMakeLists.txt, skips with exit 77 unlessCERALIVE_BOARD_TEST=1.Unchanged on purpose: one port reset per silence episode (retries inside the budget are reopens only), and the
reconnect=trueladder. The ladder's loop body was factored into a sharedreopen_once()with identical attempt accounting —test_reconnect_exhaustion's exactopen_attempts == 6still passes.Why
The 4 s constant was device-specific: it over-waited on faster devices and burned the single reopen on slower ones. More seriously, the old code declared success on a clean
uvc_start_streaming()— which a still-wedged device returns happily while delivering nothing, i.e. the recovery could report success on the exact condition it exists to fix. libuvc exposes no readiness API, so a delivered frame is the only trustworthy signal.Three further defects were found on real hardware and each pinned red-first in the mock before its fix:
reopen_once()returning falsebreaks the loop, discarding the remaining budget (observed giving up after 6.9 s of a 30 s budget). Now retried with micro-backoff.uvc_exit()+uvc_init()on the recovery path took the measured recovery from never to 288 ms.How to verify
7 new ctest entries, all red-first (the first five failed with
object class 'GstLibuvcH264Src' has no property named 'reset-settle-max-ms'):reset_policy_property_defaults,reset_readiness_polls_reenumeration,reset_readiness_bounded_by_property,reset_requires_frame_delivery,reset_survives_transient_reopen_failure,reset_refreshes_libuvc_context,reset_rearm_frames_propertyThe mock gained a
MOCK_UVC_FRAME_SILENTmode (start succeeds, endpoint delivers nothing — the measured wedge, whichDISCONNECTcannot model) and amock_uvc_init_count()counter.No device-measured constants remain:
Real hardware (RK3588, Osmo Pocket 3
2ca3:0023, gated fault injection):Declared recovery bound: ~280 ms fast path (277/288 ms), 25 s worst-case tail (21880/21893 ms). RØDE
10-1negative control readuvcvideo/uvcvideobefore and after every run without exception.Risks / out of scope
reset-settle-max-msis not a hard bound and is documented as such. It budgets the element's own readiness loop;uvc_stop_streaming()/uvc_close()are synchronous with no interruption seam in this libuvc, and on a re-enumerating device push the total past it (measured ~22 s against 8 s). That is why the declared bound has two tiers — the tail is teardown-bound, not policy-bound.Named follow-ups, none attempted here:
setsid()s but stays in the creator's cgroup, soKillMode=mixed+FinalKillSignal=9kills it during unit teardown. ConsiderKillMode=process. Confirmed live: helperppid=1 sid=508217sharing the holder's cgrouppackaging/systemd/cerastream.serviceuvc_claim_if()arms the guard only after a successful claim and never rolls back a successfullibusb_detach_kernel_driver(), stranding an interface driverless in a live processsrc/device.c:1049-1057idleand waits for a human, so no in-element recovery can ever runUSB port resetstring count = 0); the device image needs a refreshedgstreamer1.0-libuvch264srcreset-settle-max-mswas documented as a bound it cannot honour — resolved in this PR as a semantics correctionUSBDEVFS_RESET(2 occurrences,error -71, physical replug required). Pre-existing in PR #20's mechanism — this PR changed the timing around the reset, not the reset itselfcamera-compat.mdThe
--repeat 3unbroken board run was not achieved and further attempts were deliberately not made: repeated port resets in a tight loop knocked the Osmo off the USB bus twice (error -71, physical replug required). Four independent real-hardware observations across separate runs stand in its place. This deviation is recorded explicitly in the frozen board proof.