From fab9d3764474c7176d6fa849fbd04a58e92c325c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 18:13:25 -0700 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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); },