From fab9d3764474c7176d6fa849fbd04a58e92c325c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 18:13:25 -0700 Subject: [PATCH 01/27] quest: open the cpp line Co-Authored-By: Claude Opus 5.5 From 8c64aab28b94ca818793c017d72f92111d032031 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 00:48:20 -0700 Subject: [PATCH 02/27] feat(cpp): generate moq-ffi C++ bindings with a pinned uniffi-bindgen-cpp (#4100) Co-authored-by: Claude Opus 5.5 --- .github/workflows/cpp.yml | 58 ++++++++++++++++ .github/workflows/nightly.yml | 37 ++++++++++ cpp/ffi/.gitignore | 3 + cpp/ffi/CMakeLists.txt | 34 +++++++++ cpp/ffi/README.md | 32 +++++++++ cpp/ffi/probe.cpp | 125 ++++++++++++++++++++++++++++++++++ cpp/ffi/uniffi.toml | 4 ++ cpp/justfile | 51 ++++++++++++++ flake.nix | 46 ++++++++++++- justfile | 5 ++ quest/m1/cpp/README.md | 12 ++-- quest/m1/cpp/generator.md | 48 ------------- quest/m1/cpp/package.md | 8 +-- quest/m2/cs/generator.md | 4 -- rs/moq-ffi/build.sh | 14 +++- 15 files changed, 417 insertions(+), 64 deletions(-) create mode 100644 .github/workflows/cpp.yml create mode 100644 cpp/ffi/.gitignore create mode 100644 cpp/ffi/CMakeLists.txt create mode 100644 cpp/ffi/README.md create mode 100644 cpp/ffi/probe.cpp create mode 100644 cpp/ffi/uniffi.toml create mode 100644 cpp/justfile delete mode 100644 quest/m1/cpp/generator.md diff --git a/.github/workflows/cpp.yml b/.github/workflows/cpp.yml new file mode 100644 index 0000000000..c43d3fa746 --- /dev/null +++ b/.github/workflows/cpp.yml @@ -0,0 +1,58 @@ +name: C++ + +# Compiles the generated moq-ffi C++ bindings with clang, outside Nix. `just +# check` already runs `just cpp check` with the dev shell's gcc; the dev shell +# has no clang++, and adding one would change which compiler every other recipe +# gets as `cc`. MSVC runs in nightly.yml, for the reason the Windows Rust check +# does: those runners are throttled too hard to gate every pull request. +# +# Outside Nix the generator comes from `cargo install` at the tag flake.nix +# pins; see the comment there for every other place that names it. + +permissions: + contents: read + +on: + pull_request: + # `closed` is here only so merging/closing a PR cancels its in-flight run + # via the concurrency group below; the job itself is skipped on close. + types: [opened, synchronize, reopened, closed] + paths: + - "cpp/ffi/**" + - "cpp/justfile" + - "rs/moq-ffi/**" + - "Cargo.lock" + - ".github/workflows/cpp.yml" + +concurrency: + group: cpp-${{ github.ref }} + cancel-in-progress: true + +jobs: + clang: + name: C++ (clang) + if: github.event.action != 'closed' + runs-on: ubuntu-24.04 + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + + # Pinned: `--locked` fixes just's own dependencies, not which version of + # just cargo selects. + - name: Install just + run: cargo install --locked just@1.52.0 + + - name: Install uniffi-bindgen-cpp + run: cargo install uniffi-bindgen-cpp --locked --git https://github.com/kixelated/uniffi-bindgen-cpp --tag v0.11.0-kixelated.1+v0.32.2 + + - name: Check + run: just cpp check + env: + CXX: clang++ diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 9f73f46109..58a4071fa7 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -295,6 +295,43 @@ jobs: shell: bash run: just rs windows + # The generated moq-ffi C++ bindings under MSVC, with exceptions and RTTI + # off. cpp.yml covers clang on pull requests and `just check` covers gcc; this + # is the Windows half, nightly for the same runner cost as `windows`. + cpp-windows: + name: C++ (MSVC) + runs-on: windows-latest + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + + # aws-lc-rs assembles its x86_64 crypto with NASM on Windows. + - name: Install NASM + shell: pwsh + run: | + choco install nasm -y --no-progress + "C:\Program Files\NASM" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: Install just + shell: bash + run: cargo install --locked just@1.52.0 + + # The tag flake.nix pins; see the comment there. + - name: Install uniffi-bindgen-cpp + shell: bash + run: cargo install uniffi-bindgen-cpp --locked --git https://github.com/kixelated/uniffi-bindgen-cpp --tag v0.11.0-kixelated.1+v0.32.2 + + - name: Check + shell: bash + run: just cpp check + # `just rs macos` is the only thing that compiles moq-video's VideoToolbox # encode/decode and its ScreenCaptureKit / AVFoundation capture, plus # moq-audio's ScreenCaptureKit system audio and its TCC permission pre-check. diff --git a/cpp/ffi/.gitignore b/cpp/ffi/.gitignore new file mode 100644 index 0000000000..c5190f8227 --- /dev/null +++ b/cpp/ffi/.gitignore @@ -0,0 +1,3 @@ +# Regenerated from rs/moq-ffi by `just cpp check`. +/generated/ +/build/ diff --git a/cpp/ffi/CMakeLists.txt b/cpp/ffi/CMakeLists.txt new file mode 100644 index 0000000000..7c87906d53 --- /dev/null +++ b/cpp/ffi/CMakeLists.txt @@ -0,0 +1,34 @@ +# Compiles the generated moq-ffi bindings and the probe that exercises them. +# `just cpp check` generates the bindings and drives this; it is not a package. +cmake_minimum_required(VERSION 3.16) +project(moq-ffi-probe LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +# The cargo output directory holding the moq-ffi cdylib. +set(MOQ_FFI_LIB_DIR "" CACHE PATH "Directory containing the moq-ffi shared library") +if(NOT MOQ_FFI_LIB_DIR) + message(FATAL_ERROR "Set MOQ_FFI_LIB_DIR to the cargo output directory") +endif() + +set(GENERATED ${CMAKE_CURRENT_SOURCE_DIR}/generated) +find_package(Threads REQUIRED) + +add_executable(probe probe.cpp ${GENERATED}/moq.cpp) +target_include_directories(probe PRIVATE ${GENERATED}) + +if(MSVC) + # No exceptions and no RTTI, the way Unreal builds. + target_compile_options(probe PRIVATE /permissive- /EHs-c- /GR-) + target_compile_definitions(probe PRIVATE _HAS_EXCEPTIONS=0) + target_link_libraries(probe PRIVATE ${MOQ_FFI_LIB_DIR}/moq_ffi.dll.lib) + add_custom_command(TARGET probe POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different ${MOQ_FFI_LIB_DIR}/moq_ffi.dll $) +else() + target_compile_options(probe PRIVATE -Wall -Wextra -Werror -pedantic-errors -fno-exceptions -fno-rtti) + find_library(MOQ_FFI moq_ffi PATHS ${MOQ_FFI_LIB_DIR} NO_DEFAULT_PATH REQUIRED) + target_link_libraries(probe PRIVATE ${MOQ_FFI} Threads::Threads) + set_target_properties(probe PROPERTIES BUILD_RPATH ${MOQ_FFI_LIB_DIR}) +endif() diff --git a/cpp/ffi/README.md b/cpp/ffi/README.md new file mode 100644 index 0000000000..0b2e996249 --- /dev/null +++ b/cpp/ffi/README.md @@ -0,0 +1,32 @@ +# C++ bindings for moq-ffi + +C++17 bindings for [rs/moq-ffi](../../rs/moq-ffi), generated by `uniffi-bindgen-cpp`. This directory holds the generator config and a probe program; the generated sources are not checked in. The ergonomic `moq::` layer and the package that ships them come later. + +## Generating + +`just cpp check` builds moq-ffi for the host, regenerates `generated/` (gitignored), then compiles and runs `probe.cpp` against it. The probe connects over QUIC, subscribes to a track, reads a frame through a future, cancels a pending read, and checks errors come back as values. It builds with exceptions and RTTI disabled. + +The generator is a fork, [kixelated/uniffi-bindgen-cpp](https://github.com/kixelated/uniffi-bindgen-cpp). It carries LiveKit's async support ported to uniffi 0.32, plus the `error_style = "expected"` option this directory's `uniffi.toml` turns on. The dev shell provides it. Without Nix: + +```bash +cargo install uniffi-bindgen-cpp --locked \ + --git https://github.com/kixelated/uniffi-bindgen-cpp \ + --tag v0.11.0-kixelated.1+v0.32.2 +``` + +`flake.nix` pins the same tag and lists every other place that names it. + +## Shape + +Every generated type lives in `namespace moq`, spelled as in Rust (`moq::MoqClient`). Objects are `std::shared_ptr`; records and enums are values. + +- A fallible call returns `uniffi::expected`. `uniffi::expected` is `std::expected` on C++23 and a bundled `tl::expected` below it. Build the generated sources with the same standard as the code that includes them. +- An async call returns `uniffi::Future`. Block on it with `get()` or `wait_for()`, or attach a continuation with `std::move(future).then(executor, callback)`, which returns a `uniffi::FutureContinuation` handle. +- `moq::MoqError` is a value holding a `std::variant` of its cases. Nothing throws. A Rust panic or a misused future aborts with a message on stderr. +- Continuations run on one process-wide dispatcher thread unless the application installs its own with `uniffi::set_async_dispatcher` before the first async call. Call `uniffi::shutdown_async_dispatcher()` before unloading the code a continuation could call into. It stops dispatch and abandons every pending future. + +## Cancellation + +Cancelling a future (`cancel()`, destroying it, or destroying the `FutureContinuation` returned by `then`) drops the Rust future. Native moq-ffi runs each async call as a spawned task that holds an `AbortOnDrop` on it (`rs/moq-ffi/src/ffi.rs`), so dropping the future aborts the work at its next await point instead of letting it finish unobserved. The continuation of a cancelled future never runs, and `get()` on it aborts. + +The abort discards the operation, not the handle it ran on, so the next call on that object works. Where a method keeps partial progress, its doc comment says so. For example, a cancelled `read_frame` leaves the current group for the next read. Every write in moq-ffi (`write_frame`, `finish`, `abort`) is synchronous, so cancelling a future can never tear a write. Only the async operations (reads, subscribes, connects, accepts, and `reject`) can be cut short. diff --git a/cpp/ffi/probe.cpp b/cpp/ffi/probe.cpp new file mode 100644 index 0000000000..feda812adb --- /dev/null +++ b/cpp/ffi/probe.cpp @@ -0,0 +1,125 @@ +// Exercises the generated C++ bindings end to end over a real QUIC session: connect, +// subscribe, read a frame through a future, cancel a pending read, and observe errors +// as returned values. Built with exceptions and RTTI disabled. + +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace std::chrono_literals; + +// Unlike assert, this survives a release build. +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + std::fprintf(stderr, "%s:%d: CHECK failed: %s\n", __FILE__, __LINE__, #expr); \ + std::abort(); \ + } \ + } while (0) + +namespace { + +[[noreturn]] void fail(const char *what, const moq::MoqError &error) { + std::fprintf(stderr, "%s failed: MoqError variant %zu\n", what, error.get_variant().index()); + std::abort(); +} + +// Unwraps a result the probe expects to succeed. +template +T ok(uniffi::expected result, const char *what) { + if (!result) { + fail(what, result.error()); + } + return std::move(*result); +} + +void ok(uniffi::expected result, const char *what) { + if (!result) { + fail(what, result.error()); + } +} + +std::vector bytes(const std::string &text) { + return std::vector(text.begin(), text.end()); +} + +} // namespace + +int main() { + // A synchronous error is a returned value, not an exception. + auto unbound = moq::MoqServer::init(); + auto fingerprints = unbound->cert_fingerprints(); + CHECK(!fingerprints); + CHECK(std::holds_alternative(fingerprints.error().get_variant())); + + // Publisher: a server whose origin serves one broadcast with one track. + auto origin = moq::MoqOriginProducer::init({}); + auto broadcast = ok(origin->create_broadcast("probe"), "create_broadcast"); + auto track = ok(broadcast->publish_track("data", std::nullopt), "publish_track"); + ok(broadcast->announce({}), "announce"); + + auto server = moq::MoqServer::init(); + ok(server->set_bind("127.0.0.1:0"), "set_bind"); + ok(server->set_tls_generate({"localhost"}), "set_tls_generate"); + ok(server->set_publish(origin), "set_publish"); + auto addr = ok(server->listen().get(), "listen"); + + // Both halves of the handshake are futures, so they run concurrently while this + // thread blocks on one at a time. + auto client = moq::MoqClient::init(); + ok(client->set_tls_verify(false), "set_tls_verify"); + auto accepting = server->accept(); + auto connecting = client->connect("https://" + addr); + auto request = ok(accepting.get(), "accept"); + CHECK(request != nullptr); + auto served = ok(request->accept().get(), "request accept"); + auto session = ok(connecting.get(), "connect"); + + // Subscriber: resolve the announced broadcast and subscribe to its track. + auto announced = ok(session->consume()->announced_broadcast("probe"), "announced_broadcast"); + auto remote = ok(announced->available().get(), "available"); + auto consumer = ok(remote->subscribe_track("data", std::nullopt).get(), "subscribe_track"); + + // Cancel a read that has nothing to deliver yet. + auto pending = consumer->read_frame(); + CHECK(pending.wait_for(50ms) == std::future_status::timeout); + pending.cancel(); + CHECK(!pending.valid()); + + // The consumer survives the cancelled read and delivers the next frame. + auto reading = consumer->read_frame(); + ok(track->write_frame({bytes("hello"), 1000}), "write_frame"); + auto frame = ok(reading.get(), "read_frame"); + CHECK(frame.has_value()); + CHECK(frame->payload == bytes("hello")); + CHECK(frame->timestamp_us == 1000); + + // An async error arrives through the future as a returned value too: this track + // finished, so reading past its end on a second, finished consumer reports it. + ok(track->finish(), "track finish"); + auto ended = consumer->read_frame().get(); + CHECK(ended && !ended->has_value()); + + auto closed = moq::MoqClient::init(); + closed->cancel(); + auto refused = closed->connect("https://" + addr).get(); + CHECK(!refused); + CHECK(std::holds_alternative(refused.error().get_variant())); + + ok(broadcast->finish(), "broadcast finish"); + session->cancel(0); + served->cancel(0); + server->cancel(); + + // Stop dispatching continuations before the process tears down. + uniffi::shutdown_async_dispatcher(); + + std::printf("probe: ok\n"); + return 0; +} diff --git a/cpp/ffi/uniffi.toml b/cpp/ffi/uniffi.toml new file mode 100644 index 0000000000..3dccc7caa8 --- /dev/null +++ b/cpp/ffi/uniffi.toml @@ -0,0 +1,4 @@ +# uniffi-bindgen-cpp config for rs/moq-ffi. Errors are returned, never thrown, so the +# bindings build with exceptions disabled (Unreal's default). +[bindings.cpp] +error_style = "expected" diff --git a/cpp/justfile b/cpp/justfile new file mode 100644 index 0000000000..efc2de2d50 --- /dev/null +++ b/cpp/justfile @@ -0,0 +1,51 @@ +#!/usr/bin/env just --justfile +# +# The generated C++ bindings for rs/moq-ffi. Invoked from the repo root as +# `just cpp ` via the `mod cpp` import. The OBS plugin has its own +# module, `just obs`. + +set working-directory := '.' + +default: + just check + +# Takes an optional newline-separated list of changed files and skips when none +# are in scope; `just cpp check` (no FILES) always runs. CXX picks the compiler +# on Unix; Windows uses MSVC. + +# Build moq-ffi, regenerate cpp/ffi/generated, then compile and run the probe. +check $FILES="": + #!/usr/bin/env bash + set -euo pipefail + if [[ -n "$FILES" ]] && ! grep -qE '^(cpp/ffi/|cpp/justfile$|rs/moq-ffi/)' <<< "$FILES"; then + echo "cpp: no C++ binding changes; skipping." + exit 0 + fi + if ! command -v uniffi-bindgen-cpp >/dev/null 2>&1; then + echo "cpp check: uniffi-bindgen-cpp not on PATH, skipping" >&2 + echo " install: cargo install uniffi-bindgen-cpp --locked --git https://github.com/kixelated/uniffi-bindgen-cpp --tag v0.11.0-kixelated.1+v0.32.2" >&2 + exit 0 + fi + + cargo build --locked --package moq-ffi + target_dir=$(cargo metadata --format-version 1 --no-deps | jq -r .target_directory)/debug + case "$(uname -s)" in + Darwin) cdylib="$target_dir/libmoq_ffi.dylib" ;; + MINGW* | MSYS* | CYGWIN*) cdylib="$target_dir/moq_ffi.dll" ;; + *) cdylib="$target_dir/libmoq_ffi.so" ;; + esac + + rm -rf ffi/generated + uniffi-bindgen-cpp --library "$cdylib" --config ffi/uniffi.toml --out-dir ffi/generated + + cmake -S ffi -B ffi/build -DMOQ_FFI_LIB_DIR="$target_dir" -DCMAKE_BUILD_TYPE=Debug + cmake --build ffi/build --config Debug + if [[ -x ffi/build/probe ]]; then + ffi/build/probe + else + ffi/build/Debug/probe.exe + fi + +# Remove the generated bindings and the probe build. +clean: + rm -rf ffi/generated ffi/build diff --git a/flake.nix b/flake.nix index 028422fd1b..1b3699d2aa 100644 --- a/flake.nix +++ b/flake.nix @@ -331,6 +331,49 @@ doCheck = false; }; + # uniffi-bindgen-cpp renders rs/moq-ffi into cpp/ffi/generated. Not in + # nixpkgs, so build it from source; without it `just cpp check` skips + # itself, which MOQ_STRICT turns into a failure in CI. + # + # Like uniffi-bindgen-go, the tag pairs the generator's version with the + # uniffi release it reads, so it moves with the `uniffi` dependency in + # rs/moq-ffi/Cargo.toml. Five other places name the same tag and must be + # bumped together: the `cargo install` lines in rs/moq-ffi/build.sh, + # cpp/justfile, cpp/ffi/README.md, .github/workflows/cpp.yml, and the + # cpp-windows job in .github/workflows/nightly.yml. + # + # This points at a fork of LiveKit's async branch (livekit/uniffi-bindgen-cpp + # PR #1): neither LiveKit nor NordSecurity has a uniffi 0.32 generator, + # and the fork adds `error_style = "expected"`, which cpp/ffi/uniffi.toml + # turns on. Its tags add a `-kixelated.N` pre-release so they never + # collide with upstream's. Move back upstream once one tags both. + uniffi-bindgen-cpp = pkgs.rustPlatform.buildRustPackage rec { + pname = "uniffi-bindgen-cpp"; + version = "0.11.0-kixelated.1+v0.32.2"; + + src = pkgs.fetchFromGitHub { + owner = "kixelated"; + repo = "uniffi-bindgen-cpp"; + rev = "v${version}"; + hash = "sha256-i5qVHviZS36TpmaWINNgLKx12cWPm0TUT9+k+YaNAvw="; + }; + + cargoHash = "sha256-+Vt69WTtR/evH+qPcI0J7I1OKWtVOo1xzoGD4Hwg3zE="; + + # The workspace's other member is the fixture crate, which pulls the + # uniffi examples in from git; build only the generator. + buildAndTestSubdir = "bindgen"; + + # The upstream tests generate fixtures and compile them with CMake, + # which is a lot of build for a binary we only invoke. + doCheck = false; + }; + + # C++ binding generator; CMake comes from rustDeps and the compiler from stdenv. + cppDeps = [ + uniffi-bindgen-cpp + ]; + # Dart bindings plus the pinned external generator. dartDeps = [ pkgs.dart @@ -444,7 +487,7 @@ moq-gst ; - inherit uniffi-bindgen-dart; + inherit uniffi-bindgen-cpp uniffi-bindgen-dart; # Bundle of packaging + repo-publish tooling, pinned via flake.lock. # CI builds this and prepends its bin/ to $PATH so subsequent steps @@ -479,6 +522,7 @@ ++ obsDeps ++ ktDeps ++ goDeps + ++ cppDeps ++ dartDeps ++ devTools; diff --git a/justfile b/justfile index dee586e408..05c941d273 100644 --- a/justfile +++ b/justfile @@ -11,6 +11,8 @@ mod kt mod swift mod go mod dart +# Generated C++ bindings for moq-ffi. +mod cpp # OBS Studio plugin (C++). See doc/bin/obs.md. mod obs 'cpp/obs' # Cross-language tests (`just test interop`, `just test drill`, ...). @@ -451,6 +453,7 @@ _tools $FILES="": # here to prevent. scoped '^(go/|rs/moq-ffi/)' && tools+=(go uniffi-bindgen-go cargo rsync) scoped '^(dart/|rs/moq-ffi/)' && tools+=(cargo dart uniffi_bindgen_dart) + scoped '^(cpp/ffi/|cpp/justfile$|rs/moq-ffi/)' && tools+=(cargo jq cmake c++ uniffi-bindgen-cpp) # Two obs recipes with two dispatch scopes, so two lines: over-requiring # would fail a diff that never runs the recipe. `just obs compile` needs # cargo to regenerate moq.h and pkg-config to locate Qt6 and ffmpeg. Every @@ -549,6 +552,7 @@ _check $BASE $TEST: just swift check just go check just dart check + just cpp check just obs check just obs compile just _flake @@ -573,6 +577,7 @@ _check $BASE $TEST: just swift check "$files" just go check "$files" just dart check "$files" + just cpp check "$files" # Type-checking the plugin and its unit tests needs only headers, so it # runs here rather than waiting for obs.yml to link them on Linux. libmoq # is in scope because the plugin calls through its generated C header, and diff --git a/quest/m1/cpp/README.md b/quest/m1/cpp/README.md index 3faa892c8f..cdb8b38282 100644 --- a/quest/m1/cpp/README.md +++ b/quest/m1/cpp/README.md @@ -31,11 +31,12 @@ Rust side carried across. Continuations run on a default bounded dispatcher unless the application installs its own (`uniffi::set_async_dispatcher`); OBS installs one that hops to its own threads. -Errors never throw. The fork gains an `error_style = expected` flag so every -generated method returns `moq::expected` and futures deliver the -same, which is what lets Unreal and other `-fno-exceptions` builds consume the -package. `moq::expected` is `std::expected` on C++23 and a bundled -`tl::expected` below it. +Errors never throw. The fork's `error_style = "expected"` flag makes every +fallible generated method return `uniffi::expected` and every +future deliver the same, which is what lets Unreal and other `-fno-exceptions` +builds consume the package. `uniffi::expected` is `std::expected` on C++23 and a +bundled `tl::expected` below it; the `moq::` layer renames both. Callback +interfaces are refused under that flag until one is needed. One library, C++17 floor (OBS's baseline), feature-gated extras: `co_await` on a future under `__cpp_impl_coroutine`, `std::expected` under @@ -49,7 +50,6 @@ reads the same release manifest so a release bumps both. ## Quests -- [Generator](/quest/m1/cpp/generator.md) - the uniffi 0.32 C++ generator with futures and expected-style errors, pinned and generating `cpp/ffi` in CI - [Package](/quest/m1/cpp/package.md) - the `cpp/moq` wrapper, CMake package, release tarball, interop client, and docs - [OBS migration](/quest/m1/cpp/obs.md) - the OBS plugin moves from libmoq handles and trampolines to the generated C++ diff --git a/quest/m1/cpp/generator.md b/quest/m1/cpp/generator.md deleted file mode 100644 index 19ac2fe6c2..0000000000 --- a/quest/m1/cpp/generator.md +++ /dev/null @@ -1,48 +0,0 @@ -# [M] uniffi-bindgen-cpp: futures and expected-style errors on uniffi 0.32 - -## Goal - -`just cpp check` regenerates `cpp/ffi` from `rs/moq-ffi` with a pinned C++ -generator and compiles a small program that connects, subscribes to a track, -reads one frame through a future, cancels a pending read, and observes an -error as a returned `expected`, on gcc, clang, and MSVC at C++17. This is the -go/no-go gate for the questline: if the generator cannot be made to hold, -stop here and write down why. - -## Plan - -- Fork `livekit/uniffi-bindgen-cpp` at branch `livekit/uniffi-0.31-async` - (futures with `get`/`wait_for`/`cancel`/`then`, async callback interfaces, - a pluggable dispatcher; last commits 2026-09-08) into - `kixelated/uniffi-bindgen-cpp`. Port it to uniffi 0.32.x: the metadata - encoding changed in 0.32 without a contract bump, so a 0.31 generator fails - to read a 0.32 cdylib at all; the Go port (`kixelated/uniffi-bindgen-go` - `v0.8.0+v0.32.0`) is the worked example. Tag `vX.Y.Z+v0.32.0`. -- Add an `error_style = expected` generator option: methods return - `moq::expected` instead of throwing, `uniffi::Future::get()` - returns the same, `then` continuations already receive a result type. Ship - a bundled `tl::expected` and alias `std::expected` when `__cpp_lib_expected` - is defined. Callback interfaces implemented in C++ return errors the same - way. The exceptions style stays the default so the fork remains upstreamable. -- Verify the generated headers compile with `-fno-exceptions` under the - expected style (Unreal's default), and that the async dispatcher shuts down - cleanly when the consumer unloads (`uniffi::shutdown_async_dispatcher`). -- Pin the fork in `flake.nix` next to `uniffi-bindgen-go` and `uniffi-bindgen-dart` - with the same comment discipline: every place that names the generator - version is listed and bumped together (`rs/moq-ffi/build.sh`, the release - workflow, `cpp/ffi/README.md`, `doc/lib/cpp`). `just cpp check` regenerates - into `cpp/ffi` and compiles the probe; wire it into the check workflow the way - `just go check` is. -- Document the cancellation contract from what moq-ffi does: native - `Task::run` and `detached` hold an `AbortOnDrop` on the spawned task - (`rs/moq-ffi/src/ffi.rs`), so dropping the Rust future aborts the work, and - a mid-write abort is possible. The generated future's `cancel()` and its - destructor map onto that; the wrapper says so where it matters (finish a - group before dropping its write future). -- Offer both the 0.32 port and the expected flag upstream to LiveKit and - NordSecurity; the fork exists only until they tag. - -## Related - -- [#2907](/quest/m4/2907-bind-the-browser-through-moq-ffi-uniffi-instead-of-a.md) - the browser generator spike; same Task and `#[cfg]`-inside-export gotchas apply -- [C# generator](/quest/m2/cs/generator.md) - the same 0.32 port against NordSecurity's C# generator diff --git a/quest/m1/cpp/package.md b/quest/m1/cpp/package.md index fa546c0e9c..c372c4831e 100644 --- a/quest/m1/cpp/package.md +++ b/quest/m1/cpp/package.md @@ -23,6 +23,10 @@ A `test/interop/clients/cpp` client joins `just test interop --all`, and - Executor: the default dispatcher is fine for scripts; document how a host installs its own (`moq::set_executor`) before the first call, and that continuations must not block on the moq-ffi runtime thread. +- Shutdown: `moq::shutdown()` calls `moq_ffi_shutdown` and + `uniffi::shutdown_async_dispatcher`, so a host that unloads (a plugin, an + engine module) releases the runtime thread's buffers; the generator probe + still reports about 72 bytes held at exit under valgrind without it. - Build: `cpp/CMakeLists.txt` builds `libmoq_ffi` (staticlib, features `video`+`audio`) with the same `cargo build` custom command and `BUILD_RUST_LIB` switch `rs/libmoq/CMakeLists.txt` uses (no Corrosion, so @@ -38,7 +42,3 @@ A `test/interop/clients/cpp` client joins `just test interop --all`, and they exist; the future, expected, executor, and coroutine rules) and a row in `doc/lib/index.md`. The C row now says libmoq is the plain-C ABI and points C++ readers at the new package. - -## Required - -- [Generator](/quest/m1/cpp/generator.md) - the pinned generator that emits `cpp/ffi` diff --git a/quest/m2/cs/generator.md b/quest/m2/cs/generator.md index 0966cbebb8..511d2eb5f1 100644 --- a/quest/m2/cs/generator.md +++ b/quest/m2/cs/generator.md @@ -20,7 +20,3 @@ catches a typed `MoqException`, on the host runtime. generator: a cancelled token drops the Rust future, and `IDisposable` on a handle cancels its pending calls. If upstream lacks the mapping, it lands in the fork here, never in the wrapper; the probe above is the acceptance test. - -## Related - -- [C++ generator](/quest/m1/cpp/generator.md) - the same port against the C++ generator diff --git a/rs/moq-ffi/build.sh b/rs/moq-ffi/build.sh index 75342c3f08..3c4e19b530 100755 --- a/rs/moq-ffi/build.sh +++ b/rs/moq-ffi/build.sh @@ -189,8 +189,20 @@ generate_bindings() { echo " Skipping dart bindings: uniffi_bindgen_dart not on PATH" fi + # C++ uses a third-party bindgen too: a fork carrying LiveKit's async support + # ported to uniffi 0.32, which upstream has not released. Install it with: + # cargo install --locked uniffi-bindgen-cpp --git https://github.com/kixelated/uniffi-bindgen-cpp --tag v0.11.0-kixelated.1+v0.32.2 + if command -v uniffi-bindgen-cpp >/dev/null 2>&1; then + echo " Generating cpp bindings..." + uniffi-bindgen-cpp --library "$lib_path" \ + --config "$WORKSPACE_DIR/cpp/ffi/uniffi.toml" \ + --out-dir "$OUTPUT_DIR/bindings/cpp" + else + echo " Skipping cpp bindings: uniffi-bindgen-cpp not on PATH" + fi + if [[ "$ARCHIVE" == true ]]; then - for lang in kotlin swift python go dart; do + for lang in kotlin swift python go dart cpp; do if [[ -d "$OUTPUT_DIR/bindings/$lang" ]]; then local archive="moq-ffi-${VERSION}-${lang}.tar.gz" tar -czf "$OUTPUT_DIR/$archive" -C "$OUTPUT_DIR/bindings" "$lang" From c02c9df880ec7b9467906883ceb8e401dffc4395 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:08:53 -0700 Subject: [PATCH 03/27] quest(cpp): C++ errors carry their message (#4203) Co-authored-by: Claude Opus 5.5 --- quest/m1/cpp/README.md | 1 + quest/m1/cpp/error-message.md | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 quest/m1/cpp/error-message.md diff --git a/quest/m1/cpp/README.md b/quest/m1/cpp/README.md index cdb8b38282..4e945df607 100644 --- a/quest/m1/cpp/README.md +++ b/quest/m1/cpp/README.md @@ -51,6 +51,7 @@ reads the same release manifest so a release bumps both. ## Quests - [Package](/quest/m1/cpp/package.md) - the `cpp/moq` wrapper, CMake package, release tarball, interop client, and docs +- [Error messages](/quest/m1/cpp/error-message.md) - a C++ `moq::Error` prints the same message Rust gives - [OBS migration](/quest/m1/cpp/obs.md) - the OBS plugin moves from libmoq handles and trampolines to the generated C++ ## Related diff --git a/quest/m1/cpp/error-message.md b/quest/m1/cpp/error-message.md new file mode 100644 index 0000000000..07e11f95be --- /dev/null +++ b/quest/m1/cpp/error-message.md @@ -0,0 +1,21 @@ +# [XS] C++ errors carry their message + +## Goal + +A `moq::Error` from the C++ package prints the same message Rust's `Display` +gives, so callers and the interop client log a reason rather than a variant +name. + +## Plan + +- The generator emits no `Display` for errors. Prefer exposing the message + through moq-ffi once (e.g. a method on the error the generator already + binds) over hand-written per-variant strings in the wrapper. +- Use it in the interop client and the doc samples. + +Public API: additive on the C++ package, and on moq-ffi if a method is added. +Wire: none. + +## Related + +- [#4187](https://github.com/moq-dev/moq/pull/4187) - the package that found it From 4d10c9dbda827839ac8d52c8edfe0178d4078c3e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 22:07:41 -0700 Subject: [PATCH 04/27] feat(cpp): the moq C++ package, release archives, interop client, and docs (#4187) Co-authored-by: Claude Opus 5.5 Co-authored-by: Grok 4.7 --- .github/workflows/alert.yml | 1 + .github/workflows/cpp.yml | 7 +- .github/workflows/interop.yml | 1 + .github/workflows/nightly.yml | 9 +- .github/workflows/release-cpp.yml | 111 +++++++++ CLAUDE.md | 2 +- cpp/ffi/.gitignore | 3 - cpp/ffi/CMakeLists.txt | 34 --- cpp/ffi/README.md | 32 --- cpp/ffi/probe.cpp | 125 ----------- cpp/justfile | 78 ++++--- cpp/moq/.gitignore | 2 + cpp/moq/CMakeLists.txt | 125 +++++++++++ cpp/moq/README.md | 40 ++++ cpp/moq/VERSION | 1 + cpp/moq/build.sh | 86 +++++++ cpp/moq/cmake/moq-cpp-config.cmake.in | 26 +++ cpp/moq/cmake/moq-cpp.pc.in | 12 + cpp/moq/include/moq/moq.hpp | 284 ++++++++++++++++++++++++ cpp/moq/src/moq.cpp | 6 + cpp/moq/test/CMakeLists.txt | 24 ++ cpp/moq/test/doc-samples.hpp | 17 ++ cpp/moq/test/probe.cpp | 212 ++++++++++++++++++ cpp/{ffi => moq}/uniffi.toml | 0 doc/.vitepress/config.ts | 2 + doc/index.md | 1 + doc/lib/c/index.md | 6 +- doc/lib/cpp/index.md | 151 +++++++++++++ doc/lib/index.md | 11 +- doc/lib/samples.sh | 7 +- flake.nix | 11 +- justfile | 4 +- quest/m1/cpp/README.md | 1 - quest/m1/cpp/obs.md | 8 +- quest/m1/cpp/package.md | 44 ---- quest/m2/cpp-conan.md | 4 +- quest/m2/cpp-vcpkg.md | 6 +- quest/m2/unreal.md | 2 +- rs/libmoq/native-libs/apple.txt | 5 +- rs/moq-ffi/build.sh | 2 +- test/interop/README.md | 20 +- test/interop/clients/cpp/CMakeLists.txt | 13 ++ test/interop/clients/cpp/main.cpp | 258 +++++++++++++++++++++ test/interop/interop.sh | 42 +++- test/justfile | 6 +- 45 files changed, 1530 insertions(+), 312 deletions(-) create mode 100644 .github/workflows/release-cpp.yml delete mode 100644 cpp/ffi/.gitignore delete mode 100644 cpp/ffi/CMakeLists.txt delete mode 100644 cpp/ffi/README.md delete mode 100644 cpp/ffi/probe.cpp create mode 100644 cpp/moq/.gitignore create mode 100644 cpp/moq/CMakeLists.txt create mode 100644 cpp/moq/README.md create mode 100644 cpp/moq/VERSION create mode 100755 cpp/moq/build.sh create mode 100644 cpp/moq/cmake/moq-cpp-config.cmake.in create mode 100644 cpp/moq/cmake/moq-cpp.pc.in create mode 100644 cpp/moq/include/moq/moq.hpp create mode 100644 cpp/moq/src/moq.cpp create mode 100644 cpp/moq/test/CMakeLists.txt create mode 100644 cpp/moq/test/doc-samples.hpp create mode 100644 cpp/moq/test/probe.cpp rename cpp/{ffi => moq}/uniffi.toml (100%) create mode 100644 doc/lib/cpp/index.md delete mode 100644 quest/m1/cpp/package.md create mode 100644 test/interop/clients/cpp/CMakeLists.txt create mode 100644 test/interop/clients/cpp/main.cpp diff --git a/.github/workflows/alert.yml b/.github/workflows/alert.yml index 4180d6bd84..b77d05733b 100644 --- a/.github/workflows/alert.yml +++ b/.github/workflows/alert.yml @@ -37,6 +37,7 @@ on: - moq-relay - Nightly - release-brew + - Release C++ - Release Dart - Release Dart FFI - Release Go diff --git a/.github/workflows/cpp.yml b/.github/workflows/cpp.yml index c43d3fa746..12988c9da4 100644 --- a/.github/workflows/cpp.yml +++ b/.github/workflows/cpp.yml @@ -1,6 +1,6 @@ name: C++ -# Compiles the generated moq-ffi C++ bindings with clang, outside Nix. `just +# Builds and tests the moq C++ package (cpp/moq) with clang, outside Nix. `just # check` already runs `just cpp check` with the dev shell's gcc; the dev shell # has no clang++, and adding one would change which compiler every other recipe # gets as `cc`. MSVC runs in nightly.yml, for the reason the Windows Rust check @@ -18,9 +18,12 @@ on: # via the concurrency group below; the job itself is skipped on close. types: [opened, synchronize, reopened, closed] paths: - - "cpp/ffi/**" + - "cpp/moq/**" - "cpp/justfile" - "rs/moq-ffi/**" + - "rs/libmoq/native-libs/**" + - "doc/lib/cpp/**" + - "doc/lib/samples.sh" - "Cargo.lock" - ".github/workflows/cpp.yml" diff --git a/.github/workflows/interop.yml b/.github/workflows/interop.yml index 7116bd39c1..0276a57d87 100644 --- a/.github/workflows/interop.yml +++ b/.github/workflows/interop.yml @@ -25,6 +25,7 @@ on: - "package.json" # The Go client builds against the modules this script stages. - "go/scripts/**" + - "cpp/moq/**" - ".github/workflows/interop.yml" concurrency: diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 58a4071fa7..7539fa9c3d 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -187,6 +187,12 @@ jobs: permissions: contents: write + release-cpp: + name: Release C++ + uses: ./.github/workflows/release-cpp.yml + permissions: + contents: write + release-moq-relay: name: moq-relay uses: ./.github/workflows/moq-relay.yml @@ -295,8 +301,7 @@ jobs: shell: bash run: just rs windows - # The generated moq-ffi C++ bindings under MSVC, with exceptions and RTTI - # off. cpp.yml covers clang on pull requests and `just check` covers gcc; this + # The moq C++ package (cpp/moq) under MSVC, with exceptions and RTTI off. cpp.yml covers clang on pull requests and `just check` covers gcc; this # is the Windows half, nightly for the same runner cost as `windows`. cpp-windows: name: C++ (MSVC) diff --git a/.github/workflows/release-cpp.yml b/.github/workflows/release-cpp.yml new file mode 100644 index 0000000000..c0af9996ab --- /dev/null +++ b/.github/workflows/release-cpp.yml @@ -0,0 +1,111 @@ +name: Release C++ + +# Builds the moq C++ package (cpp/moq) for every target and publishes one archive per +# target to a cpp-v GitHub release. The vcpkg and Conan packages fetch these. + +on: + push: + tags: + - "cpp-v*" + # Dry-run: build everything, publish nothing. Nightly calls every release + # workflow this way so a broken build surfaces before the tag is cut. + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Build (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + - target: aarch64-unknown-linux-gnu + os: ubuntu-24.04-arm + - target: aarch64-apple-darwin + os: macos-latest + - target: x86_64-pc-windows-msvc + os: windows-latest + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + + # aws-lc-rs assembles its x86_64 crypto with NASM on Windows. + - name: Install NASM + if: runner.os == 'Windows' + shell: pwsh + run: | + choco install nasm -y --no-progress + "C:\Program Files\NASM" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + # The tag flake.nix pins; see the comment there. + - name: Install uniffi-bindgen-cpp + shell: bash + run: cargo install uniffi-bindgen-cpp --locked --git https://github.com/kixelated/uniffi-bindgen-cpp --tag v0.11.0-kixelated.1+v0.32.2 + + # The package version lives in cpp/moq/VERSION; a tag that disagrees is a mistake. + - name: Check version + if: github.ref_type == 'tag' + shell: bash + env: + TAG: ${{ github.ref_name }} + run: | + version=$(tr -d '[:space:]' < cpp/moq/VERSION) + if [[ "$TAG" != "cpp-v$version" ]]; then + echo "::error::tag $TAG does not match cpp/moq/VERSION ($version)" + exit 1 + fi + + - name: Build and package + shell: bash + env: + TARGET: ${{ matrix.target }} + run: ./cpp/moq/build.sh --target "$TARGET" --output dist + + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: moq-cpp-${{ matrix.target }} + path: dist/* + + release: + name: Release + needs: build + if: github.ref_type == 'tag' + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Find previous tag + id: prev_tag + run: .github/scripts/release.sh prev-tag cpp + + - name: Download artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + path: artifacts + merge-multiple: true + + - name: Create or update release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + RELEASE_PREV_TAG: ${{ steps.prev_tag.outputs.tag }} + run: RELEASE_TITLE="moq C++ v${RELEASE_TAG#cpp-v}" .github/scripts/release.sh create artifacts diff --git a/CLAUDE.md b/CLAUDE.md index ba39b50d64..52502993d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,7 +93,7 @@ These diff the branch against its base and only run the affected packages. | Change in | Also update | |---|---| -| `rs/moq-ffi` | `rs/libmoq`, `{py,swift,kt,dart}/`, `go/wrapper/moq/*.go` (the `go/ffi` and `dart/moq_ffi` bindings regenerate automatically, but a new method needs a hand-written wrapper too, like `py/moq-rs` or `dart/moq`), `doc/lib/{py,swift,kt,go,dart,c}` | +| `rs/moq-ffi` | `rs/libmoq`, `{py,swift,kt,dart}/`, `go/wrapper/moq/*.go` (the `go/ffi` and `dart/moq_ffi` bindings regenerate automatically, but a new method needs a hand-written wrapper too, like `py/moq-rs` or `dart/moq`), `cpp/moq`, `doc/lib/{py,swift,kt,go,dart,c,cpp}` | | `rs/moq-net` wire/API | `js/net`, `doc/concept`, `drafts/draft-lcurley-moq-lite.md` (if the wire spec changes) | | `rs/hang` catalog/container | `js/hang`, `doc/concept`, `drafts/draft-lcurley-moq-hang.md` (if the format spec changes) | | `rs/moq-token` | `js/token` | diff --git a/cpp/ffi/.gitignore b/cpp/ffi/.gitignore deleted file mode 100644 index c5190f8227..0000000000 --- a/cpp/ffi/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Regenerated from rs/moq-ffi by `just cpp check`. -/generated/ -/build/ diff --git a/cpp/ffi/CMakeLists.txt b/cpp/ffi/CMakeLists.txt deleted file mode 100644 index 7c87906d53..0000000000 --- a/cpp/ffi/CMakeLists.txt +++ /dev/null @@ -1,34 +0,0 @@ -# Compiles the generated moq-ffi bindings and the probe that exercises them. -# `just cpp check` generates the bindings and drives this; it is not a package. -cmake_minimum_required(VERSION 3.16) -project(moq-ffi-probe LANGUAGES CXX) - -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_CXX_EXTENSIONS OFF) - -# The cargo output directory holding the moq-ffi cdylib. -set(MOQ_FFI_LIB_DIR "" CACHE PATH "Directory containing the moq-ffi shared library") -if(NOT MOQ_FFI_LIB_DIR) - message(FATAL_ERROR "Set MOQ_FFI_LIB_DIR to the cargo output directory") -endif() - -set(GENERATED ${CMAKE_CURRENT_SOURCE_DIR}/generated) -find_package(Threads REQUIRED) - -add_executable(probe probe.cpp ${GENERATED}/moq.cpp) -target_include_directories(probe PRIVATE ${GENERATED}) - -if(MSVC) - # No exceptions and no RTTI, the way Unreal builds. - target_compile_options(probe PRIVATE /permissive- /EHs-c- /GR-) - target_compile_definitions(probe PRIVATE _HAS_EXCEPTIONS=0) - target_link_libraries(probe PRIVATE ${MOQ_FFI_LIB_DIR}/moq_ffi.dll.lib) - add_custom_command(TARGET probe POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different ${MOQ_FFI_LIB_DIR}/moq_ffi.dll $) -else() - target_compile_options(probe PRIVATE -Wall -Wextra -Werror -pedantic-errors -fno-exceptions -fno-rtti) - find_library(MOQ_FFI moq_ffi PATHS ${MOQ_FFI_LIB_DIR} NO_DEFAULT_PATH REQUIRED) - target_link_libraries(probe PRIVATE ${MOQ_FFI} Threads::Threads) - set_target_properties(probe PROPERTIES BUILD_RPATH ${MOQ_FFI_LIB_DIR}) -endif() diff --git a/cpp/ffi/README.md b/cpp/ffi/README.md deleted file mode 100644 index 0b2e996249..0000000000 --- a/cpp/ffi/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# C++ bindings for moq-ffi - -C++17 bindings for [rs/moq-ffi](../../rs/moq-ffi), generated by `uniffi-bindgen-cpp`. This directory holds the generator config and a probe program; the generated sources are not checked in. The ergonomic `moq::` layer and the package that ships them come later. - -## Generating - -`just cpp check` builds moq-ffi for the host, regenerates `generated/` (gitignored), then compiles and runs `probe.cpp` against it. The probe connects over QUIC, subscribes to a track, reads a frame through a future, cancels a pending read, and checks errors come back as values. It builds with exceptions and RTTI disabled. - -The generator is a fork, [kixelated/uniffi-bindgen-cpp](https://github.com/kixelated/uniffi-bindgen-cpp). It carries LiveKit's async support ported to uniffi 0.32, plus the `error_style = "expected"` option this directory's `uniffi.toml` turns on. The dev shell provides it. Without Nix: - -```bash -cargo install uniffi-bindgen-cpp --locked \ - --git https://github.com/kixelated/uniffi-bindgen-cpp \ - --tag v0.11.0-kixelated.1+v0.32.2 -``` - -`flake.nix` pins the same tag and lists every other place that names it. - -## Shape - -Every generated type lives in `namespace moq`, spelled as in Rust (`moq::MoqClient`). Objects are `std::shared_ptr`; records and enums are values. - -- A fallible call returns `uniffi::expected`. `uniffi::expected` is `std::expected` on C++23 and a bundled `tl::expected` below it. Build the generated sources with the same standard as the code that includes them. -- An async call returns `uniffi::Future`. Block on it with `get()` or `wait_for()`, or attach a continuation with `std::move(future).then(executor, callback)`, which returns a `uniffi::FutureContinuation` handle. -- `moq::MoqError` is a value holding a `std::variant` of its cases. Nothing throws. A Rust panic or a misused future aborts with a message on stderr. -- Continuations run on one process-wide dispatcher thread unless the application installs its own with `uniffi::set_async_dispatcher` before the first async call. Call `uniffi::shutdown_async_dispatcher()` before unloading the code a continuation could call into. It stops dispatch and abandons every pending future. - -## Cancellation - -Cancelling a future (`cancel()`, destroying it, or destroying the `FutureContinuation` returned by `then`) drops the Rust future. Native moq-ffi runs each async call as a spawned task that holds an `AbortOnDrop` on it (`rs/moq-ffi/src/ffi.rs`), so dropping the future aborts the work at its next await point instead of letting it finish unobserved. The continuation of a cancelled future never runs, and `get()` on it aborts. - -The abort discards the operation, not the handle it ran on, so the next call on that object works. Where a method keeps partial progress, its doc comment says so. For example, a cancelled `read_frame` leaves the current group for the next read. Every write in moq-ffi (`write_frame`, `finish`, `abort`) is synchronous, so cancelling a future can never tear a write. Only the async operations (reads, subscribes, connects, accepts, and `reject`) can be cut short. diff --git a/cpp/ffi/probe.cpp b/cpp/ffi/probe.cpp deleted file mode 100644 index feda812adb..0000000000 --- a/cpp/ffi/probe.cpp +++ /dev/null @@ -1,125 +0,0 @@ -// Exercises the generated C++ bindings end to end over a real QUIC session: connect, -// subscribe, read a frame through a future, cancel a pending read, and observe errors -// as returned values. Built with exceptions and RTTI disabled. - -#include - -#include -#include -#include -#include -#include -#include -#include - -using namespace std::chrono_literals; - -// Unlike assert, this survives a release build. -#define CHECK(expr) \ - do { \ - if (!(expr)) { \ - std::fprintf(stderr, "%s:%d: CHECK failed: %s\n", __FILE__, __LINE__, #expr); \ - std::abort(); \ - } \ - } while (0) - -namespace { - -[[noreturn]] void fail(const char *what, const moq::MoqError &error) { - std::fprintf(stderr, "%s failed: MoqError variant %zu\n", what, error.get_variant().index()); - std::abort(); -} - -// Unwraps a result the probe expects to succeed. -template -T ok(uniffi::expected result, const char *what) { - if (!result) { - fail(what, result.error()); - } - return std::move(*result); -} - -void ok(uniffi::expected result, const char *what) { - if (!result) { - fail(what, result.error()); - } -} - -std::vector bytes(const std::string &text) { - return std::vector(text.begin(), text.end()); -} - -} // namespace - -int main() { - // A synchronous error is a returned value, not an exception. - auto unbound = moq::MoqServer::init(); - auto fingerprints = unbound->cert_fingerprints(); - CHECK(!fingerprints); - CHECK(std::holds_alternative(fingerprints.error().get_variant())); - - // Publisher: a server whose origin serves one broadcast with one track. - auto origin = moq::MoqOriginProducer::init({}); - auto broadcast = ok(origin->create_broadcast("probe"), "create_broadcast"); - auto track = ok(broadcast->publish_track("data", std::nullopt), "publish_track"); - ok(broadcast->announce({}), "announce"); - - auto server = moq::MoqServer::init(); - ok(server->set_bind("127.0.0.1:0"), "set_bind"); - ok(server->set_tls_generate({"localhost"}), "set_tls_generate"); - ok(server->set_publish(origin), "set_publish"); - auto addr = ok(server->listen().get(), "listen"); - - // Both halves of the handshake are futures, so they run concurrently while this - // thread blocks on one at a time. - auto client = moq::MoqClient::init(); - ok(client->set_tls_verify(false), "set_tls_verify"); - auto accepting = server->accept(); - auto connecting = client->connect("https://" + addr); - auto request = ok(accepting.get(), "accept"); - CHECK(request != nullptr); - auto served = ok(request->accept().get(), "request accept"); - auto session = ok(connecting.get(), "connect"); - - // Subscriber: resolve the announced broadcast and subscribe to its track. - auto announced = ok(session->consume()->announced_broadcast("probe"), "announced_broadcast"); - auto remote = ok(announced->available().get(), "available"); - auto consumer = ok(remote->subscribe_track("data", std::nullopt).get(), "subscribe_track"); - - // Cancel a read that has nothing to deliver yet. - auto pending = consumer->read_frame(); - CHECK(pending.wait_for(50ms) == std::future_status::timeout); - pending.cancel(); - CHECK(!pending.valid()); - - // The consumer survives the cancelled read and delivers the next frame. - auto reading = consumer->read_frame(); - ok(track->write_frame({bytes("hello"), 1000}), "write_frame"); - auto frame = ok(reading.get(), "read_frame"); - CHECK(frame.has_value()); - CHECK(frame->payload == bytes("hello")); - CHECK(frame->timestamp_us == 1000); - - // An async error arrives through the future as a returned value too: this track - // finished, so reading past its end on a second, finished consumer reports it. - ok(track->finish(), "track finish"); - auto ended = consumer->read_frame().get(); - CHECK(ended && !ended->has_value()); - - auto closed = moq::MoqClient::init(); - closed->cancel(); - auto refused = closed->connect("https://" + addr).get(); - CHECK(!refused); - CHECK(std::holds_alternative(refused.error().get_variant())); - - ok(broadcast->finish(), "broadcast finish"); - session->cancel(0); - served->cancel(0); - server->cancel(); - - // Stop dispatching continuations before the process tears down. - uniffi::shutdown_async_dispatcher(); - - std::printf("probe: ok\n"); - return 0; -} diff --git a/cpp/justfile b/cpp/justfile index efc2de2d50..5064fc02a5 100644 --- a/cpp/justfile +++ b/cpp/justfile @@ -1,8 +1,7 @@ #!/usr/bin/env just --justfile # -# The generated C++ bindings for rs/moq-ffi. Invoked from the repo root as -# `just cpp ` via the `mod cpp` import. The OBS plugin has its own -# module, `just obs`. +# The moq C++ package over rs/moq-ffi. Invoked from the repo root as `just cpp ` via +# the `mod cpp` import. The OBS plugin has its own module, `just obs`. set working-directory := '.' @@ -13,12 +12,13 @@ default: # are in scope; `just cpp check` (no FILES) always runs. CXX picks the compiler # on Unix; Windows uses MSVC. -# Build moq-ffi, regenerate cpp/ffi/generated, then compile and run the probe. +# Build and install cpp/moq, then build the probe against the installed package at C++17 and +# C++23 through CMake, and at C++17 through pkg-config, and run each. Also compiles the doc samples. check $FILES="": #!/usr/bin/env bash set -euo pipefail - if [[ -n "$FILES" ]] && ! grep -qE '^(cpp/ffi/|cpp/justfile$|rs/moq-ffi/)' <<< "$FILES"; then - echo "cpp: no C++ binding changes; skipping." + if [[ -n "$FILES" ]] && ! grep -qE '^(cpp/moq/|cpp/justfile$|rs/moq-ffi/|rs/libmoq/native-libs/|doc/lib/cpp/|doc/lib/samples\.sh$)' <<< "$FILES"; then + echo "cpp: no C++ package changes; skipping." exit 0 fi if ! command -v uniffi-bindgen-cpp >/dev/null 2>&1; then @@ -27,25 +27,53 @@ check $FILES="": exit 0 fi - cargo build --locked --package moq-ffi - target_dir=$(cargo metadata --format-version 1 --no-deps | jq -r .target_directory)/debug - case "$(uname -s)" in - Darwin) cdylib="$target_dir/libmoq_ffi.dylib" ;; - MINGW* | MSYS* | CYGWIN*) cdylib="$target_dir/moq_ffi.dll" ;; - *) cdylib="$target_dir/libmoq_ffi.so" ;; - esac - - rm -rf ffi/generated - uniffi-bindgen-cpp --library "$cdylib" --config ffi/uniffi.toml --out-dir ffi/generated - - cmake -S ffi -B ffi/build -DMOQ_FFI_LIB_DIR="$target_dir" -DCMAKE_BUILD_TYPE=Debug - cmake --build ffi/build --config Debug - if [[ -x ffi/build/probe ]]; then - ffi/build/probe - else - ffi/build/Debug/probe.exe + # Single-config generators build Debug. Visual Studio ignores CMAKE_BUILD_TYPE and gets + # --config Release, since the Rust staticlib links the release CRT (/MD) either way. + build=moq/build + prefix="$PWD/$build/prefix" + cmake -S moq -B "$build/package" -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_LIBDIR=lib + cmake --build "$build/package" --config Release + rm -rf "$prefix" + cmake --install "$build/package" --config Release --prefix "$prefix" + + # Every generated moq::MoqFoo needs its moq::Foo alias in the wrapper. + missing=$(comm -23 \ + <(sed -nE 's/^(struct|enum class) Moq([A-Za-z0-9]+);$/\2/p' "$prefix/include/moq/ffi/moq.hpp" | sort -u) \ + <(sed -nE 's/^using ([A-Za-z0-9]+) = Moq\1;$/\1/p' moq/include/moq/moq.hpp | sort -u)) + if [[ -n "$missing" ]]; then + echo "cpp check: cpp/moq/include/moq/moq.hpp lacks an alias for:" $missing >&2 + exit 1 + fi + + run() { + if [[ -x "$1/probe" ]]; then "$1/probe"; else "$1/Release/probe.exe"; fi + } + for std in 17 23; do + cmake -S moq/test -B "$build/test-$std" -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_STANDARD="$std" -DCMAKE_PREFIX_PATH="$prefix" + cmake --build "$build/test-$std" --config Release + run "$build/test-$std" + done + + # The samples in doc/lib/cpp compile against the installed headers. + bash ../doc/lib/samples.sh cpp ../doc/lib/cpp/index.md >"$build/doc-samples.cpp" + if [[ "$(uname -s)" != MINGW* && "$(uname -s)" != MSYS* ]]; then + "${CXX:-c++}" -std=c++17 -fsyntax-only -Wall -Werror -include moq/test/doc-samples.hpp \ + -I"$prefix/include" "$build/doc-samples.cpp" fi -# Remove the generated bindings and the probe build. + if command -v pkg-config >/dev/null 2>&1; then + export PKG_CONFIG_PATH="$prefix/lib/pkgconfig" + mkdir -p "$build/pkg-config" + # shellcheck disable=SC2046 # pkg-config output is a flag list. + "${CXX:-c++}" -std=c++17 -o "$build/pkg-config/probe" moq/test/probe.cpp \ + $(pkg-config --variable=sources moq-cpp) $(pkg-config --cflags --libs moq-cpp) + run "$build/pkg-config" + fi + +# Build the release tarball for the host into cpp/moq/dist. +package: + moq/build.sh --output moq/dist + +# Remove the package builds. clean: - rm -rf ffi/generated ffi/build + rm -rf moq/build moq/dist diff --git a/cpp/moq/.gitignore b/cpp/moq/.gitignore new file mode 100644 index 0000000000..8a468423f1 --- /dev/null +++ b/cpp/moq/.gitignore @@ -0,0 +1,2 @@ +# `just cpp check` builds, installs, and tests here. +/build/ diff --git a/cpp/moq/CMakeLists.txt b/cpp/moq/CMakeLists.txt new file mode 100644 index 0000000000..780b3f5b56 --- /dev/null +++ b/cpp/moq/CMakeLists.txt @@ -0,0 +1,125 @@ +cmake_minimum_required(VERSION 3.21) + +# The package version is human-owned, like go/wrapper/VERSION; release-cpp.yml tags cpp-v. +file(STRINGS "${CMAKE_CURRENT_LIST_DIR}/VERSION" _version LIMIT_COUNT 1) +if(NOT _version MATCHES "^[0-9]+\\.[0-9]+\\.[0-9]+$") + message(FATAL_ERROR "Unsupported version '${_version}' in cpp/moq/VERSION (want x.y.z)") +endif() + +project(moq-cpp VERSION ${_version} LANGUAGES CXX) + +# Builds moq-ffi as a staticlib with cargo and renders its bindings with uniffi-bindgen-cpp, +# the same no-Corrosion custom target rs/libmoq/CMakeLists.txt uses. Multi-config generators +# (Visual Studio) have no CMAKE_BUILD_TYPE and get the release profile. +set(_workspace "${CMAKE_CURRENT_SOURCE_DIR}/../..") +if(CMAKE_BUILD_TYPE STREQUAL "Debug") + set(_profile debug) + set(_cargo_flags "") +else() + set(_profile release) + set(_cargo_flags --release) +endif() +if(DEFINED ENV{CARGO_TARGET_DIR}) + set(_cargo_target "$ENV{CARGO_TARGET_DIR}") +else() + set(_cargo_target "${_workspace}/target") +endif() +if(WIN32) + set(MOQ_FFI_LIB "${_cargo_target}/${_profile}/moq_ffi.lib") +else() + set(MOQ_FFI_LIB "${_cargo_target}/${_profile}/libmoq_ffi.a") +endif() + +find_program(UNIFFI_BINDGEN_CPP uniffi-bindgen-cpp REQUIRED) + +# Rendered to a scratch directory and copied only when changed, so a no-op cargo build does +# not recompile the bindings. +set(_generated "${CMAKE_CURRENT_BINARY_DIR}/include/moq/ffi") +set(_generated_files moq.hpp moq.cpp moq_scaffolding.hpp uniffi_expected.hpp) +list(TRANSFORM _generated_files PREPEND "${_generated}/" OUTPUT_VARIABLE _generated_paths) +set(_render "${CMAKE_CURRENT_BINARY_DIR}/render") +list(TRANSFORM _generated_files PREPEND "${_render}/" OUTPUT_VARIABLE _render_paths) +add_custom_target(moq_ffi ALL + COMMAND cargo build --locked --package moq-ffi ${_cargo_flags} + COMMAND ${CMAKE_COMMAND} -E rm -rf "${_render}" + COMMAND "${UNIFFI_BINDGEN_CPP}" --library "${MOQ_FFI_LIB}" + --config "${CMAKE_CURRENT_SOURCE_DIR}/uniffi.toml" --out-dir "${_render}" + COMMAND ${CMAKE_COMMAND} -E make_directory "${_generated}" + COMMAND ${CMAKE_COMMAND} -E copy_if_different ${_render_paths} "${_generated}" + WORKING_DIRECTORY "${_workspace}" + BYPRODUCTS "${MOQ_FFI_LIB}" ${_generated_paths} + COMMENT "Building moq-ffi and rendering its C++ bindings" + VERBATIM +) +# System libraries the staticlib needs from an external linker. moq-ffi and libmoq share one +# dependency tree, so they share rs/libmoq/native-libs/, which says how to regenerate it. +if(APPLE) + set(_platform apple) +elseif(WIN32) + set(_platform windows) +else() + set(_platform linux) +endif() +file(STRINGS "${_workspace}/rs/libmoq/native-libs/${_platform}.txt" _lines) +set(MOQ_NATIVE_LIBS "") +foreach(_line IN LISTS _lines) + string(STRIP "${_line}" _line) + if(_line STREQUAL "" OR _line MATCHES "^#") + continue() + elseif(_line MATCHES "^framework:(.+)$") + list(APPEND MOQ_NATIVE_LIBS "-framework ${CMAKE_MATCH_1}") + else() + list(APPEND MOQ_NATIVE_LIBS "${_line}") + endif() +endforeach() + +# The bindings compile here with the including project's standard and flags; see src/moq.cpp. +# Named moq-cpp so an in-tree build can sit beside libmoq's `moq` target. +add_library(moq-cpp STATIC src/moq.cpp) +add_library(moq-cpp::moq ALIAS moq-cpp) +add_dependencies(moq-cpp moq_ffi) +target_compile_features(moq-cpp PUBLIC cxx_std_17) +target_include_directories(moq-cpp PUBLIC + "$" + "$" +) +find_package(Threads REQUIRED) +target_link_libraries(moq-cpp PUBLIC "${MOQ_FFI_LIB}" ${MOQ_NATIVE_LIBS} Threads::Threads) + +if(PROJECT_IS_TOP_LEVEL) + include(GNUInstallDirs) + include(CMakePackageConfigHelpers) + + install(DIRECTORY include/moq DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) + install(FILES ${_generated_paths} DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/moq/ffi) + install(FILES src/moq.cpp DESTINATION ${CMAKE_INSTALL_DATADIR}/moq-cpp) + install(FILES "${MOQ_FFI_LIB}" DESTINATION ${CMAKE_INSTALL_LIBDIR}) + + # The config takes MOQ_NATIVE_LIBS as a list; pkg-config wants linker flags. + get_filename_component(MOQ_FFI_LIB_NAME "${MOQ_FFI_LIB}" NAME) + set(MOQ_NATIVE_LIBS_PC "") + foreach(_lib IN LISTS MOQ_NATIVE_LIBS) + if(_lib MATCHES "^-framework ") + string(APPEND MOQ_NATIVE_LIBS_PC " ${_lib}") + else() + string(APPEND MOQ_NATIVE_LIBS_PC " -l${_lib}") + endif() + endforeach() + + configure_package_config_file(cmake/moq-cpp-config.cmake.in + "${CMAKE_CURRENT_BINARY_DIR}/moq-cpp-config.cmake" + INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/moq-cpp + PATH_VARS CMAKE_INSTALL_INCLUDEDIR CMAKE_INSTALL_LIBDIR CMAKE_INSTALL_DATADIR + ) + write_basic_package_version_file("${CMAKE_CURRENT_BINARY_DIR}/moq-cpp-config-version.cmake" + COMPATIBILITY SameMinorVersion + ) + install(FILES + "${CMAKE_CURRENT_BINARY_DIR}/moq-cpp-config.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/moq-cpp-config-version.cmake" + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/moq-cpp + ) + + configure_file(cmake/moq-cpp.pc.in "${CMAKE_CURRENT_BINARY_DIR}/moq-cpp.pc" @ONLY) + install(FILES "${CMAKE_CURRENT_BINARY_DIR}/moq-cpp.pc" DESTINATION ${CMAKE_INSTALL_LIBDIR}/pkgconfig) +endif() diff --git a/cpp/moq/README.md b/cpp/moq/README.md new file mode 100644 index 0000000000..6545172fa7 --- /dev/null +++ b/cpp/moq/README.md @@ -0,0 +1,40 @@ +# moq C++ package + +C++17 bindings for [rs/moq-ffi](../../rs/moq-ffi): the sources `uniffi-bindgen-cpp` generates, plus `include/moq/moq.hpp`, a thin layer that renames them (`moq::MoqClient` is also `moq::Client`) and adds the executor, shutdown, and `co_await` glue. Nothing here re-implements a method; a shape problem is fixed in moq-ffi, where every binding benefits. User docs: [doc/lib/cpp](../../doc/lib/cpp/index.md). + +## Layout + +- `CMakeLists.txt` builds moq-ffi as a staticlib with cargo, renders the bindings into the build tree, and compiles them as the `moq-cpp::moq` target. Use it with `add_subdirectory`, or install it for `find_package(moq-cpp)` and `moq-cpp.pc`. +- `src/moq.cpp` compiles the generated `moq/ffi/moq.cpp`. It ships as source so it builds with the consumer's standard and flags. +- `cmake/` holds the installed CMake package and pkg-config templates. +- `build.sh` builds the release archive `release-cpp.yml` publishes. +- `test/probe.cpp` exercises the installed package over a real QUIC session. + +## Checking + +`just cpp check` builds and installs the package, builds the probe against it with `find_package` at C++17 and C++23 and with pkg-config at C++17, runs each, compiles the doc samples, and fails if a generated type lacks its short alias. The probe builds with exceptions and RTTI disabled. + +The generator is a fork, [kixelated/uniffi-bindgen-cpp](https://github.com/kixelated/uniffi-bindgen-cpp). It carries LiveKit's async support ported to uniffi 0.32, plus the `error_style = "expected"` option `uniffi.toml` turns on. The dev shell provides it. Without Nix: + +```bash +cargo install uniffi-bindgen-cpp --locked \ + --git https://github.com/kixelated/uniffi-bindgen-cpp \ + --tag v0.11.0-kixelated.1+v0.32.2 +``` + +`flake.nix` pins the same tag and lists every other place that names it. + +## Shape + +Every generated type lives in `namespace moq`. Objects are `std::shared_ptr`; records and enums are values. + +- A fallible call returns `moq::expected`, which is `std::expected` on C++23 and a bundled `tl::expected` below it. The generated sources and every includer must agree on which, so `moq/moq.hpp` references a symbol named for the one it sees and `src/moq.cpp` defines the one it was built with: a mismatch fails to link. +- An async call returns `moq::Future`. Block on it with `get()` or `wait_for()`, attach a continuation with `std::move(future).then(executor, callback)`, which returns a `moq::Continuation`, or `co_await` it on C++20. +- `moq::Error` is a value holding a `std::variant` of its cases. Nothing throws. A Rust panic or a misused future aborts with a message on stderr. +- Futures are polled and continuations run on one process-wide executor thread unless the application installs its own with `moq::set_executor` before the first async call. `moq::shutdown()` stops the moq-ffi runtime thread (`moq_ffi_shutdown`), then the executor, before unloading the code a continuation could call into. + +## Cancellation + +Cancelling a future (`cancel()`, destroying it, destroying the `moq::Continuation` returned by `then`, or destroying a coroutine suspended on it) drops the Rust future. Native moq-ffi runs each async call as a spawned task that holds an `AbortOnDrop` on it (`rs/moq-ffi/src/ffi.rs`), so dropping the future aborts the work at its next await point instead of letting it finish unobserved. The continuation of a cancelled future never runs, and `get()` on it aborts. + +The abort discards the operation, not the handle it ran on, so the next call on that object works. Where a method keeps partial progress, its doc comment says so. For example, a cancelled `read_frame` leaves the current group for the next read. Every write in moq-ffi (`write_frame`, `finish`, `abort`) is synchronous, so cancelling a future can never tear a write. Only the async operations (reads, subscribes, connects, accepts, and `reject`) can be cut short. diff --git a/cpp/moq/VERSION b/cpp/moq/VERSION new file mode 100644 index 0000000000..6e8bf73aa5 --- /dev/null +++ b/cpp/moq/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/cpp/moq/build.sh b/cpp/moq/build.sh new file mode 100755 index 0000000000..e9c961ecc9 --- /dev/null +++ b/cpp/moq/build.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build the moq C++ package for the host and archive it for release. +# Usage: ./build.sh [--target TARGET] [--output DIR] +# +# The archive holds what `cmake --install` lays out: the moq-ffi staticlib, the wrapper and +# generated headers, the generated source, the CMake package, and moq-cpp.pc. Needs cargo, cmake, +# and uniffi-bindgen-cpp on PATH. The version is cpp/moq/VERSION. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Shrink the release staticlib the way rs/libmoq/build.sh does: thin LTO with one codegen +# unit dead-strips the monomorphizations Rust bakes into a staticlib. +export CARGO_PROFILE_RELEASE_LTO="${CARGO_PROFILE_RELEASE_LTO:-thin}" +export CARGO_PROFILE_RELEASE_CODEGEN_UNITS="${CARGO_PROFILE_RELEASE_CODEGEN_UNITS:-1}" + +TARGET="" +OUTPUT_DIR="dist" + +while [[ $# -gt 0 ]]; do + case $1 in + --target) + TARGET="$2" + shift 2 + ;; + --output) + OUTPUT_DIR="$2" + shift 2 + ;; + -h | --help) + echo "Usage: $0 [--target TARGET] [--output DIR]" + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +VERSION=$(tr -d '[:space:]' <"$SCRIPT_DIR/VERSION") +HOST_TARGET=$(rustc -vV | sed -n 's/^host: //p') +TARGET="${TARGET:-$HOST_TARGET}" + +# CMake builds for the host. A mismatch would silently mislabel the archive. +if [[ "$TARGET" != "$HOST_TARGET" ]]; then + echo "Error: unsupported cross ($HOST_TARGET -> $TARGET); refusing to mislabel the archive." >&2 + exit 1 +fi + +NAME="moq-cpp-${VERSION}-${TARGET}" +mkdir -p "$OUTPUT_DIR" +OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)" +PACKAGE_DIR="$OUTPUT_DIR/$NAME" +BUILD_DIR="$SCRIPT_DIR/build/release" + +echo "Packaging $NAME..." +rm -rf "$PACKAGE_DIR" +# lib/, not the lib64/ GNUInstallDirs picks on some hosts, so every archive has one layout. +cmake -S "$SCRIPT_DIR" -B "$BUILD_DIR" -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_LIBDIR=lib +cmake --build "$BUILD_DIR" --config Release +cmake --install "$BUILD_DIR" --config Release --prefix "$PACKAGE_DIR" + +# A placeholder the configure step missed would reach a consumer's linker as a literal. +# Comments are skipped: configure_package_config_file names @PACKAGE_INIT@ in one. +if grep -rnE '^[^#]*@[A-Z_]+@' "$PACKAGE_DIR/lib/cmake" "$PACKAGE_DIR/lib/pkgconfig"; then + echo "Error: unsubstituted placeholder in the package config (see above)" >&2 + exit 1 +fi + +cd "$OUTPUT_DIR" +if [[ "$TARGET" == *"-windows-"* ]]; then + ARCHIVE="$NAME.zip" + if command -v 7z &>/dev/null; then + 7z a "$ARCHIVE" "$NAME" + else + zip -r "$ARCHIVE" "$NAME" + fi +else + ARCHIVE="$NAME.tar.gz" + tar -czf "$ARCHIVE" "$NAME" +fi +rm -rf "$NAME" + +echo "Created: $OUTPUT_DIR/$ARCHIVE" diff --git a/cpp/moq/cmake/moq-cpp-config.cmake.in b/cpp/moq/cmake/moq-cpp-config.cmake.in new file mode 100644 index 0000000000..1383fb754f --- /dev/null +++ b/cpp/moq/cmake/moq-cpp-config.cmake.in @@ -0,0 +1,26 @@ +# find_package(moq-cpp): the C++ bindings for moq-ffi as the moq-cpp::moq target. The +# namespace is the package name, so it never collides with libmoq's moq::moq. + +@PACKAGE_INIT@ + +if(NOT TARGET moq-cpp::moq) + find_package(Threads REQUIRED) + + # The prebuilt Rust staticlib, and the system libraries an external linker must add for + # it, baked in for the platform this package was built for. + add_library(moq-cpp::ffi STATIC IMPORTED) + set_target_properties(moq-cpp::ffi PROPERTIES + IMPORTED_LOCATION "@PACKAGE_CMAKE_INSTALL_LIBDIR@/@MOQ_FFI_LIB_NAME@" + INTERFACE_LINK_LIBRARIES "@MOQ_NATIVE_LIBS@;Threads::Threads" + ) + + # The generated bindings compile here, in the consumer's project, so they see the same + # standard and flags as the code that includes . + add_library(moq-cpp STATIC "@PACKAGE_CMAKE_INSTALL_DATADIR@/moq-cpp/moq.cpp") + target_compile_features(moq-cpp PUBLIC cxx_std_17) + target_include_directories(moq-cpp PUBLIC "@PACKAGE_CMAKE_INSTALL_INCLUDEDIR@") + target_link_libraries(moq-cpp PUBLIC moq-cpp::ffi) + add_library(moq-cpp::moq ALIAS moq-cpp) +endif() + +check_required_components(moq-cpp) diff --git a/cpp/moq/cmake/moq-cpp.pc.in b/cpp/moq/cmake/moq-cpp.pc.in new file mode 100644 index 0000000000..4c59f60249 --- /dev/null +++ b/cpp/moq/cmake/moq-cpp.pc.in @@ -0,0 +1,12 @@ +# The generated bindings ship as source: compile `pkg-config --variable=sources moq-cpp` with the +# same standard and flags as the code that includes . +prefix=${pcfiledir}/../.. +libdir=${prefix}/@CMAKE_INSTALL_LIBDIR@ +includedir=${prefix}/@CMAKE_INSTALL_INCLUDEDIR@ +sources=${prefix}/@CMAKE_INSTALL_DATADIR@/moq-cpp/moq.cpp + +Name: moq-cpp +Description: Media over QUIC for C++ +Version: @PROJECT_VERSION@ +Cflags: -I${includedir} +Libs: -L${libdir} -lmoq_ffi@MOQ_NATIVE_LIBS_PC@ diff --git a/cpp/moq/include/moq/moq.hpp b/cpp/moq/include/moq/moq.hpp new file mode 100644 index 0000000000..a2a5ee6708 --- /dev/null +++ b/cpp/moq/include/moq/moq.hpp @@ -0,0 +1,284 @@ +// The moq C++ package: the generated moq-ffi bindings under short names, plus the +// executor, shutdown, and coroutine glue they need. Every type and method is generated; +// this header only renames and wires them. +#pragma once + +#include + +#include +#include +#include + +#if defined(__cpp_impl_coroutine) && __has_include() +#include +#include +#include +#include +#include +#define MOQ_COROUTINES 1 +#endif + +// Not a uniffi export, so the generated bindings do not declare it; see moq::shutdown. +extern "C" void moq_ffi_shutdown(void); + +// uniffi::expected is std::expected or tl::expected depending on the standard, and the +// generated sources (src/moq.cpp) and every includer must agree. The bindings define the +// symbol for the type they saw and every includer references the one it sees, so a mismatch +// fails to link instead of corrupting memory. +#if defined(__cpp_lib_expected) && __cpp_lib_expected >= 202211L +#define MOQ_ABI moq_abi_std_expected +#define MOQ_ABI_NAME "moq_abi_std_expected" +#else +#define MOQ_ABI moq_abi_tl_expected +#define MOQ_ABI_NAME "moq_abi_tl_expected" +#endif +extern "C" const int MOQ_ABI; +#if defined(MOQ_IMPLEMENTATION) +extern "C" const int MOQ_ABI = 1; +#elif defined(_MSC_VER) +#pragma comment(linker, "/include:" MOQ_ABI_NAME) +#else +namespace moq::detail { +[[maybe_unused]] __attribute__((used)) static const int *const abi = &MOQ_ABI; +} // namespace moq::detail +#endif + +namespace moq { + +// Every generated `moq::MoqFoo` is also `moq::Foo`. `just cpp check` fails when one is missing. +using AnnounceConsumer = MoqAnnounceConsumer; +using AnnounceUpdate = MoqAnnounceUpdate; +using AnnouncedBroadcast = MoqAnnouncedBroadcast; +using AudioCodec = MoqAudioCodec; +using AudioConsumer = MoqAudioConsumer; +using AudioProducer = MoqAudioProducer; +using Bandwidth = MoqBandwidth; +using BinarySnapshotProducer = MoqBinarySnapshotProducer; +using BinaryStreamProducer = MoqBinaryStreamProducer; +using BroadcastConsumer = MoqBroadcastConsumer; +using BroadcastDynamic = MoqBroadcastDynamic; +using BroadcastProducer = MoqBroadcastProducer; +using BroadcastRequest = MoqBroadcastRequest; +using CatalogConsumer = MoqCatalogConsumer; +using Client = MoqClient; +using ContainerProducer = MoqContainerProducer; +using ContainerStreamProducer = MoqContainerStreamProducer; +using GroupConsumer = MoqGroupConsumer; +using GroupProducer = MoqGroupProducer; +using GroupRequest = MoqGroupRequest; +using JsonSnapshotConsumer = MoqJsonSnapshotConsumer; +using JsonSnapshotProducer = MoqJsonSnapshotProducer; +using JsonStreamConsumer = MoqJsonStreamConsumer; +using JsonStreamProducer = MoqJsonStreamProducer; +using MediaConsumer = MoqMediaConsumer; +using MediaGroupConsumer = MoqMediaGroupConsumer; +using MediaProducer = MoqMediaProducer; +using MediaStreamProducer = MoqMediaStreamProducer; +using OriginConsumer = MoqOriginConsumer; +using OriginDynamic = MoqOriginDynamic; +using OriginProducer = MoqOriginProducer; +using Request = MoqRequest; +using Reservation = MoqReservation; +using Server = MoqServer; +using Session = MoqSession; +using TrackConsumer = MoqTrackConsumer; +using TrackDemand = MoqTrackDemand; +using TrackDynamic = MoqTrackDynamic; +using TrackProducer = MoqTrackProducer; +using TrackRequest = MoqTrackRequest; +using VideoConsumer = MoqVideoConsumer; +using VideoProducer = MoqVideoProducer; +using AnnounceConfig = MoqAnnounceConfig; +using Audio = MoqAudio; +using AudioDecoderOutput = MoqAudioDecoderOutput; +using AudioEncoderInput = MoqAudioEncoderInput; +using AudioEncoderOutput = MoqAudioEncoderOutput; +using AudioFrame = MoqAudioFrame; +using AudioInit = MoqAudioInit; +using Backoff = MoqBackoff; +using BinaryConfig = MoqBinaryConfig; +using Catalog = MoqCatalog; +using ConnectionStats = MoqConnectionStats; +using ContainerInit = MoqContainerInit; +using Datagram = MoqDatagram; +using Dimensions = MoqDimensions; +using FetchGroupOptions = MoqFetchGroupOptions; +using Frame = MoqFrame; +using JsonSnapshotConfig = MoqJsonSnapshotConfig; +using JsonStreamConfig = MoqJsonStreamConfig; +using MediaFrame = MoqMediaFrame; +using OriginConfig = MoqOriginConfig; +using ProtocolError = MoqProtocolError; +using Route = MoqRoute; +using Subscription = MoqSubscription; +using TrackInfo = MoqTrackInfo; +using Video = MoqVideo; +using VideoDecodedFrame = MoqVideoDecodedFrame; +using VideoDecoderOutput = MoqVideoDecoderOutput; +using VideoEncoderInput = MoqVideoEncoderInput; +using VideoEncoderOutput = MoqVideoEncoderOutput; +using VideoFrame = MoqVideoFrame; +using VideoHint = MoqVideoHint; +using VideoInit = MoqVideoInit; +using VideoProperties = MoqVideoProperties; +using AudioFormat = MoqAudioFormat; +using AudioSampleFormat = MoqAudioSampleFormat; +using ConnectionStatus = MoqConnectionStatus; +using Container = MoqContainer; +using ContainerFormat = MoqContainerFormat; +using Error = MoqError; +using ErrorScope = MoqErrorScope; +using ProtocolKind = MoqProtocolKind; +using Transport = MoqTransport; +using VideoCodec = MoqVideoCodec; +using VideoEncoderKind = MoqVideoEncoderKind; +using VideoFormat = MoqVideoFormat; +using VideoPixelFormat = MoqVideoPixelFormat; + +// The value of a fallible call, or the Error that stopped it. std::expected on C++23. +template +using expected = ::uniffi::expected; + +// Wraps an Error so it converts to any moq::expected. +using ::uniffi::unexpected; + +// A pending async call: block with get() or wait_for(), or attach a continuation with then(). +template +using Future = ::uniffi::Future; + +// Owns a continuation attached with then(); destroying it cancels the call. +using Continuation = ::uniffi::FutureContinuation; + +// One unit of work handed to an Executor. +using Task = ::uniffi::AsyncTask; + +// Runs a Task somewhere and returns true, or returns false to refuse it. +using Executor = ::uniffi::AsyncDispatcher; + +// Runs a continuation on the thread that completed the future, for work that never blocks. +inline bool inline_executor(Task task) { + task(); + return true; +} + +// Replaces the default executor that polls futures, before the first async call. +// `shutdown` stops accepting tasks and returns once none can still run; moq::shutdown calls it. +inline void set_executor(Executor executor, std::function shutdown) noexcept { + ::uniffi::set_async_dispatcher(std::move(executor), std::move(shutdown)); +} + +// Stops the moq-ffi runtime thread, then the executor, before the process or module goes away. +// Pending calls resolve Cancelled. Call it once from a thread that is not running a continuation. +inline void shutdown() noexcept { + moq_ffi_shutdown(); + ::uniffi::shutdown_async_dispatcher(); +} + +// Sets the log level: "error", "warn", "info", "debug", "trace", or "". Errors if called twice. +inline expected log_level(const std::string &level) { + return moq_log_level(level); +} + +#ifdef MOQ_COROUTINES +namespace detail { + +// Resumes a coroutine with a future's result. Completion and cancellation share +// `state_`. The continuation resumes a claimed handle before it drops the mutex, +// so the destructor cannot free the frame between the claim and the resume. +template +class Awaiter { +public: + using Output = typename Future::Output; + + explicit Awaiter(Future future) noexcept: + future_(std::move(future)), state_(std::make_shared()) {} + + Awaiter(const Awaiter &) = delete; + Awaiter &operator=(const Awaiter &) = delete; + + // Destroying a suspended coroutine cancels the call, and it never resumes. + ~Awaiter() { + std::optional continuation; + { + // Recursive: resume() calls this on the completing thread while that + // thread still holds the mutex inside the continuation. + std::lock_guard guard(state_->mutex); + if (state_->resuming && state_->resume_on == std::this_thread::get_id()) { + return; + } + state_->destroying = true; + state_->handle = nullptr; + continuation = std::move(state_->continuation); + } + } + + bool await_ready() const noexcept { + return false; + } + + bool await_suspend(std::coroutine_handle<> handle) noexcept { + auto continuation = std::move(future_).then(inline_executor, [state = state_](Output output) { + std::lock_guard guard(state->mutex); + state->output.emplace(std::move(output)); + if (state->destroying || !state->handle) { + return; + } + auto resume = std::exchange(state->handle, nullptr); + state->resuming = true; + state->resume_on = std::this_thread::get_id(); + struct Clear { + State &state; + ~Clear() { + state.resuming = false; + } + } clear{*state}; + resume.resume(); + }); + + std::lock_guard guard(state_->mutex); + state_->continuation.emplace(std::move(continuation)); + if (state_->output) { + // Completed before suspending: carry on without a round trip. + return false; + } + state_->handle = handle; + return true; + } + + Output await_resume() noexcept { + std::lock_guard guard(state_->mutex); + return std::move(*state_->output); + } + +private: + struct State { + std::recursive_mutex mutex; + std::coroutine_handle<> handle; + std::optional output; + std::optional continuation; + bool destroying = false; + bool resuming = false; + std::thread::id resume_on{}; + }; + + Future future_; + std::shared_ptr state_; +}; + +} // namespace detail +#endif + +} // namespace moq + +#ifdef MOQ_COROUTINES +// Found by argument-dependent lookup on uniffi::Future. +namespace uniffi { + +// `co_await future` resumes with the future's result on the thread that completed it. +template +moq::detail::Awaiter operator co_await(Future &&future) noexcept { + return moq::detail::Awaiter(std::move(future)); +} + +} // namespace uniffi +#endif diff --git a/cpp/moq/src/moq.cpp b/cpp/moq/src/moq.cpp new file mode 100644 index 0000000000..7502a9f3ee --- /dev/null +++ b/cpp/moq/src/moq.cpp @@ -0,0 +1,6 @@ +// Compiles the generated bindings. Build it with the same C++ standard as the code that +// includes ; the MOQ_ABI symbol below turns a mismatch into a link error. +#define MOQ_IMPLEMENTATION +#include + +#include diff --git a/cpp/moq/test/CMakeLists.txt b/cpp/moq/test/CMakeLists.txt new file mode 100644 index 0000000000..b76f382000 --- /dev/null +++ b/cpp/moq/test/CMakeLists.txt @@ -0,0 +1,24 @@ +# A consumer of the installed package: `just cpp check` installs cpp/moq to a prefix, then +# builds this with find_package(moq-cpp) at C++17 and C++23. +cmake_minimum_required(VERSION 3.21) +project(moq-probe LANGUAGES CXX) + +if(NOT CMAKE_CXX_STANDARD) + set(CMAKE_CXX_STANDARD 17) +endif() +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +# No exceptions and no RTTI, the way Unreal builds. Set before find_package so the bindings, +# which compile in this project, get the same flags. +if(MSVC) + add_compile_options(/permissive- /EHs-c- /GR-) + add_compile_definitions(_HAS_EXCEPTIONS=0) +else() + add_compile_options(-Wall -Wextra -Werror -pedantic-errors -fno-exceptions -fno-rtti) +endif() + +find_package(moq-cpp REQUIRED) + +add_executable(probe probe.cpp) +target_link_libraries(probe PRIVATE moq-cpp::moq) diff --git a/cpp/moq/test/doc-samples.hpp b/cpp/moq/test/doc-samples.hpp new file mode 100644 index 0000000000..cb294154ba --- /dev/null +++ b/cpp/moq/test/doc-samples.hpp @@ -0,0 +1,17 @@ +// Inputs the samples in doc/lib/cpp/index.md leave undefined. `just cpp check` compiles +// every sample against the installed package with this included first. +#pragma once + +#include + +#include +#include +#include + +inline void report(const moq::Error &) {} + +inline moq::expected> session; +inline moq::expected> media; +inline std::vector opus_init; +inline std::vector packet; +inline std::vector rgba; diff --git a/cpp/moq/test/probe.cpp b/cpp/moq/test/probe.cpp new file mode 100644 index 0000000000..895adb6600 --- /dev/null +++ b/cpp/moq/test/probe.cpp @@ -0,0 +1,212 @@ +// Exercises the installed package end to end over a real QUIC session: connect, subscribe, +// read a frame through a future, a continuation, and (on C++20) a coroutine, cancel pending +// reads, and observe errors as returned values. Built with exceptions and RTTI disabled. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace std::chrono_literals; + +// Unlike assert, this survives a release build. +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + std::fprintf(stderr, "%s:%d: CHECK failed: %s\n", __FILE__, __LINE__, #expr); \ + std::abort(); \ + } \ + } while (0) + +namespace { + +[[noreturn]] void fail(const char *what, const moq::Error &error) { + std::fprintf(stderr, "%s failed: moq::Error variant %zu\n", what, error.get_variant().index()); + std::abort(); +} + +// Unwraps a result the probe expects to succeed. +template +T ok(moq::expected result, const char *what) { + if (!result) { + fail(what, result.error()); + } + return std::move(*result); +} + +void ok(moq::expected result, const char *what) { + if (!result) { + fail(what, result.error()); + } +} + +std::vector bytes(const std::string &text) { + return std::vector(text.begin(), text.end()); +} + +// Blocks main until a continuation or coroutine on the executor thread reports back. +class Latch { +public: + // Notifies under the lock: the waiter may destroy the latch as soon as it sees done_. + void set() { + std::lock_guard guard(mutex_); + done_ = true; + ready_.notify_all(); + } + + bool wait_for(std::chrono::milliseconds timeout) { + std::unique_lock lock(mutex_); + return ready_.wait_for(lock, timeout, [this] { return done_; }); + } + +private: + std::mutex mutex_; + std::condition_variable ready_; + bool done_ = false; +}; + +#ifdef MOQ_COROUTINES +// Just enough of a coroutine type to start one from main. It frees itself when it finishes, +// and main destroys one that is still suspended. +struct Coroutine { + struct promise_type { + Coroutine get_return_object() noexcept { + return Coroutine{std::coroutine_handle::from_promise(*this)}; + } + std::suspend_never initial_suspend() noexcept { return {}; } + std::suspend_never final_suspend() noexcept { return {}; } + void return_void() noexcept {} + void unhandled_exception() noexcept { std::abort(); } + }; + + std::coroutine_handle handle; +}; + +Coroutine read_one(std::shared_ptr consumer, std::optional &out, Latch &done) { + auto frame = co_await consumer->read_frame(); + out = ok(std::move(frame), "co_await read_frame"); + done.set(); +} +#endif + +} // namespace + +int main() { + // A synchronous error is a returned value, not an exception. + auto unbound = moq::Server::init(); + auto fingerprints = unbound->cert_fingerprints(); + CHECK(!fingerprints); + CHECK(std::holds_alternative(fingerprints.error().get_variant())); + + // Publisher: a server whose origin serves one broadcast with one track. + auto origin = moq::OriginProducer::init({}); + auto broadcast = ok(origin->create_broadcast("probe"), "create_broadcast"); + auto track = ok(broadcast->publish_track("data", std::nullopt), "publish_track"); + ok(broadcast->announce({}), "announce"); + + auto server = moq::Server::init(); + ok(server->set_bind("127.0.0.1:0"), "set_bind"); + ok(server->set_tls_generate({"localhost"}), "set_tls_generate"); + ok(server->set_publish(origin), "set_publish"); + auto addr = ok(server->listen().get(), "listen"); + + // Both halves of the handshake are futures, so they run concurrently while this + // thread blocks on one at a time. + auto client = moq::Client::init(); + ok(client->set_tls_verify(false), "set_tls_verify"); + auto accepting = server->accept(); + auto connecting = client->connect("https://" + addr); + auto request = ok(accepting.get(), "accept"); + CHECK(request != nullptr); + auto served = ok(request->accept().get(), "request accept"); + auto session = ok(connecting.get(), "connect"); + + // Subscriber: resolve the announced broadcast and subscribe to its track. + auto announced = ok(session->consume()->announced_broadcast("probe"), "announced_broadcast"); + auto remote = ok(announced->available().get(), "available"); + auto consumer = ok(remote->subscribe_track("data", std::nullopt).get(), "subscribe_track"); + + // Cancel a read that has nothing to deliver yet. + auto pending = consumer->read_frame(); + CHECK(pending.wait_for(50ms) == std::future_status::timeout); + pending.cancel(); + CHECK(!pending.valid()); + + // The consumer survives the cancelled read and delivers the next frame. + auto reading = consumer->read_frame(); + ok(track->write_frame({bytes("hello"), 1000}), "write_frame"); + auto frame = ok(reading.get(), "read_frame"); + CHECK(frame.has_value()); + CHECK(frame->payload == bytes("hello")); + CHECK(frame->timestamp_us == 1000); + + // A continuation receives the next frame on the executor. + { + Latch done; + std::optional received; + auto continuation = consumer->read_frame().then(moq::inline_executor, [&](moq::expected> result) { + received = ok(std::move(result), "then read_frame"); + done.set(); + }); + ok(track->write_frame({bytes("then"), 2000}), "write_frame"); + CHECK(done.wait_for(5s)); + CHECK(received && received->payload == bytes("then")); + } + +#ifdef MOQ_COROUTINES + // Destroying a coroutine suspended on a read cancels the read, and it never resumes. + { + Latch done; + std::optional received; + auto abandoned = read_one(consumer, received, done); + CHECK(!done.wait_for(50ms)); + abandoned.handle.destroy(); + } + + // A coroutine resumes with the next frame. + { + Latch done; + std::optional received; + auto reader = read_one(consumer, received, done); + ok(track->write_frame({bytes("co_await"), 3000}), "write_frame"); + CHECK(done.wait_for(5s)); + CHECK(received && received->payload == bytes("co_await")); + (void)reader; + } +#endif + + // An async error arrives through the future as a returned value too: this track + // finished, so reading past its end reports it. + ok(track->finish(), "track finish"); + auto ended = consumer->read_frame().get(); + CHECK(ended && !ended->has_value()); + + auto closed = moq::Client::init(); + closed->cancel(); + auto refused = closed->connect("https://" + addr).get(); + CHECK(!refused); + CHECK(std::holds_alternative(refused.error().get_variant())); + + ok(broadcast->finish(), "broadcast finish"); + session->cancel(0); + served->cancel(0); + server->cancel(); + + // Stop the runtime and the executor before the process tears down. The handles above + // stay safe to drop afterwards. + moq::shutdown(); + +#ifdef MOQ_COROUTINES + std::printf("probe: ok (with coroutines)\n"); +#else + std::printf("probe: ok\n"); +#endif + return 0; +} diff --git a/cpp/ffi/uniffi.toml b/cpp/moq/uniffi.toml similarity index 100% rename from cpp/ffi/uniffi.toml rename to cpp/moq/uniffi.toml diff --git a/doc/.vitepress/config.ts b/doc/.vitepress/config.ts index 5b6eb122e7..771ec87314 100644 --- a/doc/.vitepress/config.ts +++ b/doc/.vitepress/config.ts @@ -60,6 +60,7 @@ export default defineConfig({ { text: "Python", link: "/lib/py/" }, { text: "Go", link: "/lib/go/" }, { text: "Dart", link: "/lib/dart/" }, + { text: "C++", link: "/lib/cpp/" }, { text: "C", link: "/lib/c/" }, ], }, @@ -194,6 +195,7 @@ export default defineConfig({ { text: "Python", link: "/lib/py/" }, { text: "Go", link: "/lib/go/" }, { text: "Dart", link: "/lib/dart/" }, + { text: "C++", link: "/lib/cpp/" }, { text: "C", link: "/lib/c/" }, ], }, diff --git a/doc/index.md b/doc/index.md index 99cad74bd5..3d40448b70 100644 --- a/doc/index.md +++ b/doc/index.md @@ -145,6 +145,7 @@ Some highlights: FFI bindings around the Rust core, with idiomatic APIs in each language: +- [C++](/lib/cpp/) - RAII objects and cancellable futures, `co_await`-able on C++20. - [C](/lib/c/) - `libmoq` static + shared library with an auto-generated header. - [Python](/lib/py/) - `asyncio`-friendly bindings, published to PyPI. - [Kotlin](/lib/kt/) - Coroutines and `Flow` for Android and the JVM. diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index 7a3c5b08dd..408ca76f9b 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -7,10 +7,12 @@ description: libmoq, the stable C ABI over the Rust core [![GitHub release](https://img.shields.io/github/v/release/moq-dev/moq?filter=libmoq-v*\&label=libmoq)](https://github.com/moq-dev/moq/releases?q=libmoq) -`libmoq` exposes MoQ to C, C++, and any language with a C FFI through a +`libmoq` exposes MoQ to C and any language with a C FFI through a stable ABI: a generated `moq.h`, a static `libmoq.a` that links the whole Rust runtime in, and a pkg-config file for its native link dependencies. The -[OBS plugin](/bin/obs) is built on it. +[OBS plugin](/bin/obs) is built on it. C++ projects should prefer the +[C++ package](/lib/cpp/) (`moq-cpp`): the same core as RAII objects and futures instead of +handles and callbacks. ## Install diff --git a/doc/lib/cpp/index.md b/doc/lib/cpp/index.md new file mode 100644 index 0000000000..751ce0083c --- /dev/null +++ b/doc/lib/cpp/index.md @@ -0,0 +1,151 @@ +--- +title: C++ +description: RAII objects, futures, and moq::expected over the Rust core +--- + +# C++ + +[![GitHub release](https://img.shields.io/github/v/release/moq-dev/moq?filter=cpp-v*\&label=cpp)](https://github.com/moq-dev/moq/releases?q=cpp-v) + +``: every `moq-ffi` object as a `std::shared_ptr`, every async +call as a cancellable future, and every error as a returned `moq::expected` +value, so it builds with exceptions and RTTI disabled (Unreal's defaults). +C++17 is the floor; C++20 adds `co_await` on a future, and C++23 makes +`moq::expected` a `std::expected`. The bindings are generated from the same +[UniFFI](https://mozilla.github.io/uniffi-rs/) crate as the other wrappers, so +a method means the same thing here as in Python or Go. For a plain C ABI, use +[libmoq](/lib/c/). + +## Install + +Each [`cpp-v*` release](https://github.com/moq-dev/moq/releases?q=cpp-v) ships +`moq-cpp--.tar.gz` (`.zip` on Windows) holding the `moq-ffi` +static library, the headers, the generated bindings source, a CMake package, +and `moq-cpp.pc`. Targets: Linux x86\_64 and aarch64, macOS arm64, Windows x64. +No Rust toolchain is needed to consume it. + +```cmake ignore +find_package(moq-cpp REQUIRED) # with CMAKE_PREFIX_PATH at the unpacked archive +target_link_libraries(app PRIVATE moq-cpp::moq) +``` + +```bash +export PKG_CONFIG_PATH="moq-cpp-$ver-$target/lib/pkgconfig" +c++ -std=c++17 app.cpp $(pkg-config --variable=sources moq-cpp) $(pkg-config --cflags --libs moq-cpp) -o app +``` + +The generated bindings ship as source (`share/moq-cpp/moq.cpp`) and compile inside +your build, because `uniffi::expected` is `std::expected` or a bundled +`tl::expected` depending on the standard. Compile it with the same standard +as the code that includes ``; `find_package` does this for you, +and a mismatch fails to link rather than corrupting memory. On MSVC, link the +release runtime (`/MD`), which the Rust library uses in every configuration. + +From source, `add_subdirectory(cpp/moq)` in a checkout builds `moq-ffi` with +cargo and renders the bindings with the pinned `uniffi-bindgen-cpp` (see +[`cpp/moq`](https://github.com/moq-dev/moq/tree/main/cpp/moq)), then exposes +the same `moq-cpp::moq` target. The package is `moq-cpp`, so it installs beside +libmoq's `moq` package without colliding. + +## Example + +```cpp +#include + +// Subscribe. Every fallible call returns moq::expected; nothing throws. +auto client = moq::Client::init(); +auto session = client->connect("https://relay.example.com").get(); +if (!session) { + report(session.error()); // a moq::Error value + return; +} + +auto announced = (*session)->consume()->announced_broadcast("my-stream.hang"); +auto broadcast = (*announced)->available().get(); +auto catalogs = (*broadcast)->subscribe_catalog().get(); +auto catalog = (*catalogs)->next().get(); +if (catalog && *catalog) { + for (const auto &[name, video] : (*catalog)->video) { + auto media = (*broadcast)->subscribe_media(name, video.container, std::nullopt).get(); + auto frame = (*media)->next().get(); // std::nullopt once the track ends + } +} +``` + +```cpp +// Publish encoded frames, or raw pixels with the codec inside the binding. +// opus_init, packet, and rgba come from your encoder or capture source. +auto broadcast = (*session)->publish()->create_broadcast("my-stream.hang"); +auto audio = (*broadcast)->publish_audio({moq::AudioFormat::kOpus, opus_init}); +(void)(*audio)->write_frame({packet, 20'000}); + +moq::VideoEncoderOutput output{moq::VideoCodec::kH264, "camera", std::nullopt, std::nullopt, moq::VideoEncoderKind::kAuto{}}; +auto video = (*broadcast)->encode_video({moq::VideoPixelFormat::kRgba, 1280, 720, 30}, output, nullptr); +(void)(*video)->write({0, rgba}); +(void)(*broadcast)->announce({}); +(void)(*broadcast)->finish(); // keep the producer alive while publishing, then finish explicitly +``` + +## Futures + +An async call returns `moq::Future`, which delivers a `moq::expected`: + +- **Block** with `get()`, or bound the wait with `wait_for(timeout)`. +- **Continue** with `std::move(future).then(executor, callback)`. The returned + `moq::Continuation` owns the call: destroying it cancels. +- **Await** with `co_await` on C++20. The coroutine resumes on the thread that + completed the future. Destroying a coroutine suspended on a future cancels + the call, and it never resumes. +- **Cancel** by calling `cancel()` or destroying the future. The Rust future is + dropped at its next await point; the object it ran on stays usable, so the + next call works. A cancelled future's continuation never runs. + +```cpp +auto reading = (*media)->next(); +auto continuation = std::move(reading).then(moq::inline_executor, [](moq::expected> frame) { + // Runs on the executor thread: hand the frame off, never block here. +}); +``` + +```cpp ignore +// Inside a coroutine; the result is still a moq::expected. +auto frame = co_await (*media)->next(); +``` + +## Executor + +Futures are polled, and continuations run, on one process-wide executor +thread unless you install your own with `moq::set_executor(executor, +shutdown)` before the first async call. An `Executor` takes a `moq::Task` and +returns true once it has accepted it; `shutdown` stops accepting and returns +once no task can still run. A host with its own threads (a game engine, OBS) +installs one that hops onto them. + +Never block the executor. A continuation or resumed coroutine that calls +`get()` on another future waits for a poll that only the executor can run, +which deadlocks. `moq::inline_executor` runs a continuation right where the +future completed, which is the executor thread. + +## Shutdown + +`moq::shutdown()` stops the `moq-ffi` runtime thread, then the executor. +Pending calls resolve `Cancelled`, and every object stays safe to drop. Call +it once before a plugin or engine module unloads, from a thread that is not +running a continuation, so no thread is left calling into unmapped code. A +process that exits normally can skip it. + +## Errors + +`moq::Error` holds a `std::variant` of cases (`moq::Error::kCancelled`, +`kUnauthorized`, `kProtocol`, ...); test one with +`std::holds_alternative(error.get_variant())`. Auth +rejections are their own cases, so you don't retry them. A Rust panic or a +misused future (`get()` twice) aborts with a message on stderr instead of +throwing. + +Everything else maps one to one onto the +[shared feature list](/lib/#what-every-binding-can-do): each generated +`moq::MoqFoo` is also `moq::Foo`, and each Rust method keeps its name. The +header is the reference; every method carries its doc comment. + +- Source: [`cpp/moq`](https://github.com/moq-dev/moq/tree/main/cpp/moq); `just cpp check` builds, installs, and tests it locally diff --git a/doc/lib/index.md b/doc/lib/index.md index 27eaf5edd1..925936fd70 100644 --- a/doc/lib/index.md +++ b/doc/lib/index.md @@ -1,11 +1,11 @@ --- title: Libraries -description: MoQ libraries for Rust, TypeScript, C, Python, Kotlin, Swift, Go, and Dart +description: MoQ libraries for Rust, TypeScript, C++, C, Python, Kotlin, Swift, Go, and Dart --- # Libraries -Two primary implementations and six bindings, all speaking the same wire +Two primary implementations and seven bindings, all speaking the same wire protocol. A publisher in Python is consumable by a subscriber in Swift. | Language | Package | Best for | @@ -17,13 +17,14 @@ protocol. A publisher in Python is consumable by a subscriber in Swift. | [Python](/lib/py/) | `moq-rs` on PyPI | Scripts, ML pipelines, voice agents. | | [Go](/lib/go/) | `moq.dev/moq` | Go services and tooling. | | [Dart](/lib/dart/) | `moq` on pub.dev | Flutter apps. | -| [C](/lib/c/) | `libmoq` | C/C++ and any language with a C FFI. | +| [C++](/lib/cpp/) | `moq-cpp` via CMake or pkg-config | C++ apps, engines, and plugins. | +| [C](/lib/c/) | `libmoq` | The plain-C ABI, for C and any language with a C FFI; C++ should use the package above. | ## How they relate Rust is the reference implementation; every server-side tool is built on it. -TypeScript is a from-scratch browser implementation. The other six wrap the -Rust core: Python, Kotlin, Swift, Go, and Dart are generated from one +TypeScript is a from-scratch browser implementation. The other seven wrap the +Rust core: Python, Kotlin, Swift, Go, Dart, and C++ are generated from one [UniFFI](https://mozilla.github.io/uniffi-rs/) crate (`moq-ffi`) and then wrapped in an idiomatic layer, while C gets a hand-written stable ABI (`libmoq`). A feature added to the core lands in all of them together. diff --git a/doc/lib/samples.sh b/doc/lib/samples.sh index fe3636c277..ab2cbf318a 100755 --- a/doc/lib/samples.sh +++ b/doc/lib/samples.sh @@ -8,11 +8,11 @@ set -euo pipefail # # Inputs a sample leaves undefined (`opus_init_bytes`, `pts`) come from a prelude # the caller compiles alongside. Imports are hoisted above the functions, where -# Kotlin, Swift, and C require them. +# Kotlin, Swift, C, and C++ require them. # -# Usage: samples.sh python|kotlin|swift|c FILE... +# Usage: samples.sh python|kotlin|swift|c|cpp FILE... -lang="${1:?usage: samples.sh python|kotlin|swift|c FILE...}" +lang="${1:?usage: samples.sh python|kotlin|swift|c|cpp FILE...}" shift awk -v lang="$lang" ' @@ -22,6 +22,7 @@ BEGIN { else if (lang == "kotlin") { hoist = "^import "; opener = "suspend fun docSample%d() {"; closer = "}\n" } else if (lang == "swift") { hoist = "^import "; opener = "func docSample%d() async throws {"; closer = "}\n" } else if (lang == "c") { hoist = "^#include "; opener = "static int doc_sample_%d(void) {"; closer = " return 0;\n}\n" } + else if (lang == "cpp") { hoist = "^#include "; opener = "[[maybe_unused]] static void doc_sample_%d() {"; closer = "}\n" } else { print "samples.sh: unknown language " lang > "/dev/stderr"; exit 2 } comment = (lang == "python") ? "#" : "//" } diff --git a/flake.nix b/flake.nix index 1b3699d2aa..8b46681673 100644 --- a/flake.nix +++ b/flake.nix @@ -331,20 +331,21 @@ doCheck = false; }; - # uniffi-bindgen-cpp renders rs/moq-ffi into cpp/ffi/generated. Not in + # uniffi-bindgen-cpp renders rs/moq-ffi for cpp/moq's CMake build. Not in # nixpkgs, so build it from source; without it `just cpp check` skips # itself, which MOQ_STRICT turns into a failure in CI. # # Like uniffi-bindgen-go, the tag pairs the generator's version with the # uniffi release it reads, so it moves with the `uniffi` dependency in - # rs/moq-ffi/Cargo.toml. Five other places name the same tag and must be + # rs/moq-ffi/Cargo.toml. Six other places name the same tag and must be # bumped together: the `cargo install` lines in rs/moq-ffi/build.sh, - # cpp/justfile, cpp/ffi/README.md, .github/workflows/cpp.yml, and the - # cpp-windows job in .github/workflows/nightly.yml. + # cpp/justfile, cpp/moq/README.md, .github/workflows/cpp.yml, + # .github/workflows/release-cpp.yml, and the cpp-windows job in + # .github/workflows/nightly.yml. # # This points at a fork of LiveKit's async branch (livekit/uniffi-bindgen-cpp # PR #1): neither LiveKit nor NordSecurity has a uniffi 0.32 generator, - # and the fork adds `error_style = "expected"`, which cpp/ffi/uniffi.toml + # and the fork adds `error_style = "expected"`, which cpp/moq/uniffi.toml # turns on. Its tags add a `-kixelated.N` pre-release so they never # collide with upstream's. Move back upstream once one tags both. uniffi-bindgen-cpp = pkgs.rustPlatform.buildRustPackage rec { diff --git a/justfile b/justfile index 05c941d273..c4628f5817 100644 --- a/justfile +++ b/justfile @@ -11,7 +11,7 @@ mod kt mod swift mod go mod dart -# Generated C++ bindings for moq-ffi. +# The C++ package over moq-ffi. mod cpp # OBS Studio plugin (C++). See doc/bin/obs.md. mod obs 'cpp/obs' @@ -453,7 +453,7 @@ _tools $FILES="": # here to prevent. scoped '^(go/|rs/moq-ffi/)' && tools+=(go uniffi-bindgen-go cargo rsync) scoped '^(dart/|rs/moq-ffi/)' && tools+=(cargo dart uniffi_bindgen_dart) - scoped '^(cpp/ffi/|cpp/justfile$|rs/moq-ffi/)' && tools+=(cargo jq cmake c++ uniffi-bindgen-cpp) + scoped '^(cpp/moq/|cpp/justfile$|rs/moq-ffi/|rs/libmoq/native-libs/|doc/lib/cpp/|doc/lib/samples\.sh$)' && tools+=(cargo cmake c++ pkg-config uniffi-bindgen-cpp) # Two obs recipes with two dispatch scopes, so two lines: over-requiring # would fail a diff that never runs the recipe. `just obs compile` needs # cargo to regenerate moq.h and pkg-config to locate Qt6 and ffmpeg. Every diff --git a/quest/m1/cpp/README.md b/quest/m1/cpp/README.md index 4e945df607..7c0f612258 100644 --- a/quest/m1/cpp/README.md +++ b/quest/m1/cpp/README.md @@ -50,7 +50,6 @@ reads the same release manifest so a release bumps both. ## Quests -- [Package](/quest/m1/cpp/package.md) - the `cpp/moq` wrapper, CMake package, release tarball, interop client, and docs - [Error messages](/quest/m1/cpp/error-message.md) - a C++ `moq::Error` prints the same message Rust gives - [OBS migration](/quest/m1/cpp/obs.md) - the OBS plugin moves from libmoq handles and trampolines to the generated C++ diff --git a/quest/m1/cpp/obs.md b/quest/m1/cpp/obs.md index 475e1206cc..8d2314b285 100644 --- a/quest/m1/cpp/obs.md +++ b/quest/m1/cpp/obs.md @@ -31,10 +31,10 @@ unchanged: same settings, same dock, same reconnect and teardown timing. fake executor; keep the scenarios (stop during connect, late terminal, superseded attempt). - Build and release: `cpp/obs/CMakeLists.txt` consumes the package - (in-tree `cpp/` for `MOQ_LOCAL`, the release tarball otherwise); `obs.yml` + (in-tree `cpp/moq` for `MOQ_LOCAL`, the release tarball otherwise); `obs.yml` rides `release-cpp.yml` instead of `libmoq.yml`. `doc/bin/obs.md` says the plugin is C++ over the generated bindings. -- Shutdown: define `obs_module_unload` and call `moq_ffi_shutdown` there, +- Shutdown: define `obs_module_unload` and call `moq::shutdown()` there, after OBS has destroyed the outputs and sources, so the runtime thread is joined before the module is unmapped; log it so a hung shutdown is visible. A `cpp/obs/test` stub test asserts the unload calls it. Reproduce the crash @@ -44,10 +44,6 @@ unchanged: same settings, same dock, same reconnect and teardown timing. platforms; a manual publish and watch round trip against a relay with reconnect and mid-stream source deletion. -## Required - -- [Package](/quest/m1/cpp/package.md) - the wrapper and CMake package OBS links - ## Related - [OBS native codecs](/quest/m1/obs-moq-video/README.md) - its quests require this migration and build on moq-ffi's audio and video types diff --git a/quest/m1/cpp/package.md b/quest/m1/cpp/package.md deleted file mode 100644 index c372c4831e..0000000000 --- a/quest/m1/cpp/package.md +++ /dev/null @@ -1,44 +0,0 @@ -# [M] cpp/moq: the wrapper, CMake package, release tarball, interop client, and docs - -## Goal - -`find_package(moq)` or the tarball's `moq.pc` gives a C++17 program -``, and the program publishes and subscribes through the full -moq-ffi surface with futures and `moq::expected`, `co_await`-able on C++20. -A `test/interop/clients/cpp` client joins `just test interop --all`, and -`doc/lib/cpp` documents the package the way `doc/lib/go` does Go. - -## Plan - -- `cpp/ffi` is generated only (committed like `go/ffi`, regenerated by `just - cpp check`). `cpp/moq` is the thin wrapper: `namespace moq` aliases that drop - the `Moq` prefix and mirror the names the other wrappers use (`moq::Client`, - `moq::Broadcast`, `moq::Track`), `std::chrono::microseconds` for timestamps, - `std::string_view` and `std::span`-like views at the edges, and - `moq::Executor` for `then`. Nothing in the wrapper re-implements a method; - a shape problem is fixed in moq-ffi. -- Coroutines: an awaiter over `uniffi::Future` that resumes on the future's - continuation, compiled only under `__cpp_impl_coroutine`. Cancellation on - awaiter destruction follows the future's own rule. -- Executor: the default dispatcher is fine for scripts; document how a host - installs its own (`moq::set_executor`) before the first call, and that - continuations must not block on the moq-ffi runtime thread. -- Shutdown: `moq::shutdown()` calls `moq_ffi_shutdown` and - `uniffi::shutdown_async_dispatcher`, so a host that unloads (a plugin, an - engine module) releases the runtime thread's buffers; the generator probe - still reports about 72 bytes held at exit under valgrind without it. -- Build: `cpp/CMakeLists.txt` builds `libmoq_ffi` (staticlib, features - `video`+`audio`) with the same `cargo build` custom command and - `BUILD_RUST_LIB` switch `rs/libmoq/CMakeLists.txt` uses (no Corrosion, so - the two CMake entry points stay alike), compiles `cpp/ffi`, installs headers, `moqConfig.cmake`, and `moq.pc`. Windows MSVC, - macOS, and Linux, both architectures, matching `libmoq.yml`'s matrix. -- Release: `release-cpp.yml` mirrors `libmoq.yml` (tag `cpp-v*`, nightly - dry-run through the `workflow_call` chain), producing one tarball per target - containing the static library, generated sources, wrapper headers, and the - CMake package. The registries quest consumes these artifacts. -- Interop: `test/interop/clients/cpp` subscribes and publishes like the Go client; - the C client stays, since libmoq stays. -- Docs: `doc/lib/cpp/index.md` (install via tarball, then the registries once - they exist; the future, expected, executor, and coroutine rules) and a row in - `doc/lib/index.md`. The C row now says libmoq is the plain-C ABI and points - C++ readers at the new package. diff --git a/quest/m2/cpp-conan.md b/quest/m2/cpp-conan.md index a4d1dfe8d4..902229bac8 100644 --- a/quest/m2/cpp-conan.md +++ b/quest/m2/cpp-conan.md @@ -2,14 +2,14 @@ ## Goal -A consumer adds the moq Conan remote, requires `moq/`, and gets the +A consumer adds the moq Conan remote, requires `moq-cpp/`, and gets the prebuilt package for their `os`, `arch`, and `compiler` without a Rust toolchain or the bindgen fork. A fresh consumer project installs it in CI on Windows, macOS, and Linux. ## Plan -- A `moq` recipe on a moq-dev remote (Artifactory or a GitHub-hosted `conan` +- A `moq-cpp` recipe on a moq-dev remote (Artifactory or a GitHub-hosted `conan` index) that packages the prebuilt release tarball per setting and exports the CMake target from `package_info`. - The recipe reads the release manifest the vcpkg quest introduced, so one diff --git a/quest/m2/cpp-vcpkg.md b/quest/m2/cpp-vcpkg.md index 40b98e73fc..5cc31a069d 100644 --- a/quest/m2/cpp-vcpkg.md +++ b/quest/m2/cpp-vcpkg.md @@ -3,13 +3,13 @@ ## Goal A consumer adds `moq-dev/vcpkg-registry` to `vcpkg-configuration.json`, -depends on `moq`, and gets the prebuilt package for their triple without a +depends on `moq-cpp`, and gets the prebuilt package for their triple without a Rust toolchain or the bindgen fork. A fresh consumer project installs it in CI on Windows, macOS, and Linux. ## Plan -- `moq-dev/vcpkg-registry`: a git registry with a `moq` port whose portfile +- `moq-dev/vcpkg-registry`: a git registry with a `moq-cpp` port whose portfile downloads the per-target release tarball from `release-cpp.yml` by version and hash, installs headers, the static library, and the CMake config, and declares `supports` for exactly the release matrix. Versioning follows the @@ -25,7 +25,7 @@ CI on Windows, macOS, and Linux. ## Required -- [Package](/quest/m1/cpp/package.md) - the release tarballs the port fetches +- The first `cpp-v*` release from `release-cpp.yml`, the tarballs the port fetches ## Related diff --git a/quest/m2/unreal.md b/quest/m2/unreal.md index 7062b81245..b349a5db96 100644 --- a/quest/m2/unreal.md +++ b/quest/m2/unreal.md @@ -25,5 +25,5 @@ editor stability across a play-stop-play cycle. ## Required -- [Package](/quest/m1/cpp/package.md) - the tarball the module links +- The first `cpp-v*` release from `release-cpp.yml`, the tarball the module links - [Decoded frame ownership](/quest/m1/decoded-frames.md) - the decoded frames the texture needs diff --git a/rs/libmoq/native-libs/apple.txt b/rs/libmoq/native-libs/apple.txt index a4315b8748..eee3ecb3bb 100644 --- a/rs/libmoq/native-libs/apple.txt +++ b/rs/libmoq/native-libs/apple.txt @@ -3,8 +3,9 @@ # linking the staticlib has to spell them out. # # Canonical list. build.rs bakes it into moq.pc, CMakeLists.txt reads it for the -# moq::moq target and the installed find_package config, and test/interop/interop.sh -# links its C client with it. Keep the derived consumers reading this file rather +# moq::moq target and the installed find_package config, test/interop/interop.sh +# links its C client with it, and cpp/moq/CMakeLists.txt reuses it for moq-ffi, +# which has the same dependency tree. Keep the derived consumers reading this file rather # than repeating the list. # # One entry per line. `framework:Foo` is an Apple framework; anything else is a diff --git a/rs/moq-ffi/build.sh b/rs/moq-ffi/build.sh index 3c4e19b530..d1c36ca316 100755 --- a/rs/moq-ffi/build.sh +++ b/rs/moq-ffi/build.sh @@ -195,7 +195,7 @@ generate_bindings() { if command -v uniffi-bindgen-cpp >/dev/null 2>&1; then echo " Generating cpp bindings..." uniffi-bindgen-cpp --library "$lib_path" \ - --config "$WORKSPACE_DIR/cpp/ffi/uniffi.toml" \ + --config "$WORKSPACE_DIR/cpp/moq/uniffi.toml" \ --out-dir "$OUTPUT_DIR/bindings/cpp" else echo " Skipping cpp bindings: uniffi-bindgen-cpp not on PATH" diff --git a/test/interop/README.md b/test/interop/README.md index e41a0d7179..84c7493546 100644 --- a/test/interop/README.md +++ b/test/interop/README.md @@ -16,7 +16,7 @@ Most subscribers check for a non-empty frame. The browser additionally verifies WebCodecs output painted to a canvas and drives the player's pause/resume controls. Every publisher but the Rust CLI also carries an Opus track, which the browser subscriber checks end-to-end: the browser encodes fake microphone audio, and the -Python and Go clients encode a synthetic tone through `moq-ffi` at a 2.5 ms frame +Python, Go, and C++ clients encode a synthetic tone through `moq-ffi` at a 2.5 ms frame duration, so the matrix covers the FFI audio path with a non-default codec config. `just test media` is a separate, browser-only run that asks a harder @@ -30,6 +30,7 @@ player survive the publication lifecycle. See [Media QA](#media-qa). | Rust | `rs/moq-relay` + `rs/moq-cli` | `cargo build` | publish (video) + subscribe | | Python | `py/moq-rs` (+ `rs/moq-ffi`, import `moq`) | `just py build` (maturin editable into `.venv`) | publish (video + audio) + subscribe | | Go | `go/wrapper` (+ `rs/moq-ffi`, import `moq-go/moq`) | `go/scripts/stage.sh` (uniffi-bindgen-go) + `go build` | publish (video + audio) + subscribe | +| C++ | `cpp/moq` (+ `rs/moq-ffi`, `find_package(moq-cpp)`) | `cmake` build + install of `cpp/moq` (uniffi-bindgen-cpp), then `cmake` for the client | publish (video + audio) + subscribe | | Browser | `js/watch` + `js/publish` | `vite build` + headless Chromium (Playwright) | publish (video + audio) + rendered playback | | Native JS | `js/net` + `js/hang` + the npm `@moq/web-transport` polyfill | `node` (tsx) and `bun` | subscribe | | C | `rs/libmoq` | `cargo build -p libmoq` + `cc` | subscribe | @@ -38,7 +39,7 @@ player survive the publication lifecycle. See [Media QA](#media-qa). The browser, native JS, C, and GStreamer clients subscribe only by choice (publishing media needs an encoder the native JS runtimes lack, the C client is intentionally minimal, and `moqsink` publishing needs request-pad muxing this -client doesn't drive). Rust, Python, Go, and the browser publish. +client doesn't drive). Rust, Python, Go, C++, and the browser publish. The Go client builds against the modules `go/scripts/stage.sh` assembles from this checkout: `moq-ffi` compiled for the host, bindings regenerated with @@ -47,6 +48,12 @@ That is the same staging `just go check` uses, so this cell covers the Go wrapper end to end rather than only compiling it. A shell without `uniffi-bindgen-go` (the nix devShell ships it) marks the cell unavailable. +The C++ client builds against the package the way an external project would: +`interop.sh` configures, builds, and installs `cpp/moq` into the run directory, +then builds `clients/cpp` with `find_package(moq-cpp)` pointed at that prefix. A +shell without `cmake` or `uniffi-bindgen-cpp` (the nix devShell ships both) +marks the cell unavailable. + The GStreamer client builds the `moqsrc` plugin from `rs/moq-gst` and points `GST_PLUGIN_PATH` at it, then reads a broadcast with `gst-launch-1.0 moqsrc ... ! filesink`. The plugin dynamic-links the host's @@ -62,7 +69,7 @@ workspace packages, because the JS clients here are bun workspace members. ## Running locally You need the workspace toolchain on `PATH` (cargo, ffmpeg, bun, uv, go, -uniffi-bindgen-go, a C compiler). `nix develop` provides all of it except +uniffi-bindgen-go, cmake, uniffi-bindgen-cpp, a C/C++ compiler). `nix develop` provides all of it except Playwright's Chromium, which `interop.sh` fetches on first run (`bunx playwright install chromium`). @@ -70,7 +77,7 @@ Playwright's Chromium, which # Default: rust publishes, rust subscribes (a fast sanity check). just test interop -# Full matrix: rust/python/go/browser publish; everyone subscribes. +# Full matrix: rust/python/go/cpp/browser publish; everyone subscribes. just test interop --all # Pick your own axes: @@ -83,8 +90,8 @@ just test interop-negative just test media ``` -Subscriber names: `rust`, `python`, `go`, `js` (browser), `js-native-node`, -`js-native-bun`, `c`, `gst`. Publisher names: `rust`, `python`, `go`, `js`. +Subscriber names: `rust`, `python`, `go`, `cpp`, `js` (browser), `js-native-node`, +`js-native-bun`, `c`, `gst`. Publisher names: `rust`, `python`, `go`, `cpp`, `js`. A client whose source build fails fails only its own matrix cells (see `mark_broken` in `interop.sh`); it never aborts the rest of the run. @@ -165,6 +172,7 @@ interop.toml relay config (anonymous, self-signed localhost) clients/ python/interop.py publish/subscribe via py/moq-rs (import moq) go/main.go publish/subscribe via go/wrapper (import moq-go/moq) + cpp/main.cpp publish/subscribe via cpp/moq (find_package(moq-cpp)) js/ headless-Chromium publish/subscribe via @moq/watch + @moq/publish driver.ts the interop matrix's browser publisher/subscriber media.ts the media output + lifecycle checks diff --git a/test/interop/clients/cpp/CMakeLists.txt b/test/interop/clients/cpp/CMakeLists.txt new file mode 100644 index 0000000000..b1ad0c3d34 --- /dev/null +++ b/test/interop/clients/cpp/CMakeLists.txt @@ -0,0 +1,13 @@ +# The C++ interop client, built against the installed cpp/moq package the way an external +# project would: interop.sh installs cpp/moq to a prefix and points CMAKE_PREFIX_PATH at it. +cmake_minimum_required(VERSION 3.21) +project(cpp-interop LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +find_package(moq-cpp REQUIRED) + +add_executable(cpp-interop main.cpp) +target_link_libraries(cpp-interop PRIVATE moq-cpp::moq) diff --git a/test/interop/clients/cpp/main.cpp b/test/interop/clients/cpp/main.cpp new file mode 100644 index 0000000000..7ed23a253b --- /dev/null +++ b/test/interop/clients/cpp/main.cpp @@ -0,0 +1,258 @@ +// Cross-language interop client for the interop test, built against the installed cpp/moq +// package with find_package(moq-cpp). +// +// publish reads raw Annex-B H.264 from stdin (e.g. piped from ffmpeg) and feeds it to a +// streaming importer, which infers frame boundaries. Alongside it, a synthetic tone is +// encoded through libopus so the matrix exercises the FFI audio path, not only the video +// one. subscribe connects, finds the video track in the catalog, and exits 0 as soon as any +// non-empty frame arrives (exit 1 on timeout or no data). +// +// ffmpeg ... -f h264 - | cpp-interop publish --url http://localhost:4443 --broadcast b.hang +// cpp-interop subscribe --url http://localhost:4443 --broadcast b.hang --timeout 20 + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#define read_stdin(buf, len) _read(0, buf, static_cast(len)) +#else +#include +#define read_stdin(buf, len) ::read(0, buf, len) +#endif + +namespace { + +using Clock = std::chrono::steady_clock; + +constexpr size_t READ_CHUNK = 64 * 1024; + +// SubscribeMedia max age: how much reordering the jitter buffer tolerates. +constexpr uint64_t MAX_AGE_US = 1'000'000; + +// Synthetic audio: a 48 kHz mono tone, encoded as Opus. +constexpr const char *AUDIO_TRACK = "tone"; +constexpr uint32_t AUDIO_RATE = 48'000; +constexpr double AUDIO_TONE_HZ = 440.0; +constexpr double PI = 3.14159265358979323846; +// A non-default frame duration, and the shortest Opus offers. 20 ms would pass even if the +// microsecond field were truncated to milliseconds somewhere. +constexpr uint32_t AUDIO_FRAME_DURATION_US = 2'500; +// Written in 20 ms batches, so each write spans eight encoded Opus frames. +constexpr uint64_t AUDIO_BATCH_US = 20'000; +constexpr size_t AUDIO_BATCH_SAMPLES = AUDIO_RATE * AUDIO_BATCH_US / 1'000'000; + +template +struct has_message: std::false_type {}; +template +struct has_message().v1)>>: std::true_type {}; + +// moq::Error has no Display in the bindings yet, so name the variant and its message. +[[noreturn]] void fail(const char *what, const moq::Error &error) { + std::string message = std::visit( + [](const auto &variant) -> std::string { + if constexpr (has_message>::value) { + return variant.v1; + } else { + return ""; + } + }, + error.get_variant() + ); + std::fprintf(stderr, "error: %s: moq::Error variant %zu %s\n", what, error.get_variant().index(), message.c_str()); + std::exit(1); +} + +[[noreturn]] void fail(const char *what) { + std::fprintf(stderr, "error: %s\n", what); + std::exit(1); +} + +template +T ok(moq::expected result, const char *what) { + if (!result) { + fail(what, result.error()); + } + return std::move(*result); +} + +void ok(moq::expected result, const char *what) { + if (!result) { + fail(what, result.error()); + } +} + +// Blocks on a future until the deadline; a timeout cancels it and fails the run. +template +T until(moq::Future future, Clock::time_point deadline, const char *what) { + if (future.wait_for(deadline - Clock::now()) != std::future_status::ready) { + fail((std::string(what) + ": timed out").c_str()); + } + return ok(future.get(), what); +} + +// The session owns its connection; the client that made it can go. +std::shared_ptr connect(const std::string &url, Clock::time_point deadline) { + auto client = moq::Client::init(); + ok(client->set_tls_verify(false), "set_tls_verify"); + return until(client->connect(url), deadline, "connect"); +} + +// Feeds the encoder a real-time tone until `stop` is set. +void publish_tone(const std::shared_ptr &audio, const std::atomic &stop) { + const auto started = Clock::now(); + std::vector data(AUDIO_BATCH_SAMPLES * sizeof(float)); + uint64_t timestamp_us = 0; + size_t phase = 0; + while (!stop.load()) { + for (size_t i = 0; i < AUDIO_BATCH_SAMPLES; i++) { + auto sample = static_cast(std::sin(2 * PI * AUDIO_TONE_HZ * static_cast(phase + i) / AUDIO_RATE)); + // Little-endian f32, which is every target this runs on. + std::memcpy(&data[i * sizeof(float)], &sample, sizeof(float)); + } + ok(audio->write({timestamp_us, data}), "tone write"); + phase += AUDIO_BATCH_SAMPLES; + timestamp_us += AUDIO_BATCH_US; + // Pace against the start, so encoding cost doesn't accumulate as drift. + std::this_thread::sleep_until(started + std::chrono::microseconds(timestamp_us)); + } +} + +int publish(const std::string &url, const std::string &path) { + // No deadline to publish: the harness stops the publisher when the round ends. + const auto forever = Clock::now() + std::chrono::hours(24); + auto session = connect(url, forever); + + // Hold the producer for the lifetime of the publish loop; finish() unpublishes. + auto broadcast = ok(session->publish()->create_broadcast(path), "create_broadcast"); + auto media = ok(broadcast->publish_video_stream({moq::VideoFormat::kAvc3, {}}), "publish_video_stream"); + moq::AudioEncoderOutput output{moq::AudioCodec::opus()}; + output.frame_duration_us = AUDIO_FRAME_DURATION_US; + auto audio = ok( + broadcast->encode_audio(AUDIO_TRACK, {moq::AudioSampleFormat::kF32, AUDIO_RATE, 1}, output, nullptr), + "encode_audio" + ); + ok(broadcast->announce({}), "announce"); + std::printf( + "publishing \"%s\" (Annex-B H.264 from stdin + a %.0f Hz tone) to %s\n", path.c_str(), AUDIO_TONE_HZ, + url.c_str() + ); + std::fflush(stdout); + + std::atomic stop{false}; + std::thread tone([&] { publish_tone(audio, stop); }); + + // read returns as soon as any bytes are available, so ffmpeg's real-time output is + // forwarded rather than batched into full chunks. + std::vector buf(READ_CHUNK); + for (;;) { + auto n = read_stdin(buf.data(), buf.size()); + if (n < 0) { + fail("read stdin"); + } + if (n == 0) { + break; + } + ok(media->write(std::vector(buf.begin(), buf.begin() + n)), "video write"); + } + + // Let the tone unwind before finishing, so no write races a finished producer. + stop.store(true); + tone.join(); + ok(audio->finish(), "audio finish"); + ok(media->finish(), "video finish"); + ok(broadcast->finish(), "broadcast finish"); + session->cancel(0); + moq::shutdown(); + return 0; +} + +int subscribe(const std::string &url, const std::string &path, double timeout) { + const auto deadline = + Clock::now() + std::chrono::duration_cast(std::chrono::duration(timeout)); + auto session = connect(url, deadline); + + auto announced = ok(session->consume()->announced_broadcast(path), "announced_broadcast"); + auto consumer = until(announced->available(), deadline, "available"); + + // The catalog is a live track. A lazy publisher (e.g. the browser, which only encodes on + // demand) may announce video in a later update rather than the first snapshot, so wait + // for a catalog that actually has a video track. + auto catalogs = until(consumer->subscribe_catalog(), deadline, "subscribe_catalog"); + std::optional catalog; + while (!catalog || catalog->video.empty()) { + catalog = until(catalogs->next(), deadline, "catalog"); + if (!catalog) { + fail("catalog stream ended without a video track"); + } + } + const auto &[name, video] = *catalog->video.begin(); + + moq::Subscription subscription; + subscription.max_age_us = MAX_AGE_US; + auto media = until(consumer->subscribe_media(name, video.container, subscription), deadline, "subscribe_media"); + + size_t total = 0; + while (total == 0) { + auto frame = until(media->next(), deadline, "next frame"); + if (!frame) { + break; + } + total += frame->payload.size(); + } + if (total == 0) { + fail("no frame data received"); + } + std::printf("received %zu bytes from \"%s\"\n", total, path.c_str()); + session->cancel(0); + moq::shutdown(); + return 0; +} + +} // namespace + +int main(int argc, char **argv) { + if (argc < 2) { + fail("usage: cpp-interop publish|subscribe --url URL --broadcast PATH [--timeout SECONDS]"); + } + const std::string role = argv[1]; + std::string url; + std::string broadcast; + double timeout = 20; + for (int i = 2; i + 1 < argc; i += 2) { + const std::string flag = argv[i]; + if (flag == "--url") { + url = argv[i + 1]; + } else if (flag == "--broadcast") { + broadcast = argv[i + 1]; + } else if (flag == "--timeout") { + timeout = std::atof(argv[i + 1]); + } else { + fail(("unknown flag " + flag).c_str()); + } + } + if (url.empty() || broadcast.empty()) { + fail("--url and --broadcast are required"); + } + + if (role == "publish") { + return publish(url, broadcast); + } + if (role == "subscribe") { + return subscribe(url, broadcast, timeout); + } + fail(("unknown role " + role).c_str()); +} diff --git a/test/interop/interop.sh b/test/interop/interop.sh index 713d4e6865..0e702ff033 100755 --- a/test/interop/interop.sh +++ b/test/interop/interop.sh @@ -5,8 +5,9 @@ # public registry to catch packaging breakage), this builds every client from # the workspace source. It proves the code in the tree interoperates across # implementations before anything is published: a relay built from rs/moq-relay, -# clients built from rs/moq-cli, py/, js/, and rs/libmoq, all talking to each -# other. There's no apt/brew/npm/PyPI here, just cargo/bun/uv/cc. +# clients built from rs/moq-cli, py/, go/, cpp/moq, js/, and rs/libmoq, all +# talking to each other. There's no apt/brew/npm/PyPI here, just +# cargo/bun/uv/go/cmake/cc. # # It stands up a moq-relay, then for each publisher language publishes an H.264 # broadcast and confirms every subscriber sees data flowing before the timeout. @@ -130,6 +131,7 @@ TARGET_BASE="" # cargo target dir (resolved in require_tools) PY="" # python interpreter with the workspace moq build (set in prepare) C_INTEROP="" # compiled C client binary (set in prepare) GO_INTEROP="" # compiled Go client binary (set in prepare) +CPP_INTEROP="" # compiled C++ client binary (set in prepare) GST_PLUGIN_DIR="" # dir holding the built moq-gst plugin (set in prepare) BROKEN_LANGS="" # clients whose source build failed @@ -280,6 +282,35 @@ prepare_go() { fi } +# Build and install cpp/moq (cargo builds moq-ffi, uniffi-bindgen-cpp renders the +# bindings), then build the C++ client against the installed package with +# find_package(moq-cpp), the way an external project consumes it. Debug, like the +# rest of the run. +prepare_cpp() { + local t + for t in cmake uniffi-bindgen-cpp; do + have "$t" || { + mark_broken cpp "$t not found (see cpp/moq/README.md)" + return + } + done + echo "building c++ client (workspace cpp/moq via uniffi-bindgen-cpp + cmake)..." + local build="$HARNESS_RUN/cpp-build" prefix="$HARNESS_RUN/cpp-prefix" config=Debug + [[ "$PROFILE" == "release" ]] && config=Release + if ! { + cmake -S "$WORKSPACE/cpp/moq" -B "$build/package" -DCMAKE_BUILD_TYPE="$config" -DCMAKE_INSTALL_LIBDIR=lib && + cmake --build "$build/package" && + cmake --install "$build/package" --prefix "$prefix" && + cmake -S "$CLIENTS/cpp" -B "$build/client" -DCMAKE_BUILD_TYPE="$config" -DCMAKE_PREFIX_PATH="$prefix" && + cmake --build "$build/client" + } >"$HARNESS_RUN/cpp-build.log" 2>&1; then + mark_broken cpp "cmake build failed" + sed 's/^/ /' "$HARNESS_RUN/cpp-build.log" >&2 || true + return + fi + CPP_INTEROP="$build/client/cpp-interop" +} + # Build libmoq (the C staticlib + cbindgen header) and compile the C subscriber # against it. cargo writes moq.h to $TARGET_BASE/include and libmoq.a to the # profile dir. @@ -368,6 +399,7 @@ echo "moq-cli: $MOQ" needs python && prepare_python needs go && prepare_go +needs cpp && prepare_cpp needs_js && prepare_js needs c && prepare_c needs gst && prepare_gst @@ -428,6 +460,9 @@ run_publisher() { go) ffmpeg_h264 | "$GO_INTEROP" publish --url "$URL" --broadcast "$broadcast" ;; + cpp) + ffmpeg_h264 | "$CPP_INTEROP" publish --url "$URL" --broadcast "$broadcast" + ;; js) # Headless Chromium encodes its own H.264 from a fake camera via # WebCodecs (lazily, once a subscriber creates demand). @@ -481,6 +516,9 @@ run_subscriber() { go) "$GO_INTEROP" subscribe --url "$URL" --broadcast "$broadcast" --timeout "$TIMEOUT" ;; + cpp) + "$CPP_INTEROP" subscribe --url "$URL" --broadcast "$broadcast" --timeout "$TIMEOUT" + ;; c) "$C_INTEROP" subscribe --url "$URL" --broadcast "$broadcast" --timeout "$TIMEOUT" ;; diff --git a/test/justfile b/test/justfile index 91ba59ba80..a060e3518c 100644 --- a/test/justfile +++ b/test/justfile @@ -25,8 +25,8 @@ default: # Cross-language media interop test, built from this checkout. Stands up a # relay and runs the publisher x subscriber matrix. Default: rust only (fast # sanity check). `interop --all` runs the full matrix: rust + python + go + -# browser publish; everyone subscribes (incl. native node/bun JS, the C libmoq -# client, and the GStreamer moqsrc plugin). --timeout 30 gives headless Chromium +# C++ + browser publish; everyone subscribes (incl. native node/bun JS, the C +# libmoq client, and the GStreamer moqsrc plugin). --timeout 30 gives headless Chromium # cold-start headroom. Other flags pass through, e.g. # `just test interop --publishers rust,python --subscribers rust,c`. interop *args: @@ -34,7 +34,7 @@ interop *args: set -euo pipefail if [[ "${1:-}" == --all ]]; then shift - ./interop/interop.sh --publishers rust,python,go,js --subscribers rust,python,go,js,js-native-node,js-native-bun,c,gst --timeout 30 "$@" + ./interop/interop.sh --publishers rust,python,go,cpp,js --subscribers rust,python,go,cpp,js,js-native-node,js-native-bun,c,gst --timeout 30 "$@" else ./interop/interop.sh "$@" fi From 341f09d535d5a2230df3e333e50b329df3649bfa Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 20:44:34 -0700 Subject: [PATCH 05/27] feat(cpp): moq::Error prints Rust's message (#4292) Co-authored-by: Claude Opus 5.5 --- .github/workflows/cpp.yml | 2 +- .github/workflows/nightly.yml | 2 +- .github/workflows/release-cpp.yml | 2 +- cpp/justfile | 2 +- cpp/moq/README.md | 4 ++-- cpp/moq/test/doc-samples.hpp | 2 -- cpp/moq/test/probe.cpp | 4 +++- dart/moq_ffi/lib/src/moq.dart | 8 ++++++++ doc/lib/cpp/index.md | 10 ++++++---- flake.nix | 6 +++--- quest/m1/cpp/README.md | 1 - quest/m1/cpp/error-message.md | 21 --------------------- rs/moq-ffi/build.sh | 2 +- rs/moq-ffi/src/error.rs | 3 +++ test/interop/clients/cpp/main.cpp | 20 +------------------- test/interop/clients/js/src/contract.ts | 2 +- test/interop/clients/js/src/setup.ts | 5 +++++ 17 files changed, 37 insertions(+), 59 deletions(-) delete mode 100644 quest/m1/cpp/error-message.md diff --git a/.github/workflows/cpp.yml b/.github/workflows/cpp.yml index 12988c9da4..ee97f42eba 100644 --- a/.github/workflows/cpp.yml +++ b/.github/workflows/cpp.yml @@ -53,7 +53,7 @@ jobs: run: cargo install --locked just@1.52.0 - name: Install uniffi-bindgen-cpp - run: cargo install uniffi-bindgen-cpp --locked --git https://github.com/kixelated/uniffi-bindgen-cpp --tag v0.11.0-kixelated.1+v0.32.2 + run: cargo install uniffi-bindgen-cpp --locked --git https://github.com/kixelated/uniffi-bindgen-cpp --tag v0.11.0-kixelated.2+v0.32.2 - name: Check run: just cpp check diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index a65c102eee..4b9bfea9f1 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -345,7 +345,7 @@ jobs: # The tag flake.nix pins; see the comment there. - name: Install uniffi-bindgen-cpp shell: bash - run: cargo install uniffi-bindgen-cpp --locked --git https://github.com/kixelated/uniffi-bindgen-cpp --tag v0.11.0-kixelated.1+v0.32.2 + run: cargo install uniffi-bindgen-cpp --locked --git https://github.com/kixelated/uniffi-bindgen-cpp --tag v0.11.0-kixelated.2+v0.32.2 - name: Check shell: bash diff --git a/.github/workflows/release-cpp.yml b/.github/workflows/release-cpp.yml index c0af9996ab..3c79288f9a 100644 --- a/.github/workflows/release-cpp.yml +++ b/.github/workflows/release-cpp.yml @@ -52,7 +52,7 @@ jobs: # The tag flake.nix pins; see the comment there. - name: Install uniffi-bindgen-cpp shell: bash - run: cargo install uniffi-bindgen-cpp --locked --git https://github.com/kixelated/uniffi-bindgen-cpp --tag v0.11.0-kixelated.1+v0.32.2 + run: cargo install uniffi-bindgen-cpp --locked --git https://github.com/kixelated/uniffi-bindgen-cpp --tag v0.11.0-kixelated.2+v0.32.2 # The package version lives in cpp/moq/VERSION; a tag that disagrees is a mistake. - name: Check version diff --git a/cpp/justfile b/cpp/justfile index 5064fc02a5..a12b5a6b22 100644 --- a/cpp/justfile +++ b/cpp/justfile @@ -23,7 +23,7 @@ check $FILES="": fi if ! command -v uniffi-bindgen-cpp >/dev/null 2>&1; then echo "cpp check: uniffi-bindgen-cpp not on PATH, skipping" >&2 - echo " install: cargo install uniffi-bindgen-cpp --locked --git https://github.com/kixelated/uniffi-bindgen-cpp --tag v0.11.0-kixelated.1+v0.32.2" >&2 + echo " install: cargo install uniffi-bindgen-cpp --locked --git https://github.com/kixelated/uniffi-bindgen-cpp --tag v0.11.0-kixelated.2+v0.32.2" >&2 exit 0 fi diff --git a/cpp/moq/README.md b/cpp/moq/README.md index 6545172fa7..aef1608ff8 100644 --- a/cpp/moq/README.md +++ b/cpp/moq/README.md @@ -19,7 +19,7 @@ The generator is a fork, [kixelated/uniffi-bindgen-cpp](https://github.com/kixel ```bash cargo install uniffi-bindgen-cpp --locked \ --git https://github.com/kixelated/uniffi-bindgen-cpp \ - --tag v0.11.0-kixelated.1+v0.32.2 + --tag v0.11.0-kixelated.2+v0.32.2 ``` `flake.nix` pins the same tag and lists every other place that names it. @@ -30,7 +30,7 @@ Every generated type lives in `namespace moq`. Objects are `std::shared_ptr`; re - A fallible call returns `moq::expected`, which is `std::expected` on C++23 and a bundled `tl::expected` below it. The generated sources and every includer must agree on which, so `moq/moq.hpp` references a symbol named for the one it sees and `src/moq.cpp` defines the one it was built with: a mismatch fails to link. - An async call returns `moq::Future`. Block on it with `get()` or `wait_for()`, attach a continuation with `std::move(future).then(executor, callback)`, which returns a `moq::Continuation`, or `co_await` it on C++20. -- `moq::Error` is a value holding a `std::variant` of its cases. Nothing throws. A Rust panic or a misused future aborts with a message on stderr. +- `moq::Error` is a value holding a `std::variant` of its cases, and `to_string()` gives Rust's `Display` message. Nothing throws. A Rust panic or a misused future aborts with a message on stderr. - Futures are polled and continuations run on one process-wide executor thread unless the application installs its own with `moq::set_executor` before the first async call. `moq::shutdown()` stops the moq-ffi runtime thread (`moq_ffi_shutdown`), then the executor, before unloading the code a continuation could call into. ## Cancellation diff --git a/cpp/moq/test/doc-samples.hpp b/cpp/moq/test/doc-samples.hpp index cb294154ba..d4c3396332 100644 --- a/cpp/moq/test/doc-samples.hpp +++ b/cpp/moq/test/doc-samples.hpp @@ -8,8 +8,6 @@ #include #include -inline void report(const moq::Error &) {} - inline moq::expected> session; inline moq::expected> media; inline std::vector opus_init; diff --git a/cpp/moq/test/probe.cpp b/cpp/moq/test/probe.cpp index 895adb6600..53236e90dc 100644 --- a/cpp/moq/test/probe.cpp +++ b/cpp/moq/test/probe.cpp @@ -28,7 +28,7 @@ using namespace std::chrono_literals; namespace { [[noreturn]] void fail(const char *what, const moq::Error &error) { - std::fprintf(stderr, "%s failed: moq::Error variant %zu\n", what, error.get_variant().index()); + std::fprintf(stderr, "%s failed: %s\n", what, error.to_string().c_str()); std::abort(); } @@ -104,6 +104,8 @@ int main() { auto fingerprints = unbound->cert_fingerprints(); CHECK(!fingerprints); CHECK(std::holds_alternative(fingerprints.error().get_variant())); + // It prints the message Rust's Display gives. + CHECK(fingerprints.error().to_string() == "bind: not listening; call listen() first"); // Publisher: a server whose origin serves one broadcast with one track. auto origin = moq::OriginProducer::init({}); diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 12e167a28a..ae1681b6a8 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -11667,6 +11667,14 @@ external Pointer uniffi_moq_ffi_fn_method_moqsession_status( Pointer ptr, ); +@Native)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqerror_uniffi_trait_display( + RustBuffer ptr, + Pointer uniffiStatus, +); + @Native)>( assetId: _uniffiAssetId, ) diff --git a/doc/lib/cpp/index.md b/doc/lib/cpp/index.md index 751ce0083c..8744eaf81a 100644 --- a/doc/lib/cpp/index.md +++ b/doc/lib/cpp/index.md @@ -51,12 +51,13 @@ libmoq's `moq` package without colliding. ```cpp #include +#include // Subscribe. Every fallible call returns moq::expected; nothing throws. auto client = moq::Client::init(); auto session = client->connect("https://relay.example.com").get(); if (!session) { - report(session.error()); // a moq::Error value + std::fprintf(stderr, "connect: %s\n", session.error().to_string().c_str()); return; } @@ -139,9 +140,10 @@ process that exits normally can skip it. `moq::Error` holds a `std::variant` of cases (`moq::Error::kCancelled`, `kUnauthorized`, `kProtocol`, ...); test one with `std::holds_alternative(error.get_variant())`. Auth -rejections are their own cases, so you don't retry them. A Rust panic or a -misused future (`get()` twice) aborts with a message on stderr instead of -throwing. +rejections are their own cases, so you don't retry them. `error.to_string()` +gives the message Rust's `Display` does, for logs; branch on the case, not the +text. A Rust panic or a misused future (`get()` twice) aborts with a message on +stderr instead of throwing. Everything else maps one to one onto the [shared feature list](/lib/#what-every-binding-can-do): each generated diff --git a/flake.nix b/flake.nix index 8b46681673..f01574edd3 100644 --- a/flake.nix +++ b/flake.nix @@ -350,16 +350,16 @@ # collide with upstream's. Move back upstream once one tags both. uniffi-bindgen-cpp = pkgs.rustPlatform.buildRustPackage rec { pname = "uniffi-bindgen-cpp"; - version = "0.11.0-kixelated.1+v0.32.2"; + version = "0.11.0-kixelated.2+v0.32.2"; src = pkgs.fetchFromGitHub { owner = "kixelated"; repo = "uniffi-bindgen-cpp"; rev = "v${version}"; - hash = "sha256-i5qVHviZS36TpmaWINNgLKx12cWPm0TUT9+k+YaNAvw="; + hash = "sha256-NtlhJNjpIZ/h6v8Umi7iyd91/OYlj/8Gkw0cIlAKgp0="; }; - cargoHash = "sha256-+Vt69WTtR/evH+qPcI0J7I1OKWtVOo1xzoGD4Hwg3zE="; + cargoHash = "sha256-WbcpuxTvH8v8TnUHX5VNFRZwLB+PfEfub0vlNVBMKEg="; # The workspace's other member is the fixture crate, which pulls the # uniffi examples in from git; build only the generator. diff --git a/quest/m1/cpp/README.md b/quest/m1/cpp/README.md index 7c0f612258..c30cf8d800 100644 --- a/quest/m1/cpp/README.md +++ b/quest/m1/cpp/README.md @@ -50,7 +50,6 @@ reads the same release manifest so a release bumps both. ## Quests -- [Error messages](/quest/m1/cpp/error-message.md) - a C++ `moq::Error` prints the same message Rust gives - [OBS migration](/quest/m1/cpp/obs.md) - the OBS plugin moves from libmoq handles and trampolines to the generated C++ ## Related diff --git a/quest/m1/cpp/error-message.md b/quest/m1/cpp/error-message.md deleted file mode 100644 index 07e11f95be..0000000000 --- a/quest/m1/cpp/error-message.md +++ /dev/null @@ -1,21 +0,0 @@ -# [XS] C++ errors carry their message - -## Goal - -A `moq::Error` from the C++ package prints the same message Rust's `Display` -gives, so callers and the interop client log a reason rather than a variant -name. - -## Plan - -- The generator emits no `Display` for errors. Prefer exposing the message - through moq-ffi once (e.g. a method on the error the generator already - binds) over hand-written per-variant strings in the wrapper. -- Use it in the interop client and the doc samples. - -Public API: additive on the C++ package, and on moq-ffi if a method is added. -Wire: none. - -## Related - -- [#4187](https://github.com/moq-dev/moq/pull/4187) - the package that found it diff --git a/rs/moq-ffi/build.sh b/rs/moq-ffi/build.sh index d1c36ca316..7314063b77 100755 --- a/rs/moq-ffi/build.sh +++ b/rs/moq-ffi/build.sh @@ -191,7 +191,7 @@ generate_bindings() { # C++ uses a third-party bindgen too: a fork carrying LiveKit's async support # ported to uniffi 0.32, which upstream has not released. Install it with: - # cargo install --locked uniffi-bindgen-cpp --git https://github.com/kixelated/uniffi-bindgen-cpp --tag v0.11.0-kixelated.1+v0.32.2 + # cargo install --locked uniffi-bindgen-cpp --git https://github.com/kixelated/uniffi-bindgen-cpp --tag v0.11.0-kixelated.2+v0.32.2 if command -v uniffi-bindgen-cpp >/dev/null 2>&1; then echo " Generating cpp bindings..." uniffi-bindgen-cpp --library "$lib_path" \ diff --git a/rs/moq-ffi/src/error.rs b/rs/moq-ffi/src/error.rs index cce70fb82f..6a2e92b329 100644 --- a/rs/moq-ffi/src/error.rs +++ b/rs/moq-ffi/src/error.rs @@ -150,7 +150,10 @@ fn stream_kind(err: &moq_net::StreamError) -> MoqProtocolKind { } /// Error returned by all UniFFI-exported functions. +/// +/// Exports `Display`, which the bindings that support it surface as the error's string form. #[derive(Debug, thiserror::Error, uniffi::Error)] +#[uniffi::export(Display)] #[non_exhaustive] pub enum MoqError { /// A protocol failure carrying the peer's session or stream code. diff --git a/test/interop/clients/cpp/main.cpp b/test/interop/clients/cpp/main.cpp index 7ed23a253b..776a0f06e5 100644 --- a/test/interop/clients/cpp/main.cpp +++ b/test/interop/clients/cpp/main.cpp @@ -22,8 +22,6 @@ #include #include #include -#include -#include #include #ifdef _WIN32 @@ -55,24 +53,8 @@ constexpr uint32_t AUDIO_FRAME_DURATION_US = 2'500; constexpr uint64_t AUDIO_BATCH_US = 20'000; constexpr size_t AUDIO_BATCH_SAMPLES = AUDIO_RATE * AUDIO_BATCH_US / 1'000'000; -template -struct has_message: std::false_type {}; -template -struct has_message().v1)>>: std::true_type {}; - -// moq::Error has no Display in the bindings yet, so name the variant and its message. [[noreturn]] void fail(const char *what, const moq::Error &error) { - std::string message = std::visit( - [](const auto &variant) -> std::string { - if constexpr (has_message>::value) { - return variant.v1; - } else { - return ""; - } - }, - error.get_variant() - ); - std::fprintf(stderr, "error: %s: moq::Error variant %zu %s\n", what, error.get_variant().index(), message.c_str()); + std::fprintf(stderr, "error: %s: %s\n", what, error.to_string().c_str()); std::exit(1); } diff --git a/test/interop/clients/js/src/contract.ts b/test/interop/clients/js/src/contract.ts index d33d91d49a..d7fc63342c 100644 --- a/test/interop/clients/js/src/contract.ts +++ b/test/interop/clients/js/src/contract.ts @@ -165,7 +165,7 @@ export type InteropControl = { start(): void; /** Remove the player from the DOM. */ detach(): void; - /** Put the player back and resume sampling. */ + /** Blank the canvas the old session left behind, put the player back, and resume sampling. */ reattach(): void; /** Connect a second player and leave it behind for the leaked-session negative control. */ startLeak(): void; diff --git a/test/interop/clients/js/src/setup.ts b/test/interop/clients/js/src/setup.ts index 1ebd379e00..aeb4177abb 100644 --- a/test/interop/clients/js/src/setup.ts +++ b/test/interop/clients/js/src/setup.ts @@ -110,6 +110,11 @@ if (role === "publish") { }, reattach: () => { stop(); + // The torn-down player leaves its last frame on the canvas, which can be newer than the frame + // the driver read before detaching. Blank it so every frame read after this was presented by + // the new session, not left over from the old one. + const canvas = el.querySelector("canvas"); + canvas?.getContext("2d")?.clearRect(0, 0, canvas.width, canvas.height); player.appendChild(el); stop = attach(el); }, From 50cd8e4a35405840f094416ed778d0c02ff144f0 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 27 Sep 2026 18:40:16 -0700 Subject: [PATCH 06/27] feat(cpp)!: export the C++ package as moq::cpp (#4297) Co-authored-by: Claude Opus 5.5 --- cpp/moq/CMakeLists.txt | 2 +- cpp/moq/README.md | 2 +- cpp/moq/cmake/moq-cpp-config.cmake.in | 14 +++++++------- cpp/moq/test/CMakeLists.txt | 2 +- doc/lib/cpp/index.md | 4 ++-- test/interop/clients/cpp/CMakeLists.txt | 2 +- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/cpp/moq/CMakeLists.txt b/cpp/moq/CMakeLists.txt index 780b3f5b56..ae9209d71d 100644 --- a/cpp/moq/CMakeLists.txt +++ b/cpp/moq/CMakeLists.txt @@ -76,7 +76,7 @@ endforeach() # The bindings compile here with the including project's standard and flags; see src/moq.cpp. # Named moq-cpp so an in-tree build can sit beside libmoq's `moq` target. add_library(moq-cpp STATIC src/moq.cpp) -add_library(moq-cpp::moq ALIAS moq-cpp) +add_library(moq::cpp ALIAS moq-cpp) add_dependencies(moq-cpp moq_ffi) target_compile_features(moq-cpp PUBLIC cxx_std_17) target_include_directories(moq-cpp PUBLIC diff --git a/cpp/moq/README.md b/cpp/moq/README.md index aef1608ff8..28fb95498b 100644 --- a/cpp/moq/README.md +++ b/cpp/moq/README.md @@ -4,7 +4,7 @@ C++17 bindings for [rs/moq-ffi](../../rs/moq-ffi): the sources `uniffi-bindgen-c ## Layout -- `CMakeLists.txt` builds moq-ffi as a staticlib with cargo, renders the bindings into the build tree, and compiles them as the `moq-cpp::moq` target. Use it with `add_subdirectory`, or install it for `find_package(moq-cpp)` and `moq-cpp.pc`. +- `CMakeLists.txt` builds moq-ffi as a staticlib with cargo, renders the bindings into the build tree, and compiles them as the `moq::cpp` target. Use it with `add_subdirectory`, or install it for `find_package(moq-cpp)` and `moq-cpp.pc`. - `src/moq.cpp` compiles the generated `moq/ffi/moq.cpp`. It ships as source so it builds with the consumer's standard and flags. - `cmake/` holds the installed CMake package and pkg-config templates. - `build.sh` builds the release archive `release-cpp.yml` publishes. diff --git a/cpp/moq/cmake/moq-cpp-config.cmake.in b/cpp/moq/cmake/moq-cpp-config.cmake.in index 1383fb754f..d4ed0477fb 100644 --- a/cpp/moq/cmake/moq-cpp-config.cmake.in +++ b/cpp/moq/cmake/moq-cpp-config.cmake.in @@ -1,15 +1,15 @@ -# find_package(moq-cpp): the C++ bindings for moq-ffi as the moq-cpp::moq target. The -# namespace is the package name, so it never collides with libmoq's moq::moq. +# find_package(moq-cpp): the C++ bindings for moq-ffi as the moq::cpp target. Every moq +# package exports into the one moq:: namespace, and the package names keep installs apart. @PACKAGE_INIT@ -if(NOT TARGET moq-cpp::moq) +if(NOT TARGET moq::cpp) find_package(Threads REQUIRED) # The prebuilt Rust staticlib, and the system libraries an external linker must add for # it, baked in for the platform this package was built for. - add_library(moq-cpp::ffi STATIC IMPORTED) - set_target_properties(moq-cpp::ffi PROPERTIES + add_library(moq::ffi STATIC IMPORTED) + set_target_properties(moq::ffi PROPERTIES IMPORTED_LOCATION "@PACKAGE_CMAKE_INSTALL_LIBDIR@/@MOQ_FFI_LIB_NAME@" INTERFACE_LINK_LIBRARIES "@MOQ_NATIVE_LIBS@;Threads::Threads" ) @@ -19,8 +19,8 @@ if(NOT TARGET moq-cpp::moq) add_library(moq-cpp STATIC "@PACKAGE_CMAKE_INSTALL_DATADIR@/moq-cpp/moq.cpp") target_compile_features(moq-cpp PUBLIC cxx_std_17) target_include_directories(moq-cpp PUBLIC "@PACKAGE_CMAKE_INSTALL_INCLUDEDIR@") - target_link_libraries(moq-cpp PUBLIC moq-cpp::ffi) - add_library(moq-cpp::moq ALIAS moq-cpp) + target_link_libraries(moq-cpp PUBLIC moq::ffi) + add_library(moq::cpp ALIAS moq-cpp) endif() check_required_components(moq-cpp) diff --git a/cpp/moq/test/CMakeLists.txt b/cpp/moq/test/CMakeLists.txt index b76f382000..c7c7d16e9d 100644 --- a/cpp/moq/test/CMakeLists.txt +++ b/cpp/moq/test/CMakeLists.txt @@ -21,4 +21,4 @@ endif() find_package(moq-cpp REQUIRED) add_executable(probe probe.cpp) -target_link_libraries(probe PRIVATE moq-cpp::moq) +target_link_libraries(probe PRIVATE moq::cpp) diff --git a/doc/lib/cpp/index.md b/doc/lib/cpp/index.md index 8744eaf81a..3dea12e9c9 100644 --- a/doc/lib/cpp/index.md +++ b/doc/lib/cpp/index.md @@ -26,7 +26,7 @@ No Rust toolchain is needed to consume it. ```cmake ignore find_package(moq-cpp REQUIRED) # with CMAKE_PREFIX_PATH at the unpacked archive -target_link_libraries(app PRIVATE moq-cpp::moq) +target_link_libraries(app PRIVATE moq::cpp) ``` ```bash @@ -44,7 +44,7 @@ release runtime (`/MD`), which the Rust library uses in every configuration. From source, `add_subdirectory(cpp/moq)` in a checkout builds `moq-ffi` with cargo and renders the bindings with the pinned `uniffi-bindgen-cpp` (see [`cpp/moq`](https://github.com/moq-dev/moq/tree/main/cpp/moq)), then exposes -the same `moq-cpp::moq` target. The package is `moq-cpp`, so it installs beside +the same `moq::cpp` target. The package is `moq-cpp`, so it installs beside libmoq's `moq` package without colliding. ## Example diff --git a/test/interop/clients/cpp/CMakeLists.txt b/test/interop/clients/cpp/CMakeLists.txt index b1ad0c3d34..894e8caaf9 100644 --- a/test/interop/clients/cpp/CMakeLists.txt +++ b/test/interop/clients/cpp/CMakeLists.txt @@ -10,4 +10,4 @@ set(CMAKE_CXX_EXTENSIONS OFF) find_package(moq-cpp REQUIRED) add_executable(cpp-interop main.cpp) -target_link_libraries(cpp-interop PRIVATE moq-cpp::moq) +target_link_libraries(cpp-interop PRIVATE moq::cpp) From 2ae9f9a5d66473d6f16906cee78f19685b7e3d94 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 27 Sep 2026 19:28:27 -0700 Subject: [PATCH 07/27] feat(obs): the OBS plugin on the generated C++ bindings (#4281) Co-authored-by: Claude Opus 5.5 --- .github/workflows/libmoq.yml | 103 -- .github/workflows/nightly.yml | 10 +- .github/workflows/obs.yml | 45 +- .github/workflows/release-cpp.yml | 120 ++ AGENTS.md | 4 +- CONTRIBUTING.md | 3 +- cpp/obs/CMakeLists.txt | 55 +- cpp/obs/README.md | 3 +- cpp/obs/VERSION | 1 + cpp/obs/build.sh | 24 +- cpp/obs/buildspec.json | 1 - cpp/obs/cmake/common/bootstrap.cmake | 11 +- cpp/obs/justfile | 120 +- cpp/obs/src/moq-dock.cpp | 14 +- cpp/obs/src/moq-error.h | 11 +- cpp/obs/src/moq-output.cpp | 732 ++++++----- cpp/obs/src/moq-output.h | 186 ++- cpp/obs/src/moq-settings.cpp | 308 +---- cpp/obs/src/moq-settings.h | 44 +- cpp/obs/src/moq-source.cpp | 1451 +++++++--------------- cpp/obs/src/moq-worker.h | 91 ++ cpp/obs/src/obs-moq.cpp | 22 +- cpp/obs/test/moq-error-test.cpp | 14 +- cpp/obs/test/moq-output-test.cpp | 991 ++++----------- cpp/obs/test/moq-source-test.cpp | 1438 +++------------------ cpp/obs/test/moq-test-relay.h | 221 ++++ cpp/obs/test/obs-moq-test.cpp | 69 + cpp/obs/test/tsan.supp | 10 + doc/bin/obs.md | 26 +- doc/lib/c/index.md | 4 +- doc/lib/cpp/index.md | 6 +- justfile | 23 +- quest/m1/cpp/README.md | 3 +- quest/m1/cpp/client-config.md | 36 + quest/m1/cpp/obs.md | 49 - quest/m1/cpp/session-report.md | 27 + quest/m1/kt-jvm-exit.md | 2 +- quest/m1/obs-moq-video/README.md | 2 +- quest/m1/obs-moq-video/adapter.md | 2 +- quest/m1/obs-moq-video/audio-playback.md | 2 +- quest/m1/obs-moq-video/audio-publish.md | 2 +- quest/m1/obs-moq-video/rate-control.md | 2 +- quest/m1/obs-moq-video/source.md | 2 +- quest/m3/libmoq-shutdown.md | 23 +- 44 files changed, 2182 insertions(+), 4131 deletions(-) create mode 100644 cpp/obs/VERSION create mode 100644 cpp/obs/src/moq-worker.h create mode 100644 cpp/obs/test/moq-test-relay.h create mode 100644 cpp/obs/test/obs-moq-test.cpp create mode 100644 cpp/obs/test/tsan.supp create mode 100644 quest/m1/cpp/client-config.md delete mode 100644 quest/m1/cpp/obs.md create mode 100644 quest/m1/cpp/session-report.md diff --git a/.github/workflows/libmoq.yml b/.github/workflows/libmoq.yml index 7eb12759de..bf0e8456bf 100644 --- a/.github/workflows/libmoq.yml +++ b/.github/workflows/libmoq.yml @@ -106,106 +106,3 @@ jobs: RELEASE_TITLE: "libmoq v${{ steps.parse.outputs.version }}" RELEASE_PREV_TAG: ${{ steps.prev_tag.outputs.tag }} run: .github/scripts/release.sh create artifacts - - # The OBS plugin (cpp/obs) statically links libmoq, so it ships with every - # libmoq release: rebuild it against the release we just published (no - # second cargo build, guaranteed-matching ABI) and cut a parallel - # obs-moq-v release. Runs after `release` so the libmoq archives - # the plugin's CMake fetches are already live. - obs-build: - name: OBS plugin (${{ matrix.target }}) - needs: release - runs-on: ${{ matrix.os }} - - # macOS + Windows only. These link the ffmpeg the obs-deps bundle ships - # (the same one OBS uses), so the binaries are portable. A Linux build - # would link nix/distro ffmpeg and not be portable, so Linux is - # build-from-source (see doc/bin/obs.md) and ships no prebuilt binary. - strategy: - fail-fast: false - matrix: - include: - - target: aarch64-apple-darwin - os: macos-latest - - target: x86_64-pc-windows-msvc - # The plugin preset uses the Visual Studio 17 2022 generator. - os: windows-2022 - - steps: - # Shallow: this job only compiles, it never reads git history or tags. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Parse version - id: parse - shell: bash - run: .github/scripts/release.sh parse-version libmoq - - # Native Xcode / Visual Studio; libobs/Qt6/ffmpeg come from the obs-deps - # bundle and libmoq from the release we just published (--libmoq-release). - - name: Build and package - shell: bash - env: - TARGET: ${{ matrix.target }} - VERSION: ${{ steps.parse.outputs.version }} - run: | - ./cpp/obs/build.sh --target "$TARGET" --libmoq-release "$VERSION" --output dist - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: obs-moq-${{ matrix.target }} - path: dist/* - - obs-release: - name: OBS plugin release - needs: obs-build - runs-on: ubuntu-latest - permissions: - contents: write - - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Parse version - id: parse - run: .github/scripts/release.sh parse-version libmoq - - # Only the obs-moq archives: the libmoq-* artifacts from the build job - # belong to the libmoq release, not this one. - - name: Download artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - path: artifacts - pattern: obs-moq-* - merge-multiple: true - - # release.sh prev-tag keys off the current tag, but the trigger here is a - # libmoq-v* tag, so it can't find the previous obs-moq tag. Take the - # newest existing obs-moq tag (excluding this version, for safe re-runs) - # so the release notes span only since the last obs-moq release. - - name: Find previous obs-moq tag - id: prev_tag - env: - VERSION: ${{ steps.parse.outputs.version }} - run: | - cur="obs-moq-v${VERSION}" - # awk (not grep -vx | head) so an empty result is exit 0, not 1: - # under `set -eo pipefail` the inaugural release has no prior tag and - # grep's exit 1 would fail the step. Empty tag is fine downstream. - prev=$(git tag --list 'obs-moq-v*' --sort=-v:refname | awk -v cur="$cur" '$0 != cur { print; exit }') - echo "tag=${prev}" >>"$GITHUB_OUTPUT" - - # Cut a parallel obs-moq-v release at this commit (gh creates - # the tag). - - name: Create or update release - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: "obs-moq-v${{ steps.parse.outputs.version }}" - RELEASE_TITLE: "obs-moq v${{ steps.parse.outputs.version }}" - RELEASE_PREV_TAG: ${{ steps.prev_tag.outputs.tag }} - run: .github/scripts/release.sh create artifacts diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index adbbea4d67..ad77b178fe 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -9,8 +9,8 @@ name: Nightly # no artifacts with the default one, which is several minutes of duplicate work # that a PR gate can't justify for how rarely it catches something. `obs` is # diff-independent because its PR-time counterpart can't be: obs.yml has to -# name the paths that can break an external link to libmoq.a, and no list of -# paths covers every one of them (see the comment there). `windows` and `macos` +# name the paths that can break an external link to a Rust staticlib, and no +# list of paths covers every one of them (see the comment there). `windows` and `macos` # are diff-independent because what they catch is a whole target's worth of # `#[cfg(target_os = ...)]` code that no Linux job compiles at all, and those # runners are throttled too hard to gate every PR on. @@ -112,9 +112,9 @@ jobs: if: ${{ !cancelled() }} run: nix develop --command just rs audit - # The OBS plugin and libmoq's C fixtures are the only things that link - # libmoq.a from outside cargo, so a stale `rs/libmoq/native-libs/*.txt` - # breaks them and nothing else. obs.yml lists the inputs that can do that, + # The OBS plugin (over moq-ffi) and libmoq's C fixtures link a Rust + # staticlib from outside cargo, so a stale `rs/libmoq/native-libs/*.txt` + # breaks them and no cargo build. obs.yml lists the inputs that can do that, # but a list of paths can't be complete: a build script emitting # `cargo::rustc-link-lib` needs no manifest change, and widening that # filter to `rs/**` would run a link on nearly every PR. Being diff --git a/.github/workflows/obs.yml b/.github/workflows/obs.yml index 8b4c03420c..17a87e2f27 100644 --- a/.github/workflows/obs.yml +++ b/.github/workflows/obs.yml @@ -6,32 +6,33 @@ name: OBS # # Linux is the platform that needs no bundle at all -- nixpkgs has obs-studio, # Qt6 and ffmpeg, so the dev shell is the whole dependency set. The plugin is -# platform-independent C++ over libmoq's C ABI, so a Linux compile catches the -# breakage a macOS developer would otherwise ship uncompiled. +# platform-independent C++ over the generated moq-ffi bindings (cpp/moq), so a +# Linux compile catches the breakage a macOS developer would otherwise ship +# uncompiled. # -# Narrow trigger, like swift.yml: the plugin's own sources, the libmoq header -# they call through, and the flake that supplies the toolchain. +# Narrow trigger, like swift.yml: the plugin's own sources, the C++ package and +# the moq-ffi API it is generated from, and the flake that supplies the +# toolchain, including uniffi-bindgen-cpp. # # libmoq's own C fixtures (`just rs c-tests`) run here too. They link libmoq.a -# from outside cargo the same way, which is too slow for nextest's hang budget -# and needs the debug staticlib this job has just built, so this is the one -# PR gate that already has everything they need. They also compile the C doc -# samples, hence `doc/lib/c/**` in the trigger. +# from outside cargo the same way the plugin links moq-ffi, which is too slow +# for nextest's hang budget, so this job, which already does that kind of link, +# is where they gate PRs. They also compile the C doc samples, hence +# `doc/lib/c/**` in the trigger. # # The manifests and Cargo.lock are in there for the one way a crate the plugin -# never names can break it: linking libmoq.a from outside cargo needs the native -# libraries in `rs/libmoq/native-libs/*.txt`, which are maintained by hand. A -# dependency that starts pulling in a new one leaves that list stale, and every -# Rust gate stays green, because cargo passes the flag itself and only this job -# links without it. +# never names can break it: linking a Rust staticlib from outside cargo needs +# the native libraries in `rs/libmoq/native-libs/*.txt`, which moq-ffi and +# libmoq share and which are maintained by hand. A dependency that starts +# pulling in a new one leaves that list stale, and every Rust gate stays green, +# because cargo passes the flag itself and only this job links without it. # # Manifests and build scripts rather than Cargo.lock alone, because neither a -# feature toggle nor a build script has to touch the lockfile. libmoq takes -# moq-native with default features, so flipping one of those defaults on -# (`jemalloc` gates `dep:tikv-jemallocator`) changes what libmoq.a needs at link -# time while Cargo.lock stays byte-identical, since moq-relay already put that -# package in the graph; a build.rs emitting `cargo::rustc-link-lib` needs no -# manifest change at all. +# feature toggle nor a build script has to touch the lockfile. Flipping a +# default feature on (`jemalloc` gates `dep:tikv-jemallocator`) changes what +# the staticlib needs at link time while Cargo.lock stays byte-identical, since +# moq-relay already put that package in the graph; a build.rs emitting +# `cargo::rustc-link-lib` needs no manifest change at all. # # A list of paths still can't be complete here -- a vendored C source or a # .cargo/config.toml could do it too -- and `rs/**` would run this on nearly @@ -49,6 +50,8 @@ on: types: [opened, synchronize, reopened, closed] paths: - "cpp/obs/**" + - "cpp/moq/**" + - "rs/moq-ffi/**" - "rs/libmoq/**" - "doc/lib/c/**" - "doc/lib/samples.sh" @@ -94,7 +97,7 @@ jobs: # cache.yml warms the default-branch store on `main`. Pull requests restore # it; the action refuses to publish from a pull request. It holds - # dev-profile artifacts, which is why `just obs ci` builds libmoq in debug + # dev-profile artifacts, which is why `just obs ci` builds moq-ffi in debug # rather than the preset's release. - name: Rust cache uses: ./.github/actions/rust-cache @@ -104,7 +107,5 @@ jobs: - name: Build run: nix develop --command just obs ci - # The plugin build above already produced the debug libmoq.a, so the - # cargo step inside is a no-op and this is just a cc and a run. - name: libmoq C fixtures run: nix develop --command just rs c-tests diff --git a/.github/workflows/release-cpp.yml b/.github/workflows/release-cpp.yml index 3c79288f9a..0a02075913 100644 --- a/.github/workflows/release-cpp.yml +++ b/.github/workflows/release-cpp.yml @@ -109,3 +109,123 @@ jobs: RELEASE_TAG: ${{ github.ref_name }} RELEASE_PREV_TAG: ${{ steps.prev_tag.outputs.tag }} run: RELEASE_TITLE="moq C++ v${RELEASE_TAG#cpp-v}" .github/scripts/release.sh create artifacts + + # The OBS plugin (cpp/obs) statically links the C++ package, so it ships with + # every release: rebuild it against the release just published (no second + # cargo build, guaranteed-matching bindings) and cut a parallel + # obs-moq-v release. The plugin's version is its own, + # cpp/obs/VERSION. Runs after `release` so the archives + # the plugin's CMake fetches are already live. + obs-build: + name: OBS plugin (${{ matrix.target }}) + needs: release + runs-on: ${{ matrix.os }} + + # macOS + Windows only. These link the ffmpeg the obs-deps bundle ships + # (the same one OBS uses), so the binaries are portable. A Linux build + # would link nix/distro ffmpeg and not be portable, so Linux is + # build-from-source (see doc/bin/obs.md) and ships no prebuilt binary. + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + os: macos-latest + - target: x86_64-pc-windows-msvc + # The plugin preset uses the Visual Studio 17 2022 generator. + os: windows-2022 + + steps: + # Shallow: this job only compiles, it never reads git history or tags. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # The build job already checked the tag against this file. + - name: Read C++ version + id: parse + shell: bash + run: echo "version=$(tr -d '[:space:]' < cpp/moq/VERSION)" >>"$GITHUB_OUTPUT" + + # Native Xcode / Visual Studio; libobs/Qt6/ffmpeg come from the obs-deps + # bundle and the C++ package from the release just published (--moq-release). + # build.sh versions the plugin from cpp/obs/VERSION. + - name: Build and package + shell: bash + env: + TARGET: ${{ matrix.target }} + MOQ_RELEASE: ${{ steps.parse.outputs.version }} + run: | + ./cpp/obs/build.sh --target "$TARGET" --moq-release "$MOQ_RELEASE" --output dist + + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: obs-moq-${{ matrix.target }} + path: dist/* + + obs-release: + name: OBS plugin release + needs: obs-build + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Read plugin version + id: parse + run: echo "version=$(tr -d '[:space:]' < cpp/obs/VERSION)" >>"$GITHUB_OUTPUT" + + # An obs-moq tag names one build. A later C++ release without a plugin + # version bump would overwrite that release's archives, so refuse it; a + # re-run of this same release finds its tag on this commit and carries on. + - name: Refuse a reused plugin version + env: + VERSION: ${{ steps.parse.outputs.version }} + run: | + tag="obs-moq-v${VERSION}" + if git rev-parse -q --verify "refs/tags/$tag" >/dev/null && + [ "$(git rev-parse "refs/tags/$tag^{commit}")" != "$(git rev-parse HEAD)" ]; then + echo "::error::$tag already names another commit; bump cpp/obs/VERSION" + exit 1 + fi + + # Only the obs-moq archives: the moq-cpp-* artifacts from the build job + # belong to the C++ release, not this one. + - name: Download artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + path: artifacts + pattern: obs-moq-* + merge-multiple: true + + # release.sh prev-tag keys off the current tag, but the trigger here is a + # cpp-v* tag, so it can't find the previous obs-moq tag. Take the newest + # existing obs-moq tag (excluding this version, for safe re-runs) so the + # release notes span only since the last obs-moq release. + - name: Find previous obs-moq tag + id: prev_tag + env: + VERSION: ${{ steps.parse.outputs.version }} + run: | + cur="obs-moq-v${VERSION}" + # awk (not grep -vx | head) so an empty result is exit 0, not 1: + # under `set -eo pipefail` the inaugural release has no prior tag and + # grep's exit 1 would fail the step. Empty tag is fine downstream. + prev=$(git tag --list 'obs-moq-v*' --sort=-v:refname | awk -v cur="$cur" '$0 != cur { print; exit }') + echo "tag=${prev}" >>"$GITHUB_OUTPUT" + + # Cut a parallel obs-moq-v release at this commit (gh creates + # the tag). + - name: Create or update release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: "obs-moq-v${{ steps.parse.outputs.version }}" + RELEASE_TITLE: "obs-moq v${{ steps.parse.outputs.version }}" + RELEASE_PREV_TAG: ${{ steps.prev_tag.outputs.tag }} + run: .github/scripts/release.sh create artifacts diff --git a/AGENTS.md b/AGENTS.md index 4728f9bd68..8684747b1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,7 +98,7 @@ A quest deleted on `dev` is done, even while `main` still lists it. | Change in | Also update | |---|---| -| `rs/moq-ffi` | `rs/libmoq`, `{py,swift,kt,dart}/`, `go/wrapper/moq/*.go` (the `go/ffi` and `dart/moq_ffi` bindings regenerate automatically, but a new method needs a hand-written wrapper too, like `py/moq-rs` or `dart/moq`), `cpp/moq`, `doc/lib/{py,swift,kt,go,dart,c,cpp}` | +| `rs/moq-ffi` | `rs/libmoq`, `{py,swift,kt,dart}/`, `go/wrapper/moq/*.go` (the `go/ffi` and `dart/moq_ffi` bindings regenerate automatically, but a new method needs a hand-written wrapper too, like `py/moq-rs` or `dart/moq`), `cpp/moq`, `cpp/obs/src`, `doc/lib/{py,swift,kt,go,dart,c,cpp}`, `doc/bin/obs.md` | | `rs/moq-net` wire/API | `js/net`, `doc/concept`, `drafts/draft-lcurley-moq-lite.md` (if the wire spec changes) | | `rs/hang` catalog/container | `js/hang`, `doc/concept`, `drafts/draft-lcurley-moq-hang.md` (if the format spec changes) | | `rs/moq-token` | `js/token` | @@ -107,7 +107,7 @@ A quest deleted on `dev` is done, even while `main` still lists it. | `rs/moq-cli` | `doc/bin/cli.md` | | `rs/moq-token-cli` | `doc/bin/relay/auth.md`, `doc/lib/rs/moq-token.md`, `doc/lib/rs/index.md` | | `rs/moq-gst` | `doc/bin/gstreamer.md` | -| `rs/libmoq` C ABI (`moq.h`) | `cpp/obs/src`, `doc/bin/obs.md` | +| `rs/libmoq` C ABI (`moq.h`) | `doc/lib/c` | | `js/{watch,publish}` UI/API | `demo/web` if it consumes the API | | a kramdown-rfc construct new to `drafts/` | `doc/.vitepress/drafts.ts`, which translates the drafts into `/draft/` site pages | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 28673e0f47..881d0ded88 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,4 +67,5 @@ Releases are cut separately; bump only when asked. Each package's version lives - **Python**: `py/moq-rs/pyproject.toml` only; `py/moq-ffi` follows the `moq-ffi-v*` tag and Rust crate. - **Swift**: `swift/VERSION`. **Kotlin**: `moq.version` in `kt/gradle.properties`. **Dart**: `version` in `dart/moq/pubspec.yaml`. Their FFI counterparts track the Rust crate. - **Go**: `go/wrapper/VERSION` holds a human-owned `MAJOR.MINOR` line; CI derives the patch, so only edit it for a breaking API. Leave the placeholder FFI version in `go.mod` alone. -- **OBS**: the plugin takes the `libmoq` crate's version (`cpp/obs/CMakeLists.txt`), so release-plz owns it. +- **C++**: `cpp/moq/VERSION`, human-owned; a `cpp-v` tag releases it. +- **OBS**: `cpp/obs/VERSION`, human-owned; each C++ release also cuts `obs-moq-v`, and refuses one whose version already names an earlier release. diff --git a/cpp/obs/CMakeLists.txt b/cpp/obs/CMakeLists.txt index 636b2ea81a..ad250c05f6 100644 --- a/cpp/obs/CMakeLists.txt +++ b/cpp/obs/CMakeLists.txt @@ -1,30 +1,26 @@ cmake_minimum_required(VERSION 3.28) -# Build the in-tree libmoq by default. Release-only builds clear MOQ_LOCAL and -# must select the published archive explicitly, so this never silently drifts -# to an old static version. -set(MOQ_LOCAL "${CMAKE_CURRENT_SOURCE_DIR}/../.." CACHE PATH "Path to the moq repo (builds rs/libmoq in-tree)") -set(MOQ_VERSION "" CACHE STRING "Published libmoq version (required without MOQ_LOCAL)") +# Build the in-tree C++ package (cpp/moq) by default. Release-only builds clear +# MOQ_LOCAL and must select the published archive explicitly, so this never +# silently drifts to an old static version. +set(MOQ_LOCAL "${CMAKE_CURRENT_SOURCE_DIR}/../.." CACHE PATH "Path to the moq repo (builds cpp/moq in-tree)") +set(MOQ_VERSION "" CACHE STRING "Published moq-cpp version (required without MOQ_LOCAL)") # One predicate for both the version derivation here and the link/fetch choice # below, so the two can't drift apart. set(_moq_local_build FALSE) -if(MOQ_LOCAL AND EXISTS "${MOQ_LOCAL}/rs/libmoq/CMakeLists.txt") +if(MOQ_LOCAL AND EXISTS "${MOQ_LOCAL}/cpp/moq/CMakeLists.txt") set(_moq_local_build TRUE) endif() if(_moq_local_build) - set(_moq_manifest "${MOQ_LOCAL}/rs/libmoq/Cargo.toml") - file(STRINGS "${_moq_manifest}" _moq_version_lines REGEX "^version = \"[^\"]+\"$") - list(LENGTH _moq_version_lines _moq_version_count) - if(NOT _moq_version_count EQUAL 1) - message(FATAL_ERROR "Expected one package version in ${_moq_manifest}, found ${_moq_version_count}") + file(STRINGS "${MOQ_LOCAL}/cpp/moq/VERSION" _moq_link_version LIMIT_COUNT 1) + if(NOT _moq_link_version MATCHES "^[0-9]+\\.[0-9]+\\.[0-9]+$") + message(FATAL_ERROR "Unsupported version '${_moq_link_version}' in ${MOQ_LOCAL}/cpp/moq/VERSION") endif() - list(GET _moq_version_lines 0 _moq_version_line) - string(REGEX REPLACE "^version = \"([^\"]+)\"$" "\\1" _moq_link_version "${_moq_version_line}") else() if(NOT MOQ_VERSION) - message(FATAL_ERROR "MOQ_VERSION is required when MOQ_LOCAL does not contain rs/libmoq") + message(FATAL_ERROR "MOQ_VERSION is required when MOQ_LOCAL does not contain cpp/moq") endif() set(_moq_link_version "${MOQ_VERSION}") endif() @@ -67,36 +63,20 @@ endif() target_link_libraries(obs-moq PRIVATE OBS::libobs) if(_moq_local_build) - add_subdirectory("${MOQ_LOCAL}/rs/libmoq" moq) - target_link_libraries(obs-moq PRIVATE moq) + add_subdirectory("${MOQ_LOCAL}/cpp/moq" moq-cpp) else() include(FetchContent) FetchContent_Declare( - moq + moq-cpp URL - https://github.com/moq-dev/moq/releases/download/libmoq-v${_moq_link_version}/moq-${_moq_link_version}-${MOQ_TARGET}.${MOQ_ARCHIVE} + https://github.com/moq-dev/moq/releases/download/cpp-v${_moq_link_version}/moq-cpp-${_moq_link_version}-${MOQ_TARGET}.${MOQ_ARCHIVE} ) - FetchContent_MakeAvailable(moq) + FetchContent_MakeAvailable(moq-cpp) - find_package(moq REQUIRED PATHS ${moq_SOURCE_DIR} NO_DEFAULT_PATH) - target_link_libraries(obs-moq PRIVATE moq::moq) -endif() - -# libmoq is a Rust static library; its std runtime pulls in symbols from Windows -# system libraries (e.g. NtCreateNamedPipeFile from ntdll) that MSVC does not -# auto-resolve when the archive is linked into this module. These are the OS -# import libraries reported by `rustc --print native-static-libs` that aren't -# linked by default; they resolve by name from the Windows SDK (no hard-coded -# paths). The libmoq package config also declares these, but linking them here -# keeps the plugin buildable against any libmoq (local dev tree or prebuilt -# release) whose config may predate that declaration. Duplicate links are -# harmless. -if(WIN32) - target_link_libraries( - obs-moq - PRIVATE ntdll userenv ws2_32 dbghelp bcrypt - ) + find_package(moq-cpp REQUIRED PATHS ${moq-cpp_SOURCE_DIR} NO_DEFAULT_PATH) endif() +# The package carries the system libraries its Rust staticlib needs, on every platform. +target_link_libraries(obs-moq PRIVATE moq::cpp) # The obs-deps Qt6 build references the AGL framework transitively (via # WrapOpenGL), but recent macOS SDKs ship no linkable AGL binary -- it exists @@ -150,6 +130,7 @@ target_sources( obs-moq PRIVATE src/obs-moq.cpp + src/moq-worker.h src/moq-output.h src/moq-service.h src/moq-output.cpp diff --git a/cpp/obs/README.md b/cpp/obs/README.md index 2c16160fec..0af672f572 100644 --- a/cpp/obs/README.md +++ b/cpp/obs/README.md @@ -3,7 +3,8 @@ An OBS Studio plugin for publishing to and subscribing from MoQ relays. It loads into a stock OBS Studio install (no OBS source build required) and links -`libmoq`, built from the in-tree [`rs/libmoq`](../../rs/libmoq) crate. +the moq C++ package, built from the in-tree [`cpp/moq`](../moq) over +[`rs/moq-ffi`](../../rs/moq-ffi). Build instructions for each platform live in [`doc/bin/obs.md`](../../doc/bin/obs.md). In short, from the repo root: diff --git a/cpp/obs/VERSION b/cpp/obs/VERSION new file mode 100644 index 0000000000..faef31a435 --- /dev/null +++ b/cpp/obs/VERSION @@ -0,0 +1 @@ +0.7.0 diff --git a/cpp/obs/build.sh b/cpp/obs/build.sh index dfa153c6de..9c00bff2b9 100755 --- a/cpp/obs/build.sh +++ b/cpp/obs/build.sh @@ -36,16 +36,16 @@ while [[ $# -gt 0 ]]; do OUTPUT_DIR="$2" shift 2 ;; - --libmoq-release) - # Link a published libmoq release of this version instead of - # building rs/libmoq from source. CMake fetches the matching - # moq-- archive from the GitHub release and the - # plugin is versioned to match. Used by CI on a libmoq-v* tag. + --moq-release) + # Link a published moq-cpp release of this version instead of + # building cpp/moq from source. CMake fetches the matching + # moq-cpp-- archive from the GitHub release. The + # plugin keeps its own version. Used by CI on a cpp-v* tag. MOQ_RELEASE="$2" shift 2 ;; -h | --help) - echo "Usage: $0 [--target TARGET] [--version VERSION] [--output DIR] [--libmoq-release VERSION]" + echo "Usage: $0 [--target TARGET] [--version VERSION] [--output DIR] [--moq-release VERSION]" exit 0 ;; *) @@ -55,20 +55,14 @@ while [[ $# -gt 0 ]]; do esac done -# In libmoq-release mode the plugin version tracks the libmoq version. -if [[ -n "$MOQ_RELEASE" ]]; then - VERSION="$MOQ_RELEASE" -fi - if [[ -z "$TARGET" ]]; then TARGET=$(cc -dumpmachine 2>/dev/null || echo unknown) echo "Detected target: $TARGET" fi -# Default the version from buildspec.json's top-level "version" (the nested -# dependency entries also have "version" keys, hence the leading-indent anchor). +# Default to the plugin's own version. if [[ -z "$VERSION" ]]; then - VERSION=$(grep -E '^[[:space:]]{4}"version"' "$SCRIPT_DIR/buildspec.json" | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/') + VERSION=$(tr -d '[:space:]' <"$SCRIPT_DIR/VERSION") echo "Detected version: $VERSION" fi @@ -112,7 +106,7 @@ if [[ -n "$MOQ_RELEASE" ]]; then # Empty MOQ_LOCAL forces CMake's release-download branch; MOQ_VERSION and # MOQ_TARGET steer it at this target's archive. MOQ_ARCHIVE is correct per # preset already. - echo "Linking libmoq release v$MOQ_RELEASE ($TARGET)" + echo "Linking moq-cpp release v$MOQ_RELEASE ($TARGET)" CONFIGURE_ARGS+=(-DMOQ_LOCAL= "-DMOQ_VERSION=$MOQ_RELEASE" "-DMOQ_TARGET=$TARGET") fi cmake --preset "$PRESET" ${CONFIGURE_ARGS[@]+"${CONFIGURE_ARGS[@]}"} diff --git a/cpp/obs/buildspec.json b/cpp/obs/buildspec.json index 35dd8f2332..136d3eb975 100644 --- a/cpp/obs/buildspec.json +++ b/cpp/obs/buildspec.json @@ -38,7 +38,6 @@ }, "name": "obs-moq", "displayName": "MOQ for OBS", - "version": "0.0.1", "author": "Montevideo Tech", "website": "https://montevideotech.dev", "email": "emil@qualabs.com" diff --git a/cpp/obs/cmake/common/bootstrap.cmake b/cpp/obs/cmake/common/bootstrap.cmake index c38b7d029a..48312f052f 100644 --- a/cpp/obs/cmake/common/bootstrap.cmake +++ b/cpp/obs/cmake/common/bootstrap.cmake @@ -49,12 +49,15 @@ string(JSON _name GET ${buildspec} name) string(JSON _website GET ${buildspec} website) string(JSON _author GET ${buildspec} author) string(JSON _email GET ${buildspec} email) -string(JSON _version GET ${buildspec} version) +# The plugin's own version, human-owned like cpp/moq/VERSION. +file(STRINGS "${CMAKE_CURRENT_SOURCE_DIR}/VERSION" _version LIMIT_COUNT 1) +if(NOT _version MATCHES "^[0-9]+\\.[0-9]+\\.[0-9]+$") + message(FATAL_ERROR "Unsupported version '${_version}' in ${CMAKE_CURRENT_SOURCE_DIR}/VERSION (want x.y.z)") +endif() string(JSON _bundleId GET ${buildspec} platformConfig macos bundleId) -# Release CI builds the plugin against a specific libmoq release and versions it -# to match (see build.sh --libmoq-release), so allow overriding the buildspec -# version. Everything below derives PLUGIN_VERSION_* from _version. +# build.sh --version stamps a different version than VERSION's, so allow +# overriding it. Everything below derives PLUGIN_VERSION_* from _version. if(DEFINED PLUGIN_VERSION_OVERRIDE AND NOT PLUGIN_VERSION_OVERRIDE STREQUAL "") set(_version "${PLUGIN_VERSION_OVERRIDE}") endif() diff --git a/cpp/obs/justfile b/cpp/obs/justfile index 1c1f59ed10..83401b4473 100644 --- a/cpp/obs/justfile +++ b/cpp/obs/justfile @@ -10,8 +10,9 @@ # libobs/Qt6/ffmpeg via buildspec.json (obs-deps). # Windows: needs Visual Studio 2022; same buildspec download (run from Git Bash). # -# libmoq is built from the in-tree crate (rs/libmoq) via cargo. MOQ_LOCAL points -# CMake at the repo root by default, so there's no prebuilt release to download. +# The C++ package (cpp/moq) is built in-tree: cargo builds rs/moq-ffi and +# uniffi-bindgen-cpp renders its bindings. MOQ_LOCAL points CMake at the repo root +# by default, so there's no prebuilt release to download. set quiet @@ -20,7 +21,7 @@ default: just --list # Configure via CMake presets. MOQ_LOCAL defaults to the repo root inside -# CMakeLists.txt (so libmoq builds from rs/libmoq); pass `path=/some/moq` to +# CMakeLists.txt (so the C++ package builds from cpp/moq); pass `path=/some/moq` to # override. We deliberately don't compute the path here: `justfile_directory()` # resolves to the root justfile when this runs as `just obs setup`, so the @@ -61,25 +62,23 @@ run: cp -a build_macos/RelWithDebInfo/obs-moq.plugin "$dest/" RUST_LOG=debug RUST_BACKTRACE=1 OBS_LOG_LEVEL=debug /Applications/OBS.app/Contents/MacOS/OBS -# Type-check every plugin source and unit test, without linking or an obs-deps -# download. +# Type-check every plugin source and unit test, without linking the plugin or an +# obs-deps download. # # This is the gate to run in a worktree. `just obs build` needs the -# multi-hundred-MB obs-deps bundle (macOS/Windows) and a full cargo build, per -# worktree; this needs neither, because the dev shell carries all three header -# sets: libobs pinned to the same OBS release buildspec.json downloads (see -# `obs-headers` in flake.nix), Qt6, and ffmpeg. CI links the real thing on Linux -# (.github/workflows/obs.yml). +# multi-hundred-MB obs-deps bundle (macOS/Windows); this doesn't, because the dev +# shell carries the OBS-side header sets: libobs pinned to the same OBS release +# buildspec.json downloads (see `obs-headers` in flake.nix), Qt6, and ffmpeg. The +# moq headers are generated, so it builds rs/moq-ffi to render them. CI links the +# real thing on Linux (.github/workflows/obs.yml). # # It compiles the Qt sources too, which the CMake build only does when # ENABLE_QT and ENABLE_FRONTEND_API are on, so the definitions they gate on are # set here to match. # -# test/ is in scope because each test file defines the libmoq entry points the -# plugin calls, so a signature that drifts from the generated moq.h is a -# conflicting C declaration. Catching that needs only headers, which is why it -# belongs here: `just obs ci` finds the same drift, but only where obs.yml's -# path filter reaches, and `just obs test` is manual. +# test/ is in scope because the tests drive the plugin through the same +# generated moq headers, so an API that drifts under them fails here rather than +# only where obs.yml's path filter reaches; `just obs test` is manual. compile: #!/usr/bin/env bash set -euo pipefail @@ -110,7 +109,7 @@ compile: ffmpeg=$(pkg-config --cflags libavcodec libavutil libswscale libswresample) # MOQ_VERSION_STRING only reaches a label in the dock, so any value - # type-checks the same; CMake stamps the real libmoq version. + # type-checks the same; CMake stamps the real moq-cpp version. status=0 for source in src/*.cpp test/*.cpp; do # shellcheck disable=SC2086 @@ -137,7 +136,7 @@ compile: # gates a merge. # # CMAKE_BUILD_TYPE overrides the preset's RelWithDebInfo because the Rust cache -# CI restores holds dev-profile artifacts. A release build of libmoq would +# CI restores holds dev-profile artifacts. A release build of moq-ffi would # rebuild its whole dependency tree on every run, and the gate is about whether # the plugin compiles, which the profile doesn't change. ci preset="": @@ -148,11 +147,8 @@ ci preset="": cmake --build --preset "$PRESET" just _unit -# Print the -I flags for libobs and libmoq, shared by `compile` and `_unit`. -# -# moq.h is generated by cbindgen from rs/libmoq/src, so regenerate rather than -# just locating it: a stale header is how a plugin call to a since-changed -# libmoq function type-checks anyway. +# Print the -I flags for libobs and the moq C++ package, shared by `compile` and +# `_unit`. [private] _includes: #!/usr/bin/env bash @@ -198,36 +194,45 @@ _includes: exit 1 fi - (cd ../.. && cargo check --locked --quiet -p libmoq) - - # rs/libmoq/build.rs writes the header into its OUT_DIR, so ask cargo where - # that is. Replays the check above from cache, so it costs a process rather - # than a compile. - out_dir=$(cd ../.. && cargo check --locked -p libmoq --message-format=json | - jq -r 'select(.reason == "build-script-executed") | select(.package_id | tostring | test("libmoq")) | .out_dir' | - tail -1) - if [ -z "$out_dir" ]; then - echo "cargo reported no build script output for libmoq" >&2 - exit 1 - fi - moq_include="$out_dir/include" - if [ ! -f "$moq_include/moq.h" ]; then - echo "$moq_include/moq.h missing after 'cargo check --locked -p libmoq'" >&2 - exit 1 - fi + moq_include="$(just _moq)/include" # One flag per line, path glued to the -I, so a caller can rebuild the # arguments without splitting on the spaces in a path. printf -- '-I%s\n' src "$obs_include" "$simde_include" "$moq_include" -# Unit-test the plugin sources against stubbed libobs/libmoq/ffmpeg, under -# ThreadSanitizer. +# Build the moq C++ package from the in-tree cpp/moq, install it to a prefix, +# and print the prefix. Rebuilt every time rather than trusted from a previous +# run: the headers are rendered from the moq-ffi build, so a stale copy would +# type-check the plugin against an API that has since changed. Cargo and the +# copy-if-different render keep a no-op rebuild cheap. +# +# Debug, for the same reason `ci` is: the Rust cache CI restores holds +# dev-profile artifacts. +[private] +_moq: + #!/usr/bin/env bash + set -euo pipefail + if ! command -v uniffi-bindgen-cpp >/dev/null 2>&1; then + echo "uniffi-bindgen-cpp not on PATH; run inside 'nix develop', which supplies it" >&2 + exit 1 + fi + build="$PWD/../moq/build/obs" + prefix="$build/prefix" + { + cmake -S ../moq -B "$build" -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_LIBDIR=lib + cmake --build "$build" --config Release + cmake --install "$build" --config Release --prefix "$prefix" + } >&2 + echo "$prefix" + +# Unit-test the plugin sources against stubbed libobs and ffmpeg and the real +# moq-ffi, under ThreadSanitizer. # # Manual, like `just rs macos` and `just rs windows`, because ThreadSanitizer # needs its own build. `just obs ci` runs the same tests without it, so a # regression these assertions catch still turns PR CI red; the sanitizer is what -# adds the races on top. Run this whenever you touch src/, especially the session -# callback plumbing, whose orderings the build can't check. +# adds the races on top. Run this whenever you touch src/, especially the +# continuation plumbing, whose orderings the build can't check. test: #!/usr/bin/env bash set -euo pipefail @@ -286,6 +291,13 @@ _unit mode="plain": ffmpeg=$(pkg-config --cflags libavcodec libavutil libswscale) + # The output and source tests run against the real moq-ffi, over a server in + # the same process. The bindings compile here, with the tests' flags. + PKG_CONFIG_PATH="$(just _moq)/lib/pkgconfig" + export PKG_CONFIG_PATH + moq_sources=$(pkg-config --variable=sources moq-cpp) + moq_libs=$(pkg-config --libs moq-cpp) + flags=(-std=c++17 -g -O0 -pthread) if [ "{{ mode }}" = "tsan" ]; then flags+=(-fsanitize=thread) @@ -294,21 +306,23 @@ _unit mode="plain": out=$(mktemp -d) trap 'rm -rf "$out"' EXIT - # One binary per source under test: each test file defines its own libobs and - # libmoq stubs, so two of them can't share a link. Header-only helpers - # (quality defaults) build as a third binary with no plugin source. - for name in moq-output moq-source; do + "$cxx" "${flags[@]}" "${includes[@]}" -c "$moq_sources" -o "$out/moq.o" + + # One binary per source under test: each test file defines its own libobs + # stubs, so two of them can't share a link. Header-only helpers (quality + # defaults) build as separate binaries with no plugin source. + for name in moq-output moq-source obs-moq; do # shellcheck disable=SC2086 - # $ffmpeg stays unquoted on purpose: pkg-config hands back one - # space-separated string, and splitting it is the intended reading. + # $ffmpeg and $moq_libs stay unquoted on purpose: pkg-config hands back + # one space-separated string, and splitting it is the intended reading. "$cxx" "${flags[@]}" "${includes[@]}" $ffmpeg \ - -o "$out/$name-test" "test/$name-test.cpp" "src/$name.cpp" - TSAN_OPTIONS="halt_on_error=1" "$out/$name-test" + -o "$out/$name-test" "test/$name-test.cpp" "src/$name.cpp" "$out/moq.o" $moq_libs + TSAN_OPTIONS="halt_on_error=1 suppressions=$PWD/test/tsan.supp" "$out/$name-test" done for name in moq-quality-defaults moq-dock-stop moq-error moq-encoder-latency moq-dial moq-spark; do "$cxx" "${flags[@]}" "${includes[@]}" \ -o "$out/$name-test" "test/$name-test.cpp" - TSAN_OPTIONS="halt_on_error=1" "$out/$name-test" + TSAN_OPTIONS="halt_on_error=1 suppressions=$PWD/test/tsan.supp" "$out/$name-test" done # Lint formatting. Skips a tool silently if it isn't on $PATH (matches repo convention). @@ -366,7 +380,7 @@ check: echo "CMake accepted a release build without MOQ_VERSION" >&2 exit 1 fi - if ! grep -Fq "MOQ_VERSION is required when MOQ_LOCAL does not contain rs/libmoq" "$build_dir/configure.log"; then + if ! grep -Fq "MOQ_VERSION is required when MOQ_LOCAL does not contain cpp/moq" "$build_dir/configure.log"; then cat "$build_dir/configure.log" >&2 exit 1 fi diff --git a/cpp/obs/src/moq-dock.cpp b/cpp/obs/src/moq-dock.cpp index ff6e85e1b9..0f7ea21fea 100644 --- a/cpp/obs/src/moq-dock.cpp +++ b/cpp/obs/src/moq-dock.cpp @@ -565,12 +565,12 @@ MoQDock::MoQDock(QWidget *parent) : QWidget(parent) QString("%1").arg(QString::fromUtf8(PLUGIN_VERSION_STRING))); pluginVer->setToolTip("OBS plugin docs on doc.moq.dev"); - auto *libmoqVer = new QLabel(aboutPage); - libmoqVer->setOpenExternalLinks(true); - libmoqVer->setTextInteractionFlags(Qt::TextBrowserInteraction); - libmoqVer->setText( - QString("%1").arg(QString::fromUtf8(MOQ_VERSION_STRING))); - libmoqVer->setToolTip("libmoq C API docs on doc.moq.dev"); + auto *moqVer = new QLabel(aboutPage); + moqVer->setOpenExternalLinks(true); + moqVer->setTextInteractionFlags(Qt::TextBrowserInteraction); + moqVer->setText( + QString("%1").arg(QString::fromUtf8(MOQ_VERSION_STRING))); + moqVer->setToolTip("moq C++ API docs on doc.moq.dev"); auto *moqDevLink = new QLabel(aboutPage); moqDevLink->setOpenExternalLinks(true); @@ -587,7 +587,7 @@ MoQDock::MoQDock(QWidget *parent) : QWidget(parent) detectedLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); verForm->addRow("Plugin", pluginVer); - verForm->addRow("libmoq", libmoqVer); + verForm->addRow("moq-cpp", moqVer); verForm->addRow("moq.dev", moqDevLink); verForm->addRow("moq.pro", moqProLink); verForm->addRow("Available video encoders", detectedLabel); diff --git a/cpp/obs/src/moq-error.h b/cpp/obs/src/moq-error.h index 76005ea7b8..fb2ae1973e 100644 --- a/cpp/obs/src/moq-error.h +++ b/cpp/obs/src/moq-error.h @@ -7,6 +7,9 @@ namespace MoQError { enum class Kind { Unauthorized, Forbidden, Certificate, Timeout, Network, Connect, Offline, Other }; +// What the output recorded a failure as, beside its text. None means no failure. +enum Code : int { None = 0, Unauthorized, Forbidden, Connect, Offline, Other }; + inline Kind Classify(int code, std::string reason) { for (char &c : reason) { @@ -16,9 +19,9 @@ inline Kind Classify(int code, std::string reason) auto has = [&](const char *text) { return reason.find(text) != std::string::npos; }; - if (has("unauthorized") || code == -34) + if (has("unauthorized") || code == Code::Unauthorized) return Kind::Unauthorized; - if (has("forbidden") || code == -35) + if (has("forbidden") || code == Code::Forbidden) return Kind::Forbidden; // Fetching the pin happens before TLS verification and can fail on an outage. const bool fetching = has("failed to fetch fingerprint") || has("fingerprint request failed") || @@ -29,9 +32,9 @@ inline Kind Classify(int code, std::string reason) return Kind::Timeout; if (fetching || has("failed to connect") || has("dns")) return Kind::Network; - if (code == -5 || has("connect error")) + if (code == Code::Connect || has("connect error")) return Kind::Connect; - if (code == -17 || reason == "offline") + if (code == Code::Offline || reason == "offline") return Kind::Offline; return Kind::Other; } diff --git a/cpp/obs/src/moq-output.cpp b/cpp/obs/src/moq-output.cpp index 7a5e071951..63854e45b0 100644 --- a/cpp/obs/src/moq-output.cpp +++ b/cpp/obs/src/moq-output.cpp @@ -2,6 +2,7 @@ #include #include "moq-output.h" +#include "moq-error.h" #include "moq-settings.h" #include "moq-url.h" #include "logger.h" @@ -9,10 +10,8 @@ #include #include - -extern "C" { -#include "moq.h" -} +#include +#include namespace { @@ -33,20 +32,8 @@ bool LooksGenericOffline(const std::string &reason) return false; } -bool IsAuthFailure(int code, const std::string &reason) -{ - if (code == -34 || code == -35) - return true; - std::string lower = reason; - for (char &c : lower) { - if (c >= 'A' && c <= 'Z') - c = static_cast(c - 'A' + 'a'); - } - return lower.find("unauthorized") != std::string::npos || lower.find("forbidden") != std::string::npos; -} - -// Dial URL scheme only. https races WebTransport vs WebSocket in moq-native; -// libmoq does not yet expose which won, so do not invent a transport name here. +// Dial URL scheme only. https races WebTransport vs WebSocket, and the session +// does not report which won, so do not invent a transport name here. std::string DialSchemeLabel(const std::string &url) { const auto colon = url.find(':'); @@ -66,47 +53,51 @@ std::string DialSchemeLabel(const std::string &url) return scheme; } -} // namespace - -MoQOutput::MoQOutput(obs_data_t *, obs_output_t *output) - : output(output), - state(std::make_shared(output)), - path(), - total_bytes_sent(0), - origin(moq_origin_create()), - broadcast(0) +// The failure code the dock classifies a moq::Error by, beside its text. +MoQError::Code FailureCode(const moq::Error &error) { + const auto &variant = error.get_variant(); + if (std::holds_alternative(variant)) + return MoQError::Code::Unauthorized; + if (std::holds_alternative(variant)) + return MoQError::Code::Forbidden; + if (std::holds_alternative(variant)) + return MoQError::Code::Connect; + if (const auto *protocol = std::get_if(&variant)) { + if (protocol->details.kind == moq::ProtocolKind::kUnauthorized) + return MoQError::Code::Unauthorized; + } + return MoQError::Code::Other; } +} // namespace + +MoQOutput::MoQOutput(obs_data_t *, obs_output_t *output) : output(output), path(), total_bytes_sent(0) {} + MoQOutput::~MoQOutput() { - // Retires the attempt, so a terminal callback that is still in flight won't - // signal a stop on an output that is going away. + // Retires the attempt, so a continuation that is already queued won't signal a + // stop on an output that is going away. Stop(); - // Reset() closed the session and finished the tracks and the broadcast, so - // the origin has no children left. - moq_origin_close(origin); - - // Give up the frontend. A terminal callback still in flight holds its own - // reference to the shared state, so nothing here waits on the libmoq runtime: - // whenever it arrives it finds the state detached and reports nothing. - state->Detach(); + // Drops whatever is still queued and waits out a continuation in flight, which + // may be parked on signal_mutex; so this must not hold it. Nothing touches this + // object once it returns. + worker.Stop(); } -void MoQOutput::SessionState::Detach() +template +std::function MoQOutput::Current(const std::shared_ptr ¤t, Callback callback) { - // A callback that has already decided to report parks here, so this returns - // only once nothing is inside an OBS call and the output can be freed. - std::lock_guard signal_lock(signal_mutex); - output = nullptr; -} - -void MoQOutput::SessionState::SignalStop(int code) -{ - std::lock_guard signal_lock(signal_mutex); - if (output) - obs_output_signal_stop(output, code); + return [this, weak = std::weak_ptr(current), callback = std::move(callback)](Output output) { + // Held across the check and whatever the callback reports, so a Stop() can't + // retire the attempt in between. + std::lock_guard signal_lock(signal_mutex); + auto current = weak.lock(); + if (!current || current != attempt) + return; + callback(current, std::move(output)); + }; } bool MoQOutput::Start() @@ -118,7 +109,8 @@ bool MoQOutput::Start() obs_service_t *service = obs_output_get_service(output); if (!service) { LOG_ERROR("Failed to get service from output"); - state->SignalStop(OBS_OUTPUT_ERROR); + std::lock_guard signal_lock(signal_mutex); + obs_output_signal_stop(output, OBS_OUTPUT_ERROR); return false; } @@ -133,10 +125,11 @@ bool MoQOutput::Start() } const char *server_value = obs_service_get_connect_info(service, OBS_SERVICE_CONNECT_INFO_SERVER_URL); - const std::string url = server_value ? server_value : ""; - if (url.empty()) { + const std::string server = server_value ? server_value : ""; + if (server.empty()) { LOG_ERROR("Server URL is empty"); - state->SignalStop(OBS_OUTPUT_BAD_PATH); + std::lock_guard signal_lock(signal_mutex); + obs_output_signal_stop(output, OBS_OUTPUT_BAD_PATH); return false; } @@ -157,156 +150,234 @@ bool MoQOutput::Start() return false; } + auto next = std::make_shared(); + next->client = moq::Client::init(); + next->url = server; + // Advanced settings live on the service alongside the URL and path. With the group - // switched off Pointer() is NULL, which dials with the library defaults. The config - // borrows its strings, so it has to outlive the connect below. + // switched off the client keeps the library defaults. OBSDataAutoRelease service_settings = obs_service_get_settings(service); - MoQSettings::Config client; - if (!MoQSettings::BuildConfig(service_settings, &client)) { - // BuildConfig logged why. Refusing to start beats connecting with a setting - // the user asked for quietly dropped. - obs_output_set_last_error(output, "Invalid advanced MoQ settings; see the log for details."); - state->SignalStop(OBS_OUTPUT_CONNECT_FAILED); + std::string invalid; + if (!MoQSettings::Configure(service_settings, *next->client, &invalid)) { + // Refusing to start beats connecting with a setting the user asked for + // quietly dropped. + LOG_ERROR("Invalid advanced MoQ settings: %s", invalid.c_str()); + std::lock_guard signal_lock(signal_mutex); + obs_output_set_last_error(output, ("Invalid advanced MoQ settings: " + invalid).c_str()); + obs_output_signal_stop(output, OBS_OUTPUT_CONNECT_FAILED); return false; } - LOG_INFO("Connecting to MoQ server: %s", MoQRedactUrl(url).c_str()); + // Create the broadcast on an origin of our own, then announce it so subscribers + // can discover it. Stop() finishes it, so each Start creates a fresh one. + LOG_INFO("Publishing broadcast: %s", path.c_str()); + auto next_origin = moq::OriginProducer::init(moq::OriginConfig{}); + auto next_broadcast = next_origin->create_broadcast(path); + if (!next_broadcast) { + LOG_ERROR("Failed to create broadcast: %s", next_broadcast.error().to_string().c_str()); + return false; + } + if (auto announced = (*next_broadcast)->announce(moq::Route{}); !announced) { + LOG_ERROR("Failed to announce broadcast: %s", announced.error().to_string().c_str()); + return false; + } + if (auto wired = next->client->set_publish(next_origin); !wired) { + LOG_ERROR("Failed to publish through the client: %s", wired.error().to_string().c_str()); + return false; + } - // Held from the connect through obs_output_begin_data_capture. The status - // callback takes the same lock before reporting anything, so this attempt's - // terminal cannot signal a failure against an output that is only half - // started: it waits until the output is committed, and OBS then handles the - // stop through its normal active-output path. - std::lock_guard signal_lock(state->signal_mutex); + LOG_INFO("Connecting to MoQ server: %s", MoQRedactUrl(server).c_str()); - uint64_t attempt; + // Held from the connect through obs_output_begin_data_capture. Every + // continuation takes the same lock before reporting anything, so this attempt's + // failure cannot signal against an output that is only half started: it waits + // until the output is committed, and OBS then handles the stop through its + // normal active-output path. + std::lock_guard signal_lock(signal_mutex); + + attempt = next; { - std::lock_guard lock(state->mutex); - attempt = state->attempt; - state->url = url; + std::lock_guard lock(mutex); + url = server; + } + { + std::lock_guard lock(media_mutex); + origin = next_origin; + broadcast = *next_broadcast; } - // Everything the callbacks need about this attempt, copied so they never read - // a member the next Start() may be rewriting, plus the reference that keeps - // the shared state alive. Freed by the terminal callback, so it outlives this - // scope. - auto ref = new SessionRef{state, attempt, url, std::chrono::steady_clock::now()}; - - // Start establishing a session with the MoQ server // NOTE: You could publish the same broadcasts to multiple sessions if you want (redundant ingest). - int handle = - moq_session_connect(url.data(), url.size(), client.Pointer(), origin, 0, MoQOutput::SessionStatus, ref); - - if (handle < 0) { - const char *reason = moq_error(); - LOG_ERROR("Failed to initialize MoQ server: %d: %s", handle, reason ? reason : "unknown error"); - obs_output_set_last_error(output, reason ? reason : "Failed to initialize MoQ connection"); - // No subscription was created, so no terminal will fire; drop the reference. - delete ref; - return false; + next->started = std::chrono::steady_clock::now(); + next->pending = next->client->connect(server).then( + worker.Executor(), Current>>( + next, [this](const std::shared_ptr ¤t, auto result) { + OnConnect(current, std::move(result)); + })); + + obs_output_begin_data_capture(output, 0); + + return true; +} + +void MoQOutput::OnConnect(const std::shared_ptr ¤t, moq::expected> result) +{ + if (!result) { + Fail(current, result.error()); + return; } - bool superseded; + auto elapsed = std::chrono::steady_clock::now() - current->started; + auto ms = static_cast(std::chrono::duration_cast(elapsed).count()); + const int connect_epoch = static_cast((*result)->epoch()); + + current->session = *result; + current->connected = true; + current->live = true; { - std::lock_guard lock(state->mutex); - // Holding signal_mutex keeps this attempt current: libmoq delivers the - // terminal on its runtime thread, which parks in SessionState::Closed until - // we commit. The check stands guard over that assumption rather than - // trusting libmoq's threading to stay as it is. - superseded = state->attempt != attempt; - if (!superseded) - state->session = handle; - } - - if (superseded) { - // The session died during connect without going through the callback's - // signal path. Refuse the start rather than capturing into a dead session; - // OBS surfaces the last error we recorded when info.start returns false. - LOG_ERROR("MoQ session failed before the output started: %s", MoQRedactUrl(url).c_str()); - return false; + std::lock_guard lock(mutex); + session = current->session; + connected = true; + live = true; + last_failure_code = MoQError::None; + last_failure_reason.clear(); } + // OBS and older dock paths treat connect_time_ms == 0 as "never connected". + // Clamp sub-millisecond connects to 1 so that sentinel stays honest. + connect_time_ms = ms > 0 ? ms : 1; - LOG_INFO("Publishing broadcast: %s", path.c_str()); + LOG_INFO("MoQ session connected (%d ms, epoch %d): %s", ms, connect_epoch, MoQRedactUrl(current->url).c_str()); - // Create the broadcast on the origin we created, then announce it so - // subscribers can discover it. Stop() finishes it, so each Start creates a fresh one. - broadcast = moq_origin_create_broadcast(origin, path.data(), path.size()); - if (broadcast < 0) { - LOG_ERROR("Failed to create broadcast: %d", broadcast); - broadcast = 0; - // The session connected above; close it so a retry on this same output - // doesn't reuse the stale handle. Its terminal callback releases the - // outstanding-session reference the destructor waits on. - Stop(false); - return false; + WatchStatus(current); +} + +void MoQOutput::WatchStatus(const std::shared_ptr ¤t) +{ + // Replacing `pending` from inside its own continuation is fine: that call has + // already completed, so dropping it cancels nothing. + current->pending = current->session->status().then( + worker.Executor(), Current>( + current, [this](const std::shared_ptr ¤t, auto result) { + OnStatus(current, std::move(result)); + })); +} + +void MoQOutput::OnStatus(const std::shared_ptr ¤t, moq::expected result) +{ + // The status stream ends in an error once reconnecting gives up for good. + if (!result) { + Fail(current, result.error()); + return; } - if (moq_publish_announce(broadcast, nullptr) < 0) { - LOG_ERROR("Failed to announce broadcast"); - Stop(false); - return false; + + switch (*result) { + case moq::ConnectionStatus::kConnected: { + const int connect_epoch = static_cast(current->session->epoch()); + current->live = true; + { + std::lock_guard lock(mutex); + live = true; + last_failure_code = MoQError::None; + last_failure_reason.clear(); + } + LOG_INFO("MoQ session reconnected (epoch %d): %s", connect_epoch, MoQRedactUrl(current->url).c_str()); + break; + } + case moq::ConnectionStatus::kDisconnected: { + current->live = false; + std::lock_guard lock(mutex); + live = false; + LOG_WARNING("MoQ session dropped, reconnecting: %s", MoQRedactUrl(current->url).c_str()); + break; + } + case moq::ConnectionStatus::kMigrating: + // The old session keeps serving while the replacement dials. + LOG_INFO("MoQ session migrating: %s", MoQRedactUrl(current->url).c_str()); + break; } - obs_output_begin_data_capture(output, 0); + WatchStatus(current); +} - return true; +void MoQOutput::Fail(const std::shared_ptr ¤t, const moq::Error &error) +{ + const std::string reason = error.to_string(); + const bool was_connected = current->connected; + + // Retire the attempt, which also limits the failure signal to one per Start(). + // The broadcast stays until Reset(), as OBS stops or restarts the output next. + attempt.reset(); + { + std::lock_guard lock(mutex); + session.reset(); + connected = false; + live = false; + last_failure_code = FailureCode(error); + last_failure_reason = reason; + } + connect_time_ms = 0; + + LOG_ERROR("MoQ session failed: %s: %s", MoQRedactUrl(current->url).c_str(), reason.c_str()); + + // Reconnection gave up, so nothing is reaching the server any more. Without + // this OBS keeps encoding and reporting the stream as live forever. + obs_output_set_last_error(output, reason.c_str()); + // CONNECT_FAILED is terminal for OBS. DISCONNECTED lets its own reconnect + // logic retry, which is only worth offering once we know the server works. + obs_output_signal_stop(output, was_connected ? OBS_OUTPUT_DISCONNECTED : OBS_OUTPUT_CONNECT_FAILED); } void MoQOutput::Stop(bool signal) { - std::lock_guard signal_lock(state->signal_mutex); + std::lock_guard signal_lock(signal_mutex); Reset(); - if (signal) { - state->SignalStop(OBS_OUTPUT_SUCCESS); - } + if (signal) + obs_output_signal_stop(output, OBS_OUTPUT_SUCCESS); } void MoQOutput::Reset() { - // Excludes the status callback's report: retiring the attempt and signalling - // a failure must not interleave, or a stop that already happened gets followed - // by a failure OBS turns into a reconnect. - std::lock_guard signal_lock(state->signal_mutex); + // Excludes a continuation's report: retiring the attempt and signalling a + // failure must not interleave, or a stop that already happened gets followed by + // a failure OBS turns into a reconnect. + std::lock_guard signal_lock(signal_mutex); + + // Dropping the attempt cancels its pending connect or status call. + if (attempt && attempt->session) + attempt->session->shutdown(); + attempt.reset(); - int stale; { - std::lock_guard lock(state->mutex); - // Retire the attempt so an in-flight terminal callback stays out of the way. - state->attempt++; - state->connected = false; - state->connect_time_ms = 0; - state->epoch = 0; - state->url.clear(); - state->last_failure_code = 0; - state->last_failure_reason.clear(); - stale = state->session; - state->session = 0; - } - - // Outside the lock: the status callback takes state->mutex, and libmoq is - // free to run it on its own thread while we're here. - if (stale > 0) - moq_session_close(stale); - - for (auto &[encoder, handle] : video_tracks) { - if (handle > 0) - moq_publish_media_finish(handle); + std::lock_guard lock(mutex); + session.reset(); + connected = false; + live = false; + url.clear(); + last_failure_code = MoQError::None; + last_failure_reason.clear(); + } + connect_time_ms = 0; + + std::lock_guard lock(media_mutex); + for (auto &[encoder, track] : video_tracks) { + if (track) + track->finish(); } video_tracks.clear(); - for (auto &[encoder, handle] : audio_tracks) { - if (handle > 0) - moq_publish_media_finish(handle); + for (auto &[encoder, track] : audio_tracks) { + if (track) + track->finish(); } audio_tracks.clear(); // Close the broadcast so the origin retracts it immediately; Start() // creates a fresh one on restart. - if (broadcast > 0) { - moq_publish_close(broadcast); - broadcast = 0; - } + if (broadcast) + broadcast->close(); + broadcast.reset(); + origin.reset(); } bool MoQOutput::TryGetConnectionStats(ConnectionStats *out) @@ -314,176 +385,81 @@ bool MoQOutput::TryGetConnectionStats(ConnectionStats *out) if (!out) return false; - uint32_t handle; - uint64_t attempt; - std::string dialUrl; + std::shared_ptr current; + std::string dial_url; { - std::lock_guard lock(state->mutex); - if (state->session == 0) + std::lock_guard lock(mutex); + if (!session) return false; - handle = static_cast(state->session); - attempt = state->attempt; - dialUrl = state->url; - } - - moq_connection_snapshot connection{}; - const int32_t rc = moq_session_snapshot(handle, &connection); - if (rc != 0) { - const char *message = moq_error(); - const std::string next = message && *message ? message : "offline"; - std::lock_guard lock(state->mutex); - if (state->attempt != attempt) + if (!live) { + // Between reconnects. Keep a more specific prior failure (unauthorized) + // over a generic offline blip. + if (last_failure_reason.empty() || LooksGenericOffline(last_failure_reason)) { + last_failure_code = MoQError::Offline; + last_failure_reason = "offline"; + } return false; - // Keep a more specific prior failure (unauthorized) over a generic offline blip. - if (state->last_failure_reason.empty() || LooksGenericOffline(state->last_failure_reason) || - !LooksGenericOffline(next) || IsAuthFailure(rc, next)) { - state->last_failure_code = rc; - state->last_failure_reason = next; } - return false; + current = session; + dial_url = url; } - const auto &raw = connection.stats; + const moq::ConnectionStats raw = current->stats(); + ConnectionStats snapshot; snapshot.reconnects = GetReconnectCount(); - snapshot.rtt_valid = raw.rtt_valid; - snapshot.rtt_ms = raw.rtt_valid ? static_cast(raw.rtt_us) / 1000.0 : 0; - snapshot.estimated_send_rate_valid = raw.estimated_send_rate_valid; + snapshot.rtt_valid = raw.rtt_us.has_value(); + snapshot.rtt_ms = raw.rtt_us ? static_cast(*raw.rtt_us) / 1000.0 : 0; + snapshot.estimated_send_rate_valid = raw.estimated_send_rate_bps.has_value(); snapshot.estimated_send_rate_bps = - raw.estimated_send_rate_valid ? static_cast(raw.estimated_send_rate_bps) : 0; - snapshot.estimated_recv_rate_valid = raw.estimated_recv_rate_valid; + raw.estimated_send_rate_bps ? static_cast(*raw.estimated_send_rate_bps) : 0; + snapshot.estimated_recv_rate_valid = raw.estimated_recv_rate_bps.has_value(); snapshot.estimated_recv_rate_bps = - raw.estimated_recv_rate_valid ? static_cast(raw.estimated_recv_rate_bps) : 0; - snapshot.bytes_sent_valid = raw.bytes_sent_valid; - snapshot.bytes_sent = raw.bytes_sent_valid ? raw.bytes_sent : 0; - if (raw.packets_sent_valid && raw.packets_lost_valid && raw.packets_sent > 0) { + raw.estimated_recv_rate_bps ? static_cast(*raw.estimated_recv_rate_bps) : 0; + snapshot.bytes_sent_valid = raw.bytes_sent.has_value(); + snapshot.bytes_sent = raw.bytes_sent.value_or(0); + if (raw.packets_sent && raw.packets_lost && *raw.packets_sent > 0) { snapshot.loss_valid = true; snapshot.loss_pct = - 100.0 * static_cast(raw.packets_lost) / static_cast(raw.packets_sent); + 100.0 * static_cast(*raw.packets_lost) / static_cast(*raw.packets_sent); } - snapshot.dial = DialSchemeLabel(dialUrl); + snapshot.dial = DialSchemeLabel(dial_url); - snapshot.protocol.assign(connection.protocol.data, connection.protocol.len); + // A Stop(), restart, or disconnect while stats() ran retired this session, so + // its numbers no longer describe the output. + std::lock_guard lock(mutex); + if (session != current || !live) + return false; + *out = std::move(snapshot); + return true; +} +int MoQOutput::GetReconnectCount() +{ + std::shared_ptr current; { - std::lock_guard lock(state->mutex); - if (state->attempt != attempt || static_cast(state->session) != handle) - return false; - *out = std::move(snapshot); + std::lock_guard lock(mutex); + current = session; } - - return true; + // Read live rather than tracked from status(), which coalesces a drop that + // reconnects before it is asked again. + const uint64_t connect_epoch = current ? current->epoch() : 0; + return connect_epoch > 1 ? static_cast(connect_epoch - 1) : 0; } bool MoQOutput::IsLiveSession() { - std::lock_guard lock(state->mutex); - return state->connected && state->session != 0; + std::lock_guard lock(mutex); + return connected && session != nullptr; } void MoQOutput::CopyLastFailure(int *code, std::string *reason) { - std::lock_guard lock(state->mutex); + std::lock_guard lock(mutex); if (code) - *code = state->last_failure_code; + *code = last_failure_code; if (reason) - *reason = state->last_failure_reason; -} - -// libmoq status codes (>= 0.3.0): > 0 = (re)connected, carrying the connection -// epoch; 0 = closed cleanly (terminal); < 0 = fatal, reconnection gave up (terminal). -void MoQOutput::SessionStatus(void *user_data, int code) -{ - auto ref = static_cast(user_data); - - if (code > 0) { - ref->state->Connected(*ref, code); - return; - } - - // Terminal: libmoq never touches user_data again, so we own the reference. - // Destroying it drops the last hold on the shared state when the output is - // already gone, so this is where a detached state is finally freed. - std::unique_ptr owned(ref); - owned->state->Closed(*owned, code); -} - -void MoQOutput::SessionState::Connected(const SessionRef &ref, int connect_epoch) -{ - auto elapsed = std::chrono::steady_clock::now() - ref.started; - auto ms = static_cast(std::chrono::duration_cast(elapsed).count()); - - { - std::lock_guard lock(mutex); - if (attempt != ref.attempt) - return; - connected = true; - // OBS and older dock paths treat connect_time_ms == 0 as "never connected". - // Clamp sub-millisecond connects to 1 so that sentinel stays honest. - connect_time_ms = ms > 0 ? ms : 1; - epoch = connect_epoch; - last_failure_code = 0; - last_failure_reason.clear(); - } - - LOG_INFO("MoQ session connected (%d ms, epoch %d): %s", ms, connect_epoch, MoQRedactUrl(ref.url).c_str()); -} - -void MoQOutput::SessionState::Closed(const SessionRef &ref, int code) -{ - // moq_error() only describes the most recent call on this thread, so copy the - // reason out before anything below can overwrite it. - std::string reason; - if (code < 0) { - const char *message = moq_error(); - reason = message ? message : "unknown error"; - } - - // Held across both the decision below and the signal itself. Taking it before - // mutex is the required lock order, and holding it through the signal is what - // stops a teardown from landing in between: without that, Stop() can retire - // the attempt after we decide to report, and OBS turns the late - // OBS_OUTPUT_DISCONNECTED into a reconnect of a stopped stream. It is also - // what Detach() waits on, so `output` cannot go away mid-report. - std::lock_guard signal_lock(signal_mutex); - - bool current; - bool was_connected = false; - { - std::lock_guard lock(mutex); - current = attempt == ref.attempt; - if (current) { - was_connected = connected; - // The session task has ended and dropped the handle, so retire it here - // rather than closing a handle libmoq no longer knows about. - session = 0; - attempt++; - connected = false; - connect_time_ms = 0; - epoch = 0; - if (code < 0) { - last_failure_code = code; - last_failure_reason = reason; - } - } - } - - if (code == 0) { - LOG_INFO("MoQ session closed: %s", MoQRedactUrl(ref.url).c_str()); - } else { - LOG_ERROR("MoQ session failed (%d): %s: %s", code, MoQRedactUrl(ref.url).c_str(), reason.c_str()); - } - - // Reconnection gave up, so nothing is reaching the server any more. Without - // this OBS keeps encoding and reporting the stream as live forever. Only the - // current attempt signals, which is what keeps it to one signal per Start(). - // A detached output has already been destroyed, so there is nobody to tell. - if (code < 0 && current && output) { - obs_output_set_last_error(output, reason.c_str()); - // CONNECT_FAILED is terminal for OBS. DISCONNECTED lets its own reconnect - // logic retry, which is only worth offering once we know the server works. - obs_output_signal_stop(output, was_connected ? OBS_OUTPUT_DISCONNECTED : OBS_OUTPUT_CONNECT_FAILED); - } + *reason = last_failure_reason; } void MoQOutput::Data(struct encoder_packet *packet) @@ -491,9 +467,9 @@ void MoQOutput::Data(struct encoder_packet *packet) if (!packet) { // One report for the pair, so a session failure can't slip between the // teardown and the encode error and report a second time. - std::lock_guard signal_lock(state->signal_mutex); + std::lock_guard signal_lock(signal_mutex); Stop(false); - state->SignalStop(OBS_OUTPUT_ENCODE_ERROR); + obs_output_signal_stop(output, OBS_OUTPUT_ENCODE_ERROR); return; } @@ -504,8 +480,26 @@ void MoQOutput::Data(struct encoder_packet *packet) } } +// The packet's timestamp on the broadcast clock, or nothing when it falls before zero. +static std::optional PacketTimestamp(const struct encoder_packet *packet) +{ + // Add ~1 second offset to handle negative PTS from audio priming frames, and + // the same to video for A/V sync. + // TODO: This is slightly wrong when den is not evenly divisible by num, but close enough. + int64_t pts = packet->pts + packet->timebase_den / packet->timebase_num; + if (pts < 0) + return std::nullopt; + return util_mul_div64(pts, 1000000ULL * packet->timebase_num, packet->timebase_den); +} + +static moq::Frame PacketFrame(const struct encoder_packet *packet, uint64_t timestamp_us) +{ + return moq::Frame{std::vector(packet->data, packet->data + packet->size), timestamp_us}; +} + void MoQOutput::AudioData(struct encoder_packet *packet) { + std::lock_guard lock(media_mutex); obs_encoder_t *encoder = packet->encoder; auto it = audio_tracks.find(encoder); @@ -513,25 +507,20 @@ void MoQOutput::AudioData(struct encoder_packet *packet) AudioInit(encoder); it = audio_tracks.find(encoder); } - if (it == audio_tracks.end() || it->second < 0) { + if (it == audio_tracks.end() || !it->second) { // We failed to initialize the audio track, so we can't write any data. return; } - int handle = it->second; + const auto &track = it->second; - // Add ~1 second offset to handle negative PTS from audio priming frames. - // TODO: This is slightly wrong when den is not evenly divisible by num, but close enough. - int64_t pts = packet->pts + packet->timebase_den / packet->timebase_num; - if (pts < 0) { + auto timestamp = PacketTimestamp(packet); + if (!timestamp) { LOG_WARNING("Dropping audio frame with negative PTS: %lld", (long long)packet->pts); return; } - auto pts_us = util_mul_div64(pts, 1000000ULL * packet->timebase_num, packet->timebase_den); - - auto result = moq_publish_media_frame(handle, packet->data, packet->size, pts_us); - if (result < 0) { - LOG_ERROR("Failed to write audio frame: %d", result); + if (auto result = track->write_frame(PacketFrame(packet, *timestamp)); !result) { + LOG_ERROR("Failed to write audio frame: %s", result.error().to_string().c_str()); return; } @@ -539,15 +528,13 @@ void MoQOutput::AudioData(struct encoder_packet *packet) // stream is one group. Cut per frame, which is one QUIC stream per packet forwarded without // waiting for the next, the right trade for live. Video groups at its own keyframes. // Cut before observing the flush so a failed observation never leaves the group open. - result = moq_publish_media_cut(handle); - if (result < 0) { - LOG_ERROR("Failed to cut audio group: %d", result); + if (auto result = track->cut(); !result) { + LOG_ERROR("Failed to cut audio group: %s", result.error().to_string().c_str()); return; } - result = moq_publish_media_flush(handle, pts_us); - if (result < 0) { - LOG_ERROR("Failed to observe audio encoder flush: %d", result); + if (auto result = track->flush(*timestamp); !result) { + LOG_ERROR("Failed to observe audio encoder flush: %s", result.error().to_string().c_str()); return; } @@ -556,6 +543,7 @@ void MoQOutput::AudioData(struct encoder_packet *packet) void MoQOutput::VideoData(struct encoder_packet *packet) { + std::lock_guard lock(media_mutex); obs_encoder_t *encoder = packet->encoder; auto it = video_tracks.find(encoder); @@ -563,41 +551,39 @@ void MoQOutput::VideoData(struct encoder_packet *packet) VideoInit(encoder); it = video_tracks.find(encoder); } - if (it == video_tracks.end() || it->second < 0) + if (it == video_tracks.end() || !it->second) return; - int handle = it->second; + const auto &track = it->second; - // Add ~1 second offset to match audio for A/V sync. - // TODO: This is slightly wrong when den is not evenly divisible by num, but close enough. - int64_t pts = packet->pts + packet->timebase_den / packet->timebase_num; - if (pts < 0) { + auto timestamp = PacketTimestamp(packet); + if (!timestamp) { LOG_WARNING("Dropping video frame with negative PTS: %lld", (long long)packet->pts); return; } - auto pts_us = util_mul_div64(pts, 1000000ULL * packet->timebase_num, packet->timebase_den); - - auto result = moq_publish_media_frame(handle, packet->data, packet->size, pts_us); - if (result < 0) { - LOG_ERROR("Failed to write video frame: %d", result); + if (auto result = track->write_frame(PacketFrame(packet, *timestamp)); !result) { + LOG_ERROR("Failed to write video frame: %s", result.error().to_string().c_str()); return; } - result = moq_publish_media_flush(handle, pts_us); - if (result < 0) { - LOG_ERROR("Failed to observe video encoder flush: %d", result); + if (auto result = track->flush(*timestamp); !result) { + LOG_ERROR("Failed to observe video encoder flush: %s", result.error().to_string().c_str()); return; } total_bytes_sent += packet->size; } +// NOTE: Caller must hold media_mutex. void MoQOutput::VideoInit(obs_encoder_t *encoder) { if (!encoder) { LOG_ERROR("Failed to get video encoder"); return; } + // Stopped, or not started yet: no broadcast to publish on. + if (!broadcast) + return; OBSDataAutoRelease settings = obs_encoder_get_settings(encoder); const auto video_width = obs_encoder_get_width(encoder); @@ -617,53 +603,54 @@ void MoQOutput::VideoInit(obs_encoder_t *encoder) // Map the OBS codec name onto a MoQ format. Both H.26x entries are the Annex-B framing // with inline parameter sets, which is what OBS hands us. - moq_video_init config{}; + moq::VideoInit config{}; if (strcmp(codec, "h264") == 0) { - config.format = MOQ_VIDEO_FORMAT_AVC3; + config.format = moq::VideoFormat::kAvc3; } else if (strcmp(codec, "hevc") == 0) { - config.format = MOQ_VIDEO_FORMAT_HEV1; + config.format = moq::VideoFormat::kHev1; } else if (strcmp(codec, "av1") == 0) { - config.format = MOQ_VIDEO_FORMAT_AV01; + config.format = moq::VideoFormat::kAv01; } else { LOG_ERROR("Unsupported video codec: %s", codec); + video_tracks[encoder] = nullptr; return; } - config.init = extra_data; - config.init_len = extra_size; + if (extra_data && extra_size > 0) + config.data.assign(extra_data, extra_data + extra_size); // Seed catalog fields a downstream moq-transcode needs before measured rates // arrive: coded size (also from SPS once parsed) and configured CBR bitrate so // same-height ladder rungs can undercut the mezzanine. - if (video_width > 0 && video_height > 0) { - config.hint.coded_width = video_width; - config.hint.coded_height = video_height; - config.hint.has_coded = true; - } + moq::VideoHint hint{}; + if (video_width > 0 && video_height > 0) + hint.coded = moq::Dimensions{video_width, video_height}; const std::string rate_control = settings ? obs_data_get_string(settings, "rate_control") : ""; - if (video_bitrate_kbps > 0 && (rate_control == "CBR" || rate_control == "cbr")) { - config.hint.bitrate = (uint64_t)video_bitrate_kbps * 1000ULL; - config.hint.has_bitrate = true; - } - config.hint.optimize_for_latency = true; - config.hint.has_optimize_for_latency = true; - - int handle = moq_publish_video(broadcast, &config); - video_tracks[encoder] = handle; - if (handle < 0) { - LOG_ERROR("Failed to initialize video track: %d", handle); + if (video_bitrate_kbps > 0 && (rate_control == "CBR" || rate_control == "cbr")) + hint.bitrate = (uint64_t)video_bitrate_kbps * 1000ULL; + hint.optimize_for_latency = true; + config.hint = hint; + + auto track = broadcast->publish_video(config); + if (!track) { + LOG_ERROR("Failed to initialize video track: %s", track.error().to_string().c_str()); + video_tracks[encoder] = nullptr; return; } + video_tracks[encoder] = *track; LOG_INFO("Video track initialized (%ux%u, %d kbps)", video_width, video_height, video_bitrate_kbps); } +// NOTE: Caller must hold media_mutex. void MoQOutput::AudioInit(obs_encoder_t *encoder) { if (!encoder) { LOG_ERROR("Failed to get audio encoder"); return; } + if (!broadcast) + return; // TODO Pass these along to the audio catalog somehow. /* @@ -687,28 +674,31 @@ void MoQOutput::AudioInit(obs_encoder_t *encoder) const char *codec = obs_encoder_get_codec(encoder); - // The codec string used to go straight through, so an unsupported one failed deep in the - // importer. Mapping it here means OBS says which codec it was. - moq_audio_init config{}; + // Mapping the codec here means OBS says which one it was, rather than the importer + // failing on it deep inside. + moq::AudioInit config{}; if (strcmp(codec, "opus") == 0) { - config.format = MOQ_AUDIO_FORMAT_OPUS; + config.format = moq::AudioFormat::kOpus; } else if (strcmp(codec, "aac") == 0) { - config.format = MOQ_AUDIO_FORMAT_AAC; + config.format = moq::AudioFormat::kAac; } else if (strcmp(codec, "flac") == 0) { - config.format = MOQ_AUDIO_FORMAT_FLAC; + config.format = moq::AudioFormat::kFlac; } else { LOG_ERROR("Unsupported audio codec: %s", codec); + audio_tracks[encoder] = nullptr; return; } - config.init = extra_data; - config.init_len = extra_size; - int handle = moq_publish_audio(broadcast, &config); - audio_tracks[encoder] = handle; - if (handle < 0) { - LOG_ERROR("Failed to initialize audio track: %d", handle); + if (extra_data && extra_size > 0) + config.data.assign(extra_data, extra_data + extra_size); + + auto track = broadcast->publish_audio(config); + if (!track) { + LOG_ERROR("Failed to initialize audio track: %s", track.error().to_string().c_str()); + audio_tracks[encoder] = nullptr; return; } + audio_tracks[encoder] = *track; LOG_INFO("Audio track initialized successfully"); } diff --git a/cpp/obs/src/moq-output.h b/cpp/obs/src/moq-output.h index bdff2a4cfd..ed318dad80 100644 --- a/cpp/obs/src/moq-output.h +++ b/cpp/obs/src/moq-output.h @@ -2,14 +2,18 @@ #pragma once #include +#include + #include #include #include #include #include #include +#include #include #include "logger.h" +#include "moq-worker.h" class MoQOutput { public: @@ -22,10 +26,10 @@ class MoQOutput { inline size_t GetTotalBytes() { return total_bytes_sent; } - inline int GetConnectTime() { return state->connect_time_ms; } + inline int GetConnectTime() { return connect_time_ms; } // Point-in-time QUIC/WebTransport health for the live session. False when - // there is no session or libmoq is between reconnects (no live connection). + // there is no session or it is between reconnects (no live connection). // A failed read leaves the caller's snapshot unchanged. struct ConnectionStats { int reconnects = 0; @@ -47,11 +51,7 @@ class MoQOutput { bool TryGetConnectionStats(ConnectionStats *out); // Successful (re)connects after the first for this Start(); 0 until epoch >= 2. - inline int GetReconnectCount() - { - const int epoch = state->epoch.load(std::memory_order_relaxed); - return epoch > 1 ? epoch - 1 : 0; - } + int GetReconnectCount(); // True while the current Start() attempt has an open MoQ session. // Prefer this over obs_output_get_connect_time_ms, which stays 0 for sub-ms connects. @@ -62,107 +62,32 @@ class MoQOutput { void CopyLastFailure(int *code, std::string *reason); private: - struct SessionRef; - - // Every piece of state a libmoq status callback touches, held by shared_ptr - // so it outlives MoQOutput. libmoq delivers the terminal status callback - // (code <= 0) asynchronously on its runtime thread after moq_session_close, - // and OBS is free to destroy the output long before that runtime gets around - // to it. Keeping this separate is what makes a late callback harmless: it - // finds the state detached rather than freed, so teardown never has to - // outwait a stalled runtime. - // - // It is also the only state a callback may touch: everything a callback needs - // about its own attempt is copied into its SessionRef, since the members here - // belong to whichever attempt is current. - struct SessionState { - explicit SessionState(obs_output_t *output) : output(output) {} - - // Stop reporting to OBS. Returns only once no callback is inside an OBS - // call, so the output can be torn down behind it. The wait is bounded by - // an OBS call rather than by anything libmoq does. - void Detach(); - - // Tell OBS the output stopped, unless it has already been detached. Every - // obs_output_signal_stop goes through here or through an explicit - // signal_mutex hold. - void SignalStop(int code); - - void Connected(const SessionRef &ref, int connect_epoch); - void Closed(const SessionRef &ref, int code); - - // Serializes reporting the output's fate to OBS against tearing it down, and - // is held across the OBS calls themselves. Without it the status callback can - // decide to report a failure, lose the race to Stop(), and still signal: - // OBS_OUTPUT_DISCONNECTED then makes OBS reconnect a stream the user just - // stopped, since obs_output_signal_stop never checks whether the output is - // still active. Start() also holds it from the connect through - // obs_output_begin_data_capture, so a session that dies mid-startup cannot - // report against an output that isn't committed yet. - // - // Recursive because obs_output_signal_stop and obs_output_begin_data_capture - // run the frontend's handlers inline, and a frontend may call obs_output_stop - // straight back into Stop() on this thread. - // - // Lock order: signal_mutex first, then mutex. Never the reverse, and never - // take signal_mutex from inside a libmoq call. That keeps it ordered ahead - // of libmoq's runtime lock too, since the status callback runs with no - // libmoq locks held. - // - // Two costs this buys, both bounded and deliberate: - // - A frontend that re-enters this output from a *different* thread while we - // hold it would deadlock, which recursion does not help with. OBS reaches - // one such point, the pthread_join in end_data_capture_internal. Studio's - // frontend queues its handlers, so it does not arise there. - // - libmoq's runtime is single threaded, so a terminal callback parked here - // stalls every MoQ session in the process. The holds are short, and the - // expensive part of starting (obs_output_initialize_encoders) is - // deliberately outside the lock. - std::recursive_mutex signal_mutex; - - // The OBS output, or null once MoQOutput has been destroyed. Guarded by - // signal_mutex, which every call made through it is also held under. - obs_output_t *output; - - // Guards the group below, which the OBS thread and the libmoq runtime - // thread both touch. - std::mutex mutex; - // The live session handle, or 0 when there is none. libmoq drops the handle - // before firing the terminal callback, so it is retired there rather than - // closed later (the close would just fail with "session not found"). - int session = 0; - // Bumped whenever the publish state is torn down or restarted. A status - // callback stamped with an older value belongs to a superseded attempt and - // must leave both OBS and `session` alone, which is also what limits the - // failure signal to one per Start(). - uint64_t attempt = 0; - // Whether the current attempt ever reached the server, which picks between - // telling OBS the connection failed and telling it the stream dropped. - bool connected = false; - // Dial URL of the current attempt, for the stats snapshot's scheme label. - std::string url; - int last_failure_code = 0; - std::string last_failure_reason; - - // Written by the status callback (libmoq runtime thread), read by - // GetConnectTime() and GetReconnectCount() (OBS thread); atomic to avoid a - // data race. - std::atomic connect_time_ms{0}; - std::atomic epoch{0}; - }; - - // Handed to libmoq as the status callback's user_data. Carries everything a - // callback needs about its own Start() attempt, so it never has to read a - // member the OBS thread may already be rewriting for the next attempt, plus - // the reference that keeps that state alive. Freed by the terminal callback. - struct SessionRef { - std::shared_ptr state; - uint64_t attempt; + // One Start()'s connection: the client dialing, the session once it connects, + // and the pending call whose continuation reports on it. Replaced wholesale by + // the next Start() and dropped by Stop(), which cancels that call. + struct Attempt { + std::shared_ptr client; + std::shared_ptr session; + std::optional pending; std::string url; std::chrono::steady_clock::time_point started; + // Whether this attempt ever reached the server, which picks between telling + // OBS the connection failed and telling it the stream dropped. + bool connected = false; + // False while the session is between reconnects. + bool live = false; }; - static void SessionStatus(void *user_data, int code); + void OnConnect(const std::shared_ptr &attempt, moq::expected> result); + void OnStatus(const std::shared_ptr &attempt, moq::expected result); + void WatchStatus(const std::shared_ptr &attempt); + void Fail(const std::shared_ptr &attempt, const moq::Error &error); + + // Runs `callback` on the worker if `attempt` is still the current one once it + // holds signal_mutex. Cancelling the call on Stop() is not enough on its own: a + // result that already completed may be queued on the worker by then. + template + std::function Current(const std::shared_ptr &attempt, Callback callback); // Tear down the publish state without telling OBS. void Reset(); @@ -172,22 +97,55 @@ class MoQOutput { void AudioInit(obs_encoder_t *encoder); void AudioData(struct encoder_packet *packet); - // The OBS thread's view of the output, valid for this object's lifetime. - // Reporting a stop goes through state->SignalStop instead, which is the only - // path a callback thread may take. obs_output_t *output; - const std::shared_ptr state; + // Runs every continuation, so none runs once the destructor has stopped it. + MoQWorker worker; + + // Serializes reporting the output's fate to OBS against tearing it down, and is + // held across the OBS calls themselves. Without it a continuation can decide to + // report a failure, lose the race to Stop(), and still signal: + // OBS_OUTPUT_DISCONNECTED then makes OBS reconnect a stream the user just + // stopped, since obs_output_signal_stop never checks whether the output is still + // active. Start() also holds it from the connect through + // obs_output_begin_data_capture, so a session that fails mid-startup cannot + // report against an output that isn't committed yet. + // + // Recursive because obs_output_signal_stop and obs_output_begin_data_capture run + // the frontend's handlers inline, and a frontend may call obs_output_stop + // straight back into Stop() on this thread. + // + // Lock order: signal_mutex, then mutex, then media_mutex. + std::recursive_mutex signal_mutex; + + // The current attempt, or null once it was stopped or failed. Guarded by + // signal_mutex; the fields the dock polls are copied out under `mutex`. + std::shared_ptr attempt; + + // Guards the group below, which the dock reads from the UI thread. + std::mutex mutex; + std::shared_ptr session; + bool connected = false; + bool live = false; + std::string url; + int last_failure_code = 0; + std::string last_failure_reason; + + // Written on the worker, read by GetConnectTime(). + std::atomic connect_time_ms{0}; + + // Guards the publishing side, which the encoder threads write through Data(). + // Never held across an OBS call that can wait on an encoder thread. + std::mutex media_mutex; + std::shared_ptr origin; + std::shared_ptr broadcast; + // An encoder maps to null when its track failed to initialize, so it isn't retried. + std::map> video_tracks; + std::map> audio_tracks; std::string path; size_t total_bytes_sent; - - int origin; - int broadcast; - - std::map video_tracks; - std::map audio_tracks; }; void register_moq_output(); diff --git a/cpp/obs/src/moq-settings.cpp b/cpp/obs/src/moq-settings.cpp index 8b3fc4b5a2..495b90a452 100644 --- a/cpp/obs/src/moq-settings.cpp +++ b/cpp/obs/src/moq-settings.cpp @@ -7,61 +7,43 @@ #include #include -extern "C" { -#include "moq.h" -} - namespace MoQSettings { namespace { // Keys. These are stable: they land in scene collections and in the dock's settings // file, so renaming one silently drops whatever the user had configured. -constexpr const char *VERSION = "version"; constexpr const char *BIND = "bind"; -constexpr const char *CONNECT_TIMEOUT = "connect_timeout_ms"; -constexpr const char *FAILOVER_DELAY = "failover_delay_ms"; constexpr const char *TLS_DISABLE_VERIFY = "tls_disable_verify"; constexpr const char *TLS_FINGERPRINT = "tls_fingerprint"; constexpr const char *TLS_ROOT = "tls_root"; -constexpr const char *TLS_HOST_NAME = "tls_host_name"; constexpr const char *BACKOFF_INITIAL = "backoff_initial_ms"; constexpr const char *BACKOFF_MAX = "backoff_max_ms"; constexpr const char *BACKOFF_TIMEOUT = "backoff_timeout_ms"; -constexpr const char *QUIC_CONGESTION_CONTROL = "quic_congestion_control"; constexpr const char *QUIC_MAX_STREAMS = "quic_max_streams"; -constexpr const char *QUIC_IDLE_TIMEOUT = "quic_idle_timeout_ms"; -constexpr const char *QUIC_KEEP_ALIVE = "quic_keep_alive_ms"; -constexpr const char *QUIC_GSO = "quic_gso"; -constexpr const char *QUIC_MTU_DISCOVERY = "quic_mtu_discovery"; -constexpr const char *QUIC_QLOG = "quic_qlog"; constexpr const char *WEBSOCKET_ENABLED = "websocket_enabled"; constexpr const char *WEBSOCKET_DELAY = "websocket_delay_ms"; -// Read a string setting, returning nullptr when it's unset or empty. libmoq's optional -// string setters take exactly that: NULL means "leave at the default". +// moq-ffi documents these defaults on the MoqClient setters but does not report them, +// so they are repeated here. The backoff defaults come from moq::Backoff itself. +constexpr long long QUIC_MAX_STREAMS_DEFAULT = 1024; +constexpr bool WEBSOCKET_ENABLED_DEFAULT = true; +constexpr long long WEBSOCKET_DELAY_DEFAULT_MS = 200; + +// Read a string setting, returning nullptr when it's unset or empty, which leaves the +// knob at the library default. const char *OptionalString(obs_data_t *settings, const char *key) { const char *value = obs_data_get_string(settings, key); return (value && *value) ? value : nullptr; } -// Resolve a tri-state choice (AUTO / "on" / "off") into an override, if any. -bool TriState(obs_data_t *settings, const char *key, bool *out) -{ - const char *value = OptionalString(settings, key); - if (!value) - return false; - *out = strcmp(value, "on") == 0; - return true; -} - // Read an integer setting inside the range declared by its Field. Scene collections -// are editable JSON, so the widget bounds alone do not protect the unsigned C API. +// are editable JSON, so the widget bounds alone do not protect the unsigned API. uint64_t Amount(obs_data_t *settings, const char *key) { const long long value = obs_data_get_int(settings, key); @@ -74,41 +56,6 @@ uint64_t Amount(obs_data_t *settings, const char *key) return 0; } -// Enumerate a libmoq capability into menu options. The names live beside the caller's -// static field table because Option borrows their pointers. -std::vector