From 10121b3147028629664701aac7fea3563e9c83b9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 11:48:27 -0700 Subject: [PATCH 1/5] quest: claim quest/m1/cpp/package Co-Authored-By: Claude Opus 5.5 From 885b1d81a4a440f33e9ba99b9faaea556b5d1e34 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 12:22:38 -0700 Subject: [PATCH 2/5] feat(cpp): the moq C++ package, release archives, interop client, and docs Co-Authored-By: Claude Opus 5.5 --- .github/workflows/cpp.yml | 7 +- .github/workflows/interop.yml | 1 + .github/workflows/nightly.yml | 9 +- .github/workflows/release-cpp.yml | 111 ++++++++++ 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 | 124 +++++++++++ cpp/moq/README.md | 40 ++++ cpp/moq/VERSION | 1 + cpp/moq/build.sh | 85 ++++++++ cpp/moq/cmake/moq-config.cmake.in | 25 +++ cpp/moq/cmake/moq.pc.in | 12 ++ cpp/moq/include/moq/moq.hpp | 264 ++++++++++++++++++++++++ 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 | 150 ++++++++++++++ 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-vcpkg.md | 2 +- 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 +- 42 files changed, 1500 insertions(+), 307 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-config.cmake.in create mode 100644 cpp/moq/cmake/moq.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/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/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..e27ee26d08 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) $(pkg-config --cflags --libs moq) + 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..24c1cf7dff --- /dev/null +++ b/cpp/moq/CMakeLists.txt @@ -0,0 +1,124 @@ +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 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. +add_library(moq STATIC src/moq.cpp) +add_library(moq::moq ALIAS moq) +add_dependencies(moq moq_ffi) +target_compile_features(moq PUBLIC cxx_std_17) +target_include_directories(moq PUBLIC + "$" + "$" +) +find_package(Threads REQUIRED) +target_link_libraries(moq 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) + 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-config.cmake.in + "${CMAKE_CURRENT_BINARY_DIR}/moq-config.cmake" + INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/moq + PATH_VARS CMAKE_INSTALL_INCLUDEDIR CMAKE_INSTALL_LIBDIR CMAKE_INSTALL_DATADIR + ) + write_basic_package_version_file("${CMAKE_CURRENT_BINARY_DIR}/moq-config-version.cmake" + COMPATIBILITY SameMinorVersion + ) + install(FILES + "${CMAKE_CURRENT_BINARY_DIR}/moq-config.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/moq-config-version.cmake" + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/moq + ) + + configure_file(cmake/moq.pc.in "${CMAKE_CURRENT_BINARY_DIR}/moq.pc" @ONLY) + install(FILES "${CMAKE_CURRENT_BINARY_DIR}/moq.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..26658bb8d5 --- /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::moq` target. Use it with `add_subdirectory`, or install it for `find_package(moq)` and `moq.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..1273e01860 --- /dev/null +++ b/cpp/moq/build.sh @@ -0,0 +1,85 @@ +#!/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.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. +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-config.cmake.in b/cpp/moq/cmake/moq-config.cmake.in new file mode 100644 index 0000000000..5b0f5f1cbe --- /dev/null +++ b/cpp/moq/cmake/moq-config.cmake.in @@ -0,0 +1,25 @@ +# find_package(moq): the C++ bindings for moq-ffi as the moq::moq target. + +@PACKAGE_INIT@ + +if(NOT TARGET moq::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::ffi STATIC IMPORTED) + set_target_properties(moq::ffi PROPERTIES + IMPORTED_LOCATION "@PACKAGE_CMAKE_INSTALL_LIBDIR@/@MOQ_FFI_LIB_NAME@" + INTERFACE_LINK_LIBRARIES "@MOQ_NATIVE_LIBS@;Threads::Threads" + ) + + # 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_bindings STATIC "@PACKAGE_CMAKE_INSTALL_DATADIR@/moq/moq.cpp") + target_compile_features(moq_bindings PUBLIC cxx_std_17) + target_include_directories(moq_bindings PUBLIC "@PACKAGE_CMAKE_INSTALL_INCLUDEDIR@") + target_link_libraries(moq_bindings PUBLIC moq::ffi) + add_library(moq::moq ALIAS moq_bindings) +endif() + +check_required_components(moq) diff --git a/cpp/moq/cmake/moq.pc.in b/cpp/moq/cmake/moq.pc.in new file mode 100644 index 0000000000..dce1304b28 --- /dev/null +++ b/cpp/moq/cmake/moq.pc.in @@ -0,0 +1,12 @@ +# The generated bindings ship as source: compile `pkg-config --variable=sources moq` 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/moq.cpp + +Name: moq +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..27f3f1c292 --- /dev/null +++ b/cpp/moq/include/moq/moq.hpp @@ -0,0 +1,264 @@ +// 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 +#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 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 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. The state is shared with the continuation, so +// whichever of completion and suspension comes second does the resuming. +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; + { + std::lock_guard guard(state_->mutex); + 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::coroutine_handle<> resume; + { + std::lock_guard guard(state->mutex); + state->output.emplace(std::move(output)); + resume = std::exchange(state->handle, nullptr); + } + if (resume) { + 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::mutex mutex; + std::coroutine_handle<> handle; + std::optional output; + std::optional continuation; + }; + + 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..298024b27f --- /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) 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- /W4 /WX) + add_compile_definitions(_HAS_EXCEPTIONS=0) +else() + add_compile_options(-Wall -Wextra -Werror -pedantic-errors -fno-exceptions -fno-rtti) +endif() + +find_package(moq REQUIRED) + +add_executable(probe probe.cpp) +target_link_libraries(probe PRIVATE moq::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..4254b525fc 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/): 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..ab00c25fcb --- /dev/null +++ b/doc/lib/cpp/index.md @@ -0,0 +1,150 @@ +--- +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.pc`. Targets: Linux x86\_64 and aarch64, macOS arm64, Windows x64. +No Rust toolchain is needed to consume it. + +```cmake ignore +find_package(moq REQUIRED) # with CMAKE_PREFIX_PATH at the unpacked archive +target_link_libraries(app PRIVATE moq::moq) +``` + +```bash +export PKG_CONFIG_PATH="moq-cpp-$ver-$target/lib/pkgconfig" +c++ -std=c++17 app.cpp $(pkg-config --variable=sources moq) $(pkg-config --cflags --libs moq) -o app +``` + +The generated bindings ship as source (`share/moq/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::moq` target. + +## 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..41f0d18323 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` 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-vcpkg.md b/quest/m2/cpp-vcpkg.md index 40b98e73fc..cc41ab9b6d 100644 --- a/quest/m2/cpp-vcpkg.md +++ b/quest/m2/cpp-vcpkg.md @@ -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..d430eb5dd9 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)`) | `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)` 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)) 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..e541c20954 --- /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 REQUIRED) + +add_executable(cpp-interop main.cpp) +target_link_libraries(cpp-interop PRIVATE moq::moq) diff --git a/test/interop/clients/cpp/main.cpp b/test/interop/clients/cpp/main.cpp new file mode 100644 index 0000000000..445f458381 --- /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). +// +// 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..eea2258958 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), 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 5d2df51cb712b820a6409cbb5325867400858b13 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 12:34:29 -0700 Subject: [PATCH 3/5] fix(cpp): skip comments in the placeholder check, drop MSVC /W4 from the probe Co-Authored-By: Claude Opus 5.5 --- cpp/moq/build.sh | 3 ++- cpp/moq/test/CMakeLists.txt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/cpp/moq/build.sh b/cpp/moq/build.sh index 1273e01860..39d40633c0 100755 --- a/cpp/moq/build.sh +++ b/cpp/moq/build.sh @@ -63,7 +63,8 @@ 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. -if grep -rnE '@[A-Z_]+@' "$PACKAGE_DIR/lib/cmake" "$PACKAGE_DIR/lib/pkgconfig"; then +# 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 diff --git a/cpp/moq/test/CMakeLists.txt b/cpp/moq/test/CMakeLists.txt index 298024b27f..d6c6ac364b 100644 --- a/cpp/moq/test/CMakeLists.txt +++ b/cpp/moq/test/CMakeLists.txt @@ -12,7 +12,7 @@ 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- /W4 /WX) + 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) From a800040ebdcb08abc2fc4bbc50006b7b5e881d0a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 14:07:40 -0700 Subject: [PATCH 4/5] refactor(cpp): name the package moq-cpp, list cpp/moq in the moq-ffi sync row Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- cpp/justfile | 2 +- cpp/moq/CMakeLists.txt | 35 +++++++++++----------- cpp/moq/README.md | 2 +- cpp/moq/build.sh | 2 +- cpp/moq/cmake/moq-config.cmake.in | 25 ---------------- cpp/moq/cmake/moq-cpp-config.cmake.in | 26 ++++++++++++++++ cpp/moq/cmake/{moq.pc.in => moq-cpp.pc.in} | 6 ++-- cpp/moq/test/CMakeLists.txt | 6 ++-- doc/lib/c/index.md | 2 +- doc/lib/cpp/index.md | 13 ++++---- doc/lib/index.md | 2 +- quest/m2/cpp-conan.md | 4 +-- quest/m2/cpp-vcpkg.md | 4 +-- test/interop/README.md | 6 ++-- test/interop/clients/cpp/CMakeLists.txt | 4 +-- test/interop/clients/cpp/main.cpp | 2 +- test/interop/interop.sh | 2 +- 18 files changed, 74 insertions(+), 71 deletions(-) delete mode 100644 cpp/moq/cmake/moq-config.cmake.in create mode 100644 cpp/moq/cmake/moq-cpp-config.cmake.in rename cpp/moq/cmake/{moq.pc.in => moq-cpp.pc.in} (78%) 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/justfile b/cpp/justfile index e27ee26d08..5064fc02a5 100644 --- a/cpp/justfile +++ b/cpp/justfile @@ -66,7 +66,7 @@ check $FILES="": 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) $(pkg-config --cflags --libs moq) + $(pkg-config --variable=sources moq-cpp) $(pkg-config --cflags --libs moq-cpp) run "$build/pkg-config" fi diff --git a/cpp/moq/CMakeLists.txt b/cpp/moq/CMakeLists.txt index 24c1cf7dff..780b3f5b56 100644 --- a/cpp/moq/CMakeLists.txt +++ b/cpp/moq/CMakeLists.txt @@ -6,7 +6,7 @@ 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 VERSION ${_version} LANGUAGES CXX) +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 @@ -74,16 +74,17 @@ foreach(_line IN LISTS _lines) endforeach() # The bindings compile here with the including project's standard and flags; see src/moq.cpp. -add_library(moq STATIC src/moq.cpp) -add_library(moq::moq ALIAS moq) -add_dependencies(moq moq_ffi) -target_compile_features(moq PUBLIC cxx_std_17) -target_include_directories(moq PUBLIC +# 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 PUBLIC "${MOQ_FFI_LIB}" ${MOQ_NATIVE_LIBS} Threads::Threads) +target_link_libraries(moq-cpp PUBLIC "${MOQ_FFI_LIB}" ${MOQ_NATIVE_LIBS} Threads::Threads) if(PROJECT_IS_TOP_LEVEL) include(GNUInstallDirs) @@ -91,7 +92,7 @@ if(PROJECT_IS_TOP_LEVEL) 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) + 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. @@ -105,20 +106,20 @@ if(PROJECT_IS_TOP_LEVEL) endif() endforeach() - configure_package_config_file(cmake/moq-config.cmake.in - "${CMAKE_CURRENT_BINARY_DIR}/moq-config.cmake" - INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/moq + 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-config-version.cmake" + write_basic_package_version_file("${CMAKE_CURRENT_BINARY_DIR}/moq-cpp-config-version.cmake" COMPATIBILITY SameMinorVersion ) install(FILES - "${CMAKE_CURRENT_BINARY_DIR}/moq-config.cmake" - "${CMAKE_CURRENT_BINARY_DIR}/moq-config-version.cmake" - DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/moq + "${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.pc.in "${CMAKE_CURRENT_BINARY_DIR}/moq.pc" @ONLY) - install(FILES "${CMAKE_CURRENT_BINARY_DIR}/moq.pc" DESTINATION ${CMAKE_INSTALL_LIBDIR}/pkgconfig) + 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 index 26658bb8d5..6545172fa7 100644 --- a/cpp/moq/README.md +++ b/cpp/moq/README.md @@ -4,7 +4,7 @@ C++17 bindings for [rs/moq-ffi](../../rs/moq-ffi): the sources `uniffi-bindgen-c ## Layout -- `CMakeLists.txt` builds moq-ffi as a staticlib with cargo, renders the bindings into the build tree, and compiles them as the `moq::moq` target. Use it with `add_subdirectory`, or install it for `find_package(moq)` and `moq.pc`. +- `CMakeLists.txt` builds moq-ffi as a staticlib with cargo, renders the bindings into the build tree, and compiles them as the `moq-cpp::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. diff --git a/cpp/moq/build.sh b/cpp/moq/build.sh index 39d40633c0..e9c961ecc9 100755 --- a/cpp/moq/build.sh +++ b/cpp/moq/build.sh @@ -5,7 +5,7 @@ set -euo pipefail # 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.pc. Needs cargo, cmake, +# 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)" diff --git a/cpp/moq/cmake/moq-config.cmake.in b/cpp/moq/cmake/moq-config.cmake.in deleted file mode 100644 index 5b0f5f1cbe..0000000000 --- a/cpp/moq/cmake/moq-config.cmake.in +++ /dev/null @@ -1,25 +0,0 @@ -# find_package(moq): the C++ bindings for moq-ffi as the moq::moq target. - -@PACKAGE_INIT@ - -if(NOT TARGET moq::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::ffi STATIC IMPORTED) - set_target_properties(moq::ffi PROPERTIES - IMPORTED_LOCATION "@PACKAGE_CMAKE_INSTALL_LIBDIR@/@MOQ_FFI_LIB_NAME@" - INTERFACE_LINK_LIBRARIES "@MOQ_NATIVE_LIBS@;Threads::Threads" - ) - - # 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_bindings STATIC "@PACKAGE_CMAKE_INSTALL_DATADIR@/moq/moq.cpp") - target_compile_features(moq_bindings PUBLIC cxx_std_17) - target_include_directories(moq_bindings PUBLIC "@PACKAGE_CMAKE_INSTALL_INCLUDEDIR@") - target_link_libraries(moq_bindings PUBLIC moq::ffi) - add_library(moq::moq ALIAS moq_bindings) -endif() - -check_required_components(moq) 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.pc.in b/cpp/moq/cmake/moq-cpp.pc.in similarity index 78% rename from cpp/moq/cmake/moq.pc.in rename to cpp/moq/cmake/moq-cpp.pc.in index dce1304b28..4c59f60249 100644 --- a/cpp/moq/cmake/moq.pc.in +++ b/cpp/moq/cmake/moq-cpp.pc.in @@ -1,11 +1,11 @@ -# The generated bindings ship as source: compile `pkg-config --variable=sources moq` with the +# 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/moq.cpp +sources=${prefix}/@CMAKE_INSTALL_DATADIR@/moq-cpp/moq.cpp -Name: moq +Name: moq-cpp Description: Media over QUIC for C++ Version: @PROJECT_VERSION@ Cflags: -I${includedir} diff --git a/cpp/moq/test/CMakeLists.txt b/cpp/moq/test/CMakeLists.txt index d6c6ac364b..b76f382000 100644 --- a/cpp/moq/test/CMakeLists.txt +++ b/cpp/moq/test/CMakeLists.txt @@ -1,5 +1,5 @@ # A consumer of the installed package: `just cpp check` installs cpp/moq to a prefix, then -# builds this with find_package(moq) at C++17 and C++23. +# builds this with find_package(moq-cpp) at C++17 and C++23. cmake_minimum_required(VERSION 3.21) project(moq-probe LANGUAGES CXX) @@ -18,7 +18,7 @@ else() add_compile_options(-Wall -Wextra -Werror -pedantic-errors -fno-exceptions -fno-rtti) endif() -find_package(moq REQUIRED) +find_package(moq-cpp REQUIRED) add_executable(probe probe.cpp) -target_link_libraries(probe PRIVATE moq::moq) +target_link_libraries(probe PRIVATE moq-cpp::moq) diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index 4254b525fc..408ca76f9b 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -11,7 +11,7 @@ description: libmoq, the stable C ABI over the Rust core 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. C++ projects should prefer the -[C++ package](/lib/cpp/): the same core as RAII objects and futures instead of +[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 index ab00c25fcb..751ce0083c 100644 --- a/doc/lib/cpp/index.md +++ b/doc/lib/cpp/index.md @@ -21,20 +21,20 @@ a method means the same thing here as in Python or Go. For a plain C ABI, use 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.pc`. Targets: Linux x86\_64 and aarch64, macOS arm64, Windows x64. +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 REQUIRED) # with CMAKE_PREFIX_PATH at the unpacked archive -target_link_libraries(app PRIVATE moq::moq) +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) $(pkg-config --cflags --libs moq) -o app +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/moq.cpp`) and compile inside +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, @@ -44,7 +44,8 @@ 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::moq` target. +the same `moq-cpp::moq` target. The package is `moq-cpp`, so it installs beside +libmoq's `moq` package without colliding. ## Example diff --git a/doc/lib/index.md b/doc/lib/index.md index 41f0d18323..925936fd70 100644 --- a/doc/lib/index.md +++ b/doc/lib/index.md @@ -17,7 +17,7 @@ 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/cpp/) | `moq` via CMake or pkg-config | C++ apps, engines, and plugins. | +| [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 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 cc41ab9b6d..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 diff --git a/test/interop/README.md b/test/interop/README.md index d430eb5dd9..84c7493546 100644 --- a/test/interop/README.md +++ b/test/interop/README.md @@ -30,7 +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)`) | `cmake` build + install of `cpp/moq` (uniffi-bindgen-cpp), then `cmake` for the client | 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 | @@ -50,7 +50,7 @@ wrapper end to end rather than only compiling it. A shell without 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)` pointed at that prefix. A +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. @@ -172,7 +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/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 index e541c20954..b1ad0c3d34 100644 --- a/test/interop/clients/cpp/CMakeLists.txt +++ b/test/interop/clients/cpp/CMakeLists.txt @@ -7,7 +7,7 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) -find_package(moq REQUIRED) +find_package(moq-cpp REQUIRED) add_executable(cpp-interop main.cpp) -target_link_libraries(cpp-interop PRIVATE moq::moq) +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 index 445f458381..7ed23a253b 100644 --- a/test/interop/clients/cpp/main.cpp +++ b/test/interop/clients/cpp/main.cpp @@ -1,5 +1,5 @@ // Cross-language interop client for the interop test, built against the installed cpp/moq -// package with find_package(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 diff --git a/test/interop/interop.sh b/test/interop/interop.sh index eea2258958..0e702ff033 100755 --- a/test/interop/interop.sh +++ b/test/interop/interop.sh @@ -284,7 +284,7 @@ prepare_go() { # 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), the way an external project consumes it. Debug, like the +# find_package(moq-cpp), the way an external project consumes it. Debug, like the # rest of the run. prepare_cpp() { local t From 2cd21260f3008014c99b87e80cf15016ad7c876d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 20:50:37 -0700 Subject: [PATCH 5/5] fix(cpp): close the coroutine cancel race and watch Release C++ The continuation resumes a claimed handle before it drops the mutex, so destroying a suspended coroutine cannot free the frame in between. alert.yml lists the new Release C++ workflow. Alias the binary producers the rebased generator now emits. Co-Authored-By: Grok 4.7 --- .github/workflows/alert.yml | 1 + cpp/moq/include/moq/moq.hpp | 48 ++++++++++++++++++++++++++----------- 2 files changed, 35 insertions(+), 14 deletions(-) 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/cpp/moq/include/moq/moq.hpp b/cpp/moq/include/moq/moq.hpp index 27f3f1c292..a2a5ee6708 100644 --- a/cpp/moq/include/moq/moq.hpp +++ b/cpp/moq/include/moq/moq.hpp @@ -14,6 +14,7 @@ #include #include #include +#include #define MOQ_COROUTINES 1 #endif @@ -52,6 +53,8 @@ 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; @@ -93,6 +96,7 @@ using AudioEncoderOutput = MoqAudioEncoderOutput; using AudioFrame = MoqAudioFrame; using AudioInit = MoqAudioInit; using Backoff = MoqBackoff; +using BinaryConfig = MoqBinaryConfig; using Catalog = MoqCatalog; using ConnectionStats = MoqConnectionStats; using ContainerInit = MoqContainerInit; @@ -178,8 +182,9 @@ inline expected log_level(const std::string &level) { #ifdef MOQ_COROUTINES namespace detail { -// Resumes a coroutine with a future's result. The state is shared with the continuation, so -// whichever of completion and suspension comes second does the resuming. +// 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: @@ -195,7 +200,13 @@ class Awaiter { ~Awaiter() { std::optional continuation; { - std::lock_guard guard(state_->mutex); + // 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); } @@ -207,18 +218,24 @@ class Awaiter { bool await_suspend(std::coroutine_handle<> handle) noexcept { auto continuation = std::move(future_).then(inline_executor, [state = state_](Output output) { - std::coroutine_handle<> resume; - { - std::lock_guard guard(state->mutex); - state->output.emplace(std::move(output)); - resume = std::exchange(state->handle, nullptr); - } - if (resume) { - resume.resume(); + 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); + std::lock_guard guard(state_->mutex); state_->continuation.emplace(std::move(continuation)); if (state_->output) { // Completed before suspending: carry on without a round trip. @@ -229,16 +246,19 @@ class Awaiter { } Output await_resume() noexcept { - std::lock_guard guard(state_->mutex); + std::lock_guard guard(state_->mutex); return std::move(*state_->output); } private: struct State { - std::mutex mutex; + 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_;