From 94a7c211abbecd1e7ab498ac049ff62b97703ac0 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Thu, 2 Jul 2026 21:30:26 -0500 Subject: [PATCH 01/11] build(libuvc): bump fork to ceralive-v0.0.7.3 --- Dockerfile | 2 +- scripts/build-libuvc.sh | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 60cc4c6..aeac342 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,7 +38,7 @@ COPY . /app # Source selected by the LIBUVC_USE_FORK build arg (see the ADR at # libuvch264src/docs/notes/libuvc-fork-adr.md): # -# 1 (default): CeraLive/libuvc fork at the pinned ceralive-v0.0.7.1 SHA. The +# 1 (default): CeraLive/libuvc fork at the pinned ceralive-v0.0.7.3 SHA. The # three changes are commits on the fork, so NO patch(1) step runs. # 0 (rollback): upstream v0.0.7 at its pinned SHA + the UVC 1.5 / H.265 patches # from patches/ (the pre-fork path). Build with diff --git a/scripts/build-libuvc.sh b/scripts/build-libuvc.sh index dca91dc..93774db 100755 --- a/scripts/build-libuvc.sh +++ b/scripts/build-libuvc.sh @@ -10,7 +10,7 @@ # Two modes (see libuvch264src/docs/notes/libuvc-fork-adr.md): # # fork (default): clone the CeraLive/libuvc fork at the pinned -# ceralive-v0.0.7.1 SHA. The UVC 1.5 / H.265 / configurable +# ceralive-v0.0.7.3 SHA. The UVC 1.5 / H.265 / configurable # auto-detach changes are commits on the fork, so NO patch(1) step # is needed. # upstream (rollback): clone upstream libuvc v0.0.7 at its pinned SHA and @@ -36,7 +36,7 @@ set -euo pipefail # --- Pinned coordinates — the ONLY place these SHAs/URLs live ---------------- FORK_URL="https://github.com/CeraLive/libuvc.git" -FORK_SHA="eae7f49c2978b6cdb21edc61fde006195588fec7" # main (hardened: CVE-2026-1991 guard + 047920b + e001f04; rebased onto main from harden/2026.6, tag ceralive-v0.0.7.2) +FORK_SHA="6210f2f64965af532440be357e6971b9b618797f" # main (hardened v0.0.7.3: eae7f49 CVE-2026-1991 base + A2 configurable transfer buffers + A4 degenerate-descriptor repair + A5 bounded stream_stop + A7/A9 zero-GET_MAX & corrupt-payload guards + A12 VC-header dwClockFrequency; tag ceralive-v0.0.7.3) UPSTREAM_URL="https://github.com/libuvc/libuvc.git" UPSTREAM_SHA="68d07a00e11d1944e27b7295ee69673239c00b4b" # v0.0.7 base From c46daeeb17612b61daf83bc7773e8cf967903c5b Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Thu, 2 Jul 2026 22:05:40 -0500 Subject: [PATCH 02/11] feat(element): opt-in transfer-buffers property (sentinel 0, clamp [2,100], reconnect-idempotent) --- CMakeLists.txt | 21 + libuvch264src/meson.build | 18 + libuvch264src/src/gstlibuvch264src.c | 124 ++++- libuvch264src/src/gstlibuvch264src_internal.h | 8 + tests/CMakeLists.txt | 59 +++ tests/mock_libuvc.c | 83 ++++ tests/mock_libuvc.h | 27 + tests/test_compat.c | 26 + tests/test_transfer_buffers.c | 465 ++++++++++++++++++ 9 files changed, 830 insertions(+), 1 deletion(-) create mode 100644 tests/test_transfer_buffers.c diff --git a/CMakeLists.txt b/CMakeLists.txt index c64cb87..e8076e3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -99,6 +99,27 @@ else() set(LIBUVC_RUNTIME_DIR "${_libuvc_prefix}/lib") endif() +# ----------------------------------------------------------------------------- +# Feature guard: the CeraLive fork exports uvc_set_transfer_buffers() (A2 API); +# raw upstream libuvc (LIBUVC_USE_FORK=OFF) does not. Probe the SELECTED libuvc +# target with its include dir - a bare check_function_exists() ignores that +# context and is unreliable - and, when present, propagate HAVE_UVC_TRANSFER_ +# BUFFERS to the plugin AND every test target: add_compile_definitions() here +# (top-level dir scope) cascades into add_subdirectory(tests). The element's +# opt-in transfer-buffers property then compiles its real apply path against the +# fork and a benign warn-and-no-op path against upstream. +# ----------------------------------------------------------------------------- +include(CheckSymbolExists) +set(CMAKE_REQUIRED_LIBRARIES ${LIBUVC_LINK_TARGET}) +set(CMAKE_REQUIRED_INCLUDES ${LIBUVC_EXTRA_INCLUDES} ${LIBUVC_INCLUDE_DIRS}) +check_symbol_exists(uvc_set_transfer_buffers "libuvc/libuvc.h" + HAVE_UVC_TRANSFER_BUFFERS) +unset(CMAKE_REQUIRED_LIBRARIES) +unset(CMAKE_REQUIRED_INCLUDES) +if(HAVE_UVC_TRANSFER_BUFFERS) + add_compile_definitions(HAVE_UVC_TRANSFER_BUFFERS) +endif() + # ----------------------------------------------------------------------------- # GStreamer plugin module (compiled from the unmodified plugin source). # Output: /gstreamer-1.0/libgstlibuvch264src.so diff --git a/libuvch264src/meson.build b/libuvch264src/meson.build index a457724..9739598 100644 --- a/libuvch264src/meson.build +++ b/libuvch264src/meson.build @@ -20,4 +20,22 @@ gst_base_dep = dependency('gstreamer-base-1.0', version: '>=1.14') libuvc_dep = dependency('libuvc', required: true) libusb_dep = dependency('libusb-1.0', required: true) +# Feature guard: the CeraLive fork exports uvc_set_transfer_buffers() (A2 API); +# raw upstream libuvc (LIBUVC_USE_FORK=OFF) does not. Probe the SELECTED libuvc +# dependency and, when present, define HAVE_UVC_TRANSFER_BUFFERS for every element +# TU (add_project_arguments applies to the library built in subdir('src')). The +# opt-in transfer-buffers property then compiles its real apply path against the +# fork and a benign warn-and-no-op path against upstream. +# +# args '-D_GNU_SOURCE': compiler checks do NOT inherit add_project_arguments, and +# under strict -std=c11 libuvc.h's `struct timespec capture_time_finished` is an +# incomplete type without the feature macro, so the probe's header include fails +# and the check falsely reports NO. Pass it here so the probe matches the element +# TUs' own build environment (mirrors the _GNU_SOURCE rationale above). +cc = meson.get_compiler('c') +if cc.has_function('uvc_set_transfer_buffers', dependencies: libuvc_dep, + prefix: '#include ', args: '-D_GNU_SOURCE') + add_project_arguments('-DHAVE_UVC_TRANSFER_BUFFERS', language: 'c') +endif + subdir('src') diff --git a/libuvch264src/src/gstlibuvch264src.c b/libuvch264src/src/gstlibuvch264src.c index 1f3be85..b6b2d48 100644 --- a/libuvch264src/src/gstlibuvch264src.c +++ b/libuvch264src/src/gstlibuvch264src.c @@ -28,6 +28,7 @@ enum { PROP_CONTROL_SOCKET_PATH, PROP_RECONNECT, PROP_MAX_PAYLOAD, + PROP_TRANSFER_BUFFERS, PROP_LAST }; @@ -52,6 +53,29 @@ enum { #define MAX_PAYLOAD_MIN_LEGAL 512u #define MAX_PAYLOAD_MAX (4u * 1024u * 1024u) +/* Opt-in USB transfer-buffer count override (Task 11, fork A2 + * uvc_set_transfer_buffers). TRANSFER_BUFFERS_DEFAULT is the sentinel: 0 = "leave + * libuvc's default transfer-buffer count unchanged", so the unset default never + * touches the fork API. A nonzero request is clamped to the fork's own + * [TRANSFER_BUFFERS_MIN, TRANSFER_BUFFERS_MAX] band in the apply helper (NOT the + * param spec, whose range stays the full 0..255 uint8 so an in-range set never + * trips a GObject range warning). */ +#define TRANSFER_BUFFERS_DEFAULT 0u +#define TRANSFER_BUFFERS_MIN 2u +#define TRANSFER_BUFFERS_MAX 100u +#define TRANSFER_BUFFERS_SPEC_MAX 255u + +/* The fork exports uvc_set_transfer_buffers(); raw upstream libuvc does not. The + * build's feature guard (HAVE_UVC_TRANSFER_BUFFERS) is set by meson.build / + * CMakeLists.txt after probing the selected libuvc. When it is absent the + * property stays registered but a nonzero value warns and no-ops so the + * upstream-fallback build stays green. */ +#if defined(HAVE_UVC_TRANSFER_BUFFERS) && !defined(LIBUVCH264SRC_NO_TRANSFER_BUFFERS_API) +#define TRANSFER_BUFFERS_API_AVAILABLE 1 +#else +#define TRANSFER_BUFFERS_API_AVAILABLE 0 +#endif + #define H264_CAPS "video/x-h264," \ "stream-format=(string)byte-stream," \ "alignment=(string)au" @@ -90,6 +114,7 @@ static gboolean gst_libuvc_h264_src_negotiate_clean_payload(GstLibuvcH264Src *se gint width, gint height, gint fps); static void gst_libuvc_h264_src_apply_max_payload(GstLibuvcH264Src *self, gint width, gint height, gint fps); +static void gst_libuvc_h264_src_apply_transfer_buffers(GstLibuvcH264Src *self); /* GAsyncQueue forbids NULL payloads, so create() can never receive a NULL * "no more frames" marker. unlock() instead pushes this dedicated address to @@ -169,6 +194,24 @@ static void gst_libuvc_h264_src_class_init(GstLibuvcH264SrcClass *klass) { 0, MAX_PAYLOAD_MAX, MAX_PAYLOAD_DEFAULT, G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS)); + /* Opt-in USB transfer-buffer count override (Task 11, fork A2). Default 0 is + * the "leave libuvc's default count unchanged" sentinel. The param-spec range + * is the full 0..255 uint8 domain; the conservative [2, 100] clamp is applied + * at streaming-start time in the apply helper, NOT here, so a value inside the + * spec never trips a GObject range warning (which gst-check would turn into a + * longjmp). A read-back reports the effective committed value. */ + g_object_class_install_property(gobject_class, PROP_TRANSFER_BUFFERS, + g_param_spec_uint("transfer-buffers", "USB transfer buffer count", + "Number of USB transfer buffers libuvc submits per stream. " + "0 = use the library default (default; unchanged). A " + "nonzero value is clamped to [2, 100] and applied via the " + "fork's uvc_set_transfer_buffers() right before streaming " + "starts; a read-back reports the effective value. Requires " + "the CeraLive libuvc fork; ignored (with a warning) on " + "upstream libuvc.", + 0, TRANSFER_BUFFERS_SPEC_MAX, TRANSFER_BUFFERS_DEFAULT, + G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS)); + /* Action signal driving all three axes in one emission; each axis is applied * only when the device supports it (gated in ptz_control.c). */ g_signal_new_class_handler("set-ptz", G_TYPE_FROM_CLASS(klass), @@ -206,6 +249,8 @@ static void gst_libuvc_h264_src_init(GstLibuvcH264Src *self) { self->reconnect_enabled = FALSE; self->max_payload = MAX_PAYLOAD_DEFAULT; self->max_payload_effective = 0; + self->transfer_buffers = TRANSFER_BUFFERS_DEFAULT; + self->transfer_buffers_effective = 0; self->frame_offset = 0; self->base_time = G_MAXUINT64; self->prev_pts = G_MAXUINT64; @@ -310,6 +355,56 @@ static void gst_libuvc_h264_src_apply_max_payload(GstLibuvcH264Src *self, GST_OBJECT_UNLOCK(self); } +/* Apply the opt-in transfer-buffers override to the open device handle, called + * right before uvc_start_streaming() in both the initial start and the reconnect + * re-arm (the fork rejects the setter mid-stream, so it must precede start). + * Unset (0) is the sentinel: it never calls the fork API, leaving libuvc's + * default transfer-buffer count byte-for-byte unchanged (ZERO extra device + * writes). A nonzero request is clamped to [TRANSFER_BUFFERS_MIN, + * TRANSFER_BUFFERS_MAX] and pushed via uvc_set_transfer_buffers(); the committed + * (clamped) value is recorded for read-back. A device that refuses it keeps + * libuvc's default (graceful; the stream never fails because of the hint). When + * the fork symbol is absent a nonzero request emits ONE warning and no-ops. */ +static void gst_libuvc_h264_src_apply_transfer_buffers(GstLibuvcH264Src *self) { + GST_OBJECT_LOCK(self); + guint requested = self->transfer_buffers; + GST_OBJECT_UNLOCK(self); + + if (requested == 0) + return; + +#if TRANSFER_BUFFERS_API_AVAILABLE + guint clamped = requested; + if (clamped < TRANSFER_BUFFERS_MIN) + clamped = TRANSFER_BUFFERS_MIN; + if (clamped > TRANSFER_BUFFERS_MAX) + clamped = TRANSFER_BUFFERS_MAX; + if (clamped != requested) + GST_WARNING_OBJECT(self, "transfer-buffers %u out of range [%u, %u]; " + "clamped to %u", requested, TRANSFER_BUFFERS_MIN, + TRANSFER_BUFFERS_MAX, clamped); + + uvc_error_t res = uvc_set_transfer_buffers(self->uvc_devh, (uint8_t) clamped); + if (res < 0) { + GST_WARNING_OBJECT(self, + "transfer-buffers %u not applied by libuvc (%s); using the library " + "default count", clamped, uvc_strerror(res)); + return; + } + + GST_INFO_OBJECT(self, "transfer-buffers applied: requested %u, applied %u", + requested, clamped); + GST_OBJECT_LOCK(self); + self->transfer_buffers_effective = clamped; + GST_OBJECT_UNLOCK(self); +#else + GST_WARNING_OBJECT(self, + "transfer-buffers %u requested but this libuvc lacks " + "uvc_set_transfer_buffers (built without the CeraLive fork API); ignoring", + requested); +#endif +} + static gboolean gst_libuvc_h264_negotiate(GstBaseSrc * basesrc) { GstLibuvcH264Src *self = GST_LIBUVC_H264_SRC(basesrc); @@ -551,6 +646,15 @@ static void gst_libuvc_h264_src_set_property(GObject *object, guint prop_id, self->max_payload = g_value_get_uint(value); GST_OBJECT_UNLOCK(self); break; + case PROP_TRANSFER_BUFFERS: + /* Stored verbatim (the [2,100] clamp is applied at streaming-start time in + * the apply helper). Read on the negotiate/reconnect streaming thread, so + * mutate under the object lock for a proper happens-before, mirroring + * max-payload. */ + GST_OBJECT_LOCK(self); + self->transfer_buffers = g_value_get_uint(value); + GST_OBJECT_UNLOCK(self); + break; default: G_OBJECT_WARN_INVALID_PROPERTY_ID(object, prop_id, pspec); break; @@ -592,6 +696,13 @@ static void gst_libuvc_h264_src_get_property(GObject *object, guint prop_id, : self->max_payload); GST_OBJECT_UNLOCK(self); break; + case PROP_TRANSFER_BUFFERS: + GST_OBJECT_LOCK(self); + g_value_set_uint(value, self->transfer_buffers_effective > 0 + ? self->transfer_buffers_effective + : self->transfer_buffers); + GST_OBJECT_UNLOCK(self); + break; default: G_OBJECT_WARN_INVALID_PROPERTY_ID(object, prop_id, pspec); break; @@ -1192,6 +1303,11 @@ gboolean gst_libuvc_h264_src_reconnect(GstLibuvcH264Src *self) { self->base_time = G_MAXUINT64; self->prev_pts = G_MAXUINT64; + // Re-apply the opt-in transfer-buffers override on the reopened handle right + // before restarting the stream (Task 11): the reopened handle starts at the + // library default, so the count must be re-armed here like max-payload. + gst_libuvc_h264_src_apply_transfer_buffers(self); + if (uvc_start_streaming(self->uvc_devh, &self->uvc_ctrl, frame_callback, self, 0) < 0) { uvc_close(self->uvc_devh); @@ -1221,6 +1337,11 @@ static GstFlowReturn gst_libuvc_h264_src_create(GstPushSrc *src, GstBuffer **buf self->streaming = TRUE; + // Apply the opt-in transfer-buffers override right before streaming starts + // (the fork rejects it mid-stream); unset leaves libuvc's default count + // unchanged (Task 11). + gst_libuvc_h264_src_apply_transfer_buffers(self); + // Start streaming res = uvc_start_streaming(self->uvc_devh, &self->uvc_ctrl, frame_callback, self, 0); if (res < 0) { @@ -1245,7 +1366,8 @@ static GstFlowReturn gst_libuvc_h264_src_create(GstPushSrc *src, GstBuffer **buf } if (res < 0) { self->streaming = FALSE; - GST_ERROR_OBJECT(self, "Unable to start streaming: %s", uvc_strerror(res)); + gst_libuvc_h264_src_post_error(GST_ELEMENT(self), res, + "starting UVC stream"); return GST_FLOW_ERROR; } } diff --git a/libuvch264src/src/gstlibuvch264src_internal.h b/libuvch264src/src/gstlibuvch264src_internal.h index bbe4035..856bf62 100644 --- a/libuvch264src/src/gstlibuvch264src_internal.h +++ b/libuvch264src/src/gstlibuvch264src_internal.h @@ -55,6 +55,14 @@ struct _GstLibuvcH264Src { * reconnect streaming thread form a proper happens-before. */ guint max_payload; guint max_payload_effective; + /* Opt-in USB transfer-buffer count override (Task 11, fork A2 + * uvc_set_transfer_buffers). transfer_buffers is the REQUESTED value + * (PROP_TRANSFER_BUFFERS); 0 is the sentinel "leave libuvc's default count + * unchanged" that never touches the fork API. transfer_buffers_effective is the + * clamped value actually applied to the device handle and is what a read-back + * reports. Both are guarded by GST_OBJECT_LOCK, mirroring max_payload. */ + guint transfer_buffers; + guint transfer_buffers_effective; GstClock *clock; GstClockTime base_time; GstClockTime prev_pts; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 06dba9a..3e80643 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1285,6 +1285,64 @@ if(ENABLE_SANITIZERS) add_reconnect_teardown_variant("asan" "address") endif() +# ----------------------------------------------------------------------------- +# Opt-in transfer-buffers property tests (Task 11). +# +# Same single-executable, statically-registered shape as test_reconnect so the +# mock's uvc_set_transfer_buffers() call counter and the count latched at +# uvc_start_streaming() are observable in-process. The normal target inherits +# HAVE_UVC_TRANSFER_BUFFERS from the top-level dir scope (see the feature guard in +# the root CMakeLists.txt), so it compiles the real fork apply path and runs the +# ON cases. A dedicated test_transfer_buffers_off target defines +# LIBUVCH264SRC_NO_TRANSFER_BUFFERS_API to simulate a libuvc WITHOUT the symbol +# (the LIBUVC_USE_FORK=OFF path) without a second build, and runs the OFF case +# that asserts the one-GST_WARNING no-op instead of a setter call. +# ----------------------------------------------------------------------------- +add_static_gstcheck_exe(test_transfer_buffers) + +set(_transfer_buffers_cases + "transfer_buffers_sentinel:test_transfer_buffers_sentinel_no_call" + "transfer_buffers_applied:test_transfer_buffers_applied_before_start" + "transfer_buffers_clamped:test_transfer_buffers_clamped" + "transfer_buffers_reconnect_reapply:test_transfer_buffers_reconnect_reapply" + "transfer_buffers_io_error:test_transfer_buffers_start_streaming_io_error" +) +# start() binds the fixed /tmp/libuvc_control socket via the control thread, so +# these must not run concurrently (RESOURCE_LOCK, applied by helper). +add_gstcheck_multicase_test(test_transfer_buffers "transfer-buffers" + "${STATIC_SUITE_SANITIZER}" "${_transfer_buffers_cases}") + +# OFF-path simulation target: force the fork API absent via a compile-time +# override so the element takes the warn-and-no-op branch, without a second +# LIBUVC_USE_FORK=OFF build. +add_executable(test_transfer_buffers_off + test_transfer_buffers.c + ${_element_srcs} + mock_libuvc.c +) +target_compile_definitions(test_transfer_buffers_off PRIVATE + LIBUVCH264SRC_NO_TRANSFER_BUFFERS_API) +target_include_directories(test_transfer_buffers_off PRIVATE + ${CMAKE_SOURCE_DIR}/libuvch264src/src + ${CMAKE_CURRENT_SOURCE_DIR} + ${LIBUVC_EXTRA_INCLUDES} + ${LIBUVC_INCLUDE_DIRS} +) +target_link_libraries(test_transfer_buffers_off PRIVATE + PkgConfig::GST + PkgConfig::GST_BASE + PkgConfig::GST_CHECK + PkgConfig::LIBUSB + Threads::Threads +) +apply_sanitizer_flags(test_transfer_buffers_off "${STATIC_SUITE_SANITIZER}") + +set(_transfer_buffers_off_cases + "transfer_buffers_off_noop:test_transfer_buffers_off_noop" +) +add_gstcheck_multicase_test(test_transfer_buffers_off "transfer-buffers-off" + "${STATIC_SUITE_SANITIZER}" "${_transfer_buffers_off_cases}") + # ----------------------------------------------------------------------------- # PTS drift-bound regression guard (Task 1, harden-v2). Committed RED. # @@ -1608,6 +1666,7 @@ add_static_gstcheck_exe(test_compat) set(_compat_cases "compat_api_surface:test_compat_api_surface" "compat_caps_contract:test_compat_caps_contract" + "compat_transfer_buffers_property:test_compat_transfer_buffers_property" "compat_pipeline_parse_h264:test_compat_pipeline_parse_h264" "compat_pipeline_parse_h265:test_compat_pipeline_parse_h265" ) diff --git a/tests/mock_libuvc.c b/tests/mock_libuvc.c index b11e905..71d8128 100644 --- a/tests/mock_libuvc.c +++ b/tests/mock_libuvc.c @@ -115,6 +115,18 @@ static mock_uvc_payload_mode_t g_payload_mode = MOCK_UVC_PAYLOAD_ACCEPT; static int g_probe_call_count = 0; static uint32_t g_last_started_payload = 0; +/* Transfer-buffers observability (A2 fork uvc_set_transfer_buffers). Records the + * last count the element pushed, the number of setter calls, and the count + * latched at uvc_start_streaming() (proving the element applied it before the + * stream started). g_start_streaming_result injects a start failure for the + * zero-transfer error path; g_reopen_fail_next fails the next N opens then + * recovers so a SUCCESSFUL reconnect (and its re-apply) is assertable. */ +static uint8_t g_last_transfer_buffers = 0; +static int g_transfer_buffers_call_count = 0; +static uint8_t g_last_started_transfer_buffers = 0; +static uvc_error_t g_start_streaming_result = UVC_SUCCESS; +static int g_reopen_fail_next = 0; + /* Open-failure injection (Task 8). g_uvc_open_attempts counts every uvc_open() * past the param/refcount checks (successes AND injected failures). After * g_uvc_open_fail_after successful opens, every further open returns @@ -191,6 +203,11 @@ void mock_uvc_reset(void) { g_payload_mode = MOCK_UVC_PAYLOAD_ACCEPT; g_probe_call_count = 0; g_last_started_payload = 0; + g_last_transfer_buffers = 0; + g_transfer_buffers_call_count = 0; + g_last_started_transfer_buffers = 0; + g_start_streaming_result = UVC_SUCCESS; + g_reopen_fail_next = 0; g_pan_min = -180000; g_pan_max = 180000; g_pan_cur = 0; g_tilt_min = -90000; g_tilt_max = 90000; g_tilt_cur = 0; g_zoom_min = 0; g_zoom_max = 100; g_zoom_cur = 0; @@ -360,6 +377,39 @@ int mock_uvc_probe_call_count(void) { return n; } +uint8_t mock_uvc_last_transfer_buffers(void) { + pthread_mutex_lock(&g_lock); + uint8_t n = g_last_transfer_buffers; + pthread_mutex_unlock(&g_lock); + return n; +} + +int mock_uvc_transfer_buffers_call_count(void) { + pthread_mutex_lock(&g_lock); + int n = g_transfer_buffers_call_count; + pthread_mutex_unlock(&g_lock); + return n; +} + +uint8_t mock_uvc_last_started_transfer_buffers(void) { + pthread_mutex_lock(&g_lock); + uint8_t n = g_last_started_transfer_buffers; + pthread_mutex_unlock(&g_lock); + return n; +} + +void mock_uvc_set_start_streaming_result(uvc_error_t result) { + pthread_mutex_lock(&g_lock); + g_start_streaming_result = result; + pthread_mutex_unlock(&g_lock); +} + +void mock_uvc_set_reopen_fail_count(int n) { + pthread_mutex_lock(&g_lock); + g_reopen_fail_next = n; + pthread_mutex_unlock(&g_lock); +} + /* -------------------------------------------------------------------------- */ /* NAL crafting. */ /* -------------------------------------------------------------------------- */ @@ -686,6 +736,10 @@ uvc_error_t uvc_open(uvc_device_t *dev, uvc_device_handle_t **devh) { g_uvc_open_attempts++; bool inject_fail = (g_uvc_open_fail_after >= 0 && g_uvc_open_count >= g_uvc_open_fail_after); + if (!inject_fail && g_reopen_fail_next > 0) { + g_reopen_fail_next--; + inject_fail = true; + } pthread_mutex_unlock(&g_lock); if (inject_fail) return UVC_ERROR_NO_DEVICE; @@ -849,6 +903,28 @@ uvc_error_t uvc_probe_stream_ctrl(uvc_device_handle_t *devh, return UVC_SUCCESS; } +/* Fork A2 API. Records the requested count and the call tally, then mirrors the + * fork contract: NULL handle -> INVALID_PARAM, a set while the stream is running + * -> BUSY (the element must apply it before uvc_start_streaming). The clamp to + * [2,100] lives in the element; the mock accepts any value the element passes. */ +uvc_error_t uvc_set_transfer_buffers(uvc_device_handle_t *devh, uint8_t count) { + if (!devh) + return UVC_ERROR_INVALID_PARAM; + + pthread_mutex_lock(&g_lock); + g_transfer_buffers_call_count++; + g_last_transfer_buffers = count; + pthread_mutex_unlock(&g_lock); + + pthread_mutex_lock(&devh->lock); + int running = devh->running; + pthread_mutex_unlock(&devh->lock); + if (running) + return UVC_ERROR_BUSY; + + return UVC_SUCCESS; +} + uvc_error_t uvc_start_streaming(uvc_device_handle_t *devh, uvc_stream_ctrl_t *ctrl, uvc_frame_callback_t *cb, void *user_ptr, @@ -857,9 +933,16 @@ uvc_error_t uvc_start_streaming(uvc_device_handle_t *devh, if (!devh || !cb) return UVC_ERROR_INVALID_PARAM; + pthread_mutex_lock(&g_lock); + uvc_error_t inject = g_start_streaming_result; + pthread_mutex_unlock(&g_lock); + if (inject != UVC_SUCCESS) + return inject; + pthread_mutex_lock(&g_lock); g_frames_delivered = 0; g_last_started_payload = (ctrl != NULL) ? ctrl->dwMaxPayloadTransferSize : 0; + g_last_started_transfer_buffers = g_last_transfer_buffers; pthread_mutex_unlock(&g_lock); devh->cb = cb; diff --git a/tests/mock_libuvc.h b/tests/mock_libuvc.h index d57a451..167970b 100644 --- a/tests/mock_libuvc.h +++ b/tests/mock_libuvc.h @@ -186,6 +186,33 @@ uint32_t mock_uvc_last_started_payload(void); * extra probe (max-payload unset = byte-for-byte unchanged negotiation). */ int mock_uvc_probe_call_count(void); +/* Transfer-buffers observability (A2 fork uvc_set_transfer_buffers). The last + * count the element pushed via uvc_set_transfer_buffers() and how many times it + * called the setter. A call count of 0 proves the sentinel (transfer-buffers=0) + * never touched the fork API, so negotiation is byte-for-byte unchanged. */ +uint8_t mock_uvc_last_transfer_buffers(void); +int mock_uvc_transfer_buffers_call_count(void); + +/* The transfer-buffer count latched at the last uvc_start_streaming(): the value + * uvc_set_transfer_buffers() had recorded when the stream started. Because the + * fork rejects the setter mid-stream, this proves the element applied the count + * BEFORE starting (0 = never applied). */ +uint8_t mock_uvc_last_started_transfer_buffers(void); + +/* Force the NEXT uvc_start_streaming() to fail with this error, modelling the + * A2 zero-transfer error path (a transfer count that submits no transfers). + * UVC_SUCCESS (the default) starts normally. */ +void mock_uvc_set_start_streaming_result(uvc_error_t result); + +/* Reconnect-SUCCESS injection: fail the next N uvc_open() calls with + * UVC_ERROR_NO_DEVICE, then let opens succeed again. Unlike + * mock_uvc_set_open_fail_after() - which fails FOREVER and drives the reconnect + * loop to exhaustion - this models a device that returns after a few failed + * reopen attempts, so a SUCCESSFUL reconnect (and the re-apply on it) is + * assertable. Set it AFTER the initial open so only the reopens fail. 0 + * (default) injects no failure. */ +void mock_uvc_set_reopen_fail_count(int n); + #ifdef __cplusplus } #endif diff --git a/tests/test_compat.c b/tests/test_compat.c index 0dadf4c..839062e 100644 --- a/tests/test_compat.c +++ b/tests/test_compat.c @@ -418,6 +418,31 @@ GST_START_TEST (test_compat_caps_contract) GST_END_TEST; +/* The opt-in transfer-buffers property is additive: it must exist as a uint with + * the 0 sentinel default, without disturbing the rest of the property surface. + * The [2,100] clamp lives in the apply helper, so the param spec stays the full + * 0..255 uint8 range and is not asserted here. */ +GST_START_TEST (test_compat_transfer_buffers_property) +{ + GstElement *element = gst_element_factory_make (ELEMENT_NAME, NULL); + fail_unless (element != NULL, "could not instantiate '%s'", ELEMENT_NAME); + + GParamSpec *pspec = g_object_class_find_property ( + G_OBJECT_GET_CLASS (element), "transfer-buffers"); + fail_unless (pspec != NULL, "expected 'transfer-buffers' property is missing"); + fail_unless (pspec->value_type == G_TYPE_UINT, + "'transfer-buffers' should be a uint"); + + guint tb = 7; + g_object_get (element, "transfer-buffers", &tb, NULL); + fail_unless (tb == 0, + "default 'transfer-buffers' should be 0 (sentinel), got %u", tb); + + gst_object_unref (element); +} + +GST_END_TEST; + /* --------------------------------------------------------------------------- * GROUP 3 tests - pipeline-parse contract (README fakesink variants) * ------------------------------------------------------------------------- */ @@ -463,6 +488,7 @@ compat_suite (void) tcase_add_test (tc, test_compat_api_surface); tcase_add_test (tc, test_compat_caps_contract); + tcase_add_test (tc, test_compat_transfer_buffers_property); tcase_add_test (tc, test_compat_pipeline_parse_h264); tcase_add_test (tc, test_compat_pipeline_parse_h265); diff --git a/tests/test_transfer_buffers.c b/tests/test_transfer_buffers.c new file mode 100644 index 0000000..7cb6eea --- /dev/null +++ b/tests/test_transfer_buffers.c @@ -0,0 +1,465 @@ +/* Opt-in transfer-buffers property tests for the libuvch264src element (Task 11). + * Like test_reconnect.c, this statically links the element translation units, the + * libuvc mock, and the driver into ONE executable and registers the element type + * directly, so the mock's uvc_set_transfer_buffers() call counter and the count + * latched at uvc_start_streaming() are observable in-process. + * + * The property mirrors the max-payload contract: 0 is the sentinel that never + * touches the fork API (negotiation byte-for-byte unchanged), a nonzero value is + * clamped to [2,100] in the apply helper (NOT the param spec, which is the full + * 0..255 uint8 range so an in-range set never trips a GObject range warning), and + * the applied value is re-armed on a successful reconnect and reported by a + * read-back. + * + * The suite is built in two shapes selected by a compile-time macro: + * - the normal target compiles the real fork apply path (HAVE_UVC_TRANSFER_ + * BUFFERS) and runs the ON cases; + * - test_transfer_buffers_off defines LIBUVCH264SRC_NO_TRANSFER_BUFFERS_API to + * simulate a libuvc WITHOUT the symbol (LIBUVC_USE_FORK=OFF) and runs the OFF + * case, which asserts the one-GST_WARNING no-op instead of a setter call. + * + * GST_CHECKS selects a single test per ctest invocation (see tests/CMakeLists.txt). + */ + +#include + +#include + +#include "gstlibuvch264src.h" +#include "mock_libuvc.h" + +#if defined(HAVE_UVC_TRANSFER_BUFFERS) && !defined(LIBUVCH264SRC_NO_TRANSFER_BUFFERS_API) +#define TB_API_AVAILABLE 1 +#else +#define TB_API_AVAILABLE 0 +#endif + +static void +load_core_elements (void) +{ + const gchar *core_plugin = g_getenv ("GST_COREELEMENTS_PLUGIN"); + if (core_plugin != NULL && *core_plugin != '\0') { + GError *lerr = NULL; + GstPlugin *p = gst_plugin_load_file (core_plugin, &lerr); + fail_unless (p != NULL, "could not load core-elements plugin '%s': %s", + core_plugin, lerr ? lerr->message : "(unknown)"); + gst_object_unref (p); + } +} + +static void +register_element (void) +{ + static gboolean registered = FALSE; + if (!registered) { + fail_unless (gst_element_register (NULL, "libuvch264src", GST_RANK_NONE, + GST_TYPE_LIBUVC_H264_SRC), "failed to register libuvch264src"); + registered = TRUE; + } +} + +static gint buffers_seen; /* atomic: buffers that reached the sink */ +static gint saw_transfer_warning; /* atomic: a "transfer-buffers" WARNING seen */ + +static GstPadProbeReturn +count_buffer_probe (GstPad * pad, GstPadProbeInfo * info, gpointer user_data) +{ + (void) pad; + (void) user_data; + if (GST_PAD_PROBE_INFO_TYPE (info) & GST_PAD_PROBE_TYPE_BUFFER) + g_atomic_int_inc (&buffers_seen); + return GST_PAD_PROBE_OK; +} + +static void +transfer_warning_log_func (GstDebugCategory * category, GstDebugLevel level, + const gchar * file, const gchar * function, gint line, GObject * object, + GstDebugMessage * message, gpointer user_data) +{ + (void) category; (void) file; (void) function; (void) line; (void) object; + (void) user_data; + if (level <= GST_LEVEL_WARNING) { + const gchar *m = gst_debug_message_get (message); + if (m != NULL && strstr (m, "transfer-buffers") != NULL) + g_atomic_int_set (&saw_transfer_warning, 1); + } +} + +static GstElement * +build_pipeline (GstElement ** src_out) +{ + GstElement *pipeline = gst_pipeline_new ("transfer-buffers-pipeline"); + GstElement *src = gst_element_factory_make ("libuvch264src", "src"); + GstElement *sink = gst_element_factory_make ("fakesink", "sink"); + + fail_unless (pipeline != NULL && src != NULL && sink != NULL, + "failed to create test elements"); + g_object_set (sink, "sync", FALSE, NULL); + + gst_bin_add_many (GST_BIN (pipeline), src, sink, NULL); + fail_unless (gst_element_link (src, sink), "failed to link src ! sink"); + + GstPad *pad = gst_element_get_static_pad (sink, "sink"); + fail_unless (pad != NULL, "fakesink has no sink pad"); + gst_pad_add_probe (pad, GST_PAD_PROBE_TYPE_BUFFER, count_buffer_probe, NULL, + NULL); + gst_object_unref (pad); + + if (src_out != NULL) + *src_out = src; + return pipeline; +} + +static gboolean +play_until_buffer (GstElement * pipeline) +{ + if (gst_element_set_state (pipeline, GST_STATE_PLAYING) == + GST_STATE_CHANGE_FAILURE) + return FALSE; + + gint64 deadline = g_get_monotonic_time () + 5 * G_TIME_SPAN_SECOND; + while (g_atomic_int_get (&buffers_seen) <= 0 + && g_get_monotonic_time () < deadline) { + g_usleep (2 * G_TIME_SPAN_MILLISECOND); + } + return g_atomic_int_get (&buffers_seen) > 0; +} + +/* ------------------------------------------------------------------------- */ +/* test_transfer_buffers_sentinel_no_call */ +/* */ +/* Default (unset) transfer-buffers is the 0 sentinel: the element must never */ +/* call the fork API, so the mock setter call count stays 0 and the count */ +/* latched at streaming start is 0 - negotiation is byte-for-byte unchanged. */ +/* ------------------------------------------------------------------------- */ + +GST_START_TEST (test_transfer_buffers_sentinel_no_call) +{ + load_core_elements (); + register_element (); + mock_uvc_reset (); + + g_atomic_int_set (&buffers_seen, 0); + + GstElement *src = NULL; + GstElement *pipeline = build_pipeline (&src); + + gboolean got = play_until_buffer (pipeline); + + gint calls = mock_uvc_transfer_buffers_call_count (); + guint8 started = mock_uvc_last_started_transfer_buffers (); + + gst_element_set_state (pipeline, GST_STATE_NULL); + gst_object_unref (pipeline); + + fail_unless (got, "stream did not start with default (unset) transfer-buffers"); + fail_unless (calls == 0, + "unset transfer-buffers must issue NO uvc_set_transfer_buffers call " + "(byte-for-byte unchanged negotiation); got %d call(s)", calls); + fail_unless (started == 0, + "unset transfer-buffers must leave the library default in place; got %u", + started); +} + +GST_END_TEST; + +/* ------------------------------------------------------------------------- */ +/* test_transfer_buffers_applied_before_start */ +/* */ +/* A nonzero value is pushed via uvc_set_transfer_buffers() BEFORE the stream */ +/* starts (the fork rejects it mid-stream), and a read-back reports it. */ +/* ------------------------------------------------------------------------- */ + +GST_START_TEST (test_transfer_buffers_applied_before_start) +{ + load_core_elements (); + register_element (); + mock_uvc_reset (); + + g_atomic_int_set (&buffers_seen, 0); + + GstElement *src = NULL; + GstElement *pipeline = build_pipeline (&src); + g_object_set (src, "transfer-buffers", 4u, NULL); + + gboolean got = play_until_buffer (pipeline); + + gint calls = mock_uvc_transfer_buffers_call_count (); + guint8 started = mock_uvc_last_started_transfer_buffers (); + guint effective = 0; + g_object_get (src, "transfer-buffers", &effective, NULL); + + gst_element_set_state (pipeline, GST_STATE_NULL); + gst_object_unref (pipeline); + + fail_unless (got, "stream must start with an in-range transfer-buffers value"); + fail_unless (calls >= 1, + "a nonzero transfer-buffers must call uvc_set_transfer_buffers; got %d", + calls); + fail_unless (started == 4, + "transfer-buffers 4 must be applied BEFORE streaming start; got %u", + started); + fail_unless (effective == 4u, + "read-back must report the applied transfer-buffers (4); got %u", effective); +} + +GST_END_TEST; + +/* ------------------------------------------------------------------------- */ +/* test_transfer_buffers_clamped */ +/* */ +/* A value inside the 0..255 param spec but above the [2,100] band (200) is */ +/* clamped to 100 by the apply helper with a warning, and the read-back */ +/* reports the effective clamped value. The set stays inside the spec so no */ +/* GObject range warning (and no gst-check longjmp) is triggered. */ +/* ------------------------------------------------------------------------- */ + +GST_START_TEST (test_transfer_buffers_clamped) +{ + load_core_elements (); + register_element (); + mock_uvc_reset (); + + g_atomic_int_set (&buffers_seen, 0); + g_atomic_int_set (&saw_transfer_warning, 0); + gst_debug_set_active (TRUE); + gst_debug_set_threshold_for_name ("libuvch264src", GST_LEVEL_WARNING); + gst_debug_add_log_function (transfer_warning_log_func, NULL, NULL); + + GstElement *src = NULL; + GstElement *pipeline = build_pipeline (&src); + g_object_set (src, "transfer-buffers", 200u, NULL); + + gboolean got = play_until_buffer (pipeline); + + guint effective = 0; + g_object_get (src, "transfer-buffers", &effective, NULL); + guint8 started = mock_uvc_last_started_transfer_buffers (); + + gst_element_set_state (pipeline, GST_STATE_NULL); + gst_debug_remove_log_function (transfer_warning_log_func); + gst_object_unref (pipeline); + + fail_unless (got, "stream must start with an out-of-band transfer-buffers value"); + fail_unless (g_atomic_int_get (&saw_transfer_warning), + "an out-of-range transfer-buffers must log a clamp warning"); + fail_unless (started == 100, + "transfer-buffers 200 must clamp to 100 before streaming start; got %u", + started); + fail_unless (effective == 100u, + "read-back must report the clamped transfer-buffers (100); got %u", + effective); +} + +GST_END_TEST; + +/* ------------------------------------------------------------------------- */ +/* test_transfer_buffers_reconnect_reapply */ +/* */ +/* A successful reconnect must re-arm BOTH opt-in overrides right before the */ +/* resumed uvc_start_streaming: transfer-buffers is pushed again, and */ +/* max-payload is re-committed (closing the pre-existing max-payload */ +/* reconnect-success coverage gap). The mock's reconnect-SUCCESS mode fails */ +/* one reopen then recovers, so the reconnect genuinely succeeds after a */ +/* failure. */ +/* ------------------------------------------------------------------------- */ + +GST_START_TEST (test_transfer_buffers_reconnect_reapply) +{ + load_core_elements (); + register_element (); + mock_uvc_reset (); + mock_uvc_set_frame_mode (MOCK_UVC_FRAME_DISCONNECT); + mock_uvc_set_max_frames (1); + + g_atomic_int_set (&buffers_seen, 0); + + GstElement *src = NULL; + GstElement *pipeline = build_pipeline (&src); + g_object_set (src, "reconnect", TRUE, "transfer-buffers", 4u, + "max-payload", 32768u, NULL); + + fail_unless (gst_element_set_state (pipeline, GST_STATE_PLAYING) + != GST_STATE_CHANGE_FAILURE, "could not set pipeline to PLAYING"); + + gint64 deadline = g_get_monotonic_time () + 5 * G_TIME_SPAN_SECOND; + while (g_atomic_int_get (&buffers_seen) < 1 + && g_get_monotonic_time () < deadline) { + g_usleep (2 * G_TIME_SPAN_MILLISECOND); + } + fail_unless (g_atomic_int_get (&buffers_seen) >= 1, + "the initial stream never delivered a frame"); + + /* Simulate a replug that returns after one failed reopen: healthy frames, + * one injected reopen failure, then recovery. */ + mock_uvc_set_frame_mode (MOCK_UVC_FRAME_VALID); + mock_uvc_set_max_frames (0); + mock_uvc_set_reopen_fail_count (1); + + gint baseline = g_atomic_int_get (&buffers_seen); + + GstBus *bus = gst_element_get_bus (pipeline); + gboolean resumed = FALSE; + gboolean errored = FALSE; + deadline = g_get_monotonic_time () + 40 * G_TIME_SPAN_SECOND; + while (g_get_monotonic_time () < deadline) { + GstMessage *msg = + gst_bus_pop_filtered (bus, GST_MESSAGE_ERROR | GST_MESSAGE_EOS); + if (msg != NULL) { + errored = (GST_MESSAGE_TYPE (msg) == GST_MESSAGE_ERROR); + gst_message_unref (msg); + break; + } + if (g_atomic_int_get (&buffers_seen) >= baseline + 5 + && mock_uvc_open_count () >= 2) { + resumed = TRUE; + break; + } + g_usleep (20 * G_TIME_SPAN_MILLISECOND); + } + gint open_count = mock_uvc_open_count (); + gint tb_calls = mock_uvc_transfer_buffers_call_count (); + guint8 started_tb = mock_uvc_last_started_transfer_buffers (); + guint32 started_payload = mock_uvc_last_started_payload (); + gst_object_unref (bus); + + gst_element_set_state (pipeline, GST_STATE_NULL); + gst_object_unref (pipeline); + + fail_unless (!errored, + "reconnect=TRUE should suppress the disconnect error, but the pipeline " + "errored out"); + fail_unless (resumed && open_count >= 2, + "stream did not reopen+resume after reconnect (open count %d)", open_count); + fail_unless (tb_calls >= 2, + "transfer-buffers must be re-applied on the successful reconnect " + "(>= 2 setter calls: initial + reconnect); got %d", tb_calls); + fail_unless (started_tb == 4, + "the resumed stream must start on the re-applied transfer-buffers (4); " + "got %u", started_tb); + fail_unless (started_payload == 32768u, + "the resumed stream must start on the re-applied max-payload (32768); " + "got %u", started_payload); +} + +GST_END_TEST; + +/* ------------------------------------------------------------------------- */ +/* test_transfer_buffers_start_streaming_io_error */ +/* */ +/* An injected UVC_ERROR_IO from uvc_start_streaming (the A2 zero-transfer */ +/* error path) must surface as a RESOURCE-domain bus error, not a silent */ +/* stall. */ +/* ------------------------------------------------------------------------- */ + +GST_START_TEST (test_transfer_buffers_start_streaming_io_error) +{ + load_core_elements (); + register_element (); + mock_uvc_reset (); + mock_uvc_set_start_streaming_result (UVC_ERROR_IO); + + g_atomic_int_set (&buffers_seen, 0); + + GstElement *src = NULL; + GstElement *pipeline = build_pipeline (&src); + + fail_unless (gst_element_set_state (pipeline, GST_STATE_PLAYING) + != GST_STATE_CHANGE_FAILURE, "could not set pipeline to PLAYING"); + + GstBus *bus = gst_element_get_bus (pipeline); + gboolean saw_resource_error = FALSE; + gint64 deadline = g_get_monotonic_time () + 12 * G_TIME_SPAN_SECOND; + while (g_get_monotonic_time () < deadline) { + GstMessage *msg = + gst_bus_timed_pop_filtered (bus, 1 * GST_SECOND, GST_MESSAGE_ERROR); + if (msg != NULL) { + GError *gerr = NULL; + gchar *dbg = NULL; + gst_message_parse_error (msg, &gerr, &dbg); + if (gerr != NULL && gerr->domain == GST_RESOURCE_ERROR) + saw_resource_error = TRUE; + g_clear_error (&gerr); + g_free (dbg); + gst_message_unref (msg); + if (saw_resource_error) + break; + } + } + gst_object_unref (bus); + + gst_element_set_state (pipeline, GST_STATE_NULL); + gst_object_unref (pipeline); + + fail_unless (saw_resource_error, + "an injected UVC_ERROR_IO from uvc_start_streaming must post a " + "RESOURCE-domain error"); +} + +GST_END_TEST; + +/* ------------------------------------------------------------------------- */ +/* test_transfer_buffers_off_noop (LIBUVC_USE_FORK=OFF simulation) */ +/* */ +/* When the fork symbol is absent the property stays registered, but a */ +/* nonzero value emits ONE GST_WARNING and no-ops: no uvc_set_transfer_buffers */ +/* call is made. Registered only for the OFF target (see tests/CMakeLists.txt). */ +/* ------------------------------------------------------------------------- */ + +GST_START_TEST (test_transfer_buffers_off_noop) +{ + load_core_elements (); + register_element (); + mock_uvc_reset (); + + g_atomic_int_set (&buffers_seen, 0); + g_atomic_int_set (&saw_transfer_warning, 0); + gst_debug_set_active (TRUE); + gst_debug_set_threshold_for_name ("libuvch264src", GST_LEVEL_WARNING); + gst_debug_add_log_function (transfer_warning_log_func, NULL, NULL); + + GstElement *src = NULL; + GstElement *pipeline = build_pipeline (&src); + g_object_set (src, "transfer-buffers", 4u, NULL); + + gboolean got = play_until_buffer (pipeline); + + gint calls = mock_uvc_transfer_buffers_call_count (); + gboolean warned = g_atomic_int_get (&saw_transfer_warning); + + gst_element_set_state (pipeline, GST_STATE_NULL); + gst_debug_remove_log_function (transfer_warning_log_func); + gst_object_unref (pipeline); + + fail_unless (got, + "the media path must still stream when the fork API is absent"); + fail_unless (calls == 0, + "with the fork API absent, transfer-buffers must NOT call the setter; " + "got %d call(s)", calls); + fail_unless (warned, + "with the fork API absent, a nonzero transfer-buffers must log ONE " + "warning"); +} + +GST_END_TEST; + +static Suite * +transfer_buffers_suite (void) +{ + Suite *s = suite_create ("libuvch264src-transfer-buffers"); + + TCase *tc = tcase_create ("transfer_buffers"); + tcase_set_timeout (tc, 90); + suite_add_tcase (s, tc); + + tcase_add_test (tc, test_transfer_buffers_sentinel_no_call); + tcase_add_test (tc, test_transfer_buffers_applied_before_start); + tcase_add_test (tc, test_transfer_buffers_clamped); + tcase_add_test (tc, test_transfer_buffers_reconnect_reapply); + tcase_add_test (tc, test_transfer_buffers_start_streaming_io_error); + tcase_add_test (tc, test_transfer_buffers_off_noop); + + return s; +} + +GST_CHECK_MAIN (transfer_buffers); From 3d5003ea1e7d450250023d6d53c7352fc13cc545 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Thu, 2 Jul 2026 22:31:26 -0500 Subject: [PATCH 03/11] feat(element): negotiation-failure descriptor inventory + vid:pid quirk seam (empty by default) --- libuvch264src/src/gstlibuvch264src.c | 54 +++++- libuvch264src/src/quirks.c | 57 +++++++ libuvch264src/src/quirks.h | 68 ++++++++ libuvch264src/src/sources.txt | 1 + tests/CMakeLists.txt | 29 ++++ tests/mock_libuvc.c | 50 +++++- tests/mock_libuvc.h | 10 ++ tests/test_negotiate.c | 89 ++++++++++ tests/test_quirks.c | 237 +++++++++++++++++++++++++++ 9 files changed, 592 insertions(+), 3 deletions(-) create mode 100644 libuvch264src/src/quirks.c create mode 100644 libuvch264src/src/quirks.h create mode 100644 tests/test_quirks.c diff --git a/libuvch264src/src/gstlibuvch264src.c b/libuvch264src/src/gstlibuvch264src.c index b6b2d48..1f0f46c 100644 --- a/libuvch264src/src/gstlibuvch264src.c +++ b/libuvch264src/src/gstlibuvch264src.c @@ -13,6 +13,7 @@ #include "frame_pipeline.h" #include "spspps_cache.h" #include "ptz_control.h" +#include "quirks.h" #include #include @@ -405,6 +406,34 @@ static void gst_libuvc_h264_src_apply_transfer_buffers(GstLibuvcH264Src *self) { #endif } +/* Log a full inventory of every format/frame descriptor the device advertises. + * Called from negotiate() right before the no-H264/H265 bus error so field + * triage (GST_DEBUG=libuvch264src:3) can see WHAT the camera actually offered - + * fourcc/guid plus each frame's resolution and interval range - when it exposes + * no codec the element can stream. */ +static void gst_libuvc_h264_src_log_format_inventory(GstLibuvcH264Src *self) { + for (const uvc_format_desc_t *format_desc = uvc_get_format_descs(self->uvc_devh); + format_desc; format_desc = format_desc->next) + { + const guint8 *g = format_desc->guidFormat; + GST_WARNING_OBJECT(self, + " format fourcc '%.4s' guid " + "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", + format_desc->fourccFormat, + g[0], g[1], g[2], g[3], g[4], g[5], g[6], g[7], + g[8], g[9], g[10], g[11], g[12], g[13], g[14], g[15]); + + for (const uvc_frame_desc_t *frame_desc = format_desc->frame_descs; + frame_desc; frame_desc = frame_desc->next) + { + GST_WARNING_OBJECT(self, + " %ux%u frame interval [%u..%u] (100ns units)", + frame_desc->wWidth, frame_desc->wHeight, + frame_desc->dwMinFrameInterval, frame_desc->dwMaxFrameInterval); + } + } +} + static gboolean gst_libuvc_h264_negotiate(GstBaseSrc * basesrc) { GstLibuvcH264Src *self = GST_LIBUVC_H264_SRC(basesrc); @@ -518,9 +547,13 @@ static gboolean gst_libuvc_h264_negotiate(GstBaseSrc * basesrc) { if (!found_codec_format) { // The device exposes no H264/H265 format descriptor at all, so there is - // nothing to stream. Post a bus ERROR (not just a debug log) so + // nothing to stream. Log a full inventory of what it DID advertise first + // (field triage), then post a bus ERROR (not just a debug log) so // downstream consumers (cerastream/CeraUI) can react, instead of falling // through with uninitialized width/height/framerate. + GST_WARNING_OBJECT(self, + "device exposes no H264/H265 format; advertised formats follow:"); + gst_libuvc_h264_src_log_format_inventory(self); gst_libuvc_h264_src_post_error(GST_ELEMENT(self), UVC_ERROR_NOT_SUPPORTED, "negotiating caps: device exposes no H264/H265 format"); goto out; @@ -533,6 +566,25 @@ 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) { + // 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, + self->frame_format, width, height, framerate); + } + int res = uvc_get_stream_ctrl_format_size(self->uvc_devh, &self->uvc_ctrl, self->frame_format, width, height, framerate); if (res < 0) { diff --git a/libuvch264src/src/quirks.c b/libuvch264src/src/quirks.c new file mode 100644 index 0000000..fbfa04c --- /dev/null +++ b/libuvch264src/src/quirks.c @@ -0,0 +1,57 @@ +#include "quirks.h" +#include "gstlibuvch264src_internal.h" + +guint32 quirks_lookup_in(const uvc_quirk_entry_t *table, gsize n, + guint16 vid, guint16 pid) { + if (table == NULL) { + return 0; + } + for (gsize i = 0; i < n; i++) { + if (table[i].vid == vid && table[i].pid == pid) { + return table[i].flags; + } + } + return 0; +} + +/* Production quirk table. SHIPS EMPTY: no device currently needs a workaround, + * so uvc_quirks_lookup() returns 0 for every camera and negotiation is + * byte-for-byte unchanged. To enable a quirk, add ONE row here with a comment + * citing the device and the upstream issue, e.g. + * + * { 0x2ca3, 0x001f, QUIRK_DOUBLE_PROBE }, // DJI Osmo Action, libuvc #242 + * + * and nothing else changes - negotiate() already branches on the flags. */ +static const uvc_quirk_entry_t g_uvc_quirk_table[] = { + /* intentionally empty */ +}; + +#ifdef LIBUVCH264SRC_TESTING +/* Test override (A14). NULL restores the production table above. Compiled only + * into the test targets that define LIBUVCH264SRC_TESTING. */ +static const uvc_quirk_entry_t *g_quirk_test_table = NULL; +static gsize g_quirk_test_table_len = 0; + +void uvc_quirks_set_test_table(const uvc_quirk_entry_t *table, gsize n) { + g_quirk_test_table = table; + g_quirk_test_table_len = n; +} +#endif + +guint32 uvc_quirks_lookup(guint16 vid, guint16 pid) { + const uvc_quirk_entry_t *table = g_uvc_quirk_table; + gsize n = G_N_ELEMENTS(g_uvc_quirk_table); + +#ifdef LIBUVCH264SRC_TESTING + if (g_quirk_test_table != NULL) { + table = g_quirk_test_table; + n = g_quirk_test_table_len; + } +#endif + + guint32 flags = quirks_lookup_in(table, n, vid, pid); + if (flags != 0) { + GST_INFO("UVC quirk match for %04x:%04x -> flags 0x%08x", vid, pid, flags); + } + return flags; +} diff --git a/libuvch264src/src/quirks.h b/libuvch264src/src/quirks.h new file mode 100644 index 0000000..32163e5 --- /dev/null +++ b/libuvch264src/src/quirks.h @@ -0,0 +1,68 @@ +#ifndef GST_LIBUVC_H264_SRC_QUIRKS_H +#define GST_LIBUVC_H264_SRC_QUIRKS_H + +/* + * Per-device (vid:pid) quirk seam for the libuvch264src element. + * + * Some UVC cameras need a device-specific workaround that has nothing to do with + * the format they advertise. Rather than scatter `if (vid == ... && pid == ...)` + * checks through negotiate(), a single flat table maps a USB vendor:product ID + * to a bitmask of quirk flags, and negotiate() branches on those flags. + * + * The design mirrors the Linux uvcvideo quirk table + * (drivers/media/usb/uvc/uvc_driver.c) in shape only: a flat array of + * {vid, pid, flags} rows and a pure lookup. No kernel code is copied. + * + * The PRODUCTION table (uvc_quirks_lookup) ships EMPTY: no device currently + * needs a quirk, so with all cameras the lookup returns 0 and behavior is + * byte-for-byte unchanged. A workaround is enabled by adding one row to the + * static table in quirks.c (with a comment citing the device and the upstream + * issue) - never by editing the lookup logic. + * + * quirks_lookup_in() is a PURE function over a caller-supplied table so it can + * be unit-tested in isolation with a test-local table (no global state). The + * production uvc_quirks_lookup() is a thin wrapper over the static empty table. + */ + +#include + +G_BEGIN_DECLS + +/* Quirk flags. A row's `flags` is the OR of the workarounds that device needs. + * + * QUIRK_DOUBLE_PROBE Issue uvc_get_stream_ctrl_format_size() TWICE, discarding + * the first result. Works around cameras that return a + * stale/rejected stream control on the first probe + * (libuvc issue #242). */ +#define QUIRK_DOUBLE_PROBE (1u << 0) + +/* One quirk table row: a USB vendor:product ID mapped to its quirk flags. */ +typedef struct { + guint16 vid; + guint16 pid; + guint32 flags; +} uvc_quirk_entry_t; + +/* Pure lookup over a caller-supplied table (n rows). Returns the `flags` of the + * first row whose (vid, pid) matches, or 0 if none matches (including a NULL or + * empty table). No global state - unit-testable with a test-local table. */ +guint32 quirks_lookup_in(const uvc_quirk_entry_t *table, gsize n, + guint16 vid, guint16 pid); + +/* Production lookup over the static (empty) quirk table. Returns the quirk flags + * for (vid, pid), or 0 when no quirk applies. Logs GST_INFO on a match. */ +guint32 uvc_quirks_lookup(guint16 vid, guint16 pid); + +#ifdef LIBUVCH264SRC_TESTING +/* Test-only seam (A14): override the table uvc_quirks_lookup() consults so a + * static-registration test can key a quirk against the mock device's vid:pid + * without shipping a populated production entry. Passing (NULL, 0) restores the + * production empty table. This symbol is compiled ONLY into the test targets + * that define LIBUVCH264SRC_TESTING (see tests/CMakeLists.txt); the production + * plugin never sees it. */ +void uvc_quirks_set_test_table(const uvc_quirk_entry_t *table, gsize n); +#endif + +G_END_DECLS + +#endif /* GST_LIBUVC_H264_SRC_QUIRKS_H */ diff --git a/libuvch264src/src/sources.txt b/libuvch264src/src/sources.txt index 6533f47..f792734 100644 --- a/libuvch264src/src/sources.txt +++ b/libuvch264src/src/sources.txt @@ -4,3 +4,4 @@ frame_pipeline.c ptz_control.c spspps_cache.c gstlibuvch264src_error.c +quirks.c diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 3e80643..ef1a9ed 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -853,6 +853,7 @@ function(add_negotiate_variant variant sanitizer) # leak case is only meaningful (and only registered) for the asan variant. set(_neg_cases "negotiate_edge_zero_format${suffix}:test_negotiate_zero_format" + "negotiate_inventory_logged${suffix}:test_negotiate_inventory_logged" "negotiate_edge_framerate${suffix}:test_negotiate_framerate_zero" "negotiate_edge_zero_interval${suffix}:test_negotiate_zero_interval" "negotiate_extreme_fps_low${suffix}:test_negotiate_extreme_fps_low" @@ -910,6 +911,34 @@ if(ENABLE_SANITIZERS) add_negotiate_variant("asan" "address") endif() +# ----------------------------------------------------------------------------- +# vid:pid quirk-seam tests (Task 12). +# +# Same single-executable, statically-registered shape as test_negotiate so the +# mock's device descriptor (mock_uvc_set_device_descriptor) and its +# uvc_get_stream_ctrl_format_size() call counter are observable in-process. This +# is the ONLY target that defines LIBUVCH264SRC_TESTING, so quirks.c compiles the +# uvc_quirks_set_test_table() seam here and NOWHERE else - the production Meson +# plugin and the mock-plugin .so never see it (asserted via nm -D in the todo-12 +# acceptance). quirks.c itself ships in every build shape via sources.txt; only +# the test seam is gated by this PRIVATE compile definition. +# ----------------------------------------------------------------------------- +add_static_gstcheck_exe(test_quirks) +target_compile_definitions(test_quirks PRIVATE LIBUVCH264SRC_TESTING) + +set(_quirks_cases + "quirks_lookup_hit:test_quirks_lookup_hit" + "quirks_lookup_miss:test_quirks_lookup_miss" + "quirks_production_table_empty:test_quirks_production_table_empty" + "quirks_double_probe:test_quirks_double_probe" + "quirks_empty_table_single_probe:test_quirks_empty_table_single_probe" +) +# The double-probe/empty-table cases enter PLAYING -> start(), which may spawn +# the control thread, so keep them off the shared control-socket resource like +# the other start()-driven suites (RESOURCE_LOCK, applied by helper). +add_gstcheck_multicase_test(test_quirks "quirks" + "${STATIC_SUITE_SANITIZER}" "${_quirks_cases}") + # ----------------------------------------------------------------------------- # PTS clamp + restart-state tests (Task 14). # diff --git a/tests/mock_libuvc.c b/tests/mock_libuvc.c index 71d8128..56a16b8 100644 --- a/tests/mock_libuvc.c +++ b/tests/mock_libuvc.c @@ -67,10 +67,15 @@ struct uvc_device { struct uvc_device_handle { uvc_device_t *dev; - /* Format descriptor returned verbatim by uvc_get_format_descs(). */ + /* Format descriptor returned verbatim by uvc_get_format_descs(). fmt_desc is + * the chain head; extra_fmt/extra_frame back the optional multi-format chain + * (MOCK_UVC_FORMAT_MULTI_NO_CODEC). In every other mode fmt_desc.next is NULL, + * so the single-descriptor behavior is unchanged. */ uvc_format_desc_t fmt_desc; uvc_frame_desc_t frame_desc; uint32_t intervals[2]; + uvc_format_desc_t extra_fmt[2]; + uvc_frame_desc_t extra_frame[2]; /* Feeder thread state. */ pthread_t feeder; @@ -113,6 +118,7 @@ static int g_uvc_close_count = 0; /* uvc_close() calls on a live handle */ * the element committed when it called uvc_start_streaming(). */ static mock_uvc_payload_mode_t g_payload_mode = MOCK_UVC_PAYLOAD_ACCEPT; static int g_probe_call_count = 0; +static int g_format_size_call_count = 0; static uint32_t g_last_started_payload = 0; /* Transfer-buffers observability (A2 fork uvc_set_transfer_buffers). Records the @@ -202,6 +208,7 @@ void mock_uvc_reset(void) { g_uvc_open_fail_after = -1; g_payload_mode = MOCK_UVC_PAYLOAD_ACCEPT; g_probe_call_count = 0; + g_format_size_call_count = 0; g_last_started_payload = 0; g_last_transfer_buffers = 0; g_transfer_buffers_call_count = 0; @@ -377,6 +384,13 @@ int mock_uvc_probe_call_count(void) { return n; } +int mock_uvc_format_size_call_count(void) { + pthread_mutex_lock(&g_lock); + int n = g_format_size_call_count; + pthread_mutex_unlock(&g_lock); + return n; +} + uint8_t mock_uvc_last_transfer_buffers(void) { pthread_mutex_lock(&g_lock); uint8_t n = g_last_transfer_buffers; @@ -806,7 +820,8 @@ uvc_error_t uvc_open(uvc_device_t *dev, uvc_device_handle_t **devh) { } memset(&h->fmt_desc, 0, sizeof(h->fmt_desc)); - if (format_mode == MOCK_UVC_FORMAT_NO_CODEC) { + if (format_mode == MOCK_UVC_FORMAT_NO_CODEC || + format_mode == MOCK_UVC_FORMAT_MULTI_NO_CODEC) { /* A format the element does not handle, so negotiate() finds no codec. */ memcpy(h->fmt_desc.fourccFormat, "MJPG", 4); } else { @@ -816,6 +831,34 @@ uvc_error_t uvc_open(uvc_device_t *dev, uvc_device_handle_t **devh) { h->fmt_desc.frame_descs = &h->frame_desc; h->fmt_desc.next = NULL; + if (format_mode == MOCK_UVC_FORMAT_MULTI_NO_CODEC) { + /* Chain two more non-codec formats after the MJPG head, each with a distinct + * resolution and interval, so negotiate()'s descriptor inventory has several + * entries to enumerate (fourcc + per-frame WxH + interval range). */ + memset(h->extra_frame, 0, sizeof(h->extra_frame)); + memset(h->extra_fmt, 0, sizeof(h->extra_fmt)); + + h->extra_frame[0].bDescriptorSubtype = UVC_VS_FRAME_UNCOMPRESSED; + h->extra_frame[0].wWidth = 1280; + h->extra_frame[0].wHeight = 720; + h->extra_frame[0].dwMinFrameInterval = 333333; /* 30 fps */ + h->extra_frame[0].dwMaxFrameInterval = 333333; + memcpy(h->extra_fmt[0].fourccFormat, "YUY2", 4); + h->extra_fmt[0].frame_descs = &h->extra_frame[0]; + h->extra_fmt[0].next = &h->extra_fmt[1]; + + h->extra_frame[1].bDescriptorSubtype = UVC_VS_FRAME_UNCOMPRESSED; + h->extra_frame[1].wWidth = 640; + h->extra_frame[1].wHeight = 480; + h->extra_frame[1].dwMinFrameInterval = 333333; /* 30 fps */ + h->extra_frame[1].dwMaxFrameInterval = 666666; /* 15 fps (a real range) */ + memcpy(h->extra_fmt[1].fourccFormat, "NV12", 4); + h->extra_fmt[1].frame_descs = &h->extra_frame[1]; + h->extra_fmt[1].next = NULL; + + h->fmt_desc.next = &h->extra_fmt[0]; + } + #ifdef MOCK_LIBUSB_TEARDOWN h->usb_handle = mock_libusb_alloc_handle(); #endif @@ -872,6 +915,9 @@ uvc_error_t uvc_get_stream_ctrl_format_size(uvc_device_handle_t *devh, (void)format; (void)width; (void)height; (void)fps; if (!devh || !ctrl) return UVC_ERROR_INVALID_PARAM; + pthread_mutex_lock(&g_lock); + g_format_size_call_count++; + pthread_mutex_unlock(&g_lock); memset(ctrl, 0, sizeof(*ctrl)); ctrl->bFormatIndex = 1; ctrl->bFrameIndex = 1; diff --git a/tests/mock_libuvc.h b/tests/mock_libuvc.h index 167970b..4de61bf 100644 --- a/tests/mock_libuvc.h +++ b/tests/mock_libuvc.h @@ -78,6 +78,11 @@ typedef enum { /* A non-codec format (fourcc "MJPG"): negotiate() must find no H264/H265 * descriptor and post a bus error instead of streaming. */ MOCK_UVC_FORMAT_NO_CODEC, + /* A CHAIN of non-codec formats (MJPG + YUY2 + NV12), each with a distinct + * resolution: negotiate() still finds no H264/H265 and posts the same bus + * error, but first logs an inventory of every advertised descriptor. Lets a + * test assert the descriptor-inventory diagnostics (Task 12). */ + MOCK_UVC_FORMAT_MULTI_NO_CODEC, /* No interval list and dwMin/MaxFrameInterval == 0: the device-interval * branch of negotiate() must not divide by zero. */ MOCK_UVC_FORMAT_ZERO_DEVICE_INTERVAL, @@ -186,6 +191,11 @@ uint32_t mock_uvc_last_started_payload(void); * extra probe (max-payload unset = byte-for-byte unchanged negotiation). */ int mock_uvc_probe_call_count(void); +/* uvc_get_stream_ctrl_format_size() calls since reset (Task 12 quirk seam). A + * default negotiation issues exactly 1; a device keyed to QUIRK_DOUBLE_PROBE + * issues exactly 2 (the first result discarded). */ +int mock_uvc_format_size_call_count(void); + /* Transfer-buffers observability (A2 fork uvc_set_transfer_buffers). The last * count the element pushed via uvc_set_transfer_buffers() and how many times it * called the setter. A call count of 0 proves the sentinel (transfer-buffers=0) diff --git a/tests/test_negotiate.c b/tests/test_negotiate.c index 30238b2..db5cd9e 100644 --- a/tests/test_negotiate.c +++ b/tests/test_negotiate.c @@ -528,6 +528,94 @@ GST_START_TEST (test_negotiate_max_payload_accepted_applied) GST_END_TEST; +/* ------------------------------------------------------------------------- * + * Descriptor-inventory diagnostics (Task 12). When the device exposes no + * H264/H265 format, negotiate() must GST_WARNING_OBJECT an inventory of every + * format descriptor it DID advertise (fourcc + per-frame WxH + interval range) + * BEFORE posting the unchanged RESOURCE/SETTINGS bus error. A capturing log + * function collects the WARNING lines; the assertions prove all three offered + * formats appear AND the bus error is byte-identical to the single-format case. + * ------------------------------------------------------------------------- */ + +static GMutex g_inventory_lock; +static GString *g_inventory_text; /* accumulates WARNING messages under lock */ + +static void +inventory_log_func (GstDebugCategory * category, GstDebugLevel level, + const gchar * file, const gchar * function, gint line, GObject * object, + GstDebugMessage * message, gpointer user_data) +{ + (void) category; (void) file; (void) function; (void) line; (void) object; + (void) user_data; + if (level > GST_LEVEL_WARNING) + return; + const gchar *m = gst_debug_message_get (message); + if (m == NULL) + return; + g_mutex_lock (&g_inventory_lock); + if (g_inventory_text != NULL) { + g_string_append (g_inventory_text, m); + g_string_append_c (g_inventory_text, '\n'); + } + g_mutex_unlock (&g_inventory_lock); +} + +GST_START_TEST (test_negotiate_inventory_logged) +{ + g_mutex_lock (&g_inventory_lock); + g_inventory_text = g_string_new (NULL); + g_mutex_unlock (&g_inventory_lock); + + gst_debug_set_active (TRUE); + gst_debug_set_threshold_for_name ("libuvch264src", GST_LEVEL_WARNING); + gst_debug_add_log_function (inventory_log_func, NULL, NULL); + + mock_uvc_set_format_mode (MOCK_UVC_FORMAT_MULTI_NO_CODEC); + + GstElement *pipeline = build_pipeline (); + GstMessage *msg = play_and_wait_error (pipeline); + + GError *gerr = NULL; + gboolean is_resource = FALSE; + if (msg != NULL) { + gchar *dbg = NULL; + gst_message_parse_error (msg, &gerr, &dbg); + g_free (dbg); + is_resource = g_error_matches (gerr, GST_RESOURCE_ERROR, + GST_RESOURCE_ERROR_SETTINGS); + g_clear_error (&gerr); + gst_message_unref (msg); + } + + gst_element_set_state (pipeline, GST_STATE_NULL); + gst_object_unref (pipeline); + gst_debug_remove_log_function (inventory_log_func); + + g_mutex_lock (&g_inventory_lock); + gchar *captured = g_string_free (g_inventory_text, FALSE); + g_inventory_text = NULL; + g_mutex_unlock (&g_inventory_lock); + + fail_unless (msg != NULL, + "expected a bus ERROR for a multi-format device with no H264/H265 format"); + fail_unless (is_resource, + "the multi-format no-codec bus error must stay RESOURCE/SETTINGS " + "(byte-identical to the single-format case)"); + + fail_unless (captured != NULL, "no diagnostics were logged"); + fail_unless (strstr (captured, "MJPG") != NULL, + "inventory must list the MJPG descriptor"); + fail_unless (strstr (captured, "YUY2") != NULL, + "inventory must list the YUY2 descriptor"); + fail_unless (strstr (captured, "NV12") != NULL, + "inventory must list the NV12 descriptor"); + fail_unless (strstr (captured, "1280x720") != NULL, + "inventory must report each descriptor's resolution"); + g_free (captured); +} + +GST_END_TEST; + static Suite * negotiate_suite (void) { @@ -540,6 +628,7 @@ negotiate_suite (void) tcase_add_test (tc, test_negotiate_leak); tcase_add_test (tc, test_negotiate_zero_format); + tcase_add_test (tc, test_negotiate_inventory_logged); tcase_add_test (tc, test_negotiate_framerate_zero); tcase_add_test (tc, test_negotiate_zero_interval); tcase_add_test (tc, test_negotiate_extreme_fps_low); diff --git a/tests/test_quirks.c b/tests/test_quirks.c new file mode 100644 index 0000000..ccfa1a1 --- /dev/null +++ b/tests/test_quirks.c @@ -0,0 +1,237 @@ +/* vid:pid quirk-seam tests for the libuvch264src element (Task 12). + * + * Like test_negotiate.c, the element TUs, the libuvc mock, and the driver are + * linked into ONE statically-registered executable so the mock's device + * descriptor (mock_uvc_set_device_descriptor) and its + * uvc_get_stream_ctrl_format_size() call counter are observable in-process. This + * target is the ONLY one compiled with -DLIBUVCH264SRC_TESTING (see + * tests/CMakeLists.txt), so uvc_quirks_set_test_table() - the A14 test seam - + * is visible here and nowhere else. Each gst-check test is its own ctest entry + * via GST_CHECKS. + * + * quirks_lookup_hit / _miss the pure quirks_lookup_in() over a + * test-local table. + * quirks_production_table_empty the shipped uvc_quirks_lookup() table has + * ZERO entries, so every vid:pid returns 0. + * quirks_double_probe a device keyed to QUIRK_DOUBLE_PROBE makes + * negotiate() call uvc_get_stream_ctrl_format_ + * size() exactly TWICE (libuvc #242). + * quirks_empty_table_single_probe with no matching quirk the count stays at + * exactly 1 (default byte-identical probe). + */ + +#include + +#include "gstlibuvch264src.h" +#include "mock_libuvc.h" +#include "quirks.h" + +/* The quirked device's USB IDs; the test table below keys QUIRK_DOUBLE_PROBE to + * this pair and mock_uvc_set_device_descriptor() advertises it. */ +#define QUIRK_TEST_VID 0x1234u +#define QUIRK_TEST_PID 0x5678u + +static gint g_buffers_seen; + +static GstPadProbeReturn +count_buffer_probe (GstPad * pad, GstPadProbeInfo * info, gpointer user_data) +{ + (void) pad; + (void) user_data; + if (GST_PAD_PROBE_INFO_TYPE (info) & GST_PAD_PROBE_TYPE_BUFFER) + g_atomic_int_inc (&g_buffers_seen); + return GST_PAD_PROBE_OK; +} + +static void +setup (void) +{ + const gchar *core_plugin = g_getenv ("GST_COREELEMENTS_PLUGIN"); + if (core_plugin != NULL && *core_plugin != '\0') { + GError *lerr = NULL; + GstPlugin *p = gst_plugin_load_file (core_plugin, &lerr); + fail_unless (p != NULL, "could not load core-elements plugin '%s': %s", + core_plugin, lerr ? lerr->message : "(unknown)"); + gst_object_unref (p); + } + + static gboolean registered = FALSE; + if (!registered) { + fail_unless (gst_element_register (NULL, "libuvch264src", GST_RANK_NONE, + GST_TYPE_LIBUVC_H264_SRC), "failed to register libuvch264src"); + registered = TRUE; + } + + mock_uvc_reset (); + /* Always start from the production (empty) table; a test that wants a quirk + * injects its own table and the next setup() clears it again. */ + uvc_quirks_set_test_table (NULL, 0); + g_atomic_int_set (&g_buffers_seen, 0); +} + +static GstElement * +build_pipeline (void) +{ + GstElement *pipeline = gst_pipeline_new ("quirks-pipeline"); + GstElement *src = gst_element_factory_make ("libuvch264src", "src"); + GstElement *sink = gst_element_factory_make ("fakesink", "sink"); + + fail_unless (pipeline != NULL && src != NULL && sink != NULL, + "failed to create test elements"); + g_object_set (sink, "sync", FALSE, NULL); + g_object_set (src, "index", "0", NULL); + + gst_bin_add_many (GST_BIN (pipeline), src, sink, NULL); + fail_unless (gst_element_link (src, sink), "failed to link src ! sink"); + return pipeline; +} + +/* Drive PLAYING against the mock feeder and return TRUE once a buffer flows + * (which proves negotiate() ran and streaming started). Caller drops to NULL. */ +static gboolean +play_until_buffer (GstElement * pipeline) +{ + GstElement *sink = gst_bin_get_by_name (GST_BIN (pipeline), "sink"); + GstPad *pad = gst_element_get_static_pad (sink, "sink"); + gst_pad_add_probe (pad, GST_PAD_PROBE_TYPE_BUFFER, count_buffer_probe, NULL, + NULL); + gst_object_unref (pad); + gst_object_unref (sink); + + if (gst_element_set_state (pipeline, GST_STATE_PLAYING) == + GST_STATE_CHANGE_FAILURE) + return FALSE; + + gint64 deadline = g_get_monotonic_time () + 3 * G_TIME_SPAN_SECOND; + while (g_atomic_int_get (&g_buffers_seen) <= 0 + && g_get_monotonic_time () < deadline) { + g_usleep (2 * G_TIME_SPAN_MILLISECOND); + } + return g_atomic_int_get (&g_buffers_seen) > 0; +} + +/* ------------------------------------------------------------------------- * + * Pure lookup (quirks_lookup_in) over a test-local table - no device, no GST. + * ------------------------------------------------------------------------- */ + +GST_START_TEST (test_quirks_lookup_hit) +{ + static const uvc_quirk_entry_t table[] = { + { 0x0bda, 0x5830, QUIRK_DOUBLE_PROBE }, + { QUIRK_TEST_VID, QUIRK_TEST_PID, QUIRK_DOUBLE_PROBE }, + }; + + fail_unless (quirks_lookup_in (table, G_N_ELEMENTS (table), + QUIRK_TEST_VID, QUIRK_TEST_PID) == QUIRK_DOUBLE_PROBE, + "exact vid:pid match must return the row's flags"); + fail_unless (quirks_lookup_in (table, G_N_ELEMENTS (table), + 0x0bda, 0x5830) == QUIRK_DOUBLE_PROBE, + "the first row must also match"); +} + +GST_END_TEST; + +GST_START_TEST (test_quirks_lookup_miss) +{ + static const uvc_quirk_entry_t table[] = { + { QUIRK_TEST_VID, QUIRK_TEST_PID, QUIRK_DOUBLE_PROBE }, + }; + + fail_unless (quirks_lookup_in (table, G_N_ELEMENTS (table), + QUIRK_TEST_VID, 0x9999) == 0, + "matching vid but wrong pid must miss"); + fail_unless (quirks_lookup_in (table, G_N_ELEMENTS (table), + 0x0000, QUIRK_TEST_PID) == 0, + "matching pid but wrong vid must miss"); + fail_unless (quirks_lookup_in (table, G_N_ELEMENTS (table), + 0xffff, 0xffff) == 0, "no match must return 0"); + fail_unless (quirks_lookup_in (NULL, 0, QUIRK_TEST_VID, QUIRK_TEST_PID) == 0, + "a NULL/empty table must return 0"); +} + +GST_END_TEST; + +GST_START_TEST (test_quirks_production_table_empty) +{ + /* No test table injected (setup() cleared it), so uvc_quirks_lookup() consults + * the shipped static table, which MUST be empty: every vid:pid returns 0. */ + fail_unless (uvc_quirks_lookup (QUIRK_TEST_VID, QUIRK_TEST_PID) == 0, + "the production quirk table must ship EMPTY (no entry may match)"); + fail_unless (uvc_quirks_lookup (0x0000, 0x0000) == 0, + "the production quirk table must ship EMPTY"); + fail_unless (uvc_quirks_lookup (0xffff, 0xffff) == 0, + "the production quirk table must ship EMPTY"); +} + +GST_END_TEST; + +/* ------------------------------------------------------------------------- * + * Wired-but-gated integration: the double-probe flag reaches negotiate(). + * ------------------------------------------------------------------------- */ + +GST_START_TEST (test_quirks_double_probe) +{ + static const uvc_quirk_entry_t table[] = { + { QUIRK_TEST_VID, QUIRK_TEST_PID, QUIRK_DOUBLE_PROBE }, + }; + + mock_uvc_set_device_descriptor (0, QUIRK_TEST_VID, QUIRK_TEST_PID, NULL, 0, 0); + uvc_quirks_set_test_table (table, G_N_ELEMENTS (table)); + + GstElement *pipeline = build_pipeline (); + gboolean got = play_until_buffer (pipeline); + gst_element_set_state (pipeline, GST_STATE_NULL); + gst_object_unref (pipeline); + + fail_unless (got, "stream must start for the quirked device"); + fail_unless (mock_uvc_format_size_call_count () == 2, + "QUIRK_DOUBLE_PROBE must issue uvc_get_stream_ctrl_format_size TWICE; got %d", + mock_uvc_format_size_call_count ()); +} + +GST_END_TEST; + +GST_START_TEST (test_quirks_empty_table_single_probe) +{ + /* Same device IDs, but the injected table has NO matching row, so no quirk + * applies and the probe count stays at the default 1 (byte-identical). */ + static const uvc_quirk_entry_t table[] = { + { 0x0bda, 0x5830, QUIRK_DOUBLE_PROBE }, + }; + + mock_uvc_set_device_descriptor (0, QUIRK_TEST_VID, QUIRK_TEST_PID, NULL, 0, 0); + uvc_quirks_set_test_table (table, G_N_ELEMENTS (table)); + + GstElement *pipeline = build_pipeline (); + gboolean got = play_until_buffer (pipeline); + gst_element_set_state (pipeline, GST_STATE_NULL); + gst_object_unref (pipeline); + + fail_unless (got, "stream must start for the unquirked device"); + fail_unless (mock_uvc_format_size_call_count () == 1, + "an unmatched (empty) quirk table must leave the probe count at 1; got %d", + mock_uvc_format_size_call_count ()); +} + +GST_END_TEST; + +static Suite * +quirks_suite (void) +{ + Suite *s = suite_create ("libuvch264src-quirks"); + TCase *tc = tcase_create ("quirks"); + + tcase_set_timeout (tc, 30); + tcase_add_checked_fixture (tc, setup, NULL); + suite_add_tcase (s, tc); + + tcase_add_test (tc, test_quirks_lookup_hit); + tcase_add_test (tc, test_quirks_lookup_miss); + tcase_add_test (tc, test_quirks_production_table_empty); + tcase_add_test (tc, test_quirks_double_probe); + tcase_add_test (tc, test_quirks_empty_table_single_probe); + + return s; +} + +GST_CHECK_MAIN (quirks); From 3cbab949289c7006fa1e100ca261c61c36d998ca Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Thu, 2 Jul 2026 23:09:59 -0500 Subject: [PATCH 04/11] test(transfer-buffers): scope fork-only ON cases behind TB_API_AVAILABLE The main test_transfer_buffers target's three ON-path cases (applied, clamped, reconnect_reapply) asserted the fork-only uvc_set_transfer_buffers() effect unconditionally. The file already defined TB_API_AVAILABLE for exactly this purpose but never used it, so a real -DLIBUVC_USE_FORK=OFF full-suite run (first exercised by the todo-13 integration gate) failed those three cases: with the fork API absent the element correctly warns and no-ops, so no setter call is made. Activate the guard so each case asserts the fork ON behavior when TB_API_AVAILABLE and the warn-and-no-op behavior otherwise; the max-payload reconnect re-arm (not fork-gated) stays asserted on both paths. No product code changed; the OFF no-op remains covered by test_transfer_buffers_off_noop. --- tests/test_transfer_buffers.c | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/tests/test_transfer_buffers.c b/tests/test_transfer_buffers.c index 7cb6eea..3cec49b 100644 --- a/tests/test_transfer_buffers.c +++ b/tests/test_transfer_buffers.c @@ -193,6 +193,7 @@ GST_START_TEST (test_transfer_buffers_applied_before_start) gst_object_unref (pipeline); fail_unless (got, "stream must start with an in-range transfer-buffers value"); +#if TB_API_AVAILABLE fail_unless (calls >= 1, "a nonzero transfer-buffers must call uvc_set_transfer_buffers; got %d", calls); @@ -201,6 +202,17 @@ GST_START_TEST (test_transfer_buffers_applied_before_start) started); fail_unless (effective == 4u, "read-back must report the applied transfer-buffers (4); got %u", effective); +#else + /* LIBUVC_USE_FORK=OFF: fork API absent -> warn-and-no-op (setter never called, + * library default kept); the one-warning no-op is covered by _off_noop. */ + fail_unless (calls == 0, + "with the fork API absent, transfer-buffers must NOT call the setter; " + "got %d call(s)", calls); + fail_unless (started == 0, + "with the fork API absent, the library default must be left in place; " + "got %u", started); + (void) effective; +#endif } GST_END_TEST; @@ -242,13 +254,22 @@ GST_START_TEST (test_transfer_buffers_clamped) fail_unless (got, "stream must start with an out-of-band transfer-buffers value"); fail_unless (g_atomic_int_get (&saw_transfer_warning), - "an out-of-range transfer-buffers must log a clamp warning"); + "an out-of-range transfer-buffers must log a warning"); +#if TB_API_AVAILABLE fail_unless (started == 100, "transfer-buffers 200 must clamp to 100 before streaming start; got %u", started); fail_unless (effective == 100u, "read-back must report the clamped transfer-buffers (100); got %u", effective); +#else + /* LIBUVC_USE_FORK=OFF: the no-op path warns (message carries "transfer-buffers", + * asserted above) and never pushes a count to the device. */ + fail_unless (started == 0, + "with the fork API absent, no transfer-buffers count must reach the " + "device; got %u", started); + (void) effective; +#endif } GST_END_TEST; @@ -331,12 +352,24 @@ GST_START_TEST (test_transfer_buffers_reconnect_reapply) "errored out"); fail_unless (resumed && open_count >= 2, "stream did not reopen+resume after reconnect (open count %d)", open_count); +#if TB_API_AVAILABLE fail_unless (tb_calls >= 2, "transfer-buffers must be re-applied on the successful reconnect " "(>= 2 setter calls: initial + reconnect); got %d", tb_calls); fail_unless (started_tb == 4, "the resumed stream must start on the re-applied transfer-buffers (4); " "got %u", started_tb); +#else + /* LIBUVC_USE_FORK=OFF: transfer-buffers no-ops (fork API absent), so no setter + * call fires on either the initial start or the reconnect re-arm; max-payload + * is NOT fork-gated and still re-arms (asserted below). */ + fail_unless (tb_calls == 0, + "with the fork API absent, transfer-buffers must NOT call the setter " + "across reconnect; got %d", tb_calls); + fail_unless (started_tb == 0, + "with the fork API absent, the library default must be left in place; " + "got %u", started_tb); +#endif fail_unless (started_payload == 32768u, "the resumed stream must start on the re-applied max-payload (32768); " "got %u", started_payload); From 05e96f0dc16e633d6161a2e81d96cc6cf8df9a22 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Fri, 3 Jul 2026 02:07:01 -0500 Subject: [PATCH 05/11] docs: camera-compat matrix, transfer-buffers property, fork v0.0.7.3 provenance --- AGENTS.md | 23 ++- README.md | 3 +- libuvch264src/docs/notes/camera-compat.md | 183 ++++++++++++++++++++ libuvch264src/docs/notes/libuvc-fork-adr.md | 39 +++++ 4 files changed, 241 insertions(+), 7 deletions(-) create mode 100644 libuvch264src/docs/notes/camera-compat.md diff --git a/AGENTS.md b/AGENTS.md index df4991e..762fde8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ GStreamer source element that pulls H.264 frames directly from DJI action cameras and UVC devices via libuvc. Developed by UnlimitedIRL; forked/maintained under CeraLive. -> **Security:** CVE-2026-1991 (null-deref in scan-streaming path) is fixed in the CeraLive fork at commit `eae7f49` (tag `ceralive-v0.0.7.2`) and also carried as `patches/cve-2026-1991-scan-streaming-nullguard.patch` for the upstream fallback path. Upstream libuvc is effectively dead (last commit 2024); the CeraLive fork at `https://github.com/CeraLive/libuvc.git` is the canonical dependency. +> **Security:** CVE-2026-1991 (null-deref in scan-streaming path) is fixed in the CeraLive fork at commit `eae7f49` (first shipped in tag `ceralive-v0.0.7.2`, carried forward in the current `ceralive-v0.0.7.3`, SHA `6210f2f64965af532440be357e6971b9b618797f`) and also carried as `patches/cve-2026-1991-scan-streaming-nullguard.patch` for the upstream fallback path. Upstream libuvc is effectively dead (last commit 2024); the CeraLive fork at `https://github.com/CeraLive/libuvc.git` is the canonical dependency. Parent manifest: [`../AGENTS.md`](../AGENTS.md) @@ -33,14 +33,16 @@ gstlibuvch264src/ │ │ ├── spspps_cache.{c,h} # SPS/PPS/VPS disk cache (path safety, resolution key) │ │ ├── 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 +│ │ ├── uvc_device.{c,h} # USB teardown helper + V4L2 capability probe +│ │ └── quirks.{c,h} # vid:pid quirk seam (table + lookup + logging); ships empty │ ├── docs/notes/ │ │ ├── reconnect-spike.md # Spike verdict: libuvc dead-handle teardown is SAFE │ │ ├── bmaxpayload-analysis.md # max-payload bandwidth tuning analysis │ │ ├── dji-xu-investigation.md # DJI XU control investigation (report only; no code shipped) │ │ ├── v4l2src-spike.md # v4l2src evaluation spike (report only; no code shipped) │ │ ├── scr-investigation.md # SCR-based PTS investigation (verdict: SCR-ABSENT; no code change) -│ │ └── libuvc-fork-adr.md # ADR: CeraLive fork as canonical libuvc dependency +│ │ ├── libuvc-fork-adr.md # ADR: CeraLive fork as canonical libuvc dependency +│ │ └── camera-compat.md # Mechanism-per-family compat matrix + field-triage + fork provenance │ └── meson.build # Canonical production build ├── tests/ # Hardware-independent ctest suite (mock-backed) │ ├── mock_libuvc.{c,h} # libuvc mock (~16 fns); env/API config; PTZ + descriptor support @@ -50,7 +52,7 @@ gstlibuvch264src/ │ ├── test_device_select.c # Device selection: ordinal/vid:pid/serial/bus + index validation │ ├── test_ptz.c # PTZ properties + capability gate │ ├── test_socket.c # Control socket: default-off, per-instance path, mode 0600 -│ ├── test_negotiate.c # Caps negotiation: leak (LSAN), zero-format, framerate edge cases +│ ├── test_negotiate.c # Caps negotiation: leak (LSAN), zero-format, framerate edge cases, inventory log │ ├── test_usb_teardown.c # USB teardown: single libusb_close, real interface count │ ├── test_pts_thread_safety.c # PTS/clock race + frame throughput │ ├── test_pts_monotonic.c # PTS monotonicity + restart IDR gate @@ -63,6 +65,8 @@ gstlibuvch264src/ │ ├── test_compat.c # API compatibility: property existence + type assertions │ ├── 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 │ ├── fuzz_nal.c # NAL parser fuzz harness (libFuzzer entry point) │ ├── tsan.suppressions # TSan suppressions for third-party + baselined GMutex blind spots │ └── tsan_pts.suppressions# TSan suppressions for PTS/clock GMutex (permanent blind spot) @@ -78,7 +82,7 @@ gstlibuvch264src/ > `libuvc/` is no longer vendored in-tree. By default (`LIBUVC_USE_FORK=ON`), > `scripts/build-libuvc.sh` clones the CeraLive fork at the hardened SHA -> (`eae7f49` on `main`, tag `ceralive-v0.0.7.2`) — no patch step needed. With +> (`6210f2f` on `main`, tag `ceralive-v0.0.7.3`) — no patch step needed. With > `LIBUVC_USE_FORK=OFF`, it falls back to upstream v0.0.7 > (`68d07a00e11d1944e27b7295ee69673239c00b4b`) and applies the patches from > `patches/` (including the CVE-2026-1991 null-guard). The Dockerfile and the @@ -96,6 +100,7 @@ gstlibuvch264src/ | USB teardown + V4L2 probe | `libuvch264src/src/uvc_device.c` | | SPS/PPS cache | `libuvch264src/src/spspps_cache.c` | | Error mapping helper | `libuvch264src/src/gstlibuvch264src_error.c` | +| vid:pid quirk seam (table + lookup) | `libuvch264src/src/quirks.c` | | Meson build config | `libuvch264src/meson.build` | | Build environment | `Dockerfile` | | Reconnect feasibility verdict | `libuvch264src/docs/notes/reconnect-spike.md` | @@ -104,6 +109,7 @@ gstlibuvch264src/ | v4l2src evaluation spike (report only) | `libuvch264src/docs/notes/v4l2src-spike.md` | | SCR/PTS investigation (verdict: SCR-ABSENT) | `libuvch264src/docs/notes/scr-investigation.md` | | libuvc fork ADR | `libuvch264src/docs/notes/libuvc-fork-adr.md` | +| Camera compat matrix + field triage + fork provenance | `libuvch264src/docs/notes/camera-compat.md` | | Example pipelines | `README.md` | --- @@ -157,6 +163,10 @@ Opt-in in-element auto-reconnect on a mid-stream disconnect. Default is **off**: USB payload transfer size hint in bytes (`dwMaxPayloadTransferSize`). `0` (the default) leaves the device-negotiated value unchanged. A nonzero value is clamped to `[512, 4194304]`, applied via UVC probe/commit with read-back, and falls back to the device-negotiated value if the device refuses it. Read-back reports the effective committed value. See `libuvch264src/docs/notes/bmaxpayload-analysis.md` for tuning guidance. +### `transfer-buffers` (uint, range 0..255, default `0`) + +USB transfer buffer count hint: the number of USB transfer buffers `libuvc` submits per stream. `0` (the default, and the sentinel) leaves the library's default count unchanged — no device write at all. A nonzero value is clamped to `[2, 100]` and applied via the CeraLive fork's `uvc_set_transfer_buffers()` right before streaming starts, in both the initial `start()` and on every reconnect re-arm (the fork API rejects the call mid-stream, so it must precede `uvc_start_streaming()`). Read-back reports the effective (clamped) value once applied; before that it reports the requested value. Requires the CeraLive libuvc fork (backs fork item A2, `libuvch264src/docs/notes/camera-compat.md` §3); on upstream libuvc (`LIBUVC_USE_FORK=OFF`) a nonzero request is a no-op with one warning, and the property itself is otherwise harmless to set on either build. + ### Action signal: `set-ptz(pan, tilt, zoom)` → boolean Drives all three PTZ axes in one emission. Each axis is applied only when the device reports it. Returns `TRUE` if at least one supported axis was driven and every attempted set succeeded. @@ -238,7 +248,7 @@ The `Dockerfile` pins both the base image and the libuvc source: FROM ubuntu:24.04@sha256:786a8b558f7be160c6c8c4a54f9a57274f3b4fb1491cf65146521ae77ff1dc54 ``` -libuvc is fetched via `scripts/build-libuvc.sh` (fork mode by default, SHA `eae7f49`). The arch matrix fails loudly on unknown `TARGETARCH` values — no silent fallback. +libuvc is fetched via `scripts/build-libuvc.sh` (fork mode by default, SHA `6210f2f` / tag `ceralive-v0.0.7.3`). The arch matrix fails loudly on unknown `TARGETARCH` values — no silent fallback. **Two stages: pinned-ubuntu `build`, then `FROM scratch` `runtime`.** The release recipe (`publish-release.yml`) exports the *final* stage wholesale (`buildx --output type=local,dest=build` → `fpm build/usr/=/usr/`). The `runtime` stage MUST stay `FROM scratch`, carrying ONLY the plugin payload that the `build` stage stages under `/out`: `usr/lib//gstreamer-1.0/libgstlibuvch264src.so` + `usr/lib//libuvc.so*` (the symlink chain; `libuvc.a`/`.pc` are build-only and excluded). Do NOT switch `runtime` back to an Ubuntu base to add runtime deps — that exports the entire distro `/usr` and produced a ~56 MB `.deb` that dpkg-file-conflicts with `coreutils`/`libc` on install. GStreamer/libusb/libjpeg are runtime deps from the target system (package `Depends: libgstreamer1.0-0`), not bundled in the image. @@ -278,6 +288,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 suite does NOT prove (requires real hardware — out of scope here):** - Actual USB enumeration, `uvc_open()`/streaming against a physical DJI/UVC camera, real bandwidth at a given `max-payload`, or real PTZ motion on a device. diff --git a/README.md b/README.md index 447dc39..e3a185f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ GStreamer source element for UVC H.264 (and H.265) capture devices — DJI actio Feeds raw H.264/H.265 bitstream into the cerastream pipeline. HDMI capture paths bypass this element entirely. -> **Security:** CVE-2026-1991 (null-deref in scan-streaming path) is fixed in the CeraLive fork at commit `eae7f49` (tag `ceralive-v0.0.7.2`) and also carried as `patches/cve-2026-1991-scan-streaming-nullguard.patch` for the upstream fallback path. Upstream libuvc is effectively dead (last commit 2024); the CeraLive fork at `https://github.com/CeraLive/libuvc.git` is the canonical dependency. +> **Security:** CVE-2026-1991 (null-deref in scan-streaming path) is fixed in the CeraLive fork at commit `eae7f49` (first shipped in tag `ceralive-v0.0.7.2`, carried forward in the current `ceralive-v0.0.7.3`, SHA `6210f2f64965af532440be357e6971b9b618797f`) and also carried as `patches/cve-2026-1991-scan-streaming-nullguard.patch` for the upstream fallback path. Upstream libuvc is effectively dead (last commit 2024); the CeraLive fork at `https://github.com/CeraLive/libuvc.git` is the canonical dependency. [![CI](https://github.com/CERALIVE/gstlibuvch264src/actions/workflows/build-check.yml/badge.svg)](https://github.com/CERALIVE/gstlibuvch264src/actions/workflows/build-check.yml) [![Release](https://github.com/CERALIVE/gstlibuvch264src/actions/workflows/publish-release.yml/badge.svg)](https://github.com/CERALIVE/gstlibuvch264src/actions/workflows/publish-release.yml) @@ -294,6 +294,7 @@ This element stamps PTS as pipeline running-time. Residual A/V drift with a Blue | `control-socket-path` | string | `null` | Explicit socket path; auto-selects `$XDG_RUNTIME_DIR/libuvch264src--.sock` when null | | `reconnect` | bool | `false` | Auto-reconnect on mid-stream disconnect with exponential backoff (default off) | | `max-payload` | uint | `0` | USB payload transfer size hint in bytes (`dwMaxPayloadTransferSize`); `0` = device default; nonzero clamped to `[512, 4194304]` with read-back | +| `transfer-buffers` | uint | `0` | USB transfer buffer count hint; `0` = library default (no device write); nonzero clamped to `[2, 100]`, applied right before streaming starts (CeraLive fork only; no-op with a warning on upstream libuvc) | Action signal: `set-ptz(pan, tilt, zoom)` — drives all three axes in one call; returns `TRUE` if at least one supported axis succeeded. diff --git a/libuvch264src/docs/notes/camera-compat.md b/libuvch264src/docs/notes/camera-compat.md new file mode 100644 index 0000000..c1fb0a1 --- /dev/null +++ b/libuvch264src/docs/notes/camera-compat.md @@ -0,0 +1,183 @@ +# Camera Compatibility Matrix + +**Status:** informational, updated as devices get validated +**Date:** 2026-07-03 +**Scope:** what `libuvch264src` can talk to today, how, and how confident we are about each device family + +This note answers three questions a field technician or on-call engineer asks +when a camera doesn't stream: which mechanism does this device use, what +commands narrow down the fault, and which fork fix (if any) already covers +the failure mode. It does not promise support for hardware nobody has tested. +Where we say "unvalidated," read that literally: the mechanism should work +per the UVC spec, but no CeraLive test hardware has confirmed it. + +--- + +## 1. Mechanism-Per-Family Matrix + +| Family | Mechanism | Status | Notes | +|--------|-----------|--------|-------| +| **DJI Osmo / Action series** | Frame-based UVC H.264/H.265 (`UVC_VS_FRAME_FRAME_BASED`), with historically degenerate frame descriptors (zero `dwMaxVideoFrameBufferSize`, bad `dwDefaultFrameInterval`) | **Supported** | Primary target device family. The degenerate-descriptor problem is now repaired in the fork (A4, `5df5401`); see §3. Confirmed working via `test_mock_smoke`/`test_negotiate` against a DJI-shaped descriptor set and, per plan history, real hardware validation upstream of this todo. | +| **Insta360 X3 / X4 / Ace Pro** | Webcam-mode UVC with on-camera H.264/H.265 codec select (device switches into a UVC-compliant mode via its own menu/app) | **Expected-compatible / unvalidated** | These cameras expose a standard UVC interface once switched into webcam mode, so the same `negotiate()` path that finds DJI's H.264/H.265 format descriptors should find theirs too. CeraLive has **no Insta360 test hardware**. Treat this as "should work per the UVC descriptors these devices are documented to expose," not as a tested claim. Field-triage with the `GST_DEBUG` incantation in §2 before assuming it's broken; if the inventory shows an H264/H265 `uvc_format_desc_t`, the negotiate path should pick it up the same way it picks up DJI's. | +| **Logitech C920-era webcams** | XU-controlled (extension-unit) H.264 encode, not a plain UVC frame-based format descriptor | **Unsupported by design** | These webcams encode H.264 through vendor extension-unit (XU) controls rather than exposing an `H264`/`H265` `uvc_format_desc_t`. `negotiate()` only walks standard format descriptors (`gst_libuvc_h264_negotiate()`, `gstlibuvch264src.c:437`) and has no XU probing path, so it will report "device exposes no H264/H265 format" (see the `dji-xu-investigation.md` note for the related DJI XU control research, which found the same class of limitation). This is a scope decision, not a bug: adding XU-based H.264 extraction would be a substantial new mechanism, not a compat fix. | +| **GoPro (HERO-series, non-UVC-webcam models)** | Non-UVC, HTTP-over-USB (GoPro's own "USB webcam mode" and control API run over a network-over-USB gadget interface, not USB Video Class) | **Out of scope** | `libuvch264src` is a `libuvc`-backed UVC source element. A device that never enumerates as a UVC Video Streaming interface is invisible to `uvc_find_devices()`/`uvc_get_device_list()` regardless of anything this plugin does. Some GoPro models do expose a genuine UVC webcam mode; if a specific unit does, it falls under the same "expected-compatible/unvalidated" bucket as Insta360 above, not this row. | +| **HDMI capture sticks (generic UVC-HDMI-to-USB dongles)** | Raw/uncompressed UVC formats (YUY2, NV12, MJPEG); no on-device H.264/H.265 encode | **Out of scope (path bypasses element entirely)** | Per the parent manifest and this repo's own AGENTS.md ("HDMI capture paths bypass this element entirely"), HDMI capture is handled by a different source element (`v4l2src` or similar) elsewhere in the cerastream pipeline. Even if such a dongle enumerates over `libuvc`, `negotiate()` requires an H264/H265 format descriptor and will reject a raw-format-only device the same way it rejects Logitech's XU-only devices. | +| **PTZ webcams (UVC+V4L2/XU pan-tilt-zoom units)** | Standard UVC with a Camera Terminal / Processing Unit PTZ control surface | **Expected-compatible for streaming; PTZ properties apply where the device exposes the controls** | If a PTZ webcam also exposes an H264/H265 format descriptor, streaming follows the same path as any other UVC H.264/H.265 device. The `pan`/`tilt`/`zoom` properties and the `set-ptz` action signal are capability-gated (see AGENTS.md PTZ CONTROL SURFACE): a set on an axis the device doesn't report is silently ignored, so pointing this element at a PTZ webcam that lacks H.264/H.265 output will still let PTZ controls work over the opt-in control socket even though the media path won't negotiate. No dedicated PTZ webcam test hardware; treat streaming support as unvalidated the same way as Insta360, while the PTZ control surface itself is unit-tested against the mock (`test_ptz.c`, `test_socket.c`) independent of any specific camera model. | + +**Reading the status column:** "Supported" means real or mock-validated end-to-end. "Expected-compatible/unvalidated" means the mechanism matches what the element already handles, but nobody has run it against that hardware. "Unsupported by design" and "out of scope" mean the element's architecture (UVC frame-based format descriptors only, no XU probing, no HTTP-over-USB) cannot reach that device class without new code, not that a bug is blocking it. + +--- + +## 2. Field-Triage Steps + +Work top-down. Each step narrows the fault before you touch code. + +### Step 1: confirm the device is on the USB bus and enumerates as UVC + +```bash +lsusb +``` + +If the camera doesn't show up here, this is a USB/cabling/power problem, not a plugin problem. + +### Step 2: capture the negotiation-diagnostics descriptor inventory + +When `negotiate()` finds no H264/H265 format descriptor, it logs a full +inventory of every format and frame descriptor the device DID advertise +(fourcc, GUID, resolution, frame-interval range) immediately before posting +the bus error. This is the single most useful piece of field-triage data for +an unfamiliar camera: it tells you exactly what the device offered instead +of the codec you expected. + +```bash +GST_DEBUG=libuvch264src:3 gst-launch-1.0 libuvch264src index=0 ! fakesink 2>&1 | grep -A2 "format fourcc" +``` + +`libuvch264src:3` selects up through the FIXME level for this element's debug +category only (ERROR, WARNING, and FIXME lines), which is enough to see both +the inventory warnings and the resulting bus error without the volume of a +full `:5` DEBUG trace. Raise to `libuvch264src:5` if you need the INFO-level +caps-negotiation trace as well (`caps of src`, `caps of peer`, `caps +intersection`). + +Sample output shape (see `gst_libuvc_h264_src_log_format_inventory()`, +`gstlibuvch264src.c:414`): + +``` +WARNING ... device exposes no H264/H265 format; advertised formats follow: +WARNING ... format fourcc 'MJPG' guid ... +WARNING ... 1920x1080 frame interval [333333..333333] (100ns units) +WARNING ... format fourcc 'YUY2' guid ... +WARNING ... 1280x720 frame interval [333333..666666] (100ns units) +``` + +If every logged fourcc is `MJPG`/`YUY2`/`NV12`/etc. with no `H264`/`H265` +entry, the device genuinely doesn't expose a UVC-native H.264/H.265 format +descriptor. That matches the Logitech/HDMI-dongle/raw-format rows in §1; +it is not something a plugin-side fix can repair, because there's no format +descriptor to select. + +### Step 3: try the other `index` selector forms + +`index=0` (ordinal) picks whatever `libuvc` enumerates first, which is +unreliable on a multi-camera bus. Narrow the selection: + +```bash +# By USB vendor:product ID (hex) +gst-launch-1.0 libuvch264src index="1234:5678" ! fakesink + +# By USB serial number (exact string match) +gst-launch-1.0 libuvch264src index="serial:CAM-001" ! fakesink + +# By USB bus and device address (decimal) +gst-launch-1.0 libuvch264src index="bus:1:5" ! fakesink +``` + +A malformed selector fails `start()` loudly with `RESOURCE/SETTINGS` rather +than silently falling back to device 0 (see AGENTS.md PROPERTIES). `vid:pid` +and `serial:` selectors survive a replug; `bus:` and ordinal selectors may +resolve to a different physical device after one. + +### Step 4: if the device drops out mid-stream, enable `reconnect` + +A camera that streams fine at start but disappears after a few +seconds/minutes (loose USB connection, power-save on a hub, thermal +shutdown) is a disconnect, not a negotiation failure. Confirm by watching +for `RESOURCE/READ` on the bus with `reconnect` left at its default `false`, +then opt into auto-reconnect: + +```bash +gst-launch-1.0 libuvch264src reconnect=true index="serial:CAM-001" ! video/x-h264 ! fakesink +``` + +See AGENTS.md DISCONNECT / RECONNECT BEHAVIOR for the exact detection window +(~5 s of silence) and backoff schedule (1, 2, 4, 8, 16 s, five attempts). + +### 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. + +--- + +## 3. Fork-Backport Provenance Table + +The CeraLive `libuvc` fork (tag `ceralive-v0.0.7.3`, SHA +`6210f2f64965af532440be357e6971b9b618797f`) carries a tiered backlog of +robustness backports, audited item-by-item against the fork's pre-hardening +state (`.omo/evidence/task-1-uvc-camera-compat-stability.md`) and finalized +in the fork's `CHANGELOG.ceralive.md` (`.omo/evidence/task-9-uvc-camera-compat-stability.txt`). +Each backlog ID (A1-A14) maps to either a landed fork commit or a +skip-equivalent reason. + +| ID | Source | Verdict | Fork commit | What it fixes / why it's skipped | +|----|--------|---------|--------------|-----------------------------------| +| A1 | upstream PR #293 | pick | `3195bbc` (shared with A3) | Retries `libusb_set_interface_alt_setting` up to 3 times on failure instead of failing on the first transient error. Relevant to any device that flakes on interface claim, DJI included. | +| A2 | upstream PR #291 (adapted API) | adapt + pick | `001e8d3` | Runtime-configurable USB transfer-buffer count (`uvc_set_transfer_buffers()`, devh-level, not PR #291's global setter) and a fail-loud fix for a bug where zero submitted transfers silently reported success. Backs the plugin's `transfer-buffers` property (§4 below). | +| A3 | upstream PR #295 (identical to #275, #295 picked) | pick | `3195bbc` (shared with A1) | Frees `frame.metadata` in `uvc_stream_close()`, closing a per-stream-close leak. | +| A4 | saki4510t `328d14d` (adapted scope) | pick | `5df5401` | Repairs degenerate frame descriptors: zero `dwMaxVideoFrameBufferSize`, bad/zero `dwDefaultFrameInterval`. **This is the DJI fix.** DJI's H.264/H.265 streams are frame-based (`uvc_parse_vs_frame_frame`, not the uncompressed parser saki's original patch targeted), so the fork extends the repair to both parsers via a shared helper. Guards strictly on the zero/degenerate case; sane descriptors are untouched. | +| A5 | pupil-labs `c534e3d` + upstream PR #59 (bounded-wait half only) | adapt + pick | `ab49e21` | Replaces `uvc_stream_stop()`'s unbounded `pthread_cond_wait` with a bounded `pthread_cond_timedwait` (5 attempts, ~1 s each), returning `UVC_ERROR_TIMEOUT` instead of hanging forever on a device that never completes its transfer cancellation. | +| A6 | pupil-labs `9004351` | skip-equivalent | none (already in v0.0.7 base) | Composite-device control-interface routing (real `bInterfaceNumber` instead of hardcoded 0) was already present in the fork's upstream base; nothing to backport. | +| A7 | upstream PR #277 | pick (shared commit with A9) | `69c7da8` | Falls back to the frame descriptor's `dwMaxBitRate` when a device reports `dwMaxPayloadTransferSize == 0` from its GET_MAX probe, instead of leaving the payload size at zero. | +| A8 | upstream PR #212 (whole-frame suppression) | folded into A9 | `69c7da8` | The per-payload/per-packet error discards this PR wanted were already present; only the whole-frame `frame_had_errors` suppression was new, and that landed as piece 3 of the A9 superset patch rather than as a standalone commit. | +| A9 | upstream PR #184 + PR #212 + saki `9e95b8a` (superset) | pick | `69c7da8` (shared commit with A7) | Corrupt/oversized-frame superset: PTS/SCR bounds guards before reading payload-header fields (prevents an out-of-bounds read on a short/malformed header), a safer `_uvc_populate_frame()` realloc that preserves DJI's zero-`step` compressed-frame tolerance, and the `frame_had_errors` whole-frame suppression from A8. | +| A10 | upstream master `e001f04` | skip-equivalent (verify-only) | already in `eae7f49` (pre-dates this hardening wave; the CVE-2026-1991 fix commit's message says "+ backport e001f04") | Confirmed byte-equivalent by diff; not re-picked. | +| 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. | + +**Plugin-side commits that consume the fork's hardening:** + +| Plugin commit | What it did | +|----------------|-------------| +| `94a7c21` | Bumped `FORK_SHA` in `scripts/build-libuvc.sh` to `6210f2f...` (ceralive-v0.0.7.3) and fixed stale `v0.0.7.1`-era prose comments. | +| `c46daee` | Added the opt-in `transfer-buffers` property (consumes fork A2's `uvc_set_transfer_buffers()`). | +| `3d5003e` | Added the negotiation-failure descriptor-inventory diagnostics (§2 above) and the `QUIRK_DOUBLE_PROBE` quirk seam (A14). | +| `3cbab94` | Test-only fix scoping the fork-only transfer-buffers test cases behind the `TB_API_AVAILABLE` build-time guard, so the `-DLIBUVC_USE_FORK=OFF` (upstream) build stays green. | + +For the full 8-commit-plus-changelog fork history, see +`libuvc-fork-adr.md`'s v0.0.7.3 addendum and the fork's own +`CHANGELOG.ceralive.md`. + +--- + +## 4. Related properties for camera-specific tuning + +Two opt-in properties exist specifically to work around device quirks +uncovered by this hardening wave. Neither changes default behavior: + +- **`max-payload`** (uint, default `0`): USB payload transfer size hint. See + `bmaxpayload-analysis.md` for tuning guidance on bandwidth-constrained + links. +- **`transfer-buffers`** (uint, default `0`): USB transfer buffer count hint, + backed by fork item A2. See AGENTS.md PROPERTIES for the full contract + (sentinel default, `[2, 100]` clamp, requires the CeraLive fork). + +Both are no-ops at their default value and require no device-specific +configuration for the common case; they exist for the rare camera that +benefits from a nonstandard USB transfer shape. diff --git a/libuvch264src/docs/notes/libuvc-fork-adr.md b/libuvch264src/docs/notes/libuvc-fork-adr.md index 672a6ed..f91dcce 100644 --- a/libuvch264src/docs/notes/libuvc-fork-adr.md +++ b/libuvch264src/docs/notes/libuvc-fork-adr.md @@ -91,6 +91,45 @@ Tasks 17/22/23 may now pin the SHA above. --- +## v0.0.7.3 Addendum (Task 9/13, 2026-07-02) + +This section records the next release on top of `ceralive-v0.0.7.2` above. It +does not modify any row in the "Fork URL" or "Commit history" sections above; +those remain the historical record of how the fork reached `v0.0.7.2`. + +- **New release tag:** `ceralive-v0.0.7.3` (hardening release: 8 additional robustness backports) +- **Tag/HEAD commit SHA:** `6210f2f64965af532440be357e6971b9b618797f` +- **Hardening branch:** `hardening/v0.0.7.3` (true fast-forward merge into `main`, so the merged main SHA, branch tip, and tag target are identical) +- **Current pin:** `scripts/build-libuvc.sh:39` `FORK_SHA` in `gstlibuvch264src` + +``` +GIT_REPOSITORY https://github.com/CeraLive/libuvc.git +GIT_TAG 6210f2f64965af532440be357e6971b9b618797f # main, tag ceralive-v0.0.7.3 +``` + +New commits landed on `hardening/v0.0.7.3` since `ceralive-v0.0.7.2` (`eae7f49`): + +| SHA | Commit | Backlog ID(s) | +|-----|--------|-----| +| `3195bbc` | `fix(stream): retry alt-setting on failure; free metadata buf in stream_close (upstream #293, #295)` | A1, A3 | +| `001e8d3` | `feat(stream): runtime-configurable transfer buffer count; fail loudly on zero submitted transfers (#291)` | A2 | +| `5df5401` | `fix(device): repair degenerate frame descriptors (zero buffer size / bad default interval)` | A4 | +| `ab49e21` | `fix(stream): bounded wait in uvc_stream_stop; return TIMEOUT instead of hanging` | A5 | +| `69c7da8` | `fix(stream): zero GET_MAX payload fallback; corrupt/oversized payload guards (#277/#184/#212 + saki 9e95b8a)` | A7, A9 (A8 folded into A9) | +| `9874f4c` | `fix(device,stream): preserve VC-header dwClockFrequency (A12); A13 confirmed no-op` | A12 | +| `6210f2f` | `chore(release): finalize CHANGELOG for ceralive-v0.0.7.3` (doc-only) | — | + +Skip-equivalent items in this wave (A6, A10, A11, A13) and the plugin-only +item (A14, a `gstlibuvch264src` quirk seam rather than a fork patch) are +detailed in `libuvch264src/docs/notes/camera-compat.md` §3, which is the +authoritative provenance table for this hardening wave. Ancestry gates +(`68d07a00e11d1944e27b7295ee69673239c00b4b` and `eae7f49` both ancestors of +`6210f2f`) hold; `LICENSE.txt` remains byte-identical to the base. + +Full detail: `.omo/evidence/task-9-uvc-camera-compat-stability.txt`. + +--- + ## Patches as Commits Each patch hunk becomes one commit on the fork, in application order. Commit messages carry the rationale that the patch files currently lack. From 0b58e2ad02e26c10fec928e20c2d8c21c1f0721f Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Fri, 3 Jul 2026 08:14:12 -0500 Subject: [PATCH 06/11] build(libuvc): bump fork to ceralive-v0.0.7.4 (UAF hotfix in uvc_stream_close timeout path) --- scripts/build-libuvc.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build-libuvc.sh b/scripts/build-libuvc.sh index 93774db..b4be502 100755 --- a/scripts/build-libuvc.sh +++ b/scripts/build-libuvc.sh @@ -36,7 +36,7 @@ set -euo pipefail # --- Pinned coordinates — the ONLY place these SHAs/URLs live ---------------- FORK_URL="https://github.com/CeraLive/libuvc.git" -FORK_SHA="6210f2f64965af532440be357e6971b9b618797f" # main (hardened v0.0.7.3: eae7f49 CVE-2026-1991 base + A2 configurable transfer buffers + A4 degenerate-descriptor repair + A5 bounded stream_stop + A7/A9 zero-GET_MAX & corrupt-payload guards + A12 VC-header dwClockFrequency; tag ceralive-v0.0.7.3) +FORK_SHA="dfba86fecb7f1d7be565d91d251cc446877bf26e" # main (hardened v0.0.7.4: v0.0.7.3 base [eae7f49 CVE-2026-1991 + A2 configurable transfer buffers + A4 degenerate-descriptor repair + A5 bounded stream_stop + A7/A9 zero-GET_MAX & corrupt-payload guards + A12 VC-header dwClockFrequency] + UAF hotfix: quarantine strmh on A5 stop-timeout in uvc_stream_close; tag ceralive-v0.0.7.4) UPSTREAM_URL="https://github.com/libuvc/libuvc.git" UPSTREAM_SHA="68d07a00e11d1944e27b7295ee69673239c00b4b" # v0.0.7 base From 7352b4d2cf54c781e5daae39f37ab3dd2cb2b7d5 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Fri, 3 Jul 2026 09:03:30 -0500 Subject: [PATCH 07/11] build(libuvc): bump fork to ceralive-v0.0.7.5 (devh lifetime hotfix for late-callback UAF) Round 1 (v0.0.7.4, dfba86f) quarantined the stream handle but uvc_close() still freed strmh->devh, so a late LIBUSB_TRANSFER_COMPLETED callback dereferencing strmh->devh->is_isight in _uvc_process_payload was a use-after-free on the freed device handle. v0.0.7.5 (a1949ae) propagates the quarantine to the device handle: uvc_close() intentionally leaks devh when a stream was quarantined, so the late deref lands on live memory. Bounded-wait preserved; common path still frees both. Only FORK_SHA + its comment change. UPSTREAM_SHA and patches/ untouched. Gate re-run against the build-libuvc.sh clone of a1949ae: ASan 148/148, plain 93/93, CVE oob+null + usb_teardown + pts/frame(+tsan) all Passed; plugin .so and test_cve_2026_1991 resolve libuvc.so.0 -> pinned install (RUNPATH). --- scripts/build-libuvc.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build-libuvc.sh b/scripts/build-libuvc.sh index b4be502..5ceaf49 100755 --- a/scripts/build-libuvc.sh +++ b/scripts/build-libuvc.sh @@ -36,7 +36,7 @@ set -euo pipefail # --- Pinned coordinates — the ONLY place these SHAs/URLs live ---------------- FORK_URL="https://github.com/CeraLive/libuvc.git" -FORK_SHA="dfba86fecb7f1d7be565d91d251cc446877bf26e" # main (hardened v0.0.7.4: v0.0.7.3 base [eae7f49 CVE-2026-1991 + A2 configurable transfer buffers + A4 degenerate-descriptor repair + A5 bounded stream_stop + A7/A9 zero-GET_MAX & corrupt-payload guards + A12 VC-header dwClockFrequency] + UAF hotfix: quarantine strmh on A5 stop-timeout in uvc_stream_close; tag ceralive-v0.0.7.4) +FORK_SHA="a1949ae04f6dfa40e6a7e8c156feda2567462117" # main (hardened v0.0.7.5: v0.0.7.3 base [eae7f49 CVE-2026-1991 + A2 configurable transfer buffers + A4 degenerate-descriptor repair + A5 bounded stream_stop + A7/A9 zero-GET_MAX & corrupt-payload guards + A12 VC-header dwClockFrequency] + UAF hotfix v0.0.7.4: quarantine strmh on A5 stop-timeout in uvc_stream_close + UAF hotfix v0.0.7.5: quarantine devh in uvc_close so a late LIBUSB_TRANSFER_COMPLETED callback deref of strmh->devh->is_isight is lifetime-safe; tag ceralive-v0.0.7.5) UPSTREAM_URL="https://github.com/libuvc/libuvc.git" UPSTREAM_SHA="68d07a00e11d1944e27b7295ee69673239c00b4b" # v0.0.7 base From ee13eea432adbee39a73f131c76d27afa3aa1580 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Fri, 3 Jul 2026 12:51:59 -0500 Subject: [PATCH 08/11] build(libuvc): bump fork to ceralive-v0.0.7.6 (context lifetime hotfix for handler-thread/libusb_exit race) FORK_SHA a1949ae -> 3ede00e (tag ceralive-v0.0.7.6). Round 3 quarantines the uvc_context on the stop-timeout path: uvc_exit() now leaks the context (skips libusb_exit + free) and uvc_open_internal() skips a duplicate event-handler thread while a still-running _uvc_handle_events loops on ctx->usb_ctx after a quarantining uvc_close(). Fixes a UAF/race on the libusb context on every plugin stop() after a quarantine, and a double handler thread on reconnect. Re-gated against the build-libuvc.sh clone of 3ede00e (fresh dirs): ASan 148/148, plain 93/93; cve_2026_1991 oob+null, usb_teardown(+asan), pts_thread_safety(+tsan), frame_throughput(+tsan), reconnect_* all Passed; real plugin .so + test_cve_2026_1991 resolve libuvc.so.0 => the pinned install. UPSTREAM_SHA + patches/ untouched. --- scripts/build-libuvc.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build-libuvc.sh b/scripts/build-libuvc.sh index 5ceaf49..91d06f7 100755 --- a/scripts/build-libuvc.sh +++ b/scripts/build-libuvc.sh @@ -36,7 +36,7 @@ set -euo pipefail # --- Pinned coordinates — the ONLY place these SHAs/URLs live ---------------- FORK_URL="https://github.com/CeraLive/libuvc.git" -FORK_SHA="a1949ae04f6dfa40e6a7e8c156feda2567462117" # main (hardened v0.0.7.5: v0.0.7.3 base [eae7f49 CVE-2026-1991 + A2 configurable transfer buffers + A4 degenerate-descriptor repair + A5 bounded stream_stop + A7/A9 zero-GET_MAX & corrupt-payload guards + A12 VC-header dwClockFrequency] + UAF hotfix v0.0.7.4: quarantine strmh on A5 stop-timeout in uvc_stream_close + UAF hotfix v0.0.7.5: quarantine devh in uvc_close so a late LIBUSB_TRANSFER_COMPLETED callback deref of strmh->devh->is_isight is lifetime-safe; tag ceralive-v0.0.7.5) +FORK_SHA="3ede00e0a432beb175792f88b6b7e552bbdc2487" # main (hardened v0.0.7.6: v0.0.7.3 base [eae7f49 CVE-2026-1991 + A2 configurable transfer buffers + A4 degenerate-descriptor repair + A5 bounded stream_stop + A7/A9 zero-GET_MAX & corrupt-payload guards + A12 VC-header dwClockFrequency] + UAF hotfix v0.0.7.4: quarantine strmh on A5 stop-timeout in uvc_stream_close + UAF hotfix v0.0.7.5: quarantine devh in uvc_close so a late LIBUSB_TRANSFER_COMPLETED callback deref of strmh->devh->is_isight is lifetime-safe + context-lifetime hotfix v0.0.7.6: quarantine uvc_context so uvc_exit skips libusb_exit/free and uvc_open_internal skips a duplicate handler thread while a stop-timeout event thread still runs on ctx->usb_ctx; tag ceralive-v0.0.7.6) UPSTREAM_URL="https://github.com/libuvc/libuvc.git" UPSTREAM_SHA="68d07a00e11d1944e27b7295ee69673239c00b4b" # v0.0.7 base From a7c13dc1da7fdff211bcda323a058fd442011824 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Fri, 3 Jul 2026 13:22:07 -0500 Subject: [PATCH 09/11] build(libuvc): bump fork to ceralive-v0.0.7.7 (gate handler-thread kill/join on quarantine state) --- scripts/build-libuvc.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build-libuvc.sh b/scripts/build-libuvc.sh index 91d06f7..33aea71 100755 --- a/scripts/build-libuvc.sh +++ b/scripts/build-libuvc.sh @@ -36,7 +36,7 @@ set -euo pipefail # --- Pinned coordinates — the ONLY place these SHAs/URLs live ---------------- FORK_URL="https://github.com/CeraLive/libuvc.git" -FORK_SHA="3ede00e0a432beb175792f88b6b7e552bbdc2487" # main (hardened v0.0.7.6: v0.0.7.3 base [eae7f49 CVE-2026-1991 + A2 configurable transfer buffers + A4 degenerate-descriptor repair + A5 bounded stream_stop + A7/A9 zero-GET_MAX & corrupt-payload guards + A12 VC-header dwClockFrequency] + UAF hotfix v0.0.7.4: quarantine strmh on A5 stop-timeout in uvc_stream_close + UAF hotfix v0.0.7.5: quarantine devh in uvc_close so a late LIBUSB_TRANSFER_COMPLETED callback deref of strmh->devh->is_isight is lifetime-safe + context-lifetime hotfix v0.0.7.6: quarantine uvc_context so uvc_exit skips libusb_exit/free and uvc_open_internal skips a duplicate handler thread while a stop-timeout event thread still runs on ctx->usb_ctx; tag ceralive-v0.0.7.6) +FORK_SHA="2dcca597e1534456273323a268d0fd98016e4aa8" # main (hardened v0.0.7.7: v0.0.7.3 base [eae7f49 CVE-2026-1991 + A2 configurable transfer buffers + A4 degenerate-descriptor repair + A5 bounded stream_stop + A7/A9 zero-GET_MAX & corrupt-payload guards + A12 VC-header dwClockFrequency] + UAF hotfix v0.0.7.4: quarantine strmh on A5 stop-timeout in uvc_stream_close + UAF hotfix v0.0.7.5: quarantine devh in uvc_close so a late LIBUSB_TRANSFER_COMPLETED callback deref of strmh->devh->is_isight is lifetime-safe + context-lifetime hotfix v0.0.7.6: quarantine uvc_context so uvc_exit skips libusb_exit/free and uvc_open_internal skips a duplicate handler thread while a stop-timeout event thread still runs on ctx->usb_ctx + handler-thread-guard hotfix v0.0.7.7: gate uvc_close's last-device kill_handler_thread/pthread_join on !has_quarantined_device so a normal close of a device reopened on a quarantined ctx never kills the surviving event thread; tag ceralive-v0.0.7.7) UPSTREAM_URL="https://github.com/libuvc/libuvc.git" UPSTREAM_SHA="68d07a00e11d1944e27b7295ee69673239c00b4b" # v0.0.7 base From b2c4cbad0d7e79ec4285c1e6ee90284f498701bf Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Fri, 3 Jul 2026 14:01:52 -0500 Subject: [PATCH 10/11] build(libuvc): bump fork to ceralive-v0.0.7.8 (safe iteration in uvc_exit over open_devices) --- scripts/build-libuvc.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build-libuvc.sh b/scripts/build-libuvc.sh index 33aea71..ed872c1 100755 --- a/scripts/build-libuvc.sh +++ b/scripts/build-libuvc.sh @@ -36,7 +36,7 @@ set -euo pipefail # --- Pinned coordinates — the ONLY place these SHAs/URLs live ---------------- FORK_URL="https://github.com/CeraLive/libuvc.git" -FORK_SHA="2dcca597e1534456273323a268d0fd98016e4aa8" # main (hardened v0.0.7.7: v0.0.7.3 base [eae7f49 CVE-2026-1991 + A2 configurable transfer buffers + A4 degenerate-descriptor repair + A5 bounded stream_stop + A7/A9 zero-GET_MAX & corrupt-payload guards + A12 VC-header dwClockFrequency] + UAF hotfix v0.0.7.4: quarantine strmh on A5 stop-timeout in uvc_stream_close + UAF hotfix v0.0.7.5: quarantine devh in uvc_close so a late LIBUSB_TRANSFER_COMPLETED callback deref of strmh->devh->is_isight is lifetime-safe + context-lifetime hotfix v0.0.7.6: quarantine uvc_context so uvc_exit skips libusb_exit/free and uvc_open_internal skips a duplicate handler thread while a stop-timeout event thread still runs on ctx->usb_ctx + handler-thread-guard hotfix v0.0.7.7: gate uvc_close's last-device kill_handler_thread/pthread_join on !has_quarantined_device so a normal close of a device reopened on a quarantined ctx never kills the surviving event thread; tag ceralive-v0.0.7.7) +FORK_SHA="71588dbc23c5204e07c575c3b2ae6ac7ee9bf90d" # main (hardened v0.0.7.8: v0.0.7.3 base [eae7f49 CVE-2026-1991 + A2 configurable transfer buffers + A4 degenerate-descriptor repair + A5 bounded stream_stop + A7/A9 zero-GET_MAX & corrupt-payload guards + A12 VC-header dwClockFrequency] + UAF hotfix v0.0.7.4: quarantine strmh on A5 stop-timeout in uvc_stream_close + UAF hotfix v0.0.7.5: quarantine devh in uvc_close so a late LIBUSB_TRANSFER_COMPLETED callback deref of strmh->devh->is_isight is lifetime-safe + context-lifetime hotfix v0.0.7.6: quarantine uvc_context so uvc_exit skips libusb_exit/free and uvc_open_internal skips a duplicate handler thread while a stop-timeout event thread still runs on ctx->usb_ctx + handler-thread-guard hotfix v0.0.7.7: gate uvc_close's last-device kill_handler_thread/pthread_join on !has_quarantined_device so a normal close of a device reopened on a quarantined ctx never kills the surviving event thread + UAF hotfix v0.0.7.8: uvc_exit iterates ctx->open_devices with DL_FOREACH_SAFE so uvc_close freeing the current device does not leave the loop increment reading a freed node's next pointer (pre-existing general defect, reproducible with 2+ devices open with or without quarantine); tag ceralive-v0.0.7.8) UPSTREAM_URL="https://github.com/libuvc/libuvc.git" UPSTREAM_SHA="68d07a00e11d1944e27b7295ee69673239c00b4b" # v0.0.7 base From 1ec26f379bb6cf8fc83d9013601f0be6e994e52c Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Fri, 3 Jul 2026 19:38:50 -0500 Subject: [PATCH 11/11] docs(libuvc): sync fork ADR pin to ceralive-v0.0.7.8 The 5 emergency hotfix rounds (v0.0.7.4->v0.0.7.8) bumped FORK_SHA in scripts/build-libuvc.sh but the fork ADR's primary pin block was never advanced past v0.0.7.2, so scripts/check-libuvc-fork.sh (the CI fork-guard) reported PIN DRIFT: build 71588db != ADR eae7f49. Add an authoritative CURRENT PIN callout at the top of the Fork URL section (71588db / ceralive-v0.0.7.8) so the guard's first-GIT_TAG / first-Tag-HEAD-SHA reads agree with the build pin, and add a v0.0.7.8 addendum documenting the UAF/lifetime hotfix chain. All prior v0.0.7.2 and v0.0.7.3 records are preserved verbatim as historical provenance. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libuvch264src/docs/notes/libuvc-fork-adr.md | 60 +++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/libuvch264src/docs/notes/libuvc-fork-adr.md b/libuvch264src/docs/notes/libuvc-fork-adr.md index f91dcce..5c98789 100644 --- a/libuvch264src/docs/notes/libuvc-fork-adr.md +++ b/libuvch264src/docs/notes/libuvc-fork-adr.md @@ -53,6 +53,29 @@ Do not use upstream `main` or re-tag from a later upstream commit. The fork base ## Fork URL (filled by Task 11) +> **CURRENT PIN (authoritative):** the build pins the fork at +> `ceralive-v0.0.7.8`. The v0.0.7.2 block immediately below is retained as the +> historical record of how the fork first reached a hardening release; the +> v0.0.7.3 and v0.0.7.4→v0.0.7.8 addenda further down record the subsequent +> hardening wave and the five emergency UAF/lifetime hotfix rounds. This +> callout is the pin the CI guard (`scripts/check-libuvc-fork.sh`) reads. + +- **Current release tag:** `ceralive-v0.0.7.8` (UAF/lifetime hotfix rounds on top of the v0.0.7.3 hardening wave) +- **Tag/HEAD commit SHA:** `71588dbc23c5204e07c575c3b2ae6ac7ee9bf90d` +- **Base SHA (provenance only):** `68d07a00e11d1944e27b7295ee69673239c00b4b` — confirmed ancestor of HEAD. + +**Pin downstream builds by SHA** (the value `FORK_SHA` carries at `scripts/build-libuvc.sh:39`): + +``` +GIT_REPOSITORY https://github.com/CeraLive/libuvc.git +GIT_TAG 71588dbc23c5204e07c575c3b2ae6ac7ee9bf90d # main, tag ceralive-v0.0.7.8 +``` + +See the "v0.0.7.8 Addendum" section below for the full v0.0.7.4→v0.0.7.8 +hotfix chain. The v0.0.7.2 record follows. + +--- + - **Fork URL:** `https://github.com/CeraLive/libuvc` - **Clone (HTTPS):** `https://github.com/CeraLive/libuvc.git` - **Visibility:** PUBLIC (no auth required to clone) @@ -130,6 +153,43 @@ Full detail: `.omo/evidence/task-9-uvc-camera-compat-stability.txt`. --- +## v0.0.7.8 Addendum (post-release UAF/lifetime hotfix rounds) + +This section records five emergency hotfix rounds landed on `main` on top of +`ceralive-v0.0.7.3` (`6210f2f`). They were surfaced by an adversarial +code-quality review of the new bounded `uvc_stream_stop()`/A5 teardown path, +which caught a use-after-free chain reachable when a stop-timeout leaves an +event/handler thread alive while teardown proceeds. Each round is a single, +narrowly-scoped commit; none change default behavior. This addendum does not +modify the v0.0.7.2 or v0.0.7.3 records above. + +- **Current release tag:** `ceralive-v0.0.7.8` +- **Tag/HEAD commit SHA:** `71588dbc23c5204e07c575c3b2ae6ac7ee9bf90d` +- **Current pin:** `scripts/build-libuvc.sh:39` `FORK_SHA` in `gstlibuvch264src` + +``` +GIT_REPOSITORY https://github.com/CeraLive/libuvc.git +GIT_TAG 71588dbc23c5204e07c575c3b2ae6ac7ee9bf90d # main, tag ceralive-v0.0.7.8 +``` + +Hotfix chain (v0.0.7.3 → v0.0.7.8), each on top of the previous: + +| Tag | Fix | +|-----|-----| +| `ceralive-v0.0.7.4` | UAF hotfix: quarantine `strmh` on the A5 stop-timeout path in `uvc_stream_close` so a late transfer callback cannot deref freed stream state. | +| `ceralive-v0.0.7.5` | `devh` lifetime hotfix: quarantine `devh` in `uvc_close` so a late `LIBUSB_TRANSFER_COMPLETED` callback deref of `strmh->devh->is_isight` is lifetime-safe. | +| `ceralive-v0.0.7.6` | Context lifetime hotfix: quarantine `uvc_context` so `uvc_exit` skips `libusb_exit`/free and `uvc_open_internal` skips a duplicate handler thread while a stop-timeout event thread still runs on `ctx->usb_ctx`. | +| `ceralive-v0.0.7.7` | Handler-thread guard: gate `uvc_close`'s last-device `kill_handler_thread`/`pthread_join` on `!has_quarantined_device` so a normal close of a device reopened on a quarantined ctx never kills the surviving event thread. | +| `ceralive-v0.0.7.8` | Safe iteration: `uvc_exit` iterates `ctx->open_devices` with `DL_FOREACH_SAFE` so `uvc_close` freeing the current device does not leave the loop increment reading a freed node's `next` pointer (pre-existing general defect, reproducible with 2+ devices open, with or without quarantine). | + +Ancestry gates (`68d07a00e11d1944e27b7295ee69673239c00b4b`, `eae7f49`, and +`6210f2f` all ancestors of `71588dbc23c5204e07c575c3b2ae6ac7ee9bf90d`) hold; +`LICENSE.txt` remains byte-identical to the base. Full per-round detail lives in +the fork's own `CHANGELOG.ceralive.md` and in +`libuvch264src/docs/notes/camera-compat.md`. + +--- + ## Patches as Commits Each patch hunk becomes one commit on the fork, in application order. Commit messages carry the rationale that the patch files currently lack.