Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ gstlibuvch264src/
│ │ ├── spspps_path.h # Pure path-builder (no GObject dep, unit-testable)
│ │ ├── ptz_control.{c,h} # PTZ probe/set helpers + control socket bind/unbind/thread
│ │ ├── uvc_device.{c,h} # USB teardown helper + V4L2 capability probe
│ │ └── quirks.{c,h} # vid:pid quirk seam (table + lookup + logging); ships empty
│ │ └── quirks.{c,h} # vid:pid quirk table: DOUBLE_PROBE + MAX_PIXEL_RATE; one row (Osmo Pocket 3, sets both)
│ ├── docs/notes/
│ │ ├── reconnect-spike.md # Spike verdict: libuvc dead-handle teardown is SAFE
│ │ ├── bmaxpayload-analysis.md # max-payload bandwidth tuning analysis
Expand Down Expand Up @@ -66,7 +66,7 @@ gstlibuvch264src/
│ ├── test_cve_2026_1991.c # CVE-2026-1991 regression: null-deref guard in scan-streaming path
│ ├── test_cache_race.c # SPS/PPS cache concurrent read/write race (TSan)
│ ├── test_transfer_buffers.c # transfer-buffers property: sentinel/clamp/reconnect re-arm, fork-only gated
│ ├── test_quirks.c # vid:pid quirk lookup: empty table, match, QUIRK_DOUBLE_PROBE
│ ├── test_quirks.c # vid:pid quirk lookup/limits, QUIRK_DOUBLE_PROBE, Osmo pixel-rate cap + double probe
│ ├── board/ # MANUAL, hardware-only; never registered with ctest
│ │ └── wedge-recovery.sh# Gated-SIGKILL wedge + real-libusb_reset_device recovery timing
│ ├── fuzz_nal.c # NAL parser fuzz harness (libFuzzer entry point)
Expand Down Expand Up @@ -312,7 +312,7 @@ The entire ctest suite is **mock-backed** — `tests/mock_libuvc.c` stands in fo
- The pure logic that does NOT depend on a real device: the Annex-B NAL parser and its count/overflow bounds (`test_nal_parse` — including the `overflow` truncation-warning and `count_bound` suites), the SPS/PPS path builder, cache-key snapshot, and the cache file-open NULL/missing-file path (`test_cache`, `test_live_source` `spspps_key_snapshot`/`cache_open_null_path`).
- Concurrency/teardown invariants observable in-process under sanitizers: the PTS/clock lock (`test_pts_thread_safety` TSan), the SPS/PPS-bounds clamp and cache index race (ASan/TSan), USB single-`libusb_close` teardown (`test_usb_teardown`), and the CVE-2026-1991 null-guard against the vendored libuvc.
- Frame-callback-driven behavior fed by crafted access units through the mock: PTS monotonicity, IDR gating, write-on-change caching, disconnect/unlock lifecycle.
- The `transfer-buffers` property contract (`test_transfer_buffers`: sentinel/clamp/reconnect re-arm, fork-only cases gated behind `TB_API_AVAILABLE` so the same test binary stays green on both `LIBUVC_USE_FORK=ON` and `OFF`) and the vid:pid quirk lookup (`test_quirks`: empty-table default, a matching entry, `QUIRK_DOUBLE_PROBE` behavior) and the negotiation-failure descriptor inventory (`test_negotiate`'s `negotiate_inventory_logged` case).
- The `transfer-buffers` property contract (`test_transfer_buffers`: sentinel/clamp/reconnect re-arm, fork-only cases gated behind `TB_API_AVAILABLE` so the same test binary stays green on both `LIBUVC_USE_FORK=ON` and `OFF`) and the vid:pid quirk table (`test_quirks`: pure lookup/limits resolution, `QUIRK_DOUBLE_PROBE` probe count, the shipped Osmo `QUIRK_MAX_PIXEL_RATE` cap, and a red/green pair driving `negotiate()` against the Osmo's real advertised H.264 ladder — one case pins that an UNquirked device still picks the top mode 3840x2160@60, the other that the quirked Osmo lands on its capped ceiling 3840x2160@30 — plus `quirks_osmo_row_double_probes`, which pins that the shipped Osmo row probes TWICE and the cap still lands the same mode) and the negotiation-failure descriptor inventory (`test_negotiate`'s `negotiate_inventory_logged` case).

**Hardware-only, run by hand (NOT in ctest):** `tests/board/wedge-recovery.sh` induces a real wedge on a board — a gated SIGKILL of a holder that is provably streaming, matching the kill discipline the wedge investigation used — then measures reset-to-advancing-frames through the REAL `libusb_reset_device()` path and asserts it against `reset-settle-max-ms`, with a second USB port as a negative control. It is deliberately absent from `tests/CMakeLists.txt` so it can never run in CI, and skips (exit 77) unless `CERALIVE_BOARD_TEST=1`.

Expand Down Expand Up @@ -366,4 +366,8 @@ The `.deb` version is derived **purely from git tags** at publish time via the `
- Do NOT call `force_usb_release()` before `uvc_close()` — it was a double-free/UAF vector; the fix lets `uvc_close()` own the single `libusb_close()`.
- Do NOT enable `control-socket` by default or fall back to a world-accessible path when `XDG_RUNTIME_DIR` is unset — the socket must be opt-in and per-instance.
- Do NOT set PTZ properties outside the param-spec range in tests — GObject emits a range warning that gst-check turns into a longjmp, skipping teardown and hanging the process.
- Do NOT raise a `QUIRK_MAX_PIXEL_RATE` cap on the strength of a descriptor, a datasheet, or a successful `uvc_get_stream_ctrl_format_size()`. Only frames that actually ADVANCE on real hardware justify a higher cap; the cap is deliberately parked at the last CONFIRMED-GOOD rate, not the last known-bad one, because a cap set too low only costs resolution while a cap set too high costs the whole stream. (The Osmo Pocket 3 row is now capped at `3840x2160x30` = `248832000u` — raised from `62208000u` only after 4K@30 was captured through this element on 2026-07-30: 300/300 access units in ~10.8 s, SPS-verified `3840x2160`/`high`/`5.2`, zero errors, reproduced twice on board `192.168.78.131`. That is the bar. 4K@60/50/48 stay capped out — an uncapped build did stream 4K@60 that day, but the original zero-frame observation was never explained; see `camera-compat.md` §2 Step 5.)
- Do NOT read an `Unable to get stream control: Invalid mode` failure as a caps-logic or descriptor problem before checking what mode the device last committed. libuvc rejects the mode when the device's SET_CUR/GET_CUR readback disagrees, and a camera that answers the first probe from its previously committed mode fails EVERY negotiation that asks for a larger mode — deterministically, though it looks intermittent in the field. That is what `QUIRK_DOUBLE_PROBE` is for; the Osmo Pocket 3 row sets it.
- Do NOT "fix" the `quirks_ladder_no_quirk_picks_4k60` test because it asserts the buggy 3840x2160@60 outcome. That is deliberate: it pins the untouched max-area-then-max-fps behavior that every camera WITHOUT a quirk row still gets, and it is the control half of the pair whose other half proves the Osmo cap works.
- Do NOT special-case a device inside `gst_libuvc_h264_negotiate()` (`if (vid == ... && w == 3840 ...)`). The quirk table exists so device knowledge stays data-driven — one row, no branching in the selection loop.
- This plugin is **not** in the device image REPOS list by default — don't assume it's always present on device.
86 changes: 79 additions & 7 deletions libuvch264src/docs/notes/camera-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,84 @@ See AGENTS.md DISCONNECT / RECONNECT BEHAVIOR for the exact detection window

### Step 5: check for a vid:pid quirk match

The element carries a `QUIRK_DOUBLE_PROBE` quirk seam (`libuvch264src/src/quirks.{c,h}`)
for cameras that need `uvc_get_stream_ctrl_format_size()` called twice before
the negotiated format sticks (libuvc upstream issue #242). The production
quirk table ships **empty**: no device is quirked by default. If you find a
camera that needs this workaround, that's a signal to add a table entry, not
something the field-triage steps above can toggle at the command line today.
The element carries a vid:pid quirk table (`libuvch264src/src/quirks.{c,h}`) with
two flags:

- `QUIRK_DOUBLE_PROBE` — for cameras that need
`uvc_get_stream_ctrl_format_size()` called twice before the negotiated format
sticks (libuvc upstream issue #242).
- `QUIRK_MAX_PIXEL_RATE` — for cameras that advertise frame intervals they
cannot deliver. The row carries the highest `width x height x fps` PROVEN to
stream, and negotiation drops every advertised rate above it.

The table currently holds **one** row: the DJI Osmo Pocket 3 (`2ca3:0023`), which
sets **both** flags, capped at `3840x2160x30` = 248 832 000 px/s.

**Why it needs the double probe (the "camera is not detected" symptom).**
`uvc_probe_stream_ctrl()` SET_CURs the control it wants, GET_CURs it back, and
rejects the mode if the readback disagrees. The Osmo answers that first GET_CUR
from the mode it PREVIOUSLY committed, so a negotiation asking for a mode LARGER
than the currently committed one fails with `Unable to get stream control:
Invalid mode` about 170 ms into `start()`. Measured on hardware
(`192.168.78.131`, 2026-07-30), one probe versus two:

| requested transition | 1 probe | 2 probes |
|---|---|---|
| `1280x720@30` → `1920x1080@30` | 3/3 **FAIL** | 3/3 pass |
| `1920x1080@30` → `3840x2160@60` | 20/20 **FAIL** | 4/4 pass |
| same mode again, or a smaller one | pass | pass |

Deterministic and direction-specific — 23/23 on a mode increase, never
otherwise. The `720p → 1080p` row matters most: the element's own shipping mode
is affected, so this is not a 4K-only concern. It presents as intermittent in the
field only because whether it fires depends on what mode the device last
committed.

**Why it also carries a pixel-rate cap.** Its H.264 descriptor advertises
3840x2160 at 60/50/48 fps, and `negotiate()` prefers the largest area at the
highest fps, so 4K@60 was selected by construction. Those rates were originally
recorded negotiating cleanly and then delivering **zero** frames, with the 5 s
silence watchdog reporting a disconnect that never happened.

> **The zero-frame premise did NOT reproduce on 2026-07-30.** On libuvc `4868e57`
> the unquirked binary negotiated 4K@60 and delivered real, sustained 4K —
> 600 access units in 10.6 s (~56 fps) twice over, with `h264parse` reading
> `3840x2160`, `high` profile, level `5.2` straight out of the SPS.

**4K@30 is settled: the cap was raised, and the capture came first.** The cap now
sits at `3840x2160x30` = 248 832 000 px/s, measured through this element on board
`192.168.78.131` on 2026-07-30 with the plugin `.so` deployed alone (the board's
libuvc untouched at `4868e57`, so the cap was the only variable). Two runs of
`num-buffers=300 ! video/x-h264,width=3840,height=2160,framerate=30/1`:

| run | access units | duration | `h264parse` caps from the SPS | errors |
|---|---|---|---|---|
| 1 | clean EOS, exit 0 | 10.71 s | `3840x2160`, `high`, level `5.2` | 0 |
| 2 | **300/300** (counted at an `identity` probe) | 10.79 s | `3840x2160`, `high`, level `5.2`, `4:2:0` | 0 |

No `RESOURCE/READ`, no silence-watchdog disconnect on either run. Both logged
`max pixel rate 248832000` and `quirk: dropped 3 non-deliverable rate(s) at
3840x2160` — 60/50/48 stayed excluded while the capped rate streamed. The value
is 4K@30 *exactly* rather than the whole 4K@60 descriptor range, which is what
makes the capture conclusive: `negotiate()` prefers max area then max fps, so
3840x2160@30 is the surviving top mode **by construction** and the runs cannot
have silently measured something else.

> **The caveat below still stands for every FUTURE raise, including 4K@60.** The
> original zero-frame observation was real and was never explained (one candidate:
> the same stale-readback defect `QUIRK_DOUBLE_PROBE` exists for can leave a
> bound-but-silent stream when the readback happens to compare equal), so
> 4K@60/50/48 remain capped out. Raising the cap further is the one-number change
> described in `quirks.c` — do not make it on the strength of a descriptor, a
> datasheet, a successful `uvc_get_stream_ctrl_format_size()`, or this note alone.
> It takes advancing frames on real hardware: a bounded access-unit count,
> SPS-verified geometry, reproduced.

See `quirks.c` for the full evidence and for how to raise the cap.

If you find another camera that needs either workaround, that's a signal to add a
table entry — not something the field-triage steps above can toggle at the
command line today.

---

Expand All @@ -147,7 +219,7 @@ state and finalized in the fork's `CHANGELOG.ceralive.md`. Each backlog ID
| A11 | upstream PR #224 | skip-equivalent | already in `2f32812` (pre-dates this hardening wave) | "Only detach an actually-active kernel driver" is already covered by the fork's `libusb_set_auto_detach_kernel_driver` call plus `uvc_claim_if`'s tolerance of the no-active-driver error codes. |
| A12 | pupil-labs `92d2f82` + `74e7a96` (clock half only) | adapt + pick | `9874f4c` | Preserves `dwClockFrequency` from the VideoControl header for `bcdUVC` 0x0110 and 0x0150 (previously only 0x0100/0x010a set it). Plumbing only; per the SCR-ABSENT verdict in `scr-investigation.md`, this value is never surfaced on frames, so it has no PTS behavior impact. |
| A13 | saki4510t `2596242` | skip-equivalent | none (confirmed no-op) | The libuvc-portion of this commit is comment-only for ref/unref (already correct in the fork) plus an Android-JNI-only function absent from this codebase entirely. Nothing to land. |
| A14 | libuvc upstream issue #242 (double-probe workaround) | plugin-only, not a fork patch | `3d5003e` (plugin repo, not the fork) | Implemented as the `QUIRK_DOUBLE_PROBE` vid:pid quirk seam in `libuvch264src/src/quirks.{c,h}`, wired into `negotiate()`. The production quirk table ships empty; no device is quirked by default. |
| A14 | libuvc upstream issue #242 (double-probe workaround) | plugin-only, not a fork patch | `3d5003e` (plugin repo, not the fork) | Implemented as the `QUIRK_DOUBLE_PROBE` vid:pid quirk seam in `libuvch264src/src/quirks.{c,h}`, wired into `negotiate()`. The DJI Osmo Pocket 3 row sets it alongside `QUIRK_MAX_PIXEL_RATE` — board-measured 23/23 `Invalid mode` failures on a mode increase with a single probe, 0 with two (§2 Step 5). |

**Plugin-side commits that consume the fork's hardening:**

Expand Down
74 changes: 59 additions & 15 deletions libuvch264src/src/gstlibuvch264src.c
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,18 @@ static gboolean gst_libuvc_h264_negotiate(GstBaseSrc * basesrc) {
gboolean result = FALSE;
gboolean found_codec_format = FALSE;

// vid:pid quirk seam (A14), resolved BEFORE the selection loop because a quirk
// can rule advertised-but-undeliverable modes OUT of the selection, not just
// change how the winning mode is probed. A device with no row gets zeroed
// limits, which impose nothing.
uvc_quirk_limits_t quirk_limits = {0};
uvc_device_descriptor_t *quirk_desc = NULL;
if (uvc_get_device_descriptor(self->uvc_dev, &quirk_desc) == UVC_SUCCESS
&& quirk_desc != NULL) {
uvc_quirks_limits(quirk_desc->idVendor, quirk_desc->idProduct, &quirk_limits);
uvc_free_device_descriptor(quirk_desc);
}

// Enumerate supported H264 / H265 resolutions and framerates
// And select the highest compatible resolution, at the highest supported framerate
for (const uvc_format_desc_t *format_desc = uvc_get_format_descs(self->uvc_devh);
Expand Down Expand Up @@ -571,8 +583,19 @@ static gboolean gst_libuvc_h264_negotiate(GstBaseSrc * basesrc) {
GValue framerates = G_VALUE_INIT;
g_value_init(&framerates, GST_TYPE_LIST);

guint excluded = 0;
for (const uint32_t *interval = frame_desc->intervals; *interval; interval++) {
gint _fps = 1e7 / *interval;

// A quirked device advertises rates it cannot deliver. Drop
// them here, before they can win the preference below OR reach
// the caps we publish downstream.
if (!uvc_quirk_mode_selectable(&quirk_limits, frame_desc->wWidth,
frame_desc->wHeight, (guint)_fps)) {
excluded++;
continue;
}

if (_fps > fps) {
fps = _fps;
}
Expand All @@ -584,6 +607,20 @@ static gboolean gst_libuvc_h264_negotiate(GstBaseSrc * basesrc) {
g_value_unset(&fps);
}

if (excluded > 0) {
GST_INFO_OBJECT(self,
"quirk: dropped %u non-deliverable rate(s) at %ux%u",
excluded, frame_desc->wWidth, frame_desc->wHeight);
}

if (fps < 0) {
// Every rate this descriptor advertises is above the cap, so
// the whole mode is unusable; an empty framerate list would
// otherwise fixate to nothing.
g_value_unset(&framerates);
continue;
}

// gst_structure_set_value() copies the list, so the local GValue
// owns a GST_TYPE_LIST that must be released or it leaks per call.
gst_structure_set_value(tmp_structure, "framerate", &framerates);
Expand All @@ -599,8 +636,24 @@ static gboolean gst_libuvc_h264_negotiate(GstBaseSrc * basesrc) {
continue;
}
gint fps_min = 1e7 / frame_desc->dwMaxFrameInterval;
gint fps = 1e7 / frame_desc->dwMinFrameInterval;
gst_structure_set(tmp_structure, "framerate", GST_TYPE_FRACTION_RANGE, fps_min, 1, fps, 1, NULL);
gint fps_max = 1e7 / frame_desc->dwMinFrameInterval;

// Same quirk cap as the discrete branch, applied to the top of the
// range instead of to a list. If even the slowest rate is over the
// cap the whole descriptor goes.
guint fps_cap = uvc_quirk_max_fps(&quirk_limits, frame_desc->wWidth,
frame_desc->wHeight);
if ((guint)fps_max > fps_cap) {
fps_max = (gint)fps_cap;
}
if (fps_max < fps_min) {
GST_INFO_OBJECT(self,
"quirk: %ux%u dropped, its whole interval range exceeds the cap",
frame_desc->wWidth, frame_desc->wHeight);
continue;
}

gst_structure_set(tmp_structure, "framerate", GST_TYPE_FRACTION_RANGE, fps_min, 1, fps_max, 1, NULL);
}

if (gst_caps_can_intersect(caps, tmp_caps)) {
Expand Down Expand Up @@ -649,19 +702,10 @@ static gboolean gst_libuvc_h264_negotiate(GstBaseSrc * basesrc) {
goto out;
}

// vid:pid quirk seam (A14). The production table ships empty, so
// uvc_quirks_lookup() returns 0 for every device and the probe count below
// is unchanged; a matching entry can request QUIRK_DOUBLE_PROBE (libuvc #242)
// to issue the format-size probe twice, discarding the first result.
guint32 quirks = 0;
uvc_device_descriptor_t *quirk_desc = NULL;
if (uvc_get_device_descriptor(self->uvc_dev, &quirk_desc) == UVC_SUCCESS
&& quirk_desc != NULL) {
quirks = uvc_quirks_lookup(quirk_desc->idVendor, quirk_desc->idProduct);
uvc_free_device_descriptor(quirk_desc);
}

if (quirks & QUIRK_DOUBLE_PROBE) {
// Reuses the flags resolved above the selection loop: a device with no row has
// none set and the probe count stays at 1. QUIRK_DOUBLE_PROBE (libuvc #242)
// issues the format-size probe twice, discarding the first result.
if (quirk_limits.flags & QUIRK_DOUBLE_PROBE) {
// Some devices return a stale/rejected stream control on the first
// probe; run it once and discard the result before the real probe.
uvc_get_stream_ctrl_format_size(self->uvc_devh, &self->uvc_ctrl,
Expand Down
Loading