diff --git a/CLAUDE.md b/CLAUDE.md index 05d698b..3aabf37 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,17 @@ DIFFERENT clocks on macOS (see the struct comment) — `audio_latency_us` is untrustworthy there until that is reconciled. Kill a mid-meeting engine only after checking `{"cmd":"status"}` — it IS the meeting session. +The macOS meeting-status callback must emit `awaiting_admission` on every SDK +status change. `ZoomSDKMeetingStatus_WaitingForHost` and +`ZoomSDKMeetingStatus_InWaitingRoom` map to `active:true`; every other status +maps to `active:false`. The dock holds its existing 120-second join watchdog +while that flag is true and starts a fresh full window after Zoom advances. +Keep the symbolic mapping in `engine/src/macos-admission-state.h`, where the +SDK-backed `CoreVideoMacosAdmissionState` test compiles it against the installed +framework. The real callback calls that header's dispatch seam inside the +fresh-callback/epoch gate; the regression records the seam's outgoing event, +asserts it precedes normal status handling, and drives the watchdog with it. + **Talkback does not exist on macOS**, and the dock says so rather than failing quietly. `engine-talkback.cpp` is in `ENGINE_SOURCES`, which only the Windows engine target uses; the macOS engine is `main-macos.mm` and never compiles it. @@ -249,6 +260,16 @@ Every one of these is documented at length where it lives; the list is the map. 12-90s for a whole show). The manual speaker Take/Release buttons still call it deliberately — that's a rare, operator-initiated action, not the automatic path. +- **Speaker-director time is monotonic under its mutex** + (`src/speaker-director.cpp`): callers sample `os_gettime_ns()` before taking + the director lock, so a contending callback can arrive with an older sample. + Every time-mutating entry point clamps that sample to the newest accepted + director time before changing candidate, hold, vacancy, or manual-take + clocks. Never subtract an unguarded caller timestamp from those clocks; + unsigned wrap can otherwise satisfy both sensitivity and hold immediately. + Actual promotions retain a bounded, ID-only attribution history and the dock + logs it outside the director mutex; keep names and callbacks out of that + locked path. - **ISO encoder demotion chain must actually chain** (`src/zoom-iso-recorder.cpp`'s `record_video_frame`): never gate a fresh demotion attempt on "has this uuid ever been demoted before" — @@ -1391,6 +1412,53 @@ Every one of these is documented at length where it lives; the list is the map. (`unsubscribe_audio()`, `forget_subscription_for_new_engine()`, the roster callback) moved to `name_mtx` alongside the reader. +## macOS raw-media lifecycle (2026-09-06 soak remediation candidate) + +`src/raw-media-lifecycle.h` owns Start intent separately from permission and room +readiness; `engine/src/main-macos.mm` applies its effects on the SDK main queue. +The two grant callbacks cannot issue duplicate starts, and callbacks retained +from a retired record delegate cannot resurrect Stop/leave/replacement intent. +Only `ZoomSDKError_NoPermission` triggers the once-per-meeting host request. +SDK request status 2 is **Timeout**, not Denied; SDK error 21 is **NoLicense**, +not evidence of a temporary permission failure. No new timed session retry loop +is justified by those codes; terminal start failures stay joined and permit an +explicit Start retry or a fresh room readiness transition. + +Breakout entry/exit invalidates SDK video/share renderers and the one global +audio subscription while preserving desired source bindings and SHM generations. +Delegates detach before `destroyRender:`. A fresh InMeeting checks raw readiness +and restores current eligible placeholders once; missing room-scoped participant +ids stay pending for plugin roster rebinding. Failed video subscriptions retain +placeholders, so recovery or an ordinary retry cannot lose bindings; removals and +rebinding update the same current table. Target `requested_resolution` survives +recovery. Shared macOS participant renderers select the maximum current target +request, including deferred/recovered targets, and raise a warm renderer in +place. A rejected SDK upgrade retains the working accepted request, targets, +and SHM generations; attachment/removal never downgrades a warm renderer. +A new renderer walks down only through SDK-accepted resolution candidates. +The diagnostic negotiated field describes an SDK-accepted request, not received +pixels: only success codes update it. Actual frame dimensions determine health. +Automatic low-quality retries stop after three attempts (20-second stable-feed +gate, then existing 120/240-second spacing). Manual retries remain available; +meeting the requested frame size or clearing subscription state resets the +budget. Exhaustion has no countdown and never marks a fresh 360p feed stale. +Controlled HD-sender delivery/entitlement and live soak remain unvalidated. +`raw_media_ready` still means raw recording started, **not** that every source is +healthy. Additive `raw_media_state` invalidates client session readiness during +permission loss/recovery/failure; per-source subscribe errors and first-frame +logs remain independent evidence. All `raw_media_start_failed` reports cross the media-only callback boundary in +`zoom-engine-error-dispatch.h`, including terminal failures without a pending +privilege flag. They cannot invoke meeting/reconnect effects. Terminal media +text is kept in `m_raw_media_error` and exposed by `last_error()` as a fallback +for the control API; internal `m_last_error` stays reserved for meeting failure, +so a later `left` cannot mistake a media failure for a failed meeting. Meeting +status callbacks capture `MeetingCallbackEpoch` at receipt and validate it on +the main queue. Leave rejects queued older work AND subsequent nonterminal +statuses while allowing its terminal SDK acknowledgement; replacement uses a +fresh epoch and delegate identity. Offline CoreVideoRawMediaLifecycle sequences +and a real SDK build cover these code defects; repeated live delayed-grant and +breakout resource-lifetime/recovery-time acceptance remains outstanding. + ## Live testing against a real meeting The control API (TCP line-JSON, `127.0.0.1:19870`, no HTTP) drives a full @@ -1495,3 +1563,56 @@ The site builder keeps `MAC_VERSION` separate from the stable Windows version. The macOS download is the locally signed/notarized `.pkg` on v0.1.45-beta.1; its GitHub filename includes `v`. Do not restore the withdrawn macOS ZIP link. Home, download, and plugin docs point to `/download/#macos`. + +## Media failure presentation (2026-09-06 soak) + +`MediaFailureState` tracks current source media failures, bounded +by live source assignments. Eight tiles × three failed attempts retain all +24 raw error diagnostics but emit one nonmodal episode notice. Dock polling +updates the affected count and escalates after three attempts or ten seconds +to Retry Media; it must show media errors while the meeting remains joined. +Connection errors still own `m_last_error` and the fatal callback path; +media failures must never vote in meeting leave/reconnect classification. + +Only a successful shared-memory read (both standalone and supersource paths) +may acknowledge video recovery. Capture an assignment ticket before reading, +then validate ticket and participant; do not clear source failures at session +`raw_media_ready` or at a retry subscribe. Removal/reassignment retires only +that source's membership. Never dispatch UI/source callbacks from the frame +acknowledgement while a source lock is held. Permission notices are deduped; +Mac grants start automatically. Denial and timeout are distinct +`raw_media_state` debug events, not necessarily `raw_media_start_failed`. + +`raw_data_controller_unavailable` follows the same per-participant video +recovery path. `shm_create_failed`, `subscribe_rejected`, and +`shm_name_collision` stay as persistent per-source media diagnostics (one +nonmodal notice per episode), clearable by removal, explicit stop, or meeting +reset. Their wire reports do not reliably identify a media lane, so neither +a successful video read nor `raw_media_ready` may clear them. + +Retry Media is an explicit operator path shared by dock and control +`start_engine`: `ZoomOutputManager::retry_media()` also visits Tiles sources. +Tiles own a separate retry budget; `start_media()` alone is a no-op when raw +media is active and cannot revive an exhausted tile. Only the manual trigger +reopens that budget; ordinary roster/speaker sweeps retain their interval and +attempt caps. Manual retry preserves assignment and failure state, releases the +old SHM mapping, then subscribes. Tile enumeration runs on the OBS UI task queue +with strong source refs and the existing callback gate, skipping collection load. + +Manual tile retry also covers retained displayed pixels when a current media +failure exists or the last successful SHM read is at least ten seconds old. +Fresh healthy tiles are skipped; mapping release preserves the decoded image. +This freshness override is manual-only, never a new automatic speaker-tick loop. + +Room-level `joined` (including breakout return and duplicate readiness) clears +connection errors only. It retains media assignments, unresolved video and +persistent source failures, and session raw-media errors; source recovery still +requires acknowledged delivery/removal. Explicit `join`, `left`, and engine +`stop` reset media session state. The Qt-backed CoreVideoEngineClientMediaSession +regression drives the production JSON handler with offline host boundaries. + +Successful renderer creation publishes each retained target's own quality request. +A trailing fallback diagnostic must use the selected target's own request too, +never the shared maximum: a 360 tile sharing a 1080 source at accepted 720 is +healthy while the 1080 source is downgraded. The complete creation publication +sequence lives in `shared_video_publish_created_resolution` and its regression. diff --git a/CMakeLists.txt b/CMakeLists.txt index 6231d37..2d908d2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -804,6 +804,10 @@ if(BUILD_TESTING) # Record-privilege handshake notice copy/classification (2026-09-05 live # defect fix). Header-only logic; no Qt/OBS/SDK dependencies. + add_executable(CoreVideoMediaFailureStateTest tests/media-failure-state-test.cpp) + target_include_directories(CoreVideoMediaFailureStateTest PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src") + add_test(NAME CoreVideoMediaFailureState COMMAND CoreVideoMediaFailureStateTest) + add_executable(CoreVideoPrivilegeNoticeTest tests/zoom-privilege-notice-test.cpp ) @@ -1071,6 +1075,24 @@ if(BUILD_TESTING) add_test(NAME CoreVideoJoinWatchdog COMMAND CoreVideoJoinWatchdogTest) + # Compile the macOS callback translation against the installed SDK's real + # symbolic meeting-status enum, then carry its production wire event into + # the portable watchdog policy. + if(APPLE AND BUILD_ZOOM_ENGINE) + add_executable(CoreVideoMacosAdmissionStateTest + tests/macos-admission-state-test.mm + ) + target_include_directories(CoreVideoMacosAdmissionStateTest PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/src" + "${CMAKE_CURRENT_SOURCE_DIR}/engine/src" + ) + target_compile_options(CoreVideoMacosAdmissionStateTest PRIVATE + "-F${ZOOM_SDK_ACTIVE_DIR}" + ) + add_test(NAME CoreVideoMacosAdmissionState + COMMAND CoreVideoMacosAdmissionStateTest) + endif() + # The talkback probe's test tone. Phase must carry across buffers: a restart # every buffer is an audible click that would be misread as a transport # fault. See src/talkback-tone.h. @@ -1426,6 +1448,14 @@ if(BUILD_TESTING) # Retry pacing for the tiles silent-slot sweep: the sweep interval and the # per-slot exponential backoff / attempt cap that stop an unbounded # renderer create-destroy loop in the engine. + add_executable(CoreVideoVideoQualityPolicyTest tests/video-quality-policy-test.cpp) + target_include_directories(CoreVideoVideoQualityPolicyTest PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src") + add_test(NAME CoreVideoVideoQualityPolicy COMMAND CoreVideoVideoQualityPolicyTest) + + add_executable(CoreVideoRawMediaLifecycleTest tests/raw-media-lifecycle-test.cpp) + target_include_directories(CoreVideoRawMediaLifecycleTest PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src") + add_test(NAME CoreVideoRawMediaLifecycle COMMAND CoreVideoRawMediaLifecycleTest) + add_executable(CoreVideoTileRetryTest tests/tile-retry-test.cpp ) @@ -1451,6 +1481,21 @@ if(BUILD_TESTING) add_test(NAME CoreVideoOscWire COMMAND CoreVideoOscWireTest) + # Offline production-client boundary test uses POSIX IPC and host stubs. + # Windows client integration remains covered by the full plugin build. + if(TARGET OBS::libobs AND NOT WIN32) + add_executable(CoreVideoEngineClientMediaSessionTest + tests/engine-client-media-session-test.cpp + src/zoom-engine-client.cpp + src/speaker-director.cpp) + target_include_directories(CoreVideoEngineClientMediaSessionTest PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/src" + $) + target_link_libraries(CoreVideoEngineClientMediaSessionTest PRIVATE Qt6::Core) + add_test(NAME CoreVideoEngineClientMediaSession + COMMAND CoreVideoEngineClientMediaSessionTest) + endif() + add_executable(CoreVideoControlParseTest tests/zoom-control-parse-test.cpp ) diff --git a/docs/superpowers/plans/2026-09-06-soak-remediation.md b/docs/superpowers/plans/2026-09-06-soak-remediation.md new file mode 100644 index 0000000..5940073 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-soak-remediation.md @@ -0,0 +1,151 @@ +# CoreVideo Soak Remediation Implementation Plan + +> **For agentic workers:** Use superpowers:executing-plans to implement this plan task by task. Steps use checkbox syntax for tracking. The user approved implementation on September 6, 2026; live validation and release gates still apply. + +**Goal:** Make late recording permission, waiting-room admission, and breakout transitions recover predictably without an error flood, while preserving the sustained video stability observed in the September 6 soak. + +**Architecture:** Repair macOS SDK lifecycle handling at the engine boundary and keep pending/recovering states separate from persistent failures in the OBS client. Preserve existing director, shared-memory, audio, and watchdog policies; use focused reproductions to determine whether switching and resolution need behavioral changes. + +**Tech Stack:** C++17, Objective-C++, Zoom Meeting SDK, OBS/Qt, CMake/CTest; plain executable regression tests. + +**Spec:** Sanitized evidence and requirements are included below; full local report is retained outside the repository. + +## Global constraints + +- Mac validation baseline: CoreVideo v0.1.45-beta.1 with OBS 32.2.1; preserve Windows compatibility. +- Signing certificates stay in the login Keychain. Use local notarytool profile `CoreVideo Plugin`; never export credentials to GitHub. +- Do not change the currently installed package while implementing. Test a separately versioned candidate after OBS is closed. +- Preserve renderer delegate detachment before destruction, nonblocking SDK frame callbacks, shared-memory generation ownership, and single-publisher audio during director handovers. +- Never call global output resubscription for ordinary automatic speaker cuts. +- Distinguish SDK code 21 from a proven licensing issue. Distinguish shared-memory generation releases from dropped video frames. +- Keep meeting data local. Tests use synthetic participant IDs and contain no credentials. +- Use separate reviewable changes for lifecycle recovery, watchdog mapping, diagnostics, director investigation, and resolution investigation. Do not make all fixes contingent on solving HD negotiation. + +## Evidence and baseline gate + +Observed: permission granted and raw recording started successfully at 09:55:52.577; breakout entry at 09:56:05.599; 24 subscription errors across eight tiles from 09:56:07.409 to 09:56:31.516. Manual stop/start at 09:57 restored delivery. Those are one error burst, counted once. The user experienced it when recording rights became available; timing alone cannot isolate permission grant from breakout entry. + +Following recovery, approximately 108 minutes passed without another observed subscription failure. Final logs counted 190 handovers (maximum 75 ms) and 26 OBS rendering-lag frames out of 192,125 attempted drawn frames. This is the stability baseline, not proof of audio or HD quality. + +Read-only code review used `/Users/jwallace/Developer/CoreVideo` at `65d288b84a998188ff4ccc78a53bf60a9bb7cb4a`. The signed PR 251 checkout is at `e96b15ba0c0389c7a6255a6f767294d74fcefb5c`. The implementation must first fetch current main in an isolated worktree and compare these code paths; the local Developer checkout is not asserted to be current main. Do not overwrite that checkout. + +- [ ] Record current main SHA, installed candidate version, SDK version, and relevant diff from the signed beta. +- [ ] Read repository instructions in the implementation worktree and establish a passing CTest baseline before editing. +- [ ] Copy this plan and its sanitized evidence summary into `docs/superpowers/plans/2026-09-06-soak-remediation.md` in the implementation branch. Keep raw logs outside the repository. + +## Task 1 — Highest priority: permission grant and breakout recovery + +**Files to inspect/modify:** `engine/src/main-macos.mm` (`CVRecordDelegate`, `handle_start_media`, `handle_stop_media`, `resubscribe_raw_media`, `video_subscribe`, `CVMeetingDelegate`); `src/zoom-engine-client.cpp` and `.h` for readiness events; `src/engine-ipc.h` for protocol documentation if fields are added. Add `src/raw-media-lifecycle.h`, `tests/raw-media-lifecycle-test.cpp`, and its CMake test only for a small SDK-independent decision model. Do not move SDK ownership into that helper. + +**Interface contract:** Meeting callbacks and explicit Start/Stop intent feed one lifecycle decision owner on the SDK main queue. Existing `raw_media_ready` consumers remain compatible. The engine owns whether a start is requested, permission is pending/denied/granted, a room transition is underway, and raw media is actually started. A successful start alone must not masquerade as proof that all video sources are healthy. + +- [ ] Reproduce delayed grant after denial without entering a breakout. Capture permission callbacks, start calls, subscription attempts, error callbacks, and first-frame success. +- [ ] Repeat with permission granted immediately before breakout entry; also transfer with permission already established. Compare against the soak timeline before choosing the recovery transition. +- [ ] Add a deterministic event-sequence regression for the reproduced failure. Assert effects (number of start/request/subscribe actions), not private field layout. Include these cases: + +| Synthetic sequence | Required result | +| --- | --- | +| Start → permission pending → duplicate Start | One host request; pending state; no subscription flood | +| Pending → denied → granted | Start once after grant; recover queued eligible outputs | +| Both grant callbacks arrive | No duplicate raw start or duplicate subscription set | +| Start → pending → explicit Stop → late grant | Media stays stopped | +| Active → breakout transition → in meeting | Revalidate readiness; restore current eligible subscriptions once ready | +| Recovering → source removed → readiness restored | Removed source is not recreated | +| Active → permission revoked → granted | Explicit pending/recovering state; recovery respects user intent | +| SDK start permanently fails | Finite retries; actionable terminal state; meeting remains joined | + +- [ ] Run the new test against the failing behavior before implementing the state transition. Use `CoreVideoRawMediaLifecycle` as the new CTest name. +- [ ] Preserve desired subscription assignments during transient recovery. Invalidate only SDK resources that the reproduction shows are invalid; detach delegates before renderer destruction and execute SDK calls on the main queue. +- [ ] Make start/grant handling idempotent, and cancel queued work on explicit stop, leave, or replacement meeting. Do not reuse a destructive manual stop path for transient recovery if it discards desired subscriptions. +- [ ] Use a finite recovery schedule: proposed delays 0, 1, 2, 4, and 8 seconds after a readiness transition. Stop immediately on success, explicit stop, leave, or confirmed denial. Confirm this budget in reproduction; do not run simultaneous tile and session retry loops. +- [ ] Run the focused regression and existing `CoreVideoTileRetry`, `CoreVideoAudioSubscriptionState`, and shared-memory tests. Repeat the real SDK scenarios because a pure state test cannot validate SDK resource lifetime. +- [ ] Commit lifecycle recovery with its tests and update `CLAUDE.md` with the demonstrated invariant. + +**Acceptance:** Eligible video returns without manual Start/Stop in five repeated delayed-grant trials and five breakout round trips. Duplicate grants cause no duplicate starts. Cancellation prevents late callbacks from resurrecting media. An unrecoverable failure stops retrying and remains visible. Record measured recovery time; target first frames within 15 seconds of actual SDK readiness, not 15 seconds after host permission alone. + +## Task 2 — Highest priority: prevent the error flood and clear recovered errors + +**Files:** `src/zoom-engine-client.cpp/.h` (`handle_event`, error and notice callbacks, `last_error`); `src/zoom-privilege-notice.h`; `src/zoom-dock.cpp` (notice/error presentation); `src/zoom-source.cpp`, `src/zoom-output-manager.cpp/.h` (source health/removal); `tests/zoom-privilege-notice-test.cpp`; new `src/media-failure-state.h` and `tests/media-failure-state-test.cpp` if needed for independent aggregation tests; `CMakeLists.txt`. + +**Code evidence:** Pending recording privilege already uses a notice path, but `video_subscribe_failed` for each known participant sets the global error and invokes error callbacks. The notice text also tells the operator to click Start Engine again although grant callbacks attempt automatic startup. Review current main before changing either behavior. + +**Interface contract:** Consume lifecycle readiness and source-specific failure/recovery events. Produce one current operator status for a shared recovery episode plus source-specific diagnostics. Preserve existing status fields; additive structured fields must be backward compatible with Windows and control clients. Do not infer source recovery merely from `raw_media_ready`. + +- [ ] Reproduce/count actual UI error callbacks for eight eligible failed tiles repeated three times. Preserve all 24 diagnostic events but require at most one user-facing notice for the shared failure episode. +- [ ] Add regression sequences: pending request repeated; denied then granted; 24 errors with one root cause; one source recovers while another remains failed; removed source; unknown/absent participant; a new failure after full recovery; fatal engine disconnect during media recovery. +- [ ] Implement aggregation by meeting/recovery episode and cause, retaining source membership. Repeated failures update details instead of opening another modal. Persistent failure transitions from recovering to one actionable error; a new independent fatal error is never swallowed. +- [ ] Clear a source's current failure after confirmed fresh delivery or removal. Clear the aggregate only when no relevant failed sources remain. Keep historical events in diagnostics. +- [ ] Replace manual-restart permission guidance after automatic recovery is proven: pending copy says `Waiting for the host to allow recording. Media will start automatically when permission is granted.` A denial supplies a host-action instruction; persistent SDK failure supplies Retry and diagnostic context without a dialog storm. +- [ ] Run `CoreVideoPrivilegeNotice`, new `CoreVideoMediaFailureState`, and `CoreVideoOutputHealth`; manually verify dock and status API agree during wait, recovery, partial failure, and success. +- [ ] Commit diagnostics separately from lifecycle recovery. + +**Acceptance:** The recorded eight-tile/three-batch scenario creates no sequence of per-tile modals. A shared pending/recovering state stays visible. Real terminal failures remain actionable. Successful or removed tiles do not leave a stale global error. This task does not pass by merely hiding errors. + +## Task 3 — High priority: macOS waiting-room watchdog mapping + +**Files:** `engine/src/main-macos.mm` (`CVMeetingDelegate::onMeetingStatusChange`); compare `engine/src/main.cpp` admission-event handling; `src/zoom-engine-client.cpp` (`awaiting_admission`); `src/zoom-dock.cpp` watchdog timer; `src/join-watchdog.h`; `tests/join-watchdog-test.cpp`. + +**Code evidence:** The existing watchdog already holds its window while awaiting admission. The reviewed macOS callback handles in-meeting, ended/disconnecting, and failed states, but emits no `awaiting_admission` event. Test this missing integration rather than weakening the already-tested 120-second policy. + +**Interface contract:** Reuse `{"cmd":"awaiting_admission","active":true}` for SDK waiting-room/waiting-for-host states. Emit false when leaving those states; retain joined/left/failure clearing in the client. + +- [ ] Verify installed SDK enum symbols and trace the Mac callback through the existing client/dock path. +- [ ] Add a regression covering callback-state translation plus watchdog evaluation: waiting at 180 seconds holds; admission then a genuinely stalled phase receives a fresh 120-second window; leave/rejoin does not retain admission state. +- [ ] Implement admission-state emission using SDK symbols, preserving the Windows wire contract. +- [ ] Run `CoreVideoJoinWatchdog` and `CoreVideoJoinDecision` plus the callback-mapping regression. +- [ ] Join using the actual dock button and wait at least five minutes for host admission. Repeat waiting for host. API-only join is insufficient: it does not arm the dock watchdog. +- [ ] Commit the mapping correction and its tests. + +**Acceptance:** No automatic leave during either legitimate host-controlled wait; a real stalled join still times out once. Dock copy accurately describes the wait. + +## Task 4 — Medium priority: explain and, if defective, correct fast speaker cuts + +**Files:** `src/speaker-director.cpp/.h`, `src/zoom-dock.cpp`, `src/zoom-source.cpp`, `src/director-handover.h`, `tests/speaker-director-test.cpp`, `tests/speaker-settings-merge-test.cpp`, `tests/director-handover-test.cpp`. + +**Interface contract:** Preserve automatic hold/sensitivity behavior, immediate intentional manual takes, and existing eligibility exceptions. Add bounded diagnostic attribution at each actual promotion: reason, effective hold/sensitivity, elapsed hold, candidate age, and synthetic/session-local IDs. No participant names or per-frame logging. + +- [ ] Reproduce alternating/overlapping speakers with 1,200 ms hold and 250 ms sensitivity; separately test manual takes, video eligibility changes, and settings updates. +- [ ] Attribute each cut to automatic promotion, manual take, or forced vacancy. Existing forced-vacancy logic can legitimately bypass the full hold; short intervals alone are not proof of a broken timer. +- [ ] Add a deterministic regression only for an unintended bypass. Required behavior: ordinary automatic cuts cannot occur before both candidate sensitivity and incumbent hold are met; manual takes remain immediate; repeated eligibility changes follow the documented vacancy policy without resetting settings unexpectedly. +- [ ] Fix the smallest proven path, or deliver a documented explanation with traces if all cuts follow intended policy. Do not globally increase hold or disable intentional exceptions. +- [ ] Run `CoreVideoSpeakerDirector`, `CoreVideoSpeakerSettingsMerge`, `CoreVideoDirectorHandover`, and `CoreVideoDirectorPreviewFrameGuard`; verify unrelated fixed outputs are not resubscribed by automatic cuts. +- [ ] Commit the demonstrated correction or diagnostic improvement independently. + +**Acceptance:** Every reproduced short cut is attributable; normal automatic cuts respect the configured hold. Preserve smooth preview coverage and single-source audio during handover. + +## Task 5 — Medium priority: determine why requested HD remains 360p + +**Files:** `src/zoom-source.cpp/.h` (quality retry/cooldown); `src/zoom-output-manager.h`; `engine/src/main-macos.mm` video subscription/resolution selection; `tests/output-health-test.cpp`; output diagnostics UI only if a proven missing status needs presentation. + +**Interface contract:** Keep requested resolution, actual frame dimensions, cooldown, and attempt counts distinct. A fresh 360p feed is lower resolution, not stale or disconnected. + +- [ ] Use a known HD-capable sender and confirm actual Zoom meeting/account HD settings; record sender configuration and the SDK's accepted request/result without presuming entitlement from camera capability. +- [ ] Compare one fixed output at 360p, 720p, and 1080p requests, then the same sender through Active Speaker and multiple tiles. Record actual dimensions for at least 60 seconds per case. +- [ ] Identify whether the limiter is upstream, SDK negotiation, or CoreVideo retry/subscription policy. Compare the Participant 2 quality retry with the other outputs that never attempted one. +- [ ] If CoreVideo is responsible, add a regression for the specific selection/cooldown defect before changing it. Keep retries finite and avoid repeatedly tearing down fresh media for unavailable HD. +- [ ] Run `CoreVideoOutputHealth` and the added regression; repeat the controlled sender comparison. If upstream-limited, document the limitation and accurate UI state instead of manufacturing an upgrade loop. + +**Acceptance:** HD appears when the controlled SDK session delivers it; otherwise diagnostics accurately show the requested and received quality with a supported explanation. No new stalls or unbounded upgrades. + +## Out of scope: missing ATEM device + +The user confirmed the missing ATEM device is their OBS configuration. No CoreVideo fix, configuration task, or release gate is required. Retain the observation only to distinguish this known log noise from plugin errors. + +## Final integration and release gate + +- [ ] Build the plugin and engine together from the implementation worktree using the established macOS configuration, including the known OpenGL/AGL workaround if that environment still requires it. +- [ ] Run `cmake --build build --config Release --parallel 8` and `ctest --test-dir build -C Release --output-on-failure` in the configured implementation worktree. Run Windows CI for shared client/policy changes. Require all applicable tests to pass. +- [ ] Perform a dedicated test meeting: delayed grant/denial/regrant, five breakout round trips, five-minute waiting room, normal/manual speaker cuts, screen share start/stop, intended audio monitoring, and a short recording/playback. Record uncovered paths explicitly; do not claim ISO support validated unless actually exercised. +- [ ] Run a two-hour soak after the last behavioral change. Require no manual media restart, no repeated modal errors, no persistent recovered errors, no unexpected disconnects, fresh frames, and no material rendering/audio degradation relative to this baseline under comparable load. +- [ ] Update `CLAUDE.md`, changelog, and operator documentation to match demonstrated behavior. Update website claims only where behavior or downloads change; regenerate public content through the site build rather than editing generated files. +- [ ] Build a new versioned Mac installer, sign and notarize locally, validate signatures and ticket, and install on a second Mac. Retain the current working signed beta for rollback. +- [ ] Review and merge approved changes, then publish the verified installer/checksum and update website links. Never remove the known-good download before its replacement is verified. Certificates remain local. + +## Review checkpoints + +1. Lifecycle + error burst: review together for end-to-end recovery, as separate commits. +2. Waiting-room mapping: independently reviewable and can ship alongside checkpoint 1. +3. Speaker/HD: ship only proven corrections; unresolved upstream questions do not block the urgent lifecycle fixes. +4. Release: tests, controlled meeting, soak, signing, second-Mac install, then publication. + +No production code, OBS settings, releases, or website content were changed while preparing this plan. diff --git a/docs/video-quality.md b/docs/video-quality.md new file mode 100644 index 0000000..3e066a3 --- /dev/null +++ b/docs/video-quality.md @@ -0,0 +1,33 @@ +# Requested and received video quality + +CoreVideo requests 360p, 720p, or 1080p from Zoom. The output canvas and the +SDK accepting a request do not prove that Zoom delivered that size. Check +**observed dimensions** and frame freshness in diagnostics. A fresh 640×360 +feed remains live even when the source asks for 1920×1080. + +On macOS, all outputs for one participant share one renderer. The highest +current target request wins when subscribing or recovering, regardless of +whether a tile or fixed source was added first. Adding an HD output raises the +existing renderer in place. A refusal keeps the working renderer and feed. +Removing or rebinding an HD target removes its request from future recovery; +a warm renderer is not downgraded merely because that target left. + +The diagnostic `negotiated_resolution` field means the SDK-accepted request, +not observed frame size. A failed resolution request does not overwrite the +last accepted quality. Its SDK result code remains available in diagnostics. + +An active source with fresh but smaller frames can make three automatic quality +retry attempts. The first requires at least 20 seconds after subscribing; +subsequent attempts have 120-second and 240-second cooldowns. After exhaustion, +automatic quality retry stops and its countdown disappears. Manual retry remains +available. Receiving the requested dimensions or clearing the subscription +state resets this budget. This policy does not imply that retries can enable +HD unavailable from Zoom. + +## Live validation still required + +A controlled HD-capable sender and verified meeting/account HD configuration +have not yet been tested against this fix. Compare fixed 360p, 720p, and 1080p +requests for at least 60 seconds each, then Active Speaker and multiple tiles. +Record SDK request results and actual dimensions separately. No claim of +successful live HD delivery, upstream entitlement, or completed soak is made. diff --git a/engine/src/macos-admission-state.h b/engine/src/macos-admission-state.h new file mode 100644 index 0000000..f188e38 --- /dev/null +++ b/engine/src/macos-admission-state.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +// ZoomSDKMeetingStatus must be declared by before +// this header is included. Keeping the SDK symbols here makes this the single +// production translation seam exercised by the macOS regression test. +inline std::string +macos_awaiting_admission_event(ZoomSDKMeetingStatus status) +{ + const bool awaiting = status == ZoomSDKMeetingStatus_WaitingForHost || + status == ZoomSDKMeetingStatus_InWaitingRoom; + return std::string(R"({"cmd":"awaiting_admission","active":)") + + (awaiting ? "true" : "false") + "}"; +} + +// The actual callback uses this seam so emission cannot silently disappear or +// move behind joined/terminal handling without breaking the SDK-backed test. +template +inline void macos_dispatch_meeting_status(ZoomSDKMeetingStatus status, + Writer &&write, + StatusHandler &&handle_status) +{ + write(macos_awaiting_admission_event(status)); + handle_status(); +} diff --git a/engine/src/main-macos.mm b/engine/src/main-macos.mm index e5bcbef..a52e06c 100644 --- a/engine/src/main-macos.mm +++ b/engine/src/main-macos.mm @@ -58,7 +58,11 @@ #include "../../src/engine-ipc.h" #include "../../src/shm-generation.h" +#include "../../src/raw-media-lifecycle.h" +#include "../../src/video-quality-policy.h" +#include "../../src/meeting-callback-epoch.h" #include "engine-writer.h" +#include "macos-admission-state.h" #include "tile-clock-log.h" #include @@ -81,6 +85,7 @@ // release renderers and SHM regions when the meeting ends, and attach the // screen-share controller once the meeting is up. static void handle_stop_media(const char *reason); +static void raw_media_event(RawMediaEvent event, const char *reason, int sdk_code = 0); static void share_attach(); static void share_teardown(); @@ -510,8 +515,15 @@ static void send_roster() // use are stubbed rather than omitted — same shape as the Windows engine's block // of empty overrides. An omitted @required method is an unrecognized-selector // crash the moment the SDK calls it. +static MeetingCallbackEpoch g_meeting_callbacks; +@class CVMeetingDelegate; +static CVMeetingDelegate *g_meeting_delegate = nil; + @interface CVMeetingDelegate : NSObject +- (void)handleCurrentMeetingStatus:(ZoomSDKMeetingStatus)state + meetingError:(ZoomSDKMeetingError)error + EndReason:(EndMeetingReason)reason; @end @implementation CVMeetingDelegate @@ -519,6 +531,30 @@ @implementation CVMeetingDelegate - (void)onMeetingStatusChange:(ZoomSDKMeetingStatus)state meetingError:(ZoomSDKMeetingError)error EndReason:(EndMeetingReason)reason +{ + const auto ticket = g_meeting_callbacks.capture(); + void (^deliver)(void) = ^{ + const bool terminal = state == ZoomSDKMeetingStatus_Disconnecting || + state == ZoomSDKMeetingStatus_Ended || state == ZoomSDKMeetingStatus_Failed; + g_meeting_callbacks.deliver(ticket, terminal, [&] { + if (g_meeting_delegate != self) return; + macos_dispatch_meeting_status( + state, + [](const std::string &event) { EngineIpc::write(event); }, + [&] { + [self handleCurrentMeetingStatus:state + meetingError:error + EndReason:reason]; + }); + }); + }; + if (![NSThread isMainThread]) dispatch_async(dispatch_get_main_queue(), deliver); + else deliver(); +} + +- (void)handleCurrentMeetingStatus:(ZoomSDKMeetingStatus)state + meetingError:(ZoomSDKMeetingError)error + EndReason:(EndMeetingReason)reason { EngineIpc::write(R"({"cmd":"debug","stage":"meeting_status","status":)" + std::to_string(static_cast(state)) + @@ -540,8 +576,13 @@ - (void)onMeetingStatusChange:(ZoomSDKMeetingStatus)state share_attach(); rebuild_roster(); send_roster(); + raw_media_event(RawMediaEvent::InMeeting, "in_meeting"); break; } + case ZoomSDKMeetingStatus_Join_Breakout_Room: + case ZoomSDKMeetingStatus_Leave_Breakout_Room: + raw_media_event(RawMediaEvent::Transition, "breakout_transition"); + break; case ZoomSDKMeetingStatus_Disconnecting: case ZoomSDKMeetingStatus_Ended: { handle_stop_media("meeting_left"); @@ -663,8 +704,6 @@ - (void)notifyToJoin3rdPartyTelephonyAudio:(NSString *)audioInfo {} @end -static CVMeetingDelegate *g_meeting_delegate = nil; - // The join context is deliberately a never-released global. main.cpp keeps its // JoinParam strings alive for the same reason: Join/joinMeeting: is asynchronous // and this file is built without ARC, so releasing the context on return would @@ -706,9 +745,6 @@ static void handle_join(const std::string &line) return; } - if (!g_meeting_delegate) g_meeting_delegate = [[CVMeetingDelegate alloc] init]; - svc.delegate = g_meeting_delegate; - long long meeting_number = 0; try { meeting_number = std::stoll(meeting_id); @@ -717,6 +753,16 @@ static void handle_join(const std::string &line) return; } + // Invalidate queued status work before retiring the old delegate. A copied + // dispatch block retains self, so its identity cannot be recycled underneath it. + g_meeting_callbacks.begin(); + auto *actions = action_controller(); + if (actions && actions.delegate == g_meeting_delegate) actions.delegate = nil; + svc.delegate = nil; + [g_meeting_delegate release]; + g_meeting_delegate = [[CVMeetingDelegate alloc] init]; + svc.delegate = g_meeting_delegate; + handle_stop_media("replacement_meeting"); g_join_ctx = [[ZoomSDKJoinMeetingElements alloc] init]; g_join_ctx.userType = ZoomSDKUserType_WithoutLogin; g_join_ctx.meetingNumber = meeting_number; @@ -765,6 +811,10 @@ static void handle_join(const std::string &line) static void handle_leave() { + // Cancel statuses queued before Leave and reject subsequent nonterminal + // statuses, but keep the delegate for the SDK's leave-completion notification. + g_meeting_callbacks.leave(); + handle_stop_media("meeting_leave_requested"); ZoomSDKMeetingService *svc = meeting_service(); if (svc) [svc leaveMeetingWithCmd:LeaveMeetingCmd_Leave]; } @@ -824,6 +874,7 @@ static uint32_t shm_seq_begin(uint32_t current) ShmRegion shm; uint64_t frame_count = 0; uint32_t shm_gen = 0; // bumped per region (re)create; sent per frame + uint32_t requested_resolution = 1; // desired quality survives recovery bool shm_fail_reported = false; }; @@ -1136,19 +1187,28 @@ static void video_subscribe(uint32_t participant_id, const std::string &source_u } g_source_participant[source_uuid] = participant_id; - auto existing = g_video.find(participant_id); - if (existing != g_video.end()) { - existing->second.targets.emplace(source_uuid, VideoTarget{}); - return; // one renderer already serves this participant + auto &pending = g_video[participant_id]; + pending.participant_id = participant_id; + pending.targets[source_uuid].requested_resolution = resolution; + resolution = shared_video_requested_resolution(pending.targets, resolution); + if (pending.renderer) { + shared_video_upgrade_resolution(pending.resolution, resolution, + [&](uint32_t requested) { + const ZoomSDKError result = + [pending.renderer setResolution:sdk_resolution(requested)]; + EngineIpc::write( + R"({"cmd":"debug","stage":"set_resolution","source_uuid":")" + + source_uuid + R"(","participant_id":)" + + std::to_string(participant_id) + R"(,"code":)" + + std::to_string(static_cast(result)) + R"(,"resolution":)" + + std::to_string(requested) + "}"); + return static_cast(result); + }); + shared_video_accept_resolution(pending, pending.resolution, + [](const std::string &event) { EngineIpc::write(event); }); + return; } - if (!g_raw_media_active) { - // Remember the binding; the renderer is created when raw media starts. - VideoSubscription pending; - pending.participant_id = participant_id; - pending.resolution = resolution; - pending.targets.emplace(source_uuid, VideoTarget{}); - g_video.emplace(participant_id, std::move(pending)); EngineIpc::write( R"({"cmd":"debug","stage":"video_subscribe_deferred","source_uuid":")" + source_uuid + R"(","participant_id":)" + @@ -1193,6 +1253,15 @@ static void video_subscribe(uint32_t participant_id, const std::string &source_u std::to_string(static_cast(res_err)) + R"(,"resolution":)" + std::to_string(candidate) + "}"); + if (res_err != ZoomSDKError_Success) { + // An unaccepted request is not negotiated quality. Try the next + // candidate before subscribing this new (not yet warm) renderer. + renderer.delegate = nil; + [delegate release]; + [rdc destroyRender:renderer]; + continue; + } + const ZoomSDKError sub_err = [renderer subscribe:participant_id rawDataType:ZoomSDKRawDataType_Video]; EngineIpc::write( @@ -1203,21 +1272,11 @@ static void video_subscribe(uint32_t participant_id, const std::string &source_u std::to_string(candidate) + "}"); if (sub_err == ZoomSDKError_Success) { - VideoSubscription sub; - sub.participant_id = participant_id; - sub.resolution = static_cast(candidate); - sub.renderer = renderer; - sub.delegate = delegate; - sub.targets.emplace(source_uuid, VideoTarget{}); - g_video.emplace(participant_id, std::move(sub)); - if (static_cast(candidate) != resolution) { - EngineIpc::write( - R"({"cmd":"debug","stage":"video_resolution_downgraded","source_uuid":")" + - source_uuid + R"(","participant_id":)" + - std::to_string(participant_id) + R"(,"requested":)" + - std::to_string(resolution) + R"(,"actual":)" + - std::to_string(candidate) + "}"); - } + pending.renderer = renderer; + pending.delegate = delegate; + shared_video_publish_created_resolution(pending, static_cast(candidate), + source_uuid, + [](const std::string &event) { EngineIpc::write(event); }); return; } @@ -1933,13 +1992,18 @@ static void resubscribe_raw_media(const char *reason) std::lock_guard lock(g_video_mtx); for (auto &entry : g_video) { if (entry.second.renderer) continue; // already live - for (auto &t : entry.second.targets) { - pending.emplace_back(entry.first, t.first, entry.second.resolution); + // Participant ids are room-scoped. Keep missing assignments pending + // until the plugin resolves/rebinds them from the new roster. + if (std::none_of(g_roster.begin(), g_roster.end(), [&](const auto &p) { + return p.user_id == entry.first; + })) continue; + // One attempt per participant; placeholders retain ALL bindings even + // if the SDK rejects every resolution. Unsubscribe can still remove them. + if (!entry.second.targets.empty()) { + const auto &t = *entry.second.targets.begin(); + pending.emplace_back(entry.first, t.first, t.second.requested_resolution); } } - // video_subscribe re-creates these entries; drop the placeholders first - // so it does not short-circuit on "a subscription already exists". - for (auto &p : pending) g_video.erase(std::get<0>(p)); } EngineIpc::write(R"({"cmd":"debug","stage":"raw_media_resubscribe","reason":")" + @@ -1966,13 +2030,9 @@ static void resubscribe_raw_media(const char *reason) } } -// Guards the privilege-request retry loop: without it, a host who denies the -// request would have us re-ask on every callback. -static bool g_privilege_requested = false; - -// Defined below; the record delegate retries the start once the host grants -// local-recording permission. -static void handle_start_media(const char *reason); +static RawMediaLifecycle g_raw_lifecycle; +@class CVRecordDelegate; +static CVRecordDelegate *g_record_delegate = nil; @interface CVRecordDelegate : NSObject @end @@ -1984,20 +2044,27 @@ - (void)onLocalRecordingPrivilegeRequestStatus:(ZoomSDKRequestLocalRecordingStat EngineIpc::write( R"({"cmd":"debug","stage":"local_recording_privilege_status","status":)" + std::to_string(static_cast(status)) + "}"); - if (status == ZoomSDKRequestLocalRecordingStatus_Granted) { - handle_start_media("privilege_granted"); - return; - } - EngineIpc::write( - R"({"cmd":"error","msg":"raw_media_start_failed","reason":"local_recording_privilege_denied","status":)" + - std::to_string(static_cast(status)) + "}"); + dispatch_async(dispatch_get_main_queue(), ^{ + if (g_record_delegate != self) return; + if (status == ZoomSDKRequestLocalRecordingStatus_Granted) { + raw_media_event(RawMediaEvent::Grant, "privilege_granted"); + } else if (status == ZoomSDKRequestLocalRecordingStatus_Denied) { + raw_media_event(RawMediaEvent::Denied, "privilege_denied"); + } else if (status == ZoomSDKRequestLocalRecordingStatus_Timeout) { + raw_media_event(RawMediaEvent::Timeout, "privilege_request_timeout"); + } + }); } - (void)onRecordPrivilegeChange:(BOOL)canRec { EngineIpc::write(R"({"cmd":"debug","stage":"record_privilege_change","can_record":)" + std::string(canRec ? "true" : "false") + "}"); - if (canRec && !g_raw_media_active) handle_start_media("privilege_changed"); + dispatch_async(dispatch_get_main_queue(), ^{ + if (g_record_delegate != self) return; + raw_media_event(canRec ? RawMediaEvent::Grant : RawMediaEvent::Denied, + canRec ? "privilege_changed" : "privilege_revoked"); + }); } - (void)onRecord2MP4Done:(BOOL)success Path:(NSString *)recordPath {} @@ -2014,91 +2081,135 @@ - (void)onSmartRecordingEnableActionCallback:(ZoomSDKSmartRecordingEnableActionH @end -static CVRecordDelegate *g_record_delegate = nil; +// Invalidate SDK resources at a demonstrated lifecycle boundary, preserving +// desired bindings and SHM generations. Never use the destructive manual Stop +// path here: it erases assignments required for grant/room recovery. +static void suspend_raw_media() +{ + g_raw_media_active = false; + { + std::lock_guard lock(g_video_mtx); + for (auto &entry : g_video) { + auto &sub = entry.second; + if (sub.renderer) { + sub.renderer.delegate = nil; + [sub.renderer unSubscribe]; + auto *rdc = raw_data_controller(); + if (rdc) [rdc destroyRender:sub.renderer]; + sub.renderer = nil; + } + [sub.delegate release]; + sub.delegate = nil; + // Restart the diagnostic first-frame counter, not SHM generation. + for (auto &target : sub.targets) target.second.frame_count = 0; + } + } + { + std::lock_guard lock(g_share_mtx); + share_unsubscribe_renderer_locked(); + } + if (g_audio_subscribed) { + ZoomSDKRawDataController *rdc = raw_data_controller(); + ZoomSDKAudioRawDataHelper *helper = nil; + if (rdc && [rdc getAudioRawDataHelper:&helper] == ZoomSDKError_Success && helper) + [helper unSubscribe]; + g_audio_subscribed = false; + } + auto *svc = meeting_service(); + auto *rec = svc ? [svc getRecordController] : nil; + if (rec) { + const auto err = [rec stopRawRecording]; + EngineIpc::write(R"({"cmd":"debug","stage":"stop_raw_recording","code":)" + + std::to_string(static_cast(err)) + "}"); + } +} -static void handle_start_media(const char *reason) +static void raw_media_event(RawMediaEvent event, const char *reason, int sdk_code) { - ZoomSDKMeetingService *svc = meeting_service(); - if (!svc) { - EngineIpc::write( - R"({"cmd":"error","msg":"raw_media_start_failed","reason":"not_in_meeting"})"); - return; + // All callers either run on the command main queue or dispatch callbacks + // onto it. The decision is committed before any potentially reentrant SDK call. + const std::string before = g_raw_lifecycle.state(); + const RawMediaAction action = g_raw_lifecycle.on(event); + if (before != g_raw_lifecycle.state() || event == RawMediaEvent::Timeout) { + EngineIpc::write(R"({"cmd":"debug","stage":"raw_media_state","state":")" + + std::string(g_raw_lifecycle.state()) + R"(","reason":")" + + std::string(reason ? reason : "") + "\"}"); } - ZoomSDKMeetingRecordController *rec = [svc getRecordController]; - if (!rec) { - EngineIpc::write(R"({"cmd":"debug","stage":"recording_controller","code":-1})"); - EngineIpc::write( - R"({"cmd":"error","msg":"raw_media_start_failed",)" - R"("reason":"recording_controller_unavailable"})"); + if (action == RawMediaAction::None) return; + if (action == RawMediaAction::Suspend) { + suspend_raw_media(); return; } - - // Attach before asking: the grant arrives asynchronously on this delegate. - if (!g_record_delegate) g_record_delegate = [[CVRecordDelegate alloc] init]; - rec.delegate = g_record_delegate; - - const ZoomSDKError can_raw = [rec canStartRawRecording]; - EngineIpc::write(R"({"cmd":"debug","stage":"can_start_raw_recording","code":)" + - std::to_string(static_cast(can_raw)) + "}"); - if (can_raw != ZoomSDKError_Success && g_privilege_requested) { - // Already asked once this meeting; do not re-prompt the host on every - // retry. Report and stop. - EngineIpc::write( - R"({"cmd":"error","msg":"raw_media_start_failed","reason":"cannot_start_raw_recording",)" - R"("code":)" + std::to_string(static_cast(can_raw)) + - R"(,"privilege_requested":true,"detail":"Local-recording permission was )" - R"(already requested and has not been granted."})"); + if (action == RawMediaAction::Fail) { + EngineIpc::write(R"({"cmd":"error","msg":"raw_media_start_failed","reason":")" + + std::string(reason ? reason : "") + R"(","code":)" + + std::to_string(sdk_code) + + R"(,"detail":"Raw media could not start. Check recording permission and SDK licensing, then retry Start. The meeting remains joined."})"); return; } - if (can_raw != ZoomSDKError_Success) { - // Usually NoPermission(6): the engine joined as a participant and the - // host has not granted local recording. Ask for it rather than giving - // up — same fallback as main.cpp. The host sees a prompt; when they - // accept, onLocalRecordingPrivilegeRequestStatus fires and we retry. - const ZoomSDKError support_req = [rec isSupportRequestLocalRecordingPrivilege]; - EngineIpc::write( - R"({"cmd":"debug","stage":"support_recording_privilege_request","code":)" + - std::to_string(static_cast(support_req)) + "}"); - const ZoomSDKError req = [rec requestLocalRecordingPrivilege]; - g_privilege_requested = true; - EngineIpc::write( - R"({"cmd":"debug","stage":"request_recording_privilege","code":)" + - std::to_string(static_cast(req)) + "}"); - EngineIpc::write( - R"({"cmd":"error","msg":"raw_media_start_failed","reason":"cannot_start_raw_recording",)" - R"("code":)" + std::to_string(static_cast(can_raw)) + - R"(,"privilege_requested":)" + - std::string(req == ZoomSDKError_Success ? "true" : "false") + - R"(,"detail":"Raw recording needs local-recording permission. )" - R"(The meeting host must allow this participant to record."})"); + if (action == RawMediaAction::Restore) { + g_raw_media_active = true; + // Session readiness is NOT proof of healthy video; first frames and + // individual subscribe failures remain the source-health authority. + EngineIpc::write(R"({"cmd":"debug","stage":"raw_media_ready","reason":")" + + std::string(reason ? reason : "") + "\"}"); + resubscribe_raw_media(reason); return; } - - const ZoomSDKError start_raw = [rec startRawRecording]; - EngineIpc::write(R"({"cmd":"debug","stage":"start_raw_recording","code":)" + - std::to_string(static_cast(start_raw)) + "}"); - if (start_raw != ZoomSDKError_Success) { - EngineIpc::write( - R"({"cmd":"error","msg":"raw_media_start_failed","reason":"start_raw_recording_failed","code":)" + - std::to_string(static_cast(start_raw)) + "}"); + auto *svc = meeting_service(); + auto *rec = svc ? [svc getRecordController] : nil; + if (!rec) { + raw_media_event(RawMediaEvent::CheckFailed, "recording_controller_unavailable", -1); return; } + if (!g_record_delegate) g_record_delegate = [[CVRecordDelegate alloc] init]; + rec.delegate = g_record_delegate; + if (action == RawMediaAction::Check) { + const auto err = [rec canStartRawRecording]; + EngineIpc::write(R"({"cmd":"debug","stage":"can_start_raw_recording","code":)" + + std::to_string(static_cast(err)) + "}"); + // Only NoPermission authorizes a host request. NoLicense (21), WrongUsage + // and service errors are not evidence that the host withheld permission. + raw_media_event(err == ZoomSDKError_Success ? RawMediaEvent::CheckReady : + err == ZoomSDKError_NoPermission ? RawMediaEvent::NoPermission : + RawMediaEvent::CheckFailed, "cannot_start_raw_recording", static_cast(err)); + } else if (action == RawMediaAction::Request) { + const auto support = [rec isSupportRequestLocalRecordingPrivilege]; + const auto err = support == ZoomSDKError_Success ? [rec requestLocalRecordingPrivilege] : support; + EngineIpc::write(R"({"cmd":"debug","stage":"request_recording_privilege","code":)" + + std::to_string(static_cast(err)) + "}"); + if (err != ZoomSDKError_Success) { + raw_media_event(RawMediaEvent::RequestFailed, "recording_privilege_request_failed", static_cast(err)); + } else { + EngineIpc::write(R"({"cmd":"error","msg":"raw_media_start_failed","reason":"cannot_start_raw_recording","privilege_requested":true,"detail":"Raw recording needs local-recording permission. The meeting host must allow this participant to record."})"); + } + } else if (action == RawMediaAction::Start) { + const auto err = [rec startRawRecording]; + EngineIpc::write(R"({"cmd":"debug","stage":"start_raw_recording","code":)" + + std::to_string(static_cast(err)) + "}"); + raw_media_event(err == ZoomSDKError_Success ? RawMediaEvent::StartSucceeded : + RawMediaEvent::StartFailed, err == ZoomSDKError_Success ? "raw_recording_started" : + "start_raw_recording_failed", static_cast(err)); + } +} - g_raw_media_active = true; - // The plugin flips its media-active flag on this exact debug stage - // (zoom-engine-client.cpp). Without it every output stays labelled - // "Raw media not ready" in the Output Manager while frames are visibly - // arriving — health that contradicts the picture on screen. - EngineIpc::write(R"({"cmd":"debug","stage":"raw_media_ready","reason":")" + - std::string(reason ? reason : "") + "\"}"); - resubscribe_raw_media(reason); +static void handle_start_media(const char *reason) +{ + raw_media_event(RawMediaEvent::Start, reason); } static void handle_stop_media(const char *reason) { g_raw_media_active = false; - // Per-meeting state: a fresh meeting should be allowed to ask again. - g_privilege_requested = false; + raw_media_event(RawMediaEvent::Stop, reason); + if (std::string(reason ? reason : "") != "manual_stop") + raw_media_event(RawMediaEvent::Reset, reason); + auto *record_service = meeting_service(); + auto *record_controller = record_service ? [record_service getRecordController] : nil; + if (record_controller && record_controller.delegate == g_record_delegate) + record_controller.delegate = nil; + [g_record_delegate release]; + g_record_delegate = nil; // queued callbacks from the retired delegate are ignored { std::lock_guard lock(g_video_mtx); std::vector ids; diff --git a/src/engine-ipc.h b/src/engine-ipc.h index d973aa4..0aee94f 100644 --- a/src/engine-ipc.h +++ b/src/engine-ipc.h @@ -34,6 +34,14 @@ #define IPC_EVT_AUDIO "audio" #define IPC_EVT_ERROR "error" +// macOS debug lifecycle events (additive; Windows and older peers may omit): +// raw_media_state: state = stopped | waiting_permission | denied | recovering | +// starting | active | failed; reason describes the triggering event. Timeout is +// reason=privilege_request_timeout, not a denial. Any non-active state invalidates +// session readiness. raw_media_ready remains the compatible successful raw-start +// event; it does NOT certify source subscriptions or first frames. Per-source +// frame events and subscribe errors remain the health authority. + // Shared-memory name prefix (no leading slash — added per-platform below) #define IPC_SHM_PREFIX "ZoomObsPlugin_" diff --git a/src/media-failure-state.h b/src/media-failure-state.h new file mode 100644 index 0000000..d563124 --- /dev/null +++ b/src/media-failure-state.h @@ -0,0 +1,85 @@ +#pragma once +#include +#include +#include + +// Current health only; the client's bounded diagnostics ring retains every +// error. Eight tiles failing three times used to create 24 modal dialogs. +// Membership is limited to live assignments, with one notice per episode. +// Caller serializes access. No callbacks or SDK calls occur under this state. +class MediaFailureState { + struct Assignment { uint32_t participant; uint64_t epoch; }; + struct Failure { std::string cause; uint64_t since; unsigned attempts; uint32_t participant; }; + std::map assignments; + std::map failures; + std::map persistent; + uint64_t epoch = 0; +public: + void assign(const std::string &uuid, uint32_t participant) { + auto it = assignments.find(uuid); + if (it != assignments.end() && it->second.participant == participant) return; + failures.erase(uuid); + assignments[uuid] = {participant, ++epoch}; + } + uint64_t ticket(const std::string &uuid, uint32_t participant) const { + auto it = assignments.find(uuid); + return it != assignments.end() && (it->second.participant == 0 || it->second.participant == participant) ? it->second.epoch : 0; + } + // True means the caller should emit the episode's single nonmodal notice. + bool fail(const std::string &uuid, uint32_t participant, const std::string &cause, + uint64_t now, bool present) { + if (!ticket(uuid, participant)) return false; + if (!present) { failures.erase(uuid); return false; } + const bool first = failures.empty() && persistent.empty(); + auto it = failures.find(uuid); + if (it == failures.end()) failures[uuid] = {cause, now, 1, participant}; + else { it->second.cause = cause; it->second.participant = participant; if (it->second.attempts < 3) ++it->second.attempts; } + return first; + } + void delivered(const std::string &uuid, uint32_t participant, uint64_t token) { + auto failed = failures.find(uuid); + if (token && ticket(uuid, participant) == token && failed != failures.end() && + failed->second.participant == participant) failures.erase(failed); + } + // Shared-memory/audio errors have no reliable video recovery signal. + // Keep them until explicit removal/stop/reset, never clear on video read. + bool persistent_fail(const std::string &uuid, const std::string &message, bool registered) { + if (uuid.empty() || !registered) return false; + const bool first = failures.empty() && persistent.empty(); + persistent[uuid] = message; + return first; + } + void stop() { + failures.clear(); + persistent.clear(); + for (auto &entry : assignments) entry.second.epoch = ++epoch; + } + void remove(const std::string &uuid) { failures.erase(uuid); assignments.erase(uuid); persistent.erase(uuid); } + template void prune(Present present) { + for (auto it = failures.begin(); it != failures.end();) { + if (!present(it->second.participant)) it = failures.erase(it); + else ++it; + } + } + void reset() { stop(); assignments.clear(); ++epoch; } + size_t size() const { return failures.size(); } + bool failed(const std::string &uuid) const { return failures.count(uuid) || persistent.count(uuid); } + bool terminal(uint64_t now) const { + if (!persistent.empty()) return true; + for (const auto &entry : failures) + if (entry.second.attempts >= 3 || now >= entry.second.since + 10000) return true; + return false; + } + std::string status(uint64_t now) const { + std::string sticky; + if (!persistent.empty()) + sticky = "Zoom media unavailable for " + std::to_string(persistent.size()) + + " source(s). " + persistent.begin()->second.substr(0, 240) + " Click Retry Media; if unresolved, stop and restart media. "; + if (failures.empty()) return sticky; + // Cause is bounded in the summary; complete source/cause lives in logs. + return sticky + std::string(terminal(now) ? "Zoom video unavailable for " : "Recovering Zoom video for ") + + std::to_string(failures.size()) + " source(s). " + + (terminal(now) ? "Click Retry Media. " : "Retrying automatically. ") + + "Diagnostic: " + failures.begin()->second.cause.substr(0, 120); + } +}; diff --git a/src/meeting-callback-epoch.h b/src/meeting-callback-epoch.h new file mode 100644 index 0000000..c8fa98d --- /dev/null +++ b/src/meeting-callback-epoch.h @@ -0,0 +1,17 @@ +#pragma once +#include +#include + +// Captured on SDK callback arrival, checked when its main-queue work executes. +class MeetingCallbackEpoch { + std::atomic epoch{1}; + bool leaving = false; // begin/leave/deliver are SDK main-queue operations + void advance() { epoch.fetch_add(1, std::memory_order_acq_rel); } +public: + uint64_t capture() const { return epoch.load(std::memory_order_acquire); } + void begin() { leaving = false; advance(); } + void leave() { leaving = true; advance(); } + template void deliver(uint64_t ticket, bool terminal, Callback callback) const { + if (ticket == capture() && (!leaving || terminal)) callback(); + } +}; diff --git a/src/raw-media-lifecycle.h b/src/raw-media-lifecycle.h new file mode 100644 index 0000000..e3eb5f1 --- /dev/null +++ b/src/raw-media-lifecycle.h @@ -0,0 +1,95 @@ +#pragma once + +// Decisions only; the macOS SDK main queue owns this state and every effect. +// Grants authorize an existing Start intent, never create one. Room readiness +// is independent of permission and starts a fresh check after a transfer. +enum class RawMediaEvent { Start, Stop, Reset, InMeeting, Transition, Grant, + Denied, Timeout, NoPermission, CheckReady, CheckFailed, StartSucceeded, StartFailed, + RequestFailed }; +enum class RawMediaAction { None, Check, Start, Request, Suspend, Restore, Fail }; +class RawMediaLifecycle { + enum class Phase { Stopped, Waiting, Denied, Recovering, Checking, Starting, Active, Failed }; + Phase phase = Phase::Stopped; + bool wanted = false, in_meeting = false, requested_permission = false; + bool granted = false; + RawMediaAction check() { + phase = Phase::Checking; + return RawMediaAction::Check; + } +public: + const char *state() const { + switch (phase) { + case Phase::Stopped: return "stopped"; + case Phase::Waiting: return "waiting_permission"; + case Phase::Denied: return "denied"; + case Phase::Recovering: return "recovering"; + case Phase::Checking: case Phase::Starting: return "starting"; + case Phase::Active: return "active"; + case Phase::Failed: return "failed"; + } + return "failed"; + } + RawMediaAction on(RawMediaEvent e) { + switch (e) { + case RawMediaEvent::Reset: + *this = RawMediaLifecycle{}; + return RawMediaAction::None; + case RawMediaEvent::Stop: + wanted = false; phase = Phase::Stopped; + // One request per meeting, including manual Stop/Start cycles. + return RawMediaAction::None; + case RawMediaEvent::Start: + if (wanted && phase != Phase::Failed) return RawMediaAction::None; + wanted = true; + if (in_meeting) return check(); + phase = Phase::Recovering; + return RawMediaAction::None; + case RawMediaEvent::InMeeting: + if (in_meeting) return RawMediaAction::None; + in_meeting = true; + if (wanted) return check(); + return RawMediaAction::None; + case RawMediaEvent::Transition: { + in_meeting = false; + const bool was_active = phase == Phase::Active; + if (wanted) phase = Phase::Recovering; + return was_active ? RawMediaAction::Suspend : RawMediaAction::None; + } + case RawMediaEvent::Denied: { + granted = false; + const bool was_active = phase == Phase::Active; + if (wanted) phase = Phase::Denied; + return was_active ? RawMediaAction::Suspend : RawMediaAction::None; + } + case RawMediaEvent::Timeout: + // A timed-out request is not a denial, and cannot revoke an + // already-established grant delivered through the other callback. + return RawMediaAction::None; + case RawMediaEvent::Grant: + if (granted) return RawMediaAction::None; + granted = true; + if (wanted && in_meeting && (phase == Phase::Waiting || phase == Phase::Denied || phase == Phase::Recovering)) return check(); + return RawMediaAction::None; + case RawMediaEvent::NoPermission: + if (phase != Phase::Checking) return RawMediaAction::None; + granted = false; + phase = Phase::Waiting; + if (requested_permission) return RawMediaAction::None; + requested_permission = true; + return RawMediaAction::Request; + case RawMediaEvent::CheckReady: + if (phase != Phase::Checking) return RawMediaAction::None; + granted = true; phase = Phase::Starting; + return RawMediaAction::Start; + case RawMediaEvent::StartSucceeded: + if (phase != Phase::Starting) return RawMediaAction::None; + phase = Phase::Active; + return RawMediaAction::Restore; + case RawMediaEvent::CheckFailed: case RawMediaEvent::StartFailed: case RawMediaEvent::RequestFailed: + if (!wanted) return RawMediaAction::None; + phase = Phase::Failed; + return RawMediaAction::Fail; + } + return RawMediaAction::None; + } +}; diff --git a/src/speaker-director.cpp b/src/speaker-director.cpp index 3538700..ddb5963 100644 --- a/src/speaker-director.cpp +++ b/src/speaker-director.cpp @@ -44,6 +44,21 @@ void SpeakerDirector::reset() m_last_switch_ms = 0; m_last_forced_fill_ms = 0; m_forced_vacancy = false; + m_latest_time_ms = 0; + ++m_session_id; + if (m_session_id == 0) + ++m_session_id; + m_promotion_sequence = 0; + m_last_promotion = {}; + m_recent_promotions.clear(); +} + +uint64_t SpeakerDirector::normalize_time_locked(uint64_t now_ms) +{ + if (now_ms < m_latest_time_ms) + return m_latest_time_ms; + m_latest_time_ms = now_ms; + return now_ms; } // Strict vetting for NEW candidates only. The incumbent is judged by @@ -134,16 +149,36 @@ uint32_t SpeakerDirector::choose_candidate_locked(uint32_t raw_speaker_id) const return it != m_roster.end() ? it->user_id : 0; } -bool SpeakerDirector::promote_locked(uint32_t participant_id, uint64_t now_ms) +bool SpeakerDirector::promote_locked(uint32_t participant_id, uint64_t now_ms, + SpeakerPromotionReason reason, + uint32_t effective_sensitivity_ms, + uint32_t effective_hold_ms, + uint64_t candidate_age_ms, + uint64_t incumbent_held_ms) { if (participant_id == 0 || participant_id == m_directed_speaker_id) return false; - m_last_speaker_id = m_directed_speaker_id; + const uint32_t previous_speaker_id = m_directed_speaker_id; + m_last_speaker_id = previous_speaker_id; m_directed_speaker_id = participant_id; m_candidate_speaker_id = 0; m_candidate_since_ms = 0; m_last_switch_ms = now_ms; m_forced_vacancy = false; + m_last_promotion.reason = reason; + m_last_promotion.session_id = m_session_id; + m_last_promotion.sequence = ++m_promotion_sequence; + m_last_promotion.previous_speaker_id = previous_speaker_id; + m_last_promotion.promoted_speaker_id = participant_id; + m_last_promotion.promoted_at_ms = now_ms; + m_last_promotion.effective_sensitivity_ms = effective_sensitivity_ms; + m_last_promotion.effective_hold_ms = effective_hold_ms; + m_last_promotion.candidate_age_ms = candidate_age_ms; + m_last_promotion.incumbent_held_ms = incumbent_held_ms; + constexpr size_t kPromotionHistoryLimit = 16; + if (m_recent_promotions.size() == kPromotionHistoryLimit) + m_recent_promotions.erase(m_recent_promotions.begin()); + m_recent_promotions.push_back(m_last_promotion); return true; } @@ -188,7 +223,9 @@ bool SpeakerDirector::fill_vacancy_locked(uint32_t candidate, uint64_t now_ms) if (candidate == 0) return false; - if (m_forced_vacancy) { + const bool forced_vacancy = m_forced_vacancy; + uint32_t effective_sensitivity_ms = 0; + if (forced_vacancy) { const bool immediate_fill_available = m_last_forced_fill_ms == 0 || now_ms < m_last_forced_fill_ms || now_ms - m_last_forced_fill_ms >= m_hold_ms; @@ -197,26 +234,44 @@ bool SpeakerDirector::fill_vacancy_locked(uint32_t candidate, uint64_t now_ms) now_ms - m_candidate_since_ms < m_sensitivity_ms)) { return false; } + if (!immediate_fill_available) + effective_sensitivity_ms = m_sensitivity_ms; m_last_forced_fill_ms = now_ms; } - return promote_locked(candidate, now_ms); + const uint64_t candidate_age = now_ms >= m_candidate_since_ms + ? now_ms - m_candidate_since_ms : 0; + const uint64_t incumbent_held_ms = forced_vacancy && + now_ms >= m_last_switch_ms + ? now_ms - m_last_switch_ms : 0; + return promote_locked(candidate, now_ms, + forced_vacancy + ? SpeakerPromotionReason::ForcedVacancy + : SpeakerPromotionReason::Automatic, + effective_sensitivity_ms, 0, candidate_age, + incumbent_held_ms); } bool SpeakerDirector::set_manual_speaker(uint32_t participant_id, uint64_t now_ms) { std::lock_guard lk(m_mtx); + now_ms = normalize_time_locked(now_ms); if (!participant_allowed_locked(participant_id)) return false; m_manual_speaker_id = participant_id; m_candidate_speaker_id = 0; m_candidate_since_ms = 0; - return promote_locked(participant_id, now_ms); + const uint64_t held_for = now_ms >= m_last_switch_ms + ? now_ms - m_last_switch_ms : 0; + return promote_locked(participant_id, now_ms, + SpeakerPromotionReason::ManualTake, + 0, 0, 0, held_for); } bool SpeakerDirector::clear_manual_speaker(uint64_t now_ms) { std::lock_guard lk(m_mtx); + now_ms = normalize_time_locked(now_ms); if (m_manual_speaker_id == 0) return false; m_manual_speaker_id = 0; @@ -230,12 +285,17 @@ bool SpeakerDirector::update_roster(const std::vector &roster, uint64_t now_ms) { std::lock_guard lk(m_mtx); + now_ms = normalize_time_locked(now_ms); m_roster = roster; m_raw_speaker_id = raw_speaker_id; enforce_incumbent_eligibility_locked(now_ms); if (m_manual_speaker_id != 0) - return promote_locked(m_manual_speaker_id, now_ms); + return promote_locked(m_manual_speaker_id, now_ms, + SpeakerPromotionReason::ManualTake, + 0, 0, 0, + now_ms >= m_last_switch_ms + ? now_ms - m_last_switch_ms : 0); const uint32_t candidate = choose_candidate_locked(raw_speaker_id); if (m_directed_speaker_id == 0) @@ -259,7 +319,11 @@ bool SpeakerDirector::tick_locked(uint64_t now_ms) { enforce_incumbent_eligibility_locked(now_ms); if (m_manual_speaker_id != 0) - return promote_locked(m_manual_speaker_id, now_ms); + return promote_locked(m_manual_speaker_id, now_ms, + SpeakerPromotionReason::ManualTake, + 0, 0, 0, + now_ms >= m_last_switch_ms + ? now_ms - m_last_switch_ms : 0); if (m_directed_speaker_id == 0) { return fill_vacancy_locked(choose_candidate_locked(m_raw_speaker_id), @@ -274,12 +338,16 @@ bool SpeakerDirector::tick_locked(uint64_t now_ms) if (candidate_age < m_sensitivity_ms || held_for < m_hold_ms) return false; - return promote_locked(m_candidate_speaker_id, now_ms); + return promote_locked(m_candidate_speaker_id, now_ms, + SpeakerPromotionReason::Automatic, + m_sensitivity_ms, m_hold_ms, + candidate_age, held_for); } bool SpeakerDirector::tick(uint64_t now_ms) { std::lock_guard lk(m_mtx); + now_ms = normalize_time_locked(now_ms); return tick_locked(now_ms); } @@ -292,6 +360,7 @@ uint32_t SpeakerDirector::directed_speaker_id() const SpeakerDirectorSnapshot SpeakerDirector::snapshot(uint64_t now_ms) const { std::lock_guard lk(m_mtx); + now_ms = std::max(now_ms, m_latest_time_ms); SpeakerDirectorSnapshot s; s.raw_speaker_id = m_raw_speaker_id; s.directed_speaker_id = m_directed_speaker_id; @@ -309,5 +378,7 @@ SpeakerDirectorSnapshot SpeakerDirector::snapshot(uint64_t now_ms) const s.excluded_participant_ids = m_excluded_participant_ids; s.require_video = m_require_video; s.manual_active = m_manual_speaker_id != 0; + s.last_promotion = m_last_promotion; + s.recent_promotions = m_recent_promotions; return s; } diff --git a/src/speaker-director.h b/src/speaker-director.h index 30638bf..fbff6ee 100644 --- a/src/speaker-director.h +++ b/src/speaker-director.h @@ -5,6 +5,26 @@ #include #include +enum class SpeakerPromotionReason : uint8_t { + None, + Automatic, + ManualTake, + ForcedVacancy, +}; + +struct SpeakerPromotionAttribution { + SpeakerPromotionReason reason = SpeakerPromotionReason::None; + uint64_t session_id = 0; + uint64_t sequence = 0; + uint32_t previous_speaker_id = 0; + uint32_t promoted_speaker_id = 0; + uint64_t promoted_at_ms = 0; + uint32_t effective_sensitivity_ms = 0; + uint32_t effective_hold_ms = 0; + uint64_t candidate_age_ms = 0; + uint64_t incumbent_held_ms = 0; +}; + struct SpeakerDirectorSnapshot { uint32_t raw_speaker_id = 0; uint32_t directed_speaker_id = 0; @@ -18,6 +38,8 @@ struct SpeakerDirectorSnapshot { std::vector excluded_participant_ids; bool require_video = true; bool manual_active = false; + SpeakerPromotionAttribution last_promotion; + std::vector recent_promotions; }; class SpeakerDirector { @@ -41,7 +63,13 @@ class SpeakerDirector { private: SpeakerDirector() = default; - bool promote_locked(uint32_t participant_id, uint64_t now_ms); + bool promote_locked(uint32_t participant_id, uint64_t now_ms, + SpeakerPromotionReason reason, + uint32_t effective_sensitivity_ms, + uint32_t effective_hold_ms, + uint64_t candidate_age_ms, + uint64_t incumbent_held_ms); + uint64_t normalize_time_locked(uint64_t now_ms); bool participant_allowed_locked(uint32_t participant_id) const; bool participant_excluded_locked(uint32_t participant_id) const; bool participant_in_roster_locked(uint32_t participant_id) const; @@ -77,4 +105,9 @@ class SpeakerDirector { uint32_t m_sensitivity_ms = 500; uint32_t m_hold_ms = 2000; bool m_require_video = true; + uint64_t m_latest_time_ms = 0; + uint64_t m_session_id = 0; + uint64_t m_promotion_sequence = 0; + SpeakerPromotionAttribution m_last_promotion; + std::vector m_recent_promotions; }; diff --git a/src/video-quality-policy.h b/src/video-quality-policy.h new file mode 100644 index 0000000..4541b1a --- /dev/null +++ b/src/video-quality-policy.h @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include + +// Shared renderer quality is a request, not the dimensions Zoom delivers. +template +uint32_t shared_video_requested_resolution(const Targets &targets, uint32_t incoming) +{ + for (const auto &entry : targets) + incoming = std::max(incoming, entry.second.requested_resolution); + return incoming; +} + +template +void shared_video_upgrade_resolution(uint32_t &accepted, uint32_t requested, + SetResolution set_resolution) +{ + // Raising quality must neither unsubscribe nor replace a working renderer. + if (requested > accepted && set_resolution(requested) == 0) + accepted = requested; +} + +inline bool video_quality_request_accepted(int code, int resolution) +{ + return code == 0 && resolution >= 0 && resolution <= 2; +} + +inline constexpr uint32_t kQualityUpgradeMaxAttempts = 3; +inline uint64_t quality_upgrade_cooldown_ns(uint32_t attempts) +{ + return 60'000'000'000ULL << std::min(attempts, 3); +} +inline bool quality_upgrade_retry_allowed(uint32_t attempts, bool force) +{ + return force || attempts < kQualityUpgradeMaxAttempts; +} + +// Commit a newly accepted renderer request. Caller owns subscription locking. +template +void shared_video_accept_resolution(Subscription &subscription, + uint32_t accepted, Publish publish) +{ + subscription.resolution = accepted; + // Every source filters diagnostics by its own UUID. Recovery only calls + // subscribe once per participant, so the selected source cannot stand in + // for the other retained outputs, including after a lower fallback. + for (const auto &target : subscription.targets) { + publish( + R"({"cmd":"debug","stage":"video_source_bound","source_uuid":")" + + target.first + R"(","participant_id":)" + + std::to_string(subscription.participant_id) + R"(,"requested":)" + + std::to_string(target.second.requested_resolution) + R"(,"actual":)" + + std::to_string(subscription.resolution) + "}"); + } +} + +// Entire successful creation publication sequence, including fallback diagnostics. +template +void shared_video_publish_created_resolution(Subscription &subscription, + uint32_t accepted, const std::string &source_uuid, Publish publish) +{ + shared_video_accept_resolution(subscription, accepted, publish); + const auto target = subscription.targets.find(source_uuid); + // The selected recovery caller may be a 360 tile sharing a 1080 request. + // Its trailing diagnostic must describe its own request, not the maximum. + if (target != subscription.targets.end() && + accepted < target->second.requested_resolution) { + const auto requested = target->second.requested_resolution; + publish( + R"({"cmd":"debug","stage":"video_resolution_downgraded","source_uuid":")" + + source_uuid + R"(","participant_id":)" + + std::to_string(subscription.participant_id) + R"(,"requested":)" + + std::to_string(requested) + R"(,"actual":)" + + std::to_string(accepted) + "}"); + } +} diff --git a/src/zoom-control-server.cpp b/src/zoom-control-server.cpp index f477332..ac083be 100644 --- a/src/zoom-control-server.cpp +++ b/src/zoom-control-server.cpp @@ -515,6 +515,7 @@ void ZoomControlServer::handle_line(QPointer socket, const QByteArra {"ok", true}, {"meeting_state", meeting_state_to_string(ZoomEngineClient::instance().state())}, {"media_active", ZoomEngineClient::instance().is_media_active()}, + {"media_notice", QString::fromStdString(ZoomEngineClient::instance().pending_privilege_notice())}, {"last_error", QString::fromStdString( ZoomEngineClient::instance().last_error())}, {"active_speaker_id", static_cast( @@ -860,7 +861,7 @@ void ZoomControlServer::handle_line(QPointer socket, const QByteArra }); return; } - ZoomEngineClient::instance().start_media(); + ZoomOutputManager::instance().retry_media(); write_response(socket, {{"ok", true}}); return; } diff --git a/src/zoom-diagnostics-dialog.cpp b/src/zoom-diagnostics-dialog.cpp index e2d3345..9893e18 100644 --- a/src/zoom-diagnostics-dialog.cpp +++ b/src/zoom-diagnostics-dialog.cpp @@ -1172,6 +1172,7 @@ void ZoomDiagnosticsDialog::export_diagnostics() engine_status["running"] = ZoomEngineClient::instance().is_running(); engine_status["meeting_state"] = state_text(ZoomEngineClient::instance().state()); engine_status["media_active"] = ZoomEngineClient::instance().is_media_active(); + engine_status["media_notice"] = QString::fromStdString(ZoomEngineClient::instance().pending_privilege_notice()); engine_status["authenticated"] = ZoomEngineClient::instance().is_authenticated(); engine_status["active_speaker_id"] = static_cast(ZoomEngineClient::instance().active_speaker_id()); diff --git a/src/zoom-dock.cpp b/src/zoom-dock.cpp index 4d30782..e3cd9b1 100644 --- a/src/zoom-dock.cpp +++ b/src/zoom-dock.cpp @@ -800,6 +800,7 @@ ZoomDock::ZoomDock(QWidget *parent) m_refresh_timer->setInterval(100); connect(m_refresh_timer, &QTimer::timeout, this, [this]() { SpeakerDirector::instance().tick(os_gettime_ns() / 1000000ULL); + log_speaker_director_promotion(); update_state_indicator(); }); m_refresh_timer->start(); @@ -907,6 +908,40 @@ void ZoomDock::apply_speaker_director_settings() sensitivity_ms, hold_ms, settings.speaker_require_video, excluded); } +void ZoomDock::log_speaker_director_promotion() +{ + const uint64_t flushed_at_ms = os_gettime_ns() / 1000000ULL; + const auto snapshot = SpeakerDirector::instance().snapshot(flushed_at_ms); + for (const auto &promotion : snapshot.recent_promotions) { + if (promotion.session_id < m_director_log_session_id || + (promotion.session_id == m_director_log_session_id && + promotion.sequence <= m_director_log_sequence)) { + continue; + } + const char *reason = "automatic"; + if (promotion.reason == SpeakerPromotionReason::ManualTake) + reason = "manual_take"; + else if (promotion.reason == SpeakerPromotionReason::ForcedVacancy) + reason = "forced_vacancy"; + blog(LOG_INFO, + "[obs-zoom-plugin] speaker_director_promotion " + "session=%llu sequence=%llu reason=%s from_id=%u to_id=%u " + "promoted_at_ms=%llu flushed_at_ms=%llu " + "effective_sensitivity_ms=%u effective_hold_ms=%u " + "candidate_age_ms=%llu incumbent_held_ms=%llu", + static_cast(promotion.session_id), + static_cast(promotion.sequence), reason, + promotion.previous_speaker_id, promotion.promoted_speaker_id, + static_cast(promotion.promoted_at_ms), + static_cast(flushed_at_ms), + promotion.effective_sensitivity_ms, promotion.effective_hold_ms, + static_cast(promotion.candidate_age_ms), + static_cast(promotion.incumbent_held_ms)); + m_director_log_session_id = promotion.session_id; + m_director_log_sequence = promotion.sequence; + } +} + // -- Internal helpers ---------------------------------------------------------- void ZoomDock::update_credentials_banner() { @@ -1068,7 +1103,8 @@ void ZoomDock::update_state_indicator() m_error_label->setVisible(true); } else { m_state_label->setText(state_label_text(s)); - m_error_label->setVisible(false); + m_error_label->setText(QString::fromStdString(last_error)); + m_error_label->setVisible(!last_error.empty()); } const bool in_meeting = (s == MeetingState::InMeeting); @@ -1091,7 +1127,9 @@ void ZoomDock::update_state_indicator() m_leave_btn->setEnabled(in_meeting || transitioning || recovering); m_leave_btn->setText(in_meeting ? "Leave" : "Cancel"); if (m_start_engine_btn && m_stop_engine_btn) { - m_start_engine_btn->setEnabled(in_meeting && !media_active && !transitioning && !recovering); + const bool media_failed = in_meeting && !last_error.empty(); + m_start_engine_btn->setText(media_failed ? "Retry Media" : "Start Engine"); + m_start_engine_btn->setEnabled(in_meeting && (!media_active || media_failed) && !transitioning && !recovering); m_stop_engine_btn->setEnabled(in_meeting && media_active && !transitioning); } @@ -1860,7 +1898,7 @@ void ZoomDock::on_start_engine_clicked() "Join the Zoom meeting before starting the broadcast engine."); return; } - ZoomEngineClient::instance().start_media(); + ZoomOutputManager::instance().retry_media(); update_state_indicator(); } diff --git a/src/zoom-dock.h b/src/zoom-dock.h index f9fffde..64d95c1 100644 --- a/src/zoom-dock.h +++ b/src/zoom-dock.h @@ -44,6 +44,7 @@ class ZoomDock : public QWidget { void on_cancel_recovery_clicked(); void update_state_indicator(); void apply_speaker_director_settings(); + void log_speaker_director_promotion(); void update_recovery_panel(); void update_credentials_banner(); void show_update_banner(const QString &tag, const QString &html_url); @@ -71,6 +72,8 @@ class ZoomDock : public QWidget { QComboBox *m_speaker_override_combo = nullptr; QPushButton *m_speaker_take_btn = nullptr; QPushButton *m_speaker_release_btn = nullptr; + uint64_t m_director_log_session_id = 0; + uint64_t m_director_log_sequence = 0; // First-run credentials notice CvBanner *m_credentials_banner = nullptr; diff --git a/src/zoom-engine-client.cpp b/src/zoom-engine-client.cpp index 2bdc5d8..ce78f19 100644 --- a/src/zoom-engine-client.cpp +++ b/src/zoom-engine-client.cpp @@ -4,7 +4,8 @@ #include "talkback-key.h" // talkback_session_mic_blocked() -- Law 1 #include "talkback-plan.h" // talkback_dedup_preserve_order() -- Task 5 fix round 1, F4 #include "zoom-join-decision.h" -#include "zoom-privilege-notice.h" // record-privilege handshake copy/classification +#include "zoom-privilege-notice.h" +#include "zoom-engine-error-dispatch.h" // record-privilege handshake copy/classification #include "zoom-reconnect.h" #include "zoom-sdk-init-retry.h" #include @@ -706,6 +707,9 @@ void ZoomEngineClient::stop_for_reconnect() // both costs nothing and covers every trigger. { std::lock_guard lk(m_mtx); + m_raw_media_error.clear(); + m_privilege_notice.clear(); + m_media_failures.reset(); talkback_nomination_reset(m_talkback_nomination_status); m_talkback_nomination_pending = TalkbackNominationPending{}; talkback_presence_reset(m_talkback_channel_presence); @@ -897,6 +901,10 @@ bool ZoomEngineClient::join(const std::string &meeting_id, m_last_jwt, meeting_id, passcode, display_name, kind, tokens); { std::lock_guard lk(m_mtx); + // An explicit join replaces the session; room-level joined reports do not. + m_raw_media_error.clear(); + m_privilege_notice.clear(); + m_media_failures.reset(); m_join_pending = true; m_pending_meeting_id = meeting_id; m_pending_passcode = passcode; @@ -915,6 +923,12 @@ bool ZoomEngineClient::join(const std::string &meeting_id, void ZoomEngineClient::subscribe_spotlight(const std::string &source_uuid, uint32_t slot) { if (!m_running.load(std::memory_order_acquire) || source_uuid.empty()) return; + { + std::lock_guard lk(m_mtx); + // Engine-selected participant; failure membership still records the + // actual participant and only that participant's read can recover it. + m_media_failures.assign(source_uuid, 0); + } write_json(R"({"cmd":"subscribe","source_uuid":")" + json_escape(source_uuid) + R"(","mode":"spotlight","slot":)" + std::to_string(slot) + "}"); } @@ -922,6 +936,12 @@ void ZoomEngineClient::subscribe_spotlight(const std::string &source_uuid, uint3 void ZoomEngineClient::subscribe_screenshare(const std::string &source_uuid) { if (!m_running.load(std::memory_order_acquire) || source_uuid.empty()) return; + { + std::lock_guard lk(m_mtx); + // Engine-selected participant; failure membership still records the + // actual participant and only that participant's read can recover it. + m_media_failures.assign(source_uuid, 0); + } write_json(R"({"cmd":"subscribe","source_uuid":")" + json_escape(source_uuid) + R"(","mode":"screenshare"})"); } @@ -1103,6 +1123,10 @@ void ZoomEngineClient::subscribe(const std::string &source_uuid, bool video_only) { if (!m_running.load(std::memory_order_acquire) || source_uuid.empty()) return; + { + std::lock_guard lk(m_mtx); + m_media_failures.assign(source_uuid, participant_id); + } write_json(R"({"cmd":"subscribe","source_uuid":")" + json_escape(source_uuid) + R"(","participant_id":)" + std::to_string(participant_id) + R"(,"resolution":)" + std::to_string(static_cast(video_resolution)) + @@ -1128,6 +1152,10 @@ bool ZoomEngineClient::subscribe_audio(const std::string &source_uuid, void ZoomEngineClient::unsubscribe(const std::string &source_uuid) { + { + std::lock_guard lk(m_mtx); + m_media_failures.remove(source_uuid); + } if (!m_running.load(std::memory_order_acquire) || source_uuid.empty()) return; write_json(R"({"cmd":"unsubscribe","source_uuid":")" + json_escape(source_uuid) + "\"}"); } @@ -1143,6 +1171,27 @@ void ZoomEngineClient::unregister_source(const std::string &source_uuid) { std::lock_guard lk(m_mtx); m_sources.erase(source_uuid); + m_media_failures.remove(source_uuid); +} + +bool ZoomEngineClient::source_media_failed(const std::string &uuid) const +{ + std::lock_guard lk(m_mtx); + return m_media_failures.failed(uuid); +} + +uint64_t ZoomEngineClient::media_delivery_ticket(const std::string &uuid, uint32_t participant) const +{ + std::lock_guard lk(m_mtx); + return m_media_failures.ticket(uuid, participant); +} + +void ZoomEngineClient::acknowledge_media_delivery(const std::string &uuid, uint32_t participant, uint64_t ticket) +{ + std::lock_guard lk(m_mtx); + m_media_failures.delivered(uuid, participant, ticket); + // Dock polling observes recovery. Never call UI/source callbacks while a + // source's frame lock may be held, or queue one UI task per video frame. } bool ZoomEngineClient::launch_engine() @@ -1367,6 +1416,7 @@ void ZoomEngineClient::set_privilege_notice_and_notify(const std::string &messag std::vector callbacks; { std::lock_guard lk(m_mtx); + if (m_privilege_notice == message) return; m_privilege_notice = message; for (const auto &entry : m_notice_callbacks) if (entry.second) callbacks.push_back(entry.second); @@ -1452,9 +1502,26 @@ void ZoomEngineClient::handle_event(const std::string &line) // see src/zoom-privilege-notice.h. A notice that never clears is // its own defect, so clear it on every successful start, not just // ones that followed a notice. + { + std::lock_guard lk(m_mtx); + m_raw_media_error.clear(); + } clear_privilege_notice_and_notify(); - } else if (stage == "raw_media_stopped") + } else if (stage == "raw_media_state") { + // Session readiness is separate from per-source frame health. + const auto media_state = obj.value("state").toString().toStdString(); + if (media_state != "active") + m_media_active.store(false, std::memory_order_release); + const auto notice = zoom_raw_media_state_notice(media_state, + obj.value("reason").toString().toStdString()); + if (!notice.empty()) set_privilege_notice_and_notify(notice); + else if (media_state == "stopped") clear_privilege_notice_and_notify(); + } else if (stage == "raw_media_stopped") { m_media_active.store(false, std::memory_order_release); + std::lock_guard lk(m_mtx); + m_raw_media_error.clear(); + m_media_failures.stop(); + } return; } if (cmd == "talkback_probe") { @@ -1589,9 +1656,11 @@ void ZoomEngineClient::handle_event(const std::string &line) if (cmd == "joined") { m_awaiting_admission.store(false, std::memory_order_release); m_state.store(MeetingState::InMeeting, std::memory_order_release); - // A successful join supersedes whatever failed before it; without + // A successful join supersedes connection errors; without // this the dock keeps showing "Connection failed" from a previous - // attempt over a perfectly healthy meeting. + // attempt over a perfectly healthy meeting. Breakout return also + // emits joined: preserve source assignments and unresolved media + // failures while the engine restores subscriptions itself. clear_last_error(); ZoomReconnectManager::instance().on_join_success(); return; @@ -1626,6 +1695,8 @@ void ZoomEngineClient::handle_event(const std::string &line) // same as this "left" handler has never notified roster callbacks // either (see this function's own doc comment). m_privilege_notice.clear(); + m_raw_media_error.clear(); + m_media_failures.reset(); keep_failed = !m_last_error.empty() && !m_user_leaving.load(std::memory_order_acquire); } @@ -1678,12 +1749,19 @@ void ZoomEngineClient::handle_event(const std::string &line) } } blog(LOG_ERROR, "[obs-zoom-plugin] Zoom engine event: %s", line.c_str()); + { + std::lock_guard lk(m_mtx); + m_debug_events.push_back({os_gettime_ns() / 1000000ULL, + obj.value("msg").toString().toStdString(), + obj.value("source_uuid").toString().toStdString(), + static_cast(obj.value("participant_id").toInt()), line}); + while (m_debug_events.size() > 300) m_debug_events.pop_front(); + } const QString emsg = obj.value("msg").toString(); // Media-path errors: the meeting itself is still healthy, so surface // them loudly to the operator but do NOT tear the session down or // trigger the reconnect flow. - if (emsg == "shm_create_failed" || emsg == "subscribe_rejected" || - emsg == "shm_name_collision") { + if (zoom_persistent_source_media_failure(emsg.toStdString())) { const std::string uuid = obj.value("source_uuid").toString().toStdString(); std::string error_message; @@ -1714,7 +1792,18 @@ void ZoomEngineClient::handle_event(const std::string &line) (limit > 0 ? " (limit " + std::to_string(limit) + ")" : std::string()); } - set_error_and_notify(error_message); + std::vector callbacks; + std::string notice; + { + std::lock_guard lk(m_mtx); + if (m_media_failures.persistent_fail(uuid, error_message, + m_sources.find(uuid) != m_sources.end()) && m_privilege_notice.empty()) { + notice = m_media_failures.status(os_gettime_ns() / 1000000ULL); + for (const auto &entry : m_notice_callbacks) + if (entry.second) callbacks.push_back(entry.second); + } + } + for (const auto &cb : callbacks) cb(notice); return; } // Another media-path error: sources keep their participant binding @@ -1723,100 +1812,83 @@ void ZoomEngineClient::handle_event(const std::string &line) // reassigns the source or that participant comes back. Subscribing // to an absent participant is therefore a waiting state, not an // error — stay quiet and let the recovery loop keep retrying. If the - // participant IS in the roster the failure is real and stays loud, + // participant IS in the roster it joins the current recovery episode, // but either way the meeting itself is healthy: never route this // into the join-failure / reconnect machinery below (doing so // flipped the session to "Connection failed" mid-meeting). - // raw_media_start_failed with privilege_requested is the NORMAL first - // half of the record-privilege handshake: canStartRawRecording returns - // NoPermission(6), the engine requests the privilege, and the grant - // lands moments later (121ms observed live 2026-08-20) followed by - // raw_media_ready. Routing it into the join-failure tail flipped a - // healthy joined session to Failed, which then gated start_engine, - // resubscription and recovery for the rest of the session. Never vote - // against the meeting -- unchanged from before this fix. - // - // What changed (live defect, 2026-09-05): this used to ALSO set - // m_last_error and fire the error-callback list, which is what pops - // the "Zoom Join" QMessageBox -- so a session working exactly as - // designed showed the operator a modal reading "raw recording - // failed". A pending grant is a STATE, not a failure, so it now goes - // to the separate notice-callback list instead (see NoticeCallback's - // doc comment in zoom-engine-client.h) and m_last_error is left - // untouched -- other code (e.g. the "left" handler's keep_failed - // check) reads that field as "the session actually failed", which - // this is not. src/zoom-privilege-notice.h picks the operator-facing - // copy and tells a first request apart from a repeat one (the engine - // only asks the host once per meeting; every later report is the same - // still-pending wait, distinguished only by its "detail" text). - if (emsg == "raw_media_start_failed" && - obj.value("privilege_requested").toBool()) { - const std::string detail = obj.value("detail").toString().toStdString(); - set_privilege_notice_and_notify(zoom_privilege_notice_text(detail)); - return; - } - if (emsg == "video_subscribe_failed") { + if (zoom_source_video_failure(emsg.toStdString())) { const uint32_t participant_id = static_cast(obj.value("participant_id").toInt()); const std::string uuid = obj.value("source_uuid").toString().toStdString(); - bool known = false; - std::string error_message; - std::vector error_callbacks; + bool first = false; + std::vector callbacks; + std::string notice; { std::lock_guard lk(m_mtx); - for (const auto &p : m_roster) { - if (p.user_id == participant_id) { - known = true; - break; - } - } - if (known) { - error_message = - "Zoom engine could not subscribe to video for source " + - (uuid.empty() ? std::string("(unknown)") : uuid); - m_last_error = error_message; - for (const auto &entry : m_error_callbacks) - if (entry.second) - error_callbacks.push_back(entry.second); + const bool known = std::any_of(m_roster.begin(), m_roster.end(), + [&](const ParticipantInfo &p) { return p.user_id == participant_id; }); + first = m_media_failures.fail(uuid, participant_id, + emsg.toStdString() + " (code " + std::to_string(obj.value("code").toInt()) + ")", + os_gettime_ns() / 1000000ULL, known); + if (first && m_privilege_notice.empty()) { + notice = m_media_failures.status(os_gettime_ns() / 1000000ULL); + for (const auto &entry : m_notice_callbacks) + if (entry.second) callbacks.push_back(entry.second); } } - if (!known) { - blog(LOG_INFO, - "[obs-zoom-plugin] Video subscribe for absent participant %u (source %s) - waiting for them to join", - participant_id, uuid.c_str()); - return; - } - for (const auto &cb : error_callbacks) cb(error_message); + for (const auto &cb : callbacks) cb(notice); return; } - const QString reason = obj.value("reason").toString(); - const int code = obj.value("code").toInt(0); - set_error_and_notify(zoom_error_message(obj)); - // Permanent failures: auth, license, host-ended. - if (cmd == "auth_fail" || reason == "auth_fail") { - m_state.store(MeetingState::Failed, std::memory_order_release); - ZoomReconnectManager::instance().on_join_failed(true); - } else if (obj.value("msg").toString() == "meeting_failed" && - is_permanent_meeting_failure(code)) { - m_state.store(MeetingState::Failed, std::memory_order_release); - blog(LOG_ERROR, - "[obs-zoom-plugin] Permanent Zoom meeting failure %d (%s) - not retrying", - code, reason.toUtf8().constData()); - ZoomReconnectManager::instance().on_join_failed(true); - } else if (reason == "license") { - m_state.store(MeetingState::Failed, std::memory_order_release); - ZoomReconnectManager::instance().trigger(RecoveryReason::LicenseError); - } else if (reason == "host_ended") { - ZoomReconnectManager::instance().trigger(RecoveryReason::HostEndedMeeting); - } else { - // Retriable failure — let reconnect manager decide. - if (!m_user_leaving.load(std::memory_order_acquire)) { - ZoomReconnectManager::instance().on_join_failed(false); - } else { - m_state.store(MeetingState::Failed, std::memory_order_release); - } - } + dispatch_zoom_engine_failure(cmd.toStdString(), emsg.toStdString(), + obj.value("privilege_requested").toBool(), + [&](bool pending) { + // Media failure never votes against meeting/reconnect state. + // Store terminal diagnostics separately: the left handler reads + // m_last_error as proof that the meeting failed. + m_media_active.store(false, std::memory_order_release); + const auto detail = obj.value("detail").toString().toStdString(); + const auto reason = obj.value("reason").toString(); + const auto message = pending ? zoom_privilege_notice_text(detail) : + (reason == "privilege_denied" + ? std::string("Recording permission denied. Ask the host to allow recording; media will start automatically when granted.") + : reason == "privilege_request_timeout" + ? std::string("Recording permission request timed out. Ask the host to allow recording, then click Retry Media.") + : (detail.empty() ? zoom_error_message(obj) : detail) + " Click Retry Media."); + { + std::lock_guard lk(m_mtx); + m_raw_media_error = pending ? std::string() : message; + } + set_privilege_notice_and_notify(message); + }, [&] { + const QString reason = obj.value("reason").toString(); + const int code = obj.value("code").toInt(0); + set_error_and_notify(zoom_error_message(obj)); + // Permanent failures: auth, license, host-ended. + if (cmd == "auth_fail" || reason == "auth_fail") { + m_state.store(MeetingState::Failed, std::memory_order_release); + ZoomReconnectManager::instance().on_join_failed(true); + } else if (obj.value("msg").toString() == "meeting_failed" && + is_permanent_meeting_failure(code)) { + m_state.store(MeetingState::Failed, std::memory_order_release); + blog(LOG_ERROR, + "[obs-zoom-plugin] Permanent Zoom meeting failure %d (%s) - not retrying", + code, reason.toUtf8().constData()); + ZoomReconnectManager::instance().on_join_failed(true); + } else if (reason == "license") { + m_state.store(MeetingState::Failed, std::memory_order_release); + ZoomReconnectManager::instance().trigger(RecoveryReason::LicenseError); + } else if (reason == "host_ended") { + ZoomReconnectManager::instance().trigger(RecoveryReason::HostEndedMeeting); + } else { + // Retriable failure — let reconnect manager decide. + if (!m_user_leaving.load(std::memory_order_acquire)) { + ZoomReconnectManager::instance().on_join_failed(false); + } else { + m_state.store(MeetingState::Failed, std::memory_order_release); + } + } + }); return; } @@ -1844,6 +1916,10 @@ void ZoomEngineClient::handle_event(const std::string &line) p.is_sharing_screen = po.value("is_sharing_screen").toBool(); m_roster.push_back(std::move(p)); } + m_media_failures.prune([&](uint32_t id) { + return std::any_of(m_roster.begin(), m_roster.end(), + [&](const ParticipantInfo &p) { return p.user_id == id; }); + }); SpeakerDirector::instance().update_roster( m_roster, m_active_speaker_id, os_gettime_ns() / 1000000ULL); }); @@ -1953,13 +2029,19 @@ uint32_t ZoomEngineClient::raw_active_speaker_id() const std::string ZoomEngineClient::last_error() const { std::lock_guard lk(m_mtx); - return m_last_error; + // Control status and diagnostics still expose terminal media failure, + // while internal meeting classification uses only m_last_error. + if (!m_last_error.empty()) return m_last_error; + if (!m_raw_media_error.empty()) return m_raw_media_error; + const auto now = os_gettime_ns() / 1000000ULL; + return m_media_failures.terminal(now) ? m_media_failures.status(now) : std::string(); } std::string ZoomEngineClient::pending_privilege_notice() const { std::lock_guard lk(m_mtx); - return m_privilege_notice; + if (!m_privilege_notice.empty()) return m_privilege_notice; + return m_media_failures.status(os_gettime_ns() / 1000000ULL); } void ZoomEngineClient::clear_last_error() diff --git a/src/zoom-engine-client.h b/src/zoom-engine-client.h index 7ccb550..d5d9ec9 100644 --- a/src/zoom-engine-client.h +++ b/src/zoom-engine-client.h @@ -2,6 +2,7 @@ #include "engine-ipc.h" #include "media-event-queue.h" +#include "media-failure-state.h" #include "talkback-nomination.h" #include "zoom-types.h" #include @@ -289,9 +290,12 @@ class ZoomEngineClient { return m_awaiting_admission.load(std::memory_order_acquire); } bool is_media_active() const { return m_media_active.load(std::memory_order_acquire); } + // Meeting error first, otherwise the terminal raw-media diagnostic. Internal + // meeting/leave classification deliberately reads only m_last_error. std::string last_error() const; void clear_last_error(); - // Empty when no record-privilege notice is pending. See + // Record-privilege notice or actionable terminal raw-media diagnostic. + // Empty when neither is pending. See // src/zoom-privilege-notice.h for what this state means and // add_notice_callback() below for how it is pushed. Exposed as a getter // too, mirroring last_error(), so a dock can resync on its own poll tick @@ -305,6 +309,12 @@ class ZoomEngineClient { void register_source(const std::string &source_uuid, SourceCallbacks callbacks); void unregister_source(const std::string &source_uuid); + // Capture before the shared-memory read, acknowledge only a successful + // read. Reassignment during the read invalidates the ticket. + bool source_media_failed(const std::string &uuid) const; + uint64_t media_delivery_ticket(const std::string &uuid, uint32_t participant) const; + void acknowledge_media_delivery(const std::string &uuid, uint32_t participant, uint64_t ticket); + using RosterCallback = std::function; // Roster callbacks are invoked on the engine reader thread with this // client's internal lock RELEASED, so a callback may call back into the @@ -449,6 +459,10 @@ class ZoomEngineClient { std::unordered_map m_error_callbacks; std::unordered_map m_notice_callbacks; std::string m_last_error; + // Terminal raw-media diagnostic, surfaced by last_error() only as fallback. + // Never consulted by meeting/reconnect classification. Guarded by m_mtx. + std::string m_raw_media_error; + MediaFailureState m_media_failures; // Empty when no record-privilege notice is pending. Deliberately NEVER // written to/from m_last_error -- see pending_privilege_notice()'s doc // comment and NoticeCallback's above. Guarded by m_mtx like m_last_error. diff --git a/src/zoom-engine-error-dispatch.h b/src/zoom-engine-error-dispatch.h new file mode 100644 index 0000000..efc477d --- /dev/null +++ b/src/zoom-engine-error-dispatch.h @@ -0,0 +1,30 @@ +#pragma once +#include + +// Actual participant-video errors emitted by the Mac engine. Both belong +// to source recovery; neither is a join/authentication failure. +inline bool zoom_source_video_failure(const std::string &message) +{ + return message == "video_subscribe_failed" || message == "raw_data_controller_unavailable"; +} + +inline bool zoom_persistent_source_media_failure(const std::string &message) +{ + return message == "shm_create_failed" || message == "subscribe_rejected" || + message == "shm_name_collision"; +} + +// The callback boundary keeps raw-media failures out of meeting/reconnect +// effects; callers retain their existing meeting-error classification. +template +inline void dispatch_zoom_engine_failure(const std::string &command, + const std::string &message, + bool privilege_requested, + MediaFailure media_failure, + MeetingFailure meeting_failure) +{ + if (command == "error" && message == "raw_media_start_failed") + media_failure(privilege_requested); + else + meeting_failure(); +} diff --git a/src/zoom-output-manager.cpp b/src/zoom-output-manager.cpp index 269a13f..cd70519 100644 --- a/src/zoom-output-manager.cpp +++ b/src/zoom-output-manager.cpp @@ -1,3 +1,5 @@ +#include "video-quality-policy.h" +#include "zoom-supersource.h" #include "zoom-output-manager.h" #include "zoom-engine-client.h" #include "zoom-output-health.h" @@ -40,7 +42,7 @@ void remember_quality_event(ZoomOutputInfo &info, info.last_set_resolution_code = json_int_or(obj, "code", info.last_set_resolution_code); const int resolution = json_int_or(obj, "resolution", -1); - if (resolution >= 0) + if (video_quality_request_accepted(json_int_or(obj, "code", -1), resolution)) info.negotiated_resolution = resolution; return; } @@ -49,7 +51,7 @@ void remember_quality_event(ZoomOutputInfo &info, info.last_video_subscribe_code = json_int_or(obj, "code", info.last_video_subscribe_code); const int resolution = json_int_or(obj, "resolution", -1); - if (resolution >= 0) + if (video_quality_request_accepted(json_int_or(obj, "code", -1), resolution)) info.negotiated_resolution = resolution; return; } @@ -67,8 +69,8 @@ void remember_quality_event(ZoomOutputInfo &info, static_cast(info.video_resolution)); if (actual >= 0) info.negotiated_resolution = actual; - if (actual >= 0 && requested >= 0 && actual < requested) - info.subscription_downgraded = true; + if (actual >= 0 && requested >= 0) + info.subscription_downgraded = actual < requested; return; } @@ -85,8 +87,8 @@ void remember_quality_event(ZoomOutputInfo &info, static_cast(info.video_resolution)); if (active >= 0) info.negotiated_resolution = active; - if (active >= 0 && requested >= 0 && active < requested) - info.subscription_downgraded = true; + if (active >= 0 && requested >= 0) + info.subscription_downgraded = active < requested; return; } } @@ -228,6 +230,13 @@ bool ZoomOutputManager::configure_output_ex(const std::string &source_name, return true; } +void ZoomOutputManager::retry_media() +{ + ZoomEngineClient::instance().start_media(); + resubscribe_all(); + zoom_supersource_retry_media(); +} + void ZoomOutputManager::resubscribe_all() { std::lock_guard lk(m_mtx); diff --git a/src/zoom-output-manager.h b/src/zoom-output-manager.h index c9d7e7b..2f18451 100644 --- a/src/zoom-output-manager.h +++ b/src/zoom-output-manager.h @@ -215,6 +215,8 @@ class ZoomOutputManager { // Re-send subscribe commands for all active sources after engine recovery. void resubscribe_all(); + // Shared dock/control manual action; automatic roster callbacks never call it. + void retry_media(); uint32_t recover_stale_sources(bool force = false); uint32_t upgrade_low_quality_sources(bool force = false); diff --git a/src/zoom-privilege-notice.h b/src/zoom-privilege-notice.h index 219f96d..bd5a4f0 100644 --- a/src/zoom-privilege-notice.h +++ b/src/zoom-privilege-notice.h @@ -2,76 +2,37 @@ #include -// The record-privilege handshake, and why the first half of it is not an -// error (live defect, 2026-09-05: starting the engine popped a modal "raw -// recording failed" dialog on a session that was working exactly as -// designed). -// -// Zoom's canStartRawRecording() comes back NoPermission for a participant who -// has not been granted local recording; the engine's own response is to call -// requestLocalRecordingPrivilege() and report -// "cmd":"error","msg":"raw_media_start_failed","privilege_requested":true -- -// see engine/src/main-macos.mm's handle_start_media(). The host sees a Zoom -// prompt, and once they grant it the SDK's own delegate callback restarts raw -// media and the engine reports "raw_media_ready". Nothing failed; this is the -// NORMAL first half of the handshake, and the operator's fix is just to click -// Start Engine again once the host has granted it. -// -// ZoomEngineClient::handle_event() already refuses to route this report into -// the join-failure/reconnect machinery -- a live incident once flipped a -// healthy joined session to Failed over exactly this report, which then gated -// start_engine, resubscription and recovery for the rest of the session (see -// the comment on that branch). What it got wrong downstream of that guard is -// the subject of this fix: it still set m_last_error and fired every -// registered error callback, which is what pops the "Zoom Join" -// QMessageBox -- the modal this header exists to stop. -// -// This header decides only the operator-facing TEXT and which of the two -// wire shapes a report is; the notice-vs-error plumbing (storage, callbacks, -// clearing on raw_media_ready) lives in zoom-engine-client.h/.cpp, which need -// the Qt JSON types this header stays free of, so the classification below -// can be host-tested without Qt or libobs. -// -// The engine's own retry loop (main-macos.mm's g_privilege_requested) asks -// the host only ONCE per meeting; every later cannot_start_raw_recording -// report is just a re-report of the SAME still-pending wait, and the engine -// marks it with a different "detail" string ("...was already requested and -// has not been granted.") rather than a separate machine-readable field. That -// substring is the only wire signal telling the two apart, so the -// classification below keys on it. - -// Whether `detail` (the engine's raw_media_start_failed "detail" field) says -// the privilege was already asked for this meeting and the host has not -// granted it yet -- i.e. a REPEAT report of the same still-pending wait, not -// a fresh request just sent to the host. +// Host permission grants restart media automatically. Repeated pending +// reports describe the same episode and must produce identical notice text. inline bool zoom_privilege_already_requested(const std::string &detail) { return detail.find("already requested") != std::string::npos; } -// Operator-facing copy for each half of the handshake. Short and actionable -// on purpose (the owner's ask, live 2026-09-05): name the ACTION, not the -// error code. "Start Engine" is named literally because that is the exact -// button label the operator has to press again. inline const char *zoom_privilege_notice_first_request() { - return "Waiting for the meeting host to approve recording. Once they " - "approve it, click Start Engine again."; + return "Waiting for the host to allow recording. Media will start automatically when permission is granted."; } - -// Firmer than the first-request copy on purpose: a repeat report means the -// host has not acted yet, so this is not a fresh ask -- it says so. inline const char *zoom_privilege_notice_still_pending() { - return "Still waiting on the host to approve recording -- nothing will " - "start until they do. Ask them to approve the Zoom prompt now, " - "then click Start Engine again."; + return zoom_privilege_notice_first_request(); +} +inline std::string zoom_privilege_notice_text(const std::string &) +{ + return zoom_privilege_notice_first_request(); } -// Picks the right copy for a raw_media_start_failed report's "detail" text. -inline std::string zoom_privilege_notice_text(const std::string &detail) +// These are raw_media_state wire values, distinct from terminal SDK failures. +inline std::string zoom_raw_media_state_notice(const std::string &state, const std::string &reason) { - return zoom_privilege_already_requested(detail) - ? zoom_privilege_notice_still_pending() - : zoom_privilege_notice_first_request(); + if (state == "denied") + return "Recording permission denied. Ask the host to allow recording; media will start automatically when granted."; + if (state == "waiting_permission") { + if (reason == "privilege_request_timeout") + return "Recording permission request timed out. Ask the host to allow recording. Media will start automatically when permission is granted."; + return zoom_privilege_notice_first_request(); + } + if (state == "recovering") return "Recovering Zoom media. Waiting for the meeting and recording permission."; + if (state == "starting") return "Starting Zoom media automatically."; + return {}; } diff --git a/src/zoom-source.cpp b/src/zoom-source.cpp index 9eb619a..5942eb2 100644 --- a/src/zoom-source.cpp +++ b/src/zoom-source.cpp @@ -1,4 +1,5 @@ #include "zoom-source.h" +#include "video-quality-policy.h" #include "director-handover.h" #include "director-preview-frame-guard.h" #include "shm-resubscribe.h" @@ -74,16 +75,6 @@ static uint64_t stale_recover_cooldown_ns(uint32_t attempts) return kStaleRecoverCooldownNs << shift; } static constexpr uint64_t kQualityUpgradeStableNs = 20'000'000'000ULL; -static constexpr uint64_t kQualityUpgradeBaseCooldownNs = 60'000'000'000ULL; - -static uint64_t quality_upgrade_cooldown_ns(uint32_t completed_attempts) -{ - // Keep trying during long meetings, but cap automatic retry pressure at - // eight minutes. A feed that reaches the requested resolution resets this. - const uint32_t shift = std::min(completed_attempts, 3); - return kQualityUpgradeBaseCooldownNs << shift; -} - static AudioChannelMode audio_mode_from_data(obs_data_t *s) { return obs_data_get_int(s, PROP_AUDIO_CHANNELS) == AUDIO_CH_STEREO @@ -381,7 +372,9 @@ static std::string recovery_status_label(const ZoomOutputInfo &info) return "Screen share follows Zoom's active share feed."; if (output_signal_below_requested(info)) { std::string text = "Using best available feed below requested canvas"; - if (info.quality_upgrade_cooldown_ms > 0) + if (!quality_upgrade_retry_allowed(info.quality_upgrade_attempts, false)) + text += "; automatic quality retries exhausted; manual retry available"; + else if (info.quality_upgrade_cooldown_ms > 0) text += "; next quality retry in " + std::to_string(info.quality_upgrade_cooldown_ms) + " ms"; else if (info.quality_upgrade_attempts > 0) @@ -632,7 +625,8 @@ ZoomOutputInfo ZoomSource::output_info() const } const uint64_t last_upgrade_ns = m_last_quality_upgrade_ns.load(std::memory_order_relaxed); - if (last_upgrade_ns != 0) { + if (last_upgrade_ns != 0 && quality_upgrade_retry_allowed( + m_quality_upgrade_attempts.load(std::memory_order_relaxed), false)) { const uint64_t upgrade_age_ns = now_ns > last_upgrade_ns ? now_ns - last_upgrade_ns : 0; const uint64_t cooldown_ns = quality_upgrade_cooldown_ns( @@ -1048,6 +1042,8 @@ bool ZoomSource::upgrade_low_quality_video(uint64_t now_ns, bool force) m_last_quality_upgrade_ns.load(std::memory_order_acquire); const uint32_t completed_attempts = m_quality_upgrade_attempts.load(std::memory_order_acquire); + if (!quality_upgrade_retry_allowed(completed_attempts, force)) + return false; if (!force && last_upgrade_ns != 0 && now_ns - last_upgrade_ns < quality_upgrade_cooldown_ns(completed_attempts)) return false; @@ -1419,6 +1415,7 @@ bool ZoomSource::output_video_from_shared_memory( uint32_t h = 0; uint32_t y_len = 0; uint64_t hdr_capture_ns = 0; + const uint64_t delivery_ticket = ZoomEngineClient::instance().media_delivery_ticket(uuid, resolved_participant_id); const ShmFrameRead read_status = shm_read_i420_frame(video_shm, IPC_SHM_PREFIX + uuid, event_width, event_height, event_shm_gen, video_shm_gen, @@ -1456,6 +1453,8 @@ bool ZoomSource::output_video_from_shared_memory( return false; } + ZoomEngineClient::instance().acknowledge_media_delivery(uuid, resolved_participant_id, delivery_ticket); + const auto *y_ptr = video_buf.data(); const auto *u_ptr = y_ptr + y_len; const auto *v_ptr = u_ptr + y_len / 4; diff --git a/src/zoom-supersource.cpp b/src/zoom-supersource.cpp index 61ca3e0..7fe93ed 100644 --- a/src/zoom-supersource.cpp +++ b/src/zoom-supersource.cpp @@ -181,6 +181,7 @@ struct TileFeed { // by mtx, which the sweep's scan already holds. `retry_epoch` records the // assignment these attempts were spent under, so repointing the slot hands // it a fresh budget instead of inheriting an exhausted one. + uint64_t last_frame_ns = 0; // successful SHM delivery; guarded by mtx uint64_t retry_epoch = 0; uint64_t last_retry_ns = 0; uint32_t retry_attempts = 0; @@ -422,6 +423,7 @@ static void tile_feed_on_frame(const TileFeedPtr &feed, uint32_t event_width, uint64_t epoch = 0; if (!feed->slot.begin_frame(event_participant_id, epoch)) return; + const uint64_t delivery_ticket = ZoomEngineClient::instance().media_delivery_ticket(feed->uuid, event_participant_id); std::lock_guard lock(feed->mtx); if (!feed->alive) return; @@ -436,6 +438,8 @@ static void tile_feed_on_frame(const TileFeedPtr &feed, uint32_t event_width, // Odd dimensions have no valid I420 chroma layout to sample. if ((w & 1u) || (h & 1u)) return; + ZoomEngineClient::instance().acknowledge_media_delivery(feed->uuid, event_participant_id, delivery_ticket); + feed->last_frame_ns = os_gettime_ns(); feed->width = w; feed->height = h; feed->frame_epoch = epoch; @@ -642,18 +646,25 @@ static void execute_feed_plan(const FeedPlan &plan) // Rate-limited across the source and attempt-bounded per slot — see // zoom-tile-retry.h for why both are needed, and for why the sweep is left on // every roster event rather than filtered to roster-only ones. -static void resubscribe_silent_feeds(tiles_source *ctx) +static void resubscribe_silent_feeds(tiles_source *ctx, + TileRetryTrigger trigger = TileRetryTrigger::Automatic) { // Claim the sweep before taking any lock. The whole point of the interval // is that a burst of roster events must not each acquire engine_mutex, // ctx->mutex and every feed->mtx on the engine reader thread. const uint64_t now_ns = os_gettime_ns(); uint64_t last_sweep = ctx->last_sweep_ns.load(std::memory_order_acquire); - if (!tile_sweep_due(now_ns, last_sweep)) return; - if (!ctx->last_sweep_ns.compare_exchange_strong(last_sweep, now_ns, - std::memory_order_acq_rel, - std::memory_order_acquire)) - return; // another sweep claimed this interval + if (trigger == TileRetryTrigger::Automatic) { + if (!tile_sweep_due(now_ns, last_sweep)) return; + if (!ctx->last_sweep_ns.compare_exchange_strong(last_sweep, now_ns, + std::memory_order_acq_rel, + std::memory_order_acquire)) + return; // another sweep claimed this interval + } else { + // The operator explicitly requested a new budget; roster/speaker + // callbacks never bypass either pacing bound. + ctx->last_sweep_ns.store(now_ns, std::memory_order_release); + } std::lock_guard engine_lock(ctx->engine_mutex); @@ -669,7 +680,10 @@ static void resubscribe_silent_feeds(tiles_source *ctx) // cleared when the draw path takes the pixels, so this stays true // for a healthy tile and flips back to "silent" on every repoint. const uint64_t epoch = feed->slot.epoch(); - if (feed->slot.frame_is_current_at(feed->frame_epoch, epoch)) { + const bool failed = trigger == TileRetryTrigger::Manual && + ZoomEngineClient::instance().source_media_failed(feed->uuid); + if (!tile_retry_needed(feed->slot.frame_is_current_at(feed->frame_epoch, epoch), + failed, trigger, now_ns, feed->last_frame_ns)) { // Healthy: a later silence gets a full budget again. feed->retry_epoch = epoch; feed->last_retry_ns = 0; @@ -686,8 +700,8 @@ static void resubscribe_silent_feeds(tiles_source *ctx) feed->retry_attempts = 0; feed->retry_exhausted_logged = false; } - if (!tile_retry_due(now_ns, feed->last_retry_ns, - feed->retry_attempts)) { + if (!tile_retry_claim(now_ns, feed->last_retry_ns, + feed->retry_attempts, trigger)) { if (feed->retry_attempts >= kTileRetryMaxAttempts && !feed->retry_exhausted_logged) { feed->retry_exhausted_logged = true; @@ -695,17 +709,16 @@ static void resubscribe_silent_feeds(tiles_source *ctx) } continue; } - feed->last_retry_ns = now_ns; - ++feed->retry_attempts; + feed->retry_exhausted_logged = false; retry.push_back(feed); } } // Issued outside ctx->mutex. The engine no-ops a subscribe that is already // active at this resolution, so this only revives dead slots. // - // The mapping is released first here too. A retried slot has, by definition, - // no frame accepted under its current assignment, so there is nothing to - // lose — but it may still hold a mapping (a frame that opened the region and + // The mapping is released first here too. Manual retry may retain an old + // displayed frame: release only unmaps SHM and preserves those pixels and + // assignment. A silent feed may also hold a mapping (a frame that opened the region and // then failed the header or size check leaves one behind). If the engine // finds this uuid's subscription dead it destroys and rebuilds the // SourceTarget, restarting at the legacy region name, and a mapping we still @@ -3502,6 +3515,33 @@ void zoom_supersource_set_collection_loading(bool loading) static_cast(tiles_sources.size())); } +void zoom_supersource_retry_media() +{ + // Enumerate on the UI thread, just like collection reconciliation. Strong + // OBS references keep ctx alive; the gate serializes with roster callbacks + // and destroy. No source/collection mutation occurs on an IPC worker. + obs_queue_task(OBS_TASK_UI, [](void *) { + if (s_collection_loading.load(std::memory_order_acquire)) return; + std::vector sources; + obs_enum_sources([](void *param, obs_source_t *src) { + const char *id = obs_source_get_id(src); + if (id && std::strcmp(id, kTilesSourceId) == 0 && !obs_source_removed(src)) { + if (auto *ref = obs_source_get_ref(src)) + static_cast *>(param)->push_back(ref); + } + return true; + }, &sources); + for (auto *src : sources) { + if (auto *ctx = static_cast(obs_obj_get_data(src))) { + std::lock_guard callback_lock(ctx->gate->mtx); + if (ctx->gate->alive && !obs_source_removed(src)) + resubscribe_silent_feeds(ctx, TileRetryTrigger::Manual); + } + obs_source_release(src); + } + }, nullptr, false); +} + void zoom_supersource_register() { obs_source_info info = {}; diff --git a/src/zoom-supersource.h b/src/zoom-supersource.h index ec4aa66..50da24d 100644 --- a/src/zoom-supersource.h +++ b/src/zoom-supersource.h @@ -29,3 +29,7 @@ void zoom_supersource_unload_gfx(); // reconcile on each, so nothing suppressed while it was on is lost. See // src/zoom-supersource.cpp's request_audio_reconcile for the full reasoning. void zoom_supersource_set_collection_loading(bool loading); + +// Explicit operator action only: reopen silent tiles' retry budgets and issue +// their next subscribe without clearing assignments or media failure state. +void zoom_supersource_retry_media(); diff --git a/src/zoom-tile-retry.h b/src/zoom-tile-retry.h index 5bc372f..c0f0f78 100644 --- a/src/zoom-tile-retry.h +++ b/src/zoom-tile-retry.h @@ -83,3 +83,27 @@ inline bool tile_retry_due(uint64_t now_ns, uint64_t last_retry_ns, if (now_ns <= last_retry_ns) return false; return now_ns - last_retry_ns >= tile_retry_cooldown_ns(attempts); } + +// Manual action is the only trigger that may reopen an exhausted budget. +// Claiming a retry changes pacing only, never assignment or delivery health. +enum class TileRetryTrigger { Automatic, Manual }; +inline bool tile_retry_claim(uint64_t now_ns, uint64_t &last_retry_ns, + uint32_t &attempts, TileRetryTrigger trigger) +{ + if (trigger == TileRetryTrigger::Manual) { + last_retry_ns = 0; + attempts = 0; + } + if (!tile_retry_due(now_ns, last_retry_ns, attempts)) return false; + last_retry_ns = now_ns; + ++attempts; + return true; +} + +inline bool tile_retry_needed(bool current_frame, bool current_failure, TileRetryTrigger trigger, + uint64_t now_ns = 0, uint64_t last_frame_ns = 0) +{ + const bool stale = now_ns > last_frame_ns && + now_ns - last_frame_ns >= kTileRetryBaseCooldownNs; + return !current_frame || (trigger == TileRetryTrigger::Manual && (current_failure || stale)); +} diff --git a/tests/engine-client-media-session-test.cpp b/tests/engine-client-media-session-test.cpp new file mode 100644 index 0000000..6e66bdf --- /dev/null +++ b/tests/engine-client-media-session-test.cpp @@ -0,0 +1,104 @@ +// Exercises the real JSON event boundary: a room joined report must not +// retire assignments that the engine restores without another subscribe. +#include "engine-ipc.h" +#include "media-event-queue.h" +#include "media-failure-state.h" +#include "talkback-nomination.h" +#include "zoom-types.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +// Test-only access to the reader boundary and offline running flag. +#define private public +#include "zoom-engine-client.h" +#undef private +#include "zoom-reconnect.h" +#include +#include +#include +#include + +// External host boundaries only. No engine is launched, OBS initialized, or +// reconnect timer scheduled. Client parsing/state/callbacks remain production. +extern "C" void blog(int, const char *, ...) {} +extern "C" uint64_t os_gettime_ns() { return 1000000000ULL; } +extern "C" void bfree(void *p) { free(p); } +obs_module_t *obs_current_module() { return nullptr; } +extern "C" char *obs_find_module_file(obs_module_t *, const char *) { return nullptr; } +ZoomReconnectManager::ZoomReconnectManager() = default; +ZoomReconnectManager::~ZoomReconnectManager() = default; +ZoomReconnectManager &ZoomReconnectManager::instance() { static ZoomReconnectManager r; return r; } +void ZoomReconnectManager::cancel() {} +void ZoomReconnectManager::clear_session() {} +void ZoomReconnectManager::on_join_success() {} +void ZoomReconnectManager::on_join_failed(bool) {} +void ZoomReconnectManager::trigger(RecoveryReason) {} +void ZoomReconnectManager::store_session(const std::string &, const std::string &, + const std::string &, const std::string &, MeetingKind, const ZoomJoinAuthTokens &) {} + +static int failures; +static void check(bool value, const char *message) { + if (!value) { std::fprintf(stderr, "FAIL: %s\n", message); ++failures; } +} +int main() { + auto &client = ZoomEngineClient::instance(); + client.m_running.store(true); // Invalid pipe drops writes; no launch needed. + auto event = [&](const char *json) { client.handle_event(json); }; + auto fail = [&] { event(R"({"cmd":"error","msg":"video_subscribe_failed","source_uuid":"fixed","participant_id":42,"code":1})"); }; + event(R"({"cmd":"participants","participants":[{"id":42,"name":"Panelist"}]})"); + client.register_source("fixed", {}); + client.subscribe("fixed", 42, false); + const auto ticket = client.media_delivery_ticket("fixed", 42); + check(ticket != 0, "subscription registers a delivery ticket"); + fail(); + check(client.source_media_failed("fixed"), "initial source failure recognized"); + int fatal_notices = 0; + int cleared_notices = 0; + client.add_error_callback(&failures, [&](const std::string &message) { + if (message.empty()) ++cleared_notices; else ++fatal_notices; + }); + event(R"({"cmd":"error","msg":"meeting_failed","code":4})"); + check(fatal_notices == 1 && !client.last_error().empty(), "fatal meeting error retains callback route"); + event(R"({"cmd":"joined"})"); + check(cleared_notices == 1 && client.last_error().empty(), "joined clears independent connection error"); + event(R"({"cmd":"joined"})"); + event(R"({"cmd":"debug","stage":"raw_media_ready"})"); + check(client.is_media_active(), "raw media readiness traverses production debug dispatch"); + check(client.source_media_failed("fixed"), "duplicate joined and room readiness retain unresolved failure"); + check(client.media_delivery_ticket("fixed", 42) == ticket, "engine restore retains assignment without client subscribe"); + client.acknowledge_media_delivery("fixed", 42, ticket); + check(!client.source_media_failed("fixed"), "acknowledged delivery clears recovered video"); + fail(); + check(client.source_media_failed("fixed"), "later engine-side restore failure still recognized"); + client.acknowledge_media_delivery("fixed", 42, ticket); + event(R"({"cmd":"error","msg":"shm_create_failed","source_uuid":"fixed"})"); + event(R"({"cmd":"joined"})"); + check(client.source_media_failed("fixed"), "joined retains persistent source error"); + client.unregister_source("fixed"); + check(!client.source_media_failed("fixed"), "source removal retires persistent failure"); + client.subscribe("fixed", 42, false); + fail(); + event(R"({"cmd":"left"})"); + check(!client.source_media_failed("fixed") && !client.media_delivery_ticket("fixed", 42), "true leave resets failure and assignment"); + event(R"({"cmd":"joined"})"); + check(!client.media_delivery_ticket("fixed", 42), "new joined does not resurrect old session assignment"); + event(R"({"cmd":"participants","participants":[{"id":42,"name":"Panelist"}]})"); + client.subscribe("fixed", 42, false); + fail(); + check(client.source_media_failed("fixed"), "new session subscription recognizes failures"); + client.join("123", "", "test", MeetingKind::Meeting, {}); + check(!client.source_media_failed("fixed") && !client.media_delivery_ticket("fixed", 42), "explicit new join resets old media session"); + client.subscribe("fixed", 42, false); + fail(); + client.remove_error_callback(&failures); + client.m_running.store(false); + client.stop(); + check(!client.source_media_failed("fixed") && !client.media_delivery_ticket("fixed", 42), "engine stop resets media session"); + return failures ? 1 : 0; +} diff --git a/tests/join-decision-test.cpp b/tests/join-decision-test.cpp index 1652867..bfe8d59 100644 --- a/tests/join-decision-test.cpp +++ b/tests/join-decision-test.cpp @@ -2,6 +2,7 @@ // Verifies the decision state machine and the error catalog without touching // Qt, OBS, the Zoom SDK, or any real secret. #include "zoom-join-decision.h" +#include "zoom-engine-error-dispatch.h" #include #include @@ -245,6 +246,40 @@ int main() "log: blocking error present"); } + // Exercise the same media/meeting callback boundary used by handle_event. + // A reconnect callback consuming its budget is the forbidden side effect. + for (bool recovering : {false, true}) { + bool in_meeting = true; + int reconnect_attempts = recovering ? 2 : 0; + const int before = reconnect_attempts; + int media_failures = 0; + dispatch_zoom_engine_failure("error", "raw_media_start_failed", false, + [&](bool) { ++media_failures; }, + [&] { in_meeting = false; ++reconnect_attempts; }); + check(in_meeting && reconnect_attempts == before && media_failures == 1, + "terminal raw-media failure stays joined and preserves reconnect budget"); + } + for (const std::string command : {"error", "auth_fail"}) { + int meeting_failures = 0; + dispatch_zoom_engine_failure(command, "meeting_failed", false, + [&](bool) { check(false, "real meeting failures cannot enter media handler"); }, + [&] { ++meeting_failures; }); + check(meeting_failures == 1, "actual meeting/auth failure still classified by meeting handler"); + } + + { + int pending_notices = 0, meeting_failures = 0; + dispatch_zoom_engine_failure("error", "raw_media_start_failed", true, + [&](bool pending) { if (pending) ++pending_notices; }, + [&] { ++meeting_failures; }); + check(pending_notices == 1 && meeting_failures == 0, + "pending permission still reaches only the media notice callback"); + dispatch_zoom_engine_failure("auth_fail", "raw_media_start_failed", false, + [&](bool) { check(false, "auth command cannot be hidden by a media-shaped message"); }, + [&] { ++meeting_failures; }); + check(meeting_failures == 1, "auth command retains meeting failure authority"); + } + // ── Redaction helper edge cases ────────────────────────────────────────── check(redacted_tail("") == "(empty)", "redact: empty"); check(redacted_tail("ab") == "****", "redact: short"); diff --git a/tests/macos-admission-state-test.mm b/tests/macos-admission-state-test.mm new file mode 100644 index 0000000..20d1fa7 --- /dev/null +++ b/tests/macos-admission-state-test.mm @@ -0,0 +1,102 @@ +// Pins the real macOS SDK meeting-status translation that feeds the join +// watchdog. This is Objective-C++ so the symbolic ZoomSDK enum values are +// compiled from the installed framework rather than duplicated as integers. +#import + +#include "macos-admission-state.h" +#include "join-watchdog.h" + +#include +#include +#include + +static int failures = 0; + +static void check(bool ok, const char *message) +{ + if (!ok) { + std::cerr << "FAIL: " << message << "\n"; + ++failures; + } +} + +int main() +{ + bool awaiting_admission = false; + std::vector delivered; + const auto deliver_status = [&](ZoomSDKMeetingStatus status) { + delivered.clear(); + macos_dispatch_meeting_status( + status, + [&](const std::string &event) { + delivered.push_back(event); + awaiting_admission = + event.find("\"active\":true") != std::string::npos; + }, + [&] { + if (status == ZoomSDKMeetingStatus_InMeeting) + delivered.emplace_back("joined-handler"); + else if (status == ZoomSDKMeetingStatus_Ended || + status == ZoomSDKMeetingStatus_Failed || + status == ZoomSDKMeetingStatus_Disconnecting) + delivered.emplace_back("terminal-handler"); + else + delivered.emplace_back("status-handler"); + }); + }; + + deliver_status(ZoomSDKMeetingStatus_WaitingForHost); + check(delivered.size() == 2 && delivered[0] == + R"({"cmd":"awaiting_admission","active":true})" && + delivered[1] == "status-handler", + "WaitingForHost did not produce the production admission-wait event"); + check(join_watchdog_action(true, false, 1000, 181000, false, + awaiting_admission, + kJoinWatchdogTimeoutMs) == + JoinWatchdogAction::HoldWindow, + "waiting for the host at 180 seconds did not hold the watchdog"); + + deliver_status(ZoomSDKMeetingStatus_InWaitingRoom); + check(delivered.size() == 2 && delivered[0] == + R"({"cmd":"awaiting_admission","active":true})" && + delivered[1] == "status-handler", + "InWaitingRoom did not produce the production admission-wait event"); + + const uint64_t admitted_at = 200000; + deliver_status(ZoomSDKMeetingStatus_Connecting); + check(delivered.size() == 2 && delivered[0] == + R"({"cmd":"awaiting_admission","active":false})" && + delivered[1] == "status-handler", + "leaving an admission wait did not clear the production event"); + check(join_watchdog_action(true, false, admitted_at, + admitted_at + kJoinWatchdogTimeoutMs, false, + awaiting_admission, + kJoinWatchdogTimeoutMs) == JoinWatchdogAction::None, + "admission did not receive a fresh 120-second watchdog window"); + check(join_watchdog_action(true, false, admitted_at, + admitted_at + kJoinWatchdogTimeoutMs + 1, false, + awaiting_admission, + kJoinWatchdogTimeoutMs) == JoinWatchdogAction::Fire, + "a genuine post-admission stall did not time out"); + + deliver_status(ZoomSDKMeetingStatus_InWaitingRoom); + deliver_status(ZoomSDKMeetingStatus_Ended); + check(!awaiting_admission, + "leaving a meeting retained the previous admission-wait state"); + check(delivered.size() == 2 && delivered[0] == + R"({"cmd":"awaiting_admission","active":false})" && + delivered[1] == "terminal-handler", + "terminal handling ran before the admission state was cleared"); + deliver_status(ZoomSDKMeetingStatus_WaitingForHost); + deliver_status(ZoomSDKMeetingStatus_InMeeting); + check(!awaiting_admission, + "a leave/rejoin cycle retained admission state after joining"); + check(delivered.size() == 2 && delivered[0] == + R"({"cmd":"awaiting_admission","active":false})" && + delivered[1] == "joined-handler", + "joined handling ran before the admission state was cleared"); + + if (failures == 0) + std::cout << "macos-admission-state: all tests passed\n"; + return failures == 0 ? 0 : 1; +} diff --git a/tests/media-failure-state-test.cpp b/tests/media-failure-state-test.cpp new file mode 100644 index 0000000..1a8c0e7 --- /dev/null +++ b/tests/media-failure-state-test.cpp @@ -0,0 +1,69 @@ +#include "media-failure-state.h" +#include "zoom-engine-error-dispatch.h" +#include +#include +static void check(bool value, const char *name) { if (!value) { std::cerr << name << '\n'; std::exit(1); } } +int main() { + check(zoom_source_video_failure("raw_data_controller_unavailable"), "raw controller unavailable belongs to source media recovery"); + check(!zoom_source_video_failure("meeting_failed") && !zoom_source_video_failure("auth_fail"), "meeting/auth errors retain fatal classification"); + MediaFailureState state; + int diagnostics = 0, notices = 0, fatal = 0; + for (int i=1; i<=8; ++i) state.assign(std::to_string(i), i); + for (int batch=0; batch<3; ++batch) for (int i=1; i<=8; ++i) { + ++diagnostics; + if (state.fail(std::to_string(i), i, "SDK code 5", 100 + batch, true)) ++notices; + } + check(diagnostics == 24 && notices == 1, "24 diagnostics produce one episode notice"); + check(state.size() == 8 && state.terminal(103), "three failed attempts are actionable"); + const auto old = state.ticket("1", 1); + state.assign("1", 9); + state.fail("1", 9, "SDK code 5", 104, true); + state.delivered("1", 1, old); + check(state.size() == 8, "stale assignment cannot recover source"); + state.delivered("1", 9, state.ticket("1",9)); + check(state.size() == 7, "one success does not clear other sources"); + state.remove("2"); + check(state.size() == 6, "removal clears only that source"); + state.fail("unknown", 55, "SDK code 5", 105, true); + state.fail("3", 3, "SDK code 5", 105, false); + check(state.size() == 5, "unknown ignored; absent participant clears obsolete failure"); + dispatch_zoom_engine_failure("error", "connection_failed", false, + [&](bool){ check(false, "fatal routed to media"); }, [&]{ ++fatal; }); + check(fatal == 1 && state.size() == 5, "independent fatal is never swallowed"); + for (int i=4; i<=8; ++i) state.remove(std::to_string(i)); + check(state.status(106).empty(), "all sources gone clears aggregate"); + state.assign("new", 10); + check(state.fail("new",10,"SDK code 7",200,true), "new episode emits new notice"); + check(!state.terminal(201) && state.terminal(10200), "persistent single failure times out to actionable status"); + state.reset(); + check(state.status(10201).empty(), "meeting reset clears failures"); + state.assign("auto", 0); + state.fail("auto", 42, "SDK code 5", 20000, true); + state.delivered("auto", 41, state.ticket("auto",41)); + check(state.size() == 1, "dynamic source requires matching failure participant"); + state.delivered("auto", 42, state.ticket("auto",42)); + check(state.size() == 0, "dynamic source success clears failure"); + state.assign("stable", 22); + state.fail("stable",22,"SDK code 5",21000,true); + state.assign("stable",22); + check(state.size() == 1, "retry subscribe is not recovery"); + state.delivered("stable",22,0); + check(state.size() == 1, "unsuccessful/missing read ticket cannot recover"); + state.prune([](uint32_t){ return false; }); + check(state.size() == 0, "roster removal clears obsolete participant failure"); + for (const auto *kind : {"shm_create_failed", "subscribe_rejected", "shm_name_collision"}) + check(zoom_persistent_source_media_failure(kind), "known persistent media errors have dedicated route"); + check(!zoom_persistent_source_media_failure("meeting_failed"), "persistent media classifier excludes meeting failure"); + state.assign("sticky", 33); + check(state.persistent_fail("sticky","audio memory failed",true), "first persistent media episode notice"); + check(!state.persistent_fail("sticky","audio memory failed",true), "persistent duplicates do not notify"); + state.delivered("sticky",33,state.ticket("sticky",33)); + check(state.terminal(22000), "video success never clears audio/shared memory failure"); + state.persistent_fail("unknown","ignored",false); + state.remove("sticky"); + check(state.status(22000).empty(), "removal clears persistent source diagnostic, unknown excluded"); + state.persistent_fail("stopped","shared memory failure",true); + state.stop(); + check(state.status(22000).empty(), "explicit stop retires persistent diagnostics"); + std::cout << "media-failure-state: all checks passed\n"; +} diff --git a/tests/output-health-test.cpp b/tests/output-health-test.cpp index 3e4b734..fdcf627 100644 --- a/tests/output-health-test.cpp +++ b/tests/output-health-test.cpp @@ -94,6 +94,15 @@ int main() ZoomOutputHealthReason::ZoomDeliveredLowerResolution)) return 1; + low.negotiated_resolution = static_cast(VideoResolution::P1080); + low.last_set_resolution_code = 0; + low.last_video_subscribe_code = 0; + low.quality_upgrade_attempts = 3; + if (!expect_reason("accepted HD request still delivering fresh 360p", low, + {participant(1)}, true, + ZoomOutputHealthReason::ZoomDeliveredLowerResolution)) + return 1; + ZoomOutputInfo exact_1080 = output(); exact_1080.video_resolution = VideoResolution::P1080; exact_1080.observed_width = 1920; diff --git a/tests/raw-media-lifecycle-test.cpp b/tests/raw-media-lifecycle-test.cpp new file mode 100644 index 0000000..1c3dcf1 --- /dev/null +++ b/tests/raw-media-lifecycle-test.cpp @@ -0,0 +1,125 @@ +#include "raw-media-lifecycle.h" +#include "meeting-callback-epoch.h" +#include +#include +#include +#include + +static int failures = 0; +static void check(bool ok, const char *message) { + if (!ok) { std::fprintf(stderr, "FAIL: %s\n", message); ++failures; } +} +// Count external effects, including the CURRENT desired output set at restore. +// Removing a binding while suspended must not be undone by a captured retry set. +struct Harness { + RawMediaLifecycle lifecycle; + int checks = 0, requests = 0, starts = 0, restores = 0, suspends = 0, errors = 0; + bool permission = false, start_ok = true; + std::set desired{"A", "B"}, restored; + void send(RawMediaEvent e) { + switch (lifecycle.on(e)) { + case RawMediaAction::Check: ++checks; send(permission ? RawMediaEvent::CheckReady : RawMediaEvent::NoPermission); break; + case RawMediaAction::Request: ++requests; break; + case RawMediaAction::Start: ++starts; send(start_ok ? RawMediaEvent::StartSucceeded : RawMediaEvent::StartFailed); break; + case RawMediaAction::Restore: ++restores; restored = desired; break; + case RawMediaAction::Suspend: ++suspends; restored.clear(); break; + case RawMediaAction::Fail: ++errors; break; + default: break; + } + } + void join() { send(RawMediaEvent::InMeeting); } + void grant() { permission = true; send(RawMediaEvent::Grant); } +}; +int main() { + { + Harness h; h.join(); h.send(RawMediaEvent::Start); h.send(RawMediaEvent::Start); + check(h.requests == 1 && h.checks == 1 && h.starts == 0, "duplicate pending Start has one host request and one check"); + check(std::string(h.lifecycle.state()) == "waiting_permission", "pending remains visible"); + h.send(RawMediaEvent::Denied); h.grant(); h.grant(); + check(h.starts == 1 && h.restores == 1, "denial then both grant callbacks start and restore once"); + } + { + Harness h; h.join(); h.send(RawMediaEvent::Start); h.send(RawMediaEvent::Stop); h.grant(); + check(h.starts == 0 && h.restores == 0, "late grant cannot resurrect explicit Stop"); + } + { + Harness h; h.join(); h.permission = true; h.send(RawMediaEvent::Start); + h.send(RawMediaEvent::Transition); h.send(RawMediaEvent::Transition); + h.desired.erase("B"); h.grant(); + check(h.starts == 1 && h.suspends == 1, "transition suspends once and grant cannot start inside transfer"); + h.join(); h.join(); + check(h.checks == 2 && h.starts == 2 && h.restores == 2, "room readiness rechecks and restores exactly once"); + check(h.restored == std::set{"A"}, "restore uses current assignments after source removal"); + } + { + Harness h; h.join(); h.permission = true; h.send(RawMediaEvent::Start); + h.send(RawMediaEvent::Denied); + check(h.suspends == 1 && std::string(h.lifecycle.state()) == "denied", "revocation invalidates active readiness"); + h.grant(); h.grant(); + check(h.starts == 2 && h.restores == 2, "revocation then grant recovers once"); + } + { + Harness h; h.join(); h.permission = true; h.start_ok = false; h.send(RawMediaEvent::Start); + for (int i = 0; i < 20; ++i) { h.grant(); h.join(); } + check(h.starts == 1 && h.errors == 1 && h.restores == 0, "permanent start failure has finite attempts and terminal error"); + check(std::string(h.lifecycle.state()) == "failed", "terminal failure remains visible"); + } + { + Harness h; h.join(); h.send(RawMediaEvent::Start); h.send(RawMediaEvent::Reset); h.grant(); h.join(); + check(h.starts == 0, "replacement meeting cancels old intent"); + } + { + Harness h; h.join(); h.send(RawMediaEvent::Start); h.send(RawMediaEvent::Timeout); + check(std::string(h.lifecycle.state()) == "waiting_permission", "timeout is still awaiting host permission, not denial"); + h.grant(); h.send(RawMediaEvent::Timeout); + check(h.starts == 1 && h.suspends == 0 && std::string(h.lifecycle.state()) == "active", "stale request timeout cannot revoke a grant"); + } + { + Harness h; h.send(RawMediaEvent::Start); h.grant(); + check(h.starts == 0 && h.checks == 0, "Start and grant wait for actual meeting readiness"); + h.join(); + check(h.starts == 1, "queued Start runs once meeting becomes ready"); + } + { + Harness h; h.join(); h.send(RawMediaEvent::Start); h.send(RawMediaEvent::Stop); + h.send(RawMediaEvent::Start); + check(h.requests == 1, "manual Stop/Start cannot repeatedly prompt the host"); + h.send(RawMediaEvent::Reset); h.join(); h.send(RawMediaEvent::Start); + check(h.requests == 2, "a replacement meeting has its own request budget"); + } + { + RawMediaLifecycle lifecycle; + lifecycle.on(RawMediaEvent::InMeeting); + lifecycle.on(RawMediaEvent::Start); + check(lifecycle.on(RawMediaEvent::CheckFailed) == RawMediaAction::Fail, + "non-permission check failure reports a terminal error without asking host"); + check(lifecycle.on(RawMediaEvent::Grant) == RawMediaAction::None, + "unsolicited grant cannot retry a permanent check failure"); + } + { + MeetingCallbackEpoch epoch; + Harness h; h.join(); h.permission = true; h.send(RawMediaEvent::Start); + const auto old = epoch.capture(); + epoch.leave(); // explicit leave invalidates already-queued statuses + h.send(RawMediaEvent::Reset); + int announced_joined = 0; + epoch.deliver(old, false, [&] { ++announced_joined; h.join(); }); + check(announced_joined == 0, "queued pre-leave InMeeting cannot announce joined"); + epoch.deliver(epoch.capture(), false, [&] { ++announced_joined; h.join(); }); + check(announced_joined == 0, "InMeeting received after Leave cannot announce joined while awaiting Ended"); + int left_acknowledgements = 0; + epoch.deliver(epoch.capture(), true, [&] { ++left_acknowledgements; }); + check(left_acknowledgements == 1, "current post-Leave terminal status can acknowledge departure"); + epoch.begin(); // accepted replacement join + h.join(); h.send(RawMediaEvent::Start); + int old_terminal_effects = 0; + for (int i = 0; i < 3; ++i) // old Disconnecting, Ended, Failed + epoch.deliver(old, true, [&] { ++old_terminal_effects; h.send(RawMediaEvent::Reset); }); + check(old_terminal_effects == 0 && std::string(h.lifecycle.state()) == "active", + "queued old terminal statuses cannot stop the replacement meeting"); + int current_effects = 0; + epoch.deliver(epoch.capture(), false, [&] { ++current_effects; }); + check(current_effects == 1, "current SDK status still reaches the main-queue handler"); + } + return failures ? EXIT_FAILURE : EXIT_SUCCESS; +} diff --git a/tests/speaker-director-test.cpp b/tests/speaker-director-test.cpp index d964993..1db8e2b 100644 --- a/tests/speaker-director-test.cpp +++ b/tests/speaker-director-test.cpp @@ -87,6 +87,42 @@ int main() director.tick(t); if (!check_directed(302)) fail("did not switch after hold expired"); + // --- A timestamp sampled before another caller acquired the director + // mutex must not wrap the unsigned elapsed clocks forward. Production + // callers sample before locking, so an older tick can arrive after a + // newer roster update under contention. --- + director.reset(); + director.configure(250, 1200, true, {}); + director.update_roster(roster({p(321, true, true), p(322, true, true)}), + 321, 1000); + director.update_roster(roster({p(321, true, true), p(322, true, true)}), + 322, 1001); + if (director.tick(999)) + fail("stale tick bypassed sensitivity and hold"); + if (!check_directed(321)) + fail("stale tick promoted the pending candidate"); + auto promotion = director.snapshot(999).last_promotion; + if (promotion.reason != SpeakerPromotionReason::Automatic || + promotion.promoted_speaker_id != 321 || promotion.session_id == 0 || + promotion.sequence != 1) + fail("initial automatic promotion attribution is incomplete"); + + // A stale roster callback still applies its fresh roster payload, but its + // old sample cannot start a new candidate clock in the past. + director.update_roster(roster({p(321, true, true), p(323, true, true)}), + 323, 900); + if (director.tick(1249)) + fail("stale roster update moved the candidate clock backwards"); + if (!director.tick(2200) || !check_directed(323)) + fail("candidate did not promote after sensitivity and hold"); + promotion = director.snapshot(2200).last_promotion; + if (promotion.reason != SpeakerPromotionReason::Automatic || + promotion.effective_sensitivity_ms != 250 || + promotion.effective_hold_ms != 1200 || + promotion.candidate_age_ms != 1199 || + promotion.incumbent_held_ms != 1200) + fail("automatic promotion timing attribution is wrong"); + // --- Manual take supersedes everything --- director.reset(); director.configure(100, 50, true, {}); // short hold @@ -95,9 +131,15 @@ int main() if (!check_directed(401)) fail("initial"); t += 50; - bool took = director.set_manual_speaker(402, t); + bool took = director.set_manual_speaker(402, 39999); if (!took) fail("manual take should succeed"); if (!check_directed(402)) fail("manual take did not promote 402"); + promotion = director.snapshot(t).last_promotion; + if (promotion.reason != SpeakerPromotionReason::ManualTake || + promotion.promoted_at_ms != 40000 || + promotion.effective_sensitivity_ms != 0 || + promotion.effective_hold_ms != 0) + fail("manual promotion attribution is wrong"); // Even if raw speaker changes, manual should stick t += 100; @@ -188,6 +230,12 @@ int main() director.configure(500, 2000, true, {1301}); director.update_roster(flap_roster, 1301, t); if (!check_directed(1302)) fail("first exclusion should still cut away immediately"); + promotion = director.snapshot(t).last_promotion; + if (promotion.reason != SpeakerPromotionReason::ForcedVacancy || + promotion.effective_sensitivity_ms != 0 || + promotion.effective_hold_ms != 0 || + promotion.incumbent_held_ms != 10) + fail("immediate forced-vacancy attribution is wrong"); // The flap: 0.3 s later the exclusion set swings onto the NEW on-air // speaker. Leaving 1302 is still immediate, but the replacement has not @@ -205,6 +253,12 @@ int main() t += 500; if (!director.tick(t)) fail("replacement should promote once sensitivity elapsed"); if (!check_directed(1301)) fail("did not promote the vetted replacement"); + promotion = director.snapshot(t).last_promotion; + if (promotion.reason != SpeakerPromotionReason::ForcedVacancy || + promotion.effective_sensitivity_ms != 500 || + promotion.effective_hold_ms != 0 || + promotion.incumbent_held_ms != 800) + fail("rate-limited forced-vacancy attribution is wrong"); // --- The replacement chosen after a rate-limited forced vacancy has to be // STABLE: half a second of cross-talk or a cough must never reach the diff --git a/tests/tile-retry-test.cpp b/tests/tile-retry-test.cpp index 91dafe0..cb32354 100644 --- a/tests/tile-retry-test.cpp +++ b/tests/tile-retry-test.cpp @@ -7,6 +7,7 @@ // dispatches frames for every source in the plugin. #include "zoom-tile-retry.h" +#include "media-failure-state.h" #include @@ -104,6 +105,42 @@ int main() "an hour of events at 10/s yields exactly kTileRetryMaxAttempts retries"); } + // Active media Start is a no-op, so an explicit action must reopen the + // independent tile budget. Ordinary roster/speaker ticks cannot do this. + { + uint64_t last = 100 * kSec; + uint32_t attempts = kTileRetryMaxAttempts; + MediaFailureState health; + health.assign("tile", 42); + health.fail("tile", 42, "video_subscribe_failed", 1, true); + int subscriptions = 0; + auto retry = [&](TileRetryTrigger trigger, uint64_t now) { + if (tile_retry_claim(now, last, attempts, trigger)) { + ++subscriptions; + health.assign("tile", 42); // production subscribe preserves health + } + }; + for (int i = 0; i < 100; ++i) retry(TileRetryTrigger::Automatic, (1000+i)*kSec); + check(subscriptions == 0, "exhausted automatic ticks never reset budget"); + const auto ticket = health.ticket("tile", 42); + retry(TileRetryTrigger::Manual, 1100*kSec); + check(subscriptions == 1 && attempts == 1, "manual action issues one retry from exhausted budget"); + check(health.size() == 1 && ticket == health.ticket("tile",42), "manual retry preserves assignment and actual failure"); + retry(TileRetryTrigger::Automatic, 1100*kSec+1); + check(subscriptions == 1, "automatic tick after manual retry observes cooldown"); + for (int i = 1; i <= 100; ++i) retry(TileRetryTrigger::Automatic, (1100+i*200)*kSec); + check(subscriptions == static_cast(kTileRetryMaxAttempts), "manual action grants one bounded budget"); + health.delivered("tile",42,ticket); + check(health.size() == 0, "only delivery clears recovered tile failure"); + } + + check(tile_retry_needed(true, true, TileRetryTrigger::Manual), "manual retry revives failed tile retaining displayed frame"); + check(!tile_retry_needed(true, false, TileRetryTrigger::Manual), "manual retry skips healthy displayed tile"); + check(!tile_retry_needed(true, true, TileRetryTrigger::Automatic), "ordinary ticks do not reopen displayed-tile retry budget"); + + check(tile_retry_needed(true, false, TileRetryTrigger::Manual, 30*kSec, 10*kSec), "manual retry revives stale displayed frame without an SDK error"); + check(!tile_retry_needed(true, false, TileRetryTrigger::Manual, 15*kSec, 10*kSec), "fresh displayed frame is not needlessly retried"); + if (g_failures > 0) { std::cerr << "tile-retry: " << g_failures << " failure(s)\n"; return 1; diff --git a/tests/video-quality-policy-test.cpp b/tests/video-quality-policy-test.cpp new file mode 100644 index 0000000..2fe2c12 --- /dev/null +++ b/tests/video-quality-policy-test.cpp @@ -0,0 +1,106 @@ +#include "video-quality-policy.h" +#include +#include +#include +#include + +struct Target { uint32_t requested_resolution; }; +using Targets = std::map; +int main() +{ + int failures = 0; + auto check = [&](bool ok, const char *why) { + if (!ok) { std::cerr << "FAIL: " << why << '\n'; ++failures; } + }; + Targets targets{{"tile", {0}}, {"source", {2}}}; + check(shared_video_requested_resolution(targets, 0) == 2, + "deferred/recovery order must preserve maximum HD intent"); + check(shared_video_requested_resolution(targets, 2) == 2, + "reverse deferred order must request HD"); + uint32_t accepted = 0; + int calls = 0; + shared_video_upgrade_resolution(accepted, 2, [&](uint32_t r) { + ++calls; check(r == 2, "upgrade must request HD"); return 0; + }); + check(accepted == 2 && calls == 1, "360 tile then HD source must upgrade in place"); + shared_video_upgrade_resolution(accepted, 0, [&](uint32_t) { ++calls; return 0; }); + check(accepted == 2 && calls == 1, "HD source then tile must preserve warm HD"); + accepted = 0; + shared_video_upgrade_resolution(accepted, 2, [&](uint32_t) { ++calls; return 5; }); + check(accepted == 0 && calls == 2, "refused upgrade must retain working quality"); + + targets.erase("source"); + check(shared_video_requested_resolution(targets, 0) == 0, + "removed or rebound HD source must not influence recovery"); + targets["tile"].requested_resolution = 1; + check(shared_video_requested_resolution(targets, 1) == 1, + "updated target intent must replace previous request"); + check(!video_quality_request_accepted(5, 2), "SDK refusal must not advertise accepted HD"); + check(!video_quality_request_accepted(-1, 2), "missing SDK result must not advertise success"); + check(video_quality_request_accepted(0, 2), "successful SDK request is accepted"); + for (uint32_t i = 0; i < 3; ++i) + check(quality_upgrade_retry_allowed(i, false), "first three automatic attempts allowed"); + check(!quality_upgrade_retry_allowed(3, false), "automatic retry must stop after three attempts"); + check(!quality_upgrade_retry_allowed(1000, false), "long meetings cannot restart exhausted budget"); + check(quality_upgrade_retry_allowed(3, true), "manual retry remains available"); + check(quality_upgrade_cooldown_ns(1) == 120'000'000'000ULL && + quality_upgrade_cooldown_ns(2) == 240'000'000'000ULL, + "automatic attempts retain existing backoff"); + // A single recovery subscribe serves both retained source UUIDs. Their + // historical accepted quality must both be replaced by the new fallback. + struct RetainedTarget { + uint32_t requested_resolution; + uint32_t shm_gen; + uint64_t frame_count; + }; + struct Subscription { + uint32_t participant_id = 42; + uint32_t resolution = 2; + int renderer = 7; + std::map targets{ + {"fixed", {2, 11, 120}}, {"tile", {0, 12, 240}}}; + } recovered; + std::vector events; + shared_video_accept_resolution(recovered, 0, + [&](const std::string &event) { events.push_back(event); }); + check(events.size() == 2, + "recovery must publish fallback quality to every retained target"); + if (events.size() == 2) { + check(events[0] == R"({"cmd":"debug","stage":"video_source_bound","source_uuid":"fixed","participant_id":42,"requested":2,"actual":0})", + "HD target must replace historical accepted1080 with fallback360"); + check(events[1] == R"({"cmd":"debug","stage":"video_source_bound","source_uuid":"tile","participant_id":42,"requested":0,"actual":0})", + "tile must receive fallback360 with its own requested quality"); + } + check(recovered.resolution == 0 && recovered.renderer == 7 && + recovered.targets.size() == 2 && + recovered.targets.at("fixed").shm_gen == 11 && + recovered.targets.at("tile").frame_count == 240, + "quality publication must preserve renderer and retained media state"); + events.clear(); + shared_video_accept_resolution(recovered, 2, + [&](const std::string &event) { events.push_back(event); }); + check(recovered.resolution == 2 && events.size() == 2 && + events[0].find("\"actual\":2") != std::string::npos && + events[1].find("\"actual\":2") != std::string::npos, + "successful HD creation must update both deferred targets too"); + // Drive the entire creation sequence: a trailing source-scoped fallback + // event must not overwrite the tile's own request with the shared maximum. + bool tile_downgraded = true; + bool fixed_downgraded = false; + shared_video_publish_created_resolution(recovered, 1, "tile", + [&](const std::string &event) { + const bool tile = event.find("\"source_uuid\":\"tile\"") != std::string::npos; + const auto request_pos = event.find("\"requested\":"); + const auto actual_pos = event.find("\"actual\":"); + check(request_pos != std::string::npos && actual_pos != std::string::npos, + "quality event carries accepted and requested levels"); + if (request_pos == std::string::npos || actual_pos == std::string::npos) return; + const int requested = std::stoi(event.substr(request_pos + 12)); + const int actual = std::stoi(event.substr(actual_pos + 9)); + if (tile) tile_downgraded = actual < requested; + else fixed_downgraded = actual < requested; + }); + check(!tile_downgraded && fixed_downgraded, + "full 720 fallback sequence exceeds tile360 request but downgrades fixed1080"); + return failures ? 1 : 0; +} diff --git a/tests/zoom-privilege-notice-test.cpp b/tests/zoom-privilege-notice-test.cpp index 3a64feb..7fcb89e 100644 --- a/tests/zoom-privilege-notice-test.cpp +++ b/tests/zoom-privilege-notice-test.cpp @@ -62,39 +62,16 @@ int main() check(!zoom_privilege_already_requested("some unrelated future detail text"), "unrecognized detail: not classified as already-requested"); - // ── The two notices are distinct copy, not the same string twice ──────── - // If a future edit collapsed both branches to identical text, the "handle - // both, and the second warrants firmer copy" requirement this fix was - // written against would silently stop being true. - const std::string first_copy = zoom_privilege_notice_first_request(); - const std::string still_copy = zoom_privilege_notice_still_pending(); - check(!first_copy.empty(), "first-request copy non-empty"); - check(!still_copy.empty(), "still-pending copy non-empty"); - check(first_copy != still_copy, "the two notices are distinct copy"); + const std::string expected = "Waiting for the host to allow recording. Media will start automatically when permission is granted."; + check(zoom_privilege_notice_text(first_request_detail) == expected, "automatic recovery guidance"); + check(zoom_privilege_notice_text(still_pending_detail) == expected, "repeat request is same wait episode"); - // ── Copy names the ACTION, not the error code ──────────────────────────── - // The owner's ask, verbatim: "capture this error and not display an - // error... you just hit it again". Neither string may leak a raw SDK - // error code digit, and both must name the actual button the operator - // has to press. - auto has_digit = [](const std::string &s) { - return s.find_first_of("0123456789") != std::string::npos; - }; - check(!has_digit(first_copy), "first-request copy: no error code digits"); - check(!has_digit(still_copy), "still-pending copy: no error code digits"); - check(first_copy.find("Start Engine") != std::string::npos, - "first-request copy: names the Start Engine action"); - check(still_copy.find("Start Engine") != std::string::npos, - "still-pending copy: names the Start Engine action"); - - // ── The still-pending copy is the firmer one ───────────────────────────── - // A repeat report means the host has not acted yet; the copy should say - // so rather than reading like a brand-new ask. Pinned narrowly (not by - // wording, which is free to change) as: it says the host has not granted - // it / nothing will start, which the first-request copy does not. - check(still_copy.find("Still waiting") != std::string::npos || - still_copy.find("nothing will") != std::string::npos, - "still-pending copy: reads as a repeat wait, not a fresh ask"); + const auto denied = zoom_raw_media_state_notice("denied", "privilege_denied"); + check(denied.find("Ask the host") != std::string::npos, "denial has host action"); + check(zoom_raw_media_state_notice("starting", "privilege_granted") != denied, "grant replaces denial"); + check(zoom_raw_media_state_notice("waiting_permission", "privilege_request_timeout").find("timed out") != std::string::npos, "timeout distinguished from denial"); + check(zoom_raw_media_state_notice("active", "privilege_request_timeout").empty(), "late timeout cannot demote active media"); + check(zoom_raw_media_state_notice("failed", "start_raw_recording_failed").empty(), "terminal failure retains SDK error path"); if (g_failures == 0) { std::cout << "zoom-privilege-notice-test: all checks passed\n";