From c90b9d4d44232d9240916355ed1be5960f91fd77 Mon Sep 17 00:00:00 2001 From: John Wallace Date: Sat, 5 Sep 2026 21:54:12 -0400 Subject: [PATCH 01/21] Require authenticated LAN control and separate OSC exposure --- companion-module-corevideopro/README.md | 21 ++++- .../HttpControlServerTests.cs | 90 +++++++++++++++++++ .../Http/HttpControlServer.cs | 40 +++++++-- .../CoreVideoPro.WinUI/MainWindow.xaml.cs | 21 +++-- 4 files changed, 155 insertions(+), 17 deletions(-) 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/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.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 +} From 5cd73e8386a60d9e7061922e5c9df4f8b90c367d Mon Sep 17 00:00:00 2001 From: John Wallace Date: Sat, 5 Sep 2026 21:55:03 -0400 Subject: [PATCH 02/21] Generate versioned lifecycle contracts and shared wire fixtures --- contracts/README.md | 50 +++ contracts/generate.mjs | 124 +++++++ contracts/lifecycle.fixtures.json | 338 ++++++++++++++++++ contracts/lifecycle.schema.json | 45 +++ .../Lifecycle.generated.swift | 95 +++++ native-core/src/generated/lifecycle.ts | 57 +++ .../LifecycleContractTests.cs | 54 +++ .../Contracts/Lifecycle.cs | 90 +++++ native/src/contracts/Lifecycle.h | 104 ++++++ native/tests/ContractParityTest.cpp | 25 ++ src/engine/generated/lifecycle.test.ts | 13 + src/engine/generated/lifecycle.ts | 57 +++ 12 files changed, 1052 insertions(+) create mode 100644 contracts/README.md create mode 100644 contracts/generate.mjs create mode 100644 contracts/lifecycle.fixtures.json create mode 100644 contracts/lifecycle.schema.json create mode 100644 mac-shell/Sources/CoreVideoProShell/Lifecycle.generated.swift create mode 100644 native-core/src/generated/lifecycle.ts create mode 100644 native-shell/CoreVideoPro.MediaCore.Tests/LifecycleContractTests.cs create mode 100644 native-shell/CoreVideoPro.MediaCore/Contracts/Lifecycle.cs create mode 100644 native/src/contracts/Lifecycle.h create mode 100644 src/engine/generated/lifecycle.test.ts create mode 100644 src/engine/generated/lifecycle.ts 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/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/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-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/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/src/contracts/Lifecycle.h b/native/src/contracts/Lifecycle.h new file mode 100644 index 00000000..2e302e15 --- /dev/null +++ b/native/src/contracts/Lifecycle.h @@ -0,0 +1,104 @@ +// Generated by contracts/generate.mjs. Do not edit. +#pragma once +#include "rpc/Json.h" +#include +#include +#include +namespace corevideo::contracts { +struct ProtocolVersion { + int major{}; + int minor{}; +}; +inline bool validateProtocolVersion(const rpc::Json& value) { + if (!value.isObject()) return false; + const auto* major = value.get("major"); + if (!major || !(major->isNumber() && std::floor(major->asNumber()) == major->asNumber() && major->asNumber() >= 1 && major->asNumber() <= 1)) return false; + const auto* minor = value.get("minor"); + if (!minor || !(minor->isNumber() && std::floor(minor->asNumber()) == minor->asNumber() && minor->asNumber() >= 0 && minor->asNumber() <= 2147483647)) return false; + return true; +} +inline rpc::Json toJson(const ProtocolVersion& value) { + rpc::Json::Object result; + result.emplace("major", value.major); + result.emplace("minor", value.minor); + return result; +} +struct OutputLifecycle { + std::string sessionId{}; + bool desiredActive{}; + std::string state{}; + std::string health{}; + bool finalized{}; + std::optional error{}; +}; +inline bool validateOutputLifecycle(const rpc::Json& value) { + if (!value.isObject()) return false; + const auto* sessionId = value.get("sessionId"); + if (!sessionId || !(sessionId->isString() && sessionId->asString().size() >= 1)) return false; + const auto* desiredActive = value.get("desiredActive"); + if (!desiredActive || !(desiredActive->isBool())) return false; + const auto* state = value.get("state"); + if (!state || !(state->isString() && (state->asString() == "idle" || state->asString() == "starting" || state->asString() == "live" || state->asString() == "stopping" || state->asString() == "finalizing" || state->asString() == "completed" || state->asString() == "failed" || state->asString() == "interrupted"))) return false; + const auto* health = value.get("health"); + if (!health || !(health->isString() && (health->asString() == "unknown" || health->asString() == "healthy" || health->asString() == "degraded" || health->asString() == "failed"))) return false; + const auto* finalized = value.get("finalized"); + if (!finalized || !(finalized->isBool())) return false; + const auto* error = value.get("error"); + if (error && !(error->isString())) return false; + return true; +} +inline rpc::Json toJson(const OutputLifecycle& value) { + rpc::Json::Object result; + result.emplace("sessionId", value.sessionId); + result.emplace("desiredActive", value.desiredActive); + result.emplace("state", value.state); + result.emplace("health", value.health); + result.emplace("finalized", value.finalized); + if (value.error) result.emplace("error", *value.error); + return result; +} +struct OperationStatus { + std::string processEpoch{}; + std::string operationId{}; + std::string state{}; + std::optional error{}; +}; +inline bool validateOperationStatus(const rpc::Json& value) { + if (!value.isObject()) return false; + const auto* processEpoch = value.get("processEpoch"); + if (!processEpoch || !(processEpoch->isString() && processEpoch->asString().size() >= 1)) return false; + const auto* operationId = value.get("operationId"); + if (!operationId || !(operationId->isString() && operationId->asString().size() >= 1)) return false; + const auto* state = value.get("state"); + if (!state || !(state->isString() && (state->asString() == "accepted" || state->asString() == "running" || state->asString() == "completed" || state->asString() == "failed" || state->asString() == "cancelled"))) return false; + const auto* error = value.get("error"); + if (error && !(error->isString())) return false; + return true; +} +inline rpc::Json toJson(const OperationStatus& value) { + rpc::Json::Object result; + result.emplace("processEpoch", value.processEpoch); + result.emplace("operationId", value.operationId); + result.emplace("state", value.state); + if (value.error) result.emplace("error", *value.error); + return result; +} +struct ProtocolFailure { + std::string code{}; + std::string message{}; +}; +inline bool validateProtocolFailure(const rpc::Json& value) { + if (!value.isObject()) return false; + const auto* code = value.get("code"); + if (!code || !(code->isString() && code->asString().size() >= 1)) return false; + const auto* message = value.get("message"); + if (!message || !(message->isString() && message->asString().size() >= 1)) return false; + return true; +} +inline rpc::Json toJson(const ProtocolFailure& value) { + rpc::Json::Object result; + result.emplace("code", value.code); + result.emplace("message", value.message); + return result; +} +} // namespace corevideo::contracts diff --git a/native/tests/ContractParityTest.cpp b/native/tests/ContractParityTest.cpp index fe7408c9..766be8bb 100644 --- a/native/tests/ContractParityTest.cpp +++ b/native/tests/ContractParityTest.cpp @@ -1,4 +1,5 @@ #include "core/Protocol.h" +#include "contracts/Lifecycle.h" #include @@ -29,6 +30,30 @@ void expectAllStringsPresent(const std::string& source, const Strings& strings) } // namespace +TEST(ContractParity, LifecycleGoldenMessagesMatchSchema) { + const auto fixtures = corevideo::rpc::Json::parse(readRepoFile("contracts/lifecycle.fixtures.json")); + ASSERT_TRUE(fixtures && fixtures->isArray()); + ASSERT_FALSE(fixtures->asArray().empty()); + for (const auto& fixture : fixtures->asArray()) { + const auto payload = corevideo::rpc::Json::parse(fixture.getString("json")); + ASSERT_TRUE(payload); + const auto name = fixture.getString("contract"); + bool valid = false; + using namespace corevideo::contracts; + if (name == "ProtocolVersion") valid = validateProtocolVersion(*payload); + else if (name == "OutputLifecycle") valid = validateOutputLifecycle(*payload); + else if (name == "OperationStatus") valid = validateOperationStatus(*payload); + else if (name == "ProtocolFailure") valid = validateProtocolFailure(*payload); + else ASSERT_TRUE(false) << "Unknown contract: " << name; + ASSERT_NE(fixture.get("accepted"), nullptr); + EXPECT_EQ(valid, fixture.get("accepted")->asBool()) << fixture.getString("id"); + } + const corevideo::contracts::OutputLifecycle lifecycle{"session-1", true, "starting", "unknown", false, std::nullopt}; + const auto wire = corevideo::contracts::toJson(lifecycle); + EXPECT_TRUE(corevideo::contracts::validateOutputLifecycle(wire)); + EXPECT_EQ(wire.get("error"), nullptr); +} + TEST(ContractParity, MediaCoreCommandTypesMatchTypeScriptProtocol) { const std::string source = readRepoFile("src/engine/nativeMediaCoreProtocol.ts"); ASSERT_FALSE(source.empty()); diff --git a/src/engine/generated/lifecycle.test.ts b/src/engine/generated/lifecycle.test.ts new file mode 100644 index 00000000..e1cefa0b --- /dev/null +++ b/src/engine/generated/lifecycle.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import fixtures from '../../../contracts/lifecycle.fixtures.json'; +import * as browser from './lifecycle'; +import * as node from '../../../native-core/src/generated/lifecycle'; + +describe('shared lifecycle wire fixtures', () => { + for (const fixture of fixtures) it(fixture.id, () => { + const payload: unknown = JSON.parse(fixture.json); + const name = `validate${fixture.contract}` as keyof typeof browser; + expect(browser[name](payload)).toBe(fixture.accepted); + expect(node[name](payload)).toBe(fixture.accepted); + }); +}); diff --git a/src/engine/generated/lifecycle.ts b/src/engine/generated/lifecycle.ts new file mode 100644 index 00000000..6c26f5e8 --- /dev/null +++ b/src/engine/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; +} From 79b8d98ba3d643819139af1b4f7d04ae91580370 Mon Sep 17 00:00:00 2001 From: John Wallace Date: Sat, 5 Sep 2026 21:55:04 -0400 Subject: [PATCH 03/21] Track recording finalization and keep controls responsive during Zoom join --- native/CMakeLists.txt | 4 +- native/src/core/MediaCore.cpp | 30 +-- native/src/core/MediaCore.h | 2 +- native/src/core/RouteSourcePolicy.h | 49 ++++ .../src/modules/AVFoundationEncoderAdapter.mm | 24 +- native/src/modules/AsyncEncoderSink.cpp | 144 ++++++++--- native/src/modules/AsyncEncoderSink.h | 10 +- native/src/modules/Interfaces.h | 4 + .../modules/MediaFoundationEncoderAdapter.cpp | 8 +- .../src/modules/RtmpOutputSenderAdapter.cpp | 15 +- native/src/modules/ZoomEngineProcess.cpp | 18 +- native/src/modules/ZoomEngineProcess.h | 7 +- native/src/modules/ZoomEngineRuntime.cpp | 78 ++++-- native/src/modules/ZoomEngineRuntime.h | 8 +- native/src/rpc/CommandMailbox.h | 84 +++++++ native/src/rpc/JsonRpcServer.cpp | 236 +++++++++++------- native/src/rpc/JsonRpcServer.h | 6 +- native/tests/AsyncEncoderSinkTest.cpp | 183 +++++++++++++- native/tests/JsonRpcServerTest.cpp | 116 +++++++++ native/tests/RouteSourcePolicyTest.cpp | 48 ++++ native/tests/ZoomEngineRuntimeTest.cpp | 32 +++ native/zoom-engine/fake/fake-engine.cpp | 12 + 22 files changed, 930 insertions(+), 188 deletions(-) create mode 100644 native/src/core/RouteSourcePolicy.h create mode 100644 native/src/rpc/CommandMailbox.h create mode 100644 native/tests/RouteSourcePolicyTest.cpp diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index 93333b05..1bdba8c9 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -230,7 +230,8 @@ endif() # windowscodecs + ole32 back the still-media WIC decoder (StillMediaFrameCache.cpp), # which is _WIN32-gated rather than option-gated so stills work in every Windows build. if(WIN32) - target_link_libraries(corevideo_native PUBLIC winmm windowscodecs ole32) + # The RPC audio worker uses MMCSS even in the stub configuration. + target_link_libraries(corevideo_native PUBLIC winmm windowscodecs ole32 avrt) endif() if(COREVIDEO_WITH_MF_ENCODER) @@ -608,6 +609,7 @@ if(BUILD_TESTING) tests/CaptureIngestTest.cpp tests/CompositorFramingTest.cpp tests/ContractParityTest.cpp + tests/RouteSourcePolicyTest.cpp tests/D3D11CompositorTest.cpp tests/DirectorTest.cpp tests/EngineIpcShmNameTest.cpp diff --git a/native/src/core/MediaCore.cpp b/native/src/core/MediaCore.cpp index 474d90bb..3589741d 100644 --- a/native/src/core/MediaCore.cpp +++ b/native/src/core/MediaCore.cpp @@ -5,6 +5,7 @@ #include "compositor/TilesMembership.h" #include "core/LockHoldGuardrail.h" #include "core/Protocol.h" +#include "core/RouteSourcePolicy.h" #include "modules/AudioDsp.h" #include "modules/ProgramFramePreview.h" #include "modules/RealZoomCaptureSource.h" @@ -521,9 +522,9 @@ bool MediaCore::zoomEngineConfigured() const { return zoomEngineRuntime_ && zoomEngineRuntime_->configured(); } -rpc::Json MediaCore::joinZoom(const rpc::Json& payload) { +rpc::Json MediaCore::joinZoom(const rpc::Json& payload, const std::function& cancelled) { if (zoomEngineRuntime_ && zoomEngineRuntime_->configured()) { - return zoomEngineRuntime_->join(payload); + return zoomEngineRuntime_->join(payload, cancelled); } zoomJoined_ = true; @@ -4224,7 +4225,8 @@ rpc::Json MediaCore::recordingState(const modules::OutputSession& session) const rpc::Json::Object recording{ {"sessionId", recordingSessionId_.empty() ? "native-recording-session" : recordingSessionId_}, - {"active", recordingStatus_ == "recording" || recordingStatus_ == "warning"}, + {"active", session.lifecycle ? session.lifecycle->state == "live" : + recordingStatus_ == "recording" || recordingStatus_ == "warning"}, {"status", recordingStatus_}, {"writerStatus", recordingWriterStatus_}, {"startedAtMs", recordingStartedAtMs_}, @@ -4274,6 +4276,9 @@ rpc::Json MediaCore::recordingState(const modules::OutputSession& session) const {"totalDroppedFrames", static_cast(droppedVideoFrames)}, {"totalBytesWritten", static_cast(totalBytesWritten)}, }; + if (session.lifecycle) { + recording.emplace("lifecycle", contracts::toJson(*session.lifecycle)); + } if (!session.recordingArtifactPath.empty()) { recording.emplace("artifactPath", session.recordingArtifactPath); } @@ -4471,26 +4476,21 @@ modules::CompositorRenderPlan MediaCore::buildRenderPlanForScene( for (const auto& route : sceneRoutes) { modules::CompositorRenderPlanLayer layer; layer.layerId = "route:" + route.routeId; - layer.kind = route.mode == "screen-share" ? "screen-share" : "participant-video"; + const auto fallbackParticipantId = videoLayerIndex < static_cast(videoFrames.size()) + ? std::optional(videoFrames[static_cast(videoLayerIndex)].participantId) : std::nullopt; + const auto binding = resolveRouteSource({route.mode, route.mediaAssetId, route.mediaAssetPath, + route.captureDeviceId, route.participantId, fallbackParticipantId}); + layer.kind = binding.kind; + layer.sourceId = binding.sourceId; + layer.participantId = binding.participantId; layer.order = videoLayerIndex; if (!route.mediaAssetId.empty() && !route.mediaAssetPath.empty()) { - layer.kind = "media-video"; - layer.sourceId = "media:" + route.mediaAssetId; layer.mediaAssetId = route.mediaAssetId; layer.mediaAssetName = route.mediaAssetName; layer.mediaAssetKind = route.mediaAssetKind; layer.mediaAssetPath = route.mediaAssetPath; layer.mediaPlaybackKey = route.mediaPlaybackKey; layer.mediaAssetPlaying = route.mediaAssetPlaying; - } else if (route.mode == "capture-input" && !route.captureDeviceId.empty()) { - layer.participantId = "capture:" + route.captureDeviceId; - layer.sourceId = layer.participantId; - } else if (!route.participantId.empty()) { - layer.participantId = route.participantId; - layer.sourceId = "zoom:" + route.participantId; - } else if (videoLayerIndex < static_cast(videoFrames.size())) { - layer.participantId = videoFrames[static_cast(videoLayerIndex)].participantId; - layer.sourceId = "zoom:" + layer.participantId; } if (route.hasRect) { layer.rect = {route.rectX, route.rectY, route.rectWidth, route.rectHeight}; diff --git a/native/src/core/MediaCore.h b/native/src/core/MediaCore.h index 48ff60ef..a8420a01 100644 --- a/native/src/core/MediaCore.h +++ b/native/src/core/MediaCore.h @@ -81,7 +81,7 @@ class MediaCore { [[nodiscard]] rpc::Json removeBrowserSource(const std::string& browserId, std::string& error); [[nodiscard]] rpc::Json reloadBrowserSource(const std::string& browserId, std::string& error); [[nodiscard]] rpc::Json browserSourcesState() const; - [[nodiscard]] rpc::Json joinZoom(const rpc::Json& payload); + [[nodiscard]] rpc::Json joinZoom(const rpc::Json& payload, const std::function& cancelled = {}); // True when a real Zoom engine subprocess is configured. Lock-free (the // runtime pointer and its executable path are fixed at construction). The // RPC server uses this to route zoom-join AROUND coreMutex: in this mode diff --git a/native/src/core/RouteSourcePolicy.h b/native/src/core/RouteSourcePolicy.h new file mode 100644 index 00000000..92e2926e --- /dev/null +++ b/native/src/core/RouteSourcePolicy.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include + +namespace corevideo::core { + +// Shared Windows/macOS runtime source-binding policy. Shells supply routing +// intent; the compositor consumes this decision. No UI, roster mutation or I/O. +struct RouteSourcePolicyInput { + std::string_view mode; + std::string_view mediaAssetId; + std::string_view mediaAssetPath; + std::string_view captureDeviceId; + std::string_view participantId; + std::optional positionalFallbackParticipantId; +}; + +struct RouteSourceBinding { + std::string kind; + std::string sourceId; + std::string participantId; +}; + +inline RouteSourceBinding resolveRouteSource(const RouteSourcePolicyInput& input) { + RouteSourceBinding binding{input.mode == "screen-share" ? "screen-share" : "participant-video", {}, {}}; + if (!input.mediaAssetId.empty() && !input.mediaAssetPath.empty()) { + binding.kind = "media-video"; + binding.sourceId = "media:" + std::string(input.mediaAssetId); + } else if (input.mode == "capture-input" && !input.captureDeviceId.empty()) { + binding.participantId = "capture:" + std::string(input.captureDeviceId); + binding.sourceId = binding.participantId; + } else if (!input.participantId.empty()) { + // Preserve an explicitly routed guest even if no frame is currently + // available. Roster order must never silently replace that guest. + binding.participantId = input.participantId; + binding.sourceId = "zoom:" + binding.participantId; + } else if (input.positionalFallbackParticipantId) { + // Compatibility with existing empty route assignments. This fallback is + // deliberately retained, including mode=none, until the route contract can + // distinguish an intentional blank from a legacy omitted assignment. + binding.participantId = *input.positionalFallbackParticipantId; + binding.sourceId = "zoom:" + binding.participantId; + } + return binding; +} + +} // namespace corevideo::core diff --git a/native/src/modules/AVFoundationEncoderAdapter.mm b/native/src/modules/AVFoundationEncoderAdapter.mm index 0dd10af2..e49b7ef1 100644 --- a/native/src/modules/AVFoundationEncoderAdapter.mm +++ b/native/src/modules/AVFoundationEncoderAdapter.mm @@ -346,7 +346,8 @@ bool writeAudio(const float* interleaved, int frameCount, int64_t pts100ns, return true; } - void finalize() { + bool finalize(std::string* errorOut = nullptr) { + bool finalized = true; if (writer_ && writing_) { [videoInput_ markAsFinished]; if (audioInput_) { @@ -356,7 +357,16 @@ void finalize() { [writer_ finishWritingWithCompletionHandler:^{ dispatch_semaphore_signal(done); }]; - dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC)); + const auto waitResult = dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC)); + if (waitResult != 0) { + finalized = false; + if (errorOut) *errorOut = "AVAssetWriter finalization timed out"; + [writer_ cancelWriting]; + } else if (writer_.status != AVAssetWriterStatusCompleted) { + finalized = false; + if (errorOut) *errorOut = writer_.error ? writer_.error.localizedDescription.UTF8String + : "AVAssetWriter did not complete finalization"; + } std::error_code ec; const auto size = std::filesystem::file_size(path_, ec); bytesWritten_ = ec ? 0 : static_cast(size); @@ -367,6 +377,7 @@ void finalize() { adaptor_ = nil; writing_ = false; audioConfigured_ = false; + return finalized; } bool audioConfigured() const { return audioConfigured_; } @@ -585,11 +596,16 @@ void stopRecording() override { if (!recordingArmed_) { return; } - writer_.finalize(); + std::string finalizeError; + if (!writer_.finalize(&finalizeError)) failRecording(finalizeError); // Each ISO finalizes independently — its own moov, no 0-byte tails. for (auto& [sourceId, entry] : isoWriters_) { if (entry.opened) { - entry.writer->finalize(); + std::string isoFinalizeError; + if (!entry.writer->finalize(&isoFinalizeError)) { + entry.status.warning = "ISO finalization failed for " + sourceId + ": " + isoFinalizeError; + failRecording(entry.status.warning); + } entry.status.bytesWritten = entry.writer->bytesWritten(); } } diff --git a/native/src/modules/AsyncEncoderSink.cpp b/native/src/modules/AsyncEncoderSink.cpp index 33f95b49..dd2c32ea 100644 --- a/native/src/modules/AsyncEncoderSink.cpp +++ b/native/src/modules/AsyncEncoderSink.cpp @@ -2,6 +2,8 @@ #include #include +#include +#include #include namespace corevideo::modules { @@ -93,6 +95,25 @@ uint64_t AsyncEncoderSink::enqueue(Item&& item) { state_->consecutiveProgramItems = 0; } + if (item.kind == Kind::Configure) { + state_->configuredSessionId = item.request.sessionId; + item.generation = state_->generation + 1; + } else if (item.kind == Kind::Start) { + item.generation = ++state_->generation; + state_->stopRequested = false; + state_->active.store(true); + std::lock_guard snapshotLock(state_->snapshotMutex); + state_->snapshot = OutputSession{}; + state_->snapshot.destinations = item.destinations; + if (std::find(item.destinations.begin(), item.destinations.end(), "recording") != item.destinations.end()) { + state_->snapshot.lifecycle = contracts::OutputLifecycle{ + state_->configuredSessionId + ":" + state_->epoch + ":" + std::to_string(item.generation), + true, "starting", "unknown", false, std::nullopt}; + } + } else { + item.generation = state_->generation; + } + seq = state_->nextSeq++; item.seq = seq; @@ -108,7 +129,7 @@ uint64_t AsyncEncoderSink::enqueue(Item&& item) { if (kind == Kind::IsoVideo && item.isoSources.size() == 1) { const std::string& sourceId = item.isoSources.front().sourceId; for (auto it = state_->queue.begin(); it != state_->queue.end(); ++it) { - if (it->kind == Kind::IsoVideo && it->isoSources.size() == 1 && + if (it->generation == item.generation && it->kind == Kind::IsoVideo && it->isoSources.size() == 1 && it->isoSources.front().sourceId == sourceId) { state_->queue.erase(it); state_->droppedVideo.fetch_add(1); @@ -127,13 +148,13 @@ uint64_t AsyncEncoderSink::enqueue(Item&& item) { : state_->maxIsoAudioQueue; size_t pending = 0; for (const auto& queued : state_->queue) { - if (queued.kind == kind) { + if (queued.generation == item.generation && queued.kind == kind) { ++pending; } } if (pending >= cap) { for (auto it = state_->queue.begin(); it != state_->queue.end(); ++it) { - if (it->kind == kind) { + if (it->generation == item.generation && it->kind == kind) { state_->queue.erase(it); if (kind == Kind::Audio || kind == Kind::IsoAudio) { state_->droppedAudio.fetch_add(1); @@ -178,17 +199,6 @@ void AsyncEncoderSink::configureRecording(const RecordingSessionRequest& request OutputSession AsyncEncoderSink::start(const std::vector& destinations, const std::vector& isoParticipantIds) { - // Publish active BEFORE enqueue so any submit() racing right behind this call is - // enqueued (ordered after the Start item) instead of dropped. - state_->active.store(true); - // Optimistically reflect the started session in the snapshot so the immediate - // return (and any session() read before the writer applies Start) shows active; - // the writer overwrites this with the wrapped sink's real session shortly. - { - std::lock_guard lock(state_->snapshotMutex); - state_->snapshot.active = true; - state_->snapshot.destinations = destinations; - } Item item; item.kind = Kind::Start; item.destinations = destinations; @@ -267,6 +277,12 @@ void AsyncEncoderSink::stopRecording() { item.kind = Kind::StopRecording; { std::lock_guard lock(state_->queueMutex); + // Desired-state sync may repeat Stop while Finalize is blocked. One + // barrier per generation keeps that repetition bounded and preserves the + // observed finalizing/completed state. Do not use the media gate here: + // writer failure closes it before the required cleanup Stop is submitted. + if (state_->stopRequested) return; + state_->stopRequested = true; // Close the producer gate under queueMutex, then append a FIFO control // barrier. Every media item accepted before this point is written before // Finalize; every racing or later submission is rejected by enqueue(). @@ -274,11 +290,15 @@ void AsyncEncoderSink::stopRecording() { // measured ~400ms Program/ISO A/V duration mismatch at every stop. state_->active.store(false, std::memory_order_release); item.seq = state_->nextSeq++; + item.generation = state_->generation; state_->queue.push_back(std::move(item)); - } - { - std::lock_guard lock(state_->snapshotMutex); + std::lock_guard snapshotLock(state_->snapshotMutex); state_->snapshot.active = false; + if (state_->snapshot.lifecycle) { + state_->snapshot.lifecycle->desiredActive = false; + if (state_->snapshot.lifecycle->state != "failed") + state_->snapshot.lifecycle->state = "stopping"; + } } state_->queueCv.notify_one(); } @@ -306,6 +326,10 @@ bool AsyncEncoderSink::drainForTest(std::chrono::milliseconds timeout) { } void AsyncEncoderSink::writerLoop(std::shared_ptr state) { + uint64_t failedGeneration = 0; + std::string generationFailure; + int64_t startVideoCount = 0; + bool madeProgress = false; for (;;) { Item item; { @@ -360,9 +384,20 @@ void AsyncEncoderSink::writerLoop(std::shared_ptr state) { state->applying = true; } - // Apply against the wrapped sink WITHOUT holding queueMutex — this is the - // (potentially blocking) I/O the async layer exists to keep off the worker. - if (state->inner) { + // Finalization remains pending until the actual writer returns. Only this + // generation may publish; queued old media/Stop must not revive a new take. + if (item.kind == Kind::StopRecording) { + std::lock_guard queueLock(state->queueMutex); + std::lock_guard lock(state->snapshotMutex); + if (state->snapshot.lifecycle && item.generation == state->generation && + state->snapshot.lifecycle->state != "failed") + state->snapshot.lifecycle->state = "finalizing"; + } + OutputSession fresh; + std::string failure; + try { + if (!state->inner) throw std::runtime_error("Recording writer is unavailable"); + if (item.generation != failedGeneration || item.kind == Kind::StopRecording) { switch (item.kind) { case Kind::Configure: state->inner->configureRecording(item.request); @@ -387,32 +422,59 @@ void AsyncEncoderSink::writerLoop(std::shared_ptr state) { state->inner->stopRecording(); break; } - } - - // Refresh the published snapshot from the wrapped session. NOTE: do NOT touch - // `active` here — it is owned by start() (set true synchronously) and the - // wrapped session's `active` LAGS behind the queue, so refreshing it from here - // could clobber the flag back to false between start() and the writer applying - // Start, causing a racing submit() to wrongly drop a frame. - OutputSession fresh; - if (state->inner) { + } fresh = state->inner->session(); + if (item.kind == Kind::Start) { + startVideoCount = fresh.recordingVideoFrameCount; + madeProgress = false; + } + // encodedFrameCount includes attempted submissions in the MF adapter; + // only successfully written recording frames establish output truth. + madeProgress = madeProgress || fresh.recordingVideoFrameCount > startVideoCount; + // Configure may retain the previous take's terminal error until Start + // resets the wrapped session. It cannot poison the next generation. + if (item.kind != Kind::Configure) + failure = item.generation == failedGeneration ? generationFailure : fresh.recordingError; + } catch (const std::exception& ex) { + failure = ex.what(); + } catch (...) { + failure = "Unknown recording writer failure"; } - if (item.kind == Kind::StopRecording) { - // Some concrete sinks retain their last-session metadata after Finalize, - // including active=true. The async decorator owns the producer gate, so - // its public snapshot must reflect the stopped state immediately and - // must not be revived by that retained inner snapshot. - fresh.active = false; + if (!failure.empty()) { + failedGeneration = item.generation; + generationFailure = failure; } { + // Same lock order as producer-side Start/Stop publication. + std::lock_guard queueLock(state->queueMutex); std::lock_guard lock(state->snapshotMutex); - state->snapshot = fresh; - // Keep `active` sticky once start() has run: the wrapped session's active flag - // lags the queue, so a snapshot refresh triggered by an earlier item (e.g. the - // queued Configure) must not report the session as inactive after start(). - if (state->active.load()) { - state->snapshot.active = true; + if (item.generation == state->generation && state->snapshot.lifecycle && + (item.kind != Kind::Configure || !failure.empty())) { + auto lifecycle = *state->snapshot.lifecycle; + if (!failure.empty()) { + lifecycle.state = "failed"; + lifecycle.health = "failed"; + lifecycle.error = failure; + state->active.store(false); + } else if (lifecycle.state != "failed") { + if (item.kind == Kind::StopRecording) { + lifecycle.finalized = madeProgress; + lifecycle.state = madeProgress ? "completed" : "failed"; + lifecycle.health = madeProgress ? "healthy" : "failed"; + if (!madeProgress) lifecycle.error = "Recording stopped before any media was written"; + } else if (lifecycle.desiredActive) { + lifecycle.state = madeProgress ? "live" : "starting"; + lifecycle.health = madeProgress ? "healthy" : "unknown"; + } + if (!fresh.recordingWarning.empty() && lifecycle.health == "healthy") + lifecycle.health = "degraded"; + } + fresh.lifecycle = std::move(lifecycle); + fresh.active = fresh.lifecycle->state == "live"; + state->snapshot = std::move(fresh); + } else if (item.generation == state->generation && !state->snapshot.lifecycle) { + // Non-recording encoder use retains its legacy observed sink state. + state->snapshot = std::move(fresh); } } diff --git a/native/src/modules/AsyncEncoderSink.h b/native/src/modules/AsyncEncoderSink.h index 475dbca0..ea9d37b4 100644 --- a/native/src/modules/AsyncEncoderSink.h +++ b/native/src/modules/AsyncEncoderSink.h @@ -38,8 +38,8 @@ namespace corevideo::modules { // - configureRecording / start: NON-BLOCKING. They are re-emitted every sync // while recording, so a blocking wait would couple the command thread to the // writer's queue drain each tick. Ordering (container open ahead of frames) -// is preserved by the single FIFO queue; start() returns an optimistic -// active snapshot the writer reconciles with the wrapped session shortly. +// is preserved by the single FIFO queue; start() returns starting until +// the writer reports actual output progress. // - stopRecording: NON-BLOCKING. The caller holds coreMutex, so it closes the // producer gate and enqueues a FIFO barrier. Already-accepted media drains // before asynchronous Finalize, preserving the take's A/V tail. The bounded @@ -108,6 +108,7 @@ class AsyncEncoderSink final : public IEncoderSink { struct Item { Kind kind; uint64_t seq = 0; + uint64_t generation = 0; // Configure RecordingSessionRequest request; // Start @@ -137,6 +138,11 @@ class AsyncEncoderSink final : public IEncoderSink { std::deque queue; uint64_t nextSeq = 1; uint64_t appliedSeq = 0; + uint64_t generation = 0; + std::string configuredSessionId = "recording"; + // Separate from active: a failed writer still needs one cleanup/finalize. + bool stopRequested = true; + std::string epoch = std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()); bool applying = false; bool stop = false; bool writerDone = false; diff --git a/native/src/modules/Interfaces.h b/native/src/modules/Interfaces.h index a9b2eedc..cca6a375 100644 --- a/native/src/modules/Interfaces.h +++ b/native/src/modules/Interfaces.h @@ -1,5 +1,7 @@ #pragma once +#include "contracts/Lifecycle.h" + #include #include #include @@ -436,6 +438,8 @@ struct IsoStreamStatus { struct OutputSession { bool active = false; + // Present only when the sink reports observed writer lifecycle. + std::optional lifecycle; std::vector destinations; std::vector isoParticipantIds; // Per-session subfolder + manifest (ISO-1 folder scheme, spec §5). Empty for diff --git a/native/src/modules/MediaFoundationEncoderAdapter.cpp b/native/src/modules/MediaFoundationEncoderAdapter.cpp index 2e2d18f9..9c765f3a 100644 --- a/native/src/modules/MediaFoundationEncoderAdapter.cpp +++ b/native/src/modules/MediaFoundationEncoderAdapter.cpp @@ -1635,9 +1635,9 @@ class MediaFoundationEncoderSink final : public IEncoderSink { } std::string programFinalizeError; const bool programFinalized = program_.finalize(&programFinalizeError); - if (!programFinalized && session_.recordingWarning.empty()) { - session_.recordingWarning = "Program recording did not finalize: " + programFinalizeError + "."; - session_.recordingError = session_.recordingWarning; + if (!programFinalized) { + session_.recordingError = "Program recording did not finalize: " + programFinalizeError + "."; + if (session_.recordingWarning.empty()) session_.recordingWarning = session_.recordingError; session_.recordingStatus = "warning"; } // Each ISO writer finalizes INDEPENDENTLY (its own moov) so a source that @@ -1655,6 +1655,8 @@ class MediaFoundationEncoderSink final : public IEncoderSink { iso.warning = "ISO writer did not finalize for " + iso.displayName + " (" + iso.sourceId + "): " + isoFinalizeError + "."; raiseIsoWarning(iso.warning); + // An earlier warning must not mask a failed container finalization. + if (session_.recordingError.empty()) session_.recordingError = iso.warning; } } // Refresh the on-disk sizes into the ISO status before clearing. diff --git a/native/src/modules/RtmpOutputSenderAdapter.cpp b/native/src/modules/RtmpOutputSenderAdapter.cpp index 626cfeb1..a84c7b91 100644 --- a/native/src/modules/RtmpOutputSenderAdapter.cpp +++ b/native/src/modules/RtmpOutputSenderAdapter.cpp @@ -728,7 +728,8 @@ class RtmpOutputSender final : public IOutputSender { // this frame, so letting a preview-sized frame through here is what // restarted the encoder mid-stream. if (!videoSourceUsable(*frame)) { - sender_.status = "live"; + sender_.status = hasWrittenVideo_ ? "live" : "starting"; + sender_.destinationHealth = hasWrittenVideo_ ? "ok" : "starting"; sender_.lastResultCode = "awaiting-full-res-frame"; appendSendProof(frame, "awaiting-full-res-frame"); return snapshot(); @@ -744,7 +745,7 @@ class RtmpOutputSender final : public IOutputSender { // 4K input pipe (the 2026-07-14 live freeze reproduced at 42 frames/0.84 s). writeAudioToFfmpeg(); if (!videoFramePacer_.shouldWrite(elapsedMs, configuredFps_)) { - sender_.status = "live"; + sender_.status = hasWrittenVideo_ ? "live" : "starting"; // A live stream still carries the unsupported-codec notice: the operator // asked for AV1/HEVC and is getting H.264, which must not go quiet just // because the stream is otherwise healthy. @@ -752,8 +753,8 @@ class RtmpOutputSender final : public IOutputSender { sender_.runtimeDetail = runtimeDetail_; sender_.audioChannels = activeAudioPresent_ ? activeAudioChannels_ : 0; sender_.audioSampleRate = activeAudioPresent_ ? activeAudioSampleRate_ : 0; - sender_.destinationHealth = "ok"; - sender_.lastResultCode = "ok"; + sender_.destinationHealth = hasWrittenVideo_ ? "ok" : "starting"; + sender_.lastResultCode = hasWrittenVideo_ ? "encoder-input-accepted" : "waiting-for-frame"; return snapshot(); } @@ -777,6 +778,7 @@ class RtmpOutputSender final : public IOutputSender { return snapshot(); } + hasWrittenVideo_ = true; sender_.status = "live"; // A live stream still carries the unsupported-codec notice: the operator // asked for AV1/HEVC and is getting H.264, which must not go quiet just @@ -784,12 +786,13 @@ class RtmpOutputSender final : public IOutputSender { sender_.warning = unsupportedCodecWarning_; sender_.runtimeDetail = runtimeDetail_; sender_.lastFrameNumber = frame->frameNumber; + // These counters prove local FFmpeg input acceptance, not destination receipt. ++sender_.framesSent; sender_.bytesSent += estimatedFrameBytes(sender_.bitrateMbps); sender_.audioChannels = activeAudioPresent_ ? activeAudioChannels_ : 0; sender_.audioSampleRate = activeAudioPresent_ ? activeAudioSampleRate_ : 0; sender_.destinationHealth = "ok"; - sender_.lastResultCode = "ok"; + sender_.lastResultCode = "encoder-input-accepted"; clearFfmpegRetryBackoff(); appendSendProof(frame, "sent"); return snapshot(); @@ -1643,6 +1646,7 @@ class RtmpOutputSender final : public IOutputSender { #endif void stopFfmpegProcess() { + hasWrittenVideo_ = false; #if defined(_WIN32) if (ffmpegStdin_) { CloseHandle(ffmpegStdin_); @@ -1830,6 +1834,7 @@ class RtmpOutputSender final : public IOutputSender { #endif OutputSender sender_; RtmpVideoFramePacer videoFramePacer_; + bool hasWrittenVideo_ = false; std::ofstream sendProof_; }; #endif diff --git a/native/src/modules/ZoomEngineProcess.cpp b/native/src/modules/ZoomEngineProcess.cpp index dffc746c..8e9fcc00 100644 --- a/native/src/modules/ZoomEngineProcess.cpp +++ b/native/src/modules/ZoomEngineProcess.cpp @@ -94,6 +94,10 @@ ZoomEngineProcessClient::~ZoomEngineProcessClient() { bool ZoomEngineProcessClient::start(const ZoomEngineProcessOptions& options) { stop(); lastError_.clear(); + if (options.cancelled && options.cancelled()) { + setError("Zoom join cancelled."); + return false; + } if (options.executablePath.empty()) { setError("Zoom engine executable path is empty."); return false; @@ -190,13 +194,18 @@ bool ZoomEngineProcessClient::start(const ZoomEngineProcessOptions& options) { processId_ = static_cast(pid); #endif - if (!connectIpc(options.connectTimeoutMs)) { + if (!connectIpc(options.connectTimeoutMs, options.cancelled)) { stop(); return false; } return true; } +void ZoomEngineProcessClient::terminate() { + closeIpc(); + stop(); +} + void ZoomEngineProcessClient::stop() { if (isConnected(parentToEngine_)) { (void)sendLine(buildZoomEngineQuitCommand()); @@ -288,7 +297,7 @@ std::optional ZoomEngineProcessClient::readEvent() { return event; } -bool ZoomEngineProcessClient::connectIpc(int timeoutMs) { +bool ZoomEngineProcessClient::connectIpc(int timeoutMs, const std::function& cancelled) { #if defined(_WIN32) const std::string pipeP2E = ipc_pipe_p2e(instanceToken_); const std::string pipeE2P = ipc_pipe_e2p(instanceToken_); @@ -298,6 +307,11 @@ bool ZoomEngineProcessClient::connectIpc(int timeoutMs) { #endif const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs); while (std::chrono::steady_clock::now() < deadline) { + if (cancelled && cancelled()) { + setError("Zoom join cancelled."); + closeIpc(); + return false; + } #if defined(_WIN32) if (!parentToEngine_) { if (WaitNamedPipeA(pipeP2E.c_str(), 100)) { diff --git a/native/src/modules/ZoomEngineProcess.h b/native/src/modules/ZoomEngineProcess.h index d6f58be2..21b4bfb2 100644 --- a/native/src/modules/ZoomEngineProcess.h +++ b/native/src/modules/ZoomEngineProcess.h @@ -3,6 +3,7 @@ #include "modules/ZoomEngineClient.h" #include +#include #include namespace corevideo::modules { @@ -15,6 +16,7 @@ struct ZoomEngineProcessOptions { // with another process on the shared "ZoomObsPlugin_" base name (chiefly the // OBS zoom plugin). Empty => the client generates one in start(). std::string instanceToken; + std::function cancelled; }; // Builds a process-unique IPC token (host pid + a monotonic spawn counter) so @@ -38,6 +40,9 @@ class ZoomEngineProcessClient { // connects to its pipe/socket IPC. This is not used by the default stub path. bool start(const ZoomEngineProcessOptions& options); virtual void stop(); + // Close IPC before process teardown: cancellation must not wait for a quit + // write to a wedged engine pipe. + void terminate(); [[nodiscard]] virtual bool running() const; [[nodiscard]] virtual std::string lastError() const; @@ -51,7 +56,7 @@ class ZoomEngineProcessClient { [[nodiscard]] const std::string& instanceToken() const { return instanceToken_; } private: - bool connectIpc(int timeoutMs); + bool connectIpc(int timeoutMs, const std::function& cancelled); void closeIpc(); void setError(std::string message); diff --git a/native/src/modules/ZoomEngineRuntime.cpp b/native/src/modules/ZoomEngineRuntime.cpp index 5338304a..b7ee8753 100644 --- a/native/src/modules/ZoomEngineRuntime.cpp +++ b/native/src/modules/ZoomEngineRuntime.cpp @@ -110,10 +110,12 @@ ZoomEngineRuntime::Config ZoomEngineRuntime::loadConfig() { } bool ZoomEngineRuntime::configured() const { + std::lock_guard lock(mutex_); return !config_.executablePath.empty(); } void ZoomEngineRuntime::applyJoinCredentialsFromPayload(const rpc::Json& payload) { + std::lock_guard lock(mutex_); config_ = loadConfig(); const auto payloadJwt = payload.getString("sdkJwt"); const auto payloadZak = payload.getString("userZak"); @@ -126,7 +128,6 @@ void ZoomEngineRuntime::applyJoinCredentialsFromPayload(const rpc::Json& payload config_.userZak = payloadZak; } if (!payloadJwt.empty() && initialized_) { - std::lock_guard lock(mutex_); if (process_ && process_->running()) { enqueueEngineSendLocked("leave", buildZoomEngineLeaveCommand()); } @@ -142,16 +143,37 @@ void ZoomEngineRuntime::applyJoinCredentialsFromPayload(const rpc::Json& payload } } -rpc::Json ZoomEngineRuntime::join(const rpc::Json& payload) { +rpc::Json ZoomEngineRuntime::join(const rpc::Json& payload, const std::function& cancelled) { if (!configured()) { return nullptr; } + if (cancelled && cancelled()) return nullptr; + bool restart; + { + std::lock_guard lock(mutex_); + restart = restartBeforeJoin_; + restartBeforeJoin_ = false; + if (restart) { + ++processGeneration_; + purgeQueuedEngineSendsLocked("join after leave"); + } + } + if (restart) { + // Rejoin uses a fresh SDK process. Old callbacks have no operation identity, + // so they must be retired before accepting results for the next meeting. + stopReader(); // no coreMutex or runtime mutex held while terminating/joining + std::lock_guard lock(mutex_); + process_.reset(); + initialized_ = false; + } + if (cancelled && cancelled()) return nullptr; applyJoinCredentialsFromPayload(payload); const auto meetingId = meetingIdFromJoinPayload(payload); if (meetingId.empty()) { std::lock_guard lock(mutex_); + if (cancelled && cancelled()) return nullptr; return rpc::Json::Object{ {"meetingState", "error"}, {"participants", rpc::Json::Array{}}, @@ -160,14 +182,17 @@ rpc::Json ZoomEngineRuntime::join(const rpc::Json& payload) { }; } - if (!ensureStarted()) { + if (!ensureStarted(cancelled)) { std::lock_guard lock(mutex_); + if (cancelled && cancelled()) return nullptr; return rawCaptureSnapshotLocked(); } bool waitForAuth = false; { std::lock_guard lock(mutex_); + if (cancelled && cancelled()) return nullptr; + acceptJoinEvents_ = true; if (!process_ || !process_->running()) { return rawCaptureSnapshotLocked(); } @@ -194,6 +219,7 @@ rpc::Json ZoomEngineRuntime::join(const rpc::Json& payload) { while (std::chrono::steady_clock::now() < authDeadline) { { std::lock_guard lock(mutex_); + if (cancelled && cancelled()) return nullptr; if (state_.sdkAuthenticated()) { authReady = true; break; @@ -206,6 +232,7 @@ rpc::Json ZoomEngineRuntime::join(const rpc::Json& payload) { } if (!authReady) { std::lock_guard lock(mutex_); + if (cancelled && cancelled()) return nullptr; state_.apply({ZoomEngineEventKind::Error, "error", "", "auth", "Timed out waiting for Zoom SDK authentication."}); return rawCaptureSnapshotLocked(); } @@ -213,6 +240,7 @@ rpc::Json ZoomEngineRuntime::join(const rpc::Json& payload) { { std::lock_guard lock(mutex_); + if (cancelled && cancelled()) return nullptr; ZoomEngineJoinCommand command; command.meetingId = meetingId; command.displayName = payload.getString("displayName", "CoreVideo Pro"); @@ -230,6 +258,7 @@ rpc::Json ZoomEngineRuntime::join(const rpc::Json& payload) { while (std::chrono::steady_clock::now() < deadline) { { std::lock_guard lock(mutex_); + if (cancelled && cancelled()) return nullptr; const auto snapshot = state_.snapshot(); if (snapshot.meetingState == "in-meeting" || snapshot.meetingState == "error") { return rawCaptureSnapshotLocked(); @@ -239,6 +268,7 @@ rpc::Json ZoomEngineRuntime::join(const rpc::Json& payload) { } std::lock_guard lock(mutex_); + if (cancelled && cancelled()) return nullptr; state_.apply({ZoomEngineEventKind::Error, "error", "", "join", "Timed out waiting for Zoom meeting join result."}); return rawCaptureSnapshotLocked(); } @@ -249,6 +279,8 @@ rpc::Json ZoomEngineRuntime::leave() { } std::lock_guard lock(mutex_); + acceptJoinEvents_ = false; + restartBeforeJoin_ = true; if (process_ && process_->running()) { enqueueEngineSendLocked("leave", buildZoomEngineLeaveCommand()); } @@ -562,7 +594,7 @@ std::vector ZoomEngineRuntime::latestDecodedVideoFrames(int64_t time return frames; } -bool ZoomEngineRuntime::ensureStarted() { +bool ZoomEngineRuntime::ensureStarted(const std::function& cancelled) { std::shared_ptr client; std::string executablePath; int connectTimeoutMs = 0; @@ -593,14 +625,15 @@ bool ZoomEngineRuntime::ensureStarted() { // spawn — the studio keeps compositing while the engine boots. Together with // the RPC server routing zoom-join around coreMutex, this closes the // "whole studio freezes for the length of every join" P0. - const bool started = client->start({executablePath, connectTimeoutMs}); + const bool started = client->start({executablePath, connectTimeoutMs, {}, cancelled}); - std::lock_guard lock(mutex_); - if (processGeneration_ != generationAtStart) { + std::unique_lock lock(mutex_); + if ((cancelled && cancelled()) || processGeneration_ != generationAtStart) { // Superseded mid-start (test process installed / another restart): discard // the freshly spawned process rather than clobbering the newer one. - client->stop(); - return process_ && process_->running(); + lock.unlock(); + client->terminate(); + return false; } if (!started) { state_.apply({ZoomEngineEventKind::Error, "error", "", "launch", client->lastError()}); @@ -626,27 +659,38 @@ void ZoomEngineRuntime::startReaderLocked() { void ZoomEngineRuntime::readerLoop() { while (true) { + std::shared_ptr process; + std::uint64_t generation; { std::lock_guard lock(mutex_); if (!readerRunning_ || !process_ || !process_->running()) { return; } + process = process_; + generation = processGeneration_; } - auto event = process_->readEvent(); + auto event = process->readEvent(); if (!event) { std::lock_guard lock(mutex_); - if (readerRunning_) { - state_.apply({ZoomEngineEventKind::Error, "error", "", "read", process_ ? process_->lastError() : "Zoom engine stopped."}); + if (readerRunning_ && generation == processGeneration_) { + state_.apply({ZoomEngineEventKind::Error, "error", "", "read", process->lastError()}); } return; } - applyEvent(*event); + applyEvent(*event, generation); } } -void ZoomEngineRuntime::applyEvent(const ZoomEngineEvent& event) { +void ZoomEngineRuntime::applyEvent(const ZoomEngineEvent& event, std::optional generation) { std::lock_guard lock(mutex_); + if (generation && *generation != processGeneration_) return; + if (event.kind == ZoomEngineEventKind::Joined && !acceptJoinEvents_) { + // An SDK callback may arrive after Leave was accepted. Keep the current + // snapshot left and reassert leave rather than reviving capture. + enqueueEngineSendLocked("leave", buildZoomEngineLeaveCommand()); + return; + } state_.apply(event); if (event.kind == ZoomEngineEventKind::Joined) { // Only auto-start raw media on join if the operator already enabled capture @@ -666,13 +710,13 @@ void ZoomEngineRuntime::applyEvent(const ZoomEngineEvent& event) { void ZoomEngineRuntime::applyEngineEventForTest(const ZoomEngineEvent& event) { applyEvent(event); } void ZoomEngineRuntime::stopReader() { + std::shared_ptr process; { std::lock_guard lock(mutex_); readerRunning_ = false; - if (process_) { - process_->stop(); - } + process = std::move(process_); } + if (process) process->terminate(); if (reader_.joinable()) { reader_.join(); } diff --git a/native/src/modules/ZoomEngineRuntime.h b/native/src/modules/ZoomEngineRuntime.h index 73a69285..fa7c51a9 100644 --- a/native/src/modules/ZoomEngineRuntime.h +++ b/native/src/modules/ZoomEngineRuntime.h @@ -28,7 +28,7 @@ class ZoomEngineRuntime { ZoomEngineRuntime& operator=(const ZoomEngineRuntime&) = delete; [[nodiscard]] bool configured() const; - [[nodiscard]] rpc::Json join(const rpc::Json& payload); + [[nodiscard]] rpc::Json join(const rpc::Json& payload, const std::function& cancelled = {}); [[nodiscard]] rpc::Json leave(); // Capture-off: stop raw media in the engine WITHOUT leaving the meeting. // Enqueues the engine `stop_media` command (engine command loop runs @@ -92,10 +92,10 @@ class ZoomEngineRuntime { // Ensures a running engine process. Takes mutex_ ITSELF, in phases: the // blocking CreateProcess + IPC connect runs UNLOCKED (a generation guard // discards a superseded spawn) so per-tick frame polls never stall behind it. - [[nodiscard]] bool ensureStarted(); + [[nodiscard]] bool ensureStarted(const std::function& cancelled); void startReaderLocked(); void readerLoop(); - void applyEvent(const ZoomEngineEvent& event); + void applyEvent(const ZoomEngineEvent& event, std::optional generation = {}); void stopReader(); void startSenderLocked(); void senderLoop(); @@ -128,6 +128,8 @@ class ZoomEngineRuntime { std::thread reader_; bool readerRunning_ = false; bool initialized_ = false; + bool acceptJoinEvents_ = true; + bool restartBeforeJoin_ = false; bool mediaStarted_ = false; // Monotonic engine-process instance counter (guarded by mutex_). Bumped every // time a new process client is installed; queued sends carry the generation diff --git a/native/src/rpc/CommandMailbox.h b/native/src/rpc/CommandMailbox.h new file mode 100644 index 00000000..92d66820 --- /dev/null +++ b/native/src/rpc/CommandMailbox.h @@ -0,0 +1,84 @@ +#pragma once +#include "rpc/Json.h" +#include +#include +#include +#include + +namespace corevideo::rpc { +// Caller serializes access. Limits account for wire bytes; parsed storage is proportional. +// Coalescing is opt-in and never crosses a command boundary or drops embedded actions. +class CommandMailbox { + public: + struct Entry { + Json request; + std::size_t bytes; + std::chrono::steady_clock::time_point enqueuedAt; + }; + enum class Result { accepted, superseded, overloaded }; + explicit CommandMailbox(std::size_t capacity = 128, std::size_t byteLimit = 8 * 1024 * 1024, + std::size_t reserved = 8) + : capacity_(capacity), byteLimit_(byteLimit), reserved_(reserved) {} + static bool replaceable(const Json& request) { + const auto type = request.getString("type"); + if (type != "media-core-sync" && type != "native-media-core-sync") return false; + const auto* optIn = request.get("replaceableFullState"); + if (!optIn || !optIn->asBool()) return false; + const auto* commands = request.get("commands"); + if (!commands || !commands->isArray()) return false; + for (const auto& command : commands->asArray()) { + const auto name = command.getString("type"); + if (name != "load-scene-graph" && name != "set-preview-scene" && + name != "sync-participant-audio-mix" && name != "sync-audio-routing-matrix" && + name != "set-multiview-layout") return false; + } + return true; + } + static bool urgent(const Json& request) { + const auto type = request.getString("type"); + if (type == "zoom-leave" || type == "zoom-cancel" || type == "zoom-stop-capture") return true; + const auto* commands = request.get("commands"); + if (commands && commands->isArray()) { + for (const auto& command : commands->asArray()) { + const auto name = command.getString("type"); + if (name == "stop-recording-session" || name == "stop-encoder-session") return true; + } + } + return type == "stop-recording-session" || type == "stop-encoder-session"; + } + Result push(Entry entry, std::optional& superseded) { + superseded.reset(); + const bool replaceTail = !queue_.empty() && replaceable(entry.request) && + replaceable(queue_.back().request) && + entry.request.getString("type") == queue_.back().request.getString("type") && + entry.request.getString("coalescingKey") == queue_.back().request.getString("coalescingKey") && + entry.request.getString("coalescingKey") != ""; + const auto replacedBytes = replaceTail ? queue_.back().bytes : 0; + const auto limit = urgent(entry.request) ? capacity_ : capacity_ - (std::min)(reserved_, capacity_); + // Reserve one eighth of bytes for cancellation/stop as well as queue entries. + const auto bytesLimit = urgent(entry.request) ? byteLimit_ : byteLimit_ - byteLimit_ / 8; + if (entry.bytes > bytesLimit || bytes_ - replacedBytes > bytesLimit - entry.bytes || + queue_.size() - (replaceTail ? 1 : 0) >= limit) return Result::overloaded; + if (replaceTail) { + superseded = queue_.back().request; + bytes_ -= queue_.back().bytes; + queue_.pop_back(); + } + bytes_ += entry.bytes; + queue_.push_back(std::move(entry)); + return replaceTail ? Result::superseded : Result::accepted; + } + Entry pop() { + auto entry = std::move(queue_.front()); + bytes_ -= entry.bytes; + queue_.pop_front(); + return entry; + } + bool empty() const { return queue_.empty(); } + std::size_t size() const { return queue_.size(); } + std::size_t bytes() const { return bytes_; } + private: + std::deque queue_; + std::size_t capacity_, byteLimit_, reserved_, bytes_ = 0; +}; +} // namespace corevideo::rpc diff --git a/native/src/rpc/JsonRpcServer.cpp b/native/src/rpc/JsonRpcServer.cpp index 0abe5b91..f7028806 100644 --- a/native/src/rpc/JsonRpcServer.cpp +++ b/native/src/rpc/JsonRpcServer.cpp @@ -1,6 +1,9 @@ #include "rpc/JsonRpcServer.h" #include "core/LockHoldGuardrail.h" +#include "rpc/CommandMailbox.h" +#include "contracts/Lifecycle.h" +#include #include #include @@ -46,7 +49,11 @@ Json::Array commandBatch(const Json& request) { } // namespace -JsonRpcServer::JsonRpcServer(core::MediaCore& mediaCore) : mediaCore_(mediaCore) {} +JsonRpcServer::JsonRpcServer(core::MediaCore& mediaCore, JoinHandler joinHandler) + : mediaCore_(mediaCore), joinHandler_(std::move(joinHandler)) { + std::random_device random; + processEpoch_ = std::to_string(random()) + "-" + std::to_string(random()); +} Json JsonRpcServer::handshake() const { return Json::Object{ @@ -54,18 +61,24 @@ Json JsonRpcServer::handshake() const { {"ok", true}, {"type", "handshake"}, {"profile", mediaCore_.profile()}, + {"protocolVersion", contracts::toJson(contracts::ProtocolVersion{1, 0})}, + {"processEpoch", processEpoch_}, }; } Json JsonRpcServer::handle(const Json& request) { const Json id = requestId(request); const std::string type = request.getString("type"); + if (const auto* version = request.get("protocolVersion"); version && !contracts::validateProtocolVersion(*version)) { + return failure(id, "incompatible-protocol", "Unsupported protocol version; this core supports major 1."); + } if (type.empty()) { return failure(id, "protocol-error", "Request is missing a type field."); } if (hasType(request, "handshake")) { - return success(id, Json::Object{{"type", "handshake"}, {"profile", mediaCore_.profile()}}); + return success(id, Json::Object{{"type", "handshake"}, {"profile", mediaCore_.profile()}, + {"protocolVersion", contracts::toJson(contracts::ProtocolVersion{1, 0})}, {"processEpoch", processEpoch_}}); } if (hasType(request, "ping")) { @@ -99,7 +112,7 @@ Json JsonRpcServer::handle(const Json& request) { }); } - if (hasType(request, "zoom-leave")) { + if (hasType(request, "zoom-leave") || hasType(request, "zoom-cancel")) { return success(id, Json::Object{ {"type", "zoom-leave"}, {"snapshot", mediaCore_.leaveZoom()}, @@ -395,35 +408,45 @@ void JsonRpcServer::run(std::istream& input, std::ostream& output) { std::mutex inMx; std::condition_variable inCv; - // INSTRUMENTATION: stamp each request as the reader enqueues it so the command - // loop can report queue-wait (dequeue - enqueue) separately from handle duration. - std::deque> inQ; + CommandMailbox inQ; std::atomic inputClosed{false}; - - // Priority relief for control commands under a command backlog. The C# bridge - // re-emits the FULL, level-triggered production state every media-core-sync - // (scene routes, overlays, audio mix AND control commands like start/stop- - // recording), so when the command loop falls behind (soak: gaming + agent - // builds -> ~174s backlog, stop-recording queued >3min), every queued sync - // except the newest is stale. `pendingSyncs` counts syncs waiting in inQ so the - // loop can skip the expensive applyCommands/render pass for a sync that already - // has a newer one behind it — the newest sync carries the current intent — and - // reach stop-recording promptly instead of replaying minutes of stale state. - std::atomic pendingSyncs{0}; - const auto isSyncLine = [](const std::string& line) { - return line.find("media-core-sync") != std::string::npos; - }; - + // The reader parses once, rejects overload explicitly, and never guesses command + // types from text inside a payload. getline's individual-line limit is handled + // below before JSON parsing; queued wire storage has a separate byte bound. std::thread reader([&] { std::string line; - while (std::getline(input, line)) { - const bool sync = isSyncLine(line); + // A 1 MiB VST state blob grows under base64; leave room for its envelope. + constexpr std::size_t maxLineBytes = 4 * 1024 * 1024; + while (input.good()) { + line.clear(); + bool oversized = false; + char ch; + while (input.get(ch) && ch != '\n') { + if (line.size() < maxLineBytes) line.push_back(ch); + else oversized = true; + } + if (oversized) { + enqueueResponse(failure(Json("unknown"), "request-too-large", "Control request exceeds 4 MiB.").stringify()); + continue; + } + if (line.empty()) continue; + std::string error; + auto request = Json::parse(line, &error); + if (!request) { + enqueueResponse(failure(Json("unknown"), "protocol-error", error).stringify()); + continue; + } + std::optional superseded; + CommandMailbox::Result result; { std::lock_guard lock(inMx); - inQ.emplace_back(std::move(line), std::chrono::steady_clock::now()); + result = inQ.push({*request, line.size(), std::chrono::steady_clock::now()}, superseded); } - if (sync) { - pendingSyncs.fetch_add(1, std::memory_order_relaxed); + if (result == CommandMailbox::Result::overloaded) { + enqueueResponse(failure(requestId(*request), "control-overloaded", "Command mailbox is full; request was not accepted.").stringify()); + } else if (superseded) { + enqueueResponse(success(requestId(*superseded), Json::Object{ + {"type", superseded->getString("type")}, {"superseded", true}}).stringify()); } inCv.notify_one(); } @@ -801,8 +824,66 @@ void JsonRpcServer::run(std::istream& input, std::ostream& output) { } }); + // One lifecycle worker owns potentially blocking join/auth. The command loop + // keeps servicing Take/Stop/Leave; pending work is bounded to one join. + std::mutex joinMx; + std::condition_variable joinCv; + std::optional> pendingJoin; + std::atomic joinGeneration{0}; + bool joinWorkerStopping = false; + bool joinBusy = false; + std::thread joinWorker([&] { + for (;;) { + Json request; + std::uint64_t generation; + { + std::unique_lock lock(joinMx); + joinCv.wait(lock, [&] { return joinWorkerStopping || pendingJoin.has_value(); }); + if (joinWorkerStopping && !pendingJoin) return; + request = std::move(pendingJoin->first); + generation = pendingJoin->second; + pendingJoin.reset(); + } + const auto cancelled = [&] { return joinGeneration.load() != generation; }; + const auto operationId = "zoom-join-" + std::to_string(generation); + const bool async = request.get("asyncOperation") && request.get("asyncOperation")->asBool(); + Json response; + try { + const auto snapshot = joinHandler_ ? joinHandler_(*request.get("payload"), cancelled) : + mediaCore_.joinZoom(*request.get("payload"), cancelled); + if (cancelled()) { + response = failure(requestId(request), "operation-cancelled", "Zoom join was cancelled."); + } else { + auto operation = contracts::OperationStatus{processEpoch_, operationId, + snapshot.getString("meetingState") == "in_meeting" ? "completed" : "failed", {}}; + response = success(requestId(request), Json::Object{{"type", "zoom-join"}, + {"snapshot", snapshot}, {"operation", contracts::toJson(operation)}}); + } + } catch (const std::exception& error) { + response = failure(requestId(request), "zoom-join-failed", error.what()); + } catch (...) { + response = failure(requestId(request), "zoom-join-failed", "Zoom lifecycle worker failed."); + } + // Serialize final publication with Leave/cancel invalidation. A completion + // cannot pass its generation check and then overtake an accepted Leave. + std::lock_guard completionLock(joinMx); + if (cancelled()) response = failure(requestId(request), "operation-cancelled", "Zoom join was cancelled."); + if (async) { + const auto* ok = response.get("ok"); + const auto* snapshot = response.get("snapshot"); + const std::string state = cancelled() ? "cancelled" : + (ok && ok->asBool() && snapshot && snapshot->getString("meetingState") == "in_meeting" ? "completed" : "failed"); + enqueueResponse(Json(Json::Object{{"type", "operation-completed"}, + {"operation", contracts::toJson(contracts::OperationStatus{processEpoch_, operationId, state, {}})}, + {"result", response}}).stringify()); + } else { + enqueueResponse(response.stringify()); + } + joinBusy = false; + } + }); + auto lastPump = std::chrono::steady_clock::now(); - long long coalescedSyncs = 0; for (;;) { { std::unique_lock lock(inMx); @@ -815,80 +896,46 @@ void JsonRpcServer::run(std::istream& input, std::ostream& output) { // Drain ALL queued commands before rendering so a burst (e.g. the Zoom join // sequence) is serviced immediately and is never paced by the display tick. for (;;) { - std::string line; + std::optional request; Stamp enqueuedAt; { std::lock_guard lock(inMx); - if (inQ.empty()) { - break; - } - line = std::move(inQ.front().first); - enqueuedAt = inQ.front().second; - inQ.pop_front(); + if (inQ.empty()) break; + auto entry = inQ.pop(); + request = std::move(entry.request); + enqueuedAt = entry.enqueuedAt; } - if (line.empty()) { - continue; - } - // Balance the pendingSyncs counter using the SAME cheap classification the - // reader used to increment it. `syncsQueuedAfter` is how many newer syncs - // are still waiting behind this one. - const bool wasSync = isSyncLine(line); - const long long syncsQueuedAfter = - wasSync ? (pendingSyncs.fetch_sub(1, std::memory_order_relaxed) - 1) : 0; - // Queue-wait: how long this request sat in inQ before the command loop got to - // it (i.e. the loop was busy handling earlier commands or pumping frames). const auto dequeuedAt = std::chrono::steady_clock::now(); const auto queueWaitMs = std::chrono::duration_cast(dequeuedAt - enqueuedAt).count(); - std::string error; - auto request = Json::parse(line, &error); - if (!request) { - // Ungated: a request that failed to parse (e.g. a truncated/split large line) - // is answered with id="unknown", so the bridge's real request id never matches - // and it times out. Surface the length + error to catch line-protocol breakage. - std::fprintf(stderr, "[parse-dbg] FAILED len=%zu err='%s' head='%.60s'\n", - line.size(), error.c_str(), line.c_str()); - enqueueResponse(failure(Json("unknown"), "protocol-error", error).stringify()); - } else { + { const std::string reqType = request->getString("type"); - const bool syncRequest = reqType == "media-core-sync" || reqType == "native-media-core-sync" || - request->get("commands") != nullptr; - if (wasSync && syncRequest && syncsQueuedAfter > 0) { - // Coalesce: a newer full-state sync is already queued, so this batch is - // superseded. Answer with the current snapshot (so the bridge's waiter - // still resolves — never a timeout) but skip the expensive apply/render - // pass. This is the backlog-relief "priority lane": the newest sync, - // which carries level-triggered control commands (stop-recording), is - // reached immediately instead of behind minutes of stale syncs. - Json snapshot; - { - std::lock_guard lock(coreMutex); - core::ScopedLockHoldTimer holdGuard("cmd.coalesced-sync", - core::LockHoldGuardrail::kCommandHandleBudgetUs); - snapshot = mediaCore_.sessionState(); - } - const std::string syncType = - reqType == "native-media-core-sync" ? "native-media-core-sync" : "media-core-sync"; - enqueueResponse( - success(requestId(*request), Json::Object{{"type", syncType}, {"snapshot", snapshot}}).stringify()); - if (++coalescedSyncs % 64 == 1) { - std::fprintf(stderr, "[cmd] coalesced %lld stale media-core-sync batch(es) (backlog relief)\n", - static_cast(coalescedSyncs)); - } - continue; - } std::string responseStr; std::chrono::steady_clock::time_point h0, h1; - if (reqType == "zoom-join" && mediaCore_.zoomEngineConfigured()) { - // The real-engine join blocks on process spawn + SDK auth + join - // handshake (observed 6.4s) and MediaCore::joinZoom is a PURE - // passthrough to ZoomEngineRuntime (its own lock discipline, no - // MediaCore state) — so run it WITHOUT coreMutex. Previously this - // single command froze the render thread — and with it the whole - // studio (program/preview/multiview) — for the entire join. - h0 = std::chrono::steady_clock::now(); - responseStr = handle(*request).stringify(); - h1 = std::chrono::steady_clock::now(); + if ((reqType == "zoom-leave" || reqType == "zoom-cancel") && + (!request->get("protocolVersion") || contracts::validateProtocolVersion(*request->get("protocolVersion")))) { + std::lock_guard lock(joinMx); + ++joinGeneration; // invalidate auth/spawn waits before applying Leave + } + if (reqType == "zoom-join" && (joinHandler_ || mediaCore_.zoomEngineConfigured()) && + request->get("payload") && request->get("payload")->isObject() && + (!request->get("protocolVersion") || contracts::validateProtocolVersion(*request->get("protocolVersion")))) { + std::lock_guard lock(joinMx); + if (joinBusy) { + enqueueResponse(failure(requestId(*request), "operation-in-progress", + "A Zoom join is already in progress; cancel or leave before retrying.").stringify()); + } else { + joinBusy = true; + const auto generation = ++joinGeneration; + if (request->get("asyncOperation") && request->get("asyncOperation")->asBool()) { + enqueueResponse(success(requestId(*request), Json::Object{{"type", "zoom-join"}, + {"operation", contracts::toJson(contracts::OperationStatus{processEpoch_, + "zoom-join-" + std::to_string(generation), "accepted", {}})}}).stringify()); + } + pendingJoin = std::make_pair(*request, generation); + joinCv.notify_one(); + } + continue; } else { std::lock_guard lock(coreMutex); // Increment 6 guardrail: sanctioned long-hold site — command-carrying @@ -957,6 +1004,13 @@ void JsonRpcServer::run(std::istream& input, std::ostream& output) { } } + { + std::lock_guard lock(joinMx); + ++joinGeneration; + joinWorkerStopping = true; + } + joinCv.notify_one(); + joinWorker.join(); // waits only for cancellation-aware spawn/auth teardown, never coreMutex stopping.store(true); outCv.notify_one(); if (renderThread.joinable()) { diff --git a/native/src/rpc/JsonRpcServer.h b/native/src/rpc/JsonRpcServer.h index 76fae070..0075df4c 100644 --- a/native/src/rpc/JsonRpcServer.h +++ b/native/src/rpc/JsonRpcServer.h @@ -9,7 +9,9 @@ namespace corevideo::rpc { class JsonRpcServer { public: - explicit JsonRpcServer(core::MediaCore& mediaCore); + using JoinHandler = std::function&)>; + // Injectable lifecycle boundary for deterministic delayed-auth/cancellation tests. + explicit JsonRpcServer(core::MediaCore& mediaCore, JoinHandler joinHandler = {}); [[nodiscard]] Json handshake() const; [[nodiscard]] Json handle(const Json& request); @@ -21,6 +23,8 @@ class JsonRpcServer { void flushFrameEvents(std::ostream& output); core::MediaCore& mediaCore_; + std::string processEpoch_; + JoinHandler joinHandler_; }; } // namespace corevideo::rpc diff --git a/native/tests/AsyncEncoderSinkTest.cpp b/native/tests/AsyncEncoderSinkTest.cpp index 948a8038..d11513a6 100644 --- a/native/tests/AsyncEncoderSinkTest.cpp +++ b/native/tests/AsyncEncoderSinkTest.cpp @@ -40,6 +40,11 @@ class ControllableEncoder final : public IEncoderSink { std::atomic lastIsoVideoTimelineTimestamp100ns{0}; std::atomic lastIsoTimelineTimestamp100ns{0}; std::atomic stopCount{0}; + std::atomic stopEntered{false}; + std::atomic throwOnStart{false}; + std::atomic throwOnSubmit{false}; + std::atomic throwOnStop{false}; + std::atomic reportWriteFailure{false}; ~ControllableEncoder() override { destroyed->store(true); } @@ -50,14 +55,17 @@ class ControllableEncoder final : public IEncoderSink { OutputSession start(const std::vector& destinations, const std::vector& /*isoParticipantIds*/) override { + if (throwOnStart.load()) throw std::runtime_error("writer open failed"); std::lock_guard lock(mutex_); session_.active = true; + session_.recordingError.clear(); session_.destinations = destinations; session_.recordingStatus = "recording"; return session_; } void submit(const ProgramFrame& frame) override { + if (throwOnSubmit.load()) throw std::runtime_error("disk write failed"); submitEntered.store(true); while (blockSubmit->load()) { std::this_thread::sleep_for(std::chrono::milliseconds(1)); @@ -66,6 +74,10 @@ class ControllableEncoder final : public IEncoderSink { lastProgramTimelineTimestamp100ns.store(frame.timelineTimestamp100ns); const int count = ++submitCount; std::lock_guard lock(mutex_); + if (reportWriteFailure.load()) { + session_.recordingError = "reported disk failure"; + return; + } session_.encodedFrameCount = count; session_.recordingVideoFrameCount = count; session_.recordingLastFrameNumber = frame.frameNumber; @@ -104,6 +116,8 @@ class ControllableEncoder final : public IEncoderSink { } void stopRecording() override { + stopEntered.store(true); + if (throwOnStop.load()) throw std::runtime_error("finalize failed"); while (blockStop->load()) { std::this_thread::sleep_for(std::chrono::milliseconds(1)); } @@ -189,7 +203,9 @@ TEST(AsyncEncoderSink, PassesFramesAndAudioThroughInOrderWhenNotOverloaded) { request.sessionId = "async-show"; sink.configureRecording(request); const auto started = sink.start({"recording"}, {}); - EXPECT_TRUE(started.active); + EXPECT_FALSE(started.active); + ASSERT_TRUE(started.lifecycle); + EXPECT_EQ(started.lifecycle->state, "starting"); for (int i = 1; i <= 5; ++i) { sink.submit(videoFrame(i)); @@ -384,3 +400,168 @@ TEST(AsyncEncoderSink, TeardownIsBoundedWhenWriterIsStuck) { } EXPECT_TRUE(destroyed->load()); } + + +TEST(AsyncEncoderSink, LifecycleRequiresWriterProgressAndActualFinalizeCompletion) { + auto inner = std::make_unique(); + auto* raw = inner.get(); + AsyncEncoderSink sink(std::move(inner)); + sink.start({"recording"}, {}); + ASSERT_TRUE(sink.drainForTest(std::chrono::seconds(2))); + ASSERT_TRUE(sink.session().lifecycle); + EXPECT_EQ(sink.session().lifecycle->state, "starting"); + EXPECT_FALSE(sink.session().active); + sink.submit(videoFrame(1)); + ASSERT_TRUE(sink.drainForTest(std::chrono::seconds(2))); + EXPECT_EQ(sink.session().lifecycle->state, "live"); + EXPECT_TRUE(sink.session().active); + raw->blockStop->store(true); + sink.stopRecording(); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); + while (!raw->stopEntered.load() && std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + EXPECT_TRUE(raw->stopEntered.load()); + EXPECT_EQ(sink.session().lifecycle->state, "finalizing"); + EXPECT_FALSE(sink.session().lifecycle->finalized); + EXPECT_FALSE(sink.session().lifecycle->desiredActive); + raw->blockStop->store(false); + ASSERT_TRUE(sink.drainForTest(std::chrono::seconds(2))); + EXPECT_EQ(sink.session().lifecycle->state, "completed"); + EXPECT_TRUE(sink.session().lifecycle->finalized); +} + +TEST(AsyncEncoderSink, OldFinalizeCannotCompleteOrReactivateNewGeneration) { + auto inner = std::make_unique(); + auto* raw = inner.get(); + AsyncEncoderSink sink(std::move(inner)); + const auto first = sink.start({"recording"}, {}); + sink.submit(videoFrame(1)); + ASSERT_TRUE(sink.drainForTest(std::chrono::seconds(2))); + raw->blockStop->store(true); + sink.stopRecording(); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); + while (!raw->stopEntered.load() && std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + const auto second = sink.start({"recording"}, {}); + EXPECT_NE(first.lifecycle->sessionId, second.lifecycle->sessionId); + raw->blockStop->store(false); + ASSERT_TRUE(sink.drainForTest(std::chrono::seconds(2))); + EXPECT_EQ(sink.session().lifecycle->sessionId, second.lifecycle->sessionId); + EXPECT_EQ(sink.session().lifecycle->state, "starting"); + EXPECT_FALSE(sink.session().lifecycle->finalized); + EXPECT_TRUE(sink.session().lifecycle->desiredActive); + EXPECT_FALSE(sink.session().active); +} + +TEST(AsyncEncoderSink, WriterExceptionsReportFailureAndNextGenerationCanRecover) { + auto inner = std::make_unique(); + auto* raw = inner.get(); + raw->throwOnStart.store(true); + AsyncEncoderSink sink(std::move(inner)); + sink.start({"recording"}, {}); + ASSERT_TRUE(sink.drainForTest(std::chrono::seconds(2))); + EXPECT_EQ(sink.session().lifecycle->state, "failed"); + EXPECT_EQ(sink.session().lifecycle->error, "writer open failed"); + EXPECT_FALSE(sink.session().active); + raw->throwOnStart.store(false); + sink.start({"recording"}, {}); + sink.submit(videoFrame(1)); + ASSERT_TRUE(sink.drainForTest(std::chrono::seconds(2))); + EXPECT_EQ(sink.session().lifecycle->state, "live"); + raw->throwOnSubmit.store(true); + sink.submit(videoFrame(2)); + ASSERT_TRUE(sink.drainForTest(std::chrono::seconds(2))); + EXPECT_EQ(sink.session().lifecycle->state, "failed"); + EXPECT_EQ(sink.session().lifecycle->error, "disk write failed"); +} + +TEST(AsyncEncoderSink, FailedFinalizeNeverReportsCompletedOrFinalized) { + auto inner = std::make_unique(); + auto* raw = inner.get(); + AsyncEncoderSink sink(std::move(inner)); + sink.start({"recording"}, {}); + sink.submit(videoFrame(1)); + ASSERT_TRUE(sink.drainForTest(std::chrono::seconds(2))); + raw->throwOnStop.store(true); + sink.stopRecording(); + ASSERT_TRUE(sink.drainForTest(std::chrono::seconds(2))); + EXPECT_EQ(sink.session().lifecycle->state, "failed"); + EXPECT_EQ(sink.session().lifecycle->error, "finalize failed"); + EXPECT_FALSE(sink.session().lifecycle->finalized); + EXPECT_FALSE(sink.session().active); +} + + +TEST(AsyncEncoderSink, StopWithoutWrittenMediaNeverClaimsCompletedRecording) { + AsyncEncoderSink sink(std::make_unique()); + sink.start({"recording"}, {}); + sink.stopRecording(); + ASSERT_TRUE(sink.drainForTest(std::chrono::seconds(2))); + ASSERT_TRUE(sink.session().lifecycle); + EXPECT_EQ(sink.session().lifecycle->state, "failed"); + EXPECT_FALSE(sink.session().lifecycle->finalized); + EXPECT_FALSE(sink.session().active); +} + + +TEST(AsyncEncoderSink, ConfigureDoesNotCarryPreviousWriterErrorIntoNewTake) { + auto inner = std::make_unique(); + auto* raw = inner.get(); + AsyncEncoderSink sink(std::move(inner)); + raw->reportWriteFailure.store(true); + sink.start({"recording"}, {}); + sink.submit(videoFrame(1)); + ASSERT_TRUE(sink.drainForTest(std::chrono::seconds(2))); + EXPECT_EQ(sink.session().lifecycle->state, "failed"); + raw->reportWriteFailure.store(false); + RecordingSessionRequest next; + next.sessionId = "retry-take"; + sink.configureRecording(next); + sink.start({"recording"}, {}); + sink.submit(videoFrame(2)); + ASSERT_TRUE(sink.drainForTest(std::chrono::seconds(2))); + EXPECT_EQ(sink.session().lifecycle->state, "live"); + EXPECT_FALSE(sink.session().lifecycle->error.has_value()); +} + + +TEST(AsyncEncoderSink, RepeatedStopDuringFinalizeAndAfterCompletionIsIdempotent) { + auto inner = std::make_unique(); + auto* raw = inner.get(); + AsyncEncoderSink sink(std::move(inner)); + sink.start({"recording"}, {}); + sink.submit(videoFrame(1)); + ASSERT_TRUE(sink.drainForTest(std::chrono::seconds(2))); + raw->blockStop->store(true); + sink.stopRecording(); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); + while (!raw->stopEntered.load() && std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + EXPECT_TRUE(raw->stopEntered.load()); + for (int i = 0; i < 1000; ++i) sink.stopRecording(); + EXPECT_EQ(sink.session().lifecycle->state, "finalizing"); + raw->blockStop->store(false); + ASSERT_TRUE(sink.drainForTest(std::chrono::seconds(2))); + EXPECT_EQ(raw->stopCount.load(), 1); + EXPECT_EQ(sink.session().lifecycle->state, "completed"); + sink.stopRecording(); + EXPECT_EQ(sink.session().lifecycle->state, "completed"); + ASSERT_TRUE(sink.drainForTest(std::chrono::seconds(2))); + EXPECT_EQ(raw->stopCount.load(), 1); +} + +TEST(AsyncEncoderSink, FailedWriterStillReceivesExactlyOneCleanupStop) { + auto inner = std::make_unique(); + auto* raw = inner.get(); + AsyncEncoderSink sink(std::move(inner)); + raw->reportWriteFailure.store(true); + sink.start({"recording"}, {}); + sink.submit(videoFrame(1)); + ASSERT_TRUE(sink.drainForTest(std::chrono::seconds(2))); + EXPECT_EQ(sink.session().lifecycle->state, "failed"); + for (int i = 0; i < 50; ++i) sink.stopRecording(); + ASSERT_TRUE(sink.drainForTest(std::chrono::seconds(2))); + EXPECT_EQ(raw->stopCount.load(), 1); + EXPECT_EQ(sink.session().lifecycle->state, "failed"); + EXPECT_FALSE(sink.session().lifecycle->desiredActive); +} diff --git a/native/tests/JsonRpcServerTest.cpp b/native/tests/JsonRpcServerTest.cpp index a580ec3b..c76ed3b9 100644 --- a/native/tests/JsonRpcServerTest.cpp +++ b/native/tests/JsonRpcServerTest.cpp @@ -2,6 +2,8 @@ #include "core/MediaCore.h" #include "rpc/Json.h" #include "rpc/JsonRpcServer.h" +#include "rpc/CommandMailbox.h" +#include #include @@ -532,3 +534,117 @@ TEST(JsonRpcServer, CoalescedSyncAppliesNewestControlState) { ASSERT_NE(active, nullptr); EXPECT_TRUE(active->asBool()); } + +TEST(CommandMailbox, BoundsBytesAndReservesStopCapacity) { + using namespace corevideo::rpc; + CommandMailbox mailbox(4, 1024, 1); + std::optional replaced; + const auto push = [&](Json request, std::size_t bytes = 100) { + return mailbox.push({std::move(request), bytes, std::chrono::steady_clock::now()}, replaced); + }; + for (int i = 0; i < 3; ++i) EXPECT_EQ(push(Json::Object{{"type", "ping"}}), CommandMailbox::Result::accepted); + EXPECT_EQ(push(Json::Object{{"type", "ping"}}), CommandMailbox::Result::overloaded); + EXPECT_EQ(push(Json::Object{{"type", "zoom-leave"}}), CommandMailbox::Result::accepted); + EXPECT_EQ(mailbox.size(), 4u); + EXPECT_EQ(mailbox.bytes(), 400u); + EXPECT_EQ(push(Json::Object{{"type", "zoom-leave"}}), CommandMailbox::Result::overloaded); + mailbox.pop(); mailbox.pop(); mailbox.pop(); mailbox.pop(); + EXPECT_EQ(mailbox.bytes(), 0u); + EXPECT_EQ(push(Json::Object{{"type", "ping"}}, 1000), CommandMailbox::Result::overloaded); + EXPECT_EQ(push(Json::Object{{"type", "zoom-leave"}}, 1000), CommandMailbox::Result::accepted); +} + +TEST(CommandMailbox, CoalescingRequiresExplicitStateAndNeverDropsEmbeddedActions) { + using namespace corevideo::rpc; + CommandMailbox mailbox; + std::optional replaced; + auto state = [](const char* id, const char* command = "load-scene-graph") -> Json { + return Json::Object{{"id", id}, {"type", "media-core-sync"}, {"replaceableFullState", true}, + {"coalescingKey", "production"}, {"commands", Json::Array{Json::Object{{"type", command}}}}}; + }; + const auto push = [&](Json request) { return mailbox.push({request, 100, std::chrono::steady_clock::now()}, replaced); }; + EXPECT_EQ(push(state("old")), CommandMailbox::Result::accepted); + EXPECT_EQ(push(state("new")), CommandMailbox::Result::superseded); + ASSERT_TRUE(replaced); + EXPECT_EQ(replaced->getString("id"), "old"); + EXPECT_EQ(mailbox.size(), 1u); + EXPECT_EQ(mailbox.bytes(), 100u); + EXPECT_EQ(push(state("take", "start-program-output")), CommandMailbox::Result::accepted); + EXPECT_EQ(push(state("after")), CommandMailbox::Result::accepted); + EXPECT_EQ(mailbox.size(), 3u); + EXPECT_FALSE(CommandMailbox::replaceable(Json::Object{{"type", "ping"}, {"label", "media-core-sync"}})); + EXPECT_FALSE(CommandMailbox::replaceable(Json::Object{{"type", "media-core-sync"}, {"commands", Json::Array{}}})); + EXPECT_FALSE(CommandMailbox::replaceable(state("unknown", "future-command"))); +} + +TEST(JsonRpcServer, ProtocolVersionIsAdditiveAndRejectsUnsupportedMajor) { + using namespace corevideo::rpc; + corevideo::core::MediaCore core; + JsonRpcServer server(core); + const auto handshake = server.handshake(); + ASSERT_NE(handshake.get("protocolVersion"), nullptr); + EXPECT_EQ(handshake.get("protocolVersion")->getNumber("major"), 1); + EXPECT_FALSE(handshake.getString("processEpoch").empty()); + EXPECT_TRUE(server.handle(Json::Object{{"id", "old"}, {"type", "ping"}}).get("ok")->asBool()); + const auto response = server.handle(Json::Object{{"id", "new"}, {"type", "ping"}, + {"protocolVersion", Json::Object{{"major", 2}, {"minor", 0}}}}); + EXPECT_FALSE(response.get("ok")->asBool()); + EXPECT_NE(response.stringify().find("incompatible-protocol"), std::string::npos); +} + +TEST(JsonRpcServer, DelayedJoinDoesNotBlockCommandsAndCancellationAnswersOriginalId) { + using namespace corevideo::rpc; + corevideo::core::MediaCore core; + JsonRpcServer server(core, [](const Json&, const std::function& cancelled) -> Json { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); + while (!cancelled() && std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + return Json::Object{{"meetingState", "in_meeting"}}; // late success must be discarded + }); + std::istringstream input( + "{\"id\":\"join\",\"type\":\"zoom-join\",\"payload\":{}}\n" + "{\"id\":\"ping\",\"type\":\"ping\"}\n" + "{\"id\":\"leave\",\"type\":\"zoom-leave\"}\n"); + std::ostringstream output; + const auto start = std::chrono::steady_clock::now(); + server.run(input, output); + EXPECT_LT(std::chrono::steady_clock::now() - start, std::chrono::seconds(2)); + const auto text = output.str(); + EXPECT_NE(text.find("\"id\":\"ping\""), std::string::npos); + EXPECT_NE(text.find("operation-cancelled"), std::string::npos); + EXPECT_EQ(text.find("in_meeting"), std::string::npos); +} + +TEST(JsonRpcServer, AsyncJoinAcknowledgesOnceAndPublishesIdentifiedCompletion) { + using namespace corevideo::rpc; + corevideo::core::MediaCore core; + JsonRpcServer server(core, [](const Json&, const std::function& cancelled) -> Json { + while (!cancelled()) std::this_thread::sleep_for(std::chrono::milliseconds(1)); + return Json::Object{{"meetingState", "in_meeting"}}; + }); + std::istringstream input("{\"id\":\"join\",\"type\":\"zoom-join\",\"asyncOperation\":true,\"payload\":{}}\n"); + std::ostringstream output; + server.run(input, output); + std::istringstream lines(output.str()); + std::string line, operationId; + int acknowledgements = 0, completions = 0; + while (std::getline(lines, line)) { + const auto message = Json::parse(line); + ASSERT_TRUE(message.has_value()); + if (message->getString("id") == "join") { + ++acknowledgements; + ASSERT_NE(message->get("operation"), nullptr); + operationId = message->get("operation")->getString("operationId"); + EXPECT_EQ(message->get("operation")->getString("state"), "accepted"); + } + if (message->getString("type") == "operation-completed") { + ++completions; + ASSERT_NE(message->get("operation"), nullptr); + EXPECT_EQ(message->get("operation")->getString("operationId"), operationId); + EXPECT_EQ(message->get("operation")->getString("state"), "cancelled"); + EXPECT_EQ(message->get("operation")->getString("processEpoch"), server.handshake().getString("processEpoch")); + } + } + EXPECT_EQ(acknowledgements, 1); + EXPECT_EQ(completions, 1); +} diff --git a/native/tests/RouteSourcePolicyTest.cpp b/native/tests/RouteSourcePolicyTest.cpp new file mode 100644 index 00000000..37b238db --- /dev/null +++ b/native/tests/RouteSourcePolicyTest.cpp @@ -0,0 +1,48 @@ +#include "core/RouteSourcePolicy.h" +#include + +using corevideo::core::resolveRouteSource; + +TEST(RouteSourcePolicy, FixedGuestSurvivesMissingFrameAndRosterReorder) { + const auto binding = resolveRouteSource({"fixed", {}, {}, {}, "guest-7", "other-guest"}); + EXPECT_EQ(binding.participantId, "guest-7"); + EXPECT_EQ(binding.sourceId, "zoom:guest-7"); +} + +TEST(RouteSourcePolicy, CaptureInputUsesExactNamespacedFrameIdentity) { + const auto binding = resolveRouteSource({"capture-input", {}, {}, "camera-2", "guest-7", "other-guest"}); + EXPECT_EQ(binding.participantId, "capture:camera-2"); + EXPECT_EQ(binding.sourceId, "capture:camera-2"); +} + +TEST(RouteSourcePolicy, ValidMediaTakesPrecedenceOverCaptureAndParticipant) { + const auto binding = resolveRouteSource({"capture-input", "clip-1", "clip.mp4", "camera-2", "guest-7", {}}); + EXPECT_EQ(binding.kind, "media-video"); + EXPECT_EQ(binding.sourceId, "media:clip-1"); + EXPECT_TRUE(binding.participantId.empty()); +} + +TEST(RouteSourcePolicy, IncompleteMediaKeepsExistingParticipantBinding) { + const auto binding = resolveRouteSource({"fixed", "clip-1", {}, {}, "guest-7", {}}); + EXPECT_EQ(binding.kind, "participant-video"); + EXPECT_EQ(binding.sourceId, "zoom:guest-7"); +} + +TEST(RouteSourcePolicy, ScreenShareRetainsFrameKind) { + const auto binding = resolveRouteSource({"screen-share", {}, {}, {}, "guest-7", {}}); + EXPECT_EQ(binding.kind, "screen-share"); + EXPECT_EQ(binding.sourceId, "zoom:guest-7"); +} + +TEST(RouteSourcePolicy, LegacyUnassignedRouteRetainsPositionalFallback) { + for (const auto* mode : {"fixed", "active-speaker", "none"}) { + const auto binding = resolveRouteSource({mode, {}, {}, {}, {}, "guest-7"}); + EXPECT_EQ(binding.sourceId, "zoom:guest-7") << mode; + } +} + +TEST(RouteSourcePolicy, MissingGuestWithoutAssignmentOrFallbackRemainsUnbound) { + const auto binding = resolveRouteSource({"fixed", {}, {}, {}, {}, {}}); + EXPECT_TRUE(binding.sourceId.empty()); + EXPECT_TRUE(binding.participantId.empty()); +} diff --git a/native/tests/ZoomEngineRuntimeTest.cpp b/native/tests/ZoomEngineRuntimeTest.cpp index d6309cae..b28459d9 100644 --- a/native/tests/ZoomEngineRuntimeTest.cpp +++ b/native/tests/ZoomEngineRuntimeTest.cpp @@ -611,3 +611,35 @@ TEST(ZoomEngineRuntime, IngestsDedicatedMeetingMixPcmAsZoomMix) { shm_region_destroy(region); } + +TEST(ZoomEngineRuntime, CancellationInterruptsAuthWaitAndLeaveIgnoresLateJoined) { + setEnv("COREVIDEO_ZOOM_ENGINE_PATH", "C:/fake/corevideo-zoom-engine.exe"); + setEnv("COREVIDEO_ZOOM_JOIN_WAIT_MS", "30000"); + auto fake = std::make_shared(); + { + corevideo::modules::ZoomEngineRuntime runtime; + runtime.installEngineProcessForTest(fake); + std::atomic cancelled{false}; + corevideo::rpc::Json result; + std::thread worker([&] { + result = runtime.join(corevideo::rpc::Json::Object{{"meetingNumber", "123456789"}}, + [&] { return cancelled.load(); }); + }); + EXPECT_TRUE(fake->waitForSentLines(1, std::chrono::seconds(3))); + const auto start = std::chrono::steady_clock::now(); + cancelled.store(true); + (void)runtime.leave(); + worker.join(); + EXPECT_LT(std::chrono::steady_clock::now() - start, std::chrono::seconds(1)); + EXPECT_TRUE(result.isNull()); + runtime.applyEngineEventForTest({corevideo::modules::ZoomEngineEventKind::Joined}); + EXPECT_NE(runtime.snapshot().getString("meetingState"), "in_meeting"); + // The next join must retire the old process even though it still reports + // running; a missing replacement executable cannot reuse its late Joined. + const auto rejoin = runtime.join(corevideo::rpc::Json::Object{{"meetingNumber", "987654321"}}); + EXPECT_FALSE(fake->running()); + EXPECT_NE(rejoin.getString("meetingState"), "in_meeting"); + } + unsetEnv("COREVIDEO_ZOOM_ENGINE_PATH"); + unsetEnv("COREVIDEO_ZOOM_JOIN_WAIT_MS"); +} diff --git a/native/zoom-engine/fake/fake-engine.cpp b/native/zoom-engine/fake/fake-engine.cpp index 944d43c3..9fed8128 100644 --- a/native/zoom-engine/fake/fake-engine.cpp +++ b/native/zoom-engine/fake/fake-engine.cpp @@ -37,6 +37,8 @@ // COREVIDEO_FAKE_ENGINE_FPS frame cadence (default 30; use 60 for // the 1080p60 load the product targets) // COREVIDEO_FAKE_ENGINE_LOG optional path for a standalone diag log +// COREVIDEO_FAKE_AUTH_DELAY_MS synthetic SDK auth stall (0 by default, max 60000) +// COREVIDEO_FAKE_JOIN_DELAY_MS synthetic meeting join stall (0 by default, max 60000) // // Windows-focused; a minimal POSIX fallback keeps the file portable so the // CMake target stays green on the Linux stub build. @@ -755,6 +757,11 @@ int main(int argc, char** argv) { } else if (line.find(IPC_CMD_INIT) != std::string::npos) { EngineIpc::write(R"({"cmd":"debug","stage":"init_received"})"); + if (const char* delay = std::getenv("COREVIDEO_FAKE_AUTH_DELAY_MS")) { + const int ms = (std::max)(0, (std::min)(60000, std::atoi(delay))); + diag("synthetic-auth-delay=" + std::to_string(ms)); + std::this_thread::sleep_for(std::chrono::milliseconds(ms)); + } EngineIpc::write(R"({"cmd":"auth_ok"})"); } else if (line.find(IPC_CMD_JOIN) != std::string::npos) { @@ -762,6 +769,11 @@ int main(int argc, char** argv) { EngineIpc::write( R"({"cmd":"debug","stage":"join_received","meeting_id":")" + json_escape(meeting_id) + "\"}"); + if (const char* delay = std::getenv("COREVIDEO_FAKE_JOIN_DELAY_MS")) { + const int ms = (std::max)(0, (std::min)(60000, std::atoi(delay))); + diag("synthetic-join-delay=" + std::to_string(ms)); + std::this_thread::sleep_for(std::chrono::milliseconds(ms)); + } { std::lock_guard lk(g_mtx); g_roster = baseline_roster(baseline); From 1cf3cbc7bc7c14906db72900389af5021f8eac4a Mon Sep 17 00:00:00 2001 From: John Wallace Date: Sat, 5 Sep 2026 21:57:12 -0400 Subject: [PATCH 04/21] Gate releases on required suites and exact-package hardware evidence --- .github/workflows/ci.yml | 14 ++- .github/workflows/release.yml | 129 +++++++++++++++++++----- docs/release-evidence.md | 121 ++++++++++++++++++++++ package.json | 9 +- scripts/release-evidence.mjs | 106 +++++++++++++++++++ scripts/test-native.ps1 | 6 +- scripts/tests/release-evidence.test.mjs | 90 +++++++++++++++++ vite.config.ts | 2 + 8 files changed, 443 insertions(+), 34 deletions(-) create mode 100644 docs/release-evidence.md create mode 100644 scripts/release-evidence.mjs create mode 100644 scripts/tests/release-evidence.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81590d59..51ee2369 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,14 @@ jobs: - run: npm ci - name: Native media-core stub gate run: npm run test:native-media-core - - name: MediaCore bridge + unit tests + - 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/**/*.trx + 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..d1f85cc7 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,88 @@ 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 + & $env:COREVIDEO_HARDWARE_HARNESS -CandidateManifest $manifest -PackagePath $package -EvidenceDirectory $evidenceDir + if ($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/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/package.json b/package.json index edec4b76..a1d23aee 100644 --- a/package.json +++ b/package.json @@ -19,14 +19,14 @@ "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --config vite.config.ts", "test:integration": "vitest run --config vitest.integration.config.ts", - "test:gate": "npm run typecheck && npm run typecheck:show-engine && npm run test && npm run test:native-core && npm run test:show-engine && npm run test:native-media-core && npm run test:native-shell && npm run test:native-shell-smoke", + "test:gate": "npm run contract:check && npm run typecheck && npm run typecheck:show-engine && npm run test && npm run test:native-core && npm run test:show-engine && npm run test:native-media-core && npm run test:native-shell && npm run test:native-shell-smoke && npm run test:release-evidence", "test:native-core": "npm run test --workspace native-core", "test:show-engine": "npm run test --workspace show-engine", "typecheck:show-engine": "npm run typecheck --workspace show-engine && npm run typecheck:tests --workspace show-engine", "build:show-engine": "npm run build --workspace show-engine", "test:native-media-core": "powershell -ExecutionPolicy Bypass -File scripts/test-native.ps1", "test:native-recording-proof": "node scripts/native-recording-proof.mjs", - "test:native-shell": "dotnet test native-shell/CoreVideoPro.MediaCore.Tests/CoreVideoPro.MediaCore.Tests.csproj && dotnet test native-shell/CoreVideoPro.Control.Tests/CoreVideoPro.Control.Tests.csproj && powershell -ExecutionPolicy Bypass -File native-shell/test-media-core-bridge.ps1", + "test:native-shell": "dotnet test native-shell/CoreVideoPro.MediaCore.Tests/CoreVideoPro.MediaCore.Tests.csproj --logger trx --results-directory artifacts/test-results/mediacore && dotnet test native-shell/CoreVideoPro.Control.Tests/CoreVideoPro.Control.Tests.csproj --logger trx --results-directory artifacts/test-results/control && dotnet test native-shell/CoreVideoPro.WinUI.Tests/CoreVideoPro.WinUI.Tests.csproj --logger trx --results-directory artifacts/test-results/winui && powershell -ExecutionPolicy Bypass -File native-shell/test-media-core-bridge.ps1", "test:native-shell-smoke": "powershell -ExecutionPolicy Bypass -File scripts/test-native-shell-smoke.ps1", "test:native-shell-dev-readiness": "powershell -ExecutionPolicy Bypass -File scripts/test-native-shell-dev-readiness.ps1", "test:studio-workflow": "powershell -ExecutionPolicy Bypass -File scripts/test-studio-workflow.ps1", @@ -49,7 +49,10 @@ "release:notes": "node scripts/release-notes.mjs", "validate:live-zoom": "node ./scripts/validate-live-zoom.mjs", "validate:record-stream": "node ./scripts/validate-record-stream.mjs", - "validate:tiles": "node scripts/validate-tiles.mjs" + "validate:tiles": "node scripts/validate-tiles.mjs", + "test:release-evidence": "node --test scripts/tests/release-evidence.test.mjs", + "contract:generate": "node contracts/generate.mjs", + "contract:check": "node contracts/generate.mjs --check" }, "dependencies": { "@vitejs/plugin-react": "^5.1.1", diff --git a/scripts/release-evidence.mjs b/scripts/release-evidence.mjs new file mode 100644 index 00000000..ce5027bc --- /dev/null +++ b/scripts/release-evidence.mjs @@ -0,0 +1,106 @@ +import { createHash } from 'node:crypto'; +import { readFileSync, writeFileSync, readdirSync } from 'node:fs'; +import { basename, dirname, join, relative, resolve, sep } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const requiredChecks = [ + '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', +]; +export const sha256 = data => createHash('sha256').update(data).digest('hex'); +const requireThat = (condition, message) => { if (!condition) throw new Error(message); }; +const nonempty = value => typeof value === 'string' && value.trim().length > 0; +const hashPattern = /^[a-f0-9]{64}$/; +const readJson = file => JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); + +// Constructed AFTER signing, from the actual cache and packaged runtime files. +// Evidence therefore tests the bytes published, rather than a local rebuild. +export function createCandidate({ sourceSha, artifact, cache, payload, now = new Date() }) { + requireThat(/^[a-f0-9]{40}$/.test(sourceSha), 'A full source commit SHA is required'); + const flags = Object.fromEntries(readFileSync(cache, 'utf8').split(/\r?\n/) + .flatMap(line => { + const match = line.match(/^(COREVIDEO_[A-Z0-9_]+|BUILD_TESTING):BOOL=(ON|OFF)$/); + return match ? [[match[1], match[2]]] : []; + }).sort(([a], [b]) => a.localeCompare(b))); + requireThat(flags.COREVIDEO_STUB === 'OFF', 'Release candidate must be a non-stub build'); + requireThat(flags.COREVIDEO_WITH_ZOOM === 'ON', 'Release candidate requires Zoom'); + const runtimeFiles = {}; + const walk = dir => { + for (const item of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const file = join(dir, item.name); + if (item.isDirectory()) walk(file); + else if (/\.(dll|exe|json)$/i.test(item.name)) + runtimeFiles[relative(payload, file).split(sep).join('/')] = sha256(readFileSync(file)); + } + }; + walk(payload); + requireThat(Object.keys(runtimeFiles).length > 0, 'Packaged runtime inventory is empty'); + const configuration = { platform: 'windows-x64', flags, runtimeFiles }; + return { schemaVersion: 1, sourceSha, createdAt: now.toISOString(), configuration, + configurationSha256: sha256(JSON.stringify(configuration)), + artifact: { name: basename(artifact), sha256: sha256(readFileSync(artifact)) } }; +} + +export function validateEvidence(candidate, evidence, { artifactBytes, readAttachment, now = new Date() } = {}) { + requireThat(candidate.schemaVersion === 1 && evidence.schemaVersion === 1, 'Unsupported evidence schema'); + requireThat(/^[a-f0-9]{40}$/.test(candidate.sourceSha), 'Invalid candidate source SHA'); + requireThat(evidence.sourceSha === candidate.sourceSha, 'Evidence belongs to another source commit'); + requireThat(candidate.configurationSha256 === sha256(JSON.stringify(candidate.configuration)), 'Candidate configuration digest mismatch'); + requireThat(evidence.configurationSha256 === candidate.configurationSha256, 'Evidence belongs to another build configuration'); + requireThat(hashPattern.test(candidate.artifact.sha256) && evidence.artifactSha256 === candidate.artifact.sha256, + 'Evidence belongs to another artifact'); + requireThat(artifactBytes && sha256(artifactBytes) === candidate.artifact.sha256, 'Candidate artifact bytes do not match'); + const created = Date.parse(candidate.createdAt), started = Date.parse(evidence.startedAt), completed = Date.parse(evidence.completedAt); + requireThat(Number.isFinite(created) && Number.isFinite(started) && Number.isFinite(completed) + && started >= created && completed >= started && completed <= now.getTime() + && now.getTime() - completed <= 7 * 24 * 60 * 60 * 1000, 'Missing, stale or invalid evidence timestamps'); + for (const key of ['rigId', 'os', 'gpu', 'driver', 'zoomSdkVersion', 'runtimeVersions', 'baselineId']) + requireThat(nonempty(evidence.environment?.[key]), `Missing environment.${key}`); + requireThat(Array.isArray(evidence.checks), 'Missing required checks'); + requireThat(new Set(evidence.checks.map(check => check.id)).size === evidence.checks.length, 'Duplicate checks'); + for (const id of requiredChecks) { + const check = evidence.checks.find(item => item.id === id); + requireThat(check?.status === 'passed', `Required check ${id} missing, skipped or failed`); + requireThat(Array.isArray(check.attachments) && check.attachments.length > 0, `Missing attachments for ${id}`); + for (const attachment of check.attachments) { + requireThat(nonempty(attachment.path) && hashPattern.test(attachment.sha256), `Invalid attachment for ${id}`); + requireThat(readAttachment && sha256(readAttachment(attachment.path)) === attachment.sha256, `Attachment mismatch for ${id}`); + } + } + const soak = evidence.checks.find(check => check.id === 'simultaneous-soak'); + requireThat(Number.isFinite(soak.durationSeconds) && soak.durationSeconds >= 7200, 'Soak must last at least two hours'); + requireThat(completed - started >= soak.durationSeconds * 1000, 'Evidence interval is shorter than soak'); + for (const name of ['memorySlopeMbPerHour', 'frameDropPercent', 'queueDepth', 'commandP95Ms', 'commandMaxMs', 'avDriftMs']) { + const metric = soak.metrics?.[name]; + requireThat(Number.isFinite(metric?.value) && metric.value >= 0 && Number.isFinite(metric?.maximum) + && metric.maximum >= 0 && metric.value <= metric.maximum, `Missing or exceeded soak threshold: ${name}`); + } + requireThat(soak.metrics.commandP95Ms.maximum <= 250 && soak.metrics.commandMaxMs.maximum <= 1000, + 'Command latency thresholds exceed the release acceptance limits'); + return { status: 'passed', sourceSha: candidate.sourceSha, artifactSha256: candidate.artifact.sha256, + configurationSha256: candidate.configurationSha256, checkedAt: now.toISOString(), checks: requiredChecks }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + const [command, ...args] = process.argv.slice(2); + if (command === 'candidate' && args.length === 5) { + const [sourceSha, artifact, cache, payload, output] = args; + writeFileSync(output, JSON.stringify(createCandidate({ sourceSha, artifact, cache, payload }), null, 2) + '\n'); + } else if (command === 'validate' && args.length === 4) { + const [manifest, evidenceFile, artifact, output] = args; + const evidenceRoot = resolve(dirname(evidenceFile)); + const result = validateEvidence(readJson(manifest), readJson(evidenceFile), { + artifactBytes: readFileSync(artifact), + readAttachment: path => { + const file = resolve(evidenceRoot, path); + requireThat(file.startsWith(evidenceRoot + sep), 'Attachment must stay within evidence directory'); + return readFileSync(file); + }, + }); + writeFileSync(output, JSON.stringify(result, null, 2) + '\n'); + } else throw new Error('Usage: release-evidence.mjs candidate SHA MSIX CACHE PAYLOAD OUT | validate MANIFEST EVIDENCE MSIX OUT'); + } catch (error) { console.error(`Release evidence rejected: ${error.message}`); process.exitCode = 1; } +} diff --git a/scripts/test-native.ps1 b/scripts/test-native.ps1 index 9ee190d6..149964e4 100644 --- a/scripts/test-native.ps1 +++ b/scripts/test-native.ps1 @@ -95,14 +95,12 @@ function Stage-NativeArtifacts { } if (-not (Test-CMakeAvailable)) { - Write-Host "[test-native] cmake not found on PATH; skipping native media-core CI gate." -ForegroundColor Yellow - exit 0 + throw "[test-native] cmake not found on PATH; install CMake before running the required native gate." } $vsDevCmd = Resolve-VsDevCmd if (-not $vsDevCmd) { - Write-Host "[test-native] Visual Studio / Build Tools not found; skipping native media-core CI gate." -ForegroundColor Yellow - exit 0 + throw "[test-native] Visual Studio / Build Tools not found; install the C++ build tools before running the required native gate." } Write-Host "[test-native] configuring portable stub build (COREVIDEO_STUB=ON)..." -ForegroundColor Cyan diff --git a/scripts/tests/release-evidence.test.mjs b/scripts/tests/release-evidence.test.mjs new file mode 100644 index 00000000..1fa193fd --- /dev/null +++ b/scripts/tests/release-evidence.test.mjs @@ -0,0 +1,90 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { createCandidate, requiredChecks, sha256, validateEvidence } from '../release-evidence.mjs'; + +function fixture() { + const artifactBytes = Buffer.from('signed candidate'), report = Buffer.from('sanitized rig report'); + const configuration = { platform: 'windows-x64', flags: { COREVIDEO_STUB: 'OFF' }, runtimeFiles: { 'sdk.dll': sha256('sdk') } }; + const candidate = { schemaVersion: 1, sourceSha: 'a'.repeat(40), createdAt: '2026-09-01T00:00:00Z', configuration, + configurationSha256: sha256(JSON.stringify(configuration)), artifact: { name: 'CoreVideoPro.msix', sha256: sha256(artifactBytes) } }; + const evidence = { schemaVersion: 1, sourceSha: candidate.sourceSha, configurationSha256: candidate.configurationSha256, + artifactSha256: candidate.artifact.sha256, startedAt: '2026-09-01T01:00:00Z', completedAt: '2026-09-01T04:00:00Z', + environment: Object.fromEntries(['rigId', 'os', 'gpu', 'driver', 'zoomSdkVersion', 'runtimeVersions', 'baselineId'].map(k => [k, 'fixture'])), + checks: requiredChecks.map(id => ({ id, status: 'passed', attachments: [{ path: 'report.txt', sha256: sha256(report) }] })) }; + Object.assign(evidence.checks.find(c => c.id === 'simultaneous-soak'), { durationSeconds: 7200, + metrics: Object.fromEntries(['memorySlopeMbPerHour', 'frameDropPercent', 'queueDepth', 'commandP95Ms', 'commandMaxMs', 'avDriftMs'] + .map(k => [k, { value: 0, maximum: 1 }])) }); + return { candidate, evidence, options: { artifactBytes, readAttachment: () => report, now: new Date('2026-09-01T05:00:00Z') } }; +} +test('accepts complete exact-candidate hardware evidence', () => { + const f = fixture(); assert.equal(validateEvidence(f.candidate, f.evidence, f.options).status, 'passed'); +}); +for (const [name, mutate] of Object.entries({ + 'wrong commit': f => f.evidence.sourceSha = 'b'.repeat(40), + 'wrong configuration': f => f.evidence.configurationSha256 = 'b'.repeat(64), + 'tampered configuration': f => f.candidate.configuration.flags.COREVIDEO_STUB = 'ON', + 'wrong artifact evidence': f => f.evidence.artifactSha256 = 'b'.repeat(64), + 'different artifact bytes': f => f.options.artifactBytes = Buffer.from('rebuild'), + 'stale evidence': f => f.options.now = new Date('2026-10-01T00:00:00Z'), + 'evidence predates candidate': f => f.evidence.startedAt = '2026-08-31T23:00:00Z', + 'future evidence': f => f.evidence.completedAt = '2026-09-02T00:00:00Z', + 'missing check': f => f.evidence.checks.pop(), + 'failed check': f => f.evidence.checks[0].status = 'failed', + 'skipped check': f => f.evidence.checks[0].status = 'skipped', + 'duplicate check': f => f.evidence.checks.push(f.evidence.checks[0]), + 'missing environment': f => delete f.evidence.environment.driver, + 'missing attachment': f => f.evidence.checks[0].attachments = [], + 'tampered attachment': f => f.options.readAttachment = () => Buffer.from('wrong'), + 'short soak': f => f.evidence.checks[2].durationSeconds = 60, + 'missing metric': f => delete f.evidence.checks[2].metrics.avDriftMs, + 'threshold exceeded': f => f.evidence.checks[2].metrics.avDriftMs.value = 2, + 'latency threshold weakened': f => f.evidence.checks[2].metrics.commandP95Ms.maximum = 251, +})) test(`rejects ${name}`, () => { + const f = fixture(); mutate(f); assert.throws(() => validateEvidence(f.candidate, f.evidence, f.options)); +}); + +test('candidate inventories actual production flags, runtime and signed package', () => { + const dir = mkdtempSync(join(tmpdir(), 'corevideo-candidate-')); + try { + const payload = join(dir, 'payload'); mkdirSync(payload); + const artifact = join(dir, 'CoreVideoPro.msix'), cache = join(dir, 'CMakeCache.txt'); + writeFileSync(artifact, 'signed bytes'); writeFileSync(join(payload, 'sdk.dll'), 'sdk bytes'); + writeFileSync(cache, 'COREVIDEO_STUB:BOOL=OFF\nCOREVIDEO_WITH_ZOOM:BOOL=ON\nPRIVATE_SDK_PATH:PATH=C:/private\n'); + const candidate = createCandidate({ sourceSha: 'a'.repeat(40), artifact, cache, payload }); + assert.equal(candidate.artifact.sha256, sha256('signed bytes')); + assert.equal(candidate.configuration.runtimeFiles['sdk.dll'], sha256('sdk bytes')); + assert.deepEqual(candidate.configuration.flags, { COREVIDEO_STUB: 'OFF', COREVIDEO_WITH_ZOOM: 'ON' }); + writeFileSync(cache, 'COREVIDEO_STUB:BOOL=ON\nCOREVIDEO_WITH_ZOOM:BOOL=ON\n'); + assert.throws(() => createCandidate({ sourceSha: 'a'.repeat(40), artifact, cache, payload }), /non-stub/); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test('CLI fails closed on missing evidence and attachment path escape', () => { + const dir = mkdtempSync(join(tmpdir(), 'corevideo-evidence-')); + try { + const f = fixture(), manifest = join(dir, 'candidate.json'), evidencePath = join(dir, 'evidence.json'); + const artifact = join(dir, 'CoreVideoPro.msix'), verdict = join(dir, 'verdict.json'); + const cli = fileURLToPath(new URL('../release-evidence.mjs', import.meta.url)); + const run = () => spawnSync(process.execPath, [cli, 'validate', manifest, evidencePath, artifact, verdict], { encoding: 'utf8' }); + writeFileSync(manifest, JSON.stringify(f.candidate)); writeFileSync(artifact, f.options.artifactBytes); + assert.equal(run().status, 1); + // Fresh dates keep this fixture independent of the wall clock. + const now = Date.now(); + f.candidate.createdAt = new Date(now - 4 * 3600000).toISOString(); + f.evidence.startedAt = new Date(now - 3 * 3600000).toISOString(); + f.evidence.completedAt = new Date(now - 1000).toISOString(); + writeFileSync(manifest, JSON.stringify(f.candidate)); + writeFileSync(join(dir, 'report.txt'), 'sanitized rig report'); + writeFileSync(evidencePath, JSON.stringify(f.evidence)); + assert.equal(run().status, 0); + assert.equal(JSON.parse(readFileSync(verdict)).status, 'passed'); + f.evidence.checks[0].attachments[0].path = '../outside.txt'; + writeFileSync(evidencePath, JSON.stringify(f.evidence)); + const rejected = run(); assert.equal(rejected.status, 1); assert.match(rejected.stderr, /within evidence directory/); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); diff --git a/vite.config.ts b/vite.config.ts index b4926eef..d62dbaa6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -30,6 +30,8 @@ export default defineConfig({ // does NOT match .claude/worktrees//native-core/**. "**/.claude/**", "native-core/**", + // Infrastructure tests use node:test and run in their own CI command. + "scripts/tests/release-evidence.test.mjs", "tests/e2e/**", "src/App.test.tsx" ], From efa34b43be180e33367f0d529b52b6890d0c6681 Mon Sep 17 00:00:00 2001 From: John Wallace Date: Sat, 5 Sep 2026 21:57:57 -0400 Subject: [PATCH 05/21] Keep output intent separate and recover saved production settings visibly --- .../OutputLifecycleTransportTests.cs | 47 ++++++ .../ProductionPreferencesDurabilityTests.cs | 128 ++++++++++++++++ .../ProductionPreferencesNoticeTests.cs | 32 ++++ .../StudioViewModelAudioStatusTests.cs | 4 +- .../TransportCoordinatorTests.cs | 15 +- .../Services/AtomicJsonFile.cs | 59 ++++++++ .../ProductionOutputPreferencesStore.cs | 138 +++++++++++------- .../Services/ProductionPreferencesNotice.cs | 19 +++ .../Services/StudioControlSurface.cs | 4 +- .../ViewModels/StudioViewModel.Preferences.cs | 14 ++ .../ViewModels/StudioViewModel.Transport.cs | 32 +++- .../ViewModels/StudioViewModel.cs | 57 ++++---- .../ViewModels/Transport/ITransportHost.cs | 2 + .../Transport/TransportCoordinator.cs | 9 +- .../Transport/TransportStatusFormatter.cs | 27 +++- .../Views/StudioWorkspace.xaml | 8 + 16 files changed, 503 insertions(+), 92 deletions(-) create mode 100644 native-shell/CoreVideoPro.WinUI.Tests/OutputLifecycleTransportTests.cs create mode 100644 native-shell/CoreVideoPro.WinUI.Tests/ProductionPreferencesDurabilityTests.cs create mode 100644 native-shell/CoreVideoPro.WinUI.Tests/ProductionPreferencesNoticeTests.cs create mode 100644 native-shell/CoreVideoPro.WinUI/Services/AtomicJsonFile.cs create mode 100644 native-shell/CoreVideoPro.WinUI/Services/ProductionPreferencesNotice.cs create mode 100644 native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.Preferences.cs 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..8fc3186e 100644 --- a/native-shell/CoreVideoPro.WinUI.Tests/TransportCoordinatorTests.cs +++ b/native-shell/CoreVideoPro.WinUI.Tests/TransportCoordinatorTests.cs @@ -131,7 +131,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 +151,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/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..4fbdfb76 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 RunTransportSet(_vm.RecordingRequested, Bool(args, 0), _vm.ToggleRecordingCommand, "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 RunTransportSet(_vm.StreamingRequested, Bool(args, 0), _vm.ToggleStreamingCommand, "streaming").ConfigureAwait(true); case "transport.engine.toggle": return await RunTransportToggle(_vm.ToggleEngineCommand, "capture engine").ConfigureAwait(true); case "transport.engine.set": 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..8f8e5448 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,43 @@ 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)); + + 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..c596a601 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})"; } @@ -10050,8 +10051,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 +10163,8 @@ private void StopMediaCoreSession(string status) UnsubscribeZoomCapture(status); Recording = false; Streaming = false; + RecordingRequested = false; + StreamingRequested = false; _bridge.Stop(); EngineStatus = status; Settings.RefreshSdkReadiness(); @@ -10190,14 +10193,13 @@ 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) + // 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; } @@ -11191,15 +11193,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..5423a911 100644 --- a/native-shell/CoreVideoPro.WinUI/ViewModels/Transport/TransportCoordinator.cs +++ b/native-shell/CoreVideoPro.WinUI/ViewModels/Transport/TransportCoordinator.cs @@ -194,6 +194,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 +236,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 +341,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(); }); @@ -498,7 +500,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 @@ +