diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81590d59..dc6b32d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ name: CI on: + workflow_call: push: branches: [main] pull_request: @@ -19,6 +20,10 @@ jobs: node-version: 22 - name: Version sources agree with package.json run: node scripts/stamp-version.mjs --check + - name: Generated contracts are current + run: node contracts/generate.mjs --check + - name: Release evidence validator tests + run: node --test scripts/tests/release-evidence.test.mjs # Design-token gate for the macOS shell (docs/design-handoff-macos.md). # The 2026-08-04 audit found ~90% of mac-shell text rendering in SF Pro / @@ -280,7 +285,19 @@ jobs: - run: npm ci - name: Native media-core stub gate run: npm run test:native-media-core - - name: MediaCore bridge + unit tests + - name: Restore and provision WinUI test runtime + run: | + dotnet restore native-shell/CoreVideoPro.WinUI.Tests/CoreVideoPro.WinUI.Tests.csproj + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + ./scripts/provision-winui-test-runtime.ps1 + - name: MediaCore, Control, WinUI and bridge tests run: npm run test:native-shell + - name: Upload Windows test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: windows-shell-test-results + path: artifacts/test-results/** + if-no-files-found: error - name: Publish WinUI shell (build gate) run: dotnet publish native-shell/CoreVideoPro.WinUI/CoreVideoPro.WinUI.csproj -c Release -r win-x64 --self-contained false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 477f848e..9c7ad578 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,12 +50,19 @@ concurrency: cancel-in-progress: false jobs: + required-tests: + uses: ./.github/workflows/ci.yml + permissions: + contents: read + # Job 0 -- validate: tag == package.json version, all version sources in sync # (D1 stamp check), and a CHANGELOG section exists for the release notes. validate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} - uses: actions/setup-node@v4 with: node-version: 22 @@ -77,7 +84,7 @@ jobs: run: node scripts/release-notes.mjs "$GITHUB_REF_NAME" release-windows: - needs: validate + needs: [validate, required-tests] runs-on: windows-latest timeout-minutes: 120 defaults: @@ -85,6 +92,8 @@ jobs: shell: pwsh steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} - uses: actions/setup-node@v4 with: node-version: 22 @@ -311,43 +320,26 @@ jobs: Set-Content -Path "artifacts/latest.json" -Value $latest -Encoding utf8 Write-Host $latest - - name: Create GitHub Release + - name: Assemble immutable signed release candidate if: github.event_name == 'push' - env: - GH_TOKEN: ${{ github.token }} run: | $version = "${{ steps.version.outputs.version }}" - $tag = $env:GITHUB_REF_NAME $releaseDir = "artifacts/release" New-Item -ItemType Directory -Path $releaseDir -Force | Out-Null Copy-Item "artifacts/native/CoreVideoPro.msix" "$releaseDir/CoreVideoPro-v$version.msix" Copy-Item "artifacts/native/CoreVideoPro.appinstaller" "$releaseDir/CoreVideoPro.appinstaller" Copy-Item "artifacts/CoreVideoPro-symbols-v$version.zip" $releaseDir Copy-Item "artifacts/latest.json" $releaseDir - $notes = Join-Path $env:RUNNER_TEMP "release-notes.md" - node scripts/release-notes.mjs $tag | Out-File -FilePath $notes -Encoding utf8 - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - $assets = Get-ChildItem -Path $releaseDir -File | ForEach-Object { $_.FullName } - gh release create $tag --title "CoreVideo Pro v$version" --notes-file $notes --verify-tag @assets + node scripts/release-evidence.mjs candidate $env:GITHUB_SHA "$releaseDir/CoreVideoPro-v$version.msix" native/build-dev/CMakeCache.txt native-shell/CoreVideoPro.WinUI/msix-payload "$releaseDir/candidate.json" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - name: Publish to update host (TODO -- D0/D4 hosting decision) + - name: Upload signed candidate for controlled hardware validation if: github.event_name == 'push' - env: - COREVIDEO_UPDATE_BASE_URL: ${{ vars.COREVIDEO_UPDATE_BASE_URL }} - run: | - # TODO(D0/D4): automate once the owner picks the update host + creds - # (spec D4 recommends an R2 bucket behind the existing Cloudflare - # account with a custom domain). Deliberately fail-soft: the signed - # release exists either way; auto-update just won't see it until the - # files below are published. - $version = "${{ steps.version.outputs.version }}" - $base = $env:COREVIDEO_UPDATE_BASE_URL.TrimEnd('/') - Write-Host "::warning::Update-host publish is NOT automated yet. To light up install/auto-update, upload these GitHub Release assets to the update host so these URLs resolve:" - Write-Host " CoreVideoPro-v$version.msix -> $base/CoreVideoPro-v$version.msix" - Write-Host " CoreVideoPro.appinstaller -> $base/CoreVideoPro.appinstaller" - Write-Host " latest.json -> $base/latest.json" - Write-Host "Example (R2): wrangler r2 object put /CoreVideoPro.appinstaller --file CoreVideoPro.appinstaller (+ msix + latest.json), or the R2 dashboard." + uses: actions/upload-artifact@v4 + with: + name: signed-release-candidate + path: artifacts/release/* + if-no-files-found: error # ---- Dry-run (workflow_dispatch) artifacts: explicitly UNSIGNED. ---- @@ -366,3 +358,92 @@ jobs: name: CoreVideoPro-symbols-dry-run path: artifacts/CoreVideoPro-symbols-v*.zip if-no-files-found: error + + hardware-evidence: + if: github.event_name == 'push' + needs: release-windows + # Dedicated controlled rig, never a pull-request runner. Its harness must + # install and exercise the downloaded package, not build another binary. + runs-on: [self-hosted, Windows, X64, corevideo-release-rig] + environment: production-hardware-validation + permissions: + contents: read + timeout-minutes: 240 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: actions/download-artifact@v4 + with: + name: signed-release-candidate + path: artifacts/candidate + - name: Run controlled rig harness on signed candidate + shell: pwsh + env: + COREVIDEO_HARDWARE_HARNESS: ${{ vars.COREVIDEO_HARDWARE_HARNESS }} + run: | + $ErrorActionPreference = 'Stop' + if (-not $env:COREVIDEO_HARDWARE_HARNESS -or -not (Test-Path -LiteralPath $env:COREVIDEO_HARDWARE_HARNESS -PathType Leaf)) { + throw 'Configure COREVIDEO_HARDWARE_HARNESS on the controlled rig; hardware evidence is mandatory for publication.' + } + $evidenceDir = Join-Path $env:RUNNER_TEMP ([guid]::NewGuid().ToString()) + New-Item -ItemType Directory -Path $evidenceDir | Out-Null + Add-Content -LiteralPath $env:GITHUB_ENV -Value "COREVIDEO_EVIDENCE_DIR=$evidenceDir" + $manifest = (Resolve-Path artifacts/candidate/candidate.json).Path + $candidate = Get-Content -LiteralPath $manifest -Raw | ConvertFrom-Json + if ($candidate.sourceSha -ne $env:GITHUB_SHA) { throw 'Candidate source SHA differs from release workflow SHA.' } + $package = (Resolve-Path (Join-Path artifacts/candidate $candidate.artifact.name)).Path + # A successful PowerShell script need not set LASTEXITCODE. Clear any + # inherited native status, then check both PowerShell and native failure. + $global:LASTEXITCODE = 0 + & $env:COREVIDEO_HARDWARE_HARNESS -CandidateManifest $manifest -PackagePath $package -EvidenceDirectory $evidenceDir + $harnessSucceeded = $? + if (-not $harnessSucceeded -or $LASTEXITCODE -ne 0) { throw "Hardware harness failed: $LASTEXITCODE" } + node scripts/release-evidence.mjs validate $manifest (Join-Path $evidenceDir evidence.json) $package (Join-Path $evidenceDir verdict.json) + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Archive sanitized hardware evidence + if: always() && env.COREVIDEO_EVIDENCE_DIR != '' + uses: actions/upload-artifact@v4 + with: + name: hardware-release-evidence + path: ${{ env.COREVIDEO_EVIDENCE_DIR }} + if-no-files-found: error + + publish: + if: github.event_name == 'push' + needs: [required-tests, release-windows, hardware-evidence] + runs-on: ubuntu-latest + environment: production-release + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: actions/download-artifact@v4 + with: + name: signed-release-candidate + path: artifacts/release + - uses: actions/download-artifact@v4 + with: + name: hardware-release-evidence + path: artifacts/evidence + - name: Revalidate candidate and evidence before publishing + run: | + PACKAGE=$(node -p "JSON.parse(require('fs').readFileSync('artifacts/release/candidate.json')).artifact.name") + node scripts/release-evidence.mjs validate artifacts/release/candidate.json artifacts/evidence/evidence.json "artifacts/release/$PACKAGE" artifacts/release/verification.json + node -e "const c=JSON.parse(require('fs').readFileSync('artifacts/release/candidate.json')); if(c.sourceSha!==process.env.GITHUB_SHA) process.exit(1)" + - name: Create GitHub Release from validated bytes + env: + GH_TOKEN: ${{ github.token }} + run: | + node scripts/release-notes.mjs "$GITHUB_REF_NAME" > "$RUNNER_TEMP/release-notes.md" + gh release create "$GITHUB_REF_NAME" --title "CoreVideo Pro $GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/release-notes.md" --verify-tag artifacts/release/* + - name: Update host publication reminder + run: echo "::warning::Update-host upload remains manual. Publish the validated release assets to COREVIDEO_UPDATE_BASE_URL; do not rebuild them." diff --git a/CLAUDE.md b/CLAUDE.md index 4e6cf7e2..a7b99cc8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ Three processes, not a web app: - **Zoom engine subprocess** — `native/zoom-engine/` → `corevideo-zoom-engine.exe` — speaks the Zoom Meeting SDK, writes raw **I420** frames to shared memory. -IPC: JSON-line commands/snapshots over named pipes; video as keyed-mutex **DXGI shared +IPC: JSON-line commands/snapshots over child stdin/stdout pipes; video as keyed-mutex **DXGI shared textures** (cross-process) for program/preview, and shared-memory I420 for Zoom frames. Process boundaries + where spine features (ISO/NDI/SRT/browser) plug in: `docs/architecture-seams.md`. diff --git a/README.md b/README.md index d63ab1db..9136b881 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CoreVideo Pro -CoreVideo Pro is a **native Windows desktop production studio** for building polished +CoreVideo Pro is a **native desktop production studio for Windows and macOS** for building polished live shows, recordings, and streams directly from Zoom participants — Magic Scene auto-layout, lower-thirds, captions, smart framing, audio leveling, and multi-destination output, in a single operator console. @@ -8,42 +8,51 @@ multi-destination output, in a single operator console. ## Architecture ```text -WinUI 3 shell (.NET 9, native XAML) native-shell/CoreVideoPro.WinUI ← the shipping UI - └─ typed command/snapshot JSON-line over named pipes; keyed-mutex DXGI +WinUI 3 shell (.NET 9, native XAML) native-shell/CoreVideoPro.WinUI ← Windows UI +SwiftUI shell mac-shell/ ← macOS UI + └─ typed command/snapshot JSON-line over child stdin/stdout; Windows DXGI protocol (JSON-line) shared textures for program/preview/multiview └─ C++ media core native/ (real-time pixels/PCM/transport) - └─ Zoom engine subprocess native/zoom-engine/ (Zoom SDK → I420 shared memory) - └─ Zoom ingest · D3D11 compositor · audio mixer/DSP · - Media Foundation recorder · RTMP/NDI/SRT senders · - virtual-camera publisher · diagnostics / support bundles + ├─ Zoom engine subprocess native/zoom-engine/ (Zoom SDK → I420 shared memory) + ├─ D3D11 / Metal compositor · audio mixer/DSP + ├─ Media Foundation / AVFoundation recorder · output adapters + └─ virtual-camera publisher · diagnostics / support bundles Virtual camera (out-of-process) native/virtualcam-dll/ → corevideo-virtualcam.dll loaded by the Windows Frame Server; reads the program from cross-session shared memory and presents "CoreVideo Pro Camera" to Zoom / Teams / OBS at 1080p60 -React + Vite dev/contract UI src/ (protocol source of truth + mock-first -Node media-core mirror native-core/ dev UI and the in-container CI parity - surface — NOT embedded in the WinUI app) +Shared generated lifecycle models contracts/ (schema + cross-language wire fixtures) +React + Vite dev/contract UI src/ (mock-first development client) +Node media-core mirror native-core/ (deterministic protocol test runtime) ``` -The renderer never owns the real-time media pipeline. It serializes production state -([`src/domain/production.ts`](src/domain/production.ts)) into transport-neutral commands -([`src/engine/nativeMediaCoreCommands.ts`](src/engine/nativeMediaCoreCommands.ts)) and -reads back immutable `*Snapshot`s. The wire types exist three times and stay in lockstep -via a parity gate: the C++ core ([`native/src/core/Protocol.h`](native/src/core/Protocol.h)), -the Node mirror ([`native-core/src/protocol.ts`](native-core/src/protocol.ts)), and the -renderer mirror ([`src/engine/nativeMediaCoreProtocol.ts`](src/engine/nativeMediaCoreProtocol.ts)). +The native core owns real-time media. Shells send production intent over child-process +stdin/stdout and consume snapshots; GPU surfaces and shared-memory media use separate +transports. Windows uses D3D11 and Media Foundation; macOS uses Metal and AVFoundation. + +The additive [lifecycle schema](contracts/lifecycle.schema.json) generates C++, C#, +TypeScript, and Swift models and runtime validators. Golden wire fixtures run across +language suites. Legacy scene/audio/capture protocols still have handwritten mirrors; +see the [coverage inventory](contracts/README.md) and +[ownership map](docs/architecture-ownership.md) for their migration boundaries. + +Recording intent, verified media activity, and file finalization are distinct. +A Stop acknowledgement does not certify a playable finalized file. Zoom join/auth +runs on a cancellable worker while the bounded command mailbox continues serving +operator requests. See [command semantics](docs/control-command-lifecycle.md). The default in-container build is **stub-first** (`-DCOREVIDEO_STUB=ON`, `-DCOREVIDEO_ENABLE_DEV_ADAPTERS=OFF`): every capability has a deterministic synthetic implementation so contracts and tests run with no hardware. Real Zoom SDK, GPU, encoder, and hardware-transport code lives behind `COREVIDEO_ENABLE_DEV_ADAPTERS` plus a per-feature -`COREVIDEO_WITH_*` flag and is only built on a Windows dev rig with the vendor SDKs staged. +`COREVIDEO_WITH_*` flag. Windows and macOS builds select their platform adapters; +vendor integrations require the corresponding SDKs and runtimes. ## Capabilities & status Status legend: **Real** = implemented and exercised in the portable/CI build · **Dev-gated** -= implemented behind a `COREVIDEO_WITH_*` flag, runs only on a Windows rig with SDKs · += implemented behind a `COREVIDEO_WITH_*` flag, requires the relevant platform/runtime · **In progress** = wired through the contract but the native pixel/PCM path is unfinished. | Area | Capability | Status | @@ -51,7 +60,7 @@ Status legend: **Real** = implemented and exercised in the portable/CI build · | **Capture** | Zoom roster, active speaker, captions, feed health, breakout filters, producer roles | Real (simulated session) | | | Real Zoom Meeting SDK ingest via the vendored `corevideo-zoom-engine` (raw I420 over shared memory) | Dev-gated (`COREVIDEO_WITH_ZOOM`) | | | Test-pattern / local-camera source delivering real pixels into the core | Real | -| | Native UVC camera capture inside the core (Media Foundation source reader, 1080p60-targeted NV12/YUY2/MJPG negotiation, I420 → GPU shader convert with per-frame range/matrix, hot-unplug safe; WinUI shm bridge stays the fallback via `COREVIDEO_NATIVE_UVC=1` opt-in). Eliminates the shell's per-frame managed copy (the operator-lag root cause), but a last-mile "native frame reaches the multiview tile" gap keeps it opt-in — see [`docs/operator-performance-plan.md`](docs/operator-performance-plan.md) | Dev-gated (`COREVIDEO_WITH_UVC`), opt-in, display gap open | +| | Native UVC camera capture inside the core (Media Foundation source reader, 1080p60-targeted NV12/YUY2/MJPG negotiation, I420 → GPU shader convert with per-frame range/matrix, hot-unplug safe; WinUI shm bridge stays the per-device fallback; native capture is default-on and can be disabled with `COREVIDEO_NATIVE_UVC=0`). Uses first-frame confirmation before committing the native path; errors/timeouts fall back to the managed bridge | Dev-gated (`COREVIDEO_WITH_UVC`), default-on when available | | | Live DeckLink/AJA frames reaching the core (not just WinUI preview) | In progress | | **Compositor** | Route resolver, render-plan layers, program/preview parity math | Real | | | Per-source framing (fit/fill/stretch, zoom/pan, borders) | Real (D3D11 + CPU stub) | @@ -60,43 +69,34 @@ Status legend: **Real** = implemented and exercised in the portable/CI build · | | Core-composited GPU multiview (single shared texture, 4 layout modes, WinUI overlay labels/tally/meters/clock) | Real (layout/tiles) · Dev-gated (D3D11 render) | | **Audio** | PCM routing matrix, program/ISO taps, BS.1770 master meter, bus-insert dynamics, limiter | Real | | | WASAPI monitor output · ASIO capture · VST3 insert host | Dev-gated / In progress | -| **Recording** | Program + ISO mux with real program audio, profile-driven resolution/fps | Real path (MF encoder is Windows) | +| **Recording** | Program + ISO mux with real program audio, profile-driven resolution/fps | Implemented: Media Foundation on Windows, AVFoundation on macOS; candidate verification required | | **Streaming** | RTMP with real program-audio feed + H.264/AAC compatibility matrix | Dev-gated (`COREVIDEO_WITH_RTMP_OUTPUT`, FFmpeg) | | | NDI sender · SRT ingest decode | Dev-gated / In progress | | | SRT **output** sender | In progress (not yet implemented) | -| | **Virtual camera** — the program feed appears as a "CoreVideo Pro Camera" webcam in Zoom / Teams / OBS / the Windows Camera app, at native **1080p60** | Real (rig-verified in Zoom) | +| | **Virtual camera** — the program feed appears as a "CoreVideo Pro Camera" webcam in Zoom / Teams / OBS / the Windows Camera app, at native **1080p60** | Implemented on Windows; candidate verification required | | **Production** | Magic Scene, Set & Forget auto-director, presets, brand kit, media playback | Real (heuristic, no ML) | | **Diagnostics** | Support bundle with redacted secrets, output/recording health, crash events | Real | > **Release readiness.** The contract surface is broad and well-tested, but the native -> hardware paths above have not yet passed a Windows dev-rig validation pass (real Zoom -> join, GPU/encoder, record-and-stream on a clean machine). See +> hardware paths require evidence for the exact packaged candidate (real Zoom +> join, GPU/encoder, record-and-stream and clean-machine installation). See > [`docs/alpha-plan.md`](docs/alpha-plan.md) and > [`docs/native-production-completion-plan.md`](docs/native-production-completion-plan.md) > for the exit bar and the remaining real-implementation work. -> **Current focus (2026-07-10).** The **virtual camera** now works end-to-end in Zoom -> at native 1080p60 — cross-session file-backed shared memory (Frame Server serves from -> session 0, the core publishes from session 1), a hold-last-frame DLL (no flashing), -> live-clock PTS (no latency drift), and an off-render-thread dedicated-D3D-device readback -> so the tap costs the render loop ~1ms. In parallel we ran a full **operator-performance -> investigation** (PresentMon + dotnet-trace): the lag/stutter/crash is the WinUI shell's -> managed webcam-capture bridge copying every frame (~50% CPU → 2–3GB heap → crash), *not* -> the core or GPU (render holds 60fps on the RTX 4090). Native UVC capture removes that cost -> (verified memory 2.4GB→267MB) but a last-mile frame-display gap keeps it opt-in. Earlier -> landings still current: GPU core-composited multiview, Phase 2 audio/output worker -> decouple, zero-copy Zoom I420 ingest + 60fps pacer, multi-layer PREVIEW bus, clean/soaked -> audio. Active work: complete native-UVC display, the **alpha validation pass** -> ([`docs/alpha-plan.md`](docs/alpha-plan.md) Tracks A–F), and DeckLink/AJA capture. Build, -> run, the multi-participant test harness, the virtual-camera pipeline, the perf-profiling -> workflow, and the `CoreMessagingXP 0xc000027b` crash class are documented in -> [`CLAUDE.md`](CLAUDE.md). +> **Architecture hardening.** Windows CI includes the WinUI suite; configuration saves +> use atomic replacement and backup recovery; LAN HTTP control requires authentication. +> Signed release candidates must pass automated checks and controlled-rig evidence +> validation before publication. The rig harness must be provisioned separately: +> [release evidence setup](docs/release-evidence.md). Code availability and a green +> portable suite are not a substitute for verified live-media evidence. ## Repository layout | Path | Role | |---|---| | `src/` | React + Vite operator console, immutable production state, engine contracts | +| `mac-shell/` | SwiftUI desktop shell and macOS presentation | | `native-shell/` | **WinUI 3 (.NET 9)** desktop shell — primary product path and packaging | | `studio/` | Native C++ Win32 test shell for fast desktop validation | | `native/` | C++20 media core (compositor, audio, encoder, output adapters) + vendored Zoom engine | @@ -136,6 +136,11 @@ npm run pack:native # stage the WinUI shell + native core for distributi Full Windows gate (typecheck + all renderer/native/shell suites): `npm run test:gate`. Offline readiness report: `npm run alpha:preflight`. +On macOS, build the Swift shell with `cd mac-shell && swift build -c release`. +Run its deterministic suite with `COREVIDEO_SHELL_TESTS=1 .build/release/CoreVideoProShell`. +The [macOS launch script](scripts/run-mac-shell.sh) documents the native-core and SDK +configuration. A successful shell build does not validate the real media adapters. + ## MVP North Star The first fully useful milestone: diff --git a/companion-module-corevideopro/README.md b/companion-module-corevideopro/README.md index 70d7b0a6..59e44a8d 100644 --- a/companion-module-corevideopro/README.md +++ b/companion-module-corevideopro/README.md @@ -22,11 +22,26 @@ CoreVideo Pro starts its control servers automatically. By default they bind to |-----------|---------|--------------| | HTTP + WebSocket | `127.0.0.1:8011` | `COREVIDEO_HTTP_PORT` | | OSC (UDP) | `127.0.0.1:8010` | `COREVIDEO_OSC_PORT` | -| LAN access (both) | off | `COREVIDEO_OSC_LAN=1` (binds `+`/`0.0.0.0`; HTTP may need a Windows `netsh http add urlacl`) | -| Bearer token | none | `COREVIDEO_CONTROL_TOKEN` | +| HTTP/WS LAN access | off | `COREVIDEO_HTTP_LAN=1` (binds `+`; may need a Windows `netsh http add urlacl`) | +| OSC LAN access | off | Both `COREVIDEO_OSC_LAN=1` and `COREVIDEO_OSC_TRUSTED_NETWORK=1` (binds `0.0.0.0`) | +| Bearer token | none on loopback; required for LAN HTTP/WS | `COREVIDEO_CONTROL_TOKEN` | This module uses the **HTTP/WebSocket** transport. If Companion runs on a different -machine than CoreVideo Pro, set `COREVIDEO_OSC_LAN=1` (and ideally a token) on the app. +machine than CoreVideo Pro, set `COREVIDEO_HTTP_LAN=1` and a strong, non-blank +`COREVIDEO_CONTROL_TOKEN` on the app, then configure the same token in Companion. +Restart CoreVideo Pro after changing environment variables. LAN HTTP/WS refuses to +listen without a token; the launch log reports the configuration error. + +HTTP requests use `Authorization: Bearer `. WebSocket upgrades to `/ws` accept +that header or `?token=`; ordinary HTTP routes never accept query +tokens. Tokens authenticate callers but plain HTTP does not encrypt them. Use LAN +control only on a trusted network; remote or untrusted-network access requires TLS +through a reverse proxy or an authenticated tunnel. Keep tokens out of URLs in logs. + +HTTP LAN access does not enable OSC. OSC has no authentication: enable its two +separate settings only when every device on that network is trusted to operate the +show. Existing `COREVIDEO_OSC_LAN=1` configurations now keep both services local +until the new HTTP setting or explicit OSC trusted-network setting is supplied. ## 2. Build the module diff --git a/contracts/README.md b/contracts/README.md new file mode 100644 index 00000000..bb1a2196 --- /dev/null +++ b/contracts/README.md @@ -0,0 +1,50 @@ +# Additive lifecycle contract slice + +`lifecycle.schema.json` is the source of truth for protocol version, output +lifecycle, asynchronous operation status and structured protocol failure objects. +`npm run contract:generate` emits checked-in C++, C#, browser TypeScript, Node +TypeScript and Swift models plus validators. `npm run contract:check` and CI +reject stale generated output. The two TypeScript outputs are generated identically +so the Node package preserves its `rootDir: src` build boundary. + +The schema is deliberately a small first slice. It does **not** generate the entire +legacy protocol or replace its envelope/dispatch adapters. `lifecycle.fixtures.json` +contains identical raw wire messages for all language suites. Tests cover required +and optional fields, explicit null, booleans, integer bounds and decimal notation, +unsupported major versions, unknown enum values, and additive object fields. +C# and Swift also exercise typed decoding/encoding after validation; C++ exercises +its generated serializer and JSON validators. Legacy parity string tests remain +until their message families gain serialized-message tests. + +Wire rules: + +- Field names are case sensitive. Additive object fields are accepted and may be + discarded by typed models. Clients must not rewrite unknown fields to persist + a newer client's complete document. +- Required fields cannot be absent or null. Optional `error` may be absent; + explicit null is invalid. Serializers omit absent optional fields. +- Integer fields use signed 32-bit bounds specified by the schema. JSON numeric + notation such as `1.0` is a valid integer; fractions and overflow are invalid. +- Unknown lifecycle/health/operation enums fail validation. A consumer should + display unknown/unverified state and report incompatibility, never coerce an + unknown enum into live/success. An unknown additive field is different from an + unknown value in a closed enum. +- Call the generated runtime validator **before** using a decoded object. DTO + deserialization alone does not enforce every enum or semantic constraint. +- Protocol major 1 is supported; higher minor versions remain additive. Legacy + messages without these new objects pass through explicit legacy adapters. + +## Remaining supported protocol families + +| Family | Current handwritten owners | Next coverage boundary | +| --- | --- | --- | +| RPC envelopes, hello/capabilities, command acknowledgements | `native/src/rpc/JsonRpcServer.cpp`, `src/engine/nativeBridgeProtocol.ts`, C# client, Swift bridge | Envelope IDs, required fields, response/error unions | +| Scene graphs, preview, tiles, overlays, backgrounds, media playback | `MediaCore.h/.cpp`, `nativeMediaCoreProtocol.ts`, C#/Swift scene builders | Route modes, coordinate fields, nullability, atomic scene batch | +| Show inputs, participant roster, Zoom source/subscription/spine | `ZoomEngineRuntime`, `zoomMediaSpineSync.ts`, C#/Swift Zoom models | Durable identity vs session ID, partial roster updates, subscription limits | +| Recording/streaming configuration and full output telemetry | Encoder/sender interfaces, core snapshots, shell snapshot DTOs | Per-destination identity, writer stats, artifact/finalization proof | +| Audio buses, mixer, DSP/VST, device routing | Native audio module DTOs and shell builders | Numeric units/ranges, topology, plugin state | +| Capture and frame transport | Native capture/shared-texture messages and platform bridges | Handle ownership, dimensions/strides, timestamps, process epoch | +| Diagnostics, support, licensing, automation/control | Core/control servers and shell view models | Redacted diagnostics, action idempotency, compatibility capabilities | + +Do not declare full generated-contract coverage until these families have their +own schemas, golden fixtures, runtime validation and tested legacy adapters. diff --git a/contracts/generate.mjs b/contracts/generate.mjs new file mode 100644 index 00000000..d9f27365 --- /dev/null +++ b/contracts/generate.mjs @@ -0,0 +1,124 @@ +// Intentionally small schema compiler: the supported subset is checked explicitly. +// It generates runtime validation as well as DTOs; adding unsupported schema keywords +// fails generation instead of silently weakening the wire contract. +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const schema = JSON.parse(readFileSync(resolve(root, 'contracts/lifecycle.schema.json'), 'utf8')); +const check = process.argv.includes('--check'); +const q = JSON.stringify; +const pascal = s => s[0].toUpperCase() + s.slice(1); +const definitions = Object.entries(schema.$defs); +for (const [name, definition] of definitions) { + if (definition.type !== 'object' || definition.additionalProperties !== true) throw Error(`Unsupported object ${name}`); + for (const key of Object.keys(definition)) if (!['type', 'additionalProperties', 'required', 'properties'].includes(key)) throw Error(`Unsupported object keyword ${name}.${key}`); + if (!Array.isArray(definition.required) || new Set(definition.required).size !== definition.required.length || definition.required.some(field => !(field in definition.properties))) throw Error(`Invalid required fields in ${name}`); + for (const [field, rule] of Object.entries(definition.properties)) { + if (!['string', 'integer', 'boolean'].includes(rule.type)) throw Error(`Unsupported type ${name}.${field}`); + for (const key of Object.keys(rule)) if (!['type', 'enum', 'minLength', 'minimum', 'maximum'].includes(key)) throw Error(`Unsupported keyword ${key}`); + if (rule.enum && (rule.type !== 'string' || !Array.isArray(rule.enum) || rule.enum.length === 0 || rule.enum.some(value => typeof value !== 'string'))) throw Error(`Unsupported enum ${name}.${field}`); + // Longer JSON Schema string lengths count Unicode code points, whereas the + // native standard libraries count bytes/code units/graphemes differently. + // Support nonempty only until a shared Unicode-length implementation exists. + if ('minLength' in rule && (rule.type !== 'string' || rule.minLength !== 1)) throw Error(`Unsupported string length ${name}.${field}`); + if (rule.type === 'integer' && (!Number.isInteger(rule.minimum) || !Number.isInteger(rule.maximum) || rule.minimum < -2147483648 || rule.maximum > 2147483647 || rule.minimum > rule.maximum)) throw Error(`Integer bounds must fit Int32: ${name}.${field}`); + if (rule.type !== 'integer' && ('minimum' in rule || 'maximum' in rule)) throw Error(`Invalid numeric bounds ${name}.${field}`); + } +} + +const ts = ['// Generated by contracts/generate.mjs. Do not edit.']; +const cpp = ['// Generated by contracts/generate.mjs. Do not edit.', '#pragma once', '#include "rpc/Json.h"', '#include ', '#include ', '#include ', 'namespace corevideo::contracts {']; +const cs = ['// Generated by contracts/generate.mjs. Do not edit.', 'using System;', 'using System.Text.Json;', 'using System.Text.Json.Serialization;', 'namespace CoreVideoPro.MediaCore.Contracts;', + 'public sealed class ContractIntegerConverter : JsonConverter {', + ' public override int Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions options) {', + ' if (reader.TokenType != JsonTokenType.Number || !reader.TryGetDouble(out var value) || !double.IsFinite(value) || Math.Truncate(value) != value || value < int.MinValue || value > int.MaxValue) throw new JsonException("Expected a 32-bit integer");', + ' return (int)value;', ' }', + ' public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) => writer.WriteNumberValue(value);', '}']; +const swift = ['// Generated by contracts/generate.mjs. Do not edit.', 'import Foundation']; + +for (const [name, def] of definitions) { + const fields = Object.entries(def.properties); + const required = field => def.required.includes(field); + ts.push(`export type ${name} = {`); + cpp.push(`struct ${name} {`); + cs.push(`public sealed record ${name} {`); + swift.push(`struct ${name}: Codable {`); + for (const [field, rule] of fields) { + const tsType = rule.enum ? rule.enum.map(q).join(' | ') : {string:'string',integer:'number',boolean:'boolean'}[rule.type]; + const cppType = {string:'std::string',integer:'int',boolean:'bool'}[rule.type]; + const csType = {string:'string',integer:'int',boolean:'bool'}[rule.type]; + const swiftType = {string:'String',integer:'Int',boolean:'Bool'}[rule.type]; + ts.push(` ${field}${required(field) ? '' : '?'}: ${tsType};`); + cpp.push(` ${required(field) ? cppType : `std::optional<${cppType}>`} ${field}{};`); + if (!required(field)) cs.push(' [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]'); + if (rule.type === 'integer') cs.push(' [JsonConverter(typeof(ContractIntegerConverter))]'); + cs.push(` [JsonPropertyName(${q(field)})] public ${required(field) ? 'required ' : ''}${csType}${required(field) ? '' : '?'} ${pascal(field)} { get; init; }`); + swift.push(` var ${field}: ${swiftType}${required(field) ? '' : '?'}${required(field) ? '' : ' = nil'}`); + } + ts.push('};'); cpp.push('};'); cs.push('}'); swift.push('}'); + + ts.push(`export function validate${name}(value: unknown): value is ${name} {`, ' if (typeof value !== "object" || value === null || Array.isArray(value)) return false;', ' const v = value as Record;'); + cpp.push(`inline bool validate${name}(const rpc::Json& value) {`, ' if (!value.isObject()) return false;'); + cs.push(`public static class ${name}Contract {`, ' public static bool Validate(JsonElement value) {', ' if (value.ValueKind != JsonValueKind.Object) return false;'); + swift.push(`func validate${name}(_ value: [String: Any]) -> Bool {`); + for (const [field, rule] of fields) { + const v = `v[${q(field)}]`; + const tsConditions = [`typeof ${v} === ${q(rule.type === 'integer' ? 'number' : rule.type)}`]; + if (rule.type === 'integer') tsConditions.push(`Number.isInteger(${v})`, `${v} as number >= ${rule.minimum}`, `${v} as number <= ${rule.maximum}`); + if (rule.minLength) tsConditions.push(`(${v} as string).length >= ${rule.minLength}`); + if (rule.enum) tsConditions.push(`${q(rule.enum)}.includes(${v} as string)`); + ts.push(` if (${required(field) ? '' : `${v} !== undefined && `}!(${tsConditions.join(' && ')})) return false;`); + + cpp.push(` const auto* ${field} = value.get(${q(field)});`); + const cc = [`${field}->${{string:'isString',integer:'isNumber',boolean:'isBool'}[rule.type]}()`]; + if (rule.type === 'integer') cc.push(`std::floor(${field}->asNumber()) == ${field}->asNumber()`, `${field}->asNumber() >= ${rule.minimum}`, `${field}->asNumber() <= ${rule.maximum}`); + if (rule.minLength) cc.push(`${field}->asString().size() >= ${rule.minLength}`); + if (rule.enum) cc.push(`(${rule.enum.map(x => `${field}->asString() == ${q(x)}`).join(' || ')})`); + cpp.push(` if (${required(field) ? `!${field} || ` : `${field} && `}!(${cc.join(' && ')})) return false;`); + + cs.push(` var has${pascal(field)} = value.TryGetProperty(${q(field)}, out var ${field});`); + const csc = [`${field}.ValueKind == JsonValueKind.${{string:'String',integer:'Number',boolean:'True'}[rule.type]}`]; + if (rule.type === 'boolean') csc[0] = `(${field}.ValueKind == JsonValueKind.True || ${field}.ValueKind == JsonValueKind.False)`; + if (rule.type === 'integer') csc.push(`${field}.TryGetDouble(out var ${field}Number)`, `double.IsFinite(${field}Number)`, `Math.Truncate(${field}Number) == ${field}Number`, `${field}Number >= ${rule.minimum}`, `${field}Number <= ${rule.maximum}`); + if (rule.minLength) csc.push(`${field}.GetString()!.Length >= ${rule.minLength}`); + if (rule.enum) csc.push(`(${rule.enum.map(x => `${field}.GetString() == ${q(x)}`).join(' || ')})`); + cs.push(` if (${required(field) ? `!has${pascal(field)} || ` : `has${pascal(field)} && `}!(${csc.join(' && ')})) return false;`); + + swift.push(` if let raw = value[${q(field)}] {`); + if (rule.type === 'string') { + swift.push(' guard let parsed = raw as? String else { return false }'); + if (rule.minLength) swift.push(` if parsed.isEmpty { return false }`); + if (rule.enum) swift.push(` if !${q(rule.enum)}.contains(parsed) { return false }`); + if (!rule.enum && !rule.minLength) swift.push(' _ = parsed'); + } else { + swift.push(' guard let parsed = raw as? NSNumber else { return false }'); + swift.push(` if ${rule.type === 'boolean' ? 'CFGetTypeID(parsed) != CFBooleanGetTypeID()' : 'CFGetTypeID(parsed) == CFBooleanGetTypeID()'} { return false }`); + if (rule.type === 'integer') swift.push(` if parsed.doubleValue.rounded() != parsed.doubleValue || parsed.doubleValue < ${rule.minimum} || parsed.doubleValue > ${rule.maximum} { return false }`); + } + swift.push(` }${required(field) ? ' else { return false }' : ''}`); + } + ts.push(' return true;', '}'); cpp.push(' return true;', '}'); cs.push(' return true;', ' }', '}'); swift.push(' return true;', '}'); + cpp.push(`inline rpc::Json toJson(const ${name}& value) {`, ' rpc::Json::Object result;'); + for (const [field] of fields) cpp.push(` ${required(field) ? '' : `if (value.${field}) `}result.emplace(${q(field)}, ${required(field) ? '' : '*'}value.${field});`); + cpp.push(' return result;', '}'); +} +cpp.push('} // namespace corevideo::contracts'); +swift.splice(2, 0, 'import CoreFoundation'); +let stale = false; +for (const [path, lines] of [ + ['src/engine/generated/lifecycle.ts', ts], + ['native-core/src/generated/lifecycle.ts', ts], + ['native/src/contracts/Lifecycle.h', cpp], + ['native-shell/CoreVideoPro.MediaCore/Contracts/Lifecycle.cs', cs], + ['mac-shell/Sources/CoreVideoProShell/Lifecycle.generated.swift', swift] +]) { + const target = resolve(root, path); + const content = lines.join('\n') + '\n'; + if (check) { + let existing = ''; try { existing = readFileSync(target, 'utf8'); } catch {} + if (existing.replaceAll('\r\n', '\n') !== content) { console.error(`Stale generated contract: ${path}`); stale = true; } + } else { mkdirSync(dirname(target), {recursive:true}); writeFileSync(target, content); } +} +if (stale) process.exitCode = 1; diff --git a/contracts/lifecycle.fixtures.json b/contracts/lifecycle.fixtures.json new file mode 100644 index 00000000..2f8eb24f --- /dev/null +++ b/contracts/lifecycle.fixtures.json @@ -0,0 +1,338 @@ +[ + { + "id": "ProtocolVersion/valid", + "contract": "ProtocolVersion", + "accepted": true, + "json": "{\"major\":1,\"minor\":0}" + }, + { + "id": "ProtocolVersion/additive-field", + "contract": "ProtocolVersion", + "accepted": true, + "json": "{\"major\":1,\"minor\":0,\"futureField\":{\"ignored\":true}}" + }, + { + "id": "ProtocolVersion/not-object", + "contract": "ProtocolVersion", + "accepted": false, + "json": "[]" + }, + { + "id": "ProtocolVersion/missing-major", + "contract": "ProtocolVersion", + "accepted": false, + "json": "{\"minor\":0}" + }, + { + "id": "ProtocolVersion/null-major", + "contract": "ProtocolVersion", + "accepted": false, + "json": "{\"major\":null,\"minor\":0}" + }, + { + "id": "ProtocolVersion/missing-minor", + "contract": "ProtocolVersion", + "accepted": false, + "json": "{\"major\":1}" + }, + { + "id": "ProtocolVersion/null-minor", + "contract": "ProtocolVersion", + "accepted": false, + "json": "{\"major\":1,\"minor\":null}" + }, + { + "id": "OutputLifecycle/valid", + "contract": "OutputLifecycle", + "accepted": true, + "json": "{\"sessionId\":\"session-1\",\"desiredActive\":true,\"state\":\"starting\",\"health\":\"unknown\",\"finalized\":false}" + }, + { + "id": "OutputLifecycle/additive-field", + "contract": "OutputLifecycle", + "accepted": true, + "json": "{\"sessionId\":\"session-1\",\"desiredActive\":true,\"state\":\"starting\",\"health\":\"unknown\",\"finalized\":false,\"futureField\":{\"ignored\":true}}" + }, + { + "id": "OutputLifecycle/not-object", + "contract": "OutputLifecycle", + "accepted": false, + "json": "[]" + }, + { + "id": "OutputLifecycle/missing-sessionId", + "contract": "OutputLifecycle", + "accepted": false, + "json": "{\"desiredActive\":true,\"state\":\"starting\",\"health\":\"unknown\",\"finalized\":false}" + }, + { + "id": "OutputLifecycle/null-sessionId", + "contract": "OutputLifecycle", + "accepted": false, + "json": "{\"sessionId\":null,\"desiredActive\":true,\"state\":\"starting\",\"health\":\"unknown\",\"finalized\":false}" + }, + { + "id": "OutputLifecycle/missing-desiredActive", + "contract": "OutputLifecycle", + "accepted": false, + "json": "{\"sessionId\":\"session-1\",\"state\":\"starting\",\"health\":\"unknown\",\"finalized\":false}" + }, + { + "id": "OutputLifecycle/null-desiredActive", + "contract": "OutputLifecycle", + "accepted": false, + "json": "{\"sessionId\":\"session-1\",\"desiredActive\":null,\"state\":\"starting\",\"health\":\"unknown\",\"finalized\":false}" + }, + { + "id": "OutputLifecycle/missing-state", + "contract": "OutputLifecycle", + "accepted": false, + "json": "{\"sessionId\":\"session-1\",\"desiredActive\":true,\"health\":\"unknown\",\"finalized\":false}" + }, + { + "id": "OutputLifecycle/null-state", + "contract": "OutputLifecycle", + "accepted": false, + "json": "{\"sessionId\":\"session-1\",\"desiredActive\":true,\"state\":null,\"health\":\"unknown\",\"finalized\":false}" + }, + { + "id": "OutputLifecycle/missing-health", + "contract": "OutputLifecycle", + "accepted": false, + "json": "{\"sessionId\":\"session-1\",\"desiredActive\":true,\"state\":\"starting\",\"finalized\":false}" + }, + { + "id": "OutputLifecycle/null-health", + "contract": "OutputLifecycle", + "accepted": false, + "json": "{\"sessionId\":\"session-1\",\"desiredActive\":true,\"state\":\"starting\",\"health\":null,\"finalized\":false}" + }, + { + "id": "OutputLifecycle/missing-finalized", + "contract": "OutputLifecycle", + "accepted": false, + "json": "{\"sessionId\":\"session-1\",\"desiredActive\":true,\"state\":\"starting\",\"health\":\"unknown\"}" + }, + { + "id": "OutputLifecycle/null-finalized", + "contract": "OutputLifecycle", + "accepted": false, + "json": "{\"sessionId\":\"session-1\",\"desiredActive\":true,\"state\":\"starting\",\"health\":\"unknown\",\"finalized\":null}" + }, + { + "id": "OutputLifecycle/optional-error", + "contract": "OutputLifecycle", + "accepted": true, + "json": "{\"sessionId\":\"session-1\",\"desiredActive\":true,\"state\":\"starting\",\"health\":\"unknown\",\"finalized\":false,\"error\":\"diagnostic\"}" + }, + { + "id": "OutputLifecycle/null-error", + "contract": "OutputLifecycle", + "accepted": false, + "json": "{\"sessionId\":\"session-1\",\"desiredActive\":true,\"state\":\"starting\",\"health\":\"unknown\",\"finalized\":false,\"error\":null}" + }, + { + "id": "OutputLifecycle/empty-sessionId", + "contract": "OutputLifecycle", + "accepted": false, + "json": "{\"sessionId\":\"\",\"desiredActive\":true,\"state\":\"starting\",\"health\":\"unknown\",\"finalized\":false}" + }, + { + "id": "OutputLifecycle/numeric-desiredActive", + "contract": "OutputLifecycle", + "accepted": false, + "json": "{\"sessionId\":\"session-1\",\"desiredActive\":1,\"state\":\"starting\",\"health\":\"unknown\",\"finalized\":false}" + }, + { + "id": "OutputLifecycle/unknown-state", + "contract": "OutputLifecycle", + "accepted": false, + "json": "{\"sessionId\":\"session-1\",\"desiredActive\":true,\"state\":\"future-enum\",\"health\":\"unknown\",\"finalized\":false}" + }, + { + "id": "OutputLifecycle/unknown-health", + "contract": "OutputLifecycle", + "accepted": false, + "json": "{\"sessionId\":\"session-1\",\"desiredActive\":true,\"state\":\"starting\",\"health\":\"future-enum\",\"finalized\":false}" + }, + { + "id": "OutputLifecycle/numeric-finalized", + "contract": "OutputLifecycle", + "accepted": false, + "json": "{\"sessionId\":\"session-1\",\"desiredActive\":true,\"state\":\"starting\",\"health\":\"unknown\",\"finalized\":1}" + }, + { + "id": "OperationStatus/valid", + "contract": "OperationStatus", + "accepted": true, + "json": "{\"processEpoch\":\"epoch-1\",\"operationId\":\"op-1\",\"state\":\"accepted\"}" + }, + { + "id": "OperationStatus/additive-field", + "contract": "OperationStatus", + "accepted": true, + "json": "{\"processEpoch\":\"epoch-1\",\"operationId\":\"op-1\",\"state\":\"accepted\",\"futureField\":{\"ignored\":true}}" + }, + { + "id": "OperationStatus/not-object", + "contract": "OperationStatus", + "accepted": false, + "json": "[]" + }, + { + "id": "OperationStatus/missing-processEpoch", + "contract": "OperationStatus", + "accepted": false, + "json": "{\"operationId\":\"op-1\",\"state\":\"accepted\"}" + }, + { + "id": "OperationStatus/null-processEpoch", + "contract": "OperationStatus", + "accepted": false, + "json": "{\"processEpoch\":null,\"operationId\":\"op-1\",\"state\":\"accepted\"}" + }, + { + "id": "OperationStatus/missing-operationId", + "contract": "OperationStatus", + "accepted": false, + "json": "{\"processEpoch\":\"epoch-1\",\"state\":\"accepted\"}" + }, + { + "id": "OperationStatus/null-operationId", + "contract": "OperationStatus", + "accepted": false, + "json": "{\"processEpoch\":\"epoch-1\",\"operationId\":null,\"state\":\"accepted\"}" + }, + { + "id": "OperationStatus/missing-state", + "contract": "OperationStatus", + "accepted": false, + "json": "{\"processEpoch\":\"epoch-1\",\"operationId\":\"op-1\"}" + }, + { + "id": "OperationStatus/null-state", + "contract": "OperationStatus", + "accepted": false, + "json": "{\"processEpoch\":\"epoch-1\",\"operationId\":\"op-1\",\"state\":null}" + }, + { + "id": "OperationStatus/optional-error", + "contract": "OperationStatus", + "accepted": true, + "json": "{\"processEpoch\":\"epoch-1\",\"operationId\":\"op-1\",\"state\":\"accepted\",\"error\":\"diagnostic\"}" + }, + { + "id": "OperationStatus/null-error", + "contract": "OperationStatus", + "accepted": false, + "json": "{\"processEpoch\":\"epoch-1\",\"operationId\":\"op-1\",\"state\":\"accepted\",\"error\":null}" + }, + { + "id": "OperationStatus/empty-processEpoch", + "contract": "OperationStatus", + "accepted": false, + "json": "{\"processEpoch\":\"\",\"operationId\":\"op-1\",\"state\":\"accepted\"}" + }, + { + "id": "OperationStatus/empty-operationId", + "contract": "OperationStatus", + "accepted": false, + "json": "{\"processEpoch\":\"epoch-1\",\"operationId\":\"\",\"state\":\"accepted\"}" + }, + { + "id": "OperationStatus/unknown-state", + "contract": "OperationStatus", + "accepted": false, + "json": "{\"processEpoch\":\"epoch-1\",\"operationId\":\"op-1\",\"state\":\"future-enum\"}" + }, + { + "id": "ProtocolFailure/valid", + "contract": "ProtocolFailure", + "accepted": true, + "json": "{\"code\":\"incompatible_protocol\",\"message\":\"Unsupported protocol major\"}" + }, + { + "id": "ProtocolFailure/additive-field", + "contract": "ProtocolFailure", + "accepted": true, + "json": "{\"code\":\"incompatible_protocol\",\"message\":\"Unsupported protocol major\",\"futureField\":{\"ignored\":true}}" + }, + { + "id": "ProtocolFailure/not-object", + "contract": "ProtocolFailure", + "accepted": false, + "json": "[]" + }, + { + "id": "ProtocolFailure/missing-code", + "contract": "ProtocolFailure", + "accepted": false, + "json": "{\"message\":\"Unsupported protocol major\"}" + }, + { + "id": "ProtocolFailure/null-code", + "contract": "ProtocolFailure", + "accepted": false, + "json": "{\"code\":null,\"message\":\"Unsupported protocol major\"}" + }, + { + "id": "ProtocolFailure/missing-message", + "contract": "ProtocolFailure", + "accepted": false, + "json": "{\"code\":\"incompatible_protocol\"}" + }, + { + "id": "ProtocolFailure/null-message", + "contract": "ProtocolFailure", + "accepted": false, + "json": "{\"code\":\"incompatible_protocol\",\"message\":null}" + }, + { + "id": "ProtocolFailure/empty-code", + "contract": "ProtocolFailure", + "accepted": false, + "json": "{\"code\":\"\",\"message\":\"Unsupported protocol major\"}" + }, + { + "id": "ProtocolFailure/empty-message", + "contract": "ProtocolFailure", + "accepted": false, + "json": "{\"code\":\"incompatible_protocol\",\"message\":\"\"}" + }, + { + "id": "unsupported-major", + "contract": "ProtocolVersion", + "accepted": false, + "json": "{\"major\":2,\"minor\":0}" + }, + { + "id": "fractional-minor", + "contract": "ProtocolVersion", + "accepted": false, + "json": "{\"major\":1,\"minor\":1.5}" + }, + { + "id": "overflow-minor", + "contract": "ProtocolVersion", + "accepted": false, + "json": "{\"major\":1,\"minor\":2147483648}" + }, + { + "id": "negative-minor", + "contract": "ProtocolVersion", + "accepted": false, + "json": "{\"major\":1,\"minor\":-1}" + }, + { + "id": "boolean-major", + "contract": "ProtocolVersion", + "accepted": false, + "json": "{\"major\":true,\"minor\":0}" + }, + { + "id": "integer-decimal-notation", + "contract": "ProtocolVersion", + "accepted": true, + "json": "{\"major\":1.0,\"minor\":0.0}" + } +] diff --git a/contracts/lifecycle.schema.json b/contracts/lifecycle.schema.json new file mode 100644 index 00000000..afbdaa35 --- /dev/null +++ b/contracts/lifecycle.schema.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://corevideopro.local/contracts/lifecycle/v1", + "title": "CoreVideo additive lifecycle contracts", + "$defs": { + "ProtocolVersion": { + "type": "object", "additionalProperties": true, + "required": ["major", "minor"], + "properties": { + "major": {"type": "integer", "minimum": 1, "maximum": 1}, + "minor": {"type": "integer", "minimum": 0, "maximum": 2147483647} + } + }, + "OutputLifecycle": { + "type": "object", "additionalProperties": true, + "required": ["sessionId", "desiredActive", "state", "health", "finalized"], + "properties": { + "sessionId": {"type": "string", "minLength": 1}, + "desiredActive": {"type": "boolean"}, + "state": {"type": "string", "enum": ["idle", "starting", "live", "stopping", "finalizing", "completed", "failed", "interrupted"]}, + "health": {"type": "string", "enum": ["unknown", "healthy", "degraded", "failed"]}, + "finalized": {"type": "boolean"}, + "error": {"type": "string"} + } + }, + "OperationStatus": { + "type": "object", "additionalProperties": true, + "required": ["processEpoch", "operationId", "state"], + "properties": { + "processEpoch": {"type": "string", "minLength": 1}, + "operationId": {"type": "string", "minLength": 1}, + "state": {"type": "string", "enum": ["accepted", "running", "completed", "failed", "cancelled"]}, + "error": {"type": "string"} + } + }, + "ProtocolFailure": { + "type": "object", "additionalProperties": true, + "required": ["code", "message"], + "properties": { + "code": {"type": "string", "minLength": 1}, + "message": {"type": "string", "minLength": 1} + } + } + } +} diff --git a/docs/architecture-migration.md b/docs/architecture-migration.md new file mode 100644 index 00000000..da31aab1 --- /dev/null +++ b/docs/architecture-migration.md @@ -0,0 +1,65 @@ +# Architecture hardening migration + +This change is additive to the existing JSON-line protocol. Windows and macOS +shells still launch the native core as a child process. Shared GPU/media surfaces +are separate from command transport. + +## Output controls + +Recording requests express intent. The core reports `starting` until a writer +successfully handles media, then `live`. Stop prevents new queued media and drains +the take through `stopping` and `finalizing`. Only `completed` with `finalized: true` +certifies successful writer completion. This still requires decode/quality checks +before a release artifact is certified. Errors remain visible after Stop. + +Shells keep desired activity separately from the live indicator. Failed Stop +replies leave intent disarmed; later full-state updates must not restart output. +A core exit marks continuity interrupted and disarms recording/streaming. Starting +again creates a new recording session. The application does not silently claim +continuous output across a process crash. + +Per-destination sender status remains the compatibility interface for streaming. +Starting is accepted but not live. A healthy remaining destination is not stopped +because another fails. RTMP `encoder-input-accepted` means media reached the local +FFmpeg input; it does not prove receipt by the remote server. + +## Protocol and control clients + +The handshake adds a process epoch and protocol major/minor version. Clients +accept absent version fields through the legacy adapter, accept additive major-1 +minor versions, and reject explicit incompatible/invalid versions. Generated +lifecycle models require validation before use; see [wire rules](../contracts/README.md). + +Legacy Zoom join calls retain their final response. Opt-in asynchronous callers +match both process epoch and operation ID on completion. A timeout does not prove +that an action failed. Do not replay an edge-triggered action such as Take merely +because its reply was lost. The bounded mailbox reports overload explicitly; see +[command ordering and limits](control-command-lifecycle.md). + +LAN HTTP requires an authentication token. Loopback retains existing behavior. +OSC remains local unless both its separate LAN switch and trusted-network switch +are enabled. Bearer tokens over plain HTTP require a trusted network; use TLS or +an authenticated tunnel across an untrusted network. Companion setup is documented +in [its README](../companion-module-corevideopro/README.md). + +## Saved configuration and releases + +Production preference saves flush a same-directory temporary file and atomically +replace the primary while retaining a validated, protected backup. Missing files +are normal first launch; corrupt/unreadable files and backup recovery are distinct +outcomes. Recovery preserves the loaded configuration if a repair write fails. + +Tag releases now depend on the reusable automated workflow and evidence bound to +the exact signed package and source commit. A separately provisioned controlled +rig supplies hardware evidence. Missing or invalid evidence blocks publication; +see [rig setup and evidence schema](release-evidence.md). + +## Remaining migration + +The generated lifecycle slice and native source-binding policy are foundations. +Legacy scene/audio/capture envelopes, durable participant identity, atomic native +Take, and outbound response backpressure remain explicit work in the +[ownership map](architecture-ownership.md) and [contract inventory](../contracts/README.md). +Live Zoom, physical fault injection, macOS adapter execution, two-hour A/V soak, +and clean-machine package validation are separate acceptance evidence, not claims +derived from unit test counts. diff --git a/docs/architecture-ownership.md b/docs/architecture-ownership.md new file mode 100644 index 00000000..4e6860eb --- /dev/null +++ b/docs/architecture-ownership.md @@ -0,0 +1,89 @@ +# Production policy ownership + +This map describes current ownership and the remaining migration. The native +engine, Zoom subprocess, platform GPU adapters and native shells remain the +architecture. A shared runtime decision is different from matching handwritten +shell implementations; only the former has one execution owner. + +## Implemented common boundary + +`native/src/core/RouteSourcePolicy.h` is the pure route-to-source binding policy +used by `MediaCore::buildCompositorRenderPlan`. Both shipping shells already send +their scene graphs to this core, so program and preview rendering use this single +policy on Windows and macOS. It is constructible/testable without the core, a GPU, +Zoom, a dispatcher or either shell. The extraction preserves existing behavior: + +| Input | Binding | +| --- | --- | +| Media ID and valid path present | Media source takes precedence | +| Capture-input mode with device ID | Exact `capture:` identity used by capture frames | +| Explicit participant ID | Keep that guest identity, even if its current frame is absent | +| No explicit identity, fallback frame at route position | Existing positional fallback | +| No explicit identity or fallback | Unbound source | + +Screen-share routes retain their screen-share frame kind. An incomplete media +reference falls through to the prior participant/capture behavior. Native tests +cover these precedence rules, missing guests and a reordered fallback roster. + +The positional fallback also exists for legacy `none` mode. It is compatibility +debt, not the desired future meaning of an intentionally blank source. This +extraction does not silently change a live show's blank/fallback semantics; a +versioned route contract must distinguish intentional blank from omitted legacy +assignment before changing that rule. + +`ZoomActiveSpeakerDirector` remains the native owner of speaker sensitivity, +minimum hold, frame freshness, exclusions and temporary roster absence grace. +`core/Director.h` remains the pure scene-recommendation kernel. These decisions +should be reused through core snapshots, not reimplemented in new shell code. + +## Ownership inventory + +| Responsibility | Current owner(s) | Intended boundary / remaining action | +| --- | --- | --- | +| Persisted production preferences and scene document | Windows `ProductionOutputPreferencesStore`, Swift `AppModel` preferences | Shell persists user document; native owns accepted rendering state. Define document schema and migration independently from runtime snapshots. | +| Scene editing and route pickers | Windows `SceneRoutingService`, `StudioViewModel`; Swift `AppModel.buildRoutes`; React scene helpers | UI labels, canvas gestures and selection stay local. Route resolution and identity rules should converge on explicit core requests. | +| Final source binding and render plan | Native `RouteSourcePolicy`, `MediaCore`, compositor adapters | Shared pure binding policy now extracted; GPU allocation/rendering stays in adapters. | +| Stable editorial identity | TypeScript show-engine `personKey`, `identity`, `panelistDb`, `liveSlots`; Windows session role registry; Swift slot source IDs | No universal durable identity contract exists yet. Define durable person key separately from Zoom participant/session ID, including ambiguous-name/PIN collision handling. | +| Program/preview Take | Windows `TransportCoordinator.TakeAsync`; Swift `AppModel.take`; TypeScript `ProgramBus` | Still shell-owned. Introduce a native atomic Take operation with an operation ID and scene revision before retiring shell swaps. | +| Zoom lifecycle and roster | Native `ZoomEngineRuntime` / subprocess; shell supervisors and participant UI | Core owns SDK operation state; shells own interaction and presentation, consume operation/status snapshots. | +| Output sessions and observed health | Native encoder/output sender/core snapshots; shell transport coordinators | Core owns writer/destination lifecycle and session IDs; requested state belongs to commands/reconciliation. | +| Audio topology and DSP | Core/audio modules; shell mixer presenters | Core owns applied routing/mix state. UI owns controls, meters and editing intent. | +| Platform UI-thread work | Windows dispatcher and Swift main actor | Retain platform scheduling at a narrow adapter boundary; pure policy must not reference UI controls. | +| Development show-engine | TypeScript identity, hands queue, roles, program bus, overlays, gallery, speaker gate | Supported deterministic development client/harness. Do not advertise every show-engine feature as shipping native behavior. | + +## Deliberate differences requiring migration work + +- Windows can Take a pending media cue when preview and program use the same + scene ID, promoting playback with a new Take version. Swift currently rejects + same-scene Take. TypeScript `ProgramBus` models a bus swap without native media + playback promotion. These are behavioral differences, not naming differences. +- Windows production roles are session assignments and intentionally not persisted. + TypeScript show-engine roles can follow `pin:`, normalized `name:`, then `id:` + person keys across reconnects. Swift production inputs bind source IDs and have + their own offline/rebind behavior. Porting name/PIN matching without collision + rules would risk routing the wrong guest. +- Swift coalesces scene edits with a 120 ms task debounce. Windows transport sends + a scene sync after Take. A future native Take must be an ordered edge operation, + distinct from replaceable full scene state, and must not be removed by debounce + or replayed after an unknown acknowledgement. + +## Next bounded migration + +1. Generate `SceneRevision`, durable identity references, route intent, and native + Take request/result schemas. Establish explicit blank/missing-guest behavior. +2. Add a pure native program/preview transition policy with expected revision, + Take operation ID, same-scene draft promotion and media playback generation. + Execute it on the serialized core state owner and expose confirmed scene IDs. +3. Drive the same golden scenarios through both shell fake bridges and the native + policy: reconnect with new participant ID, ambiguous identity, missing guest, + preview edit without Take, normal swap, same-scene media cue, active speaker, + stale revision, lost acknowledgement and duplicate operation ID. +4. Migrate Windows and Swift independently behind capability negotiation. Remove + duplicated shell mutations only after both consume confirmed native results. +5. Narrow `ITransportHost` into transport intent, scene promotion and UI feedback + capabilities incrementally; do not combine that extraction with semantic changes. + +This change establishes shared render binding and a concrete ownership map. It +does **not** complete durable-identity migration, atomic native Take, cross-shell +Take parity, full host-interface decomposition, or generated legacy-protocol +coverage. Those remain explicit acceptance items in the remediation plan. diff --git a/docs/architecture-remediation-plan.md b/docs/architecture-remediation-plan.md new file mode 100644 index 00000000..177ff241 --- /dev/null +++ b/docs/architecture-remediation-plan.md @@ -0,0 +1,123 @@ +# CoreVideo Pro architecture remediation plan + +Status: implementation and validation in progress on `codex/architecture-reliability`. + +Implemented: Windows test gate, atomic production preference saves and recovery, +LAN control policy, generated lifecycle/operation/version contract slice, recording +lifecycle and shell adoption, asynchronous Zoom join with bounded input mailbox, +native route-binding policy, exact-candidate release evidence gate, and migration +documentation. The branch preserves compatibility with legacy protocol clients. + +Remaining acceptance work: complete legacy generated protocol coverage, +durable participant identity and native atomic Take migration, outbound response +backpressure and client overload reconciliation, designated live rigs, macOS media +execution, two-hour soak, physical fault injection and clean-machine package proof. +See [ownership map](architecture-ownership.md), [contract inventory](../contracts/README.md) +and [migration notes](architecture-migration.md). These items are not implied complete +by the automated test results. + +Baseline: architecture review of `660f6266f04f780fcb9e49dacfda409418e84852`. Refresh against current `main` before editing and revalidate each finding. Preserve intervening fixes. Use small `codex/` branches and reviewable PRs. The review checkout remains available at `review-source/`. + +**Outcome** + +Operators can trust recording/streaming status, execute time-sensitive commands during Zoom operations, recover saved production configuration, and upgrade across compatible protocol versions. Releases must carry evidence for the application and native adapters they actually ship. + +Keep the C++ media core, Zoom subprocess, native shells, GPU transport, and existing adapter interfaces. Deliver bounded reliability changes first, then consolidate policy. No replacement UI framework or new always-running policy process is needed for this plan. + +**Architecture decisions** + +- The core owns observed media and output lifecycle state. Shells submit intent and present snapshots; a requested boolean never establishes that media is flowing. +- Separate a process epoch, operation ID, and output-session ID. Results from a terminated process or superseded operation cannot update the current session. +- Shared production policy belongs in a platform-neutral native module. Shells retain presentation state, user interaction, and platform integration. Inventory TypeScript show-engine behavior and migrate useful policy incrementally through shared scenario fixtures; do not immediately port the whole package. +- Use a versioned JSON Schema contract with checked-in generated models and deterministic generation. Prove generator suitability on a small vertical slice before adopting dependencies; retain JSON-line transport. Capability/version negotiation controls compatibility. +- Long SDK operations use workers and publish completion back to the serialized state executor. Do not parallelize arbitrary state mutations. +- Treat output activity, output health, and file finalization as distinct facts. A healthy remaining stream continues when another destination fails; the aggregate status becomes degraded. + +**Phase 1 — immediate safeguards** + +| PR | Work | Acceptance criteria | +|---|---|---| +| 1 — Windows test gate | Add `CoreVideoPro.WinUI.Tests` to Windows CI and root `test:gate`; capture test results as artifacts. | Existing MediaCore, Control, and WinUI suites execute; a deliberate failing WinUI test fails the gate; Windows publish still succeeds. Baseline from review: 395 MediaCore and 733 WinUI tests passed. | +| 2 — durable production saves | Introduce a narrowly scoped atomic JSON file store; write a same-directory temporary file, flush, replace, and preserve the last good backup. Serialize concurrent saves. Return distinct missing/corrupt/unreadable/recovered load outcomes. Preserve schema migrations and encrypted secret fields. | Inject interruption before replacement, truncated JSON, write failure, and interrupted migration. Previous configuration remains recoverable. First launch stays normal. Corruption is surfaced, not silently treated as an empty show. Backup never replaces a valid file with corrupt input. | +| 3 — LAN control policy | Validate bind address/authentication before starting HTTP/WS. Require a token for non-loopback binding; keep loopback compatibility. Reject unauthorized HTTP and WS requests before invoking actions. Separate OSC LAN enablement from the shared LAN switch. | LAN HTTP startup without a token fails with an actionable message; valid token succeeds; missing/wrong token fails; localhost remains functional. OSC remains local unless separately configured with an explicit trusted-network policy. Update Companion setup instructions. | + +Primary files: `.github/workflows/ci.yml`, `package.json`, `ProductionOutputPreferencesStore.cs`, `HttpControlServer.cs`, `MainWindow.xaml.cs`, and related test projects. + +For PR 3, a bearer token on plain HTTP is not protection against network interception. Document the trusted-network boundary; remote/untrusted-network operation requires TLS or an authenticated tunnel. Do not silently expose OSC when securing HTTP. + +**Phase 2 — contracts and output truth** + +**PR 4: contract foundation.** Add `contracts/` for schema, compatibility fixtures, and generation tooling. Begin with handshake, errors, asynchronous-operation envelopes, and output lifecycle. Generate C++, C#, TypeScript, and Swift representations for this slice. Specify wire names, numeric widths, optional/null behavior, unknown enum handling, and additive-field compatibility. Use immutable DTOs where practical. + +Acceptance: all clients deserialize the same golden messages; invalid payloads fail with structured errors; supported older messages remain readable; an unsupported major version produces an explicit incompatibility response; regeneration produces no diff in CI. Existing messages continue through adapters while migration proceeds. Do not require a full-protocol rewrite before subsequent fixes. + +**PR 5: authoritative core output lifecycle.** Introduce separate desired activity, actual lifecycle, and health per recording session and per stream destination. Model at least idle, starting, live, stopping, finalizing where applicable, completed, and failed. Keep degraded health separate from lifecycle. Replace optimistic encoder `active` as the source of observed truth. Carry process/session identity through queued encoder/sender work. Emit writer-start, progress, failure, and finalization outcomes. + +Acceptance: delayed start remains starting; a failed writer cannot report live; startup failures and failures after acknowledgement remain visible; one failed stream cannot imply all destinations are healthy. A stop acknowledgement means accepted, while completion means the writer finalized. Stale callbacks cannot revive a stopped or newer session. Existing frame/audio backlog protections remain bounded. + +**PR 6: shell adoption and recovery semantics.** Update Windows transport coordinator/view models, macOS model, and development clients to consume the lifecycle contract. Remove `actual || requested` from observed activity. Preserve desired state explicitly for reconciliation. Show per-destination status and finalization progress. Add compatibility adapters for old snapshots without presenting unknown activity as verified live. + +Recovery rules: core restart marks prior output sessions interrupted; any resumed recording gets a new segment/session identity and an explicit continuity warning. A lost reply produces an unknown/reconciling state and snapshot query, not an unqualified success or blind replay. Identify retryable desired-state commands separately from edge-triggered operations such as Take. + +Acceptance: UI behavior is tested with fake bridge event sequences covering delayed start, late failure, mixed destination success, stop/finalize, lost acknowledgement, and restart. A record/stop/restart sequence produces correctly identified, playable files with honest interruption reporting. Test legacy clients and new clients against supported core versions. + +Dependencies: PR 4 precedes 5; PR 5 precedes 6. Keep the new contract additive until both shipping shells are migrated. + +**Phase 3 — responsive command processing** + +**PR 7: asynchronous Zoom lifecycle and bounded queues.** Build on PR 4's operation envelope. Join/authentication is accepted immediately, runs on the Zoom lifecycle worker, and reports progress/completion. Cancellation and leave invalidate the active operation; core shutdown waits only for a bounded teardown interval and safely disposes or terminates the subprocess when necessary. SDK calls obey its documented threading rules. + +Keep state application serialized. Add a bounded mailbox: coalesce explicitly replaceable full-state syncs at enqueue time; retain ordering for output commands; reserve capacity for cancellation/stop; return explicit overload errors instead of silently dropping commands. Never classify replaceable work using substring matching. Ensure snapshot coalescing cannot discard an embedded one-time action. + +Initial acceptance targets, to validate on the designated rig: + +- During an injected 30-second join delay, Stop/Take acceptance p95 is at most 250 ms and maximum is below one second. +- Program rendering continues without a join-induced pause. Measure the existing frame-time baseline and reject regressions against it. +- Queue size and bytes stay within configured limits under sustained input; normal-state updates coalesce and overload is observable. +- Cancel/rejoin, SDK failure, process exit, and stale completion preserve current session state. +- A late accepted Take never executes twice after timeout/retry. Distinguish command acknowledgement from media-effect completion in the measurements. + +Primary files: `native/src/rpc/JsonRpcServer.cpp`, Zoom runtime, native core command dispatch, and both shell supervisors. Do not hold `coreMutex` while waiting for a lifecycle worker or while joining it. + +**Phase 4 — ownership and complete contract coverage** + +**PRs 8a–8c: incremental consolidation.** + +1. Create a written ownership map for show document/routing, source lifecycle, transport sessions, and UI presentation. Inventory current Windows/macOS/TypeScript rules and record intentional differences. Complete generated schema coverage message family by message family. +2. Extract transport/source coordination behind narrow interfaces; make coordinators constructible without launching the core. Replace the broad host interface gradually with focused capabilities. Preserve Windows UI-thread dispatch and macOS main-actor constraints. +3. Extract one shared native policy slice first: stable participant identity/routing and Take behavior. Establish golden scenarios for reconnect, missing guest, preview edit, Take, and active-speaker selection. Migrate both shells to that slice, then retire duplicate implementations only after usage searches and parity tests establish that they are unused. + +Acceptance: both shells produce equivalent routing/output intent for shared scenarios; presentation-only differences remain local; behavior tests cover the extracted responsibility. Line-count reduction is not the acceptance metric. Do not combine broad class moves with untested behavior changes. + +The development React/Node implementation remains a supported contract client and deterministic test harness. TypeScript show-engine features that are not yet shipping remain explicitly scoped as such until deliberately integrated. + +**Phase 5 — release proof and documentation** + +**PR 9: exact-commit release gates.** Refactor reusable CI validation so release jobs directly depend on required suites, rather than hoping that branch checks were run. Record source SHA, adapter/build flags, SDK/runtime versions, OS/GPU/driver, artifact hash, and test outcome. Tag releases must fail if required evidence is missing, failed, skipped without an allowed reason, or belongs to a different commit/configuration. + +Add a dedicated hardware lane or an evidence-ingestion gate for controlled Windows rig tests. Start with: + +- Real Zoom multi-participant ingest plus program and selected ISO recording and stream output. +- A two-hour simultaneous record/stream soak; inspect memory slope, frame drops, queue depths, command latency, and measured A/V drift. Establish numeric media-quality thresholds from the baseline before declaring pass/fail. +- Network interruption per destination, camera unplug/replug, core/Zoom process termination, disk exhaustion, and shutdown during finalization. Use fault injection where physical simulation is impractical. +- Open and decode output artifacts; verify expected streams, durations, timestamps, A/V alignment, and intelligible content. File existence or nonzero size is insufficient. +- Clean-machine installation/update and launch for the packaged configuration. Require macOS equivalents for macOS release artifacts. + +Acceptance: the release cannot be published by its workflow without required automated and hardware evidence for that candidate. Archive logs, test results, media metadata, and configuration. Keep credentials and personal meeting content out of public artifacts. + +**PR 10: documentation and rollout.** Update the architecture diagram, current native-UVC behavior, macOS status, actual RPC transport, output lifecycle meanings, LAN configuration, recovery behavior, and capability inventory. Derive compile-time capability facts from the build manifest; keep hardware verification as a separately dated evidence record. Publish a migration note for protocol and control clients. + +Acceptance: a developer can build the documented configuration, an operator can distinguish starting/live/finalized, and a reviewer can trace every advertised verified capability to current evidence. + +**Delivery order and rollback** + +Execute PRs 1–3 first. Then 4 → 5 → 6 → 7 → 8 → 9 → 10. Prepare the hardware harness during earlier implementation so verification is not discovered at the end. The first release-workflow test dependency can land with PR 1; PR 9 adds the full media evidence gate. + +For contract changes, use additive fields and capability negotiation before removing legacy fields. Preserve a known-good configuration backup before migrations. For behavioral changes, retain a narrow temporary compatibility path only when it remains truthful about output state. Do not roll back to optimistic success reporting or silently bypass mandatory release checks. + +**Completion definition** + +All seven architecture findings have implemented fixes or, for large ownership changes, the specifically scoped shared policy slice and explicit remaining ownership map. Generated contracts cover the supported command/snapshot surface. Windows and macOS clients pass shared lifecycle/routing fixtures. Release automation requires the relevant exact-commit tests and media evidence. No remaining issue may be called complete solely because unit tests pass; any unfinished consolidation is tracked explicitly rather than hidden behind this plan's completion. + +Next review step: validate the combined branch and review the changes before merge; +provision the controlled release rig and finish the explicit migration acceptance items. diff --git a/docs/architecture-validation.md b/docs/architecture-validation.md new file mode 100644 index 00000000..463ec570 --- /dev/null +++ b/docs/architecture-validation.md @@ -0,0 +1,105 @@ +# Architecture branch validation + +Local validation on Windows, September 5–6, 2026. These are working-branch +results, not evidence for a signed release candidate. + +| Check | Result | +| --- | --- | +| Native portable suite | 619 passed, including 18 asynchronous encoder lifecycle cases | +| WinUI suite | 750 passed; TRX retained under `artifacts/test-results/winui` | +| MediaCore suite | 487 passed, including real child-process handshake/restart cases | +| Control suite | 53 passed | +| Renderer unit / integration | 1,739 / 111 passed | +| Node protocol runtime | 88 passed | +| TypeScript show engine | 417 passed | +| Release evidence validator | 22 passed | +| Shared lifecycle fixtures | 56 wire cases in C++, C#, both TypeScript consumers and Swift | +| TypeScript checks / contract regeneration | Passed | +| Real Windows native Release build | Passed with D3D11, Media Foundation and Zoom SDK adapters | +| WinUI Release publish | Passed; existing analyzer warnings remain | +| Stub process bridge smoke | Passed with an explicitly selected stub binary | +| macOS shell CI | Build passed; 173 checks passed | +| macOS native CI | Stub and Metal/AVFoundation/CoreAudio/capture configurations passed their suites | + +## Process responsiveness + +The fake engine injects separate 30-second authentication and join stalls. Each +stage measures 40 Ping and Stop round trips and sends 128 MiB of input. Stop p95 +was 14.58 ms during authentication and 17.33 ms during join; maximums were 19.75 +and 18.29 ms. Peak private memory stayed below 4.3 MB in these runs. Cancellation +worked and an incompatible Leave request did not cancel the current operation. + +This proves synthetic process responsiveness for the exercised workload. It does +not establish real Zoom render cadence, Take effect latency, or slow-client +outbound backpressure. Mailbox overload limits are covered separately by native +tests. The reproducible harness is [validate-command-responsiveness.mjs](../scripts/validate-command-responsiveness.mjs). + +## Real recording + +A generated 1920×1080 color-bar scene and silent stereo audio were recorded with +Media Foundation and D3D11. The harness observed the matching session become live, +accepted Stop, waited for completed/finalized, then required ffprobe metadata, +full ffmpeg decoding, and varied decoded image content. Both runs passed these +checks and produced H.264 video plus 48 kHz stereo AAC. + +The isolated run delivered about 51.13 effective frames/second with 65 encoder +queue drops under a configured 60fps profile. An equivalent build of the original +`660f626` commit, with matching adapter flags/compiler/SDK and no source changes, +delivered 50.72 fps with 62 drops. The throughput ceiling exists in the baseline; +these short runs do not indicate a branch regression or establish statistical +equivalence. Successful finalization is not a 60fps acceptance result. A/V duration +agreement in silent generated content is not a lip-sync or intelligibility test. +See the [recording proof procedure](recording-finalization-validation.md). + +## Remaining evidence + +The macOS media drill still misses its 60fps recording requirement. Branch CI runs +reported 19.3 and 24.1 fps; historical main runs reported 29.4 and 25.4 fps. This +establishes an existing failing gate but shared-runner variation does not exclude +a regression statistically. The frame-rate requirement remains unchanged. + +Real Zoom +multi-participant ingest, simultaneous recording/streaming, per-destination faults, +physical camera disconnection, disk exhaustion, the two-hour soak and clean-machine +installation remain unverified for this candidate. The release workflow requires +the controlled-rig evidence described in [release-evidence.md](release-evidence.md). + +Full generated legacy-protocol coverage, durable participant identity/native Take, +and outbound backpressure remain implementation work in the +[remediation plan](architecture-remediation-plan.md). They are not hidden behind +the automated test totals. + +## PR review follow-up + +The follow-up fixes six review findings: shared media queue budgets across +recording generations; output lifecycle polling while Zoom capture is off; +explicit Windows Stop retries; macOS recording command reconciliation; +request-only Windows handshakes; and successful PowerShell release harnesses +that do not set a native exit code. + +The encoder retains media already accepted before an older Stop barrier. When +older generations fill a media budget, incoming media from a newer take is +dropped and counted until capacity returns. A new take only becomes live after +the writer reports progress. The regression holds the writer stalled across +100 recording generations and checks the preserved tail and subsequent recovery. + +Stop retries send an explicit false target rather than toggling an already-false +intent back to true. macOS also guards asynchronous completions so an older +command cannot overwrite newer intent. Windows output polling applies its output +fields independently of Zoom capture subscription. Explicit handshakes may pass +the startup gate; ordinary application commands still require a validated profile. +Both solicited and unsolicited incompatible handshakes terminate the rejected child +without an automatic restart, and profile publication is bound to the originating +process. + +Follow-up local validation: 621 native tests (20 asynchronous encoder cases), +489 MediaCore tests (11 handshake cases), 758 WinUI tests, and 23 release-evidence +tests passed. The harness regression executes the workflow's PowerShell invocation +with normal success, stale exit status, explicit script failure, an exception, and +a failed native child. The macOS policy adds 29 checks to the hosted Swift suite; +design lint passed locally, where no Swift compiler is installed. The remaining +recording-cadence and controlled-rig acceptance requirements above are unchanged. + +Final merge review added coverage for delayed live snapshots after successful or +failed Stop: those snapshots must preserve stopped intent, while observed media +still permits an explicit Stop retry. A later explicit Start can arm a new take. diff --git a/docs/control-command-lifecycle.md b/docs/control-command-lifecycle.md new file mode 100644 index 00000000..70da526d --- /dev/null +++ b/docs/control-command-lifecycle.md @@ -0,0 +1,56 @@ +# Control command lifecycle + +The native handshake includes `protocolVersion: {major: 1, minor: 0}` and a +`processEpoch`. Existing clients may omit version fields. Requests specifying an +unsupported major receive `incompatible-protocol` before execution. + +Real Zoom join/authentication runs on one dedicated lifecycle worker. The normal +command executor remains available for Take, recording/streaming commands, Leave, +and snapshots. Legacy `zoom-join` callers still receive a final reply with their +original request ID and the Zoom snapshot; moving the work off-thread does not +turn a pending join into a successful meeting. + +Clients that send `asyncOperation: true` receive an accepted `operation` containing +`processEpoch`, `operationId`, and `state`. A later `operation-completed` event +contains the final operation and `result`. Match both identity fields. Accepted +does not mean joined. `zoom-leave` and `zoom-cancel` invalidate the current join; +late worker success becomes `operation-cancelled`. Repeated join requests while +the worker is occupied receive `operation-in-progress`, without starting another +SDK operation. Retry only after cancellation/completion; never replay Take because +a reply was lost. + +The input mailbox holds at most 128 requests and 8 MiB of wire data. Eight slots +and one eighth of the byte capacity are reserved for Leave/cancel/stop commands. +FIFO order is retained. Overload returns `control-overloaded` with the request ID +and executes no action. Individual lines are limited to 4 MiB (including room for +base64-encoded VST state); larger lines are drained and rejected as +`request-too-large` with an unknown ID because their JSON was not parsed. + +Coalescing requires both `replaceableFullState: true` and a non-empty +`coalescingKey`. Only adjacent matching sync envelopes containing exclusively +allowlisted state commands can replace each other. Recording, output start/stop, +Take, VST actions, and unknown commands cannot be coalesced. A replaced request +receives `superseded: true`, without an applied-state snapshot. Current legacy +sync producers do not opt in, so their commands preserve order and effects. + +Deterministic tests cover a delayed join, cancellation during a 30-second auth +wait, discarded late success, mailbox byte/entry capacity, stop reservation, and +embedded-action preservation. Physical-rig p95/maximum command latency and render +frame-time evidence remain required. Leave suppresses late Joined callbacks while left. The next join retires the old +SDK process before spawning a fresh one; reader events carry an internal process +generation so old-process callbacks cannot revive the next meeting. Outbound +response backpressure and shell overload reconciliation remain follow-up work. + +Run the process-level regression with: + +```powershell +cmake --build native/build --config Release --target corevideo-native corevideo-zoom-engine-fake +node scripts/validate-command-responsiveness.mjs --build-dir native/build +``` + +The script requires a stub build with capture adapters disabled. It injects separate +30-second auth and join stalls through the fake engine, measures 40 Ping and Stop +round trips in each stage, sends 128 MiB of input per stage while sampling core +memory, and verifies cancellation plus rejection without side effects. It writes +`command-responsiveness-evidence.json` beside the binaries with their hashes. +These are synthetic IPC measurements, not live media or GPU acceptance evidence. diff --git a/docs/recording-finalization-validation.md b/docs/recording-finalization-validation.md new file mode 100644 index 00000000..8d1e6b6d --- /dev/null +++ b/docs/recording-finalization-validation.md @@ -0,0 +1,49 @@ +# Recording finalization and decode proof + +Run the strict local recording harness with an explicit freshly built core: + +```powershell +node scripts/validate-recording-finalization.mjs --native-core C:/path/to/build-dev/corevideo-native.exe +``` + +It requires a real Media Foundation or AVFoundation encoder plus `ffmpeg` and +`ffprobe`. Missing tools, a stub encoder, absent lifecycle fields, failed writer +finalization, invalid streams, and decode errors fail the run. + +The test generates its own 1920×1080 color-bar image, configures 60fps output, and +records six seconds after the writer becomes live. It routes no camera, starts no +Zoom engine or meeting, and configures no network destination. Silent audio is +expected. After Stop, it waits for the same session to report both `completed` and +`finalized: true` before probing and fully decoding the artifact. Pixel checks +ensure the decoded recording contains varied color content. + +Artifacts, core stderr, binary/file hashes, lifecycle transitions, probe output, +effective video rate, and encoder queue drops remain in a unique +`recording-finalization-proof-*` directory beside the executable. The test's +`passed` verdict covers finalization and decoding only. Performance warnings are +reported separately; this short synthetic scene cannot establish live Zoom, +multisource load, continuous 60fps, A/V content synchronization, or soak acceptance. + +The initial Windows validation and an isolated rerun both finalized and decoded +successfully. The isolated rerun delivered approximately 51.13 effective video +frames/second with 65 encoder queue drops despite a steady 60fps renderer. This is +an outstanding writer-throughput finding, not passing 60fps evidence. A detached +build of baseline `660f626` with identical adapter flags/compiler/SDK produced +50.72 effective fps and 62 drops on the same generated scene and duration. The +baseline needed no source changes. This comparison identifies an existing +throughput ceiling; it does not establish statistical performance equivalence. + +A detached build of the reviewed baseline (`660f626`) was tested with the same +compiler, SDK, build flags, generated scene, recording settings, and duration. +The baseline was left unchanged; its comparator waited for actual written frames, +sent Stop, then required a readable container and full decode because baseline +lifecycle fields do not exist. + +| Build | Effective video fps | Encoder queue drops | Final artifact decode | +| --- | ---: | ---: | --- | +| Reviewed baseline `660f626` | 50.72 | 62 | Passed | +| Remediation build, isolated run | 51.13 | 65 | Passed | + +This short comparison reproduces the throughput limitation on the baseline. It +does not indicate a new throughput regression from the lifecycle changes; a +controlled longer benchmark is still needed before claiming 60fps acceptance. diff --git a/docs/release-evidence.md b/docs/release-evidence.md new file mode 100644 index 00000000..744c2438 --- /dev/null +++ b/docs/release-evidence.md @@ -0,0 +1,121 @@ +# Release validation and hardware evidence + +Tag releases now call the complete reusable CI workflow at the tag's commit. +The Windows job runs native C++ tests, MediaCore, Control, WinUI, the bridge +smoke test and a WinUI publish build. The three .NET suites emit TRX under +`artifacts/test-results`; CI uploads them even when a suite fails. Missing CMake +or Visual Studio is a failed native gate. Run the same suites locally with +`npm run test:native-shell`, or the complete local gate with `npm run test:gate`. + +Publication follows this dependency chain: + +1. Required CI and version validation. +2. Build, package and sign the Windows MSIX. Generate `candidate.json` from the + source SHA, actual CMake boolean flags, hashes of packaged DLL/EXE/JSON + runtime files, and the signed package SHA-256. Upload this immutable candidate. +3. A controlled Windows rig downloads that candidate and exercises its bytes. +4. Validate hardware evidence and archive sanitized evidence/logs. +5. The publish job downloads the same candidate and evidence, rechecks both, + and publishes the existing package with `candidate.json` and `verification.json`. + +This avoids a hash cycle: evidence is collected **after** signing, and publication +does not rebuild or re-sign. An unsigned workflow-dispatch dry run cannot publish. +The private Zoom SDK and signing configuration remain required for the build. +Update-host publication still requires the existing manual upload step. + +## Controlled rig provisioning + +The workflow is fail-closed until these external prerequisites exist; this change +does not establish that a real Zoom or hardware session passed. + +- Register a dedicated runner with labels `self-hosted`, `Windows`, `X64`, and + `corevideo-release-rig`. Never use it to execute untrusted pull requests. +- Configure `production-hardware-validation` and `production-release` GitHub + environments with the intended release/tag restrictions and reviewers. +- Set `COREVIDEO_HARDWARE_HARNESS` in the hardware environment to an absolute + path to the rig-owned executable or PowerShell script. Restrict who can change + this harness, variable, runner, and release workflow. +- Provision cameras, GPU/driver, Zoom test meeting participants, stream receivers, + disposable disk/fault-injection storage and a clean Windows install/update target. + The harness needs access to a clean target; an already-configured development + machine alone does not prove installation behavior. +- Establish and retain a dated baseline with maximum memory slope, frame drop + percentage, queue depth, and absolute A/V drift. Do not invent passing limits + after seeing the candidate's results. Record the baseline ID in evidence. + +The workflow calls the harness with named arguments: + +```powershell +& $harness -CandidateManifest $manifest -PackagePath $signedMsix -EvidenceDirectory $freshDirectory +``` + +The harness installs and tests the package, exits nonzero on failure, and writes +`evidence.json` plus sanitized attachments to the fresh evidence directory. +The workflow allows four hours for the lane, including a mandatory two-hour +simultaneous recording/streaming soak. It never turns a missing harness, skipped +check, missing attachment, or stale report into success. No skip waivers exist in +schema version 1. If a packaged feature cannot pass a required test, publication +stops; changing a release's capability policy requires a reviewed workflow change. + +## Evidence contract (schema version 1) + +`evidence.json` is a JSON object with: + +| Field | Requirement | +| --- | --- | +| `schemaVersion` | `1` | +| `sourceSha` | Full 40-character commit SHA from candidate | +| `configurationSha256` | Exact digest from candidate | +| `artifactSha256` | SHA-256 of the tested signed MSIX | +| `startedAt`, `completedAt` | ISO timestamps after candidate creation, in order, no future completion, completed within seven days | +| `environment` | Nonempty strings: `rigId`, `os`, `gpu`, `driver`, `zoomSdkVersion`, `runtimeVersions`, `baselineId` | +| `checks` | Unique check objects described below | + +Each check has `id`, `status: "passed"`, and a nonempty `attachments` array of +`{ "path": "relative/sanitized-report.json", "sha256": "<64 lowercase hex characters>" }`. +The validator reads and hashes attachments; a link or claimed checksum alone is +insufficient. Attachments must be inside the evidence directory. Keep meeting +credentials, personal participant content and confidential SDK files out of +uploaded artifacts. Prefer sanitized measurements, logs and decoded media metadata; +retain sensitive source recordings on access-controlled rig storage. + +Required check IDs: + +- `zoom-multiparticipant-ingest` +- `program-iso-record-stream` +- `simultaneous-soak` +- `network-interruption-per-destination` +- `camera-unplug-replug` +- `core-process-termination` +- `zoom-process-termination` +- `disk-exhaustion` +- `shutdown-during-finalization` +- `decode-and-av-alignment` +- `clean-machine-install-update-launch` + +`simultaneous-soak` additionally has `durationSeconds >= 7200` and `metrics` with +`memorySlopeMbPerHour`, `frameDropPercent`, `queueDepth`, `commandP95Ms`, +`commandMaxMs`, and `avDriftMs`. Each metric is `{ "value": number, "maximum": number }`; +both must be finite and nonnegative, with value at most maximum. Latency maximums +cannot exceed 250 ms p95 / 1000 ms maximum; use the approved baseline's numeric +limits for other metrics. The reported test interval must span the soak duration. +Reports should distinguish request acceptance latency from actual media effects. + +Decode checks must inspect program and ISO streams, duration, timestamp order, +A/V alignment and intelligible content. A file-size check alone is not this check. +Fault reports should identify affected destinations, recovery behavior, finalization +outcome and the actual simulated or physical fault. Clean-install reports should +identify the clean target and prior version used for upgrade validation. + +Validate a collected report locally: + +```powershell +node scripts/release-evidence.mjs validate candidate.json evidence/evidence.json CoreVideoPro.msix verdict.json +npm run test:release-evidence +``` + +The validator verifies provenance consistency, completeness and thresholds; it +cannot independently prove that a human-controlled rig performed the reported +experiment. Trust rests on the protected harness/rig and retained reports. +There is currently no macOS publication workflow in `release.yml`; any future +macOS artifact must add an equivalent exact-artifact hardware gate before shipping. diff --git a/mac-shell/Sources/CoreVideoProShell/AppModel.swift b/mac-shell/Sources/CoreVideoProShell/AppModel.swift index dbb24052..eae93fac 100644 --- a/mac-shell/Sources/CoreVideoProShell/AppModel.swift +++ b/mac-shell/Sources/CoreVideoProShell/AppModel.swift @@ -286,6 +286,8 @@ final class AppModel: ObservableObject { @Published var roster: [RosterParticipant] = [] @Published var assignedIds: Set = [] @Published var recordingStatus = "idle" + @Published var recordingDesired = false + private var recordingCommands = RecordingCommandPolicy() @Published var recordingStartedAt: Date? @Published var recordingArtifactPath = "" @Published var recordingWarning = "" @@ -577,6 +579,12 @@ final class AppModel: ObservableObject { self?.onConnected() } if case .exited(let code) = status { + self?.recordingCommands.interrupted() + self?.recordingDesired = false + self?.streamingDesired = false + self?.recordingStatus = "interrupted" + self?.streamStatus = "interrupted" + self?.streamDetail = "Media core exited; output continuity was interrupted." self?.pushWarning("media core exited (code \(code)) — relaunching") } } @@ -713,7 +721,9 @@ final class AppModel: ObservableObject { refreshSlotHealth() } if let recording = snapshot["recording"] as? JSONObject { - let nextStatus = recording["status"] as? String ?? "idle" + let nextStatus = RecordingLifecycleReadModel.status(recording) + recordingCommands.observe(nextStatus) + recordingDesired = recordingCommands.desired if nextStatus == "recording", recordingStatus != "recording" { recordingStartedAt = Date() } @@ -1914,7 +1924,7 @@ final class AppModel: ObservableObject { } private var isRecordingActive: Bool { - recordingStatus == "recording" || recordingStatus == "warning" + recordingDesired } func toggleStreaming() { @@ -2244,7 +2254,9 @@ final class AppModel: ObservableObject { func toggleRecording() { guard let bridge else { return } - let stop = recordingStatus == "recording" || recordingStatus == "warning" + let operation = recordingCommands.begin() + let stop = operation.stop + recordingDesired = recordingCommands.desired Task { do { if stop { @@ -2282,7 +2294,11 @@ final class AppModel: ObservableObject { ], ]) } + recordingCommands.finish(operation, failed: false) + recordingDesired = recordingCommands.desired } catch { + guard recordingCommands.finish(operation, failed: true) else { return } + recordingDesired = recordingCommands.desired pushWarning("recording command failed: \(error.localizedDescription)") } } diff --git a/mac-shell/Sources/CoreVideoProShell/Lifecycle.generated.swift b/mac-shell/Sources/CoreVideoProShell/Lifecycle.generated.swift new file mode 100644 index 00000000..46d874c6 --- /dev/null +++ b/mac-shell/Sources/CoreVideoProShell/Lifecycle.generated.swift @@ -0,0 +1,95 @@ +// Generated by contracts/generate.mjs. Do not edit. +import Foundation +import CoreFoundation +struct ProtocolVersion: Codable { + var major: Int + var minor: Int +} +func validateProtocolVersion(_ value: [String: Any]) -> Bool { + if let raw = value["major"] { + guard let parsed = raw as? NSNumber else { return false } + if CFGetTypeID(parsed) == CFBooleanGetTypeID() { return false } + if parsed.doubleValue.rounded() != parsed.doubleValue || parsed.doubleValue < 1 || parsed.doubleValue > 1 { return false } + } else { return false } + if let raw = value["minor"] { + guard let parsed = raw as? NSNumber else { return false } + if CFGetTypeID(parsed) == CFBooleanGetTypeID() { return false } + if parsed.doubleValue.rounded() != parsed.doubleValue || parsed.doubleValue < 0 || parsed.doubleValue > 2147483647 { return false } + } else { return false } + return true; +} +struct OutputLifecycle: Codable { + var sessionId: String + var desiredActive: Bool + var state: String + var health: String + var finalized: Bool + var error: String? = nil +} +func validateOutputLifecycle(_ value: [String: Any]) -> Bool { + if let raw = value["sessionId"] { + guard let parsed = raw as? String else { return false } + if parsed.isEmpty { return false } + } else { return false } + if let raw = value["desiredActive"] { + guard let parsed = raw as? NSNumber else { return false } + if CFGetTypeID(parsed) != CFBooleanGetTypeID() { return false } + } else { return false } + if let raw = value["state"] { + guard let parsed = raw as? String else { return false } + if !["idle","starting","live","stopping","finalizing","completed","failed","interrupted"].contains(parsed) { return false } + } else { return false } + if let raw = value["health"] { + guard let parsed = raw as? String else { return false } + if !["unknown","healthy","degraded","failed"].contains(parsed) { return false } + } else { return false } + if let raw = value["finalized"] { + guard let parsed = raw as? NSNumber else { return false } + if CFGetTypeID(parsed) != CFBooleanGetTypeID() { return false } + } else { return false } + if let raw = value["error"] { + guard let parsed = raw as? String else { return false } + _ = parsed + } + return true; +} +struct OperationStatus: Codable { + var processEpoch: String + var operationId: String + var state: String + var error: String? = nil +} +func validateOperationStatus(_ value: [String: Any]) -> Bool { + if let raw = value["processEpoch"] { + guard let parsed = raw as? String else { return false } + if parsed.isEmpty { return false } + } else { return false } + if let raw = value["operationId"] { + guard let parsed = raw as? String else { return false } + if parsed.isEmpty { return false } + } else { return false } + if let raw = value["state"] { + guard let parsed = raw as? String else { return false } + if !["accepted","running","completed","failed","cancelled"].contains(parsed) { return false } + } else { return false } + if let raw = value["error"] { + guard let parsed = raw as? String else { return false } + _ = parsed + } + return true; +} +struct ProtocolFailure: Codable { + var code: String + var message: String +} +func validateProtocolFailure(_ value: [String: Any]) -> Bool { + if let raw = value["code"] { + guard let parsed = raw as? String else { return false } + if parsed.isEmpty { return false } + } else { return false } + if let raw = value["message"] { + guard let parsed = raw as? String else { return false } + if parsed.isEmpty { return false } + } else { return false } + return true; +} diff --git a/mac-shell/Sources/CoreVideoProShell/MediaCoreBridge.swift b/mac-shell/Sources/CoreVideoProShell/MediaCoreBridge.swift index 114e1817..1fd5682e 100644 --- a/mac-shell/Sources/CoreVideoProShell/MediaCoreBridge.swift +++ b/mac-shell/Sources/CoreVideoProShell/MediaCoreBridge.swift @@ -16,22 +16,56 @@ enum BridgeStatus: Equatable { case failed(String) } +// Every state transition occurs on MediaCoreBridge.stateQueue. The small policy +// is independently testable without launching a process or loading the UI. +struct BridgeGenerationPolicy { + private(set) var generation: UInt64 = 0 + private(set) var stopped = true + private(set) var running = false + private(set) var ready = false + + mutating func begin() -> UInt64 { + generation &+= 1 + stopped = false + running = true + ready = false + return generation + } + + mutating func invalidate(stopped: Bool) -> UInt64 { + generation &+= 1 + self.stopped = stopped + running = false + ready = false + return generation + } + + func isCurrent(_ token: UInt64) -> Bool { token == generation && running && !stopped } + func canWrite(_ token: UInt64) -> Bool { isCurrent(token) && ready } + func canRelaunch(_ token: UInt64) -> Bool { token == generation && !running && !stopped } + + mutating func acceptHandshake(_ token: UInt64) -> Bool { + guard isCurrent(token) else { return false } + ready = true + return true + } +} + final class MediaCoreBridge { private let corePath: String private let environmentExtras: [String: String] + // Process identity, framing, pending requests and recovery scheduling all + // belong to one serial executor. Blocking pipe writes never run on it. + private let stateQueue = DispatchQueue(label: "us.iamfatness.corevideopro.bridge-state") + private let callbackQueue = DispatchQueue(label: "us.iamfatness.corevideopro.bridge-callback") + private let writeQueue = DispatchQueue(label: "us.iamfatness.corevideopro.bridge-write") + private var policy = BridgeGenerationPolicy() private var process: Process? private var stdinHandle: FileHandle? - private var stdoutBuffer = Data() + private var lineBuffer: [UInt8] = [] private var nextId = 1 - private var pending: [String: (Result) -> Void] = [:] - private let lock = NSLock() + private var pending: [String: CompletionBox] = [:] private var relaunchAttempts = 0 - private var stopped = false - // ALL stdin writes go through one serial queue: requests originate from - // concurrent tasks (10Hz sync + zoom poll + operator commands), and - // interleaved FileHandle writes tear the JSON lines — the core answers - // with id "unknown" and every request times out. - private let writeQueue = DispatchQueue(label: "us.iamfatness.corevideopro.bridge-write") var onStatus: ((BridgeStatus) -> Void)? var onEvent: ((JSONObject) -> Void)? @@ -43,87 +77,128 @@ final class MediaCoreBridge { } func start() { - stopped = false - launch() + stateQueue.async { [weak self] in + guard let self, !self.policy.running else { return } + self.launch() + } } func stop() { - stopped = true - process?.terminate() + let detached = stateQueue.sync { () -> (Process?, FileHandle?, [CompletionBox]) in + _ = policy.invalidate(stopped: true) + let retired = detachChild() + let waiters = Array(pending.values) + pending.removeAll() + return (retired.0, retired.1, waiters) + } + // No callbacks or process disposal while owning the state executor. + Self.retire(detached.0, stdin: detached.1) + callbackQueue.async { + for waiter in detached.2 { waiter.finish(with: .failure(BridgeError.processExited)) } + } + } + + // stateQueue only. + private func detachChild() -> (Process?, FileHandle?) { + let detached = (process, stdinHandle) process = nil + stdinHandle = nil + lineBuffer.removeAll(keepingCapacity: true) + return detached } + private static func retire(_ proc: Process?, stdin: FileHandle?) { + proc?.terminationHandler = nil + (proc?.standardOutput as? Pipe)?.fileHandleForReading.readabilityHandler = nil + (proc?.standardError as? Pipe)?.fileHandleForReading.readabilityHandler = nil + // Terminate before closing a writer that might be blocked on a full pipe. + if proc?.isRunning == true { proc?.terminate() } + try? stdin?.close() + } + + // Callback delivery remains outside the state executor, so callers may + // reenter start/stop/request. A queued callback from an old child is dropped. + private func deliver(_ token: UInt64, _ action: @escaping () -> Void) { + callbackQueue.async { [weak self] in + guard let self, self.stateQueue.sync(execute: { self.policy.generation == token }) else { return } + action() + } + } + + // stateQueue only. Installing identity before run() lets a fast bootstrap + // handshake queue safely while launch is completing. private func launch() { - onStatus?(.launching) + let token = policy.begin() + lineBuffer.removeAll(keepingCapacity: true) + deliver(token) { [weak self] in self?.onStatus?(.launching) } let proc = Process() proc.executableURL = URL(fileURLWithPath: corePath) var env = ProcessInfo.processInfo.environment - for (key, value) in environmentExtras { - env[key] = value - } + for (key, value) in environmentExtras { env[key] = value } proc.environment = env - let stdinPipe = Pipe() - let stdoutPipe = Pipe() - let stderrPipe = Pipe() + let stdinPipe = Pipe(), stdoutPipe = Pipe(), stderrPipe = Pipe() proc.standardInput = stdinPipe proc.standardOutput = stdoutPipe proc.standardError = stderrPipe + process = proc stdinHandle = stdinPipe.fileHandleForWriting stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in - self?.consumeStdout(handle.availableData) + let data = handle.availableData + // Backpressure stays at the pipe reader instead of accumulating an + // unbounded queue of full frame/snapshot chunks behind state work. + self?.stateQueue.sync { self?.consumeStdout(data, token: token) } } stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in - guard let text = String(data: handle.availableData, encoding: .utf8), !text.isEmpty - else { return } - for line in text.split(separator: "\n") { - self?.onStderrLine?(String(line)) + guard let text = String(data: handle.availableData, encoding: .utf8), !text.isEmpty else { return } + self?.stateQueue.sync { + guard let self, self.policy.isCurrent(token) else { return } + self.deliver(token) { [weak self] in + for line in text.split(separator: "\n") { self?.onStderrLine?(String(line)) } + } } } proc.terminationHandler = { [weak self] finished in - guard let self else { return } - self.failAllPending(BridgeError.processExited) - self.onStatus?(.exited(code: finished.terminationStatus)) - guard !self.stopped else { return } - self.relaunchAttempts += 1 - let delay = min(30.0, pow(2.0, Double(self.relaunchAttempts))) - DispatchQueue.global().asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self, !self.stopped else { return } - self.launch() - } + let code = finished.terminationStatus + self?.stateQueue.async { [weak self] in self?.childExited(token: token, code: code) } } - do { - try proc.run() - process = proc - } catch { - onStatus?(.failed("core launch failed: \(error.localizedDescription)")) + do { try proc.run() } + catch { + let failureToken = policy.invalidate(stopped: true) + let retired = detachChild() + callbackQueue.async { Self.retire(retired.0, stdin: retired.1) } + deliver(failureToken) { [weak self] in self?.onStatus?(.failed("core launch failed: \(error.localizedDescription)")) } } } - // NOTE: Data slice indices do NOT rebase after removeSubrange — the naive - // firstIndex/removeSubrange loop corrupted framing whenever one chunk - // carried multiple lines (constant, with 30fps preview events), silently - // dropping RESPONSE lines while the tiny early handshake survived. Split - // on a plain byte array instead. - private var lineBuffer: [UInt8] = [] + // stateQueue only. The recovery token invalidates an already-scheduled + // relaunch when stop(), an explicit new start, or another exit intervenes. + private func childExited(token: UInt64, code: Int32) { + guard policy.isCurrent(token) else { return } + let recoveryToken = policy.invalidate(stopped: false) + let retired = detachChild() + failAllPending(BridgeError.processExited) + callbackQueue.async { Self.retire(retired.0, stdin: retired.1) } + deliver(recoveryToken) { [weak self] in self?.onStatus?(.exited(code: code)) } + relaunchAttempts += 1 + let delay = min(30.0, pow(2.0, Double(relaunchAttempts))) + stateQueue.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self, self.policy.canRelaunch(recoveryToken) else { return } + self.launch() + } + } - private func consumeStdout(_ data: Data) { - guard !data.isEmpty else { return } + private func consumeStdout(_ data: Data, token: UInt64) { + guard policy.isCurrent(token), !data.isEmpty else { return } lineBuffer.append(contentsOf: data) for object in Self.drainCompleteLines(&lineBuffer) { - dispatch(object) + guard policy.isCurrent(token) else { return } + dispatch(object, token: token) } } - /// Pulls every COMPLETE newline-terminated JSON object out of `buffer` and - /// leaves any partial tail behind for the next read. - /// - /// Pure and static so it can be tested without a core process. This is the - /// seam where a stdout read boundary lands mid-object: the core emits - /// megabyte snapshots, so a response IS routinely split across reads, and - /// getting this wrong drops responses at random — every command would look - /// like it timed out. A malformed line is skipped rather than poisoning the - /// buffer, because one bad event must not take the session down. + /// Parse complete newline-terminated objects, preserving only this child's + /// partial tail. Plain byte-array indices remain valid after removal. static func drainCompleteLines(_ buffer: inout [UInt8]) -> [JSONObject] { var objects: [JSONObject] = [] var start = 0 @@ -132,8 +207,7 @@ final class MediaCoreBridge { if buffer[index] == 0x0A { if index > start { let lineData = Data(buffer[start.. + if object["ok"] as? Bool == false { let message = (object["error"] as? JSONObject)?["message"] as? String - completion(.failure(BridgeError.remote(message ?? "request failed"))) - } else { - completion(.success(object)) + result = .failure(BridgeError.remote(message ?? "request failed")) + } else { result = .success(object) } + callbackQueue.async { [weak self] in + guard let self else { box.finish(with: .failure(BridgeError.processExited)); return } + let current = self.stateQueue.sync { self.policy.isCurrent(token) } + box.finish(with: current ? result : .failure(BridgeError.processExited)) } return } } - onEvent?(object) + guard policy.ready else { return } + deliver(token) { [weak self] in self?.onEvent?(object) } } + // stateQueue only; continuations resume outside it. private func failAllPending(_ error: Error) { - lock.lock() - let all = pending + let waiters = Array(pending.values) pending.removeAll() - lock.unlock() - for (_, completion) in all { - completion(.failure(error)) + callbackQueue.async { + for box in waiters { box.finish(with: .failure(error)) } } } + private func failRequest(_ id: String, token: UInt64, error: Error) { + guard policy.generation == token, let box = pending.removeValue(forKey: id) else { return } + callbackQueue.async { box.finish(with: .failure(error)) } + } + func request(_ body: JSONObject, timeout: TimeInterval = 6.0) async throws -> JSONObject { - lock.lock() - let id = "shell-\(nextId)" - nextId += 1 - lock.unlock() - var payload = body - payload["id"] = id - let data = try JSONSerialization.data(withJSONObject: payload) return try await withCheckedThrowingContinuation { continuation in let box = CompletionBox(continuation: continuation) - lock.lock() - pending[id] = { box.finish(with: $0) } - lock.unlock() - guard let handle = stdinHandle else { - lock.lock() - pending.removeValue(forKey: id) - lock.unlock() - box.finish(with: .failure(BridgeError.notRunning)) - return - } - writeQueue.async { - var line = data - line.append(0x0A) - do { - try handle.write(contentsOf: line) - print("bridge: wrote \(id) (\(line.count)B)") - } catch { - print("bridge: WRITE FAILED \(id): \(error)") + stateQueue.async { [weak self] in + guard let self else { + box.finish(with: .failure(BridgeError.notRunning)) + return } - } - DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { [weak self] in - guard let self else { return } - self.lock.lock() - let timedOut = self.pending.removeValue(forKey: id) - self.lock.unlock() - if timedOut != nil { - box.finish(with: .failure(BridgeError.timeout)) + guard self.policy.canWrite(self.policy.generation), let handle = self.stdinHandle else { + self.callbackQueue.async { box.finish(with: .failure(BridgeError.notRunning)) } + return + } + let token = self.policy.generation + let id = "shell-\(self.nextId)" + self.nextId += 1 + var payload = body + payload["id"] = id + let data: Data + do { data = try JSONSerialization.data(withJSONObject: payload) } + catch { self.callbackQueue.async { box.finish(with: .failure(error)) }; return } + self.pending[id] = box + self.writeQueue.async { [weak self] in + guard let self else { box.finish(with: .failure(BridgeError.notRunning)); return } + let writable = self.stateQueue.sync { + self.policy.canWrite(token) && self.stdinHandle === handle && self.pending[id] != nil + } + guard writable else { return } // stop/exit/timeout already owns completion + var line = data + line.append(0x0A) + do { try handle.write(contentsOf: line) } + catch { + self.stateQueue.async { [weak self] in self?.failRequest(id, token: token, error: error) } + } + } + self.stateQueue.asyncAfter(deadline: .now() + timeout) { [weak self] in + self?.failRequest(id, token: token, error: BridgeError.timeout) } } } diff --git a/mac-shell/Sources/CoreVideoProShell/RecordingCommandPolicy.swift b/mac-shell/Sources/CoreVideoProShell/RecordingCommandPolicy.swift new file mode 100644 index 00000000..d2bc0118 --- /dev/null +++ b/mac-shell/Sources/CoreVideoProShell/RecordingCommandPolicy.swift @@ -0,0 +1,66 @@ +// Main-actor owned command intent. Observed media status remains independent. +struct RecordingCommandPolicy { + struct Operation { + let token: UInt64 + let stop: Bool + let previousDesired: Bool + } + + private(set) var desired = false + private var status = "idle" + private var generation: UInt64 = 0 + private var pending: UInt64? + private var awaitingStartProgress = false + + private var observedLive: Bool { status == "recording" || status == "warning" } + + mutating func observe(_ status: String) { + self.status = status + if status == "starting" || observedLive { awaitingStartProgress = false } + guard pending == nil else { return } + // A poll issued before Start can finish after its acknowledgement. + // Idle/completed from that poll cannot revoke the newer Start intent. + if awaitingStartProgress && (status == "idle" || status == "completed") { return } + reconcile(fallback: desired) + } + + mutating func begin() -> Operation { + generation &+= 1 + let operation = Operation(token: generation, stop: observedLive || desired, + previousDesired: desired) + pending = generation + desired = !operation.stop + awaitingStartProgress = !operation.stop + return operation + } + + // Returns false for a completion superseded by a later command or exit. + @discardableResult + mutating func finish(_ operation: Operation, failed: Bool) -> Bool { + guard pending == operation.token else { return false } + pending = nil + if failed { + awaitingStartProgress = false + // Stop remains a safety intent even when its acknowledgement is lost. + reconcile(fallback: operation.stop ? false : (operation.previousDesired || observedLive)) + } + return true + } + + mutating func interrupted() { + generation &+= 1 + pending = nil + status = "interrupted" + desired = false + awaitingStartProgress = false + } + + private mutating func reconcile(fallback: Bool) { + // Live polls cannot re-arm a stopped intent. begin() independently uses + // observedLive to offer Stop retries while media is still being written. + switch status { + case "idle", "completed", "failed", "interrupted", "stopping", "finalizing": desired = false + default: desired = fallback + } + } +} diff --git a/mac-shell/Sources/CoreVideoProShell/RecordingLifecycleReadModel.swift b/mac-shell/Sources/CoreVideoProShell/RecordingLifecycleReadModel.swift new file mode 100644 index 00000000..25e9a9a6 --- /dev/null +++ b/mac-shell/Sources/CoreVideoProShell/RecordingLifecycleReadModel.swift @@ -0,0 +1,19 @@ +import Foundation + +enum RecordingLifecycleReadModel { + static func status(_ recording: [String: Any]) -> String { + guard recording.keys.contains("lifecycle") else { + return recording["status"] as? String ?? "idle" + } + guard let lifecycle = recording["lifecycle"] as? [String: Any], + validateOutputLifecycle(lifecycle), + let state = lifecycle["state"] as? String else { return "unknown" } + if state == "live" { + let health = lifecycle["health"] as? String + if health == "healthy" || health == "degraded" { return "recording" } + return health == "failed" ? "failed" : "unknown" + } + if state == "completed", lifecycle["finalized"] as? Bool != true { return "unknown" } + return state + } +} diff --git a/mac-shell/Sources/CoreVideoProShell/ShellTests.swift b/mac-shell/Sources/CoreVideoProShell/ShellTests.swift index 4b61a67a..18add42e 100644 --- a/mac-shell/Sources/CoreVideoProShell/ShellTests.swift +++ b/mac-shell/Sources/CoreVideoProShell/ShellTests.swift @@ -39,6 +39,164 @@ enum ShellTests { // ── prefs: the data-loss class ─────────────────────────────────────────── + private static func testRecordingCommandRetriesAndSupersession() { + var policy = RecordingCommandPolicy() + policy.observe("recording") + let stop = policy.begin() + expect(stop.stop, "observed live recording selects Stop") + expect(!policy.desired, "pending Stop expresses stopped intent") + policy.observe("recording") + expect(!policy.desired, "live polls do not undo pending Stop intent") + expect(policy.finish(stop, failed: true), "failed Stop owns its completion") + expect(!policy.desired, "failed Stop preserves stopped intent despite observed media") + policy.observe("recording") + expect(!policy.desired, "live poll after failed Stop cannot re-arm recording") + let retry = policy.begin() + expect(retry.stop, "retry of failed Stop sends Stop, matching Stop Rec label") + policy.observe("finalizing") + policy.finish(retry, failed: true) + expect(!policy.desired, "lost Stop reply after observed finalization stays stopped") + + policy.observe("completed") + let firstStart = policy.begin() + expect(!firstStart.stop, "completed recording selects Start") + let cancelStart = policy.begin() + expect(cancelStart.stop, "pending Start can be cancelled") + let newestStart = policy.begin() + expect(!newestStart.stop, "new Start after cancellation expresses fresh intent") + expect(!policy.finish(firstStart, failed: true), "old Start failure is ignored") + expect(!policy.finish(cancelStart, failed: false), "old Stop success is ignored") + expect(policy.desired, "old completions cannot clear newest Start intent") + policy.interrupted() + expect(!policy.finish(newestStart, failed: true), "process exit invalidates pending completion") + expect(!policy.desired, "process exit clears recording intent") + + policy.observe("idle") + let failedStart = policy.begin() + policy.finish(failedStart, failed: true) + expect(!policy.desired, "failed Start while idle permits retrying Start") + let acceptedStart = policy.begin() + policy.observe("recording") + policy.finish(acceptedStart, failed: true) + expect(policy.desired, "lost Start reply preserves observed recording") + + policy.observe("completed") + let acknowledgedStart = policy.begin() + policy.finish(acknowledgedStart, failed: false) + policy.observe("idle") + expect(policy.desired, "old idle poll cannot undo acknowledged Start") + policy.observe("completed") + expect(policy.desired, "old completed poll cannot undo acknowledged Start") + policy.observe("starting") + expect(policy.desired, "fresh starting progress keeps Start intent") + let stopWhileStarting = policy.begin() + policy.finish(stopWhileStarting, failed: true) + expect(!policy.desired, "failed Stop while starting does not re-arm unproven media") + policy.observe("starting") + expect(!policy.desired, "starting polls cannot re-arm stopped intent") + policy.observe("recording") + expect(policy.begin().stop, "observed media still permits explicit Stop retry") + + var stopped = RecordingCommandPolicy() + stopped.observe("recording") + let acknowledgedStop = stopped.begin() + stopped.finish(acknowledgedStop, failed: false) + stopped.observe("recording") + expect(!stopped.desired, "stale live poll after acknowledged Stop cannot re-arm recording") + stopped.observe("warning") + expect(!stopped.desired, "degraded live poll also preserves stopped intent") + expect(stopped.begin().stop, "acknowledged Stop still offers explicit Stop while media is live") + stopped.observe("completed") + let restarted = stopped.begin() + expect(!restarted.stop && stopped.desired, "fresh Start after completion clears stopped intent") + stopped.finish(restarted, failed: false) + stopped.observe("recording") + expect(stopped.desired, "fresh recording after explicit Start remains armed") + } + + private static func testBridgeGenerationRejectsStaleWork() { + var policy = BridgeGenerationPolicy() + expect(!policy.canWrite(policy.generation), "stopped bridge rejects writes") + let first = policy.begin() + expect(policy.isCurrent(first), "launched child owns current generation") + expect(!policy.canWrite(first), "unvalidated handshake cannot receive commands") + expect(policy.acceptHandshake(first), "current handshake is accepted") + expect(policy.canWrite(first), "validated current child accepts commands") + + let recovery = policy.invalidate(stopped: false) + expect(!policy.isCurrent(first), "old stdout and exit callbacks are stale after exit") + expect(!policy.canWrite(first), "queued old writes cannot cross process exit") + expect(policy.canRelaunch(recovery), "current recovery token can relaunch") + let second = policy.begin() + expect(!policy.canRelaunch(recovery), "new child invalidates old relaunch timer") + expect(!policy.acceptHandshake(first), "late old handshake cannot mark new child ready") + expect(!policy.canWrite(second), "new child still requires its own handshake") + expect(policy.acceptHandshake(second), "replacement handshake is independent") + expect(!policy.canWrite(first), "old queued writes cannot enter ready replacement") + expect(policy.canWrite(second), "fresh commands can use ready replacement") + + let pendingRecovery = policy.invalidate(stopped: false) + let stopped = policy.invalidate(stopped: true) + expect(!policy.canRelaunch(pendingRecovery), "stop invalidates scheduled relaunch") + expect(!policy.canRelaunch(stopped), "stop never schedules a new process") + expect(!policy.acceptHandshake(second), "late handshake cannot revive stopped bridge") + + let incompatible = policy.begin() + _ = policy.invalidate(stopped: true) + expect(policy.stopped, "rejected handshake leaves bridge stopped") + expect(!policy.canWrite(incompatible), "rejected handshake blocks captured handles immediately") + expect(!policy.canRelaunch(policy.generation), "incompatible protocol does not auto-restart") + } + + private static func testSharedLifecycleContracts() { + expectEqual(RecordingLifecycleReadModel.status(["status": "recording", "lifecycle": NSNull()]), + "unknown", "malformed lifecycle never falls back to legacy live status") + for health in ["healthy", "degraded", "unknown", "failed"] { + let lifecycle: [String: Any] = ["sessionId": "test", "desiredActive": true, + "state": "live", "health": health, "finalized": false] + let expected = health == "healthy" || health == "degraded" ? "recording" : health + expectEqual(RecordingLifecycleReadModel.status(["lifecycle": lifecycle]), expected, + "live state requires observed healthy or degraded media") + } + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + do { + let data = try Data(contentsOf: root.appendingPathComponent("contracts/lifecycle.fixtures.json")) + guard let fixtures = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { + expect(false, "lifecycle fixtures must be an array"); return + } + expect(!fixtures.isEmpty, "lifecycle fixtures are not empty") + for fixture in fixtures { + guard let id = fixture["id"] as? String, let contract = fixture["contract"] as? String, + let accepted = fixture["accepted"] as? Bool, let json = fixture["json"] as? String, + let payloadData = json.data(using: .utf8) else { + expect(false, "malformed lifecycle fixture"); continue + } + let value = try JSONSerialization.jsonObject(with: payloadData, options: [.fragmentsAllowed]) + let validate: ([String: Any]) -> Bool + switch contract { + case "ProtocolVersion": validate = validateProtocolVersion + case "OutputLifecycle": validate = validateOutputLifecycle + case "OperationStatus": validate = validateOperationStatus + case "ProtocolFailure": validate = validateProtocolFailure + default: expect(false, "unknown contract \(contract)"); continue + } + expectEqual((value as? [String: Any]).map(validate) ?? false, accepted, id) + if accepted { + let encoded: Data + switch contract { + case "ProtocolVersion": encoded = try JSONEncoder().encode(JSONDecoder().decode(ProtocolVersion.self, from: payloadData)) + case "OutputLifecycle": encoded = try JSONEncoder().encode(JSONDecoder().decode(OutputLifecycle.self, from: payloadData)) + case "OperationStatus": encoded = try JSONEncoder().encode(JSONDecoder().decode(OperationStatus.self, from: payloadData)) + default: encoded = try JSONEncoder().encode(JSONDecoder().decode(ProtocolFailure.self, from: payloadData)) + } + let roundTrip = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] + expect(roundTrip.map(validate) ?? false, "\(id) round trip") + } + } + } catch { expect(false, "lifecycle fixtures: \(error)") } + } + /// Shipping `colorGrade` as a non-optional field silently reset EVERY saved /// setting, because synthesized Decodable ignores property defaults and /// load() swallows the throw with `try?`. @@ -458,6 +616,9 @@ enum ShellTests { checks = 0 let cases: [(String, () -> Void)] = [ + ("recording/command-retry", testRecordingCommandRetriesAndSupersession), + ("bridge/generation-lifecycle", testBridgeGenerationRejectsStaleWork), + ("wire/shared-lifecycle-contracts", testSharedLifecycleContracts), ("prefs/older-file", testPrefsSurviveAnOlderFile), ("prefs/round-trip", testPrefsRoundTripKeepsEveryField), ("prefs/garbage", testPrefsToleratesGarbage), diff --git a/native-core/src/generated/lifecycle.ts b/native-core/src/generated/lifecycle.ts new file mode 100644 index 00000000..6c26f5e8 --- /dev/null +++ b/native-core/src/generated/lifecycle.ts @@ -0,0 +1,57 @@ +// Generated by contracts/generate.mjs. Do not edit. +export type ProtocolVersion = { + major: number; + minor: number; +}; +export function validateProtocolVersion(value: unknown): value is ProtocolVersion { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const v = value as Record; + if (!(typeof v["major"] === "number" && Number.isInteger(v["major"]) && v["major"] as number >= 1 && v["major"] as number <= 1)) return false; + if (!(typeof v["minor"] === "number" && Number.isInteger(v["minor"]) && v["minor"] as number >= 0 && v["minor"] as number <= 2147483647)) return false; + return true; +} +export type OutputLifecycle = { + sessionId: string; + desiredActive: boolean; + state: "idle" | "starting" | "live" | "stopping" | "finalizing" | "completed" | "failed" | "interrupted"; + health: "unknown" | "healthy" | "degraded" | "failed"; + finalized: boolean; + error?: string; +}; +export function validateOutputLifecycle(value: unknown): value is OutputLifecycle { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const v = value as Record; + if (!(typeof v["sessionId"] === "string" && (v["sessionId"] as string).length >= 1)) return false; + if (!(typeof v["desiredActive"] === "boolean")) return false; + if (!(typeof v["state"] === "string" && ["idle","starting","live","stopping","finalizing","completed","failed","interrupted"].includes(v["state"] as string))) return false; + if (!(typeof v["health"] === "string" && ["unknown","healthy","degraded","failed"].includes(v["health"] as string))) return false; + if (!(typeof v["finalized"] === "boolean")) return false; + if (v["error"] !== undefined && !(typeof v["error"] === "string")) return false; + return true; +} +export type OperationStatus = { + processEpoch: string; + operationId: string; + state: "accepted" | "running" | "completed" | "failed" | "cancelled"; + error?: string; +}; +export function validateOperationStatus(value: unknown): value is OperationStatus { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const v = value as Record; + if (!(typeof v["processEpoch"] === "string" && (v["processEpoch"] as string).length >= 1)) return false; + if (!(typeof v["operationId"] === "string" && (v["operationId"] as string).length >= 1)) return false; + if (!(typeof v["state"] === "string" && ["accepted","running","completed","failed","cancelled"].includes(v["state"] as string))) return false; + if (v["error"] !== undefined && !(typeof v["error"] === "string")) return false; + return true; +} +export type ProtocolFailure = { + code: string; + message: string; +}; +export function validateProtocolFailure(value: unknown): value is ProtocolFailure { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const v = value as Record; + if (!(typeof v["code"] === "string" && (v["code"] as string).length >= 1)) return false; + if (!(typeof v["message"] === "string" && (v["message"] as string).length >= 1)) return false; + return true; +} diff --git a/native-core/src/protocol.ts b/native-core/src/protocol.ts index 5c620034..d897ecc0 100644 --- a/native-core/src/protocol.ts +++ b/native-core/src/protocol.ts @@ -1,3 +1,5 @@ +export type { OutputLifecycle, OperationStatus, ProtocolVersion, ProtocolFailure } from './generated/lifecycle.js'; + export type MediaCoreRouteMode = "fixed" | "active-speaker" | "spotlight" | "screen-share" | "none"; export type MediaCoreAudioRole = "mix" | "isolated" | "audience"; export type MediaCoreDestination = "rtmp" | "ndi" | "srt" | "webrtc" | "recording"; @@ -289,6 +291,7 @@ export type MediaCoreRecordingStream = { }; export type MediaCoreRecordingSession = { + lifecycle?: import("./generated/lifecycle.js").OutputLifecycle; sessionId: string; active: boolean; status: MediaCoreRecordingStatus; diff --git a/native-shell/CoreVideoPro.Control.Tests/HttpControlServerTests.cs b/native-shell/CoreVideoPro.Control.Tests/HttpControlServerTests.cs index 6b936c21..e419b9af 100644 --- a/native-shell/CoreVideoPro.Control.Tests/HttpControlServerTests.cs +++ b/native-shell/CoreVideoPro.Control.Tests/HttpControlServerTests.cs @@ -84,6 +84,96 @@ public async Task AuthToken_RejectsMissingBearer() Assert.Equal(HttpStatusCode.OK, ok.StatusCode); } + [Theory] + [InlineData("+", null)] + [InlineData("*", "")] + [InlineData("0.0.0.0", " ")] + [InlineData("[::]", null)] + [InlineData("192.168.1.20", null)] + [InlineData("studio.local", null)] + public async Task NetworkBindingWithoutTokenFailsBeforeListening(string host, string? token) + { + await using var server = new HttpControlServer(new FakeControlSurface(), + new HttpControlServerOptions { Host = host, AuthToken = token, ListenPort = GetFreePort() }); + var error = Assert.Throws(() => server.Start()); + Assert.Contains("COREVIDEO_CONTROL_TOKEN", error.Message); + // Failure must not leave a listener assigned and make the next start silently succeed. + Assert.Throws(() => server.Start()); + } + + [Theory] + [InlineData("127.0.0.1", null)] + [InlineData("127.0.0.2", "")] + [InlineData("localhost", null)] + [InlineData("LOCALHOST", null)] + [InlineData("[::1]", null)] + [InlineData("+", "secret")] + [InlineData("192.168.1.20", "secret")] + public void ValidBindingPolicyDoesNotDependOnNetworkOrUrlAcl(string host, string? token) + { + new HttpControlServerOptions { Host = host, AuthToken = token }.Validate(); + } + + [Theory] + [InlineData(null, "invoke")] + [InlineData("wrong", "invoke")] + [InlineData(null, "invoke?token=s3cret")] + [InlineData("wrong", "invoke?token=s3cret")] + public async Task UnauthorizedPostNeverInvokesAction(string? bearer, string path) + { + var surface = new FakeControlSurface(); + var port = GetFreePort(); + await using var server = new HttpControlServer(surface, + new HttpControlServerOptions { ListenPort = port, AuthToken = "s3cret" }); + server.Start(); + using var http = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{port}/") }; + if (bearer is not null) + http.DefaultRequestHeaders.Authorization = new("Bearer", bearer); + using var response = await http.PostAsync(path, + new StringContent("{\"action\":\"transport.take\",\"args\":[]}", Encoding.UTF8, "application/json")); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Empty(surface.Invocations); + + http.DefaultRequestHeaders.Authorization = new("bearer", "s3cret"); + using var authorized = await http.PostAsync("invoke", + new StringContent("{\"action\":\"transport.take\",\"args\":[]}", Encoding.UTF8, "application/json")); + Assert.Equal(HttpStatusCode.OK, authorized.StatusCode); + Assert.Single(surface.Invocations); + } + + [Theory] + [InlineData(null, null, false)] + [InlineData("wrong", null, false)] + [InlineData(null, "wrong", false)] + [InlineData("s3cret", null, true)] + [InlineData(null, "s3cret", true)] + public async Task WebSocketAuthenticatesBeforeSendingState(string? bearer, string? query, bool allowed) + { + var surface = new FakeControlSurface(); + var port = GetFreePort(); + await using var server = new HttpControlServer(surface, + new HttpControlServerOptions { ListenPort = port, AuthToken = "s3cret" }); + server.Start(); + using var ws = new ClientWebSocket(); + if (bearer is not null) + ws.Options.SetRequestHeader("Authorization", $"Bearer {bearer}"); + var uri = new Uri($"ws://127.0.0.1:{port}/ws" + (query is null ? "" : $"?token={query}")); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + if (allowed) + { + await ws.ConnectAsync(uri, timeout.Token); + var initial = await ReceiveJsonAsync(ws); + Assert.False(initial.GetProperty("recording").GetBoolean()); + await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, timeout.Token); + } + else + { + var error = await Assert.ThrowsAsync(() => ws.ConnectAsync(uri, timeout.Token)); + Assert.Contains("401", error.Message); + } + Assert.Empty(surface.Invocations); + } + private static async Task ReceiveJsonAsync(ClientWebSocket ws) { var buffer = new byte[8192]; diff --git a/native-shell/CoreVideoPro.Control/Http/HttpControlServer.cs b/native-shell/CoreVideoPro.Control/Http/HttpControlServer.cs index 3374d4fa..111f1809 100644 --- a/native-shell/CoreVideoPro.Control/Http/HttpControlServer.cs +++ b/native-shell/CoreVideoPro.Control/Http/HttpControlServer.cs @@ -15,9 +15,24 @@ public sealed record HttpControlServerOptions /// for LAN access (may require a urlacl / admin on Windows — the operator opts in). public string Host { get; init; } = "127.0.0.1"; - /// Optional bearer token. When set, requests must send + /// Bearer token, required for non-loopback hosts. When set, requests must send /// Authorization: Bearer <token> (or ?token= for the WS upgrade). public string? AuthToken { get; init; } + + /// Validate policy before allocating or starting a listener. Hostnames other than + /// localhost are treated as network bindings; DNS is not a security boundary. + public void Validate() + { + if (string.IsNullOrWhiteSpace(Host)) + throw new ArgumentException("A control HTTP bind host is required.", nameof(Host)); + if (ListenPort is < 1 or > 65535) + throw new ArgumentOutOfRangeException(nameof(ListenPort)); + + var loopback = string.Equals(Host, "localhost", StringComparison.OrdinalIgnoreCase) || + (IPAddress.TryParse(Host.Trim('[', ']'), out var address) && IPAddress.IsLoopback(address)); + if (!loopback && string.IsNullOrWhiteSpace(AuthToken)) + throw new InvalidOperationException("LAN HTTP/WS control requires a non-empty COREVIDEO_CONTROL_TOKEN. Set a token or disable COREVIDEO_HTTP_LAN to use loopback."); + } } /// HTTP + WebSocket control transport over . REST actions and @@ -53,9 +68,19 @@ public void Start() return; } - _listener = new HttpListener(); - _listener.Prefixes.Add($"http://{_options.Host}:{_options.ListenPort}/"); - _listener.Start(); + _options.Validate(); + var listener = new HttpListener(); + try + { + listener.Prefixes.Add($"http://{_options.Host}:{_options.ListenPort}/"); + listener.Start(); + } + catch + { + listener.Close(); + throw; + } + _listener = listener; _cts = new CancellationTokenSource(); _surface.StateChanged += OnStateChanged; _acceptLoop = Task.Run(() => AcceptLoopAsync(_cts.Token)); @@ -131,20 +156,21 @@ private async Task HandleContextAsync(HttpListenerContext context, CancellationT private bool IsAuthorized(HttpListenerRequest request) { - if (string.IsNullOrEmpty(_options.AuthToken)) + if (string.IsNullOrWhiteSpace(_options.AuthToken)) { return true; } var header = request.Headers["Authorization"]; - if (header is not null && header.StartsWith("Bearer ", StringComparison.Ordinal) && + if (header is not null && header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) && string.Equals(header["Bearer ".Length..], _options.AuthToken, StringComparison.Ordinal)) { return true; } // Allow the token on the query string for the WS upgrade (browsers can't set WS headers). - return string.Equals(request.QueryString["token"], _options.AuthToken, StringComparison.Ordinal); + return request.IsWebSocketRequest && request.Url?.AbsolutePath.TrimEnd('/') == "/ws" && + string.Equals(request.QueryString["token"], _options.AuthToken, StringComparison.Ordinal); } private static async Task WriteResponseAsync(HttpListenerContext context, HttpControlResponse response) diff --git a/native-shell/CoreVideoPro.MediaCore.Tests/CoreProtocolLifecycleTests.cs b/native-shell/CoreVideoPro.MediaCore.Tests/CoreProtocolLifecycleTests.cs new file mode 100644 index 00000000..415a1bca --- /dev/null +++ b/native-shell/CoreVideoPro.MediaCore.Tests/CoreProtocolLifecycleTests.cs @@ -0,0 +1,75 @@ +using System.Text.Json; +using CoreVideoPro.MediaCore.Services; +using Xunit; + +namespace CoreVideoPro.MediaCore.Tests; + +public sealed class CoreProtocolLifecycleTests +{ + private static JsonDocument Response(string? lifecycle) => JsonDocument.Parse($$$$""" + {"ok":true,"snapshot":{"health":null,"profile":null,"routeCount":3,"recording":{ + "sessionId":"take-1","active":true,"status":"recording","writerStatus":"writing", + "targetFolder":"Recordings","filenamePrefix":"show","format":"mp4","quality":"high", + "programPath":"show.mp4","totalFramesWritten":100 + {{{{(lifecycle is null ? "" : ",\"lifecycle\":" + lifecycle)}}}} + }}} + """); + + [Theory] + [InlineData("null")] + [InlineData("{}")] + [InlineData("17")] + [InlineData("{\"sessionId\":\"take-1\",\"desiredActive\":true,\"state\":\"future-state\",\"health\":\"healthy\",\"finalized\":false}")] + public void ExplicitMalformedLifecycleProducesFailureInsteadOfLegacyLive(string lifecycle) + { + using var response = Response(lifecycle); + var snapshot = CoreProtocolParser.TryParseSyncSnapshot(response); + Assert.NotNull(snapshot); + Assert.Equal(3, snapshot.RouteCount); + Assert.NotNull(snapshot.Recording?.Lifecycle); + Assert.Equal("failed", snapshot.Recording.Lifecycle.State); + Assert.False(snapshot.Recording.Active); + Assert.False(OutputLifecycleReadModel.IsRecordingLive(snapshot.Recording)); + Assert.NotNull(snapshot.Recording.Lifecycle.Error); + + var wire = CoreProtocolParser.TryParseWireState(response); + Assert.NotNull(wire?.Recording?.Lifecycle); + Assert.Equal("failed", wire.Recording.Lifecycle.State); + Assert.False(OutputLifecycleReadModel.IsRecordingLive(wire.Recording)); + } + + [Fact] + public void MissingLifecycleRetainsLegacyCompatibility() + { + using var response = Response(null); + var snapshot = CoreProtocolParser.TryParseSyncSnapshot(response); + Assert.NotNull(snapshot?.Recording); + Assert.Null(snapshot.Recording.Lifecycle); + Assert.True(OutputLifecycleReadModel.IsRecordingLive(snapshot.Recording)); + } + + [Fact] + public void CaseInsensitiveDtoFieldsCannotBypassLifecycleValidation() + { + using var original = Response("null"); + using var response = JsonDocument.Parse(original.RootElement.GetRawText() + .Replace("\"recording\":", "\"Recording\":") + .Replace("\"lifecycle\":", "\"Lifecycle\":")); + var snapshot = CoreProtocolParser.TryParseSyncSnapshot(response); + Assert.NotNull(snapshot?.Recording?.Lifecycle); + Assert.Equal("failed", snapshot.Recording.Lifecycle.State); + Assert.False(OutputLifecycleReadModel.IsRecordingLive(snapshot.Recording)); + } + + [Fact] + public void ValidUnknownHealthRemainsUnverified() + { + using var response = Response(""" + {"sessionId":"take-1","desiredActive":true,"state":"live","health":"unknown","finalized":false} + """); + var snapshot = CoreProtocolParser.TryParseSyncSnapshot(response); + Assert.NotNull(snapshot?.Recording?.Lifecycle); + Assert.Equal("unknown", snapshot.Recording.Lifecycle.Health); + Assert.False(OutputLifecycleReadModel.IsRecordingLive(snapshot.Recording)); + } +} diff --git a/native-shell/CoreVideoPro.MediaCore.Tests/LifecycleContractTests.cs b/native-shell/CoreVideoPro.MediaCore.Tests/LifecycleContractTests.cs new file mode 100644 index 00000000..656cf875 --- /dev/null +++ b/native-shell/CoreVideoPro.MediaCore.Tests/LifecycleContractTests.cs @@ -0,0 +1,54 @@ +using System.Text.Json; +using CoreVideoPro.MediaCore.Contracts; +using Xunit; + +namespace CoreVideoPro.MediaCore.Tests; + +public sealed class LifecycleContractTests +{ + private static string FixturesPath() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var path = Path.Combine(directory.FullName, "contracts", "lifecycle.fixtures.json"); + if (File.Exists(path)) return path; + directory = directory.Parent; + } + throw new FileNotFoundException("Shared lifecycle fixtures must be available from repository root."); + } + + public static IEnumerable Fixtures() + { + using var file = JsonDocument.Parse(File.ReadAllText(FixturesPath())); + return file.RootElement.EnumerateArray().Select(item => new object[] { + item.GetProperty("id").GetString()!, item.GetProperty("contract").GetString()!, + item.GetProperty("accepted").GetBoolean(), item.GetProperty("json").GetString()! + }).ToArray(); + } + + [Theory] + [MemberData(nameof(Fixtures))] + public void GoldenMessagesValidateAndValidModelsRoundTrip(string id, string contract, bool accepted, string json) + { + using var document = JsonDocument.Parse(json); + Func validate = contract switch + { + "ProtocolVersion" => ProtocolVersionContract.Validate, + "OutputLifecycle" => OutputLifecycleContract.Validate, + "OperationStatus" => OperationStatusContract.Validate, + "ProtocolFailure" => ProtocolFailureContract.Validate, + _ => throw new ArgumentException(contract) + }; + Assert.True(validate(document.RootElement) == accepted, id); + if (!accepted) return; + var type = contract switch + { + "ProtocolVersion" => typeof(ProtocolVersion), "OutputLifecycle" => typeof(OutputLifecycle), + "OperationStatus" => typeof(OperationStatus), _ => typeof(ProtocolFailure) + }; + var model = JsonSerializer.Deserialize(json, type); + using var roundTrip = JsonDocument.Parse(JsonSerializer.Serialize(model, type)); + Assert.True(validate(roundTrip.RootElement), id + " round trip"); + } +} diff --git a/native-shell/CoreVideoPro.MediaCore.Tests/LifecycleOutputSummaryTests.cs b/native-shell/CoreVideoPro.MediaCore.Tests/LifecycleOutputSummaryTests.cs new file mode 100644 index 00000000..bd788765 --- /dev/null +++ b/native-shell/CoreVideoPro.MediaCore.Tests/LifecycleOutputSummaryTests.cs @@ -0,0 +1,60 @@ +using CoreVideoPro.MediaCore.Contracts; +using CoreVideoPro.MediaCore.Models; +using CoreVideoPro.MediaCore.Services; +using Xunit; + +namespace CoreVideoPro.MediaCore.Tests; + +public sealed class LifecycleOutputSummaryTests +{ + private static NativeMediaCoreStateSnapshot Snapshot(string recordingState, params NativeMediaCoreOutputSender[] senders) => new() + { + Recording = new NativeMediaCoreRecordingSession + { + SessionId = "session-1", Status = "idle", WriterStatus = "idle", TargetFolder = "", FilenamePrefix = "", + Format = "mp4", Quality = "high", ProgramPath = "", + Lifecycle = new OutputLifecycle { SessionId = "session-1", DesiredActive = false, + State = recordingState, Health = "unknown", Finalized = recordingState == "completed" } + }, + OutputSenderSession = new NativeMediaCoreOutputSenderSession { Status = "live", Senders = senders } + }; + + private static NativeMediaCoreOutputSender Sender(string destination, string status, string? error = null) => new() + { SenderId = destination + ":program", Destination = destination, Status = status, LastError = error }; + + [Theory] + [InlineData("idle")] + [InlineData("completed")] + [InlineData("finalizing")] + [InlineData("failed")] + public void RecordingDoesNotHideIndependentLiveStream(string state) + { + var snapshot = Snapshot(state, Sender("rtmp", "live")); + foreach (var summary in new[] { MediaCoreBridgeService.SummarizeOutputs(snapshot), LiveProductionSync.SummarizeOutputSession(snapshot) }) + { + Assert.Contains("Live: RTMP", summary); + if (state == "finalizing") Assert.Contains("not ready", summary); + if (state == "failed") Assert.Contains("Recording failed", summary); + } + } + + [Fact] + public void PartialStreamFailureShowsFailedAndHealthyDestinations() + { + var snapshot = Snapshot("idle", Sender("rtmp", "live"), Sender("srt", "failed", "Connection refused")); + foreach (var summary in new[] { MediaCoreBridgeService.SummarizeOutputs(snapshot), LiveProductionSync.SummarizeOutputSession(snapshot) }) + { + Assert.Contains("SRT output failed: Connection refused", summary); + Assert.Contains("Live: RTMP", summary); + } + } + + [Fact] + public void StartingStreamIsVisibleButNotLiveDuringRecordingFinalization() + { + var summary = MediaCoreBridgeService.SummarizeOutputs(Snapshot("finalizing", Sender("rtmp", "starting"))); + Assert.Contains("Recording finalizing", summary); + Assert.Contains("Starting: RTMP", summary); + Assert.DoesNotContain("Live:", summary); + } +} diff --git a/native-shell/CoreVideoPro.MediaCore.Tests/LiveProductionSyncTests.cs b/native-shell/CoreVideoPro.MediaCore.Tests/LiveProductionSyncTests.cs index ca232b56..3c4e76e1 100644 --- a/native-shell/CoreVideoPro.MediaCore.Tests/LiveProductionSyncTests.cs +++ b/native-shell/CoreVideoPro.MediaCore.Tests/LiveProductionSyncTests.cs @@ -56,7 +56,7 @@ public void MapsCaptionOverlayRecordingAndStreamingFromSnapshot() } [Fact] - public void PreservesRequestedOutputsBeforeNativeProofCatchesUp() + public void RequestedOutputsDoNotClaimObservedMedia() { var context = Context with { @@ -66,8 +66,35 @@ public void PreservesRequestedOutputsBeforeNativeProofCatchesUp() var patch = LiveProductionSync.MapSnapshotToStudioPatch(BuildSnapshot(), context); - Assert.True(patch.Recording); - Assert.True(patch.Streaming); + Assert.False(patch.Recording); + Assert.False(patch.Streaming); + } + + [Theory] + [InlineData("starting", false)] + [InlineData("live", true)] + [InlineData("stopping", false)] + [InlineData("finalizing", false)] + [InlineData("completed", false)] + [InlineData("failed", false)] + [InlineData("interrupted", false)] + [InlineData("future-state", false)] + public void LifecycleOverridesLegacyActiveAndIntent(string state, bool expectedLive) + { + var snapshot = BuildSnapshot(recordingActive: true); + snapshot = snapshot with { Recording = snapshot.Recording! with + { + Lifecycle = new CoreVideoPro.MediaCore.Contracts.OutputLifecycle + { + SessionId = "process-1:take-1", DesiredActive = true, + State = state, Health = "healthy", Finalized = state == "completed" + } + }}; + var patch = LiveProductionSync.MapSnapshotToStudioPatch(snapshot, Context with { RecordingRequested = true }); + Assert.Equal(expectedLive, patch.Recording); + Assert.Equal(state is "failed" or "interrupted" ? false : (bool?)null, patch.RecordingRequested); + if (state == "finalizing") + Assert.Contains("not ready", patch.OutputSessionStatus); } [Fact] diff --git a/native-shell/CoreVideoPro.MediaCore.Tests/MediaCoreHandshakeTests.cs b/native-shell/CoreVideoPro.MediaCore.Tests/MediaCoreHandshakeTests.cs index 691a1d20..522e4c1c 100644 --- a/native-shell/CoreVideoPro.MediaCore.Tests/MediaCoreHandshakeTests.cs +++ b/native-shell/CoreVideoPro.MediaCore.Tests/MediaCoreHandshakeTests.cs @@ -1,4 +1,6 @@ using System.Text.Json; +using System.Reflection; +using System.Diagnostics; using CoreVideoPro.MediaCore.Services; using Xunit; @@ -6,6 +8,255 @@ namespace CoreVideoPro.MediaCore.Tests; public sealed class MediaCoreHandshakeTests { + [Fact] + public async Task IncompatibleRequestOnlyChildIsTerminallyRejected() + { + var directory = Path.Combine(Path.GetTempPath(), "corevideo-rejected-request-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + var script = Path.Combine(directory, "request-only.cjs"); + var trace = Path.Combine(directory, "trace.txt"); + await File.WriteAllTextAsync(script, """ + const fs = require('node:fs'), readline = require('node:readline'); + fs.appendFileSync(process.env.COREVIDEO_HANDSHAKE_TRACE, 'started\n'); + readline.createInterface({input:process.stdin}).on('line', line => { + const message = JSON.parse(line); + fs.appendFileSync(process.env.COREVIDEO_HANDSHAKE_TRACE, message.type + '\n'); + console.log(JSON.stringify({id:message.id,ok:true,type:'handshake',protocolVersion:{major:2,minor:0},profile:{name:'incompatible',renderer:'software',maxProgramResolution:'1920x1080'}})); + }); + """); + await using var supervisor = new MediaCoreSupervisor(new MediaCoreSupervisorOptions + { + Command = "node", Args = [script], WorkingDirectory = Path.GetTempPath(), + Environment = new Dictionary { ["COREVIDEO_HANDSHAKE_TRACE"] = trace }, + HandshakeRequestTimeoutMs = 1000, RequestTimeoutMs = 3000, FrameDrainIntervalMs = 100000, MaxRestarts = 3 + }); + var startup = supervisor.StartAsync(); + var process = (Process)typeof(MediaCoreSupervisor).GetField("_process", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(supervisor)!; + using var child = Process.GetProcessById(process.Id); + Assert.Contains("incompatible", (await Assert.ThrowsAsync(() => startup.WaitAsync(TimeSpan.FromSeconds(5)))).Message); + await child.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5)); + Assert.False(supervisor.Running); + Assert.True(supervisor.Health.Stopped); + Assert.False(supervisor.Health.Recovering); + Assert.Equal(0, supervisor.Health.RestartCount); + Assert.Null(supervisor.Profile); + Assert.Contains("incompatible", (await Assert.ThrowsAsync(() => supervisor.StartAsync())).Message); + Assert.Contains("incompatible", (await Assert.ThrowsAsync(() => supervisor.HandshakeAsync())).Message); + Assert.Contains("incompatible", (await Assert.ThrowsAsync(() => supervisor.PingAsync())).Message); + Assert.Equal("started\nhandshake\n", await File.ReadAllTextAsync(trace)); + } + finally { Directory.Delete(directory, recursive: true); } + } + + [Fact] + public async Task RequestOnlyChildCanHandshakeWhileOrdinaryCommandsRemainGated() + { + var directory = Path.Combine(Path.GetTempPath(), "corevideo-request-handshake-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + var script = Path.Combine(directory, "request-only.cjs"); + var trace = Path.Combine(directory, "trace.txt"); + await File.WriteAllTextAsync(script, """ + const fs = require('node:fs'), readline = require('node:readline'); + readline.createInterface({input:process.stdin}).on('line', line => { + const message = JSON.parse(line); + fs.appendFileSync(process.env.COREVIDEO_HANDSHAKE_TRACE, message.type + '\n'); + const response = {id:message.id,ok:true,type:message.type}; + if(message.type === 'handshake') { + response.protocolVersion = {major:1,minor:0}; + response.profile = {name:'request-only',renderer:'software',maxProgramResolution:'1920x1080'}; + } + console.log(JSON.stringify(response)); + }); + """); + await using var supervisor = new MediaCoreSupervisor(new MediaCoreSupervisorOptions + { + Command = "node", Args = [script], WorkingDirectory = Path.GetTempPath(), + Environment = new Dictionary { ["COREVIDEO_HANDSHAKE_TRACE"] = trace }, + HandshakeRequestTimeoutMs = 1000, RequestTimeoutMs = 3000, FrameDrainIntervalMs = 100000 + }); + var startup = supervisor.StartAsync(); + var blocked = await Assert.ThrowsAsync(() => supervisor.PingAsync()); + Assert.Contains("handshake", blocked.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal("request-only", (await startup.WaitAsync(TimeSpan.FromSeconds(5)))?.Name); + Assert.True(await supervisor.PingAsync()); + Assert.Equal("handshake\nping\n", await File.ReadAllTextAsync(trace)); + } + finally { Directory.Delete(directory, recursive: true); } + } + + [Fact] + public async Task QueuedCommandCannotCrossIntoReplacementProcess() + { + var directory = Path.Combine(Path.GetTempPath(), "corevideo-generation-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + var script = Path.Combine(directory, "generation.cjs"); + var trace = Path.Combine(directory, "trace.txt"); + var exitSignal = Path.Combine(directory, "exit-first"); + await File.WriteAllTextAsync(script, """ + const fs = require('node:fs'), readline = require('node:readline'); + const trace = process.env.COREVIDEO_GENERATION_TRACE; + const first = !fs.existsSync(trace); + fs.appendFileSync(trace, 'started\n'); + console.log(JSON.stringify({id:'handshake',ok:true,type:'handshake',protocolVersion:{major:1,minor:0},profile:{name:'compatible',renderer:'software',maxProgramResolution:'1920x1080'}})); + readline.createInterface({input:process.stdin}).on('line', line => { + const message = JSON.parse(line); + fs.appendFileSync(trace, 'received:' + message.id + '\n'); + console.log(JSON.stringify({id:message.id,ok:true})); + }); + setInterval(() => { if(first && fs.existsSync(process.env.COREVIDEO_EXIT_SIGNAL)) process.exit(23); }, 10); + """); + await using var supervisor = new MediaCoreSupervisor(new MediaCoreSupervisorOptions + { + // Keep the fixture directory out of inherited process/console-host cwd handles. + // Script, trace and control-file paths are absolute. + Command = "node", Args = [script], WorkingDirectory = Path.GetTempPath(), + Environment = new Dictionary { ["COREVIDEO_GENERATION_TRACE"] = trace, ["COREVIDEO_EXIT_SIGNAL"] = exitSignal }, + HandshakeRequestTimeoutMs = 5000, RequestTimeoutMs = 5000, FrameDrainIntervalMs = 100000, MaxRestarts = 1 + }); + var generation = 0; + var replacementReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + supervisor.ProfileChanged += _ => { if (Interlocked.Increment(ref generation) == 2) replacementReady.TrySetResult(); }; + await supervisor.StartAsync(); + // Hold the actual write gate to deterministically reproduce an old + // command waiting while its original process is replaced. + var gate = (SemaphoreSlim)typeof(MediaCoreSupervisor).GetField("_stdinGate", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(supervisor)!; + await gate.WaitAsync(); + Task staleRequest; + try + { + staleRequest = supervisor.PingAsync(); + await File.WriteAllTextAsync(exitSignal, "exit"); + await replacementReady.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } + finally { gate.Release(); } + await Assert.ThrowsAsync(() => staleRequest); + Assert.True(await supervisor.PingAsync()); + var observed = await File.ReadAllTextAsync(trace); + Assert.DoesNotContain("received:core-1", observed); + Assert.Contains("received:core-2", observed); + Assert.Equal(1, supervisor.Health.RestartCount); + } + finally { Directory.Delete(directory, recursive: true); } + } + + [Fact] + public async Task CompatibleChildCanStopWithoutWaitingOnExitHandlerGate() + { + var directory = Path.Combine(Path.GetTempPath(), "corevideo-compatible-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + var script = Path.Combine(directory, "compatible.cjs"); + await File.WriteAllTextAsync(script, """ + console.log(JSON.stringify({id:'handshake',ok:true,type:'handshake',protocolVersion:{major:1,minor:0},profile:{name:'compatible',renderer:'software',maxProgramResolution:'1920x1080'}})); + setInterval(() => {}, 1000); + """); + await using var supervisor = new MediaCoreSupervisor(new MediaCoreSupervisorOptions + { + // Keep the fixture directory out of inherited process/console-host cwd handles. + // Script, trace and control-file paths are absolute. + Command = "node", Args = [script], WorkingDirectory = Path.GetTempPath(), + HandshakeRequestTimeoutMs = 3000, FrameDrainIntervalMs = 10000 + }); + Assert.NotNull(await supervisor.StartAsync()); + Assert.True(supervisor.Running); + await Task.Run(supervisor.Stop).WaitAsync(TimeSpan.FromSeconds(5)); + Assert.False(supervisor.Running); + Assert.Equal(0, supervisor.Health.RestartCount); + } + finally { Directory.Delete(directory, recursive: true); } + } + + [Fact] + public async Task RejectedHandshakeStopsTransportAndCannotBeBypassedBySecondStart() + { + var directory = Path.Combine(Path.GetTempPath(), "corevideo-handshake-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + Process? rejectedChild = null; + try + { + var script = Path.Combine(directory, "incompatible.cjs"); + var trace = Path.Combine(directory, "trace.txt"); + var releaseHandshake = Path.Combine(directory, "release-handshake"); + await File.WriteAllTextAsync(script, """ + const fs = require('node:fs'); + fs.appendFileSync(process.env.COREVIDEO_HANDSHAKE_TRACE, 'started\n'); + process.stdin.on('data', data => fs.appendFileSync(process.env.COREVIDEO_HANDSHAKE_TRACE, 'request\n')); + const handshake = setInterval(() => { + if (!fs.existsSync(process.env.COREVIDEO_RELEASE_HANDSHAKE)) return; + clearInterval(handshake); + console.log(JSON.stringify({id:'handshake',ok:true,type:'handshake',protocolVersion:{major:2,minor:0},profile:{name:'incompatible'}})); + }, 10); + setInterval(() => {}, 1000); + """); + await using var supervisor = new MediaCoreSupervisor(new MediaCoreSupervisorOptions + { + // Keep the fixture directory out of inherited process/console-host cwd handles. + // Script, trace and control-file paths are absolute. + Command = "node", Args = [script], WorkingDirectory = Path.GetTempPath(), + Environment = new Dictionary + { + ["COREVIDEO_HANDSHAKE_TRACE"] = trace, + ["COREVIDEO_RELEASE_HANDSHAKE"] = releaseHandshake + }, + HandshakeRequestTimeoutMs = 3000, RequestTimeoutMs = 1000, MaxRestarts = 5 + }); + var firstStart = supervisor.StartAsync(); + // Hold an independent OS process handle before allowing rejection. + // The supervisor invalidates its transport before asynchronous + // stdout processing has finished killing/disposing this child. + var child = (Process)typeof(MediaCoreSupervisor).GetField("_process", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(supervisor)!; + rejectedChild = Process.GetProcessById(child.Id); + _ = rejectedChild.Handle; + // A concurrent Start must await validation instead of returning a null + // profile through the already-running process shortcut. + var secondStart = supervisor.StartAsync(); + var pendingHandshake = await Assert.ThrowsAsync(() => supervisor.PingAsync()); + Assert.Contains("handshake", pendingHandshake.Message, StringComparison.OrdinalIgnoreCase); + await File.WriteAllTextAsync(releaseHandshake, "release"); + var firstFailure = await Assert.ThrowsAsync(() => firstStart); + var secondFailure = await Assert.ThrowsAsync(() => secondStart); + Assert.Contains("incompatible", firstFailure.Message); + Assert.Contains("incompatible", secondFailure.Message); + Assert.False(supervisor.Running); + Assert.True(supervisor.Health.Stopped); + Assert.False(supervisor.Health.Recovering); + Assert.Equal(0, supervisor.Health.RestartCount); + Assert.Null(supervisor.Profile); + Assert.Contains("incompatible", (await Assert.ThrowsAsync(() => supervisor.StartAsync())).Message); + Assert.Contains("incompatible", (await Assert.ThrowsAsync(() => supervisor.PingAsync())).Message); + Assert.Equal("started\n", await File.ReadAllTextAsync(trace)); + } + finally + { + if (rejectedChild is not null) + { + using (rejectedChild) + await rejectedChild.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5)); + } + Directory.Delete(directory, recursive: true); + } + } + + [Theory] + [InlineData("{}", true)] + [InlineData("{\"protocolVersion\":{\"major\":1,\"minor\":0}}", true)] + [InlineData("{\"protocolVersion\":{\"major\":1,\"minor\":100}}", true)] + [InlineData("{\"protocolVersion\":{\"major\":2,\"minor\":0}}", false)] + [InlineData("{\"protocolVersion\":null}", false)] + public void ExplicitProtocolVersionMustBeCompatible(string json, bool compatible) + { + using var doc = JsonDocument.Parse(json); + if (compatible) MediaCoreHandshakeRules.RequireCompatibleProtocol(doc.RootElement); + else Assert.Throws(() => MediaCoreHandshakeRules.RequireCompatibleProtocol(doc.RootElement)); + } [Fact] public void IsUnsolicitedBootstrapHandshake_AcceptsOnlyStartupLine() { @@ -17,4 +268,4 @@ public void IsUnsolicitedBootstrapHandshake_AcceptsOnlyStartupLine() Assert.True(MediaCoreHandshakeRules.IsUnsolicitedBootstrapHandshake(bootstrap.RootElement)); Assert.False(MediaCoreHandshakeRules.IsUnsolicitedBootstrapHandshake(explicitHandshake.RootElement)); } -} \ No newline at end of file +} diff --git a/native-shell/CoreVideoPro.MediaCore.Tests/StreamingObservedStateTests.cs b/native-shell/CoreVideoPro.MediaCore.Tests/StreamingObservedStateTests.cs new file mode 100644 index 00000000..03741324 --- /dev/null +++ b/native-shell/CoreVideoPro.MediaCore.Tests/StreamingObservedStateTests.cs @@ -0,0 +1,51 @@ +using CoreVideoPro.MediaCore.Models; +using CoreVideoPro.MediaCore.Services; +using Xunit; + +namespace CoreVideoPro.MediaCore.Tests; + +public sealed class StreamingObservedStateTests +{ + [Theory] + [InlineData("warning", 0, false)] + [InlineData("warning", 12, true)] + [InlineData("starting", 0, false)] + [InlineData("failed", 12, false)] + [InlineData("live", 12, true)] + public void SenderWarningRequiresObservedMedia(string status, int frames, bool expected) + { + var snapshot = new NativeMediaCoreStateSnapshot + { + OutputSenderSession = new NativeMediaCoreOutputSenderSession + { + Status = "warning", ActiveSenderCount = 1, + Senders = [new NativeMediaCoreOutputSender { SenderId = "rtmp:program", Destination = "rtmp", + Status = status, FramesSent = frames, Warning = "Connection degraded" }] + }, + OutputHealth = [new NativeMediaCoreOutputHealth { Destination = "rtmp", Status = "warning", Message = "Connection degraded" }] + }; + Assert.Equal(expected, LiveProductionSync.IsStreamingLive(snapshot)); + } + + [Fact] + public void AggregateWarningAloneCannotProveStreaming() + { + var snapshot = new NativeMediaCoreStateSnapshot + { + OutputHealth = [new NativeMediaCoreOutputHealth { Destination = "rtmp", Status = "warning", Message = "Authentication failed" }] + }; + Assert.False(LiveProductionSync.IsStreamingLive(snapshot)); + } + + [Fact] + public void FailedSenderOverridesStaleLiveAggregate() + { + var snapshot = new NativeMediaCoreStateSnapshot + { + OutputHealth = [new NativeMediaCoreOutputHealth { Destination = "rtmp", Status = "live", Message = "Old status" }], + OutputSenderSession = new NativeMediaCoreOutputSenderSession { Status = "failed", Senders = + [new NativeMediaCoreOutputSender { SenderId = "rtmp:program", Destination = "rtmp", Status = "failed", FramesSent = 20 }] } + }; + Assert.False(LiveProductionSync.IsStreamingLive(snapshot)); + } +} diff --git a/native-shell/CoreVideoPro.MediaCore/Contracts/Lifecycle.cs b/native-shell/CoreVideoPro.MediaCore/Contracts/Lifecycle.cs new file mode 100644 index 00000000..81817d4d --- /dev/null +++ b/native-shell/CoreVideoPro.MediaCore/Contracts/Lifecycle.cs @@ -0,0 +1,90 @@ +// Generated by contracts/generate.mjs. Do not edit. +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +namespace CoreVideoPro.MediaCore.Contracts; +public sealed class ContractIntegerConverter : JsonConverter { + public override int Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions options) { + if (reader.TokenType != JsonTokenType.Number || !reader.TryGetDouble(out var value) || !double.IsFinite(value) || Math.Truncate(value) != value || value < int.MinValue || value > int.MaxValue) throw new JsonException("Expected a 32-bit integer"); + return (int)value; + } + public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) => writer.WriteNumberValue(value); +} +public sealed record ProtocolVersion { + [JsonConverter(typeof(ContractIntegerConverter))] + [JsonPropertyName("major")] public required int Major { get; init; } + [JsonConverter(typeof(ContractIntegerConverter))] + [JsonPropertyName("minor")] public required int Minor { get; init; } +} +public static class ProtocolVersionContract { + public static bool Validate(JsonElement value) { + if (value.ValueKind != JsonValueKind.Object) return false; + var hasMajor = value.TryGetProperty("major", out var major); + if (!hasMajor || !(major.ValueKind == JsonValueKind.Number && major.TryGetDouble(out var majorNumber) && double.IsFinite(majorNumber) && Math.Truncate(majorNumber) == majorNumber && majorNumber >= 1 && majorNumber <= 1)) return false; + var hasMinor = value.TryGetProperty("minor", out var minor); + if (!hasMinor || !(minor.ValueKind == JsonValueKind.Number && minor.TryGetDouble(out var minorNumber) && double.IsFinite(minorNumber) && Math.Truncate(minorNumber) == minorNumber && minorNumber >= 0 && minorNumber <= 2147483647)) return false; + return true; + } +} +public sealed record OutputLifecycle { + [JsonPropertyName("sessionId")] public required string SessionId { get; init; } + [JsonPropertyName("desiredActive")] public required bool DesiredActive { get; init; } + [JsonPropertyName("state")] public required string State { get; init; } + [JsonPropertyName("health")] public required string Health { get; init; } + [JsonPropertyName("finalized")] public required bool Finalized { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("error")] public string? Error { get; init; } +} +public static class OutputLifecycleContract { + public static bool Validate(JsonElement value) { + if (value.ValueKind != JsonValueKind.Object) return false; + var hasSessionId = value.TryGetProperty("sessionId", out var sessionId); + if (!hasSessionId || !(sessionId.ValueKind == JsonValueKind.String && sessionId.GetString()!.Length >= 1)) return false; + var hasDesiredActive = value.TryGetProperty("desiredActive", out var desiredActive); + if (!hasDesiredActive || !((desiredActive.ValueKind == JsonValueKind.True || desiredActive.ValueKind == JsonValueKind.False))) return false; + var hasState = value.TryGetProperty("state", out var state); + if (!hasState || !(state.ValueKind == JsonValueKind.String && (state.GetString() == "idle" || state.GetString() == "starting" || state.GetString() == "live" || state.GetString() == "stopping" || state.GetString() == "finalizing" || state.GetString() == "completed" || state.GetString() == "failed" || state.GetString() == "interrupted"))) return false; + var hasHealth = value.TryGetProperty("health", out var health); + if (!hasHealth || !(health.ValueKind == JsonValueKind.String && (health.GetString() == "unknown" || health.GetString() == "healthy" || health.GetString() == "degraded" || health.GetString() == "failed"))) return false; + var hasFinalized = value.TryGetProperty("finalized", out var finalized); + if (!hasFinalized || !((finalized.ValueKind == JsonValueKind.True || finalized.ValueKind == JsonValueKind.False))) return false; + var hasError = value.TryGetProperty("error", out var error); + if (hasError && !(error.ValueKind == JsonValueKind.String)) return false; + return true; + } +} +public sealed record OperationStatus { + [JsonPropertyName("processEpoch")] public required string ProcessEpoch { get; init; } + [JsonPropertyName("operationId")] public required string OperationId { get; init; } + [JsonPropertyName("state")] public required string State { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("error")] public string? Error { get; init; } +} +public static class OperationStatusContract { + public static bool Validate(JsonElement value) { + if (value.ValueKind != JsonValueKind.Object) return false; + var hasProcessEpoch = value.TryGetProperty("processEpoch", out var processEpoch); + if (!hasProcessEpoch || !(processEpoch.ValueKind == JsonValueKind.String && processEpoch.GetString()!.Length >= 1)) return false; + var hasOperationId = value.TryGetProperty("operationId", out var operationId); + if (!hasOperationId || !(operationId.ValueKind == JsonValueKind.String && operationId.GetString()!.Length >= 1)) return false; + var hasState = value.TryGetProperty("state", out var state); + if (!hasState || !(state.ValueKind == JsonValueKind.String && (state.GetString() == "accepted" || state.GetString() == "running" || state.GetString() == "completed" || state.GetString() == "failed" || state.GetString() == "cancelled"))) return false; + var hasError = value.TryGetProperty("error", out var error); + if (hasError && !(error.ValueKind == JsonValueKind.String)) return false; + return true; + } +} +public sealed record ProtocolFailure { + [JsonPropertyName("code")] public required string Code { get; init; } + [JsonPropertyName("message")] public required string Message { get; init; } +} +public static class ProtocolFailureContract { + public static bool Validate(JsonElement value) { + if (value.ValueKind != JsonValueKind.Object) return false; + var hasCode = value.TryGetProperty("code", out var code); + if (!hasCode || !(code.ValueKind == JsonValueKind.String && code.GetString()!.Length >= 1)) return false; + var hasMessage = value.TryGetProperty("message", out var message); + if (!hasMessage || !(message.ValueKind == JsonValueKind.String && message.GetString()!.Length >= 1)) return false; + return true; + } +} diff --git a/native-shell/CoreVideoPro.MediaCore/Models/NativeMediaCoreProtocol.cs b/native-shell/CoreVideoPro.MediaCore/Models/NativeMediaCoreProtocol.cs index 09812b19..1c2b2c9f 100644 --- a/native-shell/CoreVideoPro.MediaCore/Models/NativeMediaCoreProtocol.cs +++ b/native-shell/CoreVideoPro.MediaCore/Models/NativeMediaCoreProtocol.cs @@ -353,6 +353,7 @@ public sealed class NativeMediaCoreRecordingStream public sealed record NativeMediaCoreRecordingSession { + public CoreVideoPro.MediaCore.Contracts.OutputLifecycle? Lifecycle { get; init; } public required string SessionId { get; init; } public bool Active { get; init; } public required string Status { get; init; } diff --git a/native-shell/CoreVideoPro.MediaCore/Services/CoreProtocolParser.cs b/native-shell/CoreVideoPro.MediaCore/Services/CoreProtocolParser.cs index 06982fdf..29f55719 100644 --- a/native-shell/CoreVideoPro.MediaCore/Services/CoreProtocolParser.cs +++ b/native-shell/CoreVideoPro.MediaCore/Services/CoreProtocolParser.cs @@ -1,4 +1,6 @@ using System.Text.Json; +using System.Text.Json.Nodes; +using CoreVideoPro.MediaCore.Contracts; using CoreVideoPro.MediaCore.Json; using CoreVideoPro.MediaCore.Models; @@ -467,6 +469,7 @@ public static ulong ParseSharedHandleHex(string handleHex) public static NativeMediaCoreProfile? TryParseHandshakeProfile(JsonDocument response) { + MediaCoreHandshakeRules.RequireCompatibleProtocol(response.RootElement); if (!response.RootElement.TryGetProperty("ok", out var okElement) || !okElement.GetBoolean() || !response.RootElement.TryGetProperty("profile", out var profileElement)) @@ -488,14 +491,14 @@ public static ulong ParseSharedHandleHex(string handleHex) if (root.TryGetProperty("snapshot", out var snapshotElement)) { return JsonSerializer.Deserialize( - snapshotElement.GetRawText(), + ValidatedRecordingLifecycleJson(snapshotElement), MediaCoreJson.Options); } if (root.TryGetProperty("state", out var stateElement)) { return JsonSerializer.Deserialize( - stateElement.GetRawText(), + ValidatedRecordingLifecycleJson(stateElement), MediaCoreJson.Options); } @@ -524,7 +527,41 @@ public static ulong ParseSharedHandleHex(string handleHex) return null; } - return JsonSerializer.Deserialize(wireElement.GetRawText(), MediaCoreJson.Options); + return JsonSerializer.Deserialize(ValidatedRecordingLifecycleJson(wireElement), MediaCoreJson.Options); + } + + private static string ValidatedRecordingLifecycleJson(JsonElement snapshot) + { + var json = snapshot.GetRawText(); + if (snapshot.ValueKind != JsonValueKind.Object) return json; + // Match the serializer's case-insensitive property handling. + var recording = snapshot.EnumerateObject().LastOrDefault(property => + property.Name.Equals("recording", StringComparison.OrdinalIgnoreCase)).Value; + if (recording.ValueKind != JsonValueKind.Object) return json; + var lifecycle = recording.EnumerateObject().LastOrDefault(property => + property.Name.Equals("lifecycle", StringComparison.OrdinalIgnoreCase)).Value; + if (lifecycle.ValueKind == JsonValueKind.Undefined || OutputLifecycleContract.Validate(lifecycle)) + return json; + + // Absence is an older protocol. Explicit null/invalid data is not: do + // not let nullable deserialization turn it into optimistic legacy state. + // Preserve the rest of the snapshot so the UI receives the failure + // instead of retaining a previous live snapshot after a parse rejection. + var root = JsonNode.Parse(json, new JsonNodeOptions { PropertyNameCaseInsensitive = true })!; + var target = root["recording"]!; + target["active"] = false; + target["status"] = "failed"; + target["writerStatus"] = "failed"; + target["lifecycle"] = JsonSerializer.SerializeToNode(new OutputLifecycle + { + SessionId = "unverified-recording", + DesiredActive = false, + State = "failed", + Health = "failed", + Finalized = false, + Error = "Malformed recording lifecycle received from the media core." + }, MediaCoreJson.Options); + return root.ToJsonString(); } public static ZoomMediaSpineNativeSnapshot? TryParseZoomMediaSpineSnapshot(JsonDocument response) diff --git a/native-shell/CoreVideoPro.MediaCore/Services/LiveProductionSync.cs b/native-shell/CoreVideoPro.MediaCore/Services/LiveProductionSync.cs index 69840407..f59af432 100644 --- a/native-shell/CoreVideoPro.MediaCore/Services/LiveProductionSync.cs +++ b/native-shell/CoreVideoPro.MediaCore/Services/LiveProductionSync.cs @@ -49,6 +49,7 @@ public sealed record StudioLiveProductionPatch public string? LowerThirdTitle { get; init; } public string? LowerThirdOrg { get; init; } public bool? Recording { get; init; } + public bool? RecordingRequested { get; init; } public bool? Streaming { get; init; } public string? OutputStatus { get; init; } public string? OutputSessionStatus { get; init; } @@ -65,8 +66,8 @@ public static StudioLiveProductionPatch MapSnapshotToStudioPatch( { var captionCue = snapshot.CaptionTrack.CurrentCue; var lowerThird = ResolveProgramLowerThird(snapshot, context); - var recording = snapshot.Recording?.Active == true || context.RecordingRequested; - var streaming = IsStreamingLive(snapshot) || context.StreamingRequested; + var recording = OutputLifecycleReadModel.IsRecordingLive(snapshot.Recording); + var streaming = IsStreamingLive(snapshot); var outputStatus = MediaCoreBridgeService.SummarizeOutputs(snapshot); var outputSessionStatus = SummarizeOutputSession(snapshot); var meetingStateLabel = ResolveMeetingStateLabel(snapshot); @@ -85,6 +86,7 @@ public static StudioLiveProductionPatch MapSnapshotToStudioPatch( LowerThirdTitle = lowerThird.Title, LowerThirdOrg = lowerThird.Org, Recording = recording, + RecordingRequested = snapshot.Recording?.Lifecycle?.State is "failed" or "interrupted" ? false : null, Streaming = streaming, OutputStatus = outputStatus, OutputSessionStatus = outputSessionStatus, @@ -249,16 +251,19 @@ public static bool IsStreamingLive(NativeMediaCoreStateSnapshot snapshot) { if (snapshot.OutputSenderSession.Status is "live" or "warning" && snapshot.OutputSenderSession.Senders.Any(sender => - sender.Status is "live" or "starting" || - sender.Status == "warning" && !IsUnavailableOutputSenderWarning(sender))) + sender.Status is "live" || + sender.Status == "warning" && sender.FramesSent > 0 && !IsUnavailableOutputSenderWarning(sender))) { return snapshot.OutputSenderSession.ActiveSenderCount > 0; } return snapshot.OutputHealth.Any(item => item.Destination is not "recording" && - (item.Status == "live" || - item.Status == "warning" && !IsUnavailableOutputHealthWarning(item.Message))); + item.Status == "live" && + // Prefer concrete sender observations over a stale aggregate label. + !snapshot.OutputSenderSession.Senders.Any(sender => + sender.Destination.Equals(item.Destination, StringComparison.OrdinalIgnoreCase) && + sender.Status is not "live")); } private static bool IsUnavailableOutputSenderWarning(NativeMediaCoreOutputSender sender) @@ -310,6 +315,10 @@ public static TransportReadoutLabels MapTransportReadouts( public static string SummarizeRecordStat(NativeMediaCoreStateSnapshot snapshot, bool recording) { + if (snapshot.Recording?.Lifecycle is not null) + { + return OutputLifecycleReadModel.RecordingStatus(snapshot.Recording); + } if (!recording && snapshot.Recording?.Active != true) { return "Idle"; @@ -420,6 +429,10 @@ public static string SummarizeLocalOutputs(bool recording, bool streaming) public static string SummarizeOutputSession(NativeMediaCoreStateSnapshot snapshot) { + if (snapshot.Recording?.Lifecycle is not null) + { + return MediaCoreBridgeService.SummarizeLifecycleOutputs(snapshot); + } if (snapshot.Recording?.Active == true) { var path = ResolveRecordingPath(snapshot) ?? snapshot.Recording.ProgramPath; diff --git a/native-shell/CoreVideoPro.MediaCore/Services/MediaCoreBridgeService.cs b/native-shell/CoreVideoPro.MediaCore/Services/MediaCoreBridgeService.cs index 398072cb..d2a72260 100644 --- a/native-shell/CoreVideoPro.MediaCore/Services/MediaCoreBridgeService.cs +++ b/native-shell/CoreVideoPro.MediaCore/Services/MediaCoreBridgeService.cs @@ -338,6 +338,10 @@ public static string SummarizeCaptureSnapshot(RawCaptureSnapshot snapshot, strin public static string SummarizeOutputs(NativeMediaCoreStateSnapshot snapshot) { + if (snapshot.Recording?.Lifecycle is not null) + { + return SummarizeLifecycleOutputs(snapshot); + } var failedOutput = snapshot.OutputHealth .FirstOrDefault(item => item.Status is "failed" or "warning" && @@ -387,7 +391,48 @@ sender.Status is "failed" or "warning" && return $"Recording {snapshot.Recording.ProgramPath}"; } - return "Outputs idle"; + var starting = snapshot.OutputSenderSession.Senders + .Where(sender => sender.Status == "starting") + .Select(sender => sender.Destination.ToUpperInvariant()).ToList(); + return starting.Count > 0 ? $"Starting: {string.Join(", ", starting)}" : "Outputs idle"; + } + + internal static string SummarizeLifecycleOutputs(NativeMediaCoreStateSnapshot snapshot) + { + // Recording lifecycle is always present in the new core, including when + // only streaming. Compose independent outputs instead of letting idle + // recording or one failed destination hide the remaining live output. + var observations = snapshot.OutputHealth + .Where(item => !item.Destination.Equals("recording", StringComparison.OrdinalIgnoreCase)) + .Select(item => (Destination: item.Destination, Status: item.Status, Detail: (string?)item.Message)) + .Concat(snapshot.OutputSenderSession.Senders.Select(sender => + (Destination: sender.Destination, Status: sender.Status, Detail: sender.Warning ?? sender.LastError))) + .GroupBy(item => item.Destination, StringComparer.OrdinalIgnoreCase); + var parts = new List(); + var live = new List(); + var starting = new List(); + foreach (var destination in observations) + { + var failure = destination.FirstOrDefault(item => item.Status == "failed"); + if (failure == default) failure = destination.FirstOrDefault(item => item.Status == "warning"); + if (failure != default) + { + var detail = string.IsNullOrWhiteSpace(failure.Detail) + ? destination.Select(item => item.Detail).FirstOrDefault(item => !string.IsNullOrWhiteSpace(item)) + : failure.Detail; + parts.Add($"{destination.Key.ToUpperInvariant()} output {failure.Status}" + + (string.IsNullOrWhiteSpace(detail) ? string.Empty : $": {NormalizeOutputFailureMessage(detail)}")); + } + else if (destination.Any(item => item.Status == "live")) live.Add(destination.Key.ToUpperInvariant()); + else if (destination.Any(item => item.Status == "starting")) starting.Add(destination.Key.ToUpperInvariant()); + } + if (snapshot.Recording?.Lifecycle?.State != "idle") + parts.Add(OutputLifecycleReadModel.RecordingStatus(snapshot.Recording)); + if (live.Count > 0) parts.Add($"Live: {string.Join(", ", live)}"); + if (starting.Count > 0) parts.Add($"Starting: {string.Join(", ", starting)}"); + var warning = snapshot.OutputSenderSession.Warnings.FirstOrDefault(item => !string.IsNullOrWhiteSpace(item)); + if (parts.Count == 0 && warning is not null) parts.Add($"Output warning: {NormalizeOutputFailureMessage(warning)}"); + return parts.Count > 0 ? string.Join(" · ", parts) : "Outputs idle"; } private static string NormalizeOutputFailureMessage(string? message) diff --git a/native-shell/CoreVideoPro.MediaCore/Services/MediaCoreHandshakeRules.cs b/native-shell/CoreVideoPro.MediaCore/Services/MediaCoreHandshakeRules.cs index 79e7e34c..27e4e97f 100644 --- a/native-shell/CoreVideoPro.MediaCore/Services/MediaCoreHandshakeRules.cs +++ b/native-shell/CoreVideoPro.MediaCore/Services/MediaCoreHandshakeRules.cs @@ -4,6 +4,16 @@ namespace CoreVideoPro.MediaCore.Services; public static class MediaCoreHandshakeRules { + public static void RequireCompatibleProtocol(JsonElement root) + { + // Absent means the supported legacy protocol. An explicitly advertised + // incompatible major must not be treated as a successful connection. + if (root.TryGetProperty("protocolVersion", out var version) && + !CoreVideoPro.MediaCore.Contracts.ProtocolVersionContract.Validate(version)) + { + throw new InvalidOperationException("Media core protocol is incompatible. Install matching shell and core versions."); + } + } public static bool IsUnsolicitedBootstrapHandshake(JsonElement root) { if (!root.TryGetProperty("id", out var idElement) || @@ -18,4 +28,4 @@ public static bool IsUnsolicitedBootstrapHandshake(JsonElement root) root.TryGetProperty("ok", out var okElement) && okElement.GetBoolean(); } -} \ No newline at end of file +} diff --git a/native-shell/CoreVideoPro.MediaCore/Services/MediaCoreSupervisor.cs b/native-shell/CoreVideoPro.MediaCore/Services/MediaCoreSupervisor.cs index a3fe855b..f1fbab2d 100644 --- a/native-shell/CoreVideoPro.MediaCore/Services/MediaCoreSupervisor.cs +++ b/native-shell/CoreVideoPro.MediaCore/Services/MediaCoreSupervisor.cs @@ -63,6 +63,7 @@ private static void PerfLog(string message) private bool _syncInFlight; private int _syncFrameNumber; private NativeMediaCoreProfile? _profile; + private string? _handshakeFailure; private Dictionary? _zoomJoinRecoveryPayload; private bool _zoomRawCapturePaused; @@ -127,13 +128,16 @@ public NativeMediaCoreProfile? Profile { lock (_gate) { + if (_handshakeFailure is not null) throw new InvalidOperationException(_handshakeFailure); if (!_stopped && _process is { HasExited: false }) { - return _profile; + if (_profile is not null) return _profile; + } + else + { + _stopped = false; + SpawnChild(); } - - _stopped = false; - SpawnChild(); } var profile = await EnsureHandshakeProfileAsync(cancellationToken).ConfigureAwait(false); @@ -145,6 +149,8 @@ public NativeMediaCoreProfile? Profile public void Stop() { + Process? process; + StreamWriter? stdin; lock (_gate) { _stopped = true; @@ -152,10 +158,17 @@ public void Stop() _zoomJoinRecoveryPayload = null; _zoomRawCapturePaused = false; StopFrameDrain(); - TeardownChild(); + RejectAll(new InvalidOperationException("Supervisor stopped.")); + process = _process; + stdin = _stdin; + _process = null; + _stdin = null; _profile = null; + _handshakeFailure = null; } + if (process is not null) process.Exited -= OnChildExited; + DisposeChild(process, stdin); RaiseHealth(); StatusChanged?.Invoke("Engine off — Zoom ingest paused"); } @@ -298,39 +311,44 @@ private static void ThrowIfRejected(JsonDocument response, string commandName) public async Task HandshakeAsync(CancellationToken cancellationToken = default) { + Process handshakeProcess; lock (_gate) { - if (_profile is not null) - { - return _profile; - } + if (_handshakeFailure is not null) throw new InvalidOperationException(_handshakeFailure); + if (_profile is not null) return _profile; + handshakeProcess = _process ?? throw new InvalidOperationException("Media core is not running."); } - var response = await SendAsync( + using var response = await SendAsync( new Dictionary { ["id"] = NextId(), ["type"] = "handshake" }, cancellationToken, - _options.HandshakeRequestTimeoutMs).ConfigureAwait(false); + _options.HandshakeRequestTimeoutMs, + handshakeProcess).ConfigureAwait(false); - using (response) + NativeMediaCoreProfile? profile; + try { profile = CoreProtocolParser.TryParseHandshakeProfile(response); } + catch (InvalidOperationException ex) + { + RejectHandshake(handshakeProcess, ex); + throw; + } + lock (_gate) { - var profile = CoreProtocolParser.TryParseHandshakeProfile(response); + if (!ReferenceEquals(_process, handshakeProcess)) + throw new InvalidOperationException("Media core changed before handshake completed."); + if (_handshakeFailure is not null) throw new InvalidOperationException(_handshakeFailure); if (profile is not null) { - lock (_gate) - { - _profile = profile; - _recovering = false; - } - - ProfileChanged?.Invoke(profile); + _profile = profile; + _recovering = false; } - - return profile; } + if (profile is not null) ProfileChanged?.Invoke(profile); + return profile; } public async Task JoinZoomAsync( @@ -806,6 +824,7 @@ private void SpawnChild() // makes HandshakeAsync return before the replacement process is ready and // lets recovery traffic race its bootstrap handshake. _profile = null; + _handshakeFailure = null; string fileName; string arguments; @@ -905,11 +924,18 @@ private void SpawnChild() WriteCoreLog($"[bridge] media core process started (pid {(_process.HasExited ? -1 : _process.Id)})"); } - private void DispatchFrame(Action action) + private void DispatchFrame(Process process, Action action) { // Non-blocking: if the consumer is behind, the bounded channel drops the // oldest frame rather than stalling the stdout reader. - _frameDispatch?.Writer.TryWrite(action); + _frameDispatch?.Writer.TryWrite(() => + { + lock (_gate) + { + if (!ReferenceEquals(_process, process)) return; + } + action(); + }); } private static string? _coreLogPath; @@ -957,6 +983,11 @@ private async Task ReadStdoutLoopAsync(Process process) break; } + lock (_gate) + { + if (!ReferenceEquals(_process, process)) return; + } + if (line.Trim().Length == 0) { continue; @@ -981,7 +1012,7 @@ private async Task ReadStdoutLoopAsync(Process process) var emit = ew.GetDouble(); var recvAge = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - emit; PerfLog($"zoom transport emit->recv={recvAge:F0}ms"); - DispatchFrame(() => + DispatchFrame(process, () => { var consumeAge = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - emit; PerfLog($"zoom transport emit->UIhandler={consumeAge:F0}ms (queue={consumeAge - recvAge:F0}ms)"); @@ -992,7 +1023,7 @@ private async Task ReadStdoutLoopAsync(Process process) } catch { } } - DispatchFrame(() => ZoomVideoFrameReceived?.Invoke(frame)); + DispatchFrame(process, () => ZoomVideoFrameReceived?.Invoke(frame)); continue; } @@ -1000,7 +1031,7 @@ private async Task ReadStdoutLoopAsync(Process process) if (previewEvent is not null) { var preview = previewEvent.Preview; - DispatchFrame(() => ProgramFramePreviewReceived?.Invoke(preview)); + DispatchFrame(process, () => ProgramFramePreviewReceived?.Invoke(preview)); continue; } @@ -1093,7 +1124,15 @@ private async Task ReadStdoutLoopAsync(Process process) } } - if (TryAcceptBootstrapHandshake(document)) + bool accepted; + try { accepted = TryAcceptBootstrapHandshake(document, process); } + catch (InvalidOperationException ex) + { + RejectHandshake(process, ex); + document.Dispose(); + return; + } + if (accepted) { document.Dispose(); continue; @@ -1103,10 +1142,36 @@ private async Task ReadStdoutLoopAsync(Process process) } } + private void RejectHandshake(Process process, InvalidOperationException error) + { + StreamWriter? rejectedStdin; + lock (_gate) + { + // A completed response from an older child cannot reject its replacement. + if (!ReferenceEquals(_process, process)) return; + _handshakeFailure = error.Message; + _profile = null; + _stopped = true; + _recovering = false; + StopFrameDrain(); + RejectAll(error); + rejectedStdin = _stdin; + _stdin = null; + _process = null; + } + // Exit handlers acquire _gate. Dispose outside it, and never auto-restart + // a child rejected for protocol incompatibility. + process.Exited -= OnChildExited; + DisposeChild(process, rejectedStdin); + RaiseHealth(); + StatusChanged?.Invoke(error.Message); + } + private void OnChildExited(object? sender, EventArgs e) { lock (_gate) { + if (!ReferenceEquals(_process, sender)) return; RejectAll(new InvalidOperationException("Media core exited.")); if (_stopped) { @@ -1242,6 +1307,8 @@ private async Task RecoverChildAsync() return _profile; } + if (_handshakeFailure is not null) throw new InvalidOperationException(_handshakeFailure); + if (_process is null || _process.HasExited) { throw new InvalidOperationException(DescribeChildStartupFailure()); @@ -1260,7 +1327,7 @@ private string DescribeChildStartupFailure() return $"Media core exited before handshake completed (exit {exitCode})."; } - private bool TryAcceptBootstrapHandshake(JsonDocument document) + private bool TryAcceptBootstrapHandshake(JsonDocument document, Process process) { if (!MediaCoreHandshakeRules.IsUnsolicitedBootstrapHandshake(document.RootElement)) { @@ -1275,6 +1342,7 @@ private bool TryAcceptBootstrapHandshake(JsonDocument document) lock (_gate) { + if (!ReferenceEquals(_process, process) || _handshakeFailure is not null) return false; _profile = profile; _recovering = false; } @@ -1286,7 +1354,8 @@ private bool TryAcceptBootstrapHandshake(JsonDocument document) private async Task SendAsync( Dictionary payload, CancellationToken cancellationToken, - int? timeoutMs = null) + int? timeoutMs = null, + Process? expectedProcess = null) { if (!payload.TryGetValue("id", out var idValue) || idValue is not string id) { @@ -1294,19 +1363,30 @@ private async Task SendAsync( } var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var requestType = payload.TryGetValue("type", out var typeValue) ? typeValue as string : null; + // A child may only advertise its profile in response to an explicit + // handshake. Keep ordinary commands gated while allowing that exchange. + var isHandshake = requestType == "handshake"; + Process requestProcess; + StreamWriter requestStdin; lock (_gate) { + if (_handshakeFailure is not null) throw new InvalidOperationException(_handshakeFailure); if (_stdin is null || _process is null || _process.HasExited) { throw new InvalidOperationException("Media core is not running."); } + if (_profile is null && !isHandshake) throw new InvalidOperationException("Media core handshake has not completed."); + if (expectedProcess is not null && !ReferenceEquals(_process, expectedProcess)) + throw new InvalidOperationException("Media core changed before handshake request was sent."); + requestProcess = _process; + requestStdin = _stdin; _pending[id] = tcs; } using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(timeoutMs ?? _options.RequestTimeoutMs); - var requestType = payload.TryGetValue("type", out var typeValue) ? typeValue as string : null; await using var registration = timeoutCts.Token.Register(() => { lock (_gate) @@ -1327,15 +1407,12 @@ private async Task SendAsync( { try { - StreamWriter? stdin; lock (_gate) { - stdin = _stdin; - } - - if (stdin is null) - { - throw new InvalidOperationException("Media core is not running."); + if (_handshakeFailure is not null) throw new InvalidOperationException(_handshakeFailure); + if (_profile is null && !isHandshake) throw new InvalidOperationException("Media core handshake has not completed."); + if (!ReferenceEquals(_process, requestProcess) || !ReferenceEquals(_stdin, requestStdin)) + throw new InvalidOperationException("Media core restarted before the queued command was written; reconcile state before retrying."); } if (requestType == "zoom-join") @@ -1343,8 +1420,11 @@ private async Task SendAsync( WriteCoreLog($"[bridge] -> id={id} type=zoom-join bytes={json.Length}"); } - await stdin.WriteLineAsync(json.AsMemory(), timeoutCts.Token).ConfigureAwait(false); - await stdin.FlushAsync(timeoutCts.Token).ConfigureAwait(false); + // Retain the original writer even after leaving _gate. A restart + // during an awaited write can fail this pipe, never retarget the + // old edge-triggered command to a replacement process. + await requestStdin.WriteLineAsync(json.AsMemory(), timeoutCts.Token).ConfigureAwait(false); + await requestStdin.FlushAsync(timeoutCts.Token).ConfigureAwait(false); if (requestType == "zoom-join") { @@ -1404,15 +1484,8 @@ private void StopFrameDrain() _frameDrainTimer = null; } - private void TeardownChild() + private static void DisposeChild(Process? process, StreamWriter? stdin) { - RejectAll(new InvalidOperationException("Supervisor stopped.")); - - var stdin = _stdin; - var process = _process; - _stdin = null; - _process = null; - try { stdin?.Close(); diff --git a/native-shell/CoreVideoPro.MediaCore/Services/OutputLifecycleReadModel.cs b/native-shell/CoreVideoPro.MediaCore/Services/OutputLifecycleReadModel.cs new file mode 100644 index 00000000..00340920 --- /dev/null +++ b/native-shell/CoreVideoPro.MediaCore/Services/OutputLifecycleReadModel.cs @@ -0,0 +1,26 @@ +using CoreVideoPro.MediaCore.Models; + +namespace CoreVideoPro.MediaCore.Services; + +/// Observed output activity is independent of operator intent. +public static class OutputLifecycleReadModel +{ + public static bool IsRecordingLive(NativeMediaCoreRecordingSession? recording) => + recording?.Lifecycle is { } lifecycle + ? !string.IsNullOrWhiteSpace(lifecycle.SessionId) && lifecycle.State == "live" && lifecycle.Health is "healthy" or "degraded" + : recording?.Active == true && recording.Status is "recording" or "warning"; + + public static string RecordingStatus(NativeMediaCoreRecordingSession? recording) => + recording?.Lifecycle?.State switch + { + "starting" => "Recording starting — waiting for media", + "live" => IsRecordingLive(recording) ? "Recording" : "Recording activity unverified", + "stopping" or "finalizing" => "Recording finalizing — file is not ready yet", + "completed" => recording.Lifecycle.Finalized ? "Recording finalized" : "Recording completion unverified", + "failed" => "Recording failed", + "interrupted" => "Recording interrupted", + "idle" => "Recording idle", + null => recording?.Status ?? "idle", + _ => "Recording status unknown" + }; +} diff --git a/native-shell/CoreVideoPro.WinUI.Tests/OutputLifecycleTransportTests.cs b/native-shell/CoreVideoPro.WinUI.Tests/OutputLifecycleTransportTests.cs new file mode 100644 index 00000000..d3b37edf --- /dev/null +++ b/native-shell/CoreVideoPro.WinUI.Tests/OutputLifecycleTransportTests.cs @@ -0,0 +1,47 @@ +using CoreVideoPro.MediaCore.Models; +using CoreVideoPro.WinUI.ViewModels.Transport; +using Xunit; + +namespace CoreVideoPro.WinUI.Tests; + +public sealed class OutputLifecycleTransportTests +{ + private static NativeMediaCoreStateSnapshot Snapshot(params (string Destination, string Status)[] senders) => new() + { + OutputSenderSession = new NativeMediaCoreOutputSenderSession + { + Status = "live", ActiveSenderCount = senders.Length, + Senders = senders.Select(s => new NativeMediaCoreOutputSender + { + SenderId = s.Destination, Destination = s.Destination, Status = s.Status, + Warning = s.Status == "failed" ? "Network unavailable" : null + }).ToList() + } + }; + + [Fact] + public void StartingIsAcceptedButNotProvenLive() + { + var snapshot = Snapshot(("rtmp", "starting")); + Assert.False(TransportStatusFormatter.IsStreamingStartProven(snapshot, ["rtmp"])); + Assert.False(TransportStatusFormatter.TryFormatStreamingStartNoSenderFailure(snapshot, ["rtmp"], out _)); + } + + [Fact] + public void PartialFailureDoesNotStopHealthyDestinationOrProveAllLive() + { + var snapshot = Snapshot(("rtmp", "live"), ("ndi", "failed")); + Assert.False(TransportStatusFormatter.IsStreamingStartProven(snapshot, ["rtmp", "ndi"])); + Assert.False(TransportStatusFormatter.TryFormatStreamingStartHealthFailure(snapshot, out _)); + Assert.False(TransportStatusFormatter.TryFormatStreamingStartNoSenderFailure(snapshot, ["rtmp", "ndi"], out _)); + } + + [Fact] + public void EveryRequestedDestinationMustBeLive() + { + var snapshot = Snapshot(("rtmp", "live"), ("ndi", "live")); + Assert.True(TransportStatusFormatter.IsStreamingStartProven(snapshot, ["rtmp", "ndi"])); + Assert.False(TransportStatusFormatter.IsStreamingStartProven(snapshot, ["rtmp", "srt"])); + Assert.True(TransportStatusFormatter.TryFormatStreamingStartHealthFailure(Snapshot(("rtmp", "failed")), out _)); + } +} diff --git a/native-shell/CoreVideoPro.WinUI.Tests/ProductionPreferencesDurabilityTests.cs b/native-shell/CoreVideoPro.WinUI.Tests/ProductionPreferencesDurabilityTests.cs new file mode 100644 index 00000000..4a92d388 --- /dev/null +++ b/native-shell/CoreVideoPro.WinUI.Tests/ProductionPreferencesDurabilityTests.cs @@ -0,0 +1,128 @@ +using CoreVideoPro.WinUI.Services; +using Xunit; + +namespace CoreVideoPro.WinUI.Tests; + +public sealed class ProductionPreferencesDurabilityTests : IDisposable +{ + private readonly string _folder = Path.Combine(Path.GetTempPath(), "corevideo-durable-tests", Guid.NewGuid().ToString("N")); + private string Primary => Path.Combine(_folder, FileProductionOutputPreferencesStore.DefaultFileName); + private string Backup => Primary + ".bak"; + private FileProductionOutputPreferencesStore Store() => new(_folder); + private static ProductionOutputPreferences Prefs(string name) => new() { RecordingFilenamePrefix = name }; + + [Fact] + public void MissingAndCorruptAreDistinct() + { + Assert.Equal(ProductionPreferencesLoadStatus.Missing, Store().LoadWithResult().Status); + Directory.CreateDirectory(_folder); + File.WriteAllText(Primary, "{truncated"); + Assert.Equal(ProductionPreferencesLoadStatus.Corrupt, Store().LoadWithResult().Status); + Assert.Null(Store().Load()); + } + + [Fact] + public void InterruptedReplacementLeavesPreviousShowAndCleansStaging() + { + Store().Save(Prefs("previous")); + var failing = new FileProductionOutputPreferencesStore(_folder, destination => + { + if (destination == Primary) throw new IOException("Injected interruption after flush"); + }); + Assert.Throws(() => failing.Save(Prefs("new"))); + Assert.Equal("previous", Store().Load()?.RecordingFilenamePrefix); + Assert.Equal("previous", ProductionOutputPreferencesSerializer.Deserialize(File.ReadAllText(Backup))?.RecordingFilenamePrefix); + Assert.Empty(Directory.GetFiles(_folder, "*.tmp")); + } + + [Fact] + public void CorruptPrimaryRecoversAndRepairsWithoutOverwritingGoodBackup() + { + Store().Save(Prefs("first")); + Store().Save(Prefs("second")); + var backup = File.ReadAllText(Backup); + File.WriteAllText(Primary, "{truncated"); + var result = Store().LoadWithResult(); + Assert.Equal(ProductionPreferencesLoadStatus.Recovered, result.Status); + Assert.Equal(ProductionPreferencesLoadStatus.Corrupt, result.PrimaryFailure); + Assert.Equal("first", result.Preferences?.RecordingFilenamePrefix); + Assert.Equal(backup, File.ReadAllText(Backup)); + Assert.Equal(ProductionPreferencesLoadStatus.Loaded, Store().LoadWithResult().Status); + } + + [Fact] + public void UnreadablePrimaryIsNotTreatedAsMissingOrOverwritten() + { + Store().Save(Prefs("first")); + using (var locked = new FileStream(Primary, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) + { + Assert.Equal(ProductionPreferencesLoadStatus.Unreadable, Store().LoadWithResult().Status); + Assert.Throws(() => Store().Save(Prefs("replacement"))); + } + Assert.Equal("first", Store().Load()?.RecordingFilenamePrefix); + } + + [Fact] + public void MissingPrimaryRecoversBackupAndIgnoresOrphanTemporaryFile() + { + Store().Save(Prefs("first")); + Store().Save(Prefs("second")); + File.Delete(Primary); + File.WriteAllText(Primary + ".orphan.tmp", "{partial"); + var result = Store().LoadWithResult(); + Assert.Equal(ProductionPreferencesLoadStatus.Recovered, result.Status); + Assert.Equal(ProductionPreferencesLoadStatus.Missing, result.PrimaryFailure); + Assert.Equal("first", result.Preferences?.RecordingFilenamePrefix); + } + + [Fact] + public void SerializationFailurePreservesBothDurableFiles() + { + Store().Save(Prefs("first")); + Store().Save(Prefs("second")); + var primary = File.ReadAllText(Primary); + var backup = File.ReadAllText(Backup); + Assert.ThrowsAny(() => Store().Save(new() { StreamTargetBitrateMbps = double.NaN })); + Assert.Equal(primary, File.ReadAllText(Primary)); + Assert.Equal(backup, File.ReadAllText(Backup)); + } + + [Fact] + public void InterruptedMigrationKeepsLoadedValuesAndEncryptedRecoverableBackup() + { + Directory.CreateDirectory(_folder); + var legacy = "{\"Version\":3,\"StreamRtmpStreamKey\":\"legacy-secret\"}"; + File.WriteAllText(Primary, legacy); + var failing = new FileProductionOutputPreferencesStore(_folder, destination => + { + if (destination == Primary) throw new IOException("Interrupted migration"); + }, DpapiSecretProtector.Protect, DpapiSecretProtector.Unprotect); + Assert.Equal("legacy-secret", failing.Load()?.StreamRtmpStreamKey); + Assert.Equal(legacy, File.ReadAllText(Primary)); + Assert.DoesNotContain("legacy-secret", File.ReadAllText(Backup)); + File.WriteAllText(Primary, "{truncated"); + var recovery = new FileProductionOutputPreferencesStore(_folder, + protectSecret: DpapiSecretProtector.Protect, unprotectSecret: DpapiSecretProtector.Unprotect); + Assert.Equal("legacy-secret", recovery.Load()?.StreamRtmpStreamKey); + Assert.DoesNotContain("legacy-secret", File.ReadAllText(Primary)); + } + + [Fact] + public async Task ConcurrentInstancesSerializeWritesAndKeepCompleteDocuments() + { + Store().Save(Prefs("seed")); + await Task.WhenAll(Enumerable.Range(0, 30).Select(i => Task.Run(() => + { + Store().Save(Prefs($"show-{i}")); + Assert.NotNull(Store().Load()); + }))); + Assert.StartsWith("show-", Store().Load()!.RecordingFilenamePrefix); + Assert.NotNull(ProductionOutputPreferencesSerializer.Deserialize(File.ReadAllText(Backup))); + Assert.Empty(Directory.GetFiles(_folder, "*.tmp")); + } + + public void Dispose() + { + if (Directory.Exists(_folder)) Directory.Delete(_folder, recursive: true); + } +} diff --git a/native-shell/CoreVideoPro.WinUI.Tests/ProductionPreferencesNoticeTests.cs b/native-shell/CoreVideoPro.WinUI.Tests/ProductionPreferencesNoticeTests.cs new file mode 100644 index 00000000..f5f84f82 --- /dev/null +++ b/native-shell/CoreVideoPro.WinUI.Tests/ProductionPreferencesNoticeTests.cs @@ -0,0 +1,32 @@ +using CoreVideoPro.WinUI.Services; +using Xunit; + +namespace CoreVideoPro.WinUI.Tests; + +public sealed class ProductionPreferencesNoticeTests +{ + [Theory] + [InlineData(ProductionPreferencesLoadStatus.Missing)] + [InlineData(ProductionPreferencesLoadStatus.Loaded)] + public void FirstLaunchAndNormalRestoreDoNotWarn(ProductionPreferencesLoadStatus status) => + Assert.Empty(ProductionPreferencesNotice.For(status)); + + [Fact] + public void RecoveryExplainsBackupAndPotentialMissingChanges() + { + var message = ProductionPreferencesNotice.For(ProductionPreferencesLoadStatus.Recovered); + Assert.Contains("backup", message); + Assert.Contains("Recent changes may be missing", message); + Assert.DoesNotContain("Default settings were loaded", message); + } + + [Theory] + [InlineData(ProductionPreferencesLoadStatus.Corrupt)] + [InlineData(ProductionPreferencesLoadStatus.Unreadable)] + public void UnrestoredShowsRequireReviewAndDiscloseDefaults(ProductionPreferencesLoadStatus status) + { + var message = ProductionPreferencesNotice.For(status); + Assert.Contains("Default settings were loaded", message); + Assert.Contains("before going live", message); + } +} diff --git a/native-shell/CoreVideoPro.WinUI.Tests/StudioViewModelAudioStatusTests.cs b/native-shell/CoreVideoPro.WinUI.Tests/StudioViewModelAudioStatusTests.cs index e8169127..fccc937d 100644 --- a/native-shell/CoreVideoPro.WinUI.Tests/StudioViewModelAudioStatusTests.cs +++ b/native-shell/CoreVideoPro.WinUI.Tests/StudioViewModelAudioStatusTests.cs @@ -528,8 +528,8 @@ public void FormatOutputStatusBrief_CollapsesStreamOutputHealthStatus(string sta [Theory] [InlineData(true, false)] - [InlineData(false, true)] - public void ResolveStreamingStateAfterFailedRetry_RollsBackRequestedState(bool requestedStarting, bool expectedStreaming) + [InlineData(false, false)] + public void ResolveStreamingStateAfterFailedRetry_NeverRearmsOutput(bool requestedStarting, bool expectedStreaming) { Assert.Equal(expectedStreaming, TransportStatusFormatter.ResolveStreamingStateAfterFailedRetry(requestedStarting)); } diff --git a/native-shell/CoreVideoPro.WinUI.Tests/TransportCoordinatorTests.cs b/native-shell/CoreVideoPro.WinUI.Tests/TransportCoordinatorTests.cs index 2855d2d5..6ec09038 100644 --- a/native-shell/CoreVideoPro.WinUI.Tests/TransportCoordinatorTests.cs +++ b/native-shell/CoreVideoPro.WinUI.Tests/TransportCoordinatorTests.cs @@ -1,3 +1,4 @@ +using CoreVideoPro.WinUI.Services; using CoreVideoPro.MediaCore.Models; using CoreVideoPro.MediaCore.Services; using CoreVideoPro.WinUI.ViewModels.Transport; @@ -31,6 +32,55 @@ private static (TransportCoordinator Coordinator, FakeMediaCoreBridge Bridge, Fa return (coordinator, bridge, host); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ExplicitStop_RetriesFailedStopWithoutRearming(bool recording) + { + var (coordinator, _, host) = Build(); + host.Recording = recording; + host.Streaming = !recording; + host.SyncThrows = new InvalidOperationException("stop did not reach core"); + Func set = recording ? coordinator.SetRecordingAsync : coordinator.SetStreamingAsync; + await set(false); + Assert.False(recording ? host.Recording : host.Streaming); + Assert.Equal(1, host.SyncCallCount); + + host.SyncThrows = null; + host.HoldSync = true; + var retry = StudioControlSurface.RunOutputSet(false, true, false, _ => true, set, "output"); + Assert.Equal(2, host.SyncCallCount); + Assert.False(recording ? host.Recording : host.Streaming); + host.ReleaseSync(); + await retry; + Assert.False(recording ? host.Recording : host.Streaming); + Assert.Contains(recording ? "Recording stop requested" : "Streaming stopped", host.OutputStatus); + } + + [Theory] + [InlineData(false, false, false, 0)] + [InlineData(true, false, true, 0)] + [InlineData(false, true, false, 1)] + [InlineData(true, true, false, 1)] + [InlineData(false, false, true, 1)] + public async Task ExplicitOutputSet_PreservesIdempotencyAndPassesTarget(bool requested, bool live, bool target, int expectedCalls) + { + var calls = new List(); + await StudioControlSurface.RunOutputSet(requested, live, target, _ => true, + value => { calls.Add(value); return Task.CompletedTask; }, "output"); + Assert.Equal(expectedCalls, calls.Count); + Assert.All(calls, value => Assert.Equal(target, value)); + } + + [Fact] + public async Task ExplicitOutputSet_DoesNotRunWhileUnavailable() + { + var called = false; + await StudioControlSurface.RunOutputSet(false, true, false, _ => false, + _ => { called = true; return Task.CompletedTask; }, "output"); + Assert.False(called); + } + // ---------------------------------------------------------------- Recording [Fact] @@ -131,7 +181,7 @@ public async Task ToggleRecording_StopWaitsForBusyStartAndKeepsCommandGuarded() Assert.False(host.Recording); Assert.False(coordinator.RecordingToggleInFlight); Assert.Equal(4, host.SyncCallCount); - Assert.Equal("Recording stopped.", host.OutputStatus); + Assert.Equal("Recording stop requested — finalizing.", host.OutputStatus); } [Fact] @@ -151,6 +201,19 @@ public async Task ToggleRecording_StopRetryExhaustionNeverRearmsRecording() // ---------------------------------------------------------------- Streaming + [Fact] + public async Task ToggleStreaming_FailedStopKeepsDesiredStateDisarmed() + { + var (coordinator, _, host) = Build(); + host.Streaming = true; + host.SyncThrows = new InvalidOperationException("connection lost during stop"); + + await coordinator.ToggleStreamingAsync(); + + Assert.False(host.Streaming); + Assert.StartsWith("Streaming stop failed:", host.OutputStatus); + } + [Fact] public async Task ToggleStreaming_ArmsAndProvesStart_WhenSenderGoesLive() { diff --git a/native-shell/CoreVideoPro.WinUI/MainWindow.xaml.cs b/native-shell/CoreVideoPro.WinUI/MainWindow.xaml.cs index c771c104..83b922d3 100644 --- a/native-shell/CoreVideoPro.WinUI/MainWindow.xaml.cs +++ b/native-shell/CoreVideoPro.WinUI/MainWindow.xaml.cs @@ -120,20 +120,27 @@ private void StartControlServer() port = configured; } - var lan = string.Equals(Environment.GetEnvironmentVariable("COREVIDEO_OSC_LAN"), "1", StringComparison.Ordinal); + var oscLanRequested = string.Equals(Environment.GetEnvironmentVariable("COREVIDEO_OSC_LAN"), "1", StringComparison.Ordinal); + var oscTrustedNetwork = string.Equals(Environment.GetEnvironmentVariable("COREVIDEO_OSC_TRUSTED_NETWORK"), "1", StringComparison.Ordinal); + var oscLan = oscLanRequested && oscTrustedNetwork; + if (oscLanRequested && !oscTrustedNetwork) + { + LaunchLog.Write("control: OSC remains on loopback. Unauthenticated LAN OSC requires COREVIDEO_OSC_TRUSTED_NETWORK=1 on a trusted network."); + } _controlSurface = new StudioControlSurface(ViewModel, _dispatcher); _controlServer = new OscControlServer(_controlSurface, new OscControlServerOptions { ListenPort = port, - BindAddress = lan ? IPAddress.Any : IPAddress.Loopback + BindAddress = oscLan ? IPAddress.Any : IPAddress.Loopback }); _controlServer.Start(); - LaunchLog.Write($"control: OSC server listening on {(lan ? "0.0.0.0" : "127.0.0.1")}:{_controlServer.BoundPort}"); + LaunchLog.Write($"control: OSC server listening on {(oscLan ? "0.0.0.0" : "127.0.0.1")}:{_controlServer.BoundPort}"); // HTTP + WebSocket API sharing the same surface. Loopback needs no privileges; - // LAN ("+") may require a Windows urlacl. Optional bearer token for LAN safety. + // LAN ("+") may require a Windows urlacl and always requires a bearer token. + var httpLan = string.Equals(Environment.GetEnvironmentVariable("COREVIDEO_HTTP_LAN"), "1", StringComparison.Ordinal); var httpPort = 8011; if (int.TryParse(Environment.GetEnvironmentVariable("COREVIDEO_HTTP_PORT"), out var httpConfigured) && httpConfigured is > 0 and < 65536) @@ -146,11 +153,11 @@ private void StartControlServer() _httpControlServer = new HttpControlServer(_controlSurface, new HttpControlServerOptions { ListenPort = httpPort, - Host = lan ? "+" : "127.0.0.1", + Host = httpLan ? "+" : "127.0.0.1", AuthToken = Environment.GetEnvironmentVariable("COREVIDEO_CONTROL_TOKEN") }); _httpControlServer.Start(); - LaunchLog.Write($"control: HTTP/WS API listening on http://{(lan ? "+" : "127.0.0.1")}:{httpPort}/ (GET /manifest, /state, /ws; POST /invoke)"); + LaunchLog.Write($"control: HTTP/WS API listening on http://{(httpLan ? "+" : "127.0.0.1")}:{httpPort}/ (GET /manifest, /state, /ws; POST /invoke)"); } catch (Exception ex) { @@ -362,4 +369,4 @@ private void OnWindowClosed(object sender, WindowEventArgs args) WindowChromeService.ClearScheduledReapply(this); App.NotifyMainWindowClosed(); } -} \ No newline at end of file +} diff --git a/native-shell/CoreVideoPro.WinUI/Services/AtomicJsonFile.cs b/native-shell/CoreVideoPro.WinUI/Services/AtomicJsonFile.cs new file mode 100644 index 00000000..3c8a139c --- /dev/null +++ b/native-shell/CoreVideoPro.WinUI/Services/AtomicJsonFile.cs @@ -0,0 +1,59 @@ +using System.Security.Cryptography; +using System.Text; +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CoreVideoPro.WinUI.Tests")] + +namespace CoreVideoPro.WinUI.Services; + +/// Same-volume replacement with durable staging and cross-instance/process serialization. +internal sealed class AtomicJsonFile(string path, Action? beforeReplace = null) +{ + public string Path { get; } = System.IO.Path.GetFullPath(path); + public string BackupPath => Path + ".bak"; + + public T Locked(Func operation) + { + var key = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(Path.ToUpperInvariant()))); + using var mutex = new Mutex(false, "Local\\CoreVideoPro.Preferences." + key); + try { mutex.WaitOne(); } + catch (AbandonedMutexException) { /* A terminated writer still grants ownership. */ } + try { return operation(); } + finally { mutex.ReleaseMutex(); } + } + + // Call under Locked. The caller supplies a validated (and protected) previous + // document; a corrupt primary must never overwrite the last usable backup. + public void Write(string json, string? previousJson) + { + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!); + if (previousJson is not null) + Replace(BackupPath, previousJson); + Replace(Path, json); + } + + private void Replace(string destination, string json) + { + var temporary = destination + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + var bytes = Encoding.UTF8.GetBytes(json); + stream.Write(bytes); + stream.Flush(flushToDisk: true); + } + beforeReplace?.Invoke(destination); + if (File.Exists(destination)) + File.Replace(temporary, destination, null); + else + File.Move(temporary, destination); + } + finally + { + try { File.Delete(temporary); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + } +} diff --git a/native-shell/CoreVideoPro.WinUI/Services/ProductionOutputPreferencesStore.cs b/native-shell/CoreVideoPro.WinUI/Services/ProductionOutputPreferencesStore.cs index f44d3af7..c5089aed 100644 --- a/native-shell/CoreVideoPro.WinUI/Services/ProductionOutputPreferencesStore.cs +++ b/native-shell/CoreVideoPro.WinUI/Services/ProductionOutputPreferencesStore.cs @@ -359,11 +359,17 @@ public static string ProtectSecretFields(string json, Func prote } } +public enum ProductionPreferencesLoadStatus { Loaded, Missing, Corrupt, Unreadable, Recovered } + +public sealed record ProductionPreferencesLoadResult( + ProductionPreferencesLoadStatus Status, ProductionOutputPreferences? Preferences, + ProductionPreferencesLoadStatus? PrimaryFailure = null); + public sealed class FileProductionOutputPreferencesStore : IProductionOutputPreferencesStore { public const string DefaultFileName = "production-output-preferences.json"; - private readonly string _filePath; + private readonly AtomicJsonFile _file; private readonly Func? _protectSecret; private readonly Func? _unprotectSecret; @@ -378,78 +384,106 @@ public FileProductionOutputPreferencesStore( Func? protectSecret = null, Func? unprotectSecret = null) { - _filePath = Path.Combine(folderPath, fileName ?? DefaultFileName); + _file = new AtomicJsonFile(Path.Combine(folderPath, fileName ?? DefaultFileName)); _protectSecret = protectSecret; _unprotectSecret = unprotectSecret; } - public void Save(ProductionOutputPreferences preferences) + internal FileProductionOutputPreferencesStore(string folderPath, Action beforeReplace, + Func? protectSecret = null, Func? unprotectSecret = null) { - var directory = Path.GetDirectoryName(_filePath); - if (!string.IsNullOrEmpty(directory)) - { - Directory.CreateDirectory(directory); - } + _file = new AtomicJsonFile(Path.Combine(folderPath, DefaultFileName), beforeReplace); + _protectSecret = protectSecret; + _unprotectSecret = unprotectSecret; + } - var json = ProductionOutputPreferencesSerializer.Serialize(preferences); - if (_protectSecret is not null) - { - json = ProductionOutputPreferencesSerializer.ProtectSecretFields(json, _protectSecret); - } + public void Save(ProductionOutputPreferences preferences) => _file.Locked(() => + { + SaveLocked(preferences); + return true; + }); + + private string Protect(string json) => _protectSecret is null ? json : + ProductionOutputPreferencesSerializer.ProtectSecretFields(json, _protectSecret); - File.WriteAllText(_filePath, json); + private void SaveLocked(ProductionOutputPreferences preferences) + { + // Serialize/protect before touching either durable file. Reject non-finite + // values or encryption failures without disturbing the existing show. + var json = Protect(ProductionOutputPreferencesSerializer.Serialize(preferences)); + var previous = Read(_file.Path, out var previousJson, out _); + if (previous.Status == ProductionPreferencesLoadStatus.Unreadable) + throw new IOException("The existing production preferences could not be read; save cancelled."); + _file.Write(json, previous.Preferences is null ? null : Protect(previousJson!)); } - public ProductionOutputPreferences? Load() + public ProductionOutputPreferences? Load() => LoadWithResult().Preferences; + + public ProductionPreferencesLoadResult LoadWithResult() => _file.Locked(() => { - if (!File.Exists(_filePath)) + var result = Read(_file.Path, out _, out var migrated); + if (result.Preferences is null) { - return null; + var backup = Read(_file.BackupPath, out _, out migrated); + if (backup.Preferences is not null) + result = new(ProductionPreferencesLoadStatus.Recovered, backup.Preferences, result.Status); + else + { + // Missing both files is normal first launch. All other failures + // are logged for operators/support instead of silently disappearing. + if (result.Status == ProductionPreferencesLoadStatus.Missing && + backup.Status != ProductionPreferencesLoadStatus.Missing) + result = backup; + if (result.Status != ProductionPreferencesLoadStatus.Missing) + LaunchLog.Write($"prefs: load failed ({result.Status}); no usable backup; defaults will be used"); + return result; + } } - try + var preferences = result.Preferences!; + var hadPlaintextSecret = false; + if (_unprotectSecret is not null) { - var preferences = ProductionOutputPreferencesSerializer.Deserialize( - File.ReadAllText(_filePath), out var migratedFromOlderVersion); - if (preferences is null) - { - return null; - } + preferences.StreamRtmpStreamKey = UnprotectField(nameof(preferences.StreamRtmpStreamKey), + preferences.StreamRtmpStreamKey, ref hadPlaintextSecret); + preferences.StreamSrtPassphrase = UnprotectField(nameof(preferences.StreamSrtPassphrase), + preferences.StreamSrtPassphrase, ref hadPlaintextSecret); + } - var hadPlaintextSecret = false; - if (_unprotectSecret is not null) - { - preferences.StreamRtmpStreamKey = - UnprotectField(nameof(preferences.StreamRtmpStreamKey), preferences.StreamRtmpStreamKey, ref hadPlaintextSecret); - preferences.StreamSrtPassphrase = - UnprotectField(nameof(preferences.StreamSrtPassphrase), preferences.StreamSrtPassphrase, ref hadPlaintextSecret); - } + if (result.Status == ProductionPreferencesLoadStatus.Recovered) + LaunchLog.Write($"prefs: recovered production preferences from backup (primary {result.PrimaryFailure})"); - // Migration (beta spec S4): plaintext secrets or an older schema - // version re-save encrypted at the new version. Best-effort — a - // failed rewrite must never lose working preferences. - if (_protectSecret is not null && (hadPlaintextSecret || migratedFromOlderVersion)) + // Recovery does not replace an unreadable primary: it may be a transient + // sharing/permissions problem. A corrupt/missing primary can be repaired. + if ((result.Status == ProductionPreferencesLoadStatus.Recovered && + result.PrimaryFailure != ProductionPreferencesLoadStatus.Unreadable) || + (result.Status == ProductionPreferencesLoadStatus.Loaded && + (migrated || (_protectSecret is not null && hadPlaintextSecret)))) + { + try { SaveLocked(preferences); } + catch (Exception ex) { - try - { - Save(preferences); - } - catch (Exception ex) - { - LaunchLog.Write($"prefs: encrypted re-save failed (keeping loaded preferences): {ex.Message}"); - } + LaunchLog.Write($"prefs: durable re-save failed (keeping loaded preferences): {ex.GetType().Name}"); } - - return preferences; - } - catch (IOException) - { - return null; } - catch (UnauthorizedAccessException) + return result; + }); + + private static ProductionPreferencesLoadResult Read(string path, out string? json, out bool migrated) + { + json = null; + migrated = false; + try { - return null; + json = File.ReadAllText(path); + var preferences = ProductionOutputPreferencesSerializer.Deserialize(json, out migrated); + return new(preferences is null ? ProductionPreferencesLoadStatus.Corrupt : + ProductionPreferencesLoadStatus.Loaded, preferences); } + catch (FileNotFoundException) { return new(ProductionPreferencesLoadStatus.Missing, null); } + catch (DirectoryNotFoundException) { return new(ProductionPreferencesLoadStatus.Missing, null); } + catch (IOException) { return new(ProductionPreferencesLoadStatus.Unreadable, null); } + catch (UnauthorizedAccessException) { return new(ProductionPreferencesLoadStatus.Unreadable, null); } } private string? UnprotectField(string fieldName, string? stored, ref bool hadPlaintextSecret) diff --git a/native-shell/CoreVideoPro.WinUI/Services/ProductionPreferencesNotice.cs b/native-shell/CoreVideoPro.WinUI/Services/ProductionPreferencesNotice.cs new file mode 100644 index 00000000..3c82bc17 --- /dev/null +++ b/native-shell/CoreVideoPro.WinUI/Services/ProductionPreferencesNotice.cs @@ -0,0 +1,19 @@ +namespace CoreVideoPro.WinUI.Services; + +/// Startup-only operator notices; normal first launch stays quiet. +public static class ProductionPreferencesNotice +{ + public const string RestoreFailure = + "Saved scenes and output settings could not be fully restored. Review the studio settings before going live."; + + public static string For(ProductionPreferencesLoadStatus status) => status switch + { + ProductionPreferencesLoadStatus.Recovered => + "Scenes and output settings were recovered from a backup. Recent changes may be missing. Review the studio settings before going live.", + ProductionPreferencesLoadStatus.Corrupt => + "Saved scenes and output settings are damaged, and no usable backup was found. Default settings were loaded. Review the studio settings before going live.", + ProductionPreferencesLoadStatus.Unreadable => + "CoreVideo Pro could not access the saved scenes and output settings. Default settings were loaded. Check file access and review the studio settings before going live.", + _ => string.Empty + }; +} diff --git a/native-shell/CoreVideoPro.WinUI/Services/StudioControlSurface.cs b/native-shell/CoreVideoPro.WinUI/Services/StudioControlSurface.cs index 5082811c..d2fd1e71 100644 --- a/native-shell/CoreVideoPro.WinUI/Services/StudioControlSurface.cs +++ b/native-shell/CoreVideoPro.WinUI/Services/StudioControlSurface.cs @@ -152,11 +152,11 @@ private async Task DispatchAsync(string actionId, IReadOnly case "transport.record.toggle": return await RunTransportToggle(_vm.ToggleRecordingCommand, "recording").ConfigureAwait(true); case "transport.record.set": - return await RunTransportSet(_vm.Recording, Bool(args, 0), _vm.ToggleRecordingCommand, "recording").ConfigureAwait(true); + return await RunOutputSet(_vm.RecordingRequested, _vm.Recording, Bool(args, 0), _vm.CanSetRecording, _vm.SetRecordingAsync, "recording").ConfigureAwait(true); case "transport.stream.toggle": return await RunTransportToggle(_vm.ToggleStreamingCommand, "streaming").ConfigureAwait(true); case "transport.stream.set": - return await RunTransportSet(_vm.Streaming, Bool(args, 0), _vm.ToggleStreamingCommand, "streaming").ConfigureAwait(true); + return await RunOutputSet(_vm.StreamingRequested, _vm.Streaming, Bool(args, 0), _vm.CanSetStreaming, _vm.SetStreamingAsync, "streaming").ConfigureAwait(true); case "transport.engine.toggle": return await RunTransportToggle(_vm.ToggleEngineCommand, "capture engine").ConfigureAwait(true); case "transport.engine.set": @@ -517,6 +517,24 @@ private static async Task RunTransportToggle( return ControlInvokeResult.Success; } + internal static async Task RunOutputSet( + bool requested, bool observedLive, bool target, Func canSet, + Func set, string what) + { + // A disarmed intent is not proof that a previous Stop reached the core. + // Retry the explicit target while media is still live; never invert intent here. + if (requested == target && (target || !observedLive)) + { + return ControlInvokeResult.Success; + } + if (!canSet(target)) + { + return ControlInvokeResult.Fail($"Cannot set {what} right now (the control is unavailable in the current state)."); + } + await set(target).ConfigureAwait(true); + return ControlInvokeResult.Success; + } + private static async Task RunTransportSet( bool current, bool target, CommunityToolkit.Mvvm.Input.IAsyncRelayCommand command, string what) { diff --git a/native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.Preferences.cs b/native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.Preferences.cs new file mode 100644 index 00000000..4f243a1e --- /dev/null +++ b/native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.Preferences.cs @@ -0,0 +1,14 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace CoreVideoPro.WinUI.ViewModels; + +public sealed partial class StudioViewModel +{ + // Kept for the session so subsequent successful autosaves cannot hide a + // startup recovery/defaults warning before the operator reviews the show. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasProductionPreferencesWarning))] + private string _productionPreferencesWarning = string.Empty; + + public bool HasProductionPreferencesWarning => !string.IsNullOrEmpty(ProductionPreferencesWarning); +} diff --git a/native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.Transport.cs b/native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.Transport.cs index 5fceef54..cf295913 100644 --- a/native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.Transport.cs +++ b/native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.Transport.cs @@ -1,6 +1,7 @@ using CoreVideoPro.MediaCore.Models; using CoreVideoPro.MediaCore.Services; using CoreVideoPro.WinUI.ViewModels.Transport; +using CommunityToolkit.Mvvm.ComponentModel; namespace CoreVideoPro.WinUI.ViewModels; @@ -21,20 +22,78 @@ public sealed partial class StudioViewModel : ITransportHost, ITransportDispatch { private readonly TransportCoordinator _transportCoordinator; + // Intent is kept separately from Recording/Streaming, which reflect observed media. + [ObservableProperty] + private bool _recordingRequested; + [ObservableProperty] + private bool _streamingRequested; + + partial void OnRecordingRequestedChanged(bool value) => OnPropertyChanged(nameof(RecordingLabel)); + partial void OnStreamingRequestedChanged(bool value) => OnPropertyChanged(nameof(StreamingLabel)); + + internal Task SetRecordingAsync(bool requested) => _transportCoordinator.SetRecordingAsync(requested); + internal Task SetStreamingAsync(bool requested) => _transportCoordinator.SetStreamingAsync(requested); + internal bool CanSetRecording(bool requested) => !_transportCoordinator.RecordingToggleInFlight && (!requested || Settings.IsInMeeting); + internal bool CanSetStreaming(bool requested) => !_transportCoordinator.StreamToggleInFlight; + + // Called only on the UI thread, including the capture-independent polling path. + private void ApplyOutputLifecyclePatch(LiveProductionSync.StudioLiveProductionPatch patch) + { + // Observed snapshots never overwrite operator intent. Terminal failure + // disarms future syncs once the active command has settled. + if (patch.RecordingRequested == false && !_transportCoordinator.RecordingToggleInFlight) + { + RecordingRequested = false; + } + if (patch.Recording is { } recording) + { + Recording = recording; + } + + if (patch.Streaming is { } streaming) + { + Streaming = streaming; + } + + if (patch.OutputStatus is { Length: > 0 } outputStatus) + { + OutputStatus = outputStatus; + } + + if (patch.OutputSessionStatus is { Length: > 0 } outputSessionStatus) + { + OutputSessionStatus = outputSessionStatus; + } + } + + private void InterruptOutputSessions() + { + var interrupted = RecordingRequested || StreamingRequested || Recording || Streaming; + RecordingRequested = false; + StreamingRequested = false; + Recording = false; + Streaming = false; + if (interrupted) + { + OutputStatus = "Outputs interrupted — recording continuity was lost. Start a new session after recovery."; + OutputSessionStatus = OutputStatus; + } + } + // --- ITransportDispatcher: preserve the exact RunOnUiThread marshalling semantics --- void ITransportDispatcher.RunOnUiThread(Action action) => RunOnUiThread(action); // --- ITransportHost: bound transport state (stays [ObservableProperty] on StudioViewModel) --- bool ITransportHost.Recording { - get => Recording; - set => Recording = value; + get => RecordingRequested; + set => RecordingRequested = value; } bool ITransportHost.Streaming { - get => Streaming; - set => Streaming = value; + get => StreamingRequested; + set => StreamingRequested = value; } bool ITransportHost.ZoomCaptureSubscribed diff --git a/native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.cs b/native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.cs index f6d7a59f..92f8b3a1 100644 --- a/native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.cs +++ b/native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.cs @@ -1613,9 +1613,9 @@ public StudioViewModel() ? "Start or stop Zoom video capture for this meeting." : "Join a Zoom meeting before starting capture."; - public string RecordingLabel => Recording ? "Recording" : "Record"; + public string RecordingLabel => Recording ? "Recording" : RecordingRequested ? "Starting…" : "Record"; - public string StreamingLabel => Streaming ? "Streaming" : "Stream"; + public string StreamingLabel => Streaming ? "Streaming" : StreamingRequested ? "Starting…" : "Stream"; public IReadOnlyList StreamRtmpProtocolOptions { get; } = ["rtmps", "rtmp"]; @@ -8499,7 +8499,7 @@ public static ParticipantAudioMix BuildWaitingForPcmAudioMixChannel( new ZoomMediaSpinePayloadBuilder.BuildInput { Participants = participants, - Recording = Recording, + Recording = RecordingRequested, SelectedBreakoutRoomId = _currentRoomId, EngineRunning = ZoomCaptureSubscribed, // Explicit opt-in: raw capture (and the Zoom recording-rights @@ -8732,8 +8732,8 @@ private MediaCoreProductionSyncContext BuildProductionSyncContext() MultiviewColumns = multiviewGrid.Columns, MultiviewRows = multiviewGrid.Rows, Participants = participants, - Recording = Recording, - Streaming = Streaming, + Recording = RecordingRequested, + Streaming = StreamingRequested, StreamDestinations = BuildSelectedStreamDestinations(validatedOnly: true), StreamDestinationSettings = BuildStreamDestinationSettings(), SrtIngestSources = BuildSrtIngestSourceSettings(), @@ -9643,15 +9643,15 @@ private void OnOutputProfileChanged() private async Task SyncOutputProfileChangeAsync() { - if (Streaming && ValidateStreamDestinations() is { Length: > 0 } validationError) + if (StreamingRequested && ValidateStreamDestinations() is { Length: > 0 } validationError) { LaunchLog.Write($"stream: profile change blocked invalid destination ({validationError})"); var failureStatus = FormatStreamingFailureStatus("settings", new InvalidOperationException(validationError)); RunOnUiThread(() => { - Streaming = false; + StreamingRequested = false; RefreshOutputStatus(); - OutputStatus = $"{failureStatus} Streaming stopped."; + OutputStatus = $"{failureStatus} Streaming stopping."; OutputSessionStatus = OutputStatus; }); @@ -9684,7 +9684,7 @@ private async void OnStreamOutputOptionChanged() OnPropertyChanged(nameof(StreamSrtSummary)); SaveProductionOutputPreferences(); - if (!Streaming || !_bridge.Running) + if (!StreamingRequested || !_bridge.Running) { return; } @@ -9692,9 +9692,9 @@ private async void OnStreamOutputOptionChanged() if (ValidateStreamDestinations() is { Length: > 0 } validationError) { var failureStatus = FormatStreamingFailureStatus("settings", new InvalidOperationException(validationError)); - Streaming = false; + StreamingRequested = false; RefreshOutputStatus(); - OutputStatus = $"{failureStatus} Streaming stopped."; + OutputStatus = $"{failureStatus} Streaming stopping."; OutputSessionStatus = OutputStatus; try { @@ -9735,7 +9735,7 @@ private async void OnRecordingOutputOptionChanged() RefreshTransportState(); SaveProductionOutputPreferences(); - if (!Recording || !_bridge.Running) + if (!RecordingRequested || !_bridge.Running) { return; } @@ -9829,6 +9829,7 @@ private void ApplyBridgeHealthChanged(MediaCoreHealth health) } else if (health.Recovering) { + InterruptOutputSessions(); EngineStatus = $"Media core recovering (restart {health.RestartCount})"; } @@ -10020,6 +10021,9 @@ void LogIfSlow(string exit) if (!ZoomCaptureSubscribed) { + // Output sessions are independent of Zoom capture. Keep observed state and + // terminal-failure disarming current even when only local inputs are used. + ApplyOutputLifecyclePatch(LiveProductionSync.MapSnapshotToStudioPatch(snapshot, BuildLiveProductionContext())); LogIfSlow("notSubscribed"); return; } @@ -10050,8 +10054,8 @@ private LiveProductionSync.LiveProductionSyncContext BuildLiveProductionContext( ActiveSceneId = ActiveSceneId, ActiveSceneLayout = ProgramScene.Layout, CurrentBreakoutRoomId = _currentRoomId, - RecordingRequested = Recording, - StreamingRequested = Streaming, + RecordingRequested = RecordingRequested, + StreamingRequested = StreamingRequested, Participants = RoomVideoParticipants .Select(participant => new LiveProductionSync.LiveProductionParticipantContext { @@ -10162,6 +10166,8 @@ private void StopMediaCoreSession(string status) UnsubscribeZoomCapture(status); Recording = false; Streaming = false; + RecordingRequested = false; + StreamingRequested = false; _bridge.Stop(); EngineStatus = status; Settings.RefreshSdkReadiness(); @@ -10190,32 +10196,7 @@ private void ApplyLiveProductionPatch(LiveProductionSync.StudioLiveProductionPat { ApplyCaptionAndLowerThirdPatch(patch); - // A record/stop command owns the requested state until its production sync - // completes. The core can publish one or more snapshots describing the old - // writer state while that sync is back-pressured. Applying those snapshots - // here used to flip Recording back to true during a deferred Stop; the retry - // then built a fresh payload with Recording=true and immediately armed a new - // recording directory. Keep the operator's intent sticky for the lifetime of - // the guarded command. Once it completes, normal snapshots resume ownership. - if (patch.Recording is { } recording && !_transportCoordinator.RecordingToggleInFlight) - { - Recording = recording; - } - - if (patch.Streaming is { } streaming) - { - Streaming = streaming; - } - - if (patch.OutputStatus is { Length: > 0 } outputStatus) - { - OutputStatus = outputStatus; - } - - if (patch.OutputSessionStatus is { Length: > 0 } outputSessionStatus) - { - OutputSessionStatus = outputSessionStatus; - } + ApplyOutputLifecyclePatch(patch); if (patch.ZoomStatus is { Length: > 0 } zoomStatus) { @@ -11191,15 +11172,20 @@ private void LoadProductionOutputPreferences() { try { - var preferences = _outputPreferencesStore.Load(); + var loaded = _outputPreferencesStore is FileProductionOutputPreferencesStore fileStore + ? fileStore.LoadWithResult() + : new ProductionPreferencesLoadResult(ProductionPreferencesLoadStatus.Loaded, _outputPreferencesStore.Load()); + ProductionPreferencesWarning = ProductionPreferencesNotice.For(loaded.Status); + var preferences = loaded.Preferences; if (preferences is not null) { ApplyProductionOutputPreferences(preferences); } } - catch (Exception) + catch (Exception ex) { - // Output settings are best-effort; defaults must still allow startup. + ProductionPreferencesWarning = ProductionPreferencesNotice.RestoreFailure; + LaunchLog.Write($"prefs: startup restore failed ({ex.GetType().Name})"); } finally { diff --git a/native-shell/CoreVideoPro.WinUI/ViewModels/Transport/ITransportHost.cs b/native-shell/CoreVideoPro.WinUI/ViewModels/Transport/ITransportHost.cs index 1b0d6c51..b4f782f1 100644 --- a/native-shell/CoreVideoPro.WinUI/ViewModels/Transport/ITransportHost.cs +++ b/native-shell/CoreVideoPro.WinUI/ViewModels/Transport/ITransportHost.cs @@ -30,6 +30,8 @@ public interface ITransportHost { // --- transport bound state (the command bodies read/write these; they stay // [ObservableProperty] on StudioViewModel so XAML x:Bind is unchanged) --- + // These legacy host names carry DESIRED activity. The shell implements them + // using RecordingRequested/StreamingRequested, never its live indicators. bool Recording { get; set; } bool Streaming { get; set; } diff --git a/native-shell/CoreVideoPro.WinUI/ViewModels/Transport/TransportCoordinator.cs b/native-shell/CoreVideoPro.WinUI/ViewModels/Transport/TransportCoordinator.cs index 69e5dcf4..8a4e53c1 100644 --- a/native-shell/CoreVideoPro.WinUI/ViewModels/Transport/TransportCoordinator.cs +++ b/native-shell/CoreVideoPro.WinUI/ViewModels/Transport/TransportCoordinator.cs @@ -164,7 +164,9 @@ public async Task TakeAsync() } } - public async Task ToggleRecordingAsync() + public Task ToggleRecordingAsync() => SetRecordingAsync(!_host.Recording); + + public async Task SetRecordingAsync(bool starting) { if (_recordingToggleInFlight) { @@ -173,7 +175,6 @@ public async Task ToggleRecordingAsync() _recordingToggleInFlight = true; _host.NotifyRecordingCommandCanExecuteChanged(); - var starting = !_host.Recording; var previousRecording = _host.Recording; LaunchLog.Write( $"recording: toggle requested action={(starting ? "start" : "stop")} " + @@ -194,6 +195,8 @@ public async Task ToggleRecordingAsync() if (preflight.ShouldBlock) { LaunchLog.Write($"recording: start BLOCKED by disk pre-flight — {preflight.Message}"); + _recordingToggleInFlight = false; + _host.NotifyRecordingCommandCanExecuteChanged(); _host.OutputStatus = preflight.Message; _host.OutputSessionStatus = _host.OutputStatus; _host.RefreshOutputStatus(); @@ -234,7 +237,7 @@ public async Task ToggleRecordingAsync() _dispatcher.RunOnUiThread(() => { - _host.OutputStatus = starting ? "Recording start requested." : "Recording stopped."; + _host.OutputStatus = starting ? "Recording start requested." : "Recording stop requested — finalizing."; _host.OutputSessionStatus = _host.OutputStatus; }); } @@ -339,7 +342,7 @@ private async Task RetryRecordingSyncAsync(bool starting) _dispatcher.RunOnUiThread(() => { - _host.OutputStatus = starting ? "Recording start requested." : "Recording stopped."; + _host.OutputStatus = starting ? "Recording start requested." : "Recording stop requested — finalizing."; _host.OutputSessionStatus = _host.OutputStatus; _host.RefreshOutputStatus(); }); @@ -389,7 +392,9 @@ private async Task RetryRecordingSyncAsync(bool starting) } } - public async Task ToggleStreamingAsync() + public Task ToggleStreamingAsync() => SetStreamingAsync(!_host.Streaming); + + public async Task SetStreamingAsync(bool starting) { if (_streamToggleInFlight) { @@ -398,7 +403,6 @@ public async Task ToggleStreamingAsync() _streamToggleInFlight = true; _host.NotifyStreamingCommandCanExecuteChanged(); - var starting = !_host.Streaming; var requestedDestinations = _host.BuildSelectedStreamDestinations(validatedOnly: true); LaunchLog.Write( $"stream: toggle requested action={(starting ? "start" : "stop")} " + @@ -498,7 +502,8 @@ public async Task ToggleStreamingAsync() LaunchLog.Write($"stream: {action} failed {ex.GetType().Name}: {ex.Message}"); _dispatcher.RunOnUiThread(() => { - _host.Streaming = previousStreaming; + // A failed Stop must not let the next state sync re-arm the sender. + _host.Streaming = starting && previousStreaming; _host.RefreshOutputStatus(); _host.OutputStatus = failureStatus; _host.OutputSessionStatus = _host.OutputStatus; diff --git a/native-shell/CoreVideoPro.WinUI/ViewModels/Transport/TransportStatusFormatter.cs b/native-shell/CoreVideoPro.WinUI/ViewModels/Transport/TransportStatusFormatter.cs index 2cc48e04..471da088 100644 --- a/native-shell/CoreVideoPro.WinUI/ViewModels/Transport/TransportStatusFormatter.cs +++ b/native-shell/CoreVideoPro.WinUI/ViewModels/Transport/TransportStatusFormatter.cs @@ -13,7 +13,7 @@ namespace CoreVideoPro.WinUI.ViewModels.Transport; /// public static class TransportStatusFormatter { - public static bool ResolveStreamingStateAfterFailedRetry(bool requestedStarting) => !requestedStarting; + public static bool ResolveStreamingStateAfterFailedRetry(bool requestedStarting) => false; public static string FormatStreamSyncRetryExhaustedStatus(bool requestedStarting) => requestedStarting @@ -29,10 +29,10 @@ public static bool IsStreamingStartProven( return false; } - return requestedDestinations.Any(destination => + return requestedDestinations.All(destination => snapshot.OutputSenderSession.Senders.Any(sender => sender.Destination.Equals(destination, StringComparison.OrdinalIgnoreCase) && - sender.Status is "starting" or "live") || + sender.Status == "live") || snapshot.OutputHealth.Any(item => item.Destination.Equals(destination, StringComparison.OrdinalIgnoreCase) && item.Status == "live")); @@ -54,6 +54,15 @@ public static bool TryFormatStreamingStartNoSenderFailure( return false; } + // An accepted sender can take longer than the command's short observation + // window to connect. Keep it armed without calling it proven/live. + if (snapshot.OutputSenderSession.Senders.Any(sender => + requestedDestinations.Contains(sender.Destination, StringComparer.OrdinalIgnoreCase) && + sender.Status is "starting" or "live")) + { + return false; + } + var unavailableSender = snapshot.OutputSenderSession.Senders.FirstOrDefault(sender => requestedDestinations.Any(destination => sender.Destination.Equals(destination, StringComparison.OrdinalIgnoreCase)) && IsUnavailableOutputSenderWarning(sender)); @@ -106,6 +115,11 @@ public static string FormatRecordingSyncRetryExhaustedStatus(bool requestedStart public static bool TryFormatRecordingStartHealthFailure(NativeMediaCoreStateSnapshot snapshot, out string failureStatus) { + if (snapshot.Recording?.Lifecycle is { State: "failed" } lifecycle) + { + failureStatus = FormatRecordingFailureStatus("start", new InvalidOperationException(lifecycle.Error ?? "Recording writer failed.")); + return true; + } var detail = snapshot.OutputHealth .Where(item => item.Status is "failed" or "warning" && @@ -129,6 +143,13 @@ item.Status is "failed" or "warning" && public static bool TryFormatStreamingStartHealthFailure(NativeMediaCoreStateSnapshot snapshot, out string failureStatus) { + // Failure of one destination must not stop another healthy output. Individual + // sender errors remain in the snapshot and output-health readouts. + if (snapshot.OutputSenderSession.Senders.Any(sender => sender.Status is "live" or "starting")) + { + failureStatus = string.Empty; + return false; + } var detail = snapshot.OutputSenderSession.Senders .Where(static sender => sender.Status is "failed" or "warning") .Select(BuildOutputSenderFailureDetail) diff --git a/native-shell/CoreVideoPro.WinUI/Views/StudioWorkspace.xaml b/native-shell/CoreVideoPro.WinUI/Views/StudioWorkspace.xaml index 851646f5..7eb4884a 100644 --- a/native-shell/CoreVideoPro.WinUI/Views/StudioWorkspace.xaml +++ b/native-shell/CoreVideoPro.WinUI/Views/StudioWorkspace.xaml @@ -131,6 +131,7 @@ +